* Migrate feature/wrangler to mono-repo

* Add wrangler files

* Fix linters, types, etc

* Fix snapshots

* Fix playwright

* Fix pipelines

* Fix more pipeline

* Fixes for pipelines

* More changes for pipeline

* Fix types

* Add support for a feature flag, but leave it defaulted on for spinwick usage for now

* Update snapshot

* fix js error when removing last value of multiselect, support CSV marshaling to string array for textsetting

* Fix linter

* Remove TODO

* Remove another TODO

* fix tests

* Fix i18n

* Add server tests

* Fix linter

* Fix linter

* Use proper icon for dot menu

* Update snapshot

* Add Cypress UI tests for various entrypoints to move thread modal, split SCSS out from forward post into its own thing

* clean up

* fix linter

* More cleanup

* Revert files to master

* Fix linter for e2e tests

* Make ForwardPostChannelSelect channel types configurable with a prop

* Add missing return

* Fixes from PR feedback

* First batch of PR Feedback

* Another batch of PR changes

* Fix linter

* Update snapshots

* Wrangler system messages are translated to each user's locale

* Initially translate Wrangler into system locale rather than initiating user

* More fixes for PR Feedback

* Fix some server tests

* More updates with master. Fixes around pipelines. Enforce Enterprise license on front/back end

* Add tests for dot_menu

* More pipeline fixes

* Fix e2etests prettier

* Update cypress tests, change occurrences of 'Wrangler' with 'Move Thread'

* Fix linter

* Remove enterprise lock

* A couple more occurrences of wrangler strings, and one more enterprise lock

* Fix server tests

* Fix i18n

* Fix e2e linter

* Feature flag shouldn't be on by default

* Enable move threads feature in smoke tests (#25657)

* enable move threads feature
* add @prod tag

* Fix move_thread_from_public_channel e2e test

* Fix e2e style

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
Этот коммит содержится в:
Nick Misasi
2023-12-11 15:27:34 -05:00
коммит произвёл GitHub
родитель bfb8320afd
Коммит f0a336ba07
56 изменённых файлов: 3107 добавлений и 16 удалений

Просмотреть файл

@@ -80,7 +80,7 @@ exports[`components/admin_console/SchemaAdminSettings should match snapshot with
onChange={[Function]}
placeholder="e.g. some setting"
setByEnv={false}
type="input"
type="text"
value="fsdsdg"
/>
<BooleanSetting

Просмотреть файл

@@ -3199,6 +3199,91 @@ const AdminDefinition: AdminDefinitionType = {
],
},
},
wrangler: {
url: 'site_config/wrangler',
title: t('admin.sidebar.move_thread'),
title_default: 'Move Thread (Beta)',
isHidden: it.any(it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.SITE.POSTS)), it.configIsFalse('FeatureFlags', 'MoveThreadsEnabled')),
schema: {
id: 'WranglerSettings',
name: t('admin.site.move_thread'),
name_default: 'Move Thread (Beta)',
settings: [
{
type: 'roles',
multiple: true,
key: 'WranglerSettings.PermittedWranglerRoles',
label: t('admin.experimental.PermittedMoveThreadRoles.title'),
label_default: 'Permitted Roles',
help_text: t('admin.experimental.PermittedMoveThreadRoles.desc'),
help_text_default: 'Choose who is allowed to move threads to other channels based on roles. (Other permissions below still apply).',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'text',
key: 'WranglerSettings.AllowedEmailDomain',
multiple: true,
label: t('admin.experimental.allowedEmailDomain.title'),
label_default: 'Allowed Email Domain',
help_text: t('admin.experimental.allowedEmailDomain.desc'),
help_text_default: '(Optional) When set, users must have an email ending in this domain to move threads. Multiple domains can be specified by separating them with commas.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'number',
key: 'WranglerSettings.MoveThreadMaxCount',
label: t('admin.experimental.moveThreadMaxCount.title'),
label_default: 'Max Thread Count Move Size',
help_text: t('admin.experimental.moveThreadMaxCount.desc'),
help_text_default: 'The maximum number of messages in a thread that the plugin is allowed to move. Leave empty for unlimited messages.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'bool',
key: 'WranglerSettings.MoveThreadToAnotherTeamEnable',
label: t('admin.experimental.moveThreadToAnotherTeamEnable.title'),
label_default: 'Enable Moving Threads To Different Teams',
help_text: t('admin.experimental.moveThreadToAnotherTeamEnable.desc'),
help_text_default: 'Control whether Wrangler is permitted to move message threads from one team to another or not.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'bool',
key: 'WranglerSettings.MoveThreadFromPrivateChannelEnable',
label: t('admin.experimental.moveThreadFromPrivateChannelEnable.title'),
label_default: 'Enable Moving Threads From Private Channels',
help_text: t('admin.experimental.moveThreadFromPrivateChannelEnable.desc'),
help_text_default: 'Control whether Wrangler is permitted to move message threads from private channels or not.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'bool',
key: 'WranglerSettings.MoveThreadFromDirectMessageChannelEnable',
label: t('admin.experimental.moveThreadFromDirectMessageChannelEnable.title'),
label_default: 'Enable Moving Threads From Direct Message Channels',
help_text: t('admin.experimental.moveThreadFromDirectMessageChannelEnable.desc'),
help_text_default: 'Control whether Wrangler is permitted to move message threads from direct message channels or not.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: 'bool',
key: 'WranglerSettings.MoveThreadFromGroupMessageChannelEnable',
label: t('admin.experimental.moveThreadFromGroupMessageChannelEnable.title'),
label_default: 'Enable Moving Threads From Group Message Channels',
help_text: t('admin.experimental.moveThreadFromGroupMessageChannelEnable.desc'),
help_text_default: 'Control whether Wrangler is permitted to move message threads from group message channels or not.',
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
],
},
},
file_sharing_downloads: {
url: 'site_config/file_sharing_downloads',
title: t('admin.sidebar.fileSharingDownloads'),

Просмотреть файл

@@ -46,7 +46,7 @@ exports[`components/admin_console/CustomPluginSettings should match snapshot wit
onChange={[Function]}
placeholder="e.g. some setting"
setByEnv={false}
type="input"
type="text"
value="setting_default"
/>
<BooleanSetting
@@ -356,7 +356,7 @@ exports[`components/admin_console/CustomPluginSettings should match snapshot wit
onChange={[Function]}
placeholder="e.g. some setting"
setByEnv={false}
type="input"
type="text"
value="fsdsdg"
/>
<BooleanSetting

Просмотреть файл

@@ -45,9 +45,9 @@ State
}
handleChange = (newValue: ValueType<Option>) => {
const values = (newValue as Option[]).map((n) => {
const values = newValue ? (newValue as Option[]).map((n) => {
return n.value;
});
}) : [];
this.props.onChange(this.props.id, values);
this.setState({error: false});

Просмотреть файл

@@ -37,6 +37,8 @@ import Setting from './setting';
import './schema_admin_settings.scss';
const emptyList = [];
export default class SchemaAdminSettings extends React.PureComponent {
static propTypes = {
config: PropTypes.object,
@@ -75,6 +77,7 @@ export default class SchemaAdminSettings extends React.PureComponent {
[Constants.SettingsTypes.TYPE_LANGUAGE]: this.buildLanguageSetting,
[Constants.SettingsTypes.TYPE_JOBSTABLE]: this.buildJobsTableSetting,
[Constants.SettingsTypes.TYPE_FILE_UPLOAD]: this.buildFileUploadSetting,
[Constants.SettingsTypes.TYPE_ROLES]: this.buildRolesSetting,
[Constants.SettingsTypes.TYPE_CUSTOM]: this.buildCustomSetting,
};
this.state = {
@@ -439,16 +442,20 @@ export default class SchemaAdminSettings extends React.PureComponent {
};
buildTextSetting = (setting) => {
let inputType = 'input';
let inputType = 'text';
if (setting.type === Constants.SettingsTypes.TYPE_NUMBER) {
inputType = 'number';
} else if (setting.type === Constants.SettingsTypes.TYPE_LONG_TEXT) {
inputType = 'textarea';
}
let value = this.state[setting.key] ?? '';
let value = '';
if (setting.dynamic_value) {
value = setting.dynamic_value(value, this.props.config, this.state, this.props.license);
} else if (setting.multiple) {
value = this.state[setting.key] ? this.state[setting.key].join(',') : '';
} else {
value = this.state[setting.key] || '';
}
let footer = null;
@@ -466,6 +473,7 @@ export default class SchemaAdminSettings extends React.PureComponent {
<TextSetting
key={this.props.schema.id + '_text_' + setting.key}
id={setting.key}
multiple={setting.multiple}
type={inputType}
label={this.renderLabel(setting)}
helpText={this.renderHelpText(setting)}
@@ -570,6 +578,53 @@ export default class SchemaAdminSettings extends React.PureComponent {
);
};
buildRolesSetting = (setting) => {
const {roles} = this.props;
const values = Object.keys(roles).map((r) => {
return {
value: roles[r].name,
text: roles[r].name,
};
});
if (setting.multiple) {
const noResultText = (
<FormattedMessage
id={setting.no_result}
defaultMessage={setting.no_result_default}
/>
);
return (
<MultiSelectSetting
key={this.props.schema.id + '_language_' + setting.key}
id={setting.key}
label={this.renderLabel(setting)}
values={values}
helpText={this.renderHelpText(setting)}
selected={(this.state[setting.key] || emptyList)}
disabled={this.isDisabled(setting)}
setByEnv={this.isSetByEnv(setting.key)}
onChange={this.handleChange}
noResultText={noResultText}
/>
);
}
return (
<DropdownSetting
key={this.props.schema.id + '_language_' + setting.key}
id={setting.key}
label={this.renderLabel(setting)}
values={values}
helpText={this.renderHelpText(setting)}
value={this.state[setting.key] || values[0].value}
disabled={this.isDisabled(setting)}
setByEnv={this.isSetByEnv(setting.key)}
onChange={this.handleChange}
/>
);
};
buildLanguageSetting = (setting) => {
const locales = I18n.getAllLanguages();
const values = Object.keys(locales).map((l) => {

Просмотреть файл

@@ -45,6 +45,14 @@ type AdminDefinitionSettingBanner = AdminDefinitionSettingBase & {
banner_type: 'info' | 'warning';
}
type AdminDefinitionSettingRole = AdminDefinitionSettingBase & {
type: 'roles';
multiple?: boolean;
help_text?: string;
help_text_default?: string;
help_text_markdown?: boolean;
}
type AdminDefinitionSettingInput = AdminDefinitionSettingBase & {
type: 'text' | 'bool' | 'longtext' | 'number' | 'color';
help_text?: string | JSX.Element;
@@ -55,6 +63,7 @@ type AdminDefinitionSettingInput = AdminDefinitionSettingBase & {
disabled_help_text_default?: string;
disabled_help_text_markdown?: boolean;
placeholder?: string;
multiple?: boolean;
placeholder_default?: string;
validate?: Validator;
setFromMetadataField?: string;
@@ -155,7 +164,7 @@ export type AdminDefinitionSetting = AdminDefinitionSettingCustom |
AdminDefinitionSettingInput | AdminDefinitionSettingGenerated |
AdminDefinitionSettingBanner | AdminDefinitionSettingDropdown |
AdminDefinitionSettingButton | AdminDefinitionSettingFileUpload |
AdminDefinitionSettingJobsTable | AdminDefinitionSettingLanguage;
AdminDefinitionSettingJobsTable | AdminDefinitionSettingLanguage | AdminDefinitionSettingRole;
type AdminDefinitionConfigSchemaSettings = {
id: string;

Просмотреть файл

@@ -1,5 +1,114 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/dot_menu/DotMenu should match snapshot, can move 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>
</body>,
"container": <div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"unmount": [Function],
"updateStoreState": [Function],
}
`;
exports[`components/dot_menu/DotMenu should match snapshot, canDelete 1`] = `
Object {
"asFragment": [Function],
@@ -109,6 +218,115 @@ Object {
}
`;
exports[`components/dot_menu/DotMenu should match snapshot, cannot move 1`] = `
Object {
"asFragment": [Function],
"baseElement": <body>
<div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>
</body>,
"container": <div>
<button
aria-controls="CENTER_dropdown_post_id_1"
aria-expanded="false"
aria-haspopup="true"
aria-label="more"
class="post-menu__item"
data-testid="PostDotMenu-Button-post_id_1"
id="CENTER_button_post_id_1"
>
<svg
fill="currentColor"
height="16"
version="1.1"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"
/>
</svg>
</button>
</div>,
"debug": [Function],
"findAllByAltText": [Function],
"findAllByDisplayValue": [Function],
"findAllByLabelText": [Function],
"findAllByPlaceholderText": [Function],
"findAllByRole": [Function],
"findAllByTestId": [Function],
"findAllByText": [Function],
"findAllByTitle": [Function],
"findByAltText": [Function],
"findByDisplayValue": [Function],
"findByLabelText": [Function],
"findByPlaceholderText": [Function],
"findByRole": [Function],
"findByTestId": [Function],
"findByText": [Function],
"findByTitle": [Function],
"getAllByAltText": [Function],
"getAllByDisplayValue": [Function],
"getAllByLabelText": [Function],
"getAllByPlaceholderText": [Function],
"getAllByRole": [Function],
"getAllByTestId": [Function],
"getAllByText": [Function],
"getAllByTitle": [Function],
"getByAltText": [Function],
"getByDisplayValue": [Function],
"getByLabelText": [Function],
"getByPlaceholderText": [Function],
"getByRole": [Function],
"getByTestId": [Function],
"getByText": [Function],
"getByTitle": [Function],
"queryAllByAltText": [Function],
"queryAllByDisplayValue": [Function],
"queryAllByLabelText": [Function],
"queryAllByPlaceholderText": [Function],
"queryAllByRole": [Function],
"queryAllByTestId": [Function],
"queryAllByText": [Function],
"queryAllByTitle": [Function],
"queryByAltText": [Function],
"queryByDisplayValue": [Function],
"queryByLabelText": [Function],
"queryByPlaceholderText": [Function],
"queryByRole": [Function],
"queryByTestId": [Function],
"queryByText": [Function],
"queryByTitle": [Function],
"replaceStoreState": [Function],
"rerender": [Function],
"unmount": [Function],
"updateStoreState": [Function],
}
`;
exports[`components/dot_menu/DotMenu should match snapshot, on Center 1`] = `
<Menu
menu={
@@ -301,6 +519,26 @@ exports[`components/dot_menu/DotMenu should match snapshot, on Center 1`] = `
/>
}
/>
<MenuItem
id="move_thread_post_id_1"
labels={
<Memo(MemoizedFormattedMessage)
defaultMessage="Move Thread"
id="post_info.move_thread"
/>
}
leadingElement={
<MessageArrowRightOutlineIcon
size={18}
/>
}
onClick={[Function]}
trailingElements={
<ShortcutKey
shortcutKey="W"
/>
}
/>
<MenuItemSeparator />
<MenuItem
data-testid="permalink_post_id_1"

Просмотреть файл

@@ -146,6 +146,7 @@ describe('components/dot_menu/DotMenu', () => {
threadReplyCount: 0,
userId: 'user_id_1',
isMilitaryTime: false,
canMove: true,
};
test('should match snapshot, on Center', () => {
@@ -180,6 +181,32 @@ describe('components/dot_menu/DotMenu', () => {
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, can move', () => {
const props = {
...baseProps,
canMove: true,
};
const wrapper = renderWithContext(
<DotMenu {...props}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot, cannot move', () => {
const props = {
...baseProps,
canMove: false,
};
const wrapper = renderWithContext(
<DotMenu {...props}/>,
initialState,
);
expect(wrapper).toMatchSnapshot();
});
test('should show mark as unread when channel is not archived', () => {
const props = {
...baseProps,

Просмотреть файл

@@ -15,6 +15,7 @@ import {
EmoticonPlusOutlineIcon,
LinkVariantIcon,
MarkAsUnreadIcon,
MessageArrowRightOutlineIcon,
MessageCheckOutlineIcon,
MessageMinusOutlineIcon,
PencilOutlineIcon,
@@ -31,6 +32,7 @@ import Permissions from 'mattermost-redux/constants/permissions';
import DeletePostModal from 'components/delete_post_modal';
import ForwardPostModal from 'components/forward_post_modal';
import * as Menu from 'components/menu';
import MoveThreadModal from 'components/move_thread_modal';
import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import {Locations, ModalIdentifiers, Constants, TELEMETRY_LABELS} from 'utils/constants';
@@ -76,6 +78,7 @@ type Props = {
isMobileView: boolean;
timezone?: string;
isMilitaryTime: boolean;
canMove: boolean;
actions: {
@@ -118,6 +121,7 @@ type Props = {
* Function to set the thread as followed/unfollowed
*/
setThreadFollow: (userId: string, teamId: string, threadId: string, newState: boolean) => void;
}; // TechDebt: Made non-mandatory while converting to typescript
canEdit: boolean;
@@ -251,6 +255,24 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
trackDotMenuEvent(e, TELEMETRY_LABELS.DELETE);
};
handleMoveThreadMenuItemActivated = (e: ChangeEvent): void => {
e.preventDefault();
if (!this.props.canMove) {
return;
}
trackDotMenuEvent(e, TELEMETRY_LABELS.MOVE_THREAD);
const moveThreadModalData = {
modalId: ModalIdentifiers.MOVE_THREAD_MODAL,
dialogType: MoveThreadModal,
dialogProps: {
post: this.props.post,
},
};
this.props.actions.openModal(moveThreadModalData);
};
handleForwardMenuItemActivated = (e: ChangeEvent): void => {
if (!this.canPostBeForwarded) {
// adding this early return since only hiding the Item from the menu is not enough,
@@ -361,6 +383,12 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
this.handleDeleteMenuItemActivated(event);
break;
// move thread
case Keyboard.isKeyPressed(event, Constants.KeyCodes.W):
this.handleMoveThreadMenuItemActivated(event);
this.props.handleDropdownOpened(false);
break;
// pin / unpin
case Keyboard.isKeyPressed(event, Constants.KeyCodes.P):
forceCloseMenu();
@@ -595,6 +623,19 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
onClick={this.handlePinMenuItemActivated}
/>
}
{Boolean(!isSystemMessage && this.props.canMove) &&
<Menu.Item
id={`move_thread_${this.props.post.id}`}
labels={
<FormattedMessage
id={'post_info.move_thread'}
defaultMessage={'Move Thread'}
/>}
leadingElement={<MessageArrowRightOutlineIcon size={18}/>}
trailingElements={<ShortcutKey shortcutKey='W'/>}
onClick={this.handleMoveThreadMenuItemActivated}
/>
}
{!isSystemMessage && (this.state.canEdit || this.state.canDelete) && <Menu.Separator/>}
{!isSystemMessage &&
<Menu.Item

Просмотреть файл

@@ -60,6 +60,7 @@ describe('components/dot_menu/DotMenu returning empty ("")', () => {
threadId: 'post_id_1',
userId: 'user_id_1',
isMilitaryTime: false,
canMove: true,
};
const wrapper = shallow(

Просмотреть файл

@@ -60,6 +60,7 @@ describe('components/dot_menu/DotMenu on mobile view', () => {
threadId: 'post_id_1',
userId: 'user_id_1',
isMilitaryTime: false,
canMove: true,
};
const wrapper = shallow(

Просмотреть файл

@@ -29,6 +29,7 @@ import {
markPostAsUnread,
} from 'actions/post_actions';
import {openModal} from 'actions/views/modals';
import {makeCanWrangler} from 'selectors/posts';
import {getIsMobileView} from 'selectors/views/browser';
import {isArchivedChannel} from 'utils/channel_utils';
@@ -58,6 +59,7 @@ type Props = {
function makeMapStateToProps() {
const getThreadOrSynthetic = makeGetThreadOrSynthetic();
const canWrangler = makeCanWrangler();
return function mapStateToProps(state: GlobalState, ownProps: Props) {
const {post} = ownProps;
@@ -123,6 +125,7 @@ function makeMapStateToProps() {
isMobileView: getIsMobileView(state),
timezone: getCurrentTimezone(state),
isMilitaryTime,
canMove: canWrangler(state, channel.type, threadReplyCount),
};
};
}

Просмотреть файл

@@ -222,15 +222,14 @@ const DropdownIndicator = (props: IndicatorProps<ChannelOption>) => {
);
};
const validChannelTypes = ['O', 'P', 'D', 'G'];
type Props<O extends OptionTypeBase> = {
onSelect: (channel: ValueType<O>) => void;
currentBodyHeight: number;
value?: O;
validChannelTypes?: string[];
}
function ForwardPostChannelSelect({onSelect, value, currentBodyHeight}: Props<ChannelOption>) {
function ForwardPostChannelSelect({onSelect, value, currentBodyHeight, validChannelTypes = ['O', 'P', 'D', 'G']}: Props<ChannelOption>) {
const {formatMessage} = useIntl();
const {current: provider} = useRef<SwitchChannelProvider>(new SwitchChannelProvider());

Просмотреть файл

@@ -141,5 +141,14 @@
opacity: 0.64 !important;
}
}
.post-error {
position: relative;
top: 3px;
margin-bottom: 0;
font-size: 0.85em;
font-weight: normal;
opacity: 0.55;
}
}
}

Просмотреть файл

@@ -223,7 +223,7 @@ const ForwardPostModal = ({onExited, post, actions}: Props) => {
const postPreviewFooterMessage = formatMessage({
id: 'forward_post_modal.preview.footer_message',
defaultMessage: 'Originally posted in ~{channelName}',
defaultMessage: 'Originally posted in ~{channel}',
},
{
channel: channel.display_name,

Просмотреть файл

@@ -0,0 +1,29 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ConnectedProps} from 'react-redux';
import {connect} from 'react-redux';
import type {ActionCreatorsMapObject, Dispatch} from 'redux';
import {bindActionCreators} from 'redux';
import {moveThread} from 'mattermost-redux/actions/posts';
import {joinChannelById, switchToChannel} from 'actions/views/channel';
import type {ActionProps} from './move_thread_modal';
import MoveThreadModal from './move_thread_modal';
export type PropsFromRedux = ConnectedProps<typeof connector>;
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<any>, ActionProps>({
joinChannelById,
switchToChannel,
moveThread,
}, dispatch),
};
}
const connector = connect(null, mapDispatchToProps);
export default connector(MoveThreadModal);

Просмотреть файл

@@ -0,0 +1,158 @@
.move-thread {
.modal-body {
overflow: visible;
}
&.modal-dialog {
margin-top: 64px;
}
&__body {
display: flex;
overflow: visible;
flex-direction: column;
color: var(--center-channel-color);
gap: 24px;
.custom-textarea,
#move_thread_textbox {
min-height: 40px;
padding: 10px 16px;
}
#move_thread_textbox {
overflow: auto;
border: none;
box-shadow: inset 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.16);
&:focus {
box-shadow: inset 0 0 0 2px var(--button-bg);
}
&:focus:not(:focus-visible) {
box-shadow: inset 0 0 0 1px rgba(var(--center-channel-color-rgb), 0.16);
}
&:focus:focus-visible {
box-shadow: inset 0 0 0 2px var(--button-bg);
}
}
}
&__select {
position: relative;
.option,
.singleValue {
top: 100%;
display: grid;
align-content: center;
align-items: center;
color: var(--center-channel-color);
gap: 12px;
grid-auto-columns: minmax(20%, max-content);
grid-template-columns: 24px 1fr;
grid-template-rows: 24px;
svg,
.status-wrapper,
.status--group {
place-self: center;
}
.status--group {
display: flex;
width: 1.8rem;
height: 1.8rem;
align-items: center;
justify-content: center;
margin: 0;
background-color: rgba(var(--center-channel-color-rgb), 0.12);
}
.status-wrapper {
height: 2.4rem;
}
&__content {
display: flex;
overflow: hidden;
max-width: 100%;
flex-direction: row;
gap: 8px;
&--text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--description {
color: rgba(var(--center-channel-color-rgb), 0.56);
}
.emoticon {
margin-left: 0;
}
}
&__team-name {
overflow: hidden;
width: 100%;
color: rgba(var(--center-channel-color-rgb), 0.56);
grid-column: 3 / 4;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
&__post-preview {
&--title {
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
&--override {
padding: 2px 0;
pointer-events: none;
&:not(.post--editing):not(.post--editing):hover {
background-color: transparent;
.attachment__content.attachment__content--permalink {
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
}
}
}
}
}
.console__body .modal .GenericModal.move-thread,
.app__body .modal .GenericModal.move-thread {
margin-top: 64px;
.modal-body {
max-height: calc(100vh - 128px - 61px - 88px);
.form-control {
border: none;
box-shadow: none;
&#move_thread_textbox_placeholder {
opacity: 0.64 !important;
}
}
.post-error {
position: relative;
top: 3px;
margin-bottom: 0;
font-size: 0.85em;
font-weight: normal;
opacity: 0.55;
}
}
}

Просмотреть файл

@@ -0,0 +1,253 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import type {ValueType} from 'react-select';
import type {ClientError} from '@mattermost/client';
import {GenericModal} from '@mattermost/components';
import type {Channel} from '@mattermost/types/channels';
import type {Post, PostPreviewMetadata} from '@mattermost/types/posts';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import type {ActionResult} from 'mattermost-redux/types/actions';
import type {ChannelOption} from 'components/forward_post_modal/forward_post_channel_select';
import ChannelSelector from 'components/forward_post_modal/forward_post_channel_select';
import NotificationBox from 'components/notification_box';
import PostMessagePreview from 'components/post_view/post_message_preview';
import Constants from 'utils/constants';
import type {GlobalState} from 'types/store';
import './move_thread_modal.scss';
export type ActionProps = {
// join the selected channel when necessary
joinChannelById: (channelId: string) => Promise<ActionResult>;
// switch to the selected channel
switchToChannel: (channel: Channel) => Promise<ActionResult>;
// action called to move the post from the original channel to a new channel
moveThread: (postId: string, channelId: string) => Promise<ActionResult>;
}
export type OwnProps = {
// The function called immediately after the modal is hidden
onExited?: () => void;
// the post that is going to be moved
post: Post;
};
export type Props = OwnProps & {actions: ActionProps};
const noop = () => {};
const getChannel = makeGetChannel();
// since the original post has a click handler specified we should prevent any action here
const preventActionOnPreview = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
};
const MoveThreadModal = ({onExited, post, actions}: Props) => {
const {formatMessage} = useIntl();
const originalChannel = useSelector((state: GlobalState) => getChannel(state, {id: post.channel_id}));
const currentTeam = useSelector(getCurrentTeam);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const [bodyHeight, setBodyHeight] = useState<number>(0);
const [hasError, setHasError] = useState<boolean>(false);
const [postError, setPostError] = useState<React.ReactNode>(null);
const [selectedChannel, setSelectedChannel] = useState<ChannelOption>();
const [isButtonClicked, setIsButtonClicked] = useState<boolean>(false);
const bodyRef = useRef<HTMLDivElement>();
const measuredRef = useCallback((node) => {
if (node !== null) {
bodyRef.current = node;
setBodyHeight(node.getBoundingClientRect().height);
}
}, []);
const onHide = useCallback(() => {
onExited?.();
}, [onExited]);
const handleChannelSelect = useCallback((channel: ValueType<ChannelOption>) => {
if (Array.isArray(channel)) {
setSelectedChannel(channel[0]);
return;
}
setSelectedChannel(channel as ChannelOption);
}, []);
const messagePreviewTitle = formatMessage({
id: 'move_thread_modal.preview.title',
defaultMessage: 'Message preview',
});
const previewMetaData: PostPreviewMetadata = useMemo(() => ({
post,
post_id: post.id,
team_name: currentTeam.name,
channel_display_name: originalChannel.display_name,
channel_type: originalChannel.type,
channel_id: originalChannel.id,
}), [post, currentTeam.name, originalChannel.display_name, originalChannel.type, originalChannel.id]);
const notificationText = formatMessage({
id: 'move_thread_modal.notification.dm_or_gm',
defaultMessage: 'Moving this thread changes who has access',
});
const notification = (
<NotificationBox
variant={'info'}
text={notificationText}
id={'move_thread'}
/>
);
const handlePostError = useCallback((error: ClientError) => {
setIsButtonClicked(false);
setPostError(error.message);
setHasError(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setHasError(false);
timeoutRef.current = null;
}, Constants.ANIMATION_TIMEOUT);
}, []);
const handleSubmit = useCallback(async () => {
setIsButtonClicked(true);
if (!selectedChannel) {
setIsButtonClicked(false);
return;
}
const channel = selectedChannel.details;
let result = await actions.moveThread(post.root_id || post.id, channel.id);
if (result.error) {
handlePostError(result.error);
return;
}
if (
channel.type === Constants.MENTION_MORE_CHANNELS &&
channel.type === Constants.OPEN_CHANNEL
) {
result = await actions.joinChannelById(channel.id);
if (result.error) {
handlePostError(result.error);
return;
}
}
result = await actions.switchToChannel(channel);
if (result.error) {
handlePostError(result.error);
return;
}
onHide();
}, [selectedChannel, post, actions, handlePostError, onHide]);
const postPreviewFooterMessage = formatMessage({
id: 'move_thread_modal.preview.footer_message',
defaultMessage: 'Originally posted in ~{channelName}',
},
{
channelName: originalChannel.display_name,
});
return (
<GenericModal
className='a11y__modal forward-post move-thread'
id='move-thread-modal'
show={true}
autoCloseOnConfirmButton={false}
compassDesign={true}
modalHeaderText={formatMessage({
id: 'move_thread_modal.title',
defaultMessage: 'Move thread',
})}
confirmButtonText={formatMessage({
id: 'move_thread_modal.button.forward',
defaultMessage: 'Move',
})}
cancelButtonText={formatMessage({
id: 'move_thread_modal.button.cancel',
defaultMessage: 'Cancel',
})}
cancelButtonClassName={'MoveThreadModal__cancel-button'}
isConfirmDisabled={isButtonClicked}
handleConfirm={handleSubmit}
handleEnterKeyPress={handleSubmit}
handleCancel={onHide}
onExited={onHide}
>
<div
className={'move-thread__body'}
ref={measuredRef}
>
{notification}
<ChannelSelector
onSelect={handleChannelSelect}
value={selectedChannel}
currentBodyHeight={bodyHeight}
validChannelTypes={['O', 'P']}
/>
<div className={'move-thread__post-preview'}>
<span className={'move-thread__post-preview--title'}>
{messagePreviewTitle}
</span>
<div
className='post move-thread__post-preview--override'
onClick={preventActionOnPreview}
>
<PostMessagePreview
metadata={previewMetaData}
handleFileDropdownOpened={noop}
preventClickAction={true}
previewFooterMessage={postPreviewFooterMessage}
/>
</div>
{postError && (
<label
className={classNames('post-error', {
'animation--highlight': hasError,
})}
>
{postError}
</label>
)}
</div>
</div>
</GenericModal>
);
};
export default MoveThreadModal;

Просмотреть файл

@@ -12,7 +12,7 @@ import Markdown from 'components/markdown';
import type {TextFormattingOptions} from 'utils/text_formatting';
import {renderReminderSystemBotMessage, renderSystemMessage} from './system_message_helpers';
import {renderReminderSystemBotMessage, renderSystemMessage, renderWranglerSystemMessage} from './system_message_helpers';
import {type PropsFromRedux} from './index';
@@ -82,6 +82,11 @@ export default class PostMarkdown extends React.PureComponent<Props> {
return <div>{renderedSystemBotMessage}</div>;
}
if (this.props.post && this.props.post.type === Posts.POST_TYPES.WRANGLER) {
const renderedWranglerMessage = renderWranglerSystemMessage(this.props.post);
return <div>{renderedWranglerMessage}</div>;
}
// Proxy images if we have an image proxy and the server hasn't already rewritten the this.props.post's image URLs.
const proxyImages = !this.props.post || !this.props.post.message_source || this.props.post.message === this.props.post.message_source;
const channelNamesMap = this.props.post && this.props.post.props && this.props.post.props.channel_mentions;

Просмотреть файл

@@ -17,6 +17,7 @@ import CombinedSystemMessage from 'components/post_view/combined_system_message'
import GMConversionMessage from 'components/post_view/gm_conversion_message/gm_conversion_message';
import PostAddChannelMember from 'components/post_view/post_add_channel_member';
import {t} from 'utils/i18n';
import type {TextFormattingOptions} from 'utils/text_formatting';
import {getSiteURL} from 'utils/url';
@@ -489,3 +490,29 @@ export function renderReminderSystemBotMessage(post: Post, currentTeam: Team): R
/>
);
}
t('app.post.move_thread_command.direct_or_group.multiple_messages');
t('app.post.move_thread_command.direct_or_group.one_message');
t('app.post.move_thread_command.channel.multiple_messages');
t('app.post.move_thread_command.channel.one_message');
t('app.post.move_thread.from_another_channel');
export function renderWranglerSystemMessage(post: Post): ReactNode {
let values = {} as any;
const id = post.props.TranslationID;
if (post.props && post.props.MovedThreadPermalink) {
values = {
Link: post.props.MovedThreadPermalink,
};
if (post.props.NumMessages > 1) {
values.NumMessages = post.props.NumMessages;
}
}
return (
<FormattedMessage
id={id}
defaultMessage={post.message}
values={values}
/>
);
}

Просмотреть файл

@@ -14,9 +14,9 @@ export type Props = {
label: ReactNode;
labelClassName?: string;
placeholder?: string;
value: string | number | string[];
helpText?: ReactNode;
footer?: ReactNode;
value: string | number;
inputClassName?: string;
maxLength?: number;
resizable?: boolean;
@@ -26,6 +26,7 @@ export type Props = {
// This is a custom prop that is not part of the HTML input element type
type?: InputTypes;
autoFocus?: boolean;
multiple?: boolean;
}
function TextSetting(props: Props) {
@@ -34,6 +35,12 @@ function TextSetting(props: Props) {
function handleChange(event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) {
if (props.type === 'number') {
props.onChange(props.id, parseInt(event.target.value, 10));
} else if (props.type === 'text' && props.multiple) {
if (event.target.value === '') {
props.onChange(props.id, []);
} else {
props.onChange(props.id, event.target.value.split(','));
}
} else {
props.onChange(props.id, event.target.value);
}

Просмотреть файл

@@ -887,6 +887,8 @@
"admin.environment.webServer": "Web Server",
"admin.experimental.allowCustomThemes.desc": "Enables the **Display > Theme > Custom Theme** section in Settings.",
"admin.experimental.allowCustomThemes.title": "Allow Custom Themes:",
"admin.experimental.allowedEmailDomain.desc": "(Optional) When set, users must have an email ending in this domain to move threads. Multiple domains can be specified by separating them with commas.",
"admin.experimental.allowedEmailDomain.title": "Allowed Email Domain",
"admin.experimental.clientSideCertCheck.desc": "When **primary**, after the client side certificate is verified, users email is retrieved from the certificate and is used to log in without a password. When **secondary**, after the client side certificate is verified, users email is retrieved from the certificate and matched against the one supplied by the user. If they match, the user logs in with regular email/password credentials.",
"admin.experimental.clientSideCertCheck.title": "Client-Side Certification Login Method:",
"admin.experimental.clientSideCertEnable.desc": "Enables client-side certification for your Mattermost server. See <link>documentation</link> to learn more.",
@@ -954,6 +956,18 @@
"admin.experimental.linkMetadataTimeoutMilliseconds.desc": "The number of milliseconds to wait for metadata from a third-party link. Used with Post Metadata.",
"admin.experimental.linkMetadataTimeoutMilliseconds.example": "E.g.: \"5000\"",
"admin.experimental.linkMetadataTimeoutMilliseconds.title": "Link Metadata Timeout:",
"admin.experimental.moveThreadFromDirectMessageChannelEnable.desc": "Control whether move thread is permitted to move message threads from direct message channels or not.",
"admin.experimental.moveThreadFromDirectMessageChannelEnable.title": "Enable Moving Threads From Direct Message Channels",
"admin.experimental.moveThreadFromGroupMessageChannelEnable.desc": "Control whether move thread is permitted to move message threads from group message channels or not.",
"admin.experimental.moveThreadFromGroupMessageChannelEnable.title": "Enable Moving Threads From Group Message Channels",
"admin.experimental.moveThreadFromPrivateChannelEnable.desc": "Control whether move thread is permitted to move message threads from private channels or not.",
"admin.experimental.moveThreadFromPrivateChannelEnable.title": "Enable Moving Threads From Private Channels",
"admin.experimental.moveThreadMaxCount.desc": "The maximum number of messages in a thread that the plugin is allowed to move. Leave empty for unlimited messages.",
"admin.experimental.moveThreadMaxCount.title": "Max Thread Count Move Size",
"admin.experimental.moveThreadToAnotherTeamEnable.desc": "Control whether move thread is permitted to move message threads from one team to another or not.",
"admin.experimental.moveThreadToAnotherTeamEnable.title": "Enable Moving Threads To Different Teams",
"admin.experimental.PermittedMoveThreadRoles.desc": "Choose who is allowed to move threads to other channels based on roles. (Other permissions below still apply).",
"admin.experimental.PermittedMoveThreadRoles.title": "Permitted Roles",
"admin.experimental.samlSettingsLoginButtonBorderColor.desc": "Specify the color of the SAML login button border for white labeling purposes. Use a hex code with a #-sign before the code. This setting only applies to the mobile apps.",
"admin.experimental.samlSettingsLoginButtonBorderColor.title": "SAML login Button Border Color:",
"admin.experimental.samlSettingsLoginButtonColor.desc": "Specify the color of the SAML login button for white labeling purposes. Use a hex code with a #-sign before the code. This setting only applies to the mobile apps.",
@@ -2335,6 +2349,7 @@
"admin.sidebar.logs": "Server Logs",
"admin.sidebar.metrics": "Performance Monitoring",
"admin.sidebar.mfa": "MFA",
"admin.sidebar.move_thread": "Move thread (Beta)",
"admin.sidebar.notices": "Notices",
"admin.sidebar.notifications": "Notifications",
"admin.sidebar.oauth": "OAuth 2.0",
@@ -2370,6 +2385,7 @@
"admin.site.emoji": "Emoji",
"admin.site.fileSharingDownloads": "File Sharing and Downloads",
"admin.site.localization": "Localization",
"admin.site.move_thread": "Move thread",
"admin.site.notices": "Notices",
"admin.site.posts": "Posts",
"admin.site.public_links": "Public Links",
@@ -2742,6 +2758,11 @@
"app.channel.post_update_channel_purpose_message.removed": "{username} removed the channel purpose (was: {old})",
"app.channel.post_update_channel_purpose_message.updated_from": "{username} updated the channel purpose from: {old} to: {new}",
"app.channel.post_update_channel_purpose_message.updated_to": "{username} updated the channel purpose to: {new}",
"app.post.move_thread_command.channel.multiple_messages": "A thread with {{.NumMessages}} messages has been moved: {{.Link}}\n",
"app.post.move_thread_command.channel.one_message": "A message has been moved: {{.Link}}\n",
"app.post.move_thread_command.direct_or_group.multiple_messages": "A thread with {{.NumMessages}} messages has been moved to a Direct/Group Message\n",
"app.post.move_thread_command.direct_or_group.one_message": "A message has been moved to a Direct/Group Message\n",
"app.post.move_thread.from_another_channel": "This thread was moved from another channel",
"apps.error": "Error: {error}",
"apps.error.command.field_missing": "Required fields missing: `{fieldName}`.",
"apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.",
@@ -4131,6 +4152,12 @@
"more_direct_channels.new_convo_note.full": "You've reached the maximum number of people for this conversation. Consider creating a private channel instead.",
"more_direct_channels.title": "Direct Messages",
"more.details": "More details",
"move_thread_modal.button.cancel": "Cancel",
"move_thread_modal.button.forward": "Move",
"move_thread_modal.notification.dm_or_gm": "Moving this thread changes who has access",
"move_thread_modal.preview.footer_message": "Originally posted in ~{channelName}",
"move_thread_modal.preview.title": "Message preview",
"move_thread_modal.title": "Move thread",
"msg_typing.areTyping": "{users} and {last} are typing...",
"msg_typing.isTyping": "{user} is typing...",
"multiselect.add": "Add",
@@ -4428,6 +4455,7 @@
"post_info.message.show_more": "Show more",
"post_info.message.visible": "(Only visible to you)",
"post_info.message.visible.compact": " (Only visible to you)",
"post_info.move_thread": "Move Thread",
"post_info.permalink": "Copy Link",
"post_info.pin": "Pin to Channel",
"post_info.post_reminder.menu": "Remind",

Просмотреть файл

@@ -58,4 +58,7 @@ export default keyMirror({
CREATE_ACK_POST_SUCCESS: null,
DELETE_ACK_POST_SUCCESS: null,
MOVE_POST_SUCCESS: null,
MOVE_POST_FAILURE: null,
});

Просмотреть файл

@@ -1260,6 +1260,23 @@ export function selectPost(postId: string) {
};
}
export function moveThread(postId: string, channelId: string) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
try {
await Client4.moveThread(postId, channelId);
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch({type: PostTypes.MOVE_POST_FAILURE, error});
dispatch(logError(error));
return {error};
}
dispatch({type: PostTypes.MOVE_POST_SUCCESS});
return {data: true};
};
}
export function selectFocusedPostId(postId: string) {
return {
type: PostTypes.RECEIVED_FOCUSED_POST,

Просмотреть файл

@@ -33,6 +33,7 @@ export const PostTypes = {
ADD_BOT_TEAMS_CHANNELS: 'add_bot_teams_channels' as PostType,
SYSTEM_WARN_METRIC_STATUS: 'warn_metric_status' as PostType,
REMINDER: 'reminder' as PostType,
WRANGLER: 'system_wrangler' as PostType,
GM_CONVERTED_TO_CHANNEL: 'system_gm_to_channel' as PostType,
};

Просмотреть файл

@@ -276,6 +276,10 @@ export function cloudReverseTrial(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'CloudReverseTrial') === 'true';
}
export function moveThreadsEnabled(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'MoveThreadsEnabled') === 'true';
}
export function streamlinedMarketplaceEnabled(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'StreamlinedMarketplace') === 'true';
}

Просмотреть файл

@@ -1,10 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Channel} from '@mattermost/types/channels';
import type {ClientConfig} from '@mattermost/types/config';
import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {moveThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {arePreviewsCollapsed} from 'selectors/preferences';
@@ -52,3 +58,69 @@ export function isInlineImageVisible(state: GlobalState, postId: string, imageKe
return getGlobalItem(state, StoragePrefixes.INLINE_IMAGE_VISIBLE + currentUserId + '_' + postId + '_' + imageKey, !imageCollapsed);
}
export function makeCanWrangler() {
return createSelector(
'makeCanWrangler',
getConfig,
getCurrentUser,
moveThreadsEnabled,
(_state: GlobalState, channelType: Channel['type']) => channelType,
(_state: GlobalState, _channelType: Channel['type'], replyCount: number) => replyCount,
(config: Partial<ClientConfig>, user: UserProfile, enabled: boolean, channelType: Channel['type'], replyCount: number) => {
if (!enabled) {
return false;
}
const {
WranglerPermittedWranglerRoles,
WranglerAllowedEmailDomain,
WranglerMoveThreadMaxCount,
WranglerMoveThreadFromPrivateChannelEnable,
WranglerMoveThreadFromDirectMessageChannelEnable,
WranglerMoveThreadFromGroupMessageChannelEnable,
} = config;
let permittedUsers: string[] = [];
if (WranglerPermittedWranglerRoles && WranglerPermittedWranglerRoles !== '') {
permittedUsers = WranglerPermittedWranglerRoles?.split(',');
}
let allowedEmailDomains: string[] = [];
if (WranglerAllowedEmailDomain && WranglerAllowedEmailDomain !== '') {
allowedEmailDomains = WranglerAllowedEmailDomain?.split(',') || [];
}
if (permittedUsers.length > 0 && !user.roles.includes('system_admin')) {
const roles = user.roles.split(' ');
const hasRole = roles.some((role) => permittedUsers.includes(role));
if (!hasRole) {
return false;
}
}
if (allowedEmailDomains?.length > 0) {
if (!user.email || !allowedEmailDomains.includes(user.email.split('@')[1])) {
return false;
}
}
if (Number(WranglerMoveThreadMaxCount) < replyCount) {
return false;
}
if (channelType === 'P' && WranglerMoveThreadFromPrivateChannelEnable === 'false') {
return false;
}
if (channelType === 'D' && WranglerMoveThreadFromDirectMessageChannelEnable === 'false') {
return false;
}
if (channelType === 'G' && WranglerMoveThreadFromGroupMessageChannelEnable === 'false') {
return false;
}
return true;
},
);
}

Просмотреть файл

@@ -47,6 +47,7 @@ export const SettingsTypes = {
TYPE_JOBSTABLE: 'jobstable',
TYPE_FILE_UPLOAD: 'fileupload',
TYPE_CUSTOM: 'custom',
TYPE_ROLES: 'roles',
};
export const InviteTypes = {
@@ -446,6 +447,7 @@ export const ModalIdentifiers = {
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
START_TRIAL_FORM_MODAL: 'start_trial_form_modal',
START_TRIAL_FORM_MODAL_RESULT: 'start_trial_form_modal_result',
MOVE_THREAD_MODAL: 'move_thread_modal',
CONVERT_GM_TO_CHANNEL: 'convert_gm_to_channel',
IP_FILTERING_ADD_EDIT_MODAL: 'ip_filtering_add_edit_modal',
IP_FILTERING_DELETE_CONFIRMATION_MODAL: 'ip_filtering_delete_confirmation_modal',
@@ -769,6 +771,7 @@ export const TELEMETRY_LABELS = {
REPLY: 'reply',
UNREAD: 'unread',
FORWARD: 'forward',
MOVE_THREAD: 'move_thread',
};
export const PostTypes = {
@@ -795,6 +798,7 @@ export const PostTypes = {
REMOVE_LINK_PREVIEW: 'remove_link_preview',
ME: 'me',
REMINDER: 'reminder',
WRANGLER: 'system_wrangler',
CUSTOM_CALLS: 'custom_calls',
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
};

Просмотреть файл

@@ -1059,6 +1059,14 @@ export default class Client4 {
);
}
moveThread = (postId: string, channelId: string) => {
const url = this.getPostRoute(postId) + '/move';
return this.doFetch<StatusOK>(
url,
{method: 'post', body: JSON.stringify({channel_id: channelId})},
);
}
switchEmailToOAuth = (service: string, email: string, password: string, mfaCode = '') => {
this.trackEvent('api', 'api_users_email_to_oauth');

Просмотреть файл

@@ -199,6 +199,12 @@ export type ClientConfig = {
PersistentNotificationIntervalMinutes: string;
AllowPersistentNotificationsForGuests: string;
DelayChannelAutocomplete: 'true' | 'false';
WranglerPermittedWranglerRoles: string;
WranglerAllowedEmailDomain: string;
WranglerMoveThreadMaxCount: string;
WranglerMoveThreadFromPrivateChannelEnable: string;
WranglerMoveThreadFromDirectMessageChannelEnable: string;
WranglerMoveThreadFromGroupMessageChannelEnable: string;
ServiceEnvironment: string;
UniqueEmojiReactionLimitPerPost: string;
};
@@ -485,6 +491,16 @@ export type PasswordSettings = {
EnableForgotLink: boolean;
};
export type WranglerSettings = {
PermittedWranglerRoles: string[];
AllowedEmailDomain: string[];
MoveThreadMaxCount: number;
MoveThreadToAnotherTeamEnable: boolean;
MoveThreadFromPrivateChannelEnable: boolean;
MoveThreadFromDirectMessageChannelEnable: boolean;
MoveThreadFromGroupMessageChannelEnable: boolean;
};
export type FileSettings = {
EnableFileAttachments: boolean;
EnableMobileUpload: boolean;
@@ -947,6 +963,7 @@ export type AdminConfig = {
FeatureFlags: FeatureFlags;
ImportSettings: ImportSettings;
ExportSettings: ExportSettings;
WranglerSettings: WranglerSettings;
};
export type ReplicaLagSetting = {

Просмотреть файл

@@ -31,6 +31,7 @@ export type PostType = 'system_add_remove' |
'system_fake_parent_deleted' |
'system_generic' |
'reminder' |
'system_wrangler' |
'';
export type PostEmbedType = 'image' | 'link' | 'message_attachment' | 'opengraph' | 'permalink';