mattermost-mobile/app/components/autocomplete/channel_mention/channel_mention.tsx
Daniel Espino García 0c4a42a06a
Remove tand prevent double tap (#9078)
* Remove the t function and all wrong uses of preventDoubleTap

* Fix existing typo

* Fix tests

* Address comments

* Fix test
2025-08-25 12:03:01 +02:00

370 lines
13 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {defineMessages} from 'react-intl';
import {Platform, SectionList, type SectionListData, type SectionListRenderItemInfo, type StyleProp, type ViewStyle} from 'react-native';
import {searchChannels} from '@actions/remote/channel';
import AutocompleteSectionHeader from '@components/autocomplete/autocomplete_section_header';
import ChannelItem from '@components/channel_item';
import {General} from '@constants';
import {CHANNEL_MENTION_REGEX, CHANNEL_MENTION_SEARCH_REGEX} from '@constants/autocomplete';
import {useServerUrl} from '@context/server';
import DatabaseManager from '@database/manager';
import useDidUpdate from '@hooks/did_update';
import {getUserById} from '@queries/servers/user';
import {hasTrailingSpaces} from '@utils/helpers';
import {getUserIdFromChannelName} from '@utils/user';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
const keyExtractor = (item: Channel) => {
return item.id;
};
const reduceChannelsForSearch = (channels: Array<Channel | ChannelModel>, members: MyChannelModel[]) => {
const memberIds = new Set(members.map((m) => m.id));
return channels.reduce<Array<Array<Channel | ChannelModel>>>(([pubC, priC, dms], c) => {
switch (c.type) {
case General.OPEN_CHANNEL:
if (memberIds.has(c.id)) {
pubC.push(c);
}
break;
case General.PRIVATE_CHANNEL:
priC.push(c);
break;
case General.DM_CHANNEL:
case General.GM_CHANNEL:
dms.push(c);
}
return [pubC, priC, dms];
}, [[], [], []]);
};
const reduceChannelsForAutocomplete = (channels: Array<Channel | ChannelModel>, members: MyChannelModel[]) => {
const memberIds = new Set(members.map((m) => m.id));
return channels.reduce<Array<Array<Channel | ChannelModel>>>(([myC, otherC], c) => {
if (memberIds.has(c.id)) {
myC.push(c);
} else {
otherC.push(c);
}
return [myC, otherC];
}, [[], []]);
};
const channelMentionMessages = defineMessages({
public: {
id: 'suggestion.search.public',
defaultMessage: 'Public Channels',
},
private: {
id: 'suggestion.search.private',
defaultMessage: 'Private Channels',
},
direct: {
id: 'suggestion.search.direct',
defaultMessage: 'Direct Messages',
},
channels: {
id: 'suggestion.mention.channels',
defaultMessage: 'My Channels',
},
morechannels: {
id: 'suggestion.mention.morechannels',
defaultMessage: 'Other Channels',
},
});
const makeSections = (channels: Array<Channel | ChannelModel>, myMembers: MyChannelModel[], loading: boolean, isSearch = false) => {
const newSections = [];
if (isSearch) {
const [publicChannels, privateChannels, directAndGroupMessages] = reduceChannelsForSearch(channels, myMembers);
if (publicChannels.length) {
newSections.push({
...channelMentionMessages.public,
data: publicChannels,
key: 'publicChannels',
hideLoadingIndicator: true,
});
}
if (privateChannels.length) {
newSections.push({
...channelMentionMessages.private,
data: privateChannels,
key: 'privateChannels',
hideLoadingIndicator: true,
});
}
if (directAndGroupMessages.length) {
newSections.push({
...channelMentionMessages.direct,
data: directAndGroupMessages,
key: 'directAndGroupMessages',
hideLoadingIndicator: true,
});
}
} else {
const [myChannels, otherChannels] = reduceChannelsForAutocomplete(channels, myMembers);
if (myChannels.length) {
newSections.push({
...channelMentionMessages.channels,
data: myChannels,
key: 'myChannels',
hideLoadingIndicator: true,
});
}
if (otherChannels.length || (!myChannels.length && loading)) {
newSections.push({
...channelMentionMessages.morechannels,
data: otherChannels,
key: 'otherChannels',
hideLoadingIndicator: true,
});
}
}
const nSections = newSections.length;
if (nSections) {
newSections[nSections - 1].hideLoadingIndicator = false;
}
return newSections;
};
const filterResults = (channels: Array<Channel | ChannelModel>, term: string) => {
return channels.filter((c) => {
const displayName = ('displayName' in c ? c.displayName : c.display_name).toLowerCase();
return c.name.toLowerCase().includes(term) ||
displayName.includes(term);
});
};
type Props = {
cursorPosition: number;
isSearch: boolean;
myMembers: MyChannelModel[];
updateValue: (v: string) => void;
onShowingChange: (c: boolean) => void;
value: string;
nestedScrollEnabled: boolean;
listStyle: StyleProp<ViewStyle>;
matchTerm: string;
localChannels: ChannelModel[];
teamId: string;
currentUserId: string;
}
const emptySections: Array<SectionListData<Channel>> = [];
const emptyChannels: Array<Channel | ChannelModel> = [];
const ChannelMention = ({
cursorPosition,
isSearch,
myMembers,
updateValue,
onShowingChange,
value,
nestedScrollEnabled,
listStyle,
matchTerm,
localChannels,
teamId,
currentUserId,
}: Props) => {
const serverUrl = useServerUrl();
const [sections, setSections] = useState<Array<SectionListData<(Channel | ChannelModel)>>>(emptySections);
const [remoteChannels, setRemoteChannels] = useState<Array<ChannelModel | Channel>>(emptyChannels);
const [loading, setLoading] = useState(false);
const [noResultsTerm, setNoResultsTerm] = useState<string|null>(null);
const [localCursorPosition, setLocalCursorPosition] = useState(cursorPosition); // To avoid errors due to delay between value changes and cursor position changes.
const latestSearchAt = useRef(0);
const runSearch = useMemo(() => debounce(async (sUrl: string, term: string, tId: string) => {
const searchAt = Date.now();
latestSearchAt.current = searchAt;
const {channels: receivedChannels} = await searchChannels(sUrl, term, tId, isSearch);
if (latestSearchAt.current > searchAt) {
return;
}
let channelsToStore: Array<Channel | ChannelModel> = receivedChannels || [];
if (hasTrailingSpaces(term)) {
channelsToStore = filterResults(receivedChannels || [], term);
}
setRemoteChannels(channelsToStore.length ? channelsToStore : emptyChannels);
setLoading(false);
}, 200), []);
const resetState = () => {
latestSearchAt.current = Date.now();
setRemoteChannels(emptyChannels);
setSections(emptySections);
setNoResultsTerm(null);
runSearch.cancel();
setLoading(false);
};
const completeMention = useCallback(async (c: ChannelModel | Channel) => {
let mention = c.name;
const teammateId = getUserIdFromChannelName(currentUserId, c.name);
if (c.type === General.DM_CHANNEL && teammateId) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const user = await getUserById(database, teammateId);
if (user) {
mention = `@${user.username}`;
}
} catch (err) {
// Do nothing
}
}
const mentionPart = value.substring(0, localCursorPosition);
let completedDraft: string;
if (isSearch) {
const channelOrIn = mentionPart.includes('in:') ? 'in:' : 'channel:';
completedDraft = mentionPart.replace(CHANNEL_MENTION_SEARCH_REGEX, `${channelOrIn} ${mention} `);
} else if (Platform.OS === 'ios') {
// We are going to set a double ~ on iOS to prevent the auto correct from taking over and replacing it
// with the wrong value, this is a hack but I could not found another way to solve it
completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~~${mention} `);
} else {
completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `);
}
const newCursorPosition = completedDraft.length - 1;
if (value.length > localCursorPosition) {
completedDraft += value.substring(localCursorPosition);
}
updateValue(completedDraft);
setLocalCursorPosition(newCursorPosition);
if (Platform.OS === 'ios') {
// This is the second part of the hack were we replace the double ~ with just one
// after the auto correct vanished
setTimeout(() => {
updateValue(completedDraft.replace(`~~${mention} `, `~${mention} `));
});
}
onShowingChange(false);
setLoading(false);
setNoResultsTerm(mention);
setSections(emptySections);
setRemoteChannels(emptyChannels);
latestSearchAt.current = Date.now();
}, [currentUserId, value, localCursorPosition, isSearch, updateValue, onShowingChange, serverUrl]);
const renderItem = useCallback(({item}: SectionListRenderItemInfo<Channel | ChannelModel>) => {
return (
<ChannelItem
channel={item}
onPress={completeMention}
testID='autocomplete.channel_mention_item'
isOnCenterBg={true}
showChannelName={true}
/>
);
}, [completeMention]);
const renderSectionHeader = useCallback(({section}: SectionListRenderItemInfo<Channel | ChannelModel>) => {
return (
<AutocompleteSectionHeader
id={section.id}
defaultMessage={section.defaultMessage}
loading={!section.hideLoadingIndicator && loading}
/>
);
}, [loading]);
useEffect(() => {
if (localCursorPosition !== cursorPosition) {
setLocalCursorPosition(cursorPosition);
}
}, [cursorPosition]);
useEffect(() => {
if (matchTerm === null) {
resetState();
onShowingChange(false);
return;
}
if (noResultsTerm != null && matchTerm.startsWith(noResultsTerm)) {
return;
}
setNoResultsTerm(null);
setLoading(true);
runSearch(serverUrl, matchTerm, teamId);
}, [matchTerm, teamId]);
const channels = useMemo(() => {
const ids = new Set(localChannels.map((c) => c.id));
return [...localChannels, ...remoteChannels.filter((c) => !ids.has(c.id))].sort((a, b) => {
const aDisplay = 'display_name' in a ? a.display_name : a.displayName;
const bDisplay = 'display_name' in b ? b.display_name : b.displayName;
const displayResult = aDisplay.localeCompare(bDisplay);
if (displayResult === 0) {
return a.name.localeCompare(b.name);
}
return displayResult;
});
}, [localChannels, remoteChannels]);
useDidUpdate(() => {
if (noResultsTerm && !loading) {
return;
}
const newSections = makeSections(channels, myMembers, loading, isSearch);
const nSections = newSections.length;
if (!loading && !nSections && noResultsTerm == null) {
setNoResultsTerm(matchTerm);
}
if (nSections) {
setNoResultsTerm(null);
}
setSections(newSections.length ? newSections : emptySections);
onShowingChange(Boolean(nSections));
}, [channels, myMembers, loading]);
if (!loading && (sections.length === 0 || noResultsTerm != null)) {
// If we are not in an active state or the mention has been completed return null so nothing is rendered
// other components are not blocked.
return null;
}
return (
<SectionList
keyboardShouldPersistTaps='always'
keyExtractor={keyExtractor}
initialNumToRender={10}
nestedScrollEnabled={nestedScrollEnabled}
removeClippedSubviews={Platform.OS === 'android'}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
style={listStyle}
sections={sections}
testID='autocomplete.channel_mention.section_list'
/>
);
};
export default ChannelMention;