diff --git a/app/actions/local/channel.test.ts b/app/actions/local/channel.test.ts index c176fb0e5..11b86caa1 100644 --- a/app/actions/local/channel.test.ts +++ b/app/actions/local/channel.test.ts @@ -386,7 +386,7 @@ describe('switchToChannel', () => { const listenerCallback = jest.fn(); const listener = DeviceEventEmitter.addListener(Navigation.NAVIGATION_HOME, listenerCallback); - const {models, error} = await switchToChannel(serverUrl, channelId, teamId, true); + const {models, error} = await switchToChannel(serverUrl, channelId, teamId, false, true); for (const model of models!) { model.cancelPrepareUpdate(); } diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index c7b4ffbde..1539070fd 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -26,7 +26,7 @@ import {displayGroupMessageName, displayUsername, getUserIdFromChannelName} from import type ChannelModel from '@typings/database/models/servers/channel'; import type UserModel from '@typings/database/models/servers/user'; -export async function switchToChannel(serverUrl: string, channelId: string, teamId?: string, prepareRecordsOnly = false) { +export async function switchToChannel(serverUrl: string, channelId: string, teamId?: string, skipLastUnread = false, prepareRecordsOnly = false) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; @@ -63,7 +63,7 @@ export async function switchToChannel(serverUrl: string, channelId: string, team } const commonValues: PrepareCommonSystemValuesArgs = { - lastUnreadChannelId: member.isUnread ? channelId : '', + lastUnreadChannelId: member.isUnread && !skipLastUnread ? channelId : '', }; if ((system.currentTeamId !== toTeamId) || (system.currentChannelId !== channelId)) { diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 7d636caca..9dd07e621 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -16,7 +16,7 @@ import NetworkManager from '@managers/network_manager'; import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo} from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {getCommonSystemValues, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system'; -import {prepareMyTeams, getNthLastChannelFromTeam, getMyTeamById, getTeamById, getTeamByName} from '@queries/servers/team'; +import {prepareMyTeams, getNthLastChannelFromTeam, getMyTeamById, getTeamById, getTeamByName, queryMyTeams} from '@queries/servers/team'; import {getCurrentUser} from '@queries/servers/user'; import EphemeralStore from '@store/ephemeral_store'; import {generateChannelNameFromDisplayName, getDirectChannelName, isDMorGM} from '@utils/channel'; @@ -440,10 +440,12 @@ export async function joinChannel(serverUrl: string, userId: string, teamId: str let channel: Channel | undefined; try { if (channelId) { + EphemeralStore.addJoiningChannel(channelId); member = await client.addToChannel(userId, channelId); channel = await client.getChannel(channelId); } else if (channelName) { channel = await client.getChannelByName(teamId, channelName, true); + EphemeralStore.addJoiningChannel(channel.id); if (isDMorGM(channel)) { member = await client.getChannelMember(channel.id, userId); } else { @@ -451,6 +453,9 @@ export async function joinChannel(serverUrl: string, userId: string, teamId: str } } } catch (error) { + if (channelId || channel?.id) { + EphemeralStore.removeJoiningChanel(channelId || channel!.id); + } forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } @@ -478,12 +483,37 @@ export async function joinChannel(serverUrl: string, userId: string, teamId: str } } } catch (error) { + if (channelId || channel?.id) { + EphemeralStore.removeJoiningChanel(channelId || channel!.id); + } return {error}; } + if (channelId || channel?.id) { + EphemeralStore.removeJoiningChanel(channelId || channel!.id); + } return {channel, member}; } +export async function joinChannelIfNeeded(serverUrl: string, channelId: string) { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + try { + const myChannel = await getMyChannel(database, channelId); + if (myChannel) { + return {error: undefined}; + } + + const userId = await getCurrentUserId(database); + return joinChannel(serverUrl, userId, '', channelId); + } catch (error) { + return {error}; + } +} + export async function markChannelAsRead(serverUrl: string, channelId: string) { try { const client = NetworkManager.getClient(serverUrl); @@ -933,14 +963,14 @@ export async function getChannelTimezones(serverUrl: string, channelId: string) } } -export async function switchToChannelById(serverUrl: string, channelId: string, teamId?: string) { +export async function switchToChannelById(serverUrl: string, channelId: string, teamId?: string, skipLastUnread = false) { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; } fetchPostsForChannel(serverUrl, channelId); - await switchToChannel(serverUrl, channelId, teamId); + await switchToChannel(serverUrl, channelId, teamId, skipLastUnread); markChannelAsRead(serverUrl, channelId); fetchChannelStats(serverUrl, channelId); @@ -1002,3 +1032,25 @@ export async function fetchChannelById(serverUrl: string, id: string) { return {error}; } } + +export async function searchAllChannels(serverUrl: string, term: string, archivedOnly = false) { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const myTeamIds = await queryMyTeams(database).fetchIds(); + const channels = await client.searchAllChannels(term, myTeamIds, archivedOnly); + return {channels}; + } catch (error) { + return {error}; + } +} diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 115303f6d..8e8566442 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -196,7 +196,7 @@ export const fetchTeamsChannelsAndUnreadPosts = async (serverUrl: string, since: const myTeams = teams.filter((t) => membershipSet.has(t.id) && t.id !== excludeTeamId); for await (const team of myTeams) { - const {channels, memberships: members} = await fetchMyChannelsForTeam(serverUrl, team.id, since > 0, since, false, true); + const {channels, memberships: members} = await fetchMyChannelsForTeam(serverUrl, team.id, true, since, false, true); if (channels?.length && members?.length) { fetchPostsForUnreadChannels(serverUrl, channels, members); diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index 7b211f13d..52b685e4d 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -420,7 +420,11 @@ export const searchProfiles = async (serverUrl: string, term: string, options: a const users = await client.searchUsers(term, options); if (!fetchOnly) { - const toStore = removeUserFromList(currentUserId, users); + const {database} = operator; + const existing = await queryUsersById(database, users.map((u) => u.id)).fetchIds(); + const existingSet = new Set(existing); + const usersToAdd = users.filter((u) => !existingSet.has(u.id)); + const toStore = removeUserFromList(currentUserId, usersToAdd); if (toStore.length) { await operator.handleUsers({ users: toStore, diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index 61ed91f24..319c4b589 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -251,9 +251,10 @@ export async function handleUserAddedToChannelEvent(serverUrl: string, msg: any) try { if (userId === currentUser?.id) { - if (EphemeralStore.isAddingToTeam(teamId)) { + if (EphemeralStore.isAddingToTeam(teamId) || EphemeralStore.isJoiningChannel(channelId)) { return; } + const {channels, memberships} = await fetchMyChannel(serverUrl, teamId, channelId, true); if (channels && memberships) { const prepare = await prepareMyChannelsForTeam(operator, teamId, channels, memberships); @@ -300,7 +301,10 @@ export async function handleUserAddedToChannelEvent(serverUrl: string, msg: any) })); } } - await operator.batchRecords(models); + + if (models.length) { + await operator.batchRecords(models); + } await fetchChannelStats(serverUrl, channelId, false); } catch { @@ -358,7 +362,7 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg: models.push(...switchToGlobalThreadsModels); } } else { - const {models: switchChannelModels} = await switchToChannel(serverUrl, channelToJumpTo, '', true); + const {models: switchChannelModels} = await switchToChannel(serverUrl, channelToJumpTo, '', false, true); if (switchChannelModels) { models.push(...switchChannelModels); } diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index fcc0d7c14..682fb765f 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -39,6 +39,7 @@ export interface ClientChannelsMix { autocompleteChannelsForSearch: (teamId: string, name: string) => Promise; searchChannels: (teamId: string, term: string) => Promise; searchArchivedChannels: (teamId: string, term: string) => Promise; + searchAllChannels: (term: string, teamIds: string[], archivedOnly?: boolean) => Promise; } const ClientChannels = (superclass: any) => class extends superclass { @@ -313,6 +314,24 @@ const ClientChannels = (superclass: any) => class extends superclass { {method: 'post', body: {term}}, ); }; + + searchAllChannels = async (term: string, teamIds: string[], archivedOnly = false) => { + const queryParams = {include_deleted: false, system_console: false, exclude_default_channels: false}; + const body = { + term, + team_ids: teamIds, + deleted: archivedOnly, + exclude_default_channels: true, + exclude_group_constrained: true, + public: true, + private: false, + }; + + return this.doFetch( + `${this.getChannelsRoute()}/search${buildQueryString(queryParams)}`, + {method: 'post', body}, + ); + }; }; export default ClientChannels; diff --git a/app/components/autocomplete/emoji_suggestion/index.ts b/app/components/autocomplete/emoji_suggestion/index.ts index bfbed429e..456e1e800 100644 --- a/app/components/autocomplete/emoji_suggestion/index.ts +++ b/app/components/autocomplete/emoji_suggestion/index.ts @@ -26,9 +26,10 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { of$(emptyEmojiList)), ), ), - skinTone: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE).observe().pipe( - switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')), - ), + skinTone: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE). + observeWithColumns(['value']).pipe( + switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')), + ), }; }); diff --git a/app/components/channel_item/channel_item.tsx b/app/components/channel_item/channel_item.tsx index 6f981dd41..af44c9f52 100644 --- a/app/components/channel_item/channel_item.tsx +++ b/app/components/channel_item/channel_item.tsx @@ -175,7 +175,7 @@ const ChannelListItem = ({ ], [height, isActive, isInfo, styles]); - if ((channel.deleteAt > 0 && !isActive) || !myChannel || !isVisible) { + if (!myChannel || !isVisible) { return null; } @@ -229,7 +229,12 @@ const ChannelListItem = ({ } - {Boolean(teammateId) && } + {Boolean(teammateId) && + + } {isInfo && Boolean(teamDisplayName) && isTablet && { +const CustomStatus = ({customStatus, customStatusExpired, isCustomStatusEnabled, isInfo}: Props) => { const showCustomStatusEmoji = Boolean(isCustomStatusEnabled && customStatus && !customStatusExpired); if (!showCustomStatusEmoji) { @@ -30,7 +34,7 @@ const CustomStatus = ({customStatus, customStatusExpired, isCustomStatusEnabled} return ( ); diff --git a/app/components/custom_status/custom_status_emoji.tsx b/app/components/custom_status/custom_status_emoji.tsx index c58d97f08..ca8daaf7a 100644 --- a/app/components/custom_status/custom_status_emoji.tsx +++ b/app/components/custom_status/custom_status_emoji.tsx @@ -2,14 +2,14 @@ // See LICENSE.txt for license information. import React from 'react'; -import {Text, TextStyle} from 'react-native'; +import {StyleProp, Text, TextStyle} from 'react-native'; import Emoji from '@components/emoji'; interface ComponentProps { customStatus: UserCustomStatus; emojiSize?: number; - style?: TextStyle; + style?: StyleProp; testID?: string; } diff --git a/app/components/custom_status/custom_status_expiry.tsx b/app/components/custom_status/custom_status_expiry.tsx index f99ad9f08..62d1c3a4f 100644 --- a/app/components/custom_status/custom_status_expiry.tsx +++ b/app/components/custom_status/custom_status_expiry.tsx @@ -126,11 +126,12 @@ const CustomStatusExpiry = ({currentUser, isMilitaryTime, showPrefix, showTimeCo }; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ - isMilitaryTime: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).observe().pipe( - switchMap( - (preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)), + isMilitaryTime: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS). + observeWithColumns(['value']).pipe( + switchMap( + (preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)), + ), ), - ), })); export default withDatabase(enhanced(CustomStatusExpiry)); diff --git a/app/components/emoji_picker/index.tsx b/app/components/emoji_picker/index.tsx index c58add061..67a2002e4 100644 --- a/app/components/emoji_picker/index.tsx +++ b/app/components/emoji_picker/index.tsx @@ -129,9 +129,10 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ customEmojisEnabled: observeConfigBooleanValue(database, 'EnableCustomEmoji'), customEmojis: queryAllCustomEmojis(database).observe(), recentEmojis: observeRecentReactions(database), - skinTone: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE).observe().pipe( - switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')), - ), + skinTone: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_EMOJI, Preferences.EMOJI_SKINTONE). + observeWithColumns(['value']).pipe( + switchMap((prefs) => of$(prefs?.[0]?.value ?? 'default')), + ), })); export default withDatabase(enhanced(EmojiPicker)); diff --git a/app/components/markdown/channel_mention/channel_mention.tsx b/app/components/markdown/channel_mention/channel_mention.tsx index b9fc20e4f..99aee0333 100644 --- a/app/components/markdown/channel_mention/channel_mention.tsx +++ b/app/components/markdown/channel_mention/channel_mention.tsx @@ -73,7 +73,7 @@ const ChannelMention = ({ if (result.error || !result.channel) { const joinFailedMessage = { id: t('mobile.join_channel.error'), - defaultMessage: "We couldn't join the channel {displayName}. Please check your connection and try again.", + defaultMessage: "We couldn't join the channel {displayName}.", }; alertErrorWithFallback(intl, result.error || {}, joinFailedMessage, {displayName: c.display_name}); } else if (result.channel) { diff --git a/app/components/post_list/post/body/content/opengraph/index.ts b/app/components/post_list/post/body/content/opengraph/index.ts index 6cc45c1d1..061899af5 100644 --- a/app/components/post_list/post/body/content/opengraph/index.ts +++ b/app/components/post_list/post/body/content/opengraph/index.ts @@ -22,7 +22,8 @@ const enhance = withObservables( } const config = observeConfig(database); - const linkPreviewPreference = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY).observe(); + const linkPreviewPreference = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY). + observeWithColumns(['value']); const showLinkPreviews = combineLatest([config, linkPreviewPreference], (cfg, pref) => { const previewsEnabled = getPreferenceAsBool(pref, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY, true); return of$(previewsEnabled && cfg?.EnableLinkPreviews === 'true'); diff --git a/app/components/post_list/post/header/index.ts b/app/components/post_list/post/header/index.ts index 181e75b61..e5c8b65df 100644 --- a/app/components/post_list/post/header/index.ts +++ b/app/components/post_list/post/header/index.ts @@ -28,7 +28,8 @@ const withHeaderProps = withObservables( ({post, database, differentThreadSequence}: WithDatabaseArgs & HeaderInputProps) => { const config = observeConfig(database); const license = observeLicense(database); - const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).observe(); + const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS). + observeWithColumns(['value']); const author = post.author.observe(); const enablePostUsernameOverride = config.pipe(map((cfg) => cfg?.EnablePostUsernameOverride === 'true')); const isTimezoneEnabled = config.pipe(map((cfg) => cfg?.ExperimentalTimezone === 'true')); diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index 78c802cb0..ca24d18d9 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -103,9 +103,10 @@ const withPost = withObservables( const author = post.userId ? post.author.observe() : of$(null); const canDelete = observePermissionForPost(post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false); const isEphemeral = of$(isPostEphemeral(post)); - const isSaved = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, post.id).observe().pipe( - switchMap((pref) => of$(Boolean(pref.length))), - ); + const isSaved = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, post.id). + observeWithColumns(['value']).pipe( + switchMap((pref) => of$(Boolean(pref.length))), + ); if (post.props?.add_channel_member && isPostEphemeral(post)) { isPostAddChannelMember = observeCanManageChannelMembers(post, currentUser); diff --git a/app/components/post_list/thread_overview/index.ts b/app/components/post_list/thread_overview/index.ts index 01c33a8c7..9d0b814b5 100644 --- a/app/components/post_list/thread_overview/index.ts +++ b/app/components/post_list/thread_overview/index.ts @@ -20,7 +20,7 @@ const enhanced = withObservables( return { rootPost: observePost(database, rootId), isSaved: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, rootId). - observe(). + observeWithColumns(['value']). pipe( switchMap((pref) => of$(Boolean(pref[0]?.value === 'true'))), ), diff --git a/app/components/system_header/index.tsx b/app/components/system_header/index.tsx index 4fd460b8c..e9c98fb76 100644 --- a/app/components/system_header/index.tsx +++ b/app/components/system_header/index.tsx @@ -81,7 +81,8 @@ const SystemHeader = ({isMilitaryTime, isTimezoneEnabled, createAt, theme, user} const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { const config = observeConfig(database); - const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time').observe(); + const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time'). + observeWithColumns(['value']); const isTimezoneEnabled = config.pipe(map((cfg) => cfg?.ExperimentalTimezone === 'true')); const isMilitaryTime = preferences.pipe( map((prefs) => getPreferenceAsBool(prefs, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)), diff --git a/app/components/team_sidebar/team_list/index.ts b/app/components/team_sidebar/team_list/index.ts index ae3229943..3829bb2af 100644 --- a/app/components/team_sidebar/team_list/index.ts +++ b/app/components/team_sidebar/team_list/index.ts @@ -21,9 +21,10 @@ const withTeams = withObservables([], ({database}: WithDatabaseArgs) => { const teamIds = queryJoinedTeams(database).observe().pipe( map((ts) => ts.map((t) => ({id: t.id, displayName: t.displayName}))), ); - const order = queryPreferencesByCategoryAndName(database, Preferences.TEAMS_ORDER).observe().pipe( - switchMap((p) => (p.length ? of$(p[0].value.split(',')) : of$([]))), - ); + const order = queryPreferencesByCategoryAndName(database, Preferences.TEAMS_ORDER). + observeWithColumns(['value']).pipe( + switchMap((p) => (p.length ? of$(p[0].value.split(',')) : of$([]))), + ); const myOrderedTeams = combineLatest([myTeams, order, teamIds]).pipe( map(([ts, o, tids]) => { let ids: string[] = o; diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index 155700b6a..90c780ec0 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -11,7 +11,8 @@ import {hasPermission} from '@utils/role'; import {prepareDeletePost} from './post'; import {queryRoles} from './role'; -import {observeCurrentChannelId, getCurrentChannelId} from './system'; +import {observeCurrentChannelId, getCurrentChannelId, observeCurrentUserId} from './system'; +import {observeTeammateNameDisplay} from './user'; import type ServerDataOperator from '@database/operator/server_data_operator'; import type ChannelModel from '@typings/database/models/servers/channel'; @@ -431,3 +432,103 @@ export function queryMyChannelsByUnread(database: Database, isUnread: boolean, s Q.take(take), ); } + +export const observeDirectChannelsByTerm = (database: Database, term: string, take = 20, matchStart = false) => { + const onlyDMs = term.startsWith('@') ? "AND c.type='D'" : ''; + const value = Q.sanitizeLikeString(term.startsWith('@') ? term.substring(1) : term); + let username = `u.username LIKE '${value}%'`; + let displayname = `c.display_name LIKE '${value}%'`; + if (!matchStart) { + username = `u.username LIKE '%${value}%' AND u.username NOT LIKE '${value}%'`; + displayname = `(c.display_name LIKE '%${value}%' AND c.display_name NOT LIKE '${value}%')`; + } + const currentUserId = observeCurrentUserId(database); + return currentUserId.pipe( + switchMap((uId) => { + return database.get(CHANNEL).query( + Q.unsafeSqlQuery(`SELECT DISTINCT my.* FROM ${MY_CHANNEL} my + INNER JOIN ${CHANNEL} c ON c.id=my.id AND c.team_id='' AND c.delete_at=0 ${onlyDMs} + INNER JOIN ${CHANNEL_MEMBERSHIP} cm ON cm.channel_id=my.id + INNER JOIN ${USER} u ON u.id=cm.user_id AND (cm.user_id != '${uId}' AND ${username}) + OR ${displayname} + ORDER BY my.last_viewed_at DESC + LIMIT ${take}`), + ).observe(); + }), + ); +}; + +export const observeNotDirectChannelsByTerm = (database: Database, term: string, take = 20, matchStart = false) => { + const teammateNameSetting = observeTeammateNameDisplay(database); + + const value = Q.sanitizeLikeString(term.startsWith('@') ? term.substring(1) : term); + let username = `u.username LIKE '${value}%'`; + let nickname = `u.nickname LIKE '${value}%'`; + let displayname = `(u.first_name || ' ' || u.last_name) LIKE '${value}%'`; + if (!matchStart) { + username = `(u.username LIKE '%${value}%' AND u.username NOT LIKE '${value}%')`; + nickname = `(u.nickname LIKE '%${value}%' AND u.nickname NOT LIKE '${value}%')`; + displayname = `((u.first_name || ' ' || u.last_name) LIKE '%${value}%' AND (u.first_name || ' ' || u.last_name) NOT LIKE '${value}%')`; + } + + return teammateNameSetting.pipe( + switchMap((setting) => { + let sortBy = ''; + switch (setting) { + case General.TEAMMATE_NAME_DISPLAY.SHOW_NICKNAME_FULLNAME: + sortBy = "ORDER BY CASE u.nickname WHEN '' THEN 1 ELSE 0 END, CASE (u.first_name || ' ' || u.last_name) WHEN '' THEN 1 ELSE 0 END, u.username"; + break; + case General.TEAMMATE_NAME_DISPLAY.SHOW_FULLNAME: + sortBy = "ORDER BY CASE (u.first_name || ' ' || u.last_name) WHEN '' THEN 1 ELSE 0 END, u.username"; + break; + default: + sortBy = 'ORDER BY u.username'; + break; + } + + return database.get(USER).query( + Q.unsafeSqlQuery(`SELECT DISTINCT u.* FROM User u + LEFT JOIN ChannelMembership cm ON cm.user_id=u.id + LEFT JOIN Channel c ON c.id=cm.id AND c.type='${General.DM_CHANNEL}' + WHERE cm.user_id IS NULL AND (${displayname} OR ${username} OR ${nickname}) + ${sortBy} LIMIT ${take}`), + ).observe(); + }), + ); +}; + +export const observeJoinedChannelsByTerm = (database: Database, term: string, take = 20, matchStart = false) => { + if (term.startsWith('@')) { + return of$([]); + } + + const value = Q.sanitizeLikeString(term); + let displayname = `c.display_name LIKE '${value}%'`; + if (!matchStart) { + displayname = `c.display_name LIKE '%${value}%' AND c.display_name NOT LIKE '${value}%'`; + } + return database.get(MY_CHANNEL).query( + Q.unsafeSqlQuery(`SELECT DISTINCT my.* FROM ${MY_CHANNEL} my + INNER JOIN ${CHANNEL} c ON c.id=my.id AND c.delete_at=0 AND c.team_id !='' AND ${displayname} + ORDER BY my.last_viewed_at DESC + LIMIT ${take}`), + ).observe(); +}; + +export const observeArchiveChannelsByTerm = (database: Database, term: string, take = 20) => { + if (term.startsWith('@')) { + return of$([]); + } + + const value = Q.sanitizeLikeString(term); + const displayname = `%${value}%`; + return database.get(MY_CHANNEL).query( + Q.on(CHANNEL, Q.and( + Q.where('delete_at', Q.gt(0)), + Q.where('team_id', Q.notEq('')), + Q.where('display_name', Q.like(displayname)), + )), + Q.sortBy('last_viewed_at'), + Q.take(take), + ).observe(); +}; diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index f80ac9752..1b7b212c7 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -55,11 +55,12 @@ export const observePost = (database: Database, postId: string) => { }; export const observePostSaved = (database: Database, postId: string) => { - return queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, postId).observe().pipe( - switchMap( - (pref) => of$(Boolean(pref[0]?.value === 'true')), - ), - ); + return queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, postId). + observeWithColumns(['value']).pipe( + switchMap( + (pref) => of$(Boolean(pref[0]?.value === 'true')), + ), + ); }; export const queryPostsInChannel = (database: Database, channelId: string) => { diff --git a/app/queries/servers/user.ts b/app/queries/servers/user.ts index d5aa81c07..36a7f10ea 100644 --- a/app/queries/servers/user.ts +++ b/app/queries/servers/user.ts @@ -65,7 +65,8 @@ export async function prepareUsers(operator: ServerDataOperator, users: UserProf export const observeTeammateNameDisplay = (database: Database) => { const config = observeConfig(database); const license = observeLicense(database); - const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).observe(); + const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS). + observeWithColumns(['value']); return combineLatest([config, license, preferences]).pipe( switchMap( ([cfg, lcs, prefs]) => of$(getTeammateNameDisplaySetting(prefs, cfg, lcs)), diff --git a/app/screens/channel/channel_post_list/index.ts b/app/screens/channel/channel_post_list/index.ts index 2a3097572..fe621f3e4 100644 --- a/app/screens/channel/channel_post_list/index.ts +++ b/app/screens/channel/channel_post_list/index.ts @@ -47,9 +47,10 @@ const enhanced = withObservables(['channelId', 'forceQueryAfterAppState'], ({dat return queryPostsBetween(database, earliest, latest, Q.desc, '', channelId, isCRTEnabled ? '' : undefined).observe(); }), ), - shouldShowJoinLeaveMessages: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE).observe().pipe( - switchMap((preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true))), - ), + shouldShowJoinLeaveMessages: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE). + observeWithColumns(['value']).pipe( + switchMap((preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE, true))), + ), }; }); diff --git a/app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts b/app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts index 086bb257f..e3a18ee76 100644 --- a/app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts +++ b/app/screens/channel/channel_post_list/intro/public_or_private_channel/index.ts @@ -23,7 +23,8 @@ const enhanced = withObservables([], ({channel, database}: {channel: ChannelMode if (channel.creatorId) { const config = observeConfig(database); const license = observeLicense(database); - const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).observe(); + const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS). + observeWithColumns(['value']); const me = observeCurrentUser(database); const profile = observeUser(database, channel.creatorId); diff --git a/app/screens/custom_status_clear_after/components/date_time_selector.tsx b/app/screens/custom_status_clear_after/components/date_time_selector.tsx index 40da92bbd..04fe03379 100644 --- a/app/screens/custom_status_clear_after/components/date_time_selector.tsx +++ b/app/screens/custom_status_clear_after/components/date_time_selector.tsx @@ -113,11 +113,12 @@ const DateTimeSelector = ({timezone, handleChange, isMilitaryTime, theme}: Props }; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ - isMilitaryTime: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).observe().pipe( - switchMap( - (preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)), + isMilitaryTime: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS). + observeWithColumns(['value']).pipe( + switchMap( + (preferences) => of$(getPreferenceAsBool(preferences, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)), + ), ), - ), })); export default withDatabase(enhanced(DateTimeSelector)); diff --git a/app/screens/find_channels/filtered_list/filtered_list.tsx b/app/screens/find_channels/filtered_list/filtered_list.tsx index ff049c16c..09c0d64c7 100644 --- a/app/screens/find_channels/filtered_list/filtered_list.tsx +++ b/app/screens/find_channels/filtered_list/filtered_list.tsx @@ -1,26 +1,59 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo, useState} from 'react'; -import {FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native'; +import {debounce, DebouncedFunc} from 'lodash'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Alert, FlatList, ListRenderItemInfo, StyleSheet, View} from 'react-native'; import Animated, {FadeInDown, FadeOutUp} from 'react-native-reanimated'; -import {switchToChannelById} from '@actions/remote/channel'; +import {joinChannelIfNeeded, makeDirectChannel, searchAllChannels, switchToChannelById} from '@actions/remote/channel'; +import {searchProfiles} from '@actions/remote/user'; import ChannelItem from '@components/channel_item'; +import Loading from '@components/loading'; import NoResultsWithTerm from '@components/no_results_with_term'; import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {sortChannelsByDisplayName} from '@utils/channel'; +import {displayUsername} from '@utils/user'; + +import RemoteChannelItem from './remote_channel_item'; +import UserItem from './user_item'; import type ChannelModel from '@typings/database/models/servers/channel'; +import type UserModel from '@typings/database/models/servers/user'; + +type RemoteChannels = { + archived: Channel[]; + startWith: Channel[]; + matches: Channel[]; +} type Props = { + archivedChannels: ChannelModel[]; close: () => Promise; + channelsMatch: ChannelModel[]; + channelsMatchStart: ChannelModel[]; + currentTeamId: string; keyboardHeight: number; + loading: boolean; + onLoading: (loading: boolean) => void; + restrictDirectMessage: boolean; showTeamName: boolean; + teamIds: Set; + teammateDisplayNameSetting: string; term: string; + usersMatch: UserModel[]; + usersMatchStart: UserModel[]; } const style = StyleSheet.create({ flex: {flex: 1}, + loading: { + height: 32, + width: 32, + justifyContent: 'center', + }, noResultContainer: { flexGrow: 1, alignItems: 'center', @@ -28,17 +61,138 @@ const style = StyleSheet.create({ }, }); -const UnfilteredList = ({close, keyboardHeight, showTeamName, term}: Props) => { - const serverUrl = useServerUrl(); - const [data] = useState([]); - const flatListStyle = useMemo(() => ({flexGrow: 1, paddingBottom: keyboardHeight}), [keyboardHeight]); +export const MAX_RESULTS = 20; - const onPress = useCallback(async (channelId: string) => { +const sortByLastPostAt = (a: Channel, b: Channel) => { + return a.last_post_at > b.last_post_at ? 1 : -1; +}; + +const sortByUserOrChannel = (locale: string, teammateDisplayNameSetting: string, a: T, b: T): number => { + const aDisplayName = 'display_name' in a ? a.display_name : displayUsername(a, locale, teammateDisplayNameSetting); + const bDisplayName = 'display_name' in b ? b.display_name : displayUsername(b, locale, teammateDisplayNameSetting); + + return aDisplayName.toLowerCase().localeCompare(bDisplayName.toLowerCase(), locale, {numeric: true}); +}; + +const FilteredList = ({ + archivedChannels, close, channelsMatch, channelsMatchStart, currentTeamId, + keyboardHeight, loading, onLoading, restrictDirectMessage, showTeamName, + teamIds, teammateDisplayNameSetting, term, usersMatch, usersMatchStart, +}: Props) => { + const bounce = useRef void>>(); + const mounted = useRef(false); + const serverUrl = useServerUrl(); + const theme = useTheme(); + const {locale, formatMessage} = useIntl(); + const flatListStyle = useMemo(() => ({flexGrow: 1, paddingBottom: keyboardHeight}), [keyboardHeight]); + const [remoteChannels, setRemoteChannels] = useState({archived: [], startWith: [], matches: []}); + const totalLocalResults = channelsMatchStart.length + channelsMatch.length + usersMatchStart.length; + + const search = async () => { + onLoading(true); + if (mounted.current) { + setRemoteChannels({archived: [], startWith: [], matches: []}); + } + const lowerCasedTerm = (term.startsWith('@') ? term.substring(1) : term).toLowerCase(); + if ((channelsMatchStart.length + channelsMatch.length) < MAX_RESULTS) { + if (restrictDirectMessage) { + searchProfiles(serverUrl, lowerCasedTerm, {team_id: currentTeamId, allow_inactive: true}); + } else { + searchProfiles(serverUrl, lowerCasedTerm, {allow_inactive: true}); + } + } + + if (!term.startsWith('@')) { + if (totalLocalResults < MAX_RESULTS) { + const {channels} = await searchAllChannels(serverUrl, lowerCasedTerm, true); + if (channels) { + const existingChannelIds = new Set(channelsMatchStart.concat(channelsMatch).concat(archivedChannels).map((c) => c.id)); + const [startWith, matches, archived] = channels.reduce<[Channel[], Channel[], Channel[]]>(([s, m, a], c) => { + if (existingChannelIds.has(c.id) || !teamIds.has(c.team_id)) { + return [s, m, a]; + } + if (!c.delete_at) { + if (c.display_name.toLowerCase().startsWith(lowerCasedTerm)) { + return [[...s, c], m, a]; + } + if (c.display_name.toLowerCase().includes(lowerCasedTerm)) { + return [s, [...m, c], a]; + } + return [s, m, a]; + } + + if (c.display_name.toLowerCase().includes(lowerCasedTerm)) { + return [s, m, [...a, c]]; + } + + return [s, m, a]; + }, [[], [], []]); + + if (mounted.current) { + setRemoteChannels({ + archived: archived.sort(sortChannelsByDisplayName.bind(null, locale)).slice(0, MAX_RESULTS + 1), + startWith: startWith.sort(sortByLastPostAt).slice(0, MAX_RESULTS + 1), + matches: matches.sort(sortChannelsByDisplayName.bind(null, locale)).slice(0, MAX_RESULTS + 1), + }); + } + } + } + } + + onLoading(false); + }; + + const onJoinChannel = useCallback(async (channelId: string, displayName: string) => { + const {error} = await joinChannelIfNeeded(serverUrl, channelId); + if (error) { + Alert.alert( + '', + formatMessage({ + id: 'mobile.join_channel.error', + defaultMessage: "We couldn't join the channel {displayName}.", + }, {displayName}), + ); + return; + } + + await close(); + switchToChannelById(serverUrl, channelId, undefined, true); + }, [serverUrl, close, locale]); + + const onOpenDirectMessage = useCallback(async (teammateId: string, displayName: string) => { + const {data, error} = await makeDirectChannel(serverUrl, teammateId, displayName, false); + if (error || !data) { + Alert.alert( + '', + formatMessage({ + id: 'mobile.direct_message.error', + defaultMessage: "We couldn't open a DM with {displayName}.", + }, {displayName}), + ); + return; + return; + } + + await close(); + switchToChannelById(serverUrl, data.id); + }, [serverUrl, close, locale]); + + const onSwitchToChannel = useCallback(async (channelId: string) => { await close(); switchToChannelById(serverUrl, channelId); }, [serverUrl, close]); - const renderNoResults = useCallback(() => { + const renderEmpty = useCallback(() => { + if (loading) { + return ( + + ); + } + if (term) { return ( @@ -48,19 +202,88 @@ const UnfilteredList = ({close, keyboardHeight, showTeamName, term}: Props) => { } return null; - }, [term]); + }, [term, loading, theme]); + + const renderItem = useCallback(({item}: ListRenderItemInfo) => { + if ('teamId' in item) { + return ( + + ); + } else if ('username' in item) { + return ( + + ); + } - const renderItem = useCallback(({item}: ListRenderItemInfo) => { return ( - ); - }, [onPress, showTeamName]); + }, [onJoinChannel, onOpenDirectMessage, onSwitchToChannel, showTeamName, teammateDisplayNameSetting]); + + const data = useMemo(() => { + const items: Array = [...channelsMatchStart]; + + // Channels that matches + if (items.length < MAX_RESULTS) { + items.push(...channelsMatch); + } + + // Users that start with + if (items.length < MAX_RESULTS) { + items.push(...usersMatchStart); + } + + // Remote Channels that start with + if (items.length < MAX_RESULTS) { + items.push(...remoteChannels.startWith); + } + + // Users & Channels that matches + if (items.length < MAX_RESULTS) { + const sortedByAlpha = [...usersMatch, ...remoteChannels.matches]. + sort(sortByUserOrChannel.bind(null, locale, teammateDisplayNameSetting)); + items.push(...sortedByAlpha.slice(0, MAX_RESULTS + 1)); + } + + // Archived channels + if (items.length < MAX_RESULTS) { + const archivedAlpha = [...archivedChannels, ...remoteChannels.archived]. + sort(sortChannelsByDisplayName.bind(null, locale)); + items.push(...archivedAlpha.slice(0, MAX_RESULTS + 1)); + } + + return [...new Set(items)].slice(0, MAX_RESULTS + 1); + }, [archivedChannels, channelsMatchStart, channelsMatch, remoteChannels, usersMatch, usersMatchStart, locale, teammateDisplayNameSetting]); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }); + + useEffect(() => { + bounce.current = debounce(search, 250); + bounce.current(); + return () => { + if (bounce.current) { + bounce.current.cancel(); + } + }; + }, [term]); return ( { contentContainerStyle={flatListStyle} keyboardDismissMode='interactive' keyboardShouldPersistTaps='handled' - ListEmptyComponent={renderNoResults} + ListEmptyComponent={renderEmpty} renderItem={renderItem} data={data} showsVerticalScrollIndicator={false} @@ -81,4 +304,4 @@ const UnfilteredList = ({close, keyboardHeight, showTeamName, term}: Props) => { ); }; -export default UnfilteredList; +export default FilteredList; diff --git a/app/screens/find_channels/filtered_list/index.ts b/app/screens/find_channels/filtered_list/index.ts index 4084d2f00..2e9fe7ad8 100644 --- a/app/screens/find_channels/filtered_list/index.ts +++ b/app/screens/find_channels/filtered_list/index.ts @@ -3,12 +3,17 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {of as of$} from 'rxjs'; +import {combineLatest, of as of$} from 'rxjs'; import {switchMap} from 'rxjs/operators'; +import {General} from '@constants'; +import {observeArchiveChannelsByTerm, observeDirectChannelsByTerm, observeJoinedChannelsByTerm, observeNotDirectChannelsByTerm} from '@queries/servers/channel'; +import {observeConfig, observeCurrentTeamId} from '@queries/servers/system'; import {queryJoinedTeams} from '@queries/servers/team'; +import {observeTeammateNameDisplay} from '@queries/servers/user'; +import {retrieveChannels} from '@screens/find_channels/utils'; -import FilteredList from './filtered_list'; +import FilteredList, {MAX_RESULTS} from './filtered_list'; import type {WithDatabaseArgs} from '@typings/database/database'; @@ -16,11 +21,50 @@ type EnhanceProps = WithDatabaseArgs & { term: string; } -const enhanced = withObservables([], ({database}: EnhanceProps) => { - const teamsCount = queryJoinedTeams(database).observeCount(); +const enhanced = withObservables(['term'], ({database, term}: EnhanceProps) => { + const teamIds = queryJoinedTeams(database).observe().pipe( + // eslint-disable-next-line max-nested-callbacks + switchMap((teams) => of$(new Set(teams.map((t) => t.id)))), + ); + const joinedChannelsMatchStart = observeJoinedChannelsByTerm(database, term, MAX_RESULTS, true); + const joinedChannelsMatch = observeJoinedChannelsByTerm(database, term, MAX_RESULTS); + const directChannelsMatchStart = observeDirectChannelsByTerm(database, term, MAX_RESULTS, true); + const directChannelsMatch = observeDirectChannelsByTerm(database, term, MAX_RESULTS); + + const channelsMatchStart = combineLatest([joinedChannelsMatchStart, directChannelsMatchStart]).pipe( + switchMap((matchStart) => { + return retrieveChannels(database, matchStart.flat(), true); + }), + ); + + const channelsMatch = combineLatest([joinedChannelsMatch, directChannelsMatch]).pipe( + switchMap((matched) => retrieveChannels(database, matched.flat(), true)), + ); + + const archivedChannels = observeArchiveChannelsByTerm(database, term, MAX_RESULTS).pipe( + switchMap((archived) => retrieveChannels(database, archived)), + ); + + const usersMatchStart = observeNotDirectChannelsByTerm(database, term, MAX_RESULTS, true); + const usersMatch = observeNotDirectChannelsByTerm(database, term, MAX_RESULTS); + + const restrictDirectMessage = observeConfig(database).pipe( + switchMap((cfg) => of$(cfg?.RestrictDirectMessage !== General.RESTRICT_DIRECT_MESSAGE_ANY)), + ); + + const teammateDisplayNameSetting = observeTeammateNameDisplay(database); return { - showTeamName: teamsCount.pipe(switchMap((count) => of$(count > 1))), + archivedChannels, + channelsMatch, + channelsMatchStart, + currentTeamId: observeCurrentTeamId(database), + restrictDirectMessage, + showTeamName: teamIds.pipe(switchMap((ids) => of$(ids.size > 1))), + teamIds, + teammateDisplayNameSetting, + usersMatchStart, + usersMatch, }; }); diff --git a/app/screens/find_channels/filtered_list/remote_channel_item/index.ts b/app/screens/find_channels/filtered_list/remote_channel_item/index.ts new file mode 100644 index 000000000..63676e062 --- /dev/null +++ b/app/screens/find_channels/filtered_list/remote_channel_item/index.ts @@ -0,0 +1,34 @@ +// 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 React from 'react'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {observeTeam} from '@queries/servers/team'; + +import RemoteChannelItem from './remote_channel_item'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type EnhanceProps = WithDatabaseArgs & { + channel: Channel; + showTeamName?: boolean; +} + +const enhance = withObservables(['channel', 'showTeamName'], ({channel, database, showTeamName}: EnhanceProps) => { + let teamDisplayName = of$(''); + if (channel.team_id && showTeamName) { + teamDisplayName = observeTeam(database, channel.team_id).pipe( + switchMap((team) => of$(team?.displayName || '')), + ); + } + + return { + teamDisplayName, + }; +}); + +export default React.memo(withDatabase(enhance(RemoteChannelItem))); diff --git a/app/screens/find_channels/filtered_list/remote_channel_item/remote_channel_item.tsx b/app/screens/find_channels/filtered_list/remote_channel_item/remote_channel_item.tsx new file mode 100644 index 000000000..901df2017 --- /dev/null +++ b/app/screens/find_channels/filtered_list/remote_channel_item/remote_channel_item.tsx @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo} from 'react'; +import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'; + +import ChannelIcon from '@components/channel_icon'; +import {useTheme} from '@context/theme'; +import {useIsTablet} from '@hooks/device'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; + +type Props = { + onPress: (channelId: string, displayName: string) => void; + channel: Channel; + teamDisplayName?: string; + testID?: string; +} + +export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flexDirection: 'row', + paddingHorizontal: 0, + minHeight: 44, + alignItems: 'center', + marginVertical: 2, + }, + wrapper: { + flex: 1, + flexDirection: 'row', + }, + text: { + marginTop: -1, + color: theme.centerChannelColor, + paddingLeft: 12, + paddingRight: 20, + ...typography('Body', 200, 'Regular'), + }, + teamName: { + color: changeOpacity(theme.centerChannelColor, 0.64), + paddingLeft: 12, + marginTop: 4, + ...typography('Body', 75), + }, + teamNameTablet: { + marginLeft: -12, + paddingLeft: 0, + marginTop: 0, + paddingBottom: 0, + top: 5, + }, +})); + +export const textStyle = StyleSheet.create({ + bright: typography('Body', 200, 'SemiBold'), + regular: typography('Body', 200, 'Regular'), +}); + +const RemoteChannelItem = ({onPress, channel, teamDisplayName, testID}: Props) => { + const theme = useTheme(); + const isTablet = useIsTablet(); + const styles = getStyleSheet(theme); + const height = (teamDisplayName && !isTablet) ? 58 : 44; + + const handleOnPress = useCallback(() => { + onPress(channel.id, channel.display_name); + }, [channel]); + + const containerStyle = useMemo(() => [ + styles.container, + {minHeight: height}, + ], + [height, styles]); + + return ( + + <> + + + 0} + name={channel.name} + shared={channel.shared} + size={24} + type={channel.type} + /> + + + {channel.display_name} + + {Boolean(teamDisplayName) && !isTablet && + + {teamDisplayName} + + } + + {Boolean(teamDisplayName) && isTablet && + + {teamDisplayName} + + } + + + + + ); +}; + +export default RemoteChannelItem; diff --git a/app/screens/find_channels/filtered_list/user_item/index.ts b/app/screens/find_channels/filtered_list/user_item/index.ts new file mode 100644 index 000000000..3eab004ec --- /dev/null +++ b/app/screens/find_channels/filtered_list/user_item/index.ts @@ -0,0 +1,26 @@ +// 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 React from 'react'; + +import {observeCurrentUserId} from '@queries/servers/system'; +import {observeTeammateNameDisplay} from '@queries/servers/user'; + +import UserItem from './user_item'; + +import type {WithDatabaseArgs} from '@typings/database/database'; +import type UserModel from '@typings/database/models/servers/user'; + +type EnhanceProps = WithDatabaseArgs & { + user: UserModel; +} + +const enhance = withObservables(['user'], ({database, user}: EnhanceProps) => ({ + currentUserId: observeCurrentUserId(database), + teammateDisplayNameSetting: observeTeammateNameDisplay(database), + user: user.observe(), +})); + +export default React.memo(withDatabase(enhance(UserItem))); diff --git a/app/screens/find_channels/filtered_list/user_item/user_item.tsx b/app/screens/find_channels/filtered_list/user_item/user_item.tsx new file mode 100644 index 000000000..5899ebc9b --- /dev/null +++ b/app/screens/find_channels/filtered_list/user_item/user_item.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import {Text, TouchableOpacity, View} from 'react-native'; + +import CustomStatus from '@components/channel_item/custom_status'; +import ProfilePicture from '@components/profile_picture'; +import {useTheme} from '@context/theme'; +import {makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; +import {displayUsername} from '@utils/user'; + +import type UserModel from '@typings/database/models/servers/user'; + +type Props = { + currentUserId: string; + onPress: (channelId: string, displayName: string) => void; + teammateDisplayNameSetting?: string; + testID?: string; + user: UserModel; +} + +export const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + flexDirection: 'row', + paddingHorizontal: 0, + height: 44, + alignItems: 'center', + marginVertical: 2, + }, + wrapper: { + flex: 1, + flexDirection: 'row', + }, + text: { + marginTop: -1, + color: theme.centerChannelColor, + paddingLeft: 12, + paddingRight: 20, + ...typography('Body', 200, 'Regular'), + }, + avatar: {marginLeft: 4}, + status: { + backgroundColor: theme.centerChannelBg, + borderWidth: 0, + }, +})); + +const UserItem = ({currentUserId, onPress, teammateDisplayNameSetting, testID, user}: Props) => { + const {formatMessage, locale} = useIntl(); + const theme = useTheme(); + const styles = getStyleSheet(theme); + const isOwnDirectMessage = currentUserId === user.id; + const displayName = displayUsername(user, locale, teammateDisplayNameSetting); + + const handleOnPress = useCallback(() => { + onPress(user.id, displayName); + }, [user.id, displayName, onPress]); + + return ( + + <> + + + + + + + + {isOwnDirectMessage ? formatMessage({id: 'channel_header.directchannel.you', defaultMessage: '{displayName} (you)'}, {displayName}) : displayName} + + + + + + + + ); +}; + +export default UserItem; diff --git a/app/screens/find_channels/index.tsx b/app/screens/find_channels/index.tsx index b21dca031..ad191b6e8 100644 --- a/app/screens/find_channels/index.tsx +++ b/app/screens/find_channels/index.tsx @@ -43,6 +43,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ const FindChannels = ({closeButtonId, componentId}: Props) => { const theme = useTheme(); const [term, setTerm] = useState(''); + const [loading, setLoading] = useState(false); const styles = getStyleSheet(theme); const color = useMemo(() => changeOpacity(theme.centerChannelColor, 0.72), [theme]); const keyboardHeight = useKeyboardHeight(); @@ -66,6 +67,9 @@ const FindChannels = ({closeButtonId, componentId}: Props) => { const onChangeText = useCallback((text) => { setTerm(text); + if (!text) { + setLoading(false); + } }, []); useEffect(() => { @@ -83,16 +87,17 @@ const FindChannels = ({closeButtonId, componentId}: Props) => { {term === '' && } @@ -107,6 +112,8 @@ const FindChannels = ({closeButtonId, componentId}: Props) => { } diff --git a/app/screens/find_channels/unfiltered_list/index.ts b/app/screens/find_channels/unfiltered_list/index.ts index 382e61388..a5925f109 100644 --- a/app/screens/find_channels/unfiltered_list/index.ts +++ b/app/screens/find_channels/unfiltered_list/index.ts @@ -9,26 +9,21 @@ import {switchMap} from 'rxjs/operators'; import {queryMyChannelsByUnread} from '@queries/servers/channel'; import {queryJoinedTeams} from '@queries/servers/team'; +import {retrieveChannels} from '@screens/find_channels/utils'; import UnfilteredList from './unfiltered_list'; import type {WithDatabaseArgs} from '@typings/database/database'; import type ChannelModel from '@typings/database/models/servers/channel'; -import type MyChannelModel from '@typings/database/models/servers/my_channel'; const MAX_UNREAD_CHANNELS = 10; const MAX_CHANNELS = 20; -const observeChannels = async (myChannels: MyChannelModel[]) => { - const channels = await Promise.all(myChannels.map((m) => m.channel.fetch())); - return channels.filter((c): c is ChannelModel => c !== null); -}; - const observeRecentChannels = (database: Database, unreads: ChannelModel[]) => { const count = MAX_CHANNELS - unreads.length; const unreadIds = unreads.map((u) => u.id); return queryMyChannelsByUnread(database, false, 'last_viewed_at', count, unreadIds).observe().pipe( - switchMap((myChannels) => observeChannels(myChannels)), + switchMap((myChannels) => retrieveChannels(database, myChannels, true)), ); }; @@ -37,7 +32,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { const unreadChannels = queryMyChannelsByUnread(database, true, 'last_post_at', MAX_UNREAD_CHANNELS). observeWithColumns(['last_post_at']).pipe( - switchMap((myChannels) => observeChannels(myChannels)), + switchMap((myChannels) => retrieveChannels(database, myChannels)), ); const recentChannels = unreadChannels.pipe( diff --git a/app/screens/find_channels/utils.ts b/app/screens/find_channels/utils.ts new file mode 100644 index 000000000..11f504bbe --- /dev/null +++ b/app/screens/find_channels/utils.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Database, Q} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; + +import {MM_TABLES} from '@constants/database'; + +import type ChannelModel from '@typings/database/models/servers/channel'; +import type MyChannelModel from '@typings/database/models/servers/my_channel'; + +const {SERVER: {CHANNEL, MY_CHANNEL}} = MM_TABLES; + +export const retrieveChannels = (database: Database, myChannels: MyChannelModel[], orderedByLastViewedAt = false) => { + const ids = myChannels.map((m) => m.id); + if (ids.length) { + const idsStr = `'${ids.join("','")}'`; + const order = orderedByLastViewedAt ? 'order by my.last_viewed_at desc' : ''; + return database.get(CHANNEL).query( + Q.unsafeSqlQuery(`select distinct c.* from ${MY_CHANNEL} my + inner join ${CHANNEL} c on c.id=my.id and c.id in (${idsStr}) + ${order}`), + ).observe(); + } + + return of$([]); +}; diff --git a/app/screens/home/channel_list/categories_list/categories/body/index.ts b/app/screens/home/channel_list/categories_list/categories/body/index.ts index e7251baf7..09d45f5c8 100644 --- a/app/screens/home/channel_list/categories_list/categories/body/index.ts +++ b/app/screens/home/channel_list/categories_list/categories/body/index.ts @@ -117,7 +117,7 @@ const enhance = withObservables(['category', 'locale'], ({category, locale, data const dmMap = (p: PreferenceModel) => getDirectChannelName(p.name, currentUserId); const hiddenDmIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DIRECT_CHANNEL_SHOW, undefined, 'false'). - observe().pipe( + observeWithColumns(['value']).pipe( switchMap((prefs: PreferenceModel[]) => { const names = prefs.map(dmMap); const channels = queryChannelsByNames(database, names).observe(); @@ -129,15 +129,16 @@ const enhance = withObservables(['category', 'locale'], ({category, locale, data ); const hiddenGmIds = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_GROUP_CHANNEL_SHOW, undefined, 'false'). - observe().pipe(switchMap(mapPrefName)); + observeWithColumns(['value']).pipe(switchMap(mapPrefName)); let limit = of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); if (category.type === DMS_CATEGORY) { - limit = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS).observe().pipe( - switchMap((val) => { - return val[0] ? of$(parseInt(val[0].value, 10)) : of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); - }), - ); + limit = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.CHANNEL_SIDEBAR_LIMIT_DMS). + observeWithColumns(['value']).pipe( + switchMap((val) => { + return val[0] ? of$(parseInt(val[0].value, 10)) : of$(Preferences.CHANNEL_SIDEBAR_LIMIT_DMS_DEFAULT); + }), + ); } const hiddenChannelIds = combineLatest([hiddenDmIds, hiddenGmIds]).pipe(switchMap( diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index a33876d52..b26b3a777 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -15,6 +15,7 @@ class EphemeralStore { // of the extra computation time. We use this to track the events that are being handled // and make sure we only handle one. private addingTeam = new Set(); + private joiningChannels = new Set(); addNavigationComponentId = (componentId: string) => { this.addToNavigationComponentIdStack(componentId); @@ -121,6 +122,19 @@ class EphemeralStore { } }; + // Ephemeral control when joining a channel locally + addJoiningChannel = (channelId: string) => { + this.joiningChannels.add(channelId); + }; + + isJoiningChannel = (channelId: string) => { + return this.joiningChannels.has(channelId); + }; + + removeJoiningChanel = (channelId: string) => { + this.joiningChannels.delete(channelId); + }; + startAddingToTeam = (teamId: string) => { this.addingTeam.add(teamId); }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 863424caa..74613cc93 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -223,7 +223,6 @@ "global_threads.options.mark_as_read": "Mark as Read", "global_threads.options.open_in_channel": "Open in Channel", "global_threads.options.title": "THREAD ACTIONS", - "global_threads.options.unfollow": "Unfollow Thread", "global_threads.unreads": "Unread Threads", "home.header.plus_menu": "Options", "interactive_dialog.submit": "Submit", @@ -311,6 +310,7 @@ "mobile.custom_status.clear_after": "Clear After", "mobile.custom_status.clear_after.title": "Clear Custom Status After", "mobile.custom_status.modal_confirm": "Done", + "mobile.direct_message.error": "We couldn't open a DM with {displayName}.", "mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n", "mobile.document_preview.failed_title": "Open Document failed", "mobile.downloader.disabled_description": "File downloads are disabled on this server. Please contact your System Admin for more details.\n", @@ -335,7 +335,7 @@ "mobile.gallery.title": "{index} of {total}", "mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.", "mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos", - "mobile.join_channel.error": "We couldn't join the channel {displayName}. Please check your connection and try again.", + "mobile.join_channel.error": "We couldn't join the channel {displayName}.", "mobile.link.error.text": "Unable to open the link.", "mobile.link.error.title": "Error", "mobile.login_options.cant_heading": "Can't Log In", @@ -567,6 +567,7 @@ "thread.header.thread_dm": "Direct Message Thread", "thread.header.thread_in": "in {channelName}", "thread.noReplies": "No replies yet", + "thread.options.title": "THREAD ACTIONS", "thread.repliesCount": "{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}", "threads": "Threads", "threads.deleted": "Original Message Deleted",