diff --git a/app/actions/local/category.ts b/app/actions/local/category.ts index 9cc204abb..210e4611b 100644 --- a/app/actions/local/category.ts +++ b/app/actions/local/category.ts @@ -3,10 +3,16 @@ import {Model} from '@nozbe/watermelondb'; +import {CHANNELS_CATEGORY, DMS_CATEGORY} from '@constants/categories'; import DatabaseManager from '@database/manager'; import {prepareCategories, prepareCategoryChannels, queryCategoriesByTeamIds, getCategoryById} from '@queries/servers/categories'; +import {getCurrentUserId} from '@queries/servers/system'; +import {queryMyTeams} from '@queries/servers/team'; +import {isDMorGM} from '@utils/channel'; import {pluckUnique} from '@utils/helpers'; +import type ChannelModel from '@typings/database/models/servers/channel'; + export const deleteCategory = async (serverUrl: string, categoryId: string) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { @@ -109,3 +115,49 @@ export const toggleCollapseCategory = async (serverUrl: string, categoryId: stri return {error}; } }; + +export async function addChannelToDefaultCategory(serverUrl: string, channel: Channel | ChannelModel, prepareRecordsOnly = false) { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + const {database} = operator; + + const teamId = 'teamId' in channel ? channel.teamId : channel.team_id; + const userId = await getCurrentUserId(database); + if (!userId) { + return {error: 'no current user id'}; + } + + const models: Model[] = []; + const categoriesWithChannels: CategoryWithChannels[] = []; + + if (isDMorGM(channel)) { + const allTeamIds = await queryMyTeams(database).fetchIds(); + const categories = await queryCategoriesByTeamIds(database, allTeamIds).fetch(); + const channelCategories = categories.filter((c) => c.type === DMS_CATEGORY); + for await (const cc of channelCategories) { + const cwc = await cc.toCategoryWithChannels(); + cwc.channel_ids.unshift(channel.id); + categoriesWithChannels.push(cwc); + } + } else { + const categories = await queryCategoriesByTeamIds(database, [teamId]).fetch(); + const channelCategory = categories.find((c) => c.type === CHANNELS_CATEGORY); + if (channelCategory) { + const cwc = await channelCategory.toCategoryWithChannels(); + cwc.channel_ids.unshift(channel.id); + categoriesWithChannels.push(cwc); + } + + const ccModels = await prepareCategoryChannels(operator, categoriesWithChannels); + models.push(...ccModels); + } + + if (models.length && !prepareRecordsOnly) { + await operator.batchRecords(models); + } + + return {models}; +} diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index bbdd206b0..6dc69ad38 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -5,7 +5,6 @@ import {Model} from '@nozbe/watermelondb'; import {DeviceEventEmitter} from 'react-native'; import {General, Navigation as NavigationConstants, Preferences, Screens} from '@constants'; -import {CHANNELS_CATEGORY, DMS_CATEGORY} from '@constants/categories'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; @@ -17,12 +16,10 @@ import { } from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {prepareCommonSystemValues, PrepareCommonSystemValuesArgs, getCommonSystemValues, getCurrentTeamId, setCurrentChannelId, getCurrentUserId} from '@queries/servers/system'; -import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, queryMyTeams, removeChannelFromTeamHistory} from '@queries/servers/team'; +import {addChannelToTeamHistory, addTeamToTeamHistory, getTeamById, removeChannelFromTeamHistory} from '@queries/servers/team'; import {getCurrentUser, queryUsersById} from '@queries/servers/user'; import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; -import {makeCategoryChannelId, makeCategoryId} from '@utils/categories'; -import {isDMorGM} from '@utils/channel'; import {isTablet} from '@utils/helpers'; import {setThemeDefaults, updateThemeIfNeeded} from '@utils/theme'; import {displayGroupMessageName, displayUsername, getUserIdFromChannelName} from '@utils/user'; @@ -428,54 +425,6 @@ export async function updateChannelsDisplayName(serverUrl: string, channels: Cha return {models}; } -export async function addChannelToDefaultCategory(serverUrl: string, channel: Channel | ChannelModel, prepareRecordsOnly = false) { - const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; - if (!operator) { - return {error: `${serverUrl} database not found`}; - } - - const {database} = operator; - - const teamId = 'teamId' in channel ? channel.teamId : channel.team_id; - const userId = await getCurrentUserId(database); - if (!userId) { - return {error: 'no current user id'}; - } - - if (!isDMorGM(channel)) { - const models = await operator.handleCategoryChannels({categoryChannels: [{ - category_id: makeCategoryId(CHANNELS_CATEGORY, userId, teamId), - channel_id: channel.id, - sort_order: 0, - id: makeCategoryChannelId(teamId, channel.id), - }], - prepareRecordsOnly}); - - return {models}; - } - - const allTeams = await queryMyTeams(database).fetch(); - const models = ( - await Promise.all( - allTeams.map( - (t) => operator.handleCategoryChannels({categoryChannels: [{ - category_id: makeCategoryId(DMS_CATEGORY, userId, t.id), - channel_id: channel.id, - sort_order: 0, - id: makeCategoryChannelId(t.id, channel.id), - }], - prepareRecordsOnly: true}), - ), - ) - ).flat(); - - if (models.length && !prepareRecordsOnly) { - operator.batchRecords(models); - } - - return {models}; -} - export async function showUnreadChannelsOnly(serverUrl: string, onlyUnreads: boolean) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { diff --git a/app/actions/local/thread.ts b/app/actions/local/thread.ts index 460ed4136..035f34ed0 100644 --- a/app/actions/local/thread.ts +++ b/app/actions/local/thread.ts @@ -38,18 +38,20 @@ export const switchToGlobalThreads = async (serverUrl: string, teamId?: string, } try { - await setCurrentTeamAndChannelId(operator, teamId, ''); + await setCurrentTeamAndChannelId(operator, teamIdToUse, ''); const history = await addChannelToTeamHistory(operator, teamIdToUse, Screens.GLOBAL_THREADS, true); models.push(...history); + + if (!prepareRecordsOnly) { + await operator.batchRecords(models); + } + const isTabletDevice = await isTablet(); if (isTabletDevice) { DeviceEventEmitter.emit(Navigation.NAVIGATION_HOME, Screens.GLOBAL_THREADS); } else { goToScreen(Screens.GLOBAL_THREADS, '', {}, {topBar: {visible: false}}); } - if (!prepareRecordsOnly) { - await operator.batchRecords(models); - } } catch (error) { return {error}; } diff --git a/app/actions/remote/category.ts b/app/actions/remote/category.ts index f1a5869f0..c4e92a53e 100644 --- a/app/actions/remote/category.ts +++ b/app/actions/remote/category.ts @@ -2,7 +2,14 @@ // See LICENSE.txt for license information. import {storeCategories} from '@actions/local/category'; +import {General} from '@constants'; +import {CHANNELS_CATEGORY, DMS_CATEGORY, FAVORITES_CATEGORY} from '@constants/categories'; +import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; +import {getChannelCategory, queryCategoriesByTeamIds} from '@queries/servers/categories'; +import {getChannelById} from '@queries/servers/channel'; +import {getCurrentTeamId} from '@queries/servers/system'; +import {showFavoriteChannelSnackbar} from '@utils/snack_bar'; import {forceLogoutIfNecessary} from './session'; @@ -34,3 +41,75 @@ export const fetchCategories = async (serverUrl: string, teamId: string, prune = return {error}; } }; + +export const toggleFavoriteChannel = async (serverUrl: string, channelId: string, showSnackBar = false) => { + const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; + if (!operator) { + return {error: `${serverUrl} database not found`}; + } + + let client: Client; + try { + client = NetworkManager.getClient(serverUrl); + } catch (error) { + return {error}; + } + + try { + const {database} = operator; + const channel = await getChannelById(database, channelId); + if (!channel) { + return {error: 'channel not found'}; + } + + const currentTeamId = await getCurrentTeamId(database); + const teamId = channel?.teamId || currentTeamId; + const currentCategory = await getChannelCategory(database, teamId, channelId); + + if (!currentCategory) { + return {error: 'channel does not belong to a category'}; + } + + const categories = await queryCategoriesByTeamIds(database, [teamId]).fetch(); + const isFavorited = currentCategory.type === FAVORITES_CATEGORY; + let targetWithChannels: CategoryWithChannels; + let favoriteWithChannels: CategoryWithChannels; + + if (isFavorited) { + const categoryType = (channel.type === General.DM_CHANNEL || channel.type === General.GM_CHANNEL) ? DMS_CATEGORY : CHANNELS_CATEGORY; + const targetCategory = categories.find((c) => c.type === categoryType); + if (!targetCategory) { + return {error: 'target category not found'}; + } + targetWithChannels = await targetCategory.toCategoryWithChannels(); + targetWithChannels.channel_ids.unshift(channelId); + + favoriteWithChannels = await currentCategory.toCategoryWithChannels(); + const channelIndex = favoriteWithChannels.channel_ids.indexOf(channelId); + favoriteWithChannels.channel_ids.splice(channelIndex, 1); + } else { + const favoritesCategory = categories.find((c) => c.type === FAVORITES_CATEGORY); + if (!favoritesCategory) { + return {error: 'No favorites category'}; + } + favoriteWithChannels = await favoritesCategory.toCategoryWithChannels(); + favoriteWithChannels.channel_ids.unshift(channelId); + + targetWithChannels = await currentCategory.toCategoryWithChannels(); + const channelIndex = targetWithChannels.channel_ids.indexOf(channelId); + targetWithChannels.channel_ids.splice(channelIndex, 1); + } + + await client.updateChannelCategories('me', teamId, [targetWithChannels, favoriteWithChannels]); + + if (showSnackBar) { + const onUndo = () => toggleFavoriteChannel(serverUrl, channelId, false); + showFavoriteChannelSnackbar(!isFavorited, onUndo); + } + + return {data: true}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 11ad7d489..1d2e91e60 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -5,8 +5,8 @@ import {Model} from '@nozbe/watermelondb'; import {IntlShape} from 'react-intl'; -import {storeCategories} from '@actions/local/category'; -import {addChannelToDefaultCategory, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; +import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category'; +import {removeCurrentUserFromChannel, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; import {switchToGlobalThreads} from '@actions/local/thread'; import {General, Preferences, Screens} from '@constants'; import DatabaseManager from '@database/manager'; @@ -15,20 +15,22 @@ import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; import NetworkManager from '@managers/network_manager'; import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds} from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; -import {getCommonSystemValues, getCurrentTeamId, getCurrentUserId} from '@queries/servers/system'; +import {getCommonSystemValues, getCurrentTeamId, getCurrentUserId, setCurrentChannelId} from '@queries/servers/system'; 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'; +import {isTablet} from '@utils/helpers'; import {showMuteChannelSnackbar} from '@utils/snack_bar'; import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import {displayGroupMessageName, displayUsername} from '@utils/user'; import {fetchPostsForChannel} from './post'; +import {setDirectChannelVisible} from './preference'; import {fetchRolesIfNeeded} from './role'; import {forceLogoutIfNecessary} from './session'; import {addUserToTeam, fetchTeamByName, removeUserFromTeam} from './team'; -import {fetchProfilesPerChannels, fetchUsersByIds} from './user'; +import {fetchProfilesPerChannels, fetchUsersByIds, updateUsersNoLongerVisible} from './user'; import type {Client} from '@client/rest'; import type ChannelModel from '@typings/database/models/servers/channel'; @@ -157,6 +159,7 @@ export async function createChannel(serverUrl: string, displayName: string, purp return {channel: channelData}; } catch (error) { EphemeralStore.creatingChannel = false; + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); return {error}; } } @@ -198,10 +201,65 @@ export async function patchChannel(serverUrl: string, channelPatch: Partial toggleMuteChannel(serverUrl, channelId, false); - showMuteChannelSnackbar(onUndo); + showMuteChannelSnackbar(mark_unread === 'mention', onUndo); } return { diff --git a/app/actions/remote/preference.ts b/app/actions/remote/preference.ts index 725558e13..f8a2b8073 100644 --- a/app/actions/remote/preference.ts +++ b/app/actions/remote/preference.ts @@ -1,15 +1,17 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Preferences} from '@constants'; +import {General, Preferences} from '@constants'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; +import {getChannelById} from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {getCurrentUserId} from '@queries/servers/system'; +import {getUserIdFromChannelName} from '@utils/user'; import {forceLogoutIfNecessary} from './session'; -const {CATEGORY_FAVORITE_CHANNEL, CATEGORY_SAVED_POST} = Preferences; +const {CATEGORY_DIRECT_CHANNEL_SHOW, CATEGORY_GROUP_CHANNEL_SHOW, CATEGORY_FAVORITE_CHANNEL, CATEGORY_SAVED_POST} = Preferences; export type MyPreferencesRequest = { preferences?: PreferenceType[]; @@ -149,3 +151,31 @@ export const deleteSavedPost = async (serverUrl: string, postId: string) => { return {error}; } }; + +export const setDirectChannelVisible = async (serverUrl: string, channelId: string, visible = true) => { + const database = DatabaseManager.serverDatabases[serverUrl]?.database; + if (!database) { + return {error: `${serverUrl} database not found`}; + } + + try { + const channel = await getChannelById(database, channelId); + if (channel?.type === General.DM_CHANNEL || channel?.type === General.GM_CHANNEL) { + const userId = await getCurrentUserId(database); + const category = channel.type === General.DM_CHANNEL ? CATEGORY_DIRECT_CHANNEL_SHOW : CATEGORY_GROUP_CHANNEL_SHOW; + const name = channel.type === General.DM_CHANNEL ? getUserIdFromChannelName(userId, channel.name) : channelId; + const pref: PreferenceType = { + user_id: userId, + category, + name, + value: visible.toString(), + }; + return savePreference(serverUrl, [pref]); + } + + return {error: undefined}; + } catch (error) { + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +}; diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index 4e453cdd1..0d7c951cd 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -4,17 +4,13 @@ import {Model} from '@nozbe/watermelondb'; import {DeviceEventEmitter} from 'react-native'; +import {addChannelToDefaultCategory} from '@actions/local/category'; import { - addChannelToDefaultCategory, - markChannelAsViewed, - removeCurrentUserFromChannel, - setChannelDeleteAt, - storeMyChannelsForTeam, - switchToChannel, - updateChannelInfoFromChannel, - updateMyChannelFromWebsocket} from '@actions/local/channel'; + markChannelAsViewed, removeCurrentUserFromChannel, setChannelDeleteAt, + storeMyChannelsForTeam, updateChannelInfoFromChannel, updateMyChannelFromWebsocket, +} from '@actions/local/channel'; import {switchToGlobalThreads} from '@actions/local/thread'; -import {fetchMissingSidebarInfo, fetchMyChannel, fetchChannelStats, fetchChannelById} from '@actions/remote/channel'; +import {fetchMissingSidebarInfo, fetchMyChannel, fetchChannelStats, fetchChannelById, switchToChannelById} from '@actions/remote/channel'; import {fetchPostsForChannel} from '@actions/remote/post'; import {fetchRolesIfNeeded} from '@actions/remote/role'; import {fetchUsersByIds, updateUsersNoLongerVisible} from '@actions/remote/user'; @@ -101,8 +97,8 @@ export async function handleChannelUpdatedEvent(serverUrl: string, msg: any) { return; } - const updatedChannel = JSON.parse(msg.data.channel); try { + const updatedChannel = JSON.parse(msg.data.channel); const models: Model[] = await operator.handleChannel({channels: [updatedChannel], prepareRecordsOnly: true}); const infoModel = await updateChannelInfoFromChannel(serverUrl, updatedChannel, true); if (infoModel.model) { @@ -241,15 +237,14 @@ export async function handleUserAddedToChannelEvent(serverUrl: string, msg: any) return; } - const {database} = operator; - - const currentUser = await getCurrentUser(database); - const userId = msg.data.user_id || msg.broadcast.userId; - const channelId = msg.data.channel_id || msg.broadcast.channel_id; - const {team_id: teamId} = msg.data; - const models: Model[] = []; - try { + const {database} = operator; + const currentUser = await getCurrentUser(database); + const userId = msg.data.user_id || msg.broadcast.userId; + const channelId = msg.data.channel_id || msg.broadcast.channel_id; + const {team_id: teamId} = msg.data; + const models: Model[] = []; + if (userId === currentUser?.id) { if (EphemeralStore.isAddingToTeam(teamId) || EphemeralStore.isJoiningChannel(channelId)) { return; @@ -318,76 +313,76 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg: return; } - const {database} = operator; + try { + // Depending on who was removed, the ids may come from one place dataset or the other. + const userId = msg.data.user_id || msg.broadcast.user_id; + const channelId = msg.data.channel_id || msg.broadcast.channel_id; - const channel = await getCurrentChannel(database); - const user = await getCurrentUser(database); - if (!user) { - return; - } - - // Depending on who was removed, the ids may come from one place dataset or the other. - const userId = msg.data.user_id || msg.broadcast.user_id; - const channelId = msg.data.channel_id || msg.broadcast.channel_id; - - const models: Model[] = []; - - if (user.isGuest) { - const {models: updateVisibleModels} = await updateUsersNoLongerVisible(serverUrl, true); - if (updateVisibleModels) { - models.push(...updateVisibleModels); - } - } - - if (user.id === userId) { - const {models: removeUserModels} = await removeCurrentUserFromChannel(serverUrl, channelId, true); - if (removeUserModels) { - models.push(...removeUserModels); + if (EphemeralStore.isLeavingChannel(channelId)) { + return; } - if (channel && channel.id === channelId) { - const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); + const {database} = operator; + const channel = await getCurrentChannel(database); + const user = await getCurrentUser(database); + if (!user) { + return; + } - if (currentServer?.url === serverUrl) { - DeviceEventEmitter.emit(Events.LEAVE_CHANNEL); - await dismissAllModals(); - await popToRoot(); + const models: Model[] = []; - if (await isTablet()) { - let tId = channel.teamId; - if (!tId) { - tId = await getCurrentTeamId(database); - } - const channelToJumpTo = await getNthLastChannelFromTeam(database, tId); - if (channelToJumpTo) { - if (channelToJumpTo === Screens.GLOBAL_THREADS) { - const {models: switchToGlobalThreadsModels} = await switchToGlobalThreads(serverUrl, tId, true); - if (switchToGlobalThreadsModels) { - models.push(...switchToGlobalThreadsModels); - } - } else { - const {models: switchChannelModels} = await switchToChannel(serverUrl, channelToJumpTo, '', false, true); - if (switchChannelModels) { - models.push(...switchChannelModels); - } + if (user.isGuest) { + const {models: updateVisibleModels} = await updateUsersNoLongerVisible(serverUrl, true); + if (updateVisibleModels) { + models.push(...updateVisibleModels); + } + } + + if (user.id === userId) { + await removeCurrentUserFromChannel(serverUrl, channelId); + if (channel && channel.id === channelId) { + const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); + + if (currentServer?.url === serverUrl) { + DeviceEventEmitter.emit(Events.LEAVE_CHANNEL, channel.displayName); + await dismissAllModals(); + await popToRoot(); + + if (await isTablet()) { + let tId = channel.teamId; + if (!tId) { + tId = await getCurrentTeamId(database); + } + const channelToJumpTo = await getNthLastChannelFromTeam(database, tId); + if (channelToJumpTo) { + if (channelToJumpTo === Screens.GLOBAL_THREADS) { + const {models: switchToGlobalThreadsModels} = await switchToGlobalThreads(serverUrl, tId, true); + if (switchToGlobalThreadsModels) { + models.push(...switchToGlobalThreadsModels); + } + } else { + switchToChannelById(serverUrl, channelToJumpTo, tId, true); + } + } // TODO else jump to "join a channel" screen https://mattermost.atlassian.net/browse/MM-41051 + } else { + const currentChannelModels = await prepareCommonSystemValues(operator, {currentChannelId: ''}); + if (currentChannelModels?.length) { + models.push(...currentChannelModels); } - } // TODO else jump to "join a channel" screen https://mattermost.atlassian.net/browse/MM-41051 - } else { - const currentChannelModels = await prepareCommonSystemValues(operator, {currentChannelId: ''}); - if (currentChannelModels?.length) { - models.push(...currentChannelModels); } } } + } else { + const {models: deleteMemberModels} = await deleteChannelMembership(operator, userId, channelId, true); + if (deleteMemberModels) { + models.push(...deleteMemberModels); + } } - } else { - const {models: deleteMemberModels} = await deleteChannelMembership(operator, userId, channelId, true); - if (deleteMemberModels) { - models.push(...deleteMemberModels); - } - } - operator.batchRecords(models); + operator.batchRecords(models); + } catch { + // Do nothing + } } export async function handleChannelDeletedEvent(serverUrl: string, msg: WebSocketMessage) { @@ -396,52 +391,58 @@ export async function handleChannelDeletedEvent(serverUrl: string, msg: WebSocke return; } - const {database} = operator; + try { + const {database} = operator; + const {channel_id: channelId, delete_at: deleteAt} = msg.data; + if (EphemeralStore.isLeavingChannel(channelId)) { + return; + } - const currentChannel = await getCurrentChannel(database); - const user = await getCurrentUser(database); - if (!user) { - return; - } + const currentChannel = await getCurrentChannel(database); + const user = await getCurrentUser(database); + if (!user) { + return; + } - const {channel_id: channelId, delete_at: deleteAt} = msg.data; + const config = await getConfig(database); - const config = await getConfig(database); + await setChannelDeleteAt(serverUrl, channelId, deleteAt); - await setChannelDeleteAt(serverUrl, channelId, deleteAt); + if (user.isGuest) { + updateUsersNoLongerVisible(serverUrl); + } - if (user.isGuest) { - updateUsersNoLongerVisible(serverUrl); - } + if (config?.ExperimentalViewArchivedChannels !== 'true') { + await removeCurrentUserFromChannel(serverUrl, channelId); - if (config?.ExperimentalViewArchivedChannels !== 'true') { - removeCurrentUserFromChannel(serverUrl, channelId); + if (currentChannel && currentChannel.id === channelId) { + const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); - if (currentChannel && currentChannel.id === channelId) { - const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database); + if (currentServer?.url === serverUrl) { + DeviceEventEmitter.emit(Events.CHANNEL_ARCHIVED, currentChannel.displayName); + await dismissAllModals(); + await popToRoot(); - if (currentServer?.url === serverUrl) { - DeviceEventEmitter.emit(Events.CHANNEL_DELETED); - await dismissAllModals(); - await popToRoot(); - - if (await isTablet()) { - let tId = currentChannel.teamId; - if (!tId) { - tId = await getCurrentTeamId(database); - } - const channelToJumpTo = await getNthLastChannelFromTeam(database, tId); - if (channelToJumpTo) { - if (channelToJumpTo === Screens.GLOBAL_THREADS) { - switchToGlobalThreads(serverUrl, tId); - return; + if (await isTablet()) { + let tId = currentChannel.teamId; + if (!tId) { + tId = await getCurrentTeamId(database); } - switchToChannel(serverUrl, channelToJumpTo); - } // TODO else jump to "join a channel" screen - } else { - setCurrentChannelId(operator, ''); + const channelToJumpTo = await getNthLastChannelFromTeam(database, tId); + if (channelToJumpTo) { + if (channelToJumpTo === Screens.GLOBAL_THREADS) { + switchToGlobalThreads(serverUrl, tId); + return; + } + switchToChannelById(serverUrl, channelToJumpTo, tId); + } // TODO else jump to "join a channel" screen + } else { + setCurrentChannelId(operator, ''); + } } } } + } catch { + // Do nothing } } diff --git a/app/client/rest/categories.ts b/app/client/rest/categories.ts index 08cdfd977..f940bdeed 100644 --- a/app/client/rest/categories.ts +++ b/app/client/rest/categories.ts @@ -5,6 +5,7 @@ export interface ClientCategoriesMix { getCategories: (userId: string, teamId: string) => Promise; getCategoriesOrder: (userId: string, teamId: string) => Promise; getCategory: (userId: string, teamId: string, categoryId: string) => Promise; + updateChannelCategories: (userId: string, teamId: string, categories: CategoryWithChannels[]) => Promise; } const ClientCategories = (superclass: any) => class extends superclass { @@ -26,6 +27,13 @@ const ClientCategories = (superclass: any) => class extends superclass { {method: 'get'}, ); }; + + updateChannelCategories = async (userId: string, teamId: string, categories: CategoryWithChannels[]) => { + return this.doFetch( + `${this.getCategoriesRoute(userId, teamId)}`, + {method: 'put', body: categories}, + ); + }; }; export default ClientCategories; diff --git a/app/components/channel_actions/add_people_box/index.tsx b/app/components/channel_actions/add_people_box/index.tsx new file mode 100644 index 000000000..7be4b322e --- /dev/null +++ b/app/components/channel_actions/add_people_box/index.tsx @@ -0,0 +1,38 @@ +// 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 {StyleProp, ViewStyle} from 'react-native'; + +import OptionBox from '@components/option_box'; +import {Screens} from '@constants'; +import {dismissBottomSheet, showModal} from '@screens/navigation'; + +type Props = { + channelId: string; + containerStyle?: StyleProp; + testID?: string; +} + +const AddPeopleBox = ({channelId, containerStyle, testID}: Props) => { + const intl = useIntl(); + + const onAddPeople = useCallback(async () => { + await dismissBottomSheet(); + const title = intl.formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'}); + showModal(Screens.CHANNEL_ADD_PEOPLE, title, {channelId}); + }, [intl, channelId]); + + return ( + + ); +}; + +export default AddPeopleBox; diff --git a/app/components/channel_actions/copy_channel_link_box/copy_channel_link_box.tsx b/app/components/channel_actions/copy_channel_link_box/copy_channel_link_box.tsx new file mode 100644 index 000000000..6eb495e10 --- /dev/null +++ b/app/components/channel_actions/copy_channel_link_box/copy_channel_link_box.tsx @@ -0,0 +1,43 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import Clipboard from '@react-native-community/clipboard'; +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import AnimatedOptionBox from '@components/option_box/animated'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; + +type Props = { + channelName?: string; + onAnimationEnd?: () => void; + teamName?: string; + testID?: string; +} + +const CopyChannelLinkBox = ({channelName, onAnimationEnd, teamName, testID}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const serverUrl = useServerUrl(); + + const onCopyLink = useCallback(() => { + Clipboard.setString(`${serverUrl}/${teamName}/channels/${channelName}`); + }, [channelName, teamName, serverUrl]); + + return ( + + ); +}; + +export default CopyChannelLinkBox; diff --git a/app/components/channel_actions/copy_channel_link_box/index.ts b/app/components/channel_actions/copy_channel_link_box/index.ts new file mode 100644 index 000000000..23fffb2e2 --- /dev/null +++ b/app/components/channel_actions/copy_channel_link_box/index.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// 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 {observeChannel} from '@queries/servers/channel'; +import {observeTeam} from '@queries/servers/team'; + +import CopyChannelLinkBox from './copy_channel_link_box'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps) => { + const channel = observeChannel(database, channelId); + const team = channel.pipe( + switchMap((c) => (c?.teamId ? observeTeam(database, c.teamId) : of$(undefined))), + ); + const teamName = team.pipe( + switchMap((t) => of$(t?.name)), + ); + + const channelName = channel.pipe( + switchMap((c) => of$(c?.name)), + ); + return { + channelName, + teamName, + }; +}); + +export default withDatabase(enhanced(CopyChannelLinkBox)); diff --git a/app/components/channel_actions/favorite_box/favorite_box.tsx b/app/components/channel_actions/favorite_box/favorite_box.tsx new file mode 100644 index 000000000..dae9aeb4b --- /dev/null +++ b/app/components/channel_actions/favorite_box/favorite_box.tsx @@ -0,0 +1,44 @@ +// 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 {StyleProp, ViewStyle} from 'react-native'; + +import {toggleFavoriteChannel} from '@actions/remote/category'; +import OptionBox from '@components/option_box'; +import {useServerUrl} from '@context/server'; +import {dismissBottomSheet} from '@screens/navigation'; + +type Props = { + channelId: string; + containerStyle?: StyleProp; + isFavorited: boolean; + showSnackBar?: boolean; + testID?: string; +} + +const FavoriteBox = ({channelId, containerStyle, isFavorited, showSnackBar = false, testID}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + + const handleOnPress = useCallback(async () => { + await dismissBottomSheet(); + toggleFavoriteChannel(serverUrl, channelId, showSnackBar); + }, [serverUrl, channelId, showSnackBar]); + + return ( + + ); +}; + +export default FavoriteBox; diff --git a/app/components/channel_actions/favorite_box/index.ts b/app/components/channel_actions/favorite_box/index.ts new file mode 100644 index 000000000..9c159d58c --- /dev/null +++ b/app/components/channel_actions/favorite_box/index.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// 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 {combineLatestWith, switchMap} from 'rxjs/operators'; + +import {observeIsChannelFavorited} from '@queries/servers/categories'; +import {observeChannel} from '@queries/servers/channel'; +import {observeCurrentTeamId} from '@queries/servers/system'; + +import FavoriteBox from './favorite_box'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps) => { + const currentTeamId = observeCurrentTeamId(database); + const channel = observeChannel(database, channelId); + const isFavorited = channel.pipe( + combineLatestWith(currentTeamId), + switchMap(([c, tId]) => observeIsChannelFavorited(database, c?.teamId || tId, channelId)), + ); + + return { + isFavorited, + }; +}); + +export default withDatabase(enhanced(FavoriteBox)); diff --git a/app/components/channel_actions/info_box/index.tsx b/app/components/channel_actions/info_box/index.tsx new file mode 100644 index 000000000..73b9d4302 --- /dev/null +++ b/app/components/channel_actions/info_box/index.tsx @@ -0,0 +1,51 @@ +// 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 {StyleProp, ViewStyle} from 'react-native'; + +import OptionBox from '@components/option_box'; +import SlideUpPanelItem from '@components/slide_up_panel_item'; +import {Screens} from '@constants'; +import {dismissBottomSheet, showModal} from '@screens/navigation'; + +type Props = { + channelId: string; + containerStyle?: StyleProp; + showAsLabel?: boolean; + testID?: string; +} + +const InfoBox = ({channelId, containerStyle, showAsLabel = false, testID}: Props) => { + const intl = useIntl(); + + const onViewInfo = useCallback(async () => { + await dismissBottomSheet(); + const title = intl.formatMessage({id: 'screens.channel_info', defaultMessage: 'Channel Info'}); + showModal(Screens.CHANNEL_INFO, title, {channelId}); + }, [intl, channelId]); + + if (showAsLabel) { + return ( + + ); + } + + return ( + + ); +}; + +export default InfoBox; diff --git a/app/components/channel_actions/leave_channel_label/index.ts b/app/components/channel_actions/leave_channel_label/index.ts new file mode 100644 index 000000000..9370b118e --- /dev/null +++ b/app/components/channel_actions/leave_channel_label/index.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// 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 {observeChannel} from '@queries/servers/channel'; + +import LeaveChannelLabel from './leave_channel_label'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps) => { + const channel = observeChannel(database, channelId); + const displayName = channel.pipe( + switchMap((c) => of$(c?.displayName)), + ); + const type = channel.pipe( + switchMap((c) => of$(c?.type)), + ); + + return { + displayName, + type, + }; +}); + +export default withDatabase(enhanced(LeaveChannelLabel)); diff --git a/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx b/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx new file mode 100644 index 000000000..5160829a3 --- /dev/null +++ b/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl} from 'react-intl'; +import {Alert} from 'react-native'; + +import {leaveChannel} from '@actions/remote/channel'; +import {setDirectChannelVisible} from '@actions/remote/preference'; +import SlideUpPanelItem from '@components/slide_up_panel_item'; +import {General} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useIsTablet} from '@hooks/device'; +import {dismissAllModals, dismissBottomSheet, popToRoot} from '@screens/navigation'; + +type Props = { + channelId: string; + displayName?: string; + type?: string; + testID?: string; +} + +const LeaveChanelLabel = ({channelId, displayName, type, testID}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + const isTablet = useIsTablet(); + + const close = async () => { + await dismissBottomSheet(); + if (!isTablet) { + await dismissAllModals(); + popToRoot(); + } + }; + + const closeDirectMessage = () => { + Alert.alert( + intl.formatMessage({id: 'channel_info.close_dm', defaultMessage: 'Close direct message'}), + intl.formatMessage({ + id: 'channel_info.close_dm_channel', + defaultMessage: 'Are you sure you want to close this direct message? This will remove it from your home screen, but you can always open it again.', + }), + [{ + text: intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}), + style: 'cancel', + }, { + text: intl.formatMessage({id: 'channel_info.close', defaultMessage: 'Close'}), + style: 'destructive', + onPress: () => { + setDirectChannelVisible(serverUrl, channelId, false); + close(); + }, + }], {cancelable: false}, + ); + }; + + const closeGroupMessage = () => { + Alert.alert( + intl.formatMessage({id: 'channel_info.close_gm', defaultMessage: 'Close group message'}), + intl.formatMessage({ + id: 'channel_info.close_gm_channel', + defaultMessage: 'Are you sure you want to close this group message? This will remove it from your home screen, but you can always open it again.', + }), + [{ + text: intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}), + style: 'cancel', + }, { + text: intl.formatMessage({id: 'channel_info.close', defaultMessage: 'Close'}), + style: 'destructive', + onPress: () => { + setDirectChannelVisible(serverUrl, channelId, false); + close(); + }, + }], {cancelable: false}, + ); + }; + + const leavePublicChannel = () => { + Alert.alert( + intl.formatMessage({id: 'channel_info.leave_channel', defaultMessage: 'Leave Channel'}), + intl.formatMessage({ + id: 'channel_info.leave_public_channel', + defaultMessage: 'Are you sure you want to leave the public channel {displayName}? You can always rejoin.', + }, {displayName}), + [{ + text: intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}), + style: 'cancel', + }, { + text: intl.formatMessage({id: 'channel_info.leave', defaultMessage: 'Leave'}), + style: 'destructive', + onPress: () => { + leaveChannel(serverUrl, channelId); + close(); + }, + }], {cancelable: false}, + ); + }; + + const leavePrivateChannel = () => { + Alert.alert( + intl.formatMessage({id: 'channel_info.leave_channel', defaultMessage: 'Leave Channel'}), + intl.formatMessage({ + id: 'channel_info.leave_private_channel', + defaultMessage: "Are you sure you want to leave the private channel {displayName}? You cannot rejoin the channel unless you're invited again.", + }, {displayName}), + [{ + text: intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}), + style: 'cancel', + }, { + text: intl.formatMessage({id: 'channel_info.leave', defaultMessage: 'Leave'}), + style: 'destructive', + onPress: () => { + leaveChannel(serverUrl, channelId); + close(); + }, + }], {cancelable: false}, + ); + }; + + const onLeave = () => { + switch (type) { + case General.OPEN_CHANNEL: + leavePublicChannel(); + break; + case General.PRIVATE_CHANNEL: + leavePrivateChannel(); + break; + case General.DM_CHANNEL: + closeDirectMessage(); + break; + case General.GM_CHANNEL: + closeGroupMessage(); + break; + } + }; + + if (!displayName || !type) { + return null; + } + + let leaveText = ''; + switch (type) { + case General.DM_CHANNEL: + leaveText = intl.formatMessage({id: 'channel_info.close_dm', defaultMessage: 'Close direct message'}); + break; + case General.GM_CHANNEL: + leaveText = intl.formatMessage({id: 'channel_info.close_gm', defaultMessage: 'Close group message'}); + break; + default: + leaveText = intl.formatMessage({id: 'channel_info.leave_channel', defaultMessage: 'Leave channel'}); + break; + } + + return ( + + ); +}; + +export default LeaveChanelLabel; diff --git a/app/components/channel_actions/mute_box/index.ts b/app/components/channel_actions/mute_box/index.ts new file mode 100644 index 000000000..6d7091991 --- /dev/null +++ b/app/components/channel_actions/mute_box/index.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// 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 {observeChannelSettings} from '@queries/servers/channel'; + +import MuteBox from './mute_box'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps) => ({ + isMuted: observeChannelSettings(database, channelId).pipe( + switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION)), + ), +})); + +export default withDatabase(enhanced(MuteBox)); diff --git a/app/components/channel_actions/mute_box/mute_box.tsx b/app/components/channel_actions/mute_box/mute_box.tsx new file mode 100644 index 000000000..fc7ae6240 --- /dev/null +++ b/app/components/channel_actions/mute_box/mute_box.tsx @@ -0,0 +1,44 @@ +// 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 {StyleProp, ViewStyle} from 'react-native'; + +import {toggleMuteChannel} from '@actions/remote/channel'; +import OptionBox from '@components/option_box'; +import {useServerUrl} from '@context/server'; +import {dismissBottomSheet} from '@screens/navigation'; + +type Props = { + channelId: string; + containerStyle?: StyleProp; + isMuted: boolean; + showSnackBar?: boolean; + testID?: string; +} + +const MutedBox = ({channelId, containerStyle, isMuted, showSnackBar = false, testID}: Props) => { + const intl = useIntl(); + const serverUrl = useServerUrl(); + + const handleOnPress = useCallback(async () => { + await dismissBottomSheet(); + toggleMuteChannel(serverUrl, channelId, showSnackBar); + }, [channelId, isMuted, serverUrl, showSnackBar]); + + return ( + + ); +}; + +export default MutedBox; diff --git a/app/components/channel_actions/set_header_box/index.ts b/app/components/channel_actions/set_header_box/index.ts new file mode 100644 index 000000000..6c2396a81 --- /dev/null +++ b/app/components/channel_actions/set_header_box/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// 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 {observeChannelInfo} from '@queries/servers/channel'; + +import SetHeaderBox from './set_header'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps) => { + const channelInfo = observeChannelInfo(database, channelId); + const isHeaderSet = channelInfo.pipe( + switchMap((c) => of$(Boolean(c?.header))), + ); + + return { + isHeaderSet, + }; +}); + +export default withDatabase(enhanced(SetHeaderBox)); diff --git a/app/components/channel_actions/set_header_box/set_header.tsx b/app/components/channel_actions/set_header_box/set_header.tsx new file mode 100644 index 000000000..6356f1eb2 --- /dev/null +++ b/app/components/channel_actions/set_header_box/set_header.tsx @@ -0,0 +1,46 @@ +// 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 {StyleProp, ViewStyle} from 'react-native'; + +import OptionBox from '@components/option_box'; +import {Screens} from '@constants'; +import {dismissBottomSheet, showModal} from '@screens/navigation'; + +type Props = { + channelId: string; + containerStyle?: StyleProp; + isHeaderSet: boolean; + testID?: string; +} + +const SetHeaderBox = ({channelId, containerStyle, isHeaderSet, testID}: Props) => { + const intl = useIntl(); + + const onSetHeader = useCallback(async () => { + await dismissBottomSheet(); + const title = intl.formatMessage({id: 'screens.channel_edit_header', defaultMessage: 'Edit Channel Header'}); + showModal(Screens.CREATE_OR_EDIT_CHANNEL, title, {channelId, headerOnly: true}); + }, [intl, channelId]); + + let text; + if (isHeaderSet) { + text = intl.formatMessage({id: 'channel_info.edit_header', defaultMessage: 'Edit Header'}); + } else { + text = intl.formatMessage({id: 'channel_info.set_header', defaultMessage: 'Set Header'}); + } + + return ( + + ); +}; + +export default SetHeaderBox; diff --git a/app/components/channel_item/channel_item.tsx b/app/components/channel_item/channel_item.tsx index a86f8fc13..d72fc7a4b 100644 --- a/app/components/channel_item/channel_item.tsx +++ b/app/components/channel_item/channel_item.tsx @@ -151,7 +151,7 @@ const ChannelListItem = ({ isActive && isTablet && !isInfo ? styles.textActive : null, isInfo ? styles.textInfo : null, isMuted && styles.muted, - isMuted && isInfo && styles.infoMuted, + isMuted && isInfo && styles.mutedInfo, ], [isBolded, styles, isMuted, isActive, isInfo, isTablet]); const containerStyle = useMemo(() => [ diff --git a/app/components/channel_item/index.ts b/app/components/channel_item/index.ts index b637cffec..324f7879c 100644 --- a/app/components/channel_item/index.ts +++ b/app/components/channel_item/index.ts @@ -23,7 +23,7 @@ type EnhanceProps = WithDatabaseArgs & { showTeamName?: boolean; } -const observeIsMutedSetting = (mc: MyChannelModel) => mc.settings.observe().pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === 'mention'))); +const observeIsMutedSetting = (mc: MyChannelModel) => mc.settings.observe().pipe(switchMap((s) => of$(s?.notifyProps?.mark_unread === General.MENTION))); const enhance = withObservables(['channel', 'showTeamName'], ({channel, database, showTeamName}: EnhanceProps) => { const currentUserId = observeCurrentUserId(database); diff --git a/app/components/navigation_header/header.tsx b/app/components/navigation_header/header.tsx index b1af2f609..cea7359a9 100644 --- a/app/components/navigation_header/header.tsx +++ b/app/components/navigation_header/header.tsx @@ -39,6 +39,7 @@ type Props = { } const hitSlop = {top: 20, bottom: 20, left: 20, right: 20}; +const rightButtonHitSlop = {top: 20, bottom: 5, left: 5, right: 5}; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ centered: { @@ -227,10 +228,11 @@ const Header = ({ 0 ? styles.rightIcon : undefined} + style={i > 0 && styles.rightIcon} testID={r.testID} > void; + onPress: () => void; + testID?: string; + text: string; +} + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + borderRadius: 4, + flex: 1, + maxHeight: OPTIONS_HEIGHT, + minWidth: 80, + }, + center: { + alignItems: 'center', + justifyContent: 'center', + }, + text: { + color: changeOpacity(theme.centerChannelColor, 0.56), + paddingHorizontal: 5, + textTransform: 'capitalize', + ...typography('Body', 50, 'SemiBold'), + }, +})); + +const AnimatedOptionBox = ({ + animatedBackgroundColor, animatedColor, animatedIconName, animatedText, + iconName, onAnimationEnd, onPress, testID, text, +}: OptionBoxProps) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + const [activated, setActivated] = useState(false); + const animate = useSharedValue(0); + + const handleOnPress = useCallback(() => { + animate.value = withTiming(1, {duration: 150, easing: Easing.out(Easing.linear)}); + setActivated(true); + onPress(); + }, [onPress]); + + const backgroundStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + animate.value, + [0, 1], + ['transparent', animatedBackgroundColor], + ), + })); + + const optionStyle = useAnimatedStyle(() => ({ + opacity: interpolate( + animate.value, + [0, 1], + [1, 0], + ), + })); + + const scaleStyle = useAnimatedStyle(() => ({ + transform: [{ + scale: interpolate( + animate.value, + [0, 1], + [0.25, 1], + ), + }], + opacity: animate.value, + }), [activated]); + + useEffect(() => { + let t: NodeJS.Timeout|undefined; + const callback = () => { + 'worklet'; + if (onAnimationEnd) { + runOnJS(onAnimationEnd)(); + } + }; + + if (activated) { + t = setTimeout(() => { + setActivated(false); + animate.value = withTiming(0, {duration: 150, easing: Easing.out(Easing.linear)}, callback); + }, 1200); + } + + return () => { + if (t) { + clearTimeout(t); + } + }; + }, [activated, animate.value, onAnimationEnd]); + + return ( + + {({pressed}) => ( + + + + + + {text} + + + + + + {animatedText} + + + + + )} + + ); +}; + +export default AnimatedOptionBox; diff --git a/app/components/option_box/index.tsx b/app/components/option_box/index.tsx index 3401c4ccf..5b711dee5 100644 --- a/app/components/option_box/index.tsx +++ b/app/components/option_box/index.tsx @@ -1,8 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {Text, TextStyle, TouchableHighlight, ViewStyle} from 'react-native'; +import React, {useCallback, useEffect, useState} from 'react'; +import {Pressable, PressableStateCallbackType, StyleProp, Text, ViewStyle} from 'react-native'; import CompassIcon from '@components/compass_icon'; import {useTheme} from '@context/theme'; @@ -10,56 +10,92 @@ import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; type OptionBoxProps = { - iconColor?: string; + activeIconName?: string; + activeText?: string; + containerStyle?: StyleProp; iconName: string; + isActive?: boolean; onPress: () => void; + testID?: string; text: string; - textStyle?: TextStyle; - style?: ViewStyle; - underlayColor?: string; } +export const OPTIONS_HEIGHT = 62; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { alignItems: 'center', backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), borderRadius: 4, flex: 1, - maxHeight: 60, + maxHeight: OPTIONS_HEIGHT, justifyContent: 'center', - minWidth: 115, + minWidth: 80, }, text: { color: changeOpacity(theme.centerChannelColor, 0.56), paddingHorizontal: 5, + textTransform: 'capitalize', ...typography('Body', 50, 'SemiBold'), }, })); -const OptionBox = ({iconColor, iconName, onPress, style, text, textStyle, underlayColor}: OptionBoxProps) => { +const OptionBox = ({activeIconName, activeText, containerStyle, iconName, isActive, onPress, testID, text}: OptionBoxProps) => { const theme = useTheme(); + const [activated, setActivated] = useState(isActive); const styles = getStyleSheet(theme); + const pressedStyle = useCallback(({pressed}: PressableStateCallbackType) => { + const style = [styles.container, Boolean(containerStyle) && containerStyle]; + + if (activated) { + style.push({ + backgroundColor: changeOpacity(theme.buttonBg, 0.08), + }); + } + + if (pressed) { + style.push({ + backgroundColor: changeOpacity(theme.buttonBg, 0.16), + }); + } + + return style; + }, [activated, containerStyle, theme]); + + const handleOnPress = useCallback(() => { + if (activeIconName || activeText) { + setActivated(!activated); + } + onPress(); + }, [activated, activeIconName, activeText, onPress]); + + useEffect(() => { + setActivated(isActive); + }, [isActive]); return ( - - <> - - - {text} - - - + {({pressed}) => ( + <> + + + {activated && activeText ? activeText : text} + + + )} + ); }; diff --git a/app/components/slide_up_panel_item/index.tsx b/app/components/slide_up_panel_item/index.tsx index 5e6fd2b4a..24342f99f 100644 --- a/app/components/slide_up_panel_item/index.tsx +++ b/app/components/slide_up_panel_item/index.tsx @@ -2,11 +2,10 @@ // See LICENSE.txt for license information. import React, {useCallback} from 'react'; -import {StyleProp, Text, TextStyle, View, ViewStyle} from 'react-native'; +import {StyleProp, Text, TextStyle, TouchableHighlight, View, ViewStyle} from 'react-native'; import FastImage, {ImageStyle, Source} from 'react-native-fast-image'; import CompassIcon from '@components/compass_icon'; -import TouchableWithFeedback from '@components/touchable_with_feedback'; import {useTheme} from '@context/theme'; import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -26,14 +25,15 @@ type SlideUpPanelProps = { export const ITEM_HEIGHT = 51; -const getStyleSheet = makeStyleSheetFromTheme((theme) => { +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { container: { height: ITEM_HEIGHT, - width: '100%', + marginHorizontal: -20, + paddingHorizontal: 20, }, destructive: { - color: '#D0021B', + color: theme.dndIndicator, }, row: { width: '100%', @@ -49,7 +49,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { width: 18, }, icon: { - color: changeOpacity(theme.centerChannelColor, 0.64), + color: changeOpacity(theme.centerChannelColor, 0.56), }, textContainer: { justifyContent: 'center', @@ -59,7 +59,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => { }, text: { color: theme.centerChannelColor, - opacity: 0.9, ...typography('Body', 200, 'Regular'), }, }; @@ -101,25 +100,24 @@ const SlideUpPanelItem = ({destructive, icon, imageStyles, onPress, testID, text } return ( - {Boolean(image) && !rightIcon && {image} } - {text} + {text} {Boolean(image) && rightIcon && {image} } - + ); }; diff --git a/app/constants/categories.ts b/app/constants/categories.ts index c0126c7e1..0ded12dab 100644 --- a/app/constants/categories.ts +++ b/app/constants/categories.ts @@ -3,8 +3,10 @@ export const CHANNELS_CATEGORY = 'channels'; export const DMS_CATEGORY = 'direct_messages'; +export const FAVORITES_CATEGORY = 'favorites'; export default { CHANNELS_CATEGORY, DMS_CATEGORY, + FAVORITES_CATEGORY, }; diff --git a/app/constants/events.ts b/app/constants/events.ts index 9994db2d7..de208b2bb 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -5,7 +5,7 @@ import keyMirror from '@utils/key_mirror'; export default keyMirror({ ACCOUNT_SELECT_TABLET_VIEW: null, - CHANNEL_DELETED: null, + CHANNEL_ARCHIVED: null, CLOSE_BOTTOM_SHEET: null, CONFIG_CHANGED: null, FETCHING_POSTS: null, diff --git a/app/constants/screens.ts b/app/constants/screens.ts index bfeb8b326..45878baaa 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -8,7 +8,7 @@ export const BOTTOM_SHEET = 'BottomSheet'; export const BROWSE_CHANNELS = 'BrowseChannels'; export const CHANNEL = 'Channel'; export const CHANNEL_ADD_PEOPLE = 'ChannelAddPeople'; -export const CHANNEL_DETAILS = 'ChannelDetails'; +export const CHANNEL_INFO = 'ChannelInfo'; export const CHANNEL_EDIT = 'ChannelEdit'; export const CODE = 'Code'; export const CREATE_DIRECT_MESSAGE = 'CreateDirectMessage'; @@ -57,8 +57,8 @@ export default { BROWSE_CHANNELS, CHANNEL, CHANNEL_ADD_PEOPLE, - CHANNEL_DETAILS, CHANNEL_EDIT, + CHANNEL_INFO, CODE, CREATE_DIRECT_MESSAGE, CREATE_OR_EDIT_CHANNEL, @@ -101,7 +101,7 @@ export default { export const MODAL_SCREENS_WITHOUT_BACK = [ BROWSE_CHANNELS, - CHANNEL_DETAILS, + CHANNEL_INFO, CREATE_DIRECT_MESSAGE, CREATE_TEAM, CUSTOM_STATUS, @@ -118,7 +118,7 @@ export const MODAL_SCREENS_WITHOUT_BACK = [ export const NOT_READY = [ CHANNEL_ADD_PEOPLE, - CHANNEL_DETAILS, + CHANNEL_INFO, CREATE_TEAM, INTEGRATION_SELECTOR, INTERACTIVE_DIALOG, diff --git a/app/constants/snack_bar.ts b/app/constants/snack_bar.ts index 3638f486c..79a0a5c30 100644 --- a/app/constants/snack_bar.ts +++ b/app/constants/snack_bar.ts @@ -5,9 +5,12 @@ import {t} from '@i18n'; import keyMirror from '@utils/key_mirror'; export const SNACK_BAR_TYPE = keyMirror({ + FAVORITE_CHANNEL: null, LINK_COPIED: null, MESSAGE_COPIED: null, MUTE_CHANNEL: null, + UNFAVORITE_CHANNEL: null, + UNMUTE_CHANNEL: null, }); type SnackBarConfig = { @@ -16,7 +19,14 @@ type SnackBarConfig = { iconName: string; canUndo: boolean; }; + export const SNACK_BAR_CONFIG: Record = { + FAVORITE_CHANNEL: { + id: t('snack.bar.favorited.channel'), + defaultMessage: 'This channel was favorited', + iconName: 'star', + canUndo: true, + }, LINK_COPIED: { id: t('snack.bar.link.copied'), defaultMessage: 'Link copied to clipboard', @@ -35,6 +45,18 @@ export const SNACK_BAR_CONFIG: Record = { iconName: 'bell-off-outline', canUndo: true, }, + UNFAVORITE_CHANNEL: { + id: t('snack.bar.unfavorite.channel'), + defaultMessage: 'This channel was unfavorited', + iconName: 'star-outline', + canUndo: true, + }, + UNMUTE_CHANNEL: { + id: t('snack.bar.unmute.channel'), + defaultMessage: 'This channel was unmuted', + iconName: 'bell-outline', + canUndo: true, + }, }; export default { diff --git a/app/database/models/server/category.ts b/app/database/models/server/category.ts index c77bba36a..13296ee14 100644 --- a/app/database/models/server/category.ts +++ b/app/database/models/server/category.ts @@ -109,4 +109,23 @@ export default class CategoryModel extends Model implements CategoryInterface { map((c) => c > 0), distinctUntilChanged(), ); + + toCategoryWithChannels = async (): Promise => { + const categoryChannels = await this.categoryChannels.fetch(); + const orderedChannelIds = categoryChannels.sort((a, b) => { + return b.sortOrder - a.sortOrder; + }).map((cc) => cc.channelId); + + return { + channel_ids: orderedChannelIds, + id: this.id, + team_id: this.teamId, + display_name: this.displayName, + sort_order: this.sortOrder, + sorting: this.sorting, + type: this.type, + muted: this.muted, + collapsed: this.collapsed, + }; + }; } diff --git a/app/queries/servers/categories.ts b/app/queries/servers/categories.ts index 8b16f6fdc..87712f72b 100644 --- a/app/queries/servers/categories.ts +++ b/app/queries/servers/categories.ts @@ -2,7 +2,10 @@ // See LICENSE.txt for license information. import {Database, Model, Q, Query} from '@nozbe/watermelondb'; +import {of as of$} from 'rxjs'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; +import {FAVORITES_CATEGORY} from '@constants/categories'; import {MM_TABLES} from '@constants/database'; import {makeCategoryChannelId} from '@utils/categories'; @@ -10,7 +13,7 @@ import type ServerDataOperator from '@database/operator/server_data_operator'; import type CategoryModel from '@typings/database/models/servers/category'; import type CategoryChannelModel from '@typings/database/models/servers/category_channel'; -const {SERVER: {CATEGORY}} = MM_TABLES; +const {SERVER: {CATEGORY, CATEGORY_CHANNEL}} = MM_TABLES; export const getCategoryById = async (database: Database, categoryId: string) => { try { @@ -70,3 +73,34 @@ export const prepareDeleteCategory = async (category: CategoryModel): Promise { + return database.get(CATEGORY).query( + Q.on(CATEGORY_CHANNEL, Q.where('id', makeCategoryChannelId(teamId, channelId))), + ); +}; + +export const getChannelCategory = async (database: Database, teamId: string, channelId: string) => { + const result = await queryChannelCategory(database, teamId, channelId).fetch(); + if (result.length) { + return result[0]; + } + + return undefined; +}; + +export const getIsChannelFavorited = async (database: Database, teamId: string, channelId: string) => { + const result = await queryChannelCategory(database, teamId, channelId).fetch(); + if (result.length > 0) { + return result[0].type === FAVORITES_CATEGORY; + } + + return false; +}; + +export const observeIsChannelFavorited = (database: Database, teamId: string, channelId: string) => { + return queryChannelCategory(database, teamId, channelId).observe().pipe( + switchMap((result) => (result.length ? of$(result[0].type === FAVORITES_CATEGORY) : of$(false))), + distinctUntilChanged(), + ); +}; diff --git a/app/queries/servers/channel.ts b/app/queries/servers/channel.ts index fd22e1b35..183ff2f42 100644 --- a/app/queries/servers/channel.ts +++ b/app/queries/servers/channel.ts @@ -127,12 +127,18 @@ export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, tea export const prepareDeleteChannel = async (channel: ChannelModel): Promise => { const preparedModels: Model[] = [channel.prepareDestroyPermanently()]; - const relations: Array | undefined> = [channel.membership, channel.info, channel.categoryChannel]; + const relations: Array | undefined> = [channel.membership, channel.info, channel.categoryChannel]; await Promise.all(relations.map(async (relation) => { try { const model = await relation?.fetch(); if (model) { preparedModels.push(model.prepareDestroyPermanently()); + if ('settings' in model) { + const settings = await model.settings?.fetch(); + if (settings) { + preparedModels.push(settings.prepareDestroyPermanently()); + } + } } } catch { // Record not found, do nothing @@ -551,3 +557,9 @@ export const observeArchiveChannelsByTerm = (database: Database, term: string, t Q.take(take), ).observe(); }; + +export const observeChannelSettings = (database: Database, channelId: string) => { + return database.get(MY_CHANNEL_SETTINGS).query(Q.where('id', channelId), Q.take(1)).observe().pipe( + switchMap((result) => (result.length ? result[0].observe() : of$(undefined))), + ); +}; diff --git a/app/queries/servers/system.ts b/app/queries/servers/system.ts index 74081c132..da1e4d6da 100644 --- a/app/queries/servers/system.ts +++ b/app/queries/servers/system.ts @@ -364,7 +364,7 @@ export async function setCurrentTeamAndChannelId(operator: ServerDataOperator, t await operator.batchRecords(models); } - return {currentChannelId: channelId}; + return {currentTeamId: teamId, currentChannelId: channelId}; } catch (error) { return {error}; } diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx b/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx index c547ca972..7124c6485 100644 --- a/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx +++ b/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx @@ -147,7 +147,6 @@ const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) = header={true} favorite={true} people={false} - theme={theme} /> ); diff --git a/app/screens/channel/channel_post_list/intro/intro.tsx b/app/screens/channel/channel_post_list/intro/intro.tsx index ef52fa4ce..f6983e1b9 100644 --- a/app/screens/channel/channel_post_list/intro/intro.tsx +++ b/app/screens/channel/channel_post_list/intro/intro.tsx @@ -15,7 +15,7 @@ import type ChannelModel from '@typings/database/models/servers/channel'; import type RoleModel from '@typings/database/models/servers/role'; type Props = { - channel: ChannelModel; + channel?: ChannelModel; hasPosts: boolean; roles: RoleModel[]; } @@ -39,6 +39,10 @@ const Intro = ({channel, hasPosts, roles}: Props) => { const [fetching, setFetching] = useState(!hasPosts); const theme = useTheme(); const element = useMemo(() => { + if (!channel) { + return null; + } + if (channel.type === General.OPEN_CHANNEL && channel.name === General.DEFAULT_CHANNEL) { return ( { - const {formatMessage} = useIntl(); - const serverUrl = useServerUrl(); - - const toggleFavorite = useCallback(() => { - saveFavoriteChannel(serverUrl, channelId, !isFavorite); - }, [channelId, isFavorite]); - - return ( - - ); -}; - -export default IntroFavorite; diff --git a/app/screens/channel/channel_post_list/intro/options/favorite/index.ts b/app/screens/channel/channel_post_list/intro/options/favorite/index.ts deleted file mode 100644 index 6b54aaacb..000000000 --- a/app/screens/channel/channel_post_list/intro/options/favorite/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 {Preferences} from '@constants'; -import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; - -import FavoriteItem from './favorite'; - -import type {WithDatabaseArgs} from '@typings/database/database'; - -const enhanced = withObservables([], ({channelId, database}: {channelId: string} & WithDatabaseArgs) => ({ - isFavorite: queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_FAVORITE_CHANNEL, channelId).observeWithColumns(['value']).pipe( - switchMap((prefs) => { - return prefs.length ? of$(prefs[0].value === 'true') : of$(false); - }), - ), -})); - -export default withDatabase(enhanced(FavoriteItem)); diff --git a/app/screens/channel/channel_post_list/intro/options/index.tsx b/app/screens/channel/channel_post_list/intro/options/index.tsx index dc071c350..5faaae3f5 100644 --- a/app/screens/channel/channel_post_list/intro/options/index.tsx +++ b/app/screens/channel/channel_post_list/intro/options/index.tsx @@ -1,22 +1,19 @@ // 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 React from 'react'; import {StyleSheet, View} from 'react-native'; -import {Screens} from '@constants'; -import {showModal} from '@screens/navigation'; - -import IntroFavorite from './favorite'; -import OptionItem from './item'; +import AddPeopleBox from '@components/channel_actions/add_people_box'; +import FavoriteBox from '@components/channel_actions/favorite_box'; +import InfoBox from '@components/channel_actions/info_box'; +import SetHeaderBox from '@components/channel_actions/set_header_box'; type Props = { channelId: string; header?: boolean; favorite?: boolean; people?: boolean; - theme: Theme; } const styles = StyleSheet.create({ @@ -27,60 +24,49 @@ const styles = StyleSheet.create({ marginTop: 28, width: '100%', }, + margin: { + marginRight: 8, + }, + item: { + alignItems: 'center', + borderRadius: 4, + height: 70, + justifyContent: 'center', + maxHeight: undefined, + paddingHorizontal: 16, + paddingVertical: 12, + width: 112, + }, }); -const IntroOptions = ({channelId, header, favorite, people, theme}: Props) => { - const {formatMessage} = useIntl(); - - const onAddPeople = useCallback(() => { - const title = formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'}); - showModal(Screens.CHANNEL_ADD_PEOPLE, title, {channelId}); - }, []); - - const onSetHeader = useCallback(() => { - const title = formatMessage({id: 'screens.channel_edit_header', defaultMessage: 'Edit Channel Header'}); - showModal(Screens.CREATE_OR_EDIT_CHANNEL, title, {channelId, headerOnly: true}); - }, []); - - const onDetails = useCallback(() => { - const title = formatMessage({id: 'screens.channel_details', defaultMessage: 'Channel Details'}); - showModal(Screens.CHANNEL_DETAILS, title, {channelId}); - }, []); - +const IntroOptions = ({channelId, header, favorite, people}: Props) => { return ( {people && - } {header && - } {favorite && - } - ); diff --git a/app/screens/channel/channel_post_list/intro/options/item.tsx b/app/screens/channel/channel_post_list/intro/options/item.tsx deleted file mode 100644 index 2fbf82707..000000000 --- a/app/screens/channel/channel_post_list/intro/options/item.tsx +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useCallback} from 'react'; -import {Pressable, PressableStateCallbackType, Text} from 'react-native'; - -import CompassIcon from '@components/compass_icon'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; -import {typography} from '@utils/typography'; - -type Props = { - applyMargin?: boolean; - color?: string; - iconName: string; - label: string; - onPress: () => void; - testID?: string; - theme: Theme; -} - -const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ - container: { - alignItems: 'center', - backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), - borderRadius: 4, - height: 70, - justifyContent: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - width: 112, - }, - containerPressed: { - backgroundColor: changeOpacity(theme.buttonBg, 0.08), - }, - label: { - marginTop: 6, - ...typography('Body', 50, 'SemiBold'), - }, - margin: { - marginRight: 8, - }, -})); - -const IntroItem = ({applyMargin, color, iconName, label, onPress, testID, theme}: Props) => { - const styles = getStyleSheet(theme); - const pressedStyle = useCallback(({pressed}: PressableStateCallbackType) => { - const style = [styles.container]; - if (pressed) { - style.push(styles.containerPressed); - } - - if (applyMargin) { - style.push(styles.margin); - } - - return style; - }, [applyMargin, theme]); - - return ( - - {({pressed}) => ( - <> - - - {label} - - - )} - - ); -}; - -export default IntroItem; diff --git a/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx b/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx index 3a5289bf2..4f4e6cac7 100644 --- a/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx +++ b/app/screens/channel/channel_post_list/intro/public_or_private_channel/public_or_private_channel.tsx @@ -140,7 +140,6 @@ const PublicOrPrivateChannel = ({channel, creator, roles, theme}: Props) => { channelId={channel.id} header={canSetHeader} people={canManagePeople} - theme={theme} /> ); diff --git a/app/screens/channel/channel_post_list/intro/townsquare/index.tsx b/app/screens/channel/channel_post_list/intro/townsquare/index.tsx index 39f6a726d..edb8ae4e3 100644 --- a/app/screens/channel/channel_post_list/intro/townsquare/index.tsx +++ b/app/screens/channel/channel_post_list/intro/townsquare/index.tsx @@ -61,7 +61,6 @@ const TownSquare = ({channelId, displayName, roles, theme}: Props) => { ); diff --git a/app/screens/channel/header/header.tsx b/app/screens/channel/header/header.tsx index 2697332d0..e97baf302 100644 --- a/app/screens/channel/header/header.tsx +++ b/app/screens/channel/header/header.tsx @@ -8,14 +8,15 @@ import {DeviceEventEmitter, Keyboard, Platform, Text, View} from 'react-native'; import CompassIcon from '@components/compass_icon'; import CustomStatusEmoji from '@components/custom_status/custom_status_emoji'; import NavigationHeader from '@components/navigation_header'; -import {Navigation} from '@constants'; +import {Navigation, Screens} from '@constants'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; -import {popTopScreen, showModal} from '@screens/navigation'; +import {bottomSheet, popTopScreen, showModal} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; import OtherMentionsBadge from './other_mentions_badge'; +import ChannelQuickAction from './quick_actions'; import type {HeaderRightButton} from '@components/navigation_header/header'; @@ -58,7 +59,7 @@ const ChannelHeader = ({ isCustomStatusExpired, isOwnDirectMessage, memberCount, searchTerm, teamId, }: ChannelProps) => { - const {formatMessage} = useIntl(); + const intl = useIntl(); const isTablet = useIsTablet(); const theme = useTheme(); const styles = getStyleSheet(theme); @@ -71,6 +72,35 @@ const ChannelHeader = ({ return (); }, [isTablet, channelId, teamId]); + const onBackPress = useCallback(() => { + Keyboard.dismiss(); + popTopScreen(componentId); + }, []); + + const onTitlePress = useCallback(() => { + const title = intl.formatMessage({id: 'screens.channel_info', defaultMessage: 'Channel Info'}); + showModal(Screens.CHANNEL_INFO, title, {channelId}); + }, [channelId, intl]); + + const onChannelQuickAction = useCallback(() => { + if (isTablet) { + onTitlePress(); + return; + } + + const renderContent = () => { + return ; + }; + + bottomSheet({ + title: '', + renderContent, + snapPoints: ['32%', 10], + theme, + closeButtonId: 'close-channel-quick-actions', + }); + }, [channelId, isTablet, onTitlePress, theme]); + const rightButtons: HeaderRightButton[] = useMemo(() => ([{ iconName: 'magnify', onPress: () => { @@ -81,31 +111,20 @@ const ChannelHeader = ({ }, }, { iconName: Platform.select({android: 'dots-vertical', default: 'dots-horizontal'}), - onPress: () => true, + onPress: onChannelQuickAction, buttonType: 'opacity', - }]), [isTablet, searchTerm]); - - const onBackPress = useCallback(() => { - Keyboard.dismiss(); - popTopScreen(componentId); - }, []); - - const onTitlePress = useCallback(() => { - // eslint-disable-next-line no-console - console.log('Title Press go to Channel Info'); - showModal('ChannelInfo', '', {channelId}); - }, [channelId]); + }]), [isTablet, searchTerm, onChannelQuickAction]); let title = displayName; if (isOwnDirectMessage) { - title = formatMessage({id: 'channel_header.directchannel.you', defaultMessage: '{displayName} (you)'}, {displayName}); + title = intl.formatMessage({id: 'channel_header.directchannel.you', defaultMessage: '{displayName} (you)'}, {displayName}); } let subtitle; if (memberCount) { - subtitle = formatMessage({id: 'channel', defaultMessage: '{count, plural, one {# member} other {# members}}'}, {count: memberCount}); + subtitle = intl.formatMessage({id: 'channel_header.member_count', defaultMessage: '{count, plural, one {# member} other {# members}}'}, {count: memberCount}); } else if (!customStatus || !customStatus.text || isCustomStatusExpired) { - subtitle = formatMessage({id: 'channel.details', defaultMessage: 'View details'}); + subtitle = intl.formatMessage({id: 'channel_header.info', defaultMessage: 'View info'}); } const subtitleCompanion = useMemo(() => { diff --git a/app/screens/channel/header/quick_actions/index.ts b/app/screens/channel/header/quick_actions/index.ts new file mode 100644 index 000000000..9dafd00df --- /dev/null +++ b/app/screens/channel/header/quick_actions/index.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// 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 {observeChannel} from '@queries/servers/channel'; + +import ChannelQuickAction from './quick_actions'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = WithDatabaseArgs & { + channelId: string; +} + +const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps) => { + const channelType = observeChannel(database, channelId).pipe( + switchMap((c) => of$(c?.type)), + ); + + return { + channelType, + }; +}); + +export default withDatabase(enhanced(ChannelQuickAction)); diff --git a/app/screens/channel/header/quick_actions/quick_actions.tsx b/app/screens/channel/header/quick_actions/quick_actions.tsx new file mode 100644 index 000000000..d9caf10b1 --- /dev/null +++ b/app/screens/channel/header/quick_actions/quick_actions.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {StyleSheet, View} from 'react-native'; + +import AddPeopleBox from '@app/components/channel_actions/add_people_box'; +import CopyChannelLinkBox from '@app/components/channel_actions/copy_channel_link_box'; +import FavoriteBox from '@components/channel_actions/favorite_box'; +import InfoBox from '@components/channel_actions/info_box'; +import LeaveChannelLabel from '@components/channel_actions/leave_channel_label'; +import MutedBox from '@components/channel_actions/mute_box'; +import SetHeaderBox from '@components/channel_actions/set_header_box'; +import {General} from '@constants'; +import {useTheme} from '@context/theme'; +import {dismissBottomSheet} from '@screens/navigation'; +import {changeOpacity} from '@utils/theme'; + +type Props = { + channelId: string; + channelType?: string; +} + +const OPTIONS_HEIGHT = 62; +const DIRECT_CHANNELS: string[] = [General.DM_CHANNEL, General.GM_CHANNEL]; + +const styles = StyleSheet.create({ + container: { + minHeight: 270, + }, + wrapper: { + flexDirection: 'row', + height: OPTIONS_HEIGHT, + marginBottom: 8, + }, + separator: { + width: 8, + }, +}); + +const ChannelQuickAction = ({channelId, channelType}: Props) => { + const theme = useTheme(); + + const onCopyLinkAnimationEnd = useCallback(() => { + requestAnimationFrame(async () => { + await dismissBottomSheet(); + }); + }, []); + + return ( + + + + + + + {channelType && DIRECT_CHANNELS.includes(channelType) && + + } + {channelType && !DIRECT_CHANNELS.includes(channelType) && + <> + + + + + } + + + + + + ); +}; + +export default ChannelQuickAction; diff --git a/app/screens/find_channels/quick_options/quick_options.tsx b/app/screens/find_channels/quick_options/quick_options.tsx index f233d8704..de2c60b35 100644 --- a/app/screens/find_channels/quick_options/quick_options.tsx +++ b/app/screens/find_channels/quick_options/quick_options.tsx @@ -7,7 +7,7 @@ import {StyleSheet, View} from 'react-native'; import Animated, {FadeInDown, FadeOutUp} from 'react-native-reanimated'; import CompassIcon from '@components/compass_icon'; -import OptionBox from '@components/option_box'; +import OptionBox, {OPTIONS_HEIGHT} from '@components/option_box'; import {Screens} from '@constants'; import {useTheme} from '@context/theme'; import {showModal} from '@screens/navigation'; @@ -18,8 +18,6 @@ type Props = { close: () => Promise; } -const OPTIONS_HEIGHT = 60; - const styles = StyleSheet.create({ container: { marginTop: 20, diff --git a/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx b/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx index e2c4554db..0176af07c 100644 --- a/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx +++ b/app/screens/home/channel_list/categories_list/categories/body/category_body.tsx @@ -32,7 +32,7 @@ const CategoryBody = ({sortedChannels, category, hiddenChannelIds, limit, onChan } if (category.type === DMS_CATEGORY && limit > 0) { - return filteredChannels.slice(0, limit - 1); + return filteredChannels.slice(0, limit); } return filteredChannels; }, [category.type, limit, hiddenChannelIds, sortedChannels]); diff --git a/app/screens/home/channel_list/categories_list/categories/header/__snapshots__/header.test.tsx.snap b/app/screens/home/channel_list/categories_list/categories/header/__snapshots__/header.test.tsx.snap index 43f5295f7..f320663ba 100644 --- a/app/screens/home/channel_list/categories_list/categories/header/__snapshots__/header.test.tsx.snap +++ b/app/screens/home/channel_list/categories_list/categories/header/__snapshots__/header.test.tsx.snap @@ -72,11 +72,12 @@ exports[`components/channel_list/categories/header should match snapshot 1`] = ` "fontSize": 12, "fontWeight": "600", "lineHeight": 16, + "textTransform": "uppercase", } } testID="category_header.custom.display_name" > - TEST CATEGORY + Test Category diff --git a/app/screens/home/channel_list/categories_list/categories/header/header.tsx b/app/screens/home/channel_list/categories_list/categories/header/header.tsx index a4071ff6b..32db634a1 100644 --- a/app/screens/home/channel_list/categories_list/categories/header/header.tsx +++ b/app/screens/home/channel_list/categories_list/categories/header/header.tsx @@ -2,11 +2,13 @@ // See LICENSE.txt for license information. import React, {useCallback, useEffect} from 'react'; +import {useIntl} from 'react-intl'; import {Text, TouchableOpacity, View} from 'react-native'; import Animated, {Easing, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming} from 'react-native-reanimated'; import {toggleCollapseCategory} from '@actions/local/category'; import CompassIcon from '@components/compass_icon'; +import {CHANNELS_CATEGORY, FAVORITES_CATEGORY, DMS_CATEGORY} from '@constants/categories'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; @@ -25,6 +27,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, heading: { color: changeOpacity(theme.sidebarText, 0.64), + textTransform: 'uppercase', ...typography('Heading', 75), }, chevron: { @@ -47,6 +50,7 @@ type Props = { const AnimatedCompassIcon = Animated.createAnimatedComponent(CompassIcon); const CategoryHeader = ({category, hasChannels}: Props) => { + const {formatMessage} = useIntl(); const theme = useTheme(); const styles = getStyleSheet(theme); const serverUrl = useServerUrl(); @@ -73,10 +77,23 @@ const CategoryHeader = ({category, hasChannels}: Props) => { }, [category.collapsed]); // Hide favs if empty - if (!hasChannels && category.type === 'favorites') { + if (!hasChannels && category.type === FAVORITES_CATEGORY) { return (null); } + let displayName = category.displayName; + switch (category.type) { + case FAVORITES_CATEGORY: + displayName = formatMessage({id: 'channel_list.favorites_category', defaultMessage: 'Favorites'}); + break; + case CHANNELS_CATEGORY: + displayName = formatMessage({id: 'channel_list.channels_category', defaultMessage: 'Channels'}); + break; + case DMS_CATEGORY: + displayName = formatMessage({id: 'channel_list.dms_category', defaultMessage: 'Direct messages'}); + break; + } + return ( { style={styles.heading} testID={`category_header.${category.type}.display_name`} > - {category.displayName.toUpperCase()} + {displayName} diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index 37896302d..bd8fc5c94 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -13,7 +13,7 @@ import {Events, Screens} from '@constants'; import {useTheme} from '@context/theme'; import {findChannels} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; -import {alertChannelRemove, alertTeamRemove} from '@utils/navigation'; +import {alertChannelArchived, alertChannelRemove, alertTeamRemove} from '@utils/navigation'; import {notificationError} from '@utils/notification'; import Account from './account'; @@ -53,22 +53,22 @@ export default function HomeScreen(props: HomeProps) { }, [intl.locale]); useEffect(() => { - const listener = DeviceEventEmitter.addListener(Events.LEAVE_TEAM, (displayName: string) => { + const leaveTeamListener = DeviceEventEmitter.addListener(Events.LEAVE_TEAM, (displayName: string) => { alertTeamRemove(displayName, intl); }); - return () => { - listener.remove(); - }; - }, [intl.locale]); - - useEffect(() => { - const listener = DeviceEventEmitter.addListener(Events.LEAVE_CHANNEL, (displayName: string) => { + const leaveChannelListener = DeviceEventEmitter.addListener(Events.LEAVE_CHANNEL, (displayName: string) => { alertChannelRemove(displayName, intl); }); + const archivedChannelListener = DeviceEventEmitter.addListener(Events.CHANNEL_ARCHIVED, (displayName: string) => { + alertChannelArchived(displayName, intl); + }); + return () => { - listener.remove(); + leaveTeamListener.remove(); + leaveChannelListener.remove(); + archivedChannelListener.remove(); }; }, [intl.locale]); diff --git a/app/screens/snack_bar/index.tsx b/app/screens/snack_bar/index.tsx index 526a8727a..38e7e2a27 100644 --- a/app/screens/snack_bar/index.tsx +++ b/app/screens/snack_bar/index.tsx @@ -5,7 +5,7 @@ import React, {useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {DeviceEventEmitter, Text, TouchableOpacity, useWindowDimensions, ViewStyle} from 'react-native'; import {Gesture, GestureDetector, GestureHandlerRootView} from 'react-native-gesture-handler'; -import {Navigation} from 'react-native-navigation'; +import {ComponentEvent, Navigation} from 'react-native-navigation'; import Animated, { AnimatedStyleProp, Extrapolation, @@ -205,7 +205,12 @@ const SnackBar = ({barType, componentId, onAction, sourceScreen}: SnackBarProps) // This effect checks if we are navigating away and if so, it dismisses the snack bar useEffect(() => { - const onHideSnackBar = () => animateHiding(true); + const onHideSnackBar = (event?: ComponentEvent) => { + if (componentId !== event?.componentId) { + animateHiding(true); + } + }; + const screenWillAppear = Navigation.events().registerComponentWillAppearListener(onHideSnackBar); const screenDidDisappear = Navigation.events().registerComponentDidDisappearListener(onHideSnackBar); const tabPress = DeviceEventEmitter.addListener('tabPress', onHideSnackBar); diff --git a/app/store/ephemeral_store.ts b/app/store/ephemeral_store.ts index da74e4b97..96565321c 100644 --- a/app/store/ephemeral_store.ts +++ b/app/store/ephemeral_store.ts @@ -18,6 +18,7 @@ class EphemeralStore { // and make sure we only handle one. private addingTeam = new Set(); private joiningChannels = new Set(); + private leavingChannels = new Set(); addNavigationComponentId = (componentId: string) => { this.addToNavigationComponentIdStack(componentId); @@ -133,6 +134,19 @@ class EphemeralStore { } }; + // Ephemeral control when leaving a channel locally + addLeavingChannel = (channelId: string) => { + this.leavingChannels.add(channelId); + }; + + isLeavingChannel = (channelId: string) => { + return this.leavingChannels.has(channelId); + }; + + removeLeavingChannel = (channelId: string) => { + this.leavingChannels.delete(channelId); + }; + // Ephemeral control when joining a channel locally addJoiningChannel = (channelId: string) => { this.joiningChannels.add(channelId); @@ -142,10 +156,11 @@ class EphemeralStore { return this.joiningChannels.has(channelId); }; - removeJoiningChanel = (channelId: string) => { + removeJoiningChannel = (channelId: string) => { this.joiningChannels.delete(channelId); }; + // Ephemeral control when adding a team locally startAddingToTeam = (teamId: string) => { this.addingTeam.add(teamId); }; @@ -158,6 +173,7 @@ class EphemeralStore { return this.addingTeam.has(teamId); }; + // Ephemeral for push proxy state setPushProxyVerificationState = (serverUrl: string, state: string) => { this.pushProxyVerification[serverUrl] = state; }; diff --git a/app/utils/categories.ts b/app/utils/categories.ts index 128c5a3c1..61039dec8 100644 --- a/app/utils/categories.ts +++ b/app/utils/categories.ts @@ -1,10 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -export function makeCategoryId(name: string, userId: string, teamId: string) { - return `${name}_${userId}_${teamId}`; -} - export function makeCategoryChannelId(teamId: string, channelId: string) { return `${teamId}_${channelId}`; } diff --git a/app/utils/navigation/index.ts b/app/utils/navigation/index.ts index b7e522b99..c4f128310 100644 --- a/app/utils/navigation/index.ts +++ b/app/utils/navigation/index.ts @@ -13,7 +13,7 @@ export function mergeNavigationOptions(componentId: string, options: Options) { Navigation.mergeOptions(componentId, options); } -export async function alertTeamRemove(displayName: string, intl: IntlShape) { +export function alertTeamRemove(displayName: string, intl: IntlShape) { Alert.alert( intl.formatMessage({ id: 'alert.removed_from_team.title', @@ -30,7 +30,7 @@ export async function alertTeamRemove(displayName: string, intl: IntlShape) { ); } -export async function alertChannelRemove(displayName: string, intl: IntlShape) { +export function alertChannelRemove(displayName: string, intl: IntlShape) { Alert.alert( intl.formatMessage({ id: 'alert.removed_from_channel.title', @@ -46,3 +46,20 @@ export async function alertChannelRemove(displayName: string, intl: IntlShape) { }], ); } + +export function alertChannelArchived(displayName: string, intl: IntlShape) { + Alert.alert( + intl.formatMessage({ + id: 'alert.channel_deleted.title', + defaultMessage: 'Archived channel', + }), + intl.formatMessage({ + id: 'alert.channel_deleted.description', + defaultMessage: 'The channel {displayName} has been archived.', + }, {displayName}), + [{ + style: 'cancel', + text: intl.formatMessage({id: 'mobile.oauth.something_wrong.okButton', defaultMessage: 'OK'}), + }], + ); +} diff --git a/app/utils/snack_bar/index.ts b/app/utils/snack_bar/index.ts index 0d80b4e35..0ccbc70e5 100644 --- a/app/utils/snack_bar/index.ts +++ b/app/utils/snack_bar/index.ts @@ -15,9 +15,16 @@ export const showSnackBar = (passProps: ShowSnackBarArgs) => { showOverlay(screen, passProps); }; -export const showMuteChannelSnackbar = (onAction: () => void) => { +export const showMuteChannelSnackbar = (muted: boolean, onAction: () => void) => { return showSnackBar({ onAction, - barType: SNACK_BAR_TYPE.MUTE_CHANNEL, + barType: muted ? SNACK_BAR_TYPE.MUTE_CHANNEL : SNACK_BAR_TYPE.UNMUTE_CHANNEL, + }); +}; + +export const showFavoriteChannelSnackbar = (favorited: boolean, onAction: () => void) => { + return showSnackBar({ + onAction, + barType: favorited ? SNACK_BAR_TYPE.FAVORITE_CHANNEL : SNACK_BAR_TYPE.UNFAVORITE_CHANNEL, }); }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index e258c008c..f40cc9184 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -16,6 +16,8 @@ "account.settings": "Settings", "account.user_status.title": "User Presence", "account.your_profile": "Your Profile", + "alert.channel_deleted.description": "The channel {displayName} has been archived.", + "alert.channel_deleted.title": "Archived channel", "alert.push_proxy_error.description": "Due to the configuration for this server, notifications cannot be received in the mobile app. Contact your system admin for more information.", "alert.push_proxy_error.title": "Notifications cannot be received from this server", "alert.push_proxy_unknown.description": "This server was unable to receive push notifications for an unknown reason. This will be attempted again next time you connect.", @@ -95,8 +97,28 @@ "camera_type.title": "Choose an action", "camera_type.video.option": "Record Video", "center_panel.archived.closeChannel": "Close Channel", - "channel": "{count, plural, one {# member} other {# members}}", "channel_header.directchannel.you": "{displayName} (you)", + "channel_header.info": "View info", + "channel_header.member_count": "{count, plural, one {# member} other {# members}}", + "channel_info.close": "Close", + "channel_info.close_dm": "Close direct message", + "channel_info.close_dm_channel": "Are you sure you want to close this direct message? This will remove it from your home screen, but you can always open it again.", + "channel_info.close_gm": "Close group message", + "channel_info.close_gm_channel": "Are you sure you want to close this group message? This will remove it from your home screen, but you can always open it again.", + "channel_info.copied": "Copied", + "channel_info.copy_link": "Copy Link", + "channel_info.edit_header": "Edit Header", + "channel_info.favorite": "Favorite", + "channel_info.favorited": "Favorited", + "channel_info.leave": "Leave", + "channel_info.leave_channel": "Leave channel", + "channel_info.leave_private_channel": "Are you sure you want to leave the private channel {displayName}? You cannot rejoin the channel unless you're invited again.", + "channel_info.leave_public_channel": "Are you sure you want to leave the public channel {displayName}? You can always rejoin.", + "channel_info.muted": "Mute", + "channel_info.set_header": "Set Header", + "channel_list.channels_category": "Channels", + "channel_list.dms_category": "Direct messages", + "channel_list.favorites_category": "Favorites", "channel_list.find_channels": "Find channels...", "channel_loader.someone": "Someone", "channel_modal.descriptionHelp": "Describe how this channel should be used.", @@ -110,7 +132,6 @@ "channel_modal.optional": "(optional)", "channel_modal.purpose": "Purpose", "channel_modal.purposeEx": "A channel to file bugs and improvements", - "channel.details": "View details", "combined_system_message.added_to_channel.many_expanded": "{users} and {lastUser} were **added to the channel** by {actor}.", "combined_system_message.added_to_channel.one": "{firstUser} **added to the channel** by {actor}.", "combined_system_message.added_to_channel.one_you": "You were **added to the channel** by {actor}.", @@ -241,14 +262,12 @@ "home.header.plus_menu": "Options", "interactive_dialog.submit": "Submit", "intro.add_people": "Add People", - "intro.channel_details": "Details", + "intro.channel_info": "Info", "intro.created_by": "created by {creator} on {date}.", "intro.direct_message": "This is the start of your conversation with {teammate}. Messages and files shared here are not shown to anyone else.", - "intro.favorite": "Favorite", "intro.group_message": "This is the start of your conversation with this group. Messages and files shared here are not shown to anyone else outside of the group.", "intro.private_channel": "Private Channel", "intro.public_channel": "Public Channel", - "intro.set_header": "Set Header", "intro.townsquare": "Welcome to {name}. Everyone automatically becomes a member of this channel when they join the team.", "intro.welcome": "Welcome to {displayName} channel.", "intro.welcome.private": "Only invited members can see messages posted in this private channel.", @@ -546,8 +565,8 @@ "screen.mentions.subtitle": "Messages you've been mentioned in", "screen.mentions.title": "Recent Mentions", "screen.search.placeholder": "Search messages & files", - "screens.channel_details": "Channel Details", "screens.channel_edit_header": "Edit Channel Header", + "screens.channel_info": "Channel Info", "search_bar.search": "Search", "select_team.description": "You are not yet a member of any teams. Select one below to get started.", "select_team.no_team.description": "To join a team, ask a team admin for an invite, or create your own team. You may also want to check your email inbox for an invitation.", @@ -567,10 +586,13 @@ "servers.login": "Log in", "servers.logout": "Log out", "servers.remove": "Remove", + "snack.bar.favorited.channel": "This channel was favorited", "snack.bar.link.copied": "Link copied to clipboard", "snack.bar.message.copied": "Text copied to clipboard", "snack.bar.mute.channel": "This channel was muted", "snack.bar.undo": "Undo", + "snack.bar.unfavorite.channel": "This channel was unfavorited", + "snack.bar.unmute.channel": "This channel was unmuted", "status_dropdown.set_away": "Away", "status_dropdown.set_dnd": "Do Not Disturb", "status_dropdown.set_offline": "Offline", diff --git a/types/database/models/servers/category.d.ts b/types/database/models/servers/category.d.ts index 7a9477d64..f9fecf66c 100644 --- a/types/database/models/servers/category.d.ts +++ b/types/database/models/servers/category.d.ts @@ -59,4 +59,7 @@ export default class CategoryModel extends Model { /** hasChannels : Whether the category has any channels */ @lazy hasChannels: Observable; + + /** toCategoryWithChannels returns a map of the Category with an array of ordered channel ids */ + toCategoryWithChannels(): Promise; }