Feature scheduled messages (#28932)
* 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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
aaf9234c8e
Коммит
e281b3f37e
@@ -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;
|
||||
}
|
||||
56
webapp/channels/src/components/drafts/draft_list/draft_list.tsx
Обычный файл
56
webapp/channels/src/components/drafts/draft_list/draft_list.tsx
Обычный файл
@@ -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 you’ve started will show here.',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
webapp/channels/src/components/drafts/draft_row.scss
Обычный файл
8
webapp/channels/src/components/drafts/draft_row.scss
Обычный файл
@@ -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 you’ve 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
54
webapp/channels/src/components/drafts/utils.ts
Обычный файл
54
webapp/channels/src/components/drafts/utils.ts
Обычный файл
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
17
webapp/channels/src/components/edit_post/style.scss
Обычный файл
17
webapp/channels/src/components/edit_post/style.scss
Обычный файл
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
26
webapp/channels/src/components/tabs/tab/index.tsx
Обычный файл
26
webapp/channels/src/components/tabs/tab/index.tsx
Обычный файл
@@ -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>
|
||||
);
|
||||
}
|
||||
42
webapp/channels/src/components/tabs/tabs/index.tsx
Обычный файл
42
webapp/channels/src/components/tabs/tabs/index.tsx
Обычный файл
@@ -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>
|
||||
);
|
||||
}
|
||||
49
webapp/channels/src/components/tabs/tabs/style.scss
Обычный файл
49
webapp/channels/src/components/tabs/tabs/style.scss
Обычный файл
@@ -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
Обычный файл
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: {
|
||||
|
||||
Ссылка в новой задаче
Block a user