diff --git a/app/actions/remote/groups.ts b/app/actions/remote/groups.ts index 508eaa1cf..01487aaeb 100644 --- a/app/actions/remote/groups.ts +++ b/app/actions/remote/groups.ts @@ -4,13 +4,37 @@ import {Client} from '@client/rest'; import NetworkManager from '@managers/network_manager'; -export const getGroupsForAutocomplete = async (serverUrl: string, channelId: string) => { +import {forceLogoutIfNecessary} from './session'; + +export const fetchGroupsForChannel = async (serverUrl: string, channelId: string) => { let client: Client; try { client = NetworkManager.getClient(serverUrl); + return client.getAllGroupsAssociatedToChannel(channelId); } catch (error) { - return []; + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const fetchGroupsForTeam = async (serverUrl: string, teamId: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + return client.getAllGroupsAssociatedToTeam(teamId); + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; + +export const fetchGroupsForAutocomplete = async (serverUrl: string, query: string) => { + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + return client.getGroups(query); + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; } - - return client.getAllGroupsAssociatedToChannel(channelId, true); }; diff --git a/app/client/rest/groups.ts b/app/client/rest/groups.ts index a39bd395b..f431af77c 100644 --- a/app/client/rest/groups.ts +++ b/app/client/rest/groups.ts @@ -6,24 +6,15 @@ import {buildQueryString} from '@utils/helpers'; import {PER_PAGE_DEFAULT} from './constants'; export interface ClientGroupsMix { - getGroups: (filterAllowReference?: boolean, page?: number, perPage?: number, since?: number) => Promise; - getGroupsByUserId: (userID: string) => Promise; + getGroups: (query?: string, filterAllowReference?: boolean, page?: number, perPage?: number, since?: number) => Promise; getAllGroupsAssociatedToTeam: (teamID: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>; - getAllGroupsAssociatedToChannelsInTeam: (teamID: string, filterAllowReference?: boolean) => Promise<{groups: Record}>; - getAllGroupsAssociatedToChannel: (channelID: string, filterAllowReference?: boolean) => Promise; + getAllGroupsAssociatedToChannel: (channelID: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>; } const ClientGroups = (superclass: any) => class extends superclass { - getGroups = async (filterAllowReference = false, page = 0, perPage = PER_PAGE_DEFAULT, since = 0) => { + getGroups = async (query = '', filterAllowReference = true, page = 0, perPage = PER_PAGE_DEFAULT, since = 0) => { return this.doFetch( - `${this.urlVersion}/groups${buildQueryString({filter_allow_reference: filterAllowReference, page, per_page: perPage, since})}`, - {method: 'get'}, - ); - }; - - getGroupsByUserId = async (userID: string) => { - return this.doFetch( - `${this.getUsersRoute()}/${userID}/groups`, + `${this.urlVersion}/groups${buildQueryString({q: query, filter_allow_reference: filterAllowReference, page, per_page: perPage, since})}`, {method: 'get'}, ); }; @@ -35,13 +26,6 @@ const ClientGroups = (superclass: any) => class extends superclass { ); }; - getAllGroupsAssociatedToChannelsInTeam = async (teamID: string, filterAllowReference = false) => { - return this.doFetch( - `${this.urlVersion}/teams/${teamID}/groups_by_channels${buildQueryString({paginate: false, filter_allow_reference: filterAllowReference})}`, - {method: 'get'}, - ); - }; - getAllGroupsAssociatedToChannel = async (channelID: string, filterAllowReference = false) => { return this.doFetch( `${this.urlVersion}/channels/${channelID}/groups${buildQueryString({paginate: false, filter_allow_reference: filterAllowReference})}`, diff --git a/app/components/autocomplete/at_mention/at_mention.tsx b/app/components/autocomplete/at_mention/at_mention.tsx index f11ee9c04..2e6715d03 100644 --- a/app/components/autocomplete/at_mention/at_mention.tsx +++ b/app/components/autocomplete/at_mention/at_mention.tsx @@ -5,7 +5,7 @@ import {debounce} from 'lodash'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {Platform, SectionList, SectionListData, SectionListRenderItemInfo} from 'react-native'; -import {getGroupsForAutocomplete} from '@actions/remote/groups'; +import {fetchGroupsForAutocomplete, fetchGroupsForChannel, fetchGroupsForTeam} from '@actions/remote/groups'; import {searchUsers} from '@actions/remote/user'; import GroupMentionItem from '@components/autocomplete/at_mention_group/at_mention_group'; import AtMentionItem from '@components/autocomplete/at_mention_item'; @@ -172,8 +172,29 @@ const makeSections = (teamMembers: Array, usersInChanne return newSections; }; +const getFilteredTeamGroups = async (serverUrl: string, teamId: string, searchTerm: string) => { + const response = await fetchGroupsForTeam(serverUrl, teamId); + + if (response && 'groups' in response) { + return response.groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase())); + } + + return []; +}; + +const getFilteredChannelGroups = async (serverUrl: string, channelId: string, searchTerm: string) => { + const response = await fetchGroupsForChannel(serverUrl, channelId); + + if (response && 'groups' in response) { + return response.groups.filter((g) => g.name.toLowerCase().includes(searchTerm.toLowerCase())); + } + + return []; +}; + type Props = { channelId?: string; + teamId?: string; cursorPosition: number; isSearch: boolean; maxListHeight: number; @@ -183,6 +204,8 @@ type Props = { nestedScrollEnabled: boolean; useChannelMentions: boolean; useGroupMentions: boolean; + isChannelConstrained: boolean; + isTeamConstrained: boolean; } const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { @@ -196,7 +219,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { const emptyProfileList: UserProfile[] = []; const emptyModelList: UserModel[] = []; -const empytSectionList: UserMentionSections = []; +const emptySectionList: UserMentionSections = []; const emptyGroupList: Group[] = []; const getAllUsers = async (serverUrl: string) => { @@ -210,6 +233,7 @@ const getAllUsers = async (serverUrl: string) => { const AtMention = ({ channelId, + teamId, cursorPosition, isSearch, maxListHeight, @@ -219,12 +243,14 @@ const AtMention = ({ nestedScrollEnabled, useChannelMentions, useGroupMentions, + isChannelConstrained, + isTeamConstrained, }: Props) => { const serverUrl = useServerUrl(); const theme = useTheme(); const style = getStyleFromTheme(theme); - const [sections, setSections] = useState(empytSectionList); + const [sections, setSections] = useState(emptySectionList); const [usersInChannel, setUsersInChannel] = useState(emptyProfileList); const [usersOutOfChannel, setUsersOutOfChannel] = useState(emptyProfileList); const [groups, setGroups] = useState(emptyGroupList); @@ -266,7 +292,7 @@ const AtMention = ({ setUsersInChannel(emptyProfileList); setUsersOutOfChannel(emptyProfileList); setFilteredLocalUsers(emptyModelList); - setSections(empytSectionList); + setSections(emptySectionList); runSearch.cancel(); }; @@ -291,7 +317,7 @@ const AtMention = ({ onShowingChange(false); setNoResultsTerm(mention); - setSections(empytSectionList); + setSections(emptySectionList); }, [value, localCursorPosition, isSearch]); const renderSpecialMentions = useCallback((item: SpecialMention) => { @@ -309,7 +335,8 @@ const AtMention = ({ return ( ); @@ -353,16 +380,37 @@ const AtMention = ({ }, [cursorPosition]); useEffect(() => { - if (useGroupMentions) { - getGroupsForAutocomplete(serverUrl, channelId || '').then((res) => { - setGroups(res.length ? res : emptyGroupList); - }).catch(() => { - setGroups(emptyGroupList); - }); + if (useGroupMentions && matchTerm && matchTerm !== '') { + // If the channel is constrained, we only show groups for that channel + if (isChannelConstrained && channelId) { + getFilteredChannelGroups(serverUrl, channelId, matchTerm).then((g) => { + setGroups(g.length ? g : emptyGroupList); + }).catch(() => { + setGroups(emptyGroupList); + }); + } + + // If there is no channel constraint, but a team constraint - only show groups for team + if (isTeamConstrained && !isChannelConstrained) { + getFilteredTeamGroups(serverUrl, teamId!, matchTerm).then((g) => { + setGroups(g.length ? g : emptyGroupList); + }).catch(() => { + setGroups(emptyGroupList); + }); + } + + // No constraints? Search all groups + if (!isTeamConstrained && !isChannelConstrained) { + fetchGroupsForAutocomplete(serverUrl, matchTerm || '').then((g) => { + setGroups(Array.isArray(g) ? g : emptyGroupList); + }).catch(() => { + setGroups(emptyGroupList); + }); + } } else { setGroups(emptyGroupList); } - }, [channelId, useGroupMentions]); + }, [matchTerm, useGroupMentions]); useEffect(() => { if (matchTerm === null) { @@ -393,7 +441,7 @@ const AtMention = ({ if (!loading && !nSections && noResultsTerm == null) { setNoResultsTerm(matchTerm); } - setSections(nSections ? newSections : empytSectionList); + setSections(nSections ? newSections : emptySectionList); onShowingChange(Boolean(nSections)); }, [!useLocal && usersInChannel, !useLocal && usersOutOfChannel, teamMembers, groups, loading, channelId, useLocal && filteredLocalUsers]); diff --git a/app/components/autocomplete/at_mention/index.ts b/app/components/autocomplete/at_mention/index.ts index e35001221..5e12e8bad 100644 --- a/app/components/autocomplete/at_mention/index.ts +++ b/app/components/autocomplete/at_mention/index.ts @@ -10,11 +10,13 @@ import {Permissions} from '@constants'; import {observeChannel} from '@queries/servers/channel'; import {observePermissionForChannel} from '@queries/servers/role'; import {observeLicense} from '@queries/servers/system'; +import {observeCurrentTeam, observeTeam} from '@queries/servers/team'; import {observeCurrentUser} from '@queries/servers/user'; import AtMention from './at_mention'; import type {WithDatabaseArgs} from '@typings/database/database'; +import type TeamModel from '@typings/database/models/servers/team'; type OwnProps = {channelId?: string} const enhanced = withObservables([], ({database, channelId}: WithDatabaseArgs & OwnProps) => { @@ -26,9 +28,20 @@ const enhanced = withObservables([], ({database, channelId}: WithDatabaseArgs & let useChannelMentions: Observable; let useGroupMentions: Observable; + let isChannelConstrained: Observable; + let isTeamConstrained: Observable; + let team: Observable; if (channelId) { const currentChannel = observeChannel(database, channelId); + team = currentChannel.pipe(switchMap((c) => { + return c?.teamId ? observeTeam(database, c.teamId) : observeCurrentTeam(database); + })); + + isChannelConstrained = currentChannel.pipe( + switchMap((c) => of$(Boolean(c?.isGroupConstrained))), + ); + useChannelMentions = combineLatest([currentUser, currentChannel]).pipe(switchMap(([u, c]) => (u && c ? observePermissionForChannel(c, u, Permissions.USE_CHANNEL_MENTIONS, false) : of$(false)))); useGroupMentions = combineLatest([currentUser, currentChannel, hasLicense]).pipe( switchMap(([u, c, lcs]) => (lcs && u && c ? observePermissionForChannel(c, u, Permissions.USE_GROUP_MENTIONS, false) : of$(false))), @@ -36,11 +49,22 @@ const enhanced = withObservables([], ({database, channelId}: WithDatabaseArgs & } else { useChannelMentions = of$(false); useGroupMentions = of$(false); + isChannelConstrained = of$(false); + isTeamConstrained = of$(false); + team = observeCurrentTeam(database); } + isTeamConstrained = team.pipe( + switchMap((t) => of$(Boolean(t?.isGroupConstrained))), + ); + const teamId = team.pipe(switchMap((t) => of$(t?.id))); + return { + isChannelConstrained, + isTeamConstrained, useChannelMentions, useGroupMentions, + teamId, }; }); diff --git a/app/components/autocomplete/at_mention_group/at_mention_group.tsx b/app/components/autocomplete/at_mention_group/at_mention_group.tsx index ff3a9b9f0..717d4a257 100644 --- a/app/components/autocomplete/at_mention_group/at_mention_group.tsx +++ b/app/components/autocomplete/at_mention_group/at_mention_group.tsx @@ -8,6 +8,7 @@ import { } from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import {typography} from '@app/utils/typography'; import CompassIcon from '@components/compass_icon'; import TouchableWithFeedback from '@components/touchable_with_feedback'; import {useTheme} from '@context/theme'; @@ -16,46 +17,55 @@ import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { return { row: { + height: 40, paddingVertical: 8, + paddingTop: 4, + paddingHorizontal: 16, flexDirection: 'row', alignItems: 'center', - backgroundColor: theme.centerChannelBg, }, rowPicture: { - marginHorizontal: 8, - width: 20, + marginRight: 10, + marginLeft: 2, + width: 24, alignItems: 'center', justifyContent: 'center', }, rowIcon: { - color: changeOpacity(theme.centerChannelColor, 0.7), - fontSize: 14, + color: changeOpacity(theme.centerChannelColor, 0.64), + fontSize: 22, }, - rowUsername: { - fontSize: 13, + rowInfo: { + flexDirection: 'row', + alignItems: 'center', + overflow: 'hidden', + maxWidth: '80%', + paddingLeft: 3, + }, + rowDisplayName: { color: theme.centerChannelColor, + flexShrink: 5, + ...typography('Body', 200), }, - rowFullname: { - color: theme.centerChannelColor, - flex: 1, - opacity: 0.6, - }, - textWrapper: { - flex: 1, - flexWrap: 'wrap', - paddingRight: 8, + rowName: { + ...typography('Body', 200), + color: changeOpacity(theme.centerChannelColor, 0.64), + flexShrink: 1, + marginLeft: 2, }, }; }); type Props = { - completeHandle: string; + name: string; + displayName: string; onPress: (handle: string) => void; } const GroupMentionItem = ({ onPress, - completeHandle, + name, + displayName, }: Props) => { const insets = useSafeAreaInsets(); const theme = useTheme(); @@ -66,8 +76,8 @@ const GroupMentionItem = ({ [insets.left, insets.right, style], ); const completeMention = useCallback(() => { - onPress(completeHandle); - }, [onPress, completeHandle]); + onPress(name); + }, [onPress, name]); return ( - {`@${completeHandle} - `} - {`${completeHandle}`} + + {`${displayName} `} + {`@${name}`} + ); }; diff --git a/app/components/autocomplete/autocomplete_section_header.tsx b/app/components/autocomplete/autocomplete_section_header.tsx index b95ac01ab..581e25498 100644 --- a/app/components/autocomplete/autocomplete_section_header.tsx +++ b/app/components/autocomplete/autocomplete_section_header.tsx @@ -12,10 +12,8 @@ import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { return { section: { - justifyContent: 'center', - position: 'relative', - top: -1, flexDirection: 'row', + paddingHorizontal: 16, }, sectionText: { fontSize: 12, @@ -24,12 +22,12 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { color: changeOpacity(theme.centerChannelColor, 0.56), paddingTop: 16, paddingBottom: 8, - paddingHorizontal: 16, flex: 1, }, sectionWrapper: { backgroundColor: theme.centerChannelBg, }, + loading: {paddingTop: 16}, }; }); @@ -62,8 +60,9 @@ const AutocompleteSectionHeader = ({ /> {loading && } diff --git a/app/components/user_item/user_item.tsx b/app/components/user_item/user_item.tsx index a69c6d368..621d7a264 100644 --- a/app/components/user_item/user_item.tsx +++ b/app/components/user_item/user_item.tsx @@ -5,6 +5,7 @@ import React from 'react'; import {IntlShape, useIntl} from 'react-intl'; import {StyleProp, Text, View, ViewStyle} from 'react-native'; +import {typography} from '@app/utils/typography'; import ChannelIcon from '@components/channel_icon'; import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; import FormattedText from '@components/formatted_text'; @@ -69,14 +70,14 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { overflow: 'hidden', }, rowFullname: { - fontSize: 15, + ...typography('Body', 200), color: theme.centerChannelColor, - fontFamily: 'OpenSans', paddingLeft: 4, flexShrink: 1, }, rowUsername: { - color: changeOpacity(theme.centerChannelColor, 0.56), + ...typography('Body', 200), + color: changeOpacity(theme.centerChannelColor, 0.64), fontSize: 15, fontFamily: 'OpenSans', flexShrink: 5,