[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:
Daniel Espino García
2022-03-03 11:01:12 +01:00
committed by GitHub
parent 684d9421a7
commit 8e026c20ac
11 changed files with 629 additions and 54 deletions

View File

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

View 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;

View 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;

View 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));

View File

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

View 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));

View File

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

View File

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

View File

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

View File

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

View File

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