mattermost-mobile-upstream/app/screens/create_direct_message/create_direct_message.tsx
Daniel Espino García 84ea188366
Mattermost Blocks (#9764)
* Mattermost Blocks

* Move block renderer to its own components folder

* Sync translation layer

* UX fixes

* Refactor button to use the button component

* Code review

* Fix CI

* Add animations to collapse blocks

* Add unit tests

* Add e2e tests and related fixes

* Address coderabbit comments

* Address UX comments

* Address feedback

* Address coderabbit comments

* Fix snapshots

* Sync with webapp translation

* Add node limit to button markdown node

* Address feedback

* i18n-extract

* Fix test

* Move image utils to the utils folder

* Remove unneeded style

* Revert build script change

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
2026-06-29 18:42:16 +02:00

312 lines
11 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard, Platform, View} from 'react-native';
import {SafeAreaView, type Edge} from 'react-native-safe-area-context';
import {makeDirectChannel, makeGroupChannel} from '@actions/remote/channel';
import {fetchProfiles, fetchProfilesInTeam, searchProfiles} from '@actions/remote/user';
import Loading from '@components/loading';
import Search from '@components/search';
import SelectedUsers from '@components/selected_users';
import ServerUserList from '@components/server_user_list';
import {General, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {TutorialProvider} from '@context/tutorial';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {navigateBack} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
const messages = defineMessages({
dm: {
id: 'mobile.open_dm.error',
defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
},
gm: {
id: 'mobile.open_gm.error',
defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.",
},
buttonText: {
id: 'mobile.create_direct_message.start',
defaultMessage: 'Start Conversation',
},
toastMessage: {
id: 'mobile.create_direct_message.max_limit_reached',
defaultMessage: 'Group messages are limited to {maxCount} members',
},
});
type Props = {
currentTeamId: string;
currentUserId: string;
restrictDirectMessage: boolean;
teammateNameDisplay: string;
tutorialWatched: boolean;
}
const safeAreaEdges: Edge[] = ['bottom', 'left', 'right'];
const close = () => {
Keyboard.dismiss();
navigateBack();
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
listContainer: {
flex: 1,
marginLeft: 12,
marginRight: 12,
},
searchBar: {
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
marginVertical: 12,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
color: changeOpacity(theme.centerChannelColor, 0.5),
...typography('Body', 600, 'Regular'),
},
};
});
function removeProfileFromList(list: Set<string>, id: string) {
const newSelectedIds = new Set(list);
newSelectedIds.delete(id);
return newSelectedIds;
}
export default function CreateDirectMessage({
currentTeamId,
currentUserId,
restrictDirectMessage,
teammateNameDisplay,
tutorialWatched,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const {formatMessage} = intl;
const [term, setTerm] = useState('');
const [startingConversation, setStartingConversation] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set<string>());
const [showToast, setShowToast] = useState(false);
const selectedCount = selectedIds.size;
const clearSearch = useCallback(() => {
setTerm('');
}, []);
const handleRemoveProfile = useCallback((id: string) => {
setSelectedIds((current) => removeProfileFromList(current, id));
}, []);
const createDirectChannel = useCallback(async (id: string): Promise<boolean> => {
const result = await makeDirectChannel(serverUrl, id);
if (result.error) {
alertErrorWithFallback(intl, result.error, messages.dm);
}
return !result.error;
}, [intl, serverUrl]);
const createGroupChannel = useCallback(async (ids: string[]): Promise<boolean> => {
const result = await makeGroupChannel(serverUrl, ids);
if (result.error) {
alertErrorWithFallback(intl, result.error, messages.gm);
}
return !result.error;
}, [intl, serverUrl]);
const startConversation = useCallback(async (selectedId?: string) => {
if (startingConversation) {
return;
}
setStartingConversation(true);
const idsToUse = selectedId ? [selectedId] : Array.from(selectedIds);
let success;
if (idsToUse.length === 0) {
success = false;
} else if (idsToUse.length > 1) {
success = await createGroupChannel(idsToUse);
} else {
success = await createDirectChannel(idsToUse[0]);
}
if (success) {
close();
} else {
setStartingConversation(false);
}
}, [startingConversation, selectedIds, createGroupChannel, createDirectChannel]);
const handleSelectProfile = useCallback((user: UserProfile) => {
if (user.id === currentUserId) {
startConversation(currentUserId);
} else {
clearSearch();
setSelectedIds((current) => {
if (current.has(user.id)) {
return removeProfileFromList(current, user.id);
}
if (selectedCount >= General.MAX_USERS_IN_GM) {
setShowToast(true);
return current;
}
const newSelectedIds = new Set(current);
newSelectedIds.add(user.id);
return newSelectedIds;
});
}
}, [currentUserId, startConversation, clearSearch, selectedCount]);
const onChangeText = useCallback((searchTerm: string) => {
setTerm(searchTerm);
}, []);
const userFetchFunction = useCallback(async (page: number) => {
let results;
if (restrictDirectMessage) {
results = await fetchProfilesInTeam(serverUrl, currentTeamId, page, General.PROFILE_CHUNK_SIZE, '', {active: true});
} else {
results = await fetchProfiles(serverUrl, page, General.PROFILE_CHUNK_SIZE, {active: true});
}
if (results.users?.length) {
return results.users;
}
return [];
}, [serverUrl, currentTeamId, restrictDirectMessage]);
const userSearchFunction = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
let results;
if (restrictDirectMessage) {
results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId, allow_inactive: false});
} else {
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: false});
}
if (results.data) {
return results.data;
}
return [];
}, [serverUrl, currentTeamId, restrictDirectMessage]);
const createUserFilter = useCallback((exactMatches: UserProfile[], searchTerm: string) => {
return (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
if (p.username === searchTerm || p.username.startsWith(searchTerm)) {
exactMatches.push(p);
return false;
}
return true;
};
}, [currentUserId, selectedCount]);
useAndroidHardwareBackHandler(Screens.CREATE_DIRECT_MESSAGE, close);
useEffect(() => {
setShowToast(selectedCount >= General.MAX_USERS_IN_GM);
}, [selectedCount]);
if (startingConversation) {
return (
<View style={style.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return (
<SafeAreaView
style={style.container}
testID='create_direct_message.screen'
edges={safeAreaEdges}
>
<TutorialProvider>
<View style={style.searchBar}>
<Search
testID='create_direct_message.search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onChangeText}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
<View style={style.listContainer}>
<ServerUserList
handleSelectProfile={handleSelectProfile}
selectedIds={selectedIds}
term={term}
testID='create_direct_message.user_list'
tutorialWatched={tutorialWatched}
fetchFunction={userFetchFunction}
searchFunction={userSearchFunction}
createFilter={createUserFilter}
location={Screens.CREATE_DIRECT_MESSAGE}
/>
</View>
<SelectedUsers
showToast={showToast}
setShowToast={setShowToast}
toastIcon={'check'}
toastMessage={formatMessage(messages.toastMessage, {maxCount: General.MAX_USERS_IN_GM})}
selectedIds={selectedIds}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
onPress={startConversation}
buttonIcon={'forum-outline'}
buttonText={formatMessage(messages.buttonText)}
testID='create_direct_message'
maxUsers={General.MAX_USERS_IN_GM}
/>
</TutorialProvider>
</SafeAreaView>
);
}