* Create scheduled post api (#27920)

* Added migration files for Postgres

* Added migrations for MySQL

* Added store method

* Added API and store tests

* Renamed migration after syncing with master

* Added app layer tests

* API is ready

* API is ready

* API is ready

* Renamed migration after syncing with master

* Updated migration list

* Fixed retry layer tests

* Allowed posts with empty messages

* Review fixes

* Reverted an incorrect change

* Renamed migration and fixed ID assignment

* CI

* Send post button changes (#28019)

* added Split button

* WIP

* Added core menu options

* WIP

* WIP

* WIP

* Handled displaying error in creating scheduled post

* lint fixes

* webapp i18n fix

* Review fixes

* Fixed a webapp test

* A few more fixes

* Removed a duplicate comment

* Scheduled post job (#28088)

* Added the job function

* Added query for fetching scheduled posts for pricessing

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Reafactoring of scheduled post job

* Lint fixes

* Updated i18n files

* FInishing touches

* Added tests for GetScheduledPosts

* Added tests for PermanentlyDeleteScheduledPosts

* Updated all layer

* Some changes as discussed with team

* Added tests for UpdatedScheduledPost

* Code review refactoring

* Added job test

* MM-60120 - Custom time selection (#28120)

* Added a common date time picker modal and used it for post reminder

* Added a common date time picker modal and used it for post reminderggp

* Added modal for custom schedule time and fixed TZ issue

* WIP

* Removed event from useSubmit hook

* Removed event from useSubmit hook

* Added timezone handling

* fixed type error

* Updated i18n strings

* Minor cleanup

* updated snapshots

* review fixes

* Handled event

* Supported for having a DM thread open in RHS while in a regular channel

* Review fixes

* MM-60136 - Scheduled messages tab (#28133)

* WIP

* WIP

* Created Tabs and Tab wrapper with added styling

* Added API to get scheduled posts

* WIP

* Displated scheduled post count

* i18n fix

* Added tests

* Handled asetting active tab absed on URL:

* Reverted unintended change

* Added API to client ad OpenAPI specs

* Renamed file

* Adding fileinfo to schedule posts

* Partial review fixes

* Made get scheduled post API return posts by teamID

* review fixes

* Moved scheduled post redux code to MM-redux package

* Usedd selector factory

* WIP:

* WIP:

* Lint fix

* Fixed an incorrect openapi spec file

* Removed redundent permission check

* Clreaed scheduled post data on logout

* Removed unused i18n string:

* lint fix

* Render scheduled posts (#28208)

* WIP

* WIP

* Created Tabs and Tab wrapper with added styling

* Added API to get scheduled posts

* WIP

* Displated scheduled post count

* i18n fix

* Added tests

* Handled asetting active tab absed on URL:

* Reverted unintended change

* Added API to client ad OpenAPI specs

* Renamed file

* Created common component for draft list item

* WIP

* WIP

* Adding fileinfo to schedule posts

* Basic rendering

* Added count badge to tabs

* WIP

* Made the Drafts LHS iteam appear if no drafts exist but scheduled posts do

* Fixed icon size

* Partial review fixes

* Made get scheduled post API return posts by teamID

* Handled initial vs team switch load

* Displayed scheduled date in panel header

* Added error message and error indiocator

* WIP

* review fixes

* WIP Adding error reason tag

* Added error codes

* Moved scheduled post redux code to MM-redux package

* Usedd selector factory

* WIP:

* WIP:

* Lint fix

* Fixed an incorrect openapi spec file

* Removed redundent permission check

* Clreaed scheduled post data on logout

* Removed unused i18n string:

* lint fix

* Opened rescheduling modal

* Updated graphic for empty state of schduled post list

* Added delete scheduled post option and modal

* Badge and timezone fix

* WIP:

* Added send now confirmation modal

* lint

* Webapp i18n fix

* Fixed webapp test

* Fixed a bug where DM/GM scheduled posts weren't immideatly showing up in UI

* Minor fixes

* WIP

* Review fixes

* Review fixes

* Optimisations

* Fixed reducer name

* Moment optimizatin

* Updated route check

* MM-60144 - added API to update a scheduled post (#28248)

* WIP

* Added api and ap layer for update scheduled post ̛̦̄

* Added API to OpenAI specs, Go client and TS client

* removed permissio check

* Added tests

* Fixed tests

* Added PreUpdate method on scheduled post model

* MM-60131 - Reschedule post integration (#28281)

* Handled rescheduling post in webapp

* Added error handling

* MM-60146 - Delete scheduled post api (#28265)

* WIP

* Added api and ap layer for update scheduled post ̛̦̄

* Added API to OpenAI specs, Go client and TS client

* removed permissio check

* Added tests

* Fixed tests

* Added PreUpdate method on scheduled post model

* Added delete scheduled post API

* Added API to Go client and OpenAPI specs

* Added API to TS client

* Added tests

* CI

* Rmeoved two incorrect code comments

* MM-60653 - Integrated delete scheduled post API (#28296)

* Integrated delete scheduled apost API

* Lint fix

* Review fixes

* Excluded draft checks from scheduled posts (#28370)

* Excluded draft checks from scheduled posts

* Added a removed todo

* MM-60125 - Scheduled post channel indicator (#28320)

* Integrated delete scheduled apost API

* Lint fix

* Added state for storing scheduled posts by channel ID

* Refactored redux store to store scheudled posts by ID, thens tore IDs everywhere

* Refactored redux store to store scheudled posts by ID, thens tore IDs everywhere

* WIP

* Added scheduled post indiocator

* Handled single and multiple scheudled posts

* Review fixes

* Fixed styling and handled center channel, RHS and threads view

* Lint fix

* i18n fix

* Fixed a cycling dependency

* Lint fix

* Added some more comments

* Updated styling

* Review fixes

* Added common component for remote user time and scheduled post indicator

* Updated scheduled post count

* Minor change

* Moved CSS code around

* Fixed a bug where files in scheduled post didn't show up until refresh (#28359)

---------

Co-authored-by: Daniel Espino García <larkox@gmail.com>

* Scheduled post config (#28485)

* Added config

* Added config on server and webapp side

* Added config check in server and webapp

* Added license check

* Added license check

* Added placeholder help text

* Added license check to job

* Fixed job test

* Review fixes

* Updated English text

* Review fixes

* MM-60118 - Added index on ScheduledPosts table (#28579)

* Added index

* Updated indexes

* Scheduled posts misc fixes (#28625)

* Added detailed logging for scheduled post job

* Limited scheduled posts processing to 24 hours

* Marked old scheduled posts as unable to send

* Added t5ests

* converted some logs to trace level

* Fixed a bug causing error message to show up on deleting a scheduled post in a deleted thread (#28630)

* Fixed scheduled posts link in RHS (#28659)

* Fixed scheduled posts link in RHS

* Review fixes

* Fix permission name in scheduled posts by team (#28580)

* Fix permission name

* fix wording

---------

Co-authored-by: Mattermost Build <build@mattermost.com>

* FIxed width of generic modal header to fix browser channel modal (#28639)

* Only consider error-free scheduled posts for indicator in channel and RHS (#28683)

* Show only errro free scheudled posts in post box indicator

* Fixed a bug to handle no scheduled posts

* Fixed draft and scheudled post UI in mobile view (#28680)

* MM-60873 and MM-60872 - Fixed a bug with updating scheduled posts (#28656)

* Fixed a bug with updating scheduled posts

* Better selectors

* MOved shceuled post message length validation to app layer

* MM-60732 - Scheduled posts channel link now takes you to the first scheduled post in channel/thread in list (#28768)

* Ordered scheudle dposts by schgeudled at nad create at

* Ordered in client

* Added scroll to target

* Removed classname prop

* Fixed tests

* Added doc

* Import fix

* MM-60961 - Fixed a bug where API used incoming create at date for scheduled post (#28703)

* Fixed a bug where API used incoming create at date for scheduled post

* Stopped sending created at value for scheduled post

* MM-60785 - Fixed a bug where scheduled posts of channel we are no longer member of didn't show up (#28637)

* Fixed a bug where scheduled posts of channel we are no longer member of didn't show up

* Added a comment

* CI

* Used data loader to optimise laoding missing channels

* Minor refactoring

* MM-60963 - Added common checks for post and scheduled posts (#28713)

* Added commen checks for post and scheuled posts

* Sanitised scheduled posts

* Fixed tests

* Splitted post checks into app and context functions

* Added checks on scheduiled posts job as well:

* i18n fix

* Fixed a test

* Renamed a func

* removed duplicate check

* Scheduled posts UI fixes (#28828)

* Fixed send button and time picker borders

* Fixed center alignment of time picker

* Removed on for today and tomorrow

* Lint fix

* Date time modal hover state fix

* Badge fix

* Fixed a mnerge issue

* Scheduled Post send now and add schedule on draft (#28851)

* Added send now option on scheduled posts

* Minor refactoring

* WIP

* WIP

* WIP

* Lint fix

* i18n fix

* Snapshot update

* Review fixes

* Scheduled post inline editing (#28893)

* Added send now option on scheduled posts

* Minor refactoring

* WIP

* WIP

* WIP

* Lint fix

* i18n fix

* Snapshot update

* Displayed editing component in scheduled post

* Added handling for updating scheduled post

* Handle events

* Fixed escape key issue in scheudled post editing

* Fixes

* Displayed error message for editing error

* Don't show mention warning

* Handled dev mode (#28918)

* MInor fixes

* client fix

* Fixes

* CI

* Removed dev mode behaviour temperorily (#29008)

---------

Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Eva Sarafianou <eva.sarafianou@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Harshil Sharma
2024-11-04 11:39:35 +05:30
коммит произвёл GitHub
родитель aaf9234c8e
Коммит e281b3f37e
135 изменённых файлов: 7792 добавлений и 684 удалений

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

@@ -16,6 +16,7 @@ import {
getChannelStats,
selectChannel,
} from 'mattermost-redux/actions/channels';
import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts';
import {logout, loadMe} from 'mattermost-redux/actions/users';
import {Preferences} from 'mattermost-redux/constants';
import {appsEnabled} from 'mattermost-redux/selectors/entities/apps';
@@ -383,6 +384,7 @@ export async function redirectUserToDefaultTeam(searchParams?: URLSearchParams)
if (team && team.delete_at === 0) {
const channel = await getTeamRedirectChannelIfIsAccesible(user, team);
if (channel) {
dispatch(fetchTeamScheduledPosts(team.id, true));
dispatch(selectChannel(channel.id));
historyPushWithQueryParams(`/${team.name}/channels/${channel.name}`, searchParams);
return;

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

@@ -4,10 +4,12 @@
import type {FileInfo} from '@mattermost/types/files';
import type {GroupChannel} from '@mattermost/types/groups';
import type {Post} from '@mattermost/types/posts';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import {SearchTypes} from 'mattermost-redux/action_types';
import {getMyChannelMember} from 'mattermost-redux/actions/channels';
import * as PostActions from 'mattermost-redux/actions/posts';
import {createSchedulePost} from 'mattermost-redux/actions/scheduled_posts';
import * as ThreadActions from 'mattermost-redux/actions/threads';
import {getChannel, getMyChannelMember as getMyChannelMemberSelector} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
@@ -15,7 +17,13 @@ import * as PostSelectors from 'mattermost-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import type {DispatchFunc, ActionFunc, ActionFuncAsync, ThunkActionFunc} from 'mattermost-redux/types/actions';
import type {
DispatchFunc,
ActionFunc,
ActionFuncAsync,
ThunkActionFunc,
GetStateFunc,
} from 'mattermost-redux/types/actions';
import {canEditPost, comparePosts} from 'mattermost-redux/utils/post_utils';
import {addRecentEmoji, addRecentEmojis} from 'actions/emoji_actions';
@@ -25,6 +33,7 @@ import {removeDraft} from 'actions/views/drafts';
import {closeModal, openModal} from 'actions/views/modals';
import * as RhsActions from 'actions/views/rhs';
import {manuallyMarkThreadAsUnread} from 'actions/views/threads';
import {getConnectionId} from 'selectors/general';
import {isEmbedVisible, isInlineImageVisible} from 'selectors/posts';
import {getSelectedPostId, getSelectedPostCardId, getRhsState} from 'selectors/rhs';
import {getGlobalItem} from 'selectors/storage';
@@ -43,9 +52,13 @@ import {makeGetIsReactionAlreadyAddedToPost, makeGetUniqueEmojiNameReactionsForP
import type {GlobalState} from 'types/store';
import {completePostReceive} from './new_post';
import type {NewPostMessageProps} from './new_post';
import type {SubmitPostReturnType} from './views/create_comment';
import {completePostReceive} from './new_post';
import type {OnSubmitOptions, SubmitPostReturnType} from './views/create_comment';
export type CreatePostOptions = {
keepDraft?: boolean;
}
export function handleNewPost(post: Post, msg?: {data?: NewPostMessageProps & GroupChannel}): ActionFuncAsync<boolean, GlobalState> {
return async (dispatch, getState) => {
@@ -106,33 +119,58 @@ export function unflagPost(postId: string): ActionFuncAsync {
};
}
export function createPost(
post: Post,
files: FileInfo[],
afterSubmit?: (response: SubmitPostReturnType) => void,
afterOptimisticSubmit?: () => void,
): ActionFuncAsync<PostActions.CreatePostReturnType, GlobalState> {
return async (dispatch) => {
function addRecentEmojisForMessage(message: string): ActionFunc {
return (dispatch) => {
// parse message and emit emoji event
const emojis = matchEmoticons(post.message);
const emojis = matchEmoticons(message);
if (emojis) {
const trimmedEmojis = emojis.map((emoji) => emoji.substring(1, emoji.length - 1));
dispatch(addRecentEmojis(trimmedEmojis));
}
return {data: true};
};
}
export function createPost(
post: Post,
files: FileInfo[],
afterSubmit?: (response: SubmitPostReturnType) => void,
options?: OnSubmitOptions,
): ActionFuncAsync<PostActions.CreatePostReturnType, GlobalState> {
return async (dispatch) => {
dispatch(addRecentEmojisForMessage(post.message));
const result = await dispatch(PostActions.createPost(post, files, afterSubmit));
if (post.root_id) {
dispatch(storeCommentDraft(post.root_id, null));
} else {
dispatch(storeDraft(post.channel_id, null));
if (!options?.keepDraft) {
if (post.root_id) {
dispatch(storeCommentDraft(post.root_id, null));
} else {
dispatch(storeDraft(post.channel_id, null));
}
}
afterOptimisticSubmit?.();
options?.afterOptimisticSubmit?.();
return result;
};
}
export function createSchedulePostFromDraft(scheduledPost: ScheduledPost): ActionFuncAsync<PostActions.CreatePostReturnType, GlobalState> {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
dispatch(addRecentEmojisForMessage(scheduledPost.message));
const state = getState() as GlobalState;
const connectionId = getConnectionId(state);
const channel = state.entities.channels.channels[scheduledPost.channel_id];
const result = await dispatch(createSchedulePost(scheduledPost, channel.team_id, connectionId));
return {
created: !result.error && result.data,
error: result.error,
};
};
}
function storeDraft(channelId: string, draft: null): ActionFunc {
return (dispatch) => {
dispatch(StorageActions.setGlobalItem('draft_' + channelId, draft));

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

@@ -2,7 +2,9 @@
// See LICENSE.txt for license information.
import type {CommandArgs} from '@mattermost/types/integrations';
import type {Post} from '@mattermost/types/posts';
import type {Post, PostMetadata} from '@mattermost/types/posts';
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
import {scheduledPostFromPost} from '@mattermost/types/schedule_post';
import type {CreatePostReturnType, SubmitReactionReturnType} from 'mattermost-redux/actions/posts';
import {addMessageIntoHistory} from 'mattermost-redux/actions/posts';
@@ -25,6 +27,7 @@ import type {ExecuteCommandReturnType} from 'actions/command';
import {executeCommand} from 'actions/command';
import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks';
import * as PostActions from 'actions/post_actions';
import {createSchedulePostFromDraft} from 'actions/post_actions';
import EmojiMap from 'utils/emoji_map';
import {containsAtChannel, groupsMentionedInText} from 'utils/post_utils';
@@ -38,7 +41,8 @@ export function submitPost(
rootId: string,
draft: PostDraft,
afterSubmit?: (response: SubmitPostReturnType) => void,
afterOptimisticSubmit?: () => void,
schedulingInfo?: SchedulingInfo,
options?: OnSubmitOptions,
): ActionFuncAsync<CreatePostReturnType, GlobalState> {
return async (dispatch, getState) => {
const state = getState();
@@ -86,7 +90,29 @@ export function submitPost(
post = hookResult.data!;
return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit, afterOptimisticSubmit));
if (schedulingInfo) {
const scheduledPost = scheduledPostFromPost(post, schedulingInfo);
scheduledPost.file_ids = draft.fileInfos.map((fileInfo) => fileInfo.id);
if (draft.fileInfos?.length > 0) {
if (!scheduledPost.metadata) {
scheduledPost.metadata = {} as PostMetadata;
}
scheduledPost.metadata.files = draft.fileInfos;
}
const response = await dispatch(createSchedulePostFromDraft(scheduledPost));
if (afterSubmit) {
const result: CreatePostReturnType = {
error: response.error,
created: !response.error,
};
afterSubmit(result);
}
return response;
}
return dispatch(PostActions.createPost(post, draft.fileInfos, afterSubmit, options));
};
}
@@ -134,16 +160,17 @@ export function submitCommand(channelId: string, rootId: string, draft: PostDraf
}
export type SubmitPostReturnType = CreatePostReturnType & SubmitCommandRerturnType & SubmitReactionReturnType;
export type OnSubmitOptions = {
ignoreSlash?: boolean;
afterSubmit?: (response: SubmitPostReturnType) => void;
afterOptimisticSubmit?: () => void;
keepDraft?: boolean;
}
export function onSubmit(
draft: PostDraft,
options: OnSubmitOptions,
schedulingInfo?: SchedulingInfo,
): ActionFuncAsync<SubmitPostReturnType, GlobalState> {
return async (dispatch, getState) => {
const {message, channelId, rootId} = draft;
@@ -151,24 +178,26 @@ export function onSubmit(
dispatch(addMessageIntoHistory(message));
const isReaction = Utils.REACTION_PATTERN.exec(message);
if (!schedulingInfo && !options.ignoreSlash) {
const isReaction = Utils.REACTION_PATTERN.exec(message);
const emojis = getCustomEmojisByName(state);
const emojiMap = new EmojiMap(emojis);
const emojis = getCustomEmojisByName(state);
const emojiMap = new EmojiMap(emojis);
if (isReaction && emojiMap.has(isReaction[2]) && !options.ignoreSlash) {
const latestPostId = getLatestInteractablePostId(state, channelId, rootId);
if (latestPostId) {
return dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2]));
if (isReaction && emojiMap.has(isReaction[2])) {
const latestPostId = getLatestInteractablePostId(state, channelId, rootId);
if (latestPostId) {
return dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2]));
}
return {error: new Error('no post to react to')};
}
if (message.indexOf('/') === 0 && !options.ignoreSlash) {
return dispatch(submitCommand(channelId, rootId, draft));
}
return {error: new Error('No post to react to')};
}
if (message.indexOf('/') === 0 && !options.ignoreSlash) {
return dispatch(submitCommand(channelId, rootId, draft));
}
return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit, options.afterOptimisticSubmit));
return dispatch(submitPost(channelId, rootId, draft, options.afterSubmit, schedulingInfo, options));
};
}

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

@@ -2611,6 +2611,15 @@ const AdminDefinition: AdminDefinitionType = {
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
isHidden: it.configIsFalse('ServiceSettings', 'PostPriority'),
},
{
type: 'bool',
key: 'ServiceSettings.ScheduledPosts',
label: defineMessage({id: 'admin.posts.scheduledPosts.title', defaultMessage: 'Scheduled Posts'}),
help_text: defineMessage({id: 'admin.posts.scheduledPosts.description', defaultMessage: 'When enabled, users can schedule and send messages in the future.'}),
help_text_markdown: false,
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
isHidden: it.not(it.licensed),
},
{
type: 'number',
key: 'ServiceSettings.PersistentNotificationMaxRecipients',

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

@@ -93,7 +93,14 @@ const initialState = {
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: TestHelper.getUserMock({id: 'current_user_id', roles: 'user_roles'}),
current_user_id: TestHelper.getUserMock({
id: 'current_user_id',
roles: 'user_roles',
timezone: {
useAutomaticTimezone: 'true',
automaticTimezone: 'America/New_York',
manualTimezone: '',
}}),
},
statuses: {
current_user_id: 'online',

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

@@ -7,6 +7,7 @@ import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {ServerError} from '@mattermost/types/errors';
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {Permissions} from 'mattermost-redux/constants';
@@ -24,6 +25,7 @@ import {makeGetDraft} from 'selectors/rhs';
import {connectionErrorCount} from 'selectors/views/system';
import LocalStorageStore from 'stores/local_storage_store';
import PostBoxIndicator from 'components/advanced_text_editor/post_box_indicator/post_box_indicator';
import {makeAsyncComponent} from 'components/async_load';
import AutoHeightSwitcher from 'components/common/auto_height_switcher';
import useDidUpdate from 'components/common/hooks/useDidUpdate';
@@ -50,7 +52,6 @@ import type {PostDraft} from 'types/store/draft';
import DoNotDisturbWarning from './do_not_disturb_warning';
import FormattingBar from './formatting_bar';
import {FormattingBarSpacer, Separator} from './formatting_bar/formatting_bar';
import RemoteUserHour from './remote_user_hour';
import SendButton from './send_button';
import ShowFormat from './show_formatting';
import TexteditorActions from './texteditor_actions';
@@ -121,7 +122,6 @@ const AdvancedTextEditor = ({
const teammateId = useSelector((state: GlobalState) => getDirectChannel(state, channelId)?.teammate_id || '');
const teammateDisplayName = useSelector((state: GlobalState) => (teammateId ? getDisplayName(state, teammateId) : ''));
const showDndWarning = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false));
const showRemoteUserHour = useSelector((state: GlobalState) => !showDndWarning && Boolean(getDirectChannel(state, channelId)?.teammate_id));
const canPost = useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
@@ -438,6 +438,8 @@ const AdvancedTextEditor = ({
draftRef.current = draft;
}, [draft]);
const handleSubmitPostAndScheduledMessage = useCallback((schedulingInfo?: SchedulingInfo) => handleSubmit(undefined, schedulingInfo), [handleSubmit]);
// Set the draft from store when changing post or channels, and store the previous one
useEffect(() => {
// Store the draft that existed when we opened the channel to know if it should be saved
@@ -456,7 +458,8 @@ const AdvancedTextEditor = ({
const sendButton = readOnlyChannel ? null : (
<SendButton
disabled={disableSendButton}
handleSubmit={handleSubmit}
handleSubmit={handleSubmitPostAndScheduledMessage}
channelId={channelId}
/>
);
@@ -576,12 +579,12 @@ const AdvancedTextEditor = ({
<FileLimitStickyBanner/>
)}
{showDndWarning && <DoNotDisturbWarning displayName={teammateDisplayName}/>}
{showRemoteUserHour && (
<RemoteUserHour
teammateId={teammateId}
displayName={teammateDisplayName}
/>
)}
<PostBoxIndicator
channelId={channelId}
teammateDisplayName={teammateDisplayName}
location={location}
postId={postId}
/>
<div
className={classNames('AdvancedTextEditor', {
'AdvancedTextEditor__attachment-disabled': !canUploadFiles,

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

@@ -0,0 +1,91 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DateTime} from 'luxon';
import React, {useEffect, useState} from 'react';
import {useSelector} from 'react-redux';
import {getDirectChannel} from 'mattermost-redux/selectors/entities/channels';
import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getTimezoneForUserProfile} from 'mattermost-redux/selectors/entities/timezone';
import {getStatusForUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import RemoteUserHour from 'components/advanced_text_editor/remote_user_hour';
import ScheduledPostIndicator from 'components/advanced_text_editor/scheduled_post_indicator/scheduled_post_indicator';
import Constants, {UserStatuses} from 'utils/constants';
import type {GlobalState} from 'types/store';
import './style.scss';
const DEFAULT_TIMEZONE = {
useAutomaticTimezone: true,
automaticTimezone: '',
manualTimezone: '',
};
type Props = {
channelId: string;
teammateDisplayName: string;
location: string;
postId: string;
}
export default function PostBoxIndicator({channelId, teammateDisplayName, location, postId}: Props) {
const teammateId = useSelector((state: GlobalState) => getDirectChannel(state, channelId)?.teammate_id || '');
const isTeammateDND = useSelector((state: GlobalState) => (teammateId ? getStatusForUserId(state, teammateId) === UserStatuses.DND : false));
const isDM = useSelector((state: GlobalState) => Boolean(getDirectChannel(state, channelId)?.teammate_id));
const showDndWarning = isTeammateDND && isDM;
const [timestamp, setTimestamp] = useState(0);
const [showIt, setShowIt] = useState(false);
const teammateTimezone = useSelector((state: GlobalState) => {
const teammate = teammateId ? getUser(state, teammateId) : undefined;
return teammate ? getTimezoneForUserProfile(teammate) : DEFAULT_TIMEZONE;
}, (a, b) => a.automaticTimezone === b.automaticTimezone &&
a.manualTimezone === b.manualTimezone &&
a.useAutomaticTimezone === b.useAutomaticTimezone);
useEffect(() => {
const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone);
setTimestamp(teammateUserDate.toMillis());
setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY);
const interval = setInterval(() => {
const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone);
setTimestamp(teammateUserDate.toMillis());
setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY);
}, 1000 * 60);
return () => clearInterval(interval);
}, [teammateTimezone.useAutomaticTimezone, teammateTimezone.automaticTimezone, teammateTimezone.manualTimezone]);
const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled);
const showRemoteUserHour = showDndWarning && showIt && timestamp !== 0;
return (
<div className='postBoxIndicator'>
{
showRemoteUserHour &&
<RemoteUserHour
displayName={teammateDisplayName}
timestamp={timestamp}
teammateTimezone={teammateTimezone}
/>
}
{
isScheduledPostEnabled &&
<ScheduledPostIndicator
location={location}
channelId={channelId}
postId={postId}
remoteUserHourDisplayed={showRemoteUserHour}
/>
}
</div>
);
}

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

@@ -0,0 +1,32 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.postBoxIndicator {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
padding-inline: 24px;
&:has(div) {
padding-block: 8px;
}
div {
padding: 0;
}
span, a {
text-wrap: nowrap;
}
.userDisplayName {
position: relative;
top: 4px;
display: inline-block;
overflow: hidden;
max-width: 350px;
text-overflow: ellipsis;
text-wrap: nowrap;
}
}

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

@@ -1,22 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {DateTime} from 'luxon';
import React, {useState, useEffect} from 'react';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import styled from 'styled-components';
import type {GlobalState} from '@mattermost/types/store';
import {getTimezoneForUserProfile} from 'mattermost-redux/selectors/entities/timezone';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import type {UserTimezone} from '@mattermost/types/users';
import Moon from 'components/common/svg_images_components/moon_svg';
import Timestamp from 'components/timestamp';
import Constants from 'utils/constants';
const Container = styled.div`
display: flex;
aling-items: center;
@@ -45,56 +38,24 @@ const Icon = styled(Moon)`
`;
type Props = {
teammateId: string;
displayName: string;
timestamp: number;
teammateTimezone: UserTimezone;
}
const DEFAULT_TIMEZONE = {
useAutomaticTimezone: true,
automaticTimezone: '',
manualTimezone: '',
};
const RemoteUserHour = ({teammateId, displayName}: Props) => {
const [timestamp, setTimestamp] = useState(0);
const [showIt, setShowIt] = useState(false);
const teammateTimezone = useSelector((state: GlobalState) => {
const teammate = teammateId ? getUser(state, teammateId) : undefined;
return teammate ? getTimezoneForUserProfile(teammate) : DEFAULT_TIMEZONE;
}, (a, b) => a.automaticTimezone === b.automaticTimezone &&
a.manualTimezone === b.manualTimezone &&
a.useAutomaticTimezone === b.useAutomaticTimezone);
useEffect(() => {
const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone);
setTimestamp(teammateUserDate.toMillis());
setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY);
const interval = setInterval(() => {
const teammateUserDate = DateTime.local().setZone(teammateTimezone.useAutomaticTimezone ? teammateTimezone.automaticTimezone : teammateTimezone.manualTimezone);
setTimestamp(teammateUserDate.toMillis());
setShowIt(teammateUserDate.get('hour') >= Constants.REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY || teammateUserDate.get('hour') < Constants.REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY);
}, 1000 * 60);
return () => clearInterval(interval);
}, [teammateTimezone.useAutomaticTimezone, teammateTimezone.automaticTimezone, teammateTimezone.manualTimezone]);
if (!showIt) {
return null;
}
if (timestamp === 0) {
return null;
}
const RemoteUserHour = ({displayName, timestamp, teammateTimezone}: Props) => {
return (
<Container>
<Icon/>
<Container className='RemoteUserHour'>
<Icon className='icon moonIcon'/>
<FormattedMessage
id='advanced_text_editor.remote_user_hour'
defaultMessage='The time for {user} is {time}'
values={{
user: displayName,
user: (
<span className='userDisplayName'>
{displayName}
</span>
),
time: (
<Timestamp
useRelative={false}

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

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ScheduledPostIndicator {
display: flex;
flex-wrap: wrap;
align-items: end;
padding: 8px 24px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
gap: 5px;
i.icon {
width: 16px;
height: 16px;
font-size: 16px;
}
}

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

@@ -0,0 +1,106 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {Link} from 'react-router-dom';
import {showChannelOrThreadScheduledPostIndicator} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams';
import {
ShortScheduledPostIndicator,
} from 'components/advanced_text_editor/scheduled_post_indicator/short_scheduled_post_indicator';
import {SCHEDULED_POST_TIME_RANGES, scheduledPostTimeFormat} from 'components/drafts/panel/panel_header';
import Timestamp from 'components/timestamp';
import {Locations} from 'utils/constants';
import type {GlobalState} from 'types/store';
import './scheduled_post_indicator.scss';
type Props = {
location: string;
channelId: string;
postId: string;
remoteUserHourDisplayed?: boolean;
}
export default function ScheduledPostIndicator({location, channelId, postId, remoteUserHourDisplayed}: Props) {
// we use RHS_COMMENT for RHS and threads view, and CENTER for center channel.
// get scheduled posts of a thread if in RHS or threads view,
// else, get those for the channel.
// Fetch scheduled posts for the thread when opening a thread, and fetch for channel
// when opening from center channel.
const id = location === Locations.RHS_COMMENT ? postId : channelId;
const scheduledPostData = useSelector((state: GlobalState) => showChannelOrThreadScheduledPostIndicator(state, id));
const currentTeamName = useSelector((state: GlobalState) => getCurrentTeam(state)?.name);
const scheduledPostLinkURL = `/${currentTeamName}/scheduled_posts?target_id=${id}`;
if (!scheduledPostData?.count) {
return null;
}
if (remoteUserHourDisplayed) {
return (
<ShortScheduledPostIndicator
scheduledPostData={scheduledPostData}
scheduledPostLinkURL={scheduledPostLinkURL}
/>
);
}
let scheduledPostText: React.ReactNode;
// display scheduled post's details of there is only one scheduled post
if (scheduledPostData.count === 1 && scheduledPostData.scheduledPost) {
scheduledPostText = (
<FormattedMessage
id='scheduled_post.channel_indicator.single'
defaultMessage='Message scheduled for {dateTime}.'
values={{
dateTime: (
<Timestamp
value={scheduledPostData.scheduledPost.scheduled_at}
ranges={SCHEDULED_POST_TIME_RANGES}
useSemanticOutput={false}
useTime={scheduledPostTimeFormat}
/>
),
}}
/>
);
}
// display scheduled post count if there are more than one scheduled post
if (scheduledPostData.count > 1) {
scheduledPostText = (
<FormattedMessage
id='scheduled_post.channel_indicator.multiple'
defaultMessage='You have {count} scheduled messages.'
values={{
count: scheduledPostData.count,
}}
/>
);
}
return (
<div className='ScheduledPostIndicator'>
<i
data-testid='scheduledPostIcon'
className='icon icon-draft-indicator icon-clock-send-outline'
/>
{scheduledPostText}
<Link to={scheduledPostLinkURL}>
<FormattedMessage
id='scheduled_post.channel_indicator.link_to_scheduled_posts.text'
defaultMessage='See all scheduled messages'
/>
</Link>
</div>
);
}

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

@@ -0,0 +1,36 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {Link} from 'react-router-dom';
import type {ChannelScheduledPostIndicatorData} from 'mattermost-redux/selectors/entities/scheduled_posts';
type Props = {
scheduledPostData: ChannelScheduledPostIndicatorData;
scheduledPostLinkURL: string;
}
export function ShortScheduledPostIndicator({scheduledPostData, scheduledPostLinkURL}: Props) {
if (scheduledPostData.count === 0) {
return null;
}
return (
<div className='ScheduledPostIndicator'>
<FormattedMessage
id='scheduled_post.channel_indicator.with_other_user_late_time'
defaultMessage='You have {count, plural, =1 {one} other {#}} <a>scheduled {count, plural, =1 {message} other {messages}}</a>.'
values={{
count: scheduledPostData.count,
a: (chunks) => (
<Link to={scheduledPostLinkURL}>
{chunks}
</Link>
),
}}
/>
</div>
);
}

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

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.DMUserTimezone {
margin-top: 8px;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 12px;
}

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

@@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {General} from 'mattermost-redux/constants';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getUser} from 'mattermost-redux/selectors/entities/users';
import Timestamp, {RelativeRanges} from 'components/timestamp';
import {getDisplayNameByUser, getUserIdFromChannelName} from 'utils/utils';
import type {GlobalState} from 'types/store';
import './dm_user_timezone.scss';
type Props = {
channelId: string;
selectedTime?: Date;
}
const DATE_RANGES = [
RelativeRanges.TODAY_TITLE_CASE,
RelativeRanges.TOMORROW_TITLE_CASE,
];
export function DMUserTimezone({channelId, selectedTime}: Props) {
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
const dmUserId = channel && channel.type === General.DM_CHANNEL ? getUserIdFromChannelName(channel) : '';
const dmUser = useSelector((state: GlobalState) => getUser(state, dmUserId));
const dmUserName = useSelector((state: GlobalState) => getDisplayNameByUser(state, dmUser));
const dmUserTime = useMemo(() => {
if (!dmUser) {
return null;
}
return (
<Timestamp
ranges={DATE_RANGES}
userTimezone={dmUser.timezone}
useTime={{
hour: 'numeric',
minute: 'numeric',
}}
value={selectedTime}
/>
);
}, [dmUser, selectedTime]);
if (!channel || channel.type !== 'D') {
return null;
}
return (
<div className='DMUserTimezone'>
<FormattedMessage
id='schedule_post.custom_time_modal.dm_user_time'
defaultMessage='{dmUserTime} for {dmUserName}'
values={{
dmUserTime,
dmUserName,
}}
/>
</div>
);
}

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

@@ -0,0 +1,92 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment';
import type {Moment} from 'moment-timezone';
import React, {useCallback, useMemo, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {generateCurrentTimezoneLabel, getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {
DMUserTimezone,
} from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/dm_user_timezone';
import DateTimePickerModal from 'components/date_time_picker_modal/date_time_picker_modal';
type Props = {
channelId: string;
onExited: () => void;
onConfirm: (timestamp: number) => Promise<{error?: string}>;
initialTime?: Moment;
}
export default function ScheduledPostCustomTimeModal({channelId, onExited, onConfirm, initialTime}: Props) {
const {formatMessage} = useIntl();
const [errorMessage, setErrorMessage] = useState<string>();
const userTimezone = useSelector(getCurrentTimezone);
const [selectedDateTime, setSelectedDateTime] = useState<Moment>(() => {
if (initialTime) {
return initialTime;
}
const now = moment().tz(userTimezone);
return now.add(1, 'days').set({hour: 9, minute: 0, second: 0, millisecond: 0});
});
const userTimezoneLabel = useMemo(() => generateCurrentTimezoneLabel(userTimezone), [userTimezone]);
const handleOnConfirm = useCallback(async (dateTime: Moment) => {
const response = await onConfirm(dateTime.valueOf());
if (response.error) {
setErrorMessage(response.error);
} else {
onExited();
}
}, [onConfirm, onExited]);
const bodySuffix = useMemo(() => {
return (
<DMUserTimezone
channelId={channelId}
selectedTime={selectedDateTime?.toDate()}
/>
);
}, [channelId, selectedDateTime]);
const label = formatMessage({id: 'schedule_post.custom_time_modal.title', defaultMessage: 'Schedule message'});
return (
<DateTimePickerModal
className='scheduled_post_custom_time_modal'
initialTime={selectedDateTime}
header={
<FormattedMessage
id='schedule_post.custom_time_modal.title'
defaultMessage='Schedule message'
/>
}
subheading={userTimezoneLabel}
confirmButtonText={
<FormattedMessage
id='schedule_post.custom_time_modal.confirm_button_text'
defaultMessage='Confirm'
/>
}
cancelButtonText={
<FormattedMessage
id='schedule_post.custom_time_modal.cancel_button_text'
defaultMessage='Cancel'
/>
}
ariaLabel={label}
onExited={onExited}
onConfirm={handleOnConfirm}
onChange={setSelectedDateTime}
bodySuffix={bodySuffix}
relativeDate={true}
onCancel={onExited}
errorText={errorMessage}
/>
);
}

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

@@ -0,0 +1,56 @@
.splitSendButton {
display: flex;
width: 48px;
height: 32px;
flex-direction: row;
justify-content: center;
border-radius: 4px;
background: var(--button-bg);
color: var(--button-color);
&.scheduledPost {
width: 56px;
}
&.disabled {
background: rgba(var(--center-channel-color-rgb), 0.08);
svg {
fill: rgba(var(--center-channel-color-rgb), 0.32);
}
.button_send_post_options {
border-color: rgba(var(--center-channel-color-rgb), 0.16);
}
}
.SendMessageButton, .button_send_post_options {
display: flex;
flex-direction: column;
justify-content: center;
border: none;
background: none;
&.disabled {
cursor: not-allowed;
}
}
.SendMessageButton {
cursor: pointer;
padding-inline: 7px;
place-content: center;
place-items: center;
transition: color 150ms;
.android &,
.ios & {
display: flex;
}
}
.button_send_post_options {
border-left: 1px solid color-mix(in srgb, currentColor 20%, transparent);
padding-inline: 2px;
}
}

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

@@ -1,74 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo} from 'react';
import {useIntl} from 'react-intl';
import styled from 'styled-components';
import classNames from 'classnames';
import React, {memo, useCallback, useMemo} from 'react';
import {defineMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {SendIcon} from '@mattermost/compass-icons/components';
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
import {isScheduledPostsEnabled} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {isSendOnCtrlEnter} from 'selectors/preferences';
import {SendPostOptions} from 'components/advanced_text_editor/send_button/send_post_options';
import WithTooltip from 'components/with_tooltip';
import type {ShortcutDefinition} from 'components/with_tooltip/shortcut';
import {ShortcutKeys} from 'components/with_tooltip/shortcut';
import './send_button.scss';
type SendButtonProps = {
handleSubmit: () => void;
handleSubmit: (schedulingInfo?: SchedulingInfo) => void;
disabled: boolean;
channelId: string;
}
const SendButtonContainer = styled.button`
display: flex;
height: 32px;
padding: 0 16px;
border: none;
background: var(--button-bg);
border-radius: 4px;
color: var(--button-color);
cursor: pointer;
place-content: center;
place-items: center;
transition: color 150ms;
&--disabled,
&[disabled] {
background: rgba(var(--center-channel-color-rgb), 0.08);
svg {
fill: rgba(var(--center-channel-color-rgb), 0.32);
}
}
.android &,
.ios & {
display: flex;
}
`;
const SendButton = ({disabled, handleSubmit}: SendButtonProps) => {
const SendButton = ({disabled, handleSubmit, channelId}: SendButtonProps) => {
const {formatMessage} = useIntl();
const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled);
const sendMessage = (e: React.FormEvent) => {
e.stopPropagation();
e.preventDefault();
handleSubmit();
};
const sendMessage = useCallback((e: React.FormEvent, schedulingInfo?: SchedulingInfo) => {
e?.stopPropagation();
e?.preventDefault();
handleSubmit(schedulingInfo);
}, [handleSubmit]);
const sendOnCtrlEnter = useSelector(isSendOnCtrlEnter);
const sendNowKeyboardShortcutDescriptor = useMemo<ShortcutDefinition>(() => {
const shortcutDefinition: ShortcutDefinition = {
default: [
defineMessage({
id: 'shortcuts.generic.enter',
defaultMessage: 'Enter',
}),
],
mac: [
defineMessage({
id: 'shortcuts.generic.enter',
defaultMessage: 'Enter',
}),
],
};
if (sendOnCtrlEnter) {
shortcutDefinition.default.unshift(ShortcutKeys.ctrl);
shortcutDefinition.mac?.unshift(ShortcutKeys.cmd);
}
return shortcutDefinition;
}, [sendOnCtrlEnter]);
return (
<SendButtonContainer
data-testid='SendMessageButton'
tabIndex={0}
aria-label={formatMessage({
id: 'create_post.send_message',
defaultMessage: 'Send a message',
})}
disabled={disabled}
onClick={sendMessage}
>
<SendIcon
size={18}
color='currentColor'
aria-label={formatMessage({
id: 'create_post.icon',
defaultMessage: 'Create a post',
})}
/>
</SendButtonContainer>
<div className={classNames('splitSendButton', {disabled, scheduledPost: isScheduledPostEnabled})}>
<WithTooltip
placement='top'
id='send_post_now_tooltip'
title={formatMessage({id: 'create_post_button.option.send_now', defaultMessage: 'Send Now'})}
shortcut={sendNowKeyboardShortcutDescriptor}
disabled={disabled}
>
<button
className={classNames('SendMessageButton', {disabled})}
data-testid='SendMessageButton'
tabIndex={0}
aria-label={formatMessage({
id: 'create_post_button.option.send_now',
defaultMessage: 'Send Now',
})}
disabled={disabled}
onClick={sendMessage}
>
<SendIcon
size={18}
color='currentColor'
/>
</button>
</WithTooltip>
{
isScheduledPostEnabled &&
<SendPostOptions
disabled={disabled}
onSelect={handleSubmit}
channelId={channelId}
/>
}
</div>
);
};

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

@@ -0,0 +1,120 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment';
import React, {memo, useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import * as Menu from 'components/menu';
import Timestamp from 'components/timestamp';
type Props = {
handleOnSelect: (e: React.FormEvent, scheduledAt: number) => void;
}
function CoreMenuOptions({handleOnSelect}: Props) {
const userTimezone = useSelector(getCurrentTimezone);
const today = moment().tz(userTimezone);
const tomorrow9amTime = moment().
tz(userTimezone).
add(1, 'days').
set({hour: 9, minute: 0, second: 0, millisecond: 0}).
valueOf();
const timeComponent = (
<Timestamp
value={tomorrow9amTime.valueOf()}
useDate={false}
/>
);
const tomorrowClickHandler = useCallback((e) => handleOnSelect(e, tomorrow9amTime), [handleOnSelect, tomorrow9amTime]);
const optionTomorrow = (
<Menu.Item
key={'scheduling_time_tomorrow_9_am'}
onClick={tomorrowClickHandler}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.tomorrow'
defaultMessage='Tomorrow at {9amTime}'
values={{'9amTime': timeComponent}}
/>
}
/>
);
const nextMonday = moment().
tz(userTimezone).
day(8). // next monday; 1 = Monday, 8 = next Monday
set({hour: 9, minute: 0, second: 0, millisecond: 0}). // 9 AM
valueOf();
const nextMondayClickHandler = useCallback((e) => handleOnSelect(e, nextMonday), [handleOnSelect, nextMonday]);
const optionNextMonday = (
<Menu.Item
key={'scheduling_time_next_monday_9_am'}
onClick={nextMondayClickHandler}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.next_monday'
defaultMessage='Next Monday at {9amTime}'
values={{'9amTime': timeComponent}}
/>
}
/>
);
const optionMonday = (
<Menu.Item
key={'scheduling_time_monday_9_am'}
onClick={nextMondayClickHandler}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.monday'
defaultMessage='Monday at {9amTime}'
values={{
'9amTime': timeComponent,
}}
/>
}
/>
);
let options: React.ReactElement[] = [];
switch (today.day()) {
// Sunday
case 0:
options = [optionTomorrow];
break;
// Monday
case 1:
options = [optionTomorrow, optionNextMonday];
break;
// Friday and Saturday
case 5:
case 6:
options = [optionMonday];
break;
// Tuesday to Thursday
default:
options = [optionTomorrow, optionMonday];
}
return (
<React.Fragment>
{options}
</React.Fragment>
);
}
export default memo(CoreMenuOptions);

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

@@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import ChevronDownIcon from '@mattermost/compass-icons/components/chevron-down';
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
import {openModal} from 'actions/views/modals';
import CoreMenuOptions from 'components/advanced_text_editor/send_button/send_post_options/core_menu_options';
import * as Menu from 'components/menu';
import {ModalIdentifiers} from 'utils/constants';
import ScheduledPostCustomTimeModal from '../scheduled_post_custom_time_modal/scheduled_post_custom_time_modal';
import './style.scss';
type Props = {
channelId: string;
disabled?: boolean;
onSelect: (schedulingInfo: SchedulingInfo) => void;
}
export function SendPostOptions({disabled, onSelect, channelId}: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const handleOnSelect = useCallback((e: React.FormEvent, scheduledAt: number) => {
e.preventDefault();
e.stopPropagation();
const schedulingInfo: SchedulingInfo = {
scheduled_at: scheduledAt,
};
onSelect(schedulingInfo);
}, [onSelect]);
const handleSelectCustomTime = useCallback((scheduledAt: number) => {
const schedulingInfo: SchedulingInfo = {
scheduled_at: scheduledAt,
};
onSelect(schedulingInfo);
return Promise.resolve({});
}, [onSelect]);
const handleChooseCustomTime = useCallback(() => {
dispatch(openModal({
modalId: ModalIdentifiers.SCHEDULED_POST_CUSTOM_TIME_MODAL,
dialogType: ScheduledPostCustomTimeModal,
dialogProps: {
channelId,
onConfirm: handleSelectCustomTime,
},
}));
}, [channelId, dispatch, handleSelectCustomTime]);
return (
<Menu.Container
hideTooltipWhenDisabled={true}
menuButtonTooltip={{
id: 'send_post_option_schedule_post',
text: formatMessage({
id: 'create_post_button.option.schedule_message',
defaultMessage: 'Schedule message',
}),
}}
menuButton={{
id: 'button_send_post_options',
class: classNames('button_send_post_options', {disabled}),
children: <ChevronDownIcon size={16}/>,
disabled,
'aria-label': formatMessage({
id: 'create_post_button.option.schedule_message',
defaultMessage: 'Schedule message',
}),
}}
menu={{
id: 'dropdown_send_post_options',
}}
transformOrigin={{
horizontal: 'right',
vertical: 'bottom',
}}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
>
<Menu.Item
disabled={true}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.header'
defaultMessage='Scheduled message'
/>
}
/>
<CoreMenuOptions handleOnSelect={handleOnSelect}/>
<Menu.Separator/>
<Menu.Item
onClick={handleChooseCustomTime}
key={'choose_custom_time'}
labels={
<FormattedMessage
id='create_post_button.option.schedule_message.options.choose_custom_time'
defaultMessage='Choose a custom time'
/>
}
/>
</Menu.Container>
);
}

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

@@ -0,0 +1,9 @@
ul#dropdown_send_post_options {
li[role="menuitem"][aria-disabled="true"] {
opacity: 1;
.label-elements {
font-weight: bold;
}
}
}

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

@@ -5,6 +5,8 @@ import type React from 'react';
import {useCallback, useEffect, useRef} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {emitShortcutReactToLastPostFrom} from 'actions/post_actions';
@@ -39,7 +41,7 @@ const useKeyHandler = (
focusTextbox: (forceFocus?: boolean) => void,
applyMarkdown: (params: ApplyMarkdownOptions) => void,
handleDraftChange: (draft: PostDraft, options?: {instant?: boolean; show?: boolean}) => void,
handleSubmit: (submittingDraft?: PostDraft) => void,
handleSubmit: (submittingDraft?: PostDraft, schedulingInfo?: SchedulingInfo) => void,
emitTypingEvent: () => void,
toggleShowPreview: () => void,
toggleAdvanceTextEditor: () => void,

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

@@ -27,16 +27,18 @@ import PriorityLabels from './priority_labels';
const usePriority = (
draft: PostDraft,
handleDraftChange: (draft: PostDraft, options: {instant?: boolean; show?: boolean}) => void,
handleDraftChange: ((draft: PostDraft, options: {instant?: boolean; show?: boolean}) => void),
focusTextbox: (keepFocus?: boolean) => void,
shouldShowPreview: boolean,
) => {
const dispatch = useDispatch();
const rootId = draft.rootId;
const channelId = draft.channelId;
const isPostPriorityEnabled = useSelector(isPostPriorityEnabledSelector);
const channelType = useSelector((state: GlobalState) => getChannel(state, draft.channelId)?.type || 'O');
const channelType = useSelector((state: GlobalState) => getChannel(state, channelId)?.type || 'O');
const channelTeammateUsername = useSelector((state: GlobalState) => {
const channel = getChannel(state, draft.channelId);
const channel = getChannel(state, channelId);
return getUser(state, channel?.teammate_id || '')?.username || '';
});
@@ -133,7 +135,7 @@ const usePriority = (
}, [isPostPriorityEnabled, showPersistNotificationModal, draft, channelType, specialMentions]);
const labels = useMemo(() => (
(hasPrioritySet && !draft.rootId) ? (
(hasPrioritySet && !rootId) ? (
<PriorityLabels
canRemove={!shouldShowPreview}
hasError={!isValidPersistentNotifications}
@@ -144,10 +146,10 @@ const usePriority = (
requestedAck={draft!.metadata!.priority?.requested_ack}
/>
) : undefined
), [shouldShowPreview, draft, hasPrioritySet, isValidPersistentNotifications, specialMentions, handleRemovePriority]);
), [hasPrioritySet, rootId, shouldShowPreview, isValidPersistentNotifications, specialMentions, handleRemovePriority, draft]);
const additionalControl = useMemo(() =>
!draft.rootId && isPostPriorityEnabled && (
!rootId && isPostPriorityEnabled && (
<PostPriorityPickerOverlay
key='post-priority-picker-key'
settings={draft.metadata?.priority}
@@ -155,7 +157,7 @@ const usePriority = (
onClose={handlePostPriorityHide}
disabled={shouldShowPreview}
/>
), [draft.rootId, isPostPriorityEnabled, draft.metadata?.priority, handlePostPriorityApply, handlePostPriorityHide, shouldShowPreview]);
), [rootId, isPostPriorityEnabled, draft.metadata?.priority, handlePostPriorityApply, handlePostPriorityHide, shouldShowPreview]);
return {
labels,

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

@@ -6,6 +6,7 @@ import {useCallback, useRef, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {ServerError} from '@mattermost/types/errors';
import type {SchedulingInfo} from '@mattermost/types/schedule_post';
import {getChannelTimezones} from 'mattermost-redux/actions/channels';
import {Permissions} from 'mattermost-redux/constants';
@@ -15,6 +16,7 @@ import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles';
import {getCurrentUserId, getStatusForUserId} from 'mattermost-redux/selectors/entities/users';
import type {CreatePostOptions} from 'actions/post_actions';
import {scrollPostListToBottom} from 'actions/views/channel';
import type {OnSubmitOptions, SubmitPostReturnType} from 'actions/views/create_comment';
import {onSubmit} from 'actions/views/create_comment';
@@ -64,7 +66,7 @@ const useSubmit = (
afterSubmit?: (response: SubmitPostReturnType) => void,
skipCommands?: boolean,
): [
(submittingDraft?: PostDraft) => Promise<void>,
(submittingDraft?: PostDraft, schedulingInfo?: SchedulingInfo, options?: CreatePostOptions) => void,
string | null,
] => {
const getGroupMentions = useGroups(channelId, draft.message);
@@ -118,7 +120,7 @@ const useSubmit = (
}));
}, [dispatch]);
const doSubmit = useCallback(async (submittingDraft = draft) => {
const doSubmit = useCallback(async (submittingDraft: PostDraft = draft, schedulingInfo?: SchedulingInfo, createPostOptions?: CreatePostOptions) => {
if (submittingDraft.uploadsInProgress.length > 0) {
isDraftSubmitting.current = false;
return;
@@ -138,10 +140,12 @@ const useSubmit = (
return;
}
if (isRootDeleted) {
showPostDeletedModal();
isDraftSubmitting.current = false;
return;
if (!schedulingInfo) {
if (isRootDeleted) {
showPostDeletedModal();
isDraftSubmitting.current = false;
return;
}
}
if (serverError && !isErrorInvalidSlashCommand(serverError)) {
@@ -156,10 +160,15 @@ const useSubmit = (
setServerError(null);
const ignoreSlash = skipCommands || (isErrorInvalidSlashCommand(serverError) && serverError?.submittedMessage === submittingDraft.message);
const options: OnSubmitOptions = {ignoreSlash, afterSubmit, afterOptimisticSubmit};
const options: OnSubmitOptions = {
ignoreSlash,
afterSubmit,
afterOptimisticSubmit,
keepDraft: createPostOptions?.keepDraft,
};
try {
const res = await dispatch(onSubmit(submittingDraft, options));
const res = await dispatch(onSubmit(submittingDraft, options, schedulingInfo));
if (res.error) {
throw res.error;
}
@@ -190,7 +199,7 @@ const useSubmit = (
return;
}
if (!postId) {
if (!postId && !schedulingInfo) {
dispatch(scrollPostListToBottom());
}
@@ -212,7 +221,7 @@ const useSubmit = (
channelId,
]);
const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number) => {
const showNotifyAllModal = useCallback((mentions: string[], channelTimezoneCount: number, memberNotifyCount: number, onConfirm: () => void) => {
dispatch(openModal({
modalId: ModalIdentifiers.NOTIFY_CONFIRM_MODAL,
dialogType: NotifyConfirmModal,
@@ -220,12 +229,12 @@ const useSubmit = (
mentions,
channelTimezoneCount,
memberNotifyCount,
onConfirm: () => doSubmit(),
onConfirm,
},
}));
}, [doSubmit, dispatch]);
}, [dispatch]);
const handleSubmit = useCallback(async (submittingDraft = draft) => {
const handleSubmit = useCallback(async (submittingDraft = draft, schedulingInfo?: SchedulingInfo, options?: CreatePostOptions) => {
if (!channel) {
return;
}
@@ -262,18 +271,19 @@ const useSubmit = (
channelTimezoneCount = data ? data.length : 0;
}
if (prioritySubmitCheck(doSubmit)) {
const onConfirm = () => doSubmit(submittingDraft, schedulingInfo);
if (prioritySubmitCheck(onConfirm)) {
isDraftSubmitting.current = false;
return;
}
if (memberNotifyCount > 0) {
showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount);
showNotifyAllModal(mentions, channelTimezoneCount, memberNotifyCount, onConfirm);
isDraftSubmitting.current = false;
return;
}
if (!skipCommands) {
if (!skipCommands && !schedulingInfo) {
const status = getStatusFromSlashCommand(submittingDraft.message);
if (userIsOutOfOffice && status) {
const resetStatusModalData = {
@@ -327,7 +337,7 @@ const useSubmit = (
}
}
await doSubmit(submittingDraft);
await doSubmit(submittingDraft, schedulingInfo, options);
}, [
doSubmit,
draft,

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

@@ -9,6 +9,7 @@ import {makeAsyncComponent} from 'components/async_load';
import ChannelIdentifierRouter from 'components/channel_layout/channel_identifier_router';
import LoadingScreen from 'components/loading_screen';
import {SCHEDULED_POST_URL_SUFFIX} from 'utils/constants';
import {IDENTIFIER_PATH_PATTERN, ID_PATH_PATTERN, TEAM_NAME_PATH_PATTERN} from 'utils/path';
import type {OwnProps, PropsFromRedux} from './index';
@@ -114,6 +115,10 @@ export default class CenterChannel extends React.PureComponent<Props, State> {
path={`/:team(${TEAM_NAME_PATH_PATTERN})/drafts`}
component={Drafts}
/>
<Route
path={`/:team(${TEAM_NAME_PATH_PATTERN})/${SCHEDULED_POST_URL_SUFFIX}`}
component={Drafts}
/>
<Redirect to={lastChannelPath}/>
</Switch>

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

@@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
// useScrollOnRender hook is used to scroll to the element when it is rendered
// Attach the returned ref to the element you want to scroll to.
export function useScrollOnRender() {
const ref = React.useRef<HTMLElement>(null);
React.useEffect(() => {
if (ref.current) {
ref.current.scrollIntoView({behavior: 'smooth'});
}
}, []);
return ref;
}

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

@@ -328,9 +328,14 @@ span.emoticon[style]:hover {
&-icon {
position: absolute;
top: 11.3px;
top: 9.5px;
left: 15px;
}
.dateTime__input {
display: flex;
align-items: center;
}
}
& &__input {

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

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import {DateTime} from 'luxon';
import type {Moment} from 'moment-timezone';
import moment from 'moment-timezone';
@@ -22,15 +23,17 @@ import Input from 'components/widgets/inputs/input/input';
import Menu from 'components/widgets/menu/menu';
import MenuWrapper from 'components/widgets/menu/menu_wrapper';
import Constants, {A11yCustomEventTypes} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import Constants, {A11yCustomEventTypes} from 'utils/constants';
import {relativeFormatDate} from 'utils/datetime';
import {isKeyPressed} from 'utils/keyboard';
import {getCurrentMomentForTimezone} from 'utils/timezone';
const CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES = 30;
export function getRoundedTime(value: Moment) {
const roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES;
const DATE_FORMAT = 'yyyy-MM-dd';
export function getRoundedTime(value: Moment, roundedTo = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES) {
const start = moment(value);
const diff = start.minute() % roundedTo;
if (diff === 0) {
@@ -40,8 +43,7 @@ export function getRoundedTime(value: Moment) {
return start.add(remainder, 'm').seconds(0).milliseconds(0);
}
export const getTimeInIntervals = (startTime: Moment): Date[] => {
const interval = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES;
export const getTimeInIntervals = (startTime: Moment, interval = CUSTOM_STATUS_TIME_PICKER_INTERVALS_IN_MINUTES): Date[] => {
let time = moment(startTime);
const nextDay = moment(startTime).add(1, 'days').startOf('day');
@@ -65,6 +67,8 @@ type Props = {
handleChange: (date: Moment) => void;
timezone?: string;
setIsDatePickerOpen?: (isDatePickerOpen: boolean) => void;
relativeDate?: boolean;
timePickerInterval?: number;
}
const DateTimeInputContainer: React.FC<Props> = (props: Props) => {
@@ -99,9 +103,9 @@ const DateTimeInputContainer: React.FC<Props> = (props: Props) => {
const currentTime = getCurrentMomentForTimezone(timezone);
let startTime = moment(time).startOf('day');
if (currentTime.isSame(time, 'date')) {
startTime = getRoundedTime(currentTime);
startTime = getRoundedTime(currentTime, props.timePickerInterval);
}
setTimeOptions(getTimeInIntervals(startTime));
setTimeOptions(getTimeInIntervals(startTime, props.timePickerInterval));
};
useEffect(setTimeAndOptions, [time]);
@@ -109,10 +113,10 @@ const DateTimeInputContainer: React.FC<Props> = (props: Props) => {
const handleDayChange = (day: Date, modifiers: DayModifiers) => {
if (modifiers.today) {
const currentTime = getCurrentMomentForTimezone(timezone);
const roundedTime = getRoundedTime(currentTime);
const roundedTime = getRoundedTime(currentTime, props.timePickerInterval);
handleChange(roundedTime);
} else {
const dayWithTimezone = timezone ? moment.tz(day, timezone) : moment(day);
const dayWithTimezone = timezone ? moment(day).tz(timezone, true) : moment(day);
handleChange(dayWithTimezone.startOf('day'));
}
handlePopperOpenState(false);
@@ -137,8 +141,8 @@ const DateTimeInputContainer: React.FC<Props> = (props: Props) => {
));
}, []);
const formatDate = (date: Date): string => {
return DateTime.fromJSDate(date).toFormat('yyyy-MM-dd');
const formatDate = (date: Moment): string => {
return props.relativeDate ? relativeFormatDate(date, formatMessage, DATE_FORMAT) : DateTime.fromJSDate(date.toDate()).toFormat(DATE_FORMAT);
};
const inputIcon = (
@@ -174,10 +178,10 @@ const DateTimeInputContainer: React.FC<Props> = (props: Props) => {
datePickerProps={datePickerProps}
>
<Input
value={formatDate(time.toDate())}
value={formatDate(time)}
id='customStatus__calendar-input'
readOnly={true}
className='dateTime__calendar-input'
className={classNames('dateTime__calendar-input', {isOpen: isPopperOpen})}
label={formatMessage({id: 'dnd_custom_time_picker_modal.date', defaultMessage: 'Date'})}
onClick={() => handlePopperOpenState(true)}
tabIndex={-1}

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

@@ -0,0 +1,123 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classnames from 'classnames';
import type {Moment} from 'moment-timezone';
import React, {useCallback, useEffect, useState} from 'react';
import {useSelector} from 'react-redux';
import {GenericModal} from '@mattermost/components';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import DateTimeInput, {getRoundedTime} from 'components/custom_status/date_time_input';
import Constants from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {getCurrentMomentForTimezone} from 'utils/timezone';
import './style.scss';
type Props = {
onExited?: () => void;
ariaLabel: string;
header: React.ReactNode;
subheading?: React.ReactNode;
onChange?: (dateTime: Moment) => void;
onCancel?: () => void;
onConfirm?: (dateTime: Moment) => void;
initialTime?: Moment;
confirmButtonText?: React.ReactNode;
cancelButtonText?: React.ReactNode;
bodyPrefix?: React.ReactNode;
bodySuffix?: React.ReactNode;
relativeDate?: boolean;
className?: string;
errorText?: string | React.ReactNode;
timePickerInterval?: number;
};
export default function DateTimePickerModal({
onExited,
ariaLabel,
header,
onConfirm,
onCancel,
initialTime,
confirmButtonText,
onChange,
cancelButtonText,
subheading,
bodyPrefix,
bodySuffix,
relativeDate,
className,
errorText,
timePickerInterval,
}: Props) {
const userTimezone = useSelector(getCurrentTimezone);
const currentTime = getCurrentMomentForTimezone(userTimezone);
const initialRoundedTime = getRoundedTime(currentTime);
const [dateTime, setDateTime] = useState(initialTime || initialRoundedTime);
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (isKeyPressed(event, Constants.KeyCodes.ESCAPE) && !isDatePickerOpen) {
event.preventDefault();
event.stopPropagation();
onExited?.();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isDatePickerOpen, onExited]);
const handleChange = useCallback((dateTime: Moment) => {
setDateTime(dateTime);
onChange?.(dateTime);
}, [onChange]);
const handleConfirm = useCallback(() => {
onConfirm?.(dateTime);
}, [dateTime, onConfirm]);
return (
<GenericModal
id='DateTimePickerModal'
ariaLabel={ariaLabel}
onExited={onExited}
modalHeaderText={header}
modalSubheaderText={subheading}
confirmButtonText={confirmButtonText}
handleConfirm={handleConfirm}
handleCancel={onCancel}
handleEnterKeyPress={handleConfirm}
className={classnames('date-time-picker-modal', className)}
compassDesign={true}
keyboardEscape={false}
cancelButtonText={cancelButtonText}
autoCloseOnConfirmButton={false}
errorText={errorText}
>
{bodyPrefix}
<DateTimeInput
time={dateTime}
handleChange={handleChange}
timezone={userTimezone}
setIsDatePickerOpen={setIsDatePickerOpen}
relativeDate={relativeDate}
timePickerInterval={timePickerInterval}
/>
{bodySuffix}
</GenericModal>
);
}

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

@@ -1,18 +1,12 @@
.post-reminder-modal {
.date-time-picker-modal {
.modal-body {
overflow: visible;
}
.dateTime__calendar-input {
width: 100%;
border: 2px solid var(--button-bg);
font-size: 14px;
&:hover {
border: 2px solid var(--button-bg);
cursor: pointer;
}
&:focus-within {
box-shadow: 0;
}
@@ -44,6 +38,36 @@
}
}
.dateTime__time-menu .dateTime__input, .dateTime__calendar-input {
height: 40px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
&:hover, &.isOpen {
border-color: rgba(var(--center-channel-color-rgb), 0.16);
box-shadow: 0 0 0 2px var(--button-bg);
cursor: pointer;
}
.Input_wrapper {
button:hover {
background: none;
}
}
}
.dateTime__time-menu {
&.MenuWrapper--open {
.dateTime__input {
box-shadow: 0 0 0 2px var(--button-bg);
cursor: pointer;
}
}
.dateTime__time-icon {
pointer-events: none;
}
}
.dateTime {
width: 100%;
margin: unset;

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

@@ -34,12 +34,8 @@ exports[`components/drafts/drafts_row should match snapshot for channel draft 1`
>
<Memo(DraftRow)
displayName="test"
draft={
Object {
"type": "channel",
}
}
isRemote={false}
item={Object {}}
status={Object {}}
user={Object {}}
/>
@@ -82,10 +78,11 @@ exports[`components/drafts/drafts_row should match snapshot for thread draft 1`]
displayName="test"
draft={
Object {
"type": "thread",
"rootId": "some_id",
}
}
isRemote={false}
item={Object {}}
status={Object {}}
user={Object {}}
/>

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

@@ -35,10 +35,13 @@ exports[`components/drafts/draft_actions should match snapshot 1`] = `
<Memo(DraftActions)
canEdit={true}
canSend={true}
channelId=""
displayName=""
draftId=""
itemId=""
onDelete={[MockFunction]}
onEdit={[MockFunction]}
onSchedule={[MockFunction]}
onSend={[MockFunction]}
/>
</ContextProvider>

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

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import noop from 'lodash/noop';
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
@@ -43,7 +44,7 @@ function DeleteDraftModal({
return (
<GenericModal
confirmButtonText={confirmButtonText}
handleCancel={() => {}}
handleCancel={noop}
handleConfirm={onConfirm}
modalHeaderText={title}
onExited={onExited}

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

@@ -13,11 +13,14 @@ describe('components/drafts/draft_actions', () => {
const baseProps = {
displayName: '',
draftId: '',
itemId: '',
onDelete: jest.fn(),
onEdit: jest.fn(),
onSend: jest.fn(),
canSend: true,
canEdit: true,
onSchedule: jest.fn(),
channelId: '',
};
it('should match snapshot', () => {

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

@@ -7,12 +7,22 @@ import {useDispatch} from 'react-redux';
import {openModal} from 'actions/views/modals';
import ScheduledPostCustomTimeModal
from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal';
import {ModalIdentifiers} from 'utils/constants';
import Action from './action';
import DeleteDraftModal from './delete_draft_modal';
import SendDraftModal from './send_draft_modal';
const scheduledDraft = (
<FormattedMessage
id='drafts.actions.scheduled'
defaultMessage='Schedule draft'
/>
);
type Props = {
displayName: string;
onDelete: () => void;
@@ -20,6 +30,8 @@ type Props = {
onSend: () => void;
canEdit: boolean;
canSend: boolean;
onSchedule: (timestamp: number) => Promise<{error?: string}>;
channelId: string;
}
function DraftActions({
@@ -29,6 +41,8 @@ function DraftActions({
onSend,
canEdit,
canSend,
onSchedule,
channelId,
}: Props) {
const dispatch = useDispatch();
@@ -54,6 +68,17 @@ function DraftActions({
}));
}, [dispatch, displayName, onSend]);
const handleScheduleDraft = useCallback(() => {
dispatch(openModal({
modalId: ModalIdentifiers.SCHEDULED_POST_CUSTOM_TIME_MODAL,
dialogType: ScheduledPostCustomTimeModal,
dialogProps: {
channelId,
onConfirm: onSchedule,
},
}));
}, [channelId, dispatch, onSchedule]);
return (
<>
<Action
@@ -82,6 +107,18 @@ function DraftActions({
onClick={onEdit}
/>
)}
{
canSend &&
<Action
icon='icon-clock-send-outline'
id='reschedule'
name='reschedule'
tooltipText={scheduledDraft}
onClick={handleScheduleDraft}
/>
}
{canSend && (
<Action
icon='icon-send-outline'

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

@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import noop from 'lodash/noop';
import React, {useCallback, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {GenericModal} from '@mattermost/components';
type Props = {
channelDisplayName: string;
onConfirm: () => Promise<{error?: string}>;
onExited: () => void;
}
export default function DeleteScheduledPostModal({
channelDisplayName,
onExited,
onConfirm,
}: Props) {
const {formatMessage} = useIntl();
const [errorMessage, setErrorMessage] = useState<string>();
const title = formatMessage({
id: 'scheduled_post.delete_modal.title',
defaultMessage: 'Delete scheduled post',
});
const confirmButtonText = formatMessage({
id: 'drafts.confirm.delete.button',
defaultMessage: 'Yes, delete',
});
const handleOnConfirm = useCallback(async () => {
const response = await onConfirm();
if (response.error) {
setErrorMessage(response.error);
} else {
onExited();
}
}, [onConfirm, onExited]);
return (
<GenericModal
className='delete_scheduled_post_modal'
confirmButtonText={confirmButtonText}
handleCancel={noop}
handleConfirm={handleOnConfirm}
modalHeaderText={title}
onExited={onExited}
compassDesign={true}
isDeleteModal={true}
autoFocusConfirmButton={true}
autoCloseOnConfirmButton={false}
errorText={errorMessage}
>
<FormattedMessage
id={'scheduled_post.delete_modal.body'}
defaultMessage={'Are you sure you want to delete this scheduled post to <strong>{displayName}</strong>?'}
values={{
strong: (chunk: string) => <strong>{chunk}</strong>,
displayName: channelDisplayName,
}}
/>
</GenericModal>
);
}

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

@@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment';
import React, {memo, useCallback} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {openModal} from 'actions/views/modals';
import ScheduledPostCustomTimeModal
from 'components/advanced_text_editor/send_button/scheduled_post_custom_time_modal/scheduled_post_custom_time_modal';
import Action from 'components/drafts/draft_actions/action';
import DeleteScheduledPostModal
from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal';
import SendDraftModal from 'components/drafts/draft_actions/send_draft_modal';
import {ModalIdentifiers} from 'utils/constants';
import './style.scss';
const deleteTooltipText = (
<FormattedMessage
id='scheduled_post.action.delete'
defaultMessage='Delete scheduled post'
/>
);
const editTooltipText = (
<FormattedMessage
id='scheduled_post.action.edit'
defaultMessage='Edit scheduled post'
/>
);
const rescheduleTooltipText = (
<FormattedMessage
id='scheduled_post.action.reschedule'
defaultMessage='Reschedule post'
/>
);
const sendNowTooltipText = (
<FormattedMessage
id='scheduled_post.action.send_now'
defaultMessage='Send now'
/>
);
type Props = {
scheduledPost: ScheduledPost;
channelDisplayName: string;
onReschedule: (timestamp: number) => Promise<{error?: string}>;
onDelete: (scheduledPostId: string) => Promise<{error?: string}>;
onSend: (scheduledPostId: string) => void;
onEdit: () => void;
}
function ScheduledPostActions({scheduledPost, onReschedule, onDelete, channelDisplayName, onSend, onEdit}: Props) {
const dispatch = useDispatch();
const userTimezone = useSelector(getCurrentTimezone);
const handleReschedulePost = useCallback(() => {
const initialTime = moment.tz(scheduledPost.scheduled_at, userTimezone);
dispatch(openModal({
modalId: ModalIdentifiers.SCHEDULED_POST_CUSTOM_TIME_MODAL,
dialogType: ScheduledPostCustomTimeModal,
dialogProps: {
channelId: scheduledPost.channel_id,
onConfirm: onReschedule,
initialTime,
},
}));
}, [dispatch, onReschedule, scheduledPost.channel_id, scheduledPost.scheduled_at, userTimezone]);
const handleDelete = useCallback(() => {
dispatch(openModal({
modalId: ModalIdentifiers.DELETE_DRAFT,
dialogType: DeleteScheduledPostModal,
dialogProps: {
channelDisplayName,
onConfirm: () => onDelete(scheduledPost.id),
},
}));
}, [channelDisplayName, dispatch, onDelete, scheduledPost.id]);
const handleSend = useCallback(() => {
dispatch(openModal({
modalId: ModalIdentifiers.SEND_DRAFT,
dialogType: SendDraftModal,
dialogProps: {
displayName: channelDisplayName,
onConfirm: () => onSend(scheduledPost.id),
},
}));
}, [channelDisplayName, dispatch, onSend, scheduledPost.id]);
return (
<div className='ScheduledPostActions'>
<Action
icon='icon-trash-can-outline'
id='delete'
name='delete'
tooltipText={deleteTooltipText}
onClick={handleDelete}
/>
{
!scheduledPost.error_code && (
<React.Fragment>
<Action
icon='icon-pencil-outline'
id='edit'
name='edit'
tooltipText={editTooltipText}
onClick={onEdit}
/>
<Action
icon='icon-clock-send-outline'
id='reschedule'
name='reschedule'
tooltipText={rescheduleTooltipText}
onClick={handleReschedulePost}
/>
<Action
icon='icon-send-outline'
id='sendNow'
name='sendNow'
tooltipText={sendNowTooltipText}
onClick={handleSend}
/>
</React.Fragment>
)
}
</div>
);
}
export default memo(ScheduledPostActions);

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

@@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ScheduledPostActions {
display: inline-flex;
}

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

@@ -0,0 +1,56 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {useIntl} from 'react-intl';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import type {Draft} from 'selectors/drafts';
import DraftRow from 'components/drafts/draft_row';
import DraftsIllustration from 'components/drafts/drafts_illustration';
import NoResultsIndicator from 'components/no_results_indicator';
type Props = {
drafts: Draft[];
user: UserProfile;
displayName: string;
draftRemotes: Record<string, boolean>;
status: UserStatus['status'];
className?: string;
}
export default function DraftList({drafts, user, displayName, draftRemotes, status, className}: Props) {
const {formatMessage} = useIntl();
return (
<div className={classNames('DraftList Drafts__main', className)}>
{drafts.map((d) => (
<DraftRow
key={d.key}
displayName={displayName}
item={d.value}
isRemote={draftRemotes?.[d.key]}
user={user}
status={status}
/>
))}
{drafts.length === 0 && (
<NoResultsIndicator
expanded={true}
iconGraphic={DraftsIllustration}
title={formatMessage({
id: 'drafts.empty.title',
defaultMessage: 'No drafts at the moment',
})}
subtitle={formatMessage({
id: 'drafts.empty.subtitle',
defaultMessage: 'Any messages youve started will show here.',
})}
/>
)}
</div>
);
}

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

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.Panel {
.post--editing__wrapper {
margin: 16px;
}
}

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

@@ -2,26 +2,26 @@
// See LICENSE.txt for license information.
import {shallow} from 'enzyme';
import type {ComponentProps} from 'react';
import React from 'react';
import {Provider} from 'react-redux';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import type {Draft} from 'selectors/drafts';
import mockStore from 'tests/test_store';
import type {PostDraft} from 'types/store/draft';
import DraftRow from './draft_row';
describe('components/drafts/drafts_row', () => {
const baseProps = {
draft: {
type: 'channel',
} as Draft,
const baseProps: ComponentProps<typeof DraftRow> = {
item: {} as PostDraft,
user: {} as UserProfile,
status: {} as UserStatus['status'],
displayName: 'test',
isRemote: false,
};
it('should match snapshot for channel draft', () => {
@@ -42,10 +42,7 @@ describe('components/drafts/drafts_row', () => {
const props = {
...baseProps,
draft: {
...baseProps.draft,
type: 'thread',
} as Draft,
draft: {rootId: 'some_id'} as PostDraft,
};
const wrapper = shallow(

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

@@ -2,15 +2,18 @@
// See LICENSE.txt for license information.
import noop from 'lodash/noop';
import React, {memo, useCallback, useMemo, useEffect, useState} from 'react';
import React, {memo, useCallback, useMemo, useEffect, useState, useRef} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useHistory} from 'react-router-dom';
import type {ServerError} from '@mattermost/types/errors';
import type {FileInfo} from '@mattermost/types/files';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import {getPost as getPostAction} from 'mattermost-redux/actions/posts';
import {deleteScheduledPost, updateScheduledPost} from 'mattermost-redux/actions/scheduled_posts';
import {Permissions} from 'mattermost-redux/constants';
import {makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
@@ -19,52 +22,70 @@ import {haveIChannelPermission} from 'mattermost-redux/selectors/entities/roles'
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {makeGetThreadOrSynthetic} from 'mattermost-redux/selectors/entities/threads';
import type {SubmitPostReturnType} from 'actions/views/create_comment';
import {removeDraft} from 'actions/views/drafts';
import {selectPostById} from 'actions/views/rhs';
import type {Draft} from 'selectors/drafts';
import {getConnectionId} from 'selectors/general';
import {getChannelURL} from 'selectors/urls';
import usePriority from 'components/advanced_text_editor/use_priority';
import useSubmit from 'components/advanced_text_editor/use_submit';
import {useScrollOnRender} from 'components/common/hooks/use_scroll_on_render';
import ScheduledPostActions from 'components/drafts/draft_actions/schedule_post_actions/scheduled_post_actions';
import PlaceholderScheduledPostsTitle
from 'components/drafts/placeholder_scheduled_post_title/placeholder_scheduled_posts_title';
import EditPost from 'components/edit_post';
import Constants, {StoragePrefixes} from 'utils/constants';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
import {scheduledPostToPostDraft} from 'types/store/draft';
import DraftActions from './draft_actions';
import DraftTitle from './draft_title';
import Panel from './panel/panel';
import PanelBody from './panel/panel_body';
import Header from './panel/panel_header';
import {getErrorStringFromCode} from './utils';
import './draft_row.scss';
type Props = {
user: UserProfile;
status: UserStatus['status'];
displayName: string;
draft: Draft;
item: PostDraft | ScheduledPost;
isRemote?: boolean;
scrollIntoView?: boolean;
}
const mockLastBlurAt = {current: 0};
function DraftRow({
draft,
item,
user,
status,
displayName,
isRemote,
scrollIntoView,
}: Props) {
const [isEditing, setIsEditing] = useState(false);
const isScheduledPost = 'scheduled_at' in item;
const intl = useIntl();
const rootId = draft.value.rootId;
const channelId = draft.value.channelId;
const rootId = ('rootId' in item) ? item.rootId : item.root_id;
const channelId = ('channelId' in item) ? item.channelId : item.channel_id;
const [serverError, setServerError] = useState<(ServerError & { submittedMessage?: string }) | null>(null);
const history = useHistory();
const dispatch = useDispatch();
const getChannel = useMemo(() => makeGetChannel(), []);
const getChannelSelector = useMemo(() => makeGetChannel(), []);
const channel = useSelector((state: GlobalState) => getChannelSelector(state, channelId));
const getThreadOrSynthetic = useMemo(() => makeGetThreadOrSynthetic(), []);
const rootPostDeleted = useSelector((state: GlobalState) => {
@@ -77,16 +98,24 @@ function DraftRow({
const tooLong = useSelector((state: GlobalState) => {
const maxPostSize = parseInt(getConfig(state).MaxPostSize || '', 10) || Constants.DEFAULT_CHARACTER_LIMIT;
return draft.value.message.length > maxPostSize;
return item.message.length > maxPostSize;
});
const readOnly = !useSelector((state: GlobalState) => {
const channel = getChannel(state, channelId);
return channel ? haveIChannelPermission(state, channel.team_id, channel.id, Permissions.CREATE_POST) : false;
});
const connectionId = useSelector(getConnectionId);
let postError = '';
if (rootPostDeleted) {
if (isScheduledPost) {
// This is applicable only for scheduled post.
if (item.error_code) {
postError = getErrorStringFromCode(intl, item.error_code);
postError = getErrorStringFromCode(intl, item.error_code);
}
} else if (rootPostDeleted) {
postError = intl.formatMessage({id: 'drafts.error.post_not_found', defaultMessage: 'Thread not found'});
} else if (tooLong) {
postError = intl.formatMessage({id: 'drafts.error.too_long', defaultMessage: 'Message too long'});
@@ -97,17 +126,19 @@ function DraftRow({
const canSend = !postError;
const canEdit = !(rootPostDeleted || readOnly);
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
const channelUrl = useSelector((state: GlobalState) => {
if (!channel) {
return '';
}
const teamId = getCurrentTeamId(state);
return getChannelURL(state, channel, teamId);
});
const goToMessage = useCallback(async () => {
if (isEditing) {
return;
}
if (rootId) {
if (rootPostDeleted) {
return;
@@ -116,25 +147,10 @@ function DraftRow({
return;
}
history.push(channelUrl);
}, [channelUrl, dispatch, history, rootId, rootPostDeleted]);
}, [channelUrl, dispatch, history, rootId, rootPostDeleted, isEditing]);
const {onSubmitCheck: prioritySubmitCheck} = usePriority(draft.value, noop, noop, false);
const [handleOnSend] = useSubmit(
draft.value,
postError,
channelId,
rootId,
serverError,
mockLastBlurAt,
noop,
setServerError,
noop,
noop,
prioritySubmitCheck,
goToMessage,
undefined,
true,
);
const isBeingScheduled = useRef(false);
const isScheduledPostBeingSent = useRef(false);
const thread = useSelector((state: GlobalState) => {
if (!rootId) {
@@ -156,59 +172,229 @@ function DraftRow({
dispatch(removeDraft(key, channelId, rootId));
}, [dispatch, channelId, rootId]);
const afterSubmit = useCallback((response: SubmitPostReturnType) => {
// if draft was being scheduled, delete the draft after it's been scheduled
if (isBeingScheduled.current && response.created && !response.error) {
handleOnDelete();
isBeingScheduled.current = false;
}
// if scheduled posts was being sent, delete the scheduled post after it's been sent
if (isScheduledPostBeingSent.current && response.created && !response.error) {
dispatch(deleteScheduledPost((item as ScheduledPost).id, connectionId));
isScheduledPostBeingSent.current = false;
}
}, [connectionId, dispatch, handleOnDelete, item]);
// TODO LOL verify the types and handled it better
const {onSubmitCheck: prioritySubmitCheck} = usePriority(item as any, noop, noop, false);
const [handleOnSend] = useSubmit(
item as any,
postError,
channelId,
rootId,
serverError,
mockLastBlurAt,
noop,
setServerError,
noop,
noop,
prioritySubmitCheck,
goToMessage,
afterSubmit,
true,
);
const onScheduleDraft = useCallback(async (scheduledAt: number): Promise<{error?: string}> => {
isBeingScheduled.current = true;
await handleOnSend(item as PostDraft, {scheduled_at: scheduledAt});
return Promise.resolve({});
}, [item, handleOnSend]);
const draftActions = useMemo(() => {
if (!channel) {
return null;
}
return (
<DraftActions
channelDisplayName={channel.display_name}
channelName={channel.name}
channelType={channel.type}
channelId={channel.id}
userId={user.id}
onDelete={handleOnDelete}
onEdit={goToMessage}
onSend={handleOnSend}
canEdit={canEdit}
canSend={canSend}
onSchedule={onScheduleDraft}
/>
);
}, [
canEdit,
canSend,
channel,
goToMessage,
handleOnDelete,
handleOnSend,
user.id,
onScheduleDraft,
]);
const handleCancelEdit = useCallback(() => {
setIsEditing(false);
}, []);
const handleSchedulePostOnReschedule = useCallback(async (updatedScheduledAtTime: number) => {
handleCancelEdit();
const updatedScheduledPost: ScheduledPost = {
...(item as ScheduledPost),
scheduled_at: updatedScheduledAtTime,
};
const result = await dispatch(updateScheduledPost(updatedScheduledPost, connectionId));
return {
error: result.error?.message,
};
}, [connectionId, dispatch, item, handleCancelEdit]);
const handleSchedulePostOnDelete = useCallback(async () => {
handleCancelEdit();
const scheduledPostId = (item as ScheduledPost).id;
const result = await dispatch(deleteScheduledPost(scheduledPostId, connectionId));
return {
error: result.error?.message,
};
}, [item, dispatch, connectionId, handleCancelEdit]);
const handleSchedulePostEdit = useCallback(() => {
setIsEditing((isEditing) => !isEditing);
}, []);
const handleScheduledPostOnSend = useCallback(() => {
handleCancelEdit();
isScheduledPostBeingSent.current = true;
const postDraft = scheduledPostToPostDraft(item as ScheduledPost);
handleOnSend(postDraft, undefined, {keepDraft: true});
return Promise.resolve({});
}, [handleOnSend, item, handleCancelEdit]);
const scheduledPostActions = useMemo(() => {
if (!channel) {
return null;
}
return (
<ScheduledPostActions
scheduledPost={item as ScheduledPost}
channelDisplayName={channel.display_name}
onReschedule={handleSchedulePostOnReschedule}
onDelete={handleSchedulePostOnDelete}
onSend={handleScheduledPostOnSend}
onEdit={handleSchedulePostEdit}
/>
);
}, [
channel,
handleSchedulePostOnDelete,
handleSchedulePostOnReschedule,
handleScheduledPostOnSend,
handleSchedulePostEdit,
item,
]);
useEffect(() => {
if (rootId && !thread?.id) {
dispatch(getPostAction(rootId));
}
}, [thread?.id]);
}, [thread?.id, rootId]);
if (!channel) {
const alertRef = useScrollOnRender();
if (!channel && !isScheduledPost) {
return null;
}
let timestamp: number;
let fileInfos: FileInfo[];
let uploadsInProgress: string[];
let actions: React.ReactNode;
if (isScheduledPost) {
timestamp = item.scheduled_at;
fileInfos = item.metadata?.files || [];
uploadsInProgress = [];
actions = scheduledPostActions;
} else {
timestamp = item.updateAt;
fileInfos = item.fileInfos;
uploadsInProgress = item.uploadsInProgress;
actions = draftActions;
}
let title: React.ReactNode;
if (channel) {
title = (
<DraftTitle
type={(rootId ? 'thread' : 'channel')}
channel={channel}
userId={user.id}
/>
);
} else {
title = (
<PlaceholderScheduledPostsTitle
type={(rootId ? 'thread' : 'channel')}
/>
);
}
return (
<Panel
onClick={goToMessage}
hasError={Boolean(postError)}
innerRef={scrollIntoView ? alertRef : undefined}
isHighlighted={scrollIntoView}
>
{({hover}) => (
<>
<Header
kind={isScheduledPost ? 'scheduledPost' : 'draft'}
hover={hover}
actions={(
<DraftActions
channelDisplayName={channel.display_name}
channelName={channel.name}
channelType={channel.type}
userId={user.id}
onDelete={handleOnDelete}
onEdit={goToMessage}
onSend={handleOnSend}
canEdit={canEdit}
canSend={canSend}
/>
)}
title={(
<DraftTitle
type={draft.type}
channel={channel}
userId={user.id}
/>
)}
timestamp={draft.value.updateAt}
actions={actions}
title={title}
timestamp={timestamp}
remote={isRemote || false}
error={postError || serverError?.message}
/>
<PanelBody
channelId={channel.id}
displayName={displayName}
fileInfos={draft.value.fileInfos}
message={draft.value.message}
status={status}
uploadsInProgress={draft.value.uploadsInProgress}
userId={user.id}
username={user.username}
/>
{
isEditing &&
<EditPost
scheduledPost={item as ScheduledPost}
onCancel={handleCancelEdit}
afterSave={handleCancelEdit}
onDeleteScheduledPost={handleSchedulePostOnDelete}
/>
}
{
!isEditing &&
<PanelBody
channelId={channel?.id}
displayName={displayName}
fileInfos={fileInfos}
message={item.message}
status={status}
priority={rootId ? undefined : item.metadata?.priority}
uploadsInProgress={uploadsInProgress}
userId={user.id}
username={user.username}
/>
}
</>
)}
</Panel>

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

@@ -20,7 +20,6 @@
height: 100%;
flex-flow: column nowrap;
padding: 24px;
grid-area: list;
}
display: grid;
@@ -37,6 +36,46 @@
margin-bottom: 20px;
}
#draft_tabs {
grid-area: list;
.nav.nav-tabs {
.drafts_tab {
.badge {
position: relative;
top: -2px;
display: inline-block;
height: 14px;
margin-left: 6px;
background: rgba(var(--center-channel-color-rgb), 0.2);
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 11px;
}
&.active {
.badge {
background: rgba(var(--button-bg-rgb), 0.08);
color: var(--button-bg);
}
}
}
}
.tab-content {
height: 100%;
div[role="tabpanel"] {
position: relative;
height: 100%;
}
}
}
span.MuiBadge-badge {
position: relative;
top: -2px;
}
@media screen and (max-width: 768px) {
grid-template-rows: 0 1fr;

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

@@ -1,26 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useEffect} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {Badge} from '@mui/base';
import React, {memo, useCallback, useEffect, useMemo} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {type match, useHistory, useRouteMatch} from 'react-router-dom';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import {
isScheduledPostsEnabled,
makeGetScheduledPostsByTeam,
} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {selectLhsItem} from 'actions/views/lhs';
import {suppressRHS, unsuppressRHS} from 'actions/views/rhs';
import type {Draft} from 'selectors/drafts';
import NoResultsIndicator from 'components/no_results_indicator';
import DraftList from 'components/drafts/draft_list/draft_list';
import ScheduledPostList from 'components/drafts/scheduled_post_list/scheduled_post_list';
import Tab from 'components/tabs/tab';
import Tabs from 'components/tabs/tabs';
import Header from 'components/widgets/header';
import {SCHEDULED_POST_URL_SUFFIX} from 'utils/constants';
import type {GlobalState} from 'types/store';
import {LhsItemType, LhsPage} from 'types/store/lhs';
import DraftRow from './draft_row';
import DraftsIllustration from './drafts_illustration';
import './drafts.scss';
const EMPTY_LIST: ScheduledPost[] = [];
type Props = {
drafts: Draft[];
user: UserProfile;
@@ -37,7 +51,17 @@ function Drafts({
user,
}: Props) {
const dispatch = useDispatch();
const {formatMessage} = useIntl();
const history = useHistory();
const match: match<{team: string}> = useRouteMatch();
const isDraftsTab = useRouteMatch('/:team/drafts');
const isScheduledPostsTab = useRouteMatch('/:team/' + SCHEDULED_POST_URL_SUFFIX);
const currentTeamId = useSelector(getCurrentTeamId);
const getScheduledPostsByTeam = makeGetScheduledPostsByTeam();
const scheduledPosts = useSelector((state: GlobalState) => getScheduledPostsByTeam(state, currentTeamId, true));
const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled);
useEffect(() => {
dispatch(selectLhsItem(LhsItemType.Page, LhsPage.Drafts));
@@ -46,8 +70,74 @@ function Drafts({
return () => {
dispatch(unsuppressRHS);
};
}, [dispatch]);
const handleSwitchTabs = useCallback((key) => {
if (key === 0 && isScheduledPostsTab) {
history.push(`/${match.params.team}/drafts`);
} else if (key === 1 && isDraftsTab) {
history.push(`/${match.params.team}/scheduled_posts`);
}
}, [history, isDraftsTab, isScheduledPostsTab, match]);
const scheduledPostsTabHeading = useMemo(() => {
return (
<div className='drafts_tab_title'>
<FormattedMessage
id='schedule_post.tab.heading'
defaultMessage='Scheduled'
/>
{
scheduledPosts?.length > 0 &&
<Badge
className='badge'
badgeContent={scheduledPosts.length}
/>
}
</div>
);
}, [scheduledPosts?.length]);
const draftTabHeading = useMemo(() => {
return (
<div className='drafts_tab_title'>
<FormattedMessage
id='drafts.heading'
defaultMessage='Drafts'
/>
{
drafts.length > 0 &&
<Badge
className='badge'
badgeContent={drafts.length}
/>
}
</div>
);
}, [drafts?.length]);
const heading = useMemo(() => {
return (
<FormattedMessage
id='drafts.heading'
defaultMessage='Drafts'
/>
);
}, []);
const subtitle = useMemo(() => {
return (
<FormattedMessage
id={'drafts.subtitle'}
defaultMessage={'Any messages you\'ve started will show here'}
/>
);
}, []);
const activeTab = isDraftsTab ? 0 : 1;
return (
<div
id='app-content'
@@ -56,41 +146,60 @@ function Drafts({
<Header
level={2}
className='Drafts__header'
heading={formatMessage({
id: 'drafts.heading',
defaultMessage: 'Drafts',
})}
subtitle={formatMessage({
id: 'drafts.subtitle',
defaultMessage: 'Any messages you\'ve started will show here',
})}
heading={heading}
subtitle={subtitle}
/>
<div className='Drafts__main'>
{drafts.map((d) => (
<DraftRow
key={d.key}
displayName={displayName}
draft={d}
isRemote={draftRemotes?.[d.key]}
user={user}
status={status}
/>
))}
{drafts.length === 0 && (
<NoResultsIndicator
expanded={true}
iconGraphic={DraftsIllustration}
title={formatMessage({
id: 'drafts.empty.title',
defaultMessage: 'No drafts at the moment',
})}
subtitle={formatMessage({
id: 'drafts.empty.subtitle',
defaultMessage: 'Any messages youve started will show here.',
})}
/>
)}
</div>
{
isScheduledPostEnabled &&
<Tabs
id='draft_tabs'
activeKey={activeTab}
mountOnEnter={true}
unmountOnExit={false}
onSelect={handleSwitchTabs}
>
<Tab
eventKey={0}
title={draftTabHeading}
unmountOnExit={false}
tabClassName='drafts_tab'
>
<DraftList
drafts={drafts}
user={user}
displayName={displayName}
draftRemotes={draftRemotes}
status={status}
/>
</Tab>
<Tab
eventKey={1}
title={scheduledPostsTabHeading}
unmountOnExit={false}
tabClassName='drafts_tab'
>
<ScheduledPostList
scheduledPosts={scheduledPosts || EMPTY_LIST}
user={user}
displayName={displayName}
status={status}
/>
</Tab>
</Tabs>
}
{
!isScheduledPostEnabled &&
<DraftList
drafts={drafts}
user={user}
displayName={displayName}
draftRemotes={draftRemotes}
status={status}
/>
}
</div>
);
}

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

@@ -19,6 +19,10 @@
.badge {
background: transparent;
color: var(--sidebar-text);
.icon.icon-draft-indicator {
font-size: 14px !important;
}
}
&:hover {
@@ -29,9 +33,38 @@
visibility: visible;
}
}
#unreadMentions {
color: rgba(var(--sidebar-text-rgb), 0.64);
margin-inline: 0;
&:not(.urgent) {
padding-inline: 0;
}
&.scheduledPostBadge {
margin-left: 8px;
padding-block: 2px;
&.urgent {
color: var(--sidebar-text);
}
.unreadMentions {
position: relative;
top: -0.5px;
}
}
}
&.active #unreadMentions {
color: var(--sidebar-text);
}
}
}
// legacy
.sidebar--left .SidebarDrafts {
padding-bottom: 0;

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

@@ -1,12 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {memo, useEffect} from 'react';
import classNames from 'classnames';
import React, {memo, useEffect, useRef} from 'react';
import {FormattedMessage} from 'react-intl';
import {useSelector, useDispatch} from 'react-redux';
import {NavLink, useRouteMatch} from 'react-router-dom';
import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts';
import {syncedDraftsAreAllowedAndEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {
getScheduledPostsByTeamCount, hasScheduledPostError, isScheduledPostsEnabled,
} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {getDrafts} from 'actions/views/drafts';
@@ -15,27 +20,68 @@ import {makeGetDraftsCount} from 'selectors/drafts';
import DraftsTourTip from 'components/drafts/drafts_link/drafts_tour_tip/drafts_tour_tip';
import ChannelMentionBadge from 'components/sidebar/sidebar_channel/channel_mention_badge';
import './drafts_link.scss';
import {SCHEDULED_POST_URL_SUFFIX} from 'utils/constants';
import type {GlobalState} from 'types/store';
const getDraftsCount = makeGetDraftsCount();
import './drafts_link.scss';
const scheduleIcon = (
<i
data-testid='scheduledPostIcon'
className='icon icon-draft-indicator icon-clock-send-outline'
/>
);
const pencilIcon = (
<i
data-testid='draftIcon'
className='icon icon-draft-indicator icon-pencil-outline'
/>
);
function DraftsLink() {
const dispatch = useDispatch();
const initialScheduledPostsLoaded = useRef(false);
const syncedDraftsAllowedAndEnabled = useSelector(syncedDraftsAreAllowedAndEnabled);
const count = useSelector(getDraftsCount);
const draftCount = useSelector(getDraftsCount);
const teamId = useSelector(getCurrentTeamId);
const teamScheduledPostCount = useSelector((state: GlobalState) => getScheduledPostsByTeamCount(state, teamId, true));
const isScheduledPostEnabled = useSelector(isScheduledPostsEnabled);
const hasDrafts = draftCount > 0;
const hasScheduledPosts = teamScheduledPostCount > 0;
const itemsExist = hasDrafts || (isScheduledPostEnabled && hasScheduledPosts);
const scheduledPostsHasError = useSelector((state: GlobalState) => hasScheduledPostError(state, teamId));
const {url} = useRouteMatch();
const isDraftUrlMatch = useRouteMatch('/:team/drafts');
const isScheduledPostUrlMatch = useRouteMatch('/:team/' + SCHEDULED_POST_URL_SUFFIX);
const urlMatches = isDraftUrlMatch || isScheduledPostUrlMatch;
useEffect(() => {
if (syncedDraftsAllowedAndEnabled) {
dispatch(getDrafts(teamId));
}
}, [teamId, syncedDraftsAllowedAndEnabled]);
}, [teamId, syncedDraftsAllowedAndEnabled, dispatch]);
if (!count && !isDraftUrlMatch) {
useEffect(() => {
const loadDMsAndGMs = !initialScheduledPostsLoaded.current;
if (isScheduledPostEnabled) {
dispatch(fetchTeamScheduledPosts(teamId, loadDMsAndGMs));
}
initialScheduledPostsLoaded.current = true;
}, [dispatch, isScheduledPostEnabled, teamId]);
if (!itemsExist && !urlMatches) {
return null;
}
@@ -54,10 +100,7 @@ function DraftsLink() {
className='SidebarLink sidebar-item'
tabIndex={0}
>
<i
data-testid='draftIcon'
className='icon icon-pencil-outline'
/>
{pencilIcon}
<div className='SidebarChannelLinkLabel_wrapper'>
<span className='SidebarChannelLinkLabel sidebar-item__name'>
<FormattedMessage
@@ -66,9 +109,23 @@ function DraftsLink() {
/>
</span>
</div>
{count > 0 && (
<ChannelMentionBadge unreadMentions={count}/>
)}
{
draftCount > 0 &&
<ChannelMentionBadge
unreadMentions={draftCount}
icon={pencilIcon}
/>
}
{
isScheduledPostEnabled && teamScheduledPostCount > 0 &&
<ChannelMentionBadge
unreadMentions={teamScheduledPostCount}
icon={scheduleIcon}
className={classNames('scheduledPostBadge', {persistent: scheduledPostsHasError})}
hasUrgent={scheduledPostsHasError}
/>
}
</NavLink>
<DraftsTourTip/>
</li>

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

@@ -12,6 +12,8 @@ type Props = {
children: ({hover}: {hover: boolean}) => React.ReactNode;
onClick: () => void;
hasError: boolean;
innerRef?: React.Ref<HTMLElement>;
isHighlighted?: boolean;
};
const isEligibleForClick = makeIsEligibleForClick('.hljs, code');
@@ -20,6 +22,8 @@ function Panel({
children,
onClick,
hasError,
innerRef,
isHighlighted,
}: Props) {
const [hover, setHover] = useState(false);
@@ -43,12 +47,14 @@ function Panel({
'Panel',
{
draftError: hasError,
highlighted: isHighlighted,
},
)}
onMouseOver={handleMouseOver}
onClick={handleOnClick}
onMouseLeave={handleMouseLeave}
role='button'
ref={innerRef}
>
{children({hover})}
</article>

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

@@ -21,7 +21,7 @@ import type {PostDraft} from 'types/store/draft';
import './panel_body.scss';
type Props = {
channelId: string;
channelId?: string;
displayName: string;
fileInfos: PostDraft['fileInfos'];
message: string;

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

@@ -9,12 +9,14 @@ import WithTooltip from 'components/with_tooltip';
import PanelHeader from './panel_header';
describe('components/drafts/panel/panel_header', () => {
const baseProps = {
const baseProps: React.ComponentProps<typeof PanelHeader> = {
kind: 'draft' as const,
actions: <div>{'actions'}</div>,
hover: false,
timestamp: 12345,
remote: false,
title: <div>{'title'}</div>,
error: undefined,
};
it('should match snapshot', () => {

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

@@ -2,17 +2,18 @@
// See LICENSE.txt for license information.
import cn from 'classnames';
import React from 'react';
import React, {useMemo} from 'react';
import type {ComponentProps} from 'react';
import {FormattedMessage} from 'react-intl';
import {SyncIcon} from '@mattermost/compass-icons/components';
import Timestamp from 'components/timestamp';
import Timestamp, {RelativeRanges} from 'components/timestamp';
import Tag from 'components/widgets/tag/tag';
import WithTooltip from 'components/with_tooltip';
import './panel_header.scss';
import {isToday} from 'utils/datetime';
const TIMESTAMP_PROPS: Partial<ComponentProps<typeof Timestamp>> = {
day: 'numeric',
@@ -21,7 +22,16 @@ const TIMESTAMP_PROPS: Partial<ComponentProps<typeof Timestamp>> = {
units: ['now', 'minute', 'hour', 'day', 'week', 'month', 'year'],
};
export const SCHEDULED_POST_TIME_RANGES = [
RelativeRanges.TODAY_TITLE_CASE,
RelativeRanges.YESTERDAY_TITLE_CASE,
RelativeRanges.TOMORROW_TITLE_CASE,
];
export const scheduledPostTimeFormat: ComponentProps<typeof Timestamp>['useTime'] = (_, {hour, minute}) => ({hour, minute});
type Props = {
kind: 'draft' | 'scheduledPost';
actions: React.ReactNode;
hover: boolean;
timestamp: number;
@@ -31,6 +41,7 @@ type Props = {
};
function PanelHeader({
kind,
actions,
hover,
timestamp,
@@ -38,6 +49,8 @@ function PanelHeader({
title,
error,
}: Props) {
const timestampDateObject = useMemo(() => new Date(timestamp), [timestamp]);
return (
<header className='PanelHeader'>
<div className='PanelHeader__left'>{title}</div>
@@ -63,14 +76,37 @@ function PanelHeader({
</div>
)}
<div className='PanelHeader__timestamp'>
{Boolean(timestamp) && (
<Timestamp
value={new Date(timestamp)}
{...TIMESTAMP_PROPS}
/>
)}
{
Boolean(timestamp) && kind === 'draft' && (
<Timestamp
value={new Date(timestamp)}
{...TIMESTAMP_PROPS}
/>
)
}
{
Boolean(timestamp) && kind === 'scheduledPost' && (
<FormattedMessage
id='scheduled_post.panel.header.time'
defaultMessage='Send {isTodayOrTomorrow, select, true {} other {on}} {scheduledDateTime}'
values={{
scheduledDateTime: (
<Timestamp
value={timestamp}
ranges={SCHEDULED_POST_TIME_RANGES}
useSemanticOutput={false}
useTime={scheduledPostTimeFormat}
/>
),
isTodayOrTomorrow: isToday(timestampDateObject),
}}
/>
)
}
</div>
{!error && (
{kind === 'draft' && !error && (
<Tag
variant={'danger'}
uppercase={true}

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

@@ -0,0 +1,43 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
type Props = {
type: 'channel' | 'thread';
}
export default function PlaceholderScheduledPostsTitle({type}: Props) {
let title;
const icon = (
<i
className='icon icon-pencil-outline'
/>
);
if (type === 'thread') {
title = (
<FormattedMessage
id='scheduled_posts.row_title_thread.placeholder'
defaultMessage={'Thread to: {icon} No Destination'}
values={{
icon,
}}
/>
);
} else {
title = (
<FormattedMessage
id='scheduled_posts.row_title_channel.placeholder'
defaultMessage={'In: {icon} No Destination'}
values={{
icon,
}}
/>
);
}
return title;
}

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

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
export default (
<svg
width='206'
height='167'
viewBox='0 0 206 167'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g clipPath='url(#clip0_4287_66385)'>
<path
d='M146.419 0.368244H46.4124C44.5797 0.362044 42.7637 0.716143 41.0681 1.41031C39.3725 2.10449 37.8305 3.12513 36.5302 4.41399C35.2299 5.70285 34.1967 7.23467 33.4896 8.922C32.7825 10.6093 32.4154 12.4191 32.4092 14.248V77.6732C32.4154 79.5021 32.7825 81.3119 33.4896 82.9992C34.1967 84.6866 35.2299 86.2184 36.5302 87.5072C37.8305 88.7961 39.3725 89.8168 41.0681 90.5109C42.7637 91.2051 44.5797 91.5592 46.4124 91.553H61.1713V115.295L83.3097 91.553H146.383C148.216 91.5592 150.032 91.2051 151.728 90.5109C153.423 89.8168 154.965 88.7961 156.266 87.5072C157.566 86.2184 158.599 84.6866 159.306 82.9992C160.013 81.3119 160.38 79.5021 160.387 77.6732V14.248C160.374 10.5605 158.897 7.02846 156.278 4.42651C153.66 1.82455 150.114 0.365103 146.419 0.368244Z'
fill='#FFBC1F'
/>
<path
d='M83.3096 91.553H146.383C148.216 91.5592 150.032 91.2051 151.728 90.5109C153.423 89.8167 154.965 88.7961 156.265 87.5072C157.566 86.2184 158.599 84.6865 159.306 82.9992C160.013 81.3119 160.38 79.5021 160.386 77.6732V39.168C160.386 39.168 155.982 74.7747 155.191 77.9088C154.4 81.043 152.83 85.7324 145.391 86.5101C137.953 87.2877 83.3096 91.553 83.3096 91.553Z'
fill='#CC8F00'
/>
<path
d='M62.2569 36.8936C64.0387 36.8936 65.7804 37.4208 67.2619 38.4086C68.7434 39.3965 69.8981 40.8005 70.58 42.4433C71.2618 44.086 71.4402 45.8936 71.0926 47.6375C70.745 49.3814 69.887 50.9833 68.6271 52.2405C67.3672 53.4978 65.762 54.354 64.0144 54.7009C62.2669 55.0478 60.4555 54.8698 58.8094 54.1893C57.1632 53.5089 55.7562 52.3566 54.7663 50.8782C53.7764 49.3998 53.2481 47.6617 53.2481 45.8836C53.2465 44.7026 53.4785 43.5328 53.9307 42.4414C54.3828 41.35 55.0464 40.3583 55.8832 39.5232C56.7201 38.6881 57.7138 38.026 58.8075 37.5747C59.9012 37.1235 61.0734 36.892 62.2569 36.8936Z'
fill='white'
/>
<path
d='M96.4161 36.8936C98.1979 36.8936 99.9396 37.4208 101.421 38.4086C102.903 39.3965 104.057 40.8005 104.739 42.4433C105.421 44.086 105.599 45.8936 105.252 47.6375C104.904 49.3814 104.046 50.9833 102.786 52.2405C101.526 53.4978 99.9212 54.354 98.1736 54.7009C96.4261 55.0478 94.6147 54.8698 92.9685 54.1893C91.3224 53.5089 89.9154 52.3566 88.9255 50.8782C87.9356 49.3998 87.4072 47.6617 87.4072 45.8836C87.4057 44.7026 87.6376 43.5328 88.0898 42.4414C88.542 41.35 89.2055 40.3583 90.0424 39.5232C90.8793 38.6881 91.873 38.026 92.9667 37.5747C94.0604 37.1235 95.2326 36.892 96.4161 36.8936Z'
fill='white'
/>
<path
d='M130.538 36.8936C132.32 36.8912 134.063 37.4165 135.546 38.4029C137.029 39.3893 138.186 40.7926 138.87 42.4351C139.553 44.0776 139.733 45.8856 139.387 47.6303C139.041 49.3749 138.184 50.9779 136.924 52.2364C135.665 53.4948 134.06 54.3522 132.312 54.7C130.564 55.0479 128.752 54.8705 127.105 54.1905C125.458 53.5104 124.051 52.3581 123.06 50.8794C122.07 49.4008 121.541 47.6622 121.541 45.8836C121.539 44.7036 121.771 43.5348 122.222 42.4442C122.674 41.3535 123.336 40.3623 124.172 39.5274C125.008 38.6924 126 38.0301 127.092 37.5781C128.185 37.1262 129.356 36.8936 130.538 36.8936Z'
fill='white'
/>
<path
d='M42.7877 26.9265C43.8039 23.3373 45.5215 19.9841 47.8417 17.06C50.162 14.136 53.0389 11.6989 56.3069 9.88902C56.5005 9.79136 56.6561 9.63239 56.7494 9.437C56.8427 9.2416 56.8685 9.02083 56.8225 8.80929C56.7766 8.59775 56.6617 8.40737 56.4957 8.26801C56.3298 8.12864 56.1222 8.04817 55.9055 8.03917C49.6005 7.66213 36.8724 9.00533 40.8632 26.8323C40.9053 27.0499 41.0193 27.2471 41.1871 27.3923C41.3548 27.5375 41.5666 27.6224 41.7884 27.6332C42.0102 27.6441 42.2293 27.5803 42.4105 27.4522C42.5917 27.3241 42.7245 27.139 42.7877 26.9265Z'
fill='#FFD470'
/>
<path
d='M143.323 125.892C133.808 125.702 125.916 122.444 119.649 116.118C113.382 109.793 110.104 101.914 109.816 92.4823C110.099 83.0454 113.377 75.1643 119.649 68.8388C125.921 62.5134 133.813 59.2554 143.323 59.0649C152.746 59.2603 160.591 62.5183 166.859 68.8388C173.126 75.1594 176.403 83.0405 176.691 92.4823C176.398 101.919 173.121 109.798 166.859 116.118C160.596 122.439 152.751 125.697 143.323 125.892Z'
fill='white'
/>
<path
d='M143.324 55.4165C153.815 55.6168 162.533 59.2166 169.478 66.2161C176.423 73.2156 180.042 81.9711 180.335 92.4825C180.042 102.984 176.423 111.737 169.478 118.742C162.533 125.746 153.815 129.346 143.324 129.541C132.74 129.341 123.985 125.741 117.059 118.742C110.134 111.742 106.517 102.989 106.21 92.4825C106.493 81.976 110.109 73.2205 117.059 66.2161C124.01 59.2118 132.764 55.6119 143.324 55.4165ZM121.449 70.6635C115.596 76.4516 112.585 83.7246 112.414 92.4825C112.604 101.231 115.616 108.501 121.449 114.294C127.282 120.087 134.566 123.128 143.302 123.416C151.949 123.123 159.187 120.082 165.015 114.294C170.844 108.506 173.853 101.236 174.043 92.4825C173.853 83.7295 170.844 76.4565 165.015 70.6635C159.187 64.8705 151.956 61.8299 143.324 61.5417C134.593 61.8347 127.302 64.8753 121.449 70.6635Z'
fill='#1E325C'
/>
<path
d='M142.875 95.2521C142.511 95.256 142.15 95.1871 141.812 95.0493C141.475 94.9115 141.169 94.7076 140.911 94.4497C140.654 94.1917 140.45 93.8848 140.312 93.547C140.175 93.2093 140.106 92.8474 140.11 92.4826V68.5461C140.098 68.1908 140.163 67.8371 140.3 67.5095C140.438 67.1818 140.645 66.8881 140.907 66.6484C141.428 66.1809 142.103 65.9224 142.802 65.9224C143.501 65.9224 144.176 66.1809 144.697 66.6484C144.96 66.8877 145.168 67.1812 145.307 67.5088C145.446 67.8364 145.512 68.1903 145.502 68.5461V92.4826C145.509 92.8477 145.442 93.2104 145.303 93.5482C145.165 93.8861 144.958 94.1917 144.697 94.4461C144.465 94.6989 144.184 94.901 143.87 95.0397C143.557 95.1783 143.218 95.2507 142.875 95.2521Z'
fill='#1E325C'
/>
<path
d='M161.76 84.0131C161.683 84.3559 161.532 84.6775 161.317 84.9549C161.102 85.2323 160.829 85.4586 160.516 85.6177L146.967 93.5012C146.766 94.2878 146.304 94.9828 145.657 95.4721C144.923 96.0238 144.021 96.3057 143.104 96.2708C142.616 96.2786 142.132 96.1857 141.681 95.9979C141.231 95.81 140.823 95.5313 140.485 95.1791C139.782 94.4567 139.389 93.4879 139.389 92.4792C139.389 91.4705 139.782 90.5017 140.485 89.7793C141.247 89.0315 142.258 88.5908 143.324 88.541C143.67 88.5499 144.013 88.5991 144.348 88.6876L157.751 80.9505C158.052 80.7639 158.389 80.6415 158.741 80.5911C159.092 80.5407 159.45 80.5633 159.792 80.6575C160.147 80.7378 160.482 80.8902 160.776 81.1054C161.07 81.3205 161.317 81.5938 161.502 81.9083C161.686 82.2228 161.804 82.5719 161.849 82.9339C161.893 83.2959 161.863 83.6632 161.76 84.0131Z'
fill='#1E325C'
/>
</g>
<defs>
<clipPath id='clip0_4287_66385'>
<rect
width='204.933'
height='204.933'
fill='white'
transform='translate(0.797852 -38.3296)'
/>
</clipPath>
</defs>
</svg>
);

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

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useRef} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import type {UserProfile, UserStatus} from '@mattermost/types/users';
import {fetchMissingChannels} from 'mattermost-redux/actions/channels';
import {hasScheduledPostError} from 'mattermost-redux/selectors/entities/scheduled_posts';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import AlertBanner from 'components/alert_banner';
import NoScheduledPostsIllustration from 'components/drafts/scheduled_post_list/empty_scheduled_post_list_illustration';
import NoResultsIndicator from 'components/no_results_indicator';
import {useQuery} from 'utils/http_utils';
import type {GlobalState} from 'types/store';
import DraftRow from '../draft_row';
import './style.scss';
type Props = {
scheduledPosts: ScheduledPost[];
user: UserProfile;
displayName: string;
status: UserStatus['status'];
}
export default function ScheduledPostList({
scheduledPosts,
user,
displayName,
status,
}: Props) {
const {formatMessage} = useIntl();
const currentTeamId = useSelector(getCurrentTeamId);
const scheduledPostsHasError = useSelector((state: GlobalState) => hasScheduledPostError(state, currentTeamId));
const query = useQuery();
const targetId = query.get('target_id');
const targetScheduledPostId = useRef<string>();
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchMissingChannels(scheduledPosts.map((post) => post.channel_id)));
}, []);
return (
<div className='ScheduledPostList'>
{
scheduledPostsHasError &&
<AlertBanner
mode='danger'
className='scheduledPostListErrorIndicator'
message={
<FormattedMessage
id='scheduled_post.panel.error_indicator.message'
defaultMessage='One of your scheduled drafts cannot be sent.'
/>
}
/>
}
{
scheduledPosts.map((scheduledPost) => {
// find the first scheduled posst with the target
const scrollIntoView = !targetScheduledPostId.current && (scheduledPost.channel_id === targetId || scheduledPost.root_id === targetId);
if (scrollIntoView) {
// if found, save the scheduled post's ID
targetScheduledPostId.current = scheduledPost.id;
}
return (
<DraftRow
key={scheduledPost.id}
item={scheduledPost}
displayName={displayName}
status={status}
user={user}
scrollIntoView={targetScheduledPostId.current === scheduledPost.id} // scroll into view if this is the target scheduled post
/>
);
})
}
{
scheduledPosts.length === 0 && (
<NoResultsIndicator
expanded={true}
iconGraphic={NoScheduledPostsIllustration}
title={formatMessage({
id: 'Schedule_post.empty_state.title',
defaultMessage: 'No scheduled drafts at the moment',
})}
subtitle={formatMessage({
id: 'Schedule_post.empty_state.subtitle',
defaultMessage: 'Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.',
})}
/>
)
}
</div>
);
}

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

@@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.ScheduledPostList {
position: absolute;
width: 100%;
height: 100%;
padding: 24px;
overflow-y: auto;
.scheduledPostListErrorIndicator {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 16px;
font-weight: bold;
padding-block: 10px;
}
.AlertBanner {
&__body {
color: rgba(var(--center-channel-color-rgb), 0.75);
}
}
@keyframes borderColorAnimation {
0% {
background: rgba(var(--mention-highlight-bg-rgb), 0.5);
}
100% {
background: initial;
}
}
.Panel.highlighted {
animation: borderColorAnimation 10s;
}
}

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

@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {IntlShape} from 'react-intl';
import {defineMessages} from 'react-intl';
import type {ScheduledPostErrorCode} from '@mattermost/types/schedule_post';
const errorCodeToErrorMessage = defineMessages<ScheduledPostErrorCode>({
unknown: {
id: 'scheduled_post.error_code.unknown_error',
defaultMessage: 'Unknown Error',
},
channel_archived: {
id: 'scheduled_post.error_code.channel_archived',
defaultMessage: 'Channel Archived',
},
channel_not_found: {
id: 'scheduled_post.error_code.channel_removed',
defaultMessage: 'Channel Removed',
},
user_missing: {
id: 'scheduled_post.error_code.user_missing',
defaultMessage: 'User Deleted',
},
user_deleted: {
id: 'scheduled_post.error_code.user_deleted',
defaultMessage: 'User Deleted',
},
no_channel_permission: {
id: 'scheduled_post.error_code.no_channel_permission',
defaultMessage: 'Missing Permission',
},
no_channel_member: {
id: 'scheduled_post.error_code.no_channel_member',
defaultMessage: 'Not In Channel',
},
thread_deleted: {
id: 'scheduled_post.error_code.thread_deleted',
defaultMessage: 'Thread Deleted',
},
unable_to_send: {
id: 'scheduled_post.error_code.unable_to_send',
defaultMessage: 'Unable to Send',
},
invalid_post: {
id: 'scheduled_post.error_code.invalid_post',
defaultMessage: 'Invalid Post',
},
});
export function getErrorStringFromCode(intl: IntlShape, errorCode: ScheduledPostErrorCode = 'unknown') {
return intl.formatMessage(errorCodeToErrorMessage[errorCode]).toUpperCase();
}

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

@@ -4,23 +4,32 @@
import classNames from 'classnames';
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {EmoticonPlusOutlineIcon, InformationOutlineIcon} from '@mattermost/compass-icons/components';
import type {Emoji} from '@mattermost/types/emojis';
import type {Post} from '@mattermost/types/posts';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import {scheduledPostToPost} from '@mattermost/types/schedule_post';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
import {openModal} from 'actions/views/modals';
import {getConnectionId} from 'selectors/general';
import DeletePostModal from 'components/delete_post_modal';
import DeleteScheduledPostModal
from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal';
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
import Textbox from 'components/textbox';
import type {TextboxClass, TextboxElement} from 'components/textbox';
import {AppEvents, Constants, ModalIdentifiers, StoragePrefixes} from 'utils/constants';
import * as Keyboard from 'utils/keyboard';
import {applyMarkdown} from 'utils/markdown/apply_markdown';
import type {ApplyMarkdownOptions} from 'utils/markdown/apply_markdown';
import {applyMarkdown} from 'utils/markdown/apply_markdown';
import {
formatGithubCodePaste,
formatMarkdownMessage,
@@ -32,24 +41,21 @@ import {postMessageOnKeyPress, splitMessageBasedOnCaretPosition} from 'utils/pos
import {allAtMentions} from 'utils/text_formatting';
import * as Utils from 'utils/utils';
import type {ModalData} from 'types/actions';
import type {GlobalState} from 'types/store';
import type {PostDraft} from 'types/store/draft';
import EditPostFooter from './edit_post_footer';
type DialogProps = {
post?: Post;
isRHS?: boolean;
};
import './style.scss';
export type Actions = {
addMessageIntoHistory: (message: string) => void;
editPost: (input: Partial<Post>) => Promise<Post>;
setDraft: (name: string, value: PostDraft | null) => void;
unsetEditingPost: () => void;
openModal: (input: ModalData<DialogProps>) => void;
scrollPostListToBottom: () => void;
runMessageWillBeUpdatedHooks: (newPost: Partial<Post>, oldPost: Post) => Promise<ActionResult>;
updateScheduledPost: (scheduledPost: ScheduledPost, connectionId: string) => Promise<ActionResult>;
}
export type Props = {
@@ -77,6 +83,10 @@ export type Props = {
isRHSOpened: boolean;
isEditHistoryShowing: boolean;
actions: Actions;
scheduledPost?: ScheduledPost;
afterSave?: () => void;
onCancel?: () => void;
onDeleteScheduledPost?: () => Promise<{error?: string}>;
};
export type State = {
@@ -95,9 +105,14 @@ const {KeyCodes} = Constants;
const TOP_OFFSET = 0;
const RIGHT_OFFSET = 10;
const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, ...rest}: Props): JSX.Element | null => {
const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, scheduledPost, afterSave, onCancel, onDeleteScheduledPost, ...rest}: Props): JSX.Element | null => {
const connectionId = useSelector(getConnectionId);
const channel = useSelector((state: GlobalState) => getChannel(state, channelId));
const dispatch = useDispatch();
const [editText, setEditText] = useState<string>(
draft.message || editingPost?.post?.message_source || editingPost?.post?.message || '',
draft.message || editingPost?.post?.message_source || editingPost?.post?.message || scheduledPost?.message || '',
);
const [selectionRange, setSelectionRange] = useState<State['selectionRange']>({start: editText.length, end: editText.length});
const caretPosition = useRef<number>(editText.length);
@@ -117,7 +132,8 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
const draftRef = useRef<PostDraft>(draft);
const saveDraftFrame = useRef<number|null>();
const draftStorageId = `${StoragePrefixes.EDIT_DRAFT}${editingPost.postId}`;
const id = scheduledPost ? scheduledPost.id : editingPost.postId;
const draftStorageId = `${StoragePrefixes.EDIT_DRAFT}${id}`;
const {formatMessage} = useIntl();
@@ -141,9 +157,11 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
actions.setDraft(draftStorageId, draftRef.current);
}, Constants.SAVE_DRAFT_TIMEOUT);
const isMentions = allAtMentions(editText).length > 0;
setShowMentionHelper(isMentions);
}, [actions, draftStorageId, editText]);
if (!scheduledPost) {
const isMentions = allAtMentions(editText).length > 0;
setShowMentionHelper(isMentions);
}
}, [actions, draftStorageId, editText, scheduledPost]);
useEffect(() => {
const focusTextBox = () => textboxRef?.current?.focus();
@@ -247,6 +265,11 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
};
const handleEdit = async () => {
if (scheduledPost) {
await handleEditScheduledPost();
return;
}
if (!editingPost.post || isSaveDisabled()) {
return;
}
@@ -291,15 +314,98 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
},
};
actions.openModal(deletePostModalData);
dispatch(openModal(deletePostModalData));
return;
}
await actions.editPost(updatedPost as Post);
handleAutomatedRefocusAndExit();
afterSave?.();
};
const handleCancel = useCallback(() => {
onCancel?.();
handleAutomatedRefocusAndExit();
}, [onCancel, handleAutomatedRefocusAndExit]);
const handleEditScheduledPost = useCallback(async () => {
if (!scheduledPost || isSaveDisabled() || !channel || !onDeleteScheduledPost) {
return;
}
const post = scheduledPostToPost(scheduledPost);
let updatedPost = {
message: editText,
id: scheduledPost.id,
channel_id: scheduledPost?.channel_id,
};
const hookResult = await actions.runMessageWillBeUpdatedHooks(updatedPost, post);
if (hookResult.error && hookResult.error.message) {
setPostError(<>{hookResult.error.message}</>);
return;
}
updatedPost = hookResult.data;
if (postError) {
setErrorClass('animation--highlight');
setTimeout(() => setErrorClass(''), Constants.ANIMATION_TIMEOUT);
return;
}
if (updatedPost.message === post.message) {
handleAutomatedRefocusAndExit();
return;
}
const hasAttachment = Boolean(
scheduledPost.file_ids && scheduledPost.file_ids.length > 0,
);
if (updatedPost.message.trim().length === 0 && !hasAttachment) {
handleRefocusAndExit(null);
const deleteScheduledPostModalData = {
modalId: ModalIdentifiers.DELETE_DRAFT,
dialogType: DeleteScheduledPostModal,
dialogProps: {
channelDisplayName: channel.display_name,
onConfirm: onDeleteScheduledPost,
},
};
dispatch(openModal(deleteScheduledPostModalData));
return;
}
const updatedScheduledPost = {
...scheduledPost,
message: updatedPost.message,
};
const response = await actions.updateScheduledPost(updatedScheduledPost, connectionId);
if (response.error) {
setPostError(response.error.message);
} else {
handleAutomatedRefocusAndExit();
afterSave?.();
}
}, [
actions,
connectionId,
editText,
handleAutomatedRefocusAndExit,
handleRefocusAndExit,
isSaveDisabled,
postError,
scheduledPost,
afterSave,
channel,
onDeleteScheduledPost,
]);
const handleEditKeyPress = (e: React.KeyboardEvent) => {
const {ctrlSend, codeBlockOnCtrlEnter} = rest;
const inputBox = textboxRef.current?.getInputBox();
@@ -348,6 +454,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
} else if (ctrlEnterKeyCombo) {
handleEdit();
} else if (Keyboard.isKeyPressed(e, KeyCodes.ESCAPE) && !showEmojiPicker) {
onCancel?.();
handleAutomatedRefocusAndExit();
} else if (ctrlAltCombo && markdownLinkKey) {
applyHotkeyMarkdown({
@@ -550,7 +657,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
}
<EditPostFooter
onSave={handleEdit}
onCancel={handleAutomatedRefocusAndExit}
onCancel={handleCancel}
/>
{postError && (
<div className={classNames('edit-post-footer', {'has-error': postError})}>

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

@@ -5,7 +5,10 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import {addMessageIntoHistory} from 'mattermost-redux/actions/posts';
import {updateScheduledPost} from 'mattermost-redux/actions/scheduled_posts';
import {Preferences, Permissions} from 'mattermost-redux/constants';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
@@ -18,7 +21,6 @@ import {runMessageWillBeUpdatedHooks} from 'actions/hooks';
import {unsetEditingPost} from 'actions/post_actions';
import {setGlobalItem} from 'actions/storage';
import {scrollPostListToBottom} from 'actions/views/channel';
import {openModal} from 'actions/views/modals';
import {editPost} from 'actions/views/posts';
import {getEditingPost} from 'selectors/posts';
import {getIsRhsOpen, getPostDraft, getRhsState} from 'selectors/rhs';
@@ -29,13 +31,29 @@ import type {GlobalState} from 'types/store';
import EditPost from './edit_post';
function mapStateToProps(state: GlobalState) {
type Props = {
scheduledPost?: ScheduledPost;
}
function mapStateToProps(state: GlobalState, props: Props) {
const config = getConfig(state);
const editingPost = getEditingPost(state);
let editingPost;
let channelId: string;
let draft;
if (props.scheduledPost) {
editingPost = {post: null};
channelId = props.scheduledPost.channel_id;
draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, props.scheduledPost.id);
} else {
editingPost = getEditingPost(state);
channelId = editingPost.post.channel_id;
draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, editingPost.postId);
}
const currentUserId = getCurrentUserId(state);
const channelId = editingPost.post.channel_id;
const teamId = getCurrentTeamId(state);
const draft = getPostDraft(state, StoragePrefixes.EDIT_DRAFT, editingPost.postId);
const isAuthor = editingPost?.post?.user_id === currentUserId;
const deletePermission = isAuthor ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS;
@@ -59,6 +77,7 @@ function mapStateToProps(state: GlobalState) {
useChannelMentions,
isRHSOpened: getIsRhsOpen(state),
isEditHistoryShowing: getRhsState(state) === RHSStates.EDIT_HISTORY,
scheduledPost: props.scheduledPost,
};
}
@@ -70,8 +89,8 @@ function mapDispatchToProps(dispatch: Dispatch) {
editPost,
setDraft: setGlobalItem,
unsetEditingPost,
openModal,
runMessageWillBeUpdatedHooks,
updateScheduledPost,
}, dispatch),
};
}

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

@@ -0,0 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
.post--editing__wrapper {
position: relative;
textarea, #edit_textbox_placeholder {
padding: 13px 16px 12px 16px;
padding-right: 50px;
}
.edit-post-footer {
&.has-error {
color: rgb(var(--dnd-indicator-rgb));
}
}
}

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

@@ -73,6 +73,9 @@ interface Props {
menuButtonTooltip?: MenuButtonTooltipProps;
menu: MenuProps;
children: ReactNode[];
// Use MUI Anchor Playgroup to try various anchorOrigin
// and transformOrigin values - https://mui.com/material-ui/react-popover/#anchor-playground
anchorOrigin?: {
vertical: VerticalOrigin;
horizontal: HorizontalOrigin;
@@ -81,6 +84,7 @@ interface Props {
vertical: VerticalOrigin;
horizontal: HorizontalOrigin;
};
hideTooltipWhenDisabled?: boolean;
}
/**
@@ -205,7 +209,7 @@ export function Menu(props: Props) {
id={props.menuButtonTooltip.id}
title={props.menuButtonTooltip.text}
placement={props?.menuButtonTooltip?.placement ?? 'top'}
disabled={isMenuOpen}
disabled={props.hideTooltipWhenDisabled ? props.menuButton.disabled || isMenuOpen : isMenuOpen}
>
{triggerElement}
</WithTooltip>

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

@@ -42,14 +42,18 @@ exports[`components/marketplace/ doesn't show web marketplace banner in FeatureF
closeLabel="Close"
>
<div
className="GenericModal__header"
className="GenericModal__header__text_container"
>
<h1
className="modal-title"
id="genericModalLabel"
<div
className="GenericModal__header"
>
App Marketplace
</h1>
<h1
className="modal-title"
id="genericModalLabel"
>
App Marketplace
</h1>
</div>
</div>
</ModalHeader>
<ModalBody
@@ -128,14 +132,18 @@ exports[`components/marketplace/ hides search, shows web marketplace banner in F
closeLabel="Close"
>
<div
className="GenericModal__header"
className="GenericModal__header__text_container"
>
<h1
className="modal-title"
id="genericModalLabel"
<div
className="GenericModal__header"
>
App Marketplace
</h1>
<h1
className="modal-title"
id="genericModalLabel"
>
App Marketplace
</h1>
</div>
</div>
</ModalHeader>
<ModalBody
@@ -215,35 +223,39 @@ exports[`components/marketplace/ should render default 1`] = `
closeLabel="Close"
>
<div
className="GenericModal__header"
className="GenericModal__header__text_container"
>
<h1
className="modal-title"
id="genericModalLabel"
<div
className="GenericModal__header"
>
App Marketplace
</h1>
<h1
className="modal-title"
id="genericModalLabel"
>
App Marketplace
</h1>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</ModalHeader>
<ModalBody
bsClass="modal-body"
@@ -353,35 +365,39 @@ exports[`components/marketplace/ should render with error banner 1`] = `
closeLabel="Close"
>
<div
className="GenericModal__header"
className="GenericModal__header__text_container"
>
<h1
className="modal-title"
id="genericModalLabel"
<div
className="GenericModal__header"
>
App Marketplace
</h1>
<h1
className="modal-title"
id="genericModalLabel"
>
App Marketplace
</h1>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</ModalHeader>
<ModalBody
bsClass="modal-body"
@@ -508,35 +524,39 @@ exports[`components/marketplace/ should render with no plugins available 1`] = `
closeLabel="Close"
>
<div
className="GenericModal__header"
className="GenericModal__header__text_container"
>
<h1
className="modal-title"
id="genericModalLabel"
<div
className="GenericModal__header"
>
App Marketplace
</h1>
<h1
className="modal-title"
id="genericModalLabel"
>
App Marketplace
</h1>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</ModalHeader>
<ModalBody
bsClass="modal-body"
@@ -654,35 +674,39 @@ exports[`components/marketplace/ should render with plugins available 1`] = `
closeLabel="Close"
>
<div
className="GenericModal__header"
className="GenericModal__header__text_container"
>
<h1
className="modal-title"
id="genericModalLabel"
<div
className="GenericModal__header"
>
App Marketplace
</h1>
<h1
className="modal-title"
id="genericModalLabel"
>
App Marketplace
</h1>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</ModalHeader>
<ModalBody
bsClass="modal-body"
@@ -818,35 +842,39 @@ exports[`components/marketplace/ should render with plugins installed 1`] = `
closeLabel="Close"
>
<div
className="GenericModal__header"
className="GenericModal__header__text_container"
>
<h1
className="modal-title"
id="genericModalLabel"
<div
className="GenericModal__header"
>
App Marketplace
</h1>
<h1
className="modal-title"
id="genericModalLabel"
>
App Marketplace
</h1>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</div>
<ForwardRef
autoFocus={true}
clearable={true}
containerClassName="marketplace-modal-search"
id="searchMarketplaceTextbox"
inputClassName="search_input"
inputPrefix={
<MagnifyIcon
size={24}
/>
}
inputSize="large"
name="searchMarketplaceTextbox"
onChange={[Function]}
onClear={[Function]}
placeholder="Search marketplace"
type="text"
useLegend={false}
value=""
/>
</ModalHeader>
<ModalBody
bsClass="modal-body"

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

@@ -8,8 +8,6 @@ import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {addPostReminder} from 'mattermost-redux/actions/posts';
import {Preferences} from 'mattermost-redux/constants';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
@@ -22,12 +20,10 @@ const PostReminderCustomTimePicker = makeAsyncComponent('PostReminderCustomTimeP
function mapStateToProps(state: GlobalState) {
const timezone = getCurrentTimezone(state);
const userId = getCurrentUserId(state);
const isMilitaryTime = getBool(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, false);
return {
userId,
timezone,
isMilitaryTime,
};
}

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

@@ -1,20 +1,17 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import type {Moment} from 'moment-timezone';
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {GenericModal} from '@mattermost/components';
import {getRoundedTime} from 'components/custom_status/date_time_input';
import DateTimePickerModal from 'components/date_time_picker_modal/date_time_picker_modal';
import DateTimeInput, {getRoundedTime} from 'components/custom_status/date_time_input';
import Constants from 'utils/constants';
import {toUTCUnix} from 'utils/datetime';
import {isKeyPressed} from 'utils/keyboard';
import {getCurrentMomentForTimezone} from 'utils/timezone';
import type {PropsFromRedux} from './index';
import './post_reminder_custom_time_picker_modal.scss';
type Props = PropsFromRedux & {
onExited: () => void;
@@ -25,63 +22,27 @@ type Props = PropsFromRedux & {
};
function PostReminderCustomTimePicker({userId, timezone, onExited, postId, actions}: Props) {
const {formatMessage} = useIntl();
const ariaLabel = formatMessage({id: 'post_reminder_custom_time_picker_modal.defaultMsg', defaultMessage: 'Set a reminder'});
const header = formatMessage({id: 'post_reminder.custom_time_picker_modal.header', defaultMessage: 'Set a reminder'});
const confirmButtonText = formatMessage({id: 'post_reminder.custom_time_picker_modal.submit_button', defaultMessage: 'Set reminder'});
const currentTime = getCurrentMomentForTimezone(timezone);
const initialReminderTime = getRoundedTime(currentTime);
const [customReminderTime, setCustomReminderTime] = useState(initialReminderTime);
const handleConfirm = useCallback(() => {
actions.addPostReminder(userId, postId, toUTCUnix(customReminderTime.toDate()));
}, [customReminderTime]);
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
const {formatMessage} = useIntl();
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (isKeyPressed(event, Constants.KeyCodes.ESCAPE) && !isDatePickerOpen) {
onExited();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isDatePickerOpen]);
const handleConfirm = useCallback((dateTime: Moment) => {
actions.addPostReminder(userId, postId, toUTCUnix(dateTime.toDate()));
}, [actions, postId, userId]);
return (
<GenericModal
id='PostReminderCustomTimePickerModal'
ariaLabel={formatMessage({id: 'post_reminder_custom_time_picker_modal.defaultMsg', defaultMessage: 'Set a reminder'})}
<DateTimePickerModal
onExited={onExited}
modalHeaderText={(
<FormattedMessage
id='post_reminder.custom_time_picker_modal.header'
defaultMessage='Set a reminder'
/>
)}
confirmButtonText={(
<FormattedMessage
id='post_reminder.custom_time_picker_modal.submit_button'
defaultMessage='Set reminder'
/>
)}
handleConfirm={handleConfirm}
handleEnterKeyPress={handleConfirm}
className={'post-reminder-modal'}
compassDesign={true}
keyboardEscape={false}
>
<DateTimeInput
time={customReminderTime}
handleChange={setCustomReminderTime}
timezone={timezone}
setIsDatePickerOpen={setIsDatePickerOpen}
/>
</GenericModal>
ariaLabel={ariaLabel}
header={header}
initialTime={initialReminderTime}
onConfirm={handleConfirm}
confirmButtonText={confirmButtonText}
/>
);
}

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

@@ -32,7 +32,7 @@ import SidebarMobileRightMenu from 'components/sidebar_mobile_right_menu';
import webSocketClient from 'client/web_websocket_client';
import {initializePlugins} from 'plugins';
import A11yController from 'utils/a11y_controller';
import {PageLoadContext} from 'utils/constants';
import {PageLoadContext, SCHEDULED_POST_URL_SUFFIX} from 'utils/constants';
import DesktopApp from 'utils/desktop_api';
import {EmojiIndicesByAlias} from 'utils/emoji';
import {TEAM_NAME_PATH_PATTERN} from 'utils/path';
@@ -580,7 +580,7 @@ export default class Root extends React.PureComponent<Props, State> {
export function doesRouteBelongToTeamControllerRoutes(pathname: RouteComponentProps['location']['pathname']): boolean {
// Note: we have specifically added admin_console to the negative lookahead as admin_console can have integrations as subpaths (admin_console/integrations/bot_accounts)
// and we don't want to treat those as team controller routes.
const TEAM_CONTROLLER_PATH_PATTERN = /^\/(?!admin_console)([a-z0-9\-_]+)\/(channels|messages|threads|drafts|integrations|emoji)(\/.*)?$/;
const TEAM_CONTROLLER_PATH_PATTERN = new RegExp(`^/(?!admin_console)([a-z0-9\\-_]+)/(channels|messages|threads|drafts|integrations|emoji|${SCHEDULED_POST_URL_SUFFIX})(/.*)?$`);
return TEAM_CONTROLLER_PATH_PATTERN.test(pathname);
}

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

@@ -7,16 +7,21 @@ import React from 'react';
type Props = {
unreadMentions: number;
hasUrgent?: boolean;
icon?: React.ReactNode;
className?: string;
};
export default function ChannelMentionBadge({unreadMentions, hasUrgent}: Props) {
export default function ChannelMentionBadge({unreadMentions, hasUrgent, icon, className}: Props) {
if (unreadMentions > 0) {
return (
<span
id='unreadMentions'
className={classNames({badge: true, urgent: hasUrgent})}
className={classNames({badge: true, urgent: hasUrgent}, className)}
>
{unreadMentions}
{icon}
<span className='unreadMentions'>
{unreadMentions}
</span>
</span>
);
}

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

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Tab as ReactBootstrapTab} from 'react-bootstrap';
type Props = {
children?: React.ReactNode;
eventKey?: any;
title?: React.ReactNode | undefined;
unmountOnExit?: boolean | undefined;
tabClassName?: string | undefined;
}
export default function Tab({children, title, unmountOnExit, tabClassName, eventKey}: Props) {
return (
<ReactBootstrapTab
eventKey={eventKey}
title={title}
unmountOnExit={unmountOnExit}
tabClassName={tabClassName}
>
{children}
</ReactBootstrapTab>
);
}

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

@@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import {Tabs as ReactBootstrapTabs} from 'react-bootstrap';
import type {SelectCallback} from 'react-bootstrap';
import './style.scss';
type Props = {
children?: React.ReactNode;
id?: string;
activeKey?: any;
mountOnEnter?: boolean;
unmountOnExit?: boolean;
onSelect?: SelectCallback;
className?: string;
}
export default function Tabs({
children,
id,
activeKey,
unmountOnExit,
onSelect,
className,
mountOnEnter,
}: Props) {
return (
<ReactBootstrapTabs
id={id}
activeKey={activeKey}
unmountOnExit={unmountOnExit}
onSelect={onSelect}
className={classNames('tabs', className)}
mountOnEnter={mountOnEnter}
>
{children}
</ReactBootstrapTabs>
);
}

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

@@ -0,0 +1,49 @@
.tabs {
display: flex;
width: 100%;
flex-direction: column;
margin: 0;
.nav-tabs {
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
margin: 0;
padding-inline: 12px;
li {
margin-right: 0;
a {
padding: 13px 10px;
border: none;
background: transparent;
color: rgba(var(--center-channel-color-rgb), 0.75);
font-size: 14px;
font-weight: 600;
line-height: 20px;
transition: all 0.15s ease;
&:hover,
&:active,
&:focus,
&:focus-within {
border: none;
border-radius: none;
background: transparent;
color: var(--center-channel-color);
}
}
&.active {
border-bottom: 2px solid var(--denim-button-bg);
a {
color: var(--denim-button-bg);
}
}
&:not(:first-child) {
margin-left: 8px;
}
}
}
}

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

@@ -1993,6 +1993,8 @@
"admin.posts.persistentNotificationsMaxRecipients.title": "Maximum number of recipients for persistent notifications",
"admin.posts.postPriority.desc": "When enabled, users can configure a visual indicator to communicate messages that are important or urgent. Learn more about message priority in our <link>documentation</link>.",
"admin.posts.postPriority.title": "Message Priority",
"admin.posts.scheduledPosts.description": "When enabled, users can schedule and send messages in the future.",
"admin.posts.scheduledPosts.title": "Scheduled Posts",
"admin.privacy.showEmailDescription": "When false, hides the email address of members from everyone except System Administrators and the System Roles with read/write access to Compliance, Billing, or User Management.",
"admin.privacy.showEmailTitle": "Show Email Address: ",
"admin.privacy.showFullNameDescription": "When false, hides the full name of members from everyone except System Administrators. Username is shown in place of full name.",
@@ -3448,6 +3450,13 @@
"create_group_memberships_modal.create": "Yes",
"create_group_memberships_modal.desc": "You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time.",
"create_group_memberships_modal.title": "Re-add {username} to teams and channels",
"create_post_button.option.schedule_message": "Schedule message",
"create_post_button.option.schedule_message.options.choose_custom_time": "Choose a custom time",
"create_post_button.option.schedule_message.options.header": "Schedule message",
"create_post_button.option.schedule_message.options.monday": "Monday at {9amTime}",
"create_post_button.option.schedule_message.options.next_monday": "Next Monday at {9amTime}",
"create_post_button.option.schedule_message.options.tomorrow": "Tomorrow at {9amTime}",
"create_post_button.option.send_now": "Send Now",
"create_post.dm_or_gm_remote": "Direct Messages and Group Messages with remote users are not supported.",
"create_post.error_message": "Your message is too long. Character count: {length}/{limit}",
"create_post.file_limit_sticky_banner.admin_message": "New uploads will automatically archive older files. To view them again, you can delete older files or <a>upgrade to a paid plan.</a>",
@@ -3455,7 +3464,6 @@
"create_post.file_limit_sticky_banner.non_admin_message": "New uploads will automatically archive older files. To view them again, <a>notify your admin to upgrade to a paid plan.</a>",
"create_post.file_limit_sticky_banner.snooze_tooltip": "Snooze for {snoozeDays} days",
"create_post.fileProcessing": "Processing...",
"create_post.icon": "Create a post",
"create_post.prewritten.custom": "Custom message...",
"create_post.prewritten.tip.dm_hello": "Oh hello",
"create_post.prewritten.tip.dm_hello_message": ":v: Oh hello",
@@ -3470,7 +3478,6 @@
"create_post.prewritten.tip.team_hi": "Hi team!",
"create_post.prewritten.tip.team_hi_message": ":wave: Hi team!",
"create_post.read_only": "This channel is read-only. Only members with permission can post here.",
"create_post.send_message": "Send a message",
"create_post.shortcutsNotSupported": "Keyboard shortcuts are not supported on your device.",
"create_post.write": "Write to {channelDisplayName}",
"create_team.createTeamRestricted.message": "Your workspace plan has reached the limit on the number of teams. Create unlimited teams with a free 30-day trial. Contact your System Administrator.",
@@ -3575,6 +3582,7 @@
"dnd_custom_time_picker_modal.time": "Time",
"drafts.actions.delete": "Delete draft",
"drafts.actions.edit": "Edit draft",
"drafts.actions.scheduled": "Schedule draft",
"drafts.actions.send": "Send draft",
"drafts.confirm.delete.button": "Yes, delete",
"drafts.confirm.delete.text": "Are you sure you want to delete this draft to <strong>{displayName}</strong>?",
@@ -4842,6 +4850,37 @@
"saveChangesPanel.save": "Save",
"saveChangesPanel.saved": "Settings saved",
"saveChangesPanel.tryAgain": "Try again",
"schedule_post.custom_time_modal.cancel_button_text": "Cancel",
"schedule_post.custom_time_modal.confirm_button_text": "Schedule",
"schedule_post.custom_time_modal.dm_user_time": "{dmUserTime} for {dmUserName}",
"schedule_post.custom_time_modal.title": "Schedule message",
"Schedule_post.empty_state.subtitle": "Schedule drafts to send messages at a later time. Any scheduled drafts will show up here and can be modified after being scheduled.",
"Schedule_post.empty_state.title": "No scheduled drafts at the moment",
"schedule_post.tab.heading": "Scheduled",
"scheduled_post.action.delete": "Delete scheduled post",
"scheduled_post.action.edit": "Edit scheduled post",
"scheduled_post.action.reschedule": "Reschedule post",
"scheduled_post.action.send_now": "Send now",
"scheduled_post.channel_indicator.link_to_scheduled_posts.text": "See all scheduled messages",
"scheduled_post.channel_indicator.multiple": "You have {count} scheduled messages.",
"scheduled_post.channel_indicator.single": "Message scheduled for {dateTime}.",
"scheduled_post.channel_indicator.with_other_user_late_time": "You have {count, plural, =1 {one} other {#}} <a>scheduled {count, plural, =1 {message} other {messages}}</a>.",
"scheduled_post.delete_modal.body": "Are you sure you want to delete this scheduled post to <strong>{displayName}</strong>?",
"scheduled_post.delete_modal.title": "Delete scheduled post",
"scheduled_post.error_code.channel_archived": "Channel Archived",
"scheduled_post.error_code.channel_removed": "Channel Removed",
"scheduled_post.error_code.invalid_post": "Invalid Post",
"scheduled_post.error_code.no_channel_member": "Not In Channel",
"scheduled_post.error_code.no_channel_permission": "Missing Permission",
"scheduled_post.error_code.thread_deleted": "Thread Deleted",
"scheduled_post.error_code.unable_to_send": "Unable to Send",
"scheduled_post.error_code.unknown_error": "Unknown Error",
"scheduled_post.error_code.user_deleted": "User Deleted",
"scheduled_post.error_code.user_missing": "User Deleted",
"scheduled_post.panel.error_indicator.message": "One of your scheduled drafts cannot be sent.",
"scheduled_post.panel.header.time": "Send {isTodayOrTomorrow, select, true {} other {on}} {scheduledDateTime}",
"scheduled_posts.row_title_channel.placeholder": "In: {icon} No Destination",
"scheduled_posts.row_title_thread.placeholder": "Thread to: {icon} No Destination",
"search_bar.channels": "Channels",
"search_bar.clear": "Clear",
"search_bar.file_types": "File types",
@@ -4962,6 +5001,7 @@
"shortcuts.files.upload.mac": "Upload files:\t⌘|U",
"shortcuts.generic.alt": "Alt",
"shortcuts.generic.ctrl": "Ctrl",
"shortcuts.generic.enter": "Enter",
"shortcuts.generic.shift": "Shift",
"shortcuts.header": "Keyboard shortcuts\tCtrl|/",
"shortcuts.header.mac": "Keyboard shortcuts\t⌘|/",

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

@@ -24,6 +24,7 @@ import PostTypes from './posts';
import PreferenceTypes from './preferences';
import RoleTypes from './roles';
import SchemeTypes from './schemes';
import ScheduledPostTypes from './scheudled_posts';
import SearchTypes from './search';
import TeamTypes from './teams';
import ThreadTypes from './threads';
@@ -57,4 +58,5 @@ export {
DraftTypes,
PlaybookType,
ChannelBookmarkTypes,
ScheduledPostTypes,
};

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

@@ -0,0 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from 'key-mirror';
export default keyMirror({
SCHEDULED_POSTS_RECEIVED: null,
SINGLE_SCHEDULED_POST_RECEIVED: null,
SCHEDULED_POST_UPDATED: null,
SCHEDULED_POST_DELETED: null,
});

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

@@ -35,6 +35,7 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import type {GetStateFunc, ActionFunc, ActionFuncAsync} from 'mattermost-redux/types/actions';
import {getChannelByName} from 'mattermost-redux/utils/channel_utils';
import {DelayedDataLoader} from 'mattermost-redux/utils/data_loader';
import {addChannelToInitialCategory, addChannelToCategory} from './channel_categories';
import {logError} from './errors';
@@ -1426,6 +1427,32 @@ export function getChannelMemberCountsByGroup(channelId: string) {
});
}
export function fetchMissingChannels(channelIDs: string[]): ActionFuncAsync<Array<Channel['id']>> {
return async (dispatch, getState, {loaders}: any) => {
if (!loaders.missingChannelLoader) {
loaders.missingChannelLoader = new DelayedDataLoader<Channel['id']>({
fetchBatch: (channelIDs) => {
return channelIDs.length ? dispatch(getChannel(channelIDs[0])) : Promise.resolve();
},
maxBatchSize: 1,
wait: 100,
});
}
const state = getState();
const missingChannelIDs = channelIDs.filter((channelId) => !getChannelSelector(state, channelId));
if (missingChannelIDs.length > 0) {
const loader = loaders.missingChannelLoader as DelayedDataLoader<Channel['id']>;
loader.queue(missingChannelIDs);
}
return {
data: missingChannelIDs,
};
};
}
export default {
selectChannel,
createChannel,

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

@@ -172,6 +172,7 @@ export function getPost(postId: string): ActionFuncAsync<Post> {
export type CreatePostReturnType = {
created?: boolean;
pending?: string;
error?: string;
}
export function createPost(

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

@@ -0,0 +1,96 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import {ScheduledPostTypes} from 'mattermost-redux/action_types';
import {logError} from 'mattermost-redux/actions/errors';
import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers';
import {Client4} from 'mattermost-redux/client';
import type {DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions';
export function createSchedulePost(schedulePost: ScheduledPost, teamId: string, connectionId: string) {
return async (dispatch: DispatchFunc) => {
try {
const createdPost = await Client4.createScheduledPost(schedulePost, connectionId);
dispatch({
type: ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED,
data: {
scheduledPost: createdPost.data,
teamId,
},
});
return {data: createdPost};
} catch (error) {
return {
error,
};
}
};
}
export function fetchTeamScheduledPosts(teamId: string, includeDirectChannels: boolean) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
let scheduledPosts;
try {
scheduledPosts = await Client4.getScheduledPosts(teamId, includeDirectChannels);
dispatch({
type: ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED,
data: {
scheduledPostsByTeamId: scheduledPosts.data,
},
});
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
}
return {data: scheduledPosts};
};
}
export function updateScheduledPost(scheduledPost: ScheduledPost, connectionId: string) {
return async (dispatch: DispatchFunc) => {
try {
const updatedScheduledPost = await Client4.updateScheduledPost(scheduledPost, connectionId);
dispatch({
type: ScheduledPostTypes.SCHEDULED_POST_UPDATED,
data: {
scheduledPost: updatedScheduledPost.data,
},
});
return {data: updatedScheduledPost};
} catch (error) {
return {
error,
};
}
};
}
export function deleteScheduledPost(scheduledPostId: string, connectionId: string) {
return async (dispatch: DispatchFunc) => {
try {
const deletedScheduledPost = await Client4.deleteScheduledPost(scheduledPostId, connectionId);
dispatch({
type: ScheduledPostTypes.SCHEDULED_POST_DELETED,
data: {
scheduledPost: deletedScheduledPost.data,
},
});
return {data: deletedScheduledPost};
} catch (error) {
return {
error,
};
}
};
}

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

@@ -21,6 +21,7 @@ import limits from './limits';
import posts from './posts';
import preferences from './preferences';
import roles from './roles';
import scheduledPosts from './scheduled_posts';
import schemes from './schemes';
import search from './search';
import teams from './teams';
@@ -55,4 +56,5 @@ export default combineReducers({
usage,
hostedCustomer,
channelBookmarks,
scheduledPosts,
});

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

@@ -0,0 +1,244 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AnyAction} from 'redux';
import {combineReducers} from 'redux';
import type {ScheduledPost, ScheduledPostsState} from '@mattermost/types/schedule_post';
import {ScheduledPostTypes, UserTypes} from 'mattermost-redux/action_types';
function byId(state: ScheduledPostsState['byId'] = {}, action: AnyAction) {
switch (action.type) {
case ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED: {
const {scheduledPostsByTeamId} = action.data;
const newState = {...state};
Object.keys(scheduledPostsByTeamId).forEach((teamId: string) => {
if (scheduledPostsByTeamId.hasOwnProperty(teamId)) {
scheduledPostsByTeamId[teamId].forEach((scheduledPost: ScheduledPost) => {
newState[scheduledPost.id] = scheduledPost;
});
}
});
return newState;
}
case ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED: {
const scheduledPost = action.data.scheduledPost;
return {
...state,
[scheduledPost.id]: scheduledPost,
};
}
case ScheduledPostTypes.SCHEDULED_POST_UPDATED: {
const scheduledPost = action.data.scheduledPost;
return {
...state,
[scheduledPost.id]: scheduledPost,
};
}
case ScheduledPostTypes.SCHEDULED_POST_DELETED: {
const scheduledPost = action.data.scheduledPost;
const newState = {...state};
delete newState[scheduledPost.id];
return newState;
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function byTeamId(state: ScheduledPostsState['byTeamId'] = {}, action: AnyAction) {
switch (action.type) {
case ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED: {
const {scheduledPostsByTeamId} = action.data;
const newState = {...state};
Object.keys(scheduledPostsByTeamId).forEach((teamId: string) => {
if (scheduledPostsByTeamId.hasOwnProperty(teamId)) {
newState[teamId] = scheduledPostsByTeamId[teamId].map((scheduledPost: ScheduledPost) => scheduledPost.id);
}
});
return newState;
}
case ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED: {
const scheduledPost = action.data.scheduledPost as ScheduledPost;
const teamId = action.data.teamId || 'directChannels';
const newState = {...state};
const existingIndex = newState[teamId].findIndex((existingScheduledPostId) => existingScheduledPostId === scheduledPost.id);
if (existingIndex >= 0) {
newState[teamId].splice(existingIndex, 1);
}
if (newState[teamId]) {
newState[teamId] = [...newState[teamId], scheduledPost.id];
} else {
newState[teamId] = [scheduledPost.id];
}
return newState;
}
case ScheduledPostTypes.SCHEDULED_POST_DELETED: {
const scheduledPost = action.data.scheduledPost as ScheduledPost;
const newState = {...state};
let modified = false;
for (const teamId of Object.keys(state)) {
const index = newState[teamId].findIndex((existingScheduledPostId) => existingScheduledPostId === scheduledPost.id);
if (index >= 0) {
newState[teamId] = [...newState[teamId]];
newState[teamId].splice(index, 1);
modified = true;
break;
}
}
return modified ? newState : state;
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function errorsByTeamId(state: ScheduledPostsState['errorsByTeamId'] = {}, action: AnyAction) {
switch (action.type) {
case ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED: {
const {scheduledPostsByTeamId} = action.data;
const newState = {...state};
Object.keys(scheduledPostsByTeamId).forEach((teamId: string) => {
if (scheduledPostsByTeamId.hasOwnProperty(teamId)) {
const teamScheduledPosts = scheduledPostsByTeamId[teamId] as ScheduledPost[];
newState[teamId] = teamScheduledPosts.filter((scheduledPost) => scheduledPost.error_code).map((scheduledPost) => scheduledPost.id);
}
});
return newState;
}
case ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED: {
let changed = false;
const teamId = action.data.teamId || 'directChannels';
const newState = {...state};
if (!newState[teamId]) {
newState[teamId] = [];
}
const scheduledPost = action.data.scheduledPost as ScheduledPost;
if (scheduledPost.error_code) {
const alreadyExists = newState[teamId].find((scheduledPostId) => scheduledPostId === scheduledPost.id);
if (!alreadyExists) {
newState[teamId] = [...newState[teamId], scheduledPost.id];
changed = true;
}
}
return changed ? newState : state;
}
case ScheduledPostTypes.SCHEDULED_POST_DELETED: {
let changed = false;
const scheduledPost = action.data.scheduledPost as ScheduledPost;
const newState = {...state};
for (const teamId of Object.keys(state)) {
const index = newState[teamId].findIndex((scheduledPostId) => scheduledPostId === scheduledPost.id);
if (index >= 0) {
changed = true;
newState[teamId] = [...newState[teamId]];
newState[teamId].splice(index, 1);
break;
}
}
return changed ? newState : state;
}
case UserTypes.LOGOUT_SUCCESS: {
return {};
}
default:
return state;
}
}
function byChannelOrThreadId(state: ScheduledPostsState['byChannelOrThreadId'] = {}, action: AnyAction) {
switch (action.type) {
case ScheduledPostTypes.SCHEDULED_POSTS_RECEIVED: {
const {scheduledPostsByTeamId} = action.data;
const newState = {...state};
Object.keys(scheduledPostsByTeamId).forEach((teamId: string) => {
if (scheduledPostsByTeamId.hasOwnProperty(teamId)) {
scheduledPostsByTeamId[teamId].forEach((scheduledPost: ScheduledPost) => {
const id = scheduledPost.root_id || scheduledPost.channel_id;
if (newState[id]) {
newState[id].push(scheduledPost.id);
} else {
newState[id] = [scheduledPost.id];
}
});
}
});
return newState;
}
case ScheduledPostTypes.SINGLE_SCHEDULED_POST_RECEIVED: {
const scheduledPost = action.data.scheduledPost;
const newState = {...state};
const id = scheduledPost.root_id || scheduledPost.channel_id;
if (!newState[id]) {
newState[id] = [scheduledPost.id];
return newState;
}
let changed = false;
const existingIndex = newState[id].findIndex((scheduledPostId) => scheduledPostId === scheduledPost.id);
if (existingIndex) {
newState[id] = [...newState[id], scheduledPost.id];
changed = true;
}
return changed ? newState : state;
}
case ScheduledPostTypes.SCHEDULED_POST_DELETED: {
const scheduledPost = action.data.scheduledPost;
const id = scheduledPost.root_id || scheduledPost.channel_id;
if (!state[id]) {
return state;
}
const newState = {...state};
const index = newState[id].findIndex((scheduledPostId) => scheduledPostId === scheduledPost.id);
newState[id] = [...newState[id]];
newState[id].splice(index, 1);
return newState;
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
export default combineReducers({
byId,
byTeamId,
byChannelOrThreadId,
errorsByTeamId,
});

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

@@ -157,3 +157,7 @@ export const getUsersStatusAndProfileFetchingPollInterval: (state: GlobalState)
return null;
},
);
export function developerModeEnabled(state: GlobalState): boolean {
return state.entities.general.config.EnableDeveloper === 'true';
}

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

@@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {ScheduledPost, ScheduledPostsState} from '@mattermost/types/schedule_post';
import type {GlobalState} from '@mattermost/types/store';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
const emptyList: string[] = [];
export type ChannelScheduledPostIndicatorData = {
scheduledPost?: ScheduledPost;
count: number;
}
export function makeGetScheduledPostsByTeam(): (state: GlobalState, teamId: string, includeDirectChannels: boolean) => ScheduledPost[] {
return createSelector(
'makeGetScheduledPostsByTeam',
(state: GlobalState) => state.entities.scheduledPosts.byId,
(state: GlobalState, teamId: string, includeDirectChannels: boolean) => includeDirectChannels,
(state: GlobalState, teamId: string) => state.entities.scheduledPosts.byTeamId[teamId] || emptyList,
(state: GlobalState) => state.entities.scheduledPosts.byTeamId.directChannels || emptyList,
(scheduledPostsById: ScheduledPostsState['byId'], includeDirectChannels: boolean, teamScheduledPostsIDs: string[], directChannelScheduledPostsIDs: string[]) => {
const scheduledPosts: ScheduledPost[] = [];
const extractor = (scheduledPostId: string) => {
const scheduledPost = scheduledPostsById[scheduledPostId];
if (scheduledPost) {
scheduledPosts.push(scheduledPost);
}
};
teamScheduledPostsIDs.forEach(extractor);
if (includeDirectChannels) {
directChannelScheduledPostsIDs.forEach(extractor);
}
// Most recently upcoming post shows up first.
scheduledPosts.sort((a, b) => a.scheduled_at - b.scheduled_at || a.create_at - b.create_at);
return scheduledPosts;
},
);
}
export function getScheduledPostsByTeamCount(state: GlobalState, teamId: string, includeDirectChannels: boolean) {
let count = state.entities.scheduledPosts.byTeamId[teamId]?.length || 0;
if (includeDirectChannels) {
count += (state.entities.scheduledPosts.byTeamId.directChannels?.length || 0);
}
return count;
}
export function hasScheduledPostError(state: GlobalState, teamId: string) {
return state.entities.scheduledPosts.errorsByTeamId[teamId]?.length > 0 || state.entities.scheduledPosts.errorsByTeamId.directChannels?.length > 0;
}
export function showChannelOrThreadScheduledPostIndicator(state: GlobalState, channelOrThreadId: string): ChannelScheduledPostIndicatorData {
const allChannelScheduledPosts = state.entities.scheduledPosts.byChannelOrThreadId[channelOrThreadId] || emptyList;
const eligibleScheduledPosts = allChannelScheduledPosts.filter((scheduledPostId: string) => {
const scheduledPost = state.entities.scheduledPosts.byId[scheduledPostId];
return !scheduledPost.error_code;
});
const data = {
count: eligibleScheduledPosts.length,
} as ChannelScheduledPostIndicatorData;
if (data.count === 1) {
const scheduledPostId = eligibleScheduledPosts[0];
data.scheduledPost = state.entities.scheduledPosts.byId[scheduledPostId];
}
return data;
}
export function isScheduledPostsEnabled(state: GlobalState) {
return getConfig(state).ScheduledPosts === 'true';
}

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

@@ -211,6 +211,12 @@ const state: GlobalState = {
teamsLoaded: false,
},
},
scheduledPosts: {
byId: {},
byTeamId: {},
errorsByTeamId: {},
byChannelOrThreadId: {},
},
},
errors: [],
requests: {

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

@@ -25,6 +25,17 @@
color: #fff;
}
}
.badge,
.badge.persistent {
background: var(--mention-bg);
color: var(--mention-color);
&.urgent {
background-color: var(--dnd-indicator);
color: #fff;
}
}
}
.team-sidebar,

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

@@ -257,7 +257,6 @@
.modal-header {
display: flex;
min-height: 76px;
align-items: center;
justify-content: space-between;
padding: 16px 64px 16px 32px;
border: none;

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

@@ -655,7 +655,6 @@
.custom-textarea {
max-height: calc(50vh - 40px);
padding: 13px 16px 12px 16px;
background-color: var(--center-channel-bg);
&.custom-textarea--emoji-picker {

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

@@ -15,3 +15,21 @@ export const arePreviewsCollapsed = (state: GlobalState) => {
Preferences.COLLAPSE_DISPLAY_DEFAULT !== 'false',
);
};
export const isSendOnCtrlEnter = (state: GlobalState) => {
return getBoolPreference(
state,
Preferences.CATEGORY_ADVANCED_SETTINGS,
'send_on_ctrl_enter',
false,
);
};
export const isUseMilitaryTime = (state: GlobalState) => {
return getBoolPreference(
state,
Preferences.CATEGORY_DISPLAY_SETTINGS,
Preferences.USE_MILITARY_TIME,
false,
);
};

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

@@ -3,6 +3,7 @@
import type {FileInfo} from '@mattermost/types/files';
import type {PostPriority} from '@mattermost/types/posts';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
export type DraftInfo = {
id: string;
@@ -28,3 +29,19 @@ export type PostDraft = {
};
};
};
export function scheduledPostToPostDraft(scheduledPost: ScheduledPost): PostDraft {
return {
message: scheduledPost.message,
fileInfos: scheduledPost.metadata?.files || [],
uploadsInProgress: [],
props: scheduledPost.props,
channelId: scheduledPost.channel_id,
rootId: scheduledPost.root_id,
createAt: 0,
updateAt: 0,
metadata: {
priority: scheduledPost.priority,
},
};
}

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

@@ -463,6 +463,7 @@ export const ModalIdentifiers = {
CHANNEL_BOOKMARK_DELETE: 'channel_bookmark_delete',
CHANNEL_BOOKMARK_CREATE: 'channel_bookmark_create',
CONFIRM_MANAGE_USER_SETTINGS_MODAL: 'confirm_switch_to_settings',
SCHEDULED_POST_CUSTOM_TIME_MODAL: 'scheduled_post_custom_time',
SECURE_CONNECTION_DELETE: 'secure_connection_delete',
SECURE_CONNECTION_CREATE_INVITE: 'secure_connection_create_invite',
SECURE_CONNECTION_ACCEPT_INVITE: 'secure_connection_accept_invite',
@@ -2216,4 +2217,7 @@ export const PageLoadContext = {
RECONNECT: 'reconnect',
} as const;
export const SCHEDULED_POST_URL_SUFFIX = 'scheduled_posts';
export default Constants;

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

@@ -1,7 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment-timezone';
import {DateTime} from 'luxon';
import moment, {type Moment} from 'moment-timezone';
import type {useIntl} from 'react-intl';
const shouldTruncate = new Map<Intl.RelativeTimeFormatUnit, boolean>([
['year', true],
@@ -88,3 +90,16 @@ export function toUTCUnix(date: Date): number {
return Math.round(new Date(date.toISOString()).getTime() / 1000);
}
export function relativeFormatDate(date: Moment, formatMessage: ReturnType<typeof useIntl>['formatMessage'], format = 'yyyy-MM-dd'): string {
const now = moment();
const inputDate = moment(date);
if (inputDate.isSame(now, 'day')) {
return formatMessage({id: 'date_separator.today', defaultMessage: 'Today'});
} else if (inputDate.isSame(now.clone().subtract(1, 'days'), 'day')) {
return formatMessage({id: 'date_separator.yesterday', defaultMessage: 'Yesterday'});
} else if (inputDate.isSame(now.clone().add(1, 'days'), 'day')) {
return formatMessage({id: 'date_separator.tomorrow', defaultMessage: 'Tomorrow'});
}
return DateTime.fromJSDate(date.toDate()).toFormat(format);
}

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

@@ -37,6 +37,11 @@ describe('identifyElementRegion', () => {
const user = TestHelper.getUserMock({
id: 'test-user-id',
roles: 'system_admin system_user',
timezone: {
useAutomaticTimezone: 'true',
automaticTimezone: 'America/New_York',
manualTimezone: '',
},
});
const post = TestHelper.getPostMock({
id: 'test-post-id',

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

@@ -114,6 +114,7 @@ import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, Remot
import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports';
import type {Role} from '@mattermost/types/roles';
import type {SamlCertificateStatus, SamlMetadataResponse} from '@mattermost/types/saml';
import type {ScheduledPost} from '@mattermost/types/schedule_post';
import type {Scheme} from '@mattermost/types/schemes';
import type {Session} from '@mattermost/types/sessions';
import type {CompleteOnboardingRequest} from '@mattermost/types/setup';
@@ -4419,6 +4420,36 @@ export default class Client4 {
{method: 'post', body: JSON.stringify(body)},
);
};
// Schedule Post methods
createScheduledPost = (schedulePost: ScheduledPost, connectionId: string) => {
return this.doFetchWithResponse<ScheduledPost>(
`${this.getPostsRoute()}/schedule`,
{method: 'post', body: JSON.stringify(schedulePost), headers: {'Connection-Id': connectionId}},
);
};
// get user's current team's scheduled posts
getScheduledPosts = (teamId: string, includeDirectChannels: boolean) => {
return this.doFetchWithResponse<{[key: string]: ScheduledPost[]}>(
`${this.getPostsRoute()}/scheduled/team/${teamId}?includeDirectChannels=${includeDirectChannels}`,
{method: 'get'},
);
};
updateScheduledPost = (schedulePost: ScheduledPost, connectionId: string) => {
return this.doFetchWithResponse<ScheduledPost>(
`${this.getPostsRoute()}/schedule/${schedulePost.id}`,
{method: 'put', body: JSON.stringify(schedulePost), headers: {'Connection-Id': connectionId}},
);
};
deleteScheduledPost = (schedulePostId: string, connectionId: string) => {
return this.doFetchWithResponse<ScheduledPost>(
`${this.getPostsRoute()}/schedule/${schedulePostId}`,
{method: 'delete', headers: {'Connection-Id': connectionId}},
);
};
}
export function parseAndMergeNestedHeaders(originalHeaders: any) {

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

@@ -54,6 +54,9 @@ exports[`GenericModal should match snapshot for base case 1`] = `
Close
</span>
</button>
<div
class="GenericModal__header__text_container"
/>
</div>
<div
class="modal-body"

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

@@ -22,6 +22,22 @@
margin-top: -40px;
}
.modal-header {
.GenericModal__header__text_container {
margin-top: 8px;
width: 100%;
.modal-subheading-container {
color: rgba(var(--center-channel-color-rgb), 0.75);
}
}
p#genericModalSubheading {
font-size: 12px;
margin-block: 10px;
}
}
.modal-body {
max-height: 100%;
padding: 0;
@@ -96,7 +112,7 @@
display: flex;
box-sizing: border-box;
flex: 1;
padding: 14px;
padding: 14px 32px;
border: 1px solid rgba(var(--dnd-indicator-rgb), 0.16);
margin-bottom: 24px;
background: rgba(var(--dnd-indicator-rgb), 0.08);

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

@@ -12,6 +12,7 @@ export type Props = {
className?: string;
onExited: () => void;
modalHeaderText?: React.ReactNode;
modalSubheaderText?: React.ReactNode;
show?: boolean;
handleCancel?: () => void;
handleConfirm?: () => void;
@@ -205,12 +206,26 @@ export class GenericModal extends React.PureComponent<Props, State> {
className='GenericModal__wrapper-enter-key-press-catcher'
>
<Modal.Header closeButton={true}>
{this.props.compassDesign && (
<>
{headerText}
{this.props.headerInput}
</>
)}
<div className='GenericModal__header__text_container'>
{this.props.compassDesign && (
<>
{headerText}
{this.props.headerInput}
</>
)}
{
this.props.modalSubheaderText &&
<div className='modal-subheading-container'>
<p
id='genericModalSubheading'
className='modal-subheading'
>
{this.props.modalSubheaderText}
</p>
</div>
}
</div>
</Modal.Header>
<Modal.Body className={classNames({divider: this.props.bodyDivider, 'overflow-visible': this.props.bodyOverflowVisible})}>
{this.props.compassDesign ? (

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

@@ -213,6 +213,7 @@ export type ClientConfig = {
UniqueEmojiReactionLimitPerPost: string;
UsersStatusAndProfileFetchingPollIntervalMilliseconds: string;
YoutubeReferrerPolicy: 'true' | 'false';
ScheduledPosts: string;
};
export type License = {

81
webapp/platform/types/src/schedule_post.ts Обычный файл
Просмотреть файл

@@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Draft} from './drafts';
import type {Post} from './posts';
export type ScheduledPostErrorCode = 'unknown' | 'channel_archived' | 'channel_not_found' | 'user_missing' | 'user_deleted' | 'no_channel_permission' | 'no_channel_member' | 'thread_deleted' | 'unable_to_send' | 'invalid_post';
export type SchedulingInfo = {
scheduled_at: number;
processed_at?: number;
error_code?: ScheduledPostErrorCode;
}
export type ScheduledPost = Omit<Draft, 'delete_at'> & SchedulingInfo & {
id: string;
}
export type ScheduledPostsState = {
byId: {
[scheduledPostId: string]: ScheduledPost;
};
byTeamId: {
[teamId: string]: string[];
};
errorsByTeamId: {
[teamId: string]: string[];
};
byChannelOrThreadId: {
[channelId: string]: string[];
};
}
export function scheduledPostFromPost(post: Post, schedulingInfo: SchedulingInfo): ScheduledPost {
return {
id: '',
scheduled_at: schedulingInfo.scheduled_at,
create_at: 0,
update_at: post.update_at,
user_id: post.user_id,
channel_id: post.channel_id,
root_id: post.root_id,
message: post.message,
props: post.props,
metadata: post.metadata,
priority: post.metadata.priority,
};
}
export function scheduledPostToPost(scheduledPost: ScheduledPost): Post {
const post: Post = {
edit_at: 0,
hashtags: '',
is_pinned: false,
original_id: '',
pending_post_id: '',
reply_count: 0,
type: '',
id: scheduledPost.id,
create_at: scheduledPost.create_at,
update_at: scheduledPost.update_at,
delete_at: 0,
user_id: scheduledPost.user_id,
channel_id: scheduledPost.channel_id,
root_id: scheduledPost.root_id,
message: scheduledPost.message,
props: scheduledPost.props,
metadata: {
embeds: [],
emojis: [],
files: [],
images: {},
},
};
if (scheduledPost.metadata) {
post.metadata = scheduledPost.metadata;
}
return post;
}

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

@@ -25,6 +25,7 @@ import type {
TeamsRequestsStatuses, UsersRequestsStatuses,
} from './requests';
import type {Role} from './roles';
import type {ScheduledPostsState} from './schedule_post';
import type {SchemesState} from './schemes';
import type {SearchState} from './search';
import type {TeamsState} from './teams';
@@ -75,6 +76,7 @@ export type GlobalState = {
cloud: CloudState;
hostedCustomer: HostedCustomerState;
usage: CloudUsage;
scheduledPosts: ScheduledPostsState;
};
errors: any[];
requests: {