diff --git a/app/actions/helpers/channels.ts b/app/actions/helpers/channels.ts index d23873b18..11dd16aa8 100644 --- a/app/actions/helpers/channels.ts +++ b/app/actions/helpers/channels.ts @@ -120,7 +120,7 @@ export async function fetchMyChannelMember(channelId: string) { } } -export function markChannelAsUnread(state: GlobalState, teamId: string, channelId: string, mentions: Array): Array { +export function markChannelAsUnread(state: GlobalState, teamId: string, channelId: string, mentions: Array, isRoot = false): Array { const {myMembers} = state.entities.channels; const {currentUserId} = state.entities.users; @@ -129,6 +129,7 @@ export function markChannelAsUnread(state: GlobalState, teamId: string, channelI data: { channelId, amount: 1, + amountRoot: isRoot ? 1 : 0, }, }, { type: ChannelTypes.INCREMENT_UNREAD_MSG_COUNT, @@ -136,6 +137,7 @@ export function markChannelAsUnread(state: GlobalState, teamId: string, channelI teamId, channelId, amount: 1, + amountRoot: isRoot ? 1 : 0, onlyMentions: myMembers[channelId] && myMembers[channelId].notify_props && myMembers[channelId].notify_props.mark_unread === General.MENTION, }, @@ -148,6 +150,7 @@ export function markChannelAsUnread(state: GlobalState, teamId: string, channelI teamId, channelId, amount: 1, + amountRoot: isRoot ? 1 : 0, }, }); } diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index dfc987ce4..85b4ff88a 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -18,7 +18,6 @@ import {addUserToTeam, getTeamByName, removeUserFromTeam, selectTeam} from '@mm- import {Client4} from '@client/rest'; import {General, Preferences} from '@mm-redux/constants'; import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts'; -import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import { getCurrentChannelId, getRedirectChannelNameForTeam, @@ -26,22 +25,26 @@ import { getMyChannelMemberships, isManuallyUnread, } from '@mm-redux/selectors/entities/channels'; -import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import {isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {getTeamByName as selectTeamByName, getCurrentTeam, getTeamMemberships} from '@mm-redux/selectors/entities/teams'; - +import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; import {getChannelByName as selectChannelByName, getChannelsIdForTeam} from '@mm-redux/utils/channel_utils'; import EventEmitter from '@mm-redux/utils/event_emitter'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {lastChannelIdForTeam, loadSidebarDirectMessagesProfiles} from '@actions/helpers/channels'; import {getPosts, getPostsBefore, getPostsSince, loadUnreadChannelPosts} from '@actions/views/post'; import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from '@constants/post_draft'; import {getChannelReachable} from '@selectors/channel'; +import {getViewingGlobalThreads} from '@selectors/threads'; import telemetry, {PERF_MARKERS} from '@telemetry'; import {isDirectChannelVisible, isGroupChannelVisible, getChannelSinceValue, privateChannelJoinPrompt} from '@utils/channels'; import {isPendingPost} from '@utils/general'; import {fetchAppBindings} from '@mm-redux/actions/apps'; import {appsEnabled} from '@utils/apps'; +import {handleNotViewingGlobalThreadsScreen} from './threads'; + const MAX_RETRIES = 3; export function loadChannelsByTeamName(teamName, errorHandler) { @@ -124,9 +127,11 @@ export function fetchPostActionWithRetry(action, maxTries = MAX_RETRIES) { export function selectInitialChannel(teamId) { return (dispatch, getState) => { const state = getState(); - const channelId = lastChannelIdForTeam(state, teamId); - - dispatch(handleSelectChannel(channelId)); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + if (!collapsedThreadsEnabled || (collapsedThreadsEnabled && !getViewingGlobalThreads(state))) { + const channelId = lastChannelIdForTeam(state, teamId); + dispatch(handleSelectChannel(channelId)); + } }; } @@ -219,6 +224,9 @@ export function handleSelectChannel(channelId) { teamId: channel.team_id || currentTeamId, }, }); + if (getViewingGlobalThreads(state)) { + actions.push(handleNotViewingGlobalThreadsScreen()); + } dispatch(batchActions(actions, 'BATCH_SWITCH_CHANNEL')); @@ -376,6 +384,7 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m if (channel) { const unreadMessageCount = channel.total_msg_count - member.msg_count; + const unreadMessageCountRoot = channel.total_msg_count_root - member.msg_count_root; actions.push({ type: ChannelTypes.SET_UNREAD_MSG_COUNT, data: { @@ -388,6 +397,7 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m teamId: channel.team_id, channelId, amount: unreadMessageCount, + amountRoot: unreadMessageCountRoot, }, }, { type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT, @@ -395,6 +405,7 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m teamId: channel.team_id, channelId, amount: member.mention_count, + amountRoot: member.mention_count_root, }, }); } @@ -415,6 +426,7 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m teamId: prevChannel.team_id, channelId: prevChannelId, amount: prevChannel.total_msg_count - prevMember.msg_count, + amountRoot: prevChannel.total_msg_count_root - prevMember.msg_count_root, }, }, { type: ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT, @@ -422,6 +434,7 @@ export function markAsViewedAndReadBatch(state, channelId, prevChannelId = '', m teamId: prevChannel.team_id, channelId: prevChannelId, amount: prevMember.mention_count, + amountRoot: prevMember.mention_count_root, }, }); } diff --git a/app/actions/views/crt.ts b/app/actions/views/crt.ts new file mode 100644 index 000000000..ea8742196 --- /dev/null +++ b/app/actions/views/crt.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {PreferenceTypes} from '@mm-redux/action_types'; +import {General, Preferences} from '@mm-redux/constants'; +import {getConfig} from '@mm-redux/selectors/entities/general'; +import {getCollapsedThreadsPreference} from '@mm-redux/selectors/entities/preferences'; +import type {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; +import type {PreferenceType} from '@mm-redux/types/preferences'; + +import EventEmitter from '@mm-redux/utils/event_emitter'; + +export function handleCRTPreferenceChange(preferences: PreferenceType[]) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + const newCRTPreference = preferences.find((preference) => preference.name === Preferences.COLLAPSED_REPLY_THREADS); + if (newCRTPreference && getConfig(state).CollapsedThreads !== undefined) { + const newCRTValue = newCRTPreference.value; + const oldCRTValue = getCollapsedThreadsPreference(state); + if (newCRTValue !== oldCRTValue) { + dispatch({ + type: PreferenceTypes.RECEIVED_PREFERENCES, + data: preferences, + }); + EventEmitter.emit(General.CRT_PREFERENCE_CHANGED, newCRTValue); + return {data: true}; + } + } + return {data: false}; + }; +} diff --git a/app/actions/views/post.js b/app/actions/views/post.js index 640bfbb0d..9d39faadb 100644 --- a/app/actions/views/post.js +++ b/app/actions/views/post.js @@ -19,6 +19,7 @@ import {Client4} from '@client/rest'; import {Posts} from '@mm-redux/constants'; import {getPost as selectPost, getPostIdsInChannel} from '@mm-redux/selectors/entities/posts'; import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; +import {isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {removeUserFromList} from '@mm-redux/utils/user_utils'; import {isUnreadChannel, isArchivedChannel} from '@mm-redux/utils/channel_utils'; @@ -51,7 +52,7 @@ export function sendEphemeralPost(message, channelId = '', parentId = '', userId } export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') { - return async (dispatch) => { + return async (dispatch, getState) => { const timestamp = Date.now(); const post = { id: generateId(), @@ -69,7 +70,8 @@ export function sendAddToChannelEphemeralPost(user, addedUsername, message, chan }, }; - dispatch(receivedNewPost(post)); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + dispatch(receivedNewPost(post, collapsedThreadsEnabled)); }; } @@ -102,13 +104,14 @@ export function selectAttachmentMenuAction(postId, actionId, text, value) { }; } -export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE) { +export function getPosts(channelId, page = 0, perPage = Posts.POST_CHUNK_SIZE, fetchThreads = true, collapsedThreadsExtended = false) { return async (dispatch, getState) => { try { const state = getState(); const {postsInChannel} = state.entities.posts; const postForChannel = postsInChannel[channelId]; - const data = await Client4.getPosts(channelId, page, perPage); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + const data = await Client4.getPosts(channelId, page, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); const posts = Object.values(data.posts); const actions = [{ type: ViewTypes.SET_CHANNEL_RETRY_FAILED, @@ -161,10 +164,11 @@ export function getPost(postId) { }; } -export function getPostsSince(channelId, since) { - return async (dispatch) => { +export function getPostsSince(channelId, since, fetchThreads = true, collapsedThreadsExtended = false) { + return async (dispatch, getState) => { try { - const data = await Client4.getPostsSince(channelId, since); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + const data = await Client4.getPostsSince(channelId, since, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); const posts = Object.values(data.posts); if (posts?.length) { @@ -188,10 +192,11 @@ export function getPostsSince(channelId, since) { }; } -export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST_CHUNK_SIZE) { - return async (dispatch) => { +export function getPostsBefore(channelId, postId, page = 0, perPage = Posts.POST_CHUNK_SIZE, fetchThreads = true, collapsedThreadsExtended = false) { + return async (dispatch, getState) => { try { - const data = await Client4.getPostsBefore(channelId, postId, page, perPage); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + const data = await Client4.getPostsBefore(channelId, postId, page, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); const posts = Object.values(data.posts); if (posts?.length) { @@ -246,13 +251,14 @@ export function getPostThread(rootId, skipDispatch = false) { }; } -export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZE / 2) { - return async (dispatch) => { +export function getPostsAround(channelId, postId, perPage = Posts.POST_CHUNK_SIZE / 2, fetchThreads = true, collapsedThreadsExtended = false) { + return async (dispatch, getState) => { try { + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); const [before, thread, after] = await Promise.all([ - Client4.getPostsBefore(channelId, postId, 0, perPage), - Client4.getPostThread(postId), - Client4.getPostsAfter(channelId, postId, 0, perPage), + Client4.getPostsBefore(channelId, postId, 0, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended), + Client4.getPostThread(postId, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended), + Client4.getPostsAfter(channelId, postId, 0, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended), ]); const data = { @@ -297,7 +303,8 @@ export function handleNewPostBatch(WebSocketMessage) { return async (dispatch, getState) => { const state = getState(); const post = JSON.parse(WebSocketMessage.data.post); - const actions = [receivedNewPost(post)]; + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + const actions = [receivedNewPost(post, collapsedThreadsEnabled)]; // If we don't have the thread for this post, fetch it from the server // and include the actions in the batch @@ -438,8 +445,8 @@ export function loadUnreadChannelPosts(channels, channelMembers) { if (channel.id === currentChannelId || isArchivedChannel(channel)) { return; } - - const isUnread = isUnreadChannel(channelMembersByChannel, channel); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + const isUnread = isUnreadChannel(channelMembersByChannel, channel, collapsedThreadsEnabled); if (!isUnread) { return; } @@ -453,10 +460,10 @@ export function loadUnreadChannelPosts(channels, channelMembers) { }; if (!postIds || !postIds.length) { // Get the first page of posts if it appears we haven't gotten it yet, like the webapp - promise = Client4.getPosts(channel.id); + promise = Client4.getPosts(channel.id, undefined, undefined, true, collapsedThreadsEnabled); } else { const since = getChannelSinceValue(state, channel.id, postIds); - promise = Client4.getPostsSince(channel.id, since); + promise = Client4.getPostsSince(channel.id, since, true, collapsedThreadsEnabled); trace.since = since; } diff --git a/app/actions/views/root.js b/app/actions/views/root.js index 1f47d8198..124966bd3 100644 --- a/app/actions/views/root.js +++ b/app/actions/views/root.js @@ -11,11 +11,14 @@ import {receivedNewPost} from '@mm-redux/actions/posts'; import {getMyTeams, getMyTeamMembers} from '@mm-redux/actions/teams'; import {Client4} from '@client/rest'; import {General} from '@mm-redux/constants'; +import {isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import EventEmitter from '@mm-redux/utils/event_emitter'; +import {getViewingGlobalThreads} from '@selectors/threads'; import initialState from '@store/initial_state'; import {getStateForReset} from '@store/utils'; import {markAsViewedAndReadBatch} from './channel'; +import {handleNotViewingGlobalThreadsScreen} from './threads'; export function startDataCleanup() { return async (dispatch, getState) => { @@ -111,6 +114,10 @@ export function handleSelectTeamAndChannel(teamId, channelId) { const member = myMembers[channelId]; const actions = markAsViewedAndReadBatch(state, channelId); + if (getViewingGlobalThreads(state)) { + actions.push(handleNotViewingGlobalThreadsScreen()); + } + // when the notification is from a team other than the current team if (teamId !== currentTeamId) { actions.push({type: TeamTypes.SELECT_TEAM, data: teamId}); @@ -147,6 +154,7 @@ export function purgeOfflineStore() { }); EventEmitter.emit(NavigationTypes.RESTART_APP); + return {data: true}; }; } @@ -169,7 +177,8 @@ export function createPostForNotificationReply(post) { try { const data = await Client4.createPost({...newPost, create_at: 0}); - dispatch(receivedNewPost(data)); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + dispatch(receivedNewPost(data, collapsedThreadsEnabled)); return {data}; } catch (error) { diff --git a/app/actions/views/threads.ts b/app/actions/views/threads.ts new file mode 100644 index 000000000..16430046c --- /dev/null +++ b/app/actions/views/threads.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {ViewTypes} from '@constants'; + +export const handleViewingGlobalThreadsScreen = () => ( + { + type: ViewTypes.VIEWING_GLOBAL_THREADS_SCREEN, + } +); + +export const handleNotViewingGlobalThreadsScreen = () => ({ + type: ViewTypes.NOT_VIEWING_GLOBAL_THREADS_SCREEN, +}); + +export const handleViewingGlobalThreadsAll = () => ({ + type: ViewTypes.VIEWING_GLOBAL_THREADS_ALL, +}); + +export const handleViewingGlobalThreadsUnreads = () => ({ + type: ViewTypes.VIEWING_GLOBAL_THREADS_UNREADS, +}); diff --git a/app/actions/views/user.js b/app/actions/views/user.js index fd00c231f..1165c45f7 100644 --- a/app/actions/views/user.js +++ b/app/actions/views/user.js @@ -4,6 +4,7 @@ import {batchActions} from 'redux-batched-actions'; import {NavigationTypes} from 'app/constants'; +import {handleCRTPreferenceChange} from '@actions/views/crt'; import {GeneralTypes, RoleTypes, UserTypes} from '@mm-redux/action_types'; import {getDataRetentionPolicy} from '@mm-redux/actions/general'; import * as HelperActions from '@mm-redux/actions/helpers'; @@ -98,26 +99,29 @@ export function loadMe(user, deviceToken, skipDispatch = false) { analytics.setUserId(data.user.id); analytics.setUserRoles(data.user.roles); + data.preferences = await Client4.getMyPreferences(); + const crtPreferenceChanged = dispatch(handleCRTPreferenceChange(data.preferences)); + if (crtPreferenceChanged.data) { + return {data}; + } + // Execute all other requests in parallel const teamsRequest = Client4.getMyTeams(); const teamMembersRequest = Client4.getMyTeamMembers(); const teamUnreadRequest = Client4.getMyTeamUnreads(); - const preferencesRequest = Client4.getMyPreferences(); const configRequest = Client4.getClientConfigOld(); const actions = []; - const [teams, teamMembers, teamUnreads, preferences, config] = await Promise.all([ + const [teams, teamMembers, teamUnreads, config] = await Promise.all([ teamsRequest, teamMembersRequest, teamUnreadRequest, - preferencesRequest, configRequest, ]); data.teams = teams; data.teamMembers = teamMembers; data.teamUnreads = teamUnreads; - data.preferences = preferences; data.config = config; data.url = Client4.getUrl(); diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 46344650c..e5b68e280 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -8,9 +8,12 @@ import {WebsocketEvents} from '@constants'; import {ChannelTypes, GeneralTypes, PreferenceTypes, TeamTypes, UserTypes, RoleTypes} from '@mm-redux/action_types'; import {getProfilesByIds, getStatusesByIds} from '@mm-redux/actions/users'; import {Client4} from '@client/rest'; +import {getThreads} from '@mm-redux/actions/threads'; import {General} from '@mm-redux/constants'; import {getCurrentChannelId, getCurrentChannelStats} from '@mm-redux/selectors/entities/channels'; import {getConfig} from '@mm-redux/selectors/entities/general'; +import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts'; +import {isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {getCurrentUserId, getUsers, getUserStatuses} from '@mm-redux/selectors/entities/users'; import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions'; @@ -43,9 +46,9 @@ import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePrefe import {handleAddEmoji, handleReactionAddedEvent, handleReactionRemovedEvent} from './reactions'; import {handleRoleAddedEvent, handleRoleRemovedEvent, handleRoleUpdatedEvent} from './roles'; import {handleLeaveTeamEvent, handleUpdateTeamEvent, handleTeamAddedEvent} from './teams'; +import {handleThreadUpdated, handleThreadReadChanged, handleThreadFollowChanged} from './threads'; import {handleStatusChangedEvent, handleUserAddedEvent, handleUserRemovedEvent, handleUserRoleUpdated, handleUserUpdatedEvent} from './users'; import {getChannelSinceValue} from '@utils/channels'; -import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts'; import {handleRefreshAppsBindings} from './apps'; export function init(additionalOptions: any = {}) { @@ -178,6 +181,10 @@ export function doReconnect(now: number) { data: myData, }); + if (isCollapsedThreadsEnabled(state)) { + dispatch(getThreads(currentUserId, currentTeamId, '', '', undefined, false, false, (state.websocket?.lastDisconnectAt || Date.now()))); + } + const stillMemberOfCurrentChannel = myData.channelMembers.find((cm: ChannelMembership) => cm.channel_id === currentChannelId); const channelStillExists = myData.channels.find((c: Channel) => c.id === currentChannelId); @@ -396,6 +403,12 @@ function handleEvent(msg: WebSocketMessage) { return dispatch(handleOpenDialogEvent(msg)); case WebsocketEvents.RECEIVED_GROUP: return dispatch(handleGroupUpdatedEvent(msg)); + case WebsocketEvents.THREAD_UPDATED: + return dispatch(handleThreadUpdated(msg)); + case WebsocketEvents.THREAD_READ_CHANGED: + return dispatch(handleThreadReadChanged(msg)); + case WebsocketEvents.THREAD_FOLLOW_CHANGED: + return dispatch(handleThreadFollowChanged(msg)); case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: { return dispatch(handleRefreshAppsBindings()); } diff --git a/app/actions/websocket/posts.ts b/app/actions/websocket/posts.ts index 7f1efcc36..b6fb84982 100644 --- a/app/actions/websocket/posts.ts +++ b/app/actions/websocket/posts.ts @@ -20,19 +20,23 @@ import { getMyChannelMember as selectMyChannelMember, isManuallyUnread, } from '@mm-redux/selectors/entities/channels'; -import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; import {getPost as selectPost} from '@mm-redux/selectors/entities/posts'; -import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils'; +import {isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; import EventEmitter from '@mm-redux/utils/event_emitter'; -import {isFromWebhook, isSystemMessage, shouldIgnorePost} from '@mm-redux/utils/post_utils'; import {ActionResult, DispatchFunc, GenericAction, GetStateFunc, batchActions} from '@mm-redux/types/actions'; import {WebSocketMessage} from '@mm-redux/types/websocket'; +import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils'; +import {isFromWebhook, isSystemMessage, shouldIgnorePost} from '@mm-redux/utils/post_utils'; +import {getViewingGlobalThreads} from '@selectors/threads'; export function handleNewPostEvent(msg: WebSocketMessage) { return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { const state = getState(); const currentChannelId = getCurrentChannelId(state); const currentUserId = getCurrentUserId(state); + const viewingGlobalThreads = getViewingGlobalThreads(state); + const data = JSON.parse(msg.data.post); const post = { ...data, @@ -70,7 +74,8 @@ export function handleNewPostEvent(msg: WebSocketMessage) { } } - actions.push(receivedNewPost(post)); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + actions.push(receivedNewPost(post, collapsedThreadsEnabled)); // If we don't have the thread for this post, fetch it from the server // and include the actions in the batch @@ -132,7 +137,9 @@ export function handleNewPostEvent(msg: WebSocketMessage) { ) { markAsRead = true; markAsReadOnServer = false; - } else if (post.channel_id === currentChannelId) { + } else if ((post.channel_id === currentChannelId) && !viewingGlobalThreads) { + // Don't mark as read if we're in global threads screen + // the currentChannelId still refers to previously viewed channel markAsRead = true; markAsReadOnServer = true; } @@ -142,7 +149,7 @@ export function handleNewPostEvent(msg: WebSocketMessage) { const readActions = markAsViewedAndReadBatch(state, post.channel_id, undefined, markAsReadOnServer); actions.push(...readActions); } else { - const unreadActions = markChannelAsUnread(state, msg.data.team_id, post.channel_id, msg.data.mentions); + const unreadActions = markChannelAsUnread(state, msg.data.team_id, post.channel_id, msg.data.mentions, post.root_id === ''); actions.push(...unreadActions); } } diff --git a/app/actions/websocket/preferences.ts b/app/actions/websocket/preferences.ts index 9d040d0ca..a356a8af7 100644 --- a/app/actions/websocket/preferences.ts +++ b/app/actions/websocket/preferences.ts @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. import {getAddedDmUsersIfNecessary} from '@actions/helpers/channels'; +import {handleCRTPreferenceChange} from '@actions/views/crt'; + import {getPost} from '@actions/views/post'; import {PreferenceTypes} from '@mm-redux/action_types'; import {Preferences} from '@mm-redux/constants'; @@ -17,12 +19,15 @@ export function handlePreferenceChangedEvent(msg: WebSocketMessage) { type: PreferenceTypes.RECEIVED_PREFERENCES, data: [preference], }]; - - const dmActions = await getAddedDmUsersIfNecessary(getState(), [preference]); + const crtPreferenceChanged = dispatch(handleCRTPreferenceChange([preference])) as ActionResult; + if (crtPreferenceChanged.data) { + return {data: true}; + } + const state = getState(); + const dmActions = await getAddedDmUsersIfNecessary(state, [preference]); if (dmActions.length) { actions.push(...dmActions); } - dispatch(batchActions(actions, 'BATCH_WS_PREFERENCE_CHANGED')); return {data: true}; }; @@ -43,7 +48,13 @@ export function handlePreferencesChangedEvent(msg: WebSocketMessage) { } }); - const dmActions = await getAddedDmUsersIfNecessary(getState(), preferences); + const crtPreferenceChanged = dispatch(handleCRTPreferenceChange(preferences)) as ActionResult; + if (crtPreferenceChanged.data) { + return {data: true}; + } + + const state = getState(); + const dmActions = await getAddedDmUsersIfNecessary(state, preferences); if (dmActions.length) { actions.push(...dmActions); } diff --git a/app/actions/websocket/threads.ts b/app/actions/websocket/threads.ts new file mode 100644 index 000000000..85e9df7ed --- /dev/null +++ b/app/actions/websocket/threads.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {handleThreadArrived, handleReadChanged, handleAllMarkedRead, handleFollowChanged, getThread as fetchThread} from '@mm-redux/actions/threads'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/common'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {getThread} from '@mm-redux/selectors/entities/threads'; +import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; +import {WebSocketMessage} from '@mm-redux/types/websocket'; + +export function handleThreadUpdated(msg: WebSocketMessage) { + return (dispatch: DispatchFunc): ActionResult => { + try { + const threadData = JSON.parse(msg.data.thread); + dispatch(handleThreadArrived(threadData, msg.broadcast.team_id)); + } catch { + // does nothing + } + + return {data: true}; + }; +} + +export function handleThreadReadChanged(msg: WebSocketMessage) { + return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { + if (msg.data.thread_id) { + const state = getState(); + const thread = getThread(state, msg.data.thread_id); + if (thread) { + dispatch( + handleReadChanged( + msg.data.thread_id, + msg.broadcast.team_id, + msg.data.channel_id, + { + lastViewedAt: msg.data.timestamp, + prevUnreadMentions: thread.unread_mentions, + newUnreadMentions: msg.data.unread_mentions, + prevUnreadReplies: thread.unread_replies, + newUnreadReplies: msg.data.unread_replies, + }, + ), + ); + } + } else { + dispatch(handleAllMarkedRead(msg.broadcast.team_id)); + } + return {data: true}; + }; +} + +export function handleThreadFollowChanged(msg: WebSocketMessage) { + return async (dispatch: DispatchFunc, getState: GetStateFunc): Promise => { + const state = getState(); + const thread = getThread(state, msg.data.thread_id); + if (!thread && msg.data.state) { + await dispatch(fetchThread(getCurrentUserId(state), getCurrentTeamId(state), msg.data.thread_id, true)); + } + dispatch(handleFollowChanged(msg.data.thread_id, msg.broadcast.team_id, msg.data.state)); + return {data: true}; + }; +} diff --git a/app/client/rest/base.ts b/app/client/rest/base.ts index 58d3df9da..342e0971a 100644 --- a/app/client/rest/base.ts +++ b/app/client/rest/base.ts @@ -278,6 +278,14 @@ export default class ClientBase { return `${this.getBotsRoute()}/${botUserId}`; } + getUserThreadsRoute(userID: string, teamID: string): string { + return `${this.getUserRoute(userID)}/teams/${teamID}/threads`; + } + + getUserThreadRoute(userId: string, teamId: string, threadId: string): string { + return `${this.getUserThreadsRoute(userId, teamId)}/${threadId}`; + } + getAppsProxyRoute() { return `${this.url}/plugins/com.mattermost.apps`; } diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index c09a52020..1b8762b01 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -272,7 +272,7 @@ const ClientChannels = (superclass: any) => class extends superclass { }; viewMyChannel = async (channelId: string, prevChannelId?: string) => { - const data = {channel_id: channelId, prev_channel_id: prevChannelId}; + const data = {channel_id: channelId, prev_channel_id: prevChannelId, collapsed_threads_supported: true}; return this.doFetch( `${this.getChannelsRoute()}/members/me/view`, {method: 'post', body: JSON.stringify(data)}, diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index d6c99a4b5..1e38d52b4 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -14,11 +14,16 @@ export interface ClientPostsMix { getPost: (postId: string) => Promise; patchPost: (postPatch: Partial & {id: string}) => Promise; deletePost: (postId: string) => Promise; - getPostThread: (postId: string) => Promise; - getPosts: (channelId: string, page?: number, perPage?: number) => Promise; - getPostsSince: (channelId: string, since: number) => Promise; - getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; - getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise; + getPostThread: (postId: string, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; + getPosts: (channelId: string, page?: number, perPage?: number, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; + getPostsSince: (channelId: string, since: number, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; + getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; + getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise; + getUserThreads: (userId: string, teamId: string, before?: string, after?: string, pageSize?: number, extended?: boolean, deleted?: boolean, unread?: boolean, since?: number) => Promise; + getUserThread: (userId: string, teamId: string, threadId: string, extended?: boolean) => Promise; + updateThreadsReadForUser: (userId: string, teamId: string) => Promise; + updateThreadReadForUser: (userId: string, teamId: string, threadId: string, timestamp: number) => Promise; + updateThreadFollowForUser: (userId: string, teamId: string, threadId: string, state: boolean) => Promise; getFileInfosForPost: (postId: string) => Promise; getFlaggedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise; getPinnedPosts: (channelId: string) => Promise; @@ -82,45 +87,83 @@ const ClientPosts = (superclass: any) => class extends superclass { ); }; - getPostThread = async (postId: string) => { + getPostThread = async (postId: string, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) => { return this.doFetch( - `${this.getPostRoute(postId)}/thread`, + `${this.getPostRoute(postId)}/thread${buildQueryString({skipFetchThreads: !fetchThreads, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; - getPosts = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { + getPosts = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) => { return this.doFetch( - `${this.getChannelRoute(channelId)}/posts${buildQueryString({page, per_page: perPage})}`, + `${this.getChannelRoute(channelId)}/posts${buildQueryString({page, per_page: perPage, skipFetchThreads: !fetchThreads, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; - getPostsSince = async (channelId: string, since: number) => { + getPostsSince = async (channelId: string, since: number, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) => { return this.doFetch( - `${this.getChannelRoute(channelId)}/posts${buildQueryString({since})}`, + `${this.getChannelRoute(channelId)}/posts${buildQueryString({since, skipFetchThreads: !fetchThreads, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; - getPostsBefore = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { + getPostsBefore = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) => { analytics.trackAPI('api_posts_get_before', {channel_id: channelId}); return this.doFetch( - `${this.getChannelRoute(channelId)}/posts${buildQueryString({before: postId, page, per_page: perPage})}`, + `${this.getChannelRoute(channelId)}/posts${buildQueryString({before: postId, page, per_page: perPage, skipFetchThreads: !fetchThreads, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; - getPostsAfter = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT) => { + getPostsAfter = async (channelId: string, postId: string, page = 0, perPage = PER_PAGE_DEFAULT, fetchThreads = true, collapsedThreads = false, collapsedThreadsExtended = false) => { analytics.trackAPI('api_posts_get_after', {channel_id: channelId}); return this.doFetch( - `${this.getChannelRoute(channelId)}/posts${buildQueryString({after: postId, page, per_page: perPage})}`, + `${this.getChannelRoute(channelId)}/posts${buildQueryString({after: postId, page, per_page: perPage, skipFetchThreads: !fetchThreads, collapsedThreads, collapsedThreadsExtended})}`, {method: 'get'}, ); }; + getUserThreads = async (userId: string, teamId: string, before = '', after = '', pageSize = PER_PAGE_DEFAULT, extended = false, deleted = false, unread = false, since = 0) => { + return this.doFetch( + `${this.getUserThreadsRoute(userId, teamId)}${buildQueryString({before, after, pageSize, extended, deleted, unread, since})}`, + {method: 'get'}, + ); + }; + + getUserThread = async (userId: string, teamId: string, threadId: string, extended = false) => { + return this.doFetch( + `${this.getUserThreadRoute(userId, teamId, threadId)}${buildQueryString({extended})}`, + {method: 'get'}, + ); + }; + + updateThreadsReadForUser = (userId: string, teamId: string) => { + const url = `${this.getUserThreadsRoute(userId, teamId)}/read`; + return this.doFetch( + url, + {method: 'put'}, + ); + }; + + updateThreadReadForUser = (userId: string, teamId: string, threadId: string, timestamp: number) => { + const url = `${this.getUserThreadRoute(userId, teamId, threadId)}/read/${timestamp}`; + return this.doFetch( + url, + {method: 'put'}, + ); + }; + + updateThreadFollowForUser = (userId: string, teamId: string, threadId: string, state: boolean) => { + const url = this.getUserThreadRoute(userId, teamId, threadId) + '/following'; + return this.doFetch( + url, + {method: state ? 'put' : 'delete'}, + ); + }; + getFileInfosForPost = async (postId: string) => { return this.doFetch( `${this.getPostRoute(postId)}/files/info`, @@ -150,7 +193,7 @@ const ClientPosts = (superclass: any) => class extends superclass { return this.doFetch( `${this.getUserRoute(userId)}/posts/${postId}/set_unread`, - {method: 'post'}, + {method: 'post', body: JSON.stringify({collapsed_threads_supported: true})}, ); } diff --git a/app/components/avatars/__snapshots__/avatars.test.tsx.snap b/app/components/avatars/__snapshots__/avatars.test.tsx.snap index 720f350ed..2a73a19ab 100644 --- a/app/components/avatars/__snapshots__/avatars.test.tsx.snap +++ b/app/components/avatars/__snapshots__/avatars.test.tsx.snap @@ -2,7 +2,8 @@ exports[`Avatars should match snapshot for overflow 1`] = ` { @@ -70,6 +70,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }, overflowText: { fontSize: 10, + fontWeight: 'bold', color: changeOpacity(theme.centerChannelColor, 0.64), textAlign: 'center', }, @@ -78,9 +79,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { const OVERFLOW_DISPLAY_LIMIT = 99; -interface AvatarsProps { +export interface AvatarsProps { userIds: string[]; breakAt?: number; + style?: StyleProp; theme: Theme; } @@ -89,15 +91,30 @@ export default class Avatars extends PureComponent { breakAt: 3, }; + showParticipantsList = () => { + const {userIds} = this.props; + + const screen = 'ParticipantsList'; + const passProps = { + userIds, + }; + + showModalOverCurrentContext(screen, passProps); + }; + render() { - const {userIds, breakAt, theme} = this.props; + const {userIds, breakAt, style: baseContainerStyle, theme} = this.props; const displayUserIds = userIds.slice(0, breakAt); const overflowUsersCount = Math.min(userIds.length - displayUserIds.length, OVERFLOW_DISPLAY_LIMIT); const style = getStyleSheet(theme); return ( - + {displayUserIds.map((userId: string, i: number) => ( { + it('should render correctly', () => { + const justNow = new Date(); + justNow.setSeconds(justNow.getSeconds() - 10); + const justNowText = renderWithIntl( + , + ); + expect(justNowText.getByText('Now')).toBeTruthy(); + + const minutesAgo = new Date(); + minutesAgo.setMinutes(minutesAgo.getMinutes() - 1); + const minutesAgoText = renderWithIntl( + , + ); + expect(minutesAgoText.getByText('1 min ago')).toBeTruthy(); + + const hoursAgo = new Date(); + hoursAgo.setHours(hoursAgo.getHours() - 4); + const hoursAgoText = renderWithIntl( + , + ); + expect(hoursAgoText.getByText('4 hours ago')).toBeTruthy(); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdayText = renderWithIntl( + , + ); + expect(yesterdayText.getByText('Yesterday')).toBeTruthy(); + + const daysAgo = new Date(); + daysAgo.setDate(daysAgo.getDate() - 10); + const daysAgoText = renderWithIntl( + , + ); + expect(daysAgoText.getByText('10 days ago')).toBeTruthy(); + + // Difference is less than 30 days + const daysEdgeCase = new Date('2020/02/28'); + const daysEdgeCaseTodayDate = new Date('2020/03/28'); + const daysEdgeCaseText = renderWithIntl( + , + ); + expect(daysEdgeCaseText.getByText('1 month ago')).toBeTruthy(); + + const daysAgoMax = new Date('2020/03/06'); + const daysAgoMaxTodayDate = new Date('2020/04/05'); + const daysAgoMaxText = renderWithIntl( + , + ); + expect(daysAgoMaxText.getByText('30 days ago')).toBeTruthy(); + + const monthsAgo = new Date(); + monthsAgo.setMonth(monthsAgo.getMonth() - 2); + const monthsAgoText = renderWithIntl( + , + ); + expect(monthsAgoText.getByText('2 months ago')).toBeTruthy(); + + const yearsAgo = new Date(); + yearsAgo.setFullYear(yearsAgo.getFullYear() - 2); + const yearsAgoText = renderWithIntl( + , + ); + expect(yearsAgoText.getByText('2 years ago')).toBeTruthy(); + }); +}); diff --git a/app/components/friendly_date/index.tsx b/app/components/friendly_date/index.tsx new file mode 100644 index 000000000..15763bfc9 --- /dev/null +++ b/app/components/friendly_date/index.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleProp, Text, ViewStyle} from 'react-native'; +import {injectIntl, intlShape} from 'react-intl'; + +import {DateTypes} from '@constants'; +import {isYesterday} from '@utils/datetime'; + +type Props = { + intl: typeof intlShape; + style?: StyleProp; + sourceDate?: number | Date; + value: number | Date; +}; + +function FriendlyDate({intl, style, sourceDate, value}: Props) { + const formattedTime = getFriendlyDate(intl, value, sourceDate); + return ( + {formattedTime} + ); +} + +export function getFriendlyDate(intl: typeof intlShape, inputDate: number | Date, sourceDate?: number | Date): string { + const today = sourceDate ? new Date(sourceDate) : new Date(); + const date = new Date(inputDate); + const difference = (today.getTime() - date.getTime()) / 1000; + + // Message: Now + if (difference < DateTypes.SECONDS.MINUTE) { + return intl.formatMessage({ + id: 'friendly_date.now', + defaultMessage: 'Now', + }); + } + + // Message: Minutes Ago + if (difference < DateTypes.SECONDS.HOUR) { + const minutes = Math.floor(difference / DateTypes.SECONDS.MINUTE); + return intl.formatMessage({ + id: 'friendly_date.minsAgo', + defaultMessage: '{count} {count, plural, one {min} other {mins}} ago', + }, { + count: minutes, + }); + } + + // Message: Hours Ago + if (difference < DateTypes.SECONDS.DAY) { + const hours = Math.floor(difference / DateTypes.SECONDS.HOUR); + return intl.formatMessage({ + id: 'friendly_date.hoursAgo', + defaultMessage: '{count} {count, plural, one {hour} other {hours}} ago', + }, { + count: hours, + }); + } + + // Message: Days Ago + if (difference < DateTypes.SECONDS.DAYS_31) { + if (isYesterday(date)) { + return intl.formatMessage({ + id: 'friendly_date.yesterday', + defaultMessage: 'Yesterday', + }); + } + const completedAMonth = today.getMonth() !== date.getMonth() && today.getDate() >= date.getDate(); + if (!completedAMonth) { + const days = Math.floor(difference / DateTypes.SECONDS.DAY) || 1; + return intl.formatMessage({ + id: 'friendly_date.daysAgo', + defaultMessage: '{count} {count, plural, one {day} other {days}} ago', + }, { + count: days, + }); + } + } + + // Message: Months Ago + if (difference < DateTypes.SECONDS.DAYS_366) { + const completedAnYear = today.getFullYear() !== date.getFullYear() && + today.getMonth() >= date.getMonth() && + today.getDate() >= date.getDate(); + if (!completedAnYear) { + const months = Math.floor(difference / DateTypes.SECONDS.DAYS_30) || 1; + return intl.formatMessage({ + id: 'friendly_date.monthsAgo', + defaultMessage: '{count} {count, plural, one {month} other {months}} ago', + }, { + count: months, + }); + } + } + + // Message: Years Ago + const years = Math.floor(difference / DateTypes.SECONDS.DAYS_365) || 1; + return intl.formatMessage({ + id: 'friendly_date.yearsAgo', + defaultMessage: '{count} {count, plural, one {year} other {years}} ago', + }, { + count: years, + }); +} + +export default injectIntl(FriendlyDate); diff --git a/app/components/global_threads/empty_state.tsx b/app/components/global_threads/empty_state.tsx new file mode 100644 index 000000000..bb0799e9c --- /dev/null +++ b/app/components/global_threads/empty_state.tsx @@ -0,0 +1,251 @@ +// 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 {intlShape} from 'react-intl'; +import Svg, { + Ellipse, + G, + Path, + Defs, + ClipPath, + Rect, +} from 'react-native-svg'; +import {useSelector} from 'react-redux'; + +import {makeStyleSheetFromTheme} from 'app/utils/theme'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import type {Theme} from '@mm-redux/types/preferences'; +import type {GlobalState} from '@mm-redux/types/store'; + +type Props = { + intl: typeof intlShape; + isUnreads: boolean; +} + +function EmptyState({intl, isUnreads}: Props) { + const theme = useSelector((state: GlobalState) => getTheme(state)); + const style = getStyleSheet(theme); + let title; + let subTitle; + if (isUnreads) { + title = intl.formatMessage({ + id: 'global_threads.emptyUnreads.title', + defaultMessage: 'No unread threads', + }); + subTitle = intl.formatMessage({ + id: 'global_threads.emptyUnreads.message', + defaultMessage: "Looks like you're all caught up.", + }); + } else { + title = intl.formatMessage({ + id: 'global_threads.emptyThreads.title', + defaultMessage: 'No followed threads yet', + }); + subTitle = intl.formatMessage({ + id: 'global_threads.emptyThreads.message', + defaultMessage: 'Any threads you are mentioned in or have participated in will show here along with any threads you have followed.', + }); + } + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {title} + + + {subTitle} + + + + ); +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + alignItems: 'center', + flexGrow: 1, + justifyContent: 'center', + }, + textContainer: { + padding: 32, + }, + title: { + color: theme.centerChannelColor, + fontSize: 20, + fontWeight: '600', + textAlign: 'center', + }, + subTitle: { + color: theme.centerChannelColor, + fontSize: 16, + fontWeight: '400', + lineHeight: 24, + marginTop: 16, + textAlign: 'center', + }, + }; +}); + +export default EmptyState; diff --git a/app/components/global_threads/global_threads.tsx b/app/components/global_threads/global_threads.tsx new file mode 100644 index 000000000..fc7aa7379 --- /dev/null +++ b/app/components/global_threads/global_threads.tsx @@ -0,0 +1,140 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {injectIntl, intlShape} from 'react-intl'; +import {Alert, FlatList} from 'react-native'; + +import type {ActionResult} from '@mm-redux/types/actions'; +import type {Theme} from '@mm-redux/types/preferences'; +import type {Team} from '@mm-redux/types/teams'; +import type {UserProfile} from '@mm-redux/types/users'; +import type {$ID} from '@mm-redux/types/utilities'; +import type {ThreadsState, UserThread} from '@mm-redux/types/threads'; + +import ThreadList from './thread_list'; + +type Props = { + actions: { + getThreads: (userId: $ID, teamId: $ID, before?: $ID, after?: $ID, perPage?: number, deleted?: boolean, unread?: boolean) => Promise; + handleViewingGlobalThreadsAll: () => void; + handleViewingGlobalThreadsUnreads: () => void; + markAllThreadsInTeamRead: (userId: $ID, teamId: $ID) => void; + }, + allThreadIds: $ID[]; + intl: typeof intlShape; + teamId: $ID; + theme: Theme; + threadCount: ThreadsState['counts'][$ID]; + unreadThreadIds: $ID[]; + userId: $ID; + viewingUnreads: boolean; +} + +function GlobalThreadsList({actions, allThreadIds, intl, teamId, theme, threadCount, unreadThreadIds, userId, viewingUnreads}: Props) { + const ids = viewingUnreads ? unreadThreadIds : allThreadIds; + const haveUnreads = threadCount?.total_unread_threads > 0; + + const listRef = React.useRef(null); + + const [isLoading, setIsLoading] = React.useState(true); + + const scrollToTop = () => { + listRef.current?.scrollToOffset({offset: 0}); + }; + + const loadThreads = async (before = '', after = '', unread = false) => { + if (!isLoading) { + setIsLoading(true); + } + await actions.getThreads(userId, teamId, before, after, undefined, false, unread); + setIsLoading(false); + }; + + React.useEffect(() => { + // Loads on mount, Loads on team change + scrollToTop(); + loadThreads('', ids[0]); + }, [teamId]); + + // Prevent from being called when an active request is pending. + const loadMoreThreads = async () => { + if ( + !isLoading && + ids.length && + threadCount.total + ) { + // Check if we have more threads to load. + if (viewingUnreads) { + if (threadCount.total_unread_threads === unreadThreadIds.length) { + return; + } + } else if (threadCount.total === allThreadIds.length) { + return; + } + + // Get the last thread, send request for threads after this thread. + const lastThreadId = ids[ids.length - 1]; + await loadThreads(lastThreadId, '', viewingUnreads); + } + }; + + const handleViewAllThreads = () => { + scrollToTop(); + loadThreads('', allThreadIds[0], false); + actions.handleViewingGlobalThreadsAll(); + }; + + const handleViewUnreadThreads = () => { + scrollToTop(); + loadThreads('', unreadThreadIds[0], true); + actions.handleViewingGlobalThreadsUnreads(); + }; + + const markAllAsRead = () => { + Alert.alert( + intl.formatMessage({ + id: 'global_threads.markAllRead.title', + defaultMessage: 'Are you sure you want to mark all threads as read?', + }), + intl.formatMessage({ + id: 'global_threads.markAllRead.message', + defaultMessage: 'This will clear any unread status for all of your threads shown here', + }), + [{ + text: intl.formatMessage({ + id: 'global_threads.markAllRead.cancel', + defaultMessage: 'Cancel', + }), + style: 'cancel', + }, { + text: intl.formatMessage({ + id: 'global_threads.markAllRead.markRead', + defaultMessage: 'Mark read', + }), + style: 'default', + onPress: () => { + actions.markAllThreadsInTeamRead(userId, teamId); + }, + }], + ); + }; + + return ( + + ); +} + +export default injectIntl(GlobalThreadsList); diff --git a/app/components/global_threads/index.ts b/app/components/global_threads/index.ts new file mode 100644 index 000000000..ff6d288a6 --- /dev/null +++ b/app/components/global_threads/index.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {bindActionCreators, Dispatch} from 'redux'; +import {connect} from 'react-redux'; + +import {handleViewingGlobalThreadsAll, handleViewingGlobalThreadsUnreads} from '@actions/views/threads'; +import {getThreads, markAllThreadsInTeamRead} from '@mm-redux/actions/threads'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/common'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {getTeamThreadCounts, getThreadOrderInCurrentTeam, getUnreadThreadOrderInCurrentTeam} from '@mm-redux/selectors/entities/threads'; +import type {GlobalState} from '@mm-redux/types/store'; +import {getViewingGlobalThreadsUnread} from '@selectors/threads'; + +import GlobalThreads from './global_threads'; + +function mapStateToProps(state: GlobalState) { + const teamId = getCurrentTeamId(state); + return { + teamId, + userId: getCurrentUserId(state), + viewingUnreads: getViewingGlobalThreadsUnread(state), + allThreadIds: getThreadOrderInCurrentTeam(state), + unreadThreadIds: getUnreadThreadOrderInCurrentTeam(state), + threadCount: getTeamThreadCounts(state, teamId), + theme: getTheme(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + getThreads, + handleViewingGlobalThreadsAll, + handleViewingGlobalThreadsUnreads, + markAllThreadsInTeamRead, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(GlobalThreads); diff --git a/app/components/global_threads/thread_footer/__snapshots__/thread_footer.test.tsx.snap b/app/components/global_threads/thread_footer/__snapshots__/thread_footer.test.tsx.snap new file mode 100644 index 000000000..3fa782763 --- /dev/null +++ b/app/components/global_threads/thread_footer/__snapshots__/thread_footer.test.tsx.snap @@ -0,0 +1,120 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Global Thread Footer Should render for channel view and unfollow the thread on press 1`] = ` + + + + + + + 2 replies + + + + Following + + + +`; + +exports[`Global Thread Footer Should render for global threads view 1`] = ` + + + + 2 new replies + + +`; diff --git a/app/components/global_threads/thread_footer/index.ts b/app/components/global_threads/thread_footer/index.ts new file mode 100644 index 000000000..a1e322bd2 --- /dev/null +++ b/app/components/global_threads/thread_footer/index.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {bindActionCreators, Dispatch} from 'redux'; +import {connect} from 'react-redux'; + +import {setThreadFollow} from '@mm-redux/actions/threads'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/common'; +import type {GlobalState} from '@mm-redux/types/store'; + +import ThreadFooter, {StateProps, DispatchProps, OwnProps} from './thread_footer'; + +function mapStateToProps(state: GlobalState) { + return { + currentUserId: getCurrentUserId(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + setThreadFollow, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ThreadFooter); diff --git a/app/components/global_threads/thread_footer/thread_footer.test.tsx b/app/components/global_threads/thread_footer/thread_footer.test.tsx new file mode 100644 index 000000000..eff5756db --- /dev/null +++ b/app/components/global_threads/thread_footer/thread_footer.test.tsx @@ -0,0 +1,106 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import {Preferences} from '@mm-redux/constants'; +import {UserProfile} from '@mm-redux/types/users'; +import {UserThread} from '@mm-redux/types/threads'; +import {intl} from 'test/intl-test-helper'; + +import {ThreadFooter} from './thread_footer'; + +describe('Global Thread Footer', () => { + const testID = 'thread_footer.footer'; + + const setThreadFollow = jest.fn(); + + const baseProps = { + actions: { + setThreadFollow, + }, + currentUserId: 'user1', + currentTeamId: 'team1', + intl, + testID, + theme: Preferences.THEMES.default, + thread: { + id: 'thread1', + participants: [{ + id: 'user1', + }], + is_following: true, + reply_count: 2, + } as UserThread, + threadStarter: { + id: 'user1', + } as UserProfile, + }; + + test('Should render for channel view and unfollow the thread on press', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + + expect(wrapper.find({testID: `${testID}.unread_replies`}).exists()).toBeFalsy(); + expect(wrapper.find({testID: `${testID}.unfollowing`}).exists()).toBeFalsy(); + + const followingButton = wrapper.find({testID: `${testID}.following`}); + expect(followingButton.exists()).toBeTruthy(); + followingButton.simulate('press'); + expect(setThreadFollow).toBeCalledWith('user1', 'thread1', false); + + const replyCount = wrapper.find({testID: `${testID}.reply_count`}); + expect(replyCount.exists()).toBeTruthy(); + expect(replyCount.children().text().trim()).toBe('2 replies'); + }); + + test('Should follow the thread on press in channel view', () => { + const props = { + ...baseProps, + thread: { + ...baseProps.thread, + is_following: false, + }, + }; + const wrapper = shallow( + , + ); + const followButton = wrapper.find({testID: `${testID}.follow`}); + expect(followButton.exists()).toBeTruthy(); + followButton.simulate('press'); + expect(setThreadFollow).toBeCalledWith('user1', 'thread1', true); + }); + + test('Should render for global threads view', () => { + const props = { + ...baseProps, + thread: { + ...baseProps.thread, + unread_replies: 2, + }, + }; + const wrapper = shallow( + , + ); + expect(wrapper.getElement()).toMatchSnapshot(); + + expect(wrapper.find({testID: `${testID}.unread_replies`}).exists()).toBeTruthy(); + + expect(wrapper.find({testID: `${testID}.reply_count`}).exists()).toBeFalsy(); + expect(wrapper.find({testID: `${testID}.following`}).exists()).toBeFalsy(); + expect(wrapper.find({testID: `${testID}.unfollowing`}).exists()).toBeFalsy(); + }); +}); diff --git a/app/components/global_threads/thread_footer/thread_footer.tsx b/app/components/global_threads/thread_footer/thread_footer.tsx new file mode 100644 index 000000000..d304f8813 --- /dev/null +++ b/app/components/global_threads/thread_footer/thread_footer.tsx @@ -0,0 +1,233 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text, TouchableOpacity, View} from 'react-native'; +import {injectIntl, intlShape} from 'react-intl'; + +import Avatars from '@components/avatars'; +import CompassIcon from '@components/compass_icon'; +import {GLOBAL_THREADS, CHANNEL} from '@constants/screen'; +import type {Theme} from '@mm-redux/types/preferences'; +import {UserThread} from '@mm-redux/types/threads'; +import {UserProfile} from '@mm-redux/types/users'; +import {$ID} from '@mm-redux/types/utilities'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +export type DispatchProps = { + actions: { + setThreadFollow: (currentUserId: $ID, threadId: $ID, newState: boolean) => void; + }; +}; + +export type OwnProps = { + location: 'GlobalThreads' | 'Channel'; + testID: string; + thread: UserThread; + threadStarter: UserProfile; + theme: Theme; +} + +export type StateProps = { + currentUserId: $ID; +}; + +export type Props = DispatchProps & OwnProps & StateProps & { + intl: typeof intlShape; +}; + +function ThreadFooter({actions, currentUserId, intl, location, testID, theme, thread, threadStarter}: Props) { + const style = getStyleSheet(theme); + + const onUnfollow = React.useCallback(preventDoubleTap(() => { + actions.setThreadFollow(currentUserId, thread.id, false); + }), []); + + const onFollow = React.useCallback(preventDoubleTap(() => { + actions.setThreadFollow(currentUserId, thread.id, true); + }), []); + + let replyIcon; + let followButton; + if (location === CHANNEL) { + if (thread.reply_count) { + replyIcon = ( + + + + ); + } + if (thread.is_following) { + followButton = ( + + + {intl.formatMessage({ + id: 'threads.following', + defaultMessage: 'Following', + })} + + + ); + } else { + followButton = ( + <> + + + + {intl.formatMessage({ + id: 'threads.follow', + defaultMessage: 'Follow', + })} + + + + ); + } + } + + let repliesComponent; + if (location === GLOBAL_THREADS && thread.unread_replies) { + repliesComponent = ( + + {intl.formatMessage({ + id: 'threads.newReplies', + defaultMessage: '{count} new {count, plural, one {reply} other {replies}}', + }, { + count: thread.unread_replies, + })} + + ); + } else if (thread.reply_count) { + repliesComponent = ( + + {intl.formatMessage({ + id: 'threads.replies', + defaultMessage: '{count} {count, plural, one {reply} other {replies}}', + }, { + count: thread.reply_count, + })} + + ); + } + + // threadstarter should be the first one in the avatars list + const participants = React.useMemo(() => { + let isThreadStarterFound = false; + const participantIds = thread.participants.flatMap((participant) => { + if (participant.id === threadStarter?.id) { + isThreadStarterFound = true; + return []; + } + return participant.id; + }); + if (isThreadStarterFound) { + participantIds.unshift(threadStarter?.id); + } + return participantIds; + }, [thread.participants, threadStarter]); + + let avatars; + if (participants.length) { + avatars = ( + + ); + } + + return ( + + {avatars} + {replyIcon} + {repliesComponent} + {followButton} + + ); +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + const followingButtonContainerBase = { + justifyContent: 'center', + height: 32, + paddingHorizontal: 12, + }; + + return { + container: { + flexDirection: 'row', + alignItems: 'center', + minHeight: 40, + }, + avatarsContainer: { + marginRight: 12, + paddingVertical: 8, + }, + replyIconContainer: { + top: -1, + marginRight: 5, + }, + replies: { + alignSelf: 'center', + color: changeOpacity(theme.centerChannelColor, 0.64), + fontSize: 12, + fontWeight: '600', + marginRight: 12, + }, + unreadReplies: { + alignSelf: 'center', + color: theme.sidebarTextActiveBorder, + fontSize: 12, + fontWeight: '600', + marginRight: 12, + }, + notFollowingButtonContainer: { + ...followingButtonContainerBase, + paddingLeft: 0, + }, + notFollowing: { + color: changeOpacity(theme.centerChannelColor, 0.64), + fontWeight: '600', + fontSize: 12, + }, + followingButtonContainer: { + ...followingButtonContainerBase, + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + borderRadius: 4, + }, + following: { + color: theme.buttonBg, + fontWeight: '600', + fontSize: 12, + }, + followSeparator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.16), + height: 16, + marginRight: 12, + width: 1, + }, + }; +}); + +export {ThreadFooter}; // Used for shallow render test cases + +export default injectIntl(ThreadFooter); diff --git a/app/components/global_threads/thread_item/__snapshots__/thread_item.test.tsx.snap b/app/components/global_threads/thread_item/__snapshots__/thread_item.test.tsx.snap new file mode 100644 index 000000000..4b4be4c44 --- /dev/null +++ b/app/components/global_threads/thread_item/__snapshots__/thread_item.test.tsx.snap @@ -0,0 +1,382 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Global Thread Item Should render thread item with unread messages dot 1`] = ` + + + + + + + + + + + + CHANNEL 1 + + + + + + + + + + + + +`; + +exports[`Global Thread Item Should show unread mentions count 1`] = ` + + + + + + 5 + + + + + + + + + + CHANNEL 1 + + + + + + + + + + + + +`; diff --git a/app/components/global_threads/thread_item/index.ts b/app/components/global_threads/thread_item/index.ts new file mode 100644 index 000000000..cacdbe90d --- /dev/null +++ b/app/components/global_threads/thread_item/index.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {bindActionCreators, Dispatch} from 'redux'; +import {connect} from 'react-redux'; + +import {selectPost} from '@mm-redux/actions/posts'; +import {getPost, getPostThread} from '@actions/views/post'; +import {getChannel} from '@mm-redux/selectors/entities/channels'; +import {getPost as getPostSelector} from '@mm-redux/selectors/entities/posts'; +import {getThread} from '@mm-redux/selectors/entities/threads'; +import {getUser} from '@mm-redux/selectors/entities/users'; +import type {GlobalState} from '@mm-redux/types/store'; + +import ThreadItem, {DispatchProps, OwnProps, StateProps} from './thread_item'; + +function mapStateToProps(state: GlobalState, props: OwnProps) { + const {threadId} = props; + const post = getPostSelector(state, threadId); + return { + channel: getChannel(state, post?.channel_id), + post: getPostSelector(state, threadId), + thread: getThread(state, threadId), + threadStarter: getUser(state, post?.user_id), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + getPost, + getPostThread, + selectPost, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ThreadItem); diff --git a/app/components/global_threads/thread_item/thread_item.test.tsx b/app/components/global_threads/thread_item/thread_item.test.tsx new file mode 100644 index 000000000..ecbbb8607 --- /dev/null +++ b/app/components/global_threads/thread_item/thread_item.test.tsx @@ -0,0 +1,113 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Text} from 'react-native'; +import {shallow} from 'enzyme'; + +import * as navigationActions from '@actions/navigation'; +import {THREAD} from '@constants/screen'; +import {Preferences} from '@mm-redux/constants'; +import {Channel} from '@mm-redux/types/channels'; +import {Post} from '@mm-redux/types/posts'; +import {UserThread} from '@mm-redux/types/threads'; +import {UserProfile} from '@mm-redux/types/users'; +import {intl} from 'test/intl-test-helper'; + +import {ThreadItem} from './thread_item'; + +describe('Global Thread Item', () => { + const testID = 'thread_item'; + + const baseProps = { + actions: { + getPost: jest.fn(), + getPostThread: jest.fn(), + selectPost: jest.fn(), + }, + channel: { + display_name: 'CHANNEL 1', + } as Channel, + intl, + post: { + id: 'post1', + } as Post, + threadId: 'post1', + testID, + theme: Preferences.THEMES.default, + thread: { + id: 'post1', + unread_replies: 5, + } as UserThread, + threadStarter: { + id: 'user1', + } as UserProfile, + }; + + const testIDPrefix = `${testID}.post1`; + + test('Should render thread item with unread messages dot', () => { + const wrapper = shallow( + , + ); + expect(wrapper.getElement()).toMatchSnapshot(); + + expect(wrapper.find({testID: `${testIDPrefix}.unread_dot`}).exists()).toBeTruthy(); + expect(wrapper.find({testID: `${testIDPrefix}.unread_mentions`}).exists()).toBeFalsy(); + + expect(wrapper.find({testID: `${testIDPrefix}.footer`}).exists()).toBeTruthy(); + }); + + test('Should show unread mentions count', () => { + const props = { + ...baseProps, + thread: { + ...baseProps.thread, + unread_mentions: 5, + }, + }; + const wrapper = shallow( + , + ); + expect(wrapper.getElement()).toMatchSnapshot(); + expect(wrapper.find({testID: `${testIDPrefix}.unread_dot`}).exists()).toBeFalsy(); + + const mentionBadge = wrapper.find({testID: `${testIDPrefix}.unread_mentions`}).at(0); + expect(mentionBadge.exists()).toBeTruthy(); + expect(mentionBadge.find(Text).children().text().trim()).toBe('5'); + }); + + test('Should show unread mentions as 99+ when over 99', () => { + const props = { + ...baseProps, + thread: { + ...baseProps.thread, + unread_mentions: 511, + }, + }; + const wrapper = shallow( + , + ); + const mentionBadge = wrapper.find({testID: `${testIDPrefix}.unread_mentions`}).at(0); + expect(mentionBadge.find(Text).children().text().trim()).toBe('99+'); + }); + + test('Should goto threads when pressed on thread item', () => { + const goToScreen = jest.spyOn(navigationActions, 'goToScreen'); + const wrapper = shallow( + , + ); + const threadItem = wrapper.find({testID: `${testIDPrefix}.item`}); + expect(threadItem.exists()).toBeTruthy(); + threadItem.simulate('press'); + expect(goToScreen).toHaveBeenCalledWith(THREAD, expect.anything(), expect.anything()); + }); +}); diff --git a/app/components/global_threads/thread_item/thread_item.tsx b/app/components/global_threads/thread_item/thread_item.tsx new file mode 100644 index 000000000..7dd743d13 --- /dev/null +++ b/app/components/global_threads/thread_item/thread_item.tsx @@ -0,0 +1,270 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {View, Text, TouchableHighlight} from 'react-native'; +import {injectIntl, intlShape} from 'react-intl'; + +import {goToScreen} from '@actions/navigation'; +import RemoveMarkdown from '@components/remove_markdown'; +import FriendlyDate from '@components/friendly_date'; +import {GLOBAL_THREADS, THREAD} from '@constants/screen'; +import {Posts, Preferences} from '@mm-redux/constants'; + +import {Channel} from '@mm-redux/types/channels'; +import {Post} from '@mm-redux/types/posts'; +import type {Theme} from '@mm-redux/types/preferences'; +import {UserThread} from '@mm-redux/types/threads'; +import {displayUsername} from '@mm-redux/utils/user_utils'; +import {UserProfile} from '@mm-redux/types/users'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import ThreadFooter from '../thread_footer'; + +export type DispatchProps = { + actions: { + getPost: (postId: string) => void; + getPostThread: (postId: string) => void; + selectPost: (postId: string) => void; + }; +} + +export type OwnProps = { + testID: string; + theme: Theme; + threadId: string; +}; + +export type StateProps = { + channel: Channel; + post: Post; + thread: UserThread | null; + threadStarter: UserProfile; +} + +type Props = DispatchProps & OwnProps & StateProps & { + intl: typeof intlShape; +}; + +function ThreadItem({actions, channel, intl, post, threadId, testID, theme, thread, threadStarter}: Props) { + const style = getStyleSheet(theme); + + if (!thread) { + return null; + } + + const postItem = post || thread.post; + + React.useEffect(() => { + // Get the latest post + if (!post) { + actions.getPost(threadId); + } + }, []); + + const channelName = channel?.display_name; + const threadStarterName = displayUsername(threadStarter, Preferences.DISPLAY_PREFER_FULL_NAME); + + const showThread = () => { + actions.getPostThread(postItem.id); + actions.selectPost(postItem.id); + const passProps = { + channelId: postItem.channel_id, + rootId: postItem.id, + }; + goToScreen(THREAD, '', passProps); + }; + + const testIDPrefix = `${testID}.${postItem?.id}`; + + const needBadge = thread.unread_mentions || thread.unread_replies; + let badgeComponent; + if (needBadge) { + if (thread.unread_mentions && thread.unread_mentions > 0) { + badgeComponent = ( + + {thread.unread_mentions > 99 ? '99+' : thread.unread_mentions} + + ); + } else if (thread.unread_replies && thread.unread_replies > 0) { + badgeComponent = ( + + ); + } + } + + let name; + if (postItem.state === Posts.POST_DELETED) { + name = ( + {intl.formatMessage({ + id: 'threads.deleted', + defaultMessage: 'Original Message Deleted', + })} + ); + } else { + name = ( + {threadStarterName} + ); + } + + let postBody; + if (postItem.state !== Posts.POST_DELETED) { + postBody = ( + + + + ); + } + + return ( + + + + {badgeComponent} + + + + + {name} + + {channelName} + + + + + {postBody} + + + + + ); +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + paddingTop: 16, + paddingRight: 16, + flex: 1, + flexDirection: 'row', + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08), + borderBottomWidth: 1, + }, + badgeContainer: { + marginTop: 3, + width: 32, + }, + postContainer: { + flex: 1, + }, + header: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + marginBottom: 9, + }, + headerInfoContainer: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + marginRight: 12, + overflow: 'hidden', + }, + threadDeleted: { + color: changeOpacity(theme.centerChannelColor, 0.72), + fontStyle: 'italic', + }, + threadStarter: { + color: theme.centerChannelColor, + fontSize: 15, + fontWeight: '600', + lineHeight: 22, + paddingRight: 8, + }, + channelNameContainer: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), + borderRadius: 4, + maxWidth: '50%', + }, + channelName: { + color: theme.centerChannelColor, + fontSize: 10, + fontWeight: '600', + lineHeight: 16, + letterSpacing: 0.1, + textTransform: 'uppercase', + marginLeft: 6, + marginRight: 6, + marginTop: 2, + marginBottom: 2, + }, + date: { + color: changeOpacity(theme.centerChannelColor, 0.64), + fontSize: 12, + fontWeight: '400', + }, + message: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: 20, + }, + unreadDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: theme.sidebarTextActiveBorder, + alignSelf: 'center', + marginTop: 5, + }, + mentionBadge: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: theme.mentionColor, + alignSelf: 'center', + }, + mentionBadgeText: { + fontFamily: 'Open Sans', + fontSize: 10, + lineHeight: 16, + fontWeight: '700', + alignSelf: 'center', + color: theme.centerChannelBg, + }, + }; +}); + +export {ThreadItem}; // Used for shallow render test cases + +export default injectIntl(ThreadItem); diff --git a/app/components/global_threads/thread_list/__snapshots__/index.test.tsx.snap b/app/components/global_threads/thread_list/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000..f49100a55 --- /dev/null +++ b/app/components/global_threads/thread_list/__snapshots__/index.test.tsx.snap @@ -0,0 +1,153 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Global Thread List Should render threads with functional tabs & mark all as read button 1`] = ` + + + + } + ListFooterComponent={null} + contentContainerStyle={ + Object { + "flexGrow": 1, + } + } + data={ + Array [ + "thread1", + ] + } + disableVirtualization={false} + horizontal={false} + initialNumToRender={10} + keyExtractor={[Function]} + maxToRenderPerBatch={10} + numColumns={1} + onEndReached={[Function]} + onEndReachedThreshold={2} + removeClippedSubviews={true} + renderItem={[Function]} + scrollEventThrottle={50} + scrollIndicatorInsets={ + Object { + "right": 1, + } + } + updateCellsBatchingPeriod={50} + windowSize={21} + /> + +`; diff --git a/app/components/global_threads/thread_list/index.test.tsx b/app/components/global_threads/thread_list/index.test.tsx new file mode 100644 index 000000000..8ecca07db --- /dev/null +++ b/app/components/global_threads/thread_list/index.test.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FlatList} from 'react-native'; +import {shallow} from 'enzyme'; + +import {Preferences} from '@mm-redux/constants'; +import {intl} from 'test/intl-test-helper'; + +import {ThreadList} from './index'; + +jest.spyOn(React, 'useRef').mockReturnValue({ + current: {}, +}); + +describe('Global Thread List', () => { + const testID = 'thread_list'; + + const markAllAsRead = jest.fn(); + const viewAllThreads = jest.fn(); + const viewUnreadThreads = jest.fn(); + + const baseProps = { + haveUnreads: true, + intl, + isLoading: false, + listRef: React.useRef(null), + loadMoreThreads: jest.fn(), + markAllAsRead, + testID, + theme: Preferences.THEMES.default, + threadIds: ['thread1'], + viewingUnreads: true, + viewAllThreads, + viewUnreadThreads, + }; + + const wrapper = shallow( + , + ); + + test('Should render threads with functional tabs & mark all as read button', () => { + expect(wrapper.getElement()).toMatchSnapshot(); + }); +}); diff --git a/app/components/global_threads/thread_list/index.tsx b/app/components/global_threads/thread_list/index.tsx new file mode 100644 index 000000000..03b005220 --- /dev/null +++ b/app/components/global_threads/thread_list/index.tsx @@ -0,0 +1,194 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {FlatList, Platform, View} from 'react-native'; +import {injectIntl, intlShape} from 'react-intl'; + +import EmptyState from '@components/global_threads/empty_state'; +import ThreadItem from '@components/global_threads/thread_item'; +import Loading from '@components/loading'; +import {INITIAL_BATCH_TO_RENDER} from '@components/post_list/post_list_config'; +import {ViewTypes} from '@constants'; +import type {Theme} from '@mm-redux/types/preferences'; +import type {UserThread} from '@mm-redux/types/threads'; +import type {$ID} from '@mm-redux/types/utilities'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import ThreadListHeader from './thread_list_header'; + +export type Props = { + haveUnreads: boolean; + intl: typeof intlShape; + isLoading: boolean; + loadMoreThreads: () => Promise; + listRef: React.RefObject; + markAllAsRead: () => void; + testID: string; + theme: Theme; + threadIds: $ID[]; + viewAllThreads: () => void; + viewUnreadThreads: () => void; + viewingUnreads: boolean; +}; + +function ThreadList({haveUnreads, intl, isLoading, loadMoreThreads, listRef, markAllAsRead, testID, theme, threadIds, viewAllThreads, viewUnreadThreads, viewingUnreads}: Props) { + const style = getStyleSheet(theme); + + const handleEndReached = React.useCallback(() => { + loadMoreThreads(); + }, [loadMoreThreads, viewingUnreads]); + + const keyExtractor = React.useCallback((item) => item, []); + + const renderPost = React.useCallback(({item}) => { + return ( + + ); + }, []); + + const renderHeader = () => { + if (!viewingUnreads && !threadIds.length) { + return null; + } + return ( + + ); + }; + + const renderEmptyList = () => { + if (isLoading) { + return ( + + ); + } + return ( + + ); + }; + + const renderFooter = () => { + if (isLoading && threadIds?.length >= ViewTypes.CRT_CHUNK_SIZE) { + return ( + + + + ); + } + return null; + }; + + return ( + + {renderHeader()} + + + ); +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + borderBottom: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.08), + borderBottomWidth: 1, + }, + + container: { + flex: 1, + }, + headerContainer: { + alignItems: 'center', + flexDirection: 'row', + }, + + menuContainer: { + alignItems: 'center', + flexGrow: 1, + flexDirection: 'row', + paddingLeft: 12, + marginVertical: 12, + }, + menuItemContainer: { + paddingVertical: 8, + paddingHorizontal: 16, + }, + menuItemContainerSelected: { + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + borderRadius: 4, + }, + menuItem: { + color: changeOpacity(theme.centerChannelColor, 0.56), + alignSelf: 'center', + fontFamily: 'Open Sans', + fontWeight: '600', + fontSize: 16, + lineHeight: 24, + }, + menuItemSelected: { + color: theme.buttonBg, + }, + + unreadsDot: { + position: 'absolute', + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: theme.sidebarTextActiveBorder, + right: -6, + top: 4, + }, + + markAllReadIconContainer: { + paddingHorizontal: 20, + }, + markAllReadIcon: { + fontSize: 28, + lineHeight: 28, + color: changeOpacity(theme.centerChannelColor, 0.56), + }, + markAllReadIconDisabled: { + opacity: 0.5, + }, + messagesContainer: { + flexGrow: 1, + }, + listScrollIndicator: { + right: 1, + }, + loadingMoreContainer: { + paddingVertical: 12, + }, + }; +}); + +export {ThreadList}; // Used for shallow render test cases + +export default injectIntl(ThreadList); diff --git a/app/components/global_threads/thread_list/thread_list_header/__snapshots__/index.test.tsx.snap b/app/components/global_threads/thread_list/thread_list_header/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000..0bdba990a --- /dev/null +++ b/app/components/global_threads/thread_list/thread_list_header/__snapshots__/index.test.tsx.snap @@ -0,0 +1,85 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Global Thread List Header Should render threads with functional tabs & mark all as read button 1`] = ` + + + + + + All Your Threads + + + + + + + + Unreads + + + + + + + + + + + + +`; diff --git a/app/components/global_threads/thread_list/thread_list_header/index.test.tsx b/app/components/global_threads/thread_list/thread_list_header/index.test.tsx new file mode 100644 index 000000000..a3cced8d9 --- /dev/null +++ b/app/components/global_threads/thread_list/thread_list_header/index.test.tsx @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {shallow} from 'enzyme'; + +import {intl} from 'test/intl-test-helper'; + +import {ThreadListHeader} from './index'; + +jest.mock('app/components/compass_icon', () => 'Icon'); + +describe('Global Thread List Header', () => { + const testID = 'thread_list'; + + const markAllAsRead = jest.fn(); + const viewAllThreads = jest.fn(); + const viewUnreadThreads = jest.fn(); + + const baseProps = { + haveUnreads: true, + intl, + markAllAsRead, + style: {}, + testID, + viewingUnreads: true, + viewAllThreads, + viewUnreadThreads, + }; + + const wrapper = shallow( + , + ); + + test('Should render threads with functional tabs & mark all as read button', () => { + expect(wrapper.getElement()).toMatchSnapshot(); + + const allThreadsTab = wrapper.find({testID: `${testID}.all_threads`}).at(0); + expect(allThreadsTab.exists()).toBeTruthy(); + allThreadsTab.simulate('press'); + expect(viewAllThreads).toHaveBeenCalled(); + + const unreadThreadsTab = wrapper.find({testID: `${testID}.unread_threads`}).at(0); + expect(unreadThreadsTab.exists()).toBeTruthy(); + unreadThreadsTab.simulate('press'); + expect(viewUnreadThreads).toHaveBeenCalled(); + + expect(wrapper.find({testID: `${testID}.unreads_dot`}).exists()).toBeTruthy(); + + const markAllAsReadButton = wrapper.find({testID: `${testID}.mark_all_read`}).at(0); + expect(markAllAsReadButton.exists()).toBeTruthy(); + expect(markAllAsReadButton.props().disabled).toBeFalsy(); + markAllAsReadButton.simulate('press'); + expect(markAllAsRead).toHaveBeenCalled(); + }); + + test('Should disable mark all as read and hide dot on UNREADS tab when no unread messages are present', () => { + wrapper.setProps({ + ...baseProps, + haveUnreads: false, + }); + + expect(wrapper.find({testID: `${testID}.unreads_dot`}).exists()).toBeFalsy(); + + const markAllAsReadButton = wrapper.find({testID: `${testID}.mark_all_read`}).at(0); + expect(markAllAsReadButton.exists()).toBeTruthy(); + expect(markAllAsReadButton.props().disabled).toBeTruthy(); + }); +}); diff --git a/app/components/global_threads/thread_list/thread_list_header/index.tsx b/app/components/global_threads/thread_list/thread_list_header/index.tsx new file mode 100644 index 000000000..aa4319f6b --- /dev/null +++ b/app/components/global_threads/thread_list/thread_list_header/index.tsx @@ -0,0 +1,81 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {StyleProp, Text, TextStyle, TouchableOpacity, View, ViewStyle} from 'react-native'; +import {injectIntl, intlShape} from 'react-intl'; + +import CompassIcon from '@components/compass_icon'; + +export type Props = { + haveUnreads: boolean; + intl: typeof intlShape; + markAllAsRead: () => void; + style: Record>; + testID: string; + viewAllThreads: () => void; + viewUnreadThreads: () => void; + viewingUnreads: boolean; +}; + +function ThreadListHeader({haveUnreads, intl, markAllAsRead, style, testID, viewAllThreads, viewUnreadThreads, viewingUnreads}: Props) { + return ( + + + + + + { + intl.formatMessage({ + id: 'global_threads.allThreads', + defaultMessage: 'All Your Threads', + }) + } + + + + + + + + { + intl.formatMessage({ + id: 'global_threads.unreads', + defaultMessage: 'Unreads', + }) + } + + {haveUnreads ? ( + + ) : null} + + + + + + + + + + + ); +} + +export {ThreadListHeader}; // Used for shallow render test cases + +export default injectIntl(ThreadListHeader); diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index 9004a39ae..28221d72a 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -21,6 +21,7 @@ import HeaderTag from './tag'; type HeaderProps = { commentCount: number; + collapsedThreadsEnabled: boolean; displayName?: string; isBot: boolean; isGuest: boolean; @@ -63,7 +64,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const Header = ({ - commentCount, displayName, location, isBot, isGuest, + commentCount, collapsedThreadsEnabled, displayName, location, isBot, isGuest, isMilitaryTime, post, rootPostAuthor, shouldRenderReplyButton, theme, userTimezone, isCustomStatusEnabled, }: HeaderProps) => { const style = getStyleSheet(theme); @@ -72,7 +73,7 @@ const Header = ({ const isWebHook = isFromWebhook(post); const isSystemPost = isSystemMessage(post); const isReplyPost = Boolean(post.root_id && (!isPostEphemeral(post) || post.state === Posts.POST_DELETED)); - const showReply = !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0)); + const showReply = !collapsedThreadsEnabled && !isReplyPost && (location !== THREAD) && (shouldRenderReplyButton || (!rootPostAuthor && commentCount > 0)); const showCustomStatusEmoji = Boolean(isCustomStatusEnabled && displayName && !(isSystemPost || isBot || isAutoResponse || isWebHook)); return ( diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index d8a371f37..3bc785560 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -4,13 +4,16 @@ import {connect} from 'react-redux'; import {showPermalink} from '@actions/views/permalink'; +import {THREAD} from '@constants/screen'; import {removePost} from '@mm-redux/actions/posts'; import {getChannel} from '@mm-redux/selectors/entities/channels'; import {getConfig} from '@mm-redux/selectors/entities/general'; import {getPost, isRootPost} from '@mm-redux/selectors/entities/posts'; -import {getMyPreferences, getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences'; +import {getMyPreferences, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {getThread} from '@mm-redux/selectors/entities/threads'; import {getUser} from '@mm-redux/selectors/entities/users'; +import {UserThread} from '@mm-redux/types/threads'; import {isPostFlagged, isSystemMessage} from '@mm-redux/utils/post_utils'; import {canDeletePost} from '@selectors/permissions'; import {areConsecutivePosts, postUserDisplayName} from '@utils/post'; @@ -23,6 +26,7 @@ import type {GlobalState} from '@mm-redux/types/store'; import Post from './post'; type OwnProps = { + location: string; highlight?: boolean; postId: string; post?: PostType; @@ -73,16 +77,26 @@ function mapSateToProps(state: GlobalState, ownProps: OwnProps) { } } + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + + let thread: UserThread | null = null; + if (collapsedThreadsEnabled && ownProps.location !== THREAD) { + thread = getThread(state, post.id, true); + } + return { canDelete, enablePostUsernameOverride, isConsecutivePost, + collapsedThreadsEnabled, isFirstReply, isFlagged: isPostFlagged(post.id, myPreferences), isLastReply, post, rootPostAuthor, teammateNameDisplay, + thread, + threadStarter: getUser(state, post.user_id), }; } diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index 8876ecabe..fbc7ed84c 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -6,11 +6,14 @@ import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'; import {injectIntl, intlShape} from 'react-intl'; import {showModalOverCurrentContext} from '@actions/navigation'; +import ThreadFooter from '@components/global_threads/thread_footer'; import SystemHeader from '@components/post_list/system_header'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import SystemAvatar from '@components/post_list/system_avatar'; import * as Screens from '@constants/screen'; import {Posts} from '@mm-redux/constants'; +import {UserProfile} from '@mm-redux/types/users'; +import {UserThread} from '@mm-redux/types/threads'; import EventEmitter from '@mm-redux/utils/event_emitter'; import {fromAutoResponder, isPostEphemeral, isPostPendingOrFailed, isSystemMessage} from '@mm-redux/utils/post_utils'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -27,6 +30,7 @@ import SystemMessage from './system_message'; type PostProps = { canDelete: boolean; + collapsedThreadsEnabled: boolean; enablePostUsernameOverride: boolean; highlight?: boolean; highlightPinnedOrFlagged?: boolean; @@ -48,6 +52,8 @@ type PostProps = { teammateNameDisplay: string; testID?: string; theme: Theme + thread: UserThread; + threadStarter: UserProfile; }; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -66,6 +72,20 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { opacity: 1, }, highlightPinnedOrFlagged: {backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.2)}, + badgeContainer: { + position: 'absolute', + left: 28, + bottom: 9, + }, + unreadDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: theme.sidebarTextActiveBorder, + alignSelf: 'center', + top: -6, + left: 4, + }, pendingPost: {opacity: 0.5}, postStyle: { overflow: 'hidden', @@ -91,9 +111,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }); const Post = ({ - canDelete, enablePostUsernameOverride, highlight, highlightPinnedOrFlagged = true, intl, isConsecutivePost, isFirstReply, isFlagged, isLastReply, - location, post, removePost, rootPostAuthor, shouldRenderReplyButton, skipFlaggedHeader, skipPinnedHeader, showAddReaction = true, showPermalink, - teammateNameDisplay, testID, theme, style, + canDelete, collapsedThreadsEnabled, enablePostUsernameOverride, highlight, highlightPinnedOrFlagged = true, intl, isConsecutivePost, isFirstReply, isFlagged, isLastReply, + location, post, removePost, rootPostAuthor, shouldRenderReplyButton, skipFlaggedHeader, skipPinnedHeader, showAddReaction = true, showPermalink, style, + teammateNameDisplay, testID, theme, thread, threadStarter, }: PostProps) => { const pressDetected = useRef(false); const styles = getStyleSheet(theme); @@ -198,6 +218,7 @@ const Post = ({ } else { header = (
+ ); + } + + let badge; + if (thread?.unread_mentions || thread?.unread_replies) { + if (thread.unread_replies && thread.unread_replies > 0) { + badge = ( + + + + ); + } + } + return ( {header} {body} + {footer} + {badge} diff --git a/app/components/sidebars/main/channels_list/channel_item/channel_item.js b/app/components/sidebars/main/channels_list/channel_item/channel_item.js index 7442d34df..b59910f32 100644 --- a/app/components/sidebars/main/channels_list/channel_item/channel_item.js +++ b/app/components/sidebars/main/channels_list/channel_item/channel_item.js @@ -39,12 +39,14 @@ export default class ChannelItem extends PureComponent { theme: PropTypes.object.isRequired, unreadMsgs: PropTypes.number.isRequired, isSearchResult: PropTypes.bool, + viewingGlobalThreads: PropTypes.bool, customStatusEnabled: PropTypes.bool.isRequired, }; static defaultProps = { isArchived: false, mentions: 0, + viewingGlobalThreads: false, }; static contextTypes = { @@ -80,6 +82,7 @@ export default class ChannelItem extends PureComponent { theme, isSearchResult, channel, + viewingGlobalThreads, teammateId, } = this.props; @@ -117,7 +120,7 @@ export default class ChannelItem extends PureComponent { } const style = getStyleSheet(theme); - const isActive = channelId === currentChannelId; + const isActive = channelId === currentChannelId && !viewingGlobalThreads; let extraItemStyle; let extraTextStyle; @@ -219,7 +222,7 @@ export default class ChannelItem extends PureComponent { } } -const getStyleSheet = makeStyleSheetFromTheme((theme) => { +export const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { container: { flex: 1, diff --git a/app/components/sidebars/main/channels_list/channel_item/index.js b/app/components/sidebars/main/channels_list/channel_item/index.js index f455084f7..2d23813fc 100644 --- a/app/components/sidebars/main/channels_list/channel_item/index.js +++ b/app/components/sidebars/main/channels_list/channel_item/index.js @@ -11,9 +11,10 @@ import { makeGetChannel, shouldHideDefaultChannel, } from '@mm-redux/selectors/entities/channels'; -import {getTheme, getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences'; +import {getTheme, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; +import {getViewingGlobalThreads} from '@selectors/threads'; import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users'; -import {getUserIdFromChannelName, isChannelMuted} from '@mm-redux/utils/channel_utils'; +import {getMsgCountInChannel, getUserIdFromChannelName, isChannelMuted} from '@mm-redux/utils/channel_utils'; import {displayUsername} from '@mm-redux/utils/user_utils'; import {isCustomStatusEnabled} from '@selectors/custom_status'; import {getDraftForChannel} from '@selectors/views'; @@ -29,6 +30,7 @@ function makeMapStateToProps() { const member = getMyChannelMember(state, channel.id); const currentUserId = getCurrentUserId(state); const channelDraft = getDraftForChannel(state, channel.id); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); let displayName = channel.display_name; let isGuest = false; @@ -63,13 +65,15 @@ function makeMapStateToProps() { let unreadMsgs = 0; if (member && channel) { - unreadMsgs = Math.max(channel.total_msg_count - member.msg_count, 0); + unreadMsgs = getMsgCountInChannel(collapsedThreadsEnabled, channel, member); } let showUnreadForMsgs = true; if (member && member.notify_props) { showUnreadForMsgs = member.notify_props.mark_unread !== General.MENTION; } + + const viewingGlobalThreads = getViewingGlobalThreads(state); return { channel, currentChannelId, @@ -80,12 +84,13 @@ function makeMapStateToProps() { isChannelMuted: isChannelMuted(member), isGuest, isManualUnread: isManuallyUnread(state, ownProps.channelId), - mentions: member ? member.mention_count : 0, + mentions: (collapsedThreadsEnabled ? member?.mention_count_root : member?.mention_count) || 0, shouldHideChannel, showUnreadForMsgs, teammateId, theme: getTheme(state), unreadMsgs, + viewingGlobalThreads, customStatusEnabled: isCustomStatusEnabled(state), }; }; diff --git a/app/components/sidebars/main/channels_list/list/__snapshots__/list.test.js.snap b/app/components/sidebars/main/channels_list/list/__snapshots__/list.test.js.snap index 987271dcd..aac21063f 100644 --- a/app/components/sidebars/main/channels_list/list/__snapshots__/list.test.js.snap +++ b/app/components/sidebars/main/channels_list/list/__snapshots__/list.test.js.snap @@ -39,3 +39,76 @@ exports[`ChannelsList List should match snapshot 1`] = ` /> `; + +exports[`ChannelsList List should match snapshot with collapsed threads enabled 1`] = ` + + + + +`; diff --git a/app/components/sidebars/main/channels_list/list/index.js b/app/components/sidebars/main/channels_list/list/index.js index 2e08d9409..c27210a44 100644 --- a/app/components/sidebars/main/channels_list/list/index.js +++ b/app/components/sidebars/main/channels_list/list/index.js @@ -12,7 +12,7 @@ import { } from '@mm-redux/selectors/entities/channels'; import {getCurrentUserId, getCurrentUserRoles} from '@mm-redux/selectors/entities/users'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; -import {getTheme, getFavoritesPreferences, getSidebarPreferences} from '@mm-redux/selectors/entities/preferences'; +import {getTheme, getFavoritesPreferences, getSidebarPreferences, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {showCreateOption} from '@mm-redux/utils/channel_utils'; import {memoizeResult} from '@mm-redux/utils/helpers'; import {isAdmin as checkIsAdmin, isSystemAdmin as checkIsSystemAdmin} from '@mm-redux/utils/user_utils'; @@ -33,6 +33,7 @@ const filterZeroUnreads = memoizeResult((sections) => { function mapStateToProps(state) { const config = getConfig(state); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); const license = getLicense(state); const roles = getCurrentUserId(state) ? getCurrentUserRoles(state) : ''; const currentTeamId = getCurrentTeamId(state); @@ -49,6 +50,7 @@ function mapStateToProps(state) { sidebarPrefs.sorting, true, // The mobile app should always display the Unreads section regardless of user settings (MM-13420) sidebarPrefs.favorite_at_top === 'true' && favoriteChannelIds.length, + collapsedThreadsEnabled, )); let canJoinPublicChannels = true; @@ -65,6 +67,7 @@ function mapStateToProps(state) { canJoinPublicChannels, canCreatePrivateChannels, canCreatePublicChannels, + collapsedThreadsEnabled, favoriteChannelIds, theme: getTheme(state), unreadChannelIds, diff --git a/app/components/sidebars/main/channels_list/list/list.js b/app/components/sidebars/main/channels_list/list/list.js index 3432c6a20..ba763528a 100644 --- a/app/components/sidebars/main/channels_list/list/list.js +++ b/app/components/sidebars/main/channels_list/list/list.js @@ -21,6 +21,7 @@ import {debounce} from '@mm-redux/actions/helpers'; import CompassIcon from '@components/compass_icon'; import ChannelItem from '@components/sidebars/main/channels_list/channel_item'; +import ThreadsSidebarEntry from '@components/sidebars/main/threads_entry'; import {DeviceTypes, ListTypes, NavigationTypes} from '@constants'; import {SidebarSectionTypes} from '@constants/view'; @@ -154,6 +155,12 @@ export default class List extends PureComponent { id: t('mobile.channel_list.channels'), defaultMessage: 'CHANNELS', }; + case SidebarSectionTypes.THREADS: // Used only to identity the threads, hence not translating "id: t('...')" + return { + data: [''], + id: 'sidebar.threads', + defaultMessage: '', + }; default: return { action: this.showCreateChannelOptions, @@ -168,12 +175,14 @@ export default class List extends PureComponent { orderedChannelIds, } = props; - return orderedChannelIds.map((s) => { + const sections = orderedChannelIds.map((s) => { return { ...this.getSectionConfigByType(props, s.type), data: s.items, }; }); + + return sections; }; showCreateChannelOptions = () => { @@ -314,7 +323,12 @@ export default class List extends PureComponent { ); }; - renderItem = ({item}) => { + renderItem = ({item, section}) => { + if (section.id === 'sidebar.threads') { + return ( + + ); + } const {testID, favoriteChannelIds, unreadChannelIds} = this.props; const channelItemTestID = `${testID}.channel_item`; @@ -334,6 +348,10 @@ export default class List extends PureComponent { const {intl} = this.context; const {action, defaultMessage, id} = section; + if (id === 'sidebar.threads') { + return null; + } + const anchor = (id === 'sidebar.types.recent' || id === 'mobile.channel_list.channels'); return ( diff --git a/app/components/sidebars/main/channels_list/list/list.test.js b/app/components/sidebars/main/channels_list/list/list.test.js index 55e510311..395c169c1 100644 --- a/app/components/sidebars/main/channels_list/list/list.test.js +++ b/app/components/sidebars/main/channels_list/list/list.test.js @@ -15,6 +15,7 @@ describe('ChannelsList List', () => { canJoinPublicChannels: true, canCreatePrivateChannels: true, canCreatePublicChannels: true, + collapsedThreadsEnabled: false, favoriteChannelIds: [], unreadChannelIds: [], styles: {}, @@ -28,4 +29,15 @@ describe('ChannelsList List', () => { expect(wrapper.getElement()).toMatchSnapshot(); }); + + test('should match snapshot with collapsed threads enabled', () => { + const wrapper = shallow( + , + ); + + expect(wrapper.getElement()).toMatchSnapshot(); + }); }); diff --git a/app/components/sidebars/main/index.js b/app/components/sidebars/main/index.js index 62d4b8023..4a79830f5 100644 --- a/app/components/sidebars/main/index.js +++ b/app/components/sidebars/main/index.js @@ -12,8 +12,10 @@ import {getCurrentUser} from '@mm-redux/selectors/entities/users'; import {setChannelDisplayName, handleSelectChannel} from 'app/actions/views/channel'; import {makeDirectChannel} from 'app/actions/views/more_dms'; +import {handleNotViewingGlobalThreadsScreen} from '@actions/views/threads'; import MainSidebar from './main_sidebar'; +import {getViewingGlobalThreads} from '@selectors/threads'; function mapStateToProps(state) { const currentUser = getCurrentUser(state); @@ -24,6 +26,7 @@ function mapStateToProps(state) { currentUserId: currentUser?.id, teamsCount: getMyTeamsCount(state), theme: getTheme(state), + viewingGlobalThreads: getViewingGlobalThreads(state), }; } @@ -35,6 +38,7 @@ function mapDispatchToProps(dispatch) { makeDirectChannel, setChannelDisplayName, handleSelectChannel, + handleNotViewingGlobalThreadsScreen, }, dispatch), }; } diff --git a/app/components/sidebars/main/main_sidebar_base.js b/app/components/sidebars/main/main_sidebar_base.js index 101f7f651..083288c47 100644 --- a/app/components/sidebars/main/main_sidebar_base.js +++ b/app/components/sidebars/main/main_sidebar_base.js @@ -28,6 +28,7 @@ export default class MainSidebarBase extends Component { joinChannel: PropTypes.func.isRequired, makeDirectChannel: PropTypes.func.isRequired, setChannelDisplayName: PropTypes.func.isRequired, + handleNotViewingGlobalThreadsScreen: PropTypes.func, }).isRequired, children: PropTypes.node, currentTeamId: PropTypes.string.isRequired, @@ -35,6 +36,11 @@ export default class MainSidebarBase extends Component { locale: PropTypes.string, teamsCount: PropTypes.number.isRequired, theme: PropTypes.object.isRequired, + viewingGlobalThreads: PropTypes.bool, + }; + + static defaultProps = { + viewingGlobalThreads: false, }; constructor(props) { @@ -53,9 +59,12 @@ export default class MainSidebarBase extends Component { } shouldComponentUpdate(nextProps, nextState) { - const {currentTeamId, teamsCount, theme} = this.props; + const {currentTeamId, teamsCount, theme, viewingGlobalThreads} = this.props; const {deviceWidth, openDrawerOffset, isSplitView, permanentSidebar, searching} = this.state; + if (viewingGlobalThreads !== nextProps.viewingGlobalThreads) { + return true; + } if (nextState.openDrawerOffset !== openDrawerOffset && Platform.OS === 'ios') { return true; } @@ -264,13 +273,14 @@ export default class MainSidebarBase extends Component { }; selectChannel = (channel, currentChannelId, closeDrawer = true) => { - const {handleSelectChannel} = this.props.actions; + const {handleSelectChannel, handleNotViewingGlobalThreadsScreen} = this.props.actions; if (closeDrawer) { this.closeMainSidebar(); } if (channel.id === currentChannelId) { + handleNotViewingGlobalThreadsScreen(); return; } diff --git a/app/components/sidebars/main/threads_entry/index.tsx b/app/components/sidebars/main/threads_entry/index.tsx new file mode 100644 index 000000000..5c2df8a92 --- /dev/null +++ b/app/components/sidebars/main/threads_entry/index.tsx @@ -0,0 +1,39 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {bindActionCreators, Dispatch} from 'redux'; +import {connect} from 'react-redux'; + +import {handleViewingGlobalThreadsScreen} from '@actions/views/threads'; +import {getThreads} from '@mm-redux/actions/threads'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {getTeamThreadCounts} from '@mm-redux/selectors/entities/threads'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; +import type {GlobalState} from '@mm-redux/types/store'; +import {getViewingGlobalThreads, getViewingGlobalThreadsUnread} from '@selectors/threads'; + +import ThreadsEntry from './threads_entry'; + +function mapStateToProps(state: GlobalState) { + const currentTeamId = getCurrentTeamId(state); + return { + currentTeamId, + currentUserId: getCurrentUserId(state), + isUnreadSelected: getViewingGlobalThreadsUnread(state), + threadCount: getTeamThreadCounts(state, currentTeamId), + theme: getTheme(state), + viewingGlobalThreads: getViewingGlobalThreads(state), + }; +} + +function mapDispatchToProps(dispatch: Dispatch) { + return { + actions: bindActionCreators({ + getThreads, + handleViewingGlobalThreadsScreen, + }, dispatch), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ThreadsEntry); diff --git a/app/components/sidebars/main/threads_entry/threads_entry.tsx b/app/components/sidebars/main/threads_entry/threads_entry.tsx new file mode 100644 index 000000000..073d8b296 --- /dev/null +++ b/app/components/sidebars/main/threads_entry/threads_entry.tsx @@ -0,0 +1,147 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect} from 'react'; +import {TouchableHighlight, Text, View} from 'react-native'; +import {injectIntl, intlShape} from 'react-intl'; + +import Badge from '@components/badge'; +import CompassIcon from '@components/compass_icon'; +import {getStyleSheet} from '@components/sidebars/main/channels_list/channel_item/channel_item'; +import {NavigationTypes} from '@constants'; +import type {Theme} from '@mm-redux/types/preferences'; +import type {Team} from '@mm-redux/types/teams'; +import type {ThreadsState} from '@mm-redux/types/threads'; +import type {UserProfile} from '@mm-redux/types/users'; +import type {$ID} from '@mm-redux/types/utilities'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {makeStyleFromTheme} from '@mm-redux/utils/theme_utils'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity} from '@utils/theme'; + +type Props = { + actions: { + getThreads: (currentUserId: $ID, currentTeamId: $ID, before: string, after: string, perPage: number, deleted: boolean, unread: boolean) => void; + handleViewingGlobalThreadsScreen: () => void; + }; + currentTeamId: $ID; + currentUserId: $ID; + intl: typeof intlShape; + isUnreadSelected: boolean; + theme: Theme; + threadCount: ThreadsState['counts']; + viewingGlobalThreads: boolean; +}; + +const ThreadsEntry = ({ + actions, + currentTeamId, + currentUserId, + intl, + isUnreadSelected, + theme, + threadCount, + viewingGlobalThreads, +}: Props) => { + useEffect(() => { + actions.getThreads(currentUserId, currentTeamId, '', '', 5, false, isUnreadSelected); + }, []); + + const onPress = React.useCallback(preventDoubleTap(() => { + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); + actions.handleViewingGlobalThreadsScreen(); + }), []); + + const style = getStyleSheet(theme); + const extraStyle = getExtraStyleSheet(theme); + + const [itemStyle, textStyle, iconStyle, border, badge] = React.useMemo(() => { + const item = [style.item]; + const text = [style.text]; + const icon = [extraStyle.icon]; + let borderComponent; + let badgeComponent; + + if (viewingGlobalThreads) { + item.push(style.itemActive); + text.push(style.textActive); + borderComponent = ( + + ); + } + + if (threadCount?.total_unread_threads) { + text.push(style.textUnread); + } + + if (viewingGlobalThreads || threadCount?.total_unread_threads) { + icon.push(extraStyle.iconActive); + } + + if (threadCount?.total_unread_mentions) { + badgeComponent = ( + + ); + } + + return [item, text, icon, borderComponent, badgeComponent]; + }, [extraStyle, style, threadCount?.total_unread_mentions, threadCount?.total_unread_threads, viewingGlobalThreads]); + + return ( + + + {border} + + + + + + {intl.formatMessage({ + id: 'threads', + defaultMessage: 'Threads', + })} + + {badge} + + + + ); +}; + +const getExtraStyleSheet = makeStyleFromTheme((theme: Theme) => { + return { + container: { + marginTop: 16, + }, + iconContainer: { + alignItems: 'center', + }, + icon: { + color: changeOpacity(theme.sidebarText, 0.4), + fontSize: 24, + }, + iconActive: { + color: theme.sidebarText, + }, + }; +}); + +export default injectIntl(ThreadsEntry); diff --git a/app/constants/date.ts b/app/constants/date.ts new file mode 100644 index 000000000..369e497d0 --- /dev/null +++ b/app/constants/date.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export default { + SECONDS: { + MINUTE: 60, + HOUR: 3600, // 60 seconds * 60 minutes + DAY: 86400, // 24 hours = 24 * 3600 + DAYS_30: 2592000, // 30 days * 86400 seconds + DAYS_31: 2678400, // 31 days * 86400 seconds + DAYS_365: 31536000, // 365 days * 86400 seconds + DAYS_366: 31622400, // 366 days * 86400 seconds + }, +}; diff --git a/app/constants/index.js b/app/constants/index.js index 729dccaf2..4b5351208 100644 --- a/app/constants/index.js +++ b/app/constants/index.js @@ -4,6 +4,7 @@ import AttachmentTypes from './attachment'; import CustomPropTypes from './custom_prop_types'; import * as CustomStatus from './custom_status'; +import DateTypes from './date'; import DeepLinkTypes from './deep_linking'; import DeviceTypes from './device'; import ListTypes from './list'; @@ -17,6 +18,7 @@ export { CustomPropTypes, DeepLinkTypes, DeviceTypes, + DateTypes, ListTypes, NavigationTypes, Types, diff --git a/app/constants/screen.js b/app/constants/screen.js index 7689bfd8c..ad169c9d5 100644 --- a/app/constants/screen.js +++ b/app/constants/screen.js @@ -5,3 +5,4 @@ export const THREAD = 'Thread'; export const PERMALINK = 'Permalink'; export const SEARCH = 'Search'; export const CHANNEL = 'Channel'; +export const GLOBAL_THREADS = 'GlobalThreads'; diff --git a/app/constants/view.js b/app/constants/view.js index ba1b7a5e6..cc359a515 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -16,6 +16,7 @@ export const SidebarSectionTypes = { DIRECT: 'direct', RECENT_ACTIVITY: 'recent', ALPHA: 'alpha', + THREADS: 'threads', }; export const NotificationLevels = { @@ -99,6 +100,12 @@ const ViewTypes = keyMirror({ INDICATOR_BAR_VISIBLE: null, CHANNEL_NAV_BAR_CHANGED: null, + + VIEWING_GLOBAL_THREADS_SCREEN: null, + NOT_VIEWING_GLOBAL_THREADS_SCREEN: null, + + VIEWING_GLOBAL_THREADS_UNREADS: null, + VIEWING_GLOBAL_THREADS_ALL: null, }); const RequiredServer = { @@ -112,6 +119,7 @@ export default { ...ViewTypes, RequiredServer, POST_VISIBILITY_CHUNK_SIZE: 60, + CRT_CHUNK_SIZE: 60, FEATURE_TOGGLE_PREFIX: 'feature_enabled_', EMBED_PREVIEW: 'embed_preview', LINK_PREVIEW_DISPLAY: 'link_previews', diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 45002b88d..d3944294a 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -43,6 +43,9 @@ const WebsocketEvents = { INCREASE_POST_VISIBILITY_BY_ONE: 'increase_post_visibility_by_one', MEMBERROLE_UPDATED: 'memberrole_updated', RECEIVED_GROUP: 'received_group', + THREAD_UPDATED: 'thread_updated', + THREAD_FOLLOW_CHANGED: 'thread_follow_changed', + THREAD_READ_CHANGED: 'thread_read_changed', APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings', }; export default WebsocketEvents; diff --git a/app/init/global_event_handler.js b/app/init/global_event_handler.js index 2ad38a111..dbae03307 100644 --- a/app/init/global_event_handler.js +++ b/app/init/global_event_handler.js @@ -1,16 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {AppState, Dimensions, Linking, Platform} from 'react-native'; +import {AppState, Dimensions, Keyboard, Linking, Platform} from 'react-native'; import AsyncStorage from '@react-native-community/async-storage'; import CookieManager from '@react-native-cookies/cookies'; import DeviceInfo from 'react-native-device-info'; import {getLocales} from 'react-native-localize'; import {setDeviceDimensions, setDeviceOrientation, setDeviceAsTablet} from '@actions/device'; +import {dismissAllModals, popToRoot, showOverlay} from '@actions/navigation'; import {selectDefaultChannel} from '@actions/views/channel'; -import {showOverlay} from '@actions/navigation'; -import {loadConfigAndLicense, setDeepLinkURL, startDataCleanup} from '@actions/views/root'; +import {loadConfigAndLicense, purgeOfflineStore, setDeepLinkURL, startDataCleanup} from '@actions/views/root'; import {loadMe, logout} from '@actions/views/user'; import LocalConfig from '@assets/config'; import {NavigationTypes, ViewTypes} from '@constants'; @@ -30,6 +30,7 @@ import {isTimezoneEnabled} from '@mm-redux/selectors/entities/timezone'; import EventEmitter from '@mm-redux/utils/event_emitter'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import initialState from '@store/initial_state'; +import EphemeralStore from '@store/ephemeral_store'; import Store from '@store/store'; import {deleteFileCache} from '@utils/file'; import {getDeviceTimezone} from '@utils/timezone'; @@ -51,6 +52,7 @@ class GlobalEventHandler { EventEmitter.on(NavigationTypes.RESTART_APP, this.onRestartApp); EventEmitter.on(General.SERVER_VERSION_CHANGED, this.onServerVersionChanged); EventEmitter.on(General.CONFIG_CHANGED, this.onServerConfigChanged); + EventEmitter.on(General.CRT_PREFERENCE_CHANGED, this.onCRTPreferenceChanged); EventEmitter.on(General.SWITCH_TO_DEFAULT_CHANNEL, this.onSwitchToDefaultChannel); Dimensions.addEventListener('change', this.onOrientationChange); AppState.addEventListener('change', this.onAppStateChange); @@ -127,6 +129,20 @@ class GlobalEventHandler { emmProvider.previousAppState = appState; }; + onCRTPreferenceChanged = () => { + Keyboard.dismiss(); + requestAnimationFrame(async () => { + const componentId = EphemeralStore.getNavigationTopComponentId(); + if (componentId) { + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); + EventEmitter.emit(NavigationTypes.CLOSE_SETTINGS_SIDEBAR); + await dismissAllModals(); + await popToRoot(); + } + Store.redux.dispatch(purgeOfflineStore()); + }); + }; + onDeepLink = (event) => { const {url} = event; if (url) { diff --git a/app/mm-redux/action_types/index.ts b/app/mm-redux/action_types/index.ts index 070aa8448..c5e304203 100644 --- a/app/mm-redux/action_types/index.ts +++ b/app/mm-redux/action_types/index.ts @@ -18,6 +18,7 @@ import GroupTypes from './groups'; import BotTypes from './bots'; import PluginTypes from './plugins'; import ChannelCategoryTypes from './channel_categories'; +import ThreadTypes from './threads'; import RemoteClusterTypes from './remote_cluster'; import AppsTypes from './apps'; @@ -39,6 +40,7 @@ export { BotTypes, PluginTypes, ChannelCategoryTypes, + ThreadTypes, RemoteClusterTypes, AppsTypes, }; diff --git a/app/mm-redux/action_types/threads.ts b/app/mm-redux/action_types/threads.ts new file mode 100644 index 000000000..f1761d113 --- /dev/null +++ b/app/mm-redux/action_types/threads.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import keyMirror from '@mm-redux/utils/key_mirror'; + +export default keyMirror({ + ALL_TEAM_THREADS_READ: null, + FOLLOW_CHANGED_THREAD: null, + READ_CHANGED_THREAD: null, + RECEIVED_THREAD: null, + RECEIVED_THREADS: null, +}); diff --git a/app/mm-redux/actions/channels.test.js b/app/mm-redux/actions/channels.test.js index 7680d0b95..244268e7a 100644 --- a/app/mm-redux/actions/channels.test.js +++ b/app/mm-redux/actions/channels.test.js @@ -539,7 +539,7 @@ describe('Actions.Channels', () => { }); nock(Client4.getBaseRoute()). - post('/channels/members/me/view', {channel_id: channelId, prev_channel_id: prevChannelId}). + post('/channels/members/me/view', {channel_id: channelId, prev_channel_id: prevChannelId, collapsed_threads_supported: true}). reply(200, OK_RESPONSE); const now = Date.now(); @@ -578,7 +578,7 @@ describe('Actions.Channels', () => { }); nock(Client4.getBaseRoute()). - post('/channels/members/me/view', {channel_id: channelId, prev_channel_id: ''}). + post('/channels/members/me/view', {channel_id: channelId, prev_channel_id: '', collapsed_threads_supported: true}). reply(200, OK_RESPONSE); const result = await store.dispatch(Actions.viewChannel(channelId)); @@ -620,7 +620,7 @@ describe('Actions.Channels', () => { }); nock(Client4.getBaseRoute()). - post('/channels/members/me/view', {channel_id: channelId, prev_channel_id: ''}). + post('/channels/members/me/view', {channel_id: channelId, prev_channel_id: '', collapsed_threads_supported: true}). reply(200, OK_RESPONSE); const now = Date.now(); diff --git a/app/mm-redux/actions/channels.ts b/app/mm-redux/actions/channels.ts index 5147bd85b..662181aa5 100644 --- a/app/mm-redux/actions/channels.ts +++ b/app/mm-redux/actions/channels.ts @@ -13,11 +13,8 @@ import { } from '@mm-redux/selectors/entities/channels'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {getConfig} from '@mm-redux/selectors/entities/general'; - import {Action, ActionFunc, batchActions, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; - import {Channel, ChannelNotifyProps, ChannelMembership} from '@mm-redux/types/channels'; - import {PreferenceType} from '@mm-redux/types/preferences'; import {Dictionary} from '@mm-redux/types/utilities'; @@ -58,6 +55,8 @@ export function createChannel(channel: Channel, userId: string): ActionFunc { last_viewed_at: 0, msg_count: 0, mention_count: 0, + msg_count_root: 0, + mention_count_root: 0, notify_props: {desktop: 'default', mark_unread: 'all'}, last_update_at: created.create_at, }; @@ -108,6 +107,8 @@ export function createDirectChannel(userId: string, otherUserId: string): Action last_viewed_at: 0, msg_count: 0, mention_count: 0, + msg_count_root: 0, + mention_count_root: 0, notify_props: {desktop: 'default', mark_unread: 'all'}, last_update_at: created.create_at, }; @@ -185,6 +186,8 @@ export function createGroupChannel(userIds: Array): ActionFunc { last_viewed_at: 0, msg_count: 0, mention_count: 0, + msg_count_root: 0, + mention_count_root: 0, notify_props: {desktop: 'default', mark_unread: 'all'}, last_update_at: created.create_at, }; @@ -1283,6 +1286,7 @@ export function markChannelAsRead(channelId: string, prevChannelId?: string, upd teamId: channel.team_id, channelId, amount: channel.total_msg_count - channelMember.msg_count, + amountRoot: channel.total_msg_count_root - channelMember.msg_count_root, }, }); @@ -1292,6 +1296,7 @@ export function markChannelAsRead(channelId: string, prevChannelId?: string, upd teamId: channel.team_id, channelId, amount: channelMember.mention_count, + amountRoot: channelMember.mention_count_root, }, }); } @@ -1310,6 +1315,7 @@ export function markChannelAsRead(channelId: string, prevChannelId?: string, upd teamId: prevChannel.team_id, channelId: prevChannelId, amount: prevChannel.total_msg_count - prevChannelMember.msg_count, + amountRoot: prevChannel.total_msg_count_root - prevChannelMember.msg_count_root, }, }); @@ -1319,6 +1325,7 @@ export function markChannelAsRead(channelId: string, prevChannelId?: string, upd teamId: prevChannel.team_id, channelId: prevChannelId, amount: prevChannelMember.mention_count, + amountRoot: prevChannelMember.mention_count_root, }, }); } diff --git a/app/mm-redux/actions/posts.test.js b/app/mm-redux/actions/posts.test.js index 932684463..b4942f0af 100644 --- a/app/mm-redux/actions/posts.test.js +++ b/app/mm-redux/actions/posts.test.js @@ -360,7 +360,7 @@ describe('Actions.Posts', () => { }; nock(Client4.getBaseRoute()). - get(`/posts/${post.id}/thread`). + get(`/posts/${post.id}/thread?skipFetchThreads=false&collapsedThreads=false&collapsedThreadsExtended=false`). reply(200, postList); await Actions.getPostThread(post.id)(store.dispatch, store.getState); @@ -390,8 +390,8 @@ describe('Actions.Posts', () => { const post0 = {id: 'post0', channel_id: 'channel1', create_at: 1000, message: ''}; const post1 = {id: 'post1', channel_id: 'channel1', create_at: 1001, message: ''}; const post2 = {id: 'post2', channel_id: 'channel1', create_at: 1002, message: ''}; - const post3 = {id: 'post3', channel_id: 'channel1', root_id: 'post2', create_at: 1003, message: ''}; - const post4 = {id: 'post4', channel_id: 'channel1', root_id: 'post0', create_at: 1004, message: ''}; + const post3 = {id: 'post3', channel_id: 'channel1', root_id: 'post2', create_at: 1003, message: '', user_id: 'user1'}; + const post4 = {id: 'post4', channel_id: 'channel1', root_id: 'post0', create_at: 1004, message: '', user_id: 'user2'}; const postList = { order: ['post4', 'post3', 'post2', 'post1'], @@ -416,9 +416,9 @@ describe('Actions.Posts', () => { const state = store.getState(); expect(state.entities.posts.posts).toEqual({ - post0, + post0: {...post0, participants: [{id: 'user2'}]}, post1, - post2, + post2: {...post2, participants: [{id: 'user1'}]}, post3, post4, }); @@ -705,7 +705,7 @@ describe('Actions.Posts', () => { const post1 = {id: 'post1', channel_id: 'channel1', create_at: 1001, message: ''}; const post2 = {id: 'post2', channel_id: 'channel1', create_at: 1002, message: ''}; const post3 = {id: 'post3', channel_id: 'channel1', create_at: 1003, message: ''}; - const post4 = {id: 'post4', channel_id: 'channel1', root_id: 'post0', create_at: 1004, message: ''}; + const post4 = {id: 'post4', channel_id: 'channel1', root_id: 'post0', create_at: 1004, message: '', user_id: 'user1'}; store = await configureStore({ entities: { @@ -745,7 +745,7 @@ describe('Actions.Posts', () => { const state = store.getState(); expect(state.entities.posts.posts).toEqual({ - post0, + post0: {...post0, participants: [{id: 'user1'}]}, post1, post2, post3, @@ -817,7 +817,7 @@ describe('Actions.Posts', () => { const channelId = 'channel1'; const post1 = {id: 'post1', channel_id: channelId, create_at: 1001, message: ''}; - const post2 = {id: 'post2', channel_id: channelId, root_id: 'post1', create_at: 1002, message: ''}; + const post2 = {id: 'post2', channel_id: channelId, root_id: 'post1', create_at: 1002, message: '', user_id: 'user1'}; const post3 = {id: 'post3', channel_id: channelId, create_at: 1003, message: ''}; store = await configureStore({ @@ -854,7 +854,11 @@ describe('Actions.Posts', () => { const state = store.getState(); - expect(state.entities.posts.posts).toEqual({post1, post2, post3}); + expect(state.entities.posts.posts).toEqual({ + post1: {...post1, participants: [{id: 'user1'}]}, + post2, + post3, + }); expect(state.entities.posts.postsInChannel.channel1).toEqual([ {order: ['post3', 'post2', 'post1'], recent: false}, ]); @@ -867,7 +871,7 @@ describe('Actions.Posts', () => { const channelId = 'channel1'; const post1 = {id: 'post1', channel_id: channelId, create_at: 1001, message: ''}; - const post2 = {id: 'post2', channel_id: channelId, root_id: 'post1', create_at: 1002, message: ''}; + const post2 = {id: 'post2', channel_id: channelId, root_id: 'post1', create_at: 1002, message: '', user_id: 'user1'}; const post3 = {id: 'post3', channel_id: channelId, create_at: 1003, message: ''}; store = await configureStore({ @@ -905,7 +909,11 @@ describe('Actions.Posts', () => { const state = store.getState(); - expect(state.entities.posts.posts).toEqual({post1, post2, post3}); + expect(state.entities.posts.posts).toEqual({ + post1: {...post1, participants: [{id: 'user1'}]}, + post2, + post3, + }); expect(state.entities.posts.postsInChannel.channel1).toEqual([ {order: ['post3', 'post2', 'post1'], recent: true}, ]); @@ -1129,7 +1137,7 @@ describe('Actions.Posts', () => { postList.posts[post1.id] = post1; nock(Client4.getBaseRoute()). - get(`/posts/${post1.id}/thread`). + get(`/posts/${post1.id}/thread?skipFetchThreads=false&collapsedThreads=false&collapsedThreadsExtended=false`). reply(200, postList); await Actions.getPostThread(post1.id)(dispatch, getState); @@ -1168,7 +1176,7 @@ describe('Actions.Posts', () => { postList.posts[post1.id] = post1; nock(Client4.getBaseRoute()). - get(`/posts/${post1.id}/thread`). + get(`/posts/${post1.id}/thread?skipFetchThreads=false&collapsedThreads=false&collapsedThreadsExtended=false`). reply(200, postList); await Actions.getPostThread(post1.id)(dispatch, getState); @@ -1607,7 +1615,7 @@ describe('Actions.Posts', () => { }; nock(Client4.getBaseRoute()). - get(`/posts/${post1.id}/thread`). + get(`/posts/${post1.id}/thread?skipFetchThreads=false&collapsedThreads=false&collapsedThreadsExtended=false`). reply(200, threadList); }); diff --git a/app/mm-redux/actions/posts.ts b/app/mm-redux/actions/posts.ts index 45cf99e81..d26848da1 100644 --- a/app/mm-redux/actions/posts.ts +++ b/app/mm-redux/actions/posts.ts @@ -5,12 +5,15 @@ import {Client4} from '@client/rest'; import {General, Preferences, Posts} from '@mm-redux/constants'; import {WebsocketEvents} from '@constants'; +import {THREAD} from '@constants/screen'; +import {handleFollowChanged, updateThreadRead} from '@mm-redux/actions/threads'; import {PostTypes, ChannelTypes, FileTypes, IntegrationTypes} from '@mm-redux/action_types'; import {getCurrentChannelId, getMyChannelMember as getMyChannelMemberSelector, isManuallyUnread} from '@mm-redux/selectors/entities/channels'; import {getCustomEmojisByName as selectCustomEmojisByName} from '@mm-redux/selectors/entities/emojis'; import {getConfig} from '@mm-redux/selectors/entities/general'; import * as Selectors from '@mm-redux/selectors/entities/posts'; +import {getThreadTeamId} from '@mm-redux/selectors/entities/threads'; import {getCurrentUserId, getUsersByUsername} from '@mm-redux/selectors/entities/users'; import {getUserIdFromChannelName} from '@mm-redux/utils/channel_utils'; @@ -31,6 +34,7 @@ import { savePreferences, } from './preferences'; import {getProfilesByIds, getProfilesByUsernames, getStatusesByIds} from './users'; +import {isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {Action, ActionResult, batchActions, DispatchFunc, GetStateFunc, GenericAction} from '@mm-redux/types/actions'; import {ChannelUnread} from '@mm-redux/types/channels'; import {GlobalState} from '@mm-redux/types/store'; @@ -51,10 +55,11 @@ export function receivedPost(post: Post) { // receivedNewPost should be dispatched when receiving a newly created post or when sending a request to the server // to make a new post. -export function receivedNewPost(post: Post) { +export function receivedNewPost(post: Post, collapsedThreadsEnabled: boolean) { return { type: PostTypes.RECEIVED_NEW_POST, data: post, + features: {collapsedThreadsEnabled}, }; } @@ -206,12 +211,14 @@ export function createPost(post: Post, files: any[] = []) { }); } + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); actions.push({ type: PostTypes.RECEIVED_NEW_POST, data: { ...newPost, id: pendingPostId, }, + features: {collapsedThreadsEnabled}, }); dispatch(batchActions(actions, 'BATCH_CREATE_POST_INIT')); @@ -226,6 +233,7 @@ export function createPost(post: Post, files: any[] = []) { data: { channelId: newPost.channel_id, amount: 1, + amountRoot: created.root_id === '' ? 1 : 0, }, }, { @@ -233,6 +241,7 @@ export function createPost(post: Post, files: any[] = []) { data: { channelId: newPost.channel_id, amount: 1, + amountRoot: created.root_id === '' ? 1 : 0, }, }, ]; @@ -304,11 +313,12 @@ export function createPostImmediately(post: Post, files: any[] = []) { }); } + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); dispatch( receivedNewPost({ ...newPost, id: pendingPostId, - }), + }, collapsedThreadsEnabled), ); try { @@ -321,6 +331,7 @@ export function createPostImmediately(post: Post, files: any[] = []) { data: { channelId: newPost.channel_id, amount: 1, + amountRoot: newPost.root_id === '' ? 1 : 0, }, }, { @@ -328,6 +339,7 @@ export function createPostImmediately(post: Post, files: any[] = []) { data: { channelId: newPost.channel_id, amount: 1, + amountRoot: newPost.root_id === '' ? 1 : 0, }, }, ]; @@ -406,29 +418,42 @@ export function editPost(post: Post) { export function getUnreadPostData(unreadChan: ChannelUnread, state: GlobalState) { const member = getMyChannelMemberSelector(state, unreadChan.channel_id); const delta = member ? member.msg_count - unreadChan.msg_count : unreadChan.msg_count; + const deltaRoot = member ? member.msg_count_root - unreadChan.msg_count_root : unreadChan.msg_count_root; const data = { teamId: unreadChan.team_id, channelId: unreadChan.channel_id, msgCount: unreadChan.msg_count, mentionCount: unreadChan.mention_count, + msgCountRoot: unreadChan.msg_count_root, + mentionCountRoot: unreadChan.mention_count_root, lastViewedAt: unreadChan.last_viewed_at, deltaMsgs: delta, + deltaMsgsRoot: deltaRoot, }; return data; } -export function setUnreadPost(userId: string, postId: string) { +export function setUnreadPost(userId: string, postId: string, location: string) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let state = getState(); const post = Selectors.getPost(state, postId); let unreadChan; - try { if (isCombinedUserActivityPost(postId)) { return {}; } + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); + const isUnreadFromThreadScreen = collapsedThreadsEnabled && location === THREAD; + if (isUnreadFromThreadScreen) { + const currentTeamId = getThreadTeamId(state, postId); + const threadId = post.root_id || post.id; + dispatch(handleFollowChanged(threadId, currentTeamId, true)); + await dispatch(updateThreadRead(userId, threadId, post.create_at)); + return {data: true}; + } + unreadChan = await Client4.markPostAsUnread(userId, postId); dispatch({ type: ChannelTypes.ADD_MANUALLY_UNREAD, @@ -667,13 +692,14 @@ export function flagPost(postId: string) { }; } -export function getPostThread(rootId: string) { +export function getPostThread(rootId: string, fetchThreads = true) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { dispatch({type: PostTypes.GET_POST_THREAD_REQUEST}); let posts; try { - posts = await Client4.getPostThread(rootId); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + posts = await Client4.getPostThread(rootId, fetchThreads, collapsedThreadsEnabled); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); @@ -696,12 +722,12 @@ export function getPostThread(rootId: string) { }; } -export function getPosts(channelId: string, page = 0, perPage = Posts.POST_CHUNK_SIZE) { +export function getPosts(channelId: string, page = 0, perPage = Posts.POST_CHUNK_SIZE, fetchThreads = true, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let posts; - try { - posts = await Client4.getPosts(channelId, page, perPage); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + posts = await Client4.getPosts(channelId, page, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); @@ -718,11 +744,40 @@ export function getPosts(channelId: string, page = 0, perPage = Posts.POST_CHUNK }; } -export function getPostsSince(channelId: string, since: number) { +export function getPostsUnread(channelId: string, fetchThreads = true, collapsedThreadsExtended = false) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const userId = getCurrentUserId(getState()); + let posts; + try { + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + posts = await Client4.getPostsUnread(channelId, userId, undefined, undefined, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); + getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch(batchActions([ + receivedPosts(posts), + receivedPostsInChannel(posts, channelId, posts.next_post_id === '', posts.prev_post_id === ''), + ])); + dispatch({ + type: PostTypes.RECEIVED_POSTS, + data: posts, + channelId, + }); + + return {data: posts}; + }; +} + +export function getPostsSince(channelId: string, since: number, fetchThreads = true, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let posts; try { - posts = await Client4.getPostsSince(channelId, since); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + posts = await Client4.getPostsSince(channelId, since, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); @@ -742,11 +797,12 @@ export function getPostsSince(channelId: string, since: number) { }; } -export function getPostsBefore(channelId: string, postId: string, page = 0, perPage = Posts.POST_CHUNK_SIZE) { +export function getPostsBefore(channelId: string, postId: string, page = 0, perPage = Posts.POST_CHUNK_SIZE, fetchThreads = true, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let posts; try { - posts = await Client4.getPostsBefore(channelId, postId, page, perPage); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + posts = await Client4.getPostsBefore(channelId, postId, page, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); @@ -763,11 +819,12 @@ export function getPostsBefore(channelId: string, postId: string, page = 0, perP }; } -export function getPostsAfter(channelId: string, postId: string, page = 0, perPage = Posts.POST_CHUNK_SIZE) { +export function getPostsAfter(channelId: string, postId: string, page = 0, perPage = Posts.POST_CHUNK_SIZE, fetchThreads = true, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let posts; try { - posts = await Client4.getPostsAfter(channelId, postId, page, perPage); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); + posts = await Client4.getPostsAfter(channelId, postId, page, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended); getProfilesAndStatusesForPosts(posts.posts, dispatch, getState); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); @@ -790,17 +847,18 @@ export type CombinedPostList = { prev_post_id: string; } -export function getPostsAround(channelId: string, postId: string, perPage = Posts.POST_CHUNK_SIZE / 2) { +export function getPostsAround(channelId: string, postId: string, perPage = Posts.POST_CHUNK_SIZE / 2, fetchThreads = true, collapsedThreadsExtended = false) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { let after; let thread; let before; try { + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(getState()); [after, thread, before] = await Promise.all([ - Client4.getPostsAfter(channelId, postId, 0, perPage), - Client4.getPostThread(postId), - Client4.getPostsBefore(channelId, postId, 0, perPage), + Client4.getPostsAfter(channelId, postId, 0, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended), + Client4.getPostThread(postId, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended), + Client4.getPostsBefore(channelId, postId, 0, perPage, fetchThreads, collapsedThreadsEnabled, collapsedThreadsExtended), ]); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); @@ -837,7 +895,7 @@ export function getPostsAround(channelId: string, postId: string, perPage = Post // getThreadsForPosts is intended for an array of posts that have been batched // (see the actions/websocket_actions/handleNewPostEvents function in the webapp) -export function getThreadsForPosts(posts: Array) { +export function getThreadsForPosts(posts: Array, fetchThreads = true) { return (dispatch: DispatchFunc, getState: GetStateFunc) => { if (!Array.isArray(posts) || !posts.length) { return {data: true}; @@ -853,7 +911,7 @@ export function getThreadsForPosts(posts: Array) { const rootPost = Selectors.getPost(state, post.root_id); if (!rootPost) { - promises.push(dispatch(getPostThread(post.root_id))); + promises.push(dispatch(getPostThread(post.root_id, fetchThreads))); } }); @@ -1226,8 +1284,9 @@ function completePostReceive(post: Post, websocketMessageProps: any) { export function lastPostActions(post: Post, websocketMessageProps: any) { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { const state = getState(); + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); const actions = [ - receivedNewPost(post), + receivedNewPost(post, collapsedThreadsEnabled), { type: WebsocketEvents.STOP_TYPING, data: { diff --git a/app/mm-redux/actions/teams.ts b/app/mm-redux/actions/teams.ts index 64526987b..0066b4c7b 100644 --- a/app/mm-redux/actions/teams.ts +++ b/app/mm-redux/actions/teams.ts @@ -152,6 +152,8 @@ export function createTeam(team: Team): ActionFunc { delete_at: 0, msg_count: 0, mention_count: 0, + msg_count_root: 0, + mention_count_root: 0, }; dispatch(batchActions([ diff --git a/app/mm-redux/actions/threads.test.js b/app/mm-redux/actions/threads.test.js new file mode 100644 index 000000000..6bb03d3f2 --- /dev/null +++ b/app/mm-redux/actions/threads.test.js @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import nock from 'nock'; + +import {getThread as fetchThread, getThreads as fetchThreads} from '@mm-redux/actions/threads'; +import {getThread, getThreadsInCurrentTeam} from '@mm-redux/selectors/entities/threads'; +import {Client4} from '@client/rest'; +import TestHelper from 'test/test_helper'; +import configureStore from 'test/test_store'; + +/** + * Returns a mock thread with 2 participants and 5 replies. + */ +function mockUserThread() { + const userId = TestHelper.generateId(); + const otherUserId = TestHelper.generateId(); + const channelId = TestHelper.generateId(); + const postId = TestHelper.generateId(); + const threadId = postId; + + /** + * @type {import('../types/threads').UserThread} + */ + const thread = { + id: threadId, + reply_count: 5, + last_reply_at: 1611786714949, + last_viewed_at: 1611786716048, + participants: [ + {id: userId}, + {id: otherUserId}, + ], + post: { + id: postId, + create_at: 1610486901110, + update_at: 1611786714912, + edit_at: 0, + delete_at: 0, + is_pinned: false, + user_id: userId, + channel_id: channelId, + root_id: '', + parent_id: '', + original_id: '', + message: `accusamus incidunt ab quidem fuga. postId: ${postId}`, + type: '', + props: {}, + hashtags: '', + pending_post_id: '', + reply_count: 0, + last_reply_at: 0, + participants: null, + }, + unread_replies: 0, + unread_mentions: 0, + }; + + return [thread, {userId, otherUserId, channelId, postId, threadId}]; +} + +describe('Actions.Threads', () => { + let store; + beforeAll(async () => { + await TestHelper.initBasic(Client4); + }); + + const currentTeamId = TestHelper.generateId(); + const currentUserId = TestHelper.generateId(); + + beforeEach(async () => { + store = await configureStore({ + entities: { + teams: { + currentTeamId, + }, + users: { + currentUserId, + }, + }, + }); + }); + + afterAll(async () => { + await TestHelper.tearDown(); + }); + + test('getThread', async () => { + const [mockThread, {threadId}] = mockUserThread(); + + nock(Client4.getBaseRoute()). + get((uri) => uri.includes(`/users/${currentUserId}/teams/${currentTeamId}/threads/${threadId}`)). + reply(200, mockThread); + + const {error, data} = await store.dispatch(fetchThread(currentUserId, currentTeamId, threadId, false)); + const state = store.getState(); + const thread = getThread(state, threadId); + expect(error).toBeUndefined(); + expect(data).toBeDefined(); + expect(thread).toEqual({...mockThread, is_following: true}); + }); + + test('getThreads', async () => { + const [mockThread0, {threadId: threadId0}] = mockUserThread(); + const [mockThread1, {threadId: threadId1}] = mockUserThread(); + const [mockThread2, {threadId: threadId2}] = mockUserThread(); + + const mockResponse = { + threads: [mockThread0, mockThread1, mockThread2], + count: 3, + total_unread_mentions: 0, + total_unread_threads: 0, + }; + + nock(Client4.getBaseRoute()). + get((uri) => uri.includes(`/users/${currentUserId}/teams/${currentTeamId}/threads`)). + reply(200, mockResponse); + + const {error, data} = await store.dispatch(fetchThreads(currentUserId, currentTeamId)); + const state = store.getState(); + const threads = getThreadsInCurrentTeam(state); + expect(error).toBeUndefined(); + expect(data).toBeDefined(); + expect(threads).toEqual([threadId0, threadId1, threadId2]); + }); +}); diff --git a/app/mm-redux/actions/threads.ts b/app/mm-redux/actions/threads.ts new file mode 100644 index 000000000..56b453dec --- /dev/null +++ b/app/mm-redux/actions/threads.ts @@ -0,0 +1,277 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {ViewTypes} from '@constants'; + +import {Client4} from '@client/rest'; +import {ThreadTypes, PostTypes, UserTypes} from '@mm-redux/action_types'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/common'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {getThread as getThreadSelector, getThreadTeamId} from '@mm-redux/selectors/entities/threads'; +import {ActionResult, batchActions, DispatchFunc, GenericAction, GetStateFunc} from '@mm-redux/types/actions'; +import type {Post} from '@mm-redux/types/posts'; +import type {UserThread, UserThreadList} from '@mm-redux/types/threads'; + +import {logError} from './errors'; +import {forceLogoutIfNecessary} from './helpers'; +import {getMissingProfilesByIds} from './users'; + +export function getThreads(userId: string, teamId: string, before = '', after = '', perPage = ViewTypes.CRT_CHUNK_SIZE, deleted = false, unread = true, since = 0) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + let userThreadList: UserThreadList; + + try { + userThreadList = await Client4.getUserThreads(userId, teamId, before, after, perPage, true, deleted, unread, since); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + if (userThreadList) { + const data = { + threads: [] as UserThread[], + participants: [] as UserThread['participants'], + participantIds: [] as string[], + posts: [] as Post[], + }; + + userThreadList.threads?.forEach((thread) => { + // threads + data.threads.push({ + ...thread, + is_following: true, + }); + + // participants, participantIds + thread.participants?.forEach((participant) => { + data.participantIds.push(participant.id); + data.participants.push(participant); + }); + + // posts + data.posts.push({ + ...thread.post, + participants: thread.participants, + }); + }); + + const getThreadsActions: Array = [ + { + type: ThreadTypes.RECEIVED_THREADS, + data: { + ...userThreadList, + threads: data.threads, + team_id: teamId, + }, + }, + ]; + + if (userThreadList.threads?.length) { + const flat = require('array.prototype.flat'); + dispatch( + getMissingProfilesByIds(Array.from( + new Set( + flat(data.participantIds) as string[], + ), + )), + ); + getThreadsActions.push( + { + type: UserTypes.RECEIVED_PROFILES_LIST, + data: flat(data.participants), + }, + { + type: PostTypes.RECEIVED_POSTS, + data: {posts: data.posts}, + }, + ); + } + + dispatch(batchActions(getThreadsActions)); + } + + return {data: userThreadList}; + }; +} + +export function getThread(userId: string, teamId: string, threadId: string, extended = false) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + let thread; + try { + thread = await Client4.getUserThread(userId, teamId, threadId, extended); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + if (thread) { + const {data} = dispatch(handleThreadArrived(thread, teamId)) as ActionResult; + thread = data; + } + + return {data: thread}; + }; +} + +export function handleThreadArrived(threadData: UserThread, teamId: string) { + return (dispatch: DispatchFunc, getState: GetStateFunc) => { + const thread = {...threadData, is_following: true}; + + const state = getState(); + const currentUserId = getCurrentUserId(state); + const currentTeamId = getCurrentTeamId(state); + + dispatch(batchActions([ + { + type: UserTypes.RECEIVED_PROFILES_LIST, + data: thread.participants?.filter((user) => user.id !== currentUserId), + }, + { + type: PostTypes.RECEIVED_POSTS, + data: {posts: [{...thread.post, participants: thread.participants}]}, + }, + { + type: ThreadTypes.RECEIVED_THREAD, + data: { + thread, + team_id: teamId, + }, + }, + ])); + + const oldThreadData = getThreadSelector(state, threadData.id); + + dispatch(handleReadChanged( + thread.id, + teamId || currentTeamId, + thread.post.channel_id, + { + lastViewedAt: thread.last_viewed_at, + prevUnreadMentions: oldThreadData?.unread_mentions ?? 0, + newUnreadMentions: thread.unread_mentions, + prevUnreadReplies: oldThreadData?.unread_replies ?? 0, + newUnreadReplies: thread.unread_replies, + }, + )); + + return {data: thread}; + }; +} + +export function handleAllMarkedRead(teamId: string) { + return (dispatch: DispatchFunc) => { + dispatch({ + type: ThreadTypes.ALL_TEAM_THREADS_READ, + data: { + team_id: teamId, + }, + }); + return {data: true}; + }; +} + +export function markAllThreadsInTeamRead(userId: string, teamId: string) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + try { + await Client4.updateThreadsReadForUser(userId, teamId); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch(handleAllMarkedRead(teamId)); + + return {data: true}; + }; +} + +export function updateThreadRead(userId: string, threadId: string, timestamp: number) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const teamId = getThreadTeamId(getState(), threadId); + try { + await Client4.updateThreadReadForUser(userId, teamId, threadId, timestamp); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + return {}; + }; +} + +export function handleFollowChanged(threadId: string, teamId: string, following: boolean) { + return (dispatch: DispatchFunc) => { + dispatch({ + type: ThreadTypes.FOLLOW_CHANGED_THREAD, + data: { + id: threadId, + team_id: teamId, + following, + }, + }); + return {data: true}; + }; +} + +export function setThreadFollow(userId: string, threadId: string, newState: boolean) { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const teamId = getThreadTeamId(getState(), threadId); + try { + await Client4.updateThreadFollowForUser(userId, teamId, threadId, newState); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + + dispatch({ + type: ThreadTypes.FOLLOW_CHANGED_THREAD, + data: { + id: threadId, + team_id: teamId, + following: newState, + }, + }); + + return {data: true}; + }; +} + +export function handleReadChanged( + threadId: string, + teamId: string, + channelId: string, + { + lastViewedAt, + prevUnreadMentions, + newUnreadMentions, + prevUnreadReplies, + newUnreadReplies, + }: { + lastViewedAt: number; + prevUnreadMentions: number; + newUnreadMentions: number; + prevUnreadReplies: number; + newUnreadReplies: number; + }, +) { + return (dispatch: DispatchFunc) => { + dispatch({ + type: ThreadTypes.READ_CHANGED_THREAD, + data: { + id: threadId, + teamId, + channelId, + lastViewedAt, + prevUnreadMentions, + newUnreadMentions, + prevUnreadReplies, + newUnreadReplies, + }, + }); + return {data: true}; + }; +} diff --git a/app/mm-redux/constants/general.ts b/app/mm-redux/constants/general.ts index c16392748..05c91108f 100644 --- a/app/mm-redux/constants/general.ts +++ b/app/mm-redux/constants/general.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. export default { CONFIG_CHANGED: 'config_changed', + CRT_PREFERENCE_CHANGED: 'crt_preference_changed', SERVER_VERSION_CHANGED: 'server_version_changed', PAGE_SIZE_DEFAULT: 60, PAGE_SIZE_MAXIMUM: 200, diff --git a/app/mm-redux/constants/index.ts b/app/mm-redux/constants/index.ts index 0b26e9c42..841adbd85 100644 --- a/app/mm-redux/constants/index.ts +++ b/app/mm-redux/constants/index.ts @@ -13,4 +13,5 @@ import Plugins from './plugins'; import Groups from './groups'; import Users from './users'; import Roles from './roles'; + export {General, Preferences, Posts, Files, RequestStatus, Teams, Stats, Permissions, Emoji, Plugins, Groups, Users, Roles}; diff --git a/app/mm-redux/constants/preferences.ts b/app/mm-redux/constants/preferences.ts index 4ba5a0589..c22f30aa5 100644 --- a/app/mm-redux/constants/preferences.ts +++ b/app/mm-redux/constants/preferences.ts @@ -13,6 +13,10 @@ const Preferences: Dictionary = { CATEGORY_FAVORITE_CHANNEL: 'favorite_channel', CATEGORY_AUTO_RESET_MANUAL_STATUS: 'auto_reset_manual_status', CATEGORY_NOTIFICATIONS: 'notifications', + COLLAPSED_REPLY_THREADS: 'collapsed_reply_threads', + COLLAPSED_REPLY_THREADS_OFF: 'off', + COLLAPSED_REPLY_THREADS_ON: 'on', + COLLAPSED_REPLY_THREADS_FALLBACK_DEFAULT: 'off', COMMENTS: 'comments', COMMENTS_ANY: 'any', COMMENTS_ROOT: 'root', diff --git a/app/mm-redux/reducers/entities/channels.ts b/app/mm-redux/reducers/entities/channels.ts index 841855340..6a891f172 100644 --- a/app/mm-redux/reducers/entities/channels.ts +++ b/app/mm-redux/reducers/entities/channels.ts @@ -139,7 +139,7 @@ function channels(state: IDMappedObjects = {}, action: GenericAction) { return state; } case ChannelTypes.INCREMENT_TOTAL_MSG_COUNT: { - const {channelId, amount} = action.data; + const {channelId, amount, amountRoot} = action.data; const channel = state[channelId]; if (!channel) { @@ -151,6 +151,7 @@ function channels(state: IDMappedObjects = {}, action: GenericAction) { [channelId]: { ...channel, total_msg_count: channel.total_msg_count + amount, + total_msg_count_root: channel.total_msg_count_root + amountRoot, }, }; } @@ -241,7 +242,7 @@ function myMembers(state: RelationOneToOne = {}, act }; } case ChannelTypes.INCREMENT_UNREAD_MSG_COUNT: { - const {channelId, amount, onlyMentions} = action.data; + const {channelId, amount, amountRoot, onlyMentions} = action.data; const member = state[channelId]; if (!member) { @@ -259,11 +260,12 @@ function myMembers(state: RelationOneToOne = {}, act [channelId]: { ...member, msg_count: member.msg_count + amount, + msg_count_root: member.msg_count_root + amountRoot, }, }; } case ChannelTypes.DECREMENT_UNREAD_MSG_COUNT: { - const {channelId, amount} = action.data; + const {channelId, amount, amountRoot} = action.data; const member = state[channelId]; @@ -277,11 +279,12 @@ function myMembers(state: RelationOneToOne = {}, act [channelId]: { ...member, msg_count: member.msg_count + amount, + msg_count_root: member.msg_count_root + amountRoot, }, }; } case ChannelTypes.INCREMENT_UNREAD_MENTION_COUNT: { - const {channelId, amount} = action.data; + const {channelId, amount, amountRoot} = action.data; const member = state[channelId]; if (!member) { @@ -294,11 +297,12 @@ function myMembers(state: RelationOneToOne = {}, act [channelId]: { ...member, mention_count: member.mention_count + amount, + mention_count_root: member.mention_count_root + amountRoot, }, }; } case ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT: { - const {channelId, amount} = action.data; + const {channelId, amount, amountRoot} = action.data; const member = state[channelId]; if (!member) { @@ -311,6 +315,7 @@ function myMembers(state: RelationOneToOne = {}, act [channelId]: { ...member, mention_count: Math.max(member.mention_count - amount, 0), + mention_count_root: Math.max(member.mention_count_root - amountRoot, 0), }, }; } @@ -344,7 +349,7 @@ function myMembers(state: RelationOneToOne = {}, act if (!channelState) { return state; } - return {...state, [data.channelId]: {...channelState, msg_count: data.msgCount, mention_count: data.mentionCount, last_viewed_at: data.lastViewedAt}}; + return {...state, [data.channelId]: {...channelState, msg_count: data.msgCount, mention_count: data.mentionCount, msg_count_root: data.msgCountRoot, mention_count_root: data.mentionCountRoot, last_viewed_at: data.lastViewedAt}}; } case ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS: { // Used by the mobile app diff --git a/app/mm-redux/reducers/entities/index.ts b/app/mm-redux/reducers/entities/index.ts index a59ce82ea..bb65204b9 100644 --- a/app/mm-redux/reducers/entities/index.ts +++ b/app/mm-redux/reducers/entities/index.ts @@ -19,6 +19,7 @@ import roles from './roles'; import groups from './groups'; import bots from './bots'; import channelCategories from './channel_categories'; +import threads from './threads'; import remoteCluster from './remote_cluster'; import apps from './apps'; @@ -39,6 +40,7 @@ export default combineReducers({ groups, bots, channelCategories, + threads, remoteCluster, apps, }); diff --git a/app/mm-redux/reducers/entities/posts.ts b/app/mm-redux/reducers/entities/posts.ts index a82c5ae96..cc049f31f 100644 --- a/app/mm-redux/reducers/entities/posts.ts +++ b/app/mm-redux/reducers/entities/posts.ts @@ -1,12 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {ChannelTypes, GeneralTypes, PostTypes} from '@mm-redux/action_types'; +import {ChannelTypes, GeneralTypes, PostTypes, ThreadTypes} from '@mm-redux/action_types'; import {Posts} from '../../constants'; import {comparePosts} from '@mm-redux/utils/post_utils'; import {Post, PostsState, PostOrderBlock, MessageHistory} from '@mm-redux/types/posts'; import {RelationOneToOne, Dictionary, IDMappedObjects, RelationOneToMany} from '@mm-redux/types/utilities'; import {GenericAction} from '@mm-redux/types/actions'; import {Reaction} from '@mm-redux/types/reactions'; +import {UserProfile} from '@mm-redux/types/users'; export function removeUnneededMetadata(post: Post) { if (!post.metadata) { @@ -165,6 +166,17 @@ export function handlePosts(state: RelationOneToOne = {}, action: Ge return nextState; } + case ThreadTypes.FOLLOW_CHANGED_THREAD: { + const {id, following} = action.data; + const post = state[id]; + return { + ...state, + [id]: { + ...post, + is_following: following, + }, + }; + } default: return state; } @@ -176,6 +188,11 @@ function handlePostReceived(nextState: any, post: Post) { return nextState; } + // Edited posts that don't have 'is_following' specified should maintain 'is_following' state + if (post.update_at > 0 && (post.is_following === null || post.is_following === undefined) && nextState[post.id]) { + post.is_following = nextState[post.id].is_following; + } + if (post.delete_at > 0) { // We've received a deleted post, so mark the post as deleted if we already have it if (nextState[post.id]) { @@ -195,6 +212,21 @@ function handlePostReceived(nextState: any, post: Post) { Reflect.deleteProperty(nextState, post.pending_post_id); } + const rootPost: Post = nextState[post.root_id]; + if (post.root_id && rootPost) { + const participants = rootPost.participants || []; + const nextRootPost = {...rootPost}; + if (!participants.find((user: UserProfile) => user.id === post.user_id)) { + nextRootPost.participants = [...participants, {id: post.user_id}]; + } + + if (post.reply_count) { + nextRootPost.reply_count = post.reply_count; + } + + nextState[post.root_id] = nextRootPost; + } + return nextState; } @@ -269,6 +301,11 @@ export function postsInChannel(state: Dictionary> = {}, ac case PostTypes.RECEIVED_NEW_POST: { const post = action.data as Post; + // When CRT enabled, do not add post to the channel if not a root post + if (action.features?.collapsedThreadsEnabled && post.root_id) { + return state; + } + const postsForChannel = state[post.channel_id]; if (!postsForChannel) { // Don't save newly created posts until the channel has been loaded diff --git a/app/mm-redux/reducers/entities/teams.test.js b/app/mm-redux/reducers/entities/teams.test.js index ac2224069..5b361e8dd 100644 --- a/app/mm-redux/reducers/entities/teams.test.js +++ b/app/mm-redux/reducers/entities/teams.test.js @@ -15,9 +15,9 @@ describe('Reducers.teams.myMembers', () => { }); it('RECEIVED_MY_TEAM_MEMBER', async () => { - const myMember1 = {user_id: 'user_id_1', team_id: 'team_id_1', delete_at: 0, mention_count: 0, msg_count: 0}; - const myMember2 = {user_id: 'user_id_2', team_id: 'team_id_2', delete_at: 0, mention_count: 0, msg_count: 0}; - const myMember3 = {user_id: 'user_id_3', team_id: 'team_id_3', delete_at: 1, mention_count: 0, msg_count: 0}; + const myMember1 = {user_id: 'user_id_1', team_id: 'team_id_1', delete_at: 0, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0}; + const myMember2 = {user_id: 'user_id_2', team_id: 'team_id_2', delete_at: 0, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0}; + const myMember3 = {user_id: 'user_id_3', team_id: 'team_id_3', delete_at: 1, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0}; let state = {myMembers: {team_id_1: myMember1}}; const testAction = { @@ -36,9 +36,9 @@ describe('Reducers.teams.myMembers', () => { it('RECEIVED_MY_TEAM_MEMBERS', async () => { let state = {}; - const myMember1 = {user_id: 'user_id_1', team_id: 'team_id_1', delete_at: 0, mention_count: 0, msg_count: 0}; - const myMember2 = {user_id: 'user_id_2', team_id: 'team_id_2', delete_at: 0, mention_count: 0, msg_count: 0}; - const myMember3 = {user_id: 'user_id_3', team_id: 'team_id_3', delete_at: 1, mention_count: 0, msg_count: 0}; + const myMember1 = {user_id: 'user_id_1', team_id: 'team_id_1', delete_at: 0, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0}; + const myMember2 = {user_id: 'user_id_2', team_id: 'team_id_2', delete_at: 0, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0}; + const myMember3 = {user_id: 'user_id_3', team_id: 'team_id_3', delete_at: 1, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0}; const testAction = { type: TeamTypes.RECEIVED_MY_TEAM_MEMBERS, data: [myMember1, myMember2, myMember3], @@ -85,9 +85,9 @@ describe('Reducers.teams.myMembers', () => { }); it('RECEIVED_TEAMS', async () => { - const myMember1 = {user_id: 'user_id_1', team_id: 'team_id_1', delete_at: 0, mention_count: 0, msg_count: 0}; - const myMember2 = {user_id: 'user_id_2', team_id: 'team_id_2', delete_at: 0, mention_count: 0, msg_count: 0}; - const myMember3 = {user_id: 'user_id_3', team_id: 'team_id_3', delete_at: 0, mention_count: 0, msg_count: 0}; + const myMember1 = {user_id: 'user_id_1', team_id: 'team_id_1', delete_at: 0, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0}; + const myMember2 = {user_id: 'user_id_2', team_id: 'team_id_2', delete_at: 0, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0}; + const myMember3 = {user_id: 'user_id_3', team_id: 'team_id_3', delete_at: 0, mention_count: 0, mention_count_root: 0, msg_count: 0, msg_count_root: 0}; let state = {myMembers: {team_id_1: myMember1, team_id_2: myMember2}}; diff --git a/app/mm-redux/reducers/entities/teams.ts b/app/mm-redux/reducers/entities/teams.ts index 302792666..42155e778 100644 --- a/app/mm-redux/reducers/entities/teams.ts +++ b/app/mm-redux/reducers/entities/teams.ts @@ -75,7 +75,7 @@ function myMembers(state: RelationOneToOne = {}, action: G const members = action.data; for (const m of members) { if (m.delete_at == null || m.delete_at === 0) { - const prevMember = state[m.team_id] || {mention_count: 0, msg_count: 0}; + const prevMember = state[m.team_id] || {mention_count: 0, msg_count: 0, mention_count_root: 0, msg_count_root: 0}; nextState[m.team_id] = { ...prevMember, ...m, @@ -102,10 +102,14 @@ function myMembers(state: RelationOneToOne = {}, action: G for (const u of unreads) { const msgCount = u.msg_count < 0 ? 0 : u.msg_count; const mentionCount = u.mention_count < 0 ? 0 : u.mention_count; + const msgCountRoot = u.msg_count_root < 0 ? 0 : u.msg_count_root; + const mentionCountRoot = u.mention_count_root < 0 ? 0 : u.mention_count_root; const m = { ...state[u.team_id], mention_count: mentionCount, msg_count: msgCount, + mention_count_root: mentionCountRoot, + msg_count_root: msgCountRoot, }; nextState[u.team_id] = m; } @@ -113,7 +117,7 @@ function myMembers(state: RelationOneToOne = {}, action: G return nextState; } case ChannelTypes.INCREMENT_UNREAD_MSG_COUNT: { - const {teamId, amount, onlyMentions} = action.data; + const {teamId, amount, amountRoot, onlyMentions} = action.data; const member = state[teamId]; if (!member) { @@ -131,11 +135,12 @@ function myMembers(state: RelationOneToOne = {}, action: G [teamId]: { ...member, msg_count: member.msg_count + amount, + msg_count_root: member.msg_count_root + amountRoot, }, }; } case ChannelTypes.DECREMENT_UNREAD_MSG_COUNT: { - const {teamId, amount} = action.data; + const {teamId, amount, amountRoot} = action.data; const member = state[teamId]; if (!member) { @@ -148,11 +153,12 @@ function myMembers(state: RelationOneToOne = {}, action: G [teamId]: { ...member, msg_count: Math.max(member.msg_count - Math.abs(amount), 0), + msg_count_root: Math.max(member.msg_count_root - Math.abs(amountRoot), 0), }, }; } case ChannelTypes.INCREMENT_UNREAD_MENTION_COUNT: { - const {teamId, amount} = action.data; + const {teamId, amount, amountRoot} = action.data; const member = state[teamId]; if (!member) { @@ -165,11 +171,12 @@ function myMembers(state: RelationOneToOne = {}, action: G [teamId]: { ...member, mention_count: member.mention_count + amount, + mention_count_root: member.mention_count_root + amountRoot, }, }; } case ChannelTypes.DECREMENT_UNREAD_MENTION_COUNT: { - const {teamId, amount} = action.data; + const {teamId, amount, amountRoot} = action.data; const member = state[teamId]; if (!member) { @@ -182,6 +189,7 @@ function myMembers(state: RelationOneToOne = {}, action: G [teamId]: { ...member, mention_count: Math.max(member.mention_count - amount, 0), + mention_count_root: Math.max(member.mention_count_root - amountRoot, 0), }, }; } @@ -220,6 +228,8 @@ function myMembers(state: RelationOneToOne = {}, action: G if (unread) { m.mention_count = unread.mention_count; m.msg_count = unread.msg_count; + m.mention_count_root = unread.mention_count_root; + m.msg_count_root = unread.msg_count_root; } nextState[m.team_id] = m; } diff --git a/app/mm-redux/reducers/entities/threads.test.js b/app/mm-redux/reducers/entities/threads.test.js new file mode 100644 index 000000000..4c0fef52e --- /dev/null +++ b/app/mm-redux/reducers/entities/threads.test.js @@ -0,0 +1,234 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import deepFreeze from '@mm-redux/utils/deep_freeze'; +import {PostTypes, TeamTypes, ThreadTypes} from '@mm-redux/action_types'; +import threadsReducer from '@mm-redux/reducers/entities/threads'; + +describe('threads', () => { + test('RECEIVED_THREADS should update the state', () => { + const state = deepFreeze({ + threadsInTeam: {}, + threads: {}, + counts: {}, + }); + + const nextState = threadsReducer(state, { + type: ThreadTypes.RECEIVED_THREADS, + data: { + team_id: 'a', + threads: [ + {id: 't1'}, + ], + total: 3, + total_unread_threads: 0, + total_unread_mentions: 1, + }, + }); + + expect(nextState).not.toEqual(state); + expect(nextState.threads.t1).toEqual({ + id: 't1', + }); + expect(nextState.counts.a).toEqual({ + total: 3, + total_unread_threads: 0, + total_unread_mentions: 1, + }); + expect(nextState.threadsInTeam.a).toContain('t1'); + }); + + test('ALL_TEAM_THREADS_READ should clear the counts', () => { + const state = deepFreeze({ + threadsInTeam: {}, + threads: {}, + counts: { + a: { + total: 3, + total_unread_threads: 0, + total_unread_mentions: 2, + }, + }, + }); + const nextState2 = threadsReducer(state, { + type: ThreadTypes.ALL_TEAM_THREADS_READ, + data: { + team_id: 'a', + }, + }); + + expect(nextState2).not.toBe(state); + expect(nextState2.counts.a).toEqual({ + total: 3, + total_unread_threads: 0, + total_unread_mentions: 0, + }); + }); + + test('READ_CHANGED_THREAD should update the count for thread per channel', () => { + const state = deepFreeze({ + threadsInTeam: {}, + threads: {}, + counts: { + a: { + total: 3, + total_unread_threads: 1, + total_unread_mentions: 3, + }, + }, + }); + const nextState2 = threadsReducer(state, { + type: ThreadTypes.READ_CHANGED_THREAD, + data: { + teamId: 'a', + prevUnreadMentions: 3, + newUnreadMentions: 0, + channelId: 'a', + }, + }); + + expect(nextState2).not.toBe(state); + expect(nextState2.counts.a).toEqual({ + total: 3, + total_unread_threads: 1, + total_unread_mentions: 0, + }); + + const nextState3 = threadsReducer(nextState2, { + type: ThreadTypes.READ_CHANGED_THREAD, + data: { + teamId: 'a', + prevUnreadMentions: 0, + newUnreadMentions: 3, + channelId: 'a', + }, + }); + + expect(nextState3).not.toBe(nextState2); + expect(nextState3.counts.a).toEqual({ + total: 3, + total_unread_threads: 1, + total_unread_mentions: 3, + }); + }); + + test('LEAVE_TEAM should clean the state', () => { + const state = deepFreeze({ + threadsInTeam: {}, + threads: {}, + counts: {}, + }); + + let nextState = threadsReducer(state, { + type: ThreadTypes.RECEIVED_THREADS, + data: { + team_id: 'a', + threads: [ + {id: 't1'}, + ], + total: 3, + unread_mentions_per_channel: {}, + total_unread_threads: 0, + total_unread_mentions: 1, + }, + }); + + expect(nextState).not.toEqual(state); + + // leave team + nextState = threadsReducer(state, { + type: TeamTypes.LEAVE_TEAM, + data: { + id: 'a', + }, + }); + + expect(nextState.threads.t1).toBe(undefined); + expect(nextState.counts.a).toBe(undefined); + expect(nextState.threadsInTeam.a).toBe(undefined); + }); + + test('POST_REMOVED should remove the thread when root post', () => { + const state = deepFreeze({ + threadsInTeam: { + a: ['t1', 't2', 't3'], + }, + threads: { + t1: { + id: 't1', + }, + t2: { + id: 't2', + }, + t3: { + id: 't3', + }, + }, + counts: {}, + }); + + const nextState = threadsReducer(state, { + type: PostTypes.POST_REMOVED, + data: {id: 't2', root_id: ''}, + }); + + expect(nextState).not.toBe(state); + expect(nextState.threads.t2).toBe(undefined); + expect(nextState.threadsInTeam.a).toEqual(['t1', 't3']); + }); + + test('POST_REMOVED should do nothing when not a root post', () => { + const state = deepFreeze({ + threadsInTeam: { + a: ['t1', 't2', 't3'], + }, + threads: { + t1: { + id: 't1', + }, + t2: { + id: 't2', + }, + t3: { + id: 't3', + }, + }, + counts: {}, + }); + + const nextState = threadsReducer(state, { + type: PostTypes.POST_REMOVED, + data: {id: 't2', root_id: 't1'}, + }); + + expect(nextState).toBe(state); + expect(nextState.threads.t2).toBe(state.threads.t2); + expect(nextState.threadsInTeam.a).toEqual(['t1', 't2', 't3']); + }); + + test('POST_REMOVED should do nothing when post not exist', () => { + const state = deepFreeze({ + threadsInTeam: { + a: ['t1', 't2'], + }, + threads: { + t1: { + id: 't1', + }, + t2: { + id: 't2', + }, + }, + counts: {}, + }); + + const nextState = threadsReducer(state, { + type: PostTypes.POST_REMOVED, + data: {id: 't3', root_id: ''}, + }); + + expect(nextState).toBe(state); + expect(nextState.threads.t2).toBe(state.threads.t2); + expect(nextState.threadsInTeam.a).toEqual(['t1', 't2']); + }); +}); diff --git a/app/mm-redux/reducers/entities/threads.ts b/app/mm-redux/reducers/entities/threads.ts new file mode 100644 index 000000000..4b01864dc --- /dev/null +++ b/app/mm-redux/reducers/entities/threads.ts @@ -0,0 +1,252 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {combineReducers} from 'redux'; + +import {PostTypes, TeamTypes, ThreadTypes} from '@mm-redux/action_types'; +import {GenericAction} from '@mm-redux/types/actions'; +import {Post} from '@mm-redux/types/posts'; +import {Team} from '@mm-redux/types/teams'; +import {ThreadsState, UserThread} from '@mm-redux/types/threads'; +import {UserProfile} from '@mm-redux/types/users'; +import {IDMappedObjects} from '@mm-redux/types/utilities'; + +export const threadsReducer = (state: ThreadsState['threads'] = {}, action: GenericAction) => { + switch (action.type) { + case ThreadTypes.RECEIVED_THREADS: { + const {threads} = action.data; + const newThreads = threads.reduce((results: IDMappedObjects, thread: UserThread) => { + results[thread.id] = thread; + return results; + }, {}); + return { + ...state, + ...newThreads, + }; + } + case ThreadTypes.RECEIVED_THREAD: { + const {thread} = action.data; + return { + ...state, + [thread.id]: thread, + }; + } + case ThreadTypes.READ_CHANGED_THREAD: { + const { + id, + newUnreadMentions, + newUnreadReplies, + lastViewedAt, + } = action.data; + + return { + ...state, + [id]: { + ...(state[id] || {}), + last_viewed_at: lastViewedAt, + unread_mentions: newUnreadMentions, + unread_replies: newUnreadReplies, + is_following: true, + }, + }; + } + case ThreadTypes.FOLLOW_CHANGED_THREAD: { + const {id, following} = action.data; + return { + ...state, + [id]: {...(state[id] || {}), is_following: following}, + }; + } + case ThreadTypes.ALL_TEAM_THREADS_READ: { + return Object.entries(state).reduce((newState, [id, thread]) => { + newState[id] = { + ...thread, + unread_mentions: 0, + unread_replies: 0, + }; + return newState; + }, {}); + } + case PostTypes.POST_REMOVED: { + const post = action.data; + + if (post.root_id || !state[post.id]) { + return state; + } + + const nextState = {...state}; + Reflect.deleteProperty(nextState, post.id); + + return nextState; + } + case PostTypes.RECEIVED_NEW_POST: { + const post: Post = action.data; + const thread: UserThread | undefined = state[post.root_id]; + if (thread) { + const participants = thread.participants || []; + const nextThread = {...thread}; + if (!participants.find((user: UserProfile | {id: string}) => user.id === post.user_id)) { + nextThread.participants = [...participants, {id: post.user_id}]; + } + + if (post.reply_count) { + nextThread.reply_count = post.reply_count; + } + + return { + ...state, + [post.root_id]: nextThread, + }; + } + return state; + } + } + return state; +}; + +export const threadsInTeamReducer = (state: ThreadsState['threadsInTeam'] = {}, action: GenericAction) => { + switch (action.type) { + case ThreadTypes.RECEIVED_THREADS: { + const nextSet = new Set(state[action.data.team_id]); + + action.data.threads.forEach((thread: UserThread) => { + nextSet.add(thread.id); + }); + + return { + ...state, + [action.data.team_id]: Array.from(nextSet), + }; + } + case ThreadTypes.RECEIVED_THREAD: { + if (state[action.data.team_id]?.includes(action.data.thread.id)) { + return state; + } + + const nextSet = new Set(state[action.data.team_id]); + + nextSet.add(action.data.thread.id); + + return { + ...state, + [action.data.team_id]: Array.from(nextSet), + }; + } + case TeamTypes.LEAVE_TEAM: { + const team: Team = action.data; + + if (!state[team.id]) { + return state; + } + + const nextState = {...state}; + Reflect.deleteProperty(nextState, team.id); + + return nextState; + } + + case PostTypes.POST_REMOVED: { + const post = action.data; + if (post.root_id) { + return state; + } + + let postIndex = 0; + const teamId = Object.keys(state).find((id) => { + const currentPostIndex = state[id].indexOf(post.id); + if (currentPostIndex > -1) { + postIndex = currentPostIndex; + return true; + } + return false; + }); + + if (!teamId) { + return state; + } + + return { + ...state, + [teamId]: [ + ...state[teamId].slice(0, postIndex), + ...state[teamId].slice(postIndex + 1), + ], + }; + } + } + return state; +}; + +export const countsReducer = (state: ThreadsState['counts'] = {}, action: GenericAction) => { + switch (action.type) { + case ThreadTypes.ALL_TEAM_THREADS_READ: { + const counts = state[action.data.team_id] ?? {}; + return { + ...state, + [action.data.team_id]: { + ...counts, + total_unread_mentions: 0, + total_unread_threads: 0, + }, + }; + } + case ThreadTypes.READ_CHANGED_THREAD: { + const { + teamId, + prevUnreadMentions = 0, + newUnreadMentions = 0, + prevUnreadReplies = 0, + newUnreadReplies = 0, + } = action.data; + const counts = state[teamId] ? { + ...state[teamId], + } : { + total_unread_threads: 0, + total: 0, + total_unread_mentions: 0, + }; + const unreadMentionDiff = newUnreadMentions - prevUnreadMentions; + + counts.total_unread_mentions += unreadMentionDiff; + + if (newUnreadReplies > 0 && prevUnreadReplies === 0) { + counts.total_unread_threads += 1; + } else if (prevUnreadReplies > 0 && newUnreadReplies === 0) { + counts.total_unread_threads -= 1; + } + + return { + ...state, + [action.data.teamId]: counts, + }; + } + case ThreadTypes.RECEIVED_THREADS: { + return { + ...state, + [action.data.team_id]: { + total: action.data.total, + total_unread_threads: action.data.total_unread_threads, + total_unread_mentions: action.data.total_unread_mentions, + }, + }; + } + case TeamTypes.LEAVE_TEAM: { + const team: Team = action.data; + + if (!state[team.id]) { + return state; + } + + const nextState = {...state}; + Reflect.deleteProperty(nextState, team.id); + + return nextState; + } + } + return state; +}; + +export default combineReducers({ + threads: threadsReducer, + threadsInTeam: threadsInTeamReducer, + counts: countsReducer, +}); diff --git a/app/mm-redux/selectors/entities/channel_categories.test.js b/app/mm-redux/selectors/entities/channel_categories.test.js index 3e7845700..a7d1f75cb 100644 --- a/app/mm-redux/selectors/entities/channel_categories.test.js +++ b/app/mm-redux/selectors/entities/channel_categories.test.js @@ -312,6 +312,7 @@ describe('makeFilterAutoclosedDMs', () => { getCurrentUserId(state), state.entities.users.profiles, getLastPostPerChannel(state), + false, getCurrentChannelId(state), now, ); diff --git a/app/mm-redux/selectors/entities/channel_categories.ts b/app/mm-redux/selectors/entities/channel_categories.ts index 87b416d3a..3f0851040 100644 --- a/app/mm-redux/selectors/entities/channel_categories.ts +++ b/app/mm-redux/selectors/entities/channel_categories.ts @@ -10,7 +10,7 @@ import {CategoryTypes} from '../../constants/channel_categories'; import {getCurrentChannelId, getMyChannelMemberships} from '@mm-redux/selectors/entities/channels'; import {getCurrentUserLocale} from '@mm-redux/selectors/entities/i18n'; import {getLastPostPerChannel} from '@mm-redux/selectors/entities/posts'; -import {getMyPreferences, getTeammateNameDisplaySetting, shouldAutocloseDMs} from '@mm-redux/selectors/entities/preferences'; +import {getMyPreferences, getTeammateNameDisplaySetting, isCollapsedThreadsEnabled, shouldAutocloseDMs} from '@mm-redux/selectors/entities/preferences'; import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; import {Channel, ChannelMembership} from '@mm-redux/types/channels'; @@ -112,7 +112,8 @@ export function makeFilterAutoclosedDMs(getAutocloseCutoff = getDefaultAutoclose getCurrentUserId, getMyChannelMemberships, getLastPostPerChannel, - (channels, categoryType, myPreferences, autocloseDMs, currentChannelId, profiles, currentUserId, myChannelMembers, lastPosts) => { + isCollapsedThreadsEnabled, + (channels, categoryType, myPreferences, autocloseDMs, currentChannelId, profiles, currentUserId, myChannelMembers, lastPosts, collapsedThreadsEnabled) => { if (categoryType !== CategoryTypes.DIRECT_MESSAGES) { // Only autoclose DMs that haven't been assigned to a category return channels; @@ -127,7 +128,7 @@ export function makeFilterAutoclosedDMs(getAutocloseCutoff = getDefaultAutoclose } // Unread channels will never be hidden - if (isUnreadChannel(myChannelMembers, channel)) { + if (isUnreadChannel(myChannelMembers, channel, collapsedThreadsEnabled)) { return true; } diff --git a/app/mm-redux/selectors/entities/channels.test.js b/app/mm-redux/selectors/entities/channels.test.js index f1f8e4dcf..a0e5c8e34 100644 --- a/app/mm-redux/selectors/entities/channels.test.js +++ b/app/mm-redux/selectors/entities/channels.test.js @@ -2981,6 +2981,12 @@ describe('Selectors.Channels.getUnreadChannelIds', () => { channelsInTeam, myMembers: myChannelMembers, }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, }, }); it('get unread channel ids in current team strict equal', () => { @@ -3350,6 +3356,9 @@ describe('Selectors.Channels.getUnreads', () => { teams, myMembers: myTeamMembers, }, + threads: { + count: {}, + }, channels: { channels, myMembers: myChannelMembers, @@ -3357,6 +3366,12 @@ describe('Selectors.Channels.getUnreads', () => { users: { profiles: {}, }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, }, }); @@ -3467,6 +3482,15 @@ describe('Selectors.Channels.getUnreads', () => { currentUserId: 'user1', profiles: {}, }, + threads: { + count: {}, + }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, }, }; @@ -3503,6 +3527,15 @@ describe('Selectors.Channels.getUnreads', () => { currentUserId: 'user1', profiles: {}, }, + threads: { + count: {}, + }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, }, }; @@ -3538,6 +3571,15 @@ describe('Selectors.Channels.getUnreads', () => { user2: {delete_at: 0}, }, }, + threads: { + count: {}, + }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, }, }; @@ -3573,6 +3615,15 @@ describe('Selectors.Channels.getUnreads', () => { user2: {delete_at: 1}, }, }, + threads: { + count: {}, + }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, }, }; @@ -3606,6 +3657,15 @@ describe('Selectors.Channels.getUnreads', () => { currentUserId: 'user1', profiles: {}, }, + threads: { + count: {}, + }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, }, }; @@ -3649,6 +3709,15 @@ describe('Selectors.Channels.getUnreads', () => { currentUserId: 'user1', profiles: {}, }, + threads: { + count: {}, + }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, }, }; diff --git a/app/mm-redux/selectors/entities/channels.ts b/app/mm-redux/selectors/entities/channels.ts index 6827bf8d8..e283115d7 100644 --- a/app/mm-redux/selectors/entities/channels.ts +++ b/app/mm-redux/selectors/entities/channels.ts @@ -4,16 +4,18 @@ import {createSelector} from 'reselect'; import {General, Permissions} from '../../constants'; import {getCurrentChannelId, getCurrentUser, getUsers, getMyChannelMemberships, getMyCurrentChannelMembership} from '@mm-redux/selectors/entities/common'; import {getConfig, getLicense, hasNewPermissions} from '@mm-redux/selectors/entities/general'; -import {getFavoritesPreferences, getMyPreferences, getTeammateNameDisplaySetting, getVisibleTeammate, getVisibleGroupIds} from '@mm-redux/selectors/entities/preferences'; +import {getFavoritesPreferences, getMyPreferences, getTeammateNameDisplaySetting, getVisibleTeammate, getVisibleGroupIds, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {getLastPostPerChannel, getAllPosts} from '@mm-redux/selectors/entities/posts'; import {getCurrentTeamId, getCurrentTeamMembership, getMyTeams, getTeamMemberships} from '@mm-redux/selectors/entities/teams'; import {haveICurrentChannelPermission, haveIChannelPermission, haveITeamPermission} from '@mm-redux/selectors/entities/roles'; import {isCurrentUserSystemAdmin, getCurrentUserId} from '@mm-redux/selectors/entities/users'; -import {buildDisplayableChannelListWithUnreadSection, canManageMembersOldPermissions, completeDirectChannelInfo, completeDirectChannelDisplayName, getUserIdFromChannelName, getChannelByName as getChannelByNameHelper, isChannelMuted, getDirectChannelName, isAutoClosed, isDirectChannelVisible, isGroupChannelVisible, isGroupOrDirectChannelVisible, sortChannelsByDisplayName, isFavoriteChannel, isDefault, sortChannelsByRecency} from '@mm-redux/utils/channel_utils'; +import {buildDisplayableChannelListWithUnreadSection, canManageMembersOldPermissions, completeDirectChannelInfo, completeDirectChannelDisplayName, getUserIdFromChannelName, getChannelByName as getChannelByNameHelper, isChannelMuted, getDirectChannelName, isAutoClosed, isDirectChannelVisible, isGroupChannelVisible, isGroupOrDirectChannelVisible, sortChannelsByDisplayName, isFavoriteChannel, isDefault, sortChannelsByRecency, getMsgCountInChannel} from '@mm-redux/utils/channel_utils'; import {createIdsSelector} from '@mm-redux/utils/helpers'; export {getCurrentChannelId, getMyChannelMemberships, getMyCurrentChannelMembership}; +import {Config} from '@mm-redux/types/config'; import {GlobalState} from '@mm-redux/types/store'; +import {ThreadsState} from '@mm-redux/types/threads'; import {Channel, ChannelStats, ChannelMembership, ChannelMemberCountsByGroup} from '@mm-redux/types/channels'; import {UsersState, UserProfile} from '@mm-redux/types/users'; import {PreferenceType} from '@mm-redux/types/preferences'; @@ -21,8 +23,9 @@ import {Post} from '@mm-redux/types/posts'; import {TeamMembership, Team} from '@mm-redux/types/teams'; import {NameMappedObjects, UserIDMappedObjects, IDMappedObjects, RelationOneToOne, RelationOneToMany} from '@mm-redux/types/utilities'; +import {getThreadCounts} from './threads'; import {getUserIdsInChannels} from './users'; -import {Config} from '@mm-redux/types/config'; + type SortingType = 'recent' | 'alpha'; export function getAllChannels(state: GlobalState): IDMappedObjects { @@ -177,12 +180,21 @@ export function isChannelReadOnly(state: GlobalState, channel: Channel): boolean export function shouldHideDefaultChannel(state: GlobalState, channel: Channel): boolean { return channel && channel.name === General.DEFAULT_CHANNEL && !isCurrentUserSystemAdmin(state) && getConfig(state).ExperimentalHideTownSquareinLHS === 'true'; } -export const countCurrentChannelUnreadMessages: (a: GlobalState) => number = createSelector(getCurrentChannel, getMyCurrentChannelMembership, (channel: Channel, membership?: ChannelMembership | null): number => { - if (!membership) { - return 0; - } - return channel.total_msg_count - membership.msg_count; -}); +export const countCurrentChannelUnreadMessages: (a: GlobalState) => number = createSelector( + getCurrentChannel, + getMyCurrentChannelMembership, + isCollapsedThreadsEnabled, + ( + channel: Channel, + membership?: ChannelMembership | null, + collapsedThreadsEnabled?: boolean, + ): number => { + if (!membership) { + return 0; + } + return collapsedThreadsEnabled ? channel.total_msg_count_root - membership.msg_count_root : channel.total_msg_count - membership.msg_count; + }, +); export function getChannelByName(state: GlobalState, channelName: string): Channel | undefined | null { return getChannelByNameHelper(getAllChannels(state), channelName); @@ -293,6 +305,7 @@ export const getChannelsWithUnreadSection: (a: GlobalState) => { getTeammateNameDisplaySetting, (state: GlobalState): UsersState => state.entities.users, getLastPostPerChannel, + isCollapsedThreadsEnabled, ( currentChannelId: string, channels: Array, @@ -304,6 +317,7 @@ export const getChannelsWithUnreadSection: (a: GlobalState) => { teammateNameDisplay: string, usersState: UsersState, lastPosts: RelationOneToOne, + collapsedThreadsEnabled: boolean, ) => { const allChannels = channels.map((c) => { const channel = {...c, @@ -311,7 +325,7 @@ export const getChannelsWithUnreadSection: (a: GlobalState) => { channel.isCurrent = c.id === currentChannelId; return channel; }); - return buildDisplayableChannelListWithUnreadSection(usersState, allChannels, myMembers, config, myPreferences, teammateNameDisplay, lastPosts); + return buildDisplayableChannelListWithUnreadSection(usersState, allChannels, myMembers, config, myPreferences, teammateNameDisplay, lastPosts, collapsedThreadsEnabled); }, ); @@ -334,7 +348,9 @@ export const getUnreads: (a: GlobalState) => { getCurrentTeamId, getMyTeams, getTeamMemberships, - (channels: IDMappedObjects, myMembers: RelationOneToOne, users: IDMappedObjects, currentUserId: string, currentTeamId: string, myTeams: Array, myTeamMemberships: RelationOneToOne): { + isCollapsedThreadsEnabled, + getThreadCounts, + (channels: IDMappedObjects, myMembers: RelationOneToOne, users: IDMappedObjects, currentUserId: string, currentTeamId: string, myTeams: Team[], myTeamMemberships: RelationOneToOne, collapsedThreadsEnabled: boolean, threadCounts: ThreadsState['counts']): { messageCount: number; mentionCount: number; } => { @@ -359,20 +375,22 @@ export const getUnreads: (a: GlobalState) => { otherUserId = getUserIdFromChannelName(currentUserId, channel.name); if (users[otherUserId] && users[otherUserId].delete_at === 0) { - mentionCountForCurrentTeam += m.mention_count; + mentionCountForCurrentTeam += (collapsedThreadsEnabled ? m.mention_count_root : m.mention_count); } } else if (m.mention_count > 0 && channel.delete_at === 0) { - mentionCountForCurrentTeam += m.mention_count; + mentionCountForCurrentTeam += (collapsedThreadsEnabled ? m.mention_count_root : m.mention_count); } - if (m.notify_props && m.notify_props.mark_unread !== 'mention' && channel.total_msg_count - m.msg_count > 0) { - if (channel.type === General.DM_CHANNEL) { - // otherUserId is guaranteed to have been set above - if (users[otherUserId] && users[otherUserId].delete_at === 0) { + if (m.notify_props && m.notify_props.mark_unread !== 'mention') { + if (getMsgCountInChannel(collapsedThreadsEnabled, channel, m) > 0) { + if (channel.type === General.DM_CHANNEL) { + // otherUserId is guaranteed to have been set above + if (users[otherUserId] && users[otherUserId].delete_at === 0) { + messageCountForCurrentTeam += 1; + } + } else if (channel.delete_at === 0) { messageCountForCurrentTeam += 1; } - } else if (channel.delete_at === 0) { - messageCountForCurrentTeam += 1; } } }); @@ -382,8 +400,8 @@ export const getUnreads: (a: GlobalState) => { const otherTeamsUnreadCountForChannels = myTeams.reduce((acc, team) => { if (currentTeamId !== team.id) { const member = myTeamMemberships[team.id]; - acc.messageCount += member.msg_count; - acc.mentionCount += member.mention_count; + acc.messageCount += (collapsedThreadsEnabled ? member.msg_count_root : member.msg_count); + acc.mentionCount += (collapsedThreadsEnabled ? member.mention_count_root : member.mention_count); } return acc; @@ -393,10 +411,20 @@ export const getUnreads: (a: GlobalState) => { }); // messageCount is the number of unread channels, mention count is the total number of mentions - return { + const result = { messageCount: messageCountForCurrentTeam + otherTeamsUnreadCountForChannels.messageCount, mentionCount: mentionCountForCurrentTeam + otherTeamsUnreadCountForChannels.mentionCount, }; + + // when collapsed threads are enabled, we start with root-post counts from channels, then add the same thread-reply counts from the global threads view + if (collapsedThreadsEnabled) { + Object.values(threadCounts).forEach((c) => { + result.mentionCount += c.total_unread_mentions; + result.messageCount += c.total_unread_threads; + }); + } + + return result; }, ); @@ -509,29 +537,42 @@ export const getChannelIdsInCurrentTeam: (a: GlobalState) => Array = cre export const getChannelIdsForCurrentTeam: (a: GlobalState) => Array = createIdsSelector(getChannelIdsInCurrentTeam, getAllDirectChannelIds, (channels, direct) => { return [...channels, ...direct]; }); -export const getUnreadChannelIds: (b: GlobalState, a?: Channel | null) => Array = createIdsSelector(getAllChannels, getMyChannelMemberships, getChannelIdsForCurrentTeam, (state: GlobalState, lastUnreadChannel: Channel | undefined | null = null): Channel | undefined | null => lastUnreadChannel, (channels: IDMappedObjects, members: RelationOneToOne, teamChannelIds: Array, lastUnreadChannel?: Channel | null): Array => { - const unreadIds = teamChannelIds.filter((id) => { - const c = channels[id]; - const m = members[id]; +export const getUnreadChannelIds: (b: GlobalState, a?: Channel | null) => Array = createIdsSelector( + isCollapsedThreadsEnabled, + getAllChannels, + getMyChannelMemberships, + getChannelIdsForCurrentTeam, + (state: GlobalState, lastUnreadChannel: Channel | undefined | null = null): Channel | undefined | null => lastUnreadChannel, + ( + collapsedThreadsEnabled: boolean, + channels: IDMappedObjects, + members: RelationOneToOne, + teamChannelIds: Array, + lastUnreadChannel?: Channel | null, + ): Array => { + const unreadIds = teamChannelIds.filter((id) => { + const c = channels[id]; + const m = members[id]; - if (c && m) { - const chHasUnread = c.total_msg_count - m.msg_count > 0; - const chHasMention = m.mention_count > 0; + if (c && m) { + const chHasUnread = getMsgCountInChannel(collapsedThreadsEnabled, c, m) > 0; + const chHasMention = (collapsedThreadsEnabled ? m.mention_count_root : m.mention_count) > 0; - if ((m.notify_props && m.notify_props.mark_unread !== 'mention' && chHasUnread) || chHasMention) { - return true; + if ((m.notify_props && m.notify_props.mark_unread !== 'mention' && chHasUnread) || chHasMention) { + return true; + } } + + return false; + }); + + if (lastUnreadChannel && !unreadIds.includes(lastUnreadChannel.id)) { + unreadIds.push(lastUnreadChannel.id); } - return false; - }); - - if (lastUnreadChannel && !unreadIds.includes(lastUnreadChannel.id)) { - unreadIds.push(lastUnreadChannel.id); - } - - return unreadIds; -}); + return unreadIds; + }, +); export const getUnreadChannels: (b: GlobalState, a?: Channel | null) => Array = createIdsSelector(getCurrentUser, getUsers, getUserIdsInChannels, getAllChannels, getUnreadChannelIds, getTeammateNameDisplaySetting, (currentUser, profiles, userIdsInChannels: any, channels, unreadIds, settings) => { // If we receive an unread for a channel and then a mention the channel // won't be sorted correctly until we receive a message in another channel @@ -729,7 +770,7 @@ export const getDirectChannelIds: (e: GlobalState, d: Channel, c: boolean, b: bo export const getSortedDirectChannelIds: (e: GlobalState, d: Channel | null, c: boolean, b: boolean, a: SortingType) => Array = createIdsSelector(getUnreadChannelIds, getFavoritesPreferences, (state: GlobalState, lastUnreadChannel: Channel, unreadsAtTop: boolean, favoritesAtTop: boolean, sorting: SortingType = 'alpha') => getDirectChannelIds(state, lastUnreadChannel, unreadsAtTop, favoritesAtTop, sorting), (state, lastUnreadChannel, unreadsAtTop = true) => unreadsAtTop, (state, lastUnreadChannel, unreadsAtTop, favoritesAtTop = true) => favoritesAtTop, filterChannels); export function getGroupOrDirectChannelVisibility(state: GlobalState, channelId: string): boolean { - return isGroupOrDirectChannelVisible(getChannel(state, channelId), getMyChannelMemberships(state), getConfig(state), getMyPreferences(state), getCurrentUser(state).id, getUsers(state), getLastPostPerChannel(state)); + return isGroupOrDirectChannelVisible(getChannel(state, channelId), getMyChannelMemberships(state), getConfig(state), getMyPreferences(state), getCurrentUser(state).id, getUsers(state), getLastPostPerChannel(state), isCollapsedThreadsEnabled(state)); } // Filters post IDs by the given condition. @@ -799,7 +840,7 @@ const hasChannelsChanged = (channels: {type: string;name: string;items: string[] return false; }; -export const getOrderedChannelIds = (state: GlobalState, lastUnreadChannel: Channel|null, grouping: 'by_type' | 'none', sorting: SortingType, unreadsAtTop: boolean, favoritesAtTop: boolean) => { +export const getOrderedChannelIds = (state: GlobalState, lastUnreadChannel: Channel|null, grouping: 'by_type' | 'none', sorting: SortingType, unreadsAtTop: boolean, favoritesAtTop: boolean, collapsedThreadsEnabled: boolean) => { const channels: {type: string;name: string;items: string[]}[] = []; if (grouping === 'by_type') { @@ -851,6 +892,14 @@ export const getOrderedChannelIds = (state: GlobalState, lastUnreadChannel: Chan }); } + if (collapsedThreadsEnabled) { + channels.unshift(({ + type: 'threads', + name: 'THREADS', + items: [''], + })); + } + if (hasChannelsChanged(channels)) { lastChannels = channels; } diff --git a/app/mm-redux/selectors/entities/general.ts b/app/mm-redux/selectors/entities/general.ts index 6ca65e0ed..e5a060af4 100644 --- a/app/mm-redux/selectors/entities/general.ts +++ b/app/mm-redux/selectors/entities/general.ts @@ -4,12 +4,19 @@ import {createSelector} from 'reselect'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {General} from '../../constants'; import {GlobalState} from '@mm-redux/types/store'; -import {Config} from '@mm-redux/types/config'; +import {Config, FeatureFlags} from '@mm-redux/types/config'; export function getConfig(state: GlobalState): Partial { return state.entities.general.config; } +/** + * Safely get value of a specific or known FeatureFlag + */ +export function getFeatureFlagValue(state: GlobalState, key: keyof FeatureFlags): string | undefined { + return getConfig(state)?.[`FeatureFlag${key}` as keyof Partial]; +} + export function getLicense(state: GlobalState): any { return state.entities.general.license; } diff --git a/app/mm-redux/selectors/entities/preferences.ts b/app/mm-redux/selectors/entities/preferences.ts index 3924cfa41..211888aca 100644 --- a/app/mm-redux/selectors/entities/preferences.ts +++ b/app/mm-redux/selectors/entities/preferences.ts @@ -3,7 +3,7 @@ import * as reselect from 'reselect'; import {General, Preferences} from '../../constants'; -import {getConfig, getLicense} from '@mm-redux/selectors/entities/general'; +import {getConfig, getFeatureFlagValue, getLicense} from '@mm-redux/selectors/entities/general'; import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; import {createShallowSelector} from '@mm-redux/utils/helpers'; import {getPreferenceKey} from '@mm-redux/utils/preference_utils'; @@ -265,3 +265,32 @@ export function shouldAutocloseDMs(state: GlobalState) { const preference = get(state, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_AUTOCLOSE_DMS, Preferences.AUTOCLOSE_DMS_ENABLED); return preference === Preferences.AUTOCLOSE_DMS_ENABLED; } + +export function getCollapsedThreadsPreference(state: GlobalState): string { + const configValue = getConfig(state)?.CollapsedThreads; + let preferenceDefault = Preferences.COLLAPSED_REPLY_THREADS_OFF; + + if (configValue === 'default_on') { + preferenceDefault = Preferences.COLLAPSED_REPLY_THREADS_ON; + } + + return get( + state, + Preferences.CATEGORY_DISPLAY_SETTINGS, + Preferences.COLLAPSED_REPLY_THREADS, + preferenceDefault ?? Preferences.COLLAPSED_REPLY_THREADS_FALLBACK_DEFAULT, + ); +} + +export function isCollapsedThreadsAllowed(state: GlobalState): boolean { + return ( + getFeatureFlagValue(state, 'CollapsedThreads') === 'true' && + getConfig(state).CollapsedThreads !== 'disabled' + ); +} + +export function isCollapsedThreadsEnabled(state: GlobalState): boolean { + const isAllowed = isCollapsedThreadsAllowed(state); + const userPreference = getCollapsedThreadsPreference(state); + return isAllowed && (userPreference === Preferences.COLLAPSED_REPLY_THREADS_ON || getConfig(state).CollapsedThreads as string === 'always_on'); +} diff --git a/app/mm-redux/selectors/entities/teams.ts b/app/mm-redux/selectors/entities/teams.ts index f095f41f6..47d981af6 100644 --- a/app/mm-redux/selectors/entities/teams.ts +++ b/app/mm-redux/selectors/entities/teams.ts @@ -12,6 +12,8 @@ import {Team, TeamMembership} from '@mm-redux/types/teams'; import {IDMappedObjects} from '@mm-redux/types/utilities'; import {GlobalState} from '@mm-redux/types/store'; +import {isCollapsedThreadsEnabled} from './preferences'; + export function getCurrentTeamId(state: GlobalState) { return state.entities.teams.currentTeamId; } @@ -246,13 +248,14 @@ export const getMyTeamsCount = reselect.createSelector( export const getChannelDrawerBadgeCount = reselect.createSelector( getCurrentTeamId, getTeamMemberships, - (currentTeamId, teamMembers) => { + isCollapsedThreadsEnabled, + (currentTeamId, teamMembers, collapsedThreadsEnabled) => { let mentionCount = 0; let messageCount = 0; Object.values(teamMembers).forEach((m: TeamMembership) => { if (m.team_id !== currentTeamId) { - mentionCount += (m.mention_count || 0); - messageCount += (m.msg_count || 0); + mentionCount += collapsedThreadsEnabled ? (m.mention_count_root || 0) : (m.mention_count || 0); + messageCount += collapsedThreadsEnabled ? (m.msg_count_root || 0) : (m.msg_count || 0); } }); @@ -275,14 +278,17 @@ export function makeGetBadgeCountForTeamId() { return reselect.createSelector( getTeamMemberships, (state: GlobalState, id: string) => id, - (members, teamId) => { + isCollapsedThreadsEnabled, + (members, teamId, collapsedThreadsEnabled) => { const member = members[teamId]; let badgeCount = 0; if (member) { - if (member.mention_count) { - badgeCount = member.mention_count; - } else if (member.msg_count) { + const mentionCount = collapsedThreadsEnabled ? member.mention_count_root : member.mention_count; + const msgCount = collapsedThreadsEnabled ? member.msg_count_root : member.msg_count; + if (mentionCount) { + badgeCount = mentionCount; + } else if (msgCount) { badgeCount = -1; } } diff --git a/app/mm-redux/selectors/entities/threads.test.js b/app/mm-redux/selectors/entities/threads.test.js new file mode 100644 index 000000000..9518443ae --- /dev/null +++ b/app/mm-redux/selectors/entities/threads.test.js @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; + +import deepFreezeAndThrowOnMutation from '@mm-redux/utils/deep_freeze'; +import TestHelper from 'test/test_helper'; +import * as Selectors from '@mm-redux/selectors/entities/threads'; + +describe('Selectors.Threads.getThreadOrderInCurrentTeam', () => { + const team1 = TestHelper.fakeTeamWithId(); + const team2 = TestHelper.fakeTeamWithId(); + + it('should return threads order in current team based on last reply time', () => { + const user = TestHelper.fakeUserWithId(); + + const profiles = { + [user.id]: user, + }; + + const testState = deepFreezeAndThrowOnMutation({ + entities: { + users: { + currentUserId: user.id, + profiles, + }, + teams: { + currentTeamId: team1.id, + }, + threads: { + threads: { + a: {last_reply_at: 1, is_following: true}, + b: {last_reply_at: 2, is_following: true}, + }, + threadsInTeam: { + [team1.id]: ['a', 'b'], + [team2.id]: ['c', 'd'], + }, + }, + }, + }); + + assert.deepStrictEqual(Selectors.getThreadOrderInCurrentTeam(testState), ['b', 'a']); + }); +}); + +describe('Selectors.Threads.getThreadsInCurrentTeam', () => { + const team1 = TestHelper.fakeTeamWithId(); + const team2 = TestHelper.fakeTeamWithId(); + + it('should return threads in current team', () => { + const user = TestHelper.fakeUserWithId(); + + const profiles = { + [user.id]: user, + }; + + const testState = deepFreezeAndThrowOnMutation({ + entities: { + users: { + currentUserId: user.id, + profiles, + }, + teams: { + currentTeamId: team1.id, + }, + threads: { + threads: { + a: {}, + b: {}, + }, + threadsInTeam: { + [team1.id]: ['a', 'b'], + [team2.id]: ['c', 'd'], + }, + }, + }, + }); + + assert.deepStrictEqual(Selectors.getThreadsInCurrentTeam(testState), ['a', 'b']); + }); +}); diff --git a/app/mm-redux/selectors/entities/threads.ts b/app/mm-redux/selectors/entities/threads.ts new file mode 100644 index 000000000..f0128621f --- /dev/null +++ b/app/mm-redux/selectors/entities/threads.ts @@ -0,0 +1,109 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createSelector} from 'reselect'; +import {getChannel} from '@mm-redux/selectors/entities/channels'; +import {getPost} from '@mm-redux/selectors/entities/posts'; +import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams'; +import {GlobalState} from '@mm-redux/types/store'; +import {Team} from '@mm-redux/types/teams'; +import {UserThread, ThreadsState} from '@mm-redux/types/threads'; +import {$ID, RelationOneToMany} from '@mm-redux/types/utilities'; + +export function getThreadsInTeam(state: GlobalState): RelationOneToMany { + return state.entities.threads.threadsInTeam; +} + +export const getThreadsInCurrentTeam: (state: GlobalState) => Array<$ID> = createSelector( + getCurrentTeamId, + getThreadsInTeam, + (currentTeamId: $ID, threadsInTeam: ThreadsState['threadsInTeam']) => { + return threadsInTeam?.[currentTeamId] ?? []; + }, +); + +export function getThreadCounts(state: GlobalState): ThreadsState['counts'] { + return state.entities.threads.counts; +} + +export function getTeamThreadCounts(state: GlobalState, teamId: $ID): ThreadsState['counts'][$ID] { + return getThreadCounts(state)[teamId]; +} + +export const getThreadCountsInCurrentTeam: (state: GlobalState) => ThreadsState['counts'][$ID] = createSelector( + getCurrentTeamId, + getThreadCounts, + (currentTeamId: $ID, counts: ThreadsState['counts']) => { + return counts?.[currentTeamId]; + }, +); + +export function getThreads(state: GlobalState) { + return state.entities.threads.threads; +} + +export function getThread(state: GlobalState, threadId: $ID, fallbackFromPosts?: boolean): UserThread | null { + const thread = getThreads(state)[threadId]; + + // fallbackfromPosts is for In-Channel view, where we need to show the footer from post data as user might not follow all the threads. + // "thread" will be undefined for unfollowed threads + // "thread.id" might not be there when user pressed on follow the thread, but thread is not received from server + if (!thread || !thread?.id) { + if (fallbackFromPosts) { + const post = getPost(state, threadId); + if (post?.participants?.length) { + const {id, is_following, reply_count, last_reply_at, participants} = post; + return { + id, + is_following: thread?.is_following || is_following, + reply_count, + participants, + last_reply_at: last_reply_at || 0, + post, + last_viewed_at: 0, + unread_mentions: 0, + unread_replies: 0, + }; + } + } + } + return thread || null; +} + +export const getThreadOrderInCurrentTeam: (state: GlobalState) => Array<$ID> = createSelector( + getThreadsInCurrentTeam, + getThreads, + (threadsInTeam: Array<$ID>, threads: ThreadsState['threads']) => { + const ids = threadsInTeam.filter((id) => { + return threads[id]?.is_following; + }); + return sortByLastReply(ids, threads); + }, +); + +export function getThreadTeamId(state: GlobalState, threadId: $ID): $ID { + let teamId; + const thread = getThread(state, threadId, true); + if (thread && thread.post.channel_id) { + const channel = getChannel(state, thread.post.channel_id); + teamId = channel.team_id; + } + return teamId || getCurrentTeamId(state); +} + +export const getUnreadThreadOrderInCurrentTeam: (state: GlobalState) => Array<$ID> = createSelector( + getThreadsInCurrentTeam, + getThreads, + (threadsInTeam: Array<$ID>, threads: ThreadsState['threads']) => { + const ids = threadsInTeam.filter((id) => { + const thread = threads[id]; + return thread?.unread_mentions || thread?.unread_replies; + }); + + return sortByLastReply(ids, threads); + }, +); + +function sortByLastReply(ids: Array<$ID>, threads: ReturnType) { + return ids.sort((a, b) => threads[b].last_reply_at - threads[a].last_reply_at); +} diff --git a/app/mm-redux/selectors/entities/users.test.js b/app/mm-redux/selectors/entities/users.test.js index c3e13a830..aff25ba08 100644 --- a/app/mm-redux/selectors/entities/users.test.js +++ b/app/mm-redux/selectors/entities/users.test.js @@ -406,6 +406,23 @@ describe('Selectors.Users', () => { }); }); + it('makeGetProfilesByIds', () => { + const getProfilesByIds = Selectors.makeGetProfilesByIds(); + + const testCases = [ + {input: [], output: []}, + {input: ['nonexistentid'], output: []}, + {input: [user1.id], output: [user1]}, + {input: [user1.id, 'nonexistentid'], output: [user1]}, + {input: [user1.id, user2.id], output: [user1, user2]}, + {input: ['nonexistentid', user1.id, user2.id], output: [user1, user2]}, + ]; + + testCases.forEach((testCase) => { + assert.deepStrictEqual(getProfilesByIds(testState, testCase.input), testCase.output); + }); + }); + describe('makeGetDisplayName', () => { const testUser1 = { ...user1, diff --git a/app/mm-redux/selectors/entities/users.ts b/app/mm-redux/selectors/entities/users.ts index cb104125c..28234ddca 100644 --- a/app/mm-redux/selectors/entities/users.ts +++ b/app/mm-redux/selectors/entities/users.ts @@ -480,6 +480,27 @@ export function makeGetProfilesByIdsAndUsernames(): (a: GlobalState, b: Array<$I ); } +export function makeGetProfilesByIds(): (a: GlobalState, b: Array<$ID>) => Array { + return createSelector( + getUsers, + (state: GlobalState, userIds: Array<$ID>) => userIds, + (allProfilesById: Dictionary, userIds: Array) => { + const userProfiles: UserProfile[] = []; + + if (userIds && userIds.length > 0) { + const profilesById = userIds. + filter((userId) => allProfilesById[userId]). + map((userId) => allProfilesById[userId]); + + if (profilesById && profilesById.length > 0) { + userProfiles.push(...profilesById); + } + } + return userProfiles; + }, + ); +} + export const getUsernamesByUserId = createSelector( getUsers, getUsersByUsername, diff --git a/app/mm-redux/types/channels.ts b/app/mm-redux/types/channels.ts index 4eb3b2633..54adee04d 100644 --- a/app/mm-redux/types/channels.ts +++ b/app/mm-redux/types/channels.ts @@ -28,6 +28,7 @@ export type Channel = { purpose: string; last_post_at: number; total_msg_count: number; + total_msg_count_root: number; extra_update_at: number; creator_id: string; scheme_id: string; @@ -49,6 +50,8 @@ export type ChannelMembership = { last_viewed_at: number; msg_count: number; mention_count: number; + msg_count_root: number; + mention_count_root: number; notify_props: Partial; last_update_at: number; scheme_user: boolean; @@ -61,6 +64,8 @@ export type ChannelUnread = { team_id: string; msg_count: number; mention_count: number; + msg_count_root: number; + mention_count_root: number; last_viewed_at: number; deltaMsgs: number; }; diff --git a/app/mm-redux/types/config.ts b/app/mm-redux/types/config.ts index 25948c3cd..a73d80777 100644 --- a/app/mm-redux/types/config.ts +++ b/app/mm-redux/types/config.ts @@ -21,6 +21,7 @@ export type Config = { BuildHashEnterprise: string; BuildNumber: string; CloseUnusedDirectMessages: string; + CollapsedThreads: 'disabled' | 'default_off' | 'default_on'; CustomBrandText: string; CustomDescriptionText: string; CustomTermsOfServiceId: string; @@ -169,3 +170,5 @@ export type Config = { WebsocketSecurePort: string; WebsocketURL: string; }; + +export type FeatureFlags = Record; diff --git a/app/mm-redux/types/index.ts b/app/mm-redux/types/index.ts index becd28972..cdfc28814 100644 --- a/app/mm-redux/types/index.ts +++ b/app/mm-redux/types/index.ts @@ -24,6 +24,7 @@ import * as users from './users'; import * as bots from './bots'; import * as plugins from './plugins'; import * as config from './config'; +import * as threads from './threads'; export { config, @@ -49,4 +50,5 @@ export { requests, reactions, users, + threads, }; diff --git a/app/mm-redux/types/posts.ts b/app/mm-redux/types/posts.ts index 192089e1b..93a6abce7 100644 --- a/app/mm-redux/types/posts.ts +++ b/app/mm-redux/types/posts.ts @@ -4,7 +4,9 @@ import {CustomEmoji} from './emojis'; import {FileInfo} from './files'; import {Reaction} from './reactions'; +import {UserProfile} from './users'; import { + $ID, RelationOneToOne, RelationOneToMany, IDMappedObjects, @@ -56,6 +58,7 @@ export type Post = { edit_at: number; delete_at: number; is_pinned: boolean; + is_following: boolean; user_id: string; channel_id: string; root_id: string; @@ -73,6 +76,8 @@ export type Post = { user_activity_posts?: Array; state?: 'DELETED'; ownPost?: boolean; + last_reply_at?: number; + participants: Array}>; }; export type PostWithFormatData = Post & { diff --git a/app/mm-redux/types/requests.ts b/app/mm-redux/types/requests.ts index 70469426a..a53c87f1f 100644 --- a/app/mm-redux/types/requests.ts +++ b/app/mm-redux/types/requests.ts @@ -25,6 +25,10 @@ export type PostsRequestsStatuses = { getPostThread: RequestStatusType; }; +export type ThreadsRequestStatuses = { + getThreads: RequestStatusType; +}; + export type TeamsRequestsStatuses = { getMyTeams: RequestStatusType; getTeams: RequestStatusType; diff --git a/app/mm-redux/types/store.ts b/app/mm-redux/types/store.ts index c8bc67a4b..49da68063 100644 --- a/app/mm-redux/types/store.ts +++ b/app/mm-redux/types/store.ts @@ -19,6 +19,7 @@ import {Bot} from './bots'; import {ChannelCategoriesState} from './channel_categories'; import {RemoteCluster} from './remote_cluster'; import {Dictionary} from './utilities'; +import {ThreadsState} from './threads'; import {AppsState} from './apps'; export type GlobalState = { @@ -28,6 +29,7 @@ export type GlobalState = { teams: TeamsState; channels: ChannelsState; posts: PostsState; + threads: ThreadsState; bots: { accounts: Dictionary; }; diff --git a/app/mm-redux/types/teams.ts b/app/mm-redux/types/teams.ts index 9ec32ac31..18c619428 100644 --- a/app/mm-redux/types/teams.ts +++ b/app/mm-redux/types/teams.ts @@ -7,6 +7,8 @@ import {Error} from './errors'; export type TeamMembership = { mention_count: number; msg_count: number; + mention_count_root: number; + msg_count_root: number; team_id: string; user_id: string; roles: string; @@ -55,4 +57,6 @@ export type TeamUnread = { team_id: string; mention_count: number; msg_count: number; + mention_count_root: number; + msg_count_root: number; }; diff --git a/app/mm-redux/types/threads.ts b/app/mm-redux/types/threads.ts new file mode 100644 index 000000000..571d8332d --- /dev/null +++ b/app/mm-redux/types/threads.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {Post} from './posts'; +import {Team} from './teams'; +import {UserProfile} from './users'; +import {$ID, IDMappedObjects, RelationOneToMany, RelationOneToOne} from './utilities'; + +export type UserThread = { + id: string; + reply_count: number; + last_reply_at: number; + last_viewed_at: number; + participants: Array<{id: $ID} | UserProfile>; + post: Post; + unread_replies: number; + unread_mentions: number; + is_following?: boolean; +} + +export type UserThreadList = { + total: number; + total_unread_threads: number; + total_unread_mentions: number; + threads: UserThread[]; +} + +export type ThreadsState = { + threadsInTeam: RelationOneToMany; + threads: IDMappedObjects; + counts: RelationOneToOne; +}; diff --git a/app/mm-redux/utils/channel_utils.ts b/app/mm-redux/utils/channel_utils.ts index d8cbd7d10..d8e32b570 100644 --- a/app/mm-redux/utils/channel_utils.ts +++ b/app/mm-redux/utils/channel_utils.ts @@ -35,7 +35,7 @@ const channelTypeOrder = { */ export function buildDisplayableChannelListWithUnreadSection(usersState: UsersState, myChannels: Array, myMembers: RelationOneToOne, config: any, myPreferences: { [x: string]: PreferenceType; -}, teammateNameDisplay: string, lastPosts: RelationOneToOne) { +}, teammateNameDisplay: string, lastPosts: RelationOneToOne, collapsedThreadsEnabled: boolean) { const { currentUserId, profiles, @@ -44,10 +44,10 @@ export function buildDisplayableChannelListWithUnreadSection(usersState: UsersSt const missingDirectChannels = createMissingDirectChannels(currentUserId, myChannels, myPreferences); const channels = buildChannels(usersState, myChannels, missingDirectChannels, teammateNameDisplay, locale); const unreadChannels = [...buildChannelsWithMentions(channels, myMembers, locale), ...buildUnreadChannels(channels, myMembers, locale)]; - const notUnreadChannels = channels.filter((channel: Channel) => !isUnreadChannel(myMembers, channel)); + const notUnreadChannels = channels.filter((channel: Channel) => !isUnreadChannel(myMembers, channel, collapsedThreadsEnabled)); const favoriteChannels = buildFavoriteChannels(notUnreadChannels, myPreferences, locale); const notFavoriteChannels = buildNotFavoriteChannels(notUnreadChannels, myPreferences); - const directAndGroupChannels = buildDirectAndGroupChannels(notFavoriteChannels, myMembers, config, myPreferences, currentUserId, profiles, lastPosts); + const directAndGroupChannels = buildDirectAndGroupChannels(notFavoriteChannels, myMembers, config, myPreferences, currentUserId, profiles, lastPosts, collapsedThreadsEnabled); return { unreadChannels, favoriteChannels, @@ -255,12 +255,13 @@ export function isGroupOrDirectChannelVisible( currentUserId: string, users: IDMappedObjects, lastPosts: RelationOneToOne, + collapsedThreadsEnabled: boolean, currentChannelId?: string, now?: number, ): boolean { const lastPost = lastPosts[channel.id]; - if (isGroupChannel(channel) && isGroupChannelVisible(config, myPreferences, channel, lastPost, isUnreadChannel(memberships, channel), now)) { + if (isGroupChannel(channel) && isGroupChannelVisible(config, myPreferences, channel, lastPost, isUnreadChannel(memberships, channel, collapsedThreadsEnabled), now)) { return true; } @@ -276,7 +277,7 @@ export function isGroupOrDirectChannelVisible( myPreferences, channel, lastPost, - isUnreadChannel(memberships, channel), + isUnreadChannel(memberships, channel, collapsedThreadsEnabled), currentChannelId, now, ); @@ -476,6 +477,7 @@ function createFakeChannel(userId: string, otherUserId: string): Channel { extra_update_at: 0, last_post_at: 0, total_msg_count: 0, + total_msg_count_root: 0, type: General.DM_CHANNEL as ChannelType, fake: true, team_id: '', @@ -556,12 +558,14 @@ function channelHasUnreadMessages(members: RelationOneToOne, channel: Channel): boolean { +export function isUnreadChannel(members: RelationOneToOne, channel: Channel, collapsedThreadsEnabled: boolean): boolean { const member = members[channel.id]; if (member) { - const msgCount = channel.total_msg_count - member.msg_count; + const msgCount = getMsgCountInChannel(collapsedThreadsEnabled, channel, member); const onlyMentions = member.notify_props && member.notify_props.mark_unread === General.MENTION; - return (member.mention_count > 0 || (Boolean(msgCount) && !onlyMentions)); + return ( + collapsedThreadsEnabled ? member.mention_count_root : member.mention_count + ) > 0 || (Boolean(msgCount) && !onlyMentions); } return false; @@ -697,9 +701,9 @@ function buildNotFavoriteChannels(channels: Array, myPreferences: { function buildDirectAndGroupChannels(channels: Array, memberships: RelationOneToOne, config: any, myPreferences: { [x: string]: PreferenceType; -}, currentUserId: string, users: IDMappedObjects, lastPosts: RelationOneToOne): Array { +}, currentUserId: string, users: IDMappedObjects, lastPosts: RelationOneToOne, collapsedThreadsEnabled: boolean): Array { return channels.filter((channel) => { - return isGroupOrDirectChannelVisible(channel, memberships, config, myPreferences, currentUserId, users, lastPosts); + return isGroupOrDirectChannelVisible(channel, memberships, config, myPreferences, currentUserId, users, lastPosts, collapsedThreadsEnabled); }); } @@ -736,3 +740,7 @@ export function filterChannelsMatchingTerm(channels: Array, term: strin displayName.startsWith(lowercasedTerm); }); } + +export function getMsgCountInChannel(collapsedThreadsEnabled: boolean, channel: Channel, member: ChannelMembership): number { + return collapsedThreadsEnabled ? Math.max(channel.total_msg_count_root - member.msg_count_root, 0) : Math.max(channel.total_msg_count - member.msg_count, 0); +} diff --git a/app/reducers/views/channel.js b/app/reducers/views/channel.js index 2e4f75b48..236511265 100644 --- a/app/reducers/views/channel.js +++ b/app/reducers/views/channel.js @@ -331,7 +331,7 @@ function lastChannelViewTime(state = {}, action) { function keepChannelIdAsUnread(state = null, action) { switch (action.type) { case ChannelTypes.SELECT_CHANNEL: { - if (!action.extra && action.data) { + if (!action.extra && (action.data || action.data === '')) { return { id: action.data, hadMentions: false, diff --git a/app/reducers/views/index.js b/app/reducers/views/index.js index 1d09f5053..99a9a665f 100644 --- a/app/reducers/views/index.js +++ b/app/reducers/views/index.js @@ -12,6 +12,7 @@ import search from './search'; import selectServer from './select_server'; import team from './team'; import thread from './thread'; +import threads from './threads'; import user from './user'; import emoji from './emoji'; import post from './post'; @@ -29,4 +30,5 @@ export default combineReducers({ user, emoji, post, + threads, }); diff --git a/app/reducers/views/threads.ts b/app/reducers/views/threads.ts new file mode 100644 index 000000000..24ae219f3 --- /dev/null +++ b/app/reducers/views/threads.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {combineReducers} from 'redux'; + +import {GenericAction} from '@mm-redux/types/actions'; +import {ViewTypes} from '@constants'; + +const viewingGlobalThreads = (state = false, action: GenericAction) => { + switch (action.type) { + case ViewTypes.VIEWING_GLOBAL_THREADS_SCREEN: { + return true; + } + case ViewTypes.NOT_VIEWING_GLOBAL_THREADS_SCREEN: { + return false; + } + } + return state; +}; + +const viewingGlobalThreadsUnreads = (state = false, action: GenericAction) => { + switch (action.type) { + case ViewTypes.VIEWING_GLOBAL_THREADS_UNREADS: { + return true; + } + case ViewTypes.VIEWING_GLOBAL_THREADS_ALL: { + return false; + } + } + return state; +}; + +export default combineReducers({ + viewingGlobalThreads, + viewingGlobalThreadsUnreads, +}); diff --git a/app/screens/channel/channel.android.js b/app/screens/channel/channel.android.js index 1d4fe7900..e96ab481b 100644 --- a/app/screens/channel/channel.android.js +++ b/app/screens/channel/channel.android.js @@ -6,13 +6,14 @@ import {StyleSheet, View, BackHandler, ToastAndroid} from 'react-native'; import {openMainSideMenu, openSettingsSideMenu} from '@actions/navigation'; import AnnouncementBanner from 'app/components/announcement_banner'; +import GlobalThreadsList from '@components/global_threads'; import KeyboardLayout from '@components/layout/keyboard_layout'; import InteractiveDialogController from '@components/interactive_dialog_controller'; import NetworkIndicator from '@components/network_indicator'; import PostDraft from '@components/post_draft'; import {NavigationTypes} from '@constants'; -import EventEmitter from '@mm-redux/utils/event_emitter'; import EphemeralStore from '@store/ephemeral_store'; +import EventEmitter from '@mm-redux/utils/event_emitter'; import ChannelNavBar from './channel_nav_bar'; import ChannelPostList from './channel_post_list'; @@ -60,26 +61,33 @@ export default class ChannelAndroid extends ChannelBase { } render() { - const {theme} = this.props; - let component = this.renderLoadingOrFailedChannel(); + const {theme, viewingGlobalThreads} = this.props; + let component; - if (!component) { + if (viewingGlobalThreads) { component = ( - - - - - - + ); + } else { + component = this.renderLoadingOrFailedChannel(); + if (!component) { + component = ( + + + + + + + ); + } } const drawerContent = ( diff --git a/app/screens/channel/channel.ios.js b/app/screens/channel/channel.ios.js index cc2f987a3..d183039c9 100644 --- a/app/screens/channel/channel.ios.js +++ b/app/screens/channel/channel.ios.js @@ -7,6 +7,7 @@ import {SafeAreaView} from 'react-native-safe-area-context'; import AnnouncementBanner from 'app/components/announcement_banner'; import Autocomplete from '@components/autocomplete'; +import GlobalThreadsList from '@components/global_threads'; import InteractiveDialogController from '@components/interactive_dialog_controller'; import NetworkIndicator from '@components/network_indicator'; import PostDraft from '@components/post_draft'; @@ -53,17 +54,27 @@ export default class ChannelIOS extends ChannelBase { }; render() { - const {currentChannelId, theme} = this.props; - let component = this.renderLoadingOrFailedChannel(); - let renderDraftArea = false; + const {currentChannelId, theme, viewingGlobalThreads} = this.props; - if (!component) { - renderDraftArea = true; + let component; + let renderDraftArea = false; + const safeAreaEdges = ['left', 'right']; + + if (viewingGlobalThreads) { component = ( - <> - - + ); + } else { + safeAreaEdges.push('bottom'); + component = this.renderLoadingOrFailedChannel(); + if (!component) { + renderDraftArea = true; + component = ( + <> + + + ); + } } const style = getStyle(theme); @@ -79,6 +90,7 @@ export default class ChannelIOS extends ChannelBase { openMainSidebar={this.openMainSidebar} openSettingsSidebar={this.openSettingsSidebar} onPress={this.goToChannelInfo} + isGlobalThreads={viewingGlobalThreads} /> ); @@ -88,22 +100,24 @@ export default class ChannelIOS extends ChannelBase { {header} {component} {indicators} - - - + {!viewingGlobalThreads && ( + + + + )} {renderDraftArea && { }, }), }, + wrapper: { + alignItems: 'center', + flex: 1, + position: 'relative', + top: -1, + flexDirection: 'row', + justifyContent: 'flex-start', + width: '90%', + }, + threadsTitle: { + color: theme.sidebarHeaderTextColor, + fontSize: 18, + fontWeight: '600', + textAlign: 'center', + }, }; }); const ChannelNavBar = (props) => { - const {isLandscape, onPress, openMainSidebar, openSettingsSidebar, theme} = props; + const {isLandscape, onPress, openMainSidebar, openSettingsSidebar, theme, isGlobalThreads} = props; const insets = useSafeAreaInsets(); const style = getStyleFromTheme(theme); const [splitView, setSplitView] = useState(false); @@ -125,6 +141,33 @@ const ChannelNavBar = (props) => { break; } + let title; + if (isGlobalThreads) { + title = ( + + + + + ); + } else { + title = ( + + + ); + } + return ( { openSidebar={openMainSidebar} visible={drawerButtonVisible()} /> - + {title} @@ -152,6 +192,7 @@ ChannelNavBar.propTypes = { openSettingsSidebar: PropTypes.func.isRequired, onPress: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, + isGlobalThreads: PropTypes.bool, }; export default ChannelNavBar; diff --git a/app/screens/channel/channel_post_list/channel_post_list.js b/app/screens/channel/channel_post_list/channel_post_list.js index e2d264b19..764faea0d 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.js +++ b/app/screens/channel/channel_post_list/channel_post_list.js @@ -11,7 +11,7 @@ import RetryBarIndicator from '@components/retry_bar_indicator'; import {TYPING_HEIGHT} from '@constants/post_draft'; import EventEmitter from '@mm-redux/utils/event_emitter'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {CHANNEL} from '@constants/screen'; +import {CHANNEL, THREAD} from '@constants/screen'; let ChannelIntro = null; let LoadMorePosts = null; @@ -87,7 +87,7 @@ export default class ChannelPostList extends PureComponent { actions.getPostThread(rootId); actions.selectPost(rootId); - const screen = 'Thread'; + const screen = THREAD; const title = ''; const passProps = { channelId: post.channel_id, diff --git a/app/screens/channel/index.js b/app/screens/channel/index.js index 92b5b00f8..4bd536c93 100644 --- a/app/screens/channel/index.js +++ b/app/screens/channel/index.js @@ -11,13 +11,14 @@ import {getChannelStats} from '@mm-redux/actions/channels'; import {Client4} from '@client/rest'; import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; import {getServerVersion} from '@mm-redux/selectors/entities/general'; -import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getTheme, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {getCurrentTeam} from '@mm-redux/selectors/entities/teams'; import {getCurrentUserId, getCurrentUserRoles, shouldShowTermsOfService} from '@mm-redux/selectors/entities/users'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {isSystemAdmin as checkIsSystemAdmin} from '@mm-redux/utils/user_utils'; import Channel from './channel'; +import {getViewingGlobalThreads} from '@selectors/threads'; function mapStateToProps(state) { const currentTeam = getCurrentTeam(state); @@ -48,6 +49,7 @@ function mapStateToProps(state) { showTermsOfService: shouldShowTermsOfService(state), teamName: currentTeam?.display_name, theme: getTheme(state), + viewingGlobalThreads: isCollapsedThreadsEnabled(state) && getViewingGlobalThreads(state), }; } diff --git a/app/screens/index.js b/app/screens/index.js index ca68a2adf..b8f7ba9e3 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -10,6 +10,8 @@ import {Provider} from 'react-redux'; import {SafeAreaProvider} from 'react-native-safe-area-context'; import RootWrapper from '@components/root'; +import ThreadFollow from '@screens/thread/thread_follow'; + let store; const withGestures = (screen, styles) => { @@ -146,6 +148,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case 'OptionsModal': screen = require('@screens/options_modal').default; break; + case 'ParticipantsList': + screen = require('@screens/participants_list').default; + break; case 'PerfMetrics': screen = require('@screens/perf_metrics').default; break; @@ -200,6 +205,10 @@ Navigation.setLazyComponentRegistrator((screenName) => { case 'Thread': screen = require('@screens/thread').default; break; + case 'ThreadFollow': { + Navigation.registerComponent('ThreadFollow', () => ThreadFollow); + break; + } case 'TimezoneSettings': screen = require('@screens/settings/timezone').default; break; diff --git a/app/screens/participants_list/index.ts b/app/screens/participants_list/index.ts new file mode 100644 index 000000000..2b9aa3fad --- /dev/null +++ b/app/screens/participants_list/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentUserId, makeGetProfilesByIds} from '@mm-redux/selectors/entities/users'; +import type {GlobalState} from '@mm-redux/types/store'; +import type {UserProfile} from '@mm-redux/types/users'; + +import ParticipantsList from './participants_list'; + +interface ListProps { + userIds: string[]; +} + +const EMPTY_USER_PROFILES: UserProfile[] = []; + +function makeMapStateToProps() { + const getProfilesByIds = makeGetProfilesByIds(); + + return function mapStateToProps(state: GlobalState, ownProps:ListProps) { + const allUserIds = ownProps.userIds; + return { + currentUserId: getCurrentUserId(state), + theme: getTheme(state), + teammateNameDisplay: getTeammateNameDisplaySetting(state), + userProfiles: getProfilesByIds(state, allUserIds) || EMPTY_USER_PROFILES, + }; + }; +} + +export default connect(makeMapStateToProps)(ParticipantsList); diff --git a/app/screens/participants_list/participant_row/index.tsx b/app/screens/participants_list/participant_row/index.tsx new file mode 100644 index 000000000..ed33a842f --- /dev/null +++ b/app/screens/participants_list/participant_row/index.tsx @@ -0,0 +1,142 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {injectIntl, intlShape} from 'react-intl'; +import {Text, TouchableOpacity, View} from 'react-native'; + +import {showModal} from '@actions/navigation'; +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import ProfilePicture from '@components/profile_picture'; +import type {Theme} from '@mm-redux/types/preferences'; +import {UserProfile} from '@mm-redux/types/users'; +import {$ID} from '@mm-redux/types/utilities'; +import {displayUsername} from '@mm-redux/utils/user_utils'; +import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + currentUserId: $ID; + teammateNameDisplay: string; + theme: Theme, + user: UserProfile, + intl: typeof intlShape; +} + +const ParticipantRow = ({currentUserId, teammateNameDisplay, theme, user, intl}: Props) => { + const goToUserProfile = React.useCallback(preventDoubleTap(async () => { + const {formatMessage} = intl; + const screen = 'UserProfile'; + const title = formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); + const passProps = { + userId: user.id, + }; + + const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor); + + const options = { + topBar: { + leftButtons: [{ + id: 'close-settings', + icon: closeButton, + }], + }, + }; + + showModal(screen, title, passProps, options); + }), []); + + if (!user.id) { + return null; + } + + const {id, username} = user; + const usernameDisplay = '@' + username; + const displayName = displayUsername(user, teammateNameDisplay); + + const style = getStyleSheet(theme); + + const isCurrentUser = currentUserId === id; + + return ( + + + + + + + + + + {`${displayName === username ? username : displayName}`} + + {displayName !== username && + + {isCurrentUser && ( + + )} + {` ${usernameDisplay}`} + + } + + + + ); +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme:Theme) => { + return { + container: { + flexDirection: 'row', + justifyContent: 'flex-start', + height: 40, + width: '100%', + alignItems: 'center', + }, + profileContainer: { + alignItems: 'center', + width: '13%', + }, + profile: { + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderRadius: 12.5, + width: 25, + height: 25, + borderColor: changeOpacity(theme.centerChannelColor, 0.08), + }, + textContainer: { + width: '74%', + flexDirection: 'row', + }, + displayName: { + fontSize: 15, + fontWeight: '400', + color: theme.centerChannelColor, + }, + username: { + fontSize: 15, + fontWeight: '400', + color: changeOpacity(theme.centerChannelColor, 0.56), + + }, + }; +}); + +export default injectIntl(ParticipantRow); diff --git a/app/screens/participants_list/participants_list.tsx b/app/screens/participants_list/participants_list.tsx new file mode 100644 index 000000000..950ab36b3 --- /dev/null +++ b/app/screens/participants_list/participants_list.tsx @@ -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} from 'react-native'; + +import {dismissModal} from '@actions/navigation'; +import FormattedText from '@components/formatted_text'; +import SlideUpPanel from '@components/slide_up_panel'; +import type {UserProfile} from '@mm-redux/types/users'; +import type {Theme} from '@mm-redux/types/preferences'; +import type {$ID} from '@mm-redux/types/utilities'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import ParticipantRow from './participant_row'; + +type Props = { + currentUserId: $ID; + userProfiles: UserProfile[]; + teammateNameDisplay: string; + theme: Theme; +} + +const ParticipantsList = ({currentUserId, teammateNameDisplay, theme, userProfiles}: Props) => { + const close = () => { + dismissModal(); + }; + + const renderHeader = () => { + const style = getStyleSheet(theme); + return ( + + + + ); + }; + + const renderParticipantRows = () => { + return userProfiles.map((user: UserProfile) => ( + + )); + }; + + return ( + + + {renderParticipantRows()} + + + ); +}; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + header: { + backgroundColor: theme.centerChannelBg, + borderTopLeftRadius: 8, + borderTopRightRadius: 8, + height: 36.5, + maxWidth: 450, + paddingHorizontal: 0, + width: '100%', + }, + headerText: { + color: changeOpacity(theme.centerChannelColor, 0.56), + fontSize: 12, + fontWeight: '600', + paddingHorizontal: 16, + paddingVertical: 0, + top: 16, + }, + }; +}); + +export default ParticipantsList; diff --git a/app/screens/post_options/index.js b/app/screens/post_options/index.js index d0a2acb66..6c1802956 100644 --- a/app/screens/post_options/index.js +++ b/app/screens/post_options/index.js @@ -16,14 +16,16 @@ import { removePost, setUnreadPost, } from '@mm-redux/actions/posts'; +import {setThreadFollow} from '@mm-redux/actions/threads'; import {General, Permissions, Posts} from '@mm-redux/constants'; import {makeGetReactionsForPost} from '@mm-redux/selectors/entities/posts'; import {isChannelReadOnlyById, getChannel, getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; import {getConfig, getLicense} from '@mm-redux/selectors/entities/general'; -import {getMyPreferences, getTheme} from '@mm-redux/selectors/entities/preferences'; +import {getMyPreferences, getTheme, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles'; import {getCurrentTeamId, getCurrentTeamUrl} from '@mm-redux/selectors/entities/teams'; +import {getThread} from '@mm-redux/selectors/entities/threads'; import {canEditPost, isPostFlagged, isSystemMessage} from '@mm-redux/utils/post_utils'; import {getDimensions} from '@selectors/device'; import {canDeletePost} from '@selectors/permissions'; @@ -144,6 +146,7 @@ export function makeMapStateToProps() { currentUserId, isFlagged: isPostFlagged(post.id, myPreferences), theme: getTheme(state), + thread: isCollapsedThreadsEnabled(state) && getThread(state, post.id, true), }; }; } @@ -158,6 +161,7 @@ function mapDispatchToProps(dispatch) { removePost, unflagPost, unpinPost, + setThreadFollow, setUnreadPost, }, dispatch), }; diff --git a/app/screens/post_options/post_options.js b/app/screens/post_options/post_options.js index 16ab1bb5d..f4b19b8d4 100644 --- a/app/screens/post_options/post_options.js +++ b/app/screens/post_options/post_options.js @@ -32,6 +32,7 @@ export default class PostOptions extends PureComponent { removePost: PropTypes.func.isRequired, unflagPost: PropTypes.func.isRequired, unpinPost: PropTypes.func.isRequired, + setThreadFollow: PropTypes.func.isRequired, setUnreadPost: PropTypes.func.isRequired, }).isRequired, canAddReaction: PropTypes.bool, @@ -48,8 +49,10 @@ export default class PostOptions extends PureComponent { currentUserId: PropTypes.string.isRequired, deviceHeight: PropTypes.number.isRequired, isFlagged: PropTypes.bool, + location: PropTypes.string, post: PropTypes.object.isRequired, theme: PropTypes.object.isRequired, + thread: PropTypes.object, }; static contextTypes = { @@ -105,6 +108,25 @@ export default class PostOptions extends PureComponent { return null; } + getFollowThreadOption = () => { + const {thread} = this.props; + if (!thread) { + return null; + } + const key = 'follow'; + let icon; + let message; + if (thread.is_following) { + icon = 'message-minus-outline'; + message = {id: t('threads.unfollowThread'), defaultMessage: 'Unfollow Thread'}; + } else { + icon = 'message-plus-outline'; + message = {id: t('threads.followThread'), defaultMessage: 'Follow Thread'}; + } + const onPress = this.handleToggleFollow; + return this.getOption(key, icon, message, onPress); + } + getCopyPermalink = () => { const {canCopyPermalink} = this.props; @@ -249,6 +271,7 @@ export default class PostOptions extends PureComponent { getPostOptions = () => { const actions = [ this.getReplyOption(), + this.getFollowThreadOption(), this.getMarkAsUnreadOption(), this.getCopyPermalink(), this.getFlagOption(), @@ -285,6 +308,12 @@ export default class PostOptions extends PureComponent { }); }; + handleToggleFollow = () => { + const {actions, currentUserId, thread} = this.props; + actions.setThreadFollow(currentUserId, thread.id, !thread.is_following); + this.closeWithAnimation(); + }; + handleAddReaction = preventDoubleTap((emoji) => { this.handleAddReactionToPost(emoji); this.closeWithAnimation(); @@ -330,11 +359,11 @@ export default class PostOptions extends PureComponent { }; handleMarkUnread = () => { - const {actions, post, currentUserId} = this.props; + const {actions, currentUserId, location, post} = this.props; this.closeWithAnimation(); requestAnimationFrame(() => { - actions.setUnreadPost(currentUserId, post.id); + actions.setUnreadPost(currentUserId, post.id, location); }); } diff --git a/app/screens/post_options/post_options.test.js b/app/screens/post_options/post_options.test.js index 3b01f47ec..703e33b27 100644 --- a/app/screens/post_options/post_options.test.js +++ b/app/screens/post_options/post_options.test.js @@ -18,6 +18,7 @@ describe('PostOptions', () => { removePost: jest.fn(), unflagPost: jest.fn(), unpinPost: jest.fn(), + setThreadFollow: jest.fn(), setUnreadPost: jest.fn(), }; @@ -39,6 +40,7 @@ describe('PostOptions', () => { canMarkAsUnread: true, canEditUntil: -1, channelIsReadOnly: false, + currentTeamId: 'current_team_id', currentTeamUrl: 'http://localhost:8065/team-name', currentUserId: 'user1', deviceHeight: 600, diff --git a/app/screens/sso/sso.test.tsx b/app/screens/sso/sso.test.tsx index ba790ce0b..a7b2eb5ca 100644 --- a/app/screens/sso/sso.test.tsx +++ b/app/screens/sso/sso.test.tsx @@ -3,9 +3,11 @@ import React from 'react'; import {Linking} from 'react-native'; +import configureMockStore from 'redux-mock-store'; +import merge from 'deepmerge'; +import initialState from '@store/initial_state'; import {renderWithReduxIntl} from 'test/testing_library'; -import configureStore from 'test/test_store'; import SSOComponent from './index'; @@ -18,15 +20,18 @@ describe('SSO', () => { }; test('implement with webview when version is less than 5.32 version', async () => { - const store = await configureStore({ - entities: { - general: { - config: { - Version: '5.30.0', + const mockStore = configureMockStore(); + const store = mockStore( + merge(initialState, { + entities: { + general: { + config: { + Version: '5.30.0', + }, }, }, - }, - }); + }), + ); const basicWrapper = renderWithReduxIntl(, store); expect(basicWrapper.queryByTestId('sso.webview')).toBeTruthy(); expect(basicWrapper.queryByTestId('sso.redirect_url')).toBeFalsy(); @@ -34,15 +39,18 @@ describe('SSO', () => { test('implement with OS browser & redirect url from version 5.33', async () => { (Linking.openURL as jest.Mock).mockResolvedValueOnce(''); - const store = await configureStore({ - entities: { - general: { - config: { - Version: '5.33.0', + const mockStore = configureMockStore(); + const store = mockStore( + merge(initialState, { + entities: { + general: { + config: { + Version: '5.33.0', + }, }, }, - }, - }); + }), + ); const basicWrapper = renderWithReduxIntl(, store); expect(basicWrapper.queryByTestId('sso.webview')).toBeFalsy(); expect(basicWrapper.queryByTestId('sso.redirect_url')).toBeTruthy(); diff --git a/app/screens/thread/index.js b/app/screens/thread/index.js index d66bc88b1..83f580e21 100644 --- a/app/screens/thread/index.js +++ b/app/screens/thread/index.js @@ -4,11 +4,13 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {getTheme} from '@mm-redux/selectors/entities/preferences'; - import {selectPost} from '@mm-redux/actions/posts'; +import {setThreadFollow, updateThreadRead} from '@mm-redux/actions/threads'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/common'; import {getChannel, getMyCurrentChannelMembership} from '@mm-redux/selectors/entities/channels'; import {makeGetPostIdsForThread} from '@mm-redux/selectors/entities/posts'; +import {getTheme, isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; +import {getThread} from '@mm-redux/selectors/entities/threads'; import Thread from './thread'; @@ -16,16 +18,18 @@ function makeMapStateToProps() { const getPostIdsForThread = makeGetPostIdsForThread(); return function mapStateToProps(state, ownProps) { const channel = getChannel(state, ownProps.channelId); - + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(state); return { channelId: ownProps.channelId, + channelIsArchived: channel ? channel.delete_at !== 0 : false, channelType: channel ? channel.type : '', + collapsedThreadsEnabled, + currentUserId: getCurrentUserId(state), displayName: channel ? channel.display_name : '', myMember: getMyCurrentChannelMembership(state), - rootId: ownProps.rootId, postIds: getPostIdsForThread(state, ownProps.rootId), theme: getTheme(state), - channelIsArchived: channel ? channel.delete_at !== 0 : false, + thread: getThread(state, ownProps.rootId, true), threadLoadingStatus: state.requests.posts.getPostThread, }; }; @@ -35,6 +39,8 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ selectPost, + setThreadFollow, + updateThreadRead, }, dispatch), }; } diff --git a/app/screens/thread/thread.ios.test.js b/app/screens/thread/thread.ios.test.js index ddb5cfe87..f2f55fee1 100644 --- a/app/screens/thread/thread.ios.test.js +++ b/app/screens/thread/thread.ios.test.js @@ -16,6 +16,7 @@ describe('thread', () => { const baseProps = { actions: { selectPost: jest.fn(), + setThreadFollow: jest.fn(), }, channelId: 'channel_id', channelType: General.OPEN_CHANNEL, diff --git a/app/screens/thread/thread_base.js b/app/screens/thread/thread_base.js index ec632d985..8947bd29e 100644 --- a/app/screens/thread/thread_base.js +++ b/app/screens/thread/thread_base.js @@ -5,6 +5,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {Animated} from 'react-native'; import {intlShape} from 'react-intl'; +import {Navigation} from 'react-native-navigation'; import {General, RequestStatus} from '@mm-redux/constants'; import EventEmitter from '@mm-redux/utils/event_emitter'; @@ -18,16 +19,20 @@ export default class ThreadBase extends PureComponent { static propTypes = { actions: PropTypes.shape({ selectPost: PropTypes.func.isRequired, + setThreadFollow: PropTypes.func.isRequired, + updateThreadRead: PropTypes.func, }).isRequired, componentId: PropTypes.string, - channelId: PropTypes.string.isRequired, channelType: PropTypes.string, + collapsedThreadsEnabled: PropTypes.bool, + currentUserId: PropTypes.string, displayName: PropTypes.string, myMember: PropTypes.object.isRequired, + postIds: PropTypes.array.isRequired, rootId: PropTypes.string.isRequired, theme: PropTypes.object.isRequired, - postIds: PropTypes.array.isRequired, channelIsArchived: PropTypes.bool, + thread: PropTypes.object, threadLoadingStatus: PropTypes.object, }; @@ -42,27 +47,60 @@ export default class ThreadBase extends PureComponent { constructor(props, context) { super(props); - const {channelType, displayName} = props; + const {channelType, displayName, theme, thread} = props; const {formatMessage} = context.intl; - let title; - if (channelType === General.DM_CHANNEL) { - title = formatMessage({id: 'mobile.routes.thread_dm', defaultMessage: 'Direct Message Thread'}); + const options = {}; + + if (props.collapsedThreadsEnabled) { + let titleText; + if (channelType === General.DM_CHANNEL) { + titleText = formatMessage({id: 'mobile.routes.thread_dm', defaultMessage: 'Direct Message Thread'}); + } else { + titleText = formatMessage({id: 'mobile.routes.thread_crt', defaultMessage: 'Thread'}); + } + options.topBar = { + title: { + text: titleText, + fontSize: 18, + fontWeight: '600', + color: theme.sidebarHeaderTextColor, + }, + rightButtons: [ + { + id: '1', + component: { + id: 'ThreadFollow', + name: 'ThreadFollow', + passProps: { + active: thread?.is_following, + intl: context.intl, + theme, + onPress: this.handleThreadFollow.bind(this), + }, + }, + }, + ], + }; + if (channelType !== General.DM_CHANNEL) { + options.topBar.subtitle = { + text: formatMessage({id: 'mobile.routes.thread_crt.in', defaultMessage: 'in {channelName}'}, {channelName: displayName}), + fontSize: 13, + color: theme.sidebarHeaderTextColor, + }; + } } else { - title = formatMessage({id: 'mobile.routes.thread', defaultMessage: '{channelName} Thread'}, {channelName: displayName}); + options.topBar = { + title: { + text: formatMessage({id: 'mobile.routes.thread', defaultMessage: '{channelName} Thread'}, {channelName: displayName}), + }, + }; } - this.postDraft = React.createRef(); - - const options = { - topBar: { - title: { - text: title, - }, - }, - }; mergeNavigationOptions(props.componentId, options); + this.postDraft = React.createRef(); + this.state = { lastViewedAt: props.myMember && props.myMember.last_viewed_at, }; @@ -72,6 +110,7 @@ export default class ThreadBase extends PureComponent { } componentDidMount() { + this.markThreadRead(); this.removeTypingAnimation = this.registerTypingAnimation(this.bottomPaddingAnimation); EventEmitter.on(TYPING_VISIBLE, this.runTypingAnimations); } @@ -85,6 +124,14 @@ export default class ThreadBase extends PureComponent { if (!this.state.lastViewedAt) { this.setState({lastViewedAt: nextProps.myMember && nextProps.myMember.last_viewed_at}); } + + if (this.props.postIds.length < nextProps.postIds.length) { + this.markThreadRead(true); + } + + if (this.props.thread?.is_following !== nextProps.thread?.is_following) { + Navigation.updateProps('ThreadFollow', {active: nextProps.thread?.is_following}); + } } componentWillUnmount() { @@ -93,6 +140,30 @@ export default class ThreadBase extends PureComponent { EventEmitter.off(TYPING_VISIBLE, this.runTypingAnimations); } + handleThreadFollow() { + const {currentUserId, rootId, thread} = this.props; + this.props.actions.setThreadFollow(currentUserId, rootId, !thread?.is_following); + } + + markThreadRead(hasNewPost = false) { + if ( + this.props.collapsedThreadsEnabled && + this.props.thread && + ( + hasNewPost || + this.props.thread.last_viewed_at < this.props.thread.last_reply_at || + this.props.thread.unread_mentions || + this.props.thread.unread_replies + ) + ) { + this.props.actions.updateThreadRead( + this.props.currentUserId, + this.props.rootId, + Date.now(), + ); + } + } + close = () => { const {componentId} = this.props; popTopScreen(componentId); diff --git a/app/screens/thread/thread_follow/index.tsx b/app/screens/thread/thread_follow/index.tsx new file mode 100644 index 000000000..48da04976 --- /dev/null +++ b/app/screens/thread/thread_follow/index.tsx @@ -0,0 +1,80 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Platform, StyleSheet, Text, TouchableOpacity, View} from 'react-native'; +import {IntlShape} from 'react-intl'; + +import {Theme} from '@mm-redux/types/preferences'; +import {changeOpacity} from '@mm-redux/utils/theme_utils'; +import {preventDoubleTap} from '@utils/tap'; +import {makeStyleSheetFromTheme} from '@utils/theme'; + +type Props = { + active: boolean; + intl: typeof IntlShape; + onPress: () => void; + theme: Theme +}; + +function ThreadFollow({active, intl, onPress, theme}: Props) { + const styles = getStyleSheet(theme); + const containerStyle = [styles.container]; + if (active) { + containerStyle.push(styles.containerActive); + } + + return ( + + + + { + active ? intl.formatMessage({ + id: 'threads.following', + defaultMessage: 'Following', + }) : intl.formatMessage({ + id: 'threads.follow', + defaultMessage: 'Follow', + }) + } + + + + ); +} + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + container: { + borderColor: theme.sidebarHeaderTextColor, + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 4, + paddingVertical: 4.5, + paddingHorizontal: 10, + opacity: 0.72, + marginLeft: 12, + ...Platform.select({ + android: { + marginRight: 12, + }, + ios: { + right: -8, + }, + }), + }, + containerActive: { + backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.24), + borderColor: 'transparent', + opacity: 1, + }, + text: { + color: theme.sidebarHeaderTextColor, + fontWeight: '600', + fontSize: 12, + }, + }; +}); + +export default ThreadFollow; diff --git a/app/selectors/threads.ts b/app/selectors/threads.ts new file mode 100644 index 000000000..a835cda02 --- /dev/null +++ b/app/selectors/threads.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {GlobalState} from '@mm-redux/types/store'; + +export function getViewingGlobalThreads(state: GlobalState): boolean { + return state.views.threads.viewingGlobalThreads; +} + +export function getViewingGlobalThreadsUnread(state: GlobalState): boolean { + return state.views.threads.viewingGlobalThreadsUnreads; +} diff --git a/app/store/initial_state.js b/app/store/initial_state.js index 5f131627b..bcefe882e 100644 --- a/app/store/initial_state.js +++ b/app/store/initial_state.js @@ -50,6 +50,11 @@ const state = { selectedPostId: '', currentFocusedPostId: '', }, + threads: { + threads: {}, + threadsInTeam: {}, + counts: {}, + }, preferences: { myPreferences: {}, }, @@ -147,6 +152,10 @@ const state = { team: { lastTeamId: '', }, + threads: { + viewingGlobalThreads: false, + viewingGlobalThreadsUnreads: false, + }, thread: { drafts: {}, }, diff --git a/app/store/middlewares/helpers.js b/app/store/middlewares/helpers.js index bfc2df71e..a669e2ce1 100644 --- a/app/store/middlewares/helpers.js +++ b/app/store/middlewares/helpers.js @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {isCollapsedThreadsEnabled} from '@mm-redux/selectors/entities/preferences'; + export function getLastChannelForTeam(payload) { if (payload?.views?.team?.lastChannelForTeam) { const lastChannelForTeam = {...payload.views.team.lastChannelForTeam}; @@ -39,6 +41,11 @@ export function cleanUpState(payload, keepCurrent = false) { files: {}, fileIdsByPostId: {}, }, + threads: { + threads: {}, + threadsInTeam: {}, + counts: payload.entities?.threads?.counts, + }, general: { ...payload.entities?.general, dataRetention: { @@ -94,6 +101,13 @@ export function cleanUpState(payload, keepCurrent = false) { const postIdsToKeep = []; + // Keep the last 60 threads in each team + nextEntities.threads = { + ...nextEntities.threads, + ...cleanUpThreadsInTeam(payload.entities.threads?.threads, payload.entities.threads?.threadsInTeam, keepCurrent ? lastTeamId : ''), + }; + postIdsToKeep.push(...getAllFromThreadsInTeam(nextEntities.threads?.threadsInTeam)); + // Keep the last 60 posts in each recently viewed channel nextEntities.posts.postsInChannel = cleanUpPostsInChannel(payload.entities.posts?.postsInChannel, lastChannelForTeam, keepCurrent ? currentChannelId : ''); @@ -122,6 +136,7 @@ export function cleanUpState(payload, keepCurrent = false) { flagged: flaggedPosts, }; + const collapsedThreadsEnabled = isCollapsedThreadsEnabled(payload); if (payload.entities.posts?.posts) { const reactions = payload.entities.posts.reactions || {}; const fileIdsByPostId = payload.entities.files?.fileIdsByPostId || {}; @@ -132,10 +147,12 @@ export function cleanUpState(payload, keepCurrent = false) { const post = payload.entities.posts.posts[postId]; if (post) { - if (shouldRemovePost(globalRetentionCutoff, channelsRetentionCutoff, post)) { + // Remove non-root posts if CRT is enabled + const crtCleanup = collapsedThreadsEnabled && post.root_id; + if (shouldRemovePost(globalRetentionCutoff, channelsRetentionCutoff, post) || crtCleanup) { // This post has been removed by data retention, so don't keep it removeFromPostsInChannel(nextEntities.posts.postsInChannel, post.channel_id, postId); - + removeFromThreadsInTeam(nextEntities.threads, postId); return; } @@ -257,6 +274,34 @@ export function cleanUpPostsInChannel(postsInChannel, lastChannelForTeam, curren return nextPostsInChannel; } +export function cleanUpThreadsInTeam(threads, threadsInTeam, currentTeamId, threadsCountPerTeam = 60) { + const newThreads = {}; + const newThreadsInTeam = {}; + if (threads && threadsInTeam) { + for (const teamId of Object.keys(threadsInTeam)) { + // Convert array of thread IDS to THREADS + // Sort them based on last reply time + const mappedThreads = ( + threadsInTeam[teamId]?.map((threadId) => { + return threads[threadId]; + }) || [] + ).sort((threadA, threadB) => { + return threadB?.last_reply_at - threadA?.last_reply_at; + }); + + newThreadsInTeam[teamId] = []; + const retainedThreads = currentTeamId === teamId ? mappedThreads : mappedThreads.slice(0, threadsCountPerTeam); + retainedThreads.forEach((thread) => { + if (thread) { + newThreadsInTeam[teamId].push(thread.id); + newThreads[thread.id] = thread; + } + }); + } + } + return {threads: newThreads, threadsInTeam: newThreadsInTeam}; +} + // getAllFromPostsInChannel returns an array of all post IDs found in postsInChannel export function getAllFromPostsInChannel(postsInChannel) { const postIds = []; @@ -272,6 +317,16 @@ export function getAllFromPostsInChannel(postsInChannel) { return postIds; } +export function getAllFromThreadsInTeam(threadsInTeam) { + const postIds = []; + if (threadsInTeam) { + for (const teamId of Object.keys(threadsInTeam)) { + postIds.push(...threadsInTeam[teamId]); + } + } + return postIds; +} + // Returns cutoff time for the policy function getRetentionCutoffFromPolicy(policy) { const periodDate = new Date(); @@ -303,6 +358,15 @@ function removeFromPostsInChannel(postsInChannel, channelId, postId) { } } +function removeFromThreadsInTeam({threads, threadsInTeam}, postId) { + if (threads[postId]) { + Reflect.deleteProperty(threads, postId); + for (const teamId of Object.keys(threadsInTeam)) { + threadsInTeam[teamId].filter((threadId) => threadId !== postId); + } + } +} + function removePendingPost(pendingPostIds, id) { const pendingIndex = pendingPostIds.indexOf(id); if (pendingIndex !== -1) { diff --git a/app/store/middlewares/middleware.test.js b/app/store/middlewares/middleware.test.js index ee14058bb..e109a3942 100644 --- a/app/store/middlewares/middleware.test.js +++ b/app/store/middlewares/middleware.test.js @@ -14,6 +14,7 @@ import initialState from '@store/initial_state'; import { cleanUpPostsInChannel, cleanUpState, + cleanUpThreadsInTeam, getAllFromPostsInChannel, } from './helpers'; import messageRetention from './message_retention'; @@ -38,6 +39,12 @@ describe('messageRetention', () => { }, posts: { }, + general: { + config: {}, + }, + preferences: { + myPreferences: {}, + }, }, views: { team: { @@ -86,7 +93,13 @@ describe('messageRetention', () => { }; const entities = { channels: {}, + general: { + config: {}, + }, posts: {}, + preferences: { + myPreferences: {}, + }, }; const views = { team: { @@ -189,6 +202,7 @@ describe('cleanUpState', () => { fileIdsByPostId: {}, }, general: { + config: {}, dataRetention: { policies: { global: { @@ -211,6 +225,9 @@ describe('cleanUpState', () => { postsInThread: {}, reactions: {}, }, + preferences: { + myPreferences: {}, + }, search: { results: ['post1', 'post2'], flagged: ['post1', 'post2', 'post3'], @@ -359,6 +376,9 @@ describe('cleanUpState', () => { files: { fileIdsByPostId: {}, }, + general: { + config: {}, + }, posts: { pendingPostIds: ['pending'], posts: { @@ -374,6 +394,9 @@ describe('cleanUpState', () => { postsInThread: {}, reactions: {}, }, + preferences: { + myPreferences: {}, + }, }, views: { team: { @@ -400,6 +423,9 @@ describe('cleanUpState', () => { files: { fileIdsByPostId: {}, }, + general: { + config: {}, + }, posts: { pendingPostIds: ['pending'], posts: { @@ -415,6 +441,9 @@ describe('cleanUpState', () => { postsInThread: {}, reactions: {}, }, + preferences: { + myPreferences: {}, + }, }, views: { team: { @@ -441,6 +470,9 @@ describe('cleanUpState', () => { files: { fileIdsByPostId: {}, }, + general: { + config: {}, + }, posts: { pendingPostIds: ['pending'], posts: { @@ -455,6 +487,9 @@ describe('cleanUpState', () => { postsInThread: {}, reactions: {}, }, + preferences: { + myPreferences: {}, + }, }, views: { team: { @@ -687,6 +722,41 @@ describe('cleanUpPostsInChannel', () => { }); }); +describe.only('cleanUpThreadsInTeam', () => { + const threadsInTeam = { + team1: ['thread1', 'thread2', 'thread3'], + team2: ['thread3', 'thread4', 'thread5'], + team3: ['thread1', 'thread2', 'thread3', 'thread4'], + }; + const threads = { + thread1: {id: 'thread1', last_reply_at: 100}, + thread2: {id: 'thread2', last_reply_at: 99}, + thread3: {id: 'thread3', last_reply_at: 98}, + thread4: {id: 'thread4', last_reply_at: 97}, + thread5: {id: 'thread5', last_reply_at: 96}, + thread6: {id: 'thread6', last_reply_at: 95}, + }; + + const {threads: nextThreads, threadsInTeam: nextThreadsInTeam} = cleanUpThreadsInTeam(threads, threadsInTeam, 'team3', 2); + + test('should only keep limited threads per team', () => { + expect(nextThreadsInTeam.team1).toEqual(['thread1', 'thread2']); + expect(nextThreadsInTeam.team2).toEqual(['thread3', 'thread4']); + expect(nextThreads.thread1).toBeTruthy(); + expect(nextThreads.thread5).toBeFalsy(); + }); + + test('should not remove the thread if included in one team and not included in another', () => { + expect(nextThreadsInTeam.team1.indexOf('thread3')).toBe(-1); + expect(nextThreadsInTeam.team2.indexOf('thread3')).toBeGreaterThan(-1); + expect(nextThreads.thread3).toBeTruthy(); + }); + + test('Should exclude passed teamId', () => { + expect(nextThreadsInTeam.team3).toEqual(threadsInTeam.team3); + }); +}); + describe('getAllFromPostsInChannel', () => { test('should return every post ID', () => { const postsInChannel = { diff --git a/app/utils/datetime.ts b/app/utils/datetime.ts new file mode 100644 index 000000000..6773718b2 --- /dev/null +++ b/app/utils/datetime.ts @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export function isSameDay(a: Date, b: Date = new Date()): boolean { + return a.getDate() === b.getDate() && isSameMonth(a, b); +} + +export function isSameMonth(a: Date, b: Date = new Date()): boolean { + return a.getMonth() === b.getMonth() && isSameYear(a, b); +} + +export function isSameYear(a: Date, b: Date = new Date()): boolean { + return a.getFullYear() === b.getFullYear(); +} +export function isYesterday(date: Date): boolean { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + return isSameDay(date, yesterday); +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 49191c366..ed21daa88 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -161,11 +161,28 @@ "emoji_skin.medium_light_skin_tone": "medium light skin tone", "emoji_skin.medium_skin_tone": "medium skin tone", "file_upload.fileAbove": "Files must be less than {max}", + "friendly_date.daysAgo": "{count} {count, plural, one {day} other {days}} ago", + "friendly_date.hoursAgo": "{count} {count, plural, one {hour} other {hours}} ago", + "friendly_date.minsAgo": "{count} {count, plural, one {min} other {mins}} ago", + "friendly_date.monthsAgo": "{count} {count, plural, one {month} other {months}} ago", + "friendly_date.now": "Now", + "friendly_date.yearsAgo": "{count} {count, plural, one {year} other {years}} ago", + "friendly_date.yesterday": "Yesterday", "gallery.download_file": "Download file", "gallery.footer.channel_name": "Shared in {channelName}", "gallery.open_file": "Open file", "gallery.unsuppored": "Preview is not supported for this file type", "get_post_link_modal.title": "Copy Link", + "global_threads.allThreads": "All Your Threads", + "global_threads.emptyThreads.message": "Any threads you are mentioned in or have participated in will show here along with any threads you have followed.", + "global_threads.emptyThreads.title": "No followed threads yet", + "global_threads.emptyUnreads.message": "Looks like you're all caught up.", + "global_threads.emptyUnreads.title": "No unread threads", + "global_threads.markAllRead.cancel": "Cancel", + "global_threads.markAllRead.markRead": "Mark read", + "global_threads.markAllRead.message": "This will clear any unread status for all of your threads shown here", + "global_threads.markAllRead.title": "Are you sure you want to mark all threads as read?", + "global_threads.unreads": "Unreads", "integrations.add": "Add", "intro_messages.anyMember": " Any member can join and read this channel.", "intro_messages.beginning": "Beginning of {name}", @@ -424,6 +441,7 @@ "mobile.open_dm.error": "We couldn't open a direct message with {displayName}. Please check your connection and try again.", "mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.", "mobile.open_unknown_channel.error": "Unable to join the channel. Please reset the cache and try again.", + "mobile.participants.header": "THREAD PARTICIPANTS", "mobile.permission_denied_dismiss": "Don't Allow", "mobile.permission_denied_retry": "Settings", "mobile.pinned_posts.empty_description": "Pin important messages which are visible to the whole channel. Long press on a message and choose Pin to Channel to save it here.", @@ -504,6 +522,8 @@ "mobile.routes.sso": "Single Sign-On", "mobile.routes.table": "Table", "mobile.routes.thread": "{channelName} Thread", + "mobile.routes.thread_crt": "Thread", + "mobile.routes.thread_crt.in": "in {channelName}", "mobile.routes.thread_dm": "Direct Message Thread", "mobile.routes.user_profile": "Profile", "mobile.routes.user_profile.edit": "Edit", @@ -671,6 +691,14 @@ "suggestion.search.public": "Public Channels", "terms_of_service.agreeButton": "I Agree", "terms_of_service.api_error": "Unable to complete the request. If this issue persists, contact your System Administrator.", + "threads": "Threads", + "threads.deleted": "Original Message Deleted", + "threads.follow": "Follow", + "threads.following": "Following", + "threads.followThread": "Follow Thread", + "threads.newReplies": "{count} new {count, plural, one {reply} other {replies}}", + "threads.replies": "{count} {count, plural, one {reply} other {replies}}", + "threads.unfollowThread": "Unfollow Thread", "user.settings.display.clockDisplay": "Clock Display", "user.settings.display.custom_theme": "Custom Theme", "user.settings.display.militaryClock": "24-hour clock (example: 16:00)", diff --git a/package-lock.json b/package-lock.json index 830538ccc..3c08e37b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,9 @@ "@react-navigation/stack": "5.14.5", "@rudderstack/rudder-sdk-react-native": "1.0.12", "@sentry/react-native": "2.6.0", + "@types/redux-mock-store": "1.0.3", "analytics-react-native": "1.2.0", + "array.prototype.flat": "1.2.4", "base-64": "1.0.0", "commonmark": "0.30.0", "commonmark-react-renderer": "4.3.5", @@ -8775,6 +8777,14 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz", "integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==" }, + "node_modules/@types/redux-mock-store": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.3.tgz", + "integrity": "sha512-Wqe3tJa6x9MxMN4DJnMfZoBRBRak1XTPklqj4qkVm5VBpZnC8PSADf4kLuFQ9NAdHaowfWoEeUMz7NWc2GMtnA==", + "dependencies": { + "redux": "^4.0.5" + } + }, "node_modules/@types/responselike": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", @@ -10250,7 +10260,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz", "integrity": "sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==", - "dev": true, "dependencies": { "call-bind": "^1.0.0", "define-properties": "^1.1.3", @@ -45733,6 +45742,14 @@ "@types/react": "*" } }, + "@types/redux-mock-store": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.3.tgz", + "integrity": "sha512-Wqe3tJa6x9MxMN4DJnMfZoBRBRak1XTPklqj4qkVm5VBpZnC8PSADf4kLuFQ9NAdHaowfWoEeUMz7NWc2GMtnA==", + "requires": { + "redux": "^4.0.5" + } + }, "@types/responselike": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz", @@ -46933,7 +46950,6 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz", "integrity": "sha512-4470Xi3GAPAjZqFcljX2xzckv1qeKPizoNkiS0+O4IoPR2ZNpcjE0pkhdihlDouK+x6QOast26B4Q/O9DJnwSg==", - "dev": true, "requires": { "call-bind": "^1.0.0", "define-properties": "^1.1.3", diff --git a/package.json b/package.json index 0cf5e163e..266b6dbfb 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "@react-navigation/stack": "5.14.5", "@rudderstack/rudder-sdk-react-native": "1.0.12", "@sentry/react-native": "2.6.0", + "@types/redux-mock-store": "1.0.3", "analytics-react-native": "1.2.0", + "array.prototype.flat": "1.2.4", "base-64": "1.0.0", "commonmark": "0.30.0", "commonmark-react-renderer": "4.3.5",