From 3383c93df8ac75402e23fecc97c27df3192d9c04 Mon Sep 17 00:00:00 2001 From: Miguel Alatzar Date: Mon, 17 Feb 2020 21:23:39 -0700 Subject: [PATCH] Performance improvements (#3911) (#3941) * Improve sidebar performance on first load * Initial work for switch channel * Revert android changes * Split Sidebar per Platform * Fix waitForHydration executing the callback more than once * Fix custom emoji not showing on Android * Finalize Channel Switch * Enable Android Ram Bundles * Select the right team for lastChannelForTeam * Channel loading post indicator * Fix main sidebar base intl provider * Update mm-redux * No need to request configAndLicense on launch * Load channel member roles * Rename closeChannelDrawer to closeMainSidebar * do not throw errors when console is called while running tests * constant for LOADING_POSTS_HEIGHT * Remove show more if a long post is edited and no longer long * Update mm-redux#batch-actions branch * Code review * Clear notifications if channel was switched * Import Platform Co-authored-by: Miguel Alatzar Co-authored-by: Elias Nahum --- app/actions/navigation/index.js | 183 ++++++-- app/actions/navigation/index.test.js | 41 +- app/actions/views/channel.js | 355 +++++++++------ app/actions/views/channel.test.js | 56 ++- app/actions/views/root.js | 2 +- app/actions/views/user.js | 179 +++++++- app/components/emoji/emoji.js | 3 +- .../message_attachments/attachment_text.js | 5 +- .../message_attachments/message_attachment.js | 1 + app/components/post/post.js | 4 +- app/components/post_body/post_body.js | 1 + app/components/post_body/post_body.test.js | 1 - .../__snapshots__/post_list.test.js.snap | 36 +- app/components/post_list/post_list.js | 29 +- app/components/post_list/post_list.test.js | 4 +- app/components/show_more_button/index.js | 145 +++++- .../show_more_button/show_more_button.js | 145 ------ .../show_more_button/show_more_button.test.js | 2 +- app/components/sidebars/drawer_layout.js | 11 +- .../main/channels_list/channels_list.js | 1 - .../main/drawer_swiper/drawer_swiper.js | 5 +- app/components/sidebars/main/index.js | 12 +- .../sidebars/main/main_sidebar.android.js | 93 ++++ .../sidebars/main/main_sidebar.ios.js | 135 ++++++ app/components/sidebars/main/main_sidebar.js | 424 ------------------ .../sidebars/main/main_sidebar.test.js | 16 +- .../sidebars/main/main_sidebar_base.js | 301 +++++++++++++ .../sidebars/main/teams_list/teams_list.js | 6 +- app/components/sidebars/settings/index.js | 5 +- .../settings/settings_sidebar.android.js | 112 +++++ .../sidebars/settings/settings_sidebar.ios.js | 178 ++++++++ .../sidebars/settings/settings_sidebar.js | 417 ----------------- .../settings/settings_sidebar.test.js | 57 --- .../settings/settings_sidebar_base.js | 278 ++++++++++++ app/constants/navigation.js | 5 + app/constants/view.js | 6 - app/mattermost.js | 22 +- app/reducers/views/channel.js | 73 +-- app/reducers/views/channel.test.js | 113 ----- app/reducers/views/team.js | 59 +-- app/screens/channel/channel.android.js | 63 ++- app/screens/channel/channel.ios.js | 81 +++- app/screens/channel/channel_base.js | 236 ++++------ app/screens/channel/channel_base.test.js | 8 +- .../channel_nav_bar.test.js.snap | 4 +- .../channel_drawer_button.js | 4 +- .../channel_drawer_button.test.js | 2 +- .../channel_nav_bar/channel_nav_bar.js | 10 +- .../channel_nav_bar/channel_nav_bar.test.js | 4 +- .../channel_nav_bar/settings_drawer_button.js | 4 +- .../channel_post_list/channel_post_list.js | 60 +-- .../channel_post_list.test.js | 40 -- .../channel/channel_post_list/index.js | 5 +- app/screens/channel/index.js | 15 +- app/screens/create_channel/create_channel.js | 7 +- app/screens/edit_channel/edit_channel.js | 4 +- app/screens/index.js | 6 + app/screens/login/index.js | 4 +- app/screens/login/login.js | 48 +- app/screens/login/login.test.js | 40 +- app/screens/mfa/index.js | 4 +- app/screens/mfa/mfa.js | 28 +- app/screens/more_channels/more_channels.js | 3 +- app/screens/more_dms/more_dms.js | 3 +- app/screens/notification/notification.js | 8 +- app/screens/select_team/select_team.js | 3 +- .../display_settings/display_settings.js | 7 +- app/screens/settings/sidebar/sidebar.test.js | 2 +- app/screens/sso/index.js | 8 +- app/screens/sso/sso.js | 25 +- app/store/store.js | 38 +- app/utils/channels.js | 53 +++ app/utils/preferences.js | 11 + app/utils/push_notifications.js | 8 +- package-lock.json | 4 +- package.json | 2 +- test/setup.js | 7 - 77 files changed, 2361 insertions(+), 2019 deletions(-) delete mode 100644 app/components/show_more_button/show_more_button.js create mode 100644 app/components/sidebars/main/main_sidebar.android.js create mode 100644 app/components/sidebars/main/main_sidebar.ios.js delete mode 100644 app/components/sidebars/main/main_sidebar.js create mode 100644 app/components/sidebars/main/main_sidebar_base.js create mode 100644 app/components/sidebars/settings/settings_sidebar.android.js create mode 100644 app/components/sidebars/settings/settings_sidebar.ios.js delete mode 100644 app/components/sidebars/settings/settings_sidebar.js delete mode 100644 app/components/sidebars/settings/settings_sidebar.test.js create mode 100644 app/components/sidebars/settings/settings_sidebar_base.js delete mode 100644 app/screens/channel/channel_post_list/channel_post_list.test.js create mode 100644 app/utils/preferences.js diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js index cfbacdb44..48ae3da72 100644 --- a/app/actions/navigation/index.js +++ b/app/actions/navigation/index.js @@ -1,9 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Platform} from 'react-native'; +import {Keyboard, Platform} from 'react-native'; import {Navigation} from 'react-native-navigation'; - import merge from 'deepmerge'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; @@ -20,38 +19,60 @@ function getThemeFromState() { export function resetToChannel(passProps = {}) { const theme = getThemeFromState(); - Navigation.setRoot({ - root: { - stack: { - children: [{ - component: { - name: 'Channel', - passProps, - options: { - layout: { - backgroundColor: 'transparent', - }, - statusBar: { - visible: true, - }, - topBar: { - visible: false, - height: 0, - backButton: { - color: theme.sidebarHeaderTextColor, - title: '', - }, - background: { - color: theme.sidebarHeaderBg, - }, - title: { - color: theme.sidebarHeaderTextColor, - }, - }, + const stack = { + children: [{ + component: { + id: 'Channel', + name: 'Channel', + passProps, + options: { + layout: { + backgroundColor: theme.centerChannelBg, + }, + statusBar: { + visible: true, + }, + topBar: { + visible: false, + height: 0, + background: { + color: theme.sidebarHeaderBg, + }, + backButton: { + visible: false, }, }, - }], + }, }, + }], + }; + + let platformStack = {stack}; + if (Platform.OS === 'android') { + platformStack = { + sideMenu: { + left: { + component: { + id: 'MainSidebar', + name: 'MainSidebar', + }, + }, + center: { + stack, + }, + right: { + component: { + id: 'SettingsSidebar', + name: 'SettingsSidebar', + }, + }, + }, + }; + } + + Navigation.setRoot({ + root: { + ...platformStack, }, }); } @@ -64,6 +85,7 @@ export function resetToSelectServer(allowOtherServers) { stack: { children: [{ component: { + id: 'SelectServer', name: 'SelectServer', passProps: { allowOtherServers, @@ -121,6 +143,7 @@ export function resetToTeams(name, title, passProps = {}, options = {}) { stack: { children: [{ component: { + id: name, name, passProps, options: merge(defaultOptions, options), @@ -138,6 +161,10 @@ export function goToScreen(name, title, passProps = {}, options = {}) { layout: { backgroundColor: theme.centerChannelBg, }, + sideMenu: { + left: {enabled: false}, + right: {enabled: false}, + }, topBar: { animate: true, visible: true, @@ -157,6 +184,7 @@ export function goToScreen(name, title, passProps = {}, options = {}) { Navigation.push(componentId, { component: { + id: name, name, passProps, options: merge(defaultOptions, options), @@ -216,6 +244,7 @@ export function showModal(name, title, passProps = {}, options = {}) { stack: { children: [{ component: { + id: name, name, passProps, options: merge(defaultOptions, options), @@ -295,23 +324,6 @@ export async function dismissAllModals(options = {}) { } } -export function peek(name, passProps = {}, options = {}) { - const componentId = EphemeralStore.getNavigationTopComponentId(); - const defaultOptions = { - preview: { - commit: false, - }, - }; - - Navigation.push(componentId, { - component: { - name, - passProps, - options: merge(defaultOptions, options), - }, - }); -} - export function setButtons(componentId, buttons = {leftButtons: [], rightButtons: []}) { const options = { topBar: { @@ -335,6 +347,7 @@ export function showOverlay(name, passProps, options = {}) { Navigation.showOverlay({ component: { + id: name, name, passProps, options: merge(defaultOptions, options), @@ -350,3 +363,77 @@ export async function dismissOverlay(componentId) { // this componentId to dismiss. We'll do nothing in this case. } } + +export function openMainSideMenu() { + if (Platform.OS === 'ios') { + return; + } + + const componentId = EphemeralStore.getNavigationTopComponentId(); + + Keyboard.dismiss(); + Navigation.mergeOptions(componentId, { + sideMenu: { + left: {visible: true}, + }, + }); +} + +export function closeMainSideMenu() { + if (Platform.OS === 'ios') { + return; + } + + const componentId = EphemeralStore.getNavigationTopComponentId(); + + Keyboard.dismiss(); + Navigation.mergeOptions(componentId, { + sideMenu: { + left: {visible: false}, + }, + }); +} + +export function enableMainSideMenu(enabled, visible = true) { + if (Platform.OS === 'ios') { + return; + } + + const componentId = EphemeralStore.getNavigationTopComponentId(); + + Navigation.mergeOptions(componentId, { + sideMenu: { + left: {enabled, visible}, + }, + }); +} + +export function openSettingsSideMenu() { + if (Platform.OS === 'ios') { + return; + } + + const componentId = EphemeralStore.getNavigationTopComponentId(); + + Keyboard.dismiss(); + Navigation.mergeOptions(componentId, { + sideMenu: { + right: {visible: true}, + }, + }); +} + +export function closeSettingsSideMenu() { + if (Platform.OS === 'ios') { + return; + } + + const componentId = EphemeralStore.getNavigationTopComponentId(); + + Keyboard.dismiss(); + Navigation.mergeOptions(componentId, { + sideMenu: { + right: {visible: false}, + }, + }); +} diff --git a/app/actions/navigation/index.test.js b/app/actions/navigation/index.test.js index cdb5d93cc..ad0cf0014 100644 --- a/app/actions/navigation/index.test.js +++ b/app/actions/navigation/index.test.js @@ -37,11 +37,12 @@ describe('app/actions/navigation', () => { stack: { children: [{ component: { + id: 'Channel', name: 'Channel', passProps, options: { layout: { - backgroundColor: 'transparent', + backgroundColor: theme.centerChannelBg, }, statusBar: { visible: true, @@ -50,15 +51,11 @@ describe('app/actions/navigation', () => { visible: false, height: 0, backButton: { - color: theme.sidebarHeaderTextColor, - title: '', + visible: false, }, background: { color: theme.sidebarHeaderBg, }, - title: { - color: theme.sidebarHeaderTextColor, - }, }, }, }, @@ -80,6 +77,7 @@ describe('app/actions/navigation', () => { stack: { children: [{ component: { + id: 'SelectServer', name: 'SelectServer', passProps: { allowOtherServers, @@ -141,6 +139,7 @@ describe('app/actions/navigation', () => { stack: { children: [{ component: { + id: name, name, passProps, options: merge(defaultOptions, options), @@ -161,6 +160,10 @@ describe('app/actions/navigation', () => { layout: { backgroundColor: theme.centerChannelBg, }, + sideMenu: { + left: {enabled: false}, + right: {enabled: false}, + }, topBar: { animate: true, visible: true, @@ -180,6 +183,7 @@ describe('app/actions/navigation', () => { const expectedLayout = { component: { + id: name, name, passProps, options: merge(defaultOptions, options), @@ -241,6 +245,7 @@ describe('app/actions/navigation', () => { stack: { children: [{ component: { + id: name, name, passProps, options: merge(defaultOptions, options), @@ -317,6 +322,7 @@ describe('app/actions/navigation', () => { stack: { children: [{ component: { + id: name, name, passProps, options: merge(showModalOptions, defaultOptions), @@ -372,6 +378,7 @@ describe('app/actions/navigation', () => { stack: { children: [{ component: { + id: showSearchModalName, name: showSearchModalName, passProps: showSearchModalPassProps, options: merge(defaultOptions, showSearchModalOptions), @@ -398,27 +405,6 @@ describe('app/actions/navigation', () => { expect(dismissAllModals).toHaveBeenCalledWith(options); }); - test('peek should call Navigation.push', async () => { - const push = jest.spyOn(Navigation, 'push'); - - const defaultOptions = { - preview: { - commit: false, - }, - }; - - const expectedLayout = { - component: { - name, - passProps, - options: merge(defaultOptions, options), - }, - }; - - await NavigationActions.peek(name, passProps, options); - expect(push).toHaveBeenCalledWith(topComponentId, expectedLayout); - }); - test('mergeNavigationOptions should call Navigation.mergeOptions', () => { const mergeOptions = jest.spyOn(Navigation, 'mergeOptions'); @@ -454,6 +440,7 @@ describe('app/actions/navigation', () => { const expectedLayout = { component: { + id: name, name, passProps, options: merge(defaultOptions, options), diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index be1451f93..bc5c4e1cd 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -5,15 +5,13 @@ import {batchActions} from 'redux-batched-actions'; import {ViewTypes} from 'app/constants'; -import {UserTypes} from 'mattermost-redux/action_types'; +import {UserTypes, ChannelTypes} from 'mattermost-redux/action_types'; import { fetchMyChannelsAndMembers, getChannelByNameAndTeamName, markChannelAsRead, markChannelAsViewed, leaveChannel as serviceLeaveChannel, - selectChannel, - getChannelStats, } from 'mattermost-redux/actions/channels'; import { getPosts, @@ -23,19 +21,22 @@ import { } from 'mattermost-redux/actions/posts'; import {getFilesForPost} from 'mattermost-redux/actions/files'; import {savePreferences} from 'mattermost-redux/actions/preferences'; +import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; import {getTeamMembersByIds, selectTeam} from 'mattermost-redux/actions/teams'; import {getProfilesInChannel} from 'mattermost-redux/actions/users'; +import {Client4} from 'mattermost-redux/client'; import {General, Preferences} from 'mattermost-redux/constants'; import {getPostIdsInChannel} from 'mattermost-redux/selectors/entities/posts'; import { - getChannel, getCurrentChannelId, - getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, isManuallyUnread, } from 'mattermost-redux/selectors/entities/channels'; -import {getCurrentTeamId, getTeamByName} from 'mattermost-redux/selectors/entities/teams'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getMyPreferences} from 'mattermost-redux/selectors/entities/preferences'; +import {getCurrentUserId, getUserIdsInChannels, getUsers} from 'mattermost-redux/selectors/entities/users'; +import {getTeamByName} from 'mattermost-redux/selectors/entities/teams'; import {getChannelReachable} from 'app/selectors/channel'; @@ -54,15 +55,12 @@ import {getLastCreateAt} from 'mattermost-redux/utils/post_utils'; import {getPreferencesByCategory} from 'mattermost-redux/utils/preference_utils'; import {INSERT_TO_COMMENT, INSERT_TO_DRAFT} from 'app/constants/post_textbox'; -import {isDirectChannelVisible, isGroupChannelVisible} from 'app/utils/channels'; +import {isDirectChannelVisible, isGroupChannelVisible, isDirectMessageVisible, isGroupMessageVisible, isDirectChannelAutoClosed} from 'app/utils/channels'; +import {buildPreference} from 'app/utils/preferences'; -const MAX_POST_TRIES = 3; +import {forceLogoutIfNecessary} from './user'; -export function loadChannelsIfNecessary(teamId) { - return async (dispatch) => { - await dispatch(fetchMyChannelsAndMembers(teamId)); - }; -} +const MAX_RETRIES = 3; export function loadChannelsByTeamName(teamName, errorHandler) { return async (dispatch, getState) => { @@ -190,22 +188,10 @@ export function loadPostsIfNecessaryWithRetry(channelId) { const time = Date.now(); let loadMorePostsVisible = true; - let received; + let postAction; if (!postsIds || postsIds.length < ViewTypes.POST_VISIBILITY_CHUNK_SIZE) { // Get the first page of posts if it appears we haven't gotten it yet, like the webapp - received = await retryGetPostsAction(getPosts(channelId), dispatch, getState); - - if (received?.order) { - const count = received.order.length; - loadMorePostsVisible = count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE; - actions.push({ - type: ViewTypes.SET_INITIAL_POST_COUNT, - data: { - channelId, - count, - }, - }); - } + postAction = getPosts(channelId); } else { const lastConnectAt = state.websocket?.lastConnectAt || 0; const lastGetPosts = state.views.channel.lastGetPosts[channelId]; @@ -221,27 +207,22 @@ export function loadPostsIfNecessaryWithRetry(channelId) { since = getLastCreateAt(postsForChannel); } - received = await retryGetPostsAction(getPostsSince(channelId, since), dispatch, getState); - - if (received?.order) { - const count = received.order.length; - loadMorePostsVisible = postsIds.length + count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE; - actions.push({ - type: ViewTypes.SET_INITIAL_POST_COUNT, - data: { - channelId, - count: postsIds.length + count, - }, - }); - } + postAction = getPostsSince(channelId, since); } + const received = await retryGetPostsAction(postAction, dispatch, getState); + if (received) { actions.push({ type: ViewTypes.RECEIVED_POSTS_FOR_CHANNEL_AT_TIME, channelId, time, }); + + if (received?.order) { + const count = received.order.length; + loadMorePostsVisible = count >= ViewTypes.POST_VISIBILITY_CHUNK_SIZE; + } } actions.push(setLoadMorePostsVisible(loadMorePostsVisible)); @@ -249,7 +230,7 @@ export function loadPostsIfNecessaryWithRetry(channelId) { }; } -export async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_POST_TRIES) { +export async function retryGetPostsAction(action, dispatch, getState, maxTries = MAX_RETRIES) { for (let i = 0; i < maxTries; i++) { const {data} = await dispatch(action); // eslint-disable-line no-await-in-loop @@ -352,7 +333,6 @@ export function selectDefaultChannel(teamId) { const channelsInTeam = getChannelsNameMapInTeam(state, teamId); const channel = getChannelByNameSelector(channelsInTeam, getRedirectChannelNameForTeam(state, teamId)); - let channelId; if (channel) { channelId = channel.id; @@ -372,37 +352,31 @@ export function selectDefaultChannel(teamId) { export function handleSelectChannel(channelId, fromPushNotification = false) { return async (dispatch, getState) => { + const dt = Date.now(); const state = getState(); - const channel = getChannel(state, channelId); - const currentTeamId = getCurrentTeamId(state); - const currentChannelId = getCurrentChannelId(state); - const sameChannel = channelId === currentChannelId; - const member = getMyChannelMember(state, channelId); - - dispatch(setLoadMorePostsVisible(true)); + const {channels, myMembers} = state.entities.channels; + const {currentTeamId} = state.entities.teams; + const channel = channels[channelId]; + const member = myMembers[channelId]; // If the app is open from push notification, we already fetched the posts. if (!fromPushNotification) { dispatch(loadPostsIfNecessaryWithRetry(channelId)); } - let previousChannelId; - if (!fromPushNotification && !sameChannel) { - previousChannelId = currentChannelId; + if (channel && member) { + dispatch({ + type: ChannelTypes.SELECT_CHANNEL, + data: channelId, + extra: { + channel, + member, + teamId: currentTeamId, + }, + }); } - const actions = [ - selectChannel(channelId), - getChannelStats(channelId), - setChannelDisplayName(channel.display_name), - setInitialPostVisibility(channelId), - setChannelLoading(false), - setLastChannelForTeam(currentTeamId, channelId), - selectChannelWithMember(channelId, channel, member), - ]; - - dispatch(batchActions(actions)); - dispatch(markChannelViewedAndRead(channelId, previousChannelId)); + console.log('channel switch in', channel?.display_name, (Date.now() - dt), 'ms'); //eslint-disable-line }; } @@ -608,8 +582,7 @@ export function setChannelDisplayName(displayName) { export function increasePostVisibility(channelId, postId) { return async (dispatch, getState) => { const state = getState(); - const {loadingPosts, postVisibility} = state.views.channel; - const currentPostVisibility = postVisibility[channelId] || 0; + const {loadingPosts} = state.views.channel; if (loadingPosts[channelId]) { return true; @@ -620,20 +593,6 @@ export function increasePostVisibility(channelId, postId) { return true; } - // Check if we already have the posts that we want to show - const loadedPostCount = state.views.channel.postCountInChannel[channelId] || 0; - const desiredPostVisibility = currentPostVisibility + ViewTypes.POST_VISIBILITY_CHUNK_SIZE; - - if (loadedPostCount >= desiredPostVisibility) { - // We already have the posts, so we just need to show them - dispatch(batchActions([ - doIncreasePostVisibility(channelId), - setLoadMorePostsVisible(true), - ])); - - return true; - } - telemetry.reset(); telemetry.start(['posts:loading']); @@ -658,18 +617,6 @@ export function increasePostVisibility(channelId, postId) { const count = result.order.length; hasMorePost = count >= pageSize; - actions.push({ - type: ViewTypes.INCREASE_POST_COUNT, - data: { - channelId, - count, - }, - }); - - // make sure to increment the posts visibility - // only if we got results - actions.push(doIncreasePostVisibility(channelId)); - actions.push(setLoadMorePostsVisible(hasMorePost)); } @@ -681,24 +628,6 @@ export function increasePostVisibility(channelId, postId) { }; } -export function increasePostVisibilityByOne(channelId) { - return (dispatch) => { - dispatch({ - type: ViewTypes.INCREASE_POST_VISIBILITY, - data: channelId, - amount: 1, - }); - }; -} - -function doIncreasePostVisibility(channelId) { - return { - type: ViewTypes.INCREASE_POST_VISIBILITY, - data: channelId, - amount: ViewTypes.POST_VISIBILITY_CHUNK_SIZE, - }; -} - function setLoadMorePostsVisible(visible) { return { type: ViewTypes.SET_LOAD_MORE_POSTS_VISIBLE, @@ -706,26 +635,202 @@ function setLoadMorePostsVisible(visible) { }; } -function setInitialPostVisibility(channelId) { - return { - type: ViewTypes.SET_INITIAL_POST_VISIBILITY, - data: channelId, +export function loadChannelsForTeam(teamId) { + return async (dispatch, getState) => { + const state = getState(); + const currentUserId = getCurrentUserId(state); + + if (currentUserId) { + const data = {sync: true, teamId}; + for (let i = 0; i < MAX_RETRIES; i++) { + try { + console.log('Fetching channels attempt', (i + 1)); //eslint-disable-line no-console + const [channels, channelMembers] = await Promise.all([ //eslint-disable-line no-await-in-loop + Client4.getMyChannels(teamId), + Client4.getMyChannelMembers(teamId), + ]); + + data.channels = channels; + data.channelMembers = channelMembers; + break; + } catch (error) { + const result = await dispatch(forceLogoutIfNecessary(error)); //eslint-disable-line no-await-in-loop + if (result || i === MAX_RETRIES) { + return {error}; + } + } + } + + if (data.channels) { + const roles = new Set(); + const members = data.channelMembers; + for (const member of members) { + for (const role of member.roles.split(' ')) { + roles.add(role); + } + } + + if (roles.size > 0) { + dispatch(loadRolesIfNeeded(roles)); + } + + // Fetch needed profiles from channel creators and direct channels + dispatch(loadSidebarDirectMessagesProfiles(data)); + + dispatch({ + type: ChannelTypes.RECEIVED_MY_CHANNELS_WITH_MEMBERS, + data, + }); + } + + return {data}; + } + + return {error: 'Cannot fetch channels without a current user'}; }; } -function setLastChannelForTeam(teamId, channelId) { - return { - type: ViewTypes.SET_LAST_CHANNEL_FOR_TEAM, - teamId, - channelId, +export function loadSidebarDirectMessagesProfiles(data) { + return async (dispatch, getState) => { + const state = getState(); + const {channels, channelMembers} = data; + const currentUserId = getCurrentUserId(state); + const usersInChannel = getUserIdsInChannels(state); + const directChannels = Object.values(channels).filter((c) => c.type === General.DM_CHANNEL || c.type === General.GM_CHANNEL); + const prefs = []; + const promises = []; //only fetch profiles that we don't have and the Direct channel should be visible + + // Prepare preferences and start fetching profiles to batch them + directChannels.forEach((c) => { + const profilesInChannel = Array.from(usersInChannel[c.id] || []).filter((u) => u.id !== currentUserId); + switch (c.type) { + case General.DM_CHANNEL: { + const dm = fetchDirectMessageProfileIfNeeded(state, c, channelMembers, profilesInChannel, prefs); + + if (dm) { + promises.push(dispatch(dm)); + } + break; + } + case General.GM_CHANNEL: { + const gm = fetchGroupMessageProfilesIfNeeded(state, c, channelMembers, profilesInChannel, prefs); + + if (gm) { + promises.push(dispatch(gm)); + } + break; + } + } + }); + + // Save preferences if there are any changes + if (prefs.length) { + dispatch(savePreferences(currentUserId, prefs)); + } + + getProfilesFromPromises(dispatch, promises, directChannels); + + return {data: true}; }; } -function selectChannelWithMember(channelId, channel, member) { - return { - type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER, - data: channelId, - channel, - member, +export function getUsersInChannel(channelId) { + return async (dispatch, getState) => { + try { + const state = getState(); + const currentUserId = getCurrentUserId(state); + const profiles = await Client4.getProfilesInChannel(channelId); + + // When fetching profiles in channels we exclude our own user + const users = profiles.filter((p) => p.id !== currentUserId); + const data = { + channelId, + users, + }; + + return {data}; + } catch (error) { + return {error}; + } }; } + +async function getProfilesFromPromises(dispatch, promiseArray, directChannels) { + // Get the profiles returned by the promises and retry those that failed + let promises = promiseArray; + for (let i = 0; i < MAX_RETRIES; i++) { + if (!promises.length) { + return; + } + + const result = await Promise.all(promises); //eslint-disable-line no-await-in-loop + const failed = []; + + result.forEach((p, index) => { + if (p.error) { + failed.push(directChannels[index].id); + } + }); + + dispatch({ + type: UserTypes.RECEIVED_BATCHED_PROFILES_IN_CHANNEL, + data: result, + }); + + if (failed.length) { + promises = failed.map((id) => dispatch(getUsersInChannel(id))); //eslint-disable-line no-loop-func + continue; + } + + return; + } +} + +function fetchDirectMessageProfileIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) { + const currentUserId = getCurrentUserId(state); + const preferences = getMyPreferences(state); + const users = getUsers(state); + const config = getConfig(state); + const currentChannelId = getCurrentChannelId(state); + const otherUserId = getUserIdFromChannelName(currentUserId, channel.name); + const otherUser = users[otherUserId]; + const dmVisible = isDirectMessageVisible(preferences, channel.id); + const dmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at, otherUser?.delete_at, currentChannelId); //eslint-disable-line camelcase + const dmIsUnread = channelMembers[channel.id]?.mention_count > 0; //eslint-disable-line camelcase + const dmFetchProfile = dmIsUnread || (dmVisible && !dmAutoClosed); + + // when then DM is hidden but has new messages + if ((!dmVisible || dmAutoClosed) && dmIsUnread) { + newPreferences.push(buildPreference(Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, currentUserId, otherUserId)); + newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString())); + } + + if (dmFetchProfile && !profilesInChannel.includes(otherUserId) && otherUserId !== currentUserId) { + return getUsersInChannel(channel.id); + } + + return null; +} + +function fetchGroupMessageProfilesIfNeeded(state, channel, channelMembers, profilesInChannel, newPreferences) { + const currentUserId = getCurrentUserId(state); + const preferences = getMyPreferences(state); + const config = getConfig(state); + const gmVisible = isGroupMessageVisible(preferences, channel.id); + const gmAutoClosed = isDirectChannelAutoClosed(config, preferences, channel.id, channel.last_post_at); + const channelMember = channelMembers[channel.id]; + const gmIsUnread = channelMember?.mention_count > 0 || channelMember?.msg_count < channel.total_msg_count; //eslint-disable-line camelcase + const gmFetchProfile = gmIsUnread || (gmVisible && !gmAutoClosed); + + // when then GM is hidden but has new messages + if ((!gmVisible || gmAutoClosed) && gmIsUnread) { + newPreferences.push(buildPreference(Preferences.CATEGORY_GROUP_CHANNEL_SHOW, currentUserId, channel.id)); + newPreferences.push(buildPreference(Preferences.CATEGORY_CHANNEL_OPEN_TIME, currentUserId, channel.id, Date.now().toString())); + } + + if (gmFetchProfile && !profilesInChannel.length) { + return getUsersInChannel(channel.id); + } + + return null; +} \ No newline at end of file diff --git a/app/actions/views/channel.test.js b/app/actions/views/channel.test.js index ff8e8269b..de48887e8 100644 --- a/app/actions/views/channel.test.js +++ b/app/actions/views/channel.test.js @@ -5,7 +5,7 @@ import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import initialState from 'app/initial_state'; -import {ViewTypes} from 'app/constants'; +import {ChannelTypes} from 'mattermost-redux/action_types'; import testHelper from 'test/test_helper'; import * as ChannelActions from 'app/actions/views/channel'; @@ -116,14 +116,19 @@ describe('Actions.Views.Channel', () => { }, channels: { currentChannelId, + channels: { + 'channel-id': {id: 'channel-id', display_name: 'Test Channel'}, + }, + myMembers: { + 'channel-id': {channel_id: 'channel-id', user_id: currentUserId, mention_count: 0, msg_count: 0}, + }, }, teams: { + currentTeamId, teams: { - currentTeamId, - currentTeams: { - [currentTeamId]: { - name: currentTeamName, - }, + [currentTeamId]: { + id: currentTeamId, + name: currentTeamName, }, }, }, @@ -147,14 +152,13 @@ describe('Actions.Views.Channel', () => { const receivedChannel = storeActions.some((action) => action.type === MOCK_RECEIVE_CHANNEL_TYPE); expect(receivedChannel).toBe(true); - const storeBatchActions = storeActions.filter(({type}) => type === 'BATCHING_REDUCER.BATCH'); - const selectedChannel = storeBatchActions[0].payload.some((action) => action.type === MOCK_SELECT_CHANNEL_TYPE); + const selectedChannel = storeActions.some(({type}) => type === MOCK_RECEIVE_CHANNEL_TYPE); expect(selectedChannel).toBe(true); }); test('handleSelectChannelByName failure from null currentTeamName', async () => { const failStoreObj = {...storeObj}; - failStoreObj.entities.teams.teams.currentTeamId = 'not-in-current-teams'; + failStoreObj.entities.teams.currentTeamId = 'not-in-current-teams'; store = mockStore(failStoreObj); await store.dispatch(handleSelectChannelByName(currentChannelName, null)); @@ -168,6 +172,7 @@ describe('Actions.Views.Channel', () => { }); test('handleSelectChannelByName failure from no permission to channel', async () => { + store = mockStore({...storeObj}); actions.getChannelByNameAndTeamName = jest.fn(() => { return { type: 'MOCK_ERROR', @@ -283,29 +288,38 @@ describe('Actions.Views.Channel', () => { [`not-${currentChannelId}`, false], ]; test.each(handleSelectChannelCases)('handleSelectChannel dispatches selectChannelWithMember', async (channelId, fromPushNotification) => { - store = mockStore({...storeObj}); + const testObj = {...storeObj}; + testObj.entities.teams.currentTeamId = currentTeamId; + store = mockStore(testObj); await store.dispatch(handleSelectChannel(channelId, fromPushNotification)); const storeActions = store.getActions(); - const storeBatchActions = storeActions.find(({type}) => type === 'BATCHING_REDUCER.BATCH'); - const selectChannelWithMember = storeBatchActions.payload.find(({type}) => type === ViewTypes.SELECT_CHANNEL_WITH_MEMBER); + const selectChannelWithMember = storeActions.find(({type}) => type === ChannelTypes.SELECT_CHANNEL); const viewedAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_VIEWED); const readAction = storeActions.find(({type}) => type === MOCK_CHANNEL_MARK_AS_READ); const expectedSelectChannelWithMember = { - type: ViewTypes.SELECT_CHANNEL_WITH_MEMBER, + type: ChannelTypes.SELECT_CHANNEL, data: channelId, - channel: { - data: channelId, - }, - member: { - data: { - member: {}, + extra: { + channel: { + id: channelId, + display_name: 'Test Channel', }, + member: { + channel_id: channelId, + user_id: currentUserId, + mention_count: 0, + msg_count: 0, + }, + teamId: currentTeamId, }, - }; - expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember); + if (channelId.includes('not')) { + expect(selectChannelWithMember).toBe(undefined); + } else { + expect(selectChannelWithMember).toStrictEqual(expectedSelectChannelWithMember); + } expect(viewedAction).not.toBe(null); expect(readAction).not.toBe(null); }); diff --git a/app/actions/views/root.js b/app/actions/views/root.js index 6bed1877d..8778248c3 100644 --- a/app/actions/views/root.js +++ b/app/actions/views/root.js @@ -37,7 +37,7 @@ export function loadConfigAndLicense() { if (currentUserId) { if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' && license.IsLicensed === 'true' && license.DataRetention === 'true') { - getDataRetentionPolicy()(dispatch, getState); + dispatch(getDataRetentionPolicy()); } else { dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}}); } diff --git a/app/actions/views/user.js b/app/actions/views/user.js index ef631e891..1cd9a9e1e 100644 --- a/app/actions/views/user.js +++ b/app/actions/views/user.js @@ -1,10 +1,185 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {UserTypes} from 'mattermost-redux/action_types'; +import {GeneralTypes, UserTypes} from 'mattermost-redux/action_types'; +import {getDataRetentionPolicy} from 'mattermost-redux/actions/general'; +import * as HelperActions from 'mattermost-redux/actions/helpers'; +import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; +import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone'; +import {Client4} from 'mattermost-redux/client'; import {General} from 'mattermost-redux/constants'; +import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {isTimezoneEnabled} from 'mattermost-redux/selectors/entities/timezone'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {setAppCredentials} from 'app/init/credentials'; +import {setCSRFFromCookie} from 'app/utils/security'; +import {getDeviceTimezoneAsync} from 'app/utils/timezone'; + +const HTTP_UNAUTHORIZED = 401; + +export function completeLogin(user, deviceToken) { + return async (dispatch, getState) => { + const state = getState(); + const config = getConfig(state); + const license = getLicense(state); + const token = Client4.getToken(); + const url = Client4.getUrl(); + + setCSRFFromCookie(url); + setAppCredentials(deviceToken, user.id, token, url); + + // Set timezone + const enableTimezone = isTimezoneEnabled(state); + if (enableTimezone) { + const timezone = await getDeviceTimezoneAsync(); + dispatch(autoUpdateTimezone(timezone)); + } + + // Data retention + if (config.DataRetentionEnableMessageDeletion && config.DataRetentionEnableMessageDeletion === 'true' && + license.IsLicensed === 'true' && license.DataRetention === 'true') { + dispatch(getDataRetentionPolicy()); + } else { + dispatch({type: GeneralTypes.RECEIVED_DATA_RETENTION_POLICY, data: {}}); + } + }; +} + +export function loadMe(user, deviceToken) { + return async (dispatch, getState) => { + const state = getState(); + const data = {user}; + const deviceId = state.entities?.general?.deviceToken; + + try { + if (deviceId && !deviceToken) { + await Client4.attachDevice(deviceId); + } + + if (!user) { + data.user = await Client4.getMe(); + } + } catch (error) { + dispatch(forceLogoutIfNecessary(error)); + return {error}; + } + + try { + Client4.setUserId(data.user.id); + Client4.setUserRoles(data.user.roles); + + // 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 [teams, teamMembers, teamUnreads, preferences, 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(); + + dispatch({ + type: UserTypes.LOGIN, + data, + }); + + const roles = new Set(); + for (const role of data.user.roles.split(' ')) { + roles.add(role); + } + for (const teamMember of teamMembers) { + for (const role of teamMember.roles.split(' ')) { + roles.add(role); + } + } + if (roles.size > 0) { + dispatch(loadRolesIfNeeded(roles)); + } + } catch (error) { + console.log('login error', error.stack); // eslint-disable-line no-console + return {error}; + } + + return {data}; + }; +} + +export function login(loginId, password, mfaToken, ldapOnly = false) { + return async (dispatch, getState) => { + const state = getState(); + const deviceToken = state.entities?.general?.deviceToken; + let user; + + try { + user = await Client4.login(loginId, password, mfaToken, deviceToken, ldapOnly); + } catch (error) { + return {error}; + } + + const result = await dispatch(loadMe(user)); + + if (!result.error) { + dispatch(completeLogin(user, deviceToken)); + } + + return result; + }; +} + +export function ssoLogin(token) { + return async (dispatch) => { + Client4.setToken(token); + const result = await dispatch(loadMe()); + + if (!result.error) { + dispatch(completeLogin(result.data.user)); + } + + return result; + }; +} + +export function logout(skipServerLogout = false) { + return async (dispatch) => { + if (!skipServerLogout) { + try { + Client4.logout(); + } catch { + // Do nothing + } + } + + dispatch({type: UserTypes.LOGOUT_SUCCESS}); + }; +} + +export function forceLogoutIfNecessary(error) { + return async (dispatch, getState) => { + const state = getState(); + const currentUserId = getCurrentUserId(state); + + if (currentUserId && error.status_code === HTTP_UNAUTHORIZED && error.url && !error.url.includes('/login')) { + dispatch(logout(true)); + return true; + } + + return false; + }; +} + export function setCurrentUserStatusOffline() { return (dispatch, getState) => { const currentUserId = getCurrentUserId(getState()); @@ -18,3 +193,5 @@ export function setCurrentUserStatusOffline() { }); }; } + +HelperActions.forceLogoutIfNecessary = forceLogoutIfNecessary; \ No newline at end of file diff --git a/app/components/emoji/emoji.js b/app/components/emoji/emoji.js index 06fae7006..9ef918112 100644 --- a/app/components/emoji/emoji.js +++ b/app/components/emoji/emoji.js @@ -9,7 +9,6 @@ import { StyleSheet, Text, } from 'react-native'; -import FastImage from 'react-native-fast-image'; import CustomPropTypes from 'app/constants/custom_prop_types'; import ImageCacheManager from 'app/utils/image_cache_manager'; @@ -143,7 +142,7 @@ export default class Emoji extends React.PureComponent { } return ( - } diff --git a/app/components/message_attachments/message_attachment.js b/app/components/message_attachments/message_attachment.js index 143fc63dc..1c750b9b0 100644 --- a/app/components/message_attachments/message_attachment.js +++ b/app/components/message_attachments/message_attachment.js @@ -95,6 +95,7 @@ export default class MessageAttachment extends PureComponent { onPermalinkPress={onPermalinkPress} textStyles={textStyles} value={attachment.text} + theme={theme} /> - + <> - + ); diff --git a/app/components/post_body/post_body.js b/app/components/post_body/post_body.js index 0ddd542f0..c7da95d09 100644 --- a/app/components/post_body/post_body.js +++ b/app/components/post_body/post_body.js @@ -431,6 +431,7 @@ export default class PostBody extends PureComponent { } {this.renderPostAdditionalContent(blockStyles, messageStyle, textStyles)} diff --git a/app/components/post_body/post_body.test.js b/app/components/post_body/post_body.test.js index c8cb6d92a..ac62b6db6 100644 --- a/app/components/post_body/post_body.test.js +++ b/app/components/post_body/post_body.test.js @@ -92,7 +92,6 @@ describe('PostBody', () => { event.nativeEvent.layout.height = wrapper.state('maxHeight') - 1; instance.measurePost(event); expect(wrapper.state('isLongPost')).toEqual(false); - event.nativeEvent.layout.height = wrapper.state('maxHeight') + 1; instance.measurePost(event); expect(wrapper.state('isLongPost')).toEqual(true); diff --git a/app/components/post_list/__snapshots__/post_list.test.js.snap b/app/components/post_list/__snapshots__/post_list.test.js.snap index 353451061..81847c7eb 100644 --- a/app/components/post_list/__snapshots__/post_list.test.js.snap +++ b/app/components/post_list/__snapshots__/post_list.test.js.snap @@ -20,10 +20,11 @@ exports[`PostList setting channel deep link 1`] = ` "channel-id", undefined, undefined, + undefined, ] } horizontal={false} - initialNumToRender={15} + initialNumToRender={7} inverted={true} keyExtractor={[Function]} keyboardDismissMode="interactive" @@ -34,7 +35,7 @@ exports[`PostList setting channel deep link 1`] = ` "minIndexForVisible": 0, } } - maxToRenderPerBatch={16} + maxToRenderPerBatch={10} numColumns={1} onContentSizeChange={[Function]} onEndReachedThreshold={2} @@ -56,8 +57,13 @@ exports[`PostList setting channel deep link 1`] = ` removeClippedSubviews={true} renderItem={[Function]} scrollEventThrottle={60} + style={ + Object { + "flex": 1, + } + } updateCellsBatchingPeriod={50} - windowSize={21} + windowSize={50} /> `; @@ -81,10 +87,11 @@ exports[`PostList setting permalink deep link 1`] = ` "channel-id", undefined, undefined, + undefined, ] } horizontal={false} - initialNumToRender={15} + initialNumToRender={7} inverted={true} keyExtractor={[Function]} keyboardDismissMode="interactive" @@ -95,7 +102,7 @@ exports[`PostList setting permalink deep link 1`] = ` "minIndexForVisible": 0, } } - maxToRenderPerBatch={16} + maxToRenderPerBatch={10} numColumns={1} onContentSizeChange={[Function]} onEndReachedThreshold={2} @@ -117,8 +124,13 @@ exports[`PostList setting permalink deep link 1`] = ` removeClippedSubviews={true} renderItem={[Function]} scrollEventThrottle={60} + style={ + Object { + "flex": 1, + } + } updateCellsBatchingPeriod={50} - windowSize={21} + windowSize={50} /> `; @@ -142,10 +154,11 @@ exports[`PostList should match snapshot 1`] = ` "channel-id", undefined, undefined, + undefined, ] } horizontal={false} - initialNumToRender={15} + initialNumToRender={7} inverted={true} keyExtractor={[Function]} keyboardDismissMode="interactive" @@ -156,7 +169,7 @@ exports[`PostList should match snapshot 1`] = ` "minIndexForVisible": 0, } } - maxToRenderPerBatch={16} + maxToRenderPerBatch={10} numColumns={1} onContentSizeChange={[Function]} onEndReachedThreshold={2} @@ -178,7 +191,12 @@ exports[`PostList should match snapshot 1`] = ` removeClippedSubviews={true} renderItem={[Function]} scrollEventThrottle={60} + style={ + Object { + "flex": 1, + } + } updateCellsBatchingPeriod={50} - windowSize={21} + windowSize={50} /> `; diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index d588c34c9..1876233b5 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -24,7 +24,8 @@ import {t} from 'app/utils/i18n'; import DateHeader from './date_header'; import NewMessagesDivider from './new_messages_divider'; -const INITIAL_BATCH_TO_RENDER = 15; +const INITIAL_BATCH_TO_RENDER = 7; +const LOADING_POSTS_HEIGHT = 53; const SCROLL_UP_MULTIPLIER = 3.5; const SCROLL_POSITION_CONFIG = { @@ -55,6 +56,7 @@ export default class PostList extends PureComponent { isSearchResult: PropTypes.bool, lastPostIndex: PropTypes.number.isRequired, lastViewedAt: PropTypes.number, // Used by container // eslint-disable-line no-unused-prop-types + loadMorePostsVisible: PropTypes.bool, onLoadMoreUp: PropTypes.func, onHashtagPress: PropTypes.func, onPermalinkPress: PropTypes.func, @@ -129,7 +131,7 @@ export default class PostList extends PureComponent { this.shouldScrollToBottom = false; } - if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && this.state.contentHeight) { + if (!this.hasDoneInitialScroll && this.props.initialIndex > 0 && this.state.contentHeight > LOADING_POSTS_HEIGHT) { this.scrollToInitialIndexIfNeeded(this.props.initialIndex); } @@ -138,7 +140,7 @@ export default class PostList extends PureComponent { this.props.postIds.length && this.state.contentHeight && this.state.contentHeight < this.state.postListHeight && - this.props.extraData + !this.props.extraData ) { this.loadToFillContent(); } @@ -169,7 +171,7 @@ export default class PostList extends PureComponent { handleContentSizeChange = (contentWidth, contentHeight) => { if (this.state.contentHeight !== contentHeight) { this.setState({contentHeight}, () => { - if (this.state.postListHeight && contentHeight < this.state.postListHeight && this.props.extraData) { + if (this.state.postListHeight && contentHeight < this.state.postListHeight && !this.props.extraData && contentHeight > LOADING_POSTS_HEIGHT) { // We still have less than 1 screen of posts loaded with more to get, so load more this.props.onLoadMoreUp(); } @@ -392,11 +394,13 @@ export default class PostList extends PureComponent { }; flatListScrollToIndex = (index) => { - this.flatListRef.current.scrollToIndex({ - animated: false, - index, - viewOffset: 0, - viewPosition: 1, // 0 is at bottom + this.animationFrameInitialIndex = requestAnimationFrame(() => { + this.flatListRef.current.scrollToIndex({ + animated: false, + index, + viewOffset: 0, + viewPosition: 1, // 0 is at bottom + }); }); } @@ -455,7 +459,9 @@ export default class PostList extends PureComponent { render() { const { channelId, + extraData, highlightPostId, + loadMorePostsVisible, postIds, refreshing, scrollViewNativeID, @@ -476,9 +482,10 @@ export default class PostList extends PureComponent { ); } diff --git a/app/components/post_list/post_list.test.js b/app/components/post_list/post_list.test.js index 9f4deca49..eb33aee35 100644 --- a/app/components/post_list/post_list.test.js +++ b/app/components/post_list/post_list.test.js @@ -109,7 +109,7 @@ describe('PostList', () => { instance.loadToFillContent = jest.fn(); wrapper.setProps({ - extraData: true, + extraData: false, }); expect(instance.loadToFillContent).toHaveBeenCalledTimes(0); @@ -120,7 +120,7 @@ describe('PostList', () => { expect(instance.loadToFillContent).toHaveBeenCalledTimes(1); wrapper.setProps({ - extraData: false, + extraData: true, }); expect(instance.loadToFillContent).toHaveBeenCalledTimes(1); diff --git a/app/components/show_more_button/index.js b/app/components/show_more_button/index.js index 0002f53a2..010d772ad 100644 --- a/app/components/show_more_button/index.js +++ b/app/components/show_more_button/index.js @@ -1,16 +1,145 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {connect} from 'react-redux'; +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {Text, View} from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; +import FormattedText from 'app/components/formatted_text'; +import TouchableWithFeedback from 'app/components/touchable_with_feedback'; +import {t} from 'app/utils/i18n'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; -import ShowMoreButton from './show_more_button'; - -function mapStateToProps(state) { - return { - theme: getTheme(state), +export default class ShowMoreButton extends PureComponent { + static propTypes = { + highlight: PropTypes.bool, + onPress: PropTypes.func.isRequired, + showMore: PropTypes.bool.isRequired, + theme: PropTypes.object.isRequired, }; + + static defaultProps = { + showMore: true, + }; + + renderButton(showMore, style) { + let sign = '+'; + let textId = t('post_info.message.show_more'); + let textMessage = 'Show More'; + if (!showMore) { + sign = '-'; + textId = t('post_info.message.show_less'); + textMessage = 'Show Less'; + } + + return ( + + {sign} + + + ); + } + + render() { + const {highlight, showMore, theme} = this.props; + const style = getStyleSheet(theme, showMore); + + let gradientColors = [ + changeOpacity(theme.centerChannelBg, 0), + changeOpacity(theme.centerChannelBg, 0.75), + theme.centerChannelBg, + ]; + if (highlight) { + gradientColors = [ + changeOpacity(theme.mentionHighlightBg, 0), + changeOpacity(theme.mentionHighlightBg, 0.15), + changeOpacity(theme.mentionHighlightBg, 0.5), + ]; + } + + return ( + + {showMore && + + } + + + + {this.renderButton(showMore, style)} + + + + + ); + } } -export default connect(mapStateToProps)(ShowMoreButton); +const getStyleSheet = makeStyleSheetFromTheme((theme, showMore) => { + return { + gradient: { + flex: 1, + height: 50, + position: 'absolute', + top: -50, + width: '100%', + }, + container: { + alignItems: 'center', + justifyContent: 'center', + flex: 1, + flexDirection: 'row', + position: 'relative', + top: showMore ? -7.5 : 10, + marginBottom: 10, + }, + dividerLeft: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + flex: 1, + height: 1, + marginRight: 10, + }, + buttonContainer: { + backgroundColor: theme.centerChannelBg, + borderColor: changeOpacity(theme.centerChannelColor, 0.2), + borderRadius: 4, + borderWidth: 1, + height: 37, + paddingHorizontal: 10, + }, + button: { + alignItems: 'center', + flex: 1, + flexDirection: 'row', + }, + sign: { + color: theme.linkColor, + fontSize: 16, + fontWeight: '600', + marginRight: 8, + }, + text: { + color: theme.linkColor, + fontSize: 13, + fontWeight: '600', + }, + dividerRight: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + flex: 1, + height: 1, + marginLeft: 10, + }, + }; +}); diff --git a/app/components/show_more_button/show_more_button.js b/app/components/show_more_button/show_more_button.js deleted file mode 100644 index 010d772ad..000000000 --- a/app/components/show_more_button/show_more_button.js +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {Text, View} from 'react-native'; -import LinearGradient from 'react-native-linear-gradient'; - -import FormattedText from 'app/components/formatted_text'; -import TouchableWithFeedback from 'app/components/touchable_with_feedback'; -import {t} from 'app/utils/i18n'; -import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; - -export default class ShowMoreButton extends PureComponent { - static propTypes = { - highlight: PropTypes.bool, - onPress: PropTypes.func.isRequired, - showMore: PropTypes.bool.isRequired, - theme: PropTypes.object.isRequired, - }; - - static defaultProps = { - showMore: true, - }; - - renderButton(showMore, style) { - let sign = '+'; - let textId = t('post_info.message.show_more'); - let textMessage = 'Show More'; - if (!showMore) { - sign = '-'; - textId = t('post_info.message.show_less'); - textMessage = 'Show Less'; - } - - return ( - - {sign} - - - ); - } - - render() { - const {highlight, showMore, theme} = this.props; - const style = getStyleSheet(theme, showMore); - - let gradientColors = [ - changeOpacity(theme.centerChannelBg, 0), - changeOpacity(theme.centerChannelBg, 0.75), - theme.centerChannelBg, - ]; - if (highlight) { - gradientColors = [ - changeOpacity(theme.mentionHighlightBg, 0), - changeOpacity(theme.mentionHighlightBg, 0.15), - changeOpacity(theme.mentionHighlightBg, 0.5), - ]; - } - - return ( - - {showMore && - - } - - - - {this.renderButton(showMore, style)} - - - - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme, showMore) => { - return { - gradient: { - flex: 1, - height: 50, - position: 'absolute', - top: -50, - width: '100%', - }, - container: { - alignItems: 'center', - justifyContent: 'center', - flex: 1, - flexDirection: 'row', - position: 'relative', - top: showMore ? -7.5 : 10, - marginBottom: 10, - }, - dividerLeft: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), - flex: 1, - height: 1, - marginRight: 10, - }, - buttonContainer: { - backgroundColor: theme.centerChannelBg, - borderColor: changeOpacity(theme.centerChannelColor, 0.2), - borderRadius: 4, - borderWidth: 1, - height: 37, - paddingHorizontal: 10, - }, - button: { - alignItems: 'center', - flex: 1, - flexDirection: 'row', - }, - sign: { - color: theme.linkColor, - fontSize: 16, - fontWeight: '600', - marginRight: 8, - }, - text: { - color: theme.linkColor, - fontSize: 13, - fontWeight: '600', - }, - dividerRight: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), - flex: 1, - height: 1, - marginLeft: 10, - }, - }; -}); diff --git a/app/components/show_more_button/show_more_button.test.js b/app/components/show_more_button/show_more_button.test.js index 1903c77fb..145b419f6 100644 --- a/app/components/show_more_button/show_more_button.test.js +++ b/app/components/show_more_button/show_more_button.test.js @@ -8,7 +8,7 @@ import LinearGradient from 'react-native-linear-gradient'; import Preferences from 'mattermost-redux/constants/preferences'; import TouchableWithFeedback from 'app/components/touchable_with_feedback'; -import ShowMoreButton from './show_more_button'; +import ShowMoreButton from './index'; describe('ShowMoreButton', () => { const baseProps = { diff --git a/app/components/sidebars/drawer_layout.js b/app/components/sidebars/drawer_layout.js index 3275fbfb2..b4f4f3691 100644 --- a/app/components/sidebars/drawer_layout.js +++ b/app/components/sidebars/drawer_layout.js @@ -214,7 +214,7 @@ export default class DrawerLayout extends Component { // 1 - main | tablet // 2 - overlay // 3 - android main drawer | settings drawer - const drawerZIndex = drawerPosition === 'left' && Platform.OS === 'ios' ? 0 : 3 + const drawerZIndex = drawerPosition === 'left' ? 0 : 3 return ( @@ -262,7 +262,7 @@ export default class DrawerLayout extends Component { } const mainStyles = [styles.main] - if (drawerPosition === 'left' && Platform.OS === 'ios') { + if (drawerPosition === 'left') { /* Drawer styles */ let outputRange; @@ -410,10 +410,7 @@ export default class DrawerLayout extends Component { return true; } } else { - const filter = Platform.select({ - ios: moveX > 0 && dx > 35, - android: moveX <= 35 && dx > 0 - }); + const filter = moveX > 0 && dx > 35; if (filter) { this._isClosing = false; return true; @@ -447,7 +444,7 @@ export default class DrawerLayout extends Component { }; _panResponderMove = (e: EventType, { moveX, dx }: PanResponderEventType) => { - const useDx = Platform.OS === 'ios' && this.getDrawerPosition() === 'left' && !this._isClosing; + const useDx = this.getDrawerPosition() === 'left' && !this._isClosing; let openValue = this._getOpenValueForX(useDx ? dx : moveX); if (this._isClosing) { diff --git a/app/components/sidebars/main/channels_list/channels_list.js b/app/components/sidebars/main/channels_list/channels_list.js index 10b38e5e9..01f03acf1 100644 --- a/app/components/sidebars/main/channels_list/channels_list.js +++ b/app/components/sidebars/main/channels_list/channels_list.js @@ -33,7 +33,6 @@ export default class ChannelsList extends PureComponent { onSelectChannel: PropTypes.func.isRequired, onShowTeams: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, - drawerOpened: PropTypes.bool, isLandscape: PropTypes.bool.isRequired, }; diff --git a/app/components/sidebars/main/drawer_swiper/drawer_swiper.js b/app/components/sidebars/main/drawer_swiper/drawer_swiper.js index cdbdbe0a7..67c8a93c8 100644 --- a/app/components/sidebars/main/drawer_swiper/drawer_swiper.js +++ b/app/components/sidebars/main/drawer_swiper/drawer_swiper.js @@ -11,9 +11,7 @@ import Swiper from 'app/components/swiper'; export default class DrawerSwiper extends Component { static propTypes = { children: PropTypes.node.isRequired, - drawerOpened: PropTypes.bool, drawerWidth: PropTypes.number.isRequired, - hasSafeAreaInsets: PropTypes.bool, onPageSelected: PropTypes.func, showTeams: PropTypes.bool.isRequired, theme: PropTypes.object.isRequired, @@ -30,8 +28,7 @@ export default class DrawerSwiper extends Component { const {drawerWidth, showTeams, theme} = this.props; return nextProps.drawerWidth !== drawerWidth || nextProps.showTeams !== showTeams || - nextProps.theme !== theme || - nextProps.drawerOpened !== this.props.drawerOpened; + nextProps.theme !== theme; } runOnLayout = (shouldRun = true) => { diff --git a/app/components/sidebars/main/index.js b/app/components/sidebars/main/index.js index 0dd39209a..5c252fd45 100644 --- a/app/components/sidebars/main/index.js +++ b/app/components/sidebars/main/index.js @@ -8,12 +8,13 @@ import {joinChannel} from 'mattermost-redux/actions/channels'; import {getTeams} from 'mattermost-redux/actions/teams'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeamId, getMyTeamsCount} from 'mattermost-redux/selectors/entities/teams'; +import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; -import {setChannelDisplayName, setChannelLoading} from 'app/actions/views/channel'; +import {setChannelDisplayName, handleSelectChannel} from 'app/actions/views/channel'; import {makeDirectChannel} from 'app/actions/views/more_dms'; import telemetry from 'app/telemetry'; -import MainSidebar from './main_sidebar.js'; +import MainSidebar from './main_sidebar'; export function logChannelSwitch(channelId, currentChannelId) { return (dispatch, getState) => { @@ -34,11 +35,12 @@ export function logChannelSwitch(channelId, currentChannelId) { } function mapStateToProps(state) { - const {currentUserId} = state.entities.users; + const currentUser = getCurrentUser(state); return { + locale: currentUser?.locale, currentTeamId: getCurrentTeamId(state), - currentUserId, + currentUserId: currentUser?.id, teamsCount: getMyTeamsCount(state), theme: getTheme(state), }; @@ -52,7 +54,7 @@ function mapDispatchToProps(dispatch) { logChannelSwitch, makeDirectChannel, setChannelDisplayName, - setChannelLoading, + handleSelectChannel, }, dispatch), }; } diff --git a/app/components/sidebars/main/main_sidebar.android.js b/app/components/sidebars/main/main_sidebar.android.js new file mode 100644 index 000000000..1652eb7ce --- /dev/null +++ b/app/components/sidebars/main/main_sidebar.android.js @@ -0,0 +1,93 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Dimensions, Keyboard} from 'react-native'; +import {IntlProvider} from 'react-intl'; + +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + +import {closeMainSideMenu, enableMainSideMenu} from 'app/actions/navigation'; +import {NavigationTypes} from 'app/constants'; +import {getTranslations} from 'app/i18n'; + +import MainSidebarBase from './main_sidebar_base'; + +export default class MainSidebarAndroid extends MainSidebarBase { + constructor(props) { + super(props); + + this.state = { + deviceWidth: Dimensions.get('window').width, + opened: false, + searching: false, + }; + } + + componentDidMount() { + super.componentDidMount(); + + EventEmitter.on(NavigationTypes.MAIN_SIDEBAR_DID_CLOSE, this.handleSidebarDidClose); + EventEmitter.on(NavigationTypes.MAIN_SIDEBAR_DID_OPEN, this.handleSidebarDidOpen); + } + + componentWillUnmount() { + super.componentWillUnmount(); + + EventEmitter.off(NavigationTypes.MAIN_SIDEBAR_DID_CLOSE, this.handleSidebarDidClose); + EventEmitter.off(NavigationTypes.MAIN_SIDEBAR_DID_OPEN, this.handleSidebarDidOpen); + } + + closeMainSidebar = () => { + closeMainSideMenu(); + }; + + handleDimensions = ({window}) => { + if (this.mounted) { + this.setState({deviceWidth: window.width}); + } + }; + + handleSidebarDidClose = () => { + this.setState({searching: false, opened: false}, () => { + enableMainSideMenu(true, false); + this.resetDrawer(true); + Keyboard.dismiss(); + }); + }; + + handleSidebarDidOpen = () => { + this.setState({opened: true}); + } + + onPageSelected = (index) => { + this.swiperIndex = index; + + if (this.state.opened) { + enableMainSideMenu(index !== 0, true); + } + }; + + setProviderRef = (ref) => { + this.providerRef = ref; + } + + render() { + const locale = this.props.locale; + + if (!locale) { + return null; + } + + return ( + + {this.renderNavigationView(this.state.deviceWidth)} + + ); + } +} diff --git a/app/components/sidebars/main/main_sidebar.ios.js b/app/components/sidebars/main/main_sidebar.ios.js new file mode 100644 index 000000000..96ede9833 --- /dev/null +++ b/app/components/sidebars/main/main_sidebar.ios.js @@ -0,0 +1,135 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Dimensions, Keyboard} from 'react-native'; +import {intlShape} from 'react-intl'; +import AsyncStorage from '@react-native-community/async-storage'; + +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + +import DrawerLayout, {DRAWER_INITIAL_OFFSET, TABLET_WIDTH} from 'app/components/sidebars/drawer_layout'; +import {DeviceTypes, NavigationTypes} from 'app/constants'; +import mattermostManaged from 'app/mattermost_managed'; + +import MainSidebarBase from './main_sidebar_base'; + +export default class MainSidebarIOS extends MainSidebarBase { + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props) { + super(props); + + this.drawerRef = React.createRef(); + this.state = { + deviceWidth: Dimensions.get('window').width, + openDrawerOffset: DRAWER_INITIAL_OFFSET, + drawerOpened: false, + searching: false, + isSplitView: false, + }; + } + + componentDidMount() { + super.componentDidMount(); + + this.handleDimensions({window: Dimensions.get('window')}); + this.handlePermanentSidebar(); + EventEmitter.on(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar); + } + + componentWillUnmount() { + super.componentWillUnmount(); + + EventEmitter.off(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar); + } + + closeMainSidebar = () => { + if (this.state.drawerOpened && this.drawerRef?.current) { + this.drawerRef.current.closeDrawer(); + } else if (this.drawerSwiper && DeviceTypes.IS_TABLET) { + this.resetDrawer(true); + } + }; + + handleDimensions = ({window}) => { + if (this.mounted) { + if (DeviceTypes.IS_TABLET) { + mattermostManaged.isRunningInSplitView().then((result) => { + const isSplitView = Boolean(result.isSplitView); + this.setState({isSplitView}); + }); + } + + if (this.state.openDrawerOffset !== 0) { + let openDrawerOffset = DRAWER_INITIAL_OFFSET; + if ((window.width > window.height) || DeviceTypes.IS_TABLET) { + openDrawerOffset = window.width * 0.5; + } + + this.setState({openDrawerOffset, deviceWidth: window.width}); + } + } + }; + + handlePermanentSidebar = async () => { + if (DeviceTypes.IS_TABLET && this.mounted) { + const enabled = await AsyncStorage.getItem(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS); + this.setState({permanentSidebar: enabled === 'true'}); + } + }; + + handleDrawerClose = () => { + this.setState({ + drawerOpened: false, + searching: false, + }); + this.resetDrawer(); + Keyboard.dismiss(); + }; + + handleDrawerOpen = () => { + this.setState({ + drawerOpened: true, + }); + }; + + open = () => { + EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + + if (this.drawerRef?.current) { + this.drawerRef.current.openDrawer(); + } + }; + + onPageSelected = (index) => { + this.swiperIndex = index; + + if (this.drawerRef?.current) { + this.drawerRef.current.canClose = this.swiperIndex !== 0; + } + }; + + render() { + const {children} = this.props; + const {deviceWidth, openDrawerOffset} = this.state; + const isTablet = DeviceTypes.IS_TABLET && !this.state.isSplitView && this.state.permanentSidebar; + const drawerWidth = DeviceTypes.IS_TABLET ? TABLET_WIDTH : (deviceWidth - openDrawerOffset); + + return ( + + {children} + + ); + } +} \ No newline at end of file diff --git a/app/components/sidebars/main/main_sidebar.js b/app/components/sidebars/main/main_sidebar.js deleted file mode 100644 index 28bc96b92..000000000 --- a/app/components/sidebars/main/main_sidebar.js +++ /dev/null @@ -1,424 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {Component} from 'react'; -import PropTypes from 'prop-types'; -import { - BackHandler, - Dimensions, - Keyboard, - StyleSheet, - View, -} from 'react-native'; -import {intlShape} from 'react-intl'; -import AsyncStorage from '@react-native-community/async-storage'; - -import {General, WebsocketEvents} from 'mattermost-redux/constants'; -import EventEmitter from 'mattermost-redux/utils/event_emitter'; - -import SafeAreaView from 'app/components/safe_area_view'; -import DrawerLayout, {DRAWER_INITIAL_OFFSET, TABLET_WIDTH} from 'app/components/sidebars/drawer_layout'; -import {DeviceTypes} from 'app/constants'; -import mattermostManaged from 'app/mattermost_managed'; -import tracker from 'app/utils/time_tracker'; -import {t} from 'app/utils/i18n'; - -import ChannelsList from './channels_list'; -import DrawerSwiper from './drawer_swiper'; -import TeamsList from './teams_list'; - -import telemetry from 'app/telemetry'; - -export default class ChannelSidebar extends Component { - static propTypes = { - actions: PropTypes.shape({ - getTeams: PropTypes.func.isRequired, - logChannelSwitch: PropTypes.func.isRequired, - makeDirectChannel: PropTypes.func.isRequired, - setChannelDisplayName: PropTypes.func.isRequired, - setChannelLoading: PropTypes.func.isRequired, - joinChannel: PropTypes.func.isRequired, - }).isRequired, - blurPostTextBox: PropTypes.func.isRequired, - children: PropTypes.node, - currentTeamId: PropTypes.string.isRequired, - currentUserId: PropTypes.string.isRequired, - teamsCount: PropTypes.number.isRequired, - theme: PropTypes.object.isRequired, - }; - - static contextTypes = { - intl: intlShape.isRequired, - }; - - constructor(props) { - super(props); - - this.swiperIndex = 1; - this.drawerRef = React.createRef(); - this.channelListRef = React.createRef(); - this.state = { - deviceWidth: Dimensions.get('window').width, - show: false, - openDrawerOffset: DRAWER_INITIAL_OFFSET, - drawerOpened: false, - searching: false, - isSplitView: false, - }; - } - - componentDidMount() { - this.mounted = true; - this.props.actions.getTeams(); - this.handleDimensions({window: Dimensions.get('window')}); - this.handlePermanentSidebar(); - EventEmitter.on('close_channel_drawer', this.closeChannelDrawer); - EventEmitter.on('renderDrawer', this.handleShowDrawerContent); - EventEmitter.on(WebsocketEvents.CHANNEL_UPDATED, this.handleUpdateTitle); - EventEmitter.on(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar); - BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack); - Dimensions.addEventListener('change', this.handleDimensions); - } - - shouldComponentUpdate(nextProps, nextState) { - const {currentTeamId, teamsCount, theme} = this.props; - const {deviceWidth, openDrawerOffset, isSplitView, permanentSidebar, show, searching} = this.state; - - if (nextState.openDrawerOffset !== openDrawerOffset || nextState.show !== show || nextState.searching !== searching || nextState.deviceWidth !== deviceWidth) { - return true; - } - - return nextProps.currentTeamId !== currentTeamId || - nextProps.teamsCount !== teamsCount || - nextProps.theme !== theme || - nextState.isSplitView !== isSplitView || - nextState.permanentSidebar !== permanentSidebar; - } - - componentWillUnmount() { - this.mounted = false; - EventEmitter.off('close_channel_drawer', this.closeChannelDrawer); - EventEmitter.off(WebsocketEvents.CHANNEL_UPDATED, this.handleUpdateTitle); - EventEmitter.off('renderDrawer', this.handleShowDrawerContent); - EventEmitter.off(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS, this.handlePermanentSidebar); - BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack); - Dimensions.removeEventListener('change', this.handleDimensions); - } - - handleAndroidBack = () => { - if (this.state.drawerOpened && this.drawerRef?.current) { - this.drawerRef.current.closeDrawer(); - return true; - } - - return false; - }; - - handleDimensions = ({window}) => { - if (this.mounted) { - if (DeviceTypes.IS_TABLET) { - mattermostManaged.isRunningInSplitView().then((result) => { - const isSplitView = Boolean(result.isSplitView); - this.setState({isSplitView}); - }); - } - - if (this.state.openDrawerOffset !== 0) { - let openDrawerOffset = DRAWER_INITIAL_OFFSET; - if ((window.width > window.height) || DeviceTypes.IS_TABLET) { - openDrawerOffset = window.width * 0.5; - } - - this.setState({openDrawerOffset, deviceWidth: window.width}); - } - } - }; - - handlePermanentSidebar = async () => { - if (DeviceTypes.IS_TABLET && this.mounted) { - const enabled = await AsyncStorage.getItem(DeviceTypes.PERMANENT_SIDEBAR_SETTINGS); - this.setState({permanentSidebar: enabled === 'true'}); - } - }; - - handleShowDrawerContent = () => { - requestAnimationFrame(() => this.setState({show: true})); - }; - - closeChannelDrawer = () => { - if (this.state.drawerOpened && this.drawerRef?.current) { - this.drawerRef.current.closeDrawer(); - } else if (this.drawerSwiper && DeviceTypes.IS_TABLET) { - this.resetDrawer(true); - } - }; - - drawerSwiperRef = (ref) => { - this.drawerSwiper = ref; - }; - - handleDrawerClose = () => { - this.setState({ - drawerOpened: false, - searching: false, - }); - this.resetDrawer(); - Keyboard.dismiss(); - }; - - handleDrawerOpen = () => { - this.setState({ - drawerOpened: true, - }); - }; - - handleUpdateTitle = (channel) => { - let channelName = ''; - if (channel.display_name) { - channelName = channel.display_name; - } - this.props.actions.setChannelDisplayName(channelName); - }; - - openChannelSidebar = () => { - this.props.blurPostTextBox(); - - if (this.drawerRef?.current) { - this.drawerRef.current.openDrawer(); - } - }; - - selectChannel = (channel, currentChannelId, closeDrawer = true) => { - const {logChannelSwitch, setChannelLoading} = this.props.actions; - - logChannelSwitch(channel.id, currentChannelId); - - tracker.channelSwitch = Date.now(); - - if (closeDrawer) { - telemetry.start(['channel:close_drawer']); - this.closeChannelDrawer(); - setChannelLoading(channel.id !== currentChannelId); - } - - if (!channel) { - const utils = require('app/utils/general'); - const {intl} = this.context; - - const unableToJoinMessage = { - id: t('mobile.open_unknown_channel.error'), - defaultMessage: "We couldn't join the channel. Please reset the cache and try again.", - }; - const erroMessage = {}; - - utils.alertErrorWithFallback(intl, erroMessage, unableToJoinMessage); - setChannelLoading(false); - return; - } - - EventEmitter.emit('switch_channel', channel, currentChannelId); - }; - - joinChannel = (channel, currentChannelId) => { - const {intl} = this.context; - const { - actions, - currentTeamId, - currentUserId, - } = this.props; - - const { - joinChannel, - makeDirectChannel, - setChannelLoading, - } = actions; - - this.closeChannelDrawer(); - setChannelLoading(channel.id !== currentChannelId); - - setTimeout(async () => { - const displayValue = {displayName: channel.display_name}; - const utils = require('app/utils/general'); - - let result; - if (channel.type === General.DM_CHANNEL) { - result = await makeDirectChannel(channel.id, false); - - if (result.error) { - const dmFailedMessage = { - id: t('mobile.open_dm.error'), - defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.", - }; - utils.alertErrorWithFallback(intl, result.error, dmFailedMessage, displayValue); - } - } else { - result = await joinChannel(currentUserId, currentTeamId, channel.id); - - if (result.error || !result.data || !result.data.channel) { - const joinFailedMessage = { - id: t('mobile.join_channel.error'), - defaultMessage: "We couldn't join the channel {displayName}. Please check your connection and try again.", - }; - utils.alertErrorWithFallback(intl, result.error, joinFailedMessage, displayValue); - } - } - - if (result.error || (!result.data && !result.data.channel)) { - setChannelLoading(false); - return; - } - - requestAnimationFrame(() => { - this.selectChannel(result.data.channel || result.data, currentChannelId, false); - }); - }, 200); - }; - - onPageSelected = (index) => { - this.swiperIndex = index; - - if (this.drawerRef?.current) { - this.drawerRef.current.canClose = this.swiperIndex !== 0; - } - }; - - onSearchEnds = () => { - this.setState({searching: false}); - }; - - onSearchStart = () => { - if (this.drawerRef?.current) { - this.drawerRef.current.canClose = false; - } - this.setState({searching: true}); - }; - - showTeams = () => { - if (this.drawerSwiper && this.props.teamsCount > 1) { - this.drawerSwiper.showTeamsPage(); - } - }; - - resetDrawer = () => { - if (this.drawerSwiper) { - this.drawerSwiper.resetPage(); - } - - if (this.drawerRef?.current) { - this.drawerRef.current.canClose = true; - } - - if (this.channelListRef?.current) { - this.channelListRef.current.cancelSearch(); - } - }; - - renderNavigationView = (drawerWidth) => { - const { - teamsCount, - theme, - } = this.props; - - const { - show, - openDrawerOffset, - searching, - } = this.state; - - if (!show) { - return null; - } - - const hasSafeAreaInsets = DeviceTypes.IS_IPHONE_WITH_INSETS || mattermostManaged.hasSafeAreaInsets; - const multipleTeams = teamsCount > 1; - const showTeams = !searching && multipleTeams; - if (this.drawerSwiper) { - if (multipleTeams) { - this.drawerSwiper.runOnLayout(); - this.drawerSwiper.scrollToInitial(); - } else if (!openDrawerOffset) { - this.drawerSwiper.scrollToStart(); - } - } - - const lists = []; - if (multipleTeams) { - const teamsList = ( - - - - ); - lists.push(teamsList); - } - - lists.push( - - - , - ); - - return ( - - - {lists} - - - ); - }; - - render() { - const {children} = this.props; - const {deviceWidth, openDrawerOffset} = this.state; - const isTablet = DeviceTypes.IS_TABLET && !this.state.isSplitView && this.state.permanentSidebar; - const drawerWidth = DeviceTypes.IS_TABLET ? TABLET_WIDTH : (deviceWidth - openDrawerOffset); - - return ( - - {children} - - ); - } -} - -const style = StyleSheet.create({ - swiperContent: { - flex: 1, - }, -}); diff --git a/app/components/sidebars/main/main_sidebar.test.js b/app/components/sidebars/main/main_sidebar.test.js index 584dc0734..31759ac14 100644 --- a/app/components/sidebars/main/main_sidebar.test.js +++ b/app/components/sidebars/main/main_sidebar.test.js @@ -2,14 +2,13 @@ // See LICENSE.txt for license information. import React from 'react'; -import {Platform} from 'react-native'; import {shallow} from 'enzyme'; import Preferences from 'mattermost-redux/constants/preferences'; import {DeviceTypes} from 'app/constants'; -import MainSidebar from './main_sidebar'; +import MainSidebar from './main_sidebar.ios'; jest.mock('react-intl'); @@ -85,8 +84,6 @@ describe('MainSidebar', () => { }); test('should render main sidebar below PostList for iOS', () => { - Platform.OS = 'ios'; - const wrapper = shallow( , ); @@ -94,15 +91,4 @@ describe('MainSidebar', () => { const drawerStyle = drawer.props().style.reduce((acc, obj) => ({...acc, ...obj})); expect(drawerStyle).toHaveProperty('zIndex', 0); }); - - test('should render main sidebar above PostList for android', () => { - Platform.OS = 'android'; - - const wrapper = shallow( - , - ); - const drawer = wrapper.dive().childAt(1); - const drawerStyle = drawer.props().style.reduce((acc, obj) => ({...acc, ...obj})); - expect(drawerStyle).toHaveProperty('zIndex', 3); - }); }); diff --git a/app/components/sidebars/main/main_sidebar_base.js b/app/components/sidebars/main/main_sidebar_base.js new file mode 100644 index 000000000..c1fb66983 --- /dev/null +++ b/app/components/sidebars/main/main_sidebar_base.js @@ -0,0 +1,301 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import { + Dimensions, + Platform, + StyleSheet, + View, +} from 'react-native'; + +import {General, WebsocketEvents} from 'mattermost-redux/constants'; +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + +import SafeAreaView from 'app/components/safe_area_view'; +import {NavigationTypes} from 'app/constants'; +import tracker from 'app/utils/time_tracker'; +import {t} from 'app/utils/i18n'; + +import ChannelsList from './channels_list'; +import DrawerSwiper from './drawer_swiper'; +import TeamsList from './teams_list'; + +import telemetry from 'app/telemetry'; + +export default class MainSidebarBase extends Component { + static propTypes = { + actions: PropTypes.shape({ + getTeams: PropTypes.func.isRequired, + handleSelectChannel: PropTypes.func, + joinChannel: PropTypes.func.isRequired, + logChannelSwitch: PropTypes.func.isRequired, + makeDirectChannel: PropTypes.func.isRequired, + setChannelDisplayName: PropTypes.func.isRequired, + }).isRequired, + children: PropTypes.node, + currentTeamId: PropTypes.string.isRequired, + currentUserId: PropTypes.string, + locale: PropTypes.string, + teamsCount: PropTypes.number.isRequired, + theme: PropTypes.object.isRequired, + }; + + constructor(props) { + super(props); + + this.swiperIndex = 1; + this.channelListRef = React.createRef(); + } + + componentDidMount() { + this.mounted = true; + this.props.actions.getTeams(); + EventEmitter.on(NavigationTypes.CLOSE_MAIN_SIDEBAR, this.closeMainSidebar); + EventEmitter.on(WebsocketEvents.CHANNEL_UPDATED, this.handleUpdateTitle); + Dimensions.addEventListener('change', this.handleDimensions); + } + + shouldComponentUpdate(nextProps, nextState) { + const {currentTeamId, teamsCount, theme} = this.props; + const {deviceWidth, openDrawerOffset, isSplitView, permanentSidebar, searching} = this.state; + + if (nextState.openDrawerOffset !== openDrawerOffset && Platform.OS === 'ios') { + return true; + } + + if (nextState.searching !== searching || nextState.deviceWidth !== deviceWidth) { + return true; + } + + const condition = nextProps.currentTeamId !== currentTeamId || + nextProps.teamsCount !== teamsCount || + nextProps.theme !== theme; + + if (Platform.OS === 'ios') { + return condition || + nextState.isSplitView !== isSplitView || + nextState.permanentSidebar !== permanentSidebar; + } + + return condition; + } + + componentWillUnmount() { + this.mounted = false; + EventEmitter.off(NavigationTypes.CLOSE_MAIN_SIDEBAR, this.closeMainSidebar); + EventEmitter.off(WebsocketEvents.CHANNEL_UPDATED, this.handleUpdateTitle); + Dimensions.removeEventListener('change', this.handleDimensions); + } + + drawerSwiperRef = (ref) => { + this.drawerSwiper = ref; + }; + + getIntl = () => { + const {intl} = this.providerRef ? this.providerRef.getChildContext() : this.context; + return intl; + }; + + handleUpdateTitle = (channel) => { + let channelName = ''; + if (channel.display_name) { + channelName = channel.display_name; + } + this.props.actions.setChannelDisplayName(channelName); + }; + + joinChannel = async (channel, currentChannelId) => { + const intl = this.getIntl(); + const { + actions, + currentTeamId, + currentUserId, + } = this.props; + + const { + joinChannel, + makeDirectChannel, + } = actions; + + this.closeMainSidebar(); + + const displayValue = {displayName: channel.display_name}; + const utils = require('app/utils/general'); + + let result; + if (channel.type === General.DM_CHANNEL) { + result = await makeDirectChannel(channel.id, false); + + if (result.error) { + const dmFailedMessage = { + id: t('mobile.open_dm.error'), + defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.", + }; + utils.alertErrorWithFallback(intl, result.error, dmFailedMessage, displayValue); + } + } else { + result = await joinChannel(currentUserId, currentTeamId, channel.id); + + if (result.error || !result.data || !result.data.channel) { + const joinFailedMessage = { + id: t('mobile.join_channel.error'), + defaultMessage: "We couldn't join the channel {displayName}. Please check your connection and try again.", + }; + utils.alertErrorWithFallback(intl, result.error, joinFailedMessage, displayValue); + } + } + + if (result.error || (!result.data && !result.data.channel)) { + return; + } + + this.selectChannel(result.data.channel || result.data, currentChannelId, false); + }; + + onSearchEnds = () => { + this.setState({searching: false}); + }; + + onSearchStart = () => { + if (this.drawerRef?.current) { + this.drawerRef.current.canClose = false; + } + this.setState({searching: true}); + }; + + showTeams = () => { + if (this.drawerSwiper && this.props.teamsCount > 1) { + this.drawerSwiper.showTeamsPage(); + } + }; + + resetDrawer = () => { + if (this.drawerSwiper) { + this.drawerSwiper.resetPage(); + } + + if (this.drawerRef?.current) { + this.drawerRef.current.canClose = true; + } + + if (this.channelListRef?.current) { + this.channelListRef.current.cancelSearch(); + } + }; + + renderNavigationView = (drawerWidth) => { + const { + teamsCount, + theme, + } = this.props; + + const { + openDrawerOffset, + searching, + } = this.state; + + const offset = Platform.select({android: 60, ios: 0}); + const multipleTeams = teamsCount > 1; + const showTeams = !searching && multipleTeams; + if (this.drawerSwiper) { + if (multipleTeams) { + this.drawerSwiper.runOnLayout(); + this.drawerSwiper.scrollToInitial(); + } else if (!openDrawerOffset) { + this.drawerSwiper.scrollToStart(); + } + } + + const lists = []; + if (multipleTeams) { + const teamsList = ( + + + + ); + lists.push(teamsList); + } + + lists.push( + + + , + ); + + return ( + + + {lists} + + + ); + }; + + selectChannel = (channel, currentChannelId, closeDrawer = true) => { + const {logChannelSwitch, handleSelectChannel} = this.props.actions; + + logChannelSwitch(channel.id, currentChannelId); + + tracker.channelSwitch = Date.now(); + + if (closeDrawer) { + telemetry.start(['channel:close_drawer']); + this.closeMainSidebar(); + } + + if (!channel) { + const utils = require('app/utils/general'); + const intl = this.getIntl(); + + const unableToJoinMessage = { + id: t('mobile.open_unknown_channel.error'), + defaultMessage: "We couldn't join the channel. Please reset the cache and try again.", + }; + const erroMessage = {}; + + utils.alertErrorWithFallback(intl, erroMessage, unableToJoinMessage); + return; + } + + handleSelectChannel(channel.id); + }; + + render() { + return; // eslint-disable-line no-useless-return + } +} + +const style = StyleSheet.create({ + swiperContent: { + flex: 1, + }, +}); \ No newline at end of file diff --git a/app/components/sidebars/main/teams_list/teams_list.js b/app/components/sidebars/main/teams_list/teams_list.js index f685e392f..4ee33802b 100644 --- a/app/components/sidebars/main/teams_list/teams_list.js +++ b/app/components/sidebars/main/teams_list/teams_list.js @@ -38,7 +38,7 @@ export default class TeamsList extends PureComponent { actions: PropTypes.shape({ handleTeamChange: PropTypes.func.isRequired, }).isRequired, - closeChannelDrawer: PropTypes.func.isRequired, + closeMainSidebar: PropTypes.func.isRequired, currentTeamId: PropTypes.string.isRequired, hasOtherJoinableTeams: PropTypes.bool, teamIds: PropTypes.array.isRequired, @@ -66,7 +66,7 @@ export default class TeamsList extends PureComponent { } selectTeam = (teamId) => { - const {actions, closeChannelDrawer, currentTeamId} = this.props; + const {actions, closeMainSidebar, currentTeamId} = this.props; if (teamId !== currentTeamId) { telemetry.reset(); @@ -80,7 +80,7 @@ export default class TeamsList extends PureComponent { actions.handleTeamChange(teamId); } - closeChannelDrawer(); + closeMainSidebar(); }); }; diff --git a/app/components/sidebars/settings/index.js b/app/components/sidebars/settings/index.js index db0d83db5..f404ca4cd 100644 --- a/app/components/sidebars/settings/index.js +++ b/app/components/sidebars/settings/index.js @@ -4,10 +4,12 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {logout, setStatus} from 'mattermost-redux/actions/users'; +import {setStatus} from 'mattermost-redux/actions/users'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUser, getStatusForUserId} from 'mattermost-redux/selectors/entities/users'; +import {logout} from 'app/actions/views/user'; + import SettingsSidebar from './settings_sidebar'; function mapStateToProps(state) { @@ -16,6 +18,7 @@ function mapStateToProps(state) { return { currentUser, + locale: currentUser?.locale, status, theme: getTheme(state), }; diff --git a/app/components/sidebars/settings/settings_sidebar.android.js b/app/components/sidebars/settings/settings_sidebar.android.js new file mode 100644 index 000000000..2f7118d3e --- /dev/null +++ b/app/components/sidebars/settings/settings_sidebar.android.js @@ -0,0 +1,112 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {IntlProvider} from 'react-intl'; +import {View} from 'react-native'; + +import {closeSettingsSideMenu} from 'app/actions/navigation'; +import {getTranslations} from 'app/i18n'; +import {preventDoubleTap} from 'app/utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import SettingsSidebarBase from './settings_sidebar_base'; + +export default class SettingsDrawerAndroid extends SettingsSidebarBase { + confirmReset = (status) => { + const {intl} = this.providerRef.getChildContext(); + this.confirmResetBase(status, intl); + }; + + closeSettingsSidebar = () => { + closeSettingsSideMenu(); + }; + + goToEditProfile = preventDoubleTap(() => { + const {intl} = this.providerRef.getChildContext(); + this.goToEditProfileScreen(intl); + }); + + goToFlagged = preventDoubleTap(() => { + const {intl} = this.providerRef.getChildContext(); + this.goToFlaggedScreen(intl); + }); + + goToMentions = preventDoubleTap(() => { + const {intl} = this.providerRef.getChildContext(); + this.goToMentionsScreen(intl); + }); + + goToUserProfile = preventDoubleTap(() => { + const {intl} = this.providerRef.getChildContext(); + this.goToUserProfileScreen(intl); + }); + + goToSettings = preventDoubleTap(() => { + const {intl} = this.providerRef.getChildContext(); + this.goToSettingsScreeen(intl); + }); + + renderNavigationView = () => { + const {theme} = this.props; + const style = getStyleSheet(theme); + + return ( + + {this.renderOptions(style)} + + ); + }; + + setProviderRef = (ref) => { + this.providerRef = ref; + } + + render() { + const locale = this.props.locale; + + if (!locale) { + return null; + } + + return ( + + {this.renderNavigationView()} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + sidebar: { + flex: 1, + backgroundColor: theme.centerChannelBg, + }, + container: { + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + }, + wrapper: { + paddingTop: 0, + }, + block: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), + borderTopWidth: 1, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + height: 1, + }, + separator: { + marginTop: 35, + }, + }; +}); diff --git a/app/components/sidebars/settings/settings_sidebar.ios.js b/app/components/sidebars/settings/settings_sidebar.ios.js new file mode 100644 index 000000000..7455f7c0d --- /dev/null +++ b/app/components/sidebars/settings/settings_sidebar.ios.js @@ -0,0 +1,178 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {intlShape} from 'react-intl'; +import {Dimensions, Keyboard, View} from 'react-native'; + +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + +import SafeAreaView from 'app/components/safe_area_view'; +import DrawerLayout, {DRAWER_INITIAL_OFFSET, TABLET_WIDTH} from 'app/components/sidebars/drawer_layout'; +import {DeviceTypes, NavigationTypes} from 'app/constants'; +import {preventDoubleTap} from 'app/utils/tap'; +import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; + +import SettingsSidebarBase from './settings_sidebar_base'; + +export default class SettingsDrawer extends SettingsSidebarBase { + static contextTypes = { + intl: intlShape, + }; + + constructor(props) { + super(props); + + this.state = { + deviceWidth: Dimensions.get('window').width, + openDrawerOffset: DRAWER_INITIAL_OFFSET, + }; + } + + componentDidMount() { + super.componentDidMount(); + + this.handleDimensions({window: Dimensions.get('window')}); + Dimensions.addEventListener('change', this.handleDimensions); + } + + componentWillUnmount() { + super.componentWillUnmount(); + + Dimensions.removeEventListener('change', this.handleDimensions); + } + + setDrawerRef = (ref) => { + this.drawerRef = ref; + } + + confirmReset = (status) => { + const {intl} = this.context; + this.confirmResetBase(status, intl); + }; + + closeSettingsSidebar = () => { + if (this.drawerRef && this.drawerOpened) { + this.drawerRef.closeDrawer(); + } + }; + + open = () => { + EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + + if (this.drawerRef && !this.drawerOpened) { + this.drawerRef.openDrawer(); + } + }; + + handleDrawerClose = () => { + this.drawerOpened = false; + Keyboard.dismiss(); + }; + + handleDrawerOpen = () => { + this.drawerOpened = true; + Keyboard.dismiss(); + }; + + handleDimensions = ({window}) => { + if (this.mounted) { + if (this.state.openDrawerOffset !== 0) { + let openDrawerOffset = DRAWER_INITIAL_OFFSET; + if ((window.width > window.height) || DeviceTypes.IS_TABLET) { + openDrawerOffset = window.width * 0.5; + } + + this.setState({openDrawerOffset, deviceWidth: window.width}); + } + } + }; + + goToEditProfile = preventDoubleTap(() => { + const {intl} = this.context; + this.goToEditProfileScreen(intl); + }); + + goToFlagged = preventDoubleTap(() => { + const {intl} = this.context; + this.goToFlaggedScreen(intl); + }); + + goToMentions = preventDoubleTap(() => { + const {intl} = this.context; + this.goToMentionsScreen(intl); + }); + + goToUserProfile = preventDoubleTap(() => { + const {intl} = this.context; + this.goToUserProfileScreen(intl); + }); + + goToSettings = preventDoubleTap(() => { + const {intl} = this.context; + this.goToSettingsScreeen(intl); + }); + + renderNavigationView = () => { + const {theme} = this.props; + const style = getStyleSheet(theme); + + return ( + } + headerComponent={} + theme={theme} + > + {this.renderOptions(style)} + + ); + }; + + render() { + const {children} = this.props; + const {deviceWidth, openDrawerOffset} = this.state; + const drawerWidth = DeviceTypes.IS_TABLET ? TABLET_WIDTH : (deviceWidth - openDrawerOffset); + + return ( + + {children} + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + container: { + flex: 1, + backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), + }, + wrapper: { + paddingTop: 0, + }, + block: { + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), + borderBottomWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), + borderTopWidth: 1, + }, + divider: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), + height: 1, + }, + separator: { + marginTop: 35, + }, + }; +}); \ No newline at end of file diff --git a/app/components/sidebars/settings/settings_sidebar.js b/app/components/sidebars/settings/settings_sidebar.js deleted file mode 100644 index 0d6225658..000000000 --- a/app/components/sidebars/settings/settings_sidebar.js +++ /dev/null @@ -1,417 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {PureComponent} from 'react'; -import PropTypes from 'prop-types'; -import {intlShape} from 'react-intl'; -import { - BackHandler, - Dimensions, - InteractionManager, - Keyboard, - ScrollView, - View, -} from 'react-native'; -import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; - -import {General} from 'mattermost-redux/constants'; -import EventEmitter from 'mattermost-redux/utils/event_emitter'; - -import SafeAreaView from 'app/components/safe_area_view'; -import DrawerLayout, {DRAWER_INITIAL_OFFSET, TABLET_WIDTH} from 'app/components/sidebars/drawer_layout'; -import UserStatus from 'app/components/user_status'; -import {DeviceTypes, NavigationTypes} from 'app/constants'; -import {confirmOutOfOfficeDisabled} from 'app/utils/status'; -import {preventDoubleTap} from 'app/utils/tap'; -import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; -import {t} from 'app/utils/i18n'; -import {showModal, showModalOverCurrentContext, dismissModal} from 'app/actions/navigation'; - -import DrawerItem from './drawer_item'; -import UserInfo from './user_info'; -import StatusLabel from './status_label'; - -export default class SettingsDrawer extends PureComponent { - static propTypes = { - actions: PropTypes.shape({ - logout: PropTypes.func.isRequired, - setStatus: PropTypes.func.isRequired, - }).isRequired, - blurPostTextBox: PropTypes.func.isRequired, - children: PropTypes.node, - currentUser: PropTypes.object.isRequired, - status: PropTypes.string, - theme: PropTypes.object.isRequired, - }; - - static defaultProps = { - currentUser: {}, - status: 'offline', - }; - - static contextTypes = { - intl: intlShape, - }; - - constructor(props) { - super(props); - - MaterialIcon.getImageSource('close', 20, props.theme.sidebarHeaderTextColor).then((source) => { - this.closeButton = source; - }); - - this.state = { - deviceWidth: Dimensions.get('window').width, - openDrawerOffset: DRAWER_INITIAL_OFFSET, - }; - } - - componentDidMount() { - this.mounted = true; - this.handleDimensions({window: Dimensions.get('window')}); - EventEmitter.on('close_settings_sidebar', this.closeSettingsSidebar); - BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack); - Dimensions.addEventListener('change', this.handleDimensions); - } - - componentWillUnmount() { - this.mounted = false; - EventEmitter.off('close_settings_sidebar', this.closeSettingsSidebar); - BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack); - Dimensions.removeEventListener('change', this.handleDimensions); - } - - setDrawerRef = (ref) => { - this.drawerRef = ref; - } - - confirmReset = (status) => { - const {intl} = this.context; - confirmOutOfOfficeDisabled(intl, status, this.updateStatus); - }; - - closeSettingsSidebar = () => { - if (this.drawerRef && this.drawerOpened) { - this.drawerRef.closeDrawer(); - } - }; - - openSettingsSidebar = () => { - this.props.blurPostTextBox(); - - if (this.drawerRef && !this.drawerOpened) { - this.drawerRef.openDrawer(); - } - }; - - handleAndroidBack = () => { - if (this.statusModal) { - this.statusModal = false; - return false; - } else if (this.drawerRef && this.drawerOpened) { - this.drawerRef.closeDrawer(); - return true; - } - - return false; - }; - - handleDrawerClose = () => { - this.drawerOpened = false; - Keyboard.dismiss(); - }; - - handleDrawerOpen = () => { - this.drawerOpened = true; - Keyboard.dismiss(); - }; - - handleDimensions = ({window}) => { - if (this.mounted) { - if (this.state.openDrawerOffset !== 0) { - let openDrawerOffset = DRAWER_INITIAL_OFFSET; - if ((window.width > window.height) || DeviceTypes.IS_TABLET) { - openDrawerOffset = window.width * 0.5; - } - - this.setState({openDrawerOffset, deviceWidth: window.width}); - } - } - }; - - handleSetStatus = preventDoubleTap(() => { - const items = [{ - action: () => this.setStatus(General.ONLINE), - text: { - id: t('mobile.set_status.online'), - defaultMessage: 'Online', - }, - }, { - action: () => this.setStatus(General.AWAY), - text: { - id: t('mobile.set_status.away'), - defaultMessage: 'Away', - }, - }, { - action: () => this.setStatus(General.DND), - text: { - id: t('mobile.set_status.dnd'), - defaultMessage: 'Do Not Disturb', - }, - }, { - action: () => this.setStatus(General.OFFLINE), - text: { - id: t('mobile.set_status.offline'), - defaultMessage: 'Offline', - }, - }]; - - this.statusModal = true; - showModalOverCurrentContext('OptionsModal', {items}); - }); - - goToEditProfile = preventDoubleTap(() => { - const {currentUser} = this.props; - const {formatMessage} = this.context.intl; - const commandType = 'ShowModal'; - - this.openModal( - 'EditProfile', - formatMessage({id: 'mobile.routes.edit_profile', defaultMessage: 'Edit Profile'}), - {currentUser, commandType}, - ); - }); - - goToFlagged = preventDoubleTap(() => { - const {formatMessage} = this.context.intl; - - this.openModal( - 'FlaggedPosts', - formatMessage({id: 'search_header.title3', defaultMessage: 'Flagged Posts'}), - ); - }); - - goToMentions = preventDoubleTap(() => { - const {intl} = this.context; - - this.openModal( - 'RecentMentions', - intl.formatMessage({id: 'search_header.title2', defaultMessage: 'Recent Mentions'}), - ); - }); - - goToUserProfile = preventDoubleTap(() => { - const userId = this.props.currentUser.id; - const {formatMessage} = this.context.intl; - - this.openModal( - 'UserProfile', - formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}), - {userId, fromSettings: true}, - ); - }); - - goToSettings = preventDoubleTap(() => { - const {intl} = this.context; - - this.openModal( - 'Settings', - intl.formatMessage({id: 'mobile.routes.settings', defaultMessage: 'Settings'}), - ); - }); - - logout = preventDoubleTap(() => { - const {logout} = this.props.actions; - this.closeSettingsSidebar(); - logout(); - }); - - openModal = (screen, title, passProps) => { - this.closeSettingsSidebar(); - - const options = { - topBar: { - leftButtons: [{ - id: 'close-settings', - icon: this.closeButton, - }], - }, - }; - - InteractionManager.runAfterInteractions(() => { - showModal(screen, title, passProps, options); - }); - }; - - updateStatus = (status) => { - const {currentUser: {id: currentUserId}} = this.props; - this.props.actions.setStatus({ - user_id: currentUserId, - status, - }); - }; - - setStatus = (status) => { - const {status: currentUserStatus} = this.props; - - if (currentUserStatus === General.OUT_OF_OFFICE) { - dismissModal(); - this.closeSettingsSidebar(); - this.confirmReset(status); - return; - } - this.updateStatus(status); - EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL); - }; - - renderUserStatusIcon = (userId) => { - return ( - - ); - }; - - renderUserStatusLabel = (userId) => { - return ( - - ); - }; - - renderNavigationView = () => { - const {currentUser, theme} = this.props; - const style = getStyleSheet(theme); - - return ( - } - headerComponent={} - theme={theme} - > - - - - - - - - - - - - - - - - - - - - - - - - ); - }; - - render() { - const {children} = this.props; - const {deviceWidth, openDrawerOffset} = this.state; - const drawerWidth = DeviceTypes.IS_TABLET ? TABLET_WIDTH : (deviceWidth - openDrawerOffset); - - return ( - - {children} - - ); - } -} - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - container: { - flex: 1, - backgroundColor: changeOpacity(theme.centerChannelColor, 0.03), - }, - wrapper: { - paddingTop: 0, - }, - block: { - borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1), - borderBottomWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.1), - borderTopWidth: 1, - }, - divider: { - backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), - height: 1, - }, - separator: { - marginTop: 35, - }, - }; -}); diff --git a/app/components/sidebars/settings/settings_sidebar.test.js b/app/components/sidebars/settings/settings_sidebar.test.js deleted file mode 100644 index 8dc98a9d6..000000000 --- a/app/components/sidebars/settings/settings_sidebar.test.js +++ /dev/null @@ -1,57 +0,0 @@ -// 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 'mattermost-redux/constants/preferences'; - -import SettingsSidebar from './settings_sidebar'; - -jest.mock('react-intl'); - -jest.mock('react-native-vector-icons/MaterialIcons', () => ({ - getImageSource: jest.fn().mockResolvedValue(null), -})); - -describe('SettingsSidebar', () => { - const baseProps = { - actions: { - logout: jest.fn(), - setStatus: jest.fn(), - }, - blurPostTextBox: jest.fn(), - currentUser: {}, - status: 'online', - theme: Preferences.THEMES.default, - }; - - test('Android back button should dismiss status modal first', () => { - const wrapper = shallow(); - - const instance = wrapper.instance(); - - // Mocking the reference the DrawerLayout as the component is not really being mounted - instance.drawerRef = { - closeDrawer: jest.fn(() => { - instance.drawerOpened = false; - }), - }; - - // simulate that the drawer is opened - instance.drawerOpened = true; - - // simulate that the status modal is opened - instance.statusModal = true; - - // this simulates the first tap on the back button that closes the status modal - let backButtonResult = instance.handleAndroidBack(); - expect(backButtonResult).toBe(false); - expect(instance.drawerOpened).toBe(true); - - // this simulates a second tap on the back button that closes the drawer - backButtonResult = instance.handleAndroidBack(); - expect(backButtonResult).toBe(true); - expect(instance.drawerOpened).toBe(false); - }); -}); diff --git a/app/components/sidebars/settings/settings_sidebar_base.js b/app/components/sidebars/settings/settings_sidebar_base.js new file mode 100644 index 000000000..79e9b83cc --- /dev/null +++ b/app/components/sidebars/settings/settings_sidebar_base.js @@ -0,0 +1,278 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {InteractionManager, ScrollView, View} from 'react-native'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; + +import {General} from 'mattermost-redux/constants'; +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + +import {showModal, showModalOverCurrentContext, dismissModal} from 'app/actions/navigation'; +import UserStatus from 'app/components/user_status'; +import {NavigationTypes} from 'app/constants'; +import {confirmOutOfOfficeDisabled} from 'app/utils/status'; +import {preventDoubleTap} from 'app/utils/tap'; +import {t} from 'app/utils/i18n'; + +import DrawerItem from './drawer_item'; +import UserInfo from './user_info'; +import StatusLabel from './status_label'; + +export default class SettingsSidebarBase extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + logout: PropTypes.func.isRequired, + setStatus: PropTypes.func.isRequired, + }).isRequired, + currentUser: PropTypes.object.isRequired, + status: PropTypes.string, + theme: PropTypes.object.isRequired, + locale: PropTypes.string, + }; + + static defaultProps = { + currentUser: {}, + status: 'offline', + }; + + constructor(props) { + super(props); + + MaterialIcon.getImageSource('close', 20, props.theme.sidebarHeaderTextColor).then((source) => { + this.closeButton = source; + }); + } + + componentDidMount() { + this.mounted = true; + EventEmitter.on(NavigationTypes.CLOSE_SETTINGS_SIDEBAR, this.closeSettingsSidebar); + } + + componentWillUnmount() { + this.mounted = false; + EventEmitter.off(NavigationTypes.CLOSE_SETTINGS_SIDEBAR, this.closeSettingsSidebar); + } + + confirmResetBase = (status, intl) => { + confirmOutOfOfficeDisabled(intl, status, this.updateStatus); + }; + + handleSetStatus = preventDoubleTap(() => { + const items = [{ + action: () => this.setStatus(General.ONLINE), + text: { + id: t('mobile.set_status.online'), + defaultMessage: 'Online', + }, + }, { + action: () => this.setStatus(General.AWAY), + text: { + id: t('mobile.set_status.away'), + defaultMessage: 'Away', + }, + }, { + action: () => this.setStatus(General.DND), + text: { + id: t('mobile.set_status.dnd'), + defaultMessage: 'Do Not Disturb', + }, + }, { + action: () => this.setStatus(General.OFFLINE), + text: { + id: t('mobile.set_status.offline'), + defaultMessage: 'Offline', + }, + }]; + + this.statusModal = true; + showModalOverCurrentContext('OptionsModal', {items}); + }); + + goToEditProfileScreen = (intl) => { + const {currentUser} = this.props; + const commandType = 'ShowModal'; + + this.openModal( + 'EditProfile', + intl.formatMessage({id: 'mobile.routes.edit_profile', defaultMessage: 'Edit Profile'}), + {currentUser, commandType}, + ); + }; + + goToFlaggedScreen = (intl) => { + this.openModal( + 'FlaggedPosts', + intl.formatMessage({id: 'search_header.title3', defaultMessage: 'Flagged Posts'}), + ); + }; + + goToMentionsScreen = (intl) => { + this.openModal( + 'RecentMentions', + intl.formatMessage({id: 'search_header.title2', defaultMessage: 'Recent Mentions'}), + ); + }; + + goToUserProfileScreen = (intl) => { + const userId = this.props.currentUser.id; + + this.openModal( + 'UserProfile', + intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}), + {userId, fromSettings: true}, + ); + }; + + goToSettingsScreeen = (intl) => { + this.openModal( + 'Settings', + intl.formatMessage({id: 'mobile.routes.settings', defaultMessage: 'Settings'}), + ); + }; + + logout = preventDoubleTap(() => { + const {logout} = this.props.actions; + this.closeSettingsSidebar(); + logout(); + }); + + openModal = (screen, title, passProps) => { + this.closeSettingsSidebar(); + + const options = { + topBar: { + leftButtons: [{ + id: 'close-settings', + icon: this.closeButton, + }], + }, + }; + + InteractionManager.runAfterInteractions(() => { + showModal(screen, title, passProps, options); + }); + }; + + updateStatus = (status) => { + const {currentUser: {id: currentUserId}} = this.props; + this.props.actions.setStatus({ + user_id: currentUserId, + status, + }); + }; + + setStatus = (status) => { + const {status: currentUserStatus} = this.props; + + if (currentUserStatus === General.OUT_OF_OFFICE) { + dismissModal(); + this.closeSettingsSidebar(); + this.confirmReset(status); + return; + } + this.updateStatus(status); + EventEmitter.emit(NavigationTypes.NAVIGATION_CLOSE_MODAL); + }; + + renderUserStatusIcon = (userId) => { + return ( + + ); + }; + + renderUserStatusLabel = (userId) => { + return ( + + ); + }; + + renderOptions = (style) => { + const {currentUser, theme} = this.props; + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); + }; + + render() { + return; // eslint-disable-line no-useless-return + } +} diff --git a/app/constants/navigation.js b/app/constants/navigation.js index 618dc5b3f..df458f39f 100644 --- a/app/constants/navigation.js +++ b/app/constants/navigation.js @@ -10,6 +10,11 @@ const NavigationTypes = keyMirror({ RESTART_APP: null, NAVIGATION_ERROR_TEAMS: null, NAVIGATION_SHOW_OVERLAY: null, + CLOSE_MAIN_SIDEBAR: null, + MAIN_SIDEBAR_DID_CLOSE: null, + MAIN_SIDEBAR_DID_OPEN: null, + CLOSE_SETTINGS_SIDEBAR: null, + BLUR_POST_TEXTBOX: null, }); export default NavigationTypes; diff --git a/app/constants/view.js b/app/constants/view.js index 9bb077a31..60a186651 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -62,7 +62,6 @@ const ViewTypes = keyMirror({ SET_CHANNEL_RETRY_FAILED: null, SET_CHANNEL_DISPLAY_NAME: null, - SET_LAST_CHANNEL_FOR_TEAM: null, REMOVE_LAST_CHANNEL_FOR_TEAM: null, GITLAB: null, @@ -70,14 +69,10 @@ const ViewTypes = keyMirror({ SAML: null, SET_INITIAL_POST_VISIBILITY: null, - INCREASE_POST_VISIBILITY: null, RECEIVED_FOCUSED_POST: null, LOADING_POSTS: null, SET_LOAD_MORE_POSTS_VISIBLE: null, - SET_INITIAL_POST_COUNT: null, - INCREASE_POST_COUNT: null, - RECEIVED_POSTS_FOR_CHANNEL_AT_TIME: null, SET_LAST_UPGRADE_CHECK: null, @@ -94,7 +89,6 @@ const ViewTypes = keyMirror({ SELECTED_ACTION_MENU: null, SUBMIT_ATTACHMENT_MENU_ACTION: null, - SELECT_CHANNEL_WITH_MEMBER: null, PORTRAIT: null, LANDSCAPE: null, diff --git a/app/mattermost.js b/app/mattermost.js index 612a2edde..9e2fc2e28 100644 --- a/app/mattermost.js +++ b/app/mattermost.js @@ -5,10 +5,13 @@ import {Linking} from 'react-native'; import {Navigation} from 'react-native-navigation'; import {Provider} from 'react-redux'; -import {loadMe} from 'mattermost-redux/actions/users'; +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + +import {loadMe} from 'app/actions/views/user'; import {resetToChannel, resetToSelectServer} from 'app/actions/navigation'; -import {setDeepLinkURL, loadConfigAndLicense} from 'app/actions/views/root'; +import {setDeepLinkURL} from 'app/actions/views/root'; +import {NavigationTypes} from 'app/constants'; import {getAppCredentials} from 'app/init/credentials'; import emmProvider from 'app/init/emm_provider'; import 'app/init/device'; @@ -49,7 +52,6 @@ const launchApp = (credentials) => { if (credentials) { waitForHydration(store, async () => { - await store.dispatch(loadConfigAndLicense()); store.dispatch(loadMe()); resetToChannel({skipMetrics: true}); }); @@ -86,9 +88,23 @@ Navigation.events().registerAppLaunchedListener(() => { // Keep track of the latest componentId to appear Navigation.events().registerComponentDidAppearListener(({componentId}) => { EphemeralStore.addNavigationComponentId(componentId); + + switch (componentId) { + case 'MainSidebar': + EventEmitter.emit(NavigationTypes.MAIN_SIDEBAR_DID_OPEN, this.handleSidebarDidOpen); + EventEmitter.emit(Navigation.BLUR_POST_TEXTBOX); + break; + case 'SettingsSidebar': + EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + break; + } }); Navigation.events().registerComponentDidDisappearListener(({componentId}) => { EphemeralStore.removeNavigationComponentId(componentId); + + if (componentId === 'MainSidebar') { + EventEmitter.emit(NavigationTypes.MAIN_SIDEBAR_DID_CLOSE); + } }); }); diff --git a/app/reducers/views/channel.js b/app/reducers/views/channel.js index c54eba511..340c6e518 100644 --- a/app/reducers/views/channel.js +++ b/app/reducers/views/channel.js @@ -15,6 +15,8 @@ function displayName(state = '', action) { switch (action.type) { case ViewTypes.SET_CHANNEL_DISPLAY_NAME: return action.displayName || ''; + case ChannelTypes.SELECT_CHANNEL: + return ''; default: return state; } @@ -257,60 +259,6 @@ function retryFailed(state = false, action) { } } -function postVisibility(state = {}, action) { - switch (action.type) { - case ViewTypes.SET_INITIAL_POST_VISIBILITY: { - const nextState = {...state}; - nextState[action.data] = ViewTypes.POST_VISIBILITY_CHUNK_SIZE; - return nextState; - } - case ViewTypes.INCREASE_POST_VISIBILITY: { - const nextState = {...state}; - if (nextState[action.data]) { - nextState[action.data] += action.amount; - } else { - nextState[action.data] = action.amount; - } - return nextState; - } - case ViewTypes.RECEIVED_FOCUSED_POST: { - const nextState = {...state}; - nextState[action.channelId] = ViewTypes.POST_VISIBILITY_CHUNK_SIZE; - return nextState; - } - case PostTypes.RECEIVED_NEW_POST: { - if (action.data.id === action.data.pending_post_id) { - const nextState = {...state}; - nextState[action.data.channel_id] += 1; - return nextState; - } - - return state; - } - default: - return state; - } -} - -function postCountInChannel(state = {}, action) { - switch (action.type) { - case ViewTypes.SET_INITIAL_POST_COUNT: { - const {channelId, count} = action.data; - const nextState = {...state}; - nextState[channelId] = count; - return nextState; - } - case ViewTypes.INCREASE_POST_COUNT: { - const {channelId, count} = action.data; - const nextState = {...state}; - nextState[channelId] += count; - return nextState; - } - default: - return state; - } -} - function loadingPosts(state = {}, action) { switch (action.type) { case ViewTypes.LOADING_POSTS: { @@ -338,6 +286,8 @@ function lastGetPosts(state = {}, action) { function loadMorePostsVisible(state = true, action) { switch (action.type) { + case ChannelTypes.SELECT_CHANNEL: + return true; case ViewTypes.SET_LOAD_MORE_POSTS_VISIBLE: return action.data; @@ -348,12 +298,14 @@ function loadMorePostsVisible(state = true, action) { function lastChannelViewTime(state = {}, action) { switch (action.type) { - case ViewTypes.SELECT_CHANNEL_WITH_MEMBER: { - if (action.member) { + case ChannelTypes.SELECT_CHANNEL: { + if (action.extra?.member) { + const {member} = action.extra; const nextState = {...state}; - nextState[action.data] = action.member.last_viewed_at; + nextState[action.data] = member.last_viewed_at; return nextState; } + return state; } @@ -369,9 +321,8 @@ function lastChannelViewTime(state = {}, action) { function keepChannelIdAsUnread(state = null, action) { switch (action.type) { - case ViewTypes.SELECT_CHANNEL_WITH_MEMBER: { - const member = action.member; - const channel = action.channel; + case ChannelTypes.SELECT_CHANNEL: { + const {channel, member} = action.extra; if (!member || !channel) { return state; @@ -410,8 +361,6 @@ export default combineReducers({ drafts, loading, refreshing, - postCountInChannel, - postVisibility, loadingPosts, lastGetPosts, retryFailed, diff --git a/app/reducers/views/channel.test.js b/app/reducers/views/channel.test.js index 4adc02bf5..d2ae8b724 100644 --- a/app/reducers/views/channel.test.js +++ b/app/reducers/views/channel.test.js @@ -1,10 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {PostTypes} from 'mattermost-redux/action_types'; - import channelReducer from './channel'; -import {ViewTypes} from 'app/constants'; describe('Reducers.channel', () => { const initialState = { @@ -12,8 +9,6 @@ describe('Reducers.channel', () => { drafts: {}, loading: false, refreshing: false, - postCountInChannel: {}, - postVisibility: {}, loadingPosts: {}, lastGetPosts: {}, retryFailed: false, @@ -29,8 +24,6 @@ describe('Reducers.channel', () => { drafts: {}, loading: false, refreshing: false, - postCountInChannel: {}, - postVisibility: {}, loadingPosts: {}, lastGetPosts: {}, retryFailed: false, @@ -43,110 +36,4 @@ describe('Reducers.channel', () => { expect(nextState).toEqual(initialState); }); - - test('should set the postVisibility amount for a channel', () => { - const channelId = 'channel_id'; - const amount = 15; - const nextState = channelReducer( - { - displayName: '', - drafts: {}, - loading: false, - refreshing: false, - postCountInChannel: {}, - postVisibility: {}, - loadingPosts: {}, - lastGetPosts: {}, - retryFailed: false, - loadMorePostsVisible: true, - lastChannelViewTime: {}, - keepChannelIdAsUnread: null, - }, - { - type: ViewTypes.INCREASE_POST_VISIBILITY, - data: channelId, - amount, - }, - ); - - expect(nextState).toEqual({ - ...initialState, - postVisibility: { - [channelId]: amount, - }, - }); - }); - - test('should increase the postVisibility amount for a channel', () => { - const channelId = 'channel_id'; - const amount = 15; - const nextState = channelReducer( - { - displayName: '', - drafts: {}, - loading: false, - refreshing: false, - postCountInChannel: {}, - postVisibility: { - [channelId]: amount, - }, - loadingPosts: {}, - lastGetPosts: {}, - retryFailed: false, - loadMorePostsVisible: true, - lastChannelViewTime: {}, - keepChannelIdAsUnread: null, - }, - { - type: ViewTypes.INCREASE_POST_VISIBILITY, - data: channelId, - amount, - }, - ); - - expect(nextState).toEqual({ - ...initialState, - postVisibility: { - [channelId]: 2 * amount, - }, - }); - }); - - test('should increase the postVisibility amount for a channel by one after creating a post', () => { - const channelId = 'channel_id'; - const amount = 15; - const state = { - ...initialState, - postVisibility: { - [channelId]: amount, - }, - }; - - const receiveOtherPostAction = { - type: PostTypes.RECEIVED_NEW_POST, - data: { - channel_id: channelId, - id: 'post-id', - pending_post_id: 'pending-post-id', - }, - }; - let nextState = channelReducer(state, receiveOtherPostAction); - expect(nextState).toEqual(state); - - const receiveCreatedPostAction = { - type: PostTypes.RECEIVED_NEW_POST, - data: { - channel_id: channelId, - id: 'post-id', - pending_post_id: 'post-id', - }, - }; - nextState = channelReducer(state, receiveCreatedPostAction); - expect(nextState).toEqual({ - ...state, - postVisibility: { - [channelId]: amount + 1, - }, - }); - }); }); diff --git a/app/reducers/views/team.js b/app/reducers/views/team.js index c89a89593..6eca847ca 100644 --- a/app/reducers/views/team.js +++ b/app/reducers/views/team.js @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {combineReducers} from 'redux'; -import {TeamTypes} from 'mattermost-redux/action_types'; +import {ChannelTypes, TeamTypes} from 'mattermost-redux/action_types'; import {ViewTypes} from 'app/constants'; @@ -15,35 +15,40 @@ function lastTeamId(state = '', action) { } } +function setLastChannelForTeam(state, teamId, channel) { + if (!channel?.id) { + return state; + } + + const team = state[channel.team_id || teamId]; + const channelIds = []; + + if (team) { + channelIds.push(...team); + const index = channelIds.indexOf(channel.id); + if (index === -1) { + channelIds.unshift(channel.id); + channelIds.slice(0, 5); + } else { + channelIds.splice(index, 1); + channelIds.unshift(channel.id); + } + } else { + channelIds.push(channel.id); + } + + return { + ...state, + [teamId]: channelIds, + }; +} + function lastChannelForTeam(state = {}, action) { switch (action.type) { - case ViewTypes.SET_LAST_CHANNEL_FOR_TEAM: { - const team = state[action.teamId]; - const channelIds = []; - - if (!action.channelId) { - return state; - } - - if (team) { - channelIds.push(...team); - const index = channelIds.indexOf(action.channelId); - if (index === -1) { - channelIds.unshift(action.channelId); - channelIds.slice(0, 5); - } else { - channelIds.splice(index, 1); - channelIds.unshift(action.channelId); - } - } else { - channelIds.push(action.channelId); - } - - return { - ...state, - [action.teamId]: channelIds, - }; + case ChannelTypes.SELECT_CHANNEL: { + return setLastChannelForTeam(state, action.extra?.teamId, action.extra?.channel); } + case ViewTypes.REMOVE_LAST_CHANNEL_FOR_TEAM: { const {data} = action; const team = state[data.teamId]; diff --git a/app/screens/channel/channel.android.js b/app/screens/channel/channel.android.js index 73fde034f..1a8b2e7fb 100644 --- a/app/screens/channel/channel.android.js +++ b/app/screens/channel/channel.android.js @@ -2,30 +2,50 @@ // See LICENSE.txt for license information. import React from 'react'; -import {Dimensions, View} from 'react-native'; +import {View} from 'react-native'; -import ChannelLoader from 'app/components/channel_loader'; +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + +import {openMainSideMenu, openSettingsSideMenu} from 'app/actions/navigation'; import KeyboardLayout from 'app/components/layout/keyboard_layout'; +import InteractiveDialogController from 'app/components/interactive_dialog_controller'; import NetworkIndicator from 'app/components/network_indicator'; import PostTextbox from 'app/components/post_textbox'; +import {NavigationTypes} from 'app/constants'; +import {makeStyleSheetFromTheme} from 'app/utils/theme'; + import LocalConfig from 'assets/config'; import ChannelNavBar from './channel_nav_bar'; import ChannelPostList from './channel_post_list'; -import ChannelBase, {ClientUpgradeListener, style} from './channel_base'; +import ChannelBase, {ClientUpgradeListener} from './channel_base'; export default class ChannelAndroid extends ChannelBase { - render() { - const {height} = Dimensions.get('window'); + openMainSidebar = () => { + EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + openMainSideMenu(); + }; - const channelLoaderStyle = [style.channelLoader, {height}]; + openSettingsSidebar = () => { + EventEmitter.emit(NavigationTypes.BLUR_POST_TEXTBOX); + openSettingsSideMenu(); + }; + + render() { + const {theme} = this.props; + const channelLoadingOrFailed = this.renderLoadingOrFailedChannel(); + if (channelLoadingOrFailed) { + return channelLoadingOrFailed; + } + + const style = getStyleFromTheme(theme); const drawerContent = ( <> @@ -37,14 +57,31 @@ export default class ChannelAndroid extends ChannelBase { screenId={this.props.componentId} /> - {LocalConfig.EnableMobileClientUpgrade && } ); - return this.renderChannel(drawerContent); + return ( + <> + + {drawerContent} + + + + ); } } + +const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { + return { + backdrop: { + flex: 1, + backgroundColor: theme.centerChannelBg, + }, + flex: { + flex: 1, + }, + }; +}); \ No newline at end of file diff --git a/app/screens/channel/channel.ios.js b/app/screens/channel/channel.ios.js index 9a92542fe..4fc33eaa1 100644 --- a/app/screens/channel/channel.ios.js +++ b/app/screens/channel/channel.ios.js @@ -2,20 +2,22 @@ // See LICENSE.txt for license information. import React from 'react'; -import {Dimensions, View} from 'react-native'; +import {View} from 'react-native'; import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view'; import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from 'app/components/autocomplete'; -import ChannelLoader from 'app/components/channel_loader'; +import InteractiveDialogController from 'app/components/interactive_dialog_controller'; +import MainSidebar from 'app/components/sidebars/main'; import NetworkIndicator from 'app/components/network_indicator'; import PostTextbox from 'app/components/post_textbox'; import SafeAreaView from 'app/components/safe_area_view'; +import SettingsSidebar from 'app/components/sidebars/settings'; import StatusBar from 'app/components/status_bar'; -import {DeviceTypes} from 'app/constants'; +import {makeStyleSheetFromTheme} from 'app/utils/theme'; import LocalConfig from 'assets/config'; -import ChannelBase, {ClientUpgradeListener, style} from './channel_base'; +import ChannelBase, {ClientUpgradeListener} from './channel_base'; import ChannelNavBar from './channel_nav_bar'; import ChannelPostList from './channel_post_list'; @@ -24,23 +26,47 @@ const CHANNEL_POST_TEXTBOX_CURSOR_CHANGE = 'onChannelTextBoxCursorChange'; const CHANNEL_POST_TEXTBOX_VALUE_CHANGE = 'onChannelTextBoxValueChange'; export default class ChannelIOS extends ChannelBase { - render() { - const {height} = Dimensions.get('window'); - const {currentChannelId} = this.props; + mainSidebarRef = (ref) => { + if (ref) { + this.mainSidebar = ref; + } + }; - const channelLoaderStyle = [style.channelLoader, {height}]; - if ((DeviceTypes.IS_IPHONE_WITH_INSETS || DeviceTypes.IS_TABLET)) { - channelLoaderStyle.push(style.iOSHomeIndicator); + settingsSidebarRef = (ref) => { + if (ref) { + this.settingsSidebar = ref; + } + }; + + openMainSidebar = () => { + if (this.mainSidebar) { + this.mainSidebar.open(); + } + }; + + openSettingsSidebar = () => { + if (this.settingsSidebar) { + this.settingsSidebar.open(); + } + }; + + render() { + const {currentChannelId, theme} = this.props; + + const channelLoadingOrFailed = this.renderLoadingOrFailedChannel(); + if (channelLoadingOrFailed) { + return channelLoadingOrFailed; } + const style = getStyle(theme); const drawerContent = ( - + <> - {LocalConfig.EnableMobileClientUpgrade && } - + ); - return this.renderChannel(drawerContent); + return ( + + + + {drawerContent} + + + + + ); } } + +export const getStyle = makeStyleSheetFromTheme((theme) => ({ + backdrop: { + flex: 1, + backgroundColor: theme.centerChannelBg, + }, + flex: { + flex: 1, + }, +})); \ No newline at end of file diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index 1889b52d2..22016084d 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -13,22 +13,16 @@ import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; -import EmptyToolbar from 'app/components/start/empty_toolbar'; -import InteractiveDialogController from 'app/components/interactive_dialog_controller'; -import MainSidebar from 'app/components/sidebars/main'; +import {showModal, showModalOverCurrentContext} from 'app/actions/navigation'; import SafeAreaView from 'app/components/safe_area_view'; -import SettingsSidebar from 'app/components/sidebars/settings'; - -import {preventDoubleTap} from 'app/utils/tap'; -import {makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; +import EmptyToolbar from 'app/components/start/empty_toolbar'; +import {NavigationTypes} from 'app/constants'; import PushNotifications from 'app/push_notifications'; import EphemeralStore from 'app/store/ephemeral_store'; -import tracker from 'app/utils/time_tracker'; import telemetry from 'app/telemetry'; -import { - showModal, - showModalOverCurrentContext, -} from 'app/actions/navigation'; +import {preventDoubleTap} from 'app/utils/tap'; +import {setNavigatorStyles} from 'app/utils/theme'; +import tracker from 'app/utils/time_tracker'; import LocalConfig from 'assets/config'; @@ -37,8 +31,8 @@ export let ClientUpgradeListener; export default class ChannelBase extends PureComponent { static propTypes = { actions: PropTypes.shape({ - loadChannelsIfNecessary: PropTypes.func.isRequired, - loadProfilesAndTeamMembersForDMSidebar: PropTypes.func.isRequired, + loadChannelsForTeam: PropTypes.func.isRequired, + markChannelViewedAndRead: PropTypes.func.isRequired, selectDefaultTeam: PropTypes.func.isRequired, selectInitialChannel: PropTypes.func.isRequired, recordLoadTime: PropTypes.func.isRequired, @@ -46,7 +40,6 @@ export default class ChannelBase extends PureComponent { }).isRequired, componentId: PropTypes.string.isRequired, currentChannelId: PropTypes.string, - channelsRequestFailed: PropTypes.bool, currentTeamId: PropTypes.string, isLandscape: PropTypes.bool, theme: PropTypes.object.isRequired, @@ -69,18 +62,19 @@ export default class ChannelBase extends PureComponent { this.postTextbox = React.createRef(); this.keyboardTracker = React.createRef(); - MaterialIcon.getImageSource('close', 20, props.theme.sidebarHeaderTextColor).then((source) => { - this.closeButton = source; - }); - setNavigatorStyles(props.componentId, props.theme); + this.state = { + channelsRequestFailed: false, + }; + if (LocalConfig.EnableMobileClientUpgrade && !ClientUpgradeListener) { ClientUpgradeListener = require('app/components/client_upgrade_listener').default; } } componentDidMount() { + EventEmitter.on(NavigationTypes.BLUR_POST_TEXTBOX, this.blurPostTextBox); EventEmitter.on('leave_team', this.handleLeaveTeam); if (this.props.currentTeamId) { @@ -91,7 +85,10 @@ export default class ChannelBase extends PureComponent { if (this.props.currentChannelId) { PushNotifications.clearChannelNotifications(this.props.currentChannelId); - this.props.actions.getChannelStats(this.props.currentChannelId); + requestAnimationFrame(() => { + this.props.actions.getChannelStats(this.props.currentChannelId); + this.props.actions.markChannelViewedAndRead(this.props.currentChannelId); + }); } if (tracker.initialLoad && !this.props.skipMetrics) { @@ -102,56 +99,46 @@ export default class ChannelBase extends PureComponent { this.showTermsOfServiceModal(); } - EventEmitter.emit('renderDrawer'); - if (!this.props.skipMetrics) { telemetry.end(['start:channel_screen']); } } - componentWillReceiveProps(nextProps) { - if (this.props.theme !== nextProps.theme) { - setNavigatorStyles(this.props.componentId, nextProps.theme); - - EphemeralStore.allNavigationComponentIds.forEach((componentId) => { - setNavigatorStyles(componentId, nextProps.theme); - }); - } - - if (nextProps.currentTeamId && this.props.currentTeamId !== nextProps.currentTeamId) { - this.loadChannels(nextProps.currentTeamId); - } - - if (nextProps.currentChannelId !== this.props.currentChannelId && - nextProps.currentTeamId === this.props.currentTeamId) { - PushNotifications.clearChannelNotifications(nextProps.currentChannelId); - } - - if (nextProps.currentChannelId !== this.props.currentChannelId) { - this.props.actions.getChannelStats(nextProps.currentChannelId); - } - - if (LocalConfig.EnableMobileClientUpgrade && !ClientUpgradeListener) { - ClientUpgradeListener = require('app/components/client_upgrade_listener').default; - } - } - componentDidUpdate(prevProps) { if (tracker.teamSwitch) { this.props.actions.recordLoadTime('Switch Team', 'teamSwitch'); } - // When the team changes emit the event to render the drawer content - if (this.props.currentChannelId && !prevProps.currentChannelId) { - EventEmitter.emit('renderDrawer'); + if (this.props.theme !== prevProps.theme) { + setNavigatorStyles(this.props.componentId, this.props.theme); + + EphemeralStore.allNavigationComponentIds.forEach((componentId) => { + setNavigatorStyles(componentId, this.props.theme); + }); + } + + if (this.props.currentTeamId && this.props.currentTeamId !== prevProps.currentTeamId) { + this.loadChannels(this.props.currentTeamId); } if (this.props.currentChannelId && this.props.currentChannelId !== prevProps.currentChannelId) { - this.updateNativeScrollView(); + const previousChannelId = EphemeralStore.appStartedFromPushNotification ? null : prevProps.currentChannelId; + PushNotifications.clearChannelNotifications(this.props.currentChannelId); + + requestAnimationFrame(() => { + this.props.actions.markChannelViewedAndRead(this.props.currentChannelId, previousChannelId); + this.props.actions.getChannelStats(this.props.currentChannelId); + this.updateNativeScrollView(); + }); + } + + if (LocalConfig.EnableMobileClientUpgrade && !ClientUpgradeListener) { + ClientUpgradeListener = require('app/components/client_upgrade_listener').default; } } componentWillUnmount() { + EventEmitter.off(NavigationTypes.BLUR_POST_TEXTBOX, this.blurPostTextBox); EventEmitter.off('leave_team', this.handleLeaveTeam); } @@ -161,58 +148,48 @@ export default class ChannelBase extends PureComponent { } }; - channelSidebarRef = (ref) => { - if (ref) { - this.channelSidebar = ref; - } - }; - - settingsSidebarRef = (ref) => { - if (ref) { - this.settingsSidebar = ref; - } - }; - showTermsOfServiceModal = async () => { const {intl} = this.context; const {theme} = this.props; - const closeButton = await MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor); const screen = 'TermsOfService'; - const passProps = {closeButton}; const title = intl.formatMessage({id: 'mobile.tos_link', defaultMessage: 'Terms of Service'}); - const options = { - layout: { - backgroundColor: theme.centerChannelBg, - }, - topBar: { - visible: true, - height: null, - title: { - color: theme.sidebarHeaderTextColor, - text: title, + MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor).then((closeButton) => { + const passProps = {closeButton}; + const options = { + layout: { + backgroundColor: theme.centerChannelBg, }, - }, - }; + topBar: { + visible: true, + height: null, + title: { + color: theme.sidebarHeaderTextColor, + text: title, + }, + }, + }; - showModalOverCurrentContext(screen, passProps, options); + showModalOverCurrentContext(screen, passProps, options); + }); }; goToChannelInfo = preventDoubleTap(() => { const {intl} = this.context; + const {theme} = this.props; const screen = 'ChannelInfo'; const title = intl.formatMessage({id: 'mobile.routes.channelInfo', defaultMessage: 'Info'}); - const options = { - topBar: { - leftButtons: [{ - id: 'close-info', - icon: this.closeButton, - }], - }, - }; + MaterialIcon.getImageSource('close', 20, theme.sidebarHeaderTextColor).then((source) => { + const options = { + topBar: { + leftButtons: [{ + id: 'close-info', + icon: source, + }], + }, + }; - Keyboard.dismiss(); + Keyboard.dismiss(); - requestAnimationFrame(() => { showModal(screen, title, null, options); }); }); @@ -228,14 +205,12 @@ export default class ChannelBase extends PureComponent { }; loadChannels = (teamId) => { - const { - loadChannelsIfNecessary, - loadProfilesAndTeamMembersForDMSidebar, - selectInitialChannel, - } = this.props.actions; - - loadChannelsIfNecessary(teamId).then(() => { - loadProfilesAndTeamMembersForDMSidebar(teamId); + const {loadChannelsForTeam, selectInitialChannel} = this.props.actions; + loadChannelsForTeam(teamId).then((result) => { + if (result?.error) { + this.setState({channelsRequestFailed: true}); + return; + } if (EphemeralStore.appStartedFromPushNotification) { EphemeralStore.appStartedFromPushNotification = false; @@ -245,36 +220,18 @@ export default class ChannelBase extends PureComponent { }); }; - openChannelSidebar = () => { - if (this.channelSidebar) { - this.channelSidebar.openChannelSidebar(); - } - }; - - openSettingsSidebar = () => { - if (this.settingsSidebar) { - this.settingsSidebar.openSettingsSidebar(); - } - }; - retryLoadChannels = () => { this.loadChannels(this.props.currentTeamId); }; - updateNativeScrollView = () => { - if (this.keyboardTracker?.current) { - this.keyboardTracker.current.resetScrollView(this.props.currentChannelId); - } - }; - - renderChannel(drawerContent) { + renderLoadingOrFailedChannel() { const { - channelsRequestFailed, currentChannelId, isLandscape, theme, } = this.props; + const {channelsRequestFailed} = this.state; if (!currentChannelId) { if (channelsRequestFailed) { const FailedNetworkAction = require('app/components/failed_network_action').default; @@ -312,27 +269,15 @@ export default class ChannelBase extends PureComponent { ); } - const baseStyle = getStyleFromTheme(theme); - return ( - - - - {drawerContent} - - - - - ); + return null; } + updateNativeScrollView = () => { + if (this.keyboardTracker?.current) { + this.keyboardTracker.current.resetScrollView(this.props.currentChannelId); + } + }; + render() { // Overriden in channel.android.js and channel.ios.js // but defined here for channel_base.test.js @@ -340,25 +285,8 @@ export default class ChannelBase extends PureComponent { } } -const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { - return { - backdrop: { - flex: 1, - backgroundColor: theme.centerChannelBg, - }, - }; -}); - export const style = StyleSheet.create({ flex: { flex: 1, }, - channelLoader: { - position: 'absolute', - width: '100%', - flex: 1, - }, - iOSHomeIndicator: { - paddingBottom: 50, - }, }); diff --git a/app/screens/channel/channel_base.test.js b/app/screens/channel/channel_base.test.js index 75406e91d..d18768649 100644 --- a/app/screens/channel/channel_base.test.js +++ b/app/screens/channel/channel_base.test.js @@ -21,12 +21,12 @@ describe('ChannelBase', () => { const componentIds = ['component-1', 'component-2', 'component-3']; const baseProps = { actions: { - loadChannelsIfNecessary: jest.fn(), - loadProfilesAndTeamMembersForDMSidebar: jest.fn(), + getChannelStats: jest.fn(), + loadChannelsForTeam: jest.fn(), + markChannelViewedAndRead: jest.fn(), + recordLoadTime: jest.fn(), selectDefaultTeam: jest.fn(), selectInitialChannel: jest.fn(), - recordLoadTime: jest.fn(), - getChannelStats: jest.fn(), }, componentId: channelBaseComponentId, theme: Preferences.THEMES.default, diff --git a/app/screens/channel/channel_nav_bar/__snapshots__/channel_nav_bar.test.js.snap b/app/screens/channel/channel_nav_bar/__snapshots__/channel_nav_bar.test.js.snap index 329721d83..d7b345368 100644 --- a/app/screens/channel/channel_nav_bar/__snapshots__/channel_nav_bar.test.js.snap +++ b/app/screens/channel/channel_nav_bar/__snapshots__/channel_nav_bar.test.js.snap @@ -19,7 +19,7 @@ exports[`ChannelNavBar should match, full snapshot 1`] = ` } > `; diff --git a/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.js b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.js index d1e9b7192..89bc63074 100644 --- a/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.js +++ b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.js @@ -21,7 +21,7 @@ import telemetry from 'app/telemetry'; export default class ChannelDrawerButton extends PureComponent { static propTypes = { - openDrawer: PropTypes.func.isRequired, + openSidebar: PropTypes.func.isRequired, badgeCount: PropTypes.number, theme: PropTypes.object, visible: PropTypes.bool, @@ -57,7 +57,7 @@ export default class ChannelDrawerButton extends PureComponent { handlePress = preventDoubleTap(() => { telemetry.start(['channel:open_drawer']); - this.props.openDrawer(); + this.props.openSidebar(); }); render() { diff --git a/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js index 45ab66b41..614b26653 100644 --- a/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js +++ b/app/screens/channel/channel_nav_bar/channel_drawer_button/channel_drawer_button.test.js @@ -39,7 +39,7 @@ jest.mock('react-native-notifications', () => { describe('ChannelDrawerButton', () => { const baseProps = { - openDrawer: jest.fn(), + openSidebar: jest.fn(), badgeCount: 0, theme: Preferences.THEMES.default, visible: false, diff --git a/app/screens/channel/channel_nav_bar/channel_nav_bar.js b/app/screens/channel/channel_nav_bar/channel_nav_bar.js index 731d9e2ea..a47d11728 100644 --- a/app/screens/channel/channel_nav_bar/channel_nav_bar.js +++ b/app/screens/channel/channel_nav_bar/channel_nav_bar.js @@ -29,8 +29,8 @@ const { export default class ChannelNavBar extends PureComponent { static propTypes = { isLandscape: PropTypes.bool.isRequired, - openChannelDrawer: PropTypes.func.isRequired, - openSettingsDrawer: PropTypes.func.isRequired, + openMainSidebar: PropTypes.func.isRequired, + openSettingsSidebar: PropTypes.func.isRequired, onPress: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, }; @@ -72,7 +72,7 @@ export default class ChannelNavBar extends PureComponent { render() { const {isLandscape, onPress, theme} = this.props; - const {openChannelDrawer, openSettingsDrawer} = this.props; + const {openMainSidebar, openSettingsSidebar} = this.props; const style = getStyleFromTheme(theme); let height; @@ -107,7 +107,7 @@ export default class ChannelNavBar extends PureComponent { return ( - + ); } diff --git a/app/screens/channel/channel_nav_bar/channel_nav_bar.test.js b/app/screens/channel/channel_nav_bar/channel_nav_bar.test.js index d693f9206..88a63b1e6 100644 --- a/app/screens/channel/channel_nav_bar/channel_nav_bar.test.js +++ b/app/screens/channel/channel_nav_bar/channel_nav_bar.test.js @@ -15,8 +15,8 @@ jest.mock('react-intl'); describe('ChannelNavBar', () => { const baseProps = { isLandscape: false, - openChannelDrawer: jest.fn(), - openSettingsDrawer: jest.fn(), + openMainSidebar: jest.fn(), + openSettingsSidebar: jest.fn(), onPress: jest.fn(), theme: Preferences.THEMES.default, }; diff --git a/app/screens/channel/channel_nav_bar/settings_drawer_button.js b/app/screens/channel/channel_nav_bar/settings_drawer_button.js index 6f0becaa1..2a2a52bf1 100644 --- a/app/screens/channel/channel_nav_bar/settings_drawer_button.js +++ b/app/screens/channel/channel_nav_bar/settings_drawer_button.js @@ -17,7 +17,7 @@ import {makeStyleSheetFromTheme} from 'app/utils/theme'; class SettingDrawerButton extends PureComponent { static propTypes = { - openDrawer: PropTypes.func.isRequired, + openSidebar: PropTypes.func.isRequired, theme: PropTypes.object, }; @@ -26,7 +26,7 @@ class SettingDrawerButton extends PureComponent { }; handlePress = preventDoubleTap(() => { - this.props.openDrawer(); + this.props.openSidebar(); }); render() { diff --git a/app/screens/channel/channel_post_list/channel_post_list.js b/app/screens/channel/channel_post_list/channel_post_list.js index 752105eed..41428f84d 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.js +++ b/app/screens/channel/channel_post_list/channel_post_list.js @@ -11,12 +11,10 @@ import { import {getLastPostIndex} from 'mattermost-redux/utils/post_list'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; -import {WebsocketEvents} from 'mattermost-redux/constants'; import AnnouncementBanner from 'app/components/announcement_banner'; import PostList from 'app/components/post_list'; import RetryBarIndicator from 'app/components/retry_bar_indicator'; -import {ViewTypes} from 'app/constants'; import tracker from 'app/utils/time_tracker'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import telemetry from 'app/telemetry'; @@ -31,7 +29,6 @@ export default class ChannelPostList extends PureComponent { loadPostsIfNecessaryWithRetry: PropTypes.func.isRequired, loadThreadIfNecessary: PropTypes.func.isRequired, increasePostVisibility: PropTypes.func.isRequired, - increasePostVisibilityByOne: PropTypes.func.isRequired, selectPost: PropTypes.func.isRequired, recordLoadTime: PropTypes.func.isRequired, refreshChannelWithRetry: PropTypes.func.isRequired, @@ -43,7 +40,6 @@ export default class ChannelPostList extends PureComponent { lastViewedAt: PropTypes.number, loadMorePostsVisible: PropTypes.bool.isRequired, postIds: PropTypes.array, - postVisibility: PropTypes.number, refreshing: PropTypes.bool.isRequired, theme: PropTypes.object.isRequired, updateNativeScrollView: PropTypes.func, @@ -51,16 +47,11 @@ export default class ChannelPostList extends PureComponent { static defaultProps = { postIds: [], - postVisibility: ViewTypes.POST_VISIBILITY_CHUNK_SIZE, }; constructor(props) { super(props); - this.state = { - visiblePostIds: this.getVisiblePostIds(props), - }; - this.contentHeight = 0; this.isLoadingMoreBottom = false; @@ -69,29 +60,14 @@ export default class ChannelPostList extends PureComponent { componentDidMount() { EventEmitter.on('goToThread', this.goToThread); - EventEmitter.on(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE, this.increasePostVisibilityByOne); - } - - componentWillReceiveProps(nextProps) { - const {postIds: nextPostIds} = nextProps; - - let visiblePostIds = this.state.visiblePostIds; - if (nextPostIds !== this.props.postIds || nextProps.postVisibility !== this.props.postVisibility) { - visiblePostIds = this.getVisiblePostIds(nextProps); - } - - if (this.props.channelId !== nextProps.channelId) { - this.isLoadingMoreTop = false; - } - - if (this.state.visiblePostIds !== visiblePostIds) { - this.setState({visiblePostIds}); - } } componentDidUpdate(prevProps) { - if (prevProps.channelId !== this.props.channelId && tracker.channelSwitch) { - this.props.actions.recordLoadTime('Switch Channel', 'channelSwitch'); + if (this.props.channelId !== prevProps.channelId) { + this.isLoadingMoreTop = false; + if (tracker.channelSwitch) { + this.props.actions.recordLoadTime('Switch Channel', 'channelSwitch'); + } } if (!prevProps.postIds?.length && this.props.postIds?.length > 0 && this.props.updateNativeScrollView) { @@ -102,13 +78,8 @@ export default class ChannelPostList extends PureComponent { componentWillUnmount() { EventEmitter.off('goToThread', this.goToThread); - EventEmitter.off(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE, this.increasePostVisibilityByOne); } - getVisiblePostIds = (props) => { - return props.postIds ? props.postIds.slice(0, props.postVisibility) : []; - }; - goToThread = (post) => { telemetry.start(['post_list:thread']); const {actions, channelId} = this.props; @@ -130,18 +101,13 @@ export default class ChannelPostList extends PureComponent { }); }; - increasePostVisibilityByOne = () => { - const {actions, channelId} = this.props; - actions.increasePostVisibilityByOne(channelId); - } - loadMorePostsTop = () => { - const {actions, channelId} = this.props; + const {actions, channelId, postIds} = this.props; if (!this.isLoadingMoreTop) { this.isLoadingMoreTop = true; actions.increasePostVisibility( channelId, - this.state.visiblePostIds[this.state.visiblePostIds.length - 1], + postIds[postIds.length - 1], ).then((hasMore) => { this.isLoadingMoreTop = !hasMore; }); @@ -190,15 +156,14 @@ export default class ChannelPostList extends PureComponent { channelRefreshingFailed, currentUserId, lastViewedAt, - loadMorePostsVisible, + postIds, refreshing, theme, } = this.props; - const {visiblePostIds} = this.state; let component; - if (visiblePostIds.length === 0 && channelRefreshingFailed) { + if (postIds.length === 0 && channelRefreshingFailed) { const FailedNetworkAction = require('app/components/failed_network_action').default; component = ( @@ -210,9 +175,9 @@ export default class ChannelPostList extends PureComponent { } else { component = ( ); } diff --git a/app/screens/channel/channel_post_list/channel_post_list.test.js b/app/screens/channel/channel_post_list/channel_post_list.test.js deleted file mode 100644 index 7ea4e9847..000000000 --- a/app/screens/channel/channel_post_list/channel_post_list.test.js +++ /dev/null @@ -1,40 +0,0 @@ -// 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 'mattermost-redux/constants/preferences'; -import EventEmitter from 'mattermost-redux/utils/event_emitter'; -import {WebsocketEvents} from 'mattermost-redux/constants'; - -import ChannelPostList from './channel_post_list'; - -describe('ChannelPostList', () => { - const baseProps = { - actions: { - loadPostsIfNecessaryWithRetry: jest.fn(), - loadThreadIfNecessary: jest.fn(), - increasePostVisibility: jest.fn(), - increasePostVisibilityByOne: jest.fn(), - selectPost: jest.fn(), - recordLoadTime: jest.fn(), - refreshChannelWithRetry: jest.fn(), - }, - channelId: 'channel-id', - loadMorePostsVisible: false, - refreshing: false, - theme: Preferences.THEMES.default, - }; - - test('should call increasePostVisibilityByOne', () => { - shallow( - , - ); - - expect(baseProps.actions.increasePostVisibilityByOne).toHaveBeenCalledTimes(0); - - EventEmitter.emit(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE); - expect(baseProps.actions.increasePostVisibilityByOne).toHaveBeenCalledWith(baseProps.channelId); - }); -}); diff --git a/app/screens/channel/channel_post_list/index.js b/app/screens/channel/channel_post_list/index.js index 97d15b748..5fe9097fb 100644 --- a/app/screens/channel/channel_post_list/index.js +++ b/app/screens/channel/channel_post_list/index.js @@ -14,7 +14,6 @@ import { loadPostsIfNecessaryWithRetry, loadThreadIfNecessary, increasePostVisibility, - increasePostVisibilityByOne, refreshChannelWithRetry, } from 'app/actions/views/channel'; import {recordLoadTime} from 'app/actions/views/root'; @@ -31,8 +30,7 @@ function mapStateToProps(state) { channelRefreshingFailed, currentUserId: getCurrentUserId(state), deviceHeight: state.device.dimension.deviceHeight, - postIds: getPostIdsInCurrentChannel(state), - postVisibility: state.views.channel.postVisibility[channelId], + postIds: getPostIdsInCurrentChannel(state) || [], lastViewedAt: state.views.channel.lastChannelViewTime[channelId], loadMorePostsVisible: state.views.channel.loadMorePostsVisible, refreshing: state.views.channel.refreshing, @@ -47,7 +45,6 @@ function mapDispatchToProps(dispatch) { loadPostsIfNecessaryWithRetry, loadThreadIfNecessary, increasePostVisibility, - increasePostVisibilityByOne, selectPost, recordLoadTime, refreshChannelWithRetry, diff --git a/app/screens/channel/index.js b/app/screens/channel/index.js index 228353389..638da338e 100644 --- a/app/screens/channel/index.js +++ b/app/screens/channel/index.js @@ -4,8 +4,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {startPeriodicStatusUpdates, stopPeriodicStatusUpdates, logout} from 'mattermost-redux/actions/users'; -import {RequestStatus} from 'mattermost-redux/constants'; +import {startPeriodicStatusUpdates, stopPeriodicStatusUpdates} from 'mattermost-redux/actions/users'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; @@ -13,22 +12,20 @@ import {shouldShowTermsOfService} from 'mattermost-redux/selectors/entities/user import {getChannelStats} from 'mattermost-redux/actions/channels'; import { - loadChannelsIfNecessary, - loadProfilesAndTeamMembersForDMSidebar, + loadChannelsForTeam, selectInitialChannel, + markChannelViewedAndRead, } from 'app/actions/views/channel'; import {connection} from 'app/actions/device'; import {recordLoadTime} from 'app/actions/views/root'; +import {logout} from 'app/actions/views/user'; import {selectDefaultTeam} from 'app/actions/views/select_team'; import {isLandscape} from 'app/selectors/device'; import Channel from './channel'; function mapStateToProps(state) { - const {myChannels: channelsRequest} = state.requests.channels; - return { - channelsRequestFailed: channelsRequest.status === RequestStatus.FAILURE, currentTeamId: getCurrentTeamId(state), currentChannelId: getCurrentChannelId(state), isLandscape: isLandscape(state), @@ -42,9 +39,9 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ getChannelStats, connection, - loadChannelsIfNecessary, - loadProfilesAndTeamMembersForDMSidebar, + loadChannelsForTeam, logout, + markChannelViewedAndRead, selectDefaultTeam, selectInitialChannel, recordLoadTime, diff --git a/app/screens/create_channel/create_channel.js b/app/screens/create_channel/create_channel.js index 2a55c286d..36a790cc7 100644 --- a/app/screens/create_channel/create_channel.js +++ b/app/screens/create_channel/create_channel.js @@ -13,9 +13,10 @@ import {Navigation} from 'react-native-navigation'; import {General, RequestStatus} from 'mattermost-redux/constants'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; -import EditChannelInfo from 'app/components/edit_channel_info'; -import {setNavigatorStyles} from 'app/utils/theme'; import {popTopScreen, dismissModal, setButtons} from 'app/actions/navigation'; +import EditChannelInfo from 'app/components/edit_channel_info'; +import {NavigationTypes} from 'app/constants'; +import {setNavigatorStyles} from 'app/utils/theme'; export default class CreateChannel extends PureComponent { static propTypes = { @@ -87,7 +88,7 @@ export default class CreateChannel extends PureComponent { this.setState({error: null, creating: true}); break; case RequestStatus.SUCCESS: - EventEmitter.emit('close_channel_drawer'); + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); InteractionManager.runAfterInteractions(() => { this.emitCreating(false); this.setState({error: null, creating: false}); diff --git a/app/screens/edit_channel/edit_channel.js b/app/screens/edit_channel/edit_channel.js index e4b43ca9e..da5505f1b 100644 --- a/app/screens/edit_channel/edit_channel.js +++ b/app/screens/edit_channel/edit_channel.js @@ -14,7 +14,7 @@ import {General, RequestStatus} from 'mattermost-redux/constants'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; import EditChannelInfo from 'app/components/edit_channel_info'; -import {ViewTypes} from 'app/constants'; +import {NavigationTypes, ViewTypes} from 'app/constants'; import {setNavigatorStyles} from 'app/utils/theme'; import {cleanUpUrlable} from 'app/utils/url'; import {t} from 'app/utils/i18n'; @@ -151,7 +151,7 @@ export default class EditChannel extends PureComponent { this.emitUpdating(true); break; case RequestStatus.SUCCESS: - EventEmitter.emit('close_channel_drawer'); + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); InteractionManager.runAfterInteractions(() => { this.emitUpdating(false); this.close(); diff --git a/app/screens/index.js b/app/screens/index.js index 8a61a7ac2..963e814a7 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import React from 'react'; +import {Platform} from 'react-native'; import {Navigation} from 'react-native-navigation'; import {gestureHandlerRootHOC} from 'react-native-gesture-handler'; @@ -78,4 +79,9 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('TimezoneSettings', () => wrapper(require('app/screens/settings/timezone').default), () => require('app/screens/settings/timezone').default); Navigation.registerComponent('ErrorTeamsList', () => wrapper(require('app/screens/error_teams_list').default), () => require('app/screens/error_teams_list').default); Navigation.registerComponent('UserProfile', () => wrapper(require('app/screens/user_profile').default), () => require('app/screens/user_profile').default); + + if (Platform.OS === 'android') { + Navigation.registerComponentWithRedux('MainSidebar', () => require('app/components/sidebars/main').default, Provider, store); + Navigation.registerComponentWithRedux('SettingsSidebar', () => require('app/components/sidebars/settings').default, Provider, store); + } } diff --git a/app/screens/login/index.js b/app/screens/login/index.js index 8b1165225..280365ee4 100644 --- a/app/screens/login/index.js +++ b/app/screens/login/index.js @@ -4,7 +4,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {login} from 'mattermost-redux/actions/users'; +import {login} from 'app/actions/views/user'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import {isLandscape} from 'app/selectors/device'; @@ -13,12 +13,10 @@ import LoginActions from 'app/actions/views/login'; import Login from './login.js'; function mapStateToProps(state) { - const {login: loginRequest} = state.requests.users; const config = getConfig(state); const license = getLicense(state); return { ...state.views.login, - loginRequest, config, license, theme: getTheme(state), diff --git a/app/screens/login/login.js b/app/screens/login/login.js index 2b009d6e0..ebc4dd9a2 100644 --- a/app/screens/login/login.js +++ b/app/screens/login/login.js @@ -19,8 +19,6 @@ import { import Button from 'react-native-button'; import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; -import {RequestStatus} from 'mattermost-redux/constants'; - import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing'; import ErrorText from 'app/components/error_text'; import FormattedText from 'app/components/formatted_text'; @@ -46,12 +44,10 @@ export default class Login extends PureComponent { scheduleExpiredNotification: PropTypes.func.isRequired, login: PropTypes.func.isRequired, }).isRequired, - theme: PropTypes.object, config: PropTypes.object.isRequired, license: PropTypes.object.isRequired, loginId: PropTypes.string.isRequired, password: PropTypes.string.isRequired, - loginRequest: PropTypes.object.isRequired, isLandscape: PropTypes.bool.isRequired, }; @@ -64,6 +60,7 @@ export default class Login extends PureComponent { this.state = { error: null, + isLoading: false, }; } @@ -73,14 +70,6 @@ export default class Login extends PureComponent { setMfaPreflightDone(false); } - componentWillReceiveProps(nextProps) { - if (this.props.loginRequest.status === RequestStatus.STARTED && nextProps.loginRequest.status === RequestStatus.SUCCESS) { - this.props.actions.handleSuccessfulLogin().then(this.goToChannel); - } else if (this.props.loginRequest.status !== nextProps.loginRequest.status && nextProps.loginRequest.status !== RequestStatus.STARTED) { - this.setState({isLoading: false}); - } - } - componentWillUnmount() { Dimensions.removeEventListener('change', this.orientationDidChange); } @@ -100,7 +89,7 @@ export default class Login extends PureComponent { const screen = 'MFA'; const title = intl.formatMessage({id: 'mobile.routes.mfa', defaultMessage: 'Multi-factor Authentication'}); - goToScreen(screen, title); + goToScreen(screen, title, {onMfaComplete: this.checkLoginResponse}); }; blur = () => { @@ -178,16 +167,32 @@ export default class Login extends PureComponent { }; signIn = () => { - const {actions, loginId, loginRequest, password} = this.props; - if (loginRequest.status !== RequestStatus.STARTED) { - actions.login(loginId.toLowerCase(), password).then(this.checkLoginResponse); + const {actions, loginId, password} = this.props; + const {isLoading} = this.state; + if (isLoading) { + actions.login(loginId.toLowerCase(), password). + then(this.checkLoginResponse); } }; checkLoginResponse = (data) => { if (mfaExpectedErrors.includes(data?.error?.server_error_id)) { // eslint-disable-line camelcase this.goToMfa(); + this.setState({isLoading: false}); + return false; } + + if (data?.error) { + this.setState({ + error: this.getLoginErrorMessage(data.error), + isLoading: false, + }); + return false; + } + + this.setState({isLoading: false}); + resetToChannel(); + return true; }; createLoginPlaceholder() { @@ -223,15 +228,14 @@ export default class Login extends PureComponent { return ''; } - getLoginErrorMessage = () => { + getLoginErrorMessage = (error) => { return ( - this.getServerErrorForLogin() || + this.getServerErrorForLogin(error) || this.state.error ); }; - getServerErrorForLogin = () => { - const {error} = this.props.loginRequest; + getServerErrorForLogin = (error) => { if (!error) { return null; } @@ -295,7 +299,7 @@ export default class Login extends PureComponent { } render() { - const isLoading = this.props.loginRequest.status === RequestStatus.STARTED || this.state.isLoading; + const {isLoading} = this.state; let proceed; if (isLoading) { @@ -373,7 +377,7 @@ export default class Login extends PureComponent { defaultMessage='All team communication in one place, searchable and accessible anywhere' /> - + { expect(wrapper.find(FormattedText).find({id: 'login.forgot'}).exists()).toBe(false); }); - test('should send the user to the login screen after login', (done) => { - const resetToChannel = jest.spyOn(NavigationActions, 'resetToChannel').mockImplementation(() => done()); - - let props = { - ...baseProps, - loginRequest: { - status: RequestStatus.NOT_STARTED, - }, - }; - - props.actions.handleSuccessfulLogin.mockImplementation(() => Promise.resolve()); - - const wrapper = shallowWithIntl(); - - expect(resetToChannel).not.toHaveBeenCalled(); - - props = { - ...props, - loginRequest: { - status: RequestStatus.STARTED, - }, - }; - wrapper.setProps(props); - - expect(resetToChannel).not.toHaveBeenCalled(); - - props = { - ...props, - loginRequest: { - status: RequestStatus.SUCCESS, - }, - }; - wrapper.setProps(props); - - // This test times out if resetToChannel hasn't been called - }); - test('should go to MFA screen when login response returns MFA error', () => { const goToScreen = jest.spyOn(NavigationActions, 'goToScreen'); @@ -132,6 +93,7 @@ describe('Login', () => { toHaveBeenCalledWith( 'MFA', 'Multi-factor Authentication', + {onMfaComplete: wrapper.instance().checkLoginResponse}, ); }); diff --git a/app/screens/mfa/index.js b/app/screens/mfa/index.js index 4707c0bd2..caf034c41 100644 --- a/app/screens/mfa/index.js +++ b/app/screens/mfa/index.js @@ -4,17 +4,15 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {login} from 'mattermost-redux/actions/users'; +import {login} from 'app/actions/views/user'; import Mfa from './mfa'; function mapStateToProps(state) { - const {login: loginRequest} = state.requests.users; const {loginId, password} = state.views.login; return { loginId, password, - loginRequest, }; } diff --git a/app/screens/mfa/mfa.js b/app/screens/mfa/mfa.js index b76cf78ed..c10359416 100644 --- a/app/screens/mfa/mfa.js +++ b/app/screens/mfa/mfa.js @@ -15,8 +15,6 @@ import { } from 'react-native'; import Button from 'react-native-button'; -import {RequestStatus} from 'mattermost-redux/constants'; - import ErrorText from 'app/components/error_text'; import FormattedText from 'app/components/formatted_text'; import StatusBar from 'app/components/status_bar'; @@ -34,7 +32,7 @@ export default class Mfa extends PureComponent { }).isRequired, loginId: PropTypes.string.isRequired, password: PropTypes.string.isRequired, - loginRequest: PropTypes.object.isRequired, + onMfaComplete: PropTypes.func.isRequired, }; constructor(props) { @@ -43,6 +41,7 @@ export default class Mfa extends PureComponent { this.state = { token: '', error: null, + isLoading: false, }; } @@ -52,14 +51,6 @@ export default class Mfa extends PureComponent { } } - componentDidUpdate(prevProps) { - // In case the login is successful the previous scene (login) will take care of the transition - if (prevProps.loginRequest.status === RequestStatus.STARTED && - this.props.loginRequest.status === RequestStatus.FAILURE) { - popTopScreen(); - } - } - componentWillUnmount() { if (Platform.OS === 'android') { Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard); @@ -86,8 +77,11 @@ export default class Mfa extends PureComponent { }; submit = preventDoubleTap(() => { + const {actions, loginId, password, onMfaComplete} = this.props; + const {token} = this.state; + Keyboard.dismiss(); - if (!this.state.token) { + if (!token) { this.setState({ error: { intl: { @@ -99,11 +93,17 @@ export default class Mfa extends PureComponent { return; } setMfaPreflightDone(true); - this.props.actions.login(this.props.loginId, this.props.password, this.state.token); + this.setState({isLoading: true}); + actions.login(loginId, password, token).then(() => { + if (!onMfaComplete()) { + popTopScreen(); + } + this.setState({isLoading: false}); + }); }); render() { - const isLoading = this.props.loginRequest.status === RequestStatus.STARTED; + const {isLoading} = this.state; let proceed; if (isLoading) { diff --git a/app/screens/more_channels/more_channels.js b/app/screens/more_channels/more_channels.js index 85a2e3c21..804f3cd5a 100644 --- a/app/screens/more_channels/more_channels.js +++ b/app/screens/more_channels/more_channels.js @@ -21,6 +21,7 @@ import KeyboardLayout from 'app/components/layout/keyboard_layout'; import Loading from 'app/components/loading'; import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; +import {NavigationTypes} from 'app/constants'; import {alertErrorWithFallback, emptyFunction} from 'app/utils/general'; import {goToScreen, dismissModal, setButtons} from 'app/actions/navigation'; import { @@ -271,7 +272,7 @@ export default class MoreChannels extends PureComponent { } await actions.handleSelectChannel(id); - EventEmitter.emit('close_channel_drawer'); + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); requestAnimationFrame(() => { this.close(); }); diff --git a/app/screens/more_dms/more_dms.js b/app/screens/more_dms/more_dms.js index cbdcda742..53c8d015b 100644 --- a/app/screens/more_dms/more_dms.js +++ b/app/screens/more_dms/more_dms.js @@ -21,6 +21,7 @@ import KeyboardLayout from 'app/components/layout/keyboard_layout'; import Loading from 'app/components/loading'; import SearchBar from 'app/components/search_bar'; import StatusBar from 'app/components/status_bar'; +import {NavigationTypes} from 'app/constants'; import {alertErrorWithFallback} from 'app/utils/general'; import {createProfilesSections, loadingText} from 'app/utils/member_list'; import { @@ -321,7 +322,7 @@ export default class MoreDirectMessages extends PureComponent { } if (success) { - EventEmitter.emit('close_channel_drawer'); + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); requestAnimationFrame(() => { this.close(); }); diff --git a/app/screens/notification/notification.js b/app/screens/notification/notification.js index 00c504b64..6284b4b98 100644 --- a/app/screens/notification/notification.js +++ b/app/screens/notification/notification.js @@ -21,11 +21,11 @@ import {isDirectChannel} from 'mattermost-redux/utils/channel_utils'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; import {displayUsername} from 'mattermost-redux/utils/user_utils'; +import {popToRoot, dismissAllModals, dismissOverlay} from 'app/actions/navigation'; import FormattedText from 'app/components/formatted_text'; import ProfilePicture from 'app/components/profile_picture'; -import {changeOpacity} from 'app/utils/theme'; import {NavigationTypes} from 'app/constants'; -import {popToRoot, dismissAllModals, dismissOverlay} from 'app/actions/navigation'; +import {changeOpacity} from 'app/utils/theme'; import logo from 'assets/images/icon.png'; import webhookIcon from 'assets/images/icons/webhook.jpg'; @@ -143,8 +143,8 @@ export default class Notification extends PureComponent { const {actions, notification} = this.props; - EventEmitter.emit('close_channel_drawer'); - EventEmitter.emit('close_settings_sidebar'); + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEABR); + EventEmitter.emit(NavigationTypes.CLOSE_SETTINGS_SIDEBAR); InteractionManager.runAfterInteractions(() => { this.dismissOverlay(); if (!notification.localNotification) { diff --git a/app/screens/select_team/select_team.js b/app/screens/select_team/select_team.js index d2c65a731..cf9c5747d 100644 --- a/app/screens/select_team/select_team.js +++ b/app/screens/select_team/select_team.js @@ -22,6 +22,7 @@ import StatusBar from 'app/components/status_bar'; import CustomList from 'app/components/custom_list'; import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing'; import TeamIcon from 'app/components/team_icon'; +import {NavigationTypes} from 'app/constants'; import {resetToChannel, dismissModal} from 'app/actions/navigation'; import {preventDoubleTap} from 'app/utils/tap'; import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; @@ -158,7 +159,7 @@ export default class SelectTeam extends PureComponent { if (userWithoutTeams) { this.goToChannelView(); } else { - EventEmitter.emit('close_channel_drawer'); + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); InteractionManager.runAfterInteractions(() => { this.close(); }); diff --git a/app/screens/settings/display_settings/display_settings.js b/app/screens/settings/display_settings/display_settings.js index 2734a5971..c96822c87 100644 --- a/app/screens/settings/display_settings/display_settings.js +++ b/app/screens/settings/display_settings/display_settings.js @@ -4,10 +4,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {intlShape} from 'react-intl'; -import { - Platform, - View, -} from 'react-native'; +import {Platform, View} from 'react-native'; import {DeviceTypes} from 'app/constants'; import StatusBar from 'app/components/status_bar'; @@ -111,7 +108,7 @@ export default class DisplaySettings extends PureComponent { } let sidebar; - if (DeviceTypes.IS_TABLET) { + if (DeviceTypes.IS_TABLET && Platform.OS === 'ios') { sidebar = ( { + if (result.error) { + this.onLoadEndError(result.error); + return; + } + this.goToChannel(); + }); } else if (this.webView && !this.state.error) { this.webView.injectJavaScript(postMessageJS); } diff --git a/app/store/store.js b/app/store/store.js index a3f11ec07..2e245ab6e 100644 --- a/app/store/store.js +++ b/app/store/store.js @@ -9,7 +9,7 @@ import {createTransform, persistStore} from 'redux-persist'; import merge from 'deepmerge'; import {ErrorTypes, GeneralTypes} from 'mattermost-redux/action_types'; -import {General, RequestStatus} from 'mattermost-redux/constants'; +import {General} from 'mattermost-redux/constants'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import configureStore from 'mattermost-redux/store'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; @@ -62,7 +62,7 @@ export default function configureAppStore(initialState) { ['typing'], ); - const channelViewBlackList = {loading: true, refreshing: true, loadingPosts: true, postVisibility: true, retryFailed: true, loadMorePostsVisible: true}; + const channelViewBlackList = {loading: true, refreshing: true, loadingPosts: true, retryFailed: true, loadMorePostsVisible: true}; const channelViewBlackListFilter = createTransform( (inboundState) => { const channel = {}; @@ -206,39 +206,7 @@ export default function configureAppStore(initialState) { setSiteUrl(config.SiteURL); } - if ((state.requests.users.logout.status === RequestStatus.SUCCESS || state.requests.users.logout.status === RequestStatus.FAILURE) && !purging) { - purging = true; - - await persistor.purge(); - - store.dispatch(batchActions([ - { - type: General.OFFLINE_STORE_RESET, - data: initialState, - }, - { - type: General.STORE_REHYDRATION_COMPLETE, - }, - { - type: ViewTypes.SERVER_URL_CHANGED, - serverUrl: state.entities.general.credentials.url || state.views.selectServer.serverUrl, - }, - { - type: GeneralTypes.RECEIVED_APP_DEVICE_TOKEN, - data: state.entities.general.deviceToken, - }, - ])); - - // When logging out remove the data stored in the bucket - mattermostBucket.removePreference('cert'); - mattermostBucket.removePreference('emm'); - mattermostBucket.removeFile('entities'); - setSiteUrl(null); - - setTimeout(() => { - purging = false; - }, 500); - } else if (state.views.root.purge && !purging) { + if (state.views.root.purge && !purging) { purging = true; await persistor.purge(); diff --git a/app/utils/channels.js b/app/utils/channels.js index dfffc71c0..8a4d8a3b0 100644 --- a/app/utils/channels.js +++ b/app/utils/channels.js @@ -14,3 +14,56 @@ export function isGroupChannelVisible(myPreferences, channel) { const gm = myPreferences[`${Preferences.CATEGORY_GROUP_CHANNEL_SHOW}--${channel.id}`]; return gm && gm.value === 'true'; } + +export function isFavoriteChannel(preferences, channelId) { + const fav = preferences[`${Preferences.CATEGORY_FAVORITE_CHANNEL}--${channelId}`]; + return fav ? fav.value === 'true' : false; +} + +// New to replace the ones above +export function isDirectMessageVisible(preferences, channelId) { + const dm = preferences[`${Preferences.CATEGORY_DIRECT_CHANNEL_SHOW}--${channelId}`]; + return dm ? dm.value === 'true' : true; +} + +export function isGroupMessageVisible(preferences, channelId) { + const gm = preferences[`${Preferences.CATEGORY_GROUP_CHANNEL_SHOW}--${channelId}`]; + return gm ? gm.value === 'true' : true; +} + +export function isDirectChannelAutoClosed(config, preferences, channelId, channelActivity, channelArchiveTime = 0, currentChannelId = '') { + // When the config is not set or is a favorite channel + if (config.CloseUnusedDirectMessages !== 'true' || isFavoriteChannel(preferences, channelId)) { + return false; + } + + const cutoff = new Date().getTime() - (7 * 24 * 60 * 60 * 1000); + const viewTimePref = preferences[`${Preferences.CATEGORY_CHANNEL_APPROXIMATE_VIEW_TIME}--${channelId}`]; + const viewTime = viewTimePref ? parseInt(viewTimePref.value || 0, 10) : 0; + + if (viewTime > cutoff) { + return false; + } + + const openTimePref = preferences[`${Preferences.CATEGORY_CHANNEL_OPEN_TIME}--${channelId}`]; + const openTime = openTimePref ? parseInt(openTimePref.value || 0, 10) : 0; + + // Only close archived channels when not being viewed + if (channelId !== currentChannelId && channelArchiveTime && channelArchiveTime > openTime) { + return true; + } + + const autoClose = preferences[`${Preferences.CATEGORY_SIDEBAR_SETTINGS}--close_unused_direct_messages`]; + if (!autoClose || autoClose.value === 'after_seven_days') { + if (channelActivity && channelActivity > cutoff) { + return false; + } + if (openTime > cutoff) { + return false; + } + + return !channelActivity || channelActivity < cutoff; + } + + return false; +} \ No newline at end of file diff --git a/app/utils/preferences.js b/app/utils/preferences.js new file mode 100644 index 000000000..0883bd308 --- /dev/null +++ b/app/utils/preferences.js @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export function buildPreference(category, userId, name, value = 'true') { + return { + user_id: userId, + category, + name, + value, + }; +} \ No newline at end of file diff --git a/app/utils/push_notifications.js b/app/utils/push_notifications.js index 2bb1e26d3..33b5c23e4 100644 --- a/app/utils/push_notifications.js +++ b/app/utils/push_notifications.js @@ -17,7 +17,7 @@ import { } from 'app/actions/views/root'; import {dismissAllModals, popToRoot} from 'app/actions/navigation'; -import {ViewTypes} from 'app/constants'; +import {NavigationTypes, ViewTypes} from 'app/constants'; import {getLocalizedMessage} from 'app/i18n'; import {getCurrentServerUrl, getAppCredentials} from 'app/init/credentials'; import PushNotifications from 'app/push_notifications'; @@ -53,8 +53,8 @@ class PushNotificationUtils { // if we have a componentId means that the app is already initialized const componentId = EphemeralStore.getNavigationTopComponentId(); if (componentId) { - EventEmitter.emit('close_channel_drawer'); - EventEmitter.emit('close_settings_sidebar'); + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); + EventEmitter.emit(NavigationTypes.CLOSE_SETTINGS_SIDEBAR); await dismissAllModals(); await popToRoot(); @@ -80,7 +80,7 @@ class PushNotificationUtils { if (foreground) { EventEmitter.emit(ViewTypes.NOTIFICATION_IN_APP, notification); } else if (userInteraction && !notification?.data?.localNotification) { - EventEmitter.emit('close_channel_drawer'); + EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR); if (getState().views.root.hydrationComplete) { //TODO: Replace when realm is ready setTimeout(() => { this.loadFromNotification(notification); diff --git a/package-lock.json b/package-lock.json index efa100aea..776fddb54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9974,8 +9974,8 @@ } }, "mattermost-redux": { - "version": "github:mattermost/mattermost-redux#a0e7a1cbedb2ec051e0d2411fd47ee19bb49d709", - "from": "github:mattermost/mattermost-redux#a0e7a1cbedb2ec051e0d2411fd47ee19bb49d709", + "version": "github:mattermost/mattermost-redux#d7e1109753a3cc1a4e79e8dfe34e149625e9443c", + "from": "github:mattermost/mattermost-redux#d7e1109753a3cc1a4e79e8dfe34e149625e9443c", "requires": { "core-js": "3.1.4", "form-data": "2.5.1", diff --git a/package.json b/package.json index 3e470441a..f31587073 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "intl": "1.2.5", "jail-monkey": "2.3.1", "jsc-android": "241213.2.0", - "mattermost-redux": "github:mattermost/mattermost-redux#a0e7a1cbedb2ec051e0d2411fd47ee19bb49d709", + "mattermost-redux": "github:mattermost/mattermost-redux#d7e1109753a3cc1a4e79e8dfe34e149625e9443c", "mime-db": "1.43.0", "moment-timezone": "0.5.27", "prop-types": "15.7.2", diff --git a/test/setup.js b/test/setup.js index c246e182f..ed1962968 100644 --- a/test/setup.js +++ b/test/setup.js @@ -156,7 +156,6 @@ jest.mock('app/actions/navigation', () => ({ showModal: jest.fn(), showModalOverCurrentContext: jest.fn(), showSearchModal: jest.fn(), - peek: jest.fn(), setButtons: jest.fn(), showOverlay: jest.fn(), mergeNavigationOptions: jest.fn(), @@ -195,12 +194,6 @@ beforeEach(() => { errors = []; }); -afterEach(() => { - if (logs.length > 0 || warns.length > 0 || errors.length > 0) { - throw new Error('Unexpected console logs' + logs + warns + errors); - } -}); - jest.mock('rn-fetch-blob', () => ({ fs: { dirs: {