[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:
parent
2cfb0ba77b
commit
10c714e02f
7 changed files with 160 additions and 68 deletions
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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})}`,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue