Compare commits

..

13 Commits

Author SHA1 Message Date
Jason Frerich
2c4195d1ed prep for performing the addmembers command 2022-10-26 14:46:15 -05:00
Jason Frerich
e0a279e155 update icon
update button text
2022-10-26 13:24:54 -05:00
Jason Frerich
ae819ec763 hook up add people screen 2022-10-26 13:16:05 -05:00
Jason Frerich
a7ae4cef41 create members_modal screen and reference from create_direct_message and
add_members screens
2022-10-26 13:02:04 -05:00
Jason Frerich
e4d8e61bb2 get db values in membersModal index instead of passing through as props 2022-10-26 12:24:59 -05:00
Jason Frerich
5289f151b4 add props for max and warn users counts 2022-10-26 12:19:42 -05:00
Jason Frerich
6f5861d441 extract MembersModal to its own component so that Add Members, Create
DM/GM, and ManageMembers components will not have to manage the search
and selection of profiles
2022-10-26 11:10:30 -05:00
Jason Frerich
af7558cc6e make onPress required and remove the callback wrapper 2022-10-25 08:57:04 -05:00
Jason Frerich
3715c5cbbe use all caps for constants 2022-10-25 08:50:56 -05:00
Jason Frerich
c7e4dc992d - sort style attributes
- update shadow values
2022-10-24 20:22:34 -05:00
Jason Frerich
fa497f6e3c - remove navigator Search Button and add button to bottom panel
- add margin when bottom sheet button has an icon
- add button with onPress, buttonIcon, and buttonText props
2022-10-24 19:36:08 -05:00
Jason Frerich
42f34727ec formating updates 2022-10-24 16:25:42 -05:00
Jason Frerich
dfb4a53a2d reformat comments and remove apostraphe so syntax highlighting works 2022-10-24 10:27:34 -05:00
27 changed files with 760 additions and 796 deletions

159
.gitpod.Dockerfile vendored
View File

@@ -1,159 +0,0 @@
FROM gitpod/workspace-full-vnc
ENV CYPRESS_CACHE_FOLDER=/workspace/.cypress-cache
# Install Cypress dependencies.
RUN sudo apt-get update \
&& sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
libgtk2.0-0 \
libgtk-3-0 \
libnotify-dev \
libgconf-2-4 \
libnss3 \
libxss1 \
libasound2 \
libxtst6 \
xauth \
xvfb \
&& sudo rm -rf /var/lib/apt/lists/*
RUN mkdir -p /workspace/persist/.cache/go-build
ENV GOCACHE=/workspace/persist/.cache/go-build
ENV MM_SERVICESETTINGS_ENABLEDEVELOPER=true
# Copied from https://github.com/react-native-community/docker-android/blob/master/Dockerfile
LABEL Description="This image provides a base Android development environment for React Native, and may be used to run tests."
ENV DEBIAN_FRONTEND=noninteractive
# set default build arguments
# https://developer.android.com/studio#command-tools
ARG SDK_VERSION=commandlinetools-linux-8512546_latest.zip
ARG ANDROID_BUILD_VERSION=31
ARG ANDROID_TOOLS_VERSION=31.0.0
ARG BUCK_VERSION=2022.05.05.01
# Buck doesn't support versions beyond NDK 21
# Therefore we need to diverge the NDK version and set NDK_HOME
# for Buck to pick it up correctly.
ARG NDK_VERSION_BUCK=21.4.7075529
ARG NDK_VERSION_GRADLE=23.1.7779620
ARG NODE_VERSION=14.x
ARG WATCHMAN_VERSION=4.9.0
ARG CMAKE_VERSION=3.18.1
# set default environment variables, please don't remove old env for compatibilty issue
ENV ADB_INSTALL_TIMEOUT=10
ENV ANDROID_HOME=/opt/android
ENV ANDROID_SDK_ROOT=${ANDROID_HOME}
ENV ANDROID_NDK_BUCK=${ANDROID_HOME}/ndk/$NDK_VERSION_BUCK
ENV ANDROID_NDK_GRADLE=${ANDROID_HOME}/ndk/$NDK_VERSION_GRADLE
# this is needed for Buck to be able to recognize NDK 21
ENV NDK_HOME=${ANDROID_HOME}/ndk/$NDK_VERSION_BUCK
ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64
ENV CMAKE_BIN_PATH=${ANDROID_HOME}/cmake/$CMAKE_VERSION/bin
ENV PATH=${CMAKE_BIN_PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/emulator:${ANDROID_HOME}/platform-tools:${ANDROID_HOME}/tools:${ANDROID_HOME}/tools/bin:/opt/buck/bin/:${PATH}
# Install system dependencies
RUN apt update -qq && apt install -qq -y --no-install-recommends \
apt-transport-https \
curl \
file \
gcc \
git \
g++ \
gnupg2 \
libc++1-10 \
libgl1 \
libtcmalloc-minimal4 \
make \
openjdk-11-jdk-headless \
openssh-client \
patch \
python3 \
python3-distutils \
rsync \
ruby \
ruby-dev \
tzdata \
unzip \
sudo \
ninja-build \
zip \
# Dev libraries requested by Hermes
libicu-dev \
# Emulator & video bridge dependencies
libc6 \
libdbus-1-3 \
libfontconfig1 \
libgcc1 \
libpulse0 \
libtinfo5 \
libx11-6 \
libxcb1 \
libxdamage1 \
libnss3 \
libxcomposite1 \
libxcursor1 \
libxi6 \
libxext6 \
libxfixes3 \
zlib1g \
libgl1 \
pulseaudio \
socat \
&& gem install bundler \
&& rm -rf /var/lib/apt/lists/*;
# install nodejs and yarn packages from nodesource
RUN curl -sL https://deb.nodesource.com/setup_${NODE_VERSION} | bash - \
&& apt-get update -qq \
&& apt-get install -qq -y --no-install-recommends nodejs \
&& npm i -g yarn \
&& rm -rf /var/lib/apt/lists/*
# download and install buck using the java11 pex from Jitpack
RUN curl -L https://jitpack.io/com/github/facebook/buck/v${BUCK_VERSION}/buck-v${BUCK_VERSION}-java11.pex -o /tmp/buck.pex \
&& mv /tmp/buck.pex /usr/local/bin/buck \
&& chmod +x /usr/local/bin/buck
# Full reference at https://dl.google.com/android/repository/repository2-1.xml
# download and unpack android
# workaround buck clang version detection by symlinking
RUN curl -sS https://dl.google.com/android/repository/${SDK_VERSION} -o /tmp/sdk.zip \
&& mkdir -p ${ANDROID_HOME}/cmdline-tools \
&& unzip -q -d ${ANDROID_HOME}/cmdline-tools /tmp/sdk.zip \
&& mv ${ANDROID_HOME}/cmdline-tools/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest \
&& rm /tmp/sdk.zip \
&& yes | sdkmanager --licenses \
&& yes | sdkmanager "platform-tools" \
"emulator" \
"platforms;android-$ANDROID_BUILD_VERSION" \
"build-tools;$ANDROID_TOOLS_VERSION" \
"cmake;$CMAKE_VERSION" \
"system-images;android-21;google_apis;armeabi-v7a" \
"ndk;$NDK_VERSION_BUCK" \
"ndk;$NDK_VERSION_GRADLE" \
&& rm -rf ${ANDROID_HOME}/.android \
&& chmod 777 -R /opt/android \
&& ln -s ${ANDROID_NDK_BUCK}/toolchains/llvm/prebuilt/linux-x86_64/lib64/clang/9.0.9 ${ANDROID_NDK_BUCK}/toolchains/llvm/prebuilt/linux-x86_64/lib64/clang/9.0.8
# Copied from https://github.com/gengjiawen/ci-sample/blob/master/.gitpod.Dockerfile
# FROM reactnativecommunity/react-native-android
### Gitpod user ###
# '-l': see https://docs.docker.com/develop/develop-images/dockerfile_best-practices/#user
RUN useradd -l -u 33333 -G sudo -md /home/gitpod -s /bin/bash -p gitpod gitpod \
# passwordless sudo for users in the 'sudo' group
&& sed -i.bkp -e 's/%sudo\s\+ALL=(ALL\(:ALL\)\?)\s\+ALL/%sudo ALL=NOPASSWD:ALL/g' /etc/sudoers
# Install custom tools, runtimes, etc.
# For example "bastet", a command-line tetris clone:
# RUN brew install bastet
#
# More information: https://www.gitpod.io/docs/config-docker/

View File

@@ -1,6 +0,0 @@
image:
file: .gitpod.Dockerfile
tasks:
- init: npm install
- command: npm run android

View File

@@ -1056,7 +1056,7 @@ export async function switchToLastChannel(serverUrl: string, teamId?: string) {
}
}
export async function searchChannels(serverUrl: string, term: string, teamId: string, isSearch = false) {
export async function searchChannels(serverUrl: string, term: string, isSearch = false) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
@@ -1070,8 +1070,9 @@ export async function searchChannels(serverUrl: string, term: string, teamId: st
}
try {
const currentTeamId = await getCurrentTeamId(database);
const autoCompleteFunc = isSearch ? client.autocompleteChannelsForSearch : client.autocompleteChannels;
const channels = await autoCompleteFunc(teamId, term);
const channels = await autoCompleteFunc(currentTeamId, term);
return {channels};
} catch (error) {
return {error};

View File

@@ -15,7 +15,7 @@ import {debounce} from '@helpers/api/general';
import NetworkManager from '@managers/network_manager';
import {getMembersCountByChannelsId, queryChannelsByTypes} from '@queries/servers/channel';
import {queryGroupsByNames} from '@queries/servers/group';
import {getConfig, getCurrentUserId} from '@queries/servers/system';
import {getConfig, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system';
import {getCurrentUser, prepareUsers, queryAllUsers, queryUsersById, queryUsersByIdsOrUsernames, queryUsersByUsername} from '@queries/servers/user';
import {logError} from '@utils/log';
import {getDeviceTimezone, isTimezoneEnabled} from '@utils/timezone';
@@ -818,7 +818,7 @@ export const uploadUserProfileImage = async (serverUrl: string, localPath: strin
return {error: undefined};
};
export const searchUsers = async (serverUrl: string, term: string, teamId: string, channelId?: string) => {
export const searchUsers = async (serverUrl: string, term: string, channelId?: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
@@ -832,7 +832,8 @@ export const searchUsers = async (serverUrl: string, term: string, teamId: strin
}
try {
const users = await client.autocompleteUsers(term, teamId, channelId);
const currentTeamId = await getCurrentTeamId(database);
const users = await client.autocompleteUsers(term, currentTeamId, channelId);
return {users};
} catch (error) {
return {error};

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, SectionList, SectionListData, SectionListRenderItemInfo, StyleProp, ViewStyle} from 'react-native';
import {searchGroupsByName, searchGroupsByNameInChannel, searchGroupsByNameInTeam} from '@actions/local/group';
@@ -175,35 +175,9 @@ const makeSections = (teamMembers: Array<UserProfile | UserModel>, usersInChanne
return newSections;
};
const searchGroups = async (serverUrl: string, matchTerm: string, useGroupMentions: boolean, isChannelConstrained: boolean, isTeamConstrained: boolean, channelId?: string, teamId?: string) => {
try {
if (useGroupMentions && matchTerm && matchTerm !== '') {
let g = emptyGroupList;
if (isChannelConstrained) {
// If the channel is constrained, we only show groups for that channel
if (channelId) {
g = await searchGroupsByNameInChannel(serverUrl, matchTerm, channelId);
}
} else if (isTeamConstrained) {
// If there is no channel constraint, but a team constraint - only show groups for team
g = await searchGroupsByNameInTeam(serverUrl, matchTerm, teamId!);
} else {
// No constraints? Search all groups
g = await searchGroupsByName(serverUrl, matchTerm || '');
}
return g.length ? g : emptyGroupList;
}
return emptyGroupList;
} catch (error) {
return emptyGroupList;
}
};
type Props = {
channelId?: string;
teamId: string;
teamId?: string;
cursorPosition: number;
isSearch: boolean;
updateValue: (v: string) => void;
@@ -258,22 +232,9 @@ const AtMention = ({
const [localUsers, setLocalUsers] = useState<UserModel[]>();
const [filteredLocalUsers, setFilteredLocalUsers] = useState(emptyUserlList);
const latestSearchAt = useRef(0);
const runSearch = useMemo(() => debounce(async (sUrl: string, term: string, groupMentions: boolean, channelConstrained: boolean, teamConstrained: boolean, tId: string, cId?: string) => {
const searchAt = Date.now();
latestSearchAt.current = searchAt;
const [{users: receivedUsers, error}, groupsResult] = await Promise.all([
searchUsers(sUrl, term, tId, cId),
searchGroups(sUrl, term, groupMentions, channelConstrained, teamConstrained, cId, tId),
]);
if (latestSearchAt.current > searchAt) {
return;
}
setGroups(groupsResult);
const runSearch = useMemo(() => debounce(async (sUrl: string, term: string, cId?: string) => {
setLoading(true);
const {users: receivedUsers, error} = await searchUsers(sUrl, term, cId);
setUseLocal(Boolean(error));
if (error) {
@@ -282,10 +243,6 @@ const AtMention = ({
fallbackUsers = await getAllUsers(sUrl);
setLocalUsers(fallbackUsers);
}
if (latestSearchAt.current > searchAt) {
return;
}
const filteredUsers = filterResults(fallbackUsers, term);
setFilteredLocalUsers(filteredUsers.length ? filteredUsers : emptyUserlList);
} else if (receivedUsers) {
@@ -313,12 +270,8 @@ const AtMention = ({
const resetState = () => {
setUsersInChannel(emptyUserlList);
setUsersOutOfChannel(emptyUserlList);
setGroups(emptyGroupList);
setFilteredLocalUsers(emptyUserlList);
setSections(emptySectionList);
setNoResultsTerm(null);
latestSearchAt.current = Date.now();
setLoading(false);
runSearch.cancel();
};
@@ -332,7 +285,7 @@ const AtMention = ({
completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `);
}
const newCursorPosition = completedDraft.length;
const newCursorPosition = completedDraft.length - 1;
if (value.length > cursorPosition) {
completedDraft += value.substring(cursorPosition);
@@ -344,7 +297,6 @@ const AtMention = ({
onShowingChange(false);
setNoResultsTerm(mention);
setSections(emptySectionList);
latestSearchAt.current = Date.now();
}, [value, localCursorPosition, isSearch]);
const renderSpecialMentions = useCallback((item: SpecialMention) => {
@@ -409,6 +361,39 @@ const AtMention = ({
}
}, [cursorPosition]);
useEffect(() => {
if (useGroupMentions && matchTerm && matchTerm !== '') {
// If the channel is constrained, we only show groups for that channel
if (isChannelConstrained && channelId) {
searchGroupsByNameInChannel(serverUrl, matchTerm, channelId).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) {
searchGroupsByNameInTeam(serverUrl, matchTerm, teamId!).then((g) => {
setGroups(g.length ? g : emptyGroupList);
}).catch(() => {
setGroups(emptyGroupList);
});
}
// No constraints? Search all groups
if (!isTeamConstrained && !isChannelConstrained) {
searchGroupsByName(serverUrl, matchTerm || '').then((g) => {
setGroups(Array.isArray(g) ? g : emptyGroupList);
}).catch(() => {
setGroups(emptyGroupList);
});
}
} else {
setGroups(emptyGroupList);
}
}, [matchTerm, useGroupMentions]);
useEffect(() => {
if (matchTerm === null) {
resetState();
@@ -421,14 +406,10 @@ const AtMention = ({
}
setNoResultsTerm(null);
setLoading(true);
runSearch(serverUrl, matchTerm, useGroupMentions, isChannelConstrained, isTeamConstrained, teamId, channelId);
}, [matchTerm, teamId, useGroupMentions, isChannelConstrained, isTeamConstrained]);
runSearch(serverUrl, matchTerm, channelId);
}, [matchTerm]);
useEffect(() => {
if (noResultsTerm && !loading) {
return;
}
const showSpecialMentions = useChannelMentions && matchTerm != null && checkSpecialMentions(matchTerm);
const buildMemberSection = isSearch || (!channelId && teamMembers.length > 0);
let newSections;

View File

@@ -18,11 +18,8 @@ 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;
teamId?: string;
}
const enhanced = withObservables(['teamId'], ({database, channelId, teamId}: WithDatabaseArgs & OwnProps) => {
type OwnProps = {channelId?: string}
const enhanced = withObservables([], ({database, channelId}: WithDatabaseArgs & OwnProps) => {
const currentUser = observeCurrentUser(database);
const hasLicense = observeLicense(database).pipe(
@@ -54,19 +51,20 @@ const enhanced = withObservables(['teamId'], ({database, channelId, teamId}: Wit
useGroupMentions = of$(false);
isChannelConstrained = of$(false);
isTeamConstrained = of$(false);
team = teamId ? observeTeam(database, teamId) : observeCurrentTeam(database);
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: team.pipe(switchMap((t) => of$(t?.id))),
teamId,
};
});

View File

@@ -61,7 +61,6 @@ type Props = {
availableSpace: SharedValue<number>;
inPost?: boolean;
growDown?: boolean;
teamId?: string;
containerStyle?: StyleProp<ViewStyle>;
}
@@ -82,7 +81,6 @@ const Autocomplete = ({
inPost = false,
growDown = false,
containerStyle,
teamId,
}: Props) => {
const theme = useTheme();
const isTablet = useIsTablet();
@@ -154,7 +152,6 @@ const Autocomplete = ({
nestedScrollEnabled={nestedScrollEnabled}
isSearch={isSearch}
channelId={channelId}
teamId={teamId}
/>
<ChannelMention
cursorPosition={cursorPosition}
@@ -164,8 +161,6 @@ const Autocomplete = ({
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
isSearch={isSearch}
channelId={channelId}
teamId={teamId}
/>
{!isSearch &&
<EmojiSuggestion

View File

@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {debounce} from 'lodash';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {Platform, SectionList, SectionListData, SectionListRenderItemInfo, StyleProp, ViewStyle} from 'react-native';
import {searchChannels} from '@actions/remote/channel';
@@ -11,8 +11,11 @@ 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 DatabaseManager from '@database/manager';
import useDidUpdate from '@hooks/did_update';
import {t} from '@i18n';
import {queryAllChannelsForTeam} from '@queries/servers/channel';
import {getCurrentTeamId} from '@queries/servers/system';
import {hasTrailingSpaces} from '@utils/helpers';
import type ChannelModel from '@typings/database/models/servers/channel';
@@ -22,6 +25,32 @@ 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: 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) => {
@@ -54,7 +83,7 @@ const reduceChannelsForAutocomplete = (channels: Array<Channel | ChannelModel>,
}, [[], []]);
};
const makeSections = (channels: Array<Channel | ChannelModel>, myMembers: MyChannelModel[], loading: boolean, isSearch = false) => {
const makeSections = (channels: Array<Channel | ChannelModel>, myMembers: MyChannelModel[], isSearch = false) => {
const newSections = [];
if (isSearch) {
const [publicChannels, privateChannels, directAndGroupMessages] = reduceChannelsForSearch(channels, myMembers);
@@ -99,7 +128,7 @@ const makeSections = (channels: Array<Channel | ChannelModel>, myMembers: MyChan
});
}
if (otherChannels.length || (!myChannels.length && loading)) {
if (otherChannels.length) {
newSections.push({
id: t('suggestion.mention.morechannels'),
defaultMessage: 'Other Channels',
@@ -135,11 +164,18 @@ type Props = {
value: string;
nestedScrollEnabled: boolean;
listStyle: StyleProp<ViewStyle>;
matchTerm: string;
localChannels: ChannelModel[];
teamId: string;
}
const getAllChannels = async (serverUrl: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return [];
}
const teamId = await getCurrentTeamId(database);
return queryAllChannelsForTeam(database, teamId).fetch();
};
const emptySections: Array<SectionListData<Channel>> = [];
const emptyChannels: Array<Channel | ChannelModel> = [];
@@ -152,45 +188,47 @@ const ChannelMention = ({
value,
nestedScrollEnabled,
listStyle,
matchTerm,
localChannels,
teamId,
}: Props) => {
const serverUrl = useServerUrl();
const [sections, setSections] = useState<Array<SectionListData<(Channel | ChannelModel)>>>(emptySections);
const [remoteChannels, setRemoteChannels] = useState<Array<ChannelModel | Channel>>(emptyChannels);
const [channels, setChannels] = 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 [useLocal, setUseLocal] = useState(true);
const [localChannels, setlocalChannels] = useState<ChannelModel[]>();
const [filteredLocalChannels, setFilteredLocalChannels] = useState(emptyChannels);
const latestSearchAt = useRef(0);
const runSearch = useMemo(() => debounce(async (sUrl: string, term: string) => {
setLoading(true);
const {channels: receivedChannels, error} = await searchChannels(sUrl, term, isSearch);
setUseLocal(Boolean(error));
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;
if (error) {
let fallbackChannels = localChannels;
if (!fallbackChannels) {
fallbackChannels = await getAllChannels(sUrl);
setlocalChannels(fallbackChannels);
}
const filteredChannels = filterResults(fallbackChannels, term);
setFilteredLocalChannels(filteredChannels.length ? filteredChannels : emptyChannels);
} else if (receivedChannels) {
let channelsToStore: Array<Channel | ChannelModel> = receivedChannels;
if (hasTrailingSpaces(term)) {
channelsToStore = filterResults(receivedChannels, term);
}
setChannels(channelsToStore.length ? channelsToStore : emptyChannels);
}
let channelsToStore: Array<Channel | ChannelModel> = receivedChannels || [];
if (hasTrailingSpaces(term)) {
channelsToStore = filterResults(receivedChannels || [], term);
}
setRemoteChannels(channelsToStore.length ? channelsToStore : emptyChannels);
setLoading(false);
}, 200), []);
const matchTerm = getMatchTermForChannelMention(value.substring(0, localCursorPosition), isSearch);
const resetState = () => {
latestSearchAt.current = Date.now();
setRemoteChannels(emptyChannels);
setFilteredLocalChannels(emptyChannels);
setChannels(emptyChannels);
setSections(emptySections);
setNoResultsTerm(null);
runSearch.cancel();
setLoading(false);
};
const completeMention = useCallback((mention: string) => {
@@ -226,11 +264,8 @@ const ChannelMention = ({
}
onShowingChange(false);
setLoading(false);
setNoResultsTerm(mention);
setSections(emptySections);
setRemoteChannels(emptyChannels);
latestSearchAt.current = Date.now();
}, [value, localCursorPosition, isSearch]);
const renderItem = useCallback(({item}: SectionListRenderItemInfo<Channel | ChannelModel>) => {
@@ -271,41 +306,21 @@ const ChannelMention = ({
}
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]);
runSearch(serverUrl, matchTerm);
}, [matchTerm]);
useDidUpdate(() => {
if (noResultsTerm && !loading) {
return;
}
const newSections = makeSections(channels, myMembers, loading, isSearch);
const newSections = makeSections(useLocal ? filteredLocalChannels : channels, myMembers, 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 (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;

View File

@@ -3,90 +3,17 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {CHANNEL_MENTION_REGEX, CHANNEL_MENTION_SEARCH_REGEX} from '@constants/autocomplete';
import {observeChannel, queryAllMyChannel, queryChannelsForAutocomplete} from '@queries/servers/channel';
import {observeCurrentTeamId} from '@queries/servers/system';
import {queryAllMyChannel} from '@queries/servers/channel';
import ChannelMention from './channel_mention';
import type {WithDatabaseArgs} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
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;
};
})();
type WithTeamIdProps = {
teamId?: string;
channelId?: string;
} & WithDatabaseArgs;
type OwnProps = {
value: string;
isSearch: boolean;
cursorPosition: number;
teamId: string;
} & WithDatabaseArgs;
const emptyChannelList: ChannelModel[] = [];
const withMembers = withObservables([], ({database}: WithDatabaseArgs) => {
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
myMembers: queryAllMyChannel(database).observe(),
};
});
const withTeamId = withObservables(['teamId', 'channelId'], ({teamId, channelId, database}: WithTeamIdProps) => {
let currentTeamId;
if (teamId) {
currentTeamId = of$(teamId);
} else if (channelId) {
currentTeamId = observeChannel(database, channelId).pipe(switchMap((c) => {
return c?.teamId ? of$(c.teamId) : observeCurrentTeamId(database);
}));
} else {
currentTeamId = observeCurrentTeamId(database);
}
return {
teamId: currentTeamId,
};
});
const enhanced = withObservables(['value', 'isSearch', 'teamId', 'cursorPosition'], ({value, isSearch, teamId, cursorPosition, database}: OwnProps) => {
const matchTerm = getMatchTermForChannelMention(value.substring(0, cursorPosition), isSearch);
const localChannels = matchTerm === null ? of$(emptyChannelList) : queryChannelsForAutocomplete(database, matchTerm, isSearch, teamId).observe();
return {
matchTerm: of$(matchTerm),
localChannels,
};
});
export default withDatabase(withMembers(withTeamId(enhanced(ChannelMention))));
export default withDatabase(enhanced(ChannelMention));

View File

@@ -1899,7 +1899,7 @@ export class AppCommandParser {
if (input[0] === '@') {
input = input.substring(1);
}
const res = await searchUsers(this.serverUrl, input, this.teamID, this.channelID);
const res = await searchUsers(this.serverUrl, input, this.channelID);
return getUserSuggestions(res.users);
};
@@ -1908,7 +1908,7 @@ export class AppCommandParser {
if (input[0] === '~') {
input = input.substring(1);
}
const res = await searchChannels(this.serverUrl, input, this.teamID);
const res = await searchChannels(this.serverUrl, input);
return getChannelSuggestions(res.channels);
};

View File

@@ -10,7 +10,7 @@ export async function inTextMentionSuggestions(serverUrl: string, pretext: strin
const incompleteLessLastWord = separatedWords.slice(0, -1).join(' ');
const lastWord = separatedWords[separatedWords.length - 1];
if (lastWord.startsWith('@')) {
const res = await searchUsers(serverUrl, lastWord.substring(1), teamID, channelID);
const res = await searchUsers(serverUrl, lastWord.substring(1), channelID);
const users = await getUserSuggestions(res.users);
users.forEach((u) => {
let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + u.Complete : u.Complete;
@@ -23,7 +23,7 @@ export async function inTextMentionSuggestions(serverUrl: string, pretext: strin
}
if (lastWord.startsWith('~') && !lastWord.startsWith('~~')) {
const res = await searchChannels(serverUrl, lastWord.substring(1), teamID);
const res = await searchChannels(serverUrl, lastWord.substring(1));
const channels = await getChannelSuggestions(res.channels);
channels.forEach((c) => {
let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + c.Complete : c.Complete;

View File

@@ -127,6 +127,7 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([
BROWSE_CHANNELS,
CHANNEL_INFO,
CREATE_DIRECT_MESSAGE,
CHANNEL_ADD_PEOPLE,
CREATE_TEAM,
CUSTOM_STATUS,
EDIT_POST,
@@ -156,7 +157,6 @@ export const OVERLAY_SCREENS = new Set<string>([
]);
export const NOT_READY = [
CHANNEL_ADD_PEOPLE,
CHANNEL_MENTION,
CREATE_TEAM,
INTEGRATION_SELECTOR,

View File

@@ -605,61 +605,3 @@ export const observeChannelsByLastPostAt = (database: Database, myChannels: MyCh
ORDER BY CASE mc.last_post_at WHEN 0 THEN c.create_at ELSE mc.last_post_at END DESC`),
).observe();
};
export const queryChannelsForAutocomplete = (database: Database, matchTerm: string, isSearch: boolean, teamId: string) => {
const likeTerm = `%${Q.sanitizeLikeString(matchTerm)}%`;
const clauses: Q.Clause[] = [];
if (isSearch) {
clauses.push(
Q.experimentalJoinTables([CHANNEL_MEMBERSHIP]),
Q.experimentalNestedJoin(CHANNEL_MEMBERSHIP, USER),
);
}
const orConditions: Q.Condition[] = [
Q.where('display_name', Q.like(matchTerm)),
Q.where('name', Q.like(likeTerm)),
];
if (isSearch) {
orConditions.push(
Q.and(
Q.where('type', Q.oneOf([General.DM_CHANNEL, General.GM_CHANNEL])),
Q.on(CHANNEL_MEMBERSHIP, Q.on(USER,
Q.or(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: condition type error
Q.unsafeSqlExpr(`first_name || ' ' || last_name LIKE '${likeTerm}'`),
Q.where('nickname', Q.like(likeTerm)),
Q.where('email', Q.like(likeTerm)),
Q.where('username', Q.like(likeTerm)),
),
)),
),
);
}
const teamsToSearch = [teamId];
if (isSearch) {
teamsToSearch.push('');
}
const andConditions: Q.Condition[] = [
Q.where('team_id', Q.oneOf(teamsToSearch)),
];
if (!isSearch) {
andConditions.push(
Q.where('type', Q.oneOf([General.OPEN_CHANNEL, General.PRIVATE_CHANNEL])),
Q.where('delete_at', 0),
);
}
clauses.push(
...andConditions,
Q.or(...orConditions),
Q.sortBy('display_name', Q.asc),
Q.sortBy('name', Q.asc),
Q.take(25),
);
return database.get<ChannelModel>(CHANNEL).query(...clauses);
};

View File

@@ -27,6 +27,7 @@ const styles = StyleSheet.create({
width: 24,
height: 24,
marginTop: 2,
marginRight: 8,
},
});

View File

@@ -218,7 +218,7 @@ export default function SearchHandler(props: Props) {
clearTimeout(searchTimeout.current);
}
searchTimeout.current = setTimeout(async () => {
const results = await searchChannels(serverUrl, text, currentTeamId);
const results = await searchChannels(serverUrl, text);
if (results.channels) {
setSearchResults(results.channels);
}

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard} from 'react-native';
import {addMembersToChannel, makeDirectChannel, makeGroupChannel} from '@actions/remote/channel';
import {useServerUrl} from '@context/server';
import {t} from '@i18n';
import MembersModal from '@screens/members_modal';
import {dismissModal} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {displayUsername} from '@utils/user';
const messages = defineMessages({
dm: {
id: 'mobile.open_dm.error',
defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
},
gm: {
id: t('mobile.open_gm.error'),
defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.",
},
buttonText: {
id: t('mobile.channel_add_people.title'),
defaultMessage: 'Add Members',
},
});
type Props = {
componentId: string;
currentChannelId: string;
teammateNameDisplay: string;
}
const close = () => {
Keyboard.dismiss();
dismissModal();
};
export default function ChannelAddPeople({
componentId,
currentChannelId,
teammateNameDisplay,
}: Props) {
const serverUrl = useServerUrl();
const intl = useIntl();
const [startingConversation, setStartingConversation] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const addMembers = useCallback(async (ids: string[]): Promise<boolean> => {
// addMembersToChannel(serverUrl: string, channelId: string, userIds: string[], postRootId = '', fetchOnly = false) {
console.log('currentChannelId', currentChannelId);
console.log('ids', ids);
const result = await addMembersToChannel(serverUrl, currentChannelId, ids);
if (result.error) {
alertErrorWithFallback(intl, result.error, messages.dm);
}
return !result.error;
}, [selectedIds, intl.locale, teammateNameDisplay, serverUrl]);
const startConversation = useCallback(async (selectedId?: {[id: string]: boolean}) => {
if (startingConversation) {
return;
}
setStartingConversation(true);
const idsToUse = selectedId ? Object.keys(selectedId) : Object.keys(selectedIds);
let success;
if (idsToUse.length === 0) {
success = false;
} else {
console.log('. IN HERE!');
success = await addMembers(idsToUse);
}
if (success) {
close();
} else {
setStartingConversation(false);
}
}, [startingConversation, selectedIds, addMembers]);
return (
<MembersModal
componentId={componentId}
selectUsersButtonIcon={'account-plus-outline'}
selectUsersButtonText={intl.formatMessage(messages.buttonText)}
selectUsersMax={7}
selectUsersWarn={5}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
startConversation={startConversation}
startingConversation={startingConversation}
/>
);
}

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 {observeCurrentChannelId} from '@app/queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import ChannelAddPeople from './channel_add_people';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
currentChannelId: observeCurrentChannelId(database),
teammateNameDisplay: observeTeammateNameDisplay(database),
};
});
export default withDatabase(enhanced(ChannelAddPeople));

View File

@@ -1,40 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import React, {useCallback, useState} from 'react';
import {defineMessages, useIntl} from 'react-intl';
import {Keyboard} from 'react-native';
import {makeDirectChannel, makeGroupChannel} from '@actions/remote/channel';
import {fetchProfiles, fetchProfilesInTeam, searchProfiles} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import Search from '@components/search';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {t} from '@i18n';
import {dismissModal, setButtons} from '@screens/navigation';
import MembersModal from '@screens/members_modal';
import {dismissModal} from '@screens/navigation';
import {alertErrorWithFallback} from '@utils/draft';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {displayUsername, filterProfilesMatchingTerm} from '@utils/user';
import {displayUsername} from '@utils/user';
import SelectedUsers from './selected_users';
import UserList from './user_list';
const START_BUTTON = 'start-conversation';
const CLOSE_BUTTON = 'close-dms';
const messages = defineMessages({
dm: {
id: 'mobile.open_dm.error',
defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
},
gm: {
id: t('mobile.open_gm.error'),
defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.",
},
buttonText: {
id: t('create_direct_message.start'),
defaultMessage: 'Start Conversation',
},
});
type Props = {
componentId: string;
currentTeamId: string;
currentUserId: string;
restrictDirectMessage: boolean;
teammateNameDisplay: string;
tutorialWatched: boolean;
}
const close = () => {
@@ -42,148 +38,31 @@ const close = () => {
dismissModal();
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
},
searchBar: {
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
marginVertical: 12,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
function reduceProfiles(state: UserProfile[], action: {type: 'add'; values?: UserProfile[]}) {
if (action.type === 'add' && action.values?.length) {
return [...state, ...action.values];
}
return state;
}
export default function CreateDirectMessage({
componentId,
currentTeamId,
currentUserId,
restrictDirectMessage,
teammateNameDisplay,
tutorialWatched,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const {formatMessage} = intl;
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
const [profiles, dispatchProfiles] = useReducer(reduceProfiles, []);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(false);
const [term, setTerm] = useState('');
const [startingConversation, setStartingConversation] = useState(false);
const [selectedIds, setSelectedIds] = useState<{[id: string]: UserProfile}>({});
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const loadedProfiles = ({users}: {users?: UserProfile[]}) => {
if (mounted.current) {
if (users && !users.length) {
next.current = false;
}
page.current += 1;
setLoading(false);
dispatchProfiles({type: 'add', values: users});
}
};
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
if (restrictDirectMessage) {
fetchProfilesInTeam(serverUrl, currentTeamId, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
} else {
fetchProfiles(serverUrl, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
}
}
}, 100), [loading, isSearch, restrictDirectMessage, serverUrl, currentTeamId]);
const handleRemoveProfile = useCallback((id: string) => {
const newSelectedIds = Object.assign({}, selectedIds);
Reflect.deleteProperty(newSelectedIds, id);
setSelectedIds(newSelectedIds);
}, [selectedIds]);
const createDirectChannel = useCallback(async (id: string): Promise<boolean> => {
const user = selectedIds[id];
const displayName = displayUsername(user, intl.locale, teammateNameDisplay);
const result = await makeDirectChannel(serverUrl, id, displayName);
if (result.error) {
alertErrorWithFallback(
intl,
result.error,
{
id: 'mobile.open_dm.error',
defaultMessage: "We couldn't open a direct message with {displayName}. Please check your connection and try again.",
},
{
displayName,
},
);
alertErrorWithFallback(intl, result.error, messages.dm, {displayName});
}
return !result.error;
}, [selectedIds, intl.locale, teammateNameDisplay, serverUrl]);
const createGroupChannel = useCallback(async (ids: string[]): Promise<boolean> => {
const result = await makeGroupChannel(serverUrl, ids);
if (result.error) {
alertErrorWithFallback(
intl,
result.error,
{
id: t('mobile.open_gm.error'),
defaultMessage: "We couldn't open a group message with those users. Please check your connection and try again.",
},
);
alertErrorWithFallback(intl, result.error, messages.gm);
}
return !result.error;
}, [serverUrl]);
@@ -211,183 +90,18 @@ export default function CreateDirectMessage({
}
}, [startingConversation, selectedIds, createGroupChannel, createDirectChannel]);
const handleSelectProfile = useCallback((user: UserProfile) => {
if (selectedIds[user.id]) {
handleRemoveProfile(user.id);
return;
}
if (user.id === currentUserId) {
const selectedId = {
[currentUserId]: true,
};
startConversation(selectedId);
} else {
const wasSelected = selectedIds[user.id];
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM - 1) {
return;
}
const newSelectedIds = Object.assign({}, selectedIds);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
setSelectedIds(newSelectedIds);
clearSearch();
}
}, [selectedIds, currentUserId, handleRemoveProfile, startConversation, clearSearch]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
let results;
if (restrictDirectMessage) {
results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId, allow_inactive: true});
} else {
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
}
let data: UserProfile[] = [];
if (results.data) {
data = results.data;
}
setSearchResults(data);
setLoading(false);
}, [restrictDirectMessage, serverUrl, currentTeamId]);
const search = useCallback(() => {
searchUsers(term);
}, [searchUsers, term]);
const onSearch = useCallback((text: string) => {
if (text) {
setTerm(text);
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(() => {
searchUsers(text);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
clearSearch();
}
}, [searchUsers, clearSearch]);
const updateNavigationButtons = useCallback(async (startEnabled: boolean) => {
const closeIcon = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
setButtons(componentId, {
leftButtons: [{
id: CLOSE_BUTTON,
icon: closeIcon,
testID: 'close.create_direct_message.button',
}],
rightButtons: [{
color: theme.sidebarHeaderTextColor,
id: START_BUTTON,
text: formatMessage({id: 'mobile.create_direct_message.start', defaultMessage: 'Start'}),
showAsAction: 'always',
enabled: startEnabled,
testID: 'create_direct_message.start.button',
}],
});
}, [intl.locale, theme]);
useNavButtonPressed(START_BUTTON, componentId, startConversation, [startConversation]);
useNavButtonPressed(CLOSE_BUTTON, componentId, close, [close]);
useEffect(() => {
mounted.current = true;
updateNavigationButtons(false);
getProfiles();
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
const canStart = selectedCount > 0 && !startingConversation;
updateNavigationButtons(canStart);
}, [selectedCount > 0, startingConversation, updateNavigationButtons]);
const data = useMemo(() => {
if (term) {
const exactMatches: UserProfile[] = [];
const filterByTerm = (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
if (p.username === term || p.username.startsWith(term)) {
exactMatches.push(p);
return false;
}
return true;
};
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
return [...exactMatches, ...results];
}
return profiles;
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
if (startingConversation) {
return (
<View style={style.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return (
<SafeAreaView
style={style.container}
testID='create_direct_message.screen'
>
<View style={style.searchBar}>
<Search
testID='create_direct_message.search_bar'
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelButtonTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onSearch}
onSubmitEditing={search}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
{selectedCount > 0 &&
<SelectedUsers
selectedIds={selectedIds}
warnCount={5}
maxCount={7}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
/>
}
<UserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
loading={loading}
profiles={data}
selectedIds={selectedIds}
showNoResults={!loading && page.current !== -1}
teammateNameDisplay={teammateNameDisplay}
fetchMore={getProfiles}
term={term}
testID='create_direct_message.user_list'
tutorialWatched={tutorialWatched}
/>
</SafeAreaView>
<MembersModal
componentId={componentId}
selectUsersButtonIcon={'forum-outline'}
selectUsersButtonText={intl.formatMessage(messages.buttonText)}
selectUsersMax={7}
selectUsersWarn={5}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
startConversation={startConversation}
startingConversation={startingConversation}
/>
);
}

View File

@@ -3,12 +3,7 @@
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 {observeProfileLongPresTutorial} from '@queries/app/global';
import {observeConfig, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import CreateDirectMessage from './create_direct_message';
@@ -16,16 +11,8 @@ import CreateDirectMessage from './create_direct_message';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const restrictDirectMessage = observeConfig(database).pipe(
switchMap((cfg) => of$(cfg?.RestrictDirectMessage !== General.RESTRICT_DIRECT_MESSAGE_ANY)),
);
return {
teammateNameDisplay: observeTeammateNameDisplay(database),
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
tutorialWatched: observeProfileLongPresTutorial(),
restrictDirectMessage,
};
});

View File

@@ -263,9 +263,8 @@ const SearchScreen = ({teamId}: Props) => {
position={autocompletePosition}
growDown={true}
containerStyle={styles.autocompleteContainer}
teamId={searchTeamId}
/>
), [cursorPosition, handleTextChange, searchValue, autocompleteMaxHeight, autocompletePosition, searchTeamId]);
), [cursorPosition, handleTextChange, searchValue, autocompleteMaxHeight, autocompletePosition]);
return (
<FreezeScreen freeze={!isFocused}>

View File

@@ -87,6 +87,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.CREATE_DIRECT_MESSAGE:
screen = withServerDatabase(require('@screens/create_direct_message').default);
break;
case Screens.CHANNEL_ADD_PEOPLE:
screen = withServerDatabase(require('@screens/channel_add_people').default);
break;
case Screens.EDIT_POST:
screen = withServerDatabase(require('@screens/edit_post').default);
break;

View File

@@ -0,0 +1,33 @@
// 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 {observeProfileLongPresTutorial} from '@queries/app/global';
import {observeConfig, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import MembersModal from './members_modal';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const restrictDirectMessage = observeConfig(database).pipe(
switchMap((cfg) => of$(cfg?.RestrictDirectMessage !== General.RESTRICT_DIRECT_MESSAGE_ANY)),
);
return {
teammateNameDisplay: observeTeammateNameDisplay(database),
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
tutorialWatched: observeProfileLongPresTutorial(),
restrictDirectMessage,
};
});
export default withDatabase(enhanced(MembersModal));

View File

@@ -0,0 +1,328 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, Platform, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {fetchProfiles, fetchProfilesInTeam, searchProfiles} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Loading from '@components/loading';
import Search from '@components/search';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {debounce} from '@helpers/api/general';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {dismissModal, setButtons} from '@screens/navigation';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {filterProfilesMatchingTerm} from '@utils/user';
import SelectedUsers from './selected_users';
import UserList from './user_list';
const CLOSE_BUTTON = 'close-dms';
type Props = {
componentId: string;
currentTeamId: string;
currentUserId: string;
restrictDirectMessage: boolean;
selectUsersButtonIcon: string;
selectUsersButtonText: string;
selectUsersMax: number;
selectUsersWarn: number;
selectedIds: {[id: string]: UserProfile};
setSelectedIds: (ids: {[id: string]: UserProfile}) => void;
startConversation: (selectedId?: {[id: string]: boolean}) => void;
startingConversation: boolean;
teammateNameDisplay: string;
tutorialWatched: boolean;
}
const close = () => {
Keyboard.dismiss();
dismissModal();
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
flex: 1,
},
searchBar: {
marginLeft: 12,
marginRight: Platform.select({ios: 4, default: 12}),
marginVertical: 12,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
function reduceProfiles(state: UserProfile[], action: {type: 'add'; values?: UserProfile[]}) {
if (action.type === 'add' && action.values?.length) {
return [...state, ...action.values];
}
return state;
}
export default function MembersModal({
componentId,
currentTeamId,
currentUserId,
selectUsersMax,
selectUsersWarn,
restrictDirectMessage,
selectUsersButtonIcon,
selectUsersButtonText,
selectedIds,
setSelectedIds,
startConversation,
startingConversation,
teammateNameDisplay,
tutorialWatched,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const style = getStyleFromTheme(theme);
const intl = useIntl();
const {formatMessage} = intl;
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const next = useRef(true);
const page = useRef(-1);
const mounted = useRef(false);
const [profiles, dispatchProfiles] = useReducer(reduceProfiles, []);
const [searchResults, setSearchResults] = useState<UserProfile[]>([]);
const [loading, setLoading] = useState(false);
const [term, setTerm] = useState('');
const selectedCount = Object.keys(selectedIds).length;
const isSearch = Boolean(term);
const loadedProfiles = ({users}: {users?: UserProfile[]}) => {
if (mounted.current) {
if (users && !users.length) {
next.current = false;
}
page.current += 1;
setLoading(false);
dispatchProfiles({type: 'add', values: users});
}
};
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
}, []);
const getProfiles = useCallback(debounce(() => {
if (next.current && !loading && !term && mounted.current) {
setLoading(true);
if (restrictDirectMessage) {
fetchProfilesInTeam(serverUrl, currentTeamId, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
} else {
fetchProfiles(serverUrl, page.current + 1, General.PROFILE_CHUNK_SIZE).then(loadedProfiles);
}
}
}, 100), [loading, isSearch, restrictDirectMessage, serverUrl, currentTeamId]);
const handleRemoveProfile = useCallback((id: string) => {
const newSelectedIds = Object.assign({}, selectedIds);
Reflect.deleteProperty(newSelectedIds, id);
setSelectedIds(newSelectedIds);
}, [selectedIds]);
const handleSelectProfile = useCallback((user: UserProfile) => {
if (selectedIds[user.id]) {
handleRemoveProfile(user.id);
return;
}
if (user.id === currentUserId) {
const selectedId = {
[currentUserId]: true,
};
startConversation(selectedId);
} else {
const wasSelected = selectedIds[user.id];
if (!wasSelected && selectedCount >= General.MAX_USERS_IN_GM - 1) {
return;
}
const newSelectedIds = Object.assign({}, selectedIds);
if (!wasSelected) {
newSelectedIds[user.id] = user;
}
setSelectedIds(newSelectedIds);
clearSearch();
}
}, [selectedIds, currentUserId, handleRemoveProfile, startConversation, clearSearch]);
const searchUsers = useCallback(async (searchTerm: string) => {
const lowerCasedTerm = searchTerm.toLowerCase();
setLoading(true);
let results;
if (restrictDirectMessage) {
results = await searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId, allow_inactive: true});
} else {
results = await searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true});
}
let data: UserProfile[] = [];
if (results.data) {
data = results.data;
}
setSearchResults(data);
setLoading(false);
}, [restrictDirectMessage, serverUrl, currentTeamId]);
const search = useCallback(() => {
searchUsers(term);
}, [searchUsers, term]);
const onSearch = useCallback((text: string) => {
if (text) {
setTerm(text);
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(() => {
searchUsers(text);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
clearSearch();
}
}, [searchUsers, clearSearch]);
const updateNavigationButtons = useCallback(async () => {
const closeIcon = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
setButtons(componentId, {
leftButtons: [{
id: CLOSE_BUTTON,
icon: closeIcon,
testID: 'close.button',
}],
});
}, [intl.locale, theme]);
useNavButtonPressed(CLOSE_BUTTON, componentId, close, [close]);
useEffect(() => {
mounted.current = true;
updateNavigationButtons();
getProfiles();
return () => {
mounted.current = false;
};
}, []);
const data = useMemo(() => {
if (term) {
const exactMatches: UserProfile[] = [];
const filterByTerm = (p: UserProfile) => {
if (selectedCount > 0 && p.id === currentUserId) {
return false;
}
if (p.username === term || p.username.startsWith(term)) {
exactMatches.push(p);
return false;
}
return true;
};
const results = filterProfilesMatchingTerm(searchResults, term).filter(filterByTerm);
return [...exactMatches, ...results];
}
return profiles;
}, [term, isSearch && selectedCount, isSearch && searchResults, profiles]);
if (startingConversation) {
return (
<View style={style.container}>
<Loading color={theme.centerChannelColor}/>
</View>
);
}
return (
<SafeAreaView
style={style.container}
testID='members.screen'
>
<View style={style.searchBar}>
<Search
testID='search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelButtonTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onSearch}
onSubmitEditing={search}
onCancel={clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
<UserList
currentUserId={currentUserId}
handleSelectProfile={handleSelectProfile}
loading={loading}
profiles={data}
selectedIds={selectedIds}
showNoResults={!loading && page.current !== -1}
teammateNameDisplay={teammateNameDisplay}
fetchMore={getProfiles}
term={term}
testID='user_list'
tutorialWatched={tutorialWatched}
/>
{selectedCount > 0 &&
<SelectedUsers
selectedIds={selectedIds}
warnCount={selectUsersWarn}
maxCount={selectUsersMax}
onRemove={handleRemoveProfile}
teammateNameDisplay={teammateNameDisplay}
onPress={startConversation}
buttonIcon={selectUsersButtonIcon}
buttonText={selectUsersButtonText}
/>
}
</SafeAreaView>
);
}

View File

@@ -10,9 +10,9 @@ import {
} from 'react-native';
import CompassIcon from '@components/compass_icon';
import ProfilePicture from '@components/profile_picture';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {displayUsername} from '@utils/user';
type Props = {
@@ -38,6 +38,9 @@ type Props = {
testID?: string;
}
export const USER_CHIP_HEIGHT = 32;
export const USER_CHIP_BOTTOM_MARGIN = 8;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
@@ -45,19 +48,27 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
justifyContent: 'center',
flexDirection: 'row',
borderRadius: 16,
height: USER_CHIP_HEIGHT,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
marginBottom: 8,
marginRight: 10,
paddingLeft: 12,
paddingVertical: 8,
paddingRight: 7,
marginBottom: USER_CHIP_BOTTOM_MARGIN,
marginRight: 8,
paddingHorizontal: 7,
},
remove: {
justifyContent: 'center',
marginLeft: 7,
},
profileContainer: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 8,
color: theme.centerChannelColor,
},
text: {
color: theme.centerChannelColor,
...typography('Body', 100, 'SemiBold'),
fontSize: 14,
lineHeight: 15,
fontFamily: 'OpenSans',
},
};
});
@@ -76,11 +87,20 @@ export default function SelectedUser({
onRemove(user.id);
}, [onRemove, user.id]);
const userItemTestID = `${testID}.${user.id}`;
return (
<View
style={style.container}
testID={`${testID}.${user.id}`}
>
<View style={style.profileContainer}>
<ProfilePicture
author={user}
size={20}
iconSize={20}
testID={`${userItemTestID}.profile_picture`}
/>
</View>
<Text
style={style.text}
testID={`${testID}.${user.id}.display_name`}
@@ -94,7 +114,7 @@ export default function SelectedUser({
>
<CompassIcon
name='close-circle'
size={17}
size={18}
color={changeOpacity(theme.centerChannelColor, 0.32)}
/>
</TouchableOpacity>

View File

@@ -2,51 +2,92 @@
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View} from 'react-native';
import {ScrollView, View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import Button from '@screens/bottom_sheet/button';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import SelectedUser from './selected_user';
import SelectedUser, {USER_CHIP_BOTTOM_MARGIN, USER_CHIP_HEIGHT} from './selected_user';
type Props = {
/*
* An object mapping user ids to a falsey value indicating whether or not they've been selected.
* An object mapping user ids to a falsey value indicating whether or not they have been selected.
*/
selectedIds: {[id: string]: UserProfile};
/*
* How to display the names of users.
*/
* How to display the names of users.
*/
teammateNameDisplay: string;
/*
* The number of users that will be selected when we start to display a message indicating
* the remaining number of users that can be selected.
*/
* The number of users that will be selected when we start to display a message indicating
* the remaining number of users that can be selected.
*/
warnCount: number;
/*
* The maximum number of users that can be selected.
*/
* The maximum number of users that can be selected.
*/
maxCount: number;
/*
* A handler function that will deselect a user when clicked on.
*/
* A handler function that will deselect a user when clicked on.
*/
onPress: (selectedId?: {[id: string]: boolean}) => void;
/*
* A handler function that will deselect a user when clicked on.
*/
onRemove: (id: string) => void;
/*
* Name of the button Icon
*/
buttonIcon: string;
/*
* Text displayed on the action button
*/
buttonText: string;
}
const MAX_ROWS = 3;
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
marginHorizontal: 12,
flexShrink: 1,
backgroundColor: theme.centerChannelBg,
borderBottomWidth: 0,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderWidth: 1,
elevation: 4,
paddingHorizontal: 20,
shadowColor: theme.centerChannelColor,
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.16,
shadowRadius: 24,
},
buttonContainer: {
marginVertical: 20,
},
containerUsers: {
marginTop: 20,
maxHeight: (USER_CHIP_HEIGHT + USER_CHIP_BOTTOM_MARGIN) * MAX_ROWS,
},
users: {
alignItems: 'flex-start',
flexDirection: 'row',
flexGrow: 1,
flexWrap: 'wrap',
},
message: {
@@ -64,11 +105,18 @@ export default function SelectedUsers({
teammateNameDisplay,
warnCount,
maxCount,
onPress,
onRemove,
buttonIcon,
buttonText,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const handleOnPress = async () => {
onPress();
};
const users = useMemo(() => {
const u = [];
for (const id of Object.keys(selectedIds)) {
@@ -128,10 +176,22 @@ export default function SelectedUsers({
return (
<View style={style.container}>
<View style={style.users}>
{users}
<View style={style.containerUsers}>
<ScrollView
contentContainerStyle={style.users}
>
{users}
</ScrollView>
</View>
{message}
<View style={style.buttonContainer}>
<Button
onPress={handleOnPress}
icon={buttonIcon}
text={buttonText}
/>
</View>
</View>
);
}

View File

@@ -396,6 +396,7 @@
"mobile.calls_you": "(you)",
"mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.",
"mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera",
"mobile.channel_add_people.title": "Add Members",
"mobile.channel_info.alertNo": "No",
"mobile.channel_info.alertYes": "Yes",
"mobile.channel_list.recent": "Recent",
@@ -415,7 +416,7 @@
"mobile.create_direct_message.add_more": "You can add {remaining, number} more users",
"mobile.create_direct_message.cannot_add_more": "You cannot add more users",
"mobile.create_direct_message.one_more": "You can add 1 more user",
"mobile.create_direct_message.start": "Start",
"mobile.create_direct_message.start": "Start Conversation",
"mobile.create_direct_message.you": "@{username} - you",
"mobile.create_post.read_only": "This channel is read-only.",
"mobile.custom_status.choose_emoji": "Choose an emoji",