Performance improvements (#3911)

* 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 <migbot@users.noreply.github.com>
This commit is contained in:
Elias Nahum 2020-02-18 01:18:09 -03:00 committed by GitHub
parent 4f7aeb6d3e
commit 334a07aabe
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
77 changed files with 2361 additions and 2019 deletions

View file

@ -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},
},
});
}

View file

@ -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),

View file

@ -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;
}

View file

@ -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);
});

View file

@ -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: {}});
}

View file

@ -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;

View file

@ -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 (
<FastImage
<Image
key={key}
style={[customEmojiStyle, {width, height}]}
source={{uri: imageUrl}}

View file

@ -20,6 +20,7 @@ export default class AttachmentText extends PureComponent {
metadata: PropTypes.object,
onPermalinkPress: PropTypes.func,
textStyles: PropTypes.object.isRequired,
theme: PropTypes.object,
value: PropTypes.string,
};
@ -68,8 +69,9 @@ export default class AttachmentText extends PureComponent {
hasThumbnail,
metadata,
onPermalinkPress,
value,
textStyles,
theme,
value,
} = this.props;
const {collapsed, isLongText, maxHeight} = this.state;
@ -103,6 +105,7 @@ export default class AttachmentText extends PureComponent {
<ShowMoreButton
onPress={this.toggleCollapseState}
showMore={collapsed}
theme={theme}
/>
}
</View>

View file

@ -95,6 +95,7 @@ export default class MessageAttachment extends PureComponent {
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}
value={attachment.text}
theme={theme}
/>
<AttachmentFields
baseTextStyle={baseTextStyle}

View file

@ -320,7 +320,7 @@ export default class Post extends PureComponent {
underlayColor={changeOpacity(theme.centerChannelColor, 0.1)}
cancelTouchOnPanning={true}
>
<React.Fragment>
<>
<PostPreHeader
isConsecutive={mergeMessage}
isFlagged={isFlagged}
@ -355,7 +355,7 @@ export default class Post extends PureComponent {
/>
</View>
</View>
</React.Fragment>
</>
</TouchableWithFeedback>
</View>
);

View file

@ -431,6 +431,7 @@ export default class PostBody extends PureComponent {
<ShowMoreButton
highlight={highlight}
onPress={this.openLongPost}
theme={theme}
/>
}
{this.renderPostAdditionalContent(blockStyles, messageStyle, textStyles)}

View file

@ -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);

View file

@ -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}
/>
`;

View file

@ -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 {
<FlatList
key={`recyclerFor-${channelId}-${hasPostsKey}`}
ref={this.flatListRef}
style={{flex: 1}}
contentContainerStyle={styles.postListContent}
data={postIds}
extraData={this.makeExtraData(channelId, highlightPostId, this.props.extraData)}
extraData={this.makeExtraData(channelId, highlightPostId, extraData, loadMorePostsVisible)}
initialNumToRender={INITIAL_BATCH_TO_RENDER}
inverted={true}
keyboardDismissMode={'interactive'}
@ -486,7 +493,6 @@ export default class PostList extends PureComponent {
keyExtractor={this.keyExtractor}
ListFooterComponent={this.props.renderFooter}
maintainVisibleContentPosition={SCROLL_POSITION_CONFIG}
maxToRenderPerBatch={INITIAL_BATCH_TO_RENDER + 1}
onContentSizeChange={this.handleContentSizeChange}
onLayout={this.handleLayout}
onScroll={this.handleScroll}
@ -496,6 +502,7 @@ export default class PostList extends PureComponent {
scrollEventThrottle={60}
refreshControl={refreshControl}
nativeID={scrollViewNativeID}
windowSize={50}
/>
);
}

View file

@ -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);

View file

@ -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 (
<View style={style.button}>
<Text style={style.sign}>{sign}</Text>
<FormattedText
id={textId}
defaultMessage={textMessage}
style={style.text}
/>
</View>
);
}
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 (
<View>
{showMore &&
<LinearGradient
colors={gradientColors}
locations={[0, 0.7, 1]}
style={style.gradient}
/>
}
<View style={style.container}>
<View style={style.dividerLeft}/>
<TouchableWithFeedback
onPress={this.props.onPress}
style={style.buttonContainer}
type={'opacity'}
>
{this.renderButton(showMore, style)}
</TouchableWithFeedback>
<View style={style.dividerRight}/>
</View>
</View>
);
}
}
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,
},
};
});

View file

@ -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 (
<View style={style.button}>
<Text style={style.sign}>{sign}</Text>
<FormattedText
id={textId}
defaultMessage={textMessage}
style={style.text}
/>
</View>
);
}
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 (
<View>
{showMore &&
<LinearGradient
colors={gradientColors}
locations={[0, 0.7, 1]}
style={style.gradient}
/>
}
<View style={style.container}>
<View style={style.dividerLeft}/>
<TouchableWithFeedback
onPress={this.props.onPress}
style={style.buttonContainer}
type={'opacity'}
>
{this.renderButton(showMore, style)}
</TouchableWithFeedback>
<View style={style.dividerRight}/>
</View>
</View>
);
}
}
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,
},
};
});

View file

@ -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 = {

View file

@ -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 (
<React.Fragment>
@ -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) {

View file

@ -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,
};

View file

@ -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) => {

View file

@ -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),
};
}

View file

@ -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 (
<IntlProvider
key={locale}
locale={locale}
ref={this.setProviderRef}
messages={getTranslations(locale)}
>
{this.renderNavigationView(this.state.deviceWidth)}
</IntlProvider>
);
}
}

View file

@ -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 (
<DrawerLayout
ref={this.drawerRef}
renderNavigationView={this.renderNavigationView}
onDrawerClose={this.handleDrawerClose}
onDrawerOpen={this.handleDrawerOpen}
drawerWidth={drawerWidth}
useNativeAnimations={true}
isTablet={isTablet}
>
{children}
</DrawerLayout>
);
}
}

View file

@ -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 = (
<View
key='teamsList'
style={style.swiperContent}
>
<TeamsList
closeChannelDrawer={this.closeChannelDrawer}
/>
</View>
);
lists.push(teamsList);
}
lists.push(
<View
key='channelsList'
style={style.swiperContent}
>
<ChannelsList
ref={this.channelListRef}
onSelectChannel={this.selectChannel}
onJoinChannel={this.joinChannel}
onShowTeams={this.showTeams}
onSearchStart={this.onSearchStart}
onSearchEnds={this.onSearchEnds}
theme={theme}
drawerOpened={this.state.drawerOpened}
/>
</View>,
);
return (
<SafeAreaView
excludeFooter={true}
navBarBackgroundColor={theme.sidebarBg}
backgroundColor={theme.sidebarHeaderBg}
footerColor={theme.sidebarBg}
>
<DrawerSwiper
ref={this.drawerSwiperRef}
onPageSelected={this.onPageSelected}
showTeams={showTeams}
drawerOpened={this.state.drawerOpened}
drawerWidth={drawerWidth}
hasSafeAreaInsets={hasSafeAreaInsets}
>
{lists}
</DrawerSwiper>
</SafeAreaView>
);
};
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 (
<DrawerLayout
ref={this.drawerRef}
renderNavigationView={this.renderNavigationView}
onDrawerClose={this.handleDrawerClose}
onDrawerOpen={this.handleDrawerOpen}
drawerWidth={drawerWidth}
useNativeAnimations={true}
isTablet={isTablet}
>
{children}
</DrawerLayout>
);
}
}
const style = StyleSheet.create({
swiperContent: {
flex: 1,
},
});

View file

@ -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(
<MainSidebar {...baseProps}/>,
);
@ -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(
<MainSidebar {...baseProps}/>,
);
const drawer = wrapper.dive().childAt(1);
const drawerStyle = drawer.props().style.reduce((acc, obj) => ({...acc, ...obj}));
expect(drawerStyle).toHaveProperty('zIndex', 3);
});
});

View file

@ -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 = (
<View
key='teamsList'
style={style.swiperContent}
>
<TeamsList
closeMainSidebar={this.closeMainSidebar}
/>
</View>
);
lists.push(teamsList);
}
lists.push(
<View
key='channelsList'
style={style.swiperContent}
>
<ChannelsList
ref={this.channelListRef}
onSelectChannel={this.selectChannel}
onJoinChannel={this.joinChannel}
onShowTeams={this.showTeams}
onSearchStart={this.onSearchStart}
onSearchEnds={this.onSearchEnds}
theme={theme}
/>
</View>,
);
return (
<SafeAreaView
excludeFooter={true}
navBarBackgroundColor={theme.sidebarBg}
backgroundColor={theme.sidebarHeaderBg}
footerColor={theme.sidebarBg}
>
<DrawerSwiper
ref={this.drawerSwiperRef}
onPageSelected={this.onPageSelected}
showTeams={showTeams}
drawerOpened={this.state.drawerOpened}
drawerWidth={drawerWidth - offset}
>
{lists}
</DrawerSwiper>
</SafeAreaView>
);
};
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,
},
});

View file

@ -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();
});
};

View file

@ -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),
};

View file

@ -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 (
<View style={style.sidebar}>
{this.renderOptions(style)}
</View>
);
};
setProviderRef = (ref) => {
this.providerRef = ref;
}
render() {
const locale = this.props.locale;
if (!locale) {
return null;
}
return (
<IntlProvider
key={locale}
locale={locale}
ref={this.setProviderRef}
messages={getTranslations(locale)}
>
{this.renderNavigationView()}
</IntlProvider>
);
}
}
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,
},
};
});

View file

@ -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 (
<SafeAreaView
backgroundColor={theme.centerChannelBg}
navBarBackgroundColor={theme.centerChannelBg}
footerColor={theme.centerChannelBg}
footerComponent={<View style={style.container}/>}
headerComponent={<View style={style.container}/>}
theme={theme}
>
{this.renderOptions(style)}
</SafeAreaView>
);
};
render() {
const {children} = this.props;
const {deviceWidth, openDrawerOffset} = this.state;
const drawerWidth = DeviceTypes.IS_TABLET ? TABLET_WIDTH : (deviceWidth - openDrawerOffset);
return (
<DrawerLayout
ref={this.setDrawerRef}
renderNavigationView={this.renderNavigationView}
onDrawerClose={this.handleDrawerClose}
onDrawerOpen={this.handleDrawerOpen}
drawerPosition='right'
drawerWidth={drawerWidth}
useNativeAnimations={true}
>
{children}
</DrawerLayout>
);
}
}
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,
},
};
});

View file

@ -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 (
<UserStatus
size={18}
userId={userId}
/>
);
};
renderUserStatusLabel = (userId) => {
return (
<StatusLabel userId={userId}/>
);
};
renderNavigationView = () => {
const {currentUser, theme} = this.props;
const style = getStyleSheet(theme);
return (
<SafeAreaView
backgroundColor={theme.centerChannelBg}
navBarBackgroundColor={theme.centerChannelBg}
footerColor={theme.centerChannelBg}
footerComponent={<View style={style.container}/>}
headerComponent={<View style={style.container}/>}
theme={theme}
>
<View style={style.container}>
<ScrollView
alwaysBounceVertical={false}
contentContainerStyle={style.wrapper}
>
<UserInfo
onPress={this.goToUserProfile}
user={currentUser}
/>
<View style={style.block}>
<DrawerItem
labelComponent={this.renderUserStatusLabel(currentUser.id)}
leftComponent={this.renderUserStatusIcon(currentUser.id)}
separator={false}
onPress={this.handleSetStatus}
theme={theme}
/>
</View>
<View style={style.separator}/>
<View style={style.block}>
<DrawerItem
defaultMessage='Recent Mentions'
i18nId='search_header.title2'
iconName='ios-at'
iconType='ion'
onPress={this.goToMentions}
separator={true}
theme={theme}
/>
<DrawerItem
defaultMessage='Flagged Posts'
i18nId='search_header.title3'
iconName='ios-flag'
iconType='ion'
onPress={this.goToFlagged}
separator={false}
theme={theme}
/>
</View>
<View style={style.separator}/>
<View style={style.block}>
<DrawerItem
defaultMessage='Edit Profile'
i18nId='mobile.routes.edit_profile'
iconName='ios-person'
iconType='ion'
onPress={this.goToEditProfile}
separator={true}
theme={theme}
/>
<DrawerItem
defaultMessage='Settings'
i18nId='mobile.routes.settings'
iconName='ios-options'
iconType='ion'
onPress={this.goToSettings}
separator={false}
theme={theme}
/>
</View>
<View style={style.separator}/>
<View style={style.block}>
<DrawerItem
centered={true}
defaultMessage='Logout'
i18nId='sidebar_right_menu.logout'
isDestructor={true}
onPress={this.logout}
separator={false}
theme={theme}
/>
</View>
</ScrollView>
</View>
</SafeAreaView>
);
};
render() {
const {children} = this.props;
const {deviceWidth, openDrawerOffset} = this.state;
const drawerWidth = DeviceTypes.IS_TABLET ? TABLET_WIDTH : (deviceWidth - openDrawerOffset);
return (
<DrawerLayout
ref={this.setDrawerRef}
renderNavigationView={this.renderNavigationView}
onDrawerClose={this.handleDrawerClose}
onDrawerOpen={this.handleDrawerOpen}
drawerPosition='right'
drawerWidth={drawerWidth}
useNativeAnimations={true}
>
{children}
</DrawerLayout>
);
}
}
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,
},
};
});

View file

@ -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(<SettingsSidebar {...baseProps}/>);
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);
});
});

View file

@ -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 (
<UserStatus
size={18}
userId={userId}
/>
);
};
renderUserStatusLabel = (userId) => {
return (
<StatusLabel userId={userId}/>
);
};
renderOptions = (style) => {
const {currentUser, theme} = this.props;
return (
<View style={style.container}>
<ScrollView
alwaysBounceVertical={false}
contentContainerStyle={style.wrapper}
>
<UserInfo
onPress={this.goToUserProfile}
user={currentUser}
/>
<View style={style.block}>
<DrawerItem
labelComponent={this.renderUserStatusLabel(currentUser.id)}
leftComponent={this.renderUserStatusIcon(currentUser.id)}
separator={false}
onPress={this.handleSetStatus}
theme={theme}
/>
</View>
<View style={style.separator}/>
<View style={style.block}>
<DrawerItem
defaultMessage='Recent Mentions'
i18nId='search_header.title2'
iconName='ios-at'
iconType='ion'
onPress={this.goToMentions}
separator={true}
theme={theme}
/>
<DrawerItem
defaultMessage='Flagged Posts'
i18nId='search_header.title3'
iconName='ios-flag'
iconType='ion'
onPress={this.goToFlagged}
separator={false}
theme={theme}
/>
</View>
<View style={style.separator}/>
<View style={style.block}>
<DrawerItem
defaultMessage='Edit Profile'
i18nId='mobile.routes.edit_profile'
iconName='ios-person'
iconType='ion'
onPress={this.goToEditProfile}
separator={true}
theme={theme}
/>
<DrawerItem
defaultMessage='Settings'
i18nId='mobile.routes.settings'
iconName='ios-options'
iconType='ion'
onPress={this.goToSettings}
separator={false}
theme={theme}
/>
</View>
<View style={style.separator}/>
<View style={style.block}>
<DrawerItem
centered={true}
defaultMessage='Logout'
i18nId='sidebar_right_menu.logout'
isDestructor={true}
onPress={this.logout}
separator={false}
theme={theme}
/>
</View>
</ScrollView>
</View>
);
};
render() {
return; // eslint-disable-line no-useless-return
}
}

View file

@ -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;

View file

@ -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,

View file

@ -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);
}
});
});

View file

@ -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,

View file

@ -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,
},
});
});
});

View file

@ -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];

View file

@ -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 = (
<>
<NetworkIndicator/>
<ChannelNavBar
openChannelDrawer={this.openChannelSidebar}
openSettingsDrawer={this.openSettingsSidebar}
openMainSidebar={this.openMainSidebar}
openSettingsSidebar={this.openSettingsSidebar}
onPress={this.goToChannelInfo}
/>
<KeyboardLayout>
@ -37,14 +57,31 @@ export default class ChannelAndroid extends ChannelBase {
screenId={this.props.componentId}
/>
</KeyboardLayout>
<ChannelLoader
height={height}
style={channelLoaderStyle}
/>
{LocalConfig.EnableMobileClientUpgrade && <ClientUpgradeListener/>}
</>
);
return this.renderChannel(drawerContent);
return (
<>
<View style={style.backdrop}>
{drawerContent}
</View>
<InteractiveDialogController
theme={theme}
/>
</>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
backdrop: {
flex: 1,
backgroundColor: theme.centerChannelBg,
},
flex: {
flex: 1,
},
};
});

View file

@ -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 = (
<React.Fragment>
<>
<SafeAreaView>
<StatusBar/>
<NetworkIndicator/>
<ChannelNavBar
openChannelDrawer={this.openChannelSidebar}
openSettingsDrawer={this.openSettingsSidebar}
openMainSidebar={this.openMainSidebar}
openSettingsSidebar={this.openSettingsSidebar}
onPress={this.goToChannelInfo}
/>
<ChannelPostList
@ -54,10 +80,6 @@ export default class ChannelIOS extends ChannelBase {
valueEvent={CHANNEL_POST_TEXTBOX_VALUE_CHANGE}
/>
</View>
<ChannelLoader
height={height}
style={channelLoaderStyle}
/>
{LocalConfig.EnableMobileClientUpgrade && <ClientUpgradeListener/>}
</SafeAreaView>
<KeyboardTrackingView
@ -72,9 +94,30 @@ export default class ChannelIOS extends ChannelBase {
screenId={this.props.componentId}
/>
</KeyboardTrackingView>
</React.Fragment>
</>
);
return this.renderChannel(drawerContent);
return (
<MainSidebar ref={this.mainSidebarRef}>
<SettingsSidebar ref={this.settingsSidebarRef}>
<View style={style.backdrop}>
{drawerContent}
</View>
</SettingsSidebar>
<InteractiveDialogController
theme={theme}
/>
</MainSidebar>
);
}
}
export const getStyle = makeStyleSheetFromTheme((theme) => ({
backdrop: {
flex: 1,
backgroundColor: theme.centerChannelBg,
},
flex: {
flex: 1,
},
}));

View file

@ -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 (
<MainSidebar
ref={this.channelSidebarRef}
blurPostTextBox={this.blurPostTextBox}
>
<SettingsSidebar
ref={this.settingsSidebarRef}
blurPostTextBox={this.blurPostTextBox}
>
<View style={baseStyle.backdrop}>
{drawerContent}
</View>
</SettingsSidebar>
<InteractiveDialogController
theme={theme}
/>
</MainSidebar>
);
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,
},
});

View file

@ -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,

View file

@ -19,7 +19,7 @@ exports[`ChannelNavBar should match, full snapshot 1`] = `
}
>
<Connect(ChannelDrawerButton)
openDrawer={[MockFunction]}
openSidebar={[MockFunction]}
visible={true}
/>
<Connect(ChannelTitle)
@ -58,7 +58,7 @@ exports[`ChannelNavBar should match, full snapshot 1`] = `
}
/>
<Connect(SettingDrawerButton)
openDrawer={[MockFunction]}
openSidebar={[MockFunction]}
/>
</View>
`;

View file

@ -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() {

View file

@ -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,

View file

@ -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 (
<View style={[style.header, padding(isLandscape), {height}]}>
<ChannelDrawerButton
openDrawer={openChannelDrawer}
openSidebar={openMainSidebar}
visible={drawerButtonVisible}
/>
<ChannelTitle
@ -117,7 +117,7 @@ export default class ChannelNavBar extends PureComponent {
<ChannelSearchButton
theme={theme}
/>
<SettingDrawerButton openDrawer={openSettingsDrawer}/>
<SettingDrawerButton openSidebar={openSettingsSidebar}/>
</View>
);
}

View file

@ -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,
};

View file

@ -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() {

View file

@ -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 = (
<PostList
postIds={visiblePostIds}
lastPostIndex={Platform.OS === 'android' ? getLastPostIndex(visiblePostIds) : -1}
extraData={loadMorePostsVisible}
postIds={postIds}
lastPostIndex={Platform.OS === 'android' ? getLastPostIndex(postIds) : -1}
extraData={postIds.length === 0}
onLoadMoreUp={this.loadMorePostsTop}
onPostPress={this.goToThread}
onRefresh={actions.setChannelRefreshing}
@ -224,6 +189,7 @@ export default class ChannelPostList extends PureComponent {
renderFooter={this.renderFooter}
refreshing={refreshing}
scrollViewNativeID={channelId}
loadMorePostsVisible={this.props.loadMorePostsVisible}
/>
);
}

View file

@ -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(
<ChannelPostList {...baseProps}/>,
);
expect(baseProps.actions.increasePostVisibilityByOne).toHaveBeenCalledTimes(0);
EventEmitter.emit(WebsocketEvents.INCREASE_POST_VISIBILITY_BY_ONE);
expect(baseProps.actions.increasePostVisibilityByOne).toHaveBeenCalledWith(baseProps.channelId);
});
});

View file

@ -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,

View file

@ -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,

View file

@ -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});

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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),

View file

@ -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) {
@ -376,7 +380,7 @@ export default class Login extends PureComponent {
defaultMessage='All team communication in one place, searchable and accessible anywhere'
/>
</View>
<ErrorText error={this.getLoginErrorMessage()}/>
<ErrorText error={this.state.error}/>
<TextInput
ref={this.loginRef}
value={this.props.loginId}

View file

@ -3,8 +3,6 @@
import React from 'react';
import {RequestStatus} from 'mattermost-redux/constants';
import FormattedText from 'app/components/formatted_text';
import {shallowWithIntl} from 'test/intl-test-helper';
@ -79,43 +77,6 @@ describe('Login', () => {
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(<Login {...props}/>);
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},
);
});

View file

@ -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,
};
}

View file

@ -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) {

View file

@ -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();
});

View file

@ -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();
});

View file

@ -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) {

View file

@ -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();
});

View file

@ -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 = (
<SettingsItem
defaultMessage='Sidebar'

View file

@ -7,7 +7,7 @@ import {shallow} from 'enzyme';
import Preferences from 'mattermost-redux/constants/preferences';
import {DeviceTypes} from 'app/constants';
import MainSidebar from 'app/components/sidebars/main/main_sidebar';
import MainSidebar from 'app/components/sidebars/main/main_sidebar.ios';
import SidebarSettings from './index';
jest.mock('react-intl');

View file

@ -4,12 +4,11 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {handleSuccessfulLogin, scheduleExpiredNotification} from 'app/actions/views/login';
import {scheduleExpiredNotification} from 'app/actions/views/login';
import {ssoLogin} from 'app/actions/views/user';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {isLandscape} from 'app/selectors/device';
import {setStoreFromLocalData} from 'mattermost-redux/actions/general';
import SSO from './sso';
function mapStateToProps(state) {
@ -24,8 +23,7 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
scheduleExpiredNotification,
handleSuccessfulLogin,
setStoreFromLocalData,
ssoLogin,
}, dispatch),
};
}

View file

@ -61,16 +61,15 @@ const oneLoginFormScalingJS = `
class SSO extends PureComponent {
static propTypes = {
intl: intlShape.isRequired,
theme: PropTypes.object,
serverUrl: PropTypes.string.isRequired,
ssoType: PropTypes.string.isRequired,
actions: PropTypes.shape({
scheduleExpiredNotification: PropTypes.func.isRequired,
handleSuccessfulLogin: PropTypes.func.isRequired,
setStoreFromLocalData: PropTypes.func.isRequired,
ssoLogin: PropTypes.func.isRequired,
}).isRequired,
intl: intlShape.isRequired,
isLandscape: PropTypes.bool.isRequired,
serverUrl: PropTypes.string.isRequired,
ssoType: PropTypes.string.isRequired,
theme: PropTypes.object,
};
useWebkit = true;
@ -158,15 +157,17 @@ class SSO extends PureComponent {
if (token) {
this.setState({renderWebView: false});
const {
handleSuccessfulLogin,
setStoreFromLocalData,
ssoLogin,
} = this.props.actions;
Client4.setToken(token);
setStoreFromLocalData({url: Client4.getUrl(), token}).
then(handleSuccessfulLogin).
then(this.goToChannel).
catch(this.onLoadEndError);
ssoLogin(token).then((result) => {
if (result.error) {
this.onLoadEndError(result.error);
return;
}
this.goToChannel();
});
} else if (this.webView && !this.state.error) {
this.webView.injectJavaScript(postMessageJS);
}

View file

@ -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();

View file

@ -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;
}

11
app/utils/preferences.js Normal file
View file

@ -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,
};
}

View file

@ -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);

4
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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: {