diff --git a/packages/shared/src/components/fields/form/FormWrapper.tsx b/packages/shared/src/components/fields/form/FormWrapper.tsx index 5e098067e39..7f713c405d2 100644 --- a/packages/shared/src/components/fields/form/FormWrapper.tsx +++ b/packages/shared/src/components/fields/form/FormWrapper.tsx @@ -21,6 +21,7 @@ export interface FormWrapperProps { copy?: Copy; leftButtonProps?: ButtonProps<'button'>; rightButtonProps?: ButtonProps<'button'>; + headerActions?: ReactNode; title?: string | React.ReactNode; isHeaderTitle?: boolean; headerRef?: MutableRefObject; @@ -33,6 +34,7 @@ export function FormWrapper({ copy = {}, leftButtonProps = {}, rightButtonProps = {}, + headerActions, title, isHeaderTitle, headerRef, @@ -63,14 +65,17 @@ export function FormWrapper({ {isHeaderTitle ? null : left} {isHeaderTitle && title && titleElement} - +
+ {headerActions} + +
{!isHeaderTitle && title && titleElement} {children} diff --git a/packages/shared/src/components/modals/post/SmartComposerModal.tsx b/packages/shared/src/components/modals/post/SmartComposerModal.tsx index 86039c33b4f..93e65ba7d2d 100644 --- a/packages/shared/src/components/modals/post/SmartComposerModal.tsx +++ b/packages/shared/src/components/modals/post/SmartComposerModal.tsx @@ -34,7 +34,9 @@ import { LogEvent } from '../../../lib/log'; import { useViewSize, ViewSize } from '../../../hooks'; import { usePrompt } from '../../../hooks/usePrompt'; import type { ExternalLinkPreview, Post } from '../../../graphql/posts'; +import type { Squad } from '../../../graphql/sources'; import { getPostByIdKey } from '../../../lib/query'; +import { moderationRequired } from '../../squads/utils'; import { AudienceChip } from '../../post/composer/AudienceChip'; import { KindModePicker } from '../../post/composer/KindModePicker'; import { @@ -52,6 +54,8 @@ import { } from '../../post/composer/useComposerAudience'; import { useComposerSubmit } from '../../post/composer/useComposerSubmit'; import { useStandupCreation } from '../../../hooks/liveRooms/useStandupCreation'; +import { useSchedulePost } from '../../post/schedule/useSchedulePost'; +import { SchedulePostButton } from '../../post/schedule/SchedulePostButton'; import { DEFAULT_LINK, DEFAULT_POLL, @@ -299,6 +303,16 @@ export function SmartComposerModal({ const primary = selected[0]; const isMulti = selected.length > 1; + const schedule = useSchedulePost(); + // Scheduling: single-source, non-moderated create only (any post type + // except standups, which schedule themselves). + const canSchedule = + kind !== 'standup' && + !isEditing && + !isMulti && + !!primary && + !moderationRequired(primary as Squad); + const { handleSubmit, isSubmitDisabled, @@ -319,6 +333,7 @@ export function SmartComposerModal({ isMulti, initialPreview, editPostId: editPost?.id, + resolveScheduledAt: canSchedule ? schedule.resolveScheduledAt : undefined, onComplete: () => { if (editPost?.id) { queryClient.invalidateQueries({ @@ -340,6 +355,8 @@ export function SmartComposerModal({ submitLabel = 'Save changes'; } else if (isStandup) { submitLabel = isStandupScheduled ? 'Schedule standup' : 'Create standup'; + } else if (canSchedule && schedule.isScheduled) { + submitLabel = 'Schedule post'; } else if (kind === 'poll') { submitLabel = 'Post poll'; } else { @@ -367,6 +384,19 @@ export function SmartComposerModal({ ); const isCoverUploading = !!cover?.isUploading; + const scheduleButtonNode = canSchedule ? ( + + ) : null; const postButtonNode = ( + ) : ( + + )} + + + + ); + + if (!isTablet) { + return ( + <> + + + {schedule && ( + + )} + + ); } diff --git a/packages/shared/src/contexts/WritePostContext.tsx b/packages/shared/src/contexts/WritePostContext.tsx index f560792adf9..881db29030e 100644 --- a/packages/shared/src/contexts/WritePostContext.tsx +++ b/packages/shared/src/contexts/WritePostContext.tsx @@ -17,6 +17,8 @@ import ConditionalWrapper from '../components/ConditionalWrapper'; import { FormWrapper } from '../components/fields/form'; import type { SourcePostModeration } from '../graphql/squads'; import { useViewSize, ViewSize } from '../hooks/useViewSize'; +import type { UseSchedulePost } from '../components/post/schedule/useSchedulePost'; +import { SchedulePostControl } from '../components/post/schedule/SchedulePostControl'; export interface WriteForm { title: string; @@ -55,6 +57,8 @@ export interface WritePostProps { updateDraft?: (props: Partial) => Promise; isUpdatingDraft?: boolean; formId?: string; + // When provided, the schedule control is offered next to the submit button. + schedule?: UseSchedulePost; } export const WritePostContext = React.createContext({ @@ -95,6 +99,14 @@ export const WritePostContextProvider = ({ copy={{ right: rightCopy ?? 'Post' }} rightButtonProps={{ disabled: props.isPosting }} leftButtonProps={{ onClick: () => router.back() }} + headerActions={ + props.schedule ? ( + + ) : undefined + } form={formId} > {component} diff --git a/packages/shared/src/graphql/posts.ts b/packages/shared/src/graphql/posts.ts index c5ca42d45ee..6274397636f 100644 --- a/packages/shared/src/graphql/posts.ts +++ b/packages/shared/src/graphql/posts.ts @@ -180,6 +180,7 @@ type PostFlags = { sources?: number; savedTime?: number; generatedAt?: Date; + scheduledAt?: string | null; digestPostIds?: string[]; ad?: DigestPostAd | null; }; @@ -699,6 +700,7 @@ export const SUBMIT_EXTERNAL_LINK_MUTATION = gql` $title: String $image: String $commentary: String + $scheduledAt: DateTime ) { submitExternalLink( url: $url @@ -706,6 +708,7 @@ export const SUBMIT_EXTERNAL_LINK_MUTATION = gql` image: $image sourceId: $sourceId commentary: $commentary + scheduledAt: $scheduledAt ) { _ } @@ -764,6 +767,7 @@ export interface SubmitExternalLink extends Pick { sourceId: string; commentary: string; + scheduledAt?: string | null; } export const submitExternalLink = ( @@ -778,8 +782,15 @@ export const EDIT_POST_MUTATION = gql` $title: String $content: String $image: Upload + $scheduledAt: DateTime ) { - editPost(id: $id, title: $title, content: $content, image: $image) { + editPost( + id: $id + title: $title + content: $content + image: $image + scheduledAt: $scheduledAt + ) { ...SharedPostInfo trending content @@ -789,6 +800,9 @@ export const EDIT_POST_MUTATION = gql` } description summary + flags { + scheduledAt + } toc { text id @@ -803,11 +817,12 @@ export type EditPostProps = { title: string; content: string; image?: File; + scheduledAt?: string | null; }; export type CreatePostProps = Pick< EditPostProps, - 'title' | 'content' | 'image' + 'title' | 'content' | 'image' | 'scheduledAt' >; export interface CreatePostPollProps @@ -827,6 +842,7 @@ type CreatePollOption = Pick; export interface CreatePollPostForm extends Pick { options: string[]; duration?: number; + scheduledAt?: string | null; } export interface CreatePostModerationProps { @@ -939,12 +955,14 @@ export const CREATE_POST_MUTATION = gql` $title: String! $content: String $image: Upload + $scheduledAt: DateTime ) { createFreeformPost( sourceId: $sourceId title: $title content: $content image: $image + scheduledAt: $scheduledAt ) { ...SharedPostInfo content @@ -954,6 +972,9 @@ export const CREATE_POST_MUTATION = gql` } description summary + flags { + scheduledAt + } } } ${SHARED_POST_INFO_FRAGMENT} @@ -1001,7 +1022,7 @@ export const CREATE_POST_IN_MULTIPLE_SOURCES = gql` `; export interface CreatePostInMultipleSourcesArgs - extends Partial, + extends Partial>, Partial> { commentary?: string; externalLink?: string; @@ -1068,14 +1089,19 @@ export const CREATE_POLL_POST_MUTATION = gql` $title: String! $options: [PollOptionInput!]! $duration: Int + $scheduledAt: DateTime ) { createPollPost( sourceId: $sourceId title: $title options: $options duration: $duration + scheduledAt: $scheduledAt ) { ...SharedPostInfo + flags { + scheduledAt + } } } ${SHARED_POST_INFO_FRAGMENT} diff --git a/packages/shared/src/graphql/squads.ts b/packages/shared/src/graphql/squads.ts index d1ec8546936..5a4dcced03f 100644 --- a/packages/shared/src/graphql/squads.ts +++ b/packages/shared/src/graphql/squads.ts @@ -59,6 +59,7 @@ interface PostToSquadProps { id: string; sourceId?: string; commentary: string; + scheduledAt?: string | null; } export const UPDATE_MEMBER_ROLE_MUTATION = gql` @@ -236,9 +237,22 @@ export const EDIT_SQUAD_MUTATION = gql` `; export const ADD_POST_TO_SQUAD_MUTATION = gql` - mutation AddPostToSquad($id: ID!, $sourceId: ID!, $commentary: String) { - sharePost(id: $id, sourceId: $sourceId, commentary: $commentary) { + mutation AddPostToSquad( + $id: ID! + $sourceId: ID! + $commentary: String + $scheduledAt: DateTime + ) { + sharePost( + id: $id + sourceId: $sourceId + commentary: $commentary + scheduledAt: $scheduledAt + ) { id + flags { + scheduledAt + } } } `; diff --git a/packages/shared/src/hooks/squads/usePostToSquad.tsx b/packages/shared/src/hooks/squads/usePostToSquad.tsx index a40647a70de..fa13a146e63 100644 --- a/packages/shared/src/hooks/squads/usePostToSquad.tsx +++ b/packages/shared/src/hooks/squads/usePostToSquad.tsx @@ -63,6 +63,7 @@ interface UsePostToSquad { e: BaseSyntheticEvent, squad: Squad, commentary: string, + scheduledAt?: string, ) => Promise; onSubmitFreeformPost: (post: CreatePostProps, squad: Squad) => Promise; onSubmitPollPost: (post: CreatePollPostForm, squad: Squad) => Promise; @@ -260,8 +261,10 @@ export const usePostToSquad = ({ const squadId = getSquadIdOrThrow(squad); moderationCreationRef.current = null; + // Scheduling isn't supported for moderated posts. + const { scheduledAt, ...moderationPost } = editedPost; await onCreatePostModeration({ - ...editedPost, + ...moderationPost, type: PostType.Freeform, postId: editedPost.id, sourceId: squadId, @@ -279,16 +282,23 @@ export const usePostToSquad = ({ ], ); - const onSharedPostSuccessfully = async (update = false) => { - const customToast = getSharedPostSuccessToast?.({ isUpdate: update }); - if (customToast) { - displayToast(customToast.message, customToast.options); - } else { - displayToast( - update - ? 'The post has been updated' - : 'This post has been shared to your squad', - ); + const onSharedPostSuccessfully = async ( + update = false, + isScheduled = false, + ) => { + // Scheduled posts aren't live yet — the "scheduled" toast is shown by the + // caller, so suppress the misleading "shared to your squad" copy here. + if (!isScheduled) { + const customToast = getSharedPostSuccessToast?.({ isUpdate: update }); + if (customToast) { + displayToast(customToast.message, customToast.options); + } else { + displayToast( + update + ? 'The post has been updated' + : 'This post has been shared to your squad', + ); + } } await client.invalidateQueries({ queryKey: ['sourceFeed', user?.id], @@ -302,14 +312,14 @@ export const usePostToSquad = ({ isSuccess: isPostSuccess, } = useMutation({ mutationFn: addPostToSquad(requestMethod), - onSuccess: (data) => { + onSuccess: (data, variables) => { logPostCreated({ postId: data.id, postType: data.type, sourceCount: 1, targetType: 'post', }); - onSharedPostSuccessfully(); + onSharedPostSuccessfully(false, !!variables.scheduledAt); handlePostSuccess(data); }, onError: (err) => handleMutationError(err), @@ -335,12 +345,13 @@ export const usePostToSquad = ({ } = useMutation({ mutationFn: (params: SubmitExternalLink) => submitExternalLink(params, requestMethod), - onSuccess: (_, { url }) => { + onSuccess: (_, variables) => { logPostCreated({ postType: PostType.Share, sourceCount: 1, }); - onSharedPostSuccessfully(); + onSharedPostSuccessfully(false, !!variables.scheduledAt); + const { url } = variables; if (!url) { throw new Error('Missing external link url in usePostToSquad'); } @@ -375,7 +386,7 @@ export const usePostToSquad = ({ isEditPostSuccess; const onSubmitPost = useCallback( - async (e, squad, commentary) => { + async (e, squad, commentary, scheduledAt) => { e?.preventDefault(); if (isPosting) { return Promise.resolve(); @@ -398,6 +409,7 @@ export const usePostToSquad = ({ id: preview.id, sourceId: squadId, commentary, + ...(scheduledAt ? { scheduledAt } : {}), }); } @@ -427,6 +439,7 @@ export const usePostToSquad = ({ image, sourceId: squadId, commentary, + ...(scheduledAt ? { scheduledAt } : {}), }); }, [ @@ -475,8 +488,10 @@ export const usePostToSquad = ({ if (moderationRequired(squad)) { moderationCreationRef.current = PostType.Freeform; + // Scheduling isn't supported for moderated posts. + const { scheduledAt, ...moderationPost } = post; await onCreatePostModeration({ - ...post, + ...moderationPost, sourceId: squadId, type: PostType.Freeform, }); @@ -501,8 +516,10 @@ export const usePostToSquad = ({ if (moderationRequired(squad)) { moderationCreationRef.current = PostType.Poll; + // Scheduling isn't supported for moderated posts. + const { scheduledAt, ...moderationPost } = post; await onCreatePostModeration({ - ...post, + ...moderationPost, pollOptions: orderedOpts, sourceId: squadId, type: PostType.Poll, diff --git a/packages/shared/src/lib/scheduledPost.spec.ts b/packages/shared/src/lib/scheduledPost.spec.ts new file mode 100644 index 00000000000..a2733c3e334 --- /dev/null +++ b/packages/shared/src/lib/scheduledPost.spec.ts @@ -0,0 +1,46 @@ +import { dateFormatInTimezone } from './timezones'; +import { + MAX_POST_SCHEDULE_DAYS, + validatePostScheduledStart, +} from './scheduledPost'; + +const tz = 'Etc/UTC'; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const toLocal = (date: Date): string => + dateFormatInTimezone(date, "yyyy-MM-dd'T'HH:mm", tz); + +describe('validatePostScheduledStart', () => { + it('returns an ISO string for a valid future time within the limit', () => { + const future = new Date(Date.now() + 60 * 60 * 1000); + const result = validatePostScheduledStart(toLocal(future), tz); + + expect('iso' in result).toBe(true); + if ('iso' in result) { + expect(new Date(result.iso).getTime()).toBeGreaterThan(Date.now()); + } + }); + + it('rejects empty input', () => { + expect(validatePostScheduledStart('', tz)).toEqual({ + error: 'Scheduled time is invalid', + }); + }); + + it('rejects a time in the past', () => { + const past = new Date(Date.now() - ONE_DAY_MS); + + expect(validatePostScheduledStart(toLocal(past), tz)).toEqual({ + error: 'Scheduled time must be in the future', + }); + }); + + it(`rejects a time more than ${MAX_POST_SCHEDULE_DAYS} days ahead`, () => { + const tooFar = new Date( + Date.now() + (MAX_POST_SCHEDULE_DAYS + 1) * ONE_DAY_MS, + ); + + expect(validatePostScheduledStart(toLocal(tooFar), tz)).toEqual({ + error: `Scheduled time must be within ${MAX_POST_SCHEDULE_DAYS} days`, + }); + }); +}); diff --git a/packages/shared/src/lib/scheduledPost.ts b/packages/shared/src/lib/scheduledPost.ts new file mode 100644 index 00000000000..cc6b2fc93cb --- /dev/null +++ b/packages/shared/src/lib/scheduledPost.ts @@ -0,0 +1,78 @@ +import zonedTimeToUtc from 'date-fns-tz/zonedTimeToUtc'; +import { dateFormatInTimezone, DEFAULT_TIMEZONE } from './timezones'; + +// Keep in sync with daily-api MAX_POST_SCHEDULE_DAYS in src/common/postScheduling.ts +export const MAX_POST_SCHEDULE_DAYS = 14; +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +export const MAX_POST_SCHEDULE_MS = MAX_POST_SCHEDULE_DAYS * ONE_DAY_MS; +const DEFAULT_SCHEDULE_DELAY_MS = 60 * 60 * 1000; + +export type PostScheduleValidation = { iso: string } | { error: string }; + +export const getDefaultPostScheduledStart = (timezone?: string): string => + dateFormatInTimezone( + new Date(Date.now() + DEFAULT_SCHEDULE_DELAY_MS), + "yyyy-MM-dd'T'HH:mm", + timezone, + ); + +export const parsePostScheduledStart = ( + value: string | undefined | null, + timezone?: string, +): Date | null => { + if (!value) { + return null; + } + + const date = zonedTimeToUtc(value, timezone || DEFAULT_TIMEZONE); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export const formatScheduleDelta = (date: Date | null): string | null => { + if (!date) { + return null; + } + + const diffMs = date.getTime() - Date.now(); + if (diffMs <= 0) { + return 'now'; + } + + const minutes = Math.max(1, Math.round(diffMs / 60_000)); + if (minutes < 60) { + return `${minutes} minute${minutes === 1 ? '' : 's'} from now`; + } + + const hours = Math.round(minutes / 60); + if (hours < 24) { + return `${hours} hour${hours === 1 ? '' : 's'} from now`; + } + + const days = Math.round(hours / 24); + return `${days} day${days === 1 ? '' : 's'} from now`; +}; + +// Validation copy mirrors the daily-api errors so the client blocks the same +// inputs the server would reject. +export const validatePostScheduledStart = ( + value: string | undefined | null, + timezone?: string, +): PostScheduleValidation => { + const date = parsePostScheduledStart(value, timezone); + + if (!date) { + return { error: 'Scheduled time is invalid' }; + } + + if (date.getTime() <= Date.now()) { + return { error: 'Scheduled time must be in the future' }; + } + + if (date.getTime() > Date.now() + MAX_POST_SCHEDULE_MS) { + return { + error: `Scheduled time must be within ${MAX_POST_SCHEDULE_DAYS} days`, + }; + } + + return { iso: date.toISOString() }; +}; diff --git a/packages/shared/src/lib/timezones.ts b/packages/shared/src/lib/timezones.ts index c42c6d14957..3d3306ad515 100644 --- a/packages/shared/src/lib/timezones.ts +++ b/packages/shared/src/lib/timezones.ts @@ -641,3 +641,10 @@ export const getTimezoneOffsetLabel = (timezone: string): string => { return `(UTC ${formatTimezoneOffset(timezoneOffset)}) ${resolvedTimezone}`; }; + +export const getTimezoneNameWithOffset = (timezone: string): string => { + const resolvedTimezone = timezone || DEFAULT_TIMEZONE; + const timezoneOffset = getTimezoneOffsetInMinutes(resolvedTimezone); + + return `${resolvedTimezone} (UTC ${formatTimezoneOffset(timezoneOffset)})`; +}; diff --git a/packages/webapp/pages/squads/create.tsx b/packages/webapp/pages/squads/create.tsx index 422d8800a25..fd8bcddd0f4 100644 --- a/packages/webapp/pages/squads/create.tsx +++ b/packages/webapp/pages/squads/create.tsx @@ -17,14 +17,20 @@ import TabContainer, { import { ShareLink } from '@dailydotdev/shared/src/components/post/write/ShareLink'; import { generateDefaultSquad, + generateUserSourceAsSquad, MultipleSourceSelect, } from '@dailydotdev/shared/src/components/post/write'; +import { moderationRequired } from '@dailydotdev/shared/src/components/squads/utils'; +import type { Post } from '@dailydotdev/shared/src/graphql/posts'; +import { PostType } from '@dailydotdev/shared/src/graphql/posts'; +import { useSchedulePost } from '@dailydotdev/shared/src/components/post/schedule/useSchedulePost'; import Unauthorized from '@dailydotdev/shared/src/components/errors/Unauthorized'; import { verifyPermission } from '@dailydotdev/shared/src/graphql/squads'; import { SourcePermissions } from '@dailydotdev/shared/src/graphql/sources'; import { useActions, useConditionalFeature, + usePostToSquad, useViewSize, ViewSize, } from '@dailydotdev/shared/src/hooks'; @@ -191,16 +197,89 @@ function CreatePost(): ReactElement { }, }); + // Scheduling is single-source and non-moderated only. Share posts keep the + // preview-driven flow in ShareLink, so scheduling for them lives in the new + // composer; here it covers new posts and polls. + const schedule = useSchedulePost(); + const singleSourceId = + selectedSourceIds.length === 1 ? selectedSourceIds[0] : undefined; + const selectedSquad = squads?.find(({ id }) => id === singleSourceId); + const isOwnSource = !!singleSourceId && singleSourceId === user?.id; + // Only schedule when the single target is the user's own source or a real, + // non-moderated squad — never the "Create new Squad" placeholder. + const canSchedule = + !!singleSourceId && + display !== WriteFormTab.Share && + (selectedSquad ? !moderationRequired(selectedSquad) : isOwnSource); + + const { + onSubmitFreeformPost, + onSubmitPollPost, + isPosting: isSchedulePosting, + } = usePostToSquad({ + onPostSuccess: () => { + displayToast('✅ Your post has been scheduled!'); + clearFormOnSuccess(); + push(`${webappUrl}${user?.username}/posts/`); + }, + onError: (data) => { + if (data?.response?.errors?.[0]) { + displayToast(data?.response?.errors?.[0].message); + } + onAskConfirmation(true); + }, + }); + + const submitScheduledPost = async ( + params: Omit & { image?: File }, + type: Post['type'], + ): Promise => { + const resolved = schedule.resolveScheduledAt(); + if (resolved.error) { + displayToast(resolved.error); + return true; + } + const scheduledAt = resolved.iso; + if (!scheduledAt) { + return false; + } + + const squad = selectedSquad ?? generateUserSourceAsSquad(user); + const { options, image, title, content, duration } = params; + const validImage = + image instanceof File && image.size > 0 ? image : undefined; + if (type === PostType.Poll) { + await onSubmitPollPost( + { title, options: options ?? [], duration, scheduledAt }, + squad, + ); + } else { + await onSubmitFreeformPost( + { title, content, image: validImage, scheduledAt }, + squad, + ); + } + return true; + }; + const onClickSubmit = async ( e: FormEvent, params: Omit & { image?: File }, + type: Post['type'], ) => { e.preventDefault(); - if (isPending || !selectedSourceIds.length) { + if (isPending || isSchedulePosting || !selectedSourceIds.length) { return; } + if (canSchedule && schedule.isScheduled) { + const handled = await submitScheduledPost(params, type); + if (handled) { + return; + } + } + const { options, image, ...args } = params; await onCreate({ @@ -303,11 +382,16 @@ function CreatePost(): ReactElement { squad={null} formRef={formRef} isUpdatingDraft={isUpdatingDraft} - isPosting={isPending} + isPosting={isPending || isSchedulePosting} updateDraft={updateDraft} onSubmitForm={onClickSubmit} formId={WriteFormTabToFormID[display]} - rightCopy={getSubmitCopy(display)} + rightCopy={ + canSchedule && schedule.isScheduled + ? 'Schedule' + : getSubmitCopy(display) + } + schedule={canSchedule ? schedule : undefined} enableUpload >