[Gekidou] channel quick actions (#6288)

* Add hitSlop to navigation header right buttons

* Fix channel_item info muted style

* Fix team switch when global threads

* Wrap WS channel events in try/catch

* Group Box component and Animated Group Box

* SlideUpPanelItem style

* Fix return value of setCurrentTeamAndChannelId

* Add observeChannelSettings and include channel settings in prepareDeleteChannel

* update OPTIONS_HEIGHT reference in find channels quick options

* Fix DM limit in channel list

* Fix category header style and translate default categories

* Add snackbar for unmute/favorite/unfavorite

* Add toggleFavoriteChannel remote action

* Add makeDirectChannelVisible remote action

* Use makeDirectChannelVisible in switchToChannelById and update toggleMuteChannel snackbar

* Add channel actions common components

* Update channel intro to use channel action common components

* Rename ChannelDetails screen to ChannelInfo

* Add channel quick actions

* Update localization strings

* Fix addChannelToDefaultCategory

* Leave channel

* Add localization strings

* Fix snackBar screen event listener

* Feedback review
This commit is contained in:
Elias Nahum 2022-05-19 14:30:55 -04:00 committed by GitHub
parent 06d2f1facd
commit 4573732fd2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 1628 additions and 479 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Chan
}
return {channel: channelData};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
}
export async function leaveChannel(serverUrl: string, channelId: string) {
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 isTabletDevice = await isTablet();
const user = await getCurrentUser(database);
const models: Model[] = [];
if (!user) {
return {error: 'current user not found'};
}
EphemeralStore.addLeavingChannel(channelId);
await client.removeFromChannel(user.id, channelId);
if (user.isGuest) {
const {models: updateVisibleModels} = await updateUsersNoLongerVisible(serverUrl, true);
if (updateVisibleModels) {
models.push(...updateVisibleModels);
}
}
const {models: removeUserModels} = await removeCurrentUserFromChannel(serverUrl, channelId, true);
if (removeUserModels) {
models.push(...removeUserModels);
}
await operator.batchRecords(models);
if (isTabletDevice) {
switchToLastChannel(serverUrl);
} else {
setCurrentChannelId(operator, '');
}
return {error: undefined};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
} finally {
EphemeralStore.removeLeavingChannel(channelId);
}
}
export async function fetchChannelCreator(serverUrl: string, channelId: string, fetchOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
@ -456,7 +514,7 @@ export async function joinChannel(serverUrl: string, userId: string, teamId: str
}
} catch (error) {
if (channelId || channel?.id) {
EphemeralStore.removeJoiningChanel(channelId || channel!.id);
EphemeralStore.removeJoiningChannel(channelId || channel!.id);
}
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
@ -486,13 +544,13 @@ export async function joinChannel(serverUrl: string, userId: string, teamId: str
}
} catch (error) {
if (channelId || channel?.id) {
EphemeralStore.removeJoiningChanel(channelId || channel!.id);
EphemeralStore.removeJoiningChannel(channelId || channel!.id);
}
return {error};
}
if (channelId || channel?.id) {
EphemeralStore.removeJoiningChanel(channelId || channel!.id);
EphemeralStore.removeJoiningChannel(channelId || channel!.id);
}
return {channel, member};
}
@ -810,6 +868,7 @@ export async function makeDirectChannel(serverUrl: string, userId: string, displ
return {error};
}
}
export async function fetchArchivedChannels(serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) {
let client: Client;
try {
@ -901,6 +960,7 @@ export async function createGroupChannel(serverUrl: string, userIds: string[]) {
return {error};
}
}
export async function fetchSharedChannels(serverUrl: string, teamId: string, page = 0, perPage: number = General.CHANNELS_CHUNK_SIZE) {
let client: Client;
try {
@ -938,6 +998,7 @@ export async function makeGroupChannel(serverUrl: string, userIds: string[], sho
return {error};
}
}
export async function getChannelMemberCountsByGroup(serverUrl: string, channelId: string, includeTimezones: boolean) {
let client: Client;
try {
@ -982,21 +1043,37 @@ export async function switchToChannelById(serverUrl: string, channelId: string,
fetchPostsForChannel(serverUrl, channelId);
await switchToChannel(serverUrl, channelId, teamId, skipLastUnread);
setDirectChannelVisible(serverUrl, channelId);
markChannelAsRead(serverUrl, channelId);
fetchChannelStats(serverUrl, channelId);
return {};
}
export async function switchToPenultimateChannel(serverUrl: string) {
export async function switchToPenultimateChannel(serverUrl: string, teamId?: string) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const currentTeam = await getCurrentTeamId(database);
const channelId = await getNthLastChannelFromTeam(database, currentTeam, 1);
const teamIdToUse = teamId || await getCurrentTeamId(database);
const channelId = await getNthLastChannelFromTeam(database, teamIdToUse, 1);
return switchToChannelById(serverUrl, channelId);
} catch (error) {
return {error};
}
}
export async function switchToLastChannel(serverUrl: string, teamId?: string) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
try {
const teamIdToUse = teamId || await getCurrentTeamId(database);
const channelId = await getNthLastChannelFromTeam(database, teamIdToUse);
return switchToChannelById(serverUrl, channelId);
} catch (error) {
return {error};
@ -1113,7 +1190,7 @@ export const toggleMuteChannel = async (serverUrl: string, channelId: string, sh
if (showSnackBar) {
const onUndo = () => toggleMuteChannel(serverUrl, channelId, false);
showMuteChannelSnackbar(onUndo);
showMuteChannelSnackbar(mark_unread === 'mention', onUndo);
}
return {

View file

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

View file

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

View file

@ -5,6 +5,7 @@ export interface ClientCategoriesMix {
getCategories: (userId: string, teamId: string) => Promise<CategoriesWithOrder>;
getCategoriesOrder: (userId: string, teamId: string) => Promise<string[]>;
getCategory: (userId: string, teamId: string, categoryId: string) => Promise<Category>;
updateChannelCategories: (userId: string, teamId: string, categories: CategoryWithChannels[]) => Promise<CategoriesWithOrder>;
}
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;

View file

@ -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<ViewStyle>;
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 (
<OptionBox
containerStyle={containerStyle}
iconName='account-plus-outline'
onPress={onAddPeople}
testID={testID}
text={intl.formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'})}
/>
);
};
export default AddPeopleBox;

View file

@ -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 (
<AnimatedOptionBox
animatedBackgroundColor={theme.onlineIndicator}
animatedColor={theme.buttonColor}
animatedIconName='check'
animatedText={intl.formatMessage({id: 'channel_info.copied', defaultMessage: 'Copied'})}
iconName='link-variant'
onAnimationEnd={onAnimationEnd}
onPress={onCopyLink}
testID={testID}
text={intl.formatMessage({id: 'channel_info.copy_link', defaultMessage: 'Copy Link'})}
/>
);
};
export default CopyChannelLinkBox;

View file

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

View file

@ -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<ViewStyle>;
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 (
<OptionBox
activeIconName='star'
activeText={intl.formatMessage({id: 'channel_info.favorited', defaultMessage: 'Favorited'})}
containerStyle={containerStyle}
iconName='star-outline'
isActive={isFavorited}
onPress={handleOnPress}
testID={testID}
text={intl.formatMessage({id: 'channel_info.favorite', defaultMessage: 'Favorite'})}
/>
);
};
export default FavoriteBox;

View file

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

View file

@ -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<ViewStyle>;
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 (
<SlideUpPanelItem
icon='information-outline'
onPress={onViewInfo}
testID={testID}
text={intl.formatMessage({id: 'channel_header.info', defaultMessage: 'View info'})}
/>
);
}
return (
<OptionBox
containerStyle={containerStyle}
iconName='information-outline'
onPress={onViewInfo}
testID={testID}
text={intl.formatMessage({id: 'intro.channel_info', defaultMessage: 'Info'})}
/>
);
};
export default InfoBox;

View file

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

View file

@ -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 (
<SlideUpPanelItem
destructive={true}
icon='close'
onPress={onLeave}
text={leaveText}
testID={testID}
/>
);
};
export default LeaveChanelLabel;

View file

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

View file

@ -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<ViewStyle>;
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 (
<OptionBox
activeIconName='bell-off-outline'
activeText={intl.formatMessage({id: 'channel_info.muted', defaultMessage: 'Muted'})}
containerStyle={containerStyle}
iconName='bell-outline'
isActive={isMuted}
onPress={handleOnPress}
testID={testID}
text={intl.formatMessage({id: 'channel_info.muted', defaultMessage: 'Mute'})}
/>
);
};
export default MutedBox;

View file

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

View file

@ -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<ViewStyle>;
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 (
<OptionBox
containerStyle={containerStyle}
iconName='pencil-outline'
onPress={onSetHeader}
testID={testID}
text={text}
/>
);
};
export default SetHeaderBox;

View file

@ -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(() => [

View file

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

View file

@ -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 = ({
<TouchableWithFeedback
key={r.iconName}
borderlessRipple={r.borderless === undefined ? true : r.borderless}
hitSlop={rightButtonHitSlop}
onPress={r.onPress}
rippleRadius={r.rippleRadius || 20}
type={r.buttonType || Platform.select({android: 'native', default: 'opacity'})}
style={i > 0 ? styles.rightIcon : undefined}
style={i > 0 && styles.rightIcon}
testID={r.testID}
>
<CompassIcon

View file

@ -0,0 +1,158 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {Pressable, StyleSheet, Text} from 'react-native';
import Animated, {Easing, interpolate, interpolateColor, runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {OPTIONS_HEIGHT} from '.';
type OptionBoxProps = {
animatedBackgroundColor: string;
animatedColor: string;
animatedIconName: string;
animatedText: string;
iconName: string;
onAnimationEnd?: () => 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 (
<AnimatedPressable
onPress={handleOnPress}
disabled={activated}
testID={testID}
>
{({pressed}) => (
<Animated.View style={[styles.container, pressed && {backgroundColor: changeOpacity(theme.buttonBg, 0.16)}]}>
<Animated.View style={[styles.container, backgroundStyle]}>
<Animated.View style={[StyleSheet.absoluteFill, styles.center, optionStyle]}>
<CompassIcon
color={pressed ? theme.buttonBg : changeOpacity(theme.centerChannelColor, 0.56)}
name={iconName}
size={24}
/>
<Text
numberOfLines={1}
style={[styles.text, {color: pressed ? theme.buttonBg : changeOpacity(theme.centerChannelColor, 0.56)}]}
testID={`${testID}.label`}
>
{text}
</Text>
</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, styles.center, scaleStyle]}>
<CompassIcon
color={animatedColor}
name={animatedIconName}
size={24}
/>
<Text
numberOfLines={1}
style={[styles.text, {color: animatedColor}]}
testID={`${testID}.animated`}
>
{animatedText}
</Text>
</Animated.View>
</Animated.View>
</Animated.View>
)}
</AnimatedPressable>
);
};
export default AnimatedOptionBox;

View file

@ -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<ViewStyle>;
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 (
<TouchableHighlight
onPress={onPress}
style={[styles.container, style]}
underlayColor={changeOpacity(theme.centerChannelColor, 0.32) || underlayColor}
<Pressable
onPress={handleOnPress}
style={pressedStyle}
testID={testID}
>
<>
<CompassIcon
color={iconColor || changeOpacity(theme.centerChannelColor, 0.56)}
name={iconName}
size={24}
/>
<Text
numberOfLines={1}
style={[styles.text, textStyle]}
>
{text}
</Text>
</>
</TouchableHighlight>
{({pressed}) => (
<>
<CompassIcon
color={(pressed || activated) ? theme.buttonBg : changeOpacity(theme.centerChannelColor, 0.56)}
name={activated && activeIconName ? activeIconName : iconName}
size={24}
/>
<Text
numberOfLines={1}
style={[styles.text, {color: (pressed || activated) ? theme.buttonBg : changeOpacity(theme.centerChannelColor, 0.56)}]}
testID={`${testID}.label`}
>
{activated && activeText ? activeText : text}
</Text>
</>
)}
</Pressable>
);
};

View file

@ -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 (
<TouchableWithFeedback
<TouchableHighlight
onPress={handleOnPress}
style={style.container}
testID={testID}
type='native'
underlayColor={changeOpacity(theme.centerChannelColor, 0.5)}
underlayColor={changeOpacity(theme.buttonBg, 0.08)}
>
<View style={style.row}>
{Boolean(image) && !rightIcon &&
<View style={iconStyle}>{image}</View>
}
<View style={style.textContainer}>
<Text style={[style.text, destructive ? style.destructive : null, textStyles]}>{text}</Text>
<Text style={[style.text, destructive && style.destructive, textStyles]}>{text}</Text>
</View>
{Boolean(image) && rightIcon &&
<View style={iconStyle}>{image}</View>
}
</View>
</TouchableWithFeedback>
</TouchableHighlight>
);
};

View file

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

View file

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

View file

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

View file

@ -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<string, SnackBarConfig> = {
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<string, SnackBarConfig> = {
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 {

View file

@ -109,4 +109,23 @@ export default class CategoryModel extends Model implements CategoryInterface {
map((c) => c > 0),
distinctUntilChanged(),
);
toCategoryWithChannels = async (): Promise<CategoryWithChannels> => {
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,
};
};
}

View file

@ -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<Mo
return preparedModels;
};
export const queryChannelCategory = (database: Database, teamId: string, channelId: string) => {
return database.get<CategoryModel>(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(),
);
};

View file

@ -127,12 +127,18 @@ export const prepareMyChannelsForTeam = async (operator: ServerDataOperator, tea
export const prepareDeleteChannel = async (channel: ChannelModel): Promise<Model[]> => {
const preparedModels: Model[] = [channel.prepareDestroyPermanently()];
const relations: Array<Relation<Model> | undefined> = [channel.membership, channel.info, channel.categoryChannel];
const relations: Array<Relation<Model|MyChannelModel> | 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<MyChannelSettingsModel>(MY_CHANNEL_SETTINGS).query(Q.where('id', channelId), Q.take(1)).observe().pipe(
switchMap((result) => (result.length ? result[0].observe() : of$(undefined))),
);
};

View file

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

View file

@ -147,7 +147,6 @@ const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) =
header={true}
favorite={true}
people={false}
theme={theme}
/>
</View>
);

View file

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

View file

@ -1,38 +0,0 @@
// 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 {saveFavoriteChannel} from '@actions/remote/preference';
import {useServerUrl} from '@context/server';
import OptionItem from '../item';
type Props = {
channelId: string;
isFavorite: boolean;
theme: Theme;
}
const IntroFavorite = ({channelId, isFavorite, theme}: Props) => {
const {formatMessage} = useIntl();
const serverUrl = useServerUrl();
const toggleFavorite = useCallback(() => {
saveFavoriteChannel(serverUrl, channelId, !isFavorite);
}, [channelId, isFavorite]);
return (
<OptionItem
applyMargin={true}
color={isFavorite ? theme.buttonBg : undefined}
iconName={isFavorite ? 'star' : 'star-outline'}
label={formatMessage({id: 'intro.favorite', defaultMessage: 'Favorite'})}
onPress={toggleFavorite}
theme={theme}
/>
);
};
export default IntroFavorite;

View file

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

View file

@ -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 (
<View style={styles.container}>
{people &&
<OptionItem
applyMargin={true}
iconName='account-plus-outline'
label={formatMessage({id: 'intro.add_people', defaultMessage: 'Add People'})}
onPress={onAddPeople}
<AddPeopleBox
channelId={channelId}
containerStyle={[styles.item, styles.margin]}
testID='channel_post_list.intro.option_item.add_people'
theme={theme}
/>
}
{header &&
<OptionItem
applyMargin={true}
iconName='pencil-outline'
label={formatMessage({id: 'intro.set_header', defaultMessage: 'Set Header'})}
onPress={onSetHeader}
<SetHeaderBox
channelId={channelId}
containerStyle={[styles.item, styles.margin]}
testID='channel_post_list.intro.option_item.set_header'
theme={theme}
/>
}
{favorite &&
<IntroFavorite
<FavoriteBox
channelId={channelId}
theme={theme}
containerStyle={[styles.item, styles.margin]}
testID='channel_post_list.intro.option_item.favorite'
/>
}
<OptionItem
iconName='information-outline'
label={formatMessage({id: 'intro.channel_details', defaultMessage: 'Details'})}
onPress={onDetails}
<InfoBox
channelId={channelId}
containerStyle={styles.item}
testID='channel_post_list.intro.option_item.channel_details'
theme={theme}
/>
</View>
);

View file

@ -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 (
<Pressable
onPress={onPress}
style={pressedStyle}
testID={testID}
>
{({pressed}) => (
<>
<CompassIcon
name={iconName}
color={pressed ? theme.linkColor : color || changeOpacity(theme.centerChannelColor, 0.56)}
size={24}
/>
<Text
style={[styles.label, {color: pressed ? theme.linkColor : color || changeOpacity(theme.centerChannelColor, 0.56)}]}
testID={`${testID}.label`}
>
{label}
</Text>
</>
)}
</Pressable>
);
};
export default IntroItem;

View file

@ -140,7 +140,6 @@ const PublicOrPrivateChannel = ({channel, creator, roles, theme}: Props) => {
channelId={channel.id}
header={canSetHeader}
people={canManagePeople}
theme={theme}
/>
</View>
);

View file

@ -61,7 +61,6 @@ const TownSquare = ({channelId, displayName, roles, theme}: Props) => {
<IntroOptions
channelId={channelId}
header={hasPermission(roles, Permissions.MANAGE_PUBLIC_CHANNEL_PROPERTIES, false)}
theme={theme}
/>
</View>
);

View file

@ -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 (<OtherMentionsBadge channelId={channelId}/>);
}, [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 <ChannelQuickAction channelId={channelId}/>;
};
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(() => {

View file

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

View file

@ -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 (
<View style={styles.container}>
<View style={styles.wrapper}>
<FavoriteBox
channelId={channelId}
showSnackBar={true}
/>
<View style={styles.separator}/>
<MutedBox
channelId={channelId}
showSnackBar={true}
/>
<View style={styles.separator}/>
{channelType && DIRECT_CHANNELS.includes(channelType) &&
<SetHeaderBox channelId={channelId}/>
}
{channelType && !DIRECT_CHANNELS.includes(channelType) &&
<>
<AddPeopleBox channelId={channelId}/>
<View style={styles.separator}/>
<CopyChannelLinkBox
channelId={channelId}
onAnimationEnd={onCopyLinkAnimationEnd}
/>
</>
}
</View>
<InfoBox
channelId={channelId}
showAsLabel={true}
/>
<View style={{backgroundColor: changeOpacity(theme.centerChannelColor, 0.08), height: 1, marginVertical: 8}}/>
<LeaveChannelLabel channelId={channelId}/>
</View>
);
};
export default ChannelQuickAction;

View file

@ -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<void>;
}
const OPTIONS_HEIGHT = 60;
const styles = StyleSheet.create({
container: {
marginTop: 20,

View file

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

View file

@ -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
</Text>
</View>
</View>

View file

@ -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 (
<TouchableOpacity
onPress={toggleCollapse}
@ -92,7 +109,7 @@ const CategoryHeader = ({category, hasChannels}: Props) => {
style={styles.heading}
testID={`category_header.${category.type}.display_name`}
>
{category.displayName.toUpperCase()}
{displayName}
</Text>
</View>
</TouchableOpacity>

View file

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

View file

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

View file

@ -18,6 +18,7 @@ class EphemeralStore {
// and make sure we only handle one.
private addingTeam = new Set<string>();
private joiningChannels = new Set<string>();
private leavingChannels = new Set<string>();
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;
};

View file

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

View file

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

View file

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

View file

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

View file

@ -59,4 +59,7 @@ export default class CategoryModel extends Model {
/** hasChannels : Whether the category has any channels */
@lazy hasChannels: Observable<boolean>;
/** toCategoryWithChannels returns a map of the Category with an array of ordered channel ids */
toCategoryWithChannels(): Promise<CategoryWithChannels>;
}