diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0cde21c2871d..e484c1fe0429 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,11 +1,7 @@ stages: - test - # These need to have separate stages, otherwise artifacts would overwrite each other - - te-build - - te-s3 - - ee-build - - ee-s3 - - create-vars + - build + - s3 - trigger variables: @@ -18,7 +14,7 @@ include: file: private.yml empty: - stage: create-vars + stage: test script: - echo "empty" diff --git a/actions/apps.ts b/actions/apps.ts index bc52c1148b22..5dbe2a81d885 100644 --- a/actions/apps.ts +++ b/actions/apps.ts @@ -15,7 +15,7 @@ import {getSiteURL, shouldOpenInNewTab} from 'utils/url'; import {browserHistory} from 'utils/browser_history'; import {makeCallErrorResponse} from 'utils/apps'; -export function doAppCall(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc { // eslint-disable-line @typescript-eslint/no-unused-vars +export function doAppCall(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc { return async (dispatch: DispatchFunc) => { try { const res = await Client4.executeAppCall(call, type) as AppCallResponse; diff --git a/actions/command.ts b/actions/command.ts index b545ca21b9f8..62219dcdac9e 100644 --- a/actions/command.ts +++ b/actions/command.ts @@ -7,6 +7,7 @@ import {savePreferences} from 'mattermost-redux/actions/preferences'; import {getCurrentChannel, getRedirectChannelNameForTeam, isFavoriteChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentRelativeTeamUrl, getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {IntegrationTypes} from 'mattermost-redux/action_types'; import {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; import type {CommandArgs} from 'mattermost-redux/types/integrations'; @@ -31,8 +32,6 @@ import {intlShim} from 'components/suggestion/command_provider/app_command_parse import {GlobalState} from 'types/store'; -import {appsEnabled} from 'utils/apps'; - import {t} from 'utils/i18n'; import {doAppCall} from './apps'; diff --git a/actions/global_actions.tsx b/actions/global_actions.tsx index 23b72d52f3cc..ad1780dc66c2 100644 --- a/actions/global_actions.tsx +++ b/actions/global_actions.tsx @@ -15,6 +15,7 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUser, getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels} from 'mattermost-redux/selectors/entities/channels'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {ChannelTypes} from 'mattermost-redux/action_types'; import {fetchAppBindings} from 'mattermost-redux/actions/apps'; import {Channel, ChannelMembership} from 'mattermost-redux/types/channels'; @@ -43,8 +44,6 @@ import {filterAndSortTeamsByDisplayName} from 'utils/team_utils.jsx'; import * as Utils from 'utils/utils.jsx'; import SubMenuModal from '../components/widgets/menu/menu_modals/submenu_modal/submenu_modal'; -import {appsEnabled} from 'utils/apps'; - import {openModal} from './views/modals'; const dispatch = store.dispatch; diff --git a/actions/marketplace.ts b/actions/marketplace.ts index 33df42ebe571..def7d48b9fda 100644 --- a/actions/marketplace.ts +++ b/actions/marketplace.ts @@ -5,6 +5,7 @@ import {Client4} from 'mattermost-redux/client'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {ActionFunc, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; import type {MarketplaceApp, MarketplacePlugin} from 'mattermost-redux/types/marketplace'; @@ -17,8 +18,6 @@ import {ActionTypes} from 'utils/constants'; import {isError} from 'types/actions'; -import {appsEnabled} from 'utils/apps'; - import {executeCommand} from './command'; // fetchPlugins fetches the latest marketplace plugins and apps, subject to any existing search filter. diff --git a/actions/websocket_actions.jsx b/actions/websocket_actions.jsx index 1b41ff86eb99..bafc67e263c5 100644 --- a/actions/websocket_actions.jsx +++ b/actions/websocket_actions.jsx @@ -58,6 +58,7 @@ import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general import {getChannelsInTeam, getChannel, getCurrentChannel, getCurrentChannelId, getRedirectChannelNameForTeam, getMembersInCurrentChannel, getChannelMembersInChannels} from 'mattermost-redux/selectors/entities/channels'; import {getPost, getMostRecentPostIdInChannel} from 'mattermost-redux/selectors/entities/posts'; import {haveISystemPermission, haveITeamPermission} from 'mattermost-redux/selectors/entities/roles'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {getStandardAnalytics} from 'mattermost-redux/actions/admin'; import {fetchAppBindings} from 'mattermost-redux/actions/apps'; @@ -83,7 +84,6 @@ import {getSiteURL} from 'utils/url'; import {isGuest} from 'utils/utils'; import RemovedFromChannelModal from 'components/removed_from_channel_modal'; import InteractiveDialog from 'components/interactive_dialog'; -import {appsEnabled} from 'utils/apps'; const dispatch = store.dispatch; const getState = store.getState; @@ -183,6 +183,7 @@ export function reconnect(includeWebSocket = true) { const mostRecentId = getMostRecentPostIdInChannel(state, currentChannelId); const mostRecentPost = getPost(state, mostRecentId); dispatch(loadChannelsForCurrentUser()); + dispatch(handleRefreshAppsBindings()); if (mostRecentPost) { dispatch(syncPostsInChannel(currentChannelId, mostRecentPost.create_at)); } else { @@ -484,8 +485,10 @@ export function handleEvent(msg) { case SocketEvents.CLOUD_PAYMENT_STATUS_UPDATED: dispatch(handleCloudPaymentStatusUpdated(msg)); break; + case SocketEvents.FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED: + handleFirstAdminVisitMarketplaceStatusReceivedEvent(msg); + break; - // Apps framework events case SocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: { dispatch(handleRefreshAppsBindings(msg)); break; @@ -1377,3 +1380,8 @@ function handleRefreshAppsBindings() { return {data: true}; }; } + +function handleFirstAdminVisitMarketplaceStatusReceivedEvent(msg) { + const receivedData = JSON.parse(msg.data.firstAdminVisitMarketplaceStatus); + store.dispatch({type: GeneralTypes.FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED, data: receivedData}); +} diff --git a/client/websocket_client.tsx b/client/websocket_client.tsx index 4f2c65032283..42758ece00db 100644 --- a/client/websocket_client.tsx +++ b/client/websocket_client.tsx @@ -8,8 +8,15 @@ const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins export default class WebSocketClient { private conn: WebSocket | null; private connectionUrl: string | null; - private sequence: number; - private eventSequence: number; + + // responseSequence is the number to track a response sent + // via the websocket. A response will always have the same sequence number + // as the request. + private responseSequence: number; + + // serverSequence is the incrementing sequence number from the + // server-sent event stream. + private serverSequence: number; private connectFailCount: number; private eventCallback: ((msg: any) => void) | null; private responseCallbacks: {[x: number]: ((msg: any) => void)}; @@ -22,8 +29,8 @@ export default class WebSocketClient { constructor() { this.conn = null; this.connectionUrl = null; - this.sequence = 1; - this.eventSequence = 0; + this.responseSequence = 1; + this.serverSequence = 0; this.connectFailCount = 0; this.eventCallback = null; this.responseCallbacks = {}; @@ -52,7 +59,7 @@ export default class WebSocketClient { this.connectionUrl = connectionUrl; this.conn.onopen = () => { - this.eventSequence = 0; + this.serverSequence = 0; if (token) { this.sendMessage('authentication_challenge', {token}); @@ -72,7 +79,7 @@ export default class WebSocketClient { this.conn.onclose = () => { this.conn = null; - this.sequence = 1; + this.responseSequence = 1; if (this.connectFailCount === 0) { console.log('websocket closed'); //eslint-disable-line no-console @@ -125,11 +132,11 @@ export default class WebSocketClient { Reflect.deleteProperty(this.responseCallbacks, msg.seq_reply); } } else if (this.eventCallback) { - if (msg.seq !== this.eventSequence && this.missedEventCallback) { - console.log('missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.eventSequence); //eslint-disable-line no-console + if (msg.seq !== this.serverSequence && this.missedEventCallback) { + console.log('missed websocket event, act_seq=' + msg.seq + ' exp_seq=' + this.serverSequence); //eslint-disable-line no-console this.missedEventCallback(); } - this.eventSequence = msg.seq + 1; + this.serverSequence = msg.seq + 1; this.eventCallback(msg); } }; @@ -161,7 +168,7 @@ export default class WebSocketClient { close() { this.connectFailCount = 0; - this.sequence = 1; + this.responseSequence = 1; if (this.conn && this.conn.readyState === WebSocket.OPEN) { this.conn.onclose = () => {}; //eslint-disable-line no-empty-function this.conn.close(); @@ -173,7 +180,7 @@ export default class WebSocketClient { sendMessage(action: string, data: any, responseCallback?: () => void) { const msg = { action, - seq: this.sequence++, + seq: this.responseSequence++, data, }; diff --git a/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap b/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap index 723871866b82..7f8b7e0f4e53 100644 --- a/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap +++ b/components/admin_console/__snapshots__/schema_admin_settings.test.jsx.snap @@ -331,7 +331,7 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with }, Object { "order": 9, - "text": "Svenska (Beta)", + "text": "Svenska", "value": "sv", }, Object { @@ -341,7 +341,7 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with }, Object { "order": 11, - "text": "Български (Beta)", + "text": "Български", "value": "bg", }, Object { @@ -447,7 +447,7 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with }, Object { "order": 9, - "text": "Svenska (Beta)", + "text": "Svenska", "value": "sv", }, Object { @@ -457,7 +457,7 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with }, Object { "order": 11, - "text": "Български (Beta)", + "text": "Български", "value": "bg", }, Object { diff --git a/components/admin_console/custom_plugin_settings/index.js b/components/admin_console/custom_plugin_settings/index.js index 200576b1a480..1c49bf567824 100644 --- a/components/admin_console/custom_plugin_settings/index.js +++ b/components/admin_console/custom_plugin_settings/index.js @@ -5,6 +5,7 @@ import {connect} from 'react-redux'; import {createSelector} from 'reselect'; import {getRoles} from 'mattermost-redux/selectors/entities/roles'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {Constants} from 'utils/constants'; import {localizeMessage} from 'utils/utils.jsx'; @@ -13,7 +14,7 @@ import {getAdminConsoleCustomComponents} from 'selectors/admin_console'; import SchemaAdminSettings from '../schema_admin_settings'; import {it} from '../admin_definition'; -import {appsEnabled, appsPluginID} from 'utils/apps'; +import {appsPluginID} from 'utils/apps'; import CustomPluginSettings from './custom_plugin_settings.jsx'; import getEnablePluginSetting from './enable_plugin_setting'; diff --git a/components/admin_console/plugin_management/index.ts b/components/admin_console/plugin_management/index.ts index 3ebca6776610..ed5aa338e6be 100644 --- a/components/admin_console/plugin_management/index.ts +++ b/components/admin_console/plugin_management/index.ts @@ -15,8 +15,7 @@ import { } from 'mattermost-redux/actions/admin'; import {GenericAction} from 'mattermost-redux/types/actions'; - -import {appsEnabled} from 'utils/apps'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import PluginManagement from './plugin_management'; diff --git a/components/admin_console/plugin_management/plugin_management.tsx b/components/admin_console/plugin_management/plugin_management.tsx index 6e4d7c4d6148..908c1e16fc30 100644 --- a/components/admin_console/plugin_management/plugin_management.tsx +++ b/components/admin_console/plugin_management/plugin_management.tsx @@ -180,7 +180,7 @@ const PluginItem = ({ appsEnabled, isDisabled, }: PluginItemProps) => { - let activateButton: JSX.Element | null; + let activateButton: React.ReactNode; const activating = pluginStatus.state === PluginState.PLUGIN_STATE_STARTING; const deactivating = pluginStatus.state === PluginState.PLUGIN_STATE_STOPPING; @@ -257,7 +257,7 @@ const PluginItem = ({ /> ); } - let removeButton: JSX.Element | null = ( + let removeButton: React.ReactNode = ( {' - '} - - +/> `; exports[`components/apps_form/AppsFormHeader should render message with supported values 1`] = ` - - bold italic link <br/> link target blank

", - } - } - id="testsupported" - /> - +/> `; diff --git a/components/apps_form/apps_form.tsx b/components/apps_form/apps_form.tsx index 13dfff1a8660..b11c59b1f9d5 100644 --- a/components/apps_form/apps_form.tsx +++ b/components/apps_form/apps_form.tsx @@ -16,7 +16,6 @@ import SpinnerButton from 'components/spinner_button'; import SuggestionList from 'components/suggestion/suggestion_list'; import ModalSuggestionList from 'components/suggestion/modal_suggestion_list'; -import EmojiMap from 'utils/emoji_map'; import {localizeMessage} from 'utils/utils.jsx'; import AppsFormField from './apps_form_field'; @@ -36,7 +35,6 @@ export type AppsFormProps = { performLookupCall: (field: AppField, values: AppFormValues, userInput: string) => Promise; refreshOnSelect: (field: AppField, values: AppFormValues) => Promise<{data: AppCallResponse}>; }; - emojiMap: EmojiMap; } type Props = AppsFormProps & WrappedComponentProps<'intl'>; @@ -353,7 +351,6 @@ export class AppsForm extends React.PureComponent { )} {this.renderElements()} diff --git a/components/apps_form/apps_form_container.tsx b/components/apps_form/apps_form_container.tsx index d7e6eb006e8f..43443e9033c0 100644 --- a/components/apps_form/apps_form_container.tsx +++ b/components/apps_form/apps_form_container.tsx @@ -8,8 +8,6 @@ import {injectIntl, IntlShape} from 'react-intl'; import {AppCallResponse, AppField, AppForm, AppFormValues, AppSelectOption, AppCallType, AppCallRequest} from 'mattermost-redux/types/apps'; import {AppCallTypes, AppCallResponseTypes} from 'mattermost-redux/constants/apps'; -import EmojiMap from 'utils/emoji_map'; - import {makeCallErrorResponse} from 'utils/apps'; import {sendEphemeralPost} from 'actions/global_actions'; @@ -24,7 +22,6 @@ type Props = { actions: { doAppCall: (call: AppCallRequest, type: AppCallType, intl: IntlShape) => Promise<{data: AppCallResponse}>; }; - emojiMap: EmojiMap; }; type State = { @@ -202,7 +199,6 @@ class AppsFormContainer extends React.PureComponent { form={form} call={call} onHide={this.onHide} - emojiMap={this.props.emojiMap} actions={{ submit: this.submitForm, performLookupCall: this.performLookupCall, diff --git a/components/apps_form/apps_form_field/apps_form_field.tsx b/components/apps_form/apps_form_field/apps_form_field.tsx index 6f69f797c2d8..431d6d412599 100644 --- a/components/apps_form/apps_form_field/apps_form_field.tsx +++ b/components/apps_form/apps_form_field/apps_form_field.tsx @@ -7,9 +7,9 @@ import {AppField, AppSelectOption} from 'mattermost-redux/types/apps'; import {Channel} from 'mattermost-redux/types/channels'; import {UserProfile} from 'mattermost-redux/types/users'; +import {AppFieldTypes} from 'mattermost-redux/constants/apps'; import {displayUsername} from 'mattermost-redux/utils/user_utils'; -import MenuActionProvider from 'components/suggestion/menu_action_provider'; import GenericUserProvider from 'components/suggestion/generic_user_provider.jsx'; import GenericChannelProvider from 'components/suggestion/generic_channel_provider.jsx'; @@ -42,16 +42,21 @@ export type Props = { } export default class AppsFormField extends React.PureComponent { - private provider?: Provider; + private providers: Provider[] = []; static defaultProps = { listComponent: ModalSuggestionList, }; + constructor(props: Props) { + super(props); + this.setProviders(); + } + handleSelected = (selected: AppSelectOption | UserProfile | Channel) => { const {name, field, onChange} = this.props; - if (field.type === 'user') { + if (field.type === AppFieldTypes.USER) { const user = selected as UserProfile; let selectedLabel = user.username; if (this.props.teammateNameDisplay) { @@ -59,7 +64,7 @@ export default class AppsFormField extends React.PureComponent { } const option = {label: selectedLabel, value: user.id}; onChange(name, option); - } else if (field.type === 'channel') { + } else if (field.type === AppFieldTypes.CHANNEL) { const channel = selected as Channel; const option = {label: channel.display_name, value: channel.id}; onChange(name, option); @@ -69,22 +74,17 @@ export default class AppsFormField extends React.PureComponent { } } - getProvider = (): Provider | void => { - if (this.provider) { - return this.provider; - } - + setProviders = () => { const {actions, field} = this.props; - if (field.type === 'user') { - this.provider = new GenericUserProvider(actions.autocompleteUsers); - } else if (field.type === 'channel') { - this.provider = new GenericChannelProvider(actions.autocompleteChannels); - } else if (field.options) { - const options = field.options.map(({label, value}) => ({text: label, value})); - this.provider = new MenuActionProvider(options); + + let providers: Provider[] = []; + if (field.type === AppFieldTypes.USER) { + providers = [new GenericUserProvider(actions.autocompleteUsers)]; + } else if (field.type === AppFieldTypes.CHANNEL) { + providers = [new GenericChannelProvider(actions.autocompleteChannels)]; } - return this.provider; + this.providers = providers; } render() { @@ -156,7 +156,7 @@ export default class AppsFormField extends React.PureComponent { resizable={false} /> ); - } else if (field.type === 'channel' || field.type === 'user') { + } else if (field.type === AppFieldTypes.CHANNEL || field.type === AppFieldTypes.USER) { let selectedValue: string | undefined; if (this.props.value) { selectedValue = (this.props.value as AppSelectOption).label; @@ -165,7 +165,7 @@ export default class AppsFormField extends React.PureComponent { { listComponent={listComponent} /> ); - } else if (field.type === 'static_select' || field.type === 'dynamic_select') { + } else if (field.type === AppFieldTypes.STATIC_SELECT || field.type === AppFieldTypes.DYNAMIC_SELECT) { return ( { value={this.props.value as AppSelectOption | null} /> ); - } else if (field.type === 'bool') { + } else if (field.type === AppFieldTypes.BOOL) { const boolValue = value as boolean; return ( @@ -92,8 +92,8 @@ export default class AppsFormSelectField extends React.PureComponent @@ -127,14 +127,12 @@ export default class AppsFormSelectField extends React.PureComponent {label} - {[ - - {selectComponent} -
- {helpText} -
-
, - ]} + + {selectComponent} +
+ {helpText} +
+
); } diff --git a/components/apps_form/apps_form_header.test.tsx b/components/apps_form/apps_form_header.test.tsx index 07d22fa8e25b..34af4f2afdbb 100644 --- a/components/apps_form/apps_form_header.test.tsx +++ b/components/apps_form/apps_form_header.test.tsx @@ -2,32 +2,26 @@ // See LICENSE.txt for license information. import React from 'react'; -import {mount} from 'enzyme'; - -import EmojiMap from 'utils/emoji_map'; +import {shallow} from 'enzyme'; import AppsFormHeader from './apps_form_header'; describe('components/apps_form/AppsFormHeader', () => { - const emojiMap = new EmojiMap(new Map()); - test('should render message with supported values', () => { - const descriptor = { + const props = { id: 'testsupported', value: '**bold** *italic* [link](https://mattermost.com/)
[link target blank](!https://mattermost.com/)', - emojiMap, }; - const wrapper = mount(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('should not fail on empty value', () => { - const descriptor = { + const props = { id: 'testblankvalue', value: '', - emojiMap, }; - const wrapper = mount(); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); }); diff --git a/components/apps_form/apps_form_header.tsx b/components/apps_form/apps_form_header.tsx index 19394b2698d0..21e0b59a3266 100644 --- a/components/apps_form/apps_form_header.tsx +++ b/components/apps_form/apps_form_header.tsx @@ -3,32 +3,20 @@ import React from 'react'; -import * as Markdown from 'utils/markdown'; -import {getSiteURL} from 'utils/url'; -import EmojiMap from 'utils/emoji_map'; +import Markdown from 'components/markdown'; type Props = { id?: string; value: string; - emojiMap: EmojiMap; }; -const AppsFormHeader: React.FC = (props: Props) => { - const formattedMessage = Markdown.format( - props.value, - { - breaks: true, - sanitize: true, - gfm: true, - siteURL: getSiteURL(), - }, - props.emojiMap, - ); +const markdownOptions = {singleline: false, mentionHighlight: false}; +const AppsFormHeader: React.FC = (props: Props) => { return ( - ); }; diff --git a/components/apps_form/index.ts b/components/apps_form/index.ts index 280237f4e962..42aa67ff1c0e 100644 --- a/components/apps_form/index.ts +++ b/components/apps_form/index.ts @@ -4,12 +4,10 @@ import {connect} from 'react-redux'; import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; -import {GlobalState} from 'mattermost-redux/types/store'; import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions'; -import {AppCallRequest, AppCallResponse, AppCallType, AppModalState} from 'mattermost-redux/types/apps'; +import {AppCallRequest, AppCallResponse, AppCallType} from 'mattermost-redux/types/apps'; import {doAppCall} from 'actions/apps'; -import {getEmojiMap} from 'selectors/emojis'; import AppsFormContainer from './apps_form_container'; @@ -17,18 +15,6 @@ type Actions = { doAppCall: (call: AppCallRequest, type: AppCallType) => Promise<{data: AppCallResponse}>; }; -function mapStateToProps(state: GlobalState, ownProps: {modal?: AppModalState}) { - const emojiMap = getEmojiMap(state); - if (!ownProps.modal) { - return {emojiMap}; - } - - return { - modal: ownProps.modal, - emojiMap, - }; -} - function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators, Actions>({ @@ -37,4 +23,4 @@ function mapDispatchToProps(dispatch: Dispatch) { }; } -export default connect(mapStateToProps, mapDispatchToProps)(AppsFormContainer); +export default connect(null, mapDispatchToProps)(AppsFormContainer); diff --git a/components/button_selector.tsx b/components/button_selector.tsx index 43c47a41e6d0..8b0fab95c705 100644 --- a/components/button_selector.tsx +++ b/components/button_selector.tsx @@ -26,11 +26,11 @@ const defaultProps: Partial = { }; const ButtonSelector: React.FC = (props: Props) => { - const onClick = (value: AppSelectOption) => { + const onClick = React.useCallback((value: AppSelectOption) => { if (props.onChange) { props.onChange(value); } - }; + }, [props.onChange]); const { footer, diff --git a/components/channel_info_modal/__snapshots__/channel_info_modal.test.jsx.snap b/components/channel_info_modal/__snapshots__/channel_info_modal.test.tsx.snap similarity index 93% rename from components/channel_info_modal/__snapshots__/channel_info_modal.test.jsx.snap rename to components/channel_info_modal/__snapshots__/channel_info_modal.test.tsx.snap index 0c0fcc6a4662..a3b0e6f6075b 100644 --- a/components/channel_info_modal/__snapshots__/channel_info_modal.test.jsx.snap +++ b/components/channel_info_modal/__snapshots__/channel_info_modal.test.tsx.snap @@ -45,9 +45,10 @@ exports[`components/ChannelInfoModal should match snapshot 1`] = ` id="channel_info.about" /> - + name @@ -69,7 +70,7 @@ exports[`components/ChannelInfoModal should match snapshot 1`] = `
- http://localhost:8065/testteam/channels/testchannel + http://localhost:8065/DN/channels/DN
+ channel_id

@@ -131,9 +133,10 @@ exports[`components/ChannelInfoModal should match snapshot with channel props 1` id="channel_info.about" /> - + name @@ -203,7 +206,7 @@ exports[`components/ChannelInfoModal should match snapshot with channel props 1`
- http://localhost:8065/testteam/channels/testchannel + http://localhost:8065/DN/channels/DN
+ channel_id

diff --git a/components/channel_info_modal/channel_info_modal.test.jsx b/components/channel_info_modal/channel_info_modal.test.tsx similarity index 63% rename from components/channel_info_modal/channel_info_modal.test.jsx rename to components/channel_info_modal/channel_info_modal.test.tsx index f7dd015dc030..e3d44b2b59b5 100644 --- a/components/channel_info_modal/channel_info_modal.test.jsx +++ b/components/channel_info_modal/channel_info_modal.test.tsx @@ -5,16 +5,24 @@ import React from 'react'; import {Modal} from 'react-bootstrap'; import {shallow} from 'enzyme'; +import {Channel} from 'mattermost-redux/types/channels'; + import {mountWithIntl} from 'tests/helpers/intl-test-helper'; -import ChannelInfoModal from 'components/channel_info_modal/channel_info_modal.jsx'; +import ChannelInfoModal from 'components/channel_info_modal/channel_info_modal'; +import {TestHelper} from 'utils/test_helper'; describe('components/ChannelInfoModal', () => { + const mockChannel = TestHelper.getChannelMock({ + header: '', + purpose: '', + }); + const mockTeam = TestHelper.getTeamMock(); it('should match snapshot', () => { const wrapper = shallow( , ); @@ -23,9 +31,8 @@ describe('components/ChannelInfoModal', () => { }); it('should match snapshot with channel props', () => { - const channel = { - name: 'testchannel', - displayName: 'testchannel', + const channel: Channel = { + ...mockChannel, header: 'See ~test', purpose: 'And ~test too', props: { @@ -41,7 +48,7 @@ describe('components/ChannelInfoModal', () => { , ); @@ -54,22 +61,22 @@ describe('components/ChannelInfoModal', () => { const wrapper = mountWithIntl( , ); - wrapper.find(Modal).first().props().onExited(); + wrapper.find(Modal).first().props().onExited!(document.createElement('div')); expect(onHide).toHaveBeenCalled(); }); it('should call onHide when current channel changes', () => { const wrapper = mountWithIntl( , ); @@ -82,9 +89,9 @@ describe('components/ChannelInfoModal', () => { it('should call hide when RHS opens', () => { const wrapper = mountWithIntl( , ); diff --git a/components/channel_info_modal/channel_info_modal.jsx b/components/channel_info_modal/channel_info_modal.tsx similarity index 85% rename from components/channel_info_modal/channel_info_modal.jsx rename to components/channel_info_modal/channel_info_modal.tsx index 208cb4ddf7bc..6e26dd19d30c 100644 --- a/components/channel_info_modal/channel_info_modal.jsx +++ b/components/channel_info_modal/channel_info_modal.tsx @@ -7,19 +7,35 @@ import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; import {memoizeResult} from 'mattermost-redux/utils/helpers'; +import {Team} from 'mattermost-redux/types/teams'; +import {Channel} from 'mattermost-redux/types/channels'; import Markdown from 'components/markdown'; import GlobeIcon from 'components/widgets/icons/globe_icon'; import LockIcon from 'components/widgets/icons/lock_icon'; import ArchiveIcon from 'components/widgets/icons/archive_icon'; +import {ChannelNamesMap} from 'utils/text_formatting'; import Constants from 'utils/constants.jsx'; import {getSiteURL} from 'utils/url'; import * as Utils from 'utils/utils.jsx'; const headerMarkdownOptions = {singleline: false, mentionHighlight: false}; -export default class ChannelInfoModal extends React.PureComponent { +type Props = { + onHide: () => void; + channel: Channel; + currentChannel: Channel; + currentTeam: Team; + isRHSOpen?: boolean; + currentRelativeTeamUrl?: string; +}; + +type State = { + show: boolean; +}; + +export default class ChannelInfoModal extends React.PureComponent { static propTypes = { /** @@ -53,17 +69,13 @@ export default class ChannelInfoModal extends React.PureComponent { currentRelativeTeamUrl: PropTypes.string, }; - constructor(props) { + constructor(props: Props) { super(props); this.state = {show: true}; - - this.getHeaderMarkdownOptions = memoizeResult((channelNamesMap) => ( - {...headerMarkdownOptions, channelNamesMap} - )); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: Props) { const RHSChanged = !prevProps.isRHSOpen && this.props.isRHSOpen; const channelChanged = prevProps.channel?.id !== this.props.currentChannel?.id; if (RHSChanged || channelChanged) { @@ -75,7 +87,9 @@ export default class ChannelInfoModal extends React.PureComponent { this.setState({show: false}); } - handleFormattedTextClick = (e) => Utils.handleFormattedTextClick(e, this.props.currentRelativeTeamUrl); + handleFormattedTextClick = (e: React.MouseEvent) => Utils.handleFormattedTextClick(e, this.props.currentRelativeTeamUrl); + + getHeaderMarkdownOptions = memoizeResult((channelNamesMap: ChannelNamesMap) => ({...headerMarkdownOptions, channelNamesMap})); render() { let channel = this.props.channel; @@ -91,6 +105,17 @@ export default class ChannelInfoModal extends React.PureComponent { purpose: notFound, header: notFound, id: notFound, + team_id: notFound, + type: notFound, + delete_at: 0, + create_at: 0, + update_at: 0, + last_post_at: 0, + total_msg_count: 0, + extra_update_at: 0, + creator_id: notFound, + scheme_id: notFound, + group_constrained: false, }; } diff --git a/components/channel_info_modal/index.js b/components/channel_info_modal/index.js index 7c0d1581acf7..5f3ba415540d 100644 --- a/components/channel_info_modal/index.js +++ b/components/channel_info_modal/index.js @@ -8,7 +8,7 @@ import {getCurrentRelativeTeamUrl, getCurrentTeam} from 'mattermost-redux/select import {getIsRhsOpen} from 'selectors/rhs'; -import ChannelInfoModal from './channel_info_modal.jsx'; +import ChannelInfoModal from './channel_info_modal'; function mapStateToProps(state) { return { diff --git a/components/dot_menu/dot_menu.tsx b/components/dot_menu/dot_menu.tsx index 0198e97c2e73..f766e24eefd0 100644 --- a/components/dot_menu/dot_menu.tsx +++ b/components/dot_menu/dot_menu.tsx @@ -49,7 +49,7 @@ type Props = { enableEmojiPicker?: boolean; // TechDebt: Made non-mandatory while converting to typescript channelIsArchived?: boolean; // TechDebt: Made non-mandatory while converting to typescript currentTeamUrl?: string; // TechDebt: Made non-mandatory while converting to typescript - appBindings: AppBinding[]; + appBindings?: AppBinding[]; appsEnabled: boolean; /** @@ -113,12 +113,13 @@ type State = { } class DotMenu extends React.PureComponent { - static defaultProps = { + public static defaultProps: Partial = { commentCount: 0, isFlagged: false, isReadOnly: false, location: Locations.CENTER, pluginMenuItems: [], + appBindings: [], } private editDisableAction: DelayedAction; private buttonRef: React.RefObject; @@ -360,7 +361,7 @@ class DotMenu extends React.PureComponent { }) || []; let appBindings = [] as JSX.Element[]; - if (this.props.appsEnabled) { + if (this.props.appsEnabled && this.props.appBindings) { appBindings = this.props.appBindings.map((item) => { let icon: JSX.Element | undefined; if (item.icon) { diff --git a/components/dot_menu/index.ts b/components/dot_menu/index.ts index 5af0adf68e9b..1813fa2ab8bc 100644 --- a/components/dot_menu/index.ts +++ b/components/dot_menu/index.ts @@ -8,7 +8,7 @@ import {getLicense, getConfig} from 'mattermost-redux/selectors/entities/general import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {getCurrentTeamId, getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; -import {getAppBindings} from 'mattermost-redux/selectors/entities/apps'; +import {appsEnabled, makeAppBindingsSelector} from 'mattermost-redux/selectors/entities/apps'; import {AppBindingLocations} from 'mattermost-redux/constants/apps'; import {ActionFunc, ActionResult, GenericAction} from 'mattermost-redux/types/actions'; @@ -34,8 +34,6 @@ import * as PostUtils from 'utils/post_utils.jsx'; import {isArchivedChannel} from 'utils/channel_utils'; import {getSiteURL} from 'utils/url'; -import {appsEnabled} from 'utils/apps'; - import DotMenu from './dot_menu'; type Props = { @@ -51,6 +49,8 @@ type Props = { enableEmojiPicker?: boolean; }; +const getPostMenuBindings = makeAppBindingsSelector(AppBindingLocations.POST_MENU_ITEM); + function mapStateToProps(state: GlobalState, ownProps: Props) { const {post} = ownProps; @@ -62,7 +62,7 @@ function mapStateToProps(state: GlobalState, ownProps: Props) { const currentTeamUrl = `${getSiteURL()}/${currentTeam.name}`; const apps = appsEnabled(state); - const appBindings = apps ? getAppBindings(state, AppBindingLocations.POST_MENU_ITEM) : []; + const appBindings = getPostMenuBindings(state); return { channelIsArchived: isArchivedChannel(channel), diff --git a/components/legacy_sidebar/header/dropdown/index.js b/components/legacy_sidebar/header/dropdown/index.js index ff59998d10d3..bb7a7d620672 100644 --- a/components/legacy_sidebar/header/dropdown/index.js +++ b/components/legacy_sidebar/header/dropdown/index.js @@ -4,6 +4,8 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; +import {getFirstAdminVisitMarketplaceStatus} from 'mattermost-redux/actions/general'; +import {getFirstAdminVisitMarketplaceStatus as firstAdminVisitMarketplaceStatus} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {getInt} from 'mattermost-redux/selectors/entities/preferences'; @@ -19,12 +21,14 @@ function mapStateToProps(state) { const currentTeam = getCurrentTeam(state); const currentUser = getCurrentUser(state); const showTutorialTip = getInt(state, Preferences.TUTORIAL_STEP, currentUser.id) === TutorialSteps.MENU_POPOVER && !Utils.isMobile(); + return { currentUser, teamDescription: currentTeam.description, teamDisplayName: currentTeam.display_name, teamId: currentTeam.id, showTutorialTip, + firstAdminVisitMarketplaceStatus: firstAdminVisitMarketplaceStatus(state), }; } @@ -32,6 +36,7 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ openModal, + getFirstAdminVisitMarketplaceStatus, }, dispatch), }; } diff --git a/components/legacy_sidebar/header/dropdown/sidebar_header_dropdown.jsx b/components/legacy_sidebar/header/dropdown/sidebar_header_dropdown.jsx index 7afd849697bf..ab478fd42f25 100644 --- a/components/legacy_sidebar/header/dropdown/sidebar_header_dropdown.jsx +++ b/components/legacy_sidebar/header/dropdown/sidebar_header_dropdown.jsx @@ -16,6 +16,8 @@ import MenuWrapper from 'components/widgets/menu/menu_wrapper'; import MainMenu from 'components/main_menu'; +import {isAdmin} from 'utils/utils.jsx'; + export default class SidebarHeaderDropdown extends React.PureComponent { static propTypes = { teamDescription: PropTypes.string.isRequired, @@ -23,8 +25,10 @@ export default class SidebarHeaderDropdown extends React.PureComponent { teamId: PropTypes.string.isRequired, currentUser: PropTypes.object, showTutorialTip: PropTypes.bool.isRequired, + firstAdminVisitMarketplaceStatus: PropTypes.bool.isRequired, actions: PropTypes.shape({ - openModal: PropTypes.func.isRequred, + openModal: PropTypes.func.isRequired, + getFirstAdminVisitMarketplaceStatus: PropTypes.func.isRequired, }).isRequired, }; @@ -74,6 +78,8 @@ export default class SidebarHeaderDropdown extends React.PureComponent { teamDisplayName={this.props.teamDisplayName} teamId={this.props.teamId} openModal={this.props.actions.openModal} + getFirstAdminVisitMarketplaceStatus={this.props.actions.getFirstAdminVisitMarketplaceStatus} + showUnread={isAdmin(this.props.currentUser.roles) && !this.props.firstAdminVisitMarketplaceStatus} /> diff --git a/components/legacy_sidebar/header/index.js b/components/legacy_sidebar/header/index.js index f7a5dc7ab619..98856942771e 100644 --- a/components/legacy_sidebar/header/index.js +++ b/components/legacy_sidebar/header/index.js @@ -3,7 +3,7 @@ import {connect} from 'react-redux'; -import {getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getConfig, getFirstAdminVisitMarketplaceStatus} from 'mattermost-redux/selectors/entities/general'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {getInt} from 'mattermost-redux/selectors/entities/preferences'; @@ -20,9 +20,12 @@ function mapStateToProps(state) { const showTutorialTip = getInt(state, Preferences.TUTORIAL_STEP, currentUser.id) === TutorialSteps.MENU_POPOVER && !Utils.isMobile(); + const firstAdminVisitMarketplaceStatus = getFirstAdminVisitMarketplaceStatus(state); + return { enableTutorial, showTutorialTip, + firstAdminVisitMarketplaceStatus, }; } diff --git a/components/legacy_sidebar/header/sidebar_header_dropdown_button.jsx b/components/legacy_sidebar/header/sidebar_header_dropdown_button.jsx index 047f87c7c295..f4ca0eb406f0 100644 --- a/components/legacy_sidebar/header/sidebar_header_dropdown_button.jsx +++ b/components/legacy_sidebar/header/sidebar_header_dropdown_button.jsx @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {Tooltip} from 'react-bootstrap'; -import {localizeMessage} from 'utils/utils.jsx'; +import {isAdmin, localizeMessage} from 'utils/utils.jsx'; import OverlayTrigger from 'components/overlay_trigger'; import MenuIcon from 'components/widgets/icons/menu_icon'; import Constants, {ModalIdentifiers} from 'utils/constants'; @@ -22,8 +22,18 @@ export default class SidebarHeaderDropdownButton extends React.PureComponent { currentUser: PropTypes.object.isRequired, teamDisplayName: PropTypes.string.isRequired, openModal: PropTypes.func, + getFirstAdminVisitMarketplaceStatus: PropTypes.func, + showUnread: PropTypes.bool, }; + constructor(props) { + super(props); + + this.state = { + showUnread: false, + }; + } + handleCustomStatusEmojiClick = (event) => { event.stopPropagation(); const customStatusInputModalData = { @@ -33,8 +43,31 @@ export default class SidebarHeaderDropdownButton extends React.PureComponent { this.props.openModal(customStatusInputModalData); } + getFirstAdminVisitMarketplaceStatus = async () => { + const {data} = await this.props.getFirstAdminVisitMarketplaceStatus(); + this.setState({showUnread: !data}); + } + + componentDidMount() { + const isSystemAdmin = isAdmin(this.props.currentUser.roles); + if (isSystemAdmin) { + this.getFirstAdminVisitMarketplaceStatus(); + } + } + + componentDidUpdate() { + const isSystemAdmin = isAdmin(this.props.currentUser.roles); + const {showUnread} = this.props; + if (isSystemAdmin && showUnread !== this.state.showUnread) { + // eslint-disable-next-line react/no-did-update-set-state + this.setState({showUnread: this.props.showUnread}); + } + } + render() { let tutorialTip = null; + let badge = null; + if (this.props.showTutorialTip) { tutorialTip = ( @@ -62,6 +95,15 @@ export default class SidebarHeaderDropdownButton extends React.PureComponent { ); } + if (this.state.showUnread) { + badge = ( + + + + + ); + } + return (
+ {badge}
diff --git a/components/main_menu/index.jsx b/components/main_menu/index.jsx index 7c66f3f90d0d..4399a9b24ae9 100644 --- a/components/main_menu/index.jsx +++ b/components/main_menu/index.jsx @@ -7,6 +7,7 @@ import {bindActionCreators} from 'redux'; import { getConfig, getLicense, + getFirstAdminVisitMarketplaceStatus, getSubscriptionStats as selectSubscriptionStats, } from 'mattermost-redux/selectors/entities/general'; import { @@ -99,6 +100,7 @@ function mapStateToProps(state) { showNextSteps: showNextSteps(state), isCloud: getLicense(state).Cloud === 'true', subscriptionStats: selectSubscriptionStats(state), // subscriptionStats are loaded in actions/views/root + firstAdminVisitMarketplaceStatus: getFirstAdminVisitMarketplaceStatus(state), }; } diff --git a/components/main_menu/main_menu.jsx b/components/main_menu/main_menu.jsx index 1b28a59d5352..7b0bbf484a39 100644 --- a/components/main_menu/main_menu.jsx +++ b/components/main_menu/main_menu.jsx @@ -64,6 +64,7 @@ class MainMenu extends React.PureComponent { showNextStepsTips: PropTypes.bool, isCloud: PropTypes.bool, subscriptionStats: PropTypes.object, + firstAdminVisitMarketplaceStatus: PropTypes.bool, actions: PropTypes.shape({ openModal: PropTypes.func.isRequred, showMentions: PropTypes.func, @@ -327,6 +328,7 @@ class MainMenu extends React.PureComponent { show={!this.props.mobile && this.props.enablePluginMarketplace} dialogType={MarketplaceModal} text={formatMessage({id: 'navbar_dropdown.marketplace', defaultMessage: 'Marketplace'})} + showUnread={!this.props.firstAdminVisitMarketplaceStatus} /> ; filterListing(filter: string): Promise<{error?: Error}>; + setFirstAdminVisitMarketplaceStatus(): Promise; } function mapDispatchToProps(dispatch: Dispatch) { @@ -39,6 +44,7 @@ function mapDispatchToProps(dispatch: Dispatch) { closeModal: () => closeModal(ModalIdentifiers.PLUGIN_MARKETPLACE), fetchListing, filterListing, + setFirstAdminVisitMarketplaceStatus, }, dispatch), }; } diff --git a/components/plugin_marketplace/marketplace_modal.test.tsx b/components/plugin_marketplace/marketplace_modal.test.tsx index f1173e10c6cc..357ace5b06b1 100644 --- a/components/plugin_marketplace/marketplace_modal.test.tsx +++ b/components/plugin_marketplace/marketplace_modal.test.tsx @@ -118,6 +118,7 @@ describe('components/marketplace/', () => { installedListing: [], pluginStatuses: {}, siteURL: 'http://example.com', + firstAdminVisitMarketplaceStatus: false, actions: { closeModal: jest.fn(), fetchListing: jest.fn(() => { @@ -126,6 +127,7 @@ describe('components/marketplace/', () => { filterListing: jest.fn(() => { return Promise.resolve({}); }), + setFirstAdminVisitMarketplaceStatus: jest.fn(), }, }; @@ -156,18 +158,18 @@ describe('components/marketplace/', () => { }); test('should fetch plugins when plugin status is changed', () => { - const fetchPlugins = baseProps.actions.fetchListing; + const fetchListing = baseProps.actions.fetchListing; const wrapper = shallow(); - expect(fetchPlugins).toBeCalledTimes(1); + expect(fetchListing).toBeCalledTimes(1); wrapper.setProps({...baseProps}); - expect(fetchPlugins).toBeCalledTimes(1); + expect(fetchListing).toBeCalledTimes(1); const status = { id: 'test', } as PluginStatusRedux; wrapper.setProps({...baseProps, pluginStatuses: {test: status}}); - expect(fetchPlugins).toBeCalledTimes(2); + expect(fetchListing).toBeCalledTimes(2); }); test('should render with error banner', () => { diff --git a/components/plugin_marketplace/marketplace_modal.tsx b/components/plugin_marketplace/marketplace_modal.tsx index 6cb4def3324e..aeb0a63e8dc7 100644 --- a/components/plugin_marketplace/marketplace_modal.tsx +++ b/components/plugin_marketplace/marketplace_modal.tsx @@ -97,10 +97,12 @@ export type MarketplaceModalProps = { installedListing: Array; siteURL: string; pluginStatuses?: Dictionary; + firstAdminVisitMarketplaceStatus: boolean; actions: { closeModal: () => void; fetchListing(localOnly?: boolean): Promise<{error?: Error}>; filterListing(filter: string): Promise<{error?: Error}>; + setFirstAdminVisitMarketplaceStatus: () => void; }; }; @@ -132,6 +134,11 @@ export class MarketplaceModal extends React.PureComponent = ({totalUsers, usersLimit, channel, setHeader, theme}: AddMembersButtonProps) => { - const isPrivate = channel.type === Constants.PRIVATE_CHANNEL; - const inviteUsers = totalUsers < usersLimit; - if (!totalUsers) { return (); } + + const isPrivate = channel.type === Constants.PRIVATE_CHANNEL; + const inviteUsers = totalUsers < usersLimit; + return ( inviteUsers && !isPrivate ? lessThanMaxFreeUsers(setHeader, theme) : moreThanMaxFreeUsers(channel, setHeader) ); diff --git a/components/post_view/combined_system_message/combined_system_message.tsx b/components/post_view/combined_system_message/combined_system_message.tsx index b89f786f29e4..dc0d686e09ad 100644 --- a/components/post_view/combined_system_message/combined_system_message.tsx +++ b/components/post_view/combined_system_message/combined_system_message.tsx @@ -174,7 +174,7 @@ export type Props = { currentUsername: string; intl: IntlShape; messageData: Array<{ - actorId: string; + actorId?: string; postType: string; userIds: string[]; }>; @@ -264,7 +264,7 @@ export class CombinedSystemMessage extends React.PureComponent { return usernames; } - renderFormattedMessage(postType: string, userIds: string[], actorId: string): JSX.Element { + renderFormattedMessage(postType: string, userIds: string[], actorId?: string): JSX.Element { const {formatMessage} = this.props.intl; const {currentUserId, currentUsername} = this.props; const usernames = this.getUsernamesByIds(userIds); @@ -320,7 +320,7 @@ export class CombinedSystemMessage extends React.PureComponent { ); } - renderMessage(postType: string, userIds: string[], actorId: string): JSX.Element { + renderMessage(postType: string, userIds: string[], actorId?: string): JSX.Element { return ( {this.renderFormattedMessage(postType, userIds, actorId)} diff --git a/components/post_view/post_body_additional_content/index.ts b/components/post_view/post_body_additional_content/index.ts index 57eefde650de..4d7a7fcf4d21 100644 --- a/components/post_view/post_body_additional_content/index.ts +++ b/components/post_view/post_body_additional_content/index.ts @@ -5,14 +5,13 @@ import {connect} from 'react-redux'; import {bindActionCreators, Dispatch} from 'redux'; import {GenericAction} from 'mattermost-redux/types/actions'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {toggleEmbedVisibility} from 'actions/post_actions'; import {isEmbedVisible} from 'selectors/posts'; import {GlobalState} from 'types/store'; import {PostWillRenderEmbedPluginComponent} from 'types/store/plugins'; -import {appsEnabled} from 'utils/apps'; - import PostBodyAdditionalContent, { Props, } from './post_body_additional_content'; diff --git a/components/sidebar/sidebar_channel/sidebar_direct_channel/__snapshots__/sidebar_direct_channel.test.tsx.snap b/components/sidebar/sidebar_channel/sidebar_direct_channel/__snapshots__/sidebar_direct_channel.test.tsx.snap index 8f87dc9d232c..75078769322b 100644 --- a/components/sidebar/sidebar_channel/sidebar_direct_channel/__snapshots__/sidebar_direct_channel.test.tsx.snap +++ b/components/sidebar/sidebar_channel/sidebar_direct_channel/__snapshots__/sidebar_direct_channel.test.tsx.snap @@ -73,6 +73,7 @@ exports[`components/sidebar/sidebar_channel/sidebar_direct_channel should match newStatusIcon={true} size="xs" src="/api/v4/users/user_id/image" + status="" statusClass="DirectChannel__status-icon " wrapperClass="DirectChannel__profile-picture" /> diff --git a/components/sidebar/sidebar_channel/sidebar_direct_channel/sidebar_direct_channel.tsx b/components/sidebar/sidebar_channel/sidebar_direct_channel/sidebar_direct_channel.tsx index 53c0477209b1..b50c207c36c2 100644 --- a/components/sidebar/sidebar_channel/sidebar_direct_channel/sidebar_direct_channel.tsx +++ b/components/sidebar/sidebar_channel/sidebar_direct_channel/sidebar_direct_channel.tsx @@ -99,7 +99,7 @@ class SidebarDirectChannel extends React.PureComponent { GlobalState; } -export const ParseState = keyMirror({ - Start: null, - Command: null, - EndCommand: null, - CommandSeparator: null, - StartParameter: null, - ParameterSeparator: null, - Flag1: null, - Flag: null, - FlagValueSeparator: null, - StartValue: null, - NonspaceValue: null, - QuotedValue: null, - TickValue: null, - EndValue: null, - EndQuotedValue: null, - EndTickedValue: null, - Error: null, -}); +export enum ParseState { + Start = 'Start', + Command = 'Command', + EndCommand = 'EndCommand', + CommandSeparator = 'CommandSeparator', + StartParameter = 'StartParameter', + ParameterSeparator = 'ParameterSeparator', + Flag1 = 'Flag1', + Flag = 'Flag', + FlagValueSeparator = 'FlagValueSeparator', + StartValue = 'StartValue', + NonspaceValue = 'NonspaceValue', + QuotedValue = 'QuotedValue', + TickValue = 'TickValue', + EndValue = 'EndValue', + EndQuotedValue = 'EndQuotedValue', + EndTickedValue = 'EndTickedValue', + Error = 'Error', +} interface FormsCache { getForm: (location: string, binding: AppBinding) => Promise; @@ -72,8 +71,10 @@ interface Intl { formatMessage(config: {id: string; defaultMessage: string}, values?: {[name: string]: any}): string; } +const getCommandBindings = makeAppBindingsSelector(AppBindingLocations.COMMAND); + export class ParsedCommand { - state: string = ParseState.Start; + state = ParseState.Start; command: string; i = 0; incomplete = ''; @@ -94,13 +95,13 @@ export class ParsedCommand { this.intl = intl; } - asError = (message: string): ParsedCommand => { + private asError = (message: string): ParsedCommand => { this.state = ParseState.Error; this.error = message; return this; }; - errorMessage = (): string => { + public errorMessage = (): string => { return this.intl.formatMessage({ id: 'apps.error.parser', defaultMessage: 'Parsing error: {error}.\n```\n{command}\n{space}^\n```', @@ -112,7 +113,7 @@ export class ParsedCommand { } // matchBinding finds the closest matching command binding. - matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise => { + public matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise => { if (commandBindings.length === 0) { return this.asError(this.intl.formatMessage({ id: 'apps.error.parser.no_bindings', @@ -232,7 +233,7 @@ export class ParsedCommand { } // parseForm parses the rest of the command using the previously matched form. - parseForm = (autocompleteMode = false): ParsedCommand => { + public parseForm = (autocompleteMode = false): ParsedCommand => { if (this.state === ParseState.Error || !this.form) { return this; } @@ -546,7 +547,7 @@ export class AppCommandParser { forms: {[location: string]: AppForm} = {}; constructor(store: Store|null, intl: Intl, channelID: string, rootPostID = '') { - this.store = store || getStore() as Store; + this.store = store || getStore(); this.channelID = channelID; this.rootPostID = rootPostID; this.intl = intl; @@ -593,8 +594,9 @@ export class AppCommandParser { const result: AutocompleteSuggestion[] = []; const bindings = this.getCommandBindings(); + for (const binding of bindings) { - let base = binding.app_id; + let base = binding.label; if (!base) { continue; } @@ -602,10 +604,11 @@ export class AppCommandParser { if (base[0] !== '/') { base = '/' + base; } + if (base.startsWith(command)) { result.push({ + Complete: binding.label, Suggestion: base, - Complete: base.substring(1), Description: binding.description || '', Hint: binding.hint || '', IconData: binding.icon || '', @@ -661,7 +664,7 @@ export class AppCommandParser { } // composeCallFromParsed creates the form submission call - composeCallFromParsed = async (parsed: ParsedCommand): Promise => { + private composeCallFromParsed = async (parsed: ParsedCommand): Promise => { if (!parsed.binding) { return null; } @@ -682,7 +685,7 @@ export class AppCommandParser { return createCallRequest(call, context, {}, values, parsed.command); } - expandOptions = async (parsed: ParsedCommand, values: AppCallValues) => { + private expandOptions = async (parsed: ParsedCommand, values: AppCallValues) => { if (!parsed.form?.fields) { return true; } @@ -767,7 +770,7 @@ export class AppCommandParser { } // decorateSuggestionComplete applies the necessary modifications for a suggestion to be processed - decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => { + private decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => { if (choice.Complete && choice.Complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) { return choice as AutocompleteSuggestion; } @@ -789,27 +792,29 @@ export class AppCommandParser { // getCommandBindings returns the commands in the redux store. // They are grouped by app id since each app has one base command - getCommandBindings = (): AppBinding[] => { - const bindings = getAppsBindings(this.store.getState(), AppBindingLocations.COMMAND); + private getCommandBindings = (): AppBinding[] => { + const bindings = getCommandBindings(this.store.getState()); return bindings; } // getChannel gets the channel in which the user is typing the command - getChannel = (): Channel | null => { + private getChannel = (): Channel | null => { const state = this.store.getState(); return getChannel(state, this.channelID); } - setChannelContext = (channelID: string, rootPostID?: string) => { + public setChannelContext = (channelID: string, rootPostID?: string) => { this.channelID = channelID; this.rootPostID = rootPostID; } - // isAppCommand determines if subcommand/form suggestions need to be returned - isAppCommand = (pretext: string): boolean => { + // isAppCommand determines if subcommand/form suggestions need to be returned. + // When this returns true, the caller knows that the parser should handle all suggestions for the current command string. + // When it returns false, the caller should call getSuggestionsBase() to check if there are any base commands that match the command string. + public isAppCommand = (pretext: string): boolean => { const command = pretext.toLowerCase(); for (const binding of this.getCommandBindings()) { - let base = binding.app_id; + let base = binding.label; if (!base) { continue; } @@ -826,7 +831,7 @@ export class AppCommandParser { } // getAppContext collects post/channel/team info for performing calls - getAppContext = (appID: string): AppContext => { + private getAppContext = (appID: string): AppContext => { const context: AppContext = { app_id: appID, location: AppBindingLocations.COMMAND, @@ -845,7 +850,7 @@ export class AppCommandParser { } // fetchForm unconditionaly retrieves the form for the given binding (subcommand) - fetchForm = async (binding: AppBinding): Promise => { + private fetchForm = async (binding: AppBinding): Promise => { if (!binding.call) { return undefined; } @@ -888,7 +893,7 @@ export class AppCommandParser { return callResponse.form; } - getForm = async (location: string, binding: AppBinding): Promise => { + public getForm = async (location: string, binding: AppBinding): Promise => { const form = this.forms[location]; if (form) { return form; @@ -902,7 +907,7 @@ export class AppCommandParser { } // displayError shows an error that was caught by the parser - displayError = (err: any): void => { + private displayError = (err: any): void => { let errStr = err as string; if (err.message) { errStr = err.message; @@ -911,7 +916,7 @@ export class AppCommandParser { } // getSuggestionsForSubCommands returns suggestions for a subcommand's name - getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + private getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { if (!parsed.binding?.bindings?.length) { return []; } @@ -934,7 +939,7 @@ export class AppCommandParser { } // getParameterSuggestions computes suggestions for positional argument values, flag names, and flag argument values - getParameterSuggestions = async (parsed: ParsedCommand): Promise => { + private getParameterSuggestions = async (parsed: ParsedCommand): Promise => { switch (parsed.state) { case ParseState.StartParameter: { // see if there's a matching positional field @@ -964,7 +969,7 @@ export class AppCommandParser { } // getMissingFields collects the required fields that were not supplied in a submission - getMissingFields = (parsed: ParsedCommand): AppField[] => { + private getMissingFields = (parsed: ParsedCommand): AppField[] => { const form = parsed.form; if (!form) { return []; @@ -984,7 +989,7 @@ export class AppCommandParser { } // getFlagNameSuggestions returns suggestions for flag names - getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + private getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { if (!parsed.form || !parsed.form.fields || !parsed.form.fields.length) { return []; } @@ -1021,7 +1026,7 @@ export class AppCommandParser { } // getSuggestionsForField gets suggestions for a positional or flag field value - getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { + private getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { if (!parsed || !parsed.field) { return []; } @@ -1055,7 +1060,7 @@ export class AppCommandParser { } // getStaticSelectSuggestions returns suggestions specified in the field's options property - getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => { + private getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => { const f = parsed.field as AutocompleteStaticSelect; const opts = f.options?.filter((opt) => opt.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())); @@ -1090,7 +1095,7 @@ export class AppCommandParser { } // getDynamicSelectSuggestions fetches and returns suggestions from the server - getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { + private getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise => { const f = parsed.field; if (!f) { // Should never happen @@ -1171,7 +1176,7 @@ export class AppCommandParser { }); } - makeSuggestionError = (message: string): AutocompleteSuggestion[] => { + private makeSuggestionError = (message: string): AutocompleteSuggestion[] => { const errMsg = this.intl.formatMessage({ id: 'apps.error', defaultMessage: 'Error: {error}', @@ -1188,7 +1193,7 @@ export class AppCommandParser { } // getUserSuggestions returns a suggestion with `@` if the user has not started typing - getUserSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + private getUserSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { if (parsed.incomplete.trim().length === 0) { return [{ Complete: '', @@ -1203,7 +1208,7 @@ export class AppCommandParser { } // getChannelSuggestions returns a suggestion with `~` if the user has not started typing - getChannelSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + private getChannelSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { if (parsed.incomplete.trim().length === 0) { return [{ Complete: '', @@ -1218,7 +1223,7 @@ export class AppCommandParser { } // getBooleanSuggestions returns true/false suggestions - getBooleanSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { + private getBooleanSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => { const suggestions: AutocompleteSuggestion[] = []; if ('true'.startsWith(parsed.incomplete)) { diff --git a/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts b/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts index 3a6aae2c474c..5d67dff97a1e 100644 --- a/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts +++ b/components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies.ts @@ -41,8 +41,7 @@ export { AppCallResponseTypes, } from 'mattermost-redux/constants/apps'; -import {getAppBindings as getAppsBindings} from 'mattermost-redux/selectors/entities/apps'; -export {getAppsBindings}; +export {makeAppBindingsSelector} from 'mattermost-redux/selectors/entities/apps'; export {getPost} from 'mattermost-redux/selectors/entities/posts'; export {getChannel, getCurrentChannel, getChannelByName as selectChannelByName} from 'mattermost-redux/selectors/entities/channels'; @@ -52,9 +51,6 @@ export {getUserByUsername as selectUserByUsername} from 'mattermost-redux/select export {getUserByUsername} from 'mattermost-redux/actions/users'; export {getChannelByNameAndTeamName} from 'mattermost-redux/actions/channels'; -import keyMirror from 'mattermost-redux/utils/key_mirror'; -export {keyMirror}; - export {doAppCall} from 'actions/apps'; import {sendEphemeralPost} from 'actions/global_actions'; diff --git a/components/suggestion/command_provider/app_command_parser/tests/app_command_parser_test_data.ts b/components/suggestion/command_provider/app_command_parser/tests/app_command_parser_test_data.ts index d372565f2e9a..6b977fb59ecb 100644 --- a/components/suggestion/command_provider/app_command_parser/tests/app_command_parser_test_data.ts +++ b/components/suggestion/command_provider/app_command_parser/tests/app_command_parser_test_data.ts @@ -89,7 +89,10 @@ export const reduxTestState = { general: { license: {IsLicensed: 'false'}, serverVersion: '5.25.0', - config: {PostEditTimeLimit: -1}, + config: { + PostEditTimeLimit: -1, + FeatureFlagAppsEnabled: 'true', + }, }, }, }; diff --git a/components/suggestion/command_provider/command_provider.tsx b/components/suggestion/command_provider/command_provider.tsx index b93e8fb5daab..68f24a844481 100644 --- a/components/suggestion/command_provider/command_provider.tsx +++ b/components/suggestion/command_provider/command_provider.tsx @@ -8,6 +8,7 @@ import {Store} from 'redux'; import {Client4} from 'mattermost-redux/client'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getChannel, getCurrentChannel, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; +import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {AutocompleteSuggestion} from 'mattermost-redux/types/integrations'; import {Post} from 'mattermost-redux/types/posts'; @@ -22,8 +23,6 @@ import {Constants} from 'utils/constants'; import Suggestion from '../suggestion'; import Provider from '../provider'; -import {appsEnabled} from 'utils/apps'; - import {GlobalState} from 'types/store'; import {AppCommandParser} from './app_command_parser/app_command_parser'; diff --git a/components/suggestion/emoticon_provider.jsx b/components/suggestion/emoticon_provider.jsx index acb50ad80653..a88b30d19db0 100644 --- a/components/suggestion/emoticon_provider.jsx +++ b/components/suggestion/emoticon_provider.jsx @@ -60,7 +60,7 @@ export default class EmoticonProvider extends Provider { } handlePretextChanged(pretext, resultsCallback) { // Look for the potential emoticons at the start of the text, after whitespace, and at the start of emoji reaction commands - const captured = (/(^|\s|^\+|^-)(:([^:\s]*))$/g).exec(pretext); + const captured = (/(^|\s|^\+|^-)(:([^:\s]*))$/g).exec(pretext.toLowerCase()); if (!captured) { return false; } diff --git a/components/toggle_modal_button_redux/toggle_modal_button_redux.jsx b/components/toggle_modal_button_redux/toggle_modal_button_redux.jsx index 4042a39ffffb..7fe3a8d989be 100644 --- a/components/toggle_modal_button_redux/toggle_modal_button_redux.jsx +++ b/components/toggle_modal_button_redux/toggle_modal_button_redux.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; + import {injectIntl} from 'react-intl'; import {intlShape} from 'utils/react_intl'; @@ -17,6 +18,7 @@ class ModalToggleButtonRedux extends React.PureComponent { intl: intlShape.isRequired, onClick: PropTypes.func, className: PropTypes.string, + showUnread: PropTypes.bool, actions: PropTypes.shape({ openModal: PropTypes.func.isRequired, }).isRequired, @@ -55,12 +57,20 @@ class ModalToggleButtonRedux extends React.PureComponent { const ariaLabel = formatMessage({id: 'accessibility.button.dialog', defaultMessage: '{dialogName} dialog'}, {dialogName: props.accessibilityLabel}); + let badge = null; + if (this.props.showUnread) { + badge = ( + + ); + } + // removing these three props since they are not valid props on buttons delete props.modalId; delete props.dialogType; delete props.dialogProps; delete props.accessibilityLabel; delete props.actions; + delete props.showUnread; // allow callers to provide an onClick which will be called before the modal is shown let clickHandler = () => this.show(); @@ -81,6 +91,7 @@ class ModalToggleButtonRedux extends React.PureComponent { onClick={clickHandler} > {children} + {badge} ); } diff --git a/components/widgets/menu/menu_items/menu_item_toggle_modal_redux.tsx b/components/widgets/menu/menu_items/menu_item_toggle_modal_redux.tsx index 8dbff2b3a75d..7abf0cb56f62 100644 --- a/components/widgets/menu/menu_items/menu_item_toggle_modal_redux.tsx +++ b/components/widgets/menu/menu_items/menu_item_toggle_modal_redux.tsx @@ -21,9 +21,10 @@ type Props = { className?: string; children?: React.ReactNode; sibling?: React.ReactNode; + showUnread?: boolean; } -export const MenuItemToggleModalReduxImpl: React.FC = ({modalId, dialogType, dialogProps, text, accessibilityLabel, extraText, children, className, sibling}: Props) => ( +export const MenuItemToggleModalReduxImpl: React.FC = ({modalId, dialogType, dialogProps, text, accessibilityLabel, extraText, children, className, sibling, showUnread}: Props) => ( <> = ({modalId, dialogTy 'MenuItem__with-help': extraText, [`${className}`]: className, })} + showUnread={showUnread} > {text && {text}} {extraText && {extraText}} diff --git a/e2e/cypress/integration/bot_accounts/in_lists_2_spec.js b/e2e/cypress/integration/bot_accounts/in_lists_2_spec.js index 3480ede9f190..f10edfce4906 100644 --- a/e2e/cypress/integration/bot_accounts/in_lists_2_spec.js +++ b/e2e/cypress/integration/bot_accounts/in_lists_2_spec.js @@ -9,8 +9,6 @@ // Group: @bot_accounts -import {zip, sortBy} from 'lodash'; - import {createBotPatch} from '../../support/api/bots'; import {generateRandomUser} from '../../support/api/user'; @@ -18,8 +16,6 @@ describe('Bots in lists', () => { let team; let channel; let testUser; - let bots; - let createdUsers; const STATUS_PRIORITY = { online: 0, @@ -43,14 +39,14 @@ describe('Bots in lists', () => { cy.makeClient().then(async (client) => { // # Create bots - bots = await Promise.all([ + const bots = await Promise.all([ client.createBot(createBotPatch()), client.createBot(createBotPatch()), client.createBot(createBotPatch()), ]); // # Create users - createdUsers = await Promise.all([ + const createdUsers = await Promise.all([ client.createUser(generateRandomUser()), client.createUser(generateRandomUser()), ]); @@ -80,15 +76,15 @@ describe('Bots in lists', () => { cy.get('#member-list-popover .more-modal__row .more-modal__name').then(async ($query) => { // # Extract usernames from jQuery collection - const usernames = $query.toArray().map(({innerText}) => innerText); + const usernames = $query.toArray().map(({innerText}) => innerText.split('\n')[0]); // # Get users const profiles = await client.getProfilesByUsernames(usernames); const statuses = await client.getStatusesByIds(profiles.map((user) => user.id)); - const users = zip(profiles, statuses).map(([profile, status]) => ({...profile, ...status})); + const users = Cypress._.zip(profiles, statuses).map(([profile, status]) => ({...profile, ...status})); // # Sort 'em - const sortedUsers = sortBy(users, [ + const sortedUsers = Cypress._.sortBy(users, [ ({is_bot: isBot}) => (isBot ? 1 : 0), // users first ({status}) => STATUS_PRIORITY[status], ({username}) => username, diff --git a/e2e/cypress/integration/channel_sidebar/category_sorting_spec.js b/e2e/cypress/integration/channel_sidebar/category_sorting_spec.js new file mode 100644 index 000000000000..163e1fe3864d --- /dev/null +++ b/e2e/cypress/integration/channel_sidebar/category_sorting_spec.js @@ -0,0 +1,300 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// *************************************************************** +// - [#] indicates a test step (e.g. # Go to a page) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element ID when selecting an element. Create one if none. +// *************************************************************** + +// Group: @channel_sidebar + +import * as TIMEOUTS from '../../fixtures/timeouts'; +import {getRandomId} from '../../utils'; + +let testTeam; +let testUser; +let testChannel; + +describe('Category sorting', () => { + beforeEach(() => { + // # Login as test user and visit town-square + cy.apiAdminLogin(); + cy.apiInitSetup({loginAfter: true}).then(({team, user, channel}) => { + testTeam = team; + testUser = user; + testChannel = channel; + cy.visit(`/${team.name}/channels/town-square`); + }); + }); + + it('MM-T3834 Category sorting', () => { + const channelNames = []; + const categoryName = createCategoryFromSidebarMenu(); + + // # Create 5 channels and add them to a custom category + for (let i = 0; i < 5; i++) { + channelNames.push(createChannelAndAddToCategory(categoryName)); + cy.get('#SidebarContainer .scrollbar--view').scrollTo('bottom', {ensureScrollable: false}); + } + + // # Sort alphabetically + cy.get(`.SidebarChannelGroupHeader:contains(${categoryName})`).within(() => { + // # Open dropdown next to channel name + cy.get('.SidebarMenu').invoke('show').get('.SidebarMenu_menuButton').should('be.visible').click({force: true}); + + // # Open sub menu + cy.get('#sortChannels').parent('.SubMenuItem').trigger('mouseover'); + + // # Click on sort alphabetically + cy.get('#sortAlphabetical').parent('.SubMenuItem').click(); + }); + + // * Verify channels are sorted alphabetically + verifyAlphabeticalSortingOrder(categoryName, channelNames.length); + + // # Add another channel + channelNames.push(createChannelAndAddToCategory(categoryName)); + cy.get('#SidebarContainer .scrollbar--view').scrollTo('bottom', {ensureScrollable: false}); + + // * Verify channels are still sorted alphabetically + verifyAlphabeticalSortingOrder(categoryName, channelNames.length); + + // # Sort by recency + cy.get(`.SidebarChannelGroupHeader:contains(${categoryName})`).within(() => { + // # Open dropdown next to channel name + cy.get('.SidebarMenu').invoke('show').get('.SidebarMenu_menuButton').should('be.visible').click({force: true}); + + // # Open sub menu + cy.get('#sortChannels').parent('.SubMenuItem').trigger('mouseover'); + + // # Click on sort by recency + cy.get('#sortByMostRecent').parent('.SubMenuItem').click(); + }); + + // # Sort channel names in reverse order that they were created (ie. most recent to least) + let sortedByRecencyChannelNames = channelNames.concat().reverse(); + for (let i = 0; i < channelNames.length; i++) { + // * Verify that the channels are in reverse order that they were created + cy.get(`.SidebarChannelGroup:contains(${categoryName}) .NavGroupContent li:nth-child(${i + 1}) a[id^="sidebarItem_${sortedByRecencyChannelNames[i]}"]`).should('be.visible'); + } + + // # Add another channel + channelNames.push(createChannelAndAddToCategory(categoryName)); + cy.get('#SidebarContainer .scrollbar--view').scrollTo('bottom', {ensureScrollable: false}); + + // # Sort channel names in reverse order that they were created (ie. most recent to least) + sortedByRecencyChannelNames = channelNames.concat().reverse(); + for (let i = 0; i < channelNames.length; i++) { + // * Verify that the channels are still in reverse order that they were created + cy.get(`.SidebarChannelGroup:contains(${categoryName}) .NavGroupContent li:nth-child(${i + 1}) a[id^="sidebarItem_${sortedByRecencyChannelNames[i]}"]`).should('be.visible'); + } + + // # Remove the oldest from the category and put it into Favourites + cy.get(`.SidebarChannelGroup:contains(${categoryName}) .NavGroupContent a[id^="sidebarItem_${channelNames[0]}"]`).should('be.visible').within(() => { + // # Open dropdown next to channel name + cy.get('.SidebarMenu').invoke('show').get('.SidebarMenu_menuButton').should('be.visible').click({force: true}); + + // # Open sub menu + cy.get('li[id^="moveTo-"]').trigger('mouseover'); + + // # Click on move to new category + cy.get('div.SubMenuItemContainer:nth-child(1) li').click(); + }); + + // * Verify the channel is now in Favourites + cy.get(`.SidebarChannelGroup:contains(FAVORITES) .NavGroupContent a[id^="sidebarItem_${channelNames[0]}"]`).should('be.visible'); + channelNames.shift(); + + // # Sort manually + cy.get(`.SidebarChannelGroupHeader:contains(${categoryName})`).within(() => { + // # Open dropdown next to channel name + cy.get('.SidebarMenu').invoke('show').get('.SidebarMenu_menuButton').should('be.visible').click({force: true}); + + // # Open sub menu + cy.get('#sortChannels').parent('.SubMenuItem').trigger('mouseover'); + + // # Click on sort manually + cy.get('#sortManual').parent('.SubMenuItem').click(); + }); + + // # Add another channel + channelNames.push(createChannelAndAddToCategory(categoryName)); + cy.get('#SidebarContainer .scrollbar--view').scrollTo('bottom', {ensureScrollable: false}); + + // * Verify that the channel has been placed at the bottom of the category + cy.get(`.SidebarChannelGroup:contains(${categoryName}) .NavGroupContent li:nth-child(1) a[id^="sidebarItem_${channelNames[channelNames.length - 1]}"]`).should('be.visible'); + }); + + it('MM-T3916 Create Category character limit', () => { + // # Click on the sidebar menu dropdown + cy.findByLabelText('Add Channel Dropdown').click(); + + // # Click on create category link + cy.findByText('Create New Category').should('be.visible').click(); + + // # Add a name 26 characters in length e.g `abcdefghijklmnopqrstuvwxyz` + cy.get('#editCategoryModal').should('be.visible').wait(TIMEOUTS.HALF_SEC).within(() => { + cy.findByText('Create New Category').should('be.visible'); + + // # Enter category name + cy.findByPlaceholderText('Name your category').should('be.visible').type('abcdefghijklmnopqrstuvwxyz'); + }); + + // * Verify error state and negative character count at the end of the textbox based on the number of characters the user has exceeded + cy.get('#editCategoryModal .MaxLengthInput.has-error').should('be.visible'); + cy.get('#editCategoryModal .MaxLengthInput__validation').should('be.visible').should('contain', '-4'); + + // * Verify Create button is disabled. + cy.get('#editCategoryModal .GenericModal__button.confirm').should('be.visible').should('be.disabled'); + + // # Use backspace to remove 4 characters + cy.get('#editCategoryModal .MaxLengthInput').should('be.visible').type('{backspace}{backspace}{backspace}{backspace}'); + + // * Verify error state and negative character count at the end of the textbox are no longer displaying + cy.get('#editCategoryModal .MaxLengthInput.has-error').should('not.be.visible'); + cy.get('#editCategoryModal .MaxLengthInput__validation').should('not.be.visible'); + + // * Verify Create button is enabled + cy.get('#editCategoryModal .GenericModal__button.confirm').should('be.visible').should('not.be.disabled'); + + // Click Create + cy.get('#editCategoryModal .GenericModal__button.confirm').should('be.visible').click(); + + // Verify new category is created + cy.findByLabelText('abcdefghijklmnopqrstuv').should('be.visible'); + }); + + it('MM-T3864 Sticky category headers', () => { + const categoryName = createCategoryFromSidebarMenu(); + + // # Move test channel to Favourites + cy.get(`#sidebarItem_${testChannel.name}`).parent().then((element) => { + // # Get id of the channel + const id = element[0].getAttribute('data-rbd-draggable-id'); + cy.get(`#sidebarItem_${testChannel.name}`).parent('li').within(() => { + // # Open dropdown next to channel name + cy.get('.SidebarMenu').invoke('show').get('.SidebarMenu_menuButton').should('be.visible').click({force: true}); + + // # Favourite the channel + cy.get(`#favorite-${id} button`).should('be.visible').click({force: true}); + }); + }); + + // # Create 15 channels and add them to a custom category + for (let i = 0; i < 15; i++) { + createChannelAndAddToCategory(categoryName); + cy.get('#SidebarContainer .scrollbar--view').scrollTo('bottom', {ensureScrollable: false}); + } + + // # Create 10 channels and add them to Favourites + for (let i = 0; i < 10; i++) { + createChannelAndAddToFavourites(); + cy.get('#SidebarContainer .scrollbar--view').scrollTo('bottom', {ensureScrollable: false}); + } + + // # Scroll to the center of the channel list + cy.get('#SidebarContainer .scrollbar--view').scrollTo('center', {ensureScrollable: false}); + + // * Verify that both the 'More Unreads' label and the category header are visible + cy.get('#unreadIndicatorTop').should('be.visible'); + cy.get('#SidebarContainer .SidebarChannelGroupHeader:contains(FAVORITES)').should('be.visible'); + + // # Scroll to the bottom of the list + cy.get('#SidebarContainer .scrollbar--view').scrollTo('bottom', {ensureScrollable: false}); + + // * Verify that the 'More Unreads' label is still visible but the category is not + cy.get('#unreadIndicatorTop').should('be.visible'); + cy.get('#SidebarContainer .SidebarChannelGroupHeader:contains(FAVORITES)').should('not.be.visible'); + }); +}); + +function createChannelAndAddToCategory(categoryName) { + const channelName = `channel-${getRandomId()}`; + const userId = testUser.id; + cy.apiCreateChannel(testTeam.id, channelName, 'New Test Channel').then(({channel}) => { + // # Add the user to the channel + cy.apiAddUserToChannel(channel.id, userId).then(() => { + // # Move to a new category + cy.get(`#sidebarItem_${channel.name}`).parent().then((element) => { + // # Get id of the channel + const id = element[0].getAttribute('data-rbd-draggable-id'); + cy.get(`.SidebarChannelGroup:contains(${categoryName})`).should('be.visible').then((categoryElement) => { + const categoryId = categoryElement[0].getAttribute('data-rbd-draggable-id'); + + cy.get(`#sidebarItem_${channel.name}`).parent('li').within(() => { + // # Open dropdown next to channel name + cy.get('.SidebarMenu').invoke('show').get('.SidebarMenu_menuButton').should('be.visible').click({force: true}); + + // # Open sub menu + cy.get(`#moveTo-${id}`).parent('.SubMenuItem').trigger('mouseover'); + + // # Click on move to new category + cy.get(`#moveToCategory-${id}-${categoryId}`).parent('.SubMenuItem').click(); + }); + }); + }); + }); + }); + return channelName; +} + +function createChannelAndAddToFavourites() { + const userId = testUser.id; + cy.apiCreateChannel(testTeam.id, `channel-${getRandomId()}`, 'New Test Channel').then(({channel}) => { + // # Add the user to the channel + cy.apiAddUserToChannel(channel.id, userId).then(() => { + // # Move to a new category + cy.get(`#sidebarItem_${channel.name}`).parent().then((element) => { + // # Get id of the channel + const id = element[0].getAttribute('data-rbd-draggable-id'); + cy.get(`#sidebarItem_${channel.name}`).parent('li').within(() => { + // # Open dropdown next to channel name + cy.get('.SidebarMenu').invoke('show').get('.SidebarMenu_menuButton').should('be.visible').click({force: true}); + + // # Favourite the channel + cy.get(`#favorite-${id} button`).should('be.visible').click({force: true}); + }); + }); + }); + }); +} + +function verifyAlphabeticalSortingOrder(categoryName, length) { + // # Go through each channel to get its name + const sortedAlphabeticalChannelNames = []; + for (let i = 0; i < length; i++) { + // Grab the elements in the order that they appear in the sidebar + cy.get(`.SidebarChannelGroup:contains(${categoryName}) .NavGroupContent li:nth-child(${i + 1}) .SidebarChannelLinkLabel`).should('be.visible').invoke('text').then((text) => { + sortedAlphabeticalChannelNames.push(text); + + // # Sort the names manually + const comparedSortedChannelNames = sortedAlphabeticalChannelNames.concat().sort((a, b) => a.localeCompare(b, 'en', {numeric: true})); + + // * Verify that the sorted order matches the order they were already in + assert.deepEqual(sortedAlphabeticalChannelNames, comparedSortedChannelNames); + }); + } +} + +function createCategoryFromSidebarMenu() { + // # Start with a new category + const categoryName = `category-${getRandomId()}`; + + // # Click on the sidebar menu dropdown + cy.findByLabelText('Add Channel Dropdown').click(); + + // # Click on create category link + cy.findByText('Create New Category').should('be.visible').click(); + + // # Verify that Create Category modal has shown up. + // # Wait for a while until the modal has fully loaded, especially during first-time access. + cy.get('#editCategoryModal').should('be.visible').wait(TIMEOUTS.HALF_SEC).within(() => { + cy.findByText('Create New Category').should('be.visible'); + + // # Enter category name and hit enter + cy.findByPlaceholderText('Name your category').should('be.visible').type(categoryName).type('{enter}'); + }); + + return categoryName; +} diff --git a/e2e/cypress/integration/channel_sidebar/dm_gm_filtering_sorting_spec.js b/e2e/cypress/integration/channel_sidebar/dm_gm_filtering_sorting_spec.js new file mode 100644 index 000000000000..0145a4b0be53 --- /dev/null +++ b/e2e/cypress/integration/channel_sidebar/dm_gm_filtering_sorting_spec.js @@ -0,0 +1,146 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +// *************************************************************** +// - [#] indicates a test step (e.g. # Go to a page) +// - [*] indicates an assertion (e.g. * Check the title) +// - Use element ID when selecting an element. Create one if none. +// *************************************************************** + +// Group: @dm_category + +import {getAdminAccount} from '../../support/env'; + +describe('DM/GM filtering and sorting', () => { + const sysadmin = getAdminAccount(); + let testUser; + before(() => { + // # Login as test user and visit town-square + cy.apiInitSetup({loginAfter: true}).then(({team, user}) => { + testUser = user; + cy.visit(`/${team.name}/channels/town-square`); + + // # upgrade user to sys admin role + cy.externalRequest({user: sysadmin, method: 'put', path: `users/${user.id}/roles`, data: {roles: 'system_user system_admin'}}); + }); + }); + + it('MM-T2003 Number of direct messages to show', () => { + const receivingUser = testUser; + + // * Verify that we can see the sidebar + cy.get('#headerTeamName').should('be.visible'); + + // # Collapse the DM category (so that we can check all unread DMs quickly without the sidebar scrolling being an issue) + cy.get('button.SidebarChannelGroupHeader_groupButton:contains(DIRECT MESSAGES)').should('be.visible').click(); + + // # Create 41 DMs (ie. one over the max displayable read limit) + for (let i = 0; i < 41; i++) { + // # Create a new user to have a DM with + cy.apiCreateUser().then(({user}) => { + cy.apiCreateDirectChannel([receivingUser.id, user.id]).then(({channel}) => { + // # Post a message as the new user + cy.postMessageAs({ + sender: user, + message: `Hey ${receivingUser.username}`, + channelId: channel.id, + }); + + // * Verify that the DM count is now correct + cy.get('.SidebarChannelGroup:contains(DIRECT MESSAGES) a[id^="sidebarItem"]').should('have.length', Math.min(i + 1, 20)); + + // # Click on the new DM channel to mark it read + cy.get(`#sidebarItem_${channel.name}`).should('be.visible').click(); + }); + }); + } + + // # Expand the DM category (so that we can check all unread DMs quickly without the sidebar scrolling being an issue) + cy.get('button.SidebarChannelGroupHeader_groupButton:contains(DIRECT MESSAGES)').should('be.visible').click(); + + // * Verify that there are 20 DMs shown in the sidebar + cy.get('.SidebarChannelGroup:contains(DIRECT MESSAGES) a[id^="sidebarItem"]').should('have.length', 20); + + // # Go to Sidebar Settings + navigateToSidebarSettings(); + + // * Verify that the default setting for DMs shown is 20 + cy.get('#limitVisibleGMsDMsDesc').should('be.visible').should('contain', '20'); + + // # Click Edit + cy.get('#limitVisibleGMsDMsEdit').should('be.visible').click(); + + // # Change the value to All Direct Messages + cy.get('#limitVisibleGMsDMs').should('be.visible').click(); + cy.get('.react-select__option:contains(All Direct Messages)').should('be.visible').click(); + + // # Click Save + cy.get('#saveSetting').should('be.visible').click(); + + // # Close Account Settings + cy.get('#accountSettingsHeader > .close').click(); + + // * Verify that there are 41 DMs shown in the sidebar + cy.get('.SidebarChannelGroup:contains(DIRECT MESSAGES) a[id^="sidebarItem"]').should('have.length', 41); + + // # Go to Sidebar Settings + navigateToSidebarSettings(); + + // # Click Edit + cy.get('#limitVisibleGMsDMsEdit').should('be.visible').click(); + + // # Change the value to 10 + cy.get('#limitVisibleGMsDMs').should('be.visible').click(); + cy.get('.react-select__option:contains(10)').should('be.visible').click(); + + // # Click Save + cy.get('#saveSetting').should('be.visible').click(); + + // # Close Account Settings + cy.get('#accountSettingsHeader > .close').click(); + + // * Verify that there are 10 DMs shown in the sidebar + cy.get('.SidebarChannelGroup:contains(DIRECT MESSAGES) a[id^="sidebarItem"]').should('have.length', 10); + }); + + it('MM-T3832 DMs/GMs should not be removed from the sidebar when only viewed (no message)', () => { + cy.apiCreateUser().then(({user}) => { + cy.apiCreateDirectChannel([testUser.id, user.id]).then(({channel}) => { + // # Post a message as the new user + cy.postMessageAs({ + sender: user, + message: `Hey ${testUser.username}`, + channelId: channel.id, + }); + + // # Click on the new DM channel to mark it read + cy.get(`#sidebarItem_${channel.name}`).should('be.visible').click(); + + // # Click on Town Square + cy.get('.SidebarLink:contains(Town Square)').should('be.visible').click(); + + // * Verify we're on Town Square + cy.url().should('contain', 'town-square'); + + // # Refresh the page + cy.visit('/'); + + // * Verify that the DM we just read remains in the sidebar + cy.get(`#sidebarItem_${channel.name}`).should('be.visible'); + }); + }); + }); +}); + +function navigateToSidebarSettings() { + cy.get('#channel_view').should('be.visible'); + cy.get('#sidebarHeaderDropdownButton').should('be.visible').click(); + cy.get('#accountSettings').should('be.visible').click(); + cy.get('#accountSettingsModal').should('be.visible'); + + cy.get('#sidebarButton').should('be.visible'); + cy.get('#sidebarButton').click(); + + cy.get('#sidebarLi.active').should('be.visible'); + cy.get('#sidebarTitle > .tab-header').should('have.text', 'Sidebar Settings'); +} diff --git a/e2e/cypress/integration/custom_status/custom_status_1_spec.js b/e2e/cypress/integration/custom_status/custom_status_1_spec.js index 4e4d01c057c5..9e7b6606b5ad 100644 --- a/e2e/cypress/integration/custom_status/custom_status_1_spec.js +++ b/e2e/cypress/integration/custom_status/custom_status_1_spec.js @@ -7,6 +7,7 @@ // - Use element ID when selecting an element. Create one if none. // *************************************************************** +// Stage: @prod // Group: @custom_status describe('Custom Status - CTAs for New Users', () => { diff --git a/e2e/cypress/integration/custom_status/custom_status_2_spec.js b/e2e/cypress/integration/custom_status/custom_status_2_spec.js index fdb95428e957..de060dcaaa60 100644 --- a/e2e/cypress/integration/custom_status/custom_status_2_spec.js +++ b/e2e/cypress/integration/custom_status/custom_status_2_spec.js @@ -7,6 +7,7 @@ // - Use element ID when selecting an element. Create one if none. // *************************************************************** +// Stage: @prod // Group: @custom_status describe('Custom Status - Setting a Custom Status', () => { diff --git a/e2e/cypress/integration/custom_status/custom_status_3_spec.js b/e2e/cypress/integration/custom_status/custom_status_3_spec.js index 154cec33733d..3d8de77c72f4 100644 --- a/e2e/cypress/integration/custom_status/custom_status_3_spec.js +++ b/e2e/cypress/integration/custom_status/custom_status_3_spec.js @@ -7,6 +7,7 @@ // - Use element ID when selecting an element. Create one if none. // *************************************************************** +// Stage: @prod // Group: @custom_status import {openCustomStatusModal} from './helper'; @@ -48,7 +49,7 @@ describe('Custom Status - Setting Your Own Custom Status', () => { cy.get('#emojiPicker').should('exist'); }); - it('MM_T3846_3 should select the emoji from the emoji picker', () => { + it('MM-T3846_3 should select the emoji from the emoji picker', () => { // * Check that the emoji picker is open cy.get('#emojiPicker').should('exist'); diff --git a/e2e/cypress/integration/custom_status/custom_status_4_spec.js b/e2e/cypress/integration/custom_status/custom_status_4_spec.js index 7807b585b816..3dc4ca7c2ee6 100644 --- a/e2e/cypress/integration/custom_status/custom_status_4_spec.js +++ b/e2e/cypress/integration/custom_status/custom_status_4_spec.js @@ -7,6 +7,7 @@ // - Use element ID when selecting an element. Create one if none. // *************************************************************** +// Stage: @prod // Group: @custom_status import {openCustomStatusModal} from './helper'; diff --git a/e2e/cypress/integration/custom_status/custom_status_6_spec.js b/e2e/cypress/integration/custom_status/custom_status_6_spec.js index dc4546fa951c..aef4adca71ce 100644 --- a/e2e/cypress/integration/custom_status/custom_status_6_spec.js +++ b/e2e/cypress/integration/custom_status/custom_status_6_spec.js @@ -7,6 +7,7 @@ // - Use element ID when selecting an element. Create one if none. // *************************************************************** +// Stage: @prod // Group: @custom_status describe('Custom Status - Slash Commands', () => { diff --git a/e2e/cypress/integration/enterprise/integrations/incoming_webhook_spec.js b/e2e/cypress/integration/enterprise/integrations/incoming_webhook_spec.js index b482af9e68d7..0c47f2b8109b 100644 --- a/e2e/cypress/integration/enterprise/integrations/incoming_webhook_spec.js +++ b/e2e/cypress/integration/enterprise/integrations/incoming_webhook_spec.js @@ -7,6 +7,7 @@ // - Use element ID when selecting an element. Create one if none. // *************************************************************** +// Stage: @prod // Group: @enterprise @incoming_webhook @not_cloud import * as TIMEOUTS from '../../../fixtures/timeouts'; diff --git a/e2e/package.json b/e2e/package.json index 621327562b18..8c6671023515 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -24,7 +24,6 @@ "lodash.mapkeys": "4.6.0", "lodash.without": "4.4.0", "lodash.xor": "4.5.0", - "lodash": "4.17.21", "mattermost-redux": "5.33.0", "mime": "2.5.2", "mime-types": "2.1.29", diff --git a/i18n/es.json b/i18n/es.json index 18f93f8666f2..647938f5a6e1 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -32,6 +32,7 @@ "accessibility.sections.channelHeader": "región encabezado del canal", "accessibility.sections.lhsHeader": "región menú de equipo", "accessibility.sections.lhsList": "región barra lateral de canales", + "accessibility.sections.lhsNavigator": "región de navegación de canales", "accessibility.sections.rhs": "región complementaria {regionTitle}", "accessibility.sections.rhsContent": "región complementaria detalles del mensaje", "accessibility.sections.rhsFooter": "región entrada de respuesta", @@ -259,6 +260,7 @@ "admin.billing.payment_info_edit.serverError": "Se produjo un error al guardar la información de pago", "admin.billing.payment_info_edit.title": "Editar información de pago", "admin.billing.subscription.cancelSubscriptionSection.contactUs": "Contactar con nosotros", + "admin.billing.subscription.cancelSubscriptionSection.description": "Ahora mismo, borrar un espacio de trabajo solo se puede hacer con la ayuda de alguien del servicio de soporte para clientes.", "admin.billing.subscription.cancelSubscriptionSection.title": "Cancelar su suscripción", "admin.billing.subscription.creditCardExpired": "Tu tarjeta de crédito ha caducado. Actualiza la información de pago para evitar interrupciones.", "admin.billing.subscription.creditCardHasExpired": "Tu tarjeta de crédito ha caducado", @@ -290,6 +292,7 @@ "admin.billing.subscription.planDetails.numberOfSeats": "{numberOfSeats} asientos", "admin.billing.subscription.planDetails.numberOfSeatsRegistered": "({userCount} registrados actualmente)", "admin.billing.subscription.planDetails.perUserPerMonth": "/usuario/mes", + "admin.billing.subscription.planDetails.planDetailsName.freeForXOrMoreUsers": "/usuario/mes para {aboveUserLimit} o más usuarios.", "admin.billing.subscription.planDetails.planDetailsName.freeUpTo": "Gratis para hasta {aboveUserLimit} usuarios.", "admin.billing.subscription.planDetails.prolongedOverages": "Los excesos prolongados pueden resultar en cargos adicionales.", "admin.billing.subscription.planDetails.seatCountOverages": "Exceso en cantidad de asientos", @@ -434,7 +437,7 @@ "admin.cluster.StreamingPort": "Puerto de Transmisión:", "admin.cluster.StreamingPortDesc": "El puerto utilizado para transmitir data entre servidores.", "admin.cluster.StreamingPortEx": "Ej.: \"8075\"", - "admin.cluster.UseExperimentalGossip": "Utilizar Murmuración Experimental:", + "admin.cluster.UseExperimentalGossip": "Utilizar Murmuración:", "admin.cluster.UseExperimentalGossipDesc": "Cuando es verdadero, el servidor intentará comunicarse a través del protocolo de murmuración utilizando el puerto de murmuración. Cuando es falso el servidor intentará comunicarse a través del puerto de transmisión. Cuando es falso el puerto y protocolo de murmuración se siguen utilizando para determinar la salud del cluster.", "admin.cluster.UseIpAddress": "Usar Dirección IP:", "admin.cluster.UseIpAddressDesc": "Cuando es verdadero, el cluster intentará comunicarse a través de direcciones IP en vez de utilizar nombres de host.", @@ -552,6 +555,9 @@ "admin.customization.gfycatApiSecretDescription": "La llave secreta del API generado por Gfycat para tu identificador del API. Si permanece en blank, se utilizará la llave secreta predeterminada suministrada por Gfycat.", "admin.customization.iosAppDownloadLinkDesc": "Agrega un enlace para descargar la aplicación para iOS. Los usuarios que tienen acceso al sitio en un navegador de web móvil serán presentados con una página que les da la opción de descargar la aplicación. Deja este campo en blanco para evitar que la página aparezca.", "admin.customization.iosAppDownloadLinkTitle": "Enlace de Descarga de la Aplicación para iOS:", + "admin.customization.restrictLinkPreviewsDesc": "La previsualización de enlaces e imágenes no se mostrará para la lista de dominios separados por comas de aquí arriba.", + "admin.customization.restrictLinkPreviewsExample": "Ej.: \"internal.mycompany.com, images.example.com\"", + "admin.customization.restrictLinkPreviewsTitle": "Deshabilitar la previsualización de enlaces para estos dominios:", "admin.data_grid.empty": "No se encontraron elementos", "admin.data_grid.loading": "Cargando", "admin.data_grid.paginatorCount": "{startCount, number} - {endCount, number} de {total, number}", @@ -565,6 +571,9 @@ "admin.data_retention.confirmChangesModal.title": "Confirmar la política de retención de datos", "admin.data_retention.createJob.help": "Inicia un trabajo de eliminación de Retención de Datos inmediatamente.", "admin.data_retention.createJob.title": "Ejecutar el Trabajo de Eliminación Ahora", + "admin.data_retention.customPolicies.addPolicy": "Añadir política", + "admin.data_retention.customPolicies.subTitle": "Personalizar durante cuanto guardaran los mensajes equipos y canales específicos.", + "admin.data_retention.customPolicies.title": "Políticas de retención personalizadas", "admin.data_retention.deletionJobStartTime.description": "Establece la hora de inicio diaria para la ejecución del trabajo de retención de datos. Elige un momento en el que menos personas estén usando el sistema. Debe ser una hora en horario de 24 horas en la forma HH:MM.", "admin.data_retention.deletionJobStartTime.example": "Ej.: \"20:00\"", "admin.data_retention.deletionJobStartTime.title": "Hora de Eliminación de Datos:", @@ -708,7 +717,7 @@ "admin.experimental.clientSideCertCheck.title": "Método de Inicio de Sesión con un Certificado del lado del Cliente:", "admin.experimental.clientSideCertEnable.desc": "Habilita el inicio de sesión con un certificado del lado del cliente a tu servidor Mattermost. Lee la [documentación](!https://docs.mattermost.com/deployment/certificate-based-authentication.html) para conocer más.", "admin.experimental.clientSideCertEnable.title": "Habilitar inicio de sesión con un certificado del lado del Cliente:", - "admin.experimental.closeUnusedDirectMessages.desc": "Cuando es verdadero, las conversaciones en mensajes directos que no tienen actividad en un período de 7 días serán escondidas de la barra lateral. Cuando es falso, las conversaciones permanecerán en la barra lateral hasta que se cierren de forma manual.", + "admin.experimental.closeUnusedDirectMessages.desc": "Cuando es verdadero, las conversaciones en mensajes directos que no tienen actividad en un período de 7 días serán escondidas de la barra lateral. Cuando es falso, las conversaciones permanecerán en la barra lateral hasta que se cierren de forma manual. Esta configuración solo esta disponible si **Habilitar Barra Lateral Antigua** esta **Habilitada**.", "admin.experimental.closeUnusedDirectMessages.title": "Cerrar mensajes directos en la barra lateral de forma automática:", "admin.experimental.defaultTheme.desc": "Establece un tema predeterminado que será aplicado a todos los usuarios nuevos del sistema.", "admin.experimental.defaultTheme.title": "Tema Predeterminado:", @@ -726,6 +735,8 @@ "admin.experimental.emailSettingsLoginButtonTextColor.title": "Color del texto del botón de inicio de sesión por correo electrónico:", "admin.experimental.enableChannelViewedMessages.desc": "Este ajuste determina si los mensajes `channel_viewed` del WebSocket, esto sincroniza las notificaciones sin leer a través de los clientes y dispositivos. La desactivación de la configuración en las implementaciones de mayor tamaño puede mejorar el rendimiento del servidor.", "admin.experimental.enableChannelViewedMessages.title": "Habilitar Mensajes del WebSocket para Canal Visto:", + "admin.experimental.enableLegacySidebar.desc": "Cuando está activo, los usuarios no puede acceder a las nuevas funcionalidades de la barra lateral, incluyendo categorías personalizadas y colapsables y filtrado de canales no leídos. Recomendamos solo habilitar la barra lateral antigua si los usuarios esta experimentando fallos.", + "admin.experimental.enableLegacySidebar.title": "Habilitar Barra Lateral Antigua", "admin.experimental.enablePreviewFeatures.desc": "Cuando es verdadero, las características preliminares pueden ser habilitadas en **Configuración de la cuenta > Avanzada > Previsualizar características de pre-lanzamiento**. Cuando es falso, estas características son inhabilitadas y se esconde la opción en **Configuración de la cuenta > Avanzada > Previsualizar características de pre-lanzamiento**.", "admin.experimental.enablePreviewFeatures.title": "Habilitar Características Preliminares:", "admin.experimental.enableThemeSelection.desc": "Habilita la pestaña **Visualización > Tema** en Configuración de la Cuenta para que los usuarios puedan escoger su tema.", @@ -736,9 +747,9 @@ "admin.experimental.enableUserDeactivation.title": "Habilitar Desactivación de Cuenta:", "admin.experimental.enableUserTypingMessages.desc": "Este ajuste determina si los mensajes de \"el usuario está escribiendo...\" se muestran debajo del cuadro de mensaje. La desactivación de la configuración en las implementaciones de mayor tamaño puede mejorar el rendimiento del servidor.", "admin.experimental.enableUserTypingMessages.title": "Habilitar Usuario está escribiendo Mensajes:", - "admin.experimental.enableXToLeaveChannelsFromLHS.desc": "Cuando es verdadero, los usuarios pueden abandonar canales Públicos y Privados clickando la \"x\" al lado del nombre del canal. Cuando es falso, los usuarios deben utilizar la opción de **Abandonar Canal** del menú para poder abandonar los canales.", + "admin.experimental.enableXToLeaveChannelsFromLHS.desc": "Cuando es verdadero, los usuarios pueden abandonar canales Públicos y Privados clickando la \"x\" al lado del nombre del canal. Cuando es falso, los usuarios deben utilizar la opción de **Abandonar Canal** del menú para poder abandonar los canales. Esta configuración solo esta disponible si **Habilitar Barra Lateral Antigua** esta **Habilitada**.", "admin.experimental.enableXToLeaveChannelsFromLHS.title": "Habilitar X para abandonar Canales desde el Panel Lateral Izquierdo:", - "admin.experimental.experimentalChannelOrganization.desc": "Habilita las opciones de organización de canales en la barra lateral en **Configuración de la Cuenta > Barra lateral > Agrupación y ordenamiento de Canales** que incluye opciones para agrupar por canales no leídos, ordenar por los más recientes y combinar todos los tipos de canales en una sola lista. Estos ajustes no están disponibles si **Configuración de la Cuenta > Barra lateral > Características Experimentales** está habilitado.", + "admin.experimental.experimentalChannelOrganization.desc": "Habilita las opciones de organización de canales en la barra lateral en **Configuración de la Cuenta > Barra lateral > Agrupación y ordenamiento de Canales** que incluye opciones para agrupar por canales no leídos, ordenar por los más recientes y combinar todos los tipos de canales en una sola lista. Esta configuración solo esta disponible si **Habilitar Barra Lateral Antigua** esta **Habilitada**.", "admin.experimental.experimentalChannelOrganization.title": "Agrupación y ordenamiento de Canales", "admin.experimental.experimentalEnableAuthenticationTransfer.desc": "Cuando es verdadero, los usuarios pueden cambiar el método de inicio de sesión a cualquier método habilitado en el servidor, sea a través de Configuración de la cuenta o a través de las APIs. Cuando es falso, los Usuarios no podrán cambiar el método de inicio de sesión sin importar que opciones de autenticación estén habilitadas.", "admin.experimental.experimentalEnableAuthenticationTransfer.title": "Habilitar Transferencia de Autenticación:", @@ -749,7 +760,7 @@ "admin.experimental.experimentalEnableHardenedMode.desc": "Habilitar el modo de endurecimiento para Mattermost que hace que la experiencia de usuario sea compensada por el interés en seguridad. Ver [documentation](!https://docs.mattermost.com/administration/config-settings.html#enable-hardened-mode-experimental) para aprender más.", "admin.experimental.experimentalEnableHardenedMode.title": "Habilitar Modo de Endurecimiento:", "admin.experimental.experimentalFeatures": "Características Experimentales", - "admin.experimental.experimentalHideTownSquareinLHS.desc": "Cuando es verdadero, oculta Town Square en la barra lateral izquierda si no hay mensajes no leídos en el canal. Cuando es falso, Town Square sera siempre visible en la barra lateral incluso si todos los mensajes han sido leídos.", + "admin.experimental.experimentalHideTownSquareinLHS.desc": "Cuando es verdadero, oculta Town Square en la barra lateral izquierda si no hay mensajes no leídos en el canal. Cuando es falso, Town Square sera siempre visible en la barra lateral incluso si todos los mensajes han sido leídos. Esta configuración solo esta disponible si **Habilitar Barra Lateral Antigua** esta **Habilitada**.", "admin.experimental.experimentalHideTownSquareinLHS.title": "Town Square oculto en la barra lateral izquierda:", "admin.experimental.experimentalPrimaryTeam.desc": "El equipo principal del cual los usuarios del servidor son miembros. Cuando se establece un equipo principal, las opciones de unirse a otros equipos o de abandonar el equipo principal están deshabilitadas.", "admin.experimental.experimentalPrimaryTeam.example": "Ej.: \"nombredeequipo\"", @@ -852,7 +863,7 @@ "admin.gitlab.siteUrlExample": "Ej.: https://", "admin.gitlab.tokenTitle": "Url para obteción de Token:", "admin.gitlab.userTitle": "URL para obtener datos de usuario:", - "admin.google.EnableMarkdownDesc": "1. [Inicia sesión](!https://accounts.google.com/login) con tu cuenta de Google.\n2. Dirígete a [https://console.developers.google.com](!https://console.developers.google.com), haz clic en **Credenciales** en el panel lateral izquierdo e ingresa \"Mattermost - el-nombre-de-tu-empresa\" como **Nombre del Proyecto** y luego haz clic en **Crear**\n3. Haz clic en el encabezado **Pantalla de consentimiento de OAuth** e ingresa \"Mattermost\" como el **Nombre del producto a ser mostrado a los usuarios** y luego haz clic en **Guardar**.\n4. En el encabezado **Credenciales**, haz clic en **Crear credenciales**, escoge **ID de Cliente OAuth** y selecciona **Aplicación Web**.\n5. En **Restricciones** y **URI Autorizados de Redirección** ingresa **tu-url-de-mattermost/signup/google/complete** (ejemplo: http://localhost:8065/signup/google/complete). Haz clic en **Crear**.\n6. Pega el **ID de Cliente** y **Clave de Cliente** en los campos que se encuentran abajo y luego haz clic en **Guardar**.\n7. Dirígete a [Google People API](!https://console.developers.google.com/apis/library/people.googleapis.com) y haz clic en *Habilitar*.", + "admin.google.EnableMarkdownDesc": "1. [Inicia sesión](!https://accounts.google.com/login) con tu cuenta de Google.\n2. Dirígete a [https://console.developers.google.com](!https://console.developers.google.com), haz clic en **Credenciales** en el panel lateral izquierdo.\n3. En el encabezado **Credenciales**, haz clic en **Crear credenciales**, escoge **ID de Cliente OAuth** y selecciona **Aplicación Web**.\n4. Introduzca en \"Mattermost - su-nombre-de-empresa\" como el **Nombre**.\n5.En **URI Autorizados de Redirección** ingresa **tu-url-de-mattermost/signup/google/complete** (ejemplo: http://localhost:8065/signup/google/complete). Haz clic en **Crear**.\n6. Pega el **ID de Cliente** y **Clave de Cliente** en los campos que se encuentran abajo y luego haz clic en **Guardar**.\n7. Dirígete a [Google People API](!https://console.developers.google.com/apis/library/people.googleapis.com) y haz clic en *Habilitar*.", "admin.google.authTitle": "URL para autentificación:", "admin.google.clientIdDescription": "El ID de Cliente que recibiste al registrar la aplicación con Google.", "admin.google.clientIdExample": "Ej.: \"7602141235235-url0fhs1mayfasbmop5qlfns8dh4.apps.googleusercontent.com\"", @@ -1119,6 +1130,12 @@ "admin.license.keyRemove": "Quitar la Licencia Empresarial y Degradar el Servidor", "admin.license.noFile": "No se cargó ningún archivo", "admin.license.removing": "Quitando Licencia...", + "admin.license.renewalCard.description": "Renueva tu licencia Enterprise a través del Portal de Clientes para evitar cualquier corte en el servicio.", + "admin.license.renewalCard.licenseExpired": "Fecha de expiración de la licencia {date, date, long}.", + "admin.license.renewalCard.licenseExpiring": "La Licencia expira en {days} días, el {date, date, long}.", + "admin.license.renewalCard.licensedUsersNum": "**Usuario con Licencia:** {licensedUsersNum}", + "admin.license.renewalCard.reviewNumbers": "Revisa los numeros de abajo para asegurar que la renovación es por el número de usuarios correcto.", + "admin.license.renewalCard.usersNumbers": "**Usuarios Activos:** {activeUsersNum}", "admin.license.title": "Edición y Licencia", "admin.license.trial-request.accept-terms": "Al hacer clic en **Iniciar prueba**, aceptas el [Acuerdo de Evaluación del Software Mattermost] (!https://mattermost.com/software-evaluation-agreement/), [Política de privacidad] (!https://mattermost.com/privacy-policy/) y recibir correos electrónicos del producto.", "admin.license.trial-request.error": "La licencia de prueba no pudo ser obtenida. Visita [https://mattermost.com/trial/](https://mattermost.com/trial/) para solicitar una licencia.", @@ -1150,7 +1167,7 @@ "admin.log.locationPlaceholder": "Ingresar locación de archivo", "admin.log.locationTitle": "Directorio del Archivo de Registro:", "admin.log.logLevel": "Nivel de registros", - "admin.logs.bannerDesc": "Para buscar usuarios por ID del usuario o ID del Token, dirigete a Reportes > Usuarios y copia el ID en el filtro de búsqueda.", + "admin.logs.bannerDesc": "Para buscar usuarios por ID del usuario o ID del Token, dirigete a Gestión de usuarios > Usuarios y copia el ID en el filtro de búsqueda.", "admin.logs.next": "Siguiente", "admin.logs.prev": "Anterior", "admin.logs.reload": "Recargar", @@ -1894,6 +1911,9 @@ "admin.site.posts": "Mensajes", "admin.site.public_links": "Enlaces Públicos", "admin.site.usersAndTeams": "Usuarios y Equipos", + "admin.sql.connMaxIdleTimeDescription": "Tiempo máximo de inactividad (en milisegundos) para conectarse a la base de datos.", + "admin.sql.connMaxIdleTimeExample": "P.Ej.: \"300000\"", + "admin.sql.connMaxIdleTimeTitle": "Tiempo máximo de conexión inactiva:", "admin.sql.connMaxLifetimeDescription": "Tiempo máximo de duración (en milisegundos) para las conexiones a base de datos.", "admin.sql.connMaxLifetimeExample": "Ej.: \"3600000\"", "admin.sql.connMaxLifetimeTitle": "Tiempo máximo de duración de la conexión:", @@ -1964,7 +1984,9 @@ "admin.team.brandTextTitle": "Texto de la marca personalizada:", "admin.team.brandTitle": "Habilitar marca personalizada: ", "admin.team.chooseImage": "Seleccionar Imagen", - "admin.team.editOthersPostsDesc": "Cuando es verdadero, Los Administradores de Equipo y Administradores del Sistema pueden editar mensajes de otros usuarios. Si es falso, sólo los Administradores del Sistema pueden editar los mensajes de otros usuarios.", + "admin.team.customUserStatusesDescription": "Cuando es verdadero, los usuarios pueden establecer un mensaje de estado descriptivo y un emoticono visible para todos los usuarios.", + "admin.team.customUserStatusesTitle": "Habilitar Estados Personalizados: ", + "admin.team.editOthersPostsDesc": "Cuando es **verdadero**, Los Administradores de Equipo y Administradores del Sistema pueden editar mensajes de otros usuarios. Si es **falso**, sólo los Administradores del Sistema pueden editar los mensajes de otros usuarios. En cualquier caso, los Administradores de Equipo y Administradores del Sistema siempre pueden borrar los mensajes de otros usuarios.", "admin.team.editOthersPostsTitle": "Permitir que Administradores de Equipo puedan editar los mensajes de otros:", "admin.team.emailInvitationsDescription": "Cuando es verdadero un usuario puede invitar a otros utilizando el correo electrónico del sistema.", "admin.team.emailInvitationsTitle": "Habilitar Invitaciones por Correo Electrónico: ", @@ -2124,6 +2146,7 @@ "admin.user_item.menuAriaLabel": "Menú de acciones de usuario", "admin.user_item.mfaNo": "**Autenticación de Múltiples factores**: No", "admin.user_item.mfaYes": "**Autenticación de Múltiples factores**: Sí", + "admin.user_item.promoteToMember": "Promover a Miembro", "admin.user_item.resetEmail": "Actualizar Correo Electrónico", "admin.user_item.resetMfa": "Quitar MFA", "admin.user_item.resetPwd": "Reiniciar Contraseña", @@ -2185,8 +2208,8 @@ "analytics.team.totalPosts": "Total de Mensajes", "analytics.team.totalUsers": "Total de Usuarios Activos", "announcement_bar.error.email_verification_required": "Revisa tu correo electrónico para verificar la dirección.", - "announcement_bar.error.license_expired": "La licencia de Empresa expiró y algunas de las características pueden que estén desactivadas. [Por favor renovar](!{link}).", - "announcement_bar.error.license_expiring": "La licencia de Empresa expira el {date, date, long}. [Por favor renovar](!{link}).", + "announcement_bar.error.license_expired": "La licencia de Empresa expiró y algunas de las características pueden que estén desactivadas.", + "announcement_bar.error.license_expiring": "La licencia de Empresa expira el {date, date, long}.", "announcement_bar.error.past_grace": "La licencia para Empresas está vencida y algunas características pueden ser inhabilitadas. Por favor contacta a tu Administrador de Sistema para más detalles.", "announcement_bar.error.preview_mode": "Modo de prueba: Las notificaciones por correo electrónico no han sido configuradas.", "announcement_bar.error.site_url.full": "Por favor configura la [URL del Sitio](https://docs.mattermost.com/administration/config-settings.html#site-url) en la [Console de Sistema](/admin_console/environment/web_server).", @@ -2195,6 +2218,10 @@ "announcement_bar.notification.email_verified": "Correo electrónico Verificado", "announcement_bar.number_active_users_warn_metric_status.text": "Ahora tiene más de {limit} usuarios. Recomendamos encarecidamente el uso de funciones avanzadas para servidores a gran escala.", "announcement_bar.number_of_posts_warn_metric_status.text": "Ahora tienes más de {límite} mensajes. Recomendamos encarecidamente el uso de funciones avanzadas para evitar la degradación en el rendimiento.", + "announcement_bar.warn.contact_support_text": "Para renovar tu licencia, contacte con soporte en support@mattermost.com.", + "announcement_bar.warn.email_support": "[Contactar con soporte](!{email}).", + "announcement_bar.warn.no_internet_connection": "Parece que no tiene acceso a internet.", + "announcement_bar.warn.renew_license_now": "Renovar licencia ahora", "announcement_bar.warn_metric_status.number_of_posts.text": "Ahora tiene más de 2.000.000 de mensajes. Recomendamos encarecidamente el uso de funciones avanzadas para evitar la degradación en el rendimiento.", "announcement_bar.warn_metric_status.number_of_posts_ack.text": "Gracias por contactar con Mattermost. Pronto nos pondremos en contacto contigo.", "announcement_bar.warn_metric_status.number_of_users.text": "Ahora tiene más de 500 usuarios. Recomendamos encarecidamente el uso de funciones avanzadas para servidores a gran escala.", @@ -2379,7 +2406,7 @@ "channel_header.convert": "Convertir a Canal Privado", "channel_header.delete": "Archivar Canal", "channel_header.directchannel.you": "{displayname} (tu) ", - "channel_header.editLink": "(Editar)", + "channel_header.editLink": "Editar", "channel_header.flagged": "Mensajes Guardados", "channel_header.groupConstrained": "Miembros gestionados por grupos enlazados.", "channel_header.groupMessageHasGuests": "Este grupo tiene huéspedes", @@ -2543,6 +2570,11 @@ "combined_system_message.removed_from_team.one_you": "Fuiste **eliminado del equipo**.", "combined_system_message.removed_from_team.two": "{firstUser} y {secondUser} fueron **eliminados del equipo**.", "combined_system_message.you": "Tu", + "commercial_support.download_support_packet": "Descargar Paquete de Soporte", + "commercial_support.title": "Soporte Comercial", + "confirm.notification_sent_to_admin.modal_body": "Una notificación ha sido enviada a su administrador.", + "confirm.notification_sent_to_admin.modal_done": "Hecho", + "confirm.notification_sent_to_admin.modal_title": "¡Gracias!", "confirm_modal.cancel": "Cancelar", "convert_channel.cancel": "No, cancelar", "convert_channel.confirm": "Sí, convertir a canal privado", @@ -2591,6 +2623,16 @@ "create_team.team_url.unavailable": "Esta dirección URL ya está en uso ó no está disponible. Por favor, pruebe con otra.", "create_team.team_url.webAddress": "Escoge la dirección web para tu nuevo equipo:", "custom_emoji.header": "Emoticonos Personalizados", + "custom_status.modal_cancel": "Eliminar Estado", + "custom_status.modal_confirm": "Establecer Estado", + "custom_status.set_status": "Establecer un Estado", + "custom_status.suggestions.in_a_meeting": "En una reunión", + "custom_status.suggestions.on_a_vacation": "De vacaciones", + "custom_status.suggestions.out_for_lunch": "Comiendo", + "custom_status.suggestions.out_sick": "Enfermo", + "custom_status.suggestions.recent_title": "RECIENTE", + "custom_status.suggestions.title": "SUGERENCIAS", + "custom_status.suggestions.working_from_home": "Trabajando desde casa", "date_separator.today": "Hoy", "date_separator.yesterday": "Ayer", "deactivate_member_modal.deactivate": "Desactivar", @@ -2708,6 +2750,7 @@ "error.local_storage.help3": "Utiliza un navegador compatible (IE 11, Chrome 61+, Firefox 60+, Safari 12+, Edge 42+)", "error.local_storage.message": "Mattermost fue incapaz de cargar debido a una configuración en el navegador impide el uso de la característica de almacenamiento local. Para permitir que Mattermost cargue, intenta las acciones siguientes:", "error.local_storage.title": "No se puede cargar Mattermost", + "error.maxFreeUsersReached.title": "Este espacio de trabajo a alcanzado el límite de usuarios.", "error.not_found.message": "La página que está intentando acceder no existe", "error.not_found.title": "Página no encontrada", "error.oauth_access_denied": "Debes autorizar a Mattermost para iniciar sesión con {service}.", @@ -3146,7 +3189,7 @@ "invitation_modal.invite_members.description": "Invitar un nuevo miembro del equipo con un enlace o por correo electrónico. Los miembros de equipo tienen acceso a mensajes y archivos en equipos abiertos y canales públicos.", "invitation_modal.invite_members.description-email-disabled": "Invitar un nuevo miembro del equipo con un enlace. Los miembros de equipo tienen acceso a mensajes y archivos en equipos abiertos y canales públicos.", "invitation_modal.invite_members.exceeded_max_add_members_batch": "No se puede invitar a más de **{text}** personas a la vez", - "invitation_modal.invite_members.hit_cloud_user_limit": "Solo puedes invitar a **{text}** más {text, plural, one {miembro} other {miembros}} en el nivel gratuito", + "invitation_modal.invite_members.hit_cloud_user_limit": "Solo puedes invitar a **{num} más {num, plural, one {miembro} other {miembros}}** al equipo en el nivel gratuito.", "invitation_modal.invite_members.title": "Invitar **Miembros**", "invitation_modal.members.invite_button": "Invitar Miembros", "invitation_modal.members.or": "Ó", @@ -3344,13 +3387,13 @@ "multiselect.addTeamsPlaceholder": "Buscar y agregar equipos", "multiselect.adding": "Agregando...", "multiselect.go": "Ir", - "multiselect.list.notFound": "No se encontraron elementos", + "multiselect.list.notFound": "No se encontraron resultados que encajen con **{searchQuery}**", "multiselect.loading": "Cargando...", "multiselect.numGroupsRemaining": "Utiliza ↑↓ para navegar, ↵ para seleccionar. Puedes agregar {num, number} {num, plural, one {grupo} other {grupos}} más. ", "multiselect.numMembers": "{memberOptions, number} de {totalCount, number} miembros", "multiselect.numPeopleRemaining": "Utiliza ↑↓ para navegar, ↵ para seleccionar. Puedes agregar {num, number} {num, plural, one {persona} other {personas}} más. ", "multiselect.numRemaining": "Puedes agregar {num, number} más", - "multiselect.placeholder": "Buscar y agregar miembros", + "multiselect.placeholder": "Buscar personas", "multiselect.selectChannels": "Utiliza ↑↓ para navegar, ↵ para seleccionar.", "multiselect.selectTeams": "Utiliza ↑↓ para navegar, ↵ para seleccionar.", "navbar.addGroups": "Agregar Grupos", @@ -3551,9 +3594,9 @@ "postlist.toast.scrollToLatest": "Saltar a los mensajes nuevos", "posts_view.loadMore": "Cargar más mensajes", "posts_view.newMsg": "Nuevos Mensajes", - "promote_to_user_modal.desc": "Esta acción promueve al huésped {username} a miembro. Esto permitirá que el usuario se pueda unir a canales públicos y pueda interactuar con usuarios fuera de los canales de los cuales es miembro actualmente. ¿Está seguro que desea promover al huésped {username} a usuario?", + "promote_to_user_modal.desc": "Esta acción promueve al huésped {username} a miembro. Esto permitirá que el usuario se pueda unir a canales públicos y pueda interactuar con usuarios fuera de los canales de los cuales es miembro actualmente. ¿Está seguro que desea promover al huésped {username} a miembro?", "promote_to_user_modal.promote": "Promover", - "promote_to_user_modal.title": "Promover huésped {username} a usuario", + "promote_to_user_modal.title": "Promover huésped {username} a miembro", "quick_switch_modal.channels": "Canales", "quick_switch_modal.channelsShortcut.mac": "- ⌘K", "quick_switch_modal.channelsShortcut.windows": "- CTRL+K", @@ -3985,7 +4028,7 @@ "upgrade.cloud": "Actualizar Mattermost Cloud", "upgrade.cloud_banner_over": "Actualmente estás por encima del límite de usuarios del nivel gratuito", "upgrade.cloud_banner_reached": "Has alcanzado el límite de usuarios del nivel gratuito", - "upgrade.cloud_modal_body": "El nivel gratuito está limitado a 10 usuarios. Actualice Mattermost Cloud para más usuarios.", + "upgrade.cloud_modal_body": "El nivel gratuito está limitado a {num} usuarios. Actualice Mattermost Cloud para más usuarios.", "upgrade.cloud_modal_title": "Has alcanzado el límite de usuarios", "upload_overlay.info": "Arrastra un archivo para cargarlo.", "user.settings.advance.confirmDeactivateAccountTitle": "Confirmar Desactivación", @@ -4317,7 +4360,7 @@ "user.settings.sidebar.unreadsDesc": "Agrupar canales no leídos por separado hasta que sean leídos.", "user.settings.sidebar.unreadsFavoritesShort": "Canales sin leer y favoritos agrupados por separado", "user.settings.sidebar.unreadsShort": "Canales sin leer agrupados por separado", - "user.settings.timezones.automatic": "Asignar automáticamente", + "user.settings.timezones.automatic": "Automático", "user.settings.timezones.promote": "Selecciona la zona horaria para ser utilizada en la interfaz de usuario y notificaciones de correo electrónico.", "user.settings.tokens.activate": "Activado", "user.settings.tokens.cancel": "Cancelar", @@ -4355,7 +4398,7 @@ "userGuideHelp.reportAProblem": "Reportar un problema", "user_list.notFound": "No se encontraron usuarios", "user_profile.account.editSettings": "Editar Configuración de la Cuenta", - "user_profile.account.localTime": "Hora Local: ", + "user_profile.account.localTime": "Hora Local", "user_profile.account.post_was_created": "Este mensaje fue creado por una integración de", "user_profile.add_user_to_channel": "Agregar al Canal", "user_profile.add_user_to_channel.icon": "Icono de Agregar Usuario al Canal", diff --git a/i18n/fr.json b/i18n/fr.json index a339b3e85b6e..b3454085e680 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -260,6 +260,7 @@ "admin.billing.payment_info_edit.title": "Modifier les moyens de paiement", "admin.billing.subscription.cancelSubscriptionSection.contactUs": "Nous contacter", "admin.billing.subscription.cancelSubscriptionSection.description": "Pour le moment, la suppression d'un espace de travail ne peut être effectuée que par un conseiller du support client.", + "admin.billing.subscription.cancelSubscriptionSection.title": "Annuler votre abonnement", "admin.billing.subscription.creditCardExpired": "Votre carte de crédit a expiré. Mettez à jour vos moyens de paiement pour éviter toute interruption de service.", "admin.billing.subscription.creditCardHasExpired": "Votre carte de crédit a expiré", "admin.billing.subscription.creditCardHasExpired.description.avoidAnyDisruption": " pour éviter toute interruption de service.", @@ -273,6 +274,7 @@ "admin.billing.subscription.mostRecentPaymentFailed": "Votre dernier paiement a échoué", "admin.billing.subscription.nextBillingDate": "À partir de {date}, vous serez facturé en fonction du nombre d'utilisateurs activés", "admin.billing.subscription.otherBillingOption": "Besoin d'autres options de facturation ?", + "admin.billing.subscription.payamentBegins": "Le paiement débute : {beginDate}", "admin.billing.subscription.paymentFailed": "Le paiement a échoué. Veuillez réessayer ou contacter le service d'aide.", "admin.billing.subscription.paymentVerificationFailed": "Désolé, la vérification du paiement a échoué", "admin.billing.subscription.perUserPerMonth": " /utilisateur/mois", @@ -289,6 +291,7 @@ "admin.billing.subscription.planDetails.numberOfSeats": "{numberOfSeats} d'utilisateurs", "admin.billing.subscription.planDetails.numberOfSeatsRegistered": "({userCount} actuellement enregistrés)", "admin.billing.subscription.planDetails.perUserPerMonth": "/utilisateur/mois", + "admin.billing.subscription.planDetails.planDetailsName.freeUpTo": "Gratuit jusqu'à {aboveUserLimit} utilisateurs.", "admin.billing.subscription.planDetails.prolongedOverages": "Les dépassements prolongés peuvent entraîner des frais supplémentaires.", "admin.billing.subscription.planDetails.seatCountOverages": "Dépassement du nombre d'utilisateurs", "admin.billing.subscription.planDetails.startDate": "Date de début : ", @@ -303,6 +306,7 @@ "admin.billing.subscription.questions": "Des questions ?", "admin.billing.subscription.stateprovince": "État/Province", "admin.billing.subscription.title": "Abonnements", + "admin.billing.subscription.updatePaymentInfo": "Mettre à jour les informations de paiement", "admin.billing.subscription.upgrade": "Mettre à jour", "admin.billing.subscription.upgradeCloudSubscription": "Mettez à jour votre abonnement Mattermost Cloud", "admin.billing.subscription.upgradeMattermostCloud.description": "La version gratuite est **limitée à 10 utilisateurs**. Pour pouvoir créer plus d'utilisateurs, d'équipes et avoir accès à d'autres fonctions intéressantes", @@ -420,6 +424,7 @@ "admin.cluster.ClusterNameEx": "Ex. : « Production » ou « Staging »", "admin.cluster.EnableExperimentalGossipEncryption": "Activer le chiffrement expérimental du protocole de bavardage (gossip protocol) :", "admin.cluster.EnableExperimentalGossipEncryptionDesc": "Si activé, toutes les communications passant par le protocole de bavardage seront chiffrées.", + "admin.cluster.EnableGossipCompression": "Activer la compression avec le protocole Gossip :", "admin.cluster.GossipPort": "Port de bavardage :", "admin.cluster.GossipPortDesc": "Le port utilisé pour le protocole de bavardage. Seuls UDP et TCP devraient être autorisés sur ce port.", "admin.cluster.GossipPortEx": "Ex. : « 8074 »", @@ -429,7 +434,7 @@ "admin.cluster.StreamingPort": "Port de streaming :", "admin.cluster.StreamingPortDesc": "Le port utilisé pour streamer des données entre les serveurs.", "admin.cluster.StreamingPortEx": "Ex. : « 8075 »", - "admin.cluster.UseExperimentalGossip": "Utiliser le protocole de bavardage expérimental :", + "admin.cluster.UseExperimentalGossip": "Utiliser le protocole de bavardage :", "admin.cluster.UseExperimentalGossipDesc": "Lorsqu'activé, le serveur va essayer de communiquer en utilisant le protocole et le port de bavardage. Lorsque désactivé, le serveur va essayer de communiquer à l'aide du port de streaming. Lorsque désactivés, les protocole et port de bavardage sont toujours utilisés pour déterminer la santé du cluster.", "admin.cluster.UseIpAddress": "Utiliser l'adresse IP :", "admin.cluster.UseIpAddressDesc": "Lorsqu'activé, le cluster va essayer de communiquer à l'aide de l'adresse IP au lieu d'utiliser le nom d'hôte.", @@ -3491,6 +3496,7 @@ "shortcuts.nav.unread_prev": "Canal non lu précédent :\tAlt|Maj|Haut", "shortcuts.nav.unread_prev.mac": "Canal non lu précédent :\t⌥|Maj|Haut", "shortcuts.team_nav.next.mac": "Next team:\t⌘|⌥|Down", + "sidebar.allDirectMessages": "Tous les messages personnels", "sidebar.browseChannelDirectChannel": "Parcourir les canaux ou messages personnels", "sidebar.createChannel": "Créer un canal public", "sidebar.createDirectMessage": "Créer un nouveau message personnel", @@ -3502,7 +3508,12 @@ "sidebar.moreElips": "Plus...", "sidebar.morePublicAria": "plus de canaux publics", "sidebar.morePublicDmAria": "plus de canaux publics et de messages personnels", + "sidebar.openDirectMessage": "Ouvrir un message personnel", "sidebar.removeList": "Retirer de la liste", + "sidebar.show": "Afficher", + "sidebar.sort": "Trier", + "sidebar.sortedByRecencyLabel": "Activité récente", + "sidebar.sortedManually": "Manuellement", "sidebar.team_select": "{siteName} - Rejoindre une équipe", "sidebar.tutorialScreen1.body": "Les **canaux** organisent les conversations en différents sujets. Ils sont ouverts à tous les membres de votre équipe. Pour envoyer des communications privées, utilisez les **messages personnels** pour une personne seule ou les **canaux privés** pour plusieurs personnes.", "sidebar.tutorialScreen1.title": "Canaux", @@ -3528,14 +3539,17 @@ "sidebar_header.tutorial.body3": "Les administrateurs système trouveront une option **Console système** pour gérer l'ensemble du système.", "sidebar_header.tutorial.title": "Menu principal", "sidebar_left.add_channel_dropdown.createNewChannel": "Créer un nouveau canal", + "sidebar_left.channel_filter.showAllChannels": "Afficher tous les canaux", "sidebar_left.channel_navigator.channelSwitcherLabel": "Sélecteur de canal", "sidebar_left.channel_navigator.goBackLabel": "Précédent", "sidebar_left.channel_navigator.jumpTo": "Aller à...", + "sidebar_left.sidebar_category_menu.muteCategory": "Catégorie en sourdine", "sidebar_left.sidebar_channel_menu.addMembers": "Ajouter Membres", "sidebar_left.sidebar_channel_menu.copyLink": "Copier le lien", "sidebar_left.sidebar_channel_menu.favoriteChannel": "Favoris", "sidebar_left.sidebar_channel_menu.leaveChannel": "Quitter le canal", "sidebar_left.sidebar_channel_menu.muteChannel": "Mettre le canal en sourdine", + "sidebar_left.sidebar_channel_menu.muteConversation": "Conversation en sourdine", "sidebar_left.sidebar_channel_menu.unmuteChannel": "Rétablir le son du canal", "sidebar_right_menu.console": "Console système", "sidebar_right_menu.flagged": "Messages marqués d'un indicateur", @@ -4015,9 +4029,12 @@ "user.settings.sidebar.groupChannelsTitle": "Groupement des canaux", "user.settings.sidebar.groupDesc": "Grouper les canaux par type, ou combiner tous les types dans une liste.", "user.settings.sidebar.icon": "Icône de paramètres de la barre latérale", + "user.settings.sidebar.limitVisibleGMsDMsTitle": "Nombre de messages personnels à afficher", "user.settings.sidebar.never": "Jamais", "user.settings.sidebar.off": "Désactivé", "user.settings.sidebar.on": "Activé", + "user.settings.sidebar.showUnreadsCategoryDesc": "Lorsqu'elle est activée, tous les canaux et messages personnels non lus sont regroupés dans la barre latérale.", + "user.settings.sidebar.showUnreadsCategoryTitle": "Regrouper les canaux non lus séparément", "user.settings.sidebar.sortAlpha": "Par ordre alphabétique", "user.settings.sidebar.sortAlphaShort": "triés par ordre alphabétique", "user.settings.sidebar.sortChannelsTitle": "Tri de canal", @@ -4026,7 +4043,7 @@ "user.settings.sidebar.sortRecentShort": "triés du plus récent au plus ancien", "user.settings.sidebar.title": "Paramètres de la barre latérale", "user.settings.sidebar.unreads": "Non lus groupés séparément", - "user.settings.sidebar.unreadsDesc": "Grouper les canaux non lus séparément jusqu'à ce qu'ils soient lus", + "user.settings.sidebar.unreadsDesc": "Grouper les canaux non lus séparément jusqu'à ce qu'ils soient lus.", "user.settings.sidebar.unreadsFavoritesShort": "Non lus et favoris groupés séparément", "user.settings.sidebar.unreadsShort": "Non lus groupés séparément", "user.settings.timezones.automatic": "Définir automatiquement", @@ -4062,9 +4079,11 @@ "user.settings.tokens.userAccessTokensNone": "Aucun jeton d'accès personnel.", "user_list.notFound": "Aucun utilisateur trouvé", "user_profile.account.editSettings": "Éditer les paramètres du compte", - "user_profile.account.localTime": "Heure locale : ", + "user_profile.account.localTime": "Heure locale", + "user_profile.account.post_was_created": "Ce message a été créé par une intégration de", "user_profile.add_user_to_channel": "Ajouter à un canal", "user_profile.add_user_to_channel.icon": "Icône d'ajout d'utilisateur à un canal", + "user_profile.custom_status": "Statut", "user_profile.send.dm": "Envoyer un message", "user_profile.send.dm.icon": "Icône d'envoi de message", "version_bar.new": "Une nouvelle version de Mattermost est disponible.", diff --git a/i18n/i18n.jsx b/i18n/i18n.jsx index 7974be758ff3..b2bc1a74e4c7 100644 --- a/i18n/i18n.jsx +++ b/i18n/i18n.jsx @@ -82,7 +82,7 @@ const languages = { }, sv: { value: 'sv', - name: 'Svenska (Beta)', + name: 'Svenska', order: 9, url: sv, }, @@ -94,7 +94,7 @@ const languages = { }, bg: { value: 'bg', - name: 'Български (Beta)', + name: 'Български', order: 11, url: bg, }, diff --git a/i18n/ja.json b/i18n/ja.json index e62792c8e280..d9ba5bb3d9ca 100644 --- a/i18n/ja.json +++ b/i18n/ja.json @@ -555,6 +555,9 @@ "admin.customization.gfycatApiSecretDescription": "APIキーに対してGfycatによって生成されたAPIシークレットです。空欄の場合、Gfycatにより提供されるデフォルトのAPIシークレットを使用します。", "admin.customization.iosAppDownloadLinkDesc": "iOSアプリのダウンロードリンクを追加してください。モバイル用ウェブブラウザーでサイトにアクセスしたユーザへ、アプリをダウンロードするか選択するページを表示します。空欄にした場合、そのページは表示されません。", "admin.customization.iosAppDownloadLinkTitle": "iOSアプリダウンロード用リンク:", + "admin.customization.restrictLinkPreviewsDesc": "カンマで区切られた上記のドメインに対しては、リンクプレビューやイメージリンクプレビューは表示されません。", + "admin.customization.restrictLinkPreviewsExample": "例:\"internal.mycompany.com, images.example.com\"", + "admin.customization.restrictLinkPreviewsTitle": "これらのドメインからのリンクプレビューを無効にします:", "admin.data_grid.empty": "アイテムが見付かりません", "admin.data_grid.loading": "読み込み中です", "admin.data_grid.paginatorCount": "全 {total, number} 中: {startCount, number} - {endCount, number}", @@ -568,6 +571,7 @@ "admin.data_retention.confirmChangesModal.title": "データ保持ポリシーを確認する", "admin.data_retention.createJob.help": "まもなくデータ保持削除処理を開始します。", "admin.data_retention.createJob.title": "今すぐ削除ジョブを実行する", + "admin.data_retention.customPolicies.addPolicy": "ポリシーを追加", "admin.data_retention.deletionJobStartTime.description": "毎日スケジュールされているデータ保持処理の開始時刻を設定します。システムを使用する人が少ない時間を選択してください。また、HH:MM形式の24時間表記を指定してください。", "admin.data_retention.deletionJobStartTime.example": "例: \"02:00\"", "admin.data_retention.deletionJobStartTime.title": "データ削除時間:", @@ -2568,6 +2572,8 @@ "commercial_support.download_support_packet": "サポートパケットをダウンロードする", "commercial_support.title": "商用サポート", "commercial_support.warning.banner": "サポートパケットをダウンロードする前に、[ログ設定](!/admin_console/environment/logging) から **ログをファイルに出力する** を **有効** にし、**ファイルログレベル** を **デバッグ** に設定してください。", + "confirm.notification_sent_to_admin.modal_body": "管理者に通知が送信されました。", + "confirm.notification_sent_to_admin.modal_title": "ありがとうございます!", "confirm_modal.cancel": "キャンセル", "convert_channel.cancel": "いいえ、キャンセルします", "convert_channel.confirm": "はい、非公開チャンネルに変更します", @@ -3135,7 +3141,11 @@ "intro_messages.creatorPrivate": "ここは非公開チャンネル {name} のトップです。{date}に{creator}によって作成されました。", "intro_messages.default": "**{display_name} へようこそ!**\n \n全員に見てほしいメッセージをここに投稿して下さい。チームに参加すると、全員が自動的にこのチャンネルのメンバーになります。", "intro_messages.group_message": "チームメイトとのグループメッセージの履歴の最初です。メッセージとそこで共有されているファイルは、この領域の外のユーザーからは見ることができません。", + "intro_messages.inviteGropusToChannel.button": "グループをこの非公開チャンネルに追加する", + "intro_messages.inviteMembersToChannel.button": "このチャンネルにメンバーを追加する", + "intro_messages.inviteMembersToPrivateChannel.button": "メンバーをこの非公開チャンネルに追加する", "intro_messages.inviteOthers": "他の人をこのチームに招待する", + "intro_messages.inviteOthersToWorkspace.button": "ワークスペースに他の人を招待する", "intro_messages.noCreator": "ここは {name} チャンネルのトップです。{date}に作成されました。", "intro_messages.noCreatorPrivate": "ここは非公開チャンネル {name} のトップです。{date}に作成されました。", "intro_messages.offTopic": "ここは{display_name}の始まりです。仕事とは関係のない会話のためのチャンネルです。", @@ -3477,6 +3487,7 @@ "next_steps_view.tips.exploreChannels": "チャンネルを探索する", "next_steps_view.tips.exploreChannels.button": "チャンネルを閲覧する", "next_steps_view.tips.exploreChannels.text": "ワークスペースのチャンネルを見てみるか、新たにチャンネルを作成してください。", + "next_steps_view.tips.manageWorkspace.button": "システムコンソールを開く", "next_steps_view.tips.viewMembers": "チームメンバーを閲覧する", "next_steps_view.tipsAndNextSteps": "コツと次のステップ", "next_steps_view.titles.completeProfile": "プロフィール入力を完了する", diff --git a/i18n/ko.json b/i18n/ko.json index 11c2e81ad7d8..28454b2b4e09 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -3,8 +3,8 @@ "about.cloudEdition": "클라우드", "about.copyright": "Copyright 2015 - {currentYear} Mattermost, Inc. 무단 전재와 무단 복제를 금합니다.", "about.database": "데이터베이스:", - "about.date": "빌드 일자:", - "about.dbversion": "데이터베이스 스키마 버전", + "about.date": "빌드 날짜:", + "about.dbversion": "데이터베이스 스키마 버전:", "about.enterpriseEditionLearn": "엔터프라이즈 에디션에 대한 자세한 정보 ", "about.enterpriseEditionSst": "엔터프라이즈에 적합한 신뢰도 높은 메시징", "about.enterpriseEditionSt": "보안 네트워크에 구축하는 현대적인 커뮤니케이션 플랫폼.", @@ -27,7 +27,7 @@ "accessibility.button.Search": "검색", "accessibility.button.attachment": "첨부", "accessibility.button.dialog": "{dialogName} 다이얼로그", - "accessibility.sections.centerContent": "메시지 목록 주요 지역", + "accessibility.sections.centerContent": "메시지 목록 주요 영역", "accessibility.sections.centerFooter": "메시지 입력 영역", "accessibility.sections.channelHeader": "채널 헤더 영역", "accessibility.sections.lhsHeader": "팀 메뉴 영역", @@ -42,7 +42,7 @@ "accessibility.sidebar.types.unread": "읽지 않음", "activity_log.activeSessions": "활성 세션", "activity_log.browser": "브라우저: {browser}", - "activity_log.firstTime": "첫 활동 시간: {date}, {time}", + "activity_log.firstTime": "첫 활동 시각: {date}, {time}", "activity_log.lastActivity": "최근 활동: {date}, {time}", "activity_log.logout": "로그아웃", "activity_log.moreInfo": "상세 정보", @@ -61,7 +61,7 @@ "add_command.autocompleteDescription": "자동완성 설명", "add_command.autocompleteDescription.help": "(선택) 자동완성 목록에서 보여질 부가적인 설명을 입력하세요.", "add_command.autocompleteDescription.placeholder": "예시: \"환자 기록에 대한 검색결과를 보여줍니다\"", - "add_command.autocompleteHint": "자동완성 힌트", + "add_command.autocompleteHint": "자동완성 제안", "add_command.autocompleteHint.help": "(선택) 슬래시 명령어의 매개변수를 지정합니다.", "add_command.autocompleteHint.placeholder": "예시: [환자 이름]", "add_command.cancel": "취소", @@ -78,7 +78,7 @@ "add_command.method.help": "Mattermost가 애플리케이션과 통신하기 위해 전송하는 POST 혹은 GET 요청의 유형을 지정합니다.", "add_command.method.post": "POST", "add_command.save": "저장", - "add_command.saving": "저장중...", + "add_command.saving": "저장 중...", "add_command.token": "**토큰**: {token}", "add_command.trigger": "명령어 트리거 단어", "add_command.trigger.help": "내장 명령어가 아닌 트리거 단어를 지정합니다. 공백을 포함하거나, 슬래시 문자열로 시작해서는 안됩니다.", @@ -86,7 +86,7 @@ "add_command.trigger.helpReserved": "사용할 수 없음: {link}", "add_command.trigger.helpReservedLinkText": "내장된 슬래시(/) 명령어를 확인해주세요", "add_command.trigger.placeholder": "슬래시를 포함하지 않는 명령 트리거. 예: \"hello\"", - "add_command.triggerInvalidLength": "단어가 {min} 글자 이상, {max} 글자 이하여야 합니다.", + "add_command.triggerInvalidLength": "트리거 단어는 {min} 글자 이상, {max} 글자 이하여야 합니다.", "add_command.triggerInvalidSlash": "단어 앞에 슬래시(/)를 사용할 수 없습니다.", "add_command.triggerInvalidSpace": "단어에 공백을 포함할 수 없습니다.", "add_command.triggerRequired": "단어가 필요합니다.", @@ -242,6 +242,7 @@ "admin.billing.history.transactions": "트랜잭션", "admin.billing.payment_info.add": "신용 카드 추가", "admin.billing.payment_info.billingAddress": "청구 주소", + "admin.billing.payment_info.cardBrandAndDigits": "{digits}로 끝나는 {brand}카드", "admin.billing.payment_info.cardExpiry": "만료일 {month}/{year}", "admin.billing.payment_info.creditCardAboutToExpire": "신용 카드가 곧 만료됩니다", "admin.billing.payment_info.creditCardAboutToExpire.description": "서비스 중단을 방지하려면 결제 정보를 업데이트하십시오.", @@ -276,8 +277,8 @@ "admin.billing.subscription.paymentFailed": "결제 실패. 다시 시도하거나 지원팀에 문의하십시오.", "admin.billing.subscription.paymentVerificationFailed": "죄송합니다. 결제 확인에 실패했습니다", "admin.billing.subscription.perUserPerMonth": " /user/month", - "admin.billing.subscription.planDetails.currentPlan": "현제 플랜", - "admin.billing.subscription.planDetails.endDate": "종료 일: ", + "admin.billing.subscription.planDetails.currentPlan": "현재 플랜", + "admin.billing.subscription.planDetails.endDate": "종료일: ", "admin.billing.subscription.planDetails.features.10GBstoragePerUser": "사용자 당 10GB 저장 용량", "admin.billing.subscription.planDetails.features.99uptime": "99.0% 가동 시간", "admin.billing.subscription.planDetails.features.guestAccounts": "게스트 계정", @@ -289,6 +290,7 @@ "admin.billing.subscription.planDetails.numberOfSeats": "{numberOfSeats} seats", "admin.billing.subscription.planDetails.numberOfSeatsRegistered": "({userCount} 현재 가입자)", "admin.billing.subscription.planDetails.perUserPerMonth": "/user/month", + "admin.billing.subscription.planDetails.planDetailsName.freeUpTo": "사용자 {aboveUserLimit}명까지 무료.", "admin.billing.subscription.planDetails.prolongedOverages": "오랜기간 초과되면 추가요금이 발생할 수 있습니다.", "admin.billing.subscription.planDetails.seatCountOverages": "인원 초과", "admin.billing.subscription.planDetails.startDate": "시작일: ", @@ -548,6 +550,8 @@ "admin.customization.gfycatApiSecretDescription": "API 키를 위해 Gfycat에서 생성한 API 시크릿입니다. 비어있으면, Gfycat에서 제공한 기본 API 시크릿을 사용합니다.", "admin.customization.iosAppDownloadLinkDesc": "iOS 앱을 다운로드할 수 있는 주소를 추가하세요. 모바일 웹 브라우저를 통해 사이트에 접속하는 사용자에게는 앱 다운로드 옵션을 제공하는 페이지가 표시됩니다. 사용하지 않으려면 이 항목을 비워두세요", "admin.customization.iosAppDownloadLinkTitle": "iOS 앱 다운로드 링크:", + "admin.customization.restrictLinkPreviewsExample": "예시: \"internal.mycompany.com, images.example.com\"", + "admin.customization.restrictLinkPreviewsTitle": "다음 도메인에서 미리 보기 방지:", "admin.data_grid.empty": "사용자를 찾을 수 없습니다 :(", "admin.data_grid.loading": "불러오는 중", "admin.data_grid.paginatorCount": "{total, number} 의 {startCount, number} - {endCount, number}", @@ -561,6 +565,8 @@ "admin.data_retention.confirmChangesModal.title": "데이터 보존 정책 확인", "admin.data_retention.createJob.help": "데이터 보존 삭제 작업을 즉시 시작합니다.", "admin.data_retention.createJob.title": "삭제 작업 지금 실행", + "admin.data_retention.customPolicies.addPolicy": "정책 추가", + "admin.data_retention.customPolicies.subTitle": "특정 팀과 채널의 메세지 보관 기간을 지정합니다.", "admin.data_retention.deletionJobStartTime.description": "일일 데이터 보존 스케쥴의 시작 시간을 설정하십시오. 시스템 사용자가 적은 시간대를 선택하십시오. HH:MM 형식의 24시간 타임 스탬프 여야합니다.", "admin.data_retention.deletionJobStartTime.example": "예시: \"02:00\"", "admin.data_retention.deletionJobStartTime.title": "데이터 삭제 시간 :", @@ -1326,7 +1332,10 @@ "admin.permissions.roles.all_users.name": "모든 멤버", "admin.permissions.roles.channel_admin.name": "채널 관리자", "admin.permissions.roles.channel_user.name": "채널 사용자", + "admin.permissions.roles.edit": "수정", + "admin.permissions.roles.system_admin.description": "전체 수정 권한 부여", "admin.permissions.roles.system_admin.name": "시스템 관리자", + "admin.permissions.roles.system_manager.name": "시스템 매니저", "admin.permissions.roles.system_user.name": "시스템 사용자", "admin.permissions.roles.team_admin.name": "팀 관리자", "admin.permissions.roles.team_user.name": "팀 사용자", diff --git a/i18n/nl.json b/i18n/nl.json index b07326701978..1c6a25658fb1 100644 --- a/i18n/nl.json +++ b/i18n/nl.json @@ -3914,6 +3914,7 @@ "signup_user_completed.usernameLength": "Gebruikersnamen moeten beginnen met een kleine letter en tussen {min}-{max} tekens lang zijn. Je kan kleine letters, cijfers, punten, streepjes en liggende streepjes gebruiken.", "signup_user_completed.validEmail": "Vul een geldig e-mail adres in", "signup_user_completed.whatis": "Wat is uw e-mail adres?", + "someting.string": "standaartText", "status_dropdown.custom_status.tooltip_clear": "Wissen", "status_dropdown.menuAriaLabel": "Zet status", "status_dropdown.set_away": "Afwezig", diff --git a/i18n/ru.json b/i18n/ru.json index 4ffca91f18af..8460908658b7 100644 --- a/i18n/ru.json +++ b/i18n/ru.json @@ -32,6 +32,7 @@ "accessibility.sections.channelHeader": "область заголовка канала", "accessibility.sections.lhsHeader": "область меню команды", "accessibility.sections.lhsList": "область боковой панели канала", + "accessibility.sections.lhsNavigator": "область навигатора канала", "accessibility.sections.rhs": "дополнительная область {regionTitle}", "accessibility.sections.rhsContent": "дополнительная область деталей сообщения", "accessibility.sections.rhsFooter": "область ввода ответа", @@ -202,6 +203,7 @@ "admin.authentication.ldap": "AD/LDAP", "admin.authentication.mfa": "Многофакторная аутентификация", "admin.authentication.oauth": "OAuth 2.0", + "admin.authentication.openid": "OpenID Connect", "admin.authentication.saml": "SAML 2.0", "admin.authentication.signup": "Регистрация", "admin.banner.heading": "Заметка:", @@ -239,6 +241,7 @@ "admin.billing.history.title": "История счетов", "admin.billing.history.total": "Всего", "admin.billing.history.transactions": "Транзакции", + "admin.billing.history.usersAndRates": "{fullUsers} пользователи на полную ставку, {partialUsers} пользователи с частичной оплатой", "admin.billing.payment_info.add": "Добавить кредитную карту", "admin.billing.payment_info.billingAddress": "Адрес для выставления счета", "admin.billing.payment_info.cardBrandAndDigits": "{brand} истекает {digits}", @@ -256,6 +259,9 @@ "admin.billing.payment_info_edit.save": "Сохранить кредитную карту", "admin.billing.payment_info_edit.serverError": "Что-то пошло не так при сохранении платежной информации", "admin.billing.payment_info_edit.title": "Изменить платежную информацию", + "admin.billing.subscription.cancelSubscriptionSection.contactUs": "Связаться с нами", + "admin.billing.subscription.cancelSubscriptionSection.description": "Сейчас удаление рабочего пространства может быть выполнено только с помощью представителя службы поддержки клиентов.", + "admin.billing.subscription.cancelSubscriptionSection.title": "Отменить подписку", "admin.billing.subscription.creditCardExpired": "Срок действия вашей карты истек. Обновите платежную информацию.", "admin.billing.subscription.creditCardHasExpired": "Срок действия карты истек", "admin.billing.subscription.creditCardHasExpired.description.avoidAnyDisruption": " во избежание проблем.", @@ -268,6 +274,8 @@ "admin.billing.subscription.mattermostCloud": "Mattermost Cloud", "admin.billing.subscription.mostRecentPaymentFailed": "Последний платеж не прошел", "admin.billing.subscription.nextBillingDate": "Начиная с {date}, с вас будет взиматься плата в зависимости от количества включенных пользователей", + "admin.billing.subscription.otherBillingOption": "Нужны другие варианты выставления счетов?", + "admin.billing.subscription.payamentBegins": "Оплата начинается: {beginDate}", "admin.billing.subscription.paymentFailed": "Платеж не прошел. Пожалуйста, попробуйте еще раз или обратитесь в службу поддержки.", "admin.billing.subscription.paymentVerificationFailed": "Извините, проверка платежа не прошла", "admin.billing.subscription.perUserPerMonth": " /пользователь/месяц", @@ -284,23 +292,45 @@ "admin.billing.subscription.planDetails.numberOfSeats": "{numberOfSeats} мест", "admin.billing.subscription.planDetails.numberOfSeatsRegistered": "({userCount} сейчас зарегистрировано)", "admin.billing.subscription.planDetails.perUserPerMonth": "/пользователь/месяц", + "admin.billing.subscription.planDetails.planDetailsName.freeForXOrMoreUsers": "/пользователь/месяц для {aboveUserLimit} или более пользователей.", + "admin.billing.subscription.planDetails.planDetailsName.freeUpTo": "Бесплатно для {aboveUserLimit} пользователей.", "admin.billing.subscription.planDetails.prolongedOverages": "Продолжительное превышение может привести к дополнительным расходам.", "admin.billing.subscription.planDetails.seatCountOverages": "Количество мест превышено", "admin.billing.subscription.planDetails.startDate": "Дата начала: ", + "admin.billing.subscription.planDetails.tiers.free": "Бесплатно", "admin.billing.subscription.planDetails.upToXUsers": "до {userLimit} пользователей", "admin.billing.subscription.planDetails.userCount": "{userCount} пользователей", "admin.billing.subscription.planDetails.userCountWithLimit": "{userCount} / {userLimit} пользователей", + "admin.billing.subscription.privateCloudCard.contactSales": "Контакты отдела продаж", "admin.billing.subscription.privateCloudCard.contactSupport": "Связаться со службой поддержки", + "admin.billing.subscription.privateCloudCard.description": "Если вам нужно программное обеспечение с выделенной архитектурой с одним арендатором, Mattermost Private Cloud (Beta) является решением для совместной работы с высоким уровнем доверия.", "admin.billing.subscription.privateCloudCard.title": "Ищете частное облако с высоким уровнем доверия?", "admin.billing.subscription.questions": "Вопросы?", "admin.billing.subscription.stateprovince": "Область/Провинция", "admin.billing.subscription.title": "Подписки", + "admin.billing.subscription.updatePaymentInfo": "Обновить информацию об оплате", "admin.billing.subscription.upgrade": "Обновить", "admin.billing.subscription.upgradeCloudSubscription": "Расширьте подписку на Mattermost Cloud", "admin.billing.subscription.upgradeMattermostCloud.description": "Уровень бесплатного использования **ограничен до 10 пользователей.** Получите доступ к большему количеству пользователей, команд и другим замечательным функциям", "admin.billing.subscription.upgradeMattermostCloud.title": "Нужно больше пользователей?", "admin.billing.subscription.upgradeMattermostCloud.upgradeButton": "Улучшить Mattermost Cloud", + "admin.billing.subscription.upgradedSuccess": "Отлично! Вы обновились", + "admin.billing.subscription.verifyPaymentInformation": "Проверка платежных данных", + "admin.billing.subscriptions.billing_summary.lastInvoice.downloadInvoice": "Скачать счёт", + "admin.billing.subscriptions.billing_summary.lastInvoice.failed": "Неудача", + "admin.billing.subscriptions.billing_summary.lastInvoice.paid": "Оплачено", + "admin.billing.subscriptions.billing_summary.lastInvoice.partialCharges": "Частичные оплаты", + "admin.billing.subscriptions.billing_summary.lastInvoice.pending": "В ожидании", + "admin.billing.subscriptions.billing_summary.lastInvoice.seeBillingHistory": "См. историю выставления счетов", + "admin.billing.subscriptions.billing_summary.lastInvoice.taxes": "Налоги", + "admin.billing.subscriptions.billing_summary.lastInvoice.title": "Последний счёт", + "admin.billing.subscriptions.billing_summary.lastInvoice.total": "Всего", + "admin.billing.subscriptions.billing_summary.lastInvoice.userCount": " x {users} пользователей", + "admin.billing.subscriptions.billing_summary.lastInvoice.userCountPartial": "{users} пользователей", + "admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges": "Что такое частичные оплаты?", + "admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges.message": "Пользователи, которые не были подключены в течение всего месяца, оплачиваются пропорционально по месячному тарифу.", "admin.billing.subscriptions.billing_summary.noBillingHistory.description": "В будущем здесь будет отображаться ваша последняя сводка счета.", + "admin.billing.subscriptions.billing_summary.noBillingHistory.link": "Посмотреть, как работает биллинг", "admin.billing.subscriptions.billing_summary.noBillingHistory.title": "Истории счетов пока нет", "admin.bleve.bulkIndexingTitle": "Массовая индексация:", "admin.bleve.createJob.help": "Все пользователи, каналы и сообщения в базе данных будут проиндексированы, начиная с самых старых. Bleve будет доступен во время индексирования, однако результаты поисковых запросов могут быть неполны.", @@ -386,7 +416,7 @@ "admin.channel_settings.channel_moderation.postReactionsDescMembers": "Способность участников создавать реакции на сообщения.", "admin.channel_settings.channel_moderation.subtitle": "Управление действиями доступно участникам и гостям канала.", "admin.channel_settings.channel_moderation.subtitleMembers": "Управление действиями доступно участникам канала.", - "admin.channel_settings.channel_moderation.title": "Модерация канала (Бета)", + "admin.channel_settings.channel_moderation.title": "Модерация канала", "admin.channel_settings.channel_row.configure": "Изменить", "admin.channel_settings.description": "Управление настройками канала.", "admin.channel_settings.groupsPageTitle": "Каналы {siteName}", @@ -396,6 +426,8 @@ "admin.cluster.ClusterNameEx": "Например: \"Production\" или \"Staging\"", "admin.cluster.EnableExperimentalGossipEncryption": "Включить экспериментальное шифрование Gossip:", "admin.cluster.EnableExperimentalGossipEncryptionDesc": "При значении 'да' все сообщения по протоколу gossip будут зашифрованы.", + "admin.cluster.EnableGossipCompression": "Включить сжатие Gossip:", + "admin.cluster.EnableGossipCompressionDesc": "Если true, то все данные, передаваемые по протоколу Gossip, будут сжаты. Рекомендуется держать этот флаг отключенным.", "admin.cluster.GossipPort": "Gossip порт:", "admin.cluster.GossipPortDesc": "Порт, используемый для протокола gossip. На этом порту должны быть разрешены как UDP, так и TCP.", "admin.cluster.GossipPortEx": "Например: \"8074\"", @@ -405,7 +437,7 @@ "admin.cluster.StreamingPort": "Потоковый порт:", "admin.cluster.StreamingPortDesc": "Порт, используемый для потоковой передачи данных между серверами.", "admin.cluster.StreamingPortEx": "Например: \"8075\"", - "admin.cluster.UseExperimentalGossip": "Использовать экспериментальный gossip:", + "admin.cluster.UseExperimentalGossip": "Использовать протокол Gossip:", "admin.cluster.UseExperimentalGossipDesc": "При значении \"да\" сервер попытается связаться по протоколу gossip через порт gossip. При значении \"нет\" сервер будет пытаться установить связь через потоковый порт. При значении \"нет\" порт и протокол gossip все еще используются для определения работоспособности кластера.", "admin.cluster.UseIpAddress": "Использовать IP-адрес:", "admin.cluster.UseIpAddressDesc": "При значении \"да\" кластер будет пытаться установить связь через IP-адрес вместо использования имени хоста.", @@ -523,6 +555,9 @@ "admin.customization.gfycatApiSecretDescription": "Секрет API, сгенерированный Gfycat для вашего ключа API. Если поле пустое, используется секрет API по умолчанию, предоставленный Gfycat.", "admin.customization.iosAppDownloadLinkDesc": "Добавляет ссылку для скачивания приложения для IOS. Пользователям, которые посещают сайт через мобильный браузер, на специальной странице будет предложена возможность скачать приложение. Оставьте это поле пустым, чтобы предотвратить появление этой страницы.", "admin.customization.iosAppDownloadLinkTitle": "Ссылка на страницу загрузки приложения для iOS:", + "admin.customization.restrictLinkPreviewsDesc": "Предварительный просмотр ссылок и просмотр ссылок на изображения не будут показаны для вышеприведенного списка доменов, разделенных запятыми.", + "admin.customization.restrictLinkPreviewsExample": "Например: \"internal.mycompany.com, images.example.com\"", + "admin.customization.restrictLinkPreviewsTitle": "Отключить просмотр ссылок с этих доменов:", "admin.data_grid.empty": "Ничего не найдено", "admin.data_grid.loading": "Загрузка", "admin.data_grid.paginatorCount": "{startCount, number} - {endCount, number} из {total, number}", @@ -536,6 +571,9 @@ "admin.data_retention.confirmChangesModal.title": "Подтвердите политику хранения данных", "admin.data_retention.createJob.help": "Инициирует задание по удалению данных немедленно.", "admin.data_retention.createJob.title": "Запустить задание по удалению сейчас", + "admin.data_retention.customPolicies.addPolicy": "Добавить политику", + "admin.data_retention.customPolicies.subTitle": "Настроить, как долго определённые команды и каналы будут хранить сообщения.", + "admin.data_retention.customPolicies.title": "Настраиваемая политика удержания", "admin.data_retention.deletionJobStartTime.description": "Установите время начала ежедневного задания сохранения данных. Выберите время, когда наименьшее число людей используют вашу систему. Время должно быть в 24-часовом формате ЧЧ:ММ.", "admin.data_retention.deletionJobStartTime.example": "Например: \"02:00\"", "admin.data_retention.deletionJobStartTime.title": "Время удаления данных:", @@ -697,6 +735,8 @@ "admin.experimental.emailSettingsLoginButtonTextColor.title": "Цвет текста кнопки входа в систему:", "admin.experimental.enableChannelViewedMessages.desc": "Этот параметр определяет, будут ли отправляться события `channel_viewed` WebSocket, которые синхронизируют непрочитанные уведомления между клиентами и устройствами. Отключение параметра в больших развертываниях может повысить производительность сервера.", "admin.experimental.enableChannelViewedMessages.title": "Включить WebSocket сообщения \"Channel Viewed\":", + "admin.experimental.enableLegacySidebar.desc": "Когда эта функция включена, пользователи не могут получить доступ к новым функциям боковой панели, включая пользовательские, складывающиеся категории и фильтрацию непрочитанных каналов. Мы рекомендуем включать старую боковую панель только в том случае, если у пользователей возникли проблемы с изменениями или ошибки.", + "admin.experimental.enableLegacySidebar.title": "Включить режим \"Старая боковая панель\"", "admin.experimental.enablePreviewFeatures.desc": "При значении \"да\" предрелизные функции можно включить из **Настройки учетной записи > Дополнительно > Ознакомление с предрелизными функциями**. При значении \"нет\" отключает и скрывает предрелизные функции из **Настройки учетной записи > Дополнительно > Ознакомление с предрелизными функциями**.", "admin.experimental.enablePreviewFeatures.title": "Включить предрелизные функции:", "admin.experimental.enableThemeSelection.desc": "Включает вкладку **Вид > Тема** в настройках учетной записи, чтобы пользователи могли выбирать свою тему.", @@ -707,9 +747,9 @@ "admin.experimental.enableUserDeactivation.title": "Включить деактивацию аккаунта:", "admin.experimental.enableUserTypingMessages.desc": "Этот параметр определяет, отображаются ли сообщения «пользователь печатает ...» под окном сообщений. Отключение параметра в больших развертываниях может повысить производительность сервера.", "admin.experimental.enableUserTypingMessages.title": "Включить сообщения \"пользователь печатает...\":", - "admin.experimental.enableXToLeaveChannelsFromLHS.desc": "При значении \"да\" пользователи могут покинуть публичные и частные каналы, щелкнув «x» рядом с названием канала. При значении \"нет\" пользователи должны использовать функцию **Покинуть канал** в меню каналов, чтобы покинуть каналы.", + "admin.experimental.enableXToLeaveChannelsFromLHS.desc": "При значении \"да\" пользователи могут покинуть публичные и частные каналы, щёлкнув «x» рядом с названием канала. При значении \"нет\" пользователи должны использовать функцию **Покинуть канал** в меню каналов, чтобы покинуть каналы.", "admin.experimental.enableXToLeaveChannelsFromLHS.title": "Разрешить нажимать X, чтобы покинуть канал с левой боковой панели:", - "admin.experimental.experimentalChannelOrganization.desc": "Включает параметры организации боковой панели канала в **Учетная запись>Боковая панель> Группировка и сортировка каналов**, включая параметры группировки непрочитанных каналов, сортировки каналов по последним публикациям и объединения всех типов каналов в один список. Эти параметры недоступны, если **Учетная запись>Боковая панель>Экспериментальные функции боковой панели** включены.", + "admin.experimental.experimentalChannelOrganization.desc": "Включает параметры организации боковой панели канала в **Учётная запись>Боковая панель> Группировка и сортировка каналов**, включая параметры группировки непрочитанных каналов, сортировки каналов по последним публикациям и объединения всех типов каналов в один список. Эти параметры недоступны, если **Учётная запись>Боковая панель>Экспериментальные функции боковой панели** включены.", "admin.experimental.experimentalChannelOrganization.title": "Группировка и сортировка каналов", "admin.experimental.experimentalEnableAuthenticationTransfer.desc": "При значении \"да\" пользователи могут изменить свой метод входа на любой, который включен на сервере, либо через настройки учетной записи, либо через API. При значении \"нет\" пользователи не могут изменять свой метод входа, независимо от того, какие параметры аутентификации включены.", "admin.experimental.experimentalEnableAuthenticationTransfer.title": "Разрешить передачу аутентификации:", @@ -755,6 +795,10 @@ "admin.experimental.userStatusAwayTimeout.example": "Например: \"300\"", "admin.experimental.userStatusAwayTimeout.title": "Тайм-аут статуса пользователя \"Отошёл\":", "admin.false": "нет", + "admin.feature_flags.flag": "Отметить", + "admin.feature_flags.flag_value": "Значение", + "admin.feature_flags.introBanner": "Значения флагов функций, отображаемых здесь, показывают состояние функций, включенных на данном сервере. Значения здесь предназначены только для отладки командой поддержки Mattermost.", + "admin.feature_flags.title": "Флаги функций", "admin.field_names.allowBannerDismissal": "Включить возможность скрытия баннера", "admin.field_names.bannerColor": "Цвет баннера", "admin.field_names.bannerText": "Текст баннера", @@ -803,7 +847,7 @@ "admin.general.localization.serverLocaleDescription": "Язык системных сообщений по умолчанию. Требует перезагрузки сервера прежде чем вступит в силу.", "admin.general.localization.serverLocaleTitle": "Язык сервера по умолчанию:", "admin.general.log": "Ведение журнала", - "admin.gitlab.EnableMarkdownDesc": "1. Войдите в свою учетную запись на GitLab и пройдите в Настройки профиля -> Приложения.\n2. Введите перенаправляющие URI-адреса \"<url-вашего-mattermost>/login/gitlab/complete\" (пример: http://localhost:8065/login/gitlab/complete) и \"<url-вашего-mattermost>/signup/gitlab/complete\".\n3. Затем используйте поля \"Секретный ключ приложения\" и \"Идентификатор приложения\" из GitLab для заполнения опций ниже.\n4. Заполните URL-адреса конечных точек ниже.", + "admin.gitlab.EnableMarkdownDesc": "1. Войдите в свою учётную запись на GitLab и пройдите в Profile Settings -> Applications.\n2. Введите Redirect URI-адреса \"/login/gitlab/complete\" (пример: http://localhost:8065/login/gitlab/complete) и \"/signup/gitlab/complete\".\n3. Затем используйте поля \"Application Secret Key\" и \"Application ID\" из GitLab для заполнения опций ниже.\n4. Заполните Endpoint URL-адреса ниже.", "admin.gitlab.authTitle": "Конечная точка авторизации:", "admin.gitlab.clientIdDescription": "Получите это значение с помощью вышеуказанных инструкций для входа в GitLab.", "admin.gitlab.clientIdExample": "Например: \"jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY\"", @@ -811,6 +855,7 @@ "admin.gitlab.clientSecretDescription": "Получите это значение с помощью вышеуказанных инструкций для входа в GitLab.", "admin.gitlab.clientSecretExample": "Например: \"jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY\"", "admin.gitlab.clientSecretTitle": "Секретный ключ приложения:", + "admin.gitlab.discoveryEndpointDesc": "Discovery Document URL для OpenID Connect с GitLab.", "admin.gitlab.enableDescription": "При значении \"да\" Mattermost позволяет создавать команды и регистрировать учетные записи с помощью GitLab OAuth.\n \n1. Войдите в свою учетную запись на GitLab и пройдите в Настройки профиля -> Приложения.\n2. Введите перенаправляющие URI-адреса \"''/login/gitlab/complete\" (пример: http://localhost:8065/login/gitlab/complete) и \"''/signup/gitlab/complete\".\n3. Затем используйте поля \"Секретный ключ приложения\" и \"Идентификатор приложения\" из GitLab для заполнения опций ниже.\n4. Заполните URL-адреса конечных точек ниже.", "admin.gitlab.enableTitle": "Включить аутентификацию через GitLab: ", "admin.gitlab.siteUrl": "URL сайта GitLab: ", @@ -826,6 +871,7 @@ "admin.google.clientSecretDescription": "Секрет клиента, полученный при регистрации вашего приложения в Google.", "admin.google.clientSecretExample": "Например: \"H8sz0Az-dDs2p15-7QzD231\"", "admin.google.clientSecretTitle": "Клиентский ключ:", + "admin.google.discoveryEndpointDesc": "Discovery Document URL для OpenID Connect с Google.", "admin.google.tokenTitle": "Адрес выдачи токена:", "admin.google.userTitle": "Конечная точка API пользователя:", "admin.group_settings.filters.isConfigured": "Настроен", @@ -3250,6 +3296,8 @@ "pending_post_actions.retry": "Повторить", "permalink.error.access": "Постоянная ссылка принадлежит к удалённому сообщению или каналу, к которому у вас нет доступа.", "permalink.error.title": "Сообщение не найдено", + "permalink.show_dialog_warn.join": "Присоединиться", + "permalink.show_dialog_warn.title": "Присоединиться к приватному каналу", "post.ariaLabel.attachment": ", 1 вложение", "post.ariaLabel.attachmentMultiple": ", {attachmentCount} вложений", "post.ariaLabel.message": "В {time} {date}, {authorName} написал, {message}", diff --git a/i18n/sv.json b/i18n/sv.json index b683d7e5dca0..e67f85e11cae 100644 --- a/i18n/sv.json +++ b/i18n/sv.json @@ -2570,7 +2570,7 @@ "combined_system_message.removed_from_team.one_you": "Du **togs bort från teamet**.", "combined_system_message.removed_from_team.two": "{firstUser} och {secondUser} **togs bort från teamet**.", "combined_system_message.you": "Du", - "commercial_support.description": "Om du upplever problem, [skapa ett supportärende.](!https://support.mattermost.com/hc/en-us/requests/new)\n \n**Ladda ner Systemdetaljer**\n \nVi rekommenderar att du laddar ner systemdetaljer om din Mattermostmiljö som stöd i felsökningen. När du laddat ner informationen, bifoga det till ditt ärende för att ge kundsupportteamet tillgång till informationen.", + "commercial_support.description": "Om du upplever problem, [skapa ett supportärende.](!{supportLink})\n \n**Ladda ner Systemdetaljer**\n \nVi rekommenderar att du laddar ner systemdetaljer om din Mattermostmiljö som stöd i felsökningen. När du laddat ner informationen, bifoga det till ditt ärende för att ge kundsupportteamet tillgång till informationen.", "commercial_support.download_support_packet": "Ladda ner Systemdetaljer", "commercial_support.title": "Kommersiell support", "commercial_support.warning.banner": "Innan du laddar ner supportdetaljer, sätt **Spara loggar till fil** till **På** och sätt **Fil-loggnivå** till **DEBUG** [här](!/admin_console/environment/logging).", @@ -3914,6 +3914,7 @@ "signup_user_completed.usernameLength": "Användarnamn måste börja med en gemen och kan var {min}-{max} tecken långt. Du kan använda gemener, siffror, punkt, streck och understreck.", "signup_user_completed.validEmail": "Ange en giltig e-postadress", "signup_user_completed.whatis": "Vad har du för e-postadress?", + "someting.string": "standardtext", "status_dropdown.custom_status.tooltip_clear": "Nollställ", "status_dropdown.menuAriaLabel": "ange status", "status_dropdown.set_away": "Tillfälligt borta", diff --git a/i18n/tr.json b/i18n/tr.json index 3555becb7463..6b8e044339ee 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -3914,6 +3914,7 @@ "signup_user_completed.usernameLength": "Kullanıcı adları bir küçük harf ile başlamalı ve {min} ile {max} karakter arasında bir uzunluğa sahip olmalıdır. Küçük harfleri, rakamları, nokta, tire ve alt çizgi karakterlerini kullanabilirsiniz.", "signup_user_completed.validEmail": "Lütfen geçerli bir e-posta adresi yazın", "signup_user_completed.whatis": "E-posta adresiniz nedir?", + "someting.string": "varsayilanDizge", "status_dropdown.custom_status.tooltip_clear": "Temizle", "status_dropdown.menuAriaLabel": "durumu ayarla", "status_dropdown.set_away": "Uzakta", diff --git a/i18n/zh-CN.json b/i18n/zh-CN.json index b0db8f25ebd1..fb7554eeef61 100644 --- a/i18n/zh-CN.json +++ b/i18n/zh-CN.json @@ -3102,7 +3102,7 @@ "intro_messages.DM": "这是您和{teammate}私信记录的开端。\n此区域外的人不能看到这里共享的私信和文件。", "intro_messages.GM": "这是您和{names}团体消息的起端。\n此区域外的人不能看到这里共享的消息和文件。", "intro_messages.addGroupsToTeam": "添加其他组到此团队", - "intro_messages.anyMember": " 任何成员可以加入和查看这个频道。", + "intro_messages.anyMember": " 任何成员可以加入与查看该频道。", "intro_messages.beginning": "{name} 的开端", "intro_messages.creator": "这是{name}频道的开端,由{creator}于{date}建立。", "intro_messages.creatorPrivate": "这是{name}私有频道的开端,由{creator}于{date}建立。", diff --git a/i18n/zh-TW.json b/i18n/zh-TW.json index 6b69bc4d2c63..70a6d4886abd 100644 --- a/i18n/zh-TW.json +++ b/i18n/zh-TW.json @@ -32,6 +32,7 @@ "accessibility.sections.channelHeader": "頻道標題區域", "accessibility.sections.lhsHeader": "團隊選單區域", "accessibility.sections.lhsList": "頻道側邊欄區域", + "accessibility.sections.lhsNavigator": "頻道導航器區域", "accessibility.sections.rhs": "{regionTitle}補助區域", "accessibility.sections.rhsContent": "訊息詳細資訊補助區域", "accessibility.sections.rhsFooter": "回應輸入區域", diff --git a/packages/mattermost-redux/src/action_types/general.ts b/packages/mattermost-redux/src/action_types/general.ts index e9a5fde0fe8b..d5e74b5338e1 100644 --- a/packages/mattermost-redux/src/action_types/general.ts +++ b/packages/mattermost-redux/src/action_types/general.ts @@ -42,4 +42,6 @@ export default keyMirror({ WARN_METRICS_STATUS_RECEIVED: null, WARN_METRIC_STATUS_RECEIVED: null, WARN_METRIC_STATUS_REMOVED: null, + + FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED: null, }); diff --git a/packages/mattermost-redux/src/actions/general.test.js b/packages/mattermost-redux/src/actions/general.test.js index 4ef90c4e1ffb..39825ae27f44 100644 --- a/packages/mattermost-redux/src/actions/general.test.js +++ b/packages/mattermost-redux/src/actions/general.test.js @@ -13,6 +13,8 @@ import configureStore from 'mattermost-redux/test/test_store'; import {FormattedError} from './helpers.ts'; +const OK_RESPONSE = {status: 'OK'}; + describe('Actions.General', () => { let store; beforeAll(() => { @@ -187,4 +189,31 @@ describe('Actions.General', () => { assert.equal(nonexistingURL, 'http://nonexisting.url'); }); }); + + it('getFirstAdminVisitMarketplaceStatus', async () => { + const responseData = { + name: 'FirstAdminVisitMarketplace', + value: 'false', + }; + + nock(Client4.getPluginsRoute()). + get('/marketplace/first_admin_visit'). + query(true). + reply(200, responseData); + + await Actions.getFirstAdminVisitMarketplaceStatus()(store.dispatch, store.getState); + const {firstAdminVisitMarketplaceStatus} = store.getState().entities.general; + assert.strictEqual(firstAdminVisitMarketplaceStatus, false); + }); + + it('setFirstAdminVisitMarketplaceStatus', async () => { + nock(Client4.getPluginsRoute()). + post('/marketplace/first_admin_visit'). + reply(200, OK_RESPONSE); + + await Actions.setFirstAdminVisitMarketplaceStatus()(store.dispatch, store.getState); + + const {firstAdminVisitMarketplaceStatus} = store.getState().entities.general; + assert.strictEqual(firstAdminVisitMarketplaceStatus, true); + }); }); diff --git a/packages/mattermost-redux/src/actions/general.ts b/packages/mattermost-redux/src/actions/general.ts index aa5be69f66a9..f17006af89e7 100644 --- a/packages/mattermost-redux/src/actions/general.ts +++ b/packages/mattermost-redux/src/actions/general.ts @@ -200,6 +200,35 @@ export function getWarnMetricsStatus(): ActionFunc { }; } +export function setFirstAdminVisitMarketplaceStatus(): ActionFunc { + return async (dispatch: DispatchFunc) => { + try { + await Client4.setFirstAdminVisitMarketplaceStatus(); + } catch (e) { + dispatch(logError(e)); + return {error: e.message}; + } + dispatch({type: GeneralTypes.FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED, data: true}); + return {data: true}; + }; +} + +export function getFirstAdminVisitMarketplaceStatus(): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + let data; + try { + data = await Client4.getFirstAdminVisitMarketplaceStatus(); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + return {error}; + } + + data = JSON.parse(data.value); + dispatch({type: GeneralTypes.FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED, data}); + return {data}; + }; +} + export default { getPing, getClientConfig, @@ -214,4 +243,5 @@ export default { setUrl, getRedirectLocation, getWarnMetricsStatus, + getFirstAdminVisitMarketplaceStatus, }; diff --git a/packages/mattermost-redux/src/client/client4.ts b/packages/mattermost-redux/src/client/client4.ts index b35916947829..0ee2ea33850b 100644 --- a/packages/mattermost-redux/src/client/client4.ts +++ b/packages/mattermost-redux/src/client/client4.ts @@ -1,5 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. + +import {SystemSetting} from 'mattermost-redux/types/general'; + import {General} from '../constants'; import {ClusterInfo, AnalyticsRow} from 'mattermost-redux/types/admin'; @@ -34,7 +37,9 @@ import { } from 'mattermost-redux/types/config'; import {CustomEmoji} from 'mattermost-redux/types/emojis'; import {ServerError} from 'mattermost-redux/types/errors'; + import {FileInfo, FileUploadResponse, FileSearchResults} from 'mattermost-redux/types/files'; + import { Group, GroupPatch, @@ -2309,6 +2314,20 @@ export default class Client4 { ); } + setFirstAdminVisitMarketplaceStatus = async () => { + return this.doFetch( + `${this.getPluginsRoute()}/marketplace/first_admin_visit`, + {method: 'post', body: JSON.stringify({first_admin_visit_marketplace_status: true})}, + ); + } + + getFirstAdminVisitMarketplaceStatus = async () => { + return this.doFetch( + `${this.getPluginsRoute()}/marketplace/first_admin_visit`, + {method: 'get'}, + ); + }; + getTranslations = (url: string) => { return this.doFetch>( url, diff --git a/packages/mattermost-redux/src/constants/websocket.ts b/packages/mattermost-redux/src/constants/websocket.ts index 41300d01f64b..4d2afc7eedaf 100644 --- a/packages/mattermost-redux/src/constants/websocket.ts +++ b/packages/mattermost-redux/src/constants/websocket.ts @@ -51,5 +51,6 @@ const WebsocketEvents = { THREAD_UPDATED: 'thread_updated', THREAD_FOLLOW_CHANGED: 'thread_follow_changed', THREAD_READ_CHANGED: 'thread_read_changed', + FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED: 'first_admin_visit_marketplace_status_received', }; export default WebsocketEvents; diff --git a/packages/mattermost-redux/src/reducers/entities/general.test.js b/packages/mattermost-redux/src/reducers/entities/general.test.js new file mode 100644 index 000000000000..618ec482ae08 --- /dev/null +++ b/packages/mattermost-redux/src/reducers/entities/general.test.js @@ -0,0 +1,44 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; + +import reducer from 'mattermost-redux/reducers/entities/general'; +import {GeneralTypes} from 'mattermost-redux/action_types'; + +describe('reducers.entities.general', () => { + describe('firstAdminVisitMarketplaceStatus', () => { + it('initial state', () => { + const state = {}; + const action = {}; + const expectedState = {}; + + const actualState = reducer({firstAdminVisitMarketplaceStatus: state}, action); + assert.deepStrictEqual(actualState.firstAdminVisitMarketplaceStatus, expectedState); + }); + + it('FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED, empty initial state', () => { + const state = {}; + const action = { + type: GeneralTypes.FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED, + data: true, + }; + const expectedState = true; + + const actualState = reducer({firstAdminVisitMarketplaceStatus: state}, action); + assert.deepStrictEqual(actualState.firstAdminVisitMarketplaceStatus, expectedState); + }); + + it('FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED, previously populated state', () => { + const state = true; + const action = { + type: GeneralTypes.FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED, + data: true, + }; + const expectedState = true; + + const actualState = reducer({firstAdminVisitMarketplaceStatus: state}, action); + assert.deepStrictEqual(actualState.firstAdminVisitMarketplaceStatus, expectedState); + }); + }); +}); diff --git a/packages/mattermost-redux/src/reducers/entities/general.ts b/packages/mattermost-redux/src/reducers/entities/general.ts index 14d272b14c39..2d9e2517b38c 100644 --- a/packages/mattermost-redux/src/reducers/entities/general.ts +++ b/packages/mattermost-redux/src/reducers/entities/general.ts @@ -125,6 +125,16 @@ function warnMetricsStatus(state: any = {}, action: GenericAction) { } } +function firstAdminVisitMarketplaceStatus(state = false, action: GenericAction) { + switch (action.type) { + case GeneralTypes.FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED: + return action.data; + + default: + return state; + } +} + export default combineReducers({ appState, credentials, @@ -135,4 +145,5 @@ export default combineReducers({ serverVersion, timezones, warnMetricsStatus, + firstAdminVisitMarketplaceStatus, }); diff --git a/packages/mattermost-redux/src/selectors/entities/apps.test.ts b/packages/mattermost-redux/src/selectors/entities/apps.test.ts new file mode 100644 index 000000000000..195578412fea --- /dev/null +++ b/packages/mattermost-redux/src/selectors/entities/apps.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import * as Selectors from 'mattermost-redux/selectors/entities/apps'; +import {GlobalState} from 'types/store'; +import {AppBinding} from 'mattermost-redux/types/apps'; +import {AppBindingLocations} from 'mattermost-redux/constants/apps'; + +const makeNewState = (flag?: string, bindings?: AppBinding[]) => ({ + entities: { + general: { + config: { + FeatureFlagAppsEnabled: flag, + }, + }, + apps: { + bindings, + }, + }, +}) as unknown as GlobalState; + +describe('Selectors.Apps', () => { + describe('appsEnabled', () => { + it('should return true when feature flag is enabled', () => { + const state: GlobalState = makeNewState('true'); + const result = Selectors.appsEnabled(state); + expect(result).toEqual(true); + }); + + it('should return false when feature flag is disabled', () => { + let state: GlobalState = makeNewState('false'); + let result = Selectors.appsEnabled(state); + expect(result).toEqual(false); + + state = makeNewState(''); + result = Selectors.appsEnabled(state); + expect(result).toEqual(false); + + state = makeNewState(); + result = Selectors.appsEnabled(state); + expect(result).toEqual(false); + }); + }); + + describe('makeAppBindingsSelector', () => { + const allBindings = [ + { + location: '/post_menu', + bindings: [ + { + app_id: 'app1', + location: 'post-menu-1', + label: 'App 1 Post Menu', + }, + { + app_id: 'app2', + location: 'post-menu-2', + label: 'App 2 Post Menu', + }, + ], + }, + { + location: '/channel_header', + bindings: [ + { + app_id: 'app1', + location: 'channel-header-1', + label: 'App 1 Channel Header', + }, + { + app_id: 'app2', + location: 'channel-header-2', + label: 'App 2 Channel Header', + }, + ], + }, + { + location: '/command', + bindings: [ + { + app_id: 'app1', + location: 'command-1', + label: 'App 1 Command', + }, + { + app_id: 'app2', + location: 'command-2', + label: 'App 2 Command', + }, + ], + }, + ] as AppBinding[]; + + it('should return an empty array when feature flag is false', () => { + const state = makeNewState('false', allBindings); + const selector = Selectors.makeAppBindingsSelector(AppBindingLocations.POST_MENU_ITEM); + const result = selector(state); + expect(result).toEqual([]); + }); + + it('should return post menu bindings', () => { + const state = makeNewState('true', allBindings); + const selector = Selectors.makeAppBindingsSelector(AppBindingLocations.POST_MENU_ITEM); + const result = selector(state); + expect(result).toEqual([ + { + app_id: 'app1', + location: 'post-menu-1', + label: 'App 1 Post Menu', + }, + { + app_id: 'app2', + location: 'post-menu-2', + label: 'App 2 Post Menu', + }, + ]); + }); + + it('should return channel header bindings', () => { + const state = makeNewState('true', allBindings); + const selector = Selectors.makeAppBindingsSelector(AppBindingLocations.CHANNEL_HEADER_ICON); + const result = selector(state); + expect(result).toEqual([ + { + app_id: 'app1', + location: 'channel-header-1', + label: 'App 1 Channel Header', + }, + { + app_id: 'app2', + location: 'channel-header-2', + label: 'App 2 Channel Header', + }, + ]); + }); + + it('should return command bindings', () => { + const state = makeNewState('true', allBindings); + const selector = Selectors.makeAppBindingsSelector(AppBindingLocations.COMMAND); + const result = selector(state); + expect(result).toEqual([ + { + app_id: 'app1', + location: 'command-1', + label: 'App 1 Command', + }, + { + app_id: 'app2', + location: 'command-2', + label: 'App 2 Command', + }, + ]); + }); + }); +}); diff --git a/packages/mattermost-redux/src/selectors/entities/apps.ts b/packages/mattermost-redux/src/selectors/entities/apps.ts index 811d0e8ed674..4ce10f482027 100644 --- a/packages/mattermost-redux/src/selectors/entities/apps.ts +++ b/packages/mattermost-redux/src/selectors/entities/apps.ts @@ -1,19 +1,36 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. + +import {createSelector} from 'reselect'; + import {GlobalState} from 'mattermost-redux/types/store'; import {AppBinding} from 'mattermost-redux/types/apps'; +import {ClientConfig} from 'mattermost-redux/types/config'; + +import {getConfig} from 'mattermost-redux/selectors/entities/general'; // This file's contents belong to the Apps Framework feature. // Apps Framework feature is experimental, and the contents of this file are // susceptible to breaking changes without pushing the major version of this package. -export function getAppBindings(state: GlobalState, location?: string): AppBinding[] { - if (!state.entities.apps.bindings) { - return []; - } +export const appsEnabled = createSelector( + (state: GlobalState) => getConfig(state), + (config?: Partial) => { + const enabled = config?.['FeatureFlagAppsEnabled' as keyof Partial]; + return enabled === 'true'; + }, +); + +export const makeAppBindingsSelector = (location: string) => { + return createSelector( + (state: GlobalState) => state.entities.apps.bindings, + (state: GlobalState) => appsEnabled(state), + (bindings: AppBinding[], areAppsEnabled: boolean) => { + if (!areAppsEnabled || !bindings) { + return []; + } - if (location) { - const headerBindings = state.entities.apps.bindings.filter((b) => b.location === location); - return headerBindings.reduce((accum: AppBinding[], current: AppBinding) => accum.concat(current.bindings || []), []); - } - return state.entities.apps.bindings; -} + const headerBindings = bindings.filter((b) => b.location === location); + return headerBindings.reduce((accum: AppBinding[], current: AppBinding) => accum.concat(current.bindings || []), []); + }, + ); +}; diff --git a/packages/mattermost-redux/src/selectors/entities/general.test.js b/packages/mattermost-redux/src/selectors/entities/general.test.js index 89d655fb2965..4874a86f6df1 100644 --- a/packages/mattermost-redux/src/selectors/entities/general.test.js +++ b/packages/mattermost-redux/src/selectors/entities/general.test.js @@ -397,5 +397,34 @@ describe('Selectors.General', () => { expect(Selectors.getFeatureFlagValue(state, 'CoolFeature')).toEqual('true'); }); }); + + describe('firstAdminVisitMarketplaceStatus', () => { + test('should return empty when status does not exist', () => { + const state = { + entities: { + general: { + firstAdminVisitMarketplaceStatus: { + }, + }, + }, + }; + + expect(Selectors.getFirstAdminVisitMarketplaceStatus(state)).toEqual({}); + }); + + test('should return the value of the status', () => { + const state = { + entities: { + general: { + firstAdminVisitMarketplaceStatus: true, + }, + }, + }; + + expect(Selectors.getFirstAdminVisitMarketplaceStatus(state)).toEqual(true); + state.entities.general.firstAdminVisitMarketplaceStatus = false; + expect(Selectors.getFirstAdminVisitMarketplaceStatus(state)).toEqual(false); + }); + }); }); diff --git a/packages/mattermost-redux/src/selectors/entities/general.ts b/packages/mattermost-redux/src/selectors/entities/general.ts index e4702c42e6a4..0872431e5b7e 100644 --- a/packages/mattermost-redux/src/selectors/entities/general.ts +++ b/packages/mattermost-redux/src/selectors/entities/general.ts @@ -104,3 +104,7 @@ export const getManagedResourcePaths: (state: GlobalState) => string[] = createS export const getServerVersion = (state: GlobalState): string => { return state.entities.general.serverVersion; }; + +export function getFirstAdminVisitMarketplaceStatus(state: GlobalState): boolean { + return state.entities.general.firstAdminVisitMarketplaceStatus; +} diff --git a/packages/mattermost-redux/src/store/initial_state.ts b/packages/mattermost-redux/src/store/initial_state.ts index 5c045c6f5323..21fef0151e83 100644 --- a/packages/mattermost-redux/src/store/initial_state.ts +++ b/packages/mattermost-redux/src/store/initial_state.ts @@ -15,6 +15,7 @@ const state: GlobalState = { serverVersion: '', timezones: [], warnMetricsStatus: {}, + firstAdminVisitMarketplaceStatus: false, }, users: { currentUserId: '', diff --git a/packages/mattermost-redux/src/types/general.ts b/packages/mattermost-redux/src/types/general.ts index 7f24264baa2c..4e63adbfa596 100644 --- a/packages/mattermost-redux/src/types/general.ts +++ b/packages/mattermost-redux/src/types/general.ts @@ -11,8 +11,14 @@ export type GeneralState = { config: Partial; dataRetentionPolicy: any; deviceToken: string; + firstAdminVisitMarketplaceStatus: boolean; license: ClientLicense; serverVersion: string; timezones: string[]; warnMetricsStatus: Dictionary; }; + +export type SystemSetting = { + name: string; + value: string; +}; diff --git a/plugins/channel_header_plug/channel_header_plug.tsx b/plugins/channel_header_plug/channel_header_plug.tsx index aa89cee90fa1..42de31d783b2 100644 --- a/plugins/channel_header_plug/channel_header_plug.tsx +++ b/plugins/channel_header_plug/channel_header_plug.tsx @@ -98,7 +98,7 @@ class CustomToggle extends React.PureComponent { type ChannelHeaderPlugProps = { intl: IntlShape; components: PluginComponent[]; - appBindings: AppBinding[]; + appBindings?: AppBinding[]; appsEnabled: boolean; channel: Channel; channelMember: ChannelMembership; @@ -113,6 +113,11 @@ type ChannelHeaderPlugState = { } class ChannelHeaderPlug extends React.PureComponent { + public static defaultProps: Partial = { + components: [], + appBindings: [], + } + constructor(props: ChannelHeaderPlugProps) { super(props); this.state = { diff --git a/plugins/channel_header_plug/index.ts b/plugins/channel_header_plug/index.ts index 1a865c8ada38..28927a81040f 100644 --- a/plugins/channel_header_plug/index.ts +++ b/plugins/channel_header_plug/index.ts @@ -5,7 +5,7 @@ import {connect} from 'react-redux'; import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {getAppBindings} from 'mattermost-redux/selectors/entities/apps'; +import {appsEnabled, makeAppBindingsSelector} from 'mattermost-redux/selectors/entities/apps'; import {AppBindingLocations} from 'mattermost-redux/constants/apps'; import {ActionFunc, ActionResult, GenericAction} from 'mattermost-redux/types/actions'; @@ -14,15 +14,15 @@ import {AppCallRequest, AppCallType} from 'mattermost-redux/types/apps'; import {doAppCall} from 'actions/apps'; import {GlobalState} from 'types/store'; -import {appsEnabled} from 'utils/apps'; - import ChannelHeaderPlug from './channel_header_plug'; +const getChannelHeaderBindings = makeAppBindingsSelector(AppBindingLocations.CHANNEL_HEADER_ICON); + function mapStateToProps(state: GlobalState) { const apps = appsEnabled(state); return { - components: state.plugins.components.ChannelHeaderButton || [], - appBindings: apps ? getAppBindings(state, AppBindingLocations.CHANNEL_HEADER_ICON) : [], + components: state.plugins.components.ChannelHeaderButton, + appBindings: getChannelHeaderBindings(state), appsEnabled: apps, theme: getTheme(state), }; diff --git a/plugins/test/__snapshots__/main_menu_action.test.jsx.snap b/plugins/test/__snapshots__/main_menu_action.test.jsx.snap index 78158bceac9b..045e445df697 100644 --- a/plugins/test/__snapshots__/main_menu_action.test.jsx.snap +++ b/plugins/test/__snapshots__/main_menu_action.test.jsx.snap @@ -281,6 +281,7 @@ exports[`plugins/MainMenuActions should match snapshot and click plugin item for id="marketplaceModal" modalId="plugin_marketplace" show={true} + showUnread={true} text="Marketplace" /> diff --git a/sass/components/_buttons.scss b/sass/components/_buttons.scss index 5fd601de09da..0b0136700bc4 100644 --- a/sass/components/_buttons.scss +++ b/sass/components/_buttons.scss @@ -1,6 +1,15 @@ @charset 'UTF-8'; button { + .unread-badge { + display: inline-block; + border-radius: 50%; + width: 8px; + height: 8px; + margin: 0 0 0 40px; + background: #F74343; + } + &.style--none { background: transparent; border: none; diff --git a/sass/components/_mentions.scss b/sass/components/_mentions.scss index 5d2333dcbcb4..0e7c6edacb5b 100644 --- a/sass/components/_mentions.scss +++ b/sass/components/_mentions.scss @@ -63,7 +63,6 @@ .status { width: auto; - margin: 0 8px; display: block; top: 1px; @@ -73,6 +72,12 @@ } } + // .mentions__name is way too multi-purpose. This rule targets its use in + // SwitchChannelSuggestion, applying margin in a way that handles null elements correctly. + .status, .mentions__fullname { + margin: 0 0 0 8px; + } + .fa-user { position: relative; top: -1px; diff --git a/sass/components/_post.scss b/sass/components/_post.scss index 6703dd743ae0..016b6336ef89 100644 --- a/sass/components/_post.scss +++ b/sass/components/_post.scss @@ -42,7 +42,7 @@ ul { white-space: normal; } - box-shadow: none; + left: 0; position: relative; top: 0; @@ -1786,12 +1786,12 @@ background: linear-gradient(transparent, var(--pinned-highlight-bg-mixed-rgb)); position: relative; } - + .post-collapse__show-more, .post-attachment-collapse__show-more { background: var(--pinned-highlight-bg-mixed-rgb); pointer-events: auto; - } - + } + } } @@ -2052,7 +2052,7 @@ background: rgba(var(--mention-highlight-bg-mixed-rgb), 1); } -.app__body .post-list__table .post.current--user.post--highlight:hover .post-collapse__gradient, +.app__body .post-list__table .post.current--user.post--highlight:hover .post-collapse__gradient, .app__body .post-list__table .post.current--user.post--highlight.post--hovered .post-collapse__gradient { background:linear-gradient(rgba(var(--mention-highlight-bg-mixed-rgb), 0.5), rgba(var(--mention-highlight-bg-mixed-rgb), 1)); } diff --git a/sass/components/_sidebar-header-dropdown-button.scss b/sass/components/_sidebar-header-dropdown-button.scss index f5f4ed15c2aa..511d0d501a49 100644 --- a/sass/components/_sidebar-header-dropdown-button.scss +++ b/sass/components/_sidebar-header-dropdown-button.scss @@ -24,6 +24,26 @@ } } + .unread-badge { + position: absolute; + border-radius: 50%; + width: 8px; + height: 8px; + right: 2px; + bottom: 2px; + background: #F74343; + } + + .unread-badge-addon { + position: absolute; + top: 8px; + right: 4px; + height: 12px; + width: 12px; + border-radius: 32px; + background-color: var(--sidebar-header-bg); + } + .menu-icon { @include opacity(.8); fill: $white; diff --git a/sass/layout/_sidebar-left.scss b/sass/layout/_sidebar-left.scss index 4ca438138716..d26a49ed149a 100644 --- a/sass/layout/_sidebar-left.scss +++ b/sass/layout/_sidebar-left.scss @@ -1079,7 +1079,7 @@ } .SidebarChannel .SidebarLink > i { font-size: 18px; - margin: 0 10px 0 -2px; + margin: 0 6px 0 -2px; display: flex; } @@ -1107,7 +1107,7 @@ .DirectChannel__profile-picture { height: 20px; - margin-right: 13px; + margin-right: 9px; .DirectChannel__status-icon { position: absolute; @@ -1115,8 +1115,8 @@ left: 10px; border-radius: 100%; background: var(--sidebar-bg); - height: 12px; - width: 12px; + height: 13px; + width: 13px; font-size: 12px; align-items: center; justify-content: center; @@ -1145,7 +1145,7 @@ .status.status--group { background: rgba(var(--sidebar-text-rgb), 0.16); - margin: 0px 13px 0 1px; + margin: 0px 9px 0 1px; width: 18px; height: 18px; flex-shrink: 0; diff --git a/selectors/views/marketplace.ts b/selectors/views/marketplace.ts index 48d8cc840bca..e7d870b83458 100644 --- a/selectors/views/marketplace.ts +++ b/selectors/views/marketplace.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {createSelector} from 'reselect'; + import {isPlugin} from 'mattermost-redux/utils/marketplace'; import type {MarketplaceApp, MarketplacePlugin} from 'mattermost-redux/types/marketplace'; @@ -10,25 +12,28 @@ export const getPlugins = (state: GlobalState): MarketplacePlugin[] => state.vie export const getApps = (state: GlobalState): MarketplaceApp[] => state.views.marketplace.apps; -export const getListing = (state: GlobalState): Array => { - const plugins = getPlugins(state); - const apps = getApps(state); - - if (plugins) { - return (plugins as Array).concat(apps); - } +export const getListing = createSelector( + getPlugins, + getApps, + (plugins, apps) => { + if (plugins) { + return (plugins as Array).concat(apps); + } - return apps; -}; + return apps; + }, +); -export const getInstalledListing = (state: GlobalState): Array => - Object.values(getListing(state)).filter((i) => { +export const getInstalledListing = createSelector( + getListing, + (listing) => listing.filter((i) => { if (isPlugin(i)) { return i.installed_version !== ''; } return i.installed; - }); + }), +); export const getPlugin = (state: GlobalState, id: string): MarketplacePlugin | undefined => getPlugins(state).find(((p) => p.manifest && p.manifest.id === id)); diff --git a/utils/apps.ts b/utils/apps.ts index ba4bc7ec2a13..272d2997c6de 100644 --- a/utils/apps.ts +++ b/utils/apps.ts @@ -1,19 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {AppCallResponseTypes} from 'mattermost-redux/constants/apps'; import {AppBinding, AppCall, AppCallRequest, AppCallValues, AppContext, AppExpand} from 'mattermost-redux/types/apps'; -import {ClientConfig} from 'mattermost-redux/types/config'; -import {GlobalState} from 'mattermost-redux/types/store'; export const appsPluginID = 'com.mattermost.apps'; -export function appsEnabled(state: GlobalState): boolean { - const enabled = getConfig(state)?.['FeatureFlagAppsEnabled' as keyof Partial]; - return enabled === 'true'; -} - export function fillBindingsInformation(binding?: AppBinding) { if (!binding) { return; diff --git a/utils/constants.jsx b/utils/constants.jsx index ba937b336333..3b26f49d31ae 100644 --- a/utils/constants.jsx +++ b/utils/constants.jsx @@ -396,6 +396,7 @@ export const SocketEvents = { USER_ACTIVATION_STATUS_CHANGED: 'user_activation_status_change', CLOUD_PAYMENT_STATUS_UPDATED: 'cloud_payment_status_updated', APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings', + FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED: 'first_admin_visit_marketplace_status_received', }; export const TutorialSteps = {