Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions packages/shared/src/components/fields/form/FormWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>;
Expand All @@ -33,6 +34,7 @@ export function FormWrapper({
copy = {},
leftButtonProps = {},
rightButtonProps = {},
headerActions,
title,
isHeaderTitle,
headerRef,
Expand Down Expand Up @@ -63,14 +65,17 @@ export function FormWrapper({
{isHeaderTitle ? null : left}
</Button>
{isHeaderTitle && title && titleElement}
<Button
{...rightButtonProps}
variant={ButtonVariant.Primary}
form={form}
className={classNames('ml-auto', rightButtonProps.className)}
>
{right}
</Button>
<div className="ml-auto flex items-center gap-2">
{headerActions}
<Button
{...rightButtonProps}
variant={ButtonVariant.Primary}
form={form}
className={rightButtonProps.className}
>
{right}
</Button>
</div>
</PageHeader>
{!isHeaderTitle && title && titleElement}
{children}
Expand Down
42 changes: 40 additions & 2 deletions packages/shared/src/components/modals/post/SmartComposerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -319,6 +333,7 @@ export function SmartComposerModal({
isMulti,
initialPreview,
editPostId: editPost?.id,
resolveScheduledAt: canSchedule ? schedule.resolveScheduledAt : undefined,
onComplete: () => {
if (editPost?.id) {
queryClient.invalidateQueries({
Expand All @@ -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 {
Expand Down Expand Up @@ -367,6 +384,19 @@ export function SmartComposerModal({
);

const isCoverUploading = !!cover?.isUploading;
const scheduleButtonNode = canSchedule ? (
<SchedulePostButton
isScheduled={schedule.isScheduled}
scheduledStart={schedule.scheduledStart}
timezone={schedule.timezone}
error={schedule.error}
disabled={isInFlight}
onScheduledStartChange={schedule.setScheduledStart}
onSeedDefault={schedule.seedDefault}
onConfirm={schedule.confirmSchedule}
onClear={schedule.clearSchedule}
/>
) : null;
const postButtonNode = (
<Button
form="smart_composer"
Expand All @@ -380,6 +410,12 @@ export function SmartComposerModal({
{submitLabel}
</Button>
);
const primaryActionsNode = (
<>
{scheduleButtonNode}
{postButtonNode}
</>
);
const notificationToggleNode = shouldShowCta ? (
<Switch
data-testid="push_notification-switch"
Expand Down Expand Up @@ -493,7 +529,7 @@ export function SmartComposerModal({
cover={cover}
onCoverChange={onCoverChange}
toolbarLeading={kindPickerNode}
toolbarRightActions={postButtonNode}
toolbarRightActions={primaryActionsNode}
onMarkdownModeChange={onMarkdownModeChange}
/>
{!isMarkdownMode && notificationToggleNode && (
Expand Down Expand Up @@ -535,7 +571,9 @@ export function SmartComposerModal({
<div className="flex shrink-0 flex-col gap-3 px-5 pb-5 pt-4">
<div className="flex items-center justify-between gap-3">
{kindPickerNode}
<span className="ml-auto">{postButtonNode}</span>
<span className="ml-auto flex items-center">
{primaryActionsNode}
</span>
</div>
{notificationToggleNode}
</div>
Expand Down
62 changes: 50 additions & 12 deletions packages/shared/src/components/post/composer/useComposerSubmit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from './types';
import type { TextFormCover } from './TextForm';
import { isPreviewForComposerUrl } from './utils';
import type { ResolvedScheduledAt } from '../schedule/useSchedulePost';

export interface StandupFieldErrors {
topic?: string;
Expand Down Expand Up @@ -72,6 +73,7 @@ interface UseComposerSubmitProps {
initialPreview?: ExternalLinkPreview;
onComplete: () => void;
editPostId?: string;
resolveScheduledAt?: () => ResolvedScheduledAt;
}

interface UseComposerSubmit {
Expand All @@ -97,6 +99,7 @@ export const useComposerSubmit = ({
initialPreview,
onComplete,
editPostId,
resolveScheduledAt,
}: UseComposerSubmitProps): UseComposerSubmit => {
const { displayToast } = useToastNotification();
const router = useRouter();
Expand All @@ -121,6 +124,12 @@ export const useComposerSubmit = ({
if (editPostId) {
return;
}
// Scheduled posts aren't visible yet, so don't navigate to the post
// page — just confirm and let onComplete close the composer.
if (post.flags?.scheduledAt) {
displayToast('✅ Your post has been scheduled!');
return;
}
router.push(
post.commentsPermalink ?? `${webappUrl}posts/${post.slug ?? post.id}`,
);
Expand Down Expand Up @@ -174,7 +183,10 @@ export const useComposerSubmit = ({
return !isPollValid(poll);
};

const submitText = async () => {
// Scheduling is single-source and non-moderated only. The `scheduledAt` here
// is already gated by the caller (undefined unless the post is schedulable),
// and it routes through the dedicated single-source mutation for each type.
const submitText = async (scheduledAt?: string) => {
const payload = {
title: text.title.trim(),
content: text.body,
Expand All @@ -192,6 +204,12 @@ export const useComposerSubmit = ({
);
return;
}

if (scheduledAt) {
await onSubmitFreeformPost({ ...payload, scheduledAt }, primary as Squad);
return;
}

if (isMulti) {
await createMulti({
sourceIds: selectedIds,
Expand All @@ -207,10 +225,10 @@ export const useComposerSubmit = ({
);
};

const submitPoll = async () => {
const submitPoll = async (scheduledAt?: string) => {
const options = trimmedOptions(poll);
const duration = poll.durationDays;
if (isMulti) {
if (!scheduledAt && isMulti) {
await createMulti({
sourceIds: selectedIds,
title: poll.question.trim(),
Expand All @@ -224,19 +242,27 @@ export const useComposerSubmit = ({
title: poll.question.trim(),
options,
...(duration != null ? { duration } : {}),
...(scheduledAt ? { scheduledAt } : {}),
},
primary as Squad,
);
};

const submitLink = async (event: FormEvent<HTMLFormElement>) => {
const submitLink = async (
event: FormEvent<HTMLFormElement>,
scheduledAt?: string,
) => {
if (!isPreviewForComposerUrl(preview, link.url)) {
displayToast('Invalid link');
return;
}
const commentary = link.commentary.trim();
if (!isMulti) {
await onSubmitPost(event, primary as Squad, commentary);
await onSubmitPost(event, primary as Squad, commentary, scheduledAt);
// submitExternalLink returns no post, so onPostSuccess can't confirm it.
if (scheduledAt && !preview?.id) {
displayToast('✅ Your post has been scheduled!');
}
return;
}
const url = preview?.finalUrl ?? preview?.url;
Expand Down Expand Up @@ -273,19 +299,30 @@ export const useComposerSubmit = ({
if (getIsSubmitDisabled()) {
return;
}
if (kind === 'text') {
await submitText();
if (kind === 'standup') {
await submitStandup();
return;
}
if (kind === 'poll') {
await submitPoll();

let scheduledAt: string | undefined;
if (!editPostId) {
const schedule = resolveScheduledAt?.() ?? {};
if (schedule.error) {
displayToast(schedule.error);
return;
}
scheduledAt = schedule.iso;
}

if (kind === 'text') {
await submitText(scheduledAt);
return;
}
if (kind === 'standup') {
await submitStandup();
if (kind === 'poll') {
await submitPoll(scheduledAt);
return;
}
await submitLink(event);
await submitLink(event, scheduledAt);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
Expand All @@ -301,6 +338,7 @@ export const useComposerSubmit = ({
selectedIds,
isInFlight,
editPostId,
resolveScheduledAt,
],
);

Expand Down
Loading
Loading