[Gekidou] [MM-39936] Add Select Team Screen (#6180)

* Add Select Team Screen

* Fixes for iPhone and iPad

* Fix tests

* Address feedback

* Fix tests

* Theme illustration

* Address feedback and fixes

* Remove database warnings by avoiding recalculations on repeated events.

* Address feedback

* Remove unneeded catch

Co-authored-by: Daniel Espino <danielespino@MacBook-Pro-de-Daniel.local>
This commit is contained in:
Daniel Espino García
2022-05-03 17:22:21 +02:00
committed by GitHub
parent 2376dc934c
commit 02b4295464
40 changed files with 1394 additions and 497 deletions

View File

@@ -46,7 +46,7 @@ export async function appEntry(serverUrl: string, since = 0) {
}
const {initialTeamId, teamData, chData, prefData, meData, removeTeamIds, removeChannelIds} = fetchedData as AppEntryData;
const rolesData = await fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user, true);
const rolesData = await fetchRoles(serverUrl, teamData?.memberships, chData?.memberships, meData?.user, true, true);
if (initialTeamId === currentTeamId) {
if (tabletDevice) {

View File

@@ -12,7 +12,7 @@ export type RolesRequest = {
roles?: Role[];
}
export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string[], fetchOnly = false): Promise<RolesRequest> => {
export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string[], fetchOnly = false, force = false): Promise<RolesRequest> => {
if (!updatedRoles.length) {
return {roles: []};
}
@@ -26,15 +26,20 @@ export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string
const database = DatabaseManager.serverDatabases[serverUrl].database;
const operator = DatabaseManager.serverDatabases[serverUrl].operator;
const existingRoles = await queryRoles(database).fetch();
let newRoles;
if (force) {
newRoles = updatedRoles;
} else {
const existingRoles = await queryRoles(database).fetch();
const roleNames = new Set(existingRoles.map((role) => {
return role.name;
}));
const roleNames = new Set(existingRoles.map((role) => {
return role.name;
}));
const newRoles = updatedRoles.filter((newRole) => {
return !roleNames.has(newRole);
});
newRoles = updatedRoles.filter((newRole) => {
return !roleNames.has(newRole);
});
}
if (!newRoles.length) {
return {roles: []};
@@ -56,7 +61,7 @@ export const fetchRolesIfNeeded = async (serverUrl: string, updatedRoles: string
}
};
export const fetchRoles = async (serverUrl: string, teamMembership?: TeamMembership[], channelMembership?: ChannelMembership[], user?: UserProfile, fetchOnly = false) => {
export const fetchRoles = async (serverUrl: string, teamMembership?: TeamMembership[], channelMembership?: ChannelMembership[], user?: UserProfile, fetchOnly = false, force = false) => {
const rolesToFetch = new Set<string>(user?.roles.split(' ') || []);
if (teamMembership?.length) {
@@ -78,7 +83,7 @@ export const fetchRoles = async (serverUrl: string, teamMembership?: TeamMembers
rolesToFetch.delete('');
if (rolesToFetch.size > 0) {
return fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch), fetchOnly);
return fetchRolesIfNeeded(serverUrl, Array.from(rolesToFetch), fetchOnly, force);
}
return {roles: []};

View File

@@ -13,6 +13,7 @@ import {prepareCategories, prepareCategoryChannels} from '@queries/servers/categ
import {prepareMyChannelsForTeam, getDefaultChannelForTeam} from '@queries/servers/channel';
import {prepareCommonSystemValues, getCurrentTeamId, getWebSocketLastDisconnected} from '@queries/servers/system';
import {addTeamToTeamHistory, prepareDeleteTeam, prepareMyTeams, getNthLastChannelFromTeam, queryTeamsById, syncTeamTable} from '@queries/servers/team';
import EphemeralStore from '@store/ephemeral_store';
import {isTablet} from '@utils/helpers';
import {fetchMyChannelsForTeam, switchToChannelById} from './channel';
@@ -37,6 +38,7 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
}
try {
EphemeralStore.startAddingToTeam(teamId);
const member = await client.addToTeam(teamId, userId);
if (!fetchOnly) {
@@ -67,9 +69,10 @@ export async function addUserToTeam(serverUrl: string, teamId: string, userId: s
}
}
}
EphemeralStore.finishAddingToTeam(teamId);
return {member};
} catch (error) {
EphemeralStore.finishAddingToTeam(teamId);
forceLogoutIfNecessary(serverUrl, error as ClientError);
return {error};
}

View File

@@ -251,6 +251,9 @@ export async function handleUserAddedToChannelEvent(serverUrl: string, msg: any)
try {
if (userId === currentUser?.id) {
if (EphemeralStore.isAddingToTeam(teamId)) {
return;
}
const {channels, memberships} = await fetchMyChannel(serverUrl, teamId, channelId, true);
if (channels && memberships) {
const prepare = await prepareMyChannelsForTeam(operator, teamId, channels, memberships);

View File

@@ -5,16 +5,19 @@ import {Model} from '@nozbe/watermelondb';
import {DeviceEventEmitter} from 'react-native';
import {removeUserFromTeam} from '@actions/local/team';
import {fetchRolesIfNeeded} from '@actions/remote/role';
import {fetchMyChannelsForTeam} from '@actions/remote/channel';
import {fetchRoles} from '@actions/remote/role';
import {fetchAllTeams, handleTeamChange, fetchMyTeam} from '@actions/remote/team';
import {updateUsersNoLongerVisible} from '@actions/remote/user';
import Events from '@constants/events';
import DatabaseManager from '@database/manager';
import {getActiveServerUrl} from '@queries/app/servers';
import {getCurrentTeamId} from '@queries/servers/system';
import {getLastTeam, prepareMyTeams} from '@queries/servers/team';
import {prepareCategories, prepareCategoryChannels} from '@queries/servers/categories';
import {prepareMyChannelsForTeam} from '@queries/servers/channel';
import {getCurrentTeam, getLastTeam, prepareMyTeams} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
import {dismissAllModals, popToRoot} from '@screens/navigation';
import {dismissAllModals, popToRoot, resetToTeams} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMessage) {
const database = DatabaseManager.serverDatabases[serverUrl];
@@ -22,7 +25,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess
return;
}
const currentTeamId = await getCurrentTeamId(database.database);
const currentTeam = await getCurrentTeam(database.database);
const user = await getCurrentUser(database.database);
if (!user) {
return;
@@ -37,7 +40,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess
updateUsersNoLongerVisible(serverUrl);
}
if (currentTeamId === teamId) {
if (currentTeam?.id === teamId) {
const appDatabase = DatabaseManager.appDatabase?.database;
let currentServer = '';
if (appDatabase) {
@@ -45,7 +48,7 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess
}
if (currentServer === serverUrl) {
DeviceEventEmitter.emit(Events.LEAVE_TEAM);
DeviceEventEmitter.emit(Events.LEAVE_TEAM, currentTeam?.displayName);
await dismissAllModals();
await popToRoot();
}
@@ -53,7 +56,9 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess
const teamToJumpTo = await getLastTeam(database.database);
if (teamToJumpTo) {
handleTeamChange(serverUrl, teamToJumpTo);
} // TODO else jump to "join a team" screen
} else if (currentServer === serverUrl) {
resetToTeams();
}
}
}
}
@@ -70,57 +75,43 @@ export async function handleUpdateTeamEvent(serverUrl: string, msg: WebSocketMes
teams: [team],
prepareRecordsOnly: false,
});
} catch {
} catch (err) {
// Do nothing
}
}
// As of today, the server sends a duplicated event to add the user to the team.
// If we do not handle this, this ends up showing some errors in the database, apart
// of the extra computation time. We use this to track the events that are being handled
// and make sure we only handle one.
const addingTeam: {[id: string]: boolean} = {};
export async function handleUserAddedToTeamEvent(serverUrl: string, msg: WebSocketMessage) {
const database = DatabaseManager.serverDatabases[serverUrl];
if (!database) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return;
}
const {team_id: teamId} = msg.data;
// Ignore duplicated team join events sent by the server
if (addingTeam[teamId]) {
if (EphemeralStore.isAddingToTeam(teamId)) {
return;
}
addingTeam[teamId] = true;
EphemeralStore.startAddingToTeam(teamId);
const {teams, memberships: teamMemberships} = await fetchMyTeam(serverUrl, teamId, true);
const modelPromises: Array<Promise<Model[]>> = [];
if (teams?.length && teamMemberships?.length) {
const myMember = teamMemberships[0];
if (myMember.roles) {
const rolesToLoad = new Set<string>();
for (const role of myMember.roles.split(' ')) {
rolesToLoad.add(role);
}
const serverRoles = await fetchRolesIfNeeded(serverUrl, Array.from(rolesToLoad), true);
if (serverRoles.roles?.length) {
const preparedRoleModels = database.operator.handleRole({
roles: serverRoles.roles,
prepareRecordsOnly: true,
});
modelPromises.push(preparedRoleModels);
}
}
const {channels, memberships, categories} = await fetchMyChannelsForTeam(serverUrl, teamId, false, 0, true);
modelPromises.push(prepareCategories(operator, categories));
modelPromises.push(prepareCategoryChannels(operator, categories));
modelPromises.push(...await prepareMyChannelsForTeam(operator, teamId, channels || [], memberships || []));
const {roles} = await fetchRoles(serverUrl, teamMemberships, memberships, undefined, true);
modelPromises.push(operator.handleRole({roles, prepareRecordsOnly: true}));
}
if (teams && teamMemberships) {
modelPromises.push(...prepareMyTeams(database.operator, teams, teamMemberships));
modelPromises.push(...prepareMyTeams(operator, teams, teamMemberships));
}
const models = await Promise.all(modelPromises);
await database.operator.batchRecords(models.flat());
await operator.batchRecords(models.flat());
delete addingTeam[teamId];
EphemeralStore.finishAddingToTeam(teamId);
}

File diff suppressed because one or more lines are too long

View File

@@ -73,78 +73,55 @@ exports[`Loading Error should match snapshot 1`] = `
Error description
</Text>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderRadius": 4,
"flex": 0,
"height": 48,
"justifyContent": "center",
"marginTop": 24,
"opacity": 1,
"paddingHorizontal": 24,
"paddingVertical": 14,
}
}
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
<Text
style={
Array [
Object {
"marginTop": 24,
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
Object {
"fontSize": 16,
"lineHeight": 18,
"marginTop": 1,
},
Object {
"color": "#1c58d9",
},
Array [
Object {
"alignItems": "center",
"borderRadius": 4,
"flex": 0,
"justifyContent": "center",
},
Object {
"height": 48,
"paddingHorizontal": 24,
"paddingVertical": 14,
},
Object {
"backgroundColor": "#ffffff",
},
],
]
}
>
<Text
style={
Array [
Object {
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
Object {
"fontSize": 16,
"lineHeight": 16,
"marginTop": 1,
},
Object {
"color": "#1c58d9",
},
]
}
>
Retry
</Text>
</View>
Retry
</Text>
</View>
</View>
`;

View File

@@ -84,6 +84,7 @@ const LoadingError = ({loading, message, onRetry, title}: Props) => {
<TouchableWithFeedback
style={buttonStyle}
onPress={onRetry}
type={'opacity'}
>
<Text style={buttonTextStyle(theme, 'lg', 'primary', 'inverted')}>{'Retry'}</Text>
</TouchableWithFeedback>

View File

@@ -21,14 +21,18 @@ export default function AddTeamSlideUp({otherTeams, canCreateTeams, showTitle =
const intl = useIntl();
const onPressCreate = useCallback(() => {
//TODO Create team screen
//TODO Create team screen https://mattermost.atlassian.net/browse/MM-43622
dismissBottomSheet();
}, []);
const onTeamAdded = useCallback(() => {
dismissBottomSheet();
}, []);
return (
<BottomSheetContent
buttonIcon='plus'
buttonText={intl.formatMessage({id: 'mobile.add_team.create_team', defaultMessage: 'Create a New Team'})}
buttonText={intl.formatMessage({id: 'mobile.add_team.create_team', defaultMessage: 'Create a new team'})}
onPress={onPressCreate}
showButton={canCreateTeams}
showTitle={showTitle}
@@ -37,6 +41,7 @@ export default function AddTeamSlideUp({otherTeams, canCreateTeams, showTitle =
>
<TeamList
teams={otherTeams}
onTeamAdded={onTeamAdded}
testID='team_sidebar.add_team_slide_up.team_list'
/>
</BottomSheetContent>

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 82 KiB

View File

@@ -1,11 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useCallback} from 'react';
import {ListRenderItemInfo, View} from 'react-native';
import {FlatList} from 'react-native-gesture-handler'; // Keep the FlatList from gesture handler so it works well with bottom sheet
import FormattedText from '@components/formatted_text';
import Empty from '@components/illustrations/no_team';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
@@ -13,10 +14,12 @@ import TeamListItem from './team_list_item';
import type TeamModel from '@typings/database/models/servers/team';
const Empty = require('./no_teams.svg').default;
type Props = {
teams: TeamModel[];
teams: Array<Team|TeamModel>;
textColor?: string;
iconTextColor?: string;
iconBackgroundColor?: string;
onTeamAdded: (id: string) => void;
testID?: string;
}
@@ -49,20 +52,24 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const renderTeam = ({item: t}: ListRenderItemInfo<TeamModel>) => {
return (
<TeamListItem
team={t}
/>
);
};
const keyExtractor = (item: TeamModel) => item.id;
export default function TeamList({teams, testID}: Props) {
export default function TeamList({teams, textColor, iconTextColor, iconBackgroundColor, onTeamAdded, testID}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const renderTeam = useCallback(({item: t}: ListRenderItemInfo<Team|TeamModel>) => {
return (
<TeamListItem
team={t}
textColor={textColor}
iconBackgroundColor={iconBackgroundColor}
iconTextColor={iconTextColor}
onTeamAdded={onTeamAdded}
/>
);
}, [textColor, iconTextColor, iconBackgroundColor, onTeamAdded]);
if (teams.length) {
return (
<View style={styles.container}>
@@ -79,7 +86,7 @@ export default function TeamList({teams, testID}: Props) {
return (
<View style={styles.empty}>
<Empty/>
<Empty theme={theme}/>
<FormattedText
id='team_list.no_other_teams.title'
defaultMessage='No additional teams to join'

View File

@@ -9,15 +9,18 @@ import TeamIcon from '@components/team_sidebar/team_list/team_item/team_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {dismissBottomSheet} from '@screens/navigation';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type TeamModel from '@typings/database/models/servers/team';
type Props = {
team: TeamModel;
team: TeamModel | Team;
currentUserId: string;
textColor?: string;
iconTextColor?: string;
iconBackgroundColor?: string;
onTeamAdded: (teamId: string) => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@@ -46,15 +49,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
export default function TeamListItem({team, currentUserId}: Props) {
export default function TeamListItem({team, currentUserId, textColor, iconTextColor, iconBackgroundColor, onTeamAdded}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const onPress = useCallback(async () => {
await addUserToTeam(serverUrl, team.id, currentUserId);
dismissBottomSheet();
}, []);
onTeamAdded(team.id);
}, [onTeamAdded]);
const displayName = 'displayName' in team ? team.displayName : team.display_name;
const lastTeamIconUpdateAt = 'lastTeamIconUpdatedAt' in team ? team.lastTeamIconUpdatedAt : team.last_team_icon_update;
const teamListItemTestId = `team_sidebar.team_list.team_list_item.${team.id}`;
return (
@@ -67,16 +72,20 @@ export default function TeamListItem({team, currentUserId}: Props) {
<View style={styles.icon_container}>
<TeamIcon
id={team.id}
displayName={team.displayName}
lastIconUpdate={team.lastTeamIconUpdatedAt}
displayName={displayName}
lastIconUpdate={lastTeamIconUpdateAt}
selected={false}
textColor={iconTextColor}
backgroundColor={iconBackgroundColor}
testID={`${teamListItemTestId}.team_icon`}
/>
</View>
<Text
style={styles.text}
style={[styles.text, {color: textColor}]}
numberOfLines={1}
>{team.displayName}</Text>
>
{displayName}
</Text>
</TouchableWithFeedback>
</View>
);

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {View, Text} from 'react-native';
import FastImage from 'react-native-fast-image';
@@ -10,64 +10,6 @@ import {useTheme} from '@context/theme';
import NetworkManager from '@managers/network_manager';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
id: string;
lastIconUpdate: number;
displayName: string;
selected: boolean;
testID?: string;
}
export default function TeamIcon({id, lastIconUpdate, displayName, selected, testID}: Props) {
const [imageError, setImageError] = useState(false);
const ref = useRef<View>(null);
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const client = NetworkManager.getClient(serverUrl);
useEffect(() =>
setImageError(false)
, [id, lastIconUpdate]);
const handleImageError = useCallback(() => {
if (ref.current) {
setImageError(true);
}
}, []);
let teamIconContent;
if (imageError || !lastIconUpdate) {
teamIconContent = (
<Text
style={styles.text}
testID={`${testID}.display_name_abbreviation`}
>
{displayName?.substring(0, 2).toUpperCase()}
</Text>
);
} else {
teamIconContent = (
<FastImage
style={styles.image}
source={{uri: `${serverUrl}${client.getTeamIconUrl(id, lastIconUpdate)}`}}
onError={handleImageError}
/>
);
}
return (
<View
style={selected ? styles.containerSelected : styles.container}
ref={ref}
testID={testID}
>
{teamIconContent}
</View>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
@@ -103,3 +45,85 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
},
};
});
type Props = {
id: string;
lastIconUpdate: number;
displayName: string;
selected: boolean;
backgroundColor?: string;
textColor?: string;
testID?: string;
}
export default function TeamIcon({
id,
lastIconUpdate,
displayName,
selected,
textColor,
backgroundColor,
testID,
}: Props) {
const [imageError, setImageError] = useState(false);
const ref = useRef<View>(null);
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
let client = null;
try {
client = NetworkManager.getClient(serverUrl);
} catch (err) {
// Do nothing
}
useEffect(() =>
setImageError(false)
, [id, lastIconUpdate]);
const handleImageError = useCallback(() => {
if (ref.current) {
setImageError(true);
}
}, []);
const containerStyle = useMemo(() => {
if (selected) {
return backgroundColor ? [styles.containerSelected, {backgroundColor}] : styles.containerSelected;
}
return backgroundColor ? [styles.container, {backgroundColor}] : styles.container;
}, [styles, backgroundColor, selected]);
let teamIconContent;
if (imageError || !lastIconUpdate || !client) {
teamIconContent = (
<Text
style={textColor ? [styles.text, {color: textColor}] : styles.text}
testID={`${testID}.display_name_abbreviation`}
>
{displayName?.substring(0, 2).toUpperCase()}
</Text>
);
} else {
teamIconContent = (
<FastImage
style={styles.image}
source={{uri: `${serverUrl}${client.getTeamIconUrl(id, lastIconUpdate)}`}}
onError={handleImageError}
/>
);
}
return (
<View
style={containerStyle}
ref={ref}
testID={testID}
>
{teamIconContent}
</View>
);
}

View File

@@ -14,6 +14,7 @@ export const CHANNEL_EDIT = 'ChannelEdit';
export const CODE = 'Code';
export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage';
export const CREATE_OR_EDIT_CHANNEL = 'CreateOrEditChannel';
export const CREATE_TEAM = 'CreateTeam';
export const CUSTOM_STATUS = 'CustomStatus';
export const CUSTOM_STATUS_CLEAR_AFTER = 'CustomStatusClearAfter';
export const EDIT_POST = 'EditPost';
@@ -31,6 +32,7 @@ export const LATEX = 'Latex';
export const LOGIN = 'Login';
export const MENTIONS = 'Mentions';
export const MFA = 'MFA';
export const SELECT_TEAM = 'SelectTeam';
export const PARTICIPANTS_LIST = 'ParticipantsList';
export const PERMALINK = 'Permalink';
export const POST_OPTIONS = 'PostOptions';
@@ -59,6 +61,7 @@ export default {
CHANNEL_DETAILS,
CODE,
CREATE_DIRECT_MESSAGE,
CREATE_TEAM,
CUSTOM_STATUS_CLEAR_AFTER,
CUSTOM_STATUS,
EDIT_POST,
@@ -76,6 +79,7 @@ export default {
LOGIN,
MENTIONS,
MFA,
SELECT_TEAM,
PARTICIPANTS_LIST,
PERMALINK,
POST_OPTIONS,

View File

@@ -11,7 +11,8 @@ import DatabaseManager from '@database/manager';
import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials';
import {getThemeForCurrentTeam} from '@queries/servers/preference';
import {getCurrentUserId} from '@queries/servers/system';
import {goToScreen, resetToHome, resetToSelectServer} from '@screens/navigation';
import {queryMyTeams} from '@queries/servers/team';
import {goToScreen, resetToHome, resetToSelectServer, resetToTeams} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkType, DeepLinkWithData, LaunchProps, LaunchType} from '@typings/launch';
import {convertToNotificationData} from '@utils/notification';
@@ -112,6 +113,7 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => {
}
launchToHome({...props, launchType, serverUrl});
return;
}
}
@@ -142,9 +144,23 @@ const launchToHome = async (props: LaunchProps) => {
break;
}
// eslint-disable-next-line no-console
console.log('Launch app in Home screen');
resetToHome(props);
let nTeams = 0;
if (props.serverUrl) {
const database = DatabaseManager.serverDatabases[props.serverUrl]?.database;
if (database) {
nTeams = await queryMyTeams(database).fetchCount();
}
}
if (nTeams) {
// eslint-disable-next-line no-console
console.log('Launch app in Home screen');
resetToHome(props);
} else {
// eslint-disable-next-line no-console
console.log('Launch app in Select Teams screen');
resetToTeams();
}
};
const launchToServer = (props: LaunchProps, resetNavigation: Boolean) => {

View File

@@ -29,18 +29,18 @@ export const queryCategoriesByTeamIds = (database: Database, teamIds: string[])
return database.get<CategoryModel>(CATEGORY).query(Q.where('team_id', Q.oneOf(teamIds)));
};
export const prepareCategories = (operator: ServerDataOperator, categories: CategoryWithChannels[]) => {
export const prepareCategories = (operator: ServerDataOperator, categories?: CategoryWithChannels[]) => {
return operator.handleCategories({categories, prepareRecordsOnly: true});
};
export async function prepareCategoryChannels(
operator: ServerDataOperator,
categories: CategoryWithChannels[],
categories?: CategoryWithChannels[],
): Promise<CategoryChannelModel[]> {
try {
const categoryChannels: CategoryChannel[] = [];
categories.forEach((category) => {
categories?.forEach((category) => {
category.channel_ids.forEach((channelId, index) => {
categoryChannels.push({
id: makeCategoryChannelId(category.team_id, channelId),

View File

@@ -9,6 +9,9 @@ import {Database as DatabaseConstants, General, Permissions} from '@constants';
import {isDMorGM} from '@utils/channel';
import {hasPermission} from '@utils/role';
import {observeChannel, observeMyChannel} from './channel';
import {observeMyTeam} from './team';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type RoleModel from '@typings/database/models/servers/role';
@@ -35,8 +38,9 @@ export const queryRolesByNames = (database: Database, names: string[]) => {
};
export function observePermissionForChannel(channel: ChannelModel, user: UserModel, permission: string, defaultValue: boolean) {
const myChannel = channel.membership.observe();
const myTeam = channel.teamId ? channel.team.observe().pipe(switchMap((t) => (t ? t.myTeam.observe() : of$(undefined)))) : of$(undefined);
const database = channel.database;
const myChannel = observeMyChannel(database, channel.id);
const myTeam = channel.teamId ? observeMyTeam(database, channel.teamId) : of$(undefined);
return combineLatest([myChannel, myTeam]).pipe(switchMap(([mc, mt]) => {
const rolesArray = [...user.roles.split(' ')];
@@ -46,32 +50,35 @@ export function observePermissionForChannel(channel: ChannelModel, user: UserMod
if (mt) {
rolesArray.push(...mt.roles.split(' '));
}
return queryRolesByNames(user.database, rolesArray).observe().pipe(
return queryRolesByNames(database, rolesArray).observeWithColumns(['permissions']).pipe(
switchMap((r) => of$(hasPermission(r, permission, defaultValue))),
);
}));
}
export function observePermissionForTeam(team: TeamModel, user: UserModel, permission: string, defaultValue: boolean) {
return team.myTeam.observe().pipe(switchMap((myTeam) => {
const rolesArray = [...user.roles.split(' ')];
const database = team.database;
return observeMyTeam(database, team.id).pipe(
switchMap((myTeam) => {
const rolesArray = [...user.roles.split(' ')];
if (myTeam) {
rolesArray.push(...myTeam.roles.split(' '));
}
if (myTeam) {
rolesArray.push(...myTeam.roles.split(' '));
}
return queryRolesByNames(user.database, rolesArray).observe().pipe(
switchMap((roles) => of$(hasPermission(roles, permission, defaultValue))),
);
}));
return queryRolesByNames(database, rolesArray).observeWithColumns(['permissions']).pipe(
switchMap((roles) => of$(hasPermission(roles, permission, defaultValue))),
);
}),
);
}
export function observePermissionForPost(post: PostModel, user: UserModel, permission: string, defaultValue: boolean) {
return post.channel.observe().pipe(switchMap((c) => (c ? observePermissionForChannel(c, user, permission, defaultValue) : of$(defaultValue))));
return observeChannel(post.database, post.channelId).pipe(switchMap((c) => (c ? observePermissionForChannel(c, user, permission, defaultValue) : of$(defaultValue))));
}
export function observeCanManageChannelMembers(post: PostModel, user: UserModel) {
return post.channel.observe().pipe((switchMap((c) => {
return observeChannel(post.database, post.channelId).pipe((switchMap((c) => {
if (!c || c.deleteAt !== 0 || isDMorGM(c) || c.name === General.DEFAULT_CHANNEL) {
return of$(false);
}

View File

@@ -13,7 +13,7 @@ import {DEFAULT_LOCALE} from '@i18n';
import {prepareDeleteCategory} from './categories';
import {prepareDeleteChannel, getDefaultChannelForTeam, observeMyChannelMentionCount} from './channel';
import {queryPreferencesByCategoryAndName} from './preference';
import {patchTeamHistory, getConfig, getTeamHistory, observeCurrentTeamId} from './system';
import {patchTeamHistory, getConfig, getTeamHistory, observeCurrentTeamId, getCurrentTeamId} from './system';
import {observeThreadMentionCount} from './thread';
import {getCurrentUser} from './user';
@@ -29,6 +29,15 @@ const {
TEAM_CHANNEL_HISTORY,
} = DatabaseConstants.MM_TABLES.SERVER;
export const getCurrentTeam = async (database: Database) => {
const currentTeamId = await getCurrentTeamId(database);
if (currentTeamId) {
return getTeamById(database, currentTeamId);
}
return undefined;
};
// Saves channels to team history & excludes & GLOBAL_THREADS from it
export const addChannelToTeamHistory = async (operator: ServerDataOperator, teamId: string, channelId: string, prepareRecordsOnly = false) => {
let tch: TeamChannelHistory|undefined;

View File

@@ -36,7 +36,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const roles = currentUserId.pipe(
switchMap((id) => observeUser(database, id)),
switchMap((u) => (u ? of$(u.roles.split(' ')) : of$([]))),
switchMap((values) => queryRolesByNames(database, values).observe()),
switchMap((values) => queryRolesByNames(database, values).observeWithColumns(['permissions'])),
);
const canCreateChannels = roles.pipe(switchMap((r) => of$(hasPermission(r, Permissions.CREATE_PUBLIC_CHANNEL, false))));

View File

@@ -30,7 +30,7 @@ const enhanced = withObservables(['channelId'], ({channelId, database}: {channel
if (memberRoles) {
combinedRoles.push(...memberRoles);
}
return queryRolesByNames(database, combinedRoles).observe();
return queryRolesByNames(database, combinedRoles).observeWithColumns(['permissions']);
}),
);

View File

@@ -227,78 +227,55 @@ exports[`components/categories_list should render channels error 1`] = `
There was a problem loading content for this team.
</Text>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderRadius": 4,
"flex": 0,
"height": 48,
"justifyContent": "center",
"marginTop": 24,
"opacity": 1,
"paddingHorizontal": 24,
"paddingVertical": 14,
}
}
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
<Text
style={
Array [
Object {
"marginTop": 24,
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
Object {
"fontSize": 16,
"lineHeight": 18,
"marginTop": 1,
},
Object {
"color": "#1c58d9",
},
Array [
Object {
"alignItems": "center",
"borderRadius": 4,
"flex": 0,
"justifyContent": "center",
},
Object {
"height": 48,
"paddingHorizontal": 24,
"paddingVertical": 14,
},
Object {
"backgroundColor": "#ffffff",
},
],
]
}
>
<Text
style={
Array [
Object {
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
Object {
"fontSize": 16,
"lineHeight": 16,
"marginTop": 1,
},
Object {
"color": "#1c58d9",
},
]
}
>
Retry
</Text>
</View>
Retry
</Text>
</View>
</View>
</View>
@@ -340,20 +317,75 @@ exports[`components/categories_list should render team error 1`] = `
}
}
>
<Text
<View
style={
Object {
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 11,
"fontWeight": "600",
"lineHeight": 16,
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"justifyContent": "space-between",
}
}
testID="channel_list_header.server_display_name"
>
</Text>
<View
style={
Object {
"alignItems": "center",
"flexDirection": "row",
"height": 40,
"justifyContent": "space-between",
}
}
>
<Text
style={
Object {
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 14,
"fontWeight": "600",
"lineHeight": 20,
}
}
testID="channel_list_header.team_display_name"
>
</Text>
</View>
<View
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
Object {
"opacity": 1,
}
}
testID="channel_list_header.plus.button"
>
<Text
style={
Object {
"color": "rgba(255,255,255,0.64)",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 14,
"fontWeight": "600",
"lineHeight": 20,
}
}
testID="channel_list_header.team_display_name"
>
Log out
</Text>
</View>
</View>
</View>
<View
accessible={true}
@@ -476,78 +508,55 @@ exports[`components/categories_list should render team error 1`] = `
There was a problem loading content for this server.
</Text>
<View
onMoveShouldSetResponder={[Function]}
onMoveShouldSetResponderCapture={[Function]}
onResponderEnd={[Function]}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderReject={[Function]}
onResponderRelease={[Function]}
onResponderStart={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
onStartShouldSetResponderCapture={[Function]}
style={
Object {
"alignItems": "center",
"backgroundColor": "#ffffff",
"borderRadius": 4,
"flex": 0,
"height": 48,
"justifyContent": "center",
"marginTop": 24,
"opacity": 1,
"paddingHorizontal": 24,
"paddingVertical": 14,
}
}
>
<View
accessible={true}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
<Text
style={
Array [
Object {
"marginTop": 24,
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
Object {
"fontSize": 16,
"lineHeight": 18,
"marginTop": 1,
},
Object {
"color": "#1c58d9",
},
Array [
Object {
"alignItems": "center",
"borderRadius": 4,
"flex": 0,
"justifyContent": "center",
},
Object {
"height": 48,
"paddingHorizontal": 24,
"paddingVertical": 14,
},
Object {
"backgroundColor": "#ffffff",
},
],
]
}
>
<Text
style={
Array [
Object {
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
Object {
"fontSize": 16,
"lineHeight": 16,
"marginTop": 1,
},
Object {
"color": "#1c58d9",
},
]
}
>
Retry
</Text>
</View>
Retry
</Text>
</View>
</View>
</View>

View File

@@ -7,14 +7,16 @@ import {Text, View} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {logout} from '@actions/remote/session';
import CompassIcon from '@components/compass_icon';
import {ITEM_HEIGHT} from '@components/slide_up_panel_item';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerDisplayName} from '@context/server';
import {useServerDisplayName, useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {bottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {alertServerLogout} from '@utils/server';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@@ -61,6 +63,16 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
color: changeOpacity(theme.sidebarText, 0.8),
fontSize: 18,
},
noTeamHeadingStyles: {
color: changeOpacity(theme.sidebarText, 0.64),
...typography('Body', 100, 'SemiBold'),
},
noTeamHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
height: 40,
},
}));
const ChannelListHeader = ({canCreateChannels, canJoinChannels, displayName, iconPad, onHeaderPress}: Props) => {
@@ -74,6 +86,7 @@ const ChannelListHeader = ({canCreateChannels, canJoinChannels, displayName, ico
const animatedStyle = useAnimatedStyle(() => ({
marginLeft: withTiming(marginLeft.value, {duration: 350}),
}), []);
const serverUrl = useServerUrl();
useEffect(() => {
marginLeft.value = iconPad ? 44 : 0;
@@ -108,51 +121,87 @@ const ChannelListHeader = ({canCreateChannels, canJoinChannels, displayName, ico
});
}, [intl, insets, isTablet, theme]);
return (
<Animated.View style={animatedStyle}>
{Boolean(displayName) &&
<View style={styles.headerRow}>
<TouchableWithFeedback
onPress={onHeaderPress}
type='opacity'
>
<View style={styles.headerRow}>
<Text
style={styles.headingStyles}
testID='channel_list_header.team_display_name'
>
{displayName}
</Text>
<View
style={styles.chevronButton}
testID='channel_list_header.chevron.button'
>
<CompassIcon
style={styles.chevronIcon}
name={'chevron-down'}
/>
const onLogoutPress = useCallback(() => {
alertServerLogout(serverDisplayName, () => logout(serverUrl), intl);
}, []);
let header;
if (displayName) {
header = (
<>
<View style={styles.headerRow}>
<TouchableWithFeedback
onPress={onHeaderPress}
type='opacity'
>
<View style={styles.headerRow}>
<Text
style={styles.headingStyles}
testID='channel_list_header.team_display_name'
>
{displayName}
</Text>
<View
style={styles.chevronButton}
testID='channel_list_header.chevron.button'
>
<CompassIcon
style={styles.chevronIcon}
name={'chevron-down'}
/>
</View>
</View>
</View>
</TouchableWithFeedback>
</TouchableWithFeedback>
<TouchableWithFeedback
onPress={onPress}
style={styles.plusButton}
testID='channel_list_header.plus.button'
type='opacity'
>
<CompassIcon
style={styles.plusIcon}
name={'plus'}
/>
</TouchableWithFeedback>
</View>
<Text
style={styles.subHeadingStyles}
testID='channel_list_header.server_display_name'
>
{serverDisplayName}
</Text>
</>
);
} else {
header = (
<View style={styles.noTeamHeaderRow}>
<View style={styles.noTeamHeaderRow}>
<Text
style={styles.noTeamHeadingStyles}
testID='channel_list_header.team_display_name'
>
{serverDisplayName}
</Text>
</View>
<TouchableWithFeedback
onPress={onPress}
style={styles.plusButton}
onPress={onLogoutPress}
testID='channel_list_header.plus.button'
type='opacity'
>
<CompassIcon
style={styles.plusIcon}
name={'plus'}
/>
<Text
style={styles.noTeamHeadingStyles}
testID='channel_list_header.team_display_name'
>
{intl.formatMessage({id: 'account.logout', defaultMessage: 'Log out'})}
</Text>
</TouchableWithFeedback>
</View>
}
<Text
style={styles.subHeadingStyles}
testID='channel_list_header.server_display_name'
>
{serverDisplayName}
</Text>
);
}
return (
<Animated.View style={animatedStyle}>
{header}
</Animated.View>
);
};

View File

@@ -3,7 +3,7 @@
import {useManagedConfig} from '@mattermost/react-native-emm';
import {useIsFocused, useRoute} from '@react-navigation/native';
import React from 'react';
import React, {useEffect} from 'react';
import {StyleSheet} from 'react-native';
import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated';
import {Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
@@ -12,6 +12,7 @@ import FreezeScreen from '@components/freeze_screen';
import TeamSidebar from '@components/team_sidebar';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {resetToTeams} from '@screens/navigation';
import AdditionalTabletView from './additional_tablet_view';
import CategoriesList from './categories_list';
@@ -28,9 +29,6 @@ type ChannelProps = {
const edges: Edge[] = ['bottom', 'left', 'right'];
const styles = StyleSheet.create({
flex: {
flex: 1,
},
content: {
flex: 1,
flexDirection: 'row',
@@ -69,6 +67,12 @@ const ChannelListScreen = (props: ChannelProps) => {
return {height: insets.top, backgroundColor: theme.sidebarBg};
}, [theme]);
useEffect(() => {
if (!props.teamsCount) {
resetToTeams();
}
}, [Boolean(props.teamsCount)]);
return (
<FreezeScreen freeze={!isFocused}>
{<Animated.View style={top}/>}

View File

@@ -16,7 +16,7 @@ import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentTeamId: observeCurrentTeamId(database),
isCRTEnabled: observeIsCRTEnabled(database),
teamsCount: queryMyTeams(database).observeCount(),
teamsCount: queryMyTeams(database).observeCount(false),
channelsCount: queryAllMyChannel(database).observeCount(),
}));

View File

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useRef, useState} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {StyleSheet} from 'react-native';
@@ -48,7 +48,11 @@ const sortServers = (servers: ServersModel[], intl: IntlShape) => {
});
};
export default function Servers() {
export type ServersRef = {
openServers: () => void;
}
const Servers = React.forwardRef<ServersRef>((props, ref) => {
const intl = useIntl();
const [total, setTotal] = useState<UnreadMessages>({mentions: 0, unread: false});
const registeredServers = useRef<ServersModel[]|undefined>();
@@ -136,6 +140,10 @@ export default function Servers() {
}
}, [isTablet, theme]);
useImperativeHandle(ref, () => ({
openServers: onPress,
}), [onPress]);
useEffect(() => {
const subscription = subscribeAllServers(serversObserver);
@@ -157,5 +165,8 @@ export default function Servers() {
testID={'channel_list.servers.server_icon'}
/>
);
}
});
Servers.displayName = 'Servers';
export default Servers;

View File

@@ -10,6 +10,7 @@ import {enableFreeze, enableScreens} from 'react-native-screens';
import {Events, Screens} from '@constants';
import {useTheme} from '@context/theme';
import {alertTeamRemove} from '@utils/navigation';
import {notificationError} from '@utils/notification';
import Account from './account';
@@ -47,6 +48,16 @@ export default function HomeScreen(props: HomeProps) {
};
}, []);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(Events.LEAVE_TEAM, (displayName: string) => {
alertTeamRemove(displayName, intl);
});
return () => {
listener.remove();
};
});
return (
<NavigationContainer
theme={{

View File

@@ -141,6 +141,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.MFA:
screen = withIntl(require('@screens/mfa').default);
break;
case Screens.SELECT_TEAM:
screen = withServerDatabase(require('@screens/select_team').default);
break;
case Screens.PERMALINK:
screen = withServerDatabase(require('@screens/permalink').default);
break;

View File

@@ -16,7 +16,7 @@ import Loading from '@components/loading';
import {FORGOT_PASSWORD, MFA} from '@constants/screens';
import {useIsTablet} from '@hooks/device';
import {t} from '@i18n';
import {goToScreen, loginAnimationOptions, resetToHome} from '@screens/navigation';
import {goToScreen, loginAnimationOptions, resetToHome, resetToTeams} from '@screens/navigation';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme} from '@utils/theme';
@@ -129,8 +129,7 @@ const LoginForm = ({config, extra, keyboardAwareRef, numberSSOs, serverDisplayNa
const result: LoginActionResponse = await login(serverUrl!, {serverDisplayName, loginId: loginId.toLowerCase(), password, config, license});
if (checkLoginResponse(result)) {
if (!result.hasTeams && !result.error) {
// eslint-disable-next-line no-console
console.log('TODO: GO TO NO TEAMS');
resetToTeams();
return;
}
goToHome(result.time || 0, result.error as never);

View File

@@ -275,35 +275,11 @@ export function resetToSelectServer(passProps: LaunchProps) {
});
}
export function resetToTeams(name: string, title: string, passProps = {}, options = {}) {
export function resetToTeams() {
const theme = getThemeFromState();
const isDark = tinyColor(theme.sidebarBg).isDark();
StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content');
const defaultOptions = {
layout: {
componentBackgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
backgroundColor: theme.sidebarBg,
},
topBar: {
visible: true,
title: {
color: theme.sidebarHeaderTextColor,
text: title,
},
backButton: {
color: theme.sidebarHeaderTextColor,
title: '',
},
background: {
color: theme.sidebarBg,
},
},
};
EphemeralStore.clearNavigationComponents();
Navigation.setRoot({
@@ -311,10 +287,28 @@ export function resetToTeams(name: string, title: string, passProps = {}, option
stack: {
children: [{
component: {
id: name,
name,
passProps,
options: merge(defaultOptions, options),
id: Screens.SELECT_TEAM,
name: Screens.SELECT_TEAM,
options: {
layout: {
componentBackgroundColor: theme.centerChannelBg,
},
statusBar: {
visible: true,
backgroundColor: theme.sidebarBg,
},
topBar: {
visible: false,
height: 0,
background: {
color: theme.sidebarBg,
},
backButton: {
visible: false,
color: theme.sidebarHeaderTextColor,
},
},
},
},
}],
},

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import {goToScreen} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
height: 64,
marginBottom: 2,
},
touchable: {
display: 'flex',
flexDirection: 'row',
borderRadius: 4,
alignItems: 'center',
height: '100%',
width: '100%',
},
text: {
color: theme.sidebarText,
marginLeft: 16,
...typography('Body', 200),
},
icon_container_container: {
width: 40,
height: 40,
},
icon_container: {
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: changeOpacity(theme.sidebarText, 0.16),
borderRadius: 10,
},
icon: {
color: theme.sidebarText,
fontSize: 24,
},
};
});
export default function AddTeamItem() {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const onPress = useCallback(async () => {
// TODO https://mattermost.atlassian.net/browse/MM-43622
goToScreen(Screens.CREATE_TEAM, 'Create team');
}, []);
return (
<View style={styles.container}>
<TouchableWithFeedback
onPress={onPress}
type='opacity'
style={styles.touchable}
>
<View style={styles.icon_container_container}>
<View style={styles.icon_container}>
<CompassIcon
name='plus'
style={styles.icon}
/>
</View>
</View>
<Text
style={styles.text}
numberOfLines={1}
>{intl.formatMessage({id: 'mobile.add_team.create_team', defaultMessage: 'Create a new team'})}</Text>
</TouchableWithFeedback>
</View>
);
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useManagedConfig} from '@mattermost/react-native-emm';
import React, {useCallback, useMemo, useRef} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {logout} from '@actions/remote/session';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useServerDisplayName, useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {alertServerLogout} from '@utils/server';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import Servers, {ServersRef} from '../home/channel_list/servers';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 20,
marginHorizontal: 24,
},
text: {
color: changeOpacity(theme.sidebarText, 0.64),
...typography('Body', 100, 'SemiBold'),
},
}));
const MARGIN_WITH_SERVER_ICON = 66;
function Header() {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverDisplayName = useServerDisplayName();
const serverUrl = useServerUrl();
const managedConfig = useManagedConfig<ManagedConfig>();
const canAddOtherServers = managedConfig?.allowOtherServers !== 'false';
const serverButtonRef = useRef<ServersRef>(null);
const headerStyle = useMemo(() => ({...styles.header, marginLeft: canAddOtherServers ? MARGIN_WITH_SERVER_ICON : undefined}), [canAddOtherServers]);
const onLogoutPress = useCallback(() => {
alertServerLogout(serverDisplayName, () => logout(serverUrl), intl);
}, [serverUrl, serverDisplayName]);
const onLabelPress = useCallback(() => {
serverButtonRef.current?.openServers();
}, []);
let serverLabel = (
<Text
style={styles.text}
testID='select_team.server_display_name'
>
{serverDisplayName}
</Text>
);
if (canAddOtherServers) {
serverLabel = (
<TouchableWithFeedback
onPress={onLabelPress}
type='opacity'
testID='select_team.server_display_name.touchable'
>
{serverLabel}
</TouchableWithFeedback>
);
}
return (
<>
{canAddOtherServers && <Servers ref={serverButtonRef}/>}
<View style={headerStyle}>
{serverLabel}
<TouchableWithFeedback
onPress={onLogoutPress}
testID='select_team.logout.button'
type='opacity'
>
<Text
style={styles.text}
testID='select_team.logout.text'
>
{intl.formatMessage({id: 'account.logout', defaultMessage: 'Log out'})}
</Text>
</TouchableWithFeedback>
</View>
</>
);
}
export default Header;

View File

@@ -0,0 +1,34 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {Permissions} from '@constants';
import {queryRolesByNames} from '@queries/servers/role';
import {queryMyTeams} from '@queries/servers/team';
import {observeCurrentUser} from '@queries/servers/user';
import {hasPermission} from '@utils/role';
import SelectTeam from './select_team';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const canCreateTeams = observeCurrentUser(database).pipe(
switchMap((u) => (u ? of$(u.roles.split(' ')) : of$([]))),
switchMap((values) => queryRolesByNames(database, values).observeWithColumns(['permissions'])),
switchMap((r) => of$(hasPermission(r, Permissions.CREATE_TEAM, false))),
);
const nTeams = queryMyTeams(database).observeCount();
return {
canCreateTeams,
nTeams,
};
});
export default withDatabase(enhanced(SelectTeam));

View File

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {StyleSheet, Text, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import Empty from '@components/illustrations/no_team';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {useTheme} from '@context/theme';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
marginHorizontal: 24,
maxWidth: 600,
alignSelf: 'center',
},
iconWrapper: {
height: 120,
width: 120,
backgroundColor: changeOpacity(theme.sidebarText, 0.08),
borderRadius: 60,
justifyContent: 'center',
alignItems: 'center',
},
title: {
color: theme.sidebarHeaderTextColor,
marginTop: 24,
textAlign: 'center',
...typography('Heading', 800),
},
description: {
color: changeOpacity(theme.sidebarText, 0.72),
textAlign: 'center',
marginTop: 12,
...typography('Body', 200, 'Regular'),
},
buttonStyle: {
...StyleSheet.flatten(buttonBackgroundStyle(theme, 'lg', 'primary', 'default')),
flexDirection: 'row',
marginTop: 24,
},
buttonText: {
...StyleSheet.flatten(buttonTextStyle(theme, 'lg', 'primary', 'default')),
marginLeft: 8,
},
plusIcon: {
color: theme.sidebarText,
fontSize: 24,
lineHeight: 22,
},
}));
type Props = {
canCreateTeams: boolean;
}
const NoTeams = ({
canCreateTeams,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const onButtonPress = useCallback(async () => {
// TODO https://mattermost.atlassian.net/browse/MM-43622
//goToScreen(Screens.CREATE_TEAM, 'Create team');
}, []);
return (
<View style={styles.container}>
<View style={styles.iconWrapper}>
<Empty theme={theme}/>
</View>
<Text style={styles.title}>
{intl.formatMessage({id: 'select_team.no_team.title', defaultMessage: 'No teams are available to join'})}
</Text>
<Text style={styles.description}>
{intl.formatMessage({id: 'select_team.no_team.description', defaultMessage: 'To join a team, ask a team admin for an invite, or create your own team. You may also want to check your email inbox for an invitation.'})}
</Text>
{canCreateTeams &&
<TouchableWithFeedback
style={styles.buttonStyle}
type={'opacity'}
onPress={onButtonPress}
>
<CompassIcon
name='plus'
style={styles.plusIcon}
/>
<Text style={styles.buttonText}>{intl.formatMessage({id: 'mobile.add_team.create_team', defaultMessage: 'Create a new team'})}</Text>
</TouchableWithFeedback>
}
</View>
);
};
export default NoTeams;

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
import {SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area-context';
import {fetchAllTeams} from '@actions/remote/team';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {resetToHome} from '../navigation';
import Header from './header';
import NoTeams from './no_teams';
import TeamList from './team_list';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
backgroundColor: theme.sidebarBg,
},
}));
type Props = {
canCreateTeams: boolean;
nTeams: number;
}
const safeAreaEdges = ['left' as const, 'right' as const];
const safeAreaStyle = {flex: 1};
const SelectTeam = ({
canCreateTeams,
nTeams,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const [loading, setLoading] = useState(true);
const insets = useSafeAreaInsets();
const top = useAnimatedStyle(() => {
return {height: insets.top, backgroundColor: theme.sidebarBg};
});
const mounted = useRef(false);
const [otherTeams, setOtherTeams] = useState<Team[]>();
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
if (nTeams > 0) {
resetToHome();
}
}, [nTeams > 0]);
useEffect(() => {
// eslint-disable-next-line max-nested-callbacks
fetchAllTeams(serverUrl, false).then((r) => {
if (mounted.current) {
setOtherTeams(r.teams || []);
}
// eslint-disable-next-line max-nested-callbacks
}).finally(() => {
if (mounted.current) {
setLoading(false);
}
});
}, []);
let body;
if (loading) {
body = null;
} else if (otherTeams?.length) {
body = (
<TeamList
teams={otherTeams}
canCreateTeam={canCreateTeams}
/>
);
} else {
body = (<NoTeams canCreateTeams={canCreateTeams}/>);
}
return (
<SafeAreaView
mode='margin'
edges={safeAreaEdges}
style={safeAreaStyle}
>
<Animated.View style={top}/>
<View style={styles.container}>
<Header/>
{body}
</View>
</SafeAreaView>
);
};
export default SelectTeam;

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {handleTeamChange} from '@actions/remote/team';
import TeamFlatList from '@components/team_sidebar/add_team/team_list';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {resetToHome} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import AddTeamItem from './add_team_item';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
backgroundColor: theme.sidebarBg,
marginHorizontal: 24,
},
title: {
color: theme.sidebarHeaderTextColor,
marginTop: 40,
...typography('Heading', 800),
},
description: {
color: changeOpacity(theme.sidebarText, 0.72),
marginTop: 12,
marginBottom: 25,
...typography('Body', 200, 'Regular'),
},
separator: {
borderColor: changeOpacity(theme.sidebarText, 0.08),
borderTopWidth: 1,
marginVertical: 8,
},
}));
type Props = {
canCreateTeam: boolean;
teams: Team[];
};
function TeamList({
canCreateTeam,
teams,
}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const serverUrl = useServerUrl();
const isTablet = useIsTablet();
const onTeamAdded = async (id: string) => {
await handleTeamChange(serverUrl, id);
resetToHome();
};
const containerStyle = useMemo(() => {
return isTablet ? [styles.container, {maxWidth: 600, alignItems: 'center'}] : styles.container;
}, [isTablet, styles]);
return (
<View style={containerStyle}>
<View>
<Text style={styles.title}>{intl.formatMessage({id: 'select_team.title', defaultMessage: 'Select a team'})}</Text>
<Text style={styles.description}>{intl.formatMessage({id: 'select_team.description', defaultMessage: 'You are not yet a member of any teams. Select one below to get started.'})}</Text>
</View>
{canCreateTeam && (
<>
<AddTeamItem/>
<View style={styles.separator}/>
</>
)}
<TeamFlatList
teams={teams}
textColor={theme.sidebarText}
iconBackgroundColor={changeOpacity(theme.sidebarText, 0.16)}
iconTextColor={theme.sidebarText}
onTeamAdded={onTeamAdded}
/>
</View>
);
}
export default TeamList;

View File

@@ -13,7 +13,7 @@ import ClientError from '@client/rest/error';
import {Screens, Sso} from '@constants';
import NetworkManager from '@managers/network_manager';
import Background from '@screens/background';
import {dismissModal, resetToHome} from '@screens/navigation';
import {dismissModal, resetToHome, resetToTeams} from '@screens/navigation';
import SSOWithRedirectURL from './sso_with_redirect_url';
import SSOWithWebView from './sso_with_webview';
@@ -102,8 +102,7 @@ const SSO = ({
return;
}
if (!result.hasTeams && !result.error) {
// eslint-disable-next-line no-console
console.log('GO TO NO TEAMS');
resetToTeams();
return;
}
goToHome(result.time || 0, result.error as never);

View File

@@ -10,6 +10,12 @@ class EphemeralStore {
creatingChannel = false;
creatingDMorGMTeammates: string[] = [];
// As of today, the server sends a duplicated event to add the user to the team.
// If we do not handle this, this ends up showing some errors in the database, apart
// of the extra computation time. We use this to track the events that are being handled
// and make sure we only handle one.
private addingTeam = new Set<string>();
addNavigationComponentId = (componentId: string) => {
this.addToNavigationComponentIdStack(componentId);
this.addToAllNavigationComponentIds(componentId);
@@ -114,6 +120,18 @@ class EphemeralStore {
found = !this.navigationComponentIdStack.includes(componentId);
}
};
startAddingToTeam = (teamId: string) => {
this.addingTeam.add(teamId);
};
finishAddingToTeam = (teamId: string) => {
this.addingTeam.delete(teamId);
};
isAddingToTeam = (teamId: string) => {
return this.addingTeam.has(teamId);
};
}
export default new EphemeralStore();

View File

@@ -421,7 +421,7 @@ export const buttonTextStyle = (
},
lg: {
fontSize: 16,
lineHeight: 16,
lineHeight: 18,
marginTop: 1,
},
});

View File

@@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IntlShape} from 'react-intl';
import {Alert} from 'react-native';
import {Navigation, Options} from 'react-native-navigation';
import {Screens} from '@constants';
@@ -10,3 +12,20 @@ export const appearanceControlledScreens = new Set([Screens.SERVER, Screens.LOGI
export function mergeNavigationOptions(componentId: string, options: Options) {
Navigation.mergeOptions(componentId, options);
}
export async function alertTeamRemove(displayName: string, intl: IntlShape) {
Alert.alert(
intl.formatMessage({
id: 'alert.removed_from_team.title',
defaultMessage: 'Removed from team',
}),
intl.formatMessage({
id: 'alert.removed_from_team.description',
defaultMessage: 'You have been removed from team {displayName}.',
}, {displayName}),
[{
style: 'cancel',
text: intl.formatMessage({id: 'mobile.oauth.something_wrong.okButton', defaultMessage: 'OK'}),
}],
);
}

View File

@@ -15,6 +15,8 @@
"account.settings": "Settings",
"account.user_status.title": "User Presence",
"account.your_profile": "Your Profile",
"alert.removed_from_team.description": "You have been removed from team {displayName}.",
"alert.removed_from_team.title": "Removed from team",
"api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.",
"api.channel.add_member.added": "{addedUsername} added to the channel by {username}.",
"api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.",
@@ -277,7 +279,7 @@
"mobile.about.serverVersionNoBuild": "Server Version: {version}",
"mobile.account.settings.save": "Save",
"mobile.action_menu.select": "Select an option",
"mobile.add_team.create_team": "Create a New Team",
"mobile.add_team.create_team": "Create a new team",
"mobile.add_team.join_team": "Join Another Team",
"mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.",
"mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos",
@@ -525,6 +527,10 @@
"screens.channel_details": "Channel Details",
"screens.channel_edit_header": "Edit Channel Header",
"search_bar.search": "Search",
"select_team.description": "You are not yet a member of any teams. Select one below to get started.",
"select_team.no_team.description": "To join a team, ask a team admin for an invite, or create your own team. You may also want to check your email inbox for an invitation.",
"select_team.no_team.title": "No teams are available to join",
"select_team.title": "Select a team",
"server.logout.alert_description": "All associated data will be removed",
"server.logout.alert_title": "Are you sure you want to log out of {displayName}?",
"server.remove.alert_description": "This will remove it from your list of servers. All associated data will be removed",