From 218f98e3e021795da85705e8e1a9ae9080618fb0 Mon Sep 17 00:00:00 2001 From: Jason Frerich Date: Fri, 3 Feb 2023 02:42:12 -0600 Subject: [PATCH] [Gekidou - MM-47653] Implement Manage Members Screen (#6771) * move user_list to component * start the modal from create_direct_message * initial commit * Add managing options to user profile * s/showManage/showManageMode/ * simplify * use helper functions * add dependency * fix togglling manage/done button * remove close button in navbar * remove close button. The only exit from this screen is the back button * utilize LeaveChannelLabel component actions * nit * nit * slight refactor * return earlier if not showManageMode * use defineMessages * don't modify leave channel component * add manage_members_label component * rename variables to imply manage actions * remove user from channel on server and locally in channel membership * prevent managing yourself. In V1, this is done by not allowing you to select yourself for channel removal * remove useReducer * - fix typography - fix icon size - don't allow tapping on yourself in manage mode * sort props * sort props * sort props * - combine try blocks - use getServerDatabaseAndOperator function to get the operator * fetchChannelStats after removing users from a channel * currently, the UI does not provide a need to remove multiple members from a channel, only one member. modify the function to only accept and remove one user * no need to pass the entire channel object. only need the channelId which is already passed into the screen * do not pass the entire user model, only the userid and if user canManage (is sysadmin or channel admin) * move members constants to its own file and out of general.ts file * pass channel displayName instead of the entire channel object * not need to store the user as it is already in the store from the fetchProfilesInChannel call * implement device emitter to notify the parent to remove to the user from the user list * rename constant in reveal removing a member from a channel. Might need for another team removal later. * add snackbar after user is removed * remove unnessary filter * remove paging. Server response is not paginated deconstruct intl * create EMPTY const * simplify getProfiles function * move constants to top of file * add function to remove the user from the server * clean up dependencies * remove @app/ prefix from imports * add comment describing reason for switch / case * rename varaible to be more intention revealing * calculate isDefaultChannel and pass in as prop so don't need to query for each user * if user cannot manage, do not show the manage nav button * move options const into function that uses it * have the caller of handeRemoveMemberFromChannel fetch channel stats, not the action * nit formatting * s/canManage/canManageMembers/ * use existing observeCanManageChannelMembers function function only requires channel id * move userInfo and manage user options to their own components * calculate bottom sheet snap points when in manage mode * implement correct permissions for managing users. For now, only channel admins can manage users (including deleting members) * working on section creation * use map instead of arrays * - handle user profile sections differently when in members are provided (manage mode) - emit event when user role is changed - modify the channelMembers in manage members modal after changing user role * remove commented code * deconstruct options * sort dependencies and add loading dependency * - when removing a user, remove them from channelMembers state also - don't add empty sections to the user list results * user profile coming from ManageChannelMembers is UserProfile joined with their ChannelMembership. Can now check for scheme_admin to see if the user is a channel admin * deconstruct locale from intl and remove intl const * Add SearchUserOptions type to provide type checking when creating options for searchProfile action and searchUsers client api * correct comment * deconstruct MANAGE_OPTIONS * Remove unused event constant * nits * Push header title in to the UserProfileTitle component * Put constants back so Diff of file is smaller * Combine switch statements Remove isOptionItem. These are always action items * Wrap onAction in a usecallback * Add help comments * Add i18n to section titles * Create RenderItemType for renderItem callback * update testID update snapshots * CanManageMembers is deterimined by observeCanManageChannelMembers * Add members chanenl option * Update after merge * Sort in order of options shown * nit refactor * Modify client getProfilesInChannel allow passing more options than sort. - sort the profiles by admin - do not show deactivated users in the manage members modal * Profiles are now sorted by admin. We can maintain the alphabetical sort also by iterating over the profiles instead of members which are not alphabetical * Type the get users Api object * Add type. Active option is a boolean, not a string * only initialize if needed. Moved inside the check for members * Create type for Manage Member Options * Remove one liners and call directly in the switch block * Keys to the map do not need to be translated. Only translate the title Place the Admins section always on top * Add removeFromChannel as a dependency * Remove manageMode option from the title component - add imageSize prop - add headerText prop * Do not show deactivated users in search * When users are showing and not in manage mode, allow the user to tap and open the profile for the user (in non-manage mode) * Add fetchOnly to getMemberInChannel function Add fetchOnly to updateChannelMembersSchemeRoles function Remove getMemberInChannel from handleUserChangeRole in manage_channel_members because it is already called via updateChannelMembersSchemeRoles * Remove todo from comment * Don't use state for defining action text, icon, and isDestructive. just set them based on the prop value manageOption * Added correct permission check for can user manage member roles * Add can manage member roles prop * Calculate snap points based on manageMemberRoles prop * Calculate snap point based on if user can remove other users * Do not show options if you cannot remove or manage members * Fix post merge issues * No need to batch because only manipulating a single model * Remove comment * Rename variable * Split and sort props into multiple lines for readability * Nit * Make dependency more specific * Remove comment. Doing this requires writing a custom search function in the app that would need to guarantee the same results as a server call * Add logError to functions with catch * Add ticket reference * Remove await from functions that are updating the database. Components that observe models these modify will get the update based from the observable change. * Keep track of which section is first so that the tutorial highlight selects the first user profile of the first section * Add a second user that creates a new section for testing tutorial * Remove unused prop * Update snapshot to include second user * Use getServerDatabaseAndOperator * remove testID change. Added a ticket to fix later * Revert tests to only one user to test if previous tests worked * Add new test that has 2 users * Add ticket context as comment * Add channelId as dependency * Use useCallback for updateChannelMemberSchemeRole * Remove async * mounted.current should only be used in an effect that executes on the first render when user has permission to manage members changed, there is no need to get the profiles again * Add await for function * Always reset loading to false after getting profiles * use !text instead of const value using Boolean() * add dependency * Add manage members ids back * When fetching users for the channel, always store them in the database. Otherwise tapping a user might not be in the database and tapping on them will cause a crash * Fetch the user profile from the server when opening the user profile * Checking management permissions should be based on the current user, not the user of the profile being opened --------- Co-authored-by: Avinash Lingaloo --- app/actions/local/user.ts | 42 +- app/actions/remote/channel.ts | 99 +- app/actions/remote/user.ts | 10 +- app/client/rest/channels.ts | 21 + app/client/rest/users.ts | 8 +- .../leave_channel_label.tsx | 4 +- .../manage_members_label/index.tsx | 33 + .../manage_members_label.tsx | 152 ++ app/components/post_list/post/index.ts | 2 +- .../__snapshots__/index.test.tsx.snap | 503 +++++ app/components/user_list/index.test.tsx | 52 + app/components/user_list/index.tsx | 110 +- app/components/user_list_row/index.tsx | 74 +- app/constants/events.ts | 2 + app/constants/index.ts | 2 + app/constants/members.ts | 16 + app/constants/screens.ts | 3 + app/constants/snack_bar.ts | 7 + app/queries/servers/role.ts | 4 +- .../intro/direct_channel/direct_channel.tsx | 2 +- app/screens/channel_info/options/index.tsx | 5 +- .../channel_info/options/members/index.ts | 7 +- .../channel_info/options/members/members.tsx | 18 +- app/screens/index.tsx | 3 + app/screens/invite/invite.tsx | 2 +- app/screens/manage_channel_members/index.tsx | 40 + .../manage_channel_members.tsx | 281 +++ app/screens/snack_bar/index.tsx | 2 +- app/screens/user_profile/index.ts | 21 +- .../user_profile/manage_user_options.tsx | 62 + app/screens/user_profile/title/avatar.tsx | 27 +- app/screens/user_profile/title/index.tsx | 95 +- app/screens/user_profile/user_info.tsx | 54 + app/screens/user_profile/user_profile.tsx | 136 +- app/utils/snack_bar/index.ts | 7 + assets/base/i18n/en.json | 1963 +++++++++-------- types/api/users.d.ts | 30 + 37 files changed, 2745 insertions(+), 1154 deletions(-) create mode 100644 app/components/channel_actions/manage_members_label/index.tsx create mode 100644 app/components/channel_actions/manage_members_label/manage_members_label.tsx create mode 100644 app/constants/members.ts create mode 100644 app/screens/manage_channel_members/index.tsx create mode 100644 app/screens/manage_channel_members/manage_channel_members.tsx create mode 100644 app/screens/user_profile/manage_user_options.tsx create mode 100644 app/screens/user_profile/user_info.tsx diff --git a/app/actions/local/user.ts b/app/actions/local/user.ts index 21ddbd83ef..bbae451914 100644 --- a/app/actions/local/user.ts +++ b/app/actions/local/user.ts @@ -97,27 +97,37 @@ export const updateRecentCustomStatuses = async (serverUrl: string, customStatus export const updateLocalUser = async ( serverUrl: string, userDetails: Partial & { status?: string}, + userId?: string, ) => { try { const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); - const user = await getCurrentUser(database); + + let user: UserModel | undefined; + + if (userId) { + user = await getUserById(database, userId); + } else { + user = await getCurrentUser(database); + } + if (user) { + const u = user; await database.write(async () => { - await user.update((userRecord: UserModel) => { - userRecord.authService = userDetails.auth_service ?? user.authService; - userRecord.email = userDetails.email ?? user.email; - userRecord.firstName = userDetails.first_name ?? user.firstName; - userRecord.lastName = userDetails.last_name ?? user.lastName; - userRecord.lastPictureUpdate = userDetails.last_picture_update ?? user.lastPictureUpdate; - userRecord.locale = userDetails.locale ?? user.locale; - userRecord.nickname = userDetails.nickname ?? user.nickname; - userRecord.notifyProps = userDetails.notify_props ?? user.notifyProps; - userRecord.position = userDetails?.position ?? user.position; - userRecord.props = userDetails.props ?? user.props; - userRecord.roles = userDetails.roles ?? user.roles; - userRecord.status = userDetails?.status ?? user.status; - userRecord.timezone = userDetails.timezone ?? user.timezone; - userRecord.username = userDetails.username ?? user.username; + await u.update((userRecord: UserModel) => { + userRecord.authService = userDetails.auth_service ?? u.authService; + userRecord.email = userDetails.email ?? u.email; + userRecord.firstName = userDetails.first_name ?? u.firstName; + userRecord.lastName = userDetails.last_name ?? u.lastName; + userRecord.lastPictureUpdate = userDetails.last_picture_update ?? u.lastPictureUpdate; + userRecord.locale = userDetails.locale ?? u.locale; + userRecord.nickname = userDetails.nickname ?? u.nickname; + userRecord.notifyProps = userDetails.notify_props ?? u.notifyProps; + userRecord.position = userDetails?.position ?? u.position; + userRecord.props = userDetails.props ?? u.props; + userRecord.roles = userDetails.roles ?? u.roles; + userRecord.status = userDetails?.status ?? u.status; + userRecord.timezone = userDetails.timezone ?? u.timezone; + userRecord.username = userDetails.username ?? u.username; }); }); } diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 9d3a92003e..8bc9ba0b3f 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -7,6 +7,7 @@ import {DeviceEventEmitter} from 'react-native'; import {addChannelToDefaultCategory, storeCategories} from '@actions/local/category'; import {removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; import {switchToGlobalThreads} from '@actions/local/thread'; +import {updateLocalUser} from '@actions/local/user'; import {loadCallForChannel} from '@calls/actions/calls'; import {DeepLink, Events, General, Preferences, Screens} from '@constants'; import DatabaseManager from '@database/manager'; @@ -15,7 +16,7 @@ import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; import AppsManager from '@managers/apps_manager'; import NetworkManager from '@managers/network_manager'; import {getActiveServer} from '@queries/app/servers'; -import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds, getMembersCountByChannelsId, queryChannelsById} from '@queries/servers/channel'; +import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds, getMembersCountByChannelsId, deleteChannelMembership, queryChannelsById} from '@queries/servers/channel'; import {queryDisplayNamePreferences} from '@queries/servers/preference'; import {getCommonSystemValues, getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, getLicense, setCurrentChannelId, setCurrentTeamAndChannelId} from '@queries/servers/system'; import {getNthLastChannelFromTeam, getMyTeamById, getTeamByName, queryMyTeams, removeChannelFromTeamHistory} from '@queries/servers/team'; @@ -35,9 +36,10 @@ import {setDirectChannelVisible} from './preference'; import {fetchRolesIfNeeded} from './role'; import {forceLogoutIfNecessary} from './session'; import {addCurrentUserToTeam, fetchTeamByName, removeCurrentUserFromTeam} from './team'; -import {fetchProfilesInGroupChannels, fetchProfilesPerChannels, fetchUsersByIds, updateUsersNoLongerVisible} from './user'; +import {fetchProfilesInChannel, fetchProfilesInGroupChannels, fetchProfilesPerChannels, fetchUsersByIds, updateUsersNoLongerVisible} from './user'; import type {Client} from '@client/rest'; +import type ClientError from '@client/rest/error'; import type {Model} from '@nozbe/watermelondb'; import type ChannelModel from '@typings/database/models/servers/channel'; import type {IntlShape} from 'react-intl'; @@ -49,6 +51,99 @@ export type MyChannelsRequest = { error?: unknown; } +export type ChannelMembersRequest = { + members?: ChannelMembership[]; + error?: unknown; +} + +export async function removeMemberFromChannel(serverUrl: string, channelId: string, userId: string) { + try { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const client = NetworkManager.getClient(serverUrl); + + await client.removeFromChannel(userId, channelId); + await deleteChannelMembership(operator, userId, channelId); + + return {error: undefined}; + } catch (error) { + logError('removeMemberFromChannel', error); + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +} + +export async function fetchChannelMembersByIds(serverUrl: string, channelId: string, userIds: string[], fetchOnly = false): Promise { + try { + const client = NetworkManager.getClient(serverUrl); + const members = await client.getChannelMembersByIds(channelId, userIds); + + if (!fetchOnly) { + const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + if (operator && members.length) { + const memberships = members.map((u) => ({ + channel_id: channelId, + user_id: u.user_id, + scheme_admin: u.scheme_admin, + })); + await operator.handleChannelMembership({ + channelMemberships: memberships, + prepareRecordsOnly: false, + }); + } + } + + return {members}; + } catch (error) { + logError('fetchChannelMembersByIds', error); + forceLogoutIfNecessary(serverUrl, error as ClientError); + return {error}; + } +} +export async function updateChannelMemberSchemeRoles(serverUrl: string, channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + await client.updateChannelMemberSchemeRoles(channelId, userId, isSchemeUser, isSchemeAdmin); + + if (!fetchOnly) { + return getMemberInChannel(serverUrl, channelId, userId); + } + + return {error: undefined}; + } catch (error) { + logError('updateChannelMemberSchemeRoles', error); + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +} + +export async function getMemberInChannel(serverUrl: string, channelId: string, userId: string, fetchOnly = false) { + try { + const client = NetworkManager.getClient(serverUrl); + const member = await client.getMemberInChannel(channelId, userId); + + if (!fetchOnly) { + updateLocalUser(serverUrl, member, userId); + } + return {member, error: undefined}; + } catch (error) { + logError('getMemberInChannel', error); + forceLogoutIfNecessary(serverUrl, error as ClientErrorProps); + return {error}; + } +} + +export async function fetchChannelMemberships(serverUrl: string, channelId: string, options: GetUsersOptions, fetchOnly = false) { + const {users = []} = await fetchProfilesInChannel(serverUrl, channelId, undefined, options, fetchOnly); + const userIds = users.map((u) => u.id); + + // MM-49896 https://mattermost.atlassian.net/browse/MM-49896 + // We are not sure the getChannelMembers API returns the same members + // from getProfilesInChannel. This guarantees a 1:1 match of the + // user IDs + const {members = []} = await fetchChannelMembersByIds(serverUrl, channelId, userIds, true); + return {users, members}; +} + export async function addMembersToChannel(serverUrl: string, channelId: string, userIds: string[], postRootId = '', fetchOnly = false) { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index a1d6dc9c78..cc6153fc2e 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -74,7 +74,7 @@ export const fetchMe = async (serverUrl: string, fetchOnly = false): Promise { +export async function fetchProfilesInChannel(serverUrl: string, channelId: string, excludeUserId?: string, options?: GetUsersOptions, fetchOnly = false): Promise { let client: Client; try { client = NetworkManager.getClient(serverUrl); @@ -83,7 +83,7 @@ export async function fetchProfilesInChannel(serverUrl: string, channelId: strin } try { - const users = await client.getProfilesInChannel(channelId); + const users = await client.getProfilesInChannel(channelId, options); const uniqueUsers = Array.from(new Set(users)); const filteredUsers = uniqueUsers.filter((u) => u.id !== excludeUserId); if (!fetchOnly) { @@ -108,6 +108,7 @@ export async function fetchProfilesInChannel(serverUrl: string, channelId: strin return {channelId, users: filteredUsers}; } catch (error) { + logError('fetchProfilesInChannel', error); forceLogoutIfNecessary(serverUrl, error as ClientError); return {channelId, error}; } @@ -198,7 +199,7 @@ export async function fetchProfilesPerChannels(serverUrl: string, channelIds: st const data: ProfilesInChannelRequest[] = []; for await (const cIds of channels) { - const requests = cIds.map((id) => fetchProfilesInChannel(serverUrl, id, excludeUserId, true)); + const requests = cIds.map((id) => fetchProfilesInChannel(serverUrl, id, excludeUserId, undefined, true)); const response = await Promise.all(requests); data.push(...response); } @@ -530,7 +531,7 @@ export const fetchProfilesInTeam = async (serverUrl: string, teamId: string, pag } }; -export const searchProfiles = async (serverUrl: string, term: string, options: any = {}, fetchOnly = false) => { +export const searchProfiles = async (serverUrl: string, term: string, options: SearchUserOptions, fetchOnly = false) => { let client: Client; try { client = NetworkManager.getClient(serverUrl); @@ -563,6 +564,7 @@ export const searchProfiles = async (serverUrl: string, term: string, options: a return {data: users}; } catch (error) { + logError('searchProfiles', error); forceLogoutIfNecessary(serverUrl, error as ClientError); return {error}; } diff --git a/app/client/rest/channels.ts b/app/client/rest/channels.ts index 15499a6077..b5adb465a0 100644 --- a/app/client/rest/channels.ts +++ b/app/client/rest/channels.ts @@ -40,6 +40,8 @@ export interface ClientChannelsMix { searchChannels: (teamId: string, term: string) => Promise; searchArchivedChannels: (teamId: string, term: string) => Promise; searchAllChannels: (term: string, teamIds: string[], archivedOnly?: boolean) => Promise; + updateChannelMemberSchemeRoles: (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => Promise; + getMemberInChannel: (channelId: string, userId: string) => Promise; } const ClientChannels = (superclass: any) => class extends superclass { @@ -327,6 +329,25 @@ const ClientChannels = (superclass: any) => class extends superclass { {method: 'post', body}, ); }; + + // Update a channel member's scheme_admin/scheme_user properties. Typically + // this should either be scheme_admin=false, scheme_user=true for ordinary + // channel member, or scheme_admin=true, scheme_user=true for a channel + // admin. + updateChannelMemberSchemeRoles = (channelId: string, userId: string, isSchemeUser: boolean, isSchemeAdmin: boolean) => { + const body = {scheme_user: isSchemeUser, scheme_admin: isSchemeAdmin}; + return this.doFetch( + `${this.getChannelMembersRoute(channelId)}/${userId}/schemeRoles`, + {method: 'put', body}, + ); + }; + + getMemberInChannel = (channelId: string, userId: string) => { + return this.doFetch( + `${this.getChannelMembersRoute(channelId)}/${userId}`, + {method: 'get'}, + ); + }; }; export default ClientChannels; diff --git a/app/client/rest/users.ts b/app/client/rest/users.ts index eada89f632..aac8d355e7 100644 --- a/app/client/rest/users.ts +++ b/app/client/rest/users.ts @@ -24,7 +24,7 @@ export interface ClientUsersMix { getProfilesInTeam: (teamId: string, page?: number, perPage?: number, sort?: string, options?: Record) => Promise; getProfilesNotInTeam: (teamId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise; getProfilesWithoutTeam: (page?: number, perPage?: number, options?: Record) => Promise; - getProfilesInChannel: (channelId: string, page?: number, perPage?: number, sort?: string) => Promise; + getProfilesInChannel: (channelId: string, options?: GetUsersOptions) => Promise; getProfilesInGroupChannels: (channelsIds: string[]) => Promise<{[x: string]: UserProfile[]}>; getProfilesNotInChannel: (teamId: string, channelId: string, groupConstrained: boolean, page?: number, perPage?: number) => Promise; getMe: () => Promise; @@ -37,7 +37,7 @@ export interface ClientUsersMix { getSessions: (userId: string) => Promise; checkUserMfa: (loginId: string) => Promise<{mfa_required: boolean}>; attachDevice: (deviceId: string) => Promise; - searchUsers: (term: string, options: any) => Promise; + searchUsers: (term: string, options: SearchUserOptions) => Promise; getStatusesByIds: (userIds: string[]) => Promise; getStatus: (userId: string) => Promise; updateStatus: (status: UserStatus) => Promise; @@ -250,10 +250,10 @@ const ClientUsers = (superclass: any) => class extends superclass { ); }; - getProfilesInChannel = async (channelId: string, page = 0, perPage = PER_PAGE_DEFAULT, sort = '') => { + getProfilesInChannel = async (channelId: string, options: GetUsersOptions) => { this.analytics.trackAPI('api_profiles_get_in_channel', {channel_id: channelId}); - const queryStringObj = {in_channel: channelId, page, per_page: perPage, sort}; + const queryStringObj = {in_channel: channelId, ...options}; return this.doFetch( `${this.getUsersRoute()}${buildQueryString(queryStringObj)}`, {method: 'get'}, diff --git a/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx b/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx index e4544209f9..009c18c398 100644 --- a/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx +++ b/app/components/channel_actions/leave_channel_label/leave_channel_label.tsx @@ -23,7 +23,7 @@ type Props = { testID?: string; } -const LeaveChanelLabel = ({canLeave, channelId, displayName, isOptionItem, type, testID}: Props) => { +const LeaveChannelLabel = ({canLeave, channelId, displayName, isOptionItem, type, testID}: Props) => { const intl = useIntl(); const serverUrl = useServerUrl(); const isTablet = useIsTablet(); @@ -183,4 +183,4 @@ const LeaveChanelLabel = ({canLeave, channelId, displayName, isOptionItem, type, ); }; -export default LeaveChanelLabel; +export default LeaveChannelLabel; diff --git a/app/components/channel_actions/manage_members_label/index.tsx b/app/components/channel_actions/manage_members_label/index.tsx new file mode 100644 index 0000000000..207c7c370a --- /dev/null +++ b/app/components/channel_actions/manage_members_label/index.tsx @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {queryUsersById} from '@queries/servers/user'; + +import ManageMembersLabel from './manage_members_label'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = WithDatabaseArgs & { + isDefaultChannel: boolean; + userId: string; +} + +const enhanced = withObservables(['isDefaultChannel', 'userId'], ({isDefaultChannel, userId, database}: OwnProps) => { + const users = queryUsersById(database, [userId]).observe(); + const canRemoveUser = users.pipe( + switchMap((u) => { + return of$(!isDefaultChannel || (isDefaultChannel && u[0].isGuest)); + }), + ); + + return { + canRemoveUser, + }; +}); + +export default withDatabase(enhanced(ManageMembersLabel)); diff --git a/app/components/channel_actions/manage_members_label/manage_members_label.tsx b/app/components/channel_actions/manage_members_label/manage_members_label.tsx new file mode 100644 index 0000000000..e7f33b9fc7 --- /dev/null +++ b/app/components/channel_actions/manage_members_label/manage_members_label.tsx @@ -0,0 +1,152 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {Alert, DeviceEventEmitter} from 'react-native'; + +import {fetchChannelStats, removeMemberFromChannel, updateChannelMemberSchemeRoles} from '@actions/remote/channel'; +import OptionItem from '@components/option_item'; +import {Events, Members} from '@constants'; +import {useServerUrl} from '@context/server'; +import {t} from '@i18n'; +import {dismissBottomSheet} from '@screens/navigation'; +import {alertErrorWithFallback} from '@utils/draft'; + +import type {ManageOptionsTypes} from '@constants/members'; + +const {MAKE_CHANNEL_ADMIN, MAKE_CHANNEL_MEMBER, REMOVE_USER} = Members.ManageOptions; + +const messages = defineMessages({ + role_change_error: { + id: t('mobile.manage_members.change_role.error'), + defaultMessage: 'An error occurred while trying to update the role. Please check your connection and try again.', + }, + make_channel_admin: { + id: t('mobile.manage_members.make_channel_admin'), + defaultMessage: 'Make Channel Admin', + }, + make_channel_member: { + id: t('mobile.manage_members.make_channel_member'), + defaultMessage: 'Make Channel Member', + }, + remove_title: { + id: t('mobile.manage_members.remove_member'), + defaultMessage: 'Remove From Channel', + }, + remove_message: { + id: t('mobile.manage_members.message'), + defaultMessage: 'Are you sure you want to remove the selected member from the channel?', + }, + remove_cancel: { + id: t('mobile.manage_members.cancel'), + defaultMessage: 'Cancel', + }, + remove_confirm: { + id: t('mobile.manage_members.remove'), + defaultMessage: 'Remove', + }, +}); + +type Props = { + canRemoveUser: boolean; + channelId: string; + manageOption: ManageOptionsTypes; + testID?: string; + userId: string; +} + +const ManageMembersLabel = ({canRemoveUser, channelId, manageOption, testID, userId}: Props) => { + const intl = useIntl(); + const {formatMessage} = intl; + const serverUrl = useServerUrl(); + + const handleRemoveUser = useCallback(async () => { + removeMemberFromChannel(serverUrl, channelId, userId); + fetchChannelStats(serverUrl, channelId, false); + await dismissBottomSheet(); + DeviceEventEmitter.emit(Events.REMOVE_USER_FROM_CHANNEL, userId); + }, [channelId, serverUrl, userId]); + + const removeFromChannel = useCallback(() => { + Alert.alert( + formatMessage(messages.remove_title), + formatMessage(messages.remove_message), + [{ + text: formatMessage(messages.remove_cancel), + style: 'cancel', + }, { + text: formatMessage(messages.remove_confirm), + style: 'destructive', + onPress: handleRemoveUser, + }], {cancelable: false}, + ); + }, [formatMessage, handleRemoveUser]); + + const updateChannelMemberSchemeRole = useCallback(async (schemeAdmin: boolean) => { + const result = await updateChannelMemberSchemeRoles(serverUrl, channelId, userId, true, schemeAdmin); + if (result.error) { + alertErrorWithFallback(intl, result.error, messages.role_change_error); + } + await dismissBottomSheet(); + DeviceEventEmitter.emit(Events.MANAGE_USER_CHANGE_ROLE, {userId, schemeAdmin}); + }, [channelId, userId, intl, serverUrl]); + + const onAction = useCallback(() => { + switch (manageOption) { + case REMOVE_USER: + removeFromChannel(); + break; + case MAKE_CHANNEL_ADMIN: + updateChannelMemberSchemeRole(true); + break; + case MAKE_CHANNEL_MEMBER: + updateChannelMemberSchemeRole(false); + break; + default: + break; + } + }, [manageOption, removeFromChannel, updateChannelMemberSchemeRole]); + + let actionText; + let icon; + let isDestructive = false; + switch (manageOption) { + case REMOVE_USER: + actionText = (formatMessage(messages.remove_title)); + icon = 'trash-can-outline'; + isDestructive = true; + break; + case MAKE_CHANNEL_ADMIN: + actionText = formatMessage(messages.make_channel_admin); + icon = 'account-outline'; + break; + case MAKE_CHANNEL_MEMBER: + actionText = formatMessage(messages.make_channel_member); + icon = 'account-outline'; + break; + default: + break; + } + + if (manageOption === REMOVE_USER && !canRemoveUser) { + return null; + } + + if (!actionText) { + return null; + } + + return ( + + ); +}; + +export default ManageMembersLabel; diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index 9863abade2..2989962c8d 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -102,7 +102,7 @@ const withPost = withObservables( const isEphemeral = of$(isPostEphemeral(post)); if (post.props?.add_channel_member && isPostEphemeral(post)) { - isPostAddChannelMember = observeCanManageChannelMembers(database, post, currentUser); + isPostAddChannelMember = observeCanManageChannelMembers(database, post.channelId, currentUser); } let highlightReplyBar = of$(false); diff --git a/app/components/user_list/__snapshots__/index.test.tsx.snap b/app/components/user_list/__snapshots__/index.test.tsx.snap index 22a2b45a71..45fc81c90d 100644 --- a/app/components/user_list/__snapshots__/index.test.tsx.snap +++ b/app/components/user_list/__snapshots__/index.test.tsx.snap @@ -928,3 +928,506 @@ exports[`components/channel_list_row should show results no tutorial 1`] = ` `; + +exports[`components/channel_list_row should show results no tutorial 2 users 1`] = ` + + + + + + + J + + + + + + + + + + + + + + + + + johndoe + + + + + + + + + + + + + + + + R + + + + + + + + + + + + + + + + + rocky + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/user_list/index.test.tsx b/app/components/user_list/index.test.tsx index 122c491fda..fd0ae5d99c 100644 --- a/app/components/user_list/index.test.tsx +++ b/app/components/user_list/index.test.tsx @@ -38,6 +38,34 @@ describe('components/channel_list_row', () => { push_status: 'away', }, }; + + const user2: UserProfile = { + id: '2', + create_at: 1111, + update_at: 1111, + delete_at: 0, + username: 'rocky', + auth_service: '', + email: 'rocky@doe.com', + nickname: '', + first_name: '', + last_name: '', + position: '', + roles: '', + locale: '', + notify_props: { + channel: 'true', + comments: 'never', + desktop: 'mention', + desktop_sound: 'true', + email: 'true', + first_name: 'true', + mention_keys: '', + push: 'mention', + push_status: 'away', + }, + }; + beforeAll(async () => { const server = await TestHelper.setupServerDatabase(); database = server.database; @@ -91,6 +119,30 @@ describe('components/channel_list_row', () => { expect(wrapper.toJSON()).toMatchSnapshot(); }); + it('should show results no tutorial 2 users', () => { + const wrapper = renderWithEverything( + { + // noop + }} + fetchMore={() => { + // noop + }} + loading={true} + selectedIds={{}} + showNoResults={true} + tutorialWatched={true} + />, + {database}, + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + it('should show results and tutorial', () => { const wrapper = renderWithEverything( & {section?: SectionListData} + const INITIAL_BATCH_TO_RENDER = 15; const SCROLL_EVENT_THROTTLE = 60; +const messages = defineMessages({ + admins: { + id: t('mobile.manage_members.section_title_admins'), + defaultMessage: 'CHANNEL ADMINS', + }, + members: { + id: t('mobile.manage_members.section_title_members'), + defaultMessage: 'MEMBERS', + }, +}); + const keyboardDismissProp = Platform.select({ android: { onScrollBeginDrag: Keyboard.dismiss, @@ -41,29 +56,58 @@ const sectionKeyExtractor = (profile: UserProfile) => { return profile.username[0].toUpperCase(); }; -export function createProfilesSections(profiles: UserProfile[]) { - const sections: {[key: string]: UserProfile[]} = {}; - const sectionKeys: string[] = []; - for (const profile of profiles) { - const sectionKey = sectionKeyExtractor(profile); +const sectionRoleKeyExtractor = (cAdmin: boolean) => { + // Group items by channel admin or channel member + return cAdmin ? messages.admins : messages.members; +}; - if (!sections[sectionKey]) { - sections[sectionKey] = []; - sectionKeys.push(sectionKey); - } - - sections[sectionKey].push(profile); +export function createProfilesSections(intl: IntlShape, profiles: UserProfile[], members?: ChannelMember[]) { + if (!profiles.length) { + return []; } - sectionKeys.sort(); + const sections = new Map(); - return sectionKeys.map((sectionKey, index) => { - return { - id: sectionKey, - first: index === 0, - data: sections[sectionKey], - }; - }); + if (members?.length) { + // when channel members are provided, build the sections by admins and members + const membersDictionary = new Map(); + const membersSections = new Map(); + const {formatMessage} = intl; + members.forEach((m) => membersDictionary.set(m.user_id, m)); + profiles.forEach((p) => { + const member = membersDictionary.get(p.id); + const sectionKey = sectionRoleKeyExtractor(member.scheme_admin!); + const sectionValue = membersSections.get(sectionKey) || []; + + // combine UserProfile and ChannelMember objects so can get channel member scheme_admin permission + const section = [...sectionValue, {...p, ...member}]; + membersSections.set(sectionKey, section); + }); + sections.set(formatMessage(messages.admins), membersSections.get(messages.admins)); + sections.set(formatMessage(messages.members), membersSections.get(messages.members)); + } else { + // when channel members are not provided, build the sections alphabetically + profiles.forEach((p) => { + const sectionKey = sectionKeyExtractor(p); + const sectionValue = sections.get(sectionKey) || []; + const section = [...sectionValue, p]; + sections.set(sectionKey, section); + }); + } + + const results = []; + let index = 0; + for (const [k, v] of sections) { + if (v) { + results.push({ + first: index === 0, + id: k, + data: v, + }); + } + index++; + } + return results; } const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { @@ -103,11 +147,14 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { type Props = { profiles: UserProfile[]; + channelMembers?: ChannelMember[]; currentUserId: string; teammateNameDisplay: string; handleSelectProfile: (user: UserProfile) => void; fetchMore: () => void; loading: boolean; + manageMode?: boolean; + showManageMode?: boolean; showNoResults: boolean; selectedIds: {[id: string]: UserProfile}; testID?: string; @@ -117,12 +164,15 @@ type Props = { export default function UserList({ profiles, + channelMembers, selectedIds, currentUserId, teammateNameDisplay, handleSelectProfile, fetchMore, loading, + manageMode = false, + showManageMode = false, showNoResults, term, testID, @@ -139,11 +189,16 @@ export default function UserList({ ], [style, keyboardHeight]); const data = useMemo(() => { + if (profiles.length === 0 && !loading) { + return []; + } + if (term) { return profiles; } - return createProfilesSections(profiles); - }, [term, profiles]); + + return createProfilesSections(intl, profiles, channelMembers); + }, [channelMembers, loading, profiles, term]); const openUserProfile = useCallback(async (profile: UserProfile) => { const {user} = await storeProfile(serverUrl, profile); @@ -162,29 +217,34 @@ export default function UserList({ } }, []); - const renderItem = useCallback(({item, index, section}: ListRenderItemInfo & {section?: SectionListData}) => { + const renderItem = useCallback(({item, index, section}: RenderItemType) => { // The list will re-render when the selection changes because it's passed into the list as extraData const selected = Boolean(selectedIds[item.id]); const canAdd = Object.keys(selectedIds).length < General.MAX_USERS_IN_GM; + const isChAdmin = item.scheme_admin || false; + return ( ); - }, [selectedIds, currentUserId, handleSelectProfile, teammateNameDisplay, tutorialWatched]); + }, [selectedIds, handleSelectProfile, showManageMode, manageMode, teammateNameDisplay, tutorialWatched]); const renderLoading = useCallback(() => { if (!loading) { diff --git a/app/components/user_list_row/index.tsx b/app/components/user_list_row/index.tsx index b908e17a02..85bcc2b919 100644 --- a/app/components/user_list_row/index.tsx +++ b/app/components/user_list_row/index.tsx @@ -12,6 +12,7 @@ import { import {storeProfileLongPressTutorial} from '@actions/app/global'; import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; import ProfilePicture from '@components/profile_picture'; import {BotTag, GuestTag} from '@components/tag'; import TouchableWithFeedback from '@components/touchable_with_feedback'; @@ -19,23 +20,27 @@ import TutorialHighlight from '@components/tutorial_highlight'; import TutorialLongPress from '@components/tutorial_highlight/long_press'; import {useTheme} from '@context/theme'; import {useIsTablet} from '@hooks/device'; +import {t} from '@i18n'; import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme'; import {typography} from '@utils/typography'; import {displayUsername, isGuest} from '@utils/user'; type Props = { + highlight?: boolean; id: string; isMyUser: boolean; - highlight?: boolean; - user: UserProfile; - teammateNameDisplay: string; - testID: string; - onPress?: (user: UserProfile) => void; + isChannelAdmin: boolean; + manageMode: boolean; onLongPress: (user: UserProfile) => void; + onPress?: (user: UserProfile) => void; selectable: boolean; disabled?: boolean; selected: boolean; + showManageMode: boolean; + teammateNameDisplay: string; + testID: string; tutorialWatched?: boolean; + user: UserProfile; } const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { @@ -84,6 +89,15 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { alignItems: 'center', justifyContent: 'center', }, + selectorManage: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + }, + manageText: { + color: changeOpacity(theme.centerChannelColor, 0.64), + ...typography('Body', 100, 'Regular'), + }, tutorial: { top: Platform.select({ios: -74, default: -94}), }, @@ -100,24 +114,26 @@ function UserListRow({ id, isMyUser, highlight, - user, - teammateNameDisplay, - testID, + isChannelAdmin, onPress, onLongPress, - tutorialWatched = false, + manageMode = false, selectable, disabled, selected, + showManageMode = false, + teammateNameDisplay, + testID, + tutorialWatched = false, + user, }: Props) { const theme = useTheme(); - const intl = useIntl(); const isTablet = useIsTablet(); const [showTutorial, setShowTutorial] = useState(false); const [itemBounds, setItemBounds] = useState({startX: 0, startY: 0, endX: 0, endY: 0}); const viewRef = useRef(null); const style = getStyleFromTheme(theme); - const {formatMessage} = intl; + const {formatMessage, locale} = useIntl(); const {username} = user; const startTutorial = () => { @@ -152,13 +168,41 @@ function UserListRow({ }, [highlight, tutorialWatched, isTablet]); const handlePress = useCallback(() => { + if (isMyUser && manageMode) { + return; + } onPress?.(user); - }, [onPress, user]); + }, [onPress, isMyUser, manageMode, user]); const handleLongPress = useCallback(() => { onLongPress?.(user); }, [onLongPress, user]); + const manageModeIcon = useMemo(() => { + if (!showManageMode || isMyUser) { + return null; + } + + const color = changeOpacity(theme.centerChannelColor, 0.64); + const i18nId = isChannelAdmin ? t('mobile.manage_members.admin') : t('mobile.manage_members.member'); + const defaultMessage = isChannelAdmin ? 'Admin' : 'Member'; + + return ( + + + + + ); + }, [isChannelAdmin, showManageMode, theme]); + const onLayout = useCallback(() => { startTutorial(); }, []); @@ -189,7 +233,7 @@ function UserListRow({ }, {username}); } - const teammateDisplay = displayUsername(user, intl.locale, teammateNameDisplay); + const teammateDisplay = displayUsername(user, locale, teammateNameDisplay); const showTeammateDisplay = teammateDisplay !== username; const userItemTestID = `${testID}.${id}`; @@ -257,7 +301,7 @@ function UserListRow({ } - {icon} + {manageMode ? manageModeIcon : icon} {showTutorial && @@ -267,7 +311,7 @@ function UserListRow({ onLayout={onLayout} > diff --git a/app/constants/events.ts b/app/constants/events.ts index 5d34955cef..27ed326847 100644 --- a/app/constants/events.ts +++ b/app/constants/events.ts @@ -15,6 +15,8 @@ export default keyMirror({ LEAVE_TEAM: null, LOADING_CHANNEL_POSTS: null, NOTIFICATION_ERROR: null, + REMOVE_USER_FROM_CHANNEL: null, + MANAGE_USER_CHANGE_ROLE: null, SERVER_LOGOUT: null, SERVER_VERSION_CHANGED: null, SESSION_EXPIRED: null, diff --git a/app/constants/index.ts b/app/constants/index.ts index 8483e6d24e..d7a407f40d 100644 --- a/app/constants/index.ts +++ b/app/constants/index.ts @@ -19,6 +19,7 @@ import Integrations from './integrations'; import Launch from './launch'; import License from './license'; import List from './list'; +import Members from './members'; import Navigation from './navigation'; import Network from './network'; import NotificationLevel from './notification_level'; @@ -57,6 +58,7 @@ export { Launch, License, List, + Members, Navigation, Network, NotificationLevel, diff --git a/app/constants/members.ts b/app/constants/members.ts new file mode 100644 index 0000000000..87fc416b5e --- /dev/null +++ b/app/constants/members.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import keyMirror from '@utils/key_mirror'; +const ManageOptions = keyMirror({ + REMOVE_USER: null, + MAKE_CHANNEL_ADMIN: null, + MAKE_CHANNEL_MEMBER: null, +}); + +export type ManageOptionsTypes = keyof typeof ManageOptions + +export default { + ManageOptions, +}; + diff --git a/app/constants/screens.ts b/app/constants/screens.ts index b4d60e4b9c..6880459e5f 100644 --- a/app/constants/screens.ts +++ b/app/constants/screens.ts @@ -33,6 +33,7 @@ export const IN_APP_NOTIFICATION = 'InAppNotification'; export const JOIN_TEAM = 'JoinTeam'; export const LATEX = 'Latex'; export const LOGIN = 'Login'; +export const MANAGE_CHANNEL_MEMBERS = 'ManageChannelMembers'; export const MENTIONS = 'Mentions'; export const MFA = 'MFA'; export const ONBOARDING = 'Onboarding'; @@ -101,6 +102,7 @@ export default { JOIN_TEAM, LATEX, LOGIN, + MANAGE_CHANNEL_MEMBERS, MENTIONS, MFA, ONBOARDING, @@ -148,6 +150,7 @@ export const MODAL_SCREENS_WITHOUT_BACK = new Set([ EDIT_SERVER, FIND_CHANNELS, GALLERY, + MANAGE_CHANNEL_MEMBERS, INVITE, PERMALINK, ]); diff --git a/app/constants/snack_bar.ts b/app/constants/snack_bar.ts index 79a0a5c302..aedc6d7905 100644 --- a/app/constants/snack_bar.ts +++ b/app/constants/snack_bar.ts @@ -9,6 +9,7 @@ export const SNACK_BAR_TYPE = keyMirror({ LINK_COPIED: null, MESSAGE_COPIED: null, MUTE_CHANNEL: null, + REMOVE_CHANNEL_USER: null, UNFAVORITE_CHANNEL: null, UNMUTE_CHANNEL: null, }); @@ -45,6 +46,12 @@ export const SNACK_BAR_CONFIG: Record = { iconName: 'bell-off-outline', canUndo: true, }, + REMOVE_CHANNEL_USER: { + id: t('snack.bar.remove.user'), + defaultMessage: '1 member was removed from the channel', + iconName: 'check', + canUndo: true, + }, UNFAVORITE_CHANNEL: { id: t('snack.bar.unfavorite.channel'), defaultMessage: 'This channel was unfavorited', diff --git a/app/queries/servers/role.ts b/app/queries/servers/role.ts index 358cd9de62..58417da697 100644 --- a/app/queries/servers/role.ts +++ b/app/queries/servers/role.ts @@ -88,8 +88,8 @@ export function observePermissionForPost(database: Database, post: PostModel, us ); } -export function observeCanManageChannelMembers(database: Database, post: PostModel, user: UserModel) { - return observeChannel(database, post.channelId).pipe( +export function observeCanManageChannelMembers(database: Database, channelId: string, user: UserModel) { + return observeChannel(database, channelId).pipe( switchMap((c) => { if (!c || c.deleteAt !== 0 || isDMorGM(c) || c.name === General.DEFAULT_CHANNEL) { return of$(false); diff --git a/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx b/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx index 895b4cc561..f9069c2c1e 100644 --- a/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx +++ b/app/screens/channel/channel_post_list/intro/direct_channel/direct_channel.tsx @@ -74,7 +74,7 @@ const DirectChannel = ({channel, currentUserId, isBot, members, theme}: Props) = useEffect(() => { const channelMembers = members?.filter((m) => m.userId !== currentUserId); if (!channelMembers?.length) { - fetchProfilesInChannel(serverUrl, channel.id, currentUserId, false); + fetchProfilesInChannel(serverUrl, channel.id, currentUserId, undefined, false); } }, []); diff --git a/app/screens/channel_info/options/index.tsx b/app/screens/channel_info/options/index.tsx index ef47a4d7ef..d2c856ec6f 100644 --- a/app/screens/channel_info/options/index.tsx +++ b/app/screens/channel_info/options/index.tsx @@ -9,10 +9,9 @@ import {isTypeDMorGM} from '@utils/channel'; import EditChannel from './edit_channel'; import IgnoreMentions from './ignore_mentions'; +import Members from './members'; import PinnedMessages from './pinned_messages'; -// import Members from './members'; - type Props = { channelId: string; type?: ChannelType; @@ -29,11 +28,9 @@ const Options = ({channelId, type, callsEnabled}: Props) => { } {/**/} - {/* Add back in after MM-47653 is resolved. https://mattermost.atlassian.net/browse/MM-47653 {type !== General.DM_CHANNEL && } - */} {callsEnabled && !isDMorGM && // if calls is not enabled, copy link will show in the channel actions { const info = observeChannelInfo(database, channelId); + + const displayName = observeChannel(database, channelId).pipe( + switchMap((c) => of$(c?.displayName))); + const count = info.pipe( switchMap((i) => of$(i?.memberCount || 0)), ); return { + displayName, count, }; }); diff --git a/app/screens/channel_info/options/members/members.tsx b/app/screens/channel_info/options/members/members.tsx index 778abbb1b5..5af7286edc 100644 --- a/app/screens/channel_info/options/members/members.tsx +++ b/app/screens/channel_info/options/members/members.tsx @@ -7,20 +7,32 @@ import {Platform} from 'react-native'; import OptionItem from '@components/option_item'; import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; import {goToScreen} from '@screens/navigation'; import {preventDoubleTap} from '@utils/tap'; +import {changeOpacity} from '@utils/theme'; type Props = { channelId: string; + displayName: string; count: number; } -const Members = ({channelId, count}: Props) => { +const Members = ({displayName, channelId, count}: Props) => { const {formatMessage} = useIntl(); + const theme = useTheme(); const title = formatMessage({id: 'channel_info.members', defaultMessage: 'Members'}); - const goToChannelMembers = preventDoubleTap(() => { - goToScreen(Screens.CHANNEL_ADD_PEOPLE, title, {channelId}); + const options = { + topBar: { + subtitle: { + color: changeOpacity(theme.sidebarHeaderTextColor, 0.72), + text: displayName, + }, + }, + }; + + goToScreen(Screens.MANAGE_CHANNEL_MEMBERS, title, {channelId}, options); }); return ( diff --git a/app/screens/index.tsx b/app/screens/index.tsx index 051d446f13..957e320670 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -149,6 +149,9 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.LOGIN: screen = withIntl(require('@screens/login').default); break; + case Screens.MANAGE_CHANNEL_MEMBERS: + screen = withServerDatabase(require('@screens/manage_channel_members').default); + break; case Screens.MFA: screen = withIntl(require('@screens/mfa').default); break; diff --git a/app/screens/invite/invite.tsx b/app/screens/invite/invite.tsx index 8f8640fea1..dda58a93a4 100644 --- a/app/screens/invite/invite.tsx +++ b/app/screens/invite/invite.tsx @@ -138,7 +138,7 @@ export default function Invite({ return; } - const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase()); + const {data} = await searchProfiles(serverUrl, searchTerm.toLowerCase(), {}); const results: SearchResult[] = data ?? []; if (!results.length && isEmail(searchTerm.trim())) { diff --git a/app/screens/manage_channel_members/index.tsx b/app/screens/manage_channel_members/index.tsx new file mode 100644 index 0000000000..ae1d739069 --- /dev/null +++ b/app/screens/manage_channel_members/index.tsx @@ -0,0 +1,40 @@ +// 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$, combineLatest, switchMap} from 'rxjs'; + +import {Permissions, Tutorial} from '@constants'; +import {observeTutorialWatched} from '@queries/app/global'; +import {observeCurrentChannel} from '@queries/servers/channel'; +import {observeCanManageChannelMembers, observePermissionForChannel} from '@queries/servers/role'; +import {observeCurrentChannelId, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; +import {observeCurrentUser, observeTeammateNameDisplay} from '@queries/servers/user'; + +import ManageChannelMembers from './manage_channel_members'; + +import type {WithDatabaseArgs} from '@typings/database/database'; + +const enhanced = withObservables([], ({database}: WithDatabaseArgs) => { + const currentUser = observeCurrentUser(database); + const currentChannelId = observeCurrentChannelId(database); + const currentChannel = observeCurrentChannel(database); + + const canManageAndRemoveMembers = combineLatest([currentChannelId, currentUser]).pipe( + switchMap(([cId, u]) => (cId && u ? observeCanManageChannelMembers(database, cId, u) : of$(false)))); + + const canChangeMemberRoles = combineLatest([currentChannel, currentUser, canManageAndRemoveMembers]).pipe( + switchMap(([c, u, m]) => (of$(c) && of$(u) && of$(m) && observePermissionForChannel(database, c, u, Permissions.MANAGE_CHANNEL_ROLES, true)))); + + return { + currentUserId: observeCurrentUserId(database), + currentTeamId: observeCurrentTeamId(database), + canManageAndRemoveMembers, + teammateNameDisplay: observeTeammateNameDisplay(database), + tutorialWatched: observeTutorialWatched(Tutorial.PROFILE_LONG_PRESS), + canChangeMemberRoles, + }; +}); + +export default withDatabase(enhanced(ManageChannelMembers)); diff --git a/app/screens/manage_channel_members/manage_channel_members.tsx b/app/screens/manage_channel_members/manage_channel_members.tsx new file mode 100644 index 0000000000..81e1199e27 --- /dev/null +++ b/app/screens/manage_channel_members/manage_channel_members.tsx @@ -0,0 +1,281 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {DeviceEventEmitter, Keyboard, Platform, StyleSheet, View} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; + +import {fetchChannelMemberships} from '@actions/remote/channel'; +import {fetchUsersByIds, searchProfiles} from '@actions/remote/user'; +import Search from '@components/search'; +import UserList from '@components/user_list'; +import {Events, General, Screens} from '@constants'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {debounce} from '@helpers/api/general'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {t} from '@i18n'; +import {openAsBottomSheet, setButtons} from '@screens/navigation'; +import NavigationStore from '@store/navigation_store'; +import {showRemoveChannelUserSnackbar} from '@utils/snack_bar'; +import {changeOpacity, getKeyboardAppearanceFromTheme} from '@utils/theme'; +import {filterProfilesMatchingTerm} from '@utils/user'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + canManageAndRemoveMembers: boolean; + channelId: string; + componentId: AvailableScreens; + currentTeamId: string; + currentUserId: string; + teammateNameDisplay: string; + tutorialWatched: boolean; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + searchBar: { + marginLeft: 12, + marginRight: Platform.select({ios: 4, default: 12}), + marginVertical: 12, + }, +}); + +const messages = defineMessages({ + button_manage: { + id: t('mobile.manage_members.manage'), + defaultMessage: 'Manage', + }, + button_done: { + id: t('mobile.manage_members.done'), + defaultMessage: 'Done', + }, +}); + +const MANAGE_BUTTON = 'manage-button'; +const EMPTY: UserProfile[] = []; +const EMPTY_MEMBERS: ChannelMembership[] = []; +const EMPTY_IDS = {}; +const {USER_PROFILE} = Screens; +const CLOSE_BUTTON_ID = 'close-user-profile'; + +export default function ManageChannelMembers({ + canManageAndRemoveMembers, + channelId, + componentId, + currentTeamId, + currentUserId, + teammateNameDisplay, + tutorialWatched, +}: Props) { + const serverUrl = useServerUrl(); + const theme = useTheme(); + const {formatMessage} = useIntl(); + + const searchTimeoutId = useRef(null); + const mounted = useRef(false); + + const [isManageMode, setIsManageMode] = useState(false); + const [profiles, setProfiles] = useState(EMPTY); + const [channelMembers, setChannelMembers] = useState(EMPTY_MEMBERS); + const [searchResults, setSearchResults] = useState(EMPTY); + const [loading, setLoading] = useState(false); + const [term, setTerm] = useState(''); + + const loadedProfiles = (users: UserProfile[], members: ChannelMembership[]) => { + if (mounted.current) { + setLoading(false); + setProfiles(users); + setChannelMembers(members); + } + }; + + const clearSearch = useCallback(() => { + setTerm(''); + setSearchResults(EMPTY); + }, []); + + const getProfiles = useCallback(debounce(async () => { + const hasTerm = Boolean(term); + if (!loading && !hasTerm && mounted.current) { + setLoading(true); + const options = {sort: 'admin', active: true}; + const {users, members} = await fetchChannelMemberships(serverUrl, channelId, options, true); + if (users.length) { + loadedProfiles(users, members); + } + setLoading(false); + } + }, 100), [channelId, loading, serverUrl, term]); + + const handleSelectProfile = useCallback(async (profile: UserProfile) => { + await fetchUsersByIds(serverUrl, [profile.id]); + const title = formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); + const props = { + channelId, + closeButtonId: CLOSE_BUTTON_ID, + location: USER_PROFILE, + manageMode: isManageMode, + userId: profile.id, + canManageAndRemoveMembers, + }; + + Keyboard.dismiss(); + openAsBottomSheet({screen: USER_PROFILE, title, theme, closeButtonId: CLOSE_BUTTON_ID, props}); + }, [canManageAndRemoveMembers, channelId, isManageMode]); + + const searchUsers = useCallback(async (searchTerm: string) => { + const lowerCasedTerm = searchTerm.toLowerCase(); + setLoading(true); + + const options: SearchUserOptions = {team_id: currentTeamId, in_channel_id: channelId, allow_inactive: false}; + const {data = EMPTY} = await searchProfiles(serverUrl, lowerCasedTerm, options); + + setSearchResults(data); + setLoading(false); + }, [serverUrl, channelId, currentTeamId]); + + const search = useCallback(() => { + searchUsers(term); + }, [searchUsers, term]); + + const onSearch = useCallback((text: string) => { + if (!text) { + clearSearch(); + return; + } + + setTerm(text); + if (searchTimeoutId.current) { + clearTimeout(searchTimeoutId.current); + } + + searchTimeoutId.current = setTimeout(() => { + searchUsers(text); + }, General.SEARCH_TIMEOUT_MILLISECONDS); + }, [searchUsers, clearSearch]); + + const updateNavigationButtons = useCallback((manage: boolean) => { + setButtons(componentId, { + rightButtons: [{ + color: theme.sidebarHeaderTextColor, + enabled: true, + id: MANAGE_BUTTON, + showAsAction: 'always', + testID: 'manage_members.button', + text: formatMessage(manage ? messages.button_done : messages.button_manage), + }], + }); + }, [theme.sidebarHeaderTextColor]); + + const toggleManageEnabled = useCallback(() => { + updateNavigationButtons(!isManageMode); + setIsManageMode((prev) => !prev); + }, [isManageMode, updateNavigationButtons]); + + const handleRemoveUser = useCallback(async (userId: string) => { + const pIndex = profiles.findIndex((user) => user.id === userId); + const mIndex = channelMembers.findIndex((m) => m.user_id === userId); + if (pIndex !== -1) { + const newProfiles = [...profiles]; + newProfiles.splice(pIndex, 1); + setProfiles(newProfiles); + + const newMembers = [...channelMembers]; + newMembers.splice(mIndex, 1); + setChannelMembers(newMembers); + + await NavigationStore.waitUntilScreensIsRemoved(USER_PROFILE); + showRemoveChannelUserSnackbar(); + } + }, [profiles, channelMembers]); + + const handleUserChangeRole = useCallback(async ({userId, schemeAdmin}: {userId: string; schemeAdmin: boolean}) => { + const clone = channelMembers.map((m) => { + if (m.user_id === userId) { + m.scheme_admin = schemeAdmin; + return m; + } + return m; + }); + + setChannelMembers(clone); + }, [channelMembers]); + + const data = useMemo(() => { + const isSearch = Boolean(term); + if (isSearch) { + return filterProfilesMatchingTerm(searchResults, term); + } + return profiles; + }, [term, searchResults, profiles]); + + useNavButtonPressed(MANAGE_BUTTON, componentId, toggleManageEnabled, [toggleManageEnabled]); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (canManageAndRemoveMembers) { + updateNavigationButtons(false); + } + }, [canManageAndRemoveMembers]); + + useEffect(() => { + const removeUserListener = DeviceEventEmitter.addListener(Events.REMOVE_USER_FROM_CHANNEL, handleRemoveUser); + const changeUserRoleListener = DeviceEventEmitter.addListener(Events.MANAGE_USER_CHANGE_ROLE, handleUserChangeRole); + return (() => { + removeUserListener?.remove(); + changeUserRoleListener?.remove(); + }); + }, [handleRemoveUser, handleUserChangeRole]); + + return ( + + + + + + {/* TODO: https://mattermost.atlassian.net/browse/MM-48830 */} + {/* fix flashing No Results page when results are present */} + + + ); +} diff --git a/app/screens/snack_bar/index.tsx b/app/screens/snack_bar/index.tsx index ec218850b9..a1e4404e05 100644 --- a/app/screens/snack_bar/index.tsx +++ b/app/screens/snack_bar/index.tsx @@ -40,7 +40,7 @@ const SNACK_BAR_WIDTH = 96; const SNACK_BAR_HEIGHT = 56; const SNACK_BAR_BOTTOM_RATIO = 0.04; -const caseScreens: AvailableScreens[] = [Screens.PERMALINK, Screens.MENTIONS, Screens.SAVED_MESSAGES]; +const caseScreens: AvailableScreens[] = [Screens.PERMALINK, Screens.MANAGE_CHANNEL_MEMBERS, Screens.MENTIONS, Screens.SAVED_MESSAGES]; const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { diff --git a/app/screens/user_profile/index.ts b/app/screens/user_profile/index.ts index 7ef852886b..47dda6e597 100644 --- a/app/screens/user_profile/index.ts +++ b/app/screens/user_profile/index.ts @@ -3,15 +3,16 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {of as of$} from 'rxjs'; +import {of as of$, combineLatest} from 'rxjs'; import {map, switchMap} from 'rxjs/operators'; -import {General, Preferences} from '@constants'; +import {General, Permissions, Preferences} from '@constants'; import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference'; import {observeChannel} from '@queries/servers/channel'; import {queryDisplayNamePreferences} from '@queries/servers/preference'; +import {observeCanManageChannelMembers, observePermissionForChannel} from '@queries/servers/role'; import {observeConfigBooleanValue, observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system'; -import {observeTeammateNameDisplay, observeUser, observeUserIsChannelAdmin, observeUserIsTeamAdmin} from '@queries/servers/user'; +import {observeTeammateNameDisplay, observeCurrentUser, observeUser, observeUserIsChannelAdmin, observeUserIsTeamAdmin} from '@queries/servers/user'; import {isSystemAdmin} from '@utils/user'; import UserProfile from './user_profile'; @@ -24,11 +25,15 @@ type EnhancedProps = WithDatabaseArgs & { } const enhanced = withObservables([], ({channelId, database, userId}: EnhancedProps) => { + const currentUser = observeCurrentUser(database); const currentUserId = observeCurrentUserId(database); const channel = channelId ? observeChannel(database, channelId) : of$(undefined); const user = observeUser(database, userId); const teammateDisplayName = observeTeammateNameDisplay(database); const isChannelAdmin = channelId ? observeUserIsChannelAdmin(database, userId, channelId) : of$(false); + const isDefaultChannel = channel ? channel.pipe( + switchMap((c) => of$(c?.name === General.DEFAULT_CHANNEL)), + ) : of$(false); const isDirectMessage = channelId ? channel.pipe( switchMap((c) => of$(c?.type === General.DM_CHANNEL)), ) : of$(false); @@ -42,12 +47,21 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro const isMilitaryTime = preferences.pipe(map((prefs) => getDisplayNamePreferenceAsBool(prefs, Preferences.USE_MILITARY_TIME))); const isCustomStatusEnabled = observeConfigBooleanValue(database, 'EnableCustomUserStatuses'); + // can remove member + const canManageAndRemoveMembers = combineLatest([channel, currentUser]).pipe( + switchMap(([c, u]) => (c && u ? observeCanManageChannelMembers(database, c.id, u) : of$(false)))); + + const canChangeMemberRoles = combineLatest([channel, currentUser, canManageAndRemoveMembers]).pipe( + switchMap(([c, u, m]) => (of$(c?.id) && of$(u) && of$(m) && observePermissionForChannel(database, c, u, Permissions.MANAGE_CHANNEL_ROLES, true)))); + return { + canManageAndRemoveMembers, currentUserId, enablePostIconOverride, enablePostUsernameOverride, isChannelAdmin, isCustomStatusEnabled, + isDefaultChannel, isDirectMessage, isMilitaryTime, isSystemAdmin: systemAdmin, @@ -55,6 +69,7 @@ const enhanced = withObservables([], ({channelId, database, userId}: EnhancedPro teamId, teammateDisplayName, user, + canChangeMemberRoles, }; }); diff --git a/app/screens/user_profile/manage_user_options.tsx b/app/screens/user_profile/manage_user_options.tsx new file mode 100644 index 0000000000..0371f4ccbf --- /dev/null +++ b/app/screens/user_profile/manage_user_options.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {View} from 'react-native'; + +import ManageMembersLabel from '@components/channel_actions/manage_members_label'; +import {Members} from '@constants'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +export const DIVIDER_MARGIN = 8; +const {MAKE_CHANNEL_ADMIN, MAKE_CHANNEL_MEMBER, REMOVE_USER} = Members.ManageOptions; + +const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { + return { + divider: { + alignSelf: 'center', + backgroundColor: changeOpacity(theme.centerChannelColor, 0.16), + height: 1, + marginVertical: DIVIDER_MARGIN, + paddingHorizontal: 20, + width: '100%', + }, + }; +}); + +type Props = { + channelId: string; + isDefaultChannel: boolean; + isChannelAdmin: boolean; + canChangeMemberRoles: boolean; + userId: string; +} + +const ManageUserOptions = ({channelId, isChannelAdmin, isDefaultChannel, userId, canChangeMemberRoles}: Props) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + return ( + <> + + {canChangeMemberRoles && + + } + + + ); +}; + +export default ManageUserOptions; diff --git a/app/screens/user_profile/title/avatar.tsx b/app/screens/user_profile/title/avatar.tsx index 26c364853b..41e7be2569 100644 --- a/app/screens/user_profile/title/avatar.tsx +++ b/app/screens/user_profile/title/avatar.tsx @@ -3,7 +3,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useMemo} from 'react'; import {StyleSheet, View} from 'react-native'; import FastImage from 'react-native-fast-image'; import Animated from 'react-native-reanimated'; @@ -18,19 +18,26 @@ const AnimatedFastImage = Animated.createAnimatedComponent(FastImage); type Props = { enablePostIconOverride: boolean; forwardRef?: React.RefObject; + imageSize?: number; user: UserModel; userIconOverride?: string; } -const styles = StyleSheet.create({ - avatar: { - borderRadius: 48, - height: 96, - width: 96, - }, -}); +const DEFAULT_IMAGE_SIZE = 96; + +const getStyles = (size?: number) => { + return StyleSheet.create({ + avatar: { + borderRadius: 48, + height: size || DEFAULT_IMAGE_SIZE, + width: size || DEFAULT_IMAGE_SIZE, + }, + }); +}; + +const UserProfileAvatar = ({enablePostIconOverride, forwardRef, imageSize, user, userIconOverride}: Props) => { + const styles = useMemo(() => getStyles(imageSize), [imageSize]); -const UserProfileAvatar = ({enablePostIconOverride, forwardRef, user, userIconOverride}: Props) => { if (enablePostIconOverride && userIconOverride) { return ( @@ -48,7 +55,7 @@ const UserProfileAvatar = ({enablePostIconOverride, forwardRef, user, userIconOv author={user} forwardRef={forwardRef} showStatus={true} - size={96} + size={imageSize || DEFAULT_IMAGE_SIZE} statusSize={24} testID={`user_profile_avatar.${user.id}.profile_picture`} /> diff --git a/app/screens/user_profile/title/index.tsx b/app/screens/user_profile/title/index.tsx index 4253f7bc6d..137813e3af 100644 --- a/app/screens/user_profile/title/index.tsx +++ b/app/screens/user_profile/title/index.tsx @@ -25,6 +25,8 @@ import type {GalleryItemType} from '@typings/screens/gallery'; type Props = { enablePostIconOverride: boolean; enablePostUsernameOverride: boolean; + headerText?: string; + imageSize?: number; isChannelAdmin: boolean; isSystemAdmin: boolean; isTeamAdmin: boolean; @@ -34,6 +36,8 @@ type Props = { usernameOverride?: string; } +export const HEADER_TEXT_HEIGHT = 30; + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ container: { flexDirection: 'row', @@ -52,14 +56,20 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ color: changeOpacity(theme.centerChannelColor, 0.64), ...typography('Body', 200), }, + heading: { + height: HEADER_TEXT_HEIGHT, + color: theme.centerChannelColor, + marginBottom: 20, + ...typography('Heading', 600, 'SemiBold'), + }, tablet: { marginTop: 20, }, })); const UserProfileTitle = ({ - enablePostIconOverride, enablePostUsernameOverride, - isChannelAdmin, isSystemAdmin, isTeamAdmin, + enablePostIconOverride, enablePostUsernameOverride, headerText, + imageSize, isChannelAdmin, isSystemAdmin, isTeamAdmin, teammateDisplayName, user, userIconOverride, usernameOverride, }: Props) => { const galleryIdentifier = `${user.id}-avatarPreview`; @@ -118,45 +128,56 @@ const UserProfileTitle = ({ const prefix = hideUsername ? '@' : ''; return ( - - - - - - - - - - + <> + {headerText && - {`${prefix}${displayName}`} + {headerText} - {!hideUsername && - - {`@${user.username}`} - - } + } + + + + + + + + + + + + {`${prefix}${displayName}`} + + {!hideUsername && + + {`@${user.username}`} + + } + - + ); }; diff --git a/app/screens/user_profile/user_info.tsx b/app/screens/user_profile/user_info.tsx new file mode 100644 index 0000000000..7905d7f7d5 --- /dev/null +++ b/app/screens/user_profile/user_info.tsx @@ -0,0 +1,54 @@ +// 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 {getUserCustomStatus} from '@utils/user'; + +import UserProfileCustomStatus from './custom_status'; +import UserProfileLabel from './label'; + +import type {UserModel} from '@database/models/server'; + +type Props = { + localTime?: string; + showCustomStatus: boolean; + showLocalTime: boolean; + showNickname: boolean; + showPosition: boolean; + user: UserModel; +} + +const UserInfo = ({localTime, showCustomStatus, showLocalTime, showNickname, showPosition, user}: Props) => { + const {formatMessage} = useIntl(); + const customStatus = getUserCustomStatus(user); + + return ( + <> + {showCustomStatus && } + {showNickname && + + } + {showPosition && + + } + {showLocalTime && + + } + + ); +}; + +export default UserInfo; diff --git a/app/screens/user_profile/user_profile.tsx b/app/screens/user_profile/user_profile.tsx index dcaf576faf..20187bbbe6 100644 --- a/app/screens/user_profile/user_profile.tsx +++ b/app/screens/user_profile/user_profile.tsx @@ -4,37 +4,41 @@ import moment from 'moment'; import mtz from 'moment-timezone'; import React, {useEffect, useMemo} from 'react'; -import {useIntl} from 'react-intl'; +import {defineMessages, useIntl} from 'react-intl'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {fetchTeamAndChannelMembership} from '@actions/remote/user'; import {Screens} from '@constants'; import {useServerUrl} from '@context/server'; -import {getLocaleFromLanguage} from '@i18n'; +import {getLocaleFromLanguage, t} from '@i18n'; import BottomSheet from '@screens/bottom_sheet'; import {bottomSheetSnapPoint} from '@utils/helpers'; import {getUserCustomStatus, getUserTimezone, isCustomStatusExpired} from '@utils/user'; -import UserProfileCustomStatus from './custom_status'; -import UserProfileLabel from './label'; +import ManageUserOptions, {DIVIDER_MARGIN} from './manage_user_options'; import UserProfileOptions, {OptionsType} from './options'; -import UserProfileTitle from './title'; +import UserProfileTitle, {HEADER_TEXT_HEIGHT} from './title'; +import UserInfo from './user_info'; import type UserModel from '@typings/database/models/servers/user'; import type {AvailableScreens} from '@typings/screens/navigation'; type Props = { + canChangeMemberRoles: boolean; channelId?: string; closeButtonId: string; currentUserId: string; enablePostIconOverride: boolean; enablePostUsernameOverride: boolean; isChannelAdmin: boolean; + canManageAndRemoveMembers?: boolean; isCustomStatusEnabled: boolean; isDirectMessage: boolean; + isDefaultChannel: boolean; isMilitaryTime: boolean; isSystemAdmin: boolean; isTeamAdmin: boolean; + manageMode?: boolean; location: AvailableScreens; teamId: string; teammateDisplayName: string; @@ -47,13 +51,39 @@ const TITLE_HEIGHT = 118; const OPTIONS_HEIGHT = 82; const SINGLE_OPTION_HEIGHT = 68; const LABEL_HEIGHT = 58; +const EXTRA_HEIGHT = 60; +const MANAGE_ICON_HEIGHT = 72; + +const messages = defineMessages({ + manageMember: { + id: t('mobile.manage_members.manage_member'), + defaultMessage: 'Manage member', + }, +}); const channelContextScreens: AvailableScreens[] = [Screens.CHANNEL, Screens.THREAD]; const UserProfile = ({ - channelId, closeButtonId, currentUserId, enablePostIconOverride, enablePostUsernameOverride, - isChannelAdmin, isCustomStatusEnabled, isDirectMessage, isMilitaryTime, isSystemAdmin, isTeamAdmin, - location, teamId, teammateDisplayName, - user, userIconOverride, usernameOverride, + canChangeMemberRoles, + canManageAndRemoveMembers, + channelId, + closeButtonId, + currentUserId, + enablePostIconOverride, + enablePostUsernameOverride, + isChannelAdmin, + isCustomStatusEnabled, + isDefaultChannel, + isDirectMessage, + isMilitaryTime, + isSystemAdmin, + isTeamAdmin, + location, + manageMode = false, + teamId, + teammateDisplayName, + user, + userIconOverride, + usernameOverride, }: Props) => { const {formatMessage, locale} = useIntl(); const serverUrl = useServerUrl(); @@ -76,37 +106,48 @@ const UserProfile = ({ } const showCustomStatus = isCustomStatusEnabled && Boolean(customStatus) && !user.isBot && !isCustomStatusExpired(user); - const showUserProfileOptions = (!isDirectMessage || !channelContext) && !override; - const showNickname = Boolean(user.nickname) && !override && !user.isBot; - const showPosition = Boolean(user.position) && !override && !user.isBot; - const showLocalTime = Boolean(localTime) && !override && !user.isBot; + const showUserProfileOptions = (!isDirectMessage || !channelContext) && !override && !manageMode; + const showNickname = Boolean(user.nickname) && !override && !user.isBot && !manageMode; + const showPosition = Boolean(user.position) && !override && !user.isBot && !manageMode; + const showLocalTime = Boolean(localTime) && !override && !user.isBot && !manageMode; + + const headerText = manageMode ? formatMessage(messages.manageMember) : undefined; const snapPoints = useMemo(() => { let title = TITLE_HEIGHT; + + if (headerText) { + title += HEADER_TEXT_HEIGHT; + } + if (showUserProfileOptions) { title += showOptions === 'all' ? OPTIONS_HEIGHT : SINGLE_OPTION_HEIGHT; } - let labels = 0; - if (showCustomStatus) { - labels += 1; + const optionsCount = [ + showCustomStatus, + showNickname, + showPosition, + showLocalTime, + ].reduce((acc, v) => { + return v ? acc + 1 : acc; + }, 0); + + if (manageMode) { + title += DIVIDER_MARGIN * 2; + if (canChangeMemberRoles) { + title += SINGLE_OPTION_HEIGHT; // roles button + } + if (canManageAndRemoveMembers) { + title += SINGLE_OPTION_HEIGHT; // roles button + } } - if (showNickname) { - labels += 1; - } - - if (showPosition) { - labels += 1; - } - - if (showLocalTime) { - labels += 1; - } + const extraHeight = manageMode ? 0 : EXTRA_HEIGHT; return [ 1, - bottomSheetSnapPoint(labels, LABEL_HEIGHT, bottom) + title, + bottomSheetSnapPoint(optionsCount, LABEL_HEIGHT, bottom) + title + extraHeight, ]; }, [ showUserProfileOptions, showCustomStatus, showNickname, @@ -125,6 +166,8 @@ const UserProfile = ({ } - {showCustomStatus && } - {showNickname && - + {!manageMode && + } - {showPosition && - - } - {showLocalTime && - + {manageMode && channelId && (canManageAndRemoveMembers || canChangeMemberRoles) && + } ); diff --git a/app/utils/snack_bar/index.ts b/app/utils/snack_bar/index.ts index 62f8afdbec..1f1c08b29d 100644 --- a/app/utils/snack_bar/index.ts +++ b/app/utils/snack_bar/index.ts @@ -30,3 +30,10 @@ export const showFavoriteChannelSnackbar = (favorited: boolean, onAction: () => barType: favorited ? SNACK_BAR_TYPE.FAVORITE_CHANNEL : SNACK_BAR_TYPE.UNFAVORITE_CHANNEL, }); }; + +export const showRemoveChannelUserSnackbar = () => { + return showSnackBar({ + barType: SNACK_BAR_TYPE.REMOVE_CHANNEL_USER, + sourceScreen: Screens.MANAGE_CHANNEL_MEMBERS, + }); +}; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index c6ea19b1a2..44e484b93e 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1,976 +1,991 @@ { - "about.date": "Build Date:", - "about.enterpriseEditione1": "Enterprise Edition", - "about.enterpriseEditionLearn": "Learn more about Enterprise Edition at ", - "about.enterpriseEditionSt": "Modern communication from behind your firewall.", - "about.hash": "Build Hash:", - "about.hashee": "EE Build Hash:", - "about.teamEditionLearn": "Join the Mattermost community at", - "about.teamEditionSt": "All your team communication in one place, instantly searchable and accessible anywhere.", - "about.teamEditiont0": "Team Edition", - "about.teamEditiont1": "Enterprise Edition", - "account.logout": "Log out", - "account.logout_from": "Log out of {serverName}", - "account.settings": "Settings", - "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.", - "alert.push_proxy_unknown.title": "Notifications could not be received from this server", - "alert.push_proxy.button": "Okay", - "alert.removed_from_channel.description": "You have been removed from channel {displayName}.", - "alert.removed_from_channel.title": "Removed from channel", - "alert.removed_from_team.description": "You have been removed from team {displayName}.", - "alert.removed_from_team.title": "Removed from team", - "announcment_banner.dismiss": "Dismiss announcement", - "announcment_banner.okay": "Okay", - "api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.", - "api.channel.add_member.added": "{addedUsername} added to the channel by {username}.", - "api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.", - "apps.error": "Error: {error}", - "apps.error.command.field_missing": "Required fields missing: `{fieldName}`.", - "apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.", - "apps.error.command.same_option": "Option repeated for field `{fieldName}`: `{option}`.", - "apps.error.command.same_user": "User repeated for field `{fieldName}`: `{option}`.", - "apps.error.command.unknown_channel": "Unknown channel for field `{fieldName}`: `{option}`.", - "apps.error.command.unknown_option": "Unknown option for field `{fieldName}`: `{option}`.", - "apps.error.command.unknown_user": "Unknown user for field `{fieldName}`: `{option}`.", - "apps.error.form.no_form": "`form` is not defined.", - "apps.error.form.no_lookup": "`lookup` is not defined.", - "apps.error.form.no_source": "`source` is not defined.", - "apps.error.form.no_submit": "`submit` is not defined", - "apps.error.form.refresh": "There has been an error fetching the select fields. Contact the app developer. Details: {details}", - "apps.error.form.refresh_no_refresh": "Called refresh on no refresh field.", - "apps.error.form.submit.pretext": "There has been an error submitting the modal. Contact the app developer. Details: {details}", - "apps.error.lookup.error_preparing_request": "Error preparing lookup request: {errorMessage}", - "apps.error.malformed_binding": "This binding is not properly formed. Contact the App developer.", - "apps.error.parser": "Parsing error: {error}", - "apps.error.parser.empty_value": "Empty values are not allowed.", - "apps.error.parser.execute_non_leaf": "You must select a subcommand.", - "apps.error.parser.missing_binding": "Missing command bindings.", - "apps.error.parser.missing_field_value": "Field value is missing.", - "apps.error.parser.missing_list_end": "Expected list closing token.", - "apps.error.parser.missing_quote": "Matching double quote expected before end of input.", - "apps.error.parser.missing_source": "Form has neither submit nor source.", - "apps.error.parser.missing_submit": "No submit call in binding or form.", - "apps.error.parser.missing_tick": "Matching tick quote expected before end of input.", - "apps.error.parser.multiple_equal": "Multiple `=` signs are not allowed.", - "apps.error.parser.no_argument_pos_x": "Unable to identify argument.", - "apps.error.parser.no_bindings": "No command bindings.", - "apps.error.parser.no_form": "No form found.", - "apps.error.parser.no_match": "`{command}`: No matching command found in this workspace.", - "apps.error.parser.no_slash_start": "Command must start with a `/`.", - "apps.error.parser.unexpected_character": "Unexpected character.", - "apps.error.parser.unexpected_comma": "Unexpected comma.", - "apps.error.parser.unexpected_error": "Unexpected error.", - "apps.error.parser.unexpected_flag": "Command does not accept flag `{flagName}`.", - "apps.error.parser.unexpected_squared_bracket": "Unexpected list opening.", - "apps.error.parser.unexpected_state": "Unreachable: Unexpected state in matchBinding: `{state}`.", - "apps.error.parser.unexpected_whitespace": "Unreachable: Unexpected whitespace.", - "apps.error.responses.form.no_form": "Response type is `form`, but no form was included in the response.", - "apps.error.responses.navigate.no_url": "Response type is `navigate`, but no url was included in response.", - "apps.error.responses.unexpected_error": "Received an unexpected error.", - "apps.error.responses.unexpected_type": "App response type was not expected. Response type: {type}", - "apps.error.responses.unknown_field_error": "Received an error for an unknown field. Field name: `{field}`. Error: `{error}`.", - "apps.error.responses.unknown_type": "App response type not supported. Response type: {type}.", - "apps.error.unknown": "Unknown error occurred.", - "apps.suggestion.dynamic.error": "Dynamic select error", - "apps.suggestion.errors.parser_error": "Parsing error", - "apps.suggestion.no_dynamic": "No data was returned for dynamic suggestions", - "apps.suggestion.no_static": "No matching options.", - "apps.suggestion.no_suggestion": "No matching suggestions.", - "archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.", - "autocomplete_selector.unknown_channel": "Unknown channel", - "browse_channels.archivedChannels": "Archived Channels", - "browse_channels.dropdownTitle": "Show", - "browse_channels.noMore": "No more channels to join", - "browse_channels.publicChannels": "Public Channels", - "browse_channels.sharedChannels": "Shared Channels", - "browse_channels.showArchivedChannels": "Show: Archived Channels", - "browse_channels.showPublicChannels": "Show: Public Channels", - "browse_channels.showSharedChannels": "Show: Shared Channels", - "browse_channels.title": "Browse channels", - "camera_type.photo.option": "Capture Photo", - "camera_type.video.option": "Record Video", - "center_panel.archived.closeChannel": "Close Channel", - "channel_header.directchannel.you": "{displayName} (you)", - "channel_header.info": "View info", - "channel_header.member_count": "{count, plural, one {# member} other {# members}}", - "channel_info.alert_retry": "Try Again", - "channel_info.alertNo": "No", - "channel_info.alertYes": "Yes", - "channel_info.archive": "Archive Channel", - "channel_info.archive_description.can_view_archived": "This will archive the channel from the team. Channel contents will still be accessible by channel members.\n\nAre you sure you wish to archive the {term} {name}?", - "channel_info.archive_description.cannot_view_archived": "This will archive the channel from the team and remove it from the user interface. Archived channels can be unarchived if needed again.\n\nAre you sure you wish to archive the {term} {name}?", - "channel_info.archive_failed": "An error occurred trying to archive the channel {displayName}", - "channel_info.archive_title": "Archive {term}", - "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.convert_failed": "We were unable to convert {displayName} to a private channel.", - "channel_info.convert_private": "Convert to private channel", - "channel_info.convert_private_description": "When you convert {displayName} to a private channel, history and membership are preserved. Publicly shared files remain accessible to anyone with the link. Membership in a private channel is by invitation only.\n\nThe change is permanent and cannot be undone.\n\nAre you sure you want to convert {displayName} to a private channel?", - "channel_info.convert_private_success": "{displayName} is now a private channel.", - "channel_info.convert_private_title": "Convert {displayName} to a private channel?", - "channel_info.copied": "Copied", - "channel_info.copy_link": "Copy Link", - "channel_info.custom_status": "Custom status:", - "channel_info.edit_header": "Edit Header", - "channel_info.error_close": "Close", - "channel_info.favorite": "Favorite", - "channel_info.favorited": "Favorited", - "channel_info.header": "Header:", - "channel_info.ignore_mentions": "Ignore @channel, @here, @all", - "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.local_time": "Local Time", - "channel_info.members": "Members", - "channel_info.mention": "Mention", - "channel_info.mobile_notifications": "Mobile Notifications", - "channel_info.muted": "Mute", - "channel_info.nickname": "Nickname", - "channel_info.notification.all": "All", - "channel_info.notification.default": "Default", - "channel_info.notification.mention": "Mentions", - "channel_info.notification.none": "Never", - "channel_info.pinned_messages": "Pinned Messages", - "channel_info.position": "Position", - "channel_info.private_channel": "Private Channel", - "channel_info.public_channel": "Public Channel", - "channel_info.send_a_mesasge": "Send a message", - "channel_info.send_mesasge": "Send message", - "channel_info.set_header": "Set Header", - "channel_info.unarchive": "Unarchive Channel", - "channel_info.unarchive_description": "Are you sure you want to unarchive the {term} {name}?", - "channel_info.unarchive_failed": "An error occurred trying to unarchive the channel {displayName}", - "channel_info.unarchive_title": "Unarchive {term}", - "channel_intro.createdBy": "Created by {user} on {date}", - "channel_intro.createdOn": "Created on {date}", - "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.", - "channel_modal.header": "Header", - "channel_modal.headerEx": "Use Markdown to format header text", - "channel_modal.headerHelp": "Specify text to appear in the channel header beside the channel name. For example, include frequently used links by typing link text [Link Title](http://example.com).", - "channel_modal.makePrivate.description": "When a channel is set to private, only invited team members can access and participate in that channel", - "channel_modal.makePrivate.label": "Make Private", - "channel_modal.name": "Name", - "channel_modal.nameEx": "Bugs, Marketing", - "channel_modal.optional": "(optional)", - "channel_modal.purpose": "Purpose", - "channel_modal.purposeEx": "A channel to file bugs and improvements", - "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}.", - "combined_system_message.added_to_channel.two": "{firstUser} and {secondUser} **added to the channel** by {actor}.", - "combined_system_message.added_to_team.many_expanded": "{users} and {lastUser} were **added to the team** by {actor}.", - "combined_system_message.added_to_team.one": "{firstUser} **added to the team** by {actor}.", - "combined_system_message.added_to_team.one_you": "You were **added to the team** by {actor}.", - "combined_system_message.added_to_team.two": "{firstUser} and {secondUser} **added to the team** by {actor}.", - "combined_system_message.joined_channel.many_expanded": "{users} and {lastUser} **joined the channel**.", - "combined_system_message.joined_channel.one": "{firstUser} **joined the channel**.", - "combined_system_message.joined_channel.one_you": "You **joined the channel**.", - "combined_system_message.joined_channel.two": "{firstUser} and {secondUser} **joined the channel**.", - "combined_system_message.joined_team.many_expanded": "{users} and {lastUser} **joined the team**.", - "combined_system_message.joined_team.one": "{firstUser} **joined the team**.", - "combined_system_message.joined_team.one_you": "You **joined the team**.", - "combined_system_message.joined_team.two": "{firstUser} and {secondUser} **joined the team**.", - "combined_system_message.left_channel.many_expanded": "{users} and {lastUser} **left the channel**.", - "combined_system_message.left_channel.one": "{firstUser} **left the channel**.", - "combined_system_message.left_channel.one_you": "You **left the channel**.", - "combined_system_message.left_channel.two": "{firstUser} and {secondUser} **left the channel**.", - "combined_system_message.left_team.many_expanded": "{users} and {lastUser} **left the team**.", - "combined_system_message.left_team.one": "{firstUser} **left the team**.", - "combined_system_message.left_team.one_you": "You **left the team**.", - "combined_system_message.left_team.two": "{firstUser} and {secondUser} **left the team**.", - "combined_system_message.removed_from_channel.many_expanded": "{users} and {lastUser} were **removed from the channel**.", - "combined_system_message.removed_from_channel.one": "{firstUser} was **removed from the channel**.", - "combined_system_message.removed_from_channel.one_you": "You were **removed from the channel**.", - "combined_system_message.removed_from_channel.two": "{firstUser} and {secondUser} were **removed from the channel**.", - "combined_system_message.removed_from_team.many_expanded": "{users} and {lastUser} were **removed from the team**.", - "combined_system_message.removed_from_team.one": "{firstUser} was **removed from the team**.", - "combined_system_message.removed_from_team.one_you": "You were **removed from the team**.", - "combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.", - "combined_system_message.you": "You", - "connection_banner.connected": "Connection restored", - "connection_banner.connecting": "Connecting...", - "connection_banner.not_connected": "No internet connection", - "connection_banner.not_reachable": "The server is not reachable", - "create_direct_message.title": "Create Direct Message", - "create_post.deactivated": "You are viewing an archived channel with a deactivated user.", - "create_post.thread_reply": "Reply to this thread...", - "create_post.write": "Write to {channelDisplayName}", - "custom_status.expiry_dropdown.custom": "Custom", - "custom_status.expiry_dropdown.date_and_time": "Date and Time", - "custom_status.expiry_dropdown.dont_clear": "Don't clear", - "custom_status.expiry_dropdown.four_hours": "4 hours", - "custom_status.expiry_dropdown.one_hour": "1 hour", - "custom_status.expiry_dropdown.thirty_minutes": "30 minutes", - "custom_status.expiry_dropdown.this_week": "This week", - "custom_status.expiry_dropdown.today": "Today", - "custom_status.expiry_time.today": "Today", - "custom_status.expiry_time.tomorrow": "Tomorrow", - "custom_status.expiry.at": "at", - "custom_status.expiry.until": "Until", - "custom_status.failure_message": "Failed to update status. Try again", - "custom_status.set_status": "Set a custom status", - "custom_status.suggestions.in_a_meeting": "In a meeting", - "custom_status.suggestions.on_a_vacation": "On a vacation", - "custom_status.suggestions.out_for_lunch": "Out for lunch", - "custom_status.suggestions.out_sick": "Out sick", - "custom_status.suggestions.recent_title": "Recent", - "custom_status.suggestions.title": "Suggestions", - "custom_status.suggestions.working_from_home": "Working from home", - "date_separator.today": "Today", - "date_separator.yesterday": "Yesterday", - "default_skin_tone": "Default Skin Tone", - "display_settings.clock.military": "24-hour", - "display_settings.clock.standard": "12-hour", - "display_settings.clockDisplay": "Clock Display", - "display_settings.crt": "Collapsed Reply Threads", - "display_settings.crt.off": "Off", - "display_settings.crt.on": "On", - "display_settings.theme": "Theme", - "display_settings.timezone": "Timezone", - "display_settings.tz.auto": "Auto", - "display_settings.tz.manual": "Manual", - "download.error": "Unable to download the file. Try again later", - "edit_post.editPost": "Edit the post...", - "edit_post.save": "Save", - "edit_server.description": "Specify a display name for this server", - "edit_server.display_help": "Server: {url}", - "edit_server.save": "Save", - "edit_server.saving": "Saving", - "edit_server.title": "Edit server name", - "emoji_picker.activities": "Activities", - "emoji_picker.animals-nature": "Animals & Nature", - "emoji_picker.custom": "Custom", - "emoji_picker.flags": "Flags", - "emoji_picker.food-drink": "Food & Drink", - "emoji_picker.objects": "Objects", - "emoji_picker.people-body": "People & Body", - "emoji_picker.recent": "Recently Used", - "emoji_picker.searchResults": "Search Results", - "emoji_picker.smileys-emotion": "Smileys & Emotion", - "emoji_picker.symbols": "Symbols", - "emoji_picker.travel-places": "Travel & Places", - "emoji_skin.dark_skin_tone": "dark skin tone", - "emoji_skin.default": "default skin tone", - "emoji_skin.light_skin_tone": "light skin tone", - "emoji_skin.medium_dark_skin_tone": "medium dark skin tone", - "emoji_skin.medium_light_skin_tone": "medium light skin tone", - "emoji_skin.medium_skin_tone": "medium skin tone", - "extension.no_memberships.description": "To share content, you'll need to be a member of a team on a Mattermost server.", - "extension.no_memberships.title": "Not a member of any team yet", - "extension.no_servers.description": "To share content, you'll need to be logged in to a Mattermost server.", - "extension.no_servers.title": "Not connected to any servers", - "file_upload.fileAbove": "Files must be less than {max}", - "find_channels.directory": "Directory", - "find_channels.new_channel": "New Channel", - "find_channels.open_dm": "Open a DM", - "find_channels.title": "Find Channels", - "friendly_date.daysAgo": "{count} {count, plural, one {day} other {days}} ago", - "friendly_date.hoursAgo": "{count} {count, plural, one {hour} other {hours}} ago", - "friendly_date.minsAgo": "{count} {count, plural, one {min} other {mins}} ago", - "friendly_date.monthsAgo": "{count} {count, plural, one {month} other {months}} ago", - "friendly_date.now": "Now", - "friendly_date.yearsAgo": "{count} {count, plural, one {year} other {years}} ago", - "friendly_date.yesterday": "Yesterday", - "gallery.copy_link.failed": "Failed to copy link to clipboard", - "gallery.downloading": "Downloading...", - "gallery.footer.channel_name": "Shared in {channelName}", - "gallery.image_saved": "Image saved", - "gallery.open_file": "Open file", - "gallery.opening": "Opening...", - "gallery.preparing": "Preparing...", - "gallery.save_failed": "Unable to save the file", - "gallery.unsupported": "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.", - "gallery.video_saved": "Video saved", - "general_settings.about": "About {appTitle}", - "general_settings.advanced_settings": "Advanced Settings", - "general_settings.display": "Display", - "general_settings.help": "Help", - "general_settings.notifications": "Notifications", - "general_settings.report_problem": "Report a problem", - "get_post_link_modal.title": "Copy Link", - "global_threads.allThreads": "All Your Threads", - "global_threads.emptyThreads.message": "Any threads you are mentioned in or have participated in will show here along with any threads you have followed.", - "global_threads.emptyThreads.title": "No followed threads yet", - "global_threads.emptyUnreads.message": "Looks like you're all caught up.", - "global_threads.emptyUnreads.title": "No unread threads", - "global_threads.markAllRead.cancel": "Cancel", - "global_threads.markAllRead.markRead": "Mark read", - "global_threads.markAllRead.message": "This will clear any unread status for all of your threads shown here", - "global_threads.markAllRead.title": "Are you sure you want to mark all threads as read?", - "global_threads.options.mark_as_read": "Mark as Read", - "global_threads.options.open_in_channel": "Open in Channel", - "global_threads.options.title": "Thread Actions", - "global_threads.unreads": "Unreads", - "home.header.plus_menu": "Options", - "integration_selector.multiselect.submit": "Done", - "interactive_dialog.submit": "Submit", - "intro.add_people": "Add People", - "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.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.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.", - "intro.welcome.public": "Add some more team members to the channel or start a conversation below.", - "invite_people_to_team.message": "Here’s a link to collaborate and communicate with us on Mattermost.", - "invite_people_to_team.title": "Join the {team} team", - "invite.members.already_member": "This person is already a team member", - "invite.members.user_is_guest": "Contact your admin to make this guest a full member", - "invite.search.email_invite": "invite", - "invite.search.no_results": "No one found matching", - "invite.searchPlaceholder": "Type a name or email address…", - "invite.send_error": "Something went wrong while trying to send invitations. Please check your network connection and try again.", - "invite.send_invite": "Send", - "invite.sendInvitationsTo": "Send invitations to…", - "invite.shareLink": "Share link", - "invite.summary.done": "Done", - "invite.summary.email_invite": "An invitation email has been sent", - "invite.summary.error": "{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully", - "invite.summary.member_invite": "Invited as a member of {teamDisplayName}", - "invite.summary.not_sent": "{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} sent", - "invite.summary.report.notSent": "{count} {count, plural, one {invitation} other {invitations}} not sent", - "invite.summary.report.sent": "{count} successful {count, plural, one {invitation} other {invitations}}", - "invite.summary.sent": "Your {sentCount, plural, one {invitation has} other {invitations have}} been sent", - "invite.summary.smtp_failure": "SMTP is not configured in System Console", - "invite.summary.some_not_sent": "{notSentCount, plural, one {An invitation was} other {Some invitations were}} not sent", - "invite.summary.try_again": "Try again", - "invite.title": "Invite", - "invite.title.summary": "Invite summary", - "join_team.error.group_error": "You need to be a member of a linked group to join this team.", - "join_team.error.message": "There has been an error joining the team", - "join_team.error.title": "Error joining a team", - "last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.", - "last_users_message.added_to_team.type": "were **added to the team** by {actor}.", - "last_users_message.first": "{firstUser} and ", - "last_users_message.joined_channel.type": "**joined the channel**.", - "last_users_message.joined_team.type": "**joined the team**.", - "last_users_message.left_channel.type": "**left the channel**.", - "last_users_message.left_team.type": "**left the team**.", - "last_users_message.others": "{numOthers} others ", - "last_users_message.removed_from_channel.type": "were **removed from the channel**.", - "last_users_message.removed_from_team.type": "were **removed from the team**.", - "load_categories_error.message": "There was a problem loading content for this server.", - "load_categories_error.title": "Couldn't load categories for {serverName}", - "load_channels_error.message": "There was a problem loading content for this team.", - "load_channels_error.title": "Couldn't load {teamDisplayName}", - "load_teams_error.message": "There was a problem loading content for this server.", - "load_teams_error.title": "Couldn't load {serverName}", - "login_mfa.enterToken": "To complete the sign in process, please enter the code from your mobile device's authenticator app.", - "login_mfa.token": "Enter MFA Token", - "login_mfa.tokenReq": "Please enter an MFA token", - "login.email": "Email", - "login.forgot": "Forgot your password?", - "login.invalid_credentials": "The email and password combination is incorrect", - "login.ldapUsername": "AD/LDAP Username", - "login.or": "or", - "login.password": "Password", - "login.signIn": "Log In", - "login.signingIn": "Logging In", - "login.username": "Username", - "markdown.latex.error": "Latex render error", - "mentions.empty.paragraph": "You'll see messages here when someone mentions you or uses terms you're monitoring.", - "mentions.empty.title": "No Mentions yet", - "mobile.about.appVersion": "App Version: {version} (Build {number})", - "mobile.account.settings.save": "Save", - "mobile.action_menu.select": "Select an option", - "mobile.add_team.create_team": "Create a new team", - "mobile.add_team.join_team": "Join Another Team", - "mobile.android.back_handler_exit": "Press back again to exit", - "mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.", - "mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos", - "mobile.announcement_banner.title": "Announcement", - "mobile.calls_call_ended": "Call ended", - "mobile.calls_call_screen": "Call", - "mobile.calls_call_thread": "Call Thread", - "mobile.calls_current_call": "Current call", - "mobile.calls_disable": "Disable calls", - "mobile.calls_dismiss": "Dismiss", - "mobile.calls_enable": "Enable calls", - "mobile.calls_end_call_title": "End call", - "mobile.calls_end_msg_channel": "Are you sure you want to end a call with {numParticipants} participants in {displayName}?", - "mobile.calls_end_msg_channel_default": "Are you sure you want to end the call?", - "mobile.calls_end_msg_dm": "Are you sure you want to end a call with {displayName}?", - "mobile.calls_end_permission_msg": "You don't have permission to end the call. Please ask the call creator to end the call.", - "mobile.calls_end_permission_title": "Error", - "mobile.calls_ended_at": "Ended at", - "mobile.calls_error_message": "Error: {error}", - "mobile.calls_error_title": "Error", - "mobile.calls_host": "host", - "mobile.calls_host_rec": "You are recording this meeting. Consider letting everyone know that this meeting is being recorded.", - "mobile.calls_host_rec_stopped": "You can find the recording in this call's chat thread once it's finished processing.", - "mobile.calls_host_rec_stopped_title": "Recording has stopped. Processing...", - "mobile.calls_host_rec_title": "You are recording", - "mobile.calls_join_call": "Join call", - "mobile.calls_lasted": "Lasted {duration}", - "mobile.calls_leave": "Leave", - "mobile.calls_leave_call": "Leave call", - "mobile.calls_limit_msg": "The maximum number of participants per call is {maxParticipants}. Contact your System Admin to increase the limit.", - "mobile.calls_limit_msg_GA": "Upgrade to Cloud Professional or Cloud Enterprise to enable group calls with more than {maxParticipants} participants.", - "mobile.calls_limit_reached": "Participant limit reached", - "mobile.calls_lower_hand": "Lower hand", - "mobile.calls_mic_error": "To participate, open Settings to grant Mattermost access to your microphone.", - "mobile.calls_more": "More", - "mobile.calls_mute": "Mute", - "mobile.calls_name_is_talking": "{name} is talking", - "mobile.calls_name_started_call": "{name} started a call", - "mobile.calls_noone_talking": "No one is talking", - "mobile.calls_not_available_msg": "Please contact your System Admin to enable the feature.", - "mobile.calls_not_available_option": "(Not available)", - "mobile.calls_not_available_title": "Calls is not enabled", - "mobile.calls_not_connected": "You're not connected to a call in the current channel.", - "mobile.calls_ok": "OK", - "mobile.calls_okay": "Okay", - "mobile.calls_open_channel": "Open Channel", - "mobile.calls_participant_limit_title_GA": "This call is at capacity", - "mobile.calls_participant_rec": "The host has started recording this meeting. By staying in the meeting you give consent to being recorded.", - "mobile.calls_participant_rec_title": "Recording is in progress", - "mobile.calls_raise_hand": "Raise hand", - "mobile.calls_react": "React", - "mobile.calls_rec": "rec", - "mobile.calls_record": "Record", - "mobile.calls_request_message": "Calls are currently running in test mode and only system admins can start them. Reach out directly to your system admin for assistance", - "mobile.calls_request_title": "Calls is not currently enabled", - "mobile.calls_see_logs": "See server logs", - "mobile.calls_speaker": "Speaker", - "mobile.calls_start_call": "Start Call", - "mobile.calls_start_call_exists": "A call is already ongoing in the channel.", - "mobile.calls_stop_recording": "Stop Recording", - "mobile.calls_unmute": "Unmute", - "mobile.calls_viewing_screen": "You are viewing {name}'s screen", - "mobile.calls_you": "(you)", - "mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.", - "mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera", - "mobile.camera_type.title": "Camera options", - "mobile.channel_info.alertNo": "No", - "mobile.channel_info.alertYes": "Yes", - "mobile.channel_list.recent": "Recent", - "mobile.channel_list.unreads": "Unreads", - "mobile.commands.error_title": "Error Executing Command", - "mobile.components.select_server_view.connect": "Connect", - "mobile.components.select_server_view.connecting": "Connecting", - "mobile.components.select_server_view.displayHelp": "Choose a display name for your server", - "mobile.components.select_server_view.displayName": "Display Name", - "mobile.components.select_server_view.enterServerUrl": "Enter Server URL", - "mobile.components.select_server_view.msg_connect": "Let’s Connect to a Server", - "mobile.components.select_server_view.msg_description": "A Server is your team's communication hub which is accessed through a unique URL", - "mobile.components.select_server_view.msg_welcome": "Welcome", - "mobile.components.select_server_view.proceed": "Proceed", - "mobile.create_channel": "Create", - "mobile.create_channel.title": "New channel", - "mobile.create_direct_message.max_limit_reached": "Group messages are limited to {maxCount} members", - "mobile.create_direct_message.start": "Start Conversation", - "mobile.create_direct_message.you": "@{username} - you", - "mobile.create_post.read_only": "This channel is read-only.", - "mobile.custom_list.no_results": "No Results", - "mobile.custom_status.choose_emoji": "Choose an emoji", - "mobile.custom_status.clear_after": "Clear After", - "mobile.custom_status.clear_after.title": "Clear Custom Status After", - "mobile.custom_status.modal_confirm": "Done", - "mobile.direct_message.error": "We couldn't open a DM with {displayName}.", - "mobile.display_settings.clockDisplay": "Clock Display", - "mobile.display_settings.crt": "Collapsed Reply Threads", - "mobile.display_settings.theme": "Theme", - "mobile.display_settings.timezone": "Timezone", - "mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n", - "mobile.document_preview.failed_title": "Open Document failed", - "mobile.downloader.disabled_description": "File downloads are disabled on this server. Please contact your System Admin for more details.\n", - "mobile.downloader.disabled_title": "Download disabled", - "mobile.downloader.failed_description": "An error occurred while downloading the file. Please check your internet connection and try again.\n", - "mobile.downloader.failed_title": "Download failed", - "mobile.edit_channel": "Save", - "mobile.edit_post.delete_question": "Are you sure you want to delete this Post?", - "mobile.edit_post.delete_title": "Confirm Post Delete", - "mobile.edit_post.error": "There was a problem editing this message. Please try again.", - "mobile.edit_post.title": "Editing Message", - "mobile.error_handler.button": "Relaunch", - "mobile.error_handler.description": "\nTap relaunch to open the app again. After restart, you can report the problem from the settings menu.\n", - "mobile.error_handler.title": "Unexpected error occurred", - "mobile.file_upload.disabled2": "File uploads from mobile are disabled.", - "mobile.file_upload.max_warning": "Uploads limited to {count} files maximum.", - "mobile.files_paste.error_description": "An error occurred while pasting the file(s). Please try again.", - "mobile.files_paste.error_dismiss": "Dismiss", - "mobile.files_paste.error_title": "Paste failed", - "mobile.gallery.title": "{index} of {total}", - "mobile.integration_selector.loading_channels": "Loading channels...", - "mobile.integration_selector.loading_options": "Loading options...", - "mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.", - "mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos", - "mobile.join_channel.error": "We couldn't join the channel {displayName}.", - "mobile.leave_and_join_confirmation": "Leave & Join", - "mobile.leave_and_join_message": "You are already on a channel call in ~{leaveChannelName}. Do you want to leave your current call and join the call in ~{joinChannelName}?", - "mobile.leave_and_join_title": "Are you sure you want to switch to a different call?", - "mobile.link.error.text": "Unable to open the link.", - "mobile.link.error.title": "Error", - "mobile.login_options.cant_heading": "Can't Log In", - "mobile.login_options.enter_credentials": "Enter your login details below.", - "mobile.login_options.gitlab": "GitLab", - "mobile.login_options.google": "Google", - "mobile.login_options.heading": "Log In to Your Account", - "mobile.login_options.none": "You can't log in to your account yet. At least one login option must be configured. Contact your System Admin for assistance.", - "mobile.login_options.office365": "Office 365", - "mobile.login_options.openid": "Open ID", - "mobile.login_options.saml": "SAML", - "mobile.login_options.select_option": "Select a login option below.", - "mobile.login_options.separator_text": "or log in with", - "mobile.login_options.sso_continue": "Continue with", - "mobile.managed.blocked_by": "Blocked by {vendor}", - "mobile.managed.exit": "Exit", - "mobile.managed.jailbreak": "Jailbroken devices are not trusted by {vendor}.\n\nReason {reason}\n\n\n\nDebug info: {debug}\n\nPlease exit the app.", - "mobile.managed.jailbreak_no_debug_info": "Not available", - "mobile.managed.jailbreak_no_reason": "Not available", - "mobile.managed.not_secured.android": "This device must be secured with a screen lock to use Mattermost.", - "mobile.managed.not_secured.ios": "This device must be secured with a passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.", - "mobile.managed.not_secured.ios.touchId": "This device must be secured with a passcode to use Mattermost.\n\nGo to Settings > Touch ID & Passcode.", - "mobile.managed.secured_by": "Secured by {vendor}", - "mobile.managed.settings": "Go to settings", - "mobile.markdown.code.copy_code": "Copy Code", - "mobile.markdown.code.plusMoreLines": "+{count, number} more {count, plural, one {line} other {lines}}", - "mobile.markdown.image.too_large": "Image exceeds max dimensions of {maxWidth} by {maxHeight}:", - "mobile.markdown.link.copy_url": "Copy URL", - "mobile.mention.copy_mention": "Copy Mention", - "mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}", - "mobile.message_length.message_split_left": "Message exceeds the character limit", - "mobile.message_length.title": "Message Length", - "mobile.no_results_with_term": "No results for “{term}”", - "mobile.no_results_with_term.files": "No files matching “{term}”", - "mobile.no_results_with_term.messages": "No matches found for “{term}”", - "mobile.no_results.spelling": "Check the spelling or try another search.", - "mobile.oauth.failed_to_login": "Your login attempt failed. Please try again.", - "mobile.oauth.failed_to_open_link": "The link failed to open. Please try again.", - "mobile.oauth.failed_to_open_link_no_browser": "The link failed to open. Please verify that a browser is installed on the device.", - "mobile.oauth.something_wrong": "Something went wrong", - "mobile.oauth.something_wrong.okButton": "OK", - "mobile.oauth.switch_to_browser": "You are being redirected to your login provider", - "mobile.oauth.switch_to_browser.error_title": "Sign in error", - "mobile.oauth.switch_to_browser.title": "Redirecting...", - "mobile.oauth.try_again": "Try again", - "mobile.onboarding.next": "Next", - "mobile.onboarding.sign_in": "Sign in", - "mobile.onboarding.sign_in_to_get_started": "Sign in to get started", - "mobile.open_dm.error": "We couldn't open a direct message with {displayName}. Please check your connection and try again.", - "mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.", - "mobile.participants.header": "Thread Participants", - "mobile.permission_denied_dismiss": "Don't Allow", - "mobile.permission_denied_retry": "Settings", - "mobile.post_info.add_reaction": "Add Reaction", - "mobile.post_info.copy_text": "Copy Text", - "mobile.post_info.mark_unread": "Mark as Unread", - "mobile.post_info.pin": "Pin to Channel", - "mobile.post_info.reply": "Reply", - "mobile.post_info.save": "Save", - "mobile.post_info.unpin": "Unpin from Channel", - "mobile.post_info.unsave": "Unsave", - "mobile.post_pre_header.pinned": "Pinned", - "mobile.post_pre_header.pinned_saved": "Pinned and Saved", - "mobile.post_pre_header.saved": "Saved", - "mobile.post_textbox.entire_channel_here.message": "By using @here you are about to send notifications to up to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?", - "mobile.post_textbox.entire_channel_here.message.with_timezones": "By using @here you are about to send notifications up to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", - "mobile.post_textbox.entire_channel.cancel": "Cancel", - "mobile.post_textbox.entire_channel.confirm": "Confirm", - "mobile.post_textbox.entire_channel.message": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?", - "mobile.post_textbox.entire_channel.message.with_timezones": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", - "mobile.post_textbox.entire_channel.title": "Confirm sending notifications to entire channel", - "mobile.post_textbox.groups.title": "Confirm sending notifications to groups", - "mobile.post_textbox.uploadFailedDesc": "Some attachments failed to upload to the server. Are you sure you want to post the message?", - "mobile.post_textbox.uploadFailedTitle": "Attachment failure", - "mobile.post.cancel": "Cancel", - "mobile.post.delete_question": "Are you sure you want to delete this post?", - "mobile.post.delete_title": "Delete Post", - "mobile.post.failed_delete": "Delete Message", - "mobile.post.failed_retry": "Try Again", - "mobile.privacy_link": "Privacy Policy", - "mobile.push_notification_reply.button": "Send", - "mobile.push_notification_reply.placeholder": "Write a reply...", - "mobile.push_notification_reply.title": "Reply", - "mobile.rename_channel.display_name_maxLength": "Channel name must be less than {maxLength, number} characters", - "mobile.rename_channel.display_name_minLength": "Channel name must be {minLength, number} or more characters", - "mobile.rename_channel.display_name_required": "Channel name is required", - "mobile.request.invalid_request_method": "Invalid request method", - "mobile.request.invalid_response": "Received invalid response from the server.", - "mobile.reset_status.alert_cancel": "Cancel", - "mobile.reset_status.alert_ok": "OK", - "mobile.reset_status.title_ooo": "Disable \"Out Of Office\"?", - "mobile.routes.code": "{language} Code", - "mobile.routes.code.noLanguage": "Code", - "mobile.routes.custom_status": "Set a custom status", - "mobile.routes.table": "Table", - "mobile.routes.user_profile": "Profile", - "mobile.screen.settings": "Settings", - "mobile.screen.your_profile": "Your Profile", - "mobile.search.jump": "Jump to recent messages", - "mobile.search.modifier.exclude": "exclude search terms", - "mobile.search.modifier.from": "a specific user", - "mobile.search.modifier.in": "a specific channel", - "mobile.search.modifier.phrases": "messages with phrases", - "mobile.search.show_less": "Show less", - "mobile.search.show_more": "Show more", - "mobile.search.team.select": "Select a team to search", - "mobile.server_identifier.exists": "You are already connected to this server.", - "mobile.server_link.error.text": "The link could not be found on this server.", - "mobile.server_link.error.title": "Link Error", - "mobile.server_link.unreachable_channel.error": "This link belongs to a deleted channel or to a channel to which you do not have access.", - "mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.", - "mobile.server_link.unreachable_user.error": "We can't redirect you to the DM. The user specified is unknown.", - "mobile.server_name.exists": "You are using this name for another server.", - "mobile.server_ping_failed": "Cannot connect to the server.", - "mobile.server_requires_client_certificate": "Server requires client certificate for authentication.", - "mobile.server_upgrade.button": "OK", - "mobile.server_upgrade.description": "\nA server upgrade is required to use the Mattermost app. Please ask your System Administrator for details.\n", - "mobile.server_upgrade.title": "Server upgrade required", - "mobile.server_url.deeplink.emm.denied": "This app is controlled by an EMM and the DeepLink server url does not match the EMM allowed server", - "mobile.server_url.empty": "Please enter a valid server URL", - "mobile.server_url.invalid_format": "URL must start with http:// or https://", - "mobile.session_expired": "Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.", - "mobile.session_expired.title": "Session Expired", - "mobile.set_status.dnd": "Do Not Disturb", - "mobile.storage_permission_denied_description": "Upload files to your server. Open Settings to grant {applicationName} Read and Write access to files on this device.", - "mobile.storage_permission_denied_title": "{applicationName} would like to access your files", - "mobile.suggestion.members": "Members", - "mobile.system_message.channel_archived_message": "{username} archived the channel", - "mobile.system_message.channel_unarchived_message": "{username} unarchived the channel", - "mobile.system_message.update_channel_displayname_message_and_forget.updated_from": "{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}", - "mobile.system_message.update_channel_header_message_and_forget.removed": "{username} removed the channel header (was: {oldHeader})", - "mobile.system_message.update_channel_header_message_and_forget.updated_from": "{username} updated the channel header from: {oldHeader} to: {newHeader}", - "mobile.system_message.update_channel_header_message_and_forget.updated_to": "{username} updated the channel header to: {newHeader}", - "mobile.system_message.update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {oldPurpose})", - "mobile.system_message.update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}", - "mobile.system_message.update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {newPurpose}", - "mobile.tos_link": "Terms of Service", - "mobile.user_list.deactivated": "Deactivated", - "mobile.write_storage_permission_denied_description": "Save files to your device. Open Settings to grant {applicationName} write access to files on this device.", - "modal.manual_status.auto_responder.message_": "Would you like to switch your status to \"{status}\" and disable Automatic Replies?", - "modal.manual_status.auto_responder.message_away": "Would you like to switch your status to \"Away\" and disable Automatic Replies?", - "modal.manual_status.auto_responder.message_dnd": "Would you like to switch your status to \"Do Not Disturb\" and disable Automatic Replies?", - "modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?", - "modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?", - "more_messages.text": "{count} new {count, plural, one {message} other {messages}}", - "msg_typing.areTyping": "{users} and {last} are typing...", - "msg_typing.isTyping": "{user} is typing...", - "notification_settings.auto_responder": "Automatic Replies", - "notification_settings.auto_responder.default_message": "Hello, I am out of office and unable to respond to messages.", - "notification_settings.auto_responder.footer.message": "Set a custom message that is automatically sent in response to direct messages, such as an out of office or vacation reply. Enabling this setting changes your status to Out of Office and disables notifications.", - "notification_settings.auto_responder.message": "Message", - "notification_settings.auto_responder.to.enable": "Enable automatic replies", - "notification_settings.email": "Email Notifications", - "notification_settings.email.crt.emailInfo": "When enabled, any reply to a thread you're following will send an email notification", - "notification_settings.email.crt.send": "Thread reply notifications", - "notification_settings.email.emailHelp2": "Email has been disabled by your System Administrator. No notification emails will be sent until it is enabled.", - "notification_settings.email.emailInfo": "Email notifications are sent for mentions and direct messages when you are offline or away for more than 5 minutes.", - "notification_settings.email.everyHour": "Every hour", - "notification_settings.email.fifteenMinutes": "Every 15 minutes", - "notification_settings.email.immediately": "Immediately", - "notification_settings.email.never": "Never", - "notification_settings.email.send": "Send email notifications", - "notification_settings.mention.reply": "Send reply notifications for", - "notification_settings.mentions": "Mentions", - "notification_settings.mentions_replies": "Mentions and Replies", - "notification_settings.mentions..keywordsDescription": "Other words that trigger a mention", - "notification_settings.mentions.channelWide": "Channel-wide mentions", - "notification_settings.mentions.keywords": "Keywords", - "notification_settings.mentions.keywords_mention": "Keywords that trigger mentions", - "notification_settings.mentions.keywordsLabel": "Keywords are not case-sensitive. Separate keywords with commas.", - "notification_settings.mentions.sensitiveName": "Your case sensitive first name", - "notification_settings.mentions.sensitiveUsername": "Your non-case sensitive username", - "notification_settings.mobile": "Push Notifications", - "notification_settings.mobile.away": "Away or offline", - "notification_settings.mobile.offline": "Offline", - "notification_settings.mobile.online": "Online, away or offline", - "notification_settings.mobile.trigger_push": "Trigger push notifications when...", - "notification_settings.ooo_auto_responder": "Automatic replies", - "notification_settings.push_notification": "Push Notifications", - "notification_settings.push_threads.following": "Notify me about replies to threads I'm following in this channel", - "notification_settings.push_threads.replies": "Thread replies", - "notification_settings.pushNotification.all_new_messages": "All new messages", - "notification_settings.pushNotification.disabled_long": "Push notifications for mobile devices have been disabled by your System Administrator.", - "notification_settings.pushNotification.mentions_only": "Mentions, direct messages only (default)", - "notification_settings.pushNotification.nothing": "Nothing", - "notification_settings.send_notification.about": "Notify me about...", - "notification_settings.threads_mentions": "Mentions in threads", - "notification_settings.threads_start": "Threads that I start", - "notification_settings.threads_start_participate": "Threads that I start or participate in", - "notification.message_not_found": "Message not found", - "notification.not_channel_member": "This message belongs to a channel where you are not a member.", - "notification.not_team_member": "This message belongs to a team where you are not a member.", - "onboarding.calls": "Start secure audio calls instantly", - "onboarding.calls_description": "When typing isn’t fast enough, switch from channel-based chat to secure audio calls with a single tap.", - "onboarding.integrations": "Integrate with tools you love", - "onboarding.integrations_description": "Go beyond chat with tightly-integrated product solutions matched to common development processes.", - "onboarding.realtime_collaboration": "Collaborate in real‑time", - "onboarding.realtime_collaboration_description": "Persistent channels, direct messaging, and file sharing works seamlessly so you can stay connected, wherever you are.", - "onboarding.welcome": "Welcome", - "onboaring.welcome_description": "Mattermost is an open source platform for developer collaboration. Secure, flexible, and integrated with your tools.", - "password_send.description": "To reset your password, enter the email address you used to sign up", - "password_send.error": "Please enter a valid email address.", - "password_send.generic_error": "We were unable to send you a reset password link. Please contact your System Admin for assistance.", - "password_send.link": "If the account exists, a password reset email will be sent to:", - "password_send.link.title": "Reset Link Sent", - "password_send.reset": "Reset Your Password", - "password_send.return": "Return to Log In", - "permalink.error.access.text": "The message you are trying to view is in a channel you don’t have access to or has been deleted.", - "permalink.error.access.title": "Message not viewable", - "permalink.error.cancel": "Cancel", - "permalink.error.okay": "Okay", - "permalink.error.private_channel_and_team.button": "Join channel and team", - "permalink.error.private_channel_and_team.text": "The message you are trying to view is in a private channel in a team you are not a member of. You have access as an admin. Do you want to join **{channelName}** and the **{teamName}** team to view it?", - "permalink.error.private_channel_and_team.title": "Join private channel and team", - "permalink.error.private_channel.button": "Join channel", - "permalink.error.private_channel.text": "The message you are trying to view is in a private channel you have not been invited to, but you have access as an admin. Do you still want to join **{channelName}**?", - "permalink.error.private_channel.title": "Join private channel", - "permalink.error.public_channel_and_team.button": "Join channel and team", - "permalink.error.public_channel_and_team.text": "The message you are trying to view is in a channel you don’t belong and a team you are not a member of. Do you want to join **{channelName}** and the **{teamName}** team to view it?", - "permalink.error.public_channel_and_team.title": "Join channel and team", - "permalink.error.public_channel.button": "Join channel", - "permalink.error.public_channel.text": "The message you are trying to view is in a channel you don’t belong to. Do you want to join **{channelName}** to view it?", - "permalink.error.public_channel.title": "Join channel", - "permalink.show_dialog_warn.cancel": "Cancel", - "permalink.show_dialog_warn.description": "You are about to join {channel} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?", - "permalink.show_dialog_warn.join": "Join", - "permalink.show_dialog_warn.title": "Join private channel", - "pinned_messages.empty.paragraph": "To pin important messages, long-press on a message and choose Pin To Channel. Pinned messages will be visible to everyone in this channel.", - "pinned_messages.empty.title": "No pinned messages yet", - "plus_menu.browse_channels.title": "Browse Channels", - "plus_menu.create_new_channel.title": "Create New Channel", - "plus_menu.invite_people_to_team.title": "Invite people to the team", - "plus_menu.open_direct_message.title": "Open a Direct Message", - "post_body.check_for_out_of_channel_groups_mentions.message": "did not get notified by this mention because they are not in the channel. They are also not a member of the groups linked to this channel.", - "post_body.check_for_out_of_channel_mentions.link.and": " and ", - "post_body.check_for_out_of_channel_mentions.link.private": "add them to this private channel", - "post_body.check_for_out_of_channel_mentions.link.public": "add them to the channel", - "post_body.check_for_out_of_channel_mentions.message_last": "? They will have access to all message history.", - "post_body.check_for_out_of_channel_mentions.message.multiple": "were mentioned but they are not in the channel. Would you like to ", - "post_body.check_for_out_of_channel_mentions.message.one": "was mentioned but is not in the channel. Would you like to ", - "post_body.commentedOn": "Commented on {name}{apostrophe} message: ", - "post_body.deleted": "(message deleted)", - "post_info.auto_responder": "Automatic Reply", - "post_info.bot": "Bot", - "post_info.del": "Delete", - "post_info.edit": "Edit", - "post_info.guest": "Guest", - "post_info.system": "System", - "post_message_view.edited": "(edited)", - "post_priority.label.important": "IMPORTANT", - "post_priority.label.urgent": "URGENT", - "post_priority.picker.beta": "BETA", - "post_priority.picker.label.important": "Important", - "post_priority.picker.label.standard": "Standard", - "post_priority.picker.label.urgent": "Urgent", - "post_priority.picker.title": "Message priority", - "post.options.title": "Options", - "post.reactions.title": "Reactions", - "posts_view.newMsg": "New Messages", - "public_link_copied": "Link copied to clipboard", - "rate.button.needs_work": "Needs work", - "rate.button.yes": "Love it!", - "rate.dont_ask_again": "Don't ask me again", - "rate.error.text": "There has been an error while opening the review modal.", - "rate.error.title": "Error", - "rate.subtitle": "Let us know what you think.", - "rate.title": "Enjoying Mattermost?", - "saved_messages.empty.paragraph": "To save something for later, long-press on a message and choose Save from the menu. Saved messages are only visible to you.", - "saved_messages.empty.title": "No saved messages yet", - "screen.mentions.subtitle": "Messages you've been mentioned in", - "screen.mentions.title": "Recent Mentions", - "screen.saved_messages.subtitle": "All messages you've saved for follow up", - "screen.saved_messages.title": "Saved Messages", - "screen.search.header.files": "Files", - "screen.search.header.messages": "Messages", - "screen.search.modifier.header": "Search options", - "screen.search.placeholder": "Search messages & files", - "screen.search.results.file_options.copy_link": "Copy link", - "screen.search.results.file_options.download": "Download", - "screen.search.results.file_options.open_in_channel": "Open in channel", - "screen.search.results.filter.all_file_types": "All file types", - "screen.search.results.filter.audio": "Audio", - "screen.search.results.filter.code": "Code", - "screen.search.results.filter.documents": "Documents", - "screen.search.results.filter.images": "Images", - "screen.search.results.filter.presentations": "Presentations", - "screen.search.results.filter.spreadsheets": "Spreadsheets", - "screen.search.results.filter.title": "Filter by file type", - "screen.search.results.filter.videos": "Videos", - "screen.search.title": "Search", - "screens.channel_edit": "Edit Channel", - "screens.channel_edit_header": "Edit Channel Header", - "screens.channel_info": "Channel Info", - "screens.channel_info.dm": "Direct message info", - "screens.channel_info.gm": "Group message info", - "search_bar.search": "Search", - "search_bar.search.placeholder": "Search timezone", - "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.", - "select_team.no_team.title": "No teams are available to join", - "select_team.title": "Select a team", - "server_list.push_proxy_error": "Notifications cannot be received from this server because of its configuration. Contact your system admin.", - "server_list.push_proxy_unknown": "Notifications could not be received from this server because of its configuration. Log out and Log in again to retry.", - "server_upgrade.alert_description": "Your server, {serverDisplayName}, is running an unsupported server version. Users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {supportedServerVersion} or later is required.", - "server_upgrade.dismiss": "Dismiss", - "server_upgrade.learn_more": "Learn More", - "server.logout.alert_description": "All associated data will be removed", - "server.logout.alert_title": "Are you sure you want to log out of {displayName}?", - "server.remove.alert_description": "This will remove it from your list of servers. All associated data will be removed", - "server.remove.alert_title": "Are you sure you want to remove {displayName}?", - "server.tutorial.swipe": "Swipe left on a server to see more actions", - "server.websocket.unreachable": "Server is unreachable.", - "servers.create_button": "Add a server", - "servers.default": "Default Server", - "servers.edit": "Edit", - "servers.login": "Log in", - "servers.logout": "Log out", - "servers.remove": "Remove", - "settings_display.clock.mz": "24-hour clock", - "settings_display.clock.mz.desc": "Example: 16:00", - "settings_display.clock.normal.desc": "Example: 4:00 PM", - "settings_display.clock.standard": "12-hour clock", - "settings_display.crt.desc": "When enabled, reply messages are not shown in the channel and you'll be notified about threads you're following in the \"Threads\" view.", - "settings_display.crt.label": "Collapsed Reply Threads", - "settings_display.custom_theme": "Custom Theme", - "settings_display.timezone.automatically": "Set automatically", - "settings_display.timezone.manual": "Change timezone", - "settings_display.timezone.off": "Off", - "settings_display.timezone.select": "Select Timezone", - "settings.about": "About {appTitle}", - "settings.about.build": "{version} (Build {number})", - "settings.about.copyright": "Copyright 2015-{currentYear} Mattermost, Inc. All rights reserved", - "settings.about.database": "Database:", - "settings.about.database.schema": "Database Schema Version:", - "settings.about.licensed": "Licensed to: {company}", - "settings.about.powered_by": "{site} is powered by Mattermost", - "settings.about.server.version.desc": "Server Version:", - "settings.about.server.version.value": "{version} (Build {number})", - "settings.about.serverVersionNoBuild": "{version}", - "settings.about.version": "App Version:", - "settings.advanced_settings": "Advanced Settings", - "settings.advanced.cancel": "Cancel", - "settings.advanced.delete": "Delete", - "settings.advanced.delete_data": "Delete local files", - "settings.advanced.delete_message.confirmation": "\nThis will delete all files downloaded through the app for this server. Please confirm to proceed.\n", - "settings.display": "Display", - "settings.link.error.text": "Unable to open the link.", - "settings.link.error.title": "Error", - "settings.notice_mobile_link": "mobile apps", - "settings.notice_platform_link": "server", - "settings.notice_text": "Mattermost is made possible by the open source software used in our {platform} and {mobile}.", - "settings.notifications": "Notifications", - "settings.save": "Save", - "share_extension.channel_error": "You are not a member of a team on the selected server. Select another server or open Mattermost to join a team.", - "share_extension.channel_label": "Channel", - "share_extension.count_limit": "You can only share {count, number} {count, plural, one {file} other {files}} on this server", - "share_extension.file_limit.multiple": "Each file must be less than {size}", - "share_extension.file_limit.single": "File must be less than {size}", - "share_extension.max_resolution": "Image exceeds maximum dimensions of 7680 x 4320 px", - "share_extension.message": "Enter a message (optional)", - "share_extension.multiple_label": "{count, number} attachments", - "share_extension.server_label": "Server", - "share_extension.servers_screen.title": "Select server", - "share_extension.share_screen.title": "Share to Mattermost", - "share_extension.upload_disabled": "File uploads are disabled for the selected server", - "share_feedback.button.no": "No, thanks", - "share_feedback.button.yes": "Yes", - "share_feedback.subtitle": "We'd love to hear how we can make your experience better.", - "share_feedback.title": "Would you share your feedback?", - "skintone_selector.tooltip.description": "You can now choose the skin tone you prefer to use for your emojis.", - "skintone_selector.tooltip.title": "Choose your default skin tone", - "smobile.search.recent_title": "Recent searches in {teamName}", - "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", - "status_dropdown.set_online": "Online", - "status_dropdown.set_ooo": "Out Of Office", - "suggestion.mention.all": "Notifies everyone in this channel", - "suggestion.mention.channel": "Notifies everyone in this channel", - "suggestion.mention.channels": "My Channels", - "suggestion.mention.groups": "Group Mentions", - "suggestion.mention.here": "Notifies everyone online in this channel", - "suggestion.mention.members": "Channel Members", - "suggestion.mention.morechannels": "Other Channels", - "suggestion.mention.nonmembers": "Not in Channel", - "suggestion.mention.special": "Special Mentions", - "suggestion.mention.you": " (you)", - "suggestion.search.direct": "Direct Messages", - "suggestion.search.private": "Private Channels", - "suggestion.search.public": "Public Channels", - "team_list.no_other_teams.description": "To join another team, ask a Team Admin for an invitation, or create your own team.", - "team_list.no_other_teams.title": "No additional teams to join", - "terms_of_service.acceptButton": "Accept", - "terms_of_service.alert_cancel": "Cancel", - "terms_of_service.alert_retry": "Try Again", - "terms_of_service.api_error": "Unable to complete the request. If this issue persists, contact your System Administrator.", - "terms_of_service.decline": "Decline", - "terms_of_service.error.description": "It was not possible to get the Terms of Service from the Server.", - "terms_of_service.error.logout": "Logout", - "terms_of_service.error.retry": "Retry", - "terms_of_service.error.title": "Failed to get the ToS.", - "terms_of_service.terms_declined.ok": "OK", - "terms_of_service.terms_declined.text": "You must accept the terms of service to access this server. Please contact your system administrator for more details. You will now be logged out. Log in again to accept the terms of service.", - "terms_of_service.terms_declined.title": "You must accept the terms of service", - "terms_of_service.title": "Terms of Service", - "thread.header.thread": "Thread", - "thread.header.thread_in": "in {channelName}", - "thread.loadingReplies": "Loading replies...", - "thread.noReplies": "No replies yet", - "thread.options.title": "Thread Actions", - "thread.repliesCount": "{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}", - "threads": "Threads", - "threads.deleted": "Original Message Deleted", - "threads.end_of_list.subtitle": "If you're looking for older conversations, try searching instead", - "threads.end_of_list.title": "That's the end of the list!", - "threads.follow": "Follow", - "threads.following": "Following", - "threads.followMessage": "Follow Message", - "threads.followThread": "Follow Thread", - "threads.newReplies": "{count} new {count, plural, one {reply} other {replies}}", - "threads.replies": "{count} {count, plural, one {reply} other {replies}}", - "threads.unfollowMessage": "Unfollow Message", - "threads.unfollowThread": "Unfollow Thread", - "unreads.empty.paragraph": "Turn off the unread filter to show all your channels.", - "unreads.empty.show_all": "Show all", - "unreads.empty.title": "No more unreads", - "unsupported_server.message": "Your server, {serverDisplayName}, is running an unsupported server version. You may experience compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Please contact your System Administrator to upgrade your Mattermost server.", - "unsupported_server.title": "Unsupported server version", - "user_profile.custom_status": "Custom Status", - "user_status.away": "Away", - "user_status.dnd": "Do Not Disturb", - "user_status.offline": "Offline", - "user_status.online": "Online", - "user_status.title": "Status", - "user.edit_profile.email.auth_service": "Login occurs through {service}. Email cannot be updated. Email address used for notifications is {email}.", - "user.edit_profile.email.web_client": "Email must be updated using a web client or desktop application.", - "user.edit_profile.profile_photo.change_photo": "Change profile photo", - "user.settings.general.email": "Email", - "user.settings.general.field_handled_externally": "Some fields below are handled through your login provider. If you want to change them, you’ll need to do so through your login provider.", - "user.settings.general.firstName": "First Name", - "user.settings.general.lastName": "Last Name", - "user.settings.general.nickname": "Nickname", - "user.settings.general.position": "Position", - "user.settings.general.username": "Username", - "user.settings.notifications.email_threads.description": "Notify me about all replies to threads I'm following", - "user.tutorial.long_press": "Long-press on an item to view a user's profile", - "video.download": "Download video", - "video.download_description": "This video must be downloaded to play it.", - "video.failed_description": "An error occurred while trying to play the video.", - "your.servers": "Your servers" + "about.date": "Build Date:", + "about.enterpriseEditione1": "Enterprise Edition", + "about.enterpriseEditionLearn": "Learn more about Enterprise Edition at ", + "about.enterpriseEditionSt": "Modern communication from behind your firewall.", + "about.hash": "Build Hash:", + "about.hashee": "EE Build Hash:", + "about.teamEditionLearn": "Join the Mattermost community at", + "about.teamEditionSt": "All your team communication in one place, instantly searchable and accessible anywhere.", + "about.teamEditiont0": "Team Edition", + "about.teamEditiont1": "Enterprise Edition", + "account.logout": "Log out", + "account.logout_from": "Log out of {serverName}", + "account.settings": "Settings", + "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.", + "alert.push_proxy_unknown.title": "Notifications could not be received from this server", + "alert.push_proxy.button": "Okay", + "alert.removed_from_channel.description": "You have been removed from channel {displayName}.", + "alert.removed_from_channel.title": "Removed from channel", + "alert.removed_from_team.description": "You have been removed from team {displayName}.", + "alert.removed_from_team.title": "Removed from team", + "announcment_banner.dismiss": "Dismiss announcement", + "announcment_banner.okay": "Okay", + "api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.", + "api.channel.add_member.added": "{addedUsername} added to the channel by {username}.", + "api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.", + "apps.error": "Error: {error}", + "apps.error.command.field_missing": "Required fields missing: `{fieldName}`.", + "apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.", + "apps.error.command.same_option": "Option repeated for field `{fieldName}`: `{option}`.", + "apps.error.command.same_user": "User repeated for field `{fieldName}`: `{option}`.", + "apps.error.command.unknown_channel": "Unknown channel for field `{fieldName}`: `{option}`.", + "apps.error.command.unknown_option": "Unknown option for field `{fieldName}`: `{option}`.", + "apps.error.command.unknown_user": "Unknown user for field `{fieldName}`: `{option}`.", + "apps.error.form.no_form": "`form` is not defined.", + "apps.error.form.no_lookup": "`lookup` is not defined.", + "apps.error.form.no_source": "`source` is not defined.", + "apps.error.form.no_submit": "`submit` is not defined", + "apps.error.form.refresh": "There has been an error fetching the select fields. Contact the app developer. Details: {details}", + "apps.error.form.refresh_no_refresh": "Called refresh on no refresh field.", + "apps.error.form.submit.pretext": "There has been an error submitting the modal. Contact the app developer. Details: {details}", + "apps.error.lookup.error_preparing_request": "Error preparing lookup request: {errorMessage}", + "apps.error.malformed_binding": "This binding is not properly formed. Contact the App developer.", + "apps.error.parser": "Parsing error: {error}", + "apps.error.parser.empty_value": "Empty values are not allowed.", + "apps.error.parser.execute_non_leaf": "You must select a subcommand.", + "apps.error.parser.missing_binding": "Missing command bindings.", + "apps.error.parser.missing_field_value": "Field value is missing.", + "apps.error.parser.missing_list_end": "Expected list closing token.", + "apps.error.parser.missing_quote": "Matching double quote expected before end of input.", + "apps.error.parser.missing_source": "Form has neither submit nor source.", + "apps.error.parser.missing_submit": "No submit call in binding or form.", + "apps.error.parser.missing_tick": "Matching tick quote expected before end of input.", + "apps.error.parser.multiple_equal": "Multiple `=` signs are not allowed.", + "apps.error.parser.no_argument_pos_x": "Unable to identify argument.", + "apps.error.parser.no_bindings": "No command bindings.", + "apps.error.parser.no_form": "No form found.", + "apps.error.parser.no_match": "`{command}`: No matching command found in this workspace.", + "apps.error.parser.no_slash_start": "Command must start with a `/`.", + "apps.error.parser.unexpected_character": "Unexpected character.", + "apps.error.parser.unexpected_comma": "Unexpected comma.", + "apps.error.parser.unexpected_error": "Unexpected error.", + "apps.error.parser.unexpected_flag": "Command does not accept flag `{flagName}`.", + "apps.error.parser.unexpected_squared_bracket": "Unexpected list opening.", + "apps.error.parser.unexpected_state": "Unreachable: Unexpected state in matchBinding: `{state}`.", + "apps.error.parser.unexpected_whitespace": "Unreachable: Unexpected whitespace.", + "apps.error.responses.form.no_form": "Response type is `form`, but no form was included in the response.", + "apps.error.responses.navigate.no_url": "Response type is `navigate`, but no url was included in response.", + "apps.error.responses.unexpected_error": "Received an unexpected error.", + "apps.error.responses.unexpected_type": "App response type was not expected. Response type: {type}", + "apps.error.responses.unknown_field_error": "Received an error for an unknown field. Field name: `{field}`. Error: `{error}`.", + "apps.error.responses.unknown_type": "App response type not supported. Response type: {type}.", + "apps.error.unknown": "Unknown error occurred.", + "apps.suggestion.dynamic.error": "Dynamic select error", + "apps.suggestion.errors.parser_error": "Parsing error", + "apps.suggestion.no_dynamic": "No data was returned for dynamic suggestions", + "apps.suggestion.no_static": "No matching options.", + "apps.suggestion.no_suggestion": "No matching suggestions.", + "archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.", + "autocomplete_selector.unknown_channel": "Unknown channel", + "browse_channels.archivedChannels": "Archived Channels", + "browse_channels.dropdownTitle": "Show", + "browse_channels.noMore": "No more channels to join", + "browse_channels.publicChannels": "Public Channels", + "browse_channels.sharedChannels": "Shared Channels", + "browse_channels.showArchivedChannels": "Show: Archived Channels", + "browse_channels.showPublicChannels": "Show: Public Channels", + "browse_channels.showSharedChannels": "Show: Shared Channels", + "browse_channels.title": "Browse channels", + "camera_type.photo.option": "Capture Photo", + "camera_type.video.option": "Record Video", + "center_panel.archived.closeChannel": "Close Channel", + "channel_header.directchannel.you": "{displayName} (you)", + "channel_header.info": "View info", + "channel_header.member_count": "{count, plural, one {# member} other {# members}}", + "channel_info.alert_retry": "Try Again", + "channel_info.alertNo": "No", + "channel_info.alertYes": "Yes", + "channel_info.archive": "Archive Channel", + "channel_info.archive_description.can_view_archived": "This will archive the channel from the team. Channel contents will still be accessible by channel members.\n\nAre you sure you wish to archive the {term} {name}?", + "channel_info.archive_description.cannot_view_archived": "This will archive the channel from the team and remove it from the user interface. Archived channels can be unarchived if needed again.\n\nAre you sure you wish to archive the {term} {name}?", + "channel_info.archive_failed": "An error occurred trying to archive the channel {displayName}", + "channel_info.archive_title": "Archive {term}", + "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.convert_failed": "We were unable to convert {displayName} to a private channel.", + "channel_info.convert_private": "Convert to private channel", + "channel_info.convert_private_description": "When you convert {displayName} to a private channel, history and membership are preserved. Publicly shared files remain accessible to anyone with the link. Membership in a private channel is by invitation only.\n\nThe change is permanent and cannot be undone.\n\nAre you sure you want to convert {displayName} to a private channel?", + "channel_info.convert_private_success": "{displayName} is now a private channel.", + "channel_info.convert_private_title": "Convert {displayName} to a private channel?", + "channel_info.copied": "Copied", + "channel_info.copy_link": "Copy Link", + "channel_info.custom_status": "Custom status:", + "channel_info.edit_header": "Edit Header", + "channel_info.error_close": "Close", + "channel_info.favorite": "Favorite", + "channel_info.favorited": "Favorited", + "channel_info.header": "Header:", + "channel_info.ignore_mentions": "Ignore @channel, @here, @all", + "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.local_time": "Local Time", + "channel_info.members": "Members", + "channel_info.mention": "Mention", + "channel_info.mobile_notifications": "Mobile Notifications", + "channel_info.muted": "Mute", + "channel_info.nickname": "Nickname", + "channel_info.notification.all": "All", + "channel_info.notification.default": "Default", + "channel_info.notification.mention": "Mentions", + "channel_info.notification.none": "Never", + "channel_info.pinned_messages": "Pinned Messages", + "channel_info.position": "Position", + "channel_info.private_channel": "Private Channel", + "channel_info.public_channel": "Public Channel", + "channel_info.send_a_mesasge": "Send a message", + "channel_info.send_mesasge": "Send message", + "channel_info.set_header": "Set Header", + "channel_info.unarchive": "Unarchive Channel", + "channel_info.unarchive_description": "Are you sure you want to unarchive the {term} {name}?", + "channel_info.unarchive_failed": "An error occurred trying to unarchive the channel {displayName}", + "channel_info.unarchive_title": "Unarchive {term}", + "channel_intro.createdBy": "Created by {user} on {date}", + "channel_intro.createdOn": "Created on {date}", + "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.", + "channel_modal.header": "Header", + "channel_modal.headerEx": "Use Markdown to format header text", + "channel_modal.headerHelp": "Specify text to appear in the channel header beside the channel name. For example, include frequently used links by typing link text [Link Title](http://example.com).", + "channel_modal.makePrivate.description": "When a channel is set to private, only invited team members can access and participate in that channel", + "channel_modal.makePrivate.label": "Make Private", + "channel_modal.name": "Name", + "channel_modal.nameEx": "Bugs, Marketing", + "channel_modal.optional": "(optional)", + "channel_modal.purpose": "Purpose", + "channel_modal.purposeEx": "A channel to file bugs and improvements", + "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}.", + "combined_system_message.added_to_channel.two": "{firstUser} and {secondUser} **added to the channel** by {actor}.", + "combined_system_message.added_to_team.many_expanded": "{users} and {lastUser} were **added to the team** by {actor}.", + "combined_system_message.added_to_team.one": "{firstUser} **added to the team** by {actor}.", + "combined_system_message.added_to_team.one_you": "You were **added to the team** by {actor}.", + "combined_system_message.added_to_team.two": "{firstUser} and {secondUser} **added to the team** by {actor}.", + "combined_system_message.joined_channel.many_expanded": "{users} and {lastUser} **joined the channel**.", + "combined_system_message.joined_channel.one": "{firstUser} **joined the channel**.", + "combined_system_message.joined_channel.one_you": "You **joined the channel**.", + "combined_system_message.joined_channel.two": "{firstUser} and {secondUser} **joined the channel**.", + "combined_system_message.joined_team.many_expanded": "{users} and {lastUser} **joined the team**.", + "combined_system_message.joined_team.one": "{firstUser} **joined the team**.", + "combined_system_message.joined_team.one_you": "You **joined the team**.", + "combined_system_message.joined_team.two": "{firstUser} and {secondUser} **joined the team**.", + "combined_system_message.left_channel.many_expanded": "{users} and {lastUser} **left the channel**.", + "combined_system_message.left_channel.one": "{firstUser} **left the channel**.", + "combined_system_message.left_channel.one_you": "You **left the channel**.", + "combined_system_message.left_channel.two": "{firstUser} and {secondUser} **left the channel**.", + "combined_system_message.left_team.many_expanded": "{users} and {lastUser} **left the team**.", + "combined_system_message.left_team.one": "{firstUser} **left the team**.", + "combined_system_message.left_team.one_you": "You **left the team**.", + "combined_system_message.left_team.two": "{firstUser} and {secondUser} **left the team**.", + "combined_system_message.removed_from_channel.many_expanded": "{users} and {lastUser} were **removed from the channel**.", + "combined_system_message.removed_from_channel.one": "{firstUser} was **removed from the channel**.", + "combined_system_message.removed_from_channel.one_you": "You were **removed from the channel**.", + "combined_system_message.removed_from_channel.two": "{firstUser} and {secondUser} were **removed from the channel**.", + "combined_system_message.removed_from_team.many_expanded": "{users} and {lastUser} were **removed from the team**.", + "combined_system_message.removed_from_team.one": "{firstUser} was **removed from the team**.", + "combined_system_message.removed_from_team.one_you": "You were **removed from the team**.", + "combined_system_message.removed_from_team.two": "{firstUser} and {secondUser} were **removed from the team**.", + "combined_system_message.you": "You", + "connection_banner.connected": "Connection restored", + "connection_banner.connecting": "Connecting...", + "connection_banner.not_connected": "No internet connection", + "connection_banner.not_reachable": "The server is not reachable", + "create_direct_message.title": "Create Direct Message", + "create_post.deactivated": "You are viewing an archived channel with a deactivated user.", + "create_post.thread_reply": "Reply to this thread...", + "create_post.write": "Write to {channelDisplayName}", + "custom_status.expiry_dropdown.custom": "Custom", + "custom_status.expiry_dropdown.date_and_time": "Date and Time", + "custom_status.expiry_dropdown.dont_clear": "Don't clear", + "custom_status.expiry_dropdown.four_hours": "4 hours", + "custom_status.expiry_dropdown.one_hour": "1 hour", + "custom_status.expiry_dropdown.thirty_minutes": "30 minutes", + "custom_status.expiry_dropdown.this_week": "This week", + "custom_status.expiry_dropdown.today": "Today", + "custom_status.expiry_time.today": "Today", + "custom_status.expiry_time.tomorrow": "Tomorrow", + "custom_status.expiry.at": "at", + "custom_status.expiry.until": "Until", + "custom_status.failure_message": "Failed to update status. Try again", + "custom_status.set_status": "Set a custom status", + "custom_status.suggestions.in_a_meeting": "In a meeting", + "custom_status.suggestions.on_a_vacation": "On a vacation", + "custom_status.suggestions.out_for_lunch": "Out for lunch", + "custom_status.suggestions.out_sick": "Out sick", + "custom_status.suggestions.recent_title": "Recent", + "custom_status.suggestions.title": "Suggestions", + "custom_status.suggestions.working_from_home": "Working from home", + "date_separator.today": "Today", + "date_separator.yesterday": "Yesterday", + "default_skin_tone": "Default Skin Tone", + "display_settings.clock.military": "24-hour", + "display_settings.clock.standard": "12-hour", + "display_settings.clockDisplay": "Clock Display", + "display_settings.crt": "Collapsed Reply Threads", + "display_settings.crt.off": "Off", + "display_settings.crt.on": "On", + "display_settings.theme": "Theme", + "display_settings.timezone": "Timezone", + "display_settings.tz.auto": "Auto", + "display_settings.tz.manual": "Manual", + "download.error": "Unable to download the file. Try again later", + "edit_post.editPost": "Edit the post...", + "edit_post.save": "Save", + "edit_server.description": "Specify a display name for this server", + "edit_server.display_help": "Server: {url}", + "edit_server.save": "Save", + "edit_server.saving": "Saving", + "edit_server.title": "Edit server name", + "emoji_picker.activities": "Activities", + "emoji_picker.animals-nature": "Animals & Nature", + "emoji_picker.custom": "Custom", + "emoji_picker.flags": "Flags", + "emoji_picker.food-drink": "Food & Drink", + "emoji_picker.objects": "Objects", + "emoji_picker.people-body": "People & Body", + "emoji_picker.recent": "Recently Used", + "emoji_picker.searchResults": "Search Results", + "emoji_picker.smileys-emotion": "Smileys & Emotion", + "emoji_picker.symbols": "Symbols", + "emoji_picker.travel-places": "Travel & Places", + "emoji_skin.dark_skin_tone": "dark skin tone", + "emoji_skin.default": "default skin tone", + "emoji_skin.light_skin_tone": "light skin tone", + "emoji_skin.medium_dark_skin_tone": "medium dark skin tone", + "emoji_skin.medium_light_skin_tone": "medium light skin tone", + "emoji_skin.medium_skin_tone": "medium skin tone", + "extension.no_memberships.description": "To share content, you'll need to be a member of a team on a Mattermost server.", + "extension.no_memberships.title": "Not a member of any team yet", + "extension.no_servers.description": "To share content, you'll need to be logged in to a Mattermost server.", + "extension.no_servers.title": "Not connected to any servers", + "file_upload.fileAbove": "Files must be less than {max}", + "find_channels.directory": "Directory", + "find_channels.new_channel": "New Channel", + "find_channels.open_dm": "Open a DM", + "find_channels.title": "Find Channels", + "friendly_date.daysAgo": "{count} {count, plural, one {day} other {days}} ago", + "friendly_date.hoursAgo": "{count} {count, plural, one {hour} other {hours}} ago", + "friendly_date.minsAgo": "{count} {count, plural, one {min} other {mins}} ago", + "friendly_date.monthsAgo": "{count} {count, plural, one {month} other {months}} ago", + "friendly_date.now": "Now", + "friendly_date.yearsAgo": "{count} {count, plural, one {year} other {years}} ago", + "friendly_date.yesterday": "Yesterday", + "gallery.copy_link.failed": "Failed to copy link to clipboard", + "gallery.downloading": "Downloading...", + "gallery.footer.channel_name": "Shared in {channelName}", + "gallery.image_saved": "Image saved", + "gallery.open_file": "Open file", + "gallery.opening": "Opening...", + "gallery.preparing": "Preparing...", + "gallery.save_failed": "Unable to save the file", + "gallery.unsupported": "Preview isn't supported for this file type. Try downloading or sharing to open it in another app.", + "gallery.video_saved": "Video saved", + "general_settings.about": "About {appTitle}", + "general_settings.advanced_settings": "Advanced Settings", + "general_settings.display": "Display", + "general_settings.help": "Help", + "general_settings.notifications": "Notifications", + "general_settings.report_problem": "Report a problem", + "get_post_link_modal.title": "Copy Link", + "global_threads.allThreads": "All Your Threads", + "global_threads.emptyThreads.message": "Any threads you are mentioned in or have participated in will show here along with any threads you have followed.", + "global_threads.emptyThreads.title": "No followed threads yet", + "global_threads.emptyUnreads.message": "Looks like you're all caught up.", + "global_threads.emptyUnreads.title": "No unread threads", + "global_threads.markAllRead.cancel": "Cancel", + "global_threads.markAllRead.markRead": "Mark read", + "global_threads.markAllRead.message": "This will clear any unread status for all of your threads shown here", + "global_threads.markAllRead.title": "Are you sure you want to mark all threads as read?", + "global_threads.options.mark_as_read": "Mark as Read", + "global_threads.options.open_in_channel": "Open in Channel", + "global_threads.options.title": "Thread Actions", + "global_threads.unreads": "Unreads", + "home.header.plus_menu": "Options", + "integration_selector.multiselect.submit": "Done", + "interactive_dialog.submit": "Submit", + "intro.add_people": "Add People", + "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.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.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.", + "intro.welcome.public": "Add some more team members to the channel or start a conversation below.", + "invite_people_to_team.message": "Here’s a link to collaborate and communicate with us on Mattermost.", + "invite_people_to_team.title": "Join the {team} team", + "invite.members.already_member": "This person is already a team member", + "invite.members.user_is_guest": "Contact your admin to make this guest a full member", + "invite.search.email_invite": "invite", + "invite.search.no_results": "No one found matching", + "invite.searchPlaceholder": "Type a name or email address…", + "invite.send_error": "Something went wrong while trying to send invitations. Please check your network connection and try again.", + "invite.send_invite": "Send", + "invite.sendInvitationsTo": "Send invitations to…", + "invite.shareLink": "Share link", + "invite.summary.done": "Done", + "invite.summary.email_invite": "An invitation email has been sent", + "invite.summary.error": "{invitationsCount, plural, one {Invitation} other {Invitations}} could not be sent successfully", + "invite.summary.member_invite": "Invited as a member of {teamDisplayName}", + "invite.summary.not_sent": "{notSentCount, plural, one {Invitation wasn’t} other {Invitations weren’t}} sent", + "invite.summary.report.notSent": "{count} {count, plural, one {invitation} other {invitations}} not sent", + "invite.summary.report.sent": "{count} successful {count, plural, one {invitation} other {invitations}}", + "invite.summary.sent": "Your {sentCount, plural, one {invitation has} other {invitations have}} been sent", + "invite.summary.smtp_failure": "SMTP is not configured in System Console", + "invite.summary.some_not_sent": "{notSentCount, plural, one {An invitation was} other {Some invitations were}} not sent", + "invite.summary.try_again": "Try again", + "invite.title": "Invite", + "invite.title.summary": "Invite summary", + "join_team.error.group_error": "You need to be a member of a linked group to join this team.", + "join_team.error.message": "There has been an error joining the team", + "join_team.error.title": "Error joining a team", + "last_users_message.added_to_channel.type": "were **added to the channel** by {actor}.", + "last_users_message.added_to_team.type": "were **added to the team** by {actor}.", + "last_users_message.first": "{firstUser} and ", + "last_users_message.joined_channel.type": "**joined the channel**.", + "last_users_message.joined_team.type": "**joined the team**.", + "last_users_message.left_channel.type": "**left the channel**.", + "last_users_message.left_team.type": "**left the team**.", + "last_users_message.others": "{numOthers} others ", + "last_users_message.removed_from_channel.type": "were **removed from the channel**.", + "last_users_message.removed_from_team.type": "were **removed from the team**.", + "load_categories_error.message": "There was a problem loading content for this server.", + "load_categories_error.title": "Couldn't load categories for {serverName}", + "load_channels_error.message": "There was a problem loading content for this team.", + "load_channels_error.title": "Couldn't load {teamDisplayName}", + "load_teams_error.message": "There was a problem loading content for this server.", + "load_teams_error.title": "Couldn't load {serverName}", + "login_mfa.enterToken": "To complete the sign in process, please enter the code from your mobile device's authenticator app.", + "login_mfa.token": "Enter MFA Token", + "login_mfa.tokenReq": "Please enter an MFA token", + "login.email": "Email", + "login.forgot": "Forgot your password?", + "login.invalid_credentials": "The email and password combination is incorrect", + "login.ldapUsername": "AD/LDAP Username", + "login.or": "or", + "login.password": "Password", + "login.signIn": "Log In", + "login.signingIn": "Logging In", + "login.username": "Username", + "markdown.latex.error": "Latex render error", + "mentions.empty.paragraph": "You'll see messages here when someone mentions you or uses terms you're monitoring.", + "mentions.empty.title": "No Mentions yet", + "mobile.about.appVersion": "App Version: {version} (Build {number})", + "mobile.account.settings.save": "Save", + "mobile.action_menu.select": "Select an option", + "mobile.add_team.create_team": "Create a new team", + "mobile.add_team.join_team": "Join Another Team", + "mobile.android.back_handler_exit": "Press back again to exit", + "mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.", + "mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos", + "mobile.announcement_banner.title": "Announcement", + "mobile.calls_call_ended": "Call ended", + "mobile.calls_call_screen": "Call", + "mobile.calls_call_thread": "Call Thread", + "mobile.calls_current_call": "Current call", + "mobile.calls_disable": "Disable calls", + "mobile.calls_dismiss": "Dismiss", + "mobile.calls_enable": "Enable calls", + "mobile.calls_end_call_title": "End call", + "mobile.calls_end_msg_channel": "Are you sure you want to end a call with {numParticipants} participants in {displayName}?", + "mobile.calls_end_msg_channel_default": "Are you sure you want to end the call?", + "mobile.calls_end_msg_dm": "Are you sure you want to end a call with {displayName}?", + "mobile.calls_end_permission_msg": "You don't have permission to end the call. Please ask the call creator to end the call.", + "mobile.calls_end_permission_title": "Error", + "mobile.calls_ended_at": "Ended at", + "mobile.calls_error_message": "Error: {error}", + "mobile.calls_error_title": "Error", + "mobile.calls_host": "host", + "mobile.calls_host_rec": "You are recording this meeting. Consider letting everyone know that this meeting is being recorded.", + "mobile.calls_host_rec_stopped": "You can find the recording in this call's chat thread once it's finished processing.", + "mobile.calls_host_rec_stopped_title": "Recording has stopped. Processing...", + "mobile.calls_host_rec_title": "You are recording", + "mobile.calls_join_call": "Join call", + "mobile.calls_lasted": "Lasted {duration}", + "mobile.calls_leave": "Leave", + "mobile.calls_leave_call": "Leave call", + "mobile.calls_limit_msg": "The maximum number of participants per call is {maxParticipants}. Contact your System Admin to increase the limit.", + "mobile.calls_limit_msg_GA": "Upgrade to Cloud Professional or Cloud Enterprise to enable group calls with more than {maxParticipants} participants.", + "mobile.calls_limit_reached": "Participant limit reached", + "mobile.calls_lower_hand": "Lower hand", + "mobile.calls_mic_error": "To participate, open Settings to grant Mattermost access to your microphone.", + "mobile.calls_more": "More", + "mobile.calls_mute": "Mute", + "mobile.calls_name_is_talking": "{name} is talking", + "mobile.calls_name_started_call": "{name} started a call", + "mobile.calls_noone_talking": "No one is talking", + "mobile.calls_not_available_msg": "Please contact your System Admin to enable the feature.", + "mobile.calls_not_available_option": "(Not available)", + "mobile.calls_not_available_title": "Calls is not enabled", + "mobile.calls_not_connected": "You're not connected to a call in the current channel.", + "mobile.calls_ok": "OK", + "mobile.calls_okay": "Okay", + "mobile.calls_open_channel": "Open Channel", + "mobile.calls_participant_limit_title_GA": "This call is at capacity", + "mobile.calls_participant_rec": "The host has started recording this meeting. By staying in the meeting you give consent to being recorded.", + "mobile.calls_participant_rec_title": "Recording is in progress", + "mobile.calls_raise_hand": "Raise hand", + "mobile.calls_react": "React", + "mobile.calls_rec": "rec", + "mobile.calls_record": "Record", + "mobile.calls_request_message": "Calls are currently running in test mode and only system admins can start them. Reach out directly to your system admin for assistance", + "mobile.calls_request_title": "Calls is not currently enabled", + "mobile.calls_see_logs": "See server logs", + "mobile.calls_speaker": "Speaker", + "mobile.calls_start_call": "Start Call", + "mobile.calls_start_call_exists": "A call is already ongoing in the channel.", + "mobile.calls_stop_recording": "Stop Recording", + "mobile.calls_unmute": "Unmute", + "mobile.calls_viewing_screen": "You are viewing {name}'s screen", + "mobile.calls_you": "(you)", + "mobile.camera_photo_permission_denied_description": "Take photos and upload them to your server or save them to your device. Open Settings to grant {applicationName} read and write access to your camera.", + "mobile.camera_photo_permission_denied_title": "{applicationName} would like to access your camera", + "mobile.camera_type.title": "Camera options", + "mobile.channel_info.alertNo": "No", + "mobile.channel_info.alertYes": "Yes", + "mobile.channel_list.recent": "Recent", + "mobile.channel_list.unreads": "Unreads", + "mobile.commands.error_title": "Error Executing Command", + "mobile.components.select_server_view.connect": "Connect", + "mobile.components.select_server_view.connecting": "Connecting", + "mobile.components.select_server_view.displayHelp": "Choose a display name for your server", + "mobile.components.select_server_view.displayName": "Display Name", + "mobile.components.select_server_view.enterServerUrl": "Enter Server URL", + "mobile.components.select_server_view.msg_connect": "Let’s Connect to a Server", + "mobile.components.select_server_view.msg_description": "A Server is your team's communication hub which is accessed through a unique URL", + "mobile.components.select_server_view.msg_welcome": "Welcome", + "mobile.components.select_server_view.proceed": "Proceed", + "mobile.create_channel": "Create", + "mobile.create_channel.title": "New channel", + "mobile.create_direct_message.max_limit_reached": "Group messages are limited to {maxCount} members", + "mobile.create_direct_message.start": "Start Conversation", + "mobile.create_direct_message.you": "@{username} - you", + "mobile.create_post.read_only": "This channel is read-only.", + "mobile.custom_list.no_results": "No Results", + "mobile.custom_status.choose_emoji": "Choose an emoji", + "mobile.custom_status.clear_after": "Clear After", + "mobile.custom_status.clear_after.title": "Clear Custom Status After", + "mobile.custom_status.modal_confirm": "Done", + "mobile.direct_message.error": "We couldn't open a DM with {displayName}.", + "mobile.display_settings.clockDisplay": "Clock Display", + "mobile.display_settings.crt": "Collapsed Reply Threads", + "mobile.display_settings.theme": "Theme", + "mobile.display_settings.timezone": "Timezone", + "mobile.document_preview.failed_description": "An error occurred while opening the document. Please make sure you have a {fileType} viewer installed and try again.\n", + "mobile.document_preview.failed_title": "Open Document failed", + "mobile.downloader.disabled_description": "File downloads are disabled on this server. Please contact your System Admin for more details.\n", + "mobile.downloader.disabled_title": "Download disabled", + "mobile.downloader.failed_description": "An error occurred while downloading the file. Please check your internet connection and try again.\n", + "mobile.downloader.failed_title": "Download failed", + "mobile.edit_channel": "Save", + "mobile.edit_post.delete_question": "Are you sure you want to delete this Post?", + "mobile.edit_post.delete_title": "Confirm Post Delete", + "mobile.edit_post.error": "There was a problem editing this message. Please try again.", + "mobile.edit_post.title": "Editing Message", + "mobile.error_handler.button": "Relaunch", + "mobile.error_handler.description": "\nTap relaunch to open the app again. After restart, you can report the problem from the settings menu.\n", + "mobile.error_handler.title": "Unexpected error occurred", + "mobile.file_upload.disabled2": "File uploads from mobile are disabled.", + "mobile.file_upload.max_warning": "Uploads limited to {count} files maximum.", + "mobile.files_paste.error_description": "An error occurred while pasting the file(s). Please try again.", + "mobile.files_paste.error_dismiss": "Dismiss", + "mobile.files_paste.error_title": "Paste failed", + "mobile.gallery.title": "{index} of {total}", + "mobile.integration_selector.loading_channels": "Loading channels...", + "mobile.integration_selector.loading_options": "Loading options...", + "mobile.ios.photos_permission_denied_description": "Upload photos and videos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo and video library.", + "mobile.ios.photos_permission_denied_title": "{applicationName} would like to access your photos", + "mobile.join_channel.error": "We couldn't join the channel {displayName}.", + "mobile.leave_and_join_confirmation": "Leave & Join", + "mobile.leave_and_join_message": "You are already on a channel call in ~{leaveChannelName}. Do you want to leave your current call and join the call in ~{joinChannelName}?", + "mobile.leave_and_join_title": "Are you sure you want to switch to a different call?", + "mobile.link.error.text": "Unable to open the link.", + "mobile.link.error.title": "Error", + "mobile.login_options.cant_heading": "Can't Log In", + "mobile.login_options.enter_credentials": "Enter your login details below.", + "mobile.login_options.gitlab": "GitLab", + "mobile.login_options.google": "Google", + "mobile.login_options.heading": "Log In to Your Account", + "mobile.login_options.none": "You can't log in to your account yet. At least one login option must be configured. Contact your System Admin for assistance.", + "mobile.login_options.office365": "Office 365", + "mobile.login_options.openid": "Open ID", + "mobile.login_options.saml": "SAML", + "mobile.login_options.select_option": "Select a login option below.", + "mobile.login_options.separator_text": "or log in with", + "mobile.login_options.sso_continue": "Continue with", + "mobile.manage_members.admin": "Admin", + "mobile.manage_members.cancel": "Cancel", + "mobile.manage_members.change_role.error": "An error occurred while trying to update the role. Please check your connection and try again.", + "mobile.manage_members.done": "Done", + "mobile.manage_members.make_channel_admin": "Make Channel Admin", + "mobile.manage_members.make_channel_member": "Make Channel Member", + "mobile.manage_members.manage": "Manage", + "mobile.manage_members.manage_member": "Manage member", + "mobile.manage_members.member": "Member", + "mobile.manage_members.message": "Are you sure you want to remove the selected member from the channel?", + "mobile.manage_members.remove": "Remove", + "mobile.manage_members.remove_member": "Remove from Channel", + "mobile.manage_members.section_title_admins": "CHANNEL ADMINS", + "mobile.manage_members.section_title_members": "MEMBERS", + "mobile.managed.blocked_by": "Blocked by {vendor}", + "mobile.managed.exit": "Exit", + "mobile.managed.jailbreak": "Jailbroken devices are not trusted by {vendor}.\n\nReason {reason}\n\n\n\nDebug info: {debug}\n\nPlease exit the app.", + "mobile.managed.jailbreak_no_debug_info": "Not available", + "mobile.managed.jailbreak_no_reason": "Not available", + "mobile.managed.not_secured.android": "This device must be secured with a screen lock to use Mattermost.", + "mobile.managed.not_secured.ios": "This device must be secured with a passcode to use Mattermost.\n\nGo to Settings > Face ID & Passcode.", + "mobile.managed.not_secured.ios.touchId": "This device must be secured with a passcode to use Mattermost.\n\nGo to Settings > Touch ID & Passcode.", + "mobile.managed.secured_by": "Secured by {vendor}", + "mobile.managed.settings": "Go to settings", + "mobile.markdown.code.copy_code": "Copy Code", + "mobile.markdown.code.plusMoreLines": "+{count, number} more {count, plural, one {line} other {lines}}", + "mobile.markdown.image.too_large": "Image exceeds max dimensions of {maxWidth} by {maxHeight}:", + "mobile.markdown.link.copy_url": "Copy URL", + "mobile.mention.copy_mention": "Copy Mention", + "mobile.message_length.message": "Your current message is too long. Current character count: {count}/{max}", + "mobile.message_length.message_split_left": "Message exceeds the character limit", + "mobile.message_length.title": "Message Length", + "mobile.no_results_with_term": "No results for “{term}”", + "mobile.no_results_with_term.files": "No files matching “{term}”", + "mobile.no_results_with_term.messages": "No matches found for “{term}”", + "mobile.no_results.spelling": "Check the spelling or try another search.", + "mobile.oauth.failed_to_login": "Your login attempt failed. Please try again.", + "mobile.oauth.failed_to_open_link": "The link failed to open. Please try again.", + "mobile.oauth.failed_to_open_link_no_browser": "The link failed to open. Please verify that a browser is installed on the device.", + "mobile.oauth.something_wrong": "Something went wrong", + "mobile.oauth.something_wrong.okButton": "OK", + "mobile.oauth.switch_to_browser": "You are being redirected to your login provider", + "mobile.oauth.switch_to_browser.error_title": "Sign in error", + "mobile.oauth.switch_to_browser.title": "Redirecting...", + "mobile.oauth.try_again": "Try again", + "mobile.onboarding.next": "Next", + "mobile.onboarding.sign_in": "Sign in", + "mobile.onboarding.sign_in_to_get_started": "Sign in to get started", + "mobile.open_dm.error": "We couldn't open a direct message with {displayName}. Please check your connection and try again.", + "mobile.open_gm.error": "We couldn't open a group message with those users. Please check your connection and try again.", + "mobile.participants.header": "Thread Participants", + "mobile.permission_denied_dismiss": "Don't Allow", + "mobile.permission_denied_retry": "Settings", + "mobile.post_info.add_reaction": "Add Reaction", + "mobile.post_info.copy_text": "Copy Text", + "mobile.post_info.mark_unread": "Mark as Unread", + "mobile.post_info.pin": "Pin to Channel", + "mobile.post_info.reply": "Reply", + "mobile.post_info.save": "Save", + "mobile.post_info.unpin": "Unpin from Channel", + "mobile.post_info.unsave": "Unsave", + "mobile.post_pre_header.pinned": "Pinned", + "mobile.post_pre_header.pinned_saved": "Pinned and Saved", + "mobile.post_pre_header.saved": "Saved", + "mobile.post_textbox.entire_channel_here.message": "By using @here you are about to send notifications to up to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?", + "mobile.post_textbox.entire_channel_here.message.with_timezones": "By using @here you are about to send notifications up to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", + "mobile.post_textbox.entire_channel.cancel": "Cancel", + "mobile.post_textbox.entire_channel.confirm": "Confirm", + "mobile.post_textbox.entire_channel.message": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?", + "mobile.post_textbox.entire_channel.message.with_timezones": "By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?", + "mobile.post_textbox.entire_channel.title": "Confirm sending notifications to entire channel", + "mobile.post_textbox.groups.title": "Confirm sending notifications to groups", + "mobile.post_textbox.uploadFailedDesc": "Some attachments failed to upload to the server. Are you sure you want to post the message?", + "mobile.post_textbox.uploadFailedTitle": "Attachment failure", + "mobile.post.cancel": "Cancel", + "mobile.post.delete_question": "Are you sure you want to delete this post?", + "mobile.post.delete_title": "Delete Post", + "mobile.post.failed_delete": "Delete Message", + "mobile.post.failed_retry": "Try Again", + "mobile.privacy_link": "Privacy Policy", + "mobile.push_notification_reply.button": "Send", + "mobile.push_notification_reply.placeholder": "Write a reply...", + "mobile.push_notification_reply.title": "Reply", + "mobile.rename_channel.display_name_maxLength": "Channel name must be less than {maxLength, number} characters", + "mobile.rename_channel.display_name_minLength": "Channel name must be {minLength, number} or more characters", + "mobile.rename_channel.display_name_required": "Channel name is required", + "mobile.request.invalid_request_method": "Invalid request method", + "mobile.request.invalid_response": "Received invalid response from the server.", + "mobile.reset_status.alert_cancel": "Cancel", + "mobile.reset_status.alert_ok": "OK", + "mobile.reset_status.title_ooo": "Disable \"Out Of Office\"?", + "mobile.routes.code": "{language} Code", + "mobile.routes.code.noLanguage": "Code", + "mobile.routes.custom_status": "Set a custom status", + "mobile.routes.table": "Table", + "mobile.routes.user_profile": "Profile", + "mobile.screen.settings": "Settings", + "mobile.screen.your_profile": "Your Profile", + "mobile.search.jump": "Jump to recent messages", + "mobile.search.modifier.exclude": "exclude search terms", + "mobile.search.modifier.from": "a specific user", + "mobile.search.modifier.in": "a specific channel", + "mobile.search.modifier.phrases": "messages with phrases", + "mobile.search.show_less": "Show less", + "mobile.search.show_more": "Show more", + "mobile.search.team.select": "Select a team to search", + "mobile.server_identifier.exists": "You are already connected to this server.", + "mobile.server_link.error.text": "The link could not be found on this server.", + "mobile.server_link.error.title": "Link Error", + "mobile.server_link.unreachable_channel.error": "This link belongs to a deleted channel or to a channel to which you do not have access.", + "mobile.server_link.unreachable_team.error": "This link belongs to a deleted team or to a team to which you do not have access.", + "mobile.server_link.unreachable_user.error": "We can't redirect you to the DM. The user specified is unknown.", + "mobile.server_name.exists": "You are using this name for another server.", + "mobile.server_ping_failed": "Cannot connect to the server.", + "mobile.server_requires_client_certificate": "Server requires client certificate for authentication.", + "mobile.server_upgrade.button": "OK", + "mobile.server_upgrade.description": "\nA server upgrade is required to use the Mattermost app. Please ask your System Administrator for details.\n", + "mobile.server_upgrade.title": "Server upgrade required", + "mobile.server_url.deeplink.emm.denied": "This app is controlled by an EMM and the DeepLink server url does not match the EMM allowed server", + "mobile.server_url.empty": "Please enter a valid server URL", + "mobile.server_url.invalid_format": "URL must start with http:// or https://", + "mobile.session_expired": "Please log in to continue receiving notifications. Sessions for {siteName} are configured to expire every {daysCount, number} {daysCount, plural, one {day} other {days}}.", + "mobile.session_expired.title": "Session Expired", + "mobile.set_status.dnd": "Do Not Disturb", + "mobile.storage_permission_denied_description": "Upload files to your server. Open Settings to grant {applicationName} Read and Write access to files on this device.", + "mobile.storage_permission_denied_title": "{applicationName} would like to access your files", + "mobile.suggestion.members": "Members", + "mobile.system_message.channel_archived_message": "{username} archived the channel", + "mobile.system_message.channel_unarchived_message": "{username} unarchived the channel", + "mobile.system_message.update_channel_displayname_message_and_forget.updated_from": "{username} updated the channel display name from: {oldDisplayName} to: {newDisplayName}", + "mobile.system_message.update_channel_header_message_and_forget.removed": "{username} removed the channel header (was: {oldHeader})", + "mobile.system_message.update_channel_header_message_and_forget.updated_from": "{username} updated the channel header from: {oldHeader} to: {newHeader}", + "mobile.system_message.update_channel_header_message_and_forget.updated_to": "{username} updated the channel header to: {newHeader}", + "mobile.system_message.update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {oldPurpose})", + "mobile.system_message.update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {oldPurpose} to: {newPurpose}", + "mobile.system_message.update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {newPurpose}", + "mobile.tos_link": "Terms of Service", + "mobile.user_list.deactivated": "Deactivated", + "mobile.write_storage_permission_denied_description": "Save files to your device. Open Settings to grant {applicationName} write access to files on this device.", + "modal.manual_status.auto_responder.message_": "Would you like to switch your status to \"{status}\" and disable Automatic Replies?", + "modal.manual_status.auto_responder.message_away": "Would you like to switch your status to \"Away\" and disable Automatic Replies?", + "modal.manual_status.auto_responder.message_dnd": "Would you like to switch your status to \"Do Not Disturb\" and disable Automatic Replies?", + "modal.manual_status.auto_responder.message_offline": "Would you like to switch your status to \"Offline\" and disable Automatic Replies?", + "modal.manual_status.auto_responder.message_online": "Would you like to switch your status to \"Online\" and disable Automatic Replies?", + "more_messages.text": "{count} new {count, plural, one {message} other {messages}}", + "msg_typing.areTyping": "{users} and {last} are typing...", + "msg_typing.isTyping": "{user} is typing...", + "notification_settings.auto_responder": "Automatic Replies", + "notification_settings.auto_responder.default_message": "Hello, I am out of office and unable to respond to messages.", + "notification_settings.auto_responder.footer.message": "Set a custom message that is automatically sent in response to direct messages, such as an out of office or vacation reply. Enabling this setting changes your status to Out of Office and disables notifications.", + "notification_settings.auto_responder.message": "Message", + "notification_settings.auto_responder.to.enable": "Enable automatic replies", + "notification_settings.email": "Email Notifications", + "notification_settings.email.crt.emailInfo": "When enabled, any reply to a thread you're following will send an email notification", + "notification_settings.email.crt.send": "Thread reply notifications", + "notification_settings.email.emailHelp2": "Email has been disabled by your System Administrator. No notification emails will be sent until it is enabled.", + "notification_settings.email.emailInfo": "Email notifications are sent for mentions and direct messages when you are offline or away for more than 5 minutes.", + "notification_settings.email.everyHour": "Every hour", + "notification_settings.email.fifteenMinutes": "Every 15 minutes", + "notification_settings.email.immediately": "Immediately", + "notification_settings.email.never": "Never", + "notification_settings.email.send": "Send email notifications", + "notification_settings.mention.reply": "Send reply notifications for", + "notification_settings.mentions": "Mentions", + "notification_settings.mentions_replies": "Mentions and Replies", + "notification_settings.mentions..keywordsDescription": "Other words that trigger a mention", + "notification_settings.mentions.channelWide": "Channel-wide mentions", + "notification_settings.mentions.keywords": "Keywords", + "notification_settings.mentions.keywords_mention": "Keywords that trigger mentions", + "notification_settings.mentions.keywordsLabel": "Keywords are not case-sensitive. Separate keywords with commas.", + "notification_settings.mentions.sensitiveName": "Your case sensitive first name", + "notification_settings.mentions.sensitiveUsername": "Your non-case sensitive username", + "notification_settings.mobile": "Push Notifications", + "notification_settings.mobile.away": "Away or offline", + "notification_settings.mobile.offline": "Offline", + "notification_settings.mobile.online": "Online, away or offline", + "notification_settings.mobile.trigger_push": "Trigger push notifications when...", + "notification_settings.ooo_auto_responder": "Automatic replies", + "notification_settings.push_notification": "Push Notifications", + "notification_settings.push_threads.following": "Notify me about replies to threads I'm following in this channel", + "notification_settings.push_threads.replies": "Thread replies", + "notification_settings.pushNotification.all_new_messages": "All new messages", + "notification_settings.pushNotification.disabled_long": "Push notifications for mobile devices have been disabled by your System Administrator.", + "notification_settings.pushNotification.mentions_only": "Mentions, direct messages only (default)", + "notification_settings.pushNotification.nothing": "Nothing", + "notification_settings.send_notification.about": "Notify me about...", + "notification_settings.threads_mentions": "Mentions in threads", + "notification_settings.threads_start": "Threads that I start", + "notification_settings.threads_start_participate": "Threads that I start or participate in", + "notification.message_not_found": "Message not found", + "notification.not_channel_member": "This message belongs to a channel where you are not a member.", + "notification.not_team_member": "This message belongs to a team where you are not a member.", + "onboarding.calls": "Start secure audio calls instantly", + "onboarding.calls_description": "When typing isn’t fast enough, switch from channel-based chat to secure audio calls with a single tap.", + "onboarding.integrations": "Integrate with tools you love", + "onboarding.integrations_description": "Go beyond chat with tightly-integrated product solutions matched to common development processes.", + "onboarding.realtime_collaboration": "Collaborate in real‑time", + "onboarding.realtime_collaboration_description": "Persistent channels, direct messaging, and file sharing works seamlessly so you can stay connected, wherever you are.", + "onboarding.welcome": "Welcome", + "onboaring.welcome_description": "Mattermost is an open source platform for developer collaboration. Secure, flexible, and integrated with your tools.", + "password_send.description": "To reset your password, enter the email address you used to sign up", + "password_send.error": "Please enter a valid email address.", + "password_send.generic_error": "We were unable to send you a reset password link. Please contact your System Admin for assistance.", + "password_send.link": "If the account exists, a password reset email will be sent to:", + "password_send.link.title": "Reset Link Sent", + "password_send.reset": "Reset Your Password", + "password_send.return": "Return to Log In", + "permalink.error.access.text": "The message you are trying to view is in a channel you don’t have access to or has been deleted.", + "permalink.error.access.title": "Message not viewable", + "permalink.error.cancel": "Cancel", + "permalink.error.okay": "Okay", + "permalink.error.private_channel_and_team.button": "Join channel and team", + "permalink.error.private_channel_and_team.text": "The message you are trying to view is in a private channel in a team you are not a member of. You have access as an admin. Do you want to join **{channelName}** and the **{teamName}** team to view it?", + "permalink.error.private_channel_and_team.title": "Join private channel and team", + "permalink.error.private_channel.button": "Join channel", + "permalink.error.private_channel.text": "The message you are trying to view is in a private channel you have not been invited to, but you have access as an admin. Do you still want to join **{channelName}**?", + "permalink.error.private_channel.title": "Join private channel", + "permalink.error.public_channel_and_team.button": "Join channel and team", + "permalink.error.public_channel_and_team.text": "The message you are trying to view is in a channel you don’t belong and a team you are not a member of. Do you want to join **{channelName}** and the **{teamName}** team to view it?", + "permalink.error.public_channel_and_team.title": "Join channel and team", + "permalink.error.public_channel.button": "Join channel", + "permalink.error.public_channel.text": "The message you are trying to view is in a channel you don’t belong to. Do you want to join **{channelName}** to view it?", + "permalink.error.public_channel.title": "Join channel", + "permalink.show_dialog_warn.cancel": "Cancel", + "permalink.show_dialog_warn.description": "You are about to join {channel} without explicitly being added by the channel admin. Are you sure you wish to join this private channel?", + "permalink.show_dialog_warn.join": "Join", + "permalink.show_dialog_warn.title": "Join private channel", + "pinned_messages.empty.paragraph": "To pin important messages, long-press on a message and choose Pin To Channel. Pinned messages will be visible to everyone in this channel.", + "pinned_messages.empty.title": "No pinned messages yet", + "plus_menu.browse_channels.title": "Browse Channels", + "plus_menu.create_new_channel.title": "Create New Channel", + "plus_menu.invite_people_to_team.title": "Invite people to the team", + "plus_menu.open_direct_message.title": "Open a Direct Message", + "post_body.check_for_out_of_channel_groups_mentions.message": "did not get notified by this mention because they are not in the channel. They are also not a member of the groups linked to this channel.", + "post_body.check_for_out_of_channel_mentions.link.and": " and ", + "post_body.check_for_out_of_channel_mentions.link.private": "add them to this private channel", + "post_body.check_for_out_of_channel_mentions.link.public": "add them to the channel", + "post_body.check_for_out_of_channel_mentions.message_last": "? They will have access to all message history.", + "post_body.check_for_out_of_channel_mentions.message.multiple": "were mentioned but they are not in the channel. Would you like to ", + "post_body.check_for_out_of_channel_mentions.message.one": "was mentioned but is not in the channel. Would you like to ", + "post_body.commentedOn": "Commented on {name}{apostrophe} message: ", + "post_body.deleted": "(message deleted)", + "post_info.auto_responder": "Automatic Reply", + "post_info.bot": "Bot", + "post_info.del": "Delete", + "post_info.edit": "Edit", + "post_info.guest": "Guest", + "post_info.system": "System", + "post_message_view.edited": "(edited)", + "post_priority.label.important": "IMPORTANT", + "post_priority.label.urgent": "URGENT", + "post_priority.picker.beta": "BETA", + "post_priority.picker.label.important": "Important", + "post_priority.picker.label.standard": "Standard", + "post_priority.picker.label.urgent": "Urgent", + "post_priority.picker.title": "Message priority", + "post.options.title": "Options", + "post.reactions.title": "Reactions", + "posts_view.newMsg": "New Messages", + "public_link_copied": "Link copied to clipboard", + "rate.button.needs_work": "Needs work", + "rate.button.yes": "Love it!", + "rate.dont_ask_again": "Don't ask me again", + "rate.error.text": "There has been an error while opening the review modal.", + "rate.error.title": "Error", + "rate.subtitle": "Let us know what you think.", + "rate.title": "Enjoying Mattermost?", + "saved_messages.empty.paragraph": "To save something for later, long-press on a message and choose Save from the menu. Saved messages are only visible to you.", + "saved_messages.empty.title": "No saved messages yet", + "screen.mentions.subtitle": "Messages you've been mentioned in", + "screen.mentions.title": "Recent Mentions", + "screen.saved_messages.subtitle": "All messages you've saved for follow up", + "screen.saved_messages.title": "Saved Messages", + "screen.search.header.files": "Files", + "screen.search.header.messages": "Messages", + "screen.search.modifier.header": "Search options", + "screen.search.placeholder": "Search messages & files", + "screen.search.results.file_options.copy_link": "Copy link", + "screen.search.results.file_options.download": "Download", + "screen.search.results.file_options.open_in_channel": "Open in channel", + "screen.search.results.filter.all_file_types": "All file types", + "screen.search.results.filter.audio": "Audio", + "screen.search.results.filter.code": "Code", + "screen.search.results.filter.documents": "Documents", + "screen.search.results.filter.images": "Images", + "screen.search.results.filter.presentations": "Presentations", + "screen.search.results.filter.spreadsheets": "Spreadsheets", + "screen.search.results.filter.title": "Filter by file type", + "screen.search.results.filter.videos": "Videos", + "screen.search.title": "Search", + "screens.channel_edit": "Edit Channel", + "screens.channel_edit_header": "Edit Channel Header", + "screens.channel_info": "Channel Info", + "screens.channel_info.dm": "Direct message info", + "screens.channel_info.gm": "Group message info", + "search_bar.search": "Search", + "search_bar.search.placeholder": "Search timezone", + "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.", + "select_team.no_team.title": "No teams are available to join", + "select_team.title": "Select a team", + "server_list.push_proxy_error": "Notifications cannot be received from this server because of its configuration. Contact your system admin.", + "server_list.push_proxy_unknown": "Notifications could not be received from this server because of its configuration. Log out and Log in again to retry.", + "server_upgrade.alert_description": "Your server, {serverDisplayName}, is running an unsupported server version. Users will be exposed to compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Upgrading to server version {supportedServerVersion} or later is required.", + "server_upgrade.dismiss": "Dismiss", + "server_upgrade.learn_more": "Learn More", + "server.logout.alert_description": "All associated data will be removed", + "server.logout.alert_title": "Are you sure you want to log out of {displayName}?", + "server.remove.alert_description": "This will remove it from your list of servers. All associated data will be removed", + "server.remove.alert_title": "Are you sure you want to remove {displayName}?", + "server.tutorial.swipe": "Swipe left on a server to see more actions", + "server.websocket.unreachable": "Server is unreachable.", + "servers.create_button": "Add a server", + "servers.default": "Default Server", + "servers.edit": "Edit", + "servers.login": "Log in", + "servers.logout": "Log out", + "servers.remove": "Remove", + "settings_display.clock.mz": "24-hour clock", + "settings_display.clock.mz.desc": "Example: 16:00", + "settings_display.clock.normal.desc": "Example: 4:00 PM", + "settings_display.clock.standard": "12-hour clock", + "settings_display.crt.desc": "When enabled, reply messages are not shown in the channel and you'll be notified about threads you're following in the \"Threads\" view.", + "settings_display.crt.label": "Collapsed Reply Threads", + "settings_display.custom_theme": "Custom Theme", + "settings_display.timezone.automatically": "Set automatically", + "settings_display.timezone.manual": "Change timezone", + "settings_display.timezone.off": "Off", + "settings_display.timezone.select": "Select Timezone", + "settings.about": "About {appTitle}", + "settings.about.build": "{version} (Build {number})", + "settings.about.copyright": "Copyright 2015-{currentYear} Mattermost, Inc. All rights reserved", + "settings.about.database": "Database:", + "settings.about.database.schema": "Database Schema Version:", + "settings.about.licensed": "Licensed to: {company}", + "settings.about.powered_by": "{site} is powered by Mattermost", + "settings.about.server.version.desc": "Server Version:", + "settings.about.server.version.value": "{version} (Build {number})", + "settings.about.serverVersionNoBuild": "{version}", + "settings.about.version": "App Version:", + "settings.advanced_settings": "Advanced Settings", + "settings.advanced.cancel": "Cancel", + "settings.advanced.delete": "Delete", + "settings.advanced.delete_data": "Delete local files", + "settings.advanced.delete_message.confirmation": "\nThis will delete all files downloaded through the app for this server. Please confirm to proceed.\n", + "settings.display": "Display", + "settings.link.error.text": "Unable to open the link.", + "settings.link.error.title": "Error", + "settings.notice_mobile_link": "mobile apps", + "settings.notice_platform_link": "server", + "settings.notice_text": "Mattermost is made possible by the open source software used in our {platform} and {mobile}.", + "settings.notifications": "Notifications", + "settings.save": "Save", + "share_extension.channel_error": "You are not a member of a team on the selected server. Select another server or open Mattermost to join a team.", + "share_extension.channel_label": "Channel", + "share_extension.count_limit": "You can only share {count, number} {count, plural, one {file} other {files}} on this server", + "share_extension.file_limit.multiple": "Each file must be less than {size}", + "share_extension.file_limit.single": "File must be less than {size}", + "share_extension.max_resolution": "Image exceeds maximum dimensions of 7680 x 4320 px", + "share_extension.message": "Enter a message (optional)", + "share_extension.multiple_label": "{count, number} attachments", + "share_extension.server_label": "Server", + "share_extension.servers_screen.title": "Select server", + "share_extension.share_screen.title": "Share to Mattermost", + "share_extension.upload_disabled": "File uploads are disabled for the selected server", + "share_feedback.button.no": "No, thanks", + "share_feedback.button.yes": "Yes", + "share_feedback.subtitle": "We'd love to hear how we can make your experience better.", + "share_feedback.title": "Would you share your feedback?", + "skintone_selector.tooltip.description": "You can now choose the skin tone you prefer to use for your emojis.", + "skintone_selector.tooltip.title": "Choose your default skin tone", + "smobile.search.recent_title": "Recent searches in {teamName}", + "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.remove.user": "1 member was removed from the channel", + "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", + "status_dropdown.set_online": "Online", + "status_dropdown.set_ooo": "Out Of Office", + "suggestion.mention.all": "Notifies everyone in this channel", + "suggestion.mention.channel": "Notifies everyone in this channel", + "suggestion.mention.channels": "My Channels", + "suggestion.mention.groups": "Group Mentions", + "suggestion.mention.here": "Notifies everyone online in this channel", + "suggestion.mention.members": "Channel Members", + "suggestion.mention.morechannels": "Other Channels", + "suggestion.mention.nonmembers": "Not in Channel", + "suggestion.mention.special": "Special Mentions", + "suggestion.mention.you": " (you)", + "suggestion.search.direct": "Direct Messages", + "suggestion.search.private": "Private Channels", + "suggestion.search.public": "Public Channels", + "team_list.no_other_teams.description": "To join another team, ask a Team Admin for an invitation, or create your own team.", + "team_list.no_other_teams.title": "No additional teams to join", + "terms_of_service.acceptButton": "Accept", + "terms_of_service.alert_cancel": "Cancel", + "terms_of_service.alert_retry": "Try Again", + "terms_of_service.api_error": "Unable to complete the request. If this issue persists, contact your System Administrator.", + "terms_of_service.decline": "Decline", + "terms_of_service.error.description": "It was not possible to get the Terms of Service from the Server.", + "terms_of_service.error.logout": "Logout", + "terms_of_service.error.retry": "Retry", + "terms_of_service.error.title": "Failed to get the ToS.", + "terms_of_service.terms_declined.ok": "OK", + "terms_of_service.terms_declined.text": "You must accept the terms of service to access this server. Please contact your system administrator for more details. You will now be logged out. Log in again to accept the terms of service.", + "terms_of_service.terms_declined.title": "You must accept the terms of service", + "terms_of_service.title": "Terms of Service", + "thread.header.thread": "Thread", + "thread.header.thread_in": "in {channelName}", + "thread.loadingReplies": "Loading replies...", + "thread.noReplies": "No replies yet", + "thread.options.title": "Thread Actions", + "thread.repliesCount": "{repliesCount, number} {repliesCount, plural, one {reply} other {replies}}", + "threads": "Threads", + "threads.deleted": "Original Message Deleted", + "threads.end_of_list.subtitle": "If you're looking for older conversations, try searching instead", + "threads.end_of_list.title": "That's the end of the list!", + "threads.follow": "Follow", + "threads.following": "Following", + "threads.followMessage": "Follow Message", + "threads.followThread": "Follow Thread", + "threads.newReplies": "{count} new {count, plural, one {reply} other {replies}}", + "threads.replies": "{count} {count, plural, one {reply} other {replies}}", + "threads.unfollowMessage": "Unfollow Message", + "threads.unfollowThread": "Unfollow Thread", + "unreads.empty.paragraph": "Turn off the unread filter to show all your channels.", + "unreads.empty.show_all": "Show all", + "unreads.empty.title": "No more unreads", + "unsupported_server.message": "Your server, {serverDisplayName}, is running an unsupported server version. You may experience compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Please contact your System Administrator to upgrade your Mattermost server.", + "unsupported_server.title": "Unsupported server version", + "user_profile.custom_status": "Custom Status", + "user_status.away": "Away", + "user_status.dnd": "Do Not Disturb", + "user_status.offline": "Offline", + "user_status.online": "Online", + "user_status.title": "Status", + "user.edit_profile.email.auth_service": "Login occurs through {service}. Email cannot be updated. Email address used for notifications is {email}.", + "user.edit_profile.email.web_client": "Email must be updated using a web client or desktop application.", + "user.edit_profile.profile_photo.change_photo": "Change profile photo", + "user.settings.general.email": "Email", + "user.settings.general.field_handled_externally": "Some fields below are handled through your login provider. If you want to change them, you’ll need to do so through your login provider.", + "user.settings.general.firstName": "First Name", + "user.settings.general.lastName": "Last Name", + "user.settings.general.nickname": "Nickname", + "user.settings.general.position": "Position", + "user.settings.general.username": "Username", + "user.settings.notifications.email_threads.description": "Notify me about all replies to threads I'm following", + "user.tutorial.long_press": "Long-press on an item to view a user's profile", + "video.download": "Download video", + "video.download_description": "This video must be downloaded to play it.", + "video.failed_description": "An error occurred while trying to play the video.", + "your.servers": "Your servers" } diff --git a/types/api/users.d.ts b/types/api/users.d.ts index cfa759c67f..3485b50509 100644 --- a/types/api/users.d.ts +++ b/types/api/users.d.ts @@ -98,3 +98,33 @@ type UserCustomStatus = { }; type CustomStatusDuration = '' | 'thirty_minutes' | 'one_hour' | 'four_hours' | 'today' | 'this_week' | 'date_and_time'; + +type SearchUserOptions = { + team_id?: string; + not_in_team?: string; + in_channel_id?: string; + in_group_id?: string; + group_constrained?: boolean; + allow_inactive?: boolean; + without_team?: string; + limit?: string; +}; + +type GetUsersOptions = { + page?: number; + per_page?: number; + in_team?: string; + not_in_team?: string; + in_channel?: string; + not_in_channel?: string; + in_group?: string; + group_constrained?: boolean; + without_team?: boolean; + active?: boolean; + inactive?: boolean; + role?: string; + sort?: string; + roles?: string; + channel_roles?: string; + team_roles?: string; +};