forked from Ivasoft/mattermost-mobile
[Gekidou] [MM-39682] Add channel autocomplete (#5998)
* Add channel autocomplete * Fix autocomplete position and shadow effect * Fix feedback issues * Fix autocomplete on iOS and piggyback removal of section borders * Fixes the channel autocomplete showing up after completion * Address feedback Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
committed by
GitHub
parent
684d9421a7
commit
8e026c20ac
@@ -1,14 +1,15 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useMemo, useState} from 'react';
|
||||
import {Keyboard, KeyboardEvent, Platform, useWindowDimensions, View} from 'react-native';
|
||||
import React, {useMemo, useState} from 'react';
|
||||
import {Platform, useWindowDimensions, View} from 'react-native';
|
||||
|
||||
import {LIST_BOTTOM, MAX_LIST_DIFF, MAX_LIST_HEIGHT, MAX_LIST_TABLET_DIFF, OFFSET_TABLET} from '@constants/autocomplete';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import useHeaderHeight from '@hooks/header';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import ChannelMention from './channel_mention/';
|
||||
import EmojiSuggestion from './emoji_suggestion/';
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
@@ -22,7 +23,8 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
borderWidth: 1,
|
||||
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
|
||||
overflow: 'hidden',
|
||||
borderRadius: 4,
|
||||
borderRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
hidden: {
|
||||
display: 'none',
|
||||
@@ -40,10 +42,10 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
shadow: {
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.12,
|
||||
shadowRadius: 8,
|
||||
shadowRadius: 6,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 8,
|
||||
height: 6,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -58,15 +60,11 @@ type Props = {
|
||||
value: string;
|
||||
enableDateSuggestion?: boolean;
|
||||
isAppsEnabled: boolean;
|
||||
offsetY?: number;
|
||||
nestedScrollEnabled?: boolean;
|
||||
updateValue: (v: string) => void;
|
||||
hasFilesAttached: boolean;
|
||||
}
|
||||
|
||||
const OFFSET_IPAD = 60;
|
||||
const AUTOCOMPLETE_MARGIN = 20;
|
||||
|
||||
const Autocomplete = ({
|
||||
cursorPosition,
|
||||
postInputTop,
|
||||
@@ -78,52 +76,46 @@ const Autocomplete = ({
|
||||
|
||||
//enableDateSuggestion = false,
|
||||
isAppsEnabled,
|
||||
offsetY = 60,
|
||||
nestedScrollEnabled = false,
|
||||
updateValue,
|
||||
hasFilesAttached,
|
||||
}: Props) => {
|
||||
const theme = useTheme();
|
||||
const isTablet = useIsTablet();
|
||||
const dimensions = useWindowDimensions();
|
||||
const style = getStyleFromTheme(theme);
|
||||
const {height: deviceHeight} = useWindowDimensions();
|
||||
const {defaultHeight: headerHeight} = useHeaderHeight(false, true, false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
|
||||
// const [showingAtMention, setShowingAtMention] = useState(false);
|
||||
// const [showingChannelMention, setShowingChannelMention] = useState(false);
|
||||
const [showingChannelMention, setShowingChannelMention] = useState(false);
|
||||
const [showingEmoji, setShowingEmoji] = useState(false);
|
||||
|
||||
// const [showingCommand, setShowingCommand] = useState(false);
|
||||
// const [showingAppCommand, setShowingAppCommand] = useState(false);
|
||||
// const [showingDate, setShowingDate] = useState(false);
|
||||
|
||||
const hasElements = showingEmoji; // || showingAtMention || showingChannelMention || showingCommand || showingAppCommand || showingDate;
|
||||
const hasElements = showingChannelMention || showingEmoji; // || showingAtMention || showingCommand || showingAppCommand || showingDate;
|
||||
const appsTakeOver = false; // showingAppCommand;
|
||||
|
||||
const maxListHeight = useMemo(() => {
|
||||
const postInputHeight = deviceHeight - postInputTop;
|
||||
let offset = 0;
|
||||
if (Platform.OS === 'ios' && isTablet) {
|
||||
offset = OFFSET_IPAD;
|
||||
const isLandscape = dimensions.width > dimensions.height;
|
||||
const offset = isTablet && isLandscape ? OFFSET_TABLET : 0;
|
||||
let postInputDiff = 0;
|
||||
if (isTablet && postInputTop && isLandscape) {
|
||||
postInputDiff = MAX_LIST_TABLET_DIFF;
|
||||
} else if (postInputTop) {
|
||||
postInputDiff = MAX_LIST_DIFF;
|
||||
}
|
||||
|
||||
if (keyboardHeight) {
|
||||
return (deviceHeight - (postInputHeight + headerHeight + AUTOCOMPLETE_MARGIN + offset));
|
||||
}
|
||||
return (deviceHeight - (postInputHeight + headerHeight + AUTOCOMPLETE_MARGIN + offset)) / 2;
|
||||
}, [postInputTop, deviceHeight, headerHeight, isTablet]); // We don't depend on keyboardHeight to avoid visual artifacts due to postInputTop and keyboardHeight not being updated in the same render.
|
||||
return MAX_LIST_HEIGHT - postInputDiff - offset;
|
||||
}, [postInputTop, isTablet, dimensions.width]);
|
||||
|
||||
const wrapperStyles = useMemo(() => {
|
||||
const s = [];
|
||||
if (Platform.OS === 'ios') {
|
||||
s.push(style.shadow);
|
||||
}
|
||||
|
||||
if (isSearch) {
|
||||
s.push(style.base, style.searchContainer, {height: maxListHeight});
|
||||
}
|
||||
|
||||
if (!hasElements) {
|
||||
s.push(style.hidden);
|
||||
}
|
||||
@@ -133,26 +125,14 @@ const Autocomplete = ({
|
||||
const containerStyles = useMemo(() => {
|
||||
const s = [style.borders];
|
||||
if (!isSearch) {
|
||||
s.push(style.base, {bottom: offsetY});
|
||||
const offset = isTablet ? -OFFSET_TABLET : 0;
|
||||
s.push(style.base, {bottom: postInputTop + LIST_BOTTOM + offset});
|
||||
}
|
||||
if (!hasElements) {
|
||||
s.push(style.hidden);
|
||||
}
|
||||
return s;
|
||||
}, [!isSearch && offsetY, hasElements]);
|
||||
|
||||
useEffect(() => {
|
||||
const keyboardEvent = (event: KeyboardEvent) => {
|
||||
setKeyboardHeight(event.endCoordinates.height);
|
||||
};
|
||||
const shown = Keyboard.addListener('keyboardDidShow', keyboardEvent);
|
||||
const hidden = Keyboard.addListener('keyboardDidHide', keyboardEvent);
|
||||
|
||||
return () => {
|
||||
shown.remove();
|
||||
hidden.remove();
|
||||
};
|
||||
}, []);
|
||||
}, [!isSearch, isTablet, hasElements, postInputTop]);
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -179,15 +159,16 @@ const Autocomplete = ({
|
||||
onResultCountChange={setShowingAtMention}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={nestedScrollEnabled}
|
||||
/>
|
||||
/> */}
|
||||
<ChannelMention
|
||||
cursorPosition={cursorPosition}
|
||||
maxListHeight={maxListHeight}
|
||||
updateValue={updateValue}
|
||||
onResultCountChange={setShowingChannelMention}
|
||||
onShowingChange={setShowingChannelMention}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={nestedScrollEnabled}
|
||||
/> */}
|
||||
isSearch={isSearch}
|
||||
/>
|
||||
{!isSearch &&
|
||||
<EmojiSuggestion
|
||||
cursorPosition={cursorPosition}
|
||||
|
||||
74
app/components/autocomplete/autocomplete_section_header.tsx
Normal file
74
app/components/autocomplete/autocomplete_section_header.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {ActivityIndicator, View} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
section: {
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
top: -1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
sectionText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||
paddingTop: 16,
|
||||
paddingBottom: 8,
|
||||
paddingHorizontal: 16,
|
||||
flex: 1,
|
||||
},
|
||||
sectionWrapper: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type Props = {
|
||||
defaultMessage: string;
|
||||
id: string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const AutocompleteSectionHeader = ({
|
||||
defaultMessage,
|
||||
id,
|
||||
loading,
|
||||
}: Props) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
const sectionStyles = useMemo(() => {
|
||||
return [style.section, {marginLeft: insets.left, marginRight: insets.right}];
|
||||
}, [style, insets]);
|
||||
|
||||
return (
|
||||
<View style={style.sectionWrapper}>
|
||||
<View style={sectionStyles}>
|
||||
<FormattedText
|
||||
id={id}
|
||||
defaultMessage={defaultMessage}
|
||||
style={style.sectionText}
|
||||
/>
|
||||
{loading &&
|
||||
<ActivityIndicator
|
||||
color={theme.centerChannelColor}
|
||||
size='small'
|
||||
/>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutocompleteSectionHeader;
|
||||
315
app/components/autocomplete/channel_mention/channel_mention.tsx
Normal file
315
app/components/autocomplete/channel_mention/channel_mention.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {debounce} from 'lodash';
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {Platform, SectionList, SectionListData} from 'react-native';
|
||||
|
||||
import {searchChannels} from '@actions/remote/channel';
|
||||
import AutocompleteSectionHeader from '@components/autocomplete/autocomplete_section_header';
|
||||
import ChannelMentionItem from '@components/autocomplete/channel_mention_item';
|
||||
import {General} from '@constants';
|
||||
import {CHANNEL_MENTION_REGEX, CHANNEL_MENTION_SEARCH_REGEX} from '@constants/autocomplete';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
import useDidUpdate from '@hooks/did_update';
|
||||
import {t} from '@i18n';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
|
||||
const keyExtractor = (item: Channel) => {
|
||||
return item.id;
|
||||
};
|
||||
|
||||
const getMatchTermForChannelMention = (() => {
|
||||
let lastMatchTerm: string | null = null;
|
||||
let lastValue: string;
|
||||
let lastIsSearch: boolean;
|
||||
return (value: string, isSearch: boolean) => {
|
||||
if (value !== lastValue || isSearch !== lastIsSearch) {
|
||||
const regex = isSearch ? CHANNEL_MENTION_SEARCH_REGEX : CHANNEL_MENTION_REGEX;
|
||||
const match = value.match(regex);
|
||||
lastValue = value;
|
||||
lastIsSearch = isSearch;
|
||||
if (match) {
|
||||
if (isSearch) {
|
||||
lastMatchTerm = match[1].toLowerCase();
|
||||
} else if (match.index && match.index > 0 && value[match.index - 1] === '~') {
|
||||
lastMatchTerm = null;
|
||||
} else {
|
||||
lastMatchTerm = match[2].toLowerCase();
|
||||
}
|
||||
} else {
|
||||
lastMatchTerm = null;
|
||||
}
|
||||
}
|
||||
return lastMatchTerm;
|
||||
};
|
||||
})();
|
||||
|
||||
const reduceChannelsForSearch = (channels: Channel[], members: MyChannelModel[]) => {
|
||||
return channels.reduce<Channel[][]>(([pubC, priC, dms], c) => {
|
||||
switch (c.type) {
|
||||
case General.OPEN_CHANNEL:
|
||||
if (members.find((m) => c.id === m.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: Channel[], members: MyChannelModel[]) => {
|
||||
return channels.reduce<Channel[][]>(([myC, otherC], c) => {
|
||||
if (members.find((m) => c.id === m.id)) {
|
||||
myC.push(c);
|
||||
} else {
|
||||
otherC.push(c);
|
||||
}
|
||||
return [myC, otherC];
|
||||
}, [[], []]);
|
||||
};
|
||||
|
||||
const makeSections = (channels: Channel[], myMembers: MyChannelModel[], isSearch = false) => {
|
||||
const newSections = [];
|
||||
if (isSearch) {
|
||||
const [publicChannels, privateChannels, directAndGroupMessages] = reduceChannelsForSearch(channels, myMembers);
|
||||
if (publicChannels.length) {
|
||||
newSections.push({
|
||||
id: t('suggestion.search.public'),
|
||||
defaultMessage: 'Public Channels',
|
||||
data: publicChannels,
|
||||
key: 'publicChannels',
|
||||
hideLoadingIndicator: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (privateChannels.length) {
|
||||
newSections.push({
|
||||
id: t('suggestion.search.private'),
|
||||
defaultMessage: 'Private Channels',
|
||||
data: privateChannels,
|
||||
key: 'privateChannels',
|
||||
hideLoadingIndicator: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (directAndGroupMessages.length) {
|
||||
newSections.push({
|
||||
id: t('suggestion.search.direct'),
|
||||
defaultMessage: 'Direct Messages',
|
||||
data: directAndGroupMessages,
|
||||
key: 'directAndGroupMessages',
|
||||
hideLoadingIndicator: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const [myChannels, otherChannels] = reduceChannelsForAutocomplete(channels, myMembers);
|
||||
if (myChannels.length) {
|
||||
newSections.push({
|
||||
id: t('suggestion.mention.channels'),
|
||||
defaultMessage: 'My Channels',
|
||||
data: myChannels,
|
||||
key: 'myChannels',
|
||||
hideLoadingIndicator: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (otherChannels.length) {
|
||||
newSections.push({
|
||||
id: t('suggestion.mention.morechannels'),
|
||||
defaultMessage: 'Other Channels',
|
||||
data: otherChannels,
|
||||
key: 'otherChannels',
|
||||
hideLoadingIndicator: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const nSections = newSections.length;
|
||||
if (nSections) {
|
||||
newSections[nSections - 1].hideLoadingIndicator = false;
|
||||
}
|
||||
|
||||
return newSections;
|
||||
};
|
||||
type Props = {
|
||||
cursorPosition: number;
|
||||
isSearch: boolean;
|
||||
maxListHeight: number;
|
||||
myMembers: MyChannelModel[];
|
||||
updateValue: (v: string) => void;
|
||||
onShowingChange: (c: boolean) => void;
|
||||
value: string;
|
||||
nestedScrollEnabled: boolean;
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
return {
|
||||
listView: {
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
borderRadius: 4,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const ChannelMention = ({
|
||||
cursorPosition,
|
||||
isSearch,
|
||||
maxListHeight,
|
||||
myMembers,
|
||||
updateValue,
|
||||
onShowingChange,
|
||||
value,
|
||||
nestedScrollEnabled,
|
||||
}: Props) => {
|
||||
const serverUrl = useServerUrl();
|
||||
const theme = useTheme();
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
const [sections, setSections] = useState<Array<SectionListData<Channel>>>([]);
|
||||
const [channels, setChannels] = useState<Channel[]>([]);
|
||||
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 listStyle = useMemo(() =>
|
||||
[style.listView, {maxHeight: maxListHeight}]
|
||||
, [style, maxListHeight]);
|
||||
|
||||
const runSearch = useMemo(() => debounce(async (sUrl: string, term: string) => {
|
||||
setLoading(true);
|
||||
const {channels: receivedChannels, error} = await searchChannels(sUrl, term);
|
||||
if (!error) {
|
||||
setChannels(receivedChannels!);
|
||||
}
|
||||
setLoading(false);
|
||||
}, 200), []);
|
||||
|
||||
const matchTerm = getMatchTermForChannelMention(value.substring(0, localCursorPosition), isSearch);
|
||||
const resetState = () => {
|
||||
setChannels([]);
|
||||
setSections([]);
|
||||
runSearch.cancel();
|
||||
};
|
||||
|
||||
const completeMention = useCallback((mention: string) => {
|
||||
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);
|
||||
setNoResultsTerm(mention);
|
||||
setSections([]);
|
||||
}, [value, localCursorPosition, isSearch]);
|
||||
|
||||
const renderItem = useCallback(({item}) => {
|
||||
return (
|
||||
<ChannelMentionItem
|
||||
channel={item}
|
||||
onPress={completeMention}
|
||||
testID={`autocomplete.channel_mention.item.${item}`}
|
||||
/>
|
||||
);
|
||||
}, [completeMention]);
|
||||
|
||||
const renderSectionHeader = useCallback(({section}) => {
|
||||
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);
|
||||
runSearch(serverUrl, matchTerm);
|
||||
}, [matchTerm]);
|
||||
|
||||
useDidUpdate(() => {
|
||||
const newSections = makeSections(channels, myMembers, isSearch);
|
||||
const nSections = newSections.length;
|
||||
|
||||
if (!loading && !nSections && noResultsTerm == null) {
|
||||
setNoResultsTerm(matchTerm);
|
||||
}
|
||||
setSections(newSections);
|
||||
onShowingChange(Boolean(nSections));
|
||||
}, [channels, myMembers, loading]);
|
||||
|
||||
if (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='channel_mention_suggestion.list'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelMention;
|
||||
21
app/components/autocomplete/channel_mention/index.ts
Normal file
21
app/components/autocomplete/channel_mention/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
|
||||
import ChannelMention from './channel_mention';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type MyChannelModel from '@typings/database/models/servers/my_channel';
|
||||
|
||||
const {SERVER: {MY_CHANNEL}} = MM_TABLES;
|
||||
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||
return {
|
||||
myMembers: database.get<MyChannelModel>(MY_CHANNEL).query().observe(),
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(ChannelMention));
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useMemo} from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
||||
import ChannelIcon from '@app/components/channel_icon';
|
||||
import {BotTag, GuestTag} from '@components/tag';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {General} from '@constants';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
icon: {
|
||||
marginRight: 11,
|
||||
opacity: 0.56,
|
||||
},
|
||||
row: {
|
||||
paddingHorizontal: 16,
|
||||
height: 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
rowDisplayName: {
|
||||
fontSize: 15,
|
||||
color: theme.centerChannelColor,
|
||||
},
|
||||
rowName: {
|
||||
fontSize: 15,
|
||||
color: theme.centerChannelColor,
|
||||
opacity: 0.56,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type Props = {
|
||||
channel: Channel;
|
||||
displayName?: string;
|
||||
isBot: boolean;
|
||||
isGuest: boolean;
|
||||
onPress: (name?: string) => void;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
const ChannelMentionItem = ({
|
||||
channel,
|
||||
displayName,
|
||||
isBot,
|
||||
isGuest,
|
||||
onPress,
|
||||
testID,
|
||||
}: Props) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const theme = useTheme();
|
||||
|
||||
const completeMention = () => {
|
||||
if (channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL) {
|
||||
onPress('@' + displayName?.replace(/ /g, ''));
|
||||
} else {
|
||||
onPress(channel.name);
|
||||
}
|
||||
};
|
||||
|
||||
const style = getStyleFromTheme(theme);
|
||||
const margins = useMemo(() => {
|
||||
return {marginLeft: insets.left, marginRight: insets.right};
|
||||
}, [insets]);
|
||||
const rowStyle = useMemo(() => {
|
||||
return [style.row, margins];
|
||||
}, [margins, style]);
|
||||
|
||||
let component;
|
||||
|
||||
if (channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL) {
|
||||
if (!displayName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
component = (
|
||||
<TouchableWithFeedback
|
||||
key={channel.id}
|
||||
onPress={completeMention}
|
||||
style={rowStyle}
|
||||
testID={testID}
|
||||
type={'opacity'}
|
||||
>
|
||||
<Text style={style.rowDisplayName}>{'@' + displayName}</Text>
|
||||
<BotTag
|
||||
show={isBot}
|
||||
/>
|
||||
<GuestTag
|
||||
show={isGuest}
|
||||
/>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
} else {
|
||||
component = (
|
||||
<TouchableWithFeedback
|
||||
key={channel.id}
|
||||
onPress={completeMention}
|
||||
style={margins}
|
||||
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
|
||||
testID={testID}
|
||||
type={'native'}
|
||||
>
|
||||
<View style={style.row}>
|
||||
<ChannelIcon
|
||||
name={channel.name}
|
||||
shared={channel.shared}
|
||||
type={channel.type}
|
||||
isInfo={true}
|
||||
isArchived={channel.delete_at > 0}
|
||||
size={18}
|
||||
style={style.icon}
|
||||
/>
|
||||
<Text style={style.rowDisplayName}>{displayName}</Text>
|
||||
<Text style={style.rowName}>{` ~${channel.name}`}</Text>
|
||||
</View>
|
||||
</TouchableWithFeedback>
|
||||
);
|
||||
}
|
||||
|
||||
return component;
|
||||
};
|
||||
|
||||
export default ChannelMentionItem;
|
||||
39
app/components/autocomplete/channel_mention_item/index.ts
Normal file
39
app/components/autocomplete/channel_mention_item/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||
import withObservables from '@nozbe/with-observables';
|
||||
import {of as of$} from 'rxjs';
|
||||
import {switchMap} from 'rxjs/operators';
|
||||
|
||||
import {General} from '@constants';
|
||||
import {MM_TABLES} from '@constants/database';
|
||||
|
||||
import ChannelMentionItem from './channel_mention_item';
|
||||
|
||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||
import type UserModel from '@typings/database/models/servers/user';
|
||||
|
||||
type OwnProps = {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
const {SERVER: {USER}} = MM_TABLES;
|
||||
const enhanced = withObservables([], ({database, channel}: WithDatabaseArgs & OwnProps) => {
|
||||
let user = of$<UserModel | undefined>(undefined);
|
||||
if (channel.type === General.DM_CHANNEL) {
|
||||
user = database.get<UserModel>(USER).findAndObserve(channel.teammate_id!);
|
||||
}
|
||||
|
||||
const isBot = user.pipe(switchMap((u) => of$(u ? u.isBot : false)));
|
||||
const isGuest = user.pipe(switchMap((u) => of$(u ? u.isGuest : false)));
|
||||
const displayName = user.pipe(switchMap((u) => of$(u ? u.username : channel.display_name)));
|
||||
|
||||
return {
|
||||
isBot,
|
||||
isGuest,
|
||||
displayName,
|
||||
};
|
||||
});
|
||||
|
||||
export default withDatabase(enhanced(ChannelMentionItem));
|
||||
@@ -49,7 +49,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
listView: {
|
||||
paddingTop: 16,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
borderRadius: 4,
|
||||
borderRadius: 8,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
@@ -167,6 +167,7 @@ const EmojiSuggestion = ({
|
||||
|
||||
const renderItem = useCallback(({item}: {item: string}) => {
|
||||
const completeItemSuggestion = () => completeSuggestion(item);
|
||||
|
||||
return (
|
||||
<TouchableWithFeedback
|
||||
onPress={completeItemSuggestion}
|
||||
@@ -202,6 +203,10 @@ const EmojiSuggestion = ({
|
||||
};
|
||||
}, [searchTerm]);
|
||||
|
||||
if (!data.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
testID='emoji_suggestion.list'
|
||||
|
||||
@@ -95,7 +95,7 @@ export default function DraftInput({
|
||||
const theme = useTheme();
|
||||
|
||||
const handleLayout = useCallback((e: LayoutChangeEvent) => {
|
||||
updatePostInputTop(e.nativeEvent.layout.y);
|
||||
updatePostInputTop(e.nativeEvent.layout.height);
|
||||
}, []);
|
||||
|
||||
// Render
|
||||
|
||||
@@ -112,7 +112,6 @@ export default function PostDraft({
|
||||
updateValue={setValue}
|
||||
rootId={rootId}
|
||||
channelId={channelId}
|
||||
offsetY={0}
|
||||
cursorPosition={cursorPosition}
|
||||
value={value}
|
||||
isSearch={isSearch}
|
||||
@@ -123,17 +122,14 @@ export default function PostDraft({
|
||||
if (Platform.OS === 'android') {
|
||||
return (
|
||||
<>
|
||||
{autoComplete}
|
||||
{draftHandler}
|
||||
{autoComplete}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View nativeID={accessoriesContainerID}>
|
||||
{autoComplete}
|
||||
</View>
|
||||
<KeyboardTrackingView
|
||||
accessoriesContainerID={accessoriesContainerID}
|
||||
ref={keyboardTracker}
|
||||
@@ -142,6 +138,9 @@ export default function PostDraft({
|
||||
>
|
||||
{draftHandler}
|
||||
</KeyboardTrackingView>
|
||||
<View nativeID={accessoriesContainerID}>
|
||||
{autoComplete}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -265,7 +265,6 @@ export default function PostInput({
|
||||
// May change when we implement Fabric
|
||||
input.current?.setNativeProps({
|
||||
text: value,
|
||||
selection: {start: cursorPosition},
|
||||
});
|
||||
lastNativeValue.current = value;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Platform} from 'react-native';
|
||||
|
||||
export const AT_MENTION_REGEX = /\B(@([^@\r\n]*))$/i;
|
||||
|
||||
export const AT_MENTION_REGEX_GLOBAL = /\B(@([^@\r\n]*))/gi;
|
||||
@@ -17,6 +19,13 @@ export const ALL_SEARCH_FLAGS_REGEX = /\b\w+:/g;
|
||||
|
||||
export const CODE_REGEX = /(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g;
|
||||
|
||||
export const LIST_BOTTOM = Platform.select({ios: 30, default: -5});
|
||||
|
||||
export const MAX_LIST_HEIGHT = 280;
|
||||
export const MAX_LIST_DIFF = 50;
|
||||
export const MAX_LIST_TABLET_DIFF = 140;
|
||||
export const OFFSET_TABLET = 35;
|
||||
|
||||
export default {
|
||||
ALL_SEARCH_FLAGS_REGEX,
|
||||
AT_MENTION_REGEX,
|
||||
@@ -26,4 +35,8 @@ export default {
|
||||
CHANNEL_MENTION_SEARCH_REGEX,
|
||||
CODE_REGEX,
|
||||
DATE_MENTION_SEARCH_REGEX,
|
||||
LIST_BOTTOM,
|
||||
MAX_LIST_HEIGHT,
|
||||
MAX_LIST_DIFF,
|
||||
OFFSET_TABLET,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user