Feat crt (#5455)
* [MM-31133]: participants screen component (#5116) * Fixed imports * Misc fixes * Added i18n string * Moved '(you)' after the name * [MM-30386][MM-30387] Client and redux changes for collapsed reply threads (#5175) * [MM-30386][MM-30387] Client and redux changes for collapsed reply threads * fix tests * add and use flat shim * use test helper function * use test helper function * revert crt changes to unused client and action functions * use toEqual * incorporate code review feedback * don't assume server success * batch actions * clearer code * WIP in changes, websockets and more redux changes * Added mark thread as read and mark all as read in a team * Added isCollapsedThreadEnabled entity * Fixed loading global screens on load if it's the last visited screen * Added root count and websocker and redux events related to threads * Fixed Message, Mention count root count on init and decrement actions * Moved old client to new client/rest * Misc fix * Misc fix * Misc. * Old redux test cases fix * Added polyfill back for array flat * Fixes mark as unread and pariticipants error in thread * Fixed initial load when CRT not enabled * Handling CRT preference change * Get threads fallsback to search in posts in case of unfollowed or unloaded threads * Gets threads on reconnect * DM, Channel counter fix * Added threads redux tests * Fixed channel dot issue when child post is arrived channel not active * Cleaning up threads on load, not letting posts included in threads getting deleted which can trigger many posts to be loaded at once * Fixed crash on team switch, partly fixed enable/disable from webapp * Fix when viewing a post from notification, to prevent from navigating back to global threads * Fixed when preferences are changed before app is launched * Fixed issue of NO THREADS appearing on reconnect * Excludes CRT handling for newly logged in users * Fixed unread dot showing even when all messages are read * Update app/mm-redux/selectors/entities/threads.ts Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * Not viewing threads action is pushed only when suer is in Global Threads * Fixed cleanup to exclude current team when cleanup starts on app going to background * Dispatch is not passed as param, misc code fixes * Preferences check and handling code refactored * added redux-mock-store * Preference change fix * Removed user login dispatch when handling crt change * Fixed fallback thread support * Return getThread * Added threads to sidebar, moved teamId logic to action * [MM-29106, MM-34847, MM-34843, MM-34846] Collapsed reply threads, Global Threads & In Channel experience UI implementation (#5376) * feat: global threads screen * Refactored global thread menu and misc design fixes * Added empty states for threads, unread. Removing markdown from thread messages. * Added friendly date, fixed long usernames & channel names in threads view. * Mark thread as read * Added intl, refactored code, seperated thread footer from thread item * Added intl to global threads, added global threads to android * Added Follow button to thread view * Added thread badges * In Channel experience .. WIP * Clean posts in channel incase CRT is enabled. * Added follow button, deleted messages doesnot have footer and design is now according to figma * Removed old post and post header * Added for follow/unfollow thread and fixed old one * MISC design & code fixes * Added options-follow/unfollow to the post's long press menu * Shows ORIGINAL MESSAGE DELETED in thread when root post gets deleted * Post list ts fix * Old testcases fix * Eslint fix * Added custom status emoji in at mention autocomplete * Fixed the width of the displayname and username * Changed the max width for the full name and username in the autocomplete item * Review fixes Changed the behaviour of ellipses for long names * Removed safearea bottom * Fixes Mark and unread and thread header theme * Fixes mark as unread and pariticipants error in thread * Unfollowing post fix and misc * Merge with master fix * Added fallback to get thread from posts in case of not following/unloaded threads * Refactored code and added tests for screens * Loads threads from global threads screen, marks thread as marked on new thread posts * Fixed tabs render on no threads, threads footer items styling and tests * Loads threads based on tab and items according to constant * Added infinite loading, unreads based on redux and not active thread list * Fixed the username cutting off after truncating display name issue * Changed avatars to touchableOpacity and added prop for parent component to style * Fixed infinite loading, tab switching scroll to top, misc styles, team switching, scroll indicator insets * Added follow button to DMs, moved follow button further right only on ios * Snapshot updated * Moved from useSelector to connect HOC * Scrolls to top on team change, changed thread constant from mm-redux * Fix merge issue * Test case fix * Fixed participants list * Update app/components/global_threads/global_threads.tsx Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * Update app/components/global_threads/thread_footer/thread_footer.tsx Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * Update app/components/sidebars/main/channels_list/channel_item/index.js Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * Update app/screens/thread/index.js Co-authored-by: Elias Nahum <nahumhbl@gmail.com> * Moved date numbers to constants * Fixed global threads component issues * Thread item display logic moved to redux * Misc fixes * Reverted to TouchableWithFeedback with opacity * Removed obselete snapshots * Removing currentTeamId as we aregetting it in action * Moved to connect component from useSelect for sidebar * Thread read issue fix * Removed react-native-navigation mock Co-authored-by: Anurag Shivarathri <anurag6713@gmail.com> Co-authored-by: Mattermod <mattermod@users.noreply.github.com> Co-authored-by: Manoj <manoj@brightscout.com> Co-authored-by: Elias Nahum <nahumhbl@gmail.com> Co-authored-by: Anurag Shivarathri <anurag6713@gmail.com> Co-authored-by: Mattermod <mattermod@users.noreply.github.com> Co-authored-by: Elias Nahum <nahumhbl@gmail.com> Co-authored-by: Manoj <manoj@brightscout.com> Co-authored-by: Ashish Bhate <bhate.ashish@gmail.com> Co-authored-by: Ashish Bhate <ashish.bhate@mattermost.com> Co-authored-by: Mattermod <mattermod@users.noreply.github.com> Co-authored-by: Elias Nahum <nahumhbl@gmail.com> Co-authored-by: Manoj <manoj@brightscout.com>
This commit is contained in:
parent
1be1278087
commit
fedf636b2f
122 changed files with 5818 additions and 306 deletions
|
|
@ -120,7 +120,7 @@ export async function fetchMyChannelMember(channelId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function markChannelAsUnread(state: GlobalState, teamId: string, channelId: string, mentions: Array<string>): Array<GenericAction> {
|
||||
export function markChannelAsUnread(state: GlobalState, teamId: string, channelId: string, mentions: Array<string>, isRoot = false): Array<GenericAction> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
31
app/actions/views/crt.ts
Normal file
31
app/actions/views/crt.ts
Normal file
|
|
@ -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<ActionResult> => {
|
||||
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};
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
21
app/actions/views/threads.ts
Normal file
21
app/actions/views/threads.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ActionResult> => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
61
app/actions/websocket/threads.ts
Normal file
61
app/actions/websocket/threads.ts
Normal file
|
|
@ -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<ActionResult> => {
|
||||
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};
|
||||
};
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)},
|
||||
|
|
|
|||
|
|
@ -14,11 +14,16 @@ export interface ClientPostsMix {
|
|||
getPost: (postId: string) => Promise<Post>;
|
||||
patchPost: (postPatch: Partial<Post> & {id: string}) => Promise<Post>;
|
||||
deletePost: (postId: string) => Promise<any>;
|
||||
getPostThread: (postId: string) => Promise<any>;
|
||||
getPosts: (channelId: string, page?: number, perPage?: number) => Promise<any>;
|
||||
getPostsSince: (channelId: string, since: number) => Promise<any>;
|
||||
getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<any>;
|
||||
getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number) => Promise<any>;
|
||||
getPostThread: (postId: string, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise<any>;
|
||||
getPosts: (channelId: string, page?: number, perPage?: number, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise<any>;
|
||||
getPostsSince: (channelId: string, since: number, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise<any>;
|
||||
getPostsBefore: (channelId: string, postId: string, page?: number, perPage?: number, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise<any>;
|
||||
getPostsAfter: (channelId: string, postId: string, page?: number, perPage?: number, fetchThreads?: boolean, collapsedThreads?: boolean, collapsedThreadsExtended?: boolean) => Promise<any>;
|
||||
getUserThreads: (userId: string, teamId: string, before?: string, after?: string, pageSize?: number, extended?: boolean, deleted?: boolean, unread?: boolean, since?: number) => Promise<any>;
|
||||
getUserThread: (userId: string, teamId: string, threadId: string, extended?: boolean) => Promise<any>;
|
||||
updateThreadsReadForUser: (userId: string, teamId: string) => Promise<any>;
|
||||
updateThreadReadForUser: (userId: string, teamId: string, threadId: string, timestamp: number) => Promise<any>;
|
||||
updateThreadFollowForUser: (userId: string, teamId: string, threadId: string, state: boolean) => Promise<any>;
|
||||
getFileInfosForPost: (postId: string) => Promise<FileInfo[]>;
|
||||
getFlaggedPosts: (userId: string, channelId?: string, teamId?: string, page?: number, perPage?: number) => Promise<any>;
|
||||
getPinnedPosts: (channelId: string) => Promise<any>;
|
||||
|
|
@ -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})},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
exports[`Avatars should match snapshot for overflow 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
type="native"
|
||||
onPress={[Function]}
|
||||
type="opacity"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
|
|
@ -197,6 +198,7 @@ exports[`Avatars should match snapshot for overflow 1`] = `
|
|||
Object {
|
||||
"color": "rgba(61,60,64,0.64)",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "bold",
|
||||
"textAlign": "center",
|
||||
}
|
||||
}
|
||||
|
|
@ -211,7 +213,8 @@ exports[`Avatars should match snapshot for overflow 1`] = `
|
|||
|
||||
exports[`Avatars should match snapshot for single avatar 1`] = `
|
||||
<TouchableWithFeedbackIOS
|
||||
type="native"
|
||||
onPress={[Function]}
|
||||
type="opacity"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
import React, {PureComponent} from 'react';
|
||||
import {Platform, Text, View} from 'react-native';
|
||||
import {Platform, StyleProp, Text, View, ViewStyle} from 'react-native';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import ProfilePicture from '@components/profile_picture';
|
||||
import {ViewTypes} from '@constants';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import type {Theme} from '@mm-redux/types/preferences';
|
||||
import {showModalOverCurrentContext} from '@actions/navigation';
|
||||
import {ViewTypes} from '@constants';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
|
|
@ -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<ViewStyle>;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
|
|
@ -89,15 +91,30 @@ export default class Avatars extends PureComponent<AvatarsProps> {
|
|||
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 (
|
||||
<TouchableWithFeedback>
|
||||
<TouchableWithFeedback
|
||||
onPress={this.showParticipantsList}
|
||||
style={baseContainerStyle}
|
||||
type={'opacity'}
|
||||
>
|
||||
<View style={style.container}>
|
||||
{displayUserIds.map((userId: string, i: number) => (
|
||||
<View
|
||||
|
|
|
|||
81
app/components/friendly_date/friendly_date.test.tsx
Normal file
81
app/components/friendly_date/friendly_date.test.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {renderWithIntl} from 'test/testing_library';
|
||||
|
||||
import FriendlyDate from './index';
|
||||
|
||||
describe('Friendly Date', () => {
|
||||
it('should render correctly', () => {
|
||||
const justNow = new Date();
|
||||
justNow.setSeconds(justNow.getSeconds() - 10);
|
||||
const justNowText = renderWithIntl(
|
||||
<FriendlyDate value={justNow}/>,
|
||||
);
|
||||
expect(justNowText.getByText('Now')).toBeTruthy();
|
||||
|
||||
const minutesAgo = new Date();
|
||||
minutesAgo.setMinutes(minutesAgo.getMinutes() - 1);
|
||||
const minutesAgoText = renderWithIntl(
|
||||
<FriendlyDate value={minutesAgo}/>,
|
||||
);
|
||||
expect(minutesAgoText.getByText('1 min ago')).toBeTruthy();
|
||||
|
||||
const hoursAgo = new Date();
|
||||
hoursAgo.setHours(hoursAgo.getHours() - 4);
|
||||
const hoursAgoText = renderWithIntl(
|
||||
<FriendlyDate value={hoursAgo}/>,
|
||||
);
|
||||
expect(hoursAgoText.getByText('4 hours ago')).toBeTruthy();
|
||||
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const yesterdayText = renderWithIntl(
|
||||
<FriendlyDate value={yesterday}/>,
|
||||
);
|
||||
expect(yesterdayText.getByText('Yesterday')).toBeTruthy();
|
||||
|
||||
const daysAgo = new Date();
|
||||
daysAgo.setDate(daysAgo.getDate() - 10);
|
||||
const daysAgoText = renderWithIntl(
|
||||
<FriendlyDate value={daysAgo}/>,
|
||||
);
|
||||
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(
|
||||
<FriendlyDate
|
||||
sourceDate={daysEdgeCaseTodayDate}
|
||||
value={daysEdgeCase}
|
||||
/>,
|
||||
);
|
||||
expect(daysEdgeCaseText.getByText('1 month ago')).toBeTruthy();
|
||||
|
||||
const daysAgoMax = new Date('2020/03/06');
|
||||
const daysAgoMaxTodayDate = new Date('2020/04/05');
|
||||
const daysAgoMaxText = renderWithIntl(
|
||||
<FriendlyDate
|
||||
sourceDate={daysAgoMaxTodayDate}
|
||||
value={daysAgoMax}
|
||||
/>,
|
||||
);
|
||||
expect(daysAgoMaxText.getByText('30 days ago')).toBeTruthy();
|
||||
|
||||
const monthsAgo = new Date();
|
||||
monthsAgo.setMonth(monthsAgo.getMonth() - 2);
|
||||
const monthsAgoText = renderWithIntl(
|
||||
<FriendlyDate value={monthsAgo}/>,
|
||||
);
|
||||
expect(monthsAgoText.getByText('2 months ago')).toBeTruthy();
|
||||
|
||||
const yearsAgo = new Date();
|
||||
yearsAgo.setFullYear(yearsAgo.getFullYear() - 2);
|
||||
const yearsAgoText = renderWithIntl(
|
||||
<FriendlyDate value={yearsAgo}/>,
|
||||
);
|
||||
expect(yearsAgoText.getByText('2 years ago')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
106
app/components/friendly_date/index.tsx
Normal file
106
app/components/friendly_date/index.tsx
Normal file
|
|
@ -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<ViewStyle>;
|
||||
sourceDate?: number | Date;
|
||||
value: number | Date;
|
||||
};
|
||||
|
||||
function FriendlyDate({intl, style, sourceDate, value}: Props) {
|
||||
const formattedTime = getFriendlyDate(intl, value, sourceDate);
|
||||
return (
|
||||
<Text style={style}>{formattedTime}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
251
app/components/global_threads/empty_state.tsx
Normal file
251
app/components/global_threads/empty_state.tsx
Normal file
|
|
@ -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 (
|
||||
<View style={style.container}>
|
||||
<Svg
|
||||
width='234'
|
||||
height='156'
|
||||
viewBox='0 0 234 156'
|
||||
fill='none'
|
||||
>
|
||||
<G clipPath='url(#clip0)'>
|
||||
<Ellipse
|
||||
cx='117'
|
||||
cy='149.878'
|
||||
rx='117'
|
||||
ry='4.03395'
|
||||
fill={theme.centerChannelColor}
|
||||
fillOpacity='0.08'
|
||||
/>
|
||||
<Path
|
||||
d='M129.797 150.773C139.288 150.773 146.981 149.998 146.981 149.042C146.981 148.086 139.288 147.311 129.797 147.311C120.306 147.311 112.613 148.086 112.613 149.042C112.613 149.998 120.306 150.773 129.797 150.773Z'
|
||||
fill={theme.centerChannelColor}
|
||||
fillOpacity='0.16'
|
||||
/>
|
||||
<Path
|
||||
d='M100.181 150.978C115.528 150.978 127.969 150.157 127.969 149.144C127.969 148.131 115.528 147.311 100.181 147.311C84.8347 147.311 72.3938 148.131 72.3938 149.144C72.3938 150.157 84.8347 150.978 100.181 150.978Z'
|
||||
fill={theme.centerChannelColor}
|
||||
fillOpacity='0.16'
|
||||
/>
|
||||
<Path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M119.084 132.652L117.389 134.816L119.248 133.584C119.327 134.03 119.391 134.478 119.441 134.928C122.973 132.109 127.104 130.578 127.104 130.578C127.104 130.578 126.988 131.683 126.67 133.319L125.407 133.774L126.579 133.775C126.31 135.095 125.974 136.398 125.572 137.676L123.106 138.564L125.275 138.567C124.664 140.406 123.8 142.13 122.714 143.681C119.199 148.463 114.931 151.075 113.182 149.515C113.045 149.393 112.928 149.25 112.83 149.086C111.408 147.383 110.77 143.068 111.382 138.19C111.46 137.585 111.572 136.987 111.718 136.398L113.48 138.465L112.072 135.142C113.653 130.138 117.064 125.704 117.064 125.704C117.064 125.704 117.484 126.716 117.979 128.297L117.112 129.405L118.115 128.74C118.505 130.023 118.828 131.329 119.084 132.652Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M119.084 132.652L117.389 134.816L119.248 133.584C119.327 134.03 119.391 134.478 119.441 134.928C122.973 132.109 127.104 130.578 127.104 130.578C127.104 130.578 126.988 131.683 126.67 133.319L125.407 133.774L126.579 133.775C126.31 135.095 125.974 136.398 125.572 137.676L123.106 138.564L125.275 138.567C124.664 140.406 123.8 142.13 122.714 143.681C119.199 148.463 114.931 151.075 113.182 149.515C113.045 149.393 112.928 149.25 112.83 149.086C111.408 147.383 110.77 143.068 111.382 138.19C111.46 137.585 111.572 136.987 111.718 136.398L113.48 138.465L112.072 135.142C113.653 130.138 117.064 125.704 117.064 125.704C117.064 125.704 117.484 126.716 117.979 128.297L117.112 129.405L118.115 128.74C118.505 130.023 118.828 131.329 119.084 132.652Z'
|
||||
fill={theme.centerChannelColor}
|
||||
fillOpacity='0.32'
|
||||
/>
|
||||
<Path
|
||||
d='M154.294 150.244C158.736 150.244 162.338 149.752 162.338 149.144C162.338 148.537 158.736 148.044 154.294 148.044C149.851 148.044 146.25 148.537 146.25 149.144C146.25 149.752 149.851 150.244 154.294 150.244Z'
|
||||
fill={theme.centerChannelColor}
|
||||
fillOpacity='0.16'
|
||||
/>
|
||||
<Path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M144.336 140.849L144.001 142.065L144.555 141.195C145.007 141.908 145.357 142.693 145.593 143.522C146.125 142.017 146.87 140.613 147.8 139.363C147.8 139.363 147.986 139.814 148.207 140.517L147.821 141.01L148.267 140.714C148.44 141.284 148.584 141.865 148.698 142.453L147.944 143.416L148.771 142.868C148.807 143.071 148.836 143.276 148.858 143.481C149.906 142.661 151.052 142.005 152.264 141.531C152.264 141.531 152.213 142.023 152.072 142.75L151.51 142.952L152.031 142.953C151.912 143.54 151.762 144.119 151.583 144.688L150.486 145.083L151.451 145.084C151.179 145.902 150.795 146.669 150.312 147.359C148.749 149.486 146.851 150.647 146.073 149.953C146.012 149.899 145.959 149.835 145.916 149.762C145.704 149.508 145.532 149.125 145.406 148.646C145.312 148.738 145.208 148.805 145.092 148.841C145.017 148.865 144.94 148.875 144.86 148.873C143.934 149.009 142.344 148.164 140.887 146.674C140.707 146.489 140.538 146.291 140.38 146.083L141.514 145.903L140.055 145.62C139.23 144.268 138.606 142.779 138.206 141.21C138.206 141.21 138.648 141.299 139.298 141.504L139.443 142.135L139.479 141.562C140.002 141.733 140.517 141.936 141.02 142.171L141.303 143.403L141.37 142.343C141.542 142.427 141.71 142.518 141.876 142.616C141.867 141.201 142.036 139.79 142.379 138.424C142.379 138.424 142.715 138.752 143.175 139.297L143.004 139.92L143.303 139.45C143.671 139.894 144.016 140.362 144.336 140.849ZM145.825 144.593C145.775 144.301 145.712 144.002 145.637 143.699L146.206 145.039L145.825 144.593Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M144.336 140.849L144.001 142.065L144.555 141.195C145.007 141.908 145.357 142.693 145.593 143.522C146.125 142.017 146.87 140.613 147.8 139.363C147.8 139.363 147.986 139.814 148.207 140.517L147.821 141.01L148.267 140.714C148.44 141.284 148.584 141.865 148.698 142.453L147.944 143.416L148.771 142.868C148.807 143.071 148.836 143.276 148.858 143.481C149.906 142.661 151.052 142.005 152.264 141.531C152.264 141.531 152.213 142.023 152.072 142.75L151.51 142.952L152.031 142.953C151.912 143.54 151.762 144.119 151.583 144.688L150.486 145.083L151.451 145.084C151.179 145.902 150.795 146.669 150.312 147.359C148.749 149.486 146.851 150.647 146.073 149.953C146.012 149.899 145.959 149.835 145.916 149.762C145.704 149.508 145.532 149.125 145.406 148.646C145.312 148.738 145.208 148.805 145.092 148.841C145.017 148.865 144.94 148.875 144.86 148.873C143.934 149.009 142.344 148.164 140.887 146.674C140.707 146.489 140.538 146.291 140.38 146.083L141.514 145.903L140.055 145.62C139.23 144.268 138.606 142.779 138.206 141.21C138.206 141.21 138.648 141.299 139.298 141.504L139.443 142.135L139.479 141.562C140.002 141.733 140.517 141.936 141.02 142.171L141.303 143.403L141.37 142.343C141.542 142.427 141.71 142.518 141.876 142.616C141.867 141.201 142.036 139.79 142.379 138.424C142.379 138.424 142.715 138.752 143.175 139.297L143.004 139.92L143.303 139.45C143.671 139.894 144.016 140.362 144.336 140.849ZM145.825 144.593C145.775 144.301 145.712 144.002 145.637 143.699L146.206 145.039L145.825 144.593Z'
|
||||
fill={theme.centerChannelColor}
|
||||
fillOpacity='0.32'
|
||||
/>
|
||||
<Path
|
||||
d='M148.444 3.62159C148.444 1.96473 147.101 0.621582 145.444 0.621582H49.8C48.1432 0.621582 46.8 1.96473 46.8 3.62158V59.7488C46.8 61.4057 48.1432 62.7488 49.8 62.7488H59.7108V79.8337L73.6195 62.7488H145.444C147.101 62.7488 148.444 61.4057 148.444 59.7488V3.62159Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
opacity='0.24'
|
||||
d='M148.444 3.62159C148.444 1.96473 147.101 0.621582 145.444 0.621582H49.8C48.1432 0.621582 46.8 1.96473 46.8 3.62158V59.7488C46.8 61.4057 48.1432 62.7488 49.8 62.7488H59.7108V79.8337L73.6195 62.7488H145.444C147.101 62.7488 148.444 61.4057 148.444 59.7488V3.62159Z'
|
||||
fill={theme.buttonBg}
|
||||
/>
|
||||
<Path
|
||||
d='M130.095 18.8716H65.1496C64.2204 18.8716 63.467 19.6249 63.467 20.5542C63.467 21.4835 64.2204 22.2368 65.1496 22.2368H130.095C131.024 22.2368 131.777 21.4835 131.777 20.5542C131.777 19.6249 131.024 18.8716 130.095 18.8716Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
d='M130.095 30.0029H65.1496C64.2204 30.0029 63.467 30.7563 63.467 31.6855C63.467 32.6148 64.2204 33.3681 65.1496 33.3681H130.095C131.024 33.3681 131.777 32.6148 131.777 31.6855C131.777 30.7563 131.024 30.0029 130.095 30.0029Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
d='M130.095 41.1338H65.1496C64.2204 41.1338 63.467 41.8871 63.467 42.8164C63.467 43.7457 64.2204 44.499 65.1496 44.499H130.095C131.024 44.499 131.777 43.7457 131.777 42.8164C131.777 41.8871 131.024 41.1338 130.095 41.1338Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
d='M108.956 56.4297C108.956 54.7728 110.299 53.4297 111.956 53.4297H187.856C189.513 53.4297 190.856 54.7728 190.856 56.4297V100.477C190.856 102.134 189.513 103.477 187.856 103.477H180.453V117.239L169.246 103.477H111.956C110.299 103.477 108.956 102.134 108.956 100.477V56.4297Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
opacity='0.48'
|
||||
d='M108.956 56.4297C108.956 54.7728 110.299 53.4297 111.956 53.4297H187.856C189.513 53.4297 190.856 54.7728 190.856 56.4297V100.477C190.856 102.134 189.513 103.477 187.856 103.477H180.453V117.239L169.246 103.477H111.956C110.299 103.477 108.956 102.134 108.956 100.477V56.4297Z'
|
||||
fill={theme.buttonBg}
|
||||
/>
|
||||
<Path
|
||||
d='M123.741 68.1309H176.071C176.82 68.1309 177.427 68.7377 177.427 69.4863C177.427 70.2349 176.82 70.8417 176.071 70.8417H123.741C122.992 70.8417 122.385 70.2349 122.385 69.4863C122.385 68.7377 122.992 68.1309 123.741 68.1309Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
d='M123.741 77.0977H176.071C176.82 77.0977 177.427 77.7045 177.427 78.4531C177.427 79.2017 176.82 79.8085 176.071 79.8085H123.741C122.992 79.8085 122.385 79.2017 122.385 78.4531C122.385 77.7045 122.992 77.0977 123.741 77.0977Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
d='M123.741 86.0645H176.071C176.82 86.0645 177.427 86.6713 177.427 87.4199C177.427 88.1685 176.82 88.7753 176.071 88.7753H123.741C122.992 88.7753 122.385 88.1685 122.385 87.4199C122.385 86.6713 122.992 86.0645 123.741 86.0645Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
d='M87.7324 149.412C88.8312 149.412 89.7221 148.238 89.7221 146.79C89.7221 145.341 88.8312 144.167 87.7324 144.167C86.6335 144.167 85.7427 145.341 85.7427 146.79C85.7427 148.238 86.6335 149.412 87.7324 149.412Z'
|
||||
fill={theme.centerChannelColor}
|
||||
/>
|
||||
<Path
|
||||
d='M87.7324 146.208C88.8312 146.208 89.7221 145.034 89.7221 143.586C89.7221 142.138 88.8312 140.963 87.7324 140.963C86.6335 140.963 85.7427 142.138 85.7427 143.586C85.7427 145.034 86.6335 146.208 87.7324 146.208Z'
|
||||
fill={theme.centerChannelColor}
|
||||
/>
|
||||
<Path
|
||||
d='M87.7324 143C88.8312 143 89.7221 141.826 89.7221 140.377C89.7221 138.929 88.8312 137.755 87.7324 137.755C86.6335 137.755 85.7427 138.929 85.7427 140.377C85.7427 141.826 86.6335 143 87.7324 143Z'
|
||||
fill={theme.centerChannelColor}
|
||||
/>
|
||||
<Path
|
||||
d='M87.7324 139.796C88.8312 139.796 89.7221 138.622 89.7221 137.173C89.7221 135.725 88.8312 134.551 87.7324 134.551C86.6335 134.551 85.7427 135.725 85.7427 137.173C85.7427 138.622 86.6335 139.796 87.7324 139.796Z'
|
||||
fill={theme.centerChannelColor}
|
||||
/>
|
||||
<Path
|
||||
d='M87.7324 136.587C88.8312 136.587 89.7221 135.413 89.7221 133.965C89.7221 132.516 88.8312 131.342 87.7324 131.342C86.6335 131.342 85.7427 132.516 85.7427 133.965C85.7427 135.413 86.6335 136.587 87.7324 136.587Z'
|
||||
fill={theme.centerChannelColor}
|
||||
/>
|
||||
<Path
|
||||
d='M87.7324 133.384C88.8312 133.384 89.7221 132.21 89.7221 130.761C89.7221 129.313 88.8312 128.139 87.7324 128.139C86.6335 128.139 85.7427 129.313 85.7427 130.761C85.7427 132.21 86.6335 133.384 87.7324 133.384Z'
|
||||
fill={theme.centerChannelColor}
|
||||
/>
|
||||
<Path
|
||||
d='M87.7324 130.175C88.8312 130.175 89.7221 129.001 89.7221 127.552C89.7221 126.104 88.8312 124.93 87.7324 124.93C86.6335 124.93 85.7427 126.104 85.7427 127.552C85.7427 129.001 86.6335 130.175 87.7324 130.175Z'
|
||||
fill={theme.centerChannelColor}
|
||||
/>
|
||||
<Path
|
||||
d='M95.1779 105.612C95.4495 105.264 95.6971 104.897 95.9188 104.514L90.6906 103.651L96.3421 103.693C96.8845 102.505 97.1806 101.217 97.2125 99.9093C97.2443 98.6015 97.0112 97.3009 96.5273 96.0868L88.939 100.053L95.9347 94.8715C95.2779 93.7345 94.396 92.7457 93.3439 91.9664C92.2917 91.1872 91.0917 90.6343 89.8184 90.3419C88.5451 90.0496 87.2258 90.0242 85.9423 90.2672C84.6589 90.5101 83.4387 91.0164 82.3577 91.7544C81.2766 92.4925 80.3578 93.4465 79.6583 94.5574C78.9588 95.6682 78.4935 96.9121 78.2913 98.2117C78.0892 99.5113 78.1545 100.839 78.4833 102.112C78.812 103.385 79.3971 104.577 80.2023 105.612C79.7784 106.156 79.413 106.744 79.1122 107.366L85.9015 110.916L78.6624 108.469C78.3303 109.462 78.1605 110.503 78.1597 111.55C78.1557 113.704 78.8757 115.795 80.2023 117.483C79.0984 118.899 78.4121 120.598 78.2218 122.388C78.0315 124.178 78.3448 125.986 79.1261 127.605C79.9074 129.224 81.125 130.589 82.6399 131.544C84.1547 132.498 85.9057 133.005 87.6928 133.005C89.4798 133.005 91.2308 132.498 92.7456 131.544C94.2605 130.589 95.4781 129.224 96.2594 127.605C97.0407 125.986 97.354 124.178 97.1637 122.388C96.9734 120.598 96.2872 118.899 95.1832 117.483C96.5048 115.791 97.2228 113.7 97.2219 111.547C97.2209 109.394 96.501 107.303 95.1779 105.612Z'
|
||||
fill={theme.onlineIndicator}
|
||||
/>
|
||||
<Path
|
||||
opacity='0.1'
|
||||
d='M78.1597 111.545C78.1557 113.698 78.8757 115.789 80.2023 117.478C79.0984 118.893 78.4121 120.593 78.2218 122.383C78.0315 124.173 78.3448 125.981 79.1261 127.6C79.9074 129.218 81.125 130.583 82.6399 131.538C84.1547 132.493 85.9057 133 87.6928 133C89.4798 133 91.2308 132.493 92.7456 131.538C94.2605 130.583 95.4781 129.218 96.2594 127.6C97.0407 125.981 97.3541 124.173 97.1638 122.383C96.9735 120.593 96.2872 118.893 95.1833 117.478C96.4586 115.847 78.1597 110.468 78.1597 111.545Z'
|
||||
fill={theme.centerChannelColor}
|
||||
/>
|
||||
<Path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M71.396 133.35L70.7304 135.764L71.8302 134.038C72.728 135.455 73.4232 137.016 73.8916 138.665C75.2513 143.886 74.8056 148.607 72.8962 149.21C72.7469 149.257 72.593 149.278 72.4356 149.273C70.5978 149.543 67.443 147.867 64.5515 144.91C64.1942 144.543 63.8582 144.15 63.5456 143.736L65.7943 143.379L62.8998 142.819C60.4156 139.026 59.2312 134.066 59.2312 134.066C59.2312 134.066 60.108 134.244 61.3969 134.65L61.6851 135.902L61.7561 134.765C62.7954 135.104 63.8165 135.508 64.8145 135.974L65.3774 138.419L65.5089 136.315C65.8415 136.478 66.1683 136.654 66.4888 136.842C66.4265 132.602 67.5119 128.538 67.5119 128.538C67.5119 128.538 68.1787 129.19 69.0918 130.271L68.751 131.506L69.3451 130.574C70.0756 131.456 70.7603 132.383 71.396 133.35Z'
|
||||
fill='white'
|
||||
/>
|
||||
<Path
|
||||
fillRule='evenodd'
|
||||
clipRule='evenodd'
|
||||
d='M71.396 133.35L70.7304 135.764L71.8302 134.038C72.728 135.455 73.4232 137.016 73.8916 138.665C75.2513 143.886 74.8056 148.607 72.8962 149.21C72.7469 149.257 72.593 149.278 72.4356 149.273C70.5978 149.543 67.443 147.867 64.5515 144.91C64.1942 144.543 63.8582 144.15 63.5456 143.736L65.7943 143.379L62.8998 142.819C60.4156 139.026 59.2312 134.066 59.2312 134.066C59.2312 134.066 60.108 134.244 61.3969 134.65L61.6851 135.902L61.7561 134.765C62.7954 135.104 63.8165 135.508 64.8145 135.974L65.3774 138.419L65.5089 136.315C65.8415 136.478 66.1683 136.654 66.4888 136.842C66.4265 132.602 67.5119 128.538 67.5119 128.538C67.5119 128.538 68.1787 129.19 69.0918 130.271L68.751 131.506L69.3451 130.574C70.0756 131.456 70.7603 132.383 71.396 133.35Z'
|
||||
fill={theme.centerChannelColor}
|
||||
fillOpacity='0.32'
|
||||
/>
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id='clip0'>
|
||||
<Rect
|
||||
width='234'
|
||||
height='154.757'
|
||||
fill='white'
|
||||
transform='translate(0 0.621582)'
|
||||
/>
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
<View style={style.textContainer}>
|
||||
<Text style={style.title}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={style.subTitle}>
|
||||
{subTitle}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
140
app/components/global_threads/global_threads.tsx
Normal file
140
app/components/global_threads/global_threads.tsx
Normal file
|
|
@ -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<UserProfile>, teamId: $ID<Team>, before?: $ID<UserThread>, after?: $ID<UserThread>, perPage?: number, deleted?: boolean, unread?: boolean) => Promise<ActionResult>;
|
||||
handleViewingGlobalThreadsAll: () => void;
|
||||
handleViewingGlobalThreadsUnreads: () => void;
|
||||
markAllThreadsInTeamRead: (userId: $ID<UserProfile>, teamId: $ID<Team>) => void;
|
||||
},
|
||||
allThreadIds: $ID<UserThread>[];
|
||||
intl: typeof intlShape;
|
||||
teamId: $ID<Team>;
|
||||
theme: Theme;
|
||||
threadCount: ThreadsState['counts'][$ID<Team>];
|
||||
unreadThreadIds: $ID<UserThread>[];
|
||||
userId: $ID<UserProfile>;
|
||||
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<FlatList>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(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 (
|
||||
<ThreadList
|
||||
haveUnreads={haveUnreads}
|
||||
isLoading={isLoading}
|
||||
listRef={listRef}
|
||||
loadMoreThreads={loadMoreThreads}
|
||||
markAllAsRead={markAllAsRead}
|
||||
testID={'global_threads'}
|
||||
theme={theme}
|
||||
threadIds={ids}
|
||||
viewingUnreads={viewingUnreads}
|
||||
viewAllThreads={handleViewAllThreads}
|
||||
viewUnreadThreads={handleViewUnreadThreads}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default injectIntl(GlobalThreadsList);
|
||||
42
app/components/global_threads/index.ts
Normal file
42
app/components/global_threads/index.ts
Normal file
|
|
@ -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);
|
||||
|
|
@ -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`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"minHeight": 40,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Connect(Avatars)
|
||||
style={
|
||||
Object {
|
||||
"marginRight": 12,
|
||||
"paddingVertical": 8,
|
||||
}
|
||||
}
|
||||
userIds={
|
||||
Array [
|
||||
"user1",
|
||||
]
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"marginRight": 5,
|
||||
"top": -1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<CompassIcon
|
||||
color="rgba(61,60,64,0.64)"
|
||||
name="reply-outline"
|
||||
size={18}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"alignSelf": "center",
|
||||
"color": "rgba(61,60,64,0.64)",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
"marginRight": 12,
|
||||
}
|
||||
}
|
||||
testID="thread_footer.footer.reply_count"
|
||||
>
|
||||
2 replies
|
||||
</Text>
|
||||
<ForwardRef
|
||||
onPress={[Function]}
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(22,109,224,0.08)",
|
||||
"borderRadius": 4,
|
||||
"height": 32,
|
||||
"justifyContent": "center",
|
||||
"paddingHorizontal": 12,
|
||||
}
|
||||
}
|
||||
testID="thread_footer.footer.following"
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"color": "#166de0",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
}
|
||||
}
|
||||
>
|
||||
Following
|
||||
</Text>
|
||||
</ForwardRef>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`Global Thread Footer Should render for global threads view 1`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"minHeight": 40,
|
||||
}
|
||||
}
|
||||
>
|
||||
<Connect(Avatars)
|
||||
style={
|
||||
Object {
|
||||
"marginRight": 12,
|
||||
"paddingVertical": 8,
|
||||
}
|
||||
}
|
||||
userIds={
|
||||
Array [
|
||||
"user1",
|
||||
]
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"alignSelf": "center",
|
||||
"color": "#579eff",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600",
|
||||
"marginRight": 12,
|
||||
}
|
||||
}
|
||||
testID="thread_footer.footer.unread_replies"
|
||||
>
|
||||
2 new replies
|
||||
</Text>
|
||||
</View>
|
||||
`;
|
||||
27
app/components/global_threads/thread_footer/index.ts
Normal file
27
app/components/global_threads/thread_footer/index.ts
Normal file
|
|
@ -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<StateProps, DispatchProps, OwnProps>(mapStateToProps, mapDispatchToProps)(ThreadFooter);
|
||||
|
|
@ -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(
|
||||
<ThreadFooter
|
||||
{...baseProps}
|
||||
location='Channel'
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ThreadFooter
|
||||
{...props}
|
||||
location='Channel'
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ThreadFooter
|
||||
{...props}
|
||||
location='GlobalThreads'
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
233
app/components/global_threads/thread_footer/thread_footer.tsx
Normal file
233
app/components/global_threads/thread_footer/thread_footer.tsx
Normal file
|
|
@ -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<UserProfile>, threadId: $ID<UserThread>, newState: boolean) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export type OwnProps = {
|
||||
location: 'GlobalThreads' | 'Channel';
|
||||
testID: string;
|
||||
thread: UserThread;
|
||||
threadStarter: UserProfile;
|
||||
theme: Theme;
|
||||
}
|
||||
|
||||
export type StateProps = {
|
||||
currentUserId: $ID<UserProfile>;
|
||||
};
|
||||
|
||||
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 = (
|
||||
<View style={style.replyIconContainer}>
|
||||
<CompassIcon
|
||||
name='reply-outline'
|
||||
size={18}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (thread.is_following) {
|
||||
followButton = (
|
||||
<TouchableOpacity
|
||||
onPress={preventDoubleTap(onUnfollow)}
|
||||
style={style.followingButtonContainer}
|
||||
testID={`${testID}.following`}
|
||||
>
|
||||
<Text style={style.following}>
|
||||
{intl.formatMessage({
|
||||
id: 'threads.following',
|
||||
defaultMessage: 'Following',
|
||||
})}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
} else {
|
||||
followButton = (
|
||||
<>
|
||||
<View style={style.followSeparator}/>
|
||||
<TouchableOpacity
|
||||
onPress={preventDoubleTap(onFollow)}
|
||||
style={style.notFollowingButtonContainer}
|
||||
testID={`${testID}.follow`}
|
||||
>
|
||||
<Text style={style.notFollowing}>
|
||||
{intl.formatMessage({
|
||||
id: 'threads.follow',
|
||||
defaultMessage: 'Follow',
|
||||
})}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let repliesComponent;
|
||||
if (location === GLOBAL_THREADS && thread.unread_replies) {
|
||||
repliesComponent = (
|
||||
<Text
|
||||
style={style.unreadReplies}
|
||||
testID={`${testID}.unread_replies`}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'threads.newReplies',
|
||||
defaultMessage: '{count} new {count, plural, one {reply} other {replies}}',
|
||||
}, {
|
||||
count: thread.unread_replies,
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
} else if (thread.reply_count) {
|
||||
repliesComponent = (
|
||||
<Text
|
||||
style={style.replies}
|
||||
testID={`${testID}.reply_count`}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'threads.replies',
|
||||
defaultMessage: '{count} {count, plural, one {reply} other {replies}}',
|
||||
}, {
|
||||
count: thread.reply_count,
|
||||
})}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 = (
|
||||
<Avatars
|
||||
style={style.avatarsContainer}
|
||||
userIds={participants}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
{avatars}
|
||||
{replyIcon}
|
||||
{repliesComponent}
|
||||
{followButton}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -0,0 +1,382 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Global Thread Item Should render thread item with unread messages dot 1`] = `
|
||||
<ForwardRef
|
||||
onPress={[Function]}
|
||||
testID="thread_item.post1.item"
|
||||
underlayColor="rgba(22,109,224,0.08)"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderBottomColor": "rgba(61,60,64,0.08)",
|
||||
"borderBottomWidth": 1,
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"paddingRight": 16,
|
||||
"paddingTop": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"marginTop": 3,
|
||||
"width": 32,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignSelf": "center",
|
||||
"backgroundColor": "#579eff",
|
||||
"borderRadius": 4,
|
||||
"height": 8,
|
||||
"marginTop": 5,
|
||||
"width": 8,
|
||||
}
|
||||
}
|
||||
testID="thread_item.post1.unread_dot"
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"marginBottom": 9,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"marginRight": 12,
|
||||
"overflow": "hidden",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={
|
||||
Object {
|
||||
"color": "#3d3c40",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 22,
|
||||
"paddingRight": 8,
|
||||
}
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(61,60,64,0.08)",
|
||||
"borderRadius": 4,
|
||||
"maxWidth": "50%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={
|
||||
Object {
|
||||
"color": "#3d3c40",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0.1,
|
||||
"lineHeight": 16,
|
||||
"marginBottom": 2,
|
||||
"marginLeft": 6,
|
||||
"marginRight": 6,
|
||||
"marginTop": 2,
|
||||
"textTransform": "uppercase",
|
||||
}
|
||||
}
|
||||
>
|
||||
CHANNEL 1
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<InjectIntl(FriendlyDate)
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.64)",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "400",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={
|
||||
Object {
|
||||
"color": "#3d3c40",
|
||||
"fontSize": 15,
|
||||
"lineHeight": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<RemoveMarkdown
|
||||
value=""
|
||||
/>
|
||||
</Text>
|
||||
<Connect(InjectIntl(ThreadFooter))
|
||||
location="GlobalThreads"
|
||||
testID="thread_item.post1.footer"
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
thread={
|
||||
Object {
|
||||
"id": "post1",
|
||||
"unread_replies": 5,
|
||||
}
|
||||
}
|
||||
threadStarter={
|
||||
Object {
|
||||
"id": "user1",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ForwardRef>
|
||||
`;
|
||||
|
||||
exports[`Global Thread Item Should show unread mentions count 1`] = `
|
||||
<ForwardRef
|
||||
onPress={[Function]}
|
||||
testID="thread_item.post1.item"
|
||||
underlayColor="rgba(22,109,224,0.08)"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"borderBottomColor": "rgba(61,60,64,0.08)",
|
||||
"borderBottomWidth": 1,
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"paddingRight": 16,
|
||||
"paddingTop": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"marginTop": 3,
|
||||
"width": 32,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignSelf": "center",
|
||||
"backgroundColor": "#145dbf",
|
||||
"borderRadius": 9,
|
||||
"height": 18,
|
||||
"width": 18,
|
||||
}
|
||||
}
|
||||
testID="thread_item.post1.unread_mentions"
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
Object {
|
||||
"alignSelf": "center",
|
||||
"color": "#ffffff",
|
||||
"fontFamily": "Open Sans",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "700",
|
||||
"lineHeight": 16,
|
||||
}
|
||||
}
|
||||
>
|
||||
5
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"marginBottom": 9,
|
||||
}
|
||||
}
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"alignItems": "center",
|
||||
"flex": 1,
|
||||
"flexDirection": "row",
|
||||
"marginRight": 12,
|
||||
"overflow": "hidden",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={
|
||||
Object {
|
||||
"color": "#3d3c40",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 22,
|
||||
"paddingRight": 8,
|
||||
}
|
||||
}
|
||||
/>
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"backgroundColor": "rgba(61,60,64,0.08)",
|
||||
"borderRadius": 4,
|
||||
"maxWidth": "50%",
|
||||
}
|
||||
}
|
||||
>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={
|
||||
Object {
|
||||
"color": "#3d3c40",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"letterSpacing": 0.1,
|
||||
"lineHeight": 16,
|
||||
"marginBottom": 2,
|
||||
"marginLeft": 6,
|
||||
"marginRight": 6,
|
||||
"marginTop": 2,
|
||||
"textTransform": "uppercase",
|
||||
}
|
||||
}
|
||||
>
|
||||
CHANNEL 1
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<InjectIntl(FriendlyDate)
|
||||
style={
|
||||
Object {
|
||||
"color": "rgba(61,60,64,0.64)",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "400",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
numberOfLines={2}
|
||||
style={
|
||||
Object {
|
||||
"color": "#3d3c40",
|
||||
"fontSize": 15,
|
||||
"lineHeight": 20,
|
||||
}
|
||||
}
|
||||
>
|
||||
<RemoveMarkdown
|
||||
value=""
|
||||
/>
|
||||
</Text>
|
||||
<Connect(InjectIntl(ThreadFooter))
|
||||
location="GlobalThreads"
|
||||
testID="thread_item.post1.footer"
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
thread={
|
||||
Object {
|
||||
"id": "post1",
|
||||
"unread_mentions": 5,
|
||||
"unread_replies": 5,
|
||||
}
|
||||
}
|
||||
threadStarter={
|
||||
Object {
|
||||
"id": "user1",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ForwardRef>
|
||||
`;
|
||||
38
app/components/global_threads/thread_item/index.ts
Normal file
38
app/components/global_threads/thread_item/index.ts
Normal file
|
|
@ -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<StateProps, DispatchProps, OwnProps>(mapStateToProps, mapDispatchToProps)(ThreadItem);
|
||||
113
app/components/global_threads/thread_item/thread_item.test.tsx
Normal file
113
app/components/global_threads/thread_item/thread_item.test.tsx
Normal file
|
|
@ -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(
|
||||
<ThreadItem
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ThreadItem
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ThreadItem
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<ThreadItem
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
const threadItem = wrapper.find({testID: `${testIDPrefix}.item`});
|
||||
expect(threadItem.exists()).toBeTruthy();
|
||||
threadItem.simulate('press');
|
||||
expect(goToScreen).toHaveBeenCalledWith(THREAD, expect.anything(), expect.anything());
|
||||
});
|
||||
});
|
||||
270
app/components/global_threads/thread_item/thread_item.tsx
Normal file
270
app/components/global_threads/thread_item/thread_item.tsx
Normal file
|
|
@ -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 = (
|
||||
<View
|
||||
style={style.mentionBadge}
|
||||
testID={`${testIDPrefix}.unread_mentions`}
|
||||
>
|
||||
<Text style={style.mentionBadgeText}>{thread.unread_mentions > 99 ? '99+' : thread.unread_mentions}</Text>
|
||||
</View>
|
||||
);
|
||||
} else if (thread.unread_replies && thread.unread_replies > 0) {
|
||||
badgeComponent = (
|
||||
<View
|
||||
style={style.unreadDot}
|
||||
testID={`${testIDPrefix}.unread_dot`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let name;
|
||||
if (postItem.state === Posts.POST_DELETED) {
|
||||
name = (
|
||||
<Text
|
||||
style={[style.threadStarter, style.threadDeleted]}
|
||||
numberOfLines={1}
|
||||
>{intl.formatMessage({
|
||||
id: 'threads.deleted',
|
||||
defaultMessage: 'Original Message Deleted',
|
||||
})}</Text>
|
||||
);
|
||||
} else {
|
||||
name = (
|
||||
<Text
|
||||
style={style.threadStarter}
|
||||
numberOfLines={1}
|
||||
>{threadStarterName}</Text>
|
||||
);
|
||||
}
|
||||
|
||||
let postBody;
|
||||
if (postItem.state !== Posts.POST_DELETED) {
|
||||
postBody = (
|
||||
<Text
|
||||
style={style.message}
|
||||
numberOfLines={2}
|
||||
>
|
||||
<RemoveMarkdown value={postItem.message || ''}/>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableHighlight
|
||||
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
|
||||
onPress={showThread}
|
||||
testID={`${testIDPrefix}.item`}
|
||||
>
|
||||
<View style={style.container}>
|
||||
<View style={style.badgeContainer}>
|
||||
{badgeComponent}
|
||||
</View>
|
||||
<View style={style.postContainer}>
|
||||
<View style={style.header}>
|
||||
<View style={style.headerInfoContainer}>
|
||||
{name}
|
||||
<View style={style.channelNameContainer}>
|
||||
<Text
|
||||
style={style.channelName}
|
||||
numberOfLines={1}
|
||||
>{channelName}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<FriendlyDate
|
||||
value={thread.last_reply_at}
|
||||
style={style.date}
|
||||
/>
|
||||
</View>
|
||||
{postBody}
|
||||
<ThreadFooter
|
||||
testID={`${testIDPrefix}.footer`}
|
||||
thread={thread}
|
||||
theme={theme}
|
||||
threadStarter={threadStarter}
|
||||
location={GLOBAL_THREADS}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -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`] = `
|
||||
<View
|
||||
style={
|
||||
Object {
|
||||
"flex": 1,
|
||||
}
|
||||
}
|
||||
>
|
||||
<InjectIntl(ThreadListHeader)
|
||||
haveUnreads={true}
|
||||
markAllAsRead={[MockFunction]}
|
||||
style={
|
||||
Object {
|
||||
"borderBottom": Object {
|
||||
"borderBottomColor": "rgba(61,60,64,0.08)",
|
||||
"borderBottomWidth": 1,
|
||||
},
|
||||
"container": Object {
|
||||
"flex": 1,
|
||||
},
|
||||
"headerContainer": Object {
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
},
|
||||
"listScrollIndicator": Object {
|
||||
"right": 1,
|
||||
},
|
||||
"loadingMoreContainer": Object {
|
||||
"paddingVertical": 12,
|
||||
},
|
||||
"markAllReadIcon": Object {
|
||||
"color": "rgba(61,60,64,0.56)",
|
||||
"fontSize": 28,
|
||||
"lineHeight": 28,
|
||||
},
|
||||
"markAllReadIconContainer": Object {
|
||||
"paddingHorizontal": 20,
|
||||
},
|
||||
"markAllReadIconDisabled": Object {
|
||||
"opacity": 0.5,
|
||||
},
|
||||
"menuContainer": Object {
|
||||
"alignItems": "center",
|
||||
"flexDirection": "row",
|
||||
"flexGrow": 1,
|
||||
"marginVertical": 12,
|
||||
"paddingLeft": 12,
|
||||
},
|
||||
"menuItem": Object {
|
||||
"alignSelf": "center",
|
||||
"color": "rgba(61,60,64,0.56)",
|
||||
"fontFamily": "Open Sans",
|
||||
"fontSize": 16,
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 24,
|
||||
},
|
||||
"menuItemContainer": Object {
|
||||
"paddingHorizontal": 16,
|
||||
"paddingVertical": 8,
|
||||
},
|
||||
"menuItemContainerSelected": Object {
|
||||
"backgroundColor": "rgba(22,109,224,0.08)",
|
||||
"borderRadius": 4,
|
||||
},
|
||||
"menuItemSelected": Object {
|
||||
"color": "#166de0",
|
||||
},
|
||||
"messagesContainer": Object {
|
||||
"flexGrow": 1,
|
||||
},
|
||||
"unreadsDot": Object {
|
||||
"backgroundColor": "#579eff",
|
||||
"borderRadius": 3,
|
||||
"height": 6,
|
||||
"position": "absolute",
|
||||
"right": -6,
|
||||
"top": 4,
|
||||
"width": 6,
|
||||
},
|
||||
}
|
||||
}
|
||||
testId="thread_list"
|
||||
viewAllThreads={[MockFunction]}
|
||||
viewUnreadThreads={[MockFunction]}
|
||||
viewingUnreads={true}
|
||||
/>
|
||||
<FlatList
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
intl={
|
||||
Object {
|
||||
"defaultFormats": Object {},
|
||||
"defaultLocale": "en",
|
||||
"formatDate": [Function],
|
||||
"formatHTMLMessage": [Function],
|
||||
"formatMessage": [Function],
|
||||
"formatNumber": [Function],
|
||||
"formatPlural": [Function],
|
||||
"formatRelative": [Function],
|
||||
"formatTime": [Function],
|
||||
"formats": Object {},
|
||||
"formatters": Object {
|
||||
"getDateTimeFormat": [Function],
|
||||
"getMessageFormat": [Function],
|
||||
"getNumberFormat": [Function],
|
||||
"getPluralFormat": [Function],
|
||||
"getRelativeFormat": [Function],
|
||||
},
|
||||
"locale": "en",
|
||||
"messages": Object {},
|
||||
"now": [Function],
|
||||
"onError": [Function],
|
||||
"textComponent": "span",
|
||||
"timeZone": null,
|
||||
}
|
||||
}
|
||||
isUnreads={true}
|
||||
/>
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
48
app/components/global_threads/thread_list/index.test.tsx
Normal file
48
app/components/global_threads/thread_list/index.test.tsx
Normal file
|
|
@ -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<FlatList>(null),
|
||||
loadMoreThreads: jest.fn(),
|
||||
markAllAsRead,
|
||||
testID,
|
||||
theme: Preferences.THEMES.default,
|
||||
threadIds: ['thread1'],
|
||||
viewingUnreads: true,
|
||||
viewAllThreads,
|
||||
viewUnreadThreads,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<ThreadList
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
|
||||
test('Should render threads with functional tabs & mark all as read button', () => {
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
194
app/components/global_threads/thread_list/index.tsx
Normal file
194
app/components/global_threads/thread_list/index.tsx
Normal file
|
|
@ -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<void>;
|
||||
listRef: React.RefObject<FlatList>;
|
||||
markAllAsRead: () => void;
|
||||
testID: string;
|
||||
theme: Theme;
|
||||
threadIds: $ID<UserThread>[];
|
||||
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 (
|
||||
<ThreadItem
|
||||
testID={testID}
|
||||
theme={theme}
|
||||
threadId={item}
|
||||
/>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderHeader = () => {
|
||||
if (!viewingUnreads && !threadIds.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<ThreadListHeader
|
||||
haveUnreads={haveUnreads}
|
||||
markAllAsRead={markAllAsRead}
|
||||
style={style}
|
||||
testId={testID}
|
||||
viewAllThreads={viewAllThreads}
|
||||
viewUnreadThreads={viewUnreadThreads}
|
||||
viewingUnreads={viewingUnreads}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEmptyList = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Loading/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<EmptyState
|
||||
intl={intl}
|
||||
isUnreads={viewingUnreads}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFooter = () => {
|
||||
if (isLoading && threadIds?.length >= ViewTypes.CRT_CHUNK_SIZE) {
|
||||
return (
|
||||
<View style={style.loadingMoreContainer}>
|
||||
<Loading size='small'/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={style.container}>
|
||||
{renderHeader()}
|
||||
<FlatList
|
||||
contentContainerStyle={style.messagesContainer}
|
||||
data={threadIds}
|
||||
keyExtractor={keyExtractor}
|
||||
ListEmptyComponent={renderEmptyList()}
|
||||
ListFooterComponent={renderFooter()}
|
||||
onEndReached={handleEndReached}
|
||||
onEndReachedThreshold={2}
|
||||
ref={listRef}
|
||||
renderItem={renderPost}
|
||||
initialNumToRender={INITIAL_BATCH_TO_RENDER}
|
||||
maxToRenderPerBatch={Platform.select({android: 5})}
|
||||
removeClippedSubviews={true}
|
||||
scrollIndicatorInsets={style.listScrollIndicator}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -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`] = `
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<View>
|
||||
<ForwardRef
|
||||
onPress={[MockFunction]}
|
||||
testID="thread_list.all_threads"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<Text
|
||||
style={
|
||||
Array [
|
||||
undefined,
|
||||
Object {},
|
||||
]
|
||||
}
|
||||
>
|
||||
All Your Threads
|
||||
</Text>
|
||||
</View>
|
||||
</ForwardRef>
|
||||
<ForwardRef
|
||||
onPress={[MockFunction]}
|
||||
testID="thread_list.unread_threads"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
Array [
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
<View>
|
||||
<Text
|
||||
style={
|
||||
Array [
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
>
|
||||
Unreads
|
||||
</Text>
|
||||
<View
|
||||
testID="thread_list.unreads_dot"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ForwardRef>
|
||||
</View>
|
||||
<View>
|
||||
<ForwardRef
|
||||
disabled={false}
|
||||
onPress={[MockFunction]}
|
||||
testID="thread_list.mark_all_read"
|
||||
>
|
||||
<Icon
|
||||
name="playlist-check"
|
||||
style={
|
||||
Array [
|
||||
undefined,
|
||||
undefined,
|
||||
]
|
||||
}
|
||||
/>
|
||||
</ForwardRef>
|
||||
</View>
|
||||
</View>
|
||||
`;
|
||||
|
|
@ -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(
|
||||
<ThreadListHeader {...baseProps}/>,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, StyleProp<ViewStyle | TextStyle>>;
|
||||
testID: string;
|
||||
viewAllThreads: () => void;
|
||||
viewUnreadThreads: () => void;
|
||||
viewingUnreads: boolean;
|
||||
};
|
||||
|
||||
function ThreadListHeader({haveUnreads, intl, markAllAsRead, style, testID, viewAllThreads, viewUnreadThreads, viewingUnreads}: Props) {
|
||||
return (
|
||||
<View style={[style.headerContainer, style.borderBottom]}>
|
||||
<View style={style.menuContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={viewAllThreads}
|
||||
testID={`${testID}.all_threads`}
|
||||
>
|
||||
<View style={[style.menuItemContainer, viewingUnreads ? undefined : style.menuItemContainerSelected]}>
|
||||
<Text style={[style.menuItem, viewingUnreads ? {} : style.menuItemSelected]}>
|
||||
{
|
||||
intl.formatMessage({
|
||||
id: 'global_threads.allThreads',
|
||||
defaultMessage: 'All Your Threads',
|
||||
})
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={viewUnreadThreads}
|
||||
testID={`${testID}.unread_threads`}
|
||||
>
|
||||
<View style={[style.menuItemContainer, viewingUnreads ? style.menuItemContainerSelected : undefined]}>
|
||||
<View>
|
||||
<Text style={[style.menuItem, viewingUnreads ? style.menuItemSelected : {}]}>
|
||||
{
|
||||
intl.formatMessage({
|
||||
id: 'global_threads.unreads',
|
||||
defaultMessage: 'Unreads',
|
||||
})
|
||||
}
|
||||
</Text>
|
||||
{haveUnreads ? (
|
||||
<View
|
||||
style={style.unreadsDot}
|
||||
testID={`${testID}.unreads_dot`}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={style.markAllReadIconContainer}>
|
||||
<TouchableOpacity
|
||||
disabled={!haveUnreads}
|
||||
onPress={markAllAsRead}
|
||||
testID={`${testID}.mark_all_read`}
|
||||
>
|
||||
<CompassIcon
|
||||
name='playlist-check'
|
||||
style={[style.markAllReadIcon, haveUnreads ? undefined : style.markAllReadIconDisabled]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export {ThreadListHeader}; // Used for shallow render test cases
|
||||
|
||||
export default injectIntl(ThreadListHeader);
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<Header
|
||||
collapsedThreadsEnabled={collapsedThreadsEnabled}
|
||||
enablePostUsernameOverride={enablePostUsernameOverride}
|
||||
location={location}
|
||||
post={post}
|
||||
|
|
@ -233,6 +254,35 @@ const Post = ({
|
|||
);
|
||||
}
|
||||
|
||||
let footer;
|
||||
if (
|
||||
collapsedThreadsEnabled &&
|
||||
Boolean(thread) &&
|
||||
post.state !== Posts.POST_DELETED &&
|
||||
thread?.participants?.length
|
||||
) {
|
||||
footer = (
|
||||
<ThreadFooter
|
||||
testID={`${itemTestID}.footer`}
|
||||
theme={theme}
|
||||
thread={thread}
|
||||
threadStarter={threadStarter}
|
||||
location={Screens.CHANNEL}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let badge;
|
||||
if (thread?.unread_mentions || thread?.unread_replies) {
|
||||
if (thread.unread_replies && thread.unread_replies > 0) {
|
||||
badge = (
|
||||
<View style={styles.badgeContainer}>
|
||||
<View style={styles.unreadDot}/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
testID={testID}
|
||||
|
|
@ -260,7 +310,9 @@ const Post = ({
|
|||
<View style={rightColumnStyle}>
|
||||
{header}
|
||||
{body}
|
||||
{footer}
|
||||
</View>
|
||||
{badge}
|
||||
</View>
|
||||
</>
|
||||
</TouchableWithFeedback>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,3 +39,76 @@ exports[`ChannelsList List should match snapshot 1`] = `
|
|||
/>
|
||||
</View>
|
||||
`;
|
||||
|
||||
exports[`ChannelsList List should match snapshot with collapsed threads enabled 1`] = `
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
<SectionList
|
||||
contentContainerStyle={
|
||||
Object {
|
||||
"paddingBottom": 44,
|
||||
}
|
||||
}
|
||||
data={Array []}
|
||||
disableVirtualization={false}
|
||||
horizontal={false}
|
||||
initialNumToRender={10}
|
||||
keyExtractor={[Function]}
|
||||
keyboardDismissMode="interactive"
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={10}
|
||||
onEndReachedThreshold={2}
|
||||
onScrollBeginDrag={[Function]}
|
||||
onViewableItemsChanged={[Function]}
|
||||
removeClippedSubviews={false}
|
||||
renderItem={[Function]}
|
||||
renderSectionHeader={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
sections={Array []}
|
||||
stickySectionHeadersEnabled={true}
|
||||
testID="main.sidebar.channels_list.list"
|
||||
updateCellsBatchingPeriod={50}
|
||||
viewabilityConfig={
|
||||
Object {
|
||||
"itemVisiblePercentThreshold": 100,
|
||||
"waitForInteraction": true,
|
||||
}
|
||||
}
|
||||
windowSize={21}
|
||||
/>
|
||||
<UnreadIndicatorIOS
|
||||
onPress={[Function]}
|
||||
theme={
|
||||
Object {
|
||||
"awayIndicator": "#ffbc42",
|
||||
"buttonBg": "#166de0",
|
||||
"buttonColor": "#ffffff",
|
||||
"centerChannelBg": "#ffffff",
|
||||
"centerChannelColor": "#3d3c40",
|
||||
"codeTheme": "github",
|
||||
"dndIndicator": "#f74343",
|
||||
"errorTextColor": "#fd5960",
|
||||
"linkColor": "#2389d7",
|
||||
"mentionBg": "#ffffff",
|
||||
"mentionBj": "#ffffff",
|
||||
"mentionColor": "#145dbf",
|
||||
"mentionHighlightBg": "#ffe577",
|
||||
"mentionHighlightLink": "#166de0",
|
||||
"newMessageSeparator": "#ff8800",
|
||||
"onlineIndicator": "#06d6a0",
|
||||
"sidebarBg": "#145dbf",
|
||||
"sidebarHeaderBg": "#1153ab",
|
||||
"sidebarHeaderTextColor": "#ffffff",
|
||||
"sidebarText": "#ffffff",
|
||||
"sidebarTextActiveBorder": "#579eff",
|
||||
"sidebarTextActiveColor": "#ffffff",
|
||||
"sidebarTextHoverBg": "#4578bf",
|
||||
"sidebarUnreadText": "#ffffff",
|
||||
"type": "Mattermost",
|
||||
}
|
||||
}
|
||||
visible={false}
|
||||
/>
|
||||
</View>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<ThreadsSidebarEntry/>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<List
|
||||
{...baseProps}
|
||||
collapsedThreadsEnabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
39
app/components/sidebars/main/threads_entry/index.tsx
Normal file
39
app/components/sidebars/main/threads_entry/index.tsx
Normal file
|
|
@ -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);
|
||||
147
app/components/sidebars/main/threads_entry/threads_entry.tsx
Normal file
147
app/components/sidebars/main/threads_entry/threads_entry.tsx
Normal file
|
|
@ -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<UserProfile>, currentTeamId: $ID<Team>, before: string, after: string, perPage: number, deleted: boolean, unread: boolean) => void;
|
||||
handleViewingGlobalThreadsScreen: () => void;
|
||||
};
|
||||
currentTeamId: $ID<Team>;
|
||||
currentUserId: $ID<UserProfile>;
|
||||
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 = (
|
||||
<View style={style.borderActive}/>
|
||||
);
|
||||
}
|
||||
|
||||
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 = (
|
||||
<Badge
|
||||
testID='threads.badge'
|
||||
containerStyle={style.badgeContainer}
|
||||
style={style.badge}
|
||||
countStyle={style.mention}
|
||||
count={threadCount?.total_unread_mentions}
|
||||
minWidth={21}
|
||||
isChannelItem={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return [item, text, icon, borderComponent, badgeComponent];
|
||||
}, [extraStyle, style, threadCount?.total_unread_mentions, threadCount?.total_unread_threads, viewingGlobalThreads]);
|
||||
|
||||
return (
|
||||
<TouchableHighlight
|
||||
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={[style.container, extraStyle.container]}>
|
||||
{border}
|
||||
<View style={itemStyle} >
|
||||
<View style={extraStyle.iconContainer}>
|
||||
<CompassIcon
|
||||
name='message-text-outline'
|
||||
style={iconStyle}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
style={textStyle}
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'threads',
|
||||
defaultMessage: 'Threads',
|
||||
})}
|
||||
</Text>
|
||||
{badge}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableHighlight>
|
||||
);
|
||||
};
|
||||
|
||||
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);
|
||||
14
app/constants/date.ts
Normal file
14
app/constants/date.ts
Normal file
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
11
app/mm-redux/action_types/threads.ts
Normal file
11
app/mm-redux/action_types/threads.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Post>) {
|
||||
export function getThreadsForPosts(posts: Array<Post>, 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<Post>) {
|
|||
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
126
app/mm-redux/actions/threads.test.js
Normal file
126
app/mm-redux/actions/threads.test.js
Normal file
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
277
app/mm-redux/actions/threads.ts
Normal file
277
app/mm-redux/actions/threads.ts
Normal file
|
|
@ -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<GenericAction> = [
|
||||
{
|
||||
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};
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ const Preferences: Dictionary<any> = {
|
|||
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',
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ function channels(state: IDMappedObjects<Channel> = {}, 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<Channel> = {}, 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<Channel, ChannelMembership> = {}, 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<Channel, ChannelMembership> = {}, 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<Channel, ChannelMembership> = {}, 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<Channel, ChannelMembership> = {}, 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<Channel, ChannelMembership> = {}, 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<Channel, ChannelMembership> = {}, 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<Post, Post> = {}, 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<Array<PostOrderBlock>> = {}, 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
|
||||
|
|
|
|||
|
|
@ -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}};
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function myMembers(state: RelationOneToOne<Team, TeamMembership> = {}, 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<Team, TeamMembership> = {}, 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<Team, TeamMembership> = {}, 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<Team, TeamMembership> = {}, 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<Team, TeamMembership> = {}, 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<Team, TeamMembership> = {}, 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<Team, TeamMembership> = {}, 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<Team, TeamMembership> = {}, 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;
|
||||
}
|
||||
|
|
|
|||
234
app/mm-redux/reducers/entities/threads.test.js
Normal file
234
app/mm-redux/reducers/entities/threads.test.js
Normal file
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
252
app/mm-redux/reducers/entities/threads.ts
Normal file
252
app/mm-redux/reducers/entities/threads.ts
Normal file
|
|
@ -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<UserThread>, 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<ThreadsState['threads']>((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,
|
||||
});
|
||||
|
|
@ -312,6 +312,7 @@ describe('makeFilterAutoclosedDMs', () => {
|
|||
getCurrentUserId(state),
|
||||
state.entities.users.profiles,
|
||||
getLastPostPerChannel(state),
|
||||
false,
|
||||
getCurrentChannelId(state),
|
||||
now,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Channel> {
|
||||
|
|
@ -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<Channel>,
|
||||
|
|
@ -304,6 +317,7 @@ export const getChannelsWithUnreadSection: (a: GlobalState) => {
|
|||
teammateNameDisplay: string,
|
||||
usersState: UsersState,
|
||||
lastPosts: RelationOneToOne<Channel, Post>,
|
||||
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<Channel>, myMembers: RelationOneToOne<Channel, ChannelMembership>, users: IDMappedObjects<UserProfile>, currentUserId: string, currentTeamId: string, myTeams: Array<Team>, myTeamMemberships: RelationOneToOne<Team, TeamMembership>): {
|
||||
isCollapsedThreadsEnabled,
|
||||
getThreadCounts,
|
||||
(channels: IDMappedObjects<Channel>, myMembers: RelationOneToOne<Channel, ChannelMembership>, users: IDMappedObjects<UserProfile>, currentUserId: string, currentTeamId: string, myTeams: Team[], myTeamMemberships: RelationOneToOne<Team, TeamMembership>, 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<string> = cre
|
|||
export const getChannelIdsForCurrentTeam: (a: GlobalState) => Array<string> = createIdsSelector(getChannelIdsInCurrentTeam, getAllDirectChannelIds, (channels, direct) => {
|
||||
return [...channels, ...direct];
|
||||
});
|
||||
export const getUnreadChannelIds: (b: GlobalState, a?: Channel | null) => Array<string> = createIdsSelector(getAllChannels, getMyChannelMemberships, getChannelIdsForCurrentTeam, (state: GlobalState, lastUnreadChannel: Channel | undefined | null = null): Channel | undefined | null => lastUnreadChannel, (channels: IDMappedObjects<Channel>, members: RelationOneToOne<Channel, ChannelMembership>, teamChannelIds: Array<string>, lastUnreadChannel?: Channel | null): Array<string> => {
|
||||
const unreadIds = teamChannelIds.filter((id) => {
|
||||
const c = channels[id];
|
||||
const m = members[id];
|
||||
export const getUnreadChannelIds: (b: GlobalState, a?: Channel | null) => Array<string> = createIdsSelector(
|
||||
isCollapsedThreadsEnabled,
|
||||
getAllChannels,
|
||||
getMyChannelMemberships,
|
||||
getChannelIdsForCurrentTeam,
|
||||
(state: GlobalState, lastUnreadChannel: Channel | undefined | null = null): Channel | undefined | null => lastUnreadChannel,
|
||||
(
|
||||
collapsedThreadsEnabled: boolean,
|
||||
channels: IDMappedObjects<Channel>,
|
||||
members: RelationOneToOne<Channel, ChannelMembership>,
|
||||
teamChannelIds: Array<string>,
|
||||
lastUnreadChannel?: Channel | null,
|
||||
): Array<string> => {
|
||||
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<Channel> = 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<string> = 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Config> {
|
||||
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<Config>];
|
||||
}
|
||||
|
||||
export function getLicense(state: GlobalState): any {
|
||||
return state.entities.general.license;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
82
app/mm-redux/selectors/entities/threads.test.js
Normal file
82
app/mm-redux/selectors/entities/threads.test.js
Normal file
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
109
app/mm-redux/selectors/entities/threads.ts
Normal file
109
app/mm-redux/selectors/entities/threads.ts
Normal file
|
|
@ -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<Team, UserThread> {
|
||||
return state.entities.threads.threadsInTeam;
|
||||
}
|
||||
|
||||
export const getThreadsInCurrentTeam: (state: GlobalState) => Array<$ID<UserThread>> = createSelector(
|
||||
getCurrentTeamId,
|
||||
getThreadsInTeam,
|
||||
(currentTeamId: $ID<Team>, 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<Team>): ThreadsState['counts'][$ID<Team>] {
|
||||
return getThreadCounts(state)[teamId];
|
||||
}
|
||||
|
||||
export const getThreadCountsInCurrentTeam: (state: GlobalState) => ThreadsState['counts'][$ID<Team>] = createSelector(
|
||||
getCurrentTeamId,
|
||||
getThreadCounts,
|
||||
(currentTeamId: $ID<Team>, counts: ThreadsState['counts']) => {
|
||||
return counts?.[currentTeamId];
|
||||
},
|
||||
);
|
||||
|
||||
export function getThreads(state: GlobalState) {
|
||||
return state.entities.threads.threads;
|
||||
}
|
||||
|
||||
export function getThread(state: GlobalState, threadId: $ID<UserThread>, 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<UserThread>> = createSelector(
|
||||
getThreadsInCurrentTeam,
|
||||
getThreads,
|
||||
(threadsInTeam: Array<$ID<UserThread>>, threads: ThreadsState['threads']) => {
|
||||
const ids = threadsInTeam.filter((id) => {
|
||||
return threads[id]?.is_following;
|
||||
});
|
||||
return sortByLastReply(ids, threads);
|
||||
},
|
||||
);
|
||||
|
||||
export function getThreadTeamId(state: GlobalState, threadId: $ID<UserThread>): $ID<Team> {
|
||||
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<UserThread>> = createSelector(
|
||||
getThreadsInCurrentTeam,
|
||||
getThreads,
|
||||
(threadsInTeam: Array<$ID<UserThread>>, 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<UserThread>>, threads: ReturnType<typeof getThreads>) {
|
||||
return ids.sort((a, b) => threads[b].last_reply_at - threads[a].last_reply_at);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -480,6 +480,27 @@ export function makeGetProfilesByIdsAndUsernames(): (a: GlobalState, b: Array<$I
|
|||
);
|
||||
}
|
||||
|
||||
export function makeGetProfilesByIds(): (a: GlobalState, b: Array<$ID<UserProfile>>) => Array<UserProfile> {
|
||||
return createSelector(
|
||||
getUsers,
|
||||
(state: GlobalState, userIds: Array<$ID<UserProfile>>) => userIds,
|
||||
(allProfilesById: Dictionary<UserProfile>, userIds: Array<string>) => {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<ChannelNotifyProps>;
|
||||
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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<string, string>;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Post>;
|
||||
state?: 'DELETED';
|
||||
ownPost?: boolean;
|
||||
last_reply_at?: number;
|
||||
participants: Array<UserProfile | {id: $ID<UserProfile>}>;
|
||||
};
|
||||
|
||||
export type PostWithFormatData = Post & {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ export type PostsRequestsStatuses = {
|
|||
getPostThread: RequestStatusType;
|
||||
};
|
||||
|
||||
export type ThreadsRequestStatuses = {
|
||||
getThreads: RequestStatusType;
|
||||
};
|
||||
|
||||
export type TeamsRequestsStatuses = {
|
||||
getMyTeams: RequestStatusType;
|
||||
getTeams: RequestStatusType;
|
||||
|
|
|
|||
|
|
@ -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<Bot>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
35
app/mm-redux/types/threads.ts
Normal file
35
app/mm-redux/types/threads.ts
Normal file
|
|
@ -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>} | 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<Team, UserThread>;
|
||||
threads: IDMappedObjects<UserThread>;
|
||||
counts: RelationOneToOne<Team, {
|
||||
total: number;
|
||||
total_unread_threads: number;
|
||||
total_unread_mentions: number;
|
||||
}>;
|
||||
};
|
||||
|
|
@ -35,7 +35,7 @@ const channelTypeOrder = {
|
|||
*/
|
||||
export function buildDisplayableChannelListWithUnreadSection(usersState: UsersState, myChannels: Array<Channel>, myMembers: RelationOneToOne<Channel, ChannelMembership>, config: any, myPreferences: {
|
||||
[x: string]: PreferenceType;
|
||||
}, teammateNameDisplay: string, lastPosts: RelationOneToOne<Channel, Post>) {
|
||||
}, teammateNameDisplay: string, lastPosts: RelationOneToOne<Channel, Post>, 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<UserProfile>,
|
||||
lastPosts: RelationOneToOne<Channel, Post>,
|
||||
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, ChannelMemb
|
|||
return false;
|
||||
}
|
||||
|
||||
export function isUnreadChannel(members: RelationOneToOne<Channel, ChannelMembership>, channel: Channel): boolean {
|
||||
export function isUnreadChannel(members: RelationOneToOne<Channel, ChannelMembership>, 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<Channel>, myPreferences: {
|
|||
|
||||
function buildDirectAndGroupChannels(channels: Array<Channel>, memberships: RelationOneToOne<Channel, ChannelMembership>, config: any, myPreferences: {
|
||||
[x: string]: PreferenceType;
|
||||
}, currentUserId: string, users: IDMappedObjects<UserProfile>, lastPosts: RelationOneToOne<Channel, Post>): Array<Channel> {
|
||||
}, currentUserId: string, users: IDMappedObjects<UserProfile>, lastPosts: RelationOneToOne<Channel, Post>, collapsedThreadsEnabled: boolean): Array<Channel> {
|
||||
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<Channel>, 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
35
app/reducers/views/threads.ts
Normal file
35
app/reducers/views/threads.ts
Normal file
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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 = (
|
||||
<KeyboardLayout>
|
||||
<View
|
||||
testID='channel.screen'
|
||||
style={style.flex}
|
||||
>
|
||||
<ChannelPostList registerTypingAnimation={this.registerTypingAnimation}/>
|
||||
</View>
|
||||
<PostDraft
|
||||
testID='channel.post_draft'
|
||||
ref={this.postDraft}
|
||||
screenId={this.props.componentId}
|
||||
registerTypingAnimation={this.registerTypingAnimation}
|
||||
/>
|
||||
</KeyboardLayout>
|
||||
<GlobalThreadsList/>
|
||||
);
|
||||
} else {
|
||||
component = this.renderLoadingOrFailedChannel();
|
||||
if (!component) {
|
||||
component = (
|
||||
<KeyboardLayout>
|
||||
<View
|
||||
testID='channel.screen'
|
||||
style={style.flex}
|
||||
>
|
||||
<ChannelPostList registerTypingAnimation={this.registerTypingAnimation}/>
|
||||
</View>
|
||||
<PostDraft
|
||||
testID='channel.post_draft'
|
||||
ref={this.postDraft}
|
||||
screenId={this.props.componentId}
|
||||
registerTypingAnimation={this.registerTypingAnimation}
|
||||
/>
|
||||
</KeyboardLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const drawerContent = (
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<>
|
||||
<ChannelPostList registerTypingAnimation={this.registerTypingAnimation}/>
|
||||
</>
|
||||
<GlobalThreadsList/>
|
||||
);
|
||||
} else {
|
||||
safeAreaEdges.push('bottom');
|
||||
component = this.renderLoadingOrFailedChannel();
|
||||
if (!component) {
|
||||
renderDraftArea = true;
|
||||
component = (
|
||||
<>
|
||||
<ChannelPostList registerTypingAnimation={this.registerTypingAnimation}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
<SafeAreaView
|
||||
mode='margin'
|
||||
edges={['left', 'right', 'bottom']}
|
||||
edges={safeAreaEdges}
|
||||
style={style.flex}
|
||||
>
|
||||
{component}
|
||||
</SafeAreaView>
|
||||
{indicators}
|
||||
<View nativeID={ACCESSORIES_CONTAINER_NATIVE_ID}>
|
||||
<Autocomplete
|
||||
maxHeight={DEVICE.AUTOCOMPLETE_MAX_HEIGHT}
|
||||
onChangeText={this.handleAutoComplete}
|
||||
cursorPositionEvent={CHANNEL_POST_TEXTBOX_CURSOR_CHANGE}
|
||||
valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE}
|
||||
channelId={currentChannelId}
|
||||
offsetY={0}
|
||||
/>
|
||||
</View>
|
||||
{!viewingGlobalThreads && (
|
||||
<View nativeID={ACCESSORIES_CONTAINER_NATIVE_ID}>
|
||||
<Autocomplete
|
||||
maxHeight={DEVICE.AUTOCOMPLETE_MAX_HEIGHT}
|
||||
onChangeText={this.handleAutoComplete}
|
||||
cursorPositionEvent={CHANNEL_POST_TEXTBOX_CURSOR_CHANGE}
|
||||
valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE}
|
||||
channelId={currentChannelId}
|
||||
offsetY={0}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{renderDraftArea &&
|
||||
<PostDraft
|
||||
testID='channel.post_draft'
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ export default class ChannelBase extends PureComponent {
|
|||
teamName: PropTypes.string,
|
||||
theme: PropTypes.object.isRequired,
|
||||
showTermsOfService: PropTypes.bool,
|
||||
skipMetrics: PropTypes.bool,
|
||||
viewingGlobalThreads: PropTypes.bool,
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
|
|
@ -45,6 +47,7 @@ export default class ChannelBase extends PureComponent {
|
|||
|
||||
static defaultProps = {
|
||||
disableTermsModal: false,
|
||||
viewingGlobalThreads: false,
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Dimensions, Platform, View} from 'react-native';
|
||||
import {Dimensions, Platform, View, Text} from 'react-native';
|
||||
import AsyncStorage from '@react-native-community/async-storage';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ import MainSidebarDrawerButton from './main_sidebar_drawer_button';
|
|||
import ChannelSearchButton from './channel_search_button';
|
||||
import ChannelTitle from './channel_title';
|
||||
import SettingsSidebarDrawerButton from './settings_sidebar_drawer_button';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
|
||||
const {
|
||||
ANDROID_TOP_LANDSCAPE,
|
||||
|
|
@ -42,11 +43,26 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
},
|
||||
}),
|
||||
},
|
||||
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 = (<View style={style.wrapper}>
|
||||
<Text
|
||||
ellipsizeMode='tail'
|
||||
numberOfLines={1}
|
||||
style={style.threadsTitle}
|
||||
>
|
||||
<FormattedText
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
id='threads'
|
||||
defaultMessage='Threads'
|
||||
/>
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
title = (
|
||||
<ChannelTitle
|
||||
onPress={onPress}
|
||||
canHaveSubtitle={canHaveSubtitle}
|
||||
/>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
onLayout={onLayout}
|
||||
|
|
@ -134,10 +177,7 @@ const ChannelNavBar = (props) => {
|
|||
openSidebar={openMainSidebar}
|
||||
visible={drawerButtonVisible()}
|
||||
/>
|
||||
<ChannelTitle
|
||||
onPress={onPress}
|
||||
canHaveSubtitle={canHaveSubtitle}
|
||||
/>
|
||||
{title}
|
||||
<ChannelSearchButton
|
||||
theme={theme}
|
||||
/>
|
||||
|
|
@ -152,6 +192,7 @@ ChannelNavBar.propTypes = {
|
|||
openSettingsSidebar: PropTypes.func.isRequired,
|
||||
onPress: PropTypes.func.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
isGlobalThreads: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default ChannelNavBar;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue