[Gekidou] Groups are included in the @metion auto-complete (#6261)

* Group autocomplete -
* Styling fixes
* Loading indicator
* Displays group name and display name
* PR Feedback
* Cleans up styling
* Adds constraints to group searches
* Channel Team before current team
* PR Feedback; displayName > name, model observable
* PR Feedback; rename fetch + model observable
* PR Feedback: return {error}, spelling fix
* PR Feedback: Add forceLogoutIfNecessary to fetch calls
* Remove doubled up logout
This commit is contained in:
Shaz MJ 2022-05-25 07:25:59 +10:00 committed by GitHub
parent 2cfb0ba77b
commit 10c714e02f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 160 additions and 68 deletions

View file

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

View file

@ -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<Group[]>;
getGroupsByUserId: (userID: string) => Promise<Group[]>;
getGroups: (query?: string, filterAllowReference?: boolean, page?: number, perPage?: number, since?: number) => Promise<Group[]>;
getAllGroupsAssociatedToTeam: (teamID: string, filterAllowReference?: boolean) => Promise<{groups: Group[]; total_group_count: number}>;
getAllGroupsAssociatedToChannelsInTeam: (teamID: string, filterAllowReference?: boolean) => Promise<{groups: Record<string, Group[]>}>;
getAllGroupsAssociatedToChannel: (channelID: string, filterAllowReference?: boolean) => Promise<Group[]>;
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})}`,

View file

@ -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<UserProfile | UserModel>, 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<UserMentionSections>(empytSectionList);
const [sections, setSections] = useState<UserMentionSections>(emptySectionList);
const [usersInChannel, setUsersInChannel] = useState<UserProfile[]>(emptyProfileList);
const [usersOutOfChannel, setUsersOutOfChannel] = useState<UserProfile[]>(emptyProfileList);
const [groups, setGroups] = useState<Group[]>(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 (
<GroupMentionItem
key={`autocomplete-group-${item.name}`}
completeHandle={item.name}
name={item.name}
displayName={item.display_name}
onPress={completeMention}
/>
);
@ -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]);

View file

@ -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<boolean>;
let useGroupMentions: Observable<boolean>;
let isChannelConstrained: Observable<boolean>;
let isTeamConstrained: Observable<boolean>;
let team: Observable<TeamModel | undefined>;
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,
};
});

View file

@ -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 (
<TouchableWithFeedback
@ -81,8 +91,10 @@ const GroupMentionItem = ({
style={style.rowIcon}
/>
</View>
<Text style={style.rowUsername}>{`@${completeHandle} - `}</Text>
<Text style={style.rowFullname}>{`${completeHandle}`}</Text>
<View style={style.rowInfo}>
<Text style={style.rowDisplayName}>{`${displayName} `}</Text>
<Text style={style.rowName}>{`@${name}`}</Text>
</View>
</TouchableWithFeedback>
);
};

View file

@ -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 &&
<ActivityIndicator
color={theme.centerChannelColor}
size='small'
style={style.loading}
color={changeOpacity(theme.centerChannelColor, 0.56)}
/>
}
</View>

View file

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