{isMarkdownMode ? (
<>
-
-
- Markdown editor
-
-
- {savingLabel}
-
- }
- onClick={switchToRichMode}
- />
-
- {onClose && (
-
- )}
+ {!hideMarkdownHeader && (
+
+
+ Markdown editor
+
+
+ {savingLabel}
+
+ }
+ onClick={switchToRichMode}
+ />
+
+ {onClose && (
+
+ )}
+
-
+ )}
) : (
<>
-
{
- if (!editor) {
- return;
- }
- if (!editor.state.selection.empty) {
- editor.chain().focus().setLink({ href: url }).run();
- return;
- }
- const linkText = label || url;
- editor
- .chain()
- .focus()
- .insertContent({
- type: 'text',
- text: linkText,
- marks: [{ type: 'link', attrs: { href: url } }],
- })
- .run();
- }}
- inlineActions={hasToolbarActions ? toolbarActions : null}
- rightActions={
-
- {savingLabel}
-
- }
- onClick={switchToMarkdownMode}
+ {(() => {
+ const toolbar = (
+ {
+ if (!editor) {
+ return;
+ }
+ if (!editor.state.selection.empty) {
+ editor.chain().focus().setLink({ href: url }).run();
+ return;
+ }
+ const linkText = label || url;
+ editor
+ .chain()
+ .focus()
+ .insertContent({
+ type: 'text',
+ text: linkText,
+ marks: [{ type: 'link', attrs: { href: url } }],
+ })
+ .run();
+ }}
+ inlineActions={hasToolbarActions ? toolbarActions : null}
+ inlineActionsLeading={inlineActionsLeading}
+ rightActions={
+
+ {savingLabel}
+ {!hideMarkdownToggle && (
+
+ }
+ onClick={switchToMarkdownMode}
+ />
+
+ )}
+ {toolbarRightActions}
+ {onClose && (
+
+ )}
+
+ }
+ />
+ );
+
+ const editorBody = (
+ <>
+ {isUploadEnabled && (
+
-
- {onClose && (
-
)}
-
+
+ {showUserAvatar && user && (
+
+ )}
+
+
+ >
+ );
+
+ if (toolbarPosition === 'bottom') {
+ return (
+ <>
+ {editorBody}
+ {aboveToolbar && (
+ {aboveToolbar}
+ )}
+ {toolbar}
+ >
+ );
}
- />
- {isUploadEnabled && (
-
- )}
-
- {showUserAvatar && user && (
-
- )}
-
-
+
+ return (
+ <>
+ {toolbar}
+ {editorBody}
+ >
+ );
+ })()}
>
)}
{textareaProps.name && (
@@ -891,41 +1034,43 @@ function RichTextInput(
onClickOutside={emoji.clearEmoji}
/>
)}
- {footer ?? (
-
- {shouldShowSubmit && !isMarkdownMode && (
-
- Press {submitShortcut} to send
-
- )}
- {maxLength && remainingCharacters !== null && (
-
+ {shouldShowSubmit && !isMarkdownMode && (
+
+ Press {submitShortcut} to send
+
+ )}
+ {maxLength && remainingCharacters !== null && (
+
+ {remainingCharacters}
+
+ )}
+ {shouldShowSubmit && (
+
)}
- >
- {remainingCharacters}
)}
- {shouldShowSubmit && (
-
- )}
-
- )}
);
}
diff --git a/packages/shared/src/components/icons/CodeBlock/index.tsx b/packages/shared/src/components/icons/CodeBlock/index.tsx
new file mode 100644
index 00000000000..8b4d43e80a9
--- /dev/null
+++ b/packages/shared/src/components/icons/CodeBlock/index.tsx
@@ -0,0 +1,9 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+
+export const CodeBlockIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/CodeBlock/outlined.svg b/packages/shared/src/components/icons/CodeBlock/outlined.svg
new file mode 100644
index 00000000000..e3053f65cf4
--- /dev/null
+++ b/packages/shared/src/components/icons/CodeBlock/outlined.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/shared/src/components/icons/Heading1/index.tsx b/packages/shared/src/components/icons/Heading1/index.tsx
new file mode 100644
index 00000000000..b8182e35ecb
--- /dev/null
+++ b/packages/shared/src/components/icons/Heading1/index.tsx
@@ -0,0 +1,9 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+
+export const Heading1Icon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/Heading1/outlined.svg b/packages/shared/src/components/icons/Heading1/outlined.svg
new file mode 100644
index 00000000000..ec622a47a9b
--- /dev/null
+++ b/packages/shared/src/components/icons/Heading1/outlined.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/shared/src/components/icons/Heading2/index.tsx b/packages/shared/src/components/icons/Heading2/index.tsx
new file mode 100644
index 00000000000..fe4248e0a60
--- /dev/null
+++ b/packages/shared/src/components/icons/Heading2/index.tsx
@@ -0,0 +1,9 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+
+export const Heading2Icon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/Heading2/outlined.svg b/packages/shared/src/components/icons/Heading2/outlined.svg
new file mode 100644
index 00000000000..b6a3d80380e
--- /dev/null
+++ b/packages/shared/src/components/icons/Heading2/outlined.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/shared/src/components/icons/Heading3/index.tsx b/packages/shared/src/components/icons/Heading3/index.tsx
new file mode 100644
index 00000000000..6d861ad5b2c
--- /dev/null
+++ b/packages/shared/src/components/icons/Heading3/index.tsx
@@ -0,0 +1,9 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+
+export const Heading3Icon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/Heading3/outlined.svg b/packages/shared/src/components/icons/Heading3/outlined.svg
new file mode 100644
index 00000000000..53dc005c91c
--- /dev/null
+++ b/packages/shared/src/components/icons/Heading3/outlined.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/shared/src/components/icons/HorizontalRule/index.tsx b/packages/shared/src/components/icons/HorizontalRule/index.tsx
new file mode 100644
index 00000000000..97265f714c4
--- /dev/null
+++ b/packages/shared/src/components/icons/HorizontalRule/index.tsx
@@ -0,0 +1,9 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+
+export const HorizontalRuleIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/HorizontalRule/outlined.svg b/packages/shared/src/components/icons/HorizontalRule/outlined.svg
new file mode 100644
index 00000000000..c6b1bdf7fd5
--- /dev/null
+++ b/packages/shared/src/components/icons/HorizontalRule/outlined.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/shared/src/components/icons/InlineCode/index.tsx b/packages/shared/src/components/icons/InlineCode/index.tsx
new file mode 100644
index 00000000000..c05dfed4696
--- /dev/null
+++ b/packages/shared/src/components/icons/InlineCode/index.tsx
@@ -0,0 +1,9 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+
+export const InlineCodeIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/InlineCode/outlined.svg b/packages/shared/src/components/icons/InlineCode/outlined.svg
new file mode 100644
index 00000000000..fb1f7d86544
--- /dev/null
+++ b/packages/shared/src/components/icons/InlineCode/outlined.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/shared/src/components/icons/Maximize/filled.svg b/packages/shared/src/components/icons/Maximize/filled.svg
new file mode 100644
index 00000000000..556f0c46199
--- /dev/null
+++ b/packages/shared/src/components/icons/Maximize/filled.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/packages/shared/src/components/icons/Maximize/index.tsx b/packages/shared/src/components/icons/Maximize/index.tsx
new file mode 100644
index 00000000000..f8e06d49f33
--- /dev/null
+++ b/packages/shared/src/components/icons/Maximize/index.tsx
@@ -0,0 +1,10 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+import FilledIcon from './filled.svg';
+
+export const MaximizeIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/Maximize/outlined.svg b/packages/shared/src/components/icons/Maximize/outlined.svg
new file mode 100644
index 00000000000..fe2fdd19b59
--- /dev/null
+++ b/packages/shared/src/components/icons/Maximize/outlined.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/packages/shared/src/components/icons/Minimize/filled.svg b/packages/shared/src/components/icons/Minimize/filled.svg
new file mode 100644
index 00000000000..35ff8743b9a
--- /dev/null
+++ b/packages/shared/src/components/icons/Minimize/filled.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/packages/shared/src/components/icons/Minimize/index.tsx b/packages/shared/src/components/icons/Minimize/index.tsx
new file mode 100644
index 00000000000..ed8622e24e8
--- /dev/null
+++ b/packages/shared/src/components/icons/Minimize/index.tsx
@@ -0,0 +1,10 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+import FilledIcon from './filled.svg';
+
+export const MinimizeIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/Minimize/outlined.svg b/packages/shared/src/components/icons/Minimize/outlined.svg
new file mode 100644
index 00000000000..cdf084f0bed
--- /dev/null
+++ b/packages/shared/src/components/icons/Minimize/outlined.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/packages/shared/src/components/icons/Strikethrough/index.tsx b/packages/shared/src/components/icons/Strikethrough/index.tsx
new file mode 100644
index 00000000000..46d99daa321
--- /dev/null
+++ b/packages/shared/src/components/icons/Strikethrough/index.tsx
@@ -0,0 +1,9 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import type { IconProps } from '../../Icon';
+import Icon from '../../Icon';
+import OutlinedIcon from './outlined.svg';
+
+export const StrikethroughIcon = (props: IconProps): ReactElement => (
+
+);
diff --git a/packages/shared/src/components/icons/Strikethrough/outlined.svg b/packages/shared/src/components/icons/Strikethrough/outlined.svg
new file mode 100644
index 00000000000..e133e7bc88e
--- /dev/null
+++ b/packages/shared/src/components/icons/Strikethrough/outlined.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/shared/src/components/icons/index.ts b/packages/shared/src/components/icons/index.ts
index f5be181e6ed..863f9e44294 100644
--- a/packages/shared/src/components/icons/index.ts
+++ b/packages/shared/src/components/icons/index.ts
@@ -29,6 +29,7 @@ export * from './ChecklistB';
export * from './Clear';
export * from './Click';
export * from './Codeberg';
+export * from './CodeBlock';
export * from './CodePen';
export * from './Coin';
export * from './CommunityPicksIcon';
@@ -72,10 +73,15 @@ export * from './Hamburger';
export * from './Hammer';
export * from './Hashnode';
export * from './Hashtag';
+export * from './Heading1';
+export * from './Heading2';
+export * from './Heading3';
export * from './Home';
+export * from './HorizontalRule';
export * from './Hot';
export * from './Image';
export * from './Info';
+export * from './InlineCode';
export * from './Invite';
export * from './Italic';
export * from './JetBrains';
@@ -96,12 +102,14 @@ export * from './Magic';
export * from './Mail';
export * from './Markdown';
export * from './Mastodon';
+export * from './Maximize';
export * from './MedalBadge';
export * from './Megaphone';
export * from './Menu';
export * from './Merge';
export * from './Microsoft';
export * from './MiniClose';
+export * from './Minimize';
export * from './Minus';
export * from './Moon';
export * from './MoveTo';
@@ -152,6 +160,7 @@ export * from './Squad';
export * from './StackOverflow';
export * from './Star';
export * from './Story';
+export * from './Strikethrough';
export * from './Sun';
export * from './Telegram';
export * from './Terminal';
diff --git a/packages/shared/src/components/modals/common.tsx b/packages/shared/src/components/modals/common.tsx
index 4924a9db1cc..9cf0da2a267 100644
--- a/packages/shared/src/components/modals/common.tsx
+++ b/packages/shared/src/components/modals/common.tsx
@@ -48,6 +48,13 @@ const CreateSharedPostModal = dynamic(
),
);
+const SmartComposerModal = dynamic(
+ () =>
+ import(
+ /* webpackChunkName: "smartComposerModal" */ './post/SmartComposerModal'
+ ),
+);
+
const ReportPostModal = dynamic(
() =>
import(
@@ -484,6 +491,7 @@ export const modals = {
[LazyModal.ReadingHistory]: ReadingHistoryModal,
[LazyModal.SquadPromotion]: SquadPromotionModal,
[LazyModal.CreateSharedPost]: CreateSharedPostModal,
+ [LazyModal.SmartComposer]: SmartComposerModal,
[LazyModal.ReportPost]: ReportPostModal,
[LazyModal.ReportComment]: ReportCommentModal,
[LazyModal.SquadNotifications]: SquadNotificationsModal,
diff --git a/packages/shared/src/components/modals/common/types.ts b/packages/shared/src/components/modals/common/types.ts
index 87b53bf16f5..8dbaf6f45b6 100644
--- a/packages/shared/src/components/modals/common/types.ts
+++ b/packages/shared/src/components/modals/common/types.ts
@@ -32,6 +32,7 @@ export enum LazyModal {
ReadingHistory = 'readingHistory',
SquadPromotion = 'squadPromotion',
CreateSharedPost = 'createSharedPost',
+ SmartComposer = 'smartComposer',
ReasonSelection = 'reasonSelection',
ReportPost = 'reportPost',
ReportComment = 'reportComment',
diff --git a/packages/shared/src/components/modals/post/SmartComposerModal.tsx b/packages/shared/src/components/modals/post/SmartComposerModal.tsx
new file mode 100644
index 00000000000..877e16f26de
--- /dev/null
+++ b/packages/shared/src/components/modals/post/SmartComposerModal.tsx
@@ -0,0 +1,1682 @@
+import type { FormEvent, ReactElement, KeyboardEvent } from 'react';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { useRouter } from 'next/router';
+import classNames from 'classnames';
+import type { LazyModalCommonProps } from '../common/Modal';
+import { Modal } from '../common/Modal';
+import { ModalKind, ModalSize } from '../common/types';
+import type { RichTextInputRef } from '../../fields/RichTextInput';
+import RichTextInput from '../../fields/RichTextInput';
+import { WriteLinkPreview } from '../../post/write/WriteLinkPreview';
+import { WritePreviewSkeleton } from '../../post/write/WritePreviewSkeleton';
+import { useDebouncedUrl } from '../../../hooks/input';
+import { usePostToSquad, useViewSize, ViewSize } from '../../../hooks';
+import { useMultipleSourcePost } from '../../../features/squads/hooks/useMultipleSourcePost';
+import {
+ useSmartComposer,
+ cleanShareCommentary,
+ type SmartComposerMode,
+} from '../../../hooks/post/useSmartComposer';
+import { getComposerVariant } from './composer/registry';
+import type {
+ ComposerKind,
+ ComposerState,
+ StandupConfig,
+} from './composer/types';
+import {
+ DEFAULT_STANDUP_CONFIG,
+ StandupBody,
+} from './composer/variants/standup';
+import { KindModePicker } from './composer/KindModePicker';
+import { useCreateLiveRoom } from '../../../hooks/liveRooms/useCreateLiveRoom';
+import { LiveRoomMode } from '../../../graphql/liveRooms';
+import { useAuthContext } from '../../../contexts/AuthContext';
+import { useLogContext } from '../../../contexts/LogContext';
+import { useToastNotification } from '../../../hooks/useToastNotification';
+import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
+import { ProfileImageSize, ProfilePicture } from '../../ProfilePicture';
+import {
+ ArrowIcon,
+ CameraIcon,
+ InfoIcon,
+ MarkdownIcon,
+ MaximizeIcon,
+ MiniCloseIcon,
+ MinimizeIcon,
+ PlusIcon,
+ VIcon,
+} from '../../icons';
+import { TruncateText } from '../../utilities';
+import { SourceAvatar } from '../../profile/source';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuOptions,
+ DropdownMenuTrigger,
+} from '../../dropdown/DropdownMenu';
+import CloseButton from '../../CloseButton';
+import { LogEvent } from '../../../lib/log';
+import type { Squad } from '../../../graphql/sources';
+import { SourceType } from '../../../graphql/sources';
+import {
+ uploadContentImage,
+ imageSizeLimitMB,
+ acceptedTypesList,
+ ACCEPTED_TYPES,
+ type CreatePostInMultipleSourcesArgs,
+ type ExternalLinkPreview,
+} from '../../../graphql/posts';
+import { useFileInput } from '../../../hooks/utils/useFileInput';
+import { isAppleDevice } from '../../../lib/func';
+import { link as appLinks } from '../../../lib/links';
+import { Tooltip } from '../../tooltip/Tooltip';
+import { IconSize } from '../../Icon';
+import { labels } from '../../../lib/labels';
+
+const MAX_AUDIENCE_SQUADS = 3;
+const MAX_TITLE_LENGTH = 250;
+const MAX_BODY_LENGTH = 10_000;
+const MAX_POLL_OPTIONS = 4;
+const MIN_POLL_OPTIONS = 2;
+const MAX_POLL_OPTION_LENGTH = 35;
+const DEFAULT_POLL_DURATION_DAYS = 7;
+
+const BODY_PLACEHOLDER = 'Share a link or write something...';
+const TITLE_PLACEHOLDER = 'Post title...';
+const POLL_QUESTION_PLACEHOLDER = 'Ask a question...';
+
+const POLL_DURATION_OPTIONS: Array<{
+ label: string;
+ value: number | undefined;
+}> = [
+ { label: '1 day', value: 1 },
+ { label: '3 days', value: 3 },
+ { label: '7 days', value: 7 },
+ { label: '14 days', value: 14 },
+ { label: '30 days', value: 30 },
+ { label: 'No end date', value: undefined },
+];
+
+export interface SmartComposerModalProps extends LazyModalCommonProps {
+ initialUrl?: string;
+ initialBody?: string;
+ initialSquadHandle?: string;
+ preview?: ExternalLinkPreview;
+}
+
+const buildEscalationParams = ({
+ title,
+ body,
+ url,
+ audience,
+}: {
+ title?: string;
+ body: string;
+ url?: string;
+ audience?: Squad;
+}): string => {
+ const params = new URLSearchParams();
+ if (audience?.handle) {
+ params.set('sid', audience.handle);
+ }
+ if (title) {
+ params.set('title', title);
+ }
+ if (body) {
+ params.set('body', body);
+ }
+ if (url) {
+ params.set('link', url);
+ }
+ if (url) {
+ params.set('share', 'true');
+ }
+ return params.toString();
+};
+
+interface AudienceChipProps {
+ selectedIds: string[];
+ options: Squad[];
+ onChange: (ids: string[]) => void;
+ disabled?: boolean;
+}
+
+const isUserAudience = (squad: Squad | undefined): boolean =>
+ (squad?.type as SourceType) === SourceType.User;
+
+const AudienceAvatarStack = ({
+ audiences,
+}: {
+ audiences: Squad[];
+}): ReactElement | null => {
+ const visible = audiences.slice(0, 3);
+ if (visible.length === 0) {
+ return null;
+ }
+ return (
+
+ {visible.map((audience) => (
+
+ ))}
+
+ );
+};
+
+const AudienceChip = ({
+ selectedIds,
+ options,
+ onChange,
+ disabled,
+}: AudienceChipProps): ReactElement | null => {
+ const [open, setOpen] = useState(false);
+
+ const selectedAudiences = useMemo(
+ () =>
+ options.filter(
+ (option) => !!option.id && selectedIds.includes(option.id),
+ ),
+ [options, selectedIds],
+ );
+ const primary = selectedAudiences[0];
+
+ if (!primary) {
+ return null;
+ }
+
+ const isMulti = selectedAudiences.length > 1;
+ const showChevron = options.length > 1 && !disabled;
+ const triggerLabel = (() => {
+ if (!isMulti) {
+ return isUserAudience(primary) ? 'Everyone' : primary.name;
+ }
+ return `${selectedAudiences.length} audiences`;
+ })();
+ const showSingleAvatar = !isMulti && !isUserAudience(primary);
+
+ const userOptionId = options.find(isUserAudience)?.id;
+ const isEveryoneSelected =
+ !!userOptionId && selectedIds.includes(userOptionId);
+ const selectedSquadCount = selectedAudiences.filter(
+ (audience) => !isUserAudience(audience),
+ ).length;
+ const isAtSquadLimit = selectedSquadCount >= MAX_AUDIENCE_SQUADS;
+
+ const toggleOption = (option: Squad) => {
+ const optionId = option.id;
+ if (!optionId) {
+ return;
+ }
+
+ if (isUserAudience(option)) {
+ // Everyone toggles on/off independently of squads. When toggled
+ // off it falls back to whatever squads are selected; if nothing
+ // would remain selected we keep Everyone (last-resort floor).
+ if (isEveryoneSelected) {
+ if (selectedSquadCount === 0) {
+ return;
+ }
+ onChange(selectedIds.filter((id) => id !== optionId));
+ return;
+ }
+ onChange([...selectedIds, optionId]);
+ return;
+ }
+
+ if (selectedIds.includes(optionId)) {
+ const remaining = selectedIds.filter((id) => id !== optionId);
+ if (remaining.length === 0) {
+ onChange(userOptionId ? [userOptionId] : [optionId]);
+ return;
+ }
+ onChange(remaining);
+ return;
+ }
+
+ if (isAtSquadLimit) {
+ return;
+ }
+
+ // Selecting the FIRST squad while only Everyone was selected —
+ // Everyone gets unchecked by default (it was the placeholder).
+ // Subsequent clicks add squads alongside.
+ const isFirstSquadFromDefault =
+ isEveryoneSelected && selectedSquadCount === 0;
+ const baseIds = isFirstSquadFromDefault
+ ? selectedIds.filter((id) => id !== userOptionId)
+ : selectedIds;
+ onChange([...baseIds, optionId]);
+ };
+
+ const handleReset = () => {
+ if (userOptionId && !isEveryoneSelected) {
+ onChange([userOptionId]);
+ }
+ };
+
+ const canReset = !isEveryoneSelected || selectedSquadCount > 0;
+
+ return (
+
+
+
+
+ {showChevron && (
+
+
+ Post to
+
+
+
+ {options.map((option) => {
+ const optionLabel = isUserAudience(option)
+ ? 'Everyone'
+ : option.name;
+ const isSelected = !!option.id && selectedIds.includes(option.id);
+ const reachedLimit =
+ !isSelected && !isUserAudience(option) && isAtSquadLimit;
+ return (
+ {
+ event.preventDefault();
+ if (reachedLimit) {
+ return;
+ }
+ toggleOption(option);
+ }}
+ disabled={reachedLimit}
+ aria-checked={isSelected}
+ className="!h-9 gap-2 !overflow-visible !px-2"
+ >
+
+
+ {optionLabel}
+
+
+ {isSelected && (
+
+ )}
+
+
+ );
+ })}
+
+ {isAtSquadLimit && (
+
+
+
+ You can post to up to {MAX_AUDIENCE_SQUADS} squads
+
+
+ )}
+
+ )}
+
+ );
+};
+
+interface FullEditorBannerProps {
+ onEscalate: () => void;
+ onDismiss: () => void;
+}
+
+const FullEditorBanner = ({
+ onEscalate,
+ onDismiss,
+}: FullEditorBannerProps): ReactElement => (
+
+
+
+
+);
+
+const SpamWarningBanner = (): ReactElement => (
+
+
+ {labels.postCreation.warnings.spammyPosts}
+
+);
+
+interface PollOptionsEditorProps {
+ options: string[];
+ onChange: (options: string[]) => void;
+}
+
+const PollOptionsEditor = ({
+ options,
+ onChange,
+}: PollOptionsEditorProps): ReactElement => {
+ const updateOption = useCallback(
+ (index: number, value: string) => {
+ const next = [...options];
+ next[index] = value;
+ onChange(next);
+ },
+ [options, onChange],
+ );
+
+ const removeOption = useCallback(
+ (index: number) => {
+ if (options.length <= MIN_POLL_OPTIONS) {
+ return;
+ }
+ onChange(options.filter((_, i) => i !== index));
+ },
+ [options, onChange],
+ );
+
+ const addOption = useCallback(() => {
+ if (options.length >= MAX_POLL_OPTIONS) {
+ return;
+ }
+ onChange([...options, '']);
+ }, [options, onChange]);
+
+ return (
+
+ {options.map((option, index) => {
+ const canRemove = options.length > MIN_POLL_OPTIONS;
+ const placeholder = `Option ${index + 1}${
+ index < MIN_POLL_OPTIONS ? '' : ' (optional)'
+ }`;
+ return (
+
+
+ {index + 1}.
+
+ updateOption(index, e.currentTarget.value)}
+ className="flex-1 bg-transparent text-text-primary outline-none typo-callout placeholder:text-text-quaternary"
+ aria-label={`Poll option ${index + 1}`}
+ />
+
+ {MAX_POLL_OPTION_LENGTH - option.length}
+
+ {canRemove && (
+
+ }
+ onClick={() => removeOption(index)}
+ aria-label={`Remove option ${index + 1}`}
+ />
+
+ )}
+
+ );
+ })}
+ {options.length < MAX_POLL_OPTIONS && (
+
}
+ onClick={addOption}
+ className="self-start"
+ >
+ Add option
+
+ )}
+
+ );
+};
+
+interface PollDurationSelectProps {
+ value: number | undefined;
+ onChange: (value: number | undefined) => void;
+}
+
+const PollDurationSelect = ({
+ value,
+ onChange,
+}: PollDurationSelectProps): ReactElement => {
+ const [open, setOpen] = useState(false);
+ const selected =
+ POLL_DURATION_OPTIONS.find((opt) => opt.value === value) ??
+ POLL_DURATION_OPTIONS[2];
+
+ return (
+
+
Poll closes after
+
+
+
+
+
+ ({
+ label: opt.label,
+ action: () => {
+ onChange(opt.value);
+ setOpen(false);
+ },
+ }))}
+ />
+
+
+
+ );
+};
+
+const useSubmitShortcutLabel = () => {
+ const [label, setLabel] = useState('Ctrl + Enter');
+ useEffect(() => {
+ setLabel(isAppleDevice() ? '⌘ + Enter' : 'Ctrl + Enter');
+ }, []);
+ return label;
+};
+
+interface CoverImageDisplayProps {
+ src: string;
+ onRemove: () => void;
+ onReplace?: () => void;
+ isUploading?: boolean;
+}
+
+const ConditionalImageWrapper = ({
+ onReplace,
+ children,
+}: {
+ onReplace?: () => void;
+ children: ReactElement;
+}): ReactElement => {
+ if (!onReplace) {
+ return children;
+ }
+ return (
+
+ );
+};
+
+const AttachmentRemoveButton = ({
+ onClick,
+ label,
+}: {
+ onClick: () => void;
+ label: string;
+}): ReactElement => (
+
+ }
+ onClick={onClick}
+ aria-label={label}
+ className="absolute right-3 top-3 z-1 !rounded-full !bg-surface-invert !text-text-primary !shadow-3 hover:!bg-text-primary hover:!text-surface-invert"
+ />
+
+);
+
+const CoverImageDisplay = ({
+ src,
+ onRemove,
+ onReplace,
+ isUploading,
+}: CoverImageDisplayProps): ReactElement => (
+
+
+
+
+
+
+);
+
+export function SmartComposerModal({
+ onRequestClose,
+ initialUrl,
+ initialBody,
+ initialSquadHandle,
+ preview: initialPreview,
+ ...props
+}: SmartComposerModalProps): ReactElement {
+ const router = useRouter();
+ const { user } = useAuthContext();
+ const { logEvent } = useLogContext();
+ const { displayToast } = useToastNotification();
+ const isLaptop = useViewSize(ViewSize.Laptop);
+ const richTextRef = useRef
(null);
+ const fileInputRef = useRef(null);
+ const titleInputRef = useRef(null);
+ const submitShortcut = useSubmitShortcutLabel();
+ const titleStartedRef = useRef(false);
+ const bodyStartedRef = useRef(false);
+
+ const seedBody = useMemo(() => {
+ if (initialBody) {
+ return initialBody;
+ }
+ if (initialUrl) {
+ return initialUrl;
+ }
+ return '';
+ }, [initialBody, initialUrl]);
+
+ const [body, setBody] = useState(seedBody);
+ const [title, setTitle] = useState('');
+ const [titleManuallyEdited, setTitleManuallyEdited] = useState(false);
+ const [titleExpanded, setTitleExpanded] = useState(false);
+ const [coverImage, setCoverImage] = useState<{
+ base64: string;
+ file?: File;
+ uploadedUrl?: string;
+ } | null>(null);
+ const [isUploadingCover, setIsUploadingCover] = useState(false);
+ const [selectedAudienceIds, setSelectedAudienceIds] = useState([]);
+ const [dismissedPreviewUrl, setDismissedPreviewUrl] = useState(
+ null,
+ );
+ const [isFullEditorBannerDismissed, setIsFullEditorBannerDismissed] =
+ useState(false);
+ const [isExpanded, setIsExpanded] = useState(false);
+ const [isMarkdownEditorMode, setIsMarkdownEditorMode] = useState(false);
+ const [kind, setKind] = useState('text');
+ const [pollOptions, setPollOptions] = useState(['', '']);
+ const [pollDuration, setPollDuration] = useState(
+ DEFAULT_POLL_DURATION_DAYS,
+ );
+ const [standupConfig, setStandupConfig] = useState(
+ DEFAULT_STANDUP_CONFIG,
+ );
+
+ const isPollMode = kind === 'poll';
+ const isStandupMode = kind === 'standup';
+ const variant = getComposerVariant(kind);
+
+ const { mutateAsync: createLiveRoom, isPending: isCreatingStandup } =
+ useCreateLiveRoom();
+
+ const {
+ mode,
+ detectedUrl,
+ audienceOptions,
+ defaultAudience,
+ rememberAudience,
+ } = useSmartComposer({
+ body,
+ isTitleManuallyEdited: titleManuallyEdited,
+ initialSquadHandle,
+ });
+
+ // Hydrate the multi-select with the inferred default audience once it's
+ // resolved. Subsequent user toggles take over.
+ useEffect(() => {
+ if (selectedAudienceIds.length > 0) {
+ return;
+ }
+ if (defaultAudience?.id) {
+ setSelectedAudienceIds([defaultAudience.id]);
+ }
+ }, [defaultAudience?.id, selectedAudienceIds.length]);
+
+ const selectedAudiences = useMemo(
+ () =>
+ audienceOptions.filter(
+ (option) => !!option.id && selectedAudienceIds.includes(option.id),
+ ),
+ [audienceOptions, selectedAudienceIds],
+ );
+ const audience = selectedAudiences[0] ?? defaultAudience;
+ const isMultiMode = selectedAudiences.length > 1;
+ const selectedSquadCountTopLevel = selectedAudiences.filter(
+ (selected) => !isUserAudience(selected),
+ ).length;
+ const showSpamWarningBanner = selectedSquadCountTopLevel > 1;
+ const previousModeRef = useRef(mode);
+ const hasLoggedOpenRef = useRef(false);
+
+ useEffect(() => {
+ if (hasLoggedOpenRef.current) {
+ return;
+ }
+ hasLoggedOpenRef.current = true;
+ logEvent({
+ event_name: LogEvent.OpenSmartComposer,
+ extra: JSON.stringify({ mode, hasInitialUrl: !!initialUrl }),
+ });
+ }, [logEvent, mode, initialUrl]);
+
+ useEffect(() => {
+ if (previousModeRef.current === mode) {
+ return;
+ }
+ previousModeRef.current = mode;
+ logEvent({
+ event_name: LogEvent.OpenSmartComposer,
+ target_id: mode,
+ extra: JSON.stringify({ inferred: true }),
+ });
+ }, [logEvent, mode]);
+
+ const isFreeform = mode === 'freeform';
+ const submissionMode: 'freeform' | 'share' | 'poll' = isPollMode
+ ? 'poll'
+ : mode;
+
+ const completeSubmit = useCallback(() => {
+ logEvent({
+ event_name: LogEvent.SubmitSmartComposer,
+ target_id: submissionMode,
+ });
+ if (audience?.id && !isMultiMode) {
+ rememberAudience(audience.id);
+ }
+ onRequestClose?.();
+ }, [
+ audience?.id,
+ isMultiMode,
+ logEvent,
+ onRequestClose,
+ rememberAudience,
+ submissionMode,
+ ]);
+
+ const onPostSuccess = useCallback(() => {
+ if (submissionMode === 'freeform') {
+ displayToast('✅ Your post has been created!');
+ } else if (submissionMode === 'poll') {
+ displayToast('✅ Your poll is live!');
+ }
+ completeSubmit();
+ }, [completeSubmit, displayToast, submissionMode]);
+
+ const onModerationSuccess = useCallback(() => {
+ displayToast('✅ Your post has been submitted for moderation');
+ completeSubmit();
+ }, [completeSubmit, displayToast]);
+
+ const {
+ getLinkPreview,
+ isLoadingPreview,
+ preview,
+ isPosting,
+ onSubmitFreeformPost,
+ onSubmitPollPost,
+ onSubmitPost,
+ onUpdatePreview,
+ } = usePostToSquad({
+ initialPreview,
+ onPostSuccess,
+ onSourcePostModerationSuccess: onModerationSuccess,
+ getSharedPostSuccessToast: ({ isUpdate }) => ({
+ message: isUpdate
+ ? 'The post has been updated'
+ : '✅ Your post has been shared',
+ }),
+ });
+
+ const { onCreate: onCreateMultiSourcePost, isPending: isMultiPosting } =
+ useMultipleSourcePost({
+ onSuccess: () => {
+ if (submissionMode === 'poll') {
+ displayToast('✅ Your poll is live!');
+ } else if (submissionMode === 'freeform') {
+ displayToast('✅ Your post has been created!');
+ } else {
+ displayToast('✅ Your post has been shared');
+ }
+ completeSubmit();
+ },
+ onError: () => {
+ displayToast('Failed to publish to one or more squads');
+ },
+ });
+
+ useEffect(() => {
+ if (mode !== 'share') {
+ return;
+ }
+ if (titleManuallyEdited) {
+ return;
+ }
+ if (!preview?.title) {
+ return;
+ }
+ setTitle(preview.title);
+ }, [mode, preview?.title, titleManuallyEdited]);
+
+ useEffect(() => {
+ if (mode === 'freeform') {
+ titleInputRef.current?.focus();
+ }
+ }, [mode]);
+
+ useEffect(() => {
+ if (titleExpanded) {
+ titleInputRef.current?.focus();
+ }
+ }, [titleExpanded]);
+
+ useEffect(() => {
+ if (isPollMode) {
+ titleInputRef.current?.focus();
+ }
+ }, [isPollMode]);
+
+ const fetchPreview = useCallback(
+ (value?: string) => {
+ if (!value) {
+ return;
+ }
+ getLinkPreview(value).catch(() => {
+ // surfaced via usePostToSquad's toast handler
+ });
+ },
+ [getLinkPreview],
+ );
+
+ const [checkUrl] = useDebouncedUrl(fetchPreview, (value) => {
+ if (!value) {
+ return false;
+ }
+ if (!preview) {
+ return true;
+ }
+ return preview.url !== value && preview.permalink !== value;
+ });
+
+ useEffect(() => {
+ if (!detectedUrl) {
+ // Clear preview when URL is removed from body
+ if (preview?.url) {
+ onUpdatePreview({});
+ }
+ return;
+ }
+ checkUrl(detectedUrl.url);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [detectedUrl?.url]);
+
+ const handleEscalate = useCallback(() => {
+ logEvent({
+ event_name: LogEvent.EscalateSmartComposer,
+ target_id: 'full',
+ });
+ const search = buildEscalationParams({
+ title: title.trim() || undefined,
+ body,
+ url: detectedUrl?.url,
+ audience,
+ });
+ const target = `${appLinks.post.create}${search ? `?${search}` : ''}`;
+ router.push(target);
+ onRequestClose?.();
+ }, [
+ audience,
+ body,
+ detectedUrl?.url,
+ logEvent,
+ onRequestClose,
+ router,
+ title,
+ ]);
+
+ const handleClose = useCallback(
+ (e?: React.MouseEvent | React.KeyboardEvent) => {
+ logEvent({
+ event_name: LogEvent.DismissSmartComposer,
+ target_id: submissionMode,
+ });
+ onRequestClose?.(e);
+ },
+ [logEvent, onRequestClose, submissionMode],
+ );
+
+ const handleToggleExpanded = useCallback(() => {
+ setIsExpanded((prev) => {
+ const next = !prev;
+ logEvent({
+ event_name: LogEvent.ExpandSmartComposer,
+ target_id: next ? 'expanded' : 'collapsed',
+ });
+ return next;
+ });
+ }, [logEvent]);
+
+ const handleKindChange = useCallback(
+ (next: ComposerKind) => {
+ setKind((prev) => {
+ if (prev === next) {
+ return prev;
+ }
+ logEvent({
+ event_name: LogEvent.ToggleModeSmartComposer,
+ target_id: next,
+ });
+ return next;
+ });
+ },
+ [logEvent],
+ );
+
+ const handleAudienceChange = useCallback(
+ (ids: string[]) => {
+ setSelectedAudienceIds(ids);
+ logEvent({
+ event_name: LogEvent.ChangeAudienceSmartComposer,
+ target_id: ids.length > 1 ? 'multi' : 'single',
+ extra: JSON.stringify({ count: ids.length }),
+ });
+ },
+ [logEvent],
+ );
+
+ const adjustTitleHeight = useCallback(() => {
+ const node = titleInputRef.current;
+ if (!node) {
+ return;
+ }
+ node.style.height = 'auto';
+ node.style.height = `${node.scrollHeight}px`;
+ }, []);
+
+ useEffect(() => {
+ adjustTitleHeight();
+ }, [title, adjustTitleHeight]);
+
+ const onTitleChange = useCallback(
+ (event: React.FormEvent) => {
+ const value = event.currentTarget.value.replace(/\n/g, '');
+ setTitle(value);
+ setTitleManuallyEdited(true);
+ if (!titleStartedRef.current && value.length > 0) {
+ titleStartedRef.current = true;
+ logEvent({ event_name: LogEvent.StartTitleSmartComposer });
+ }
+ },
+ [logEvent],
+ );
+
+ const onTitleKeyDown = useCallback(
+ (event: KeyboardEvent) => {
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
+ event.preventDefault();
+ const { form } = event.currentTarget;
+ form?.requestSubmit();
+ return;
+ }
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ richTextRef.current?.focus();
+ }
+ },
+ [],
+ );
+
+ const onCoverFileChange = useCallback(
+ (file?: File) => {
+ if (!file) {
+ setCoverImage(null);
+ return;
+ }
+ const reader = new FileReader();
+ reader.onload = () => {
+ setCoverImage({ base64: reader.result as string, file });
+ };
+ reader.readAsDataURL(file);
+ logEvent({
+ event_name: LogEvent.AttachCoverSmartComposer,
+ extra: JSON.stringify({ size: file.size, type: file.type }),
+ });
+ },
+ [logEvent],
+ );
+
+ const { onFileChange: handleFile } = useFileInput({
+ acceptedTypes: acceptedTypesList,
+ limitMb: imageSizeLimitMB,
+ onChange: (_, file) => onCoverFileChange(file),
+ });
+
+ const onFileInputChange = useCallback(
+ (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ handleFile(file);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ },
+ [handleFile],
+ );
+
+ const removeCover = useCallback(() => {
+ setCoverImage(null);
+ }, []);
+
+ const validPollOptions = useMemo(
+ () => pollOptions.map((opt) => opt.trim()).filter(Boolean),
+ [pollOptions],
+ );
+
+ const composerState: ComposerState = useMemo(
+ () => ({
+ kind,
+ title,
+ body,
+ coverImage: coverImage
+ ? {
+ url: coverImage.uploadedUrl ?? '',
+ ...(coverImage.file ? { file: coverImage.file } : {}),
+ }
+ : null,
+ preview,
+ detectedUrl,
+ pollOptions,
+ pollDurationDays: pollDuration,
+ standup: standupConfig,
+ }),
+ [
+ body,
+ coverImage,
+ detectedUrl,
+ kind,
+ pollDuration,
+ pollOptions,
+ preview,
+ standupConfig,
+ title,
+ ],
+ );
+
+ const variantValidation = variant.validate(composerState);
+
+ // Standup is a global event (not posted to a squad), so it skips the
+ // audience requirement that gates other variants.
+ const audienceRequired = !isStandupMode;
+ const isSubmitDisabled =
+ isPosting ||
+ isUploadingCover ||
+ isMultiPosting ||
+ isCreatingStandup ||
+ (audienceRequired && selectedAudienceIds.length === 0) ||
+ !variantValidation.isValid;
+
+ const ensureCoverUrl = useCallback(async (): Promise => {
+ if (!coverImage?.file) {
+ return coverImage?.uploadedUrl;
+ }
+ if (coverImage.uploadedUrl) {
+ return coverImage.uploadedUrl;
+ }
+ try {
+ setIsUploadingCover(true);
+ const uploaded = await uploadContentImage(coverImage.file);
+ setCoverImage((prev) =>
+ prev ? { ...prev, uploadedUrl: uploaded } : prev,
+ );
+ return uploaded;
+ } catch (error) {
+ displayToast(
+ error instanceof Error ? error.message : 'Failed to upload cover image',
+ );
+ return undefined;
+ } finally {
+ setIsUploadingCover(false);
+ }
+ }, [coverImage, displayToast]);
+
+ const submit = useCallback(
+ async (event: FormEvent) => {
+ event.preventDefault();
+ if (isSubmitDisabled) {
+ return;
+ }
+
+ if (isStandupMode) {
+ try {
+ const joinToken = await createLiveRoom({
+ topic: standupConfig.topic.trim(),
+ mode: standupConfig.mode,
+ speakerLimit:
+ standupConfig.mode === LiveRoomMode.FreeForAll
+ ? standupConfig.speakerLimit
+ : undefined,
+ });
+ logEvent({
+ event_name: LogEvent.SubmitSmartComposer,
+ target_id: 'standup',
+ });
+ onRequestClose?.();
+ router.push(`/standups/${joinToken.room.id}`);
+ } catch (error) {
+ displayToast(
+ error instanceof Error ? error.message : 'Failed to start standup',
+ );
+ }
+ return;
+ }
+
+ // Multi-audience branch — drives every mode through
+ // createPostInMultipleSources. The args type marks `options` as
+ // required (for polls), but the API ignores it for non-poll modes,
+ // mirroring what /squads/create does at submit time.
+ if (isMultiMode && selectedAudienceIds.length > 0) {
+ if (isPollMode) {
+ await onCreateMultiSourcePost({
+ sourceIds: selectedAudienceIds,
+ title: title.trim(),
+ options: validPollOptions.map((text, order) => ({ text, order })),
+ ...(pollDuration != null ? { duration: pollDuration } : {}),
+ } as unknown as CreatePostInMultipleSourcesArgs);
+ return;
+ }
+
+ if (isFreeform) {
+ await onCreateMultiSourcePost({
+ sourceIds: selectedAudienceIds,
+ title: title.trim(),
+ content: body,
+ ...(coverImage?.file ? { image: coverImage.file } : {}),
+ } as unknown as CreatePostInMultipleSourcesArgs);
+ return;
+ }
+
+ const coverUrl = await ensureCoverUrl();
+ const url = preview?.finalUrl ?? preview?.url ?? detectedUrl?.url;
+ const customTitle = title.trim();
+ const cleanedBody = cleanShareCommentary(body, url);
+
+ if (preview?.id) {
+ await onCreateMultiSourcePost({
+ sourceIds: selectedAudienceIds,
+ sharedPostId: preview.id,
+ commentary: cleanedBody,
+ ...(customTitle ? { title: customTitle } : {}),
+ } as unknown as CreatePostInMultipleSourcesArgs);
+ return;
+ }
+
+ if (!url || !(customTitle || preview?.title)) {
+ displayToast('Invalid link');
+ return;
+ }
+
+ await onCreateMultiSourcePost({
+ sourceIds: selectedAudienceIds,
+ externalLink: url,
+ title: customTitle || preview?.title,
+ imageUrl: coverUrl ?? preview?.image,
+ commentary: cleanedBody,
+ } as unknown as CreatePostInMultipleSourcesArgs);
+ return;
+ }
+
+ if (!audience) {
+ return;
+ }
+
+ if (isPollMode) {
+ await onSubmitPollPost(
+ {
+ title: title.trim(),
+ options: validPollOptions,
+ duration: pollDuration,
+ },
+ audience,
+ );
+ return;
+ }
+
+ if (isFreeform) {
+ await onSubmitFreeformPost(
+ {
+ title: title.trim(),
+ content: body,
+ ...(coverImage?.file ? { image: coverImage.file } : {}),
+ },
+ audience,
+ );
+ return;
+ }
+
+ // Share single-squad path: keep existing behaviour (upload cover, then submit).
+ const coverUrl = await ensureCoverUrl();
+ if (coverImage?.file && !coverUrl) {
+ return;
+ }
+ if (coverUrl) {
+ onUpdatePreview({ ...preview, image: coverUrl });
+ }
+
+ const customTitle = title.trim();
+ if (customTitle && customTitle !== preview?.title) {
+ onUpdatePreview({
+ ...preview,
+ ...(coverUrl ? { image: coverUrl } : {}),
+ title: customTitle,
+ });
+ }
+
+ const shareUrl = preview?.finalUrl ?? preview?.url ?? detectedUrl?.url;
+ const cleanedBody = cleanShareCommentary(body, shareUrl);
+ await onSubmitPost(event, audience, cleanedBody);
+ },
+ [
+ audience,
+ body,
+ coverImage,
+ createLiveRoom,
+ detectedUrl?.url,
+ displayToast,
+ ensureCoverUrl,
+ isFreeform,
+ isMultiMode,
+ isPollMode,
+ isStandupMode,
+ isSubmitDisabled,
+ logEvent,
+ onCreateMultiSourcePost,
+ onRequestClose,
+ onSubmitFreeformPost,
+ onSubmitPollPost,
+ onSubmitPost,
+ onUpdatePreview,
+ pollDuration,
+ preview,
+ router,
+ selectedAudienceIds,
+ standupConfig,
+ title,
+ validPollOptions,
+ ],
+ );
+
+ const renderTitleField = () => {
+ const showInline =
+ isPollMode ||
+ isFreeform ||
+ titleExpanded ||
+ (mode === 'share' && titleManuallyEdited);
+
+ if (!showInline && mode === 'share' && preview?.title) {
+ return (
+
+ Auto: {preview.title}
+
+
+ );
+ }
+
+ if (!showInline) {
+ return null;
+ }
+
+ const placeholder = isPollMode
+ ? POLL_QUESTION_PLACEHOLDER
+ : TITLE_PLACEHOLDER;
+
+ return (
+
+ );
+ };
+
+ const onBodyValueUpdate = useCallback(
+ (value: string) => {
+ setBody(value);
+ if (!bodyStartedRef.current && value.trim().length > 0) {
+ bodyStartedRef.current = true;
+ logEvent({ event_name: LogEvent.StartBodySmartComposer });
+ }
+ },
+ [logEvent],
+ );
+
+ const onBodySubmit = useCallback(() => {
+ const form = document.getElementById('smart-composer-form');
+ if (form instanceof HTMLFormElement) {
+ form.requestSubmit();
+ }
+ }, []);
+
+ const showPreviewArea =
+ !!detectedUrl &&
+ !coverImage &&
+ !isPollMode &&
+ detectedUrl.url !== dismissedPreviewUrl;
+ const showCoverArea = !!coverImage && !isPollMode;
+
+ const dismissPreview = useCallback(() => {
+ if (detectedUrl) {
+ setDismissedPreviewUrl(detectedUrl.url);
+ }
+ }, [detectedUrl]);
+
+ const bodyPlaceholder = (() => {
+ if (coverImage) {
+ return 'Add a caption to go with your image (optional)...';
+ }
+ return BODY_PLACEHOLDER;
+ })();
+
+ const fullEditorBanner = isFullEditorBannerDismissed ? null : (
+ setIsFullEditorBannerDismissed(true)}
+ />
+ );
+
+ const handleToggleMarkdownMode = useCallback(() => {
+ richTextRef.current?.toggleMarkdownMode();
+ }, []);
+
+ const markdownToggleButton = (
+
+ }
+ pressed={isMarkdownEditorMode}
+ onClick={handleToggleMarkdownMode}
+ aria-label={
+ isMarkdownEditorMode ? 'Switch to rich text' : 'Switch to Markdown'
+ }
+ aria-pressed={isMarkdownEditorMode}
+ />
+
+ );
+
+ const coverImagePlaceholder = !isPollMode && !coverImage && (
+
+
+
+ );
+
+ // Poll/Standup are picked from KindTabs at the top of the modal so the
+ // formatting toolbar stays focused on text-formatting actions only.
+ const inlineExtraActions = null;
+
+ const submitLabel = variant.submitLabel(composerState);
+ const postButton = (
+
+
+
+ );
+
+ const coverImageBlock = showCoverArea ? (
+
+ fileInputRef.current?.click()}
+ isUploading={isUploadingCover}
+ />
+
+ ) : null;
+
+ const linkPreviewBlock = showPreviewArea ? (
+
+
+ {isLoadingPreview &&
}
+ {!isLoadingPreview && preview?.title && (
+
+ )}
+
+
+
+ ) : null;
+
+ return (
+
+
+
+ );
+}
+
+export default SmartComposerModal;
diff --git a/packages/shared/src/components/modals/post/composer/KindModePicker.tsx b/packages/shared/src/components/modals/post/composer/KindModePicker.tsx
new file mode 100644
index 00000000000..47574de0135
--- /dev/null
+++ b/packages/shared/src/components/modals/post/composer/KindModePicker.tsx
@@ -0,0 +1,125 @@
+import type { ReactElement } from 'react';
+import React, { cloneElement, useState } from 'react';
+import classNames from 'classnames';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '../../../dropdown/DropdownMenu';
+import { IconSize } from '../../../Icon';
+import { composerVariants } from './registry';
+import type { ComposerKind } from './types';
+
+interface KindModePickerProps {
+ kind: ComposerKind;
+ onKindChange: (next: ComposerKind) => void;
+ disabled?: boolean;
+}
+
+/** Display order for the mode dropdown. */
+const MENU_ORDER: ComposerKind[] = ['text', 'poll', 'standup'];
+
+/**
+ * Neutral Subtle chip — soft gray border, primary text/icon. Same design
+ * across all post types so the picker reads as a quiet, system-default
+ * affordance.
+ */
+const TRIGGER_CLASSES = {
+ border: 'border-border-subtlest-tertiary',
+ borderHover: 'hover:border-border-subtlest-secondary',
+ bgHover: 'hover:bg-surface-float',
+ bgOpen: 'bg-surface-float',
+ text: 'text-text-primary',
+};
+
+export const KindModePicker = ({
+ kind,
+ onKindChange,
+ disabled,
+}: KindModePickerProps): ReactElement => {
+ const [open, setOpen] = useState(false);
+ const variant = composerVariants[kind];
+
+ return (
+
+
+
+
+
+ {MENU_ORDER.map((optionKind) => {
+ const optionVariant = composerVariants[optionKind];
+ const isActive = kind === optionKind;
+ return (
+ {
+ event.preventDefault();
+ onKindChange(optionKind);
+ setOpen(false);
+ }}
+ aria-checked={isActive}
+ className="!gap-2"
+ >
+ {cloneElement(optionVariant.picker.icon, {
+ size: IconSize.XXSmall,
+ className: 'shrink-0 text-text-primary',
+ })}
+
+ {optionVariant.picker.label}
+
+
+ );
+ })}
+
+
+ );
+};
diff --git a/packages/shared/src/components/modals/post/composer/registry.ts b/packages/shared/src/components/modals/post/composer/registry.ts
new file mode 100644
index 00000000000..17695f04938
--- /dev/null
+++ b/packages/shared/src/components/modals/post/composer/registry.ts
@@ -0,0 +1,21 @@
+import type { ComposerKind, ComposerVariant } from './types';
+import { textVariant } from './variants/text';
+import { pollVariant } from './variants/poll';
+import { standupVariant } from './variants/standup';
+
+/**
+ * Single source of truth for composer variants. Adding a new post type
+ * is a one-file change here:
+ *
+ * 1. Create `composer/variants/.tsx` exporting a `ComposerVariant<''>`
+ * 2. Add `''` to `ComposerKind` in `types.ts`
+ * 3. Add the variant below.
+ */
+export const composerVariants: Record = {
+ text: textVariant,
+ poll: pollVariant,
+ standup: standupVariant,
+};
+
+export const getComposerVariant = (kind: ComposerKind): ComposerVariant =>
+ composerVariants[kind];
diff --git a/packages/shared/src/components/modals/post/composer/types.ts b/packages/shared/src/components/modals/post/composer/types.ts
new file mode 100644
index 00000000000..418d5fcd0cc
--- /dev/null
+++ b/packages/shared/src/components/modals/post/composer/types.ts
@@ -0,0 +1,136 @@
+import type { ReactElement } from 'react';
+import type { DetectedUrl } from '../../../../hooks/post/useSmartComposer';
+import type { ExternalLinkPreview } from '../../../../graphql/posts';
+import type { LiveRoomMode } from '../../../../graphql/liveRooms';
+
+/**
+ * The kind a user is composing. New post types are added by dropping a file
+ * into `composer/variants/` and registering it in `registry.ts`.
+ *
+ * Note: `'text'` covers both freeform and shared-link posts. The `'text'`
+ * variant decides which submission shape to produce based on whether a URL
+ * was detected in the body.
+ */
+export type ComposerKind = 'text' | 'poll' | 'standup';
+
+export interface StandupConfig {
+ topic: string;
+ mode: LiveRoomMode;
+ /** Required when `mode === LiveRoomMode.FreeForAll`. */
+ speakerLimit?: number;
+}
+
+export interface ComposerCoverImage {
+ url: string;
+ file?: File;
+}
+
+export interface ComposerState {
+ kind: ComposerKind;
+ title: string;
+ body: string;
+ coverImage?: ComposerCoverImage | null;
+ preview?: ExternalLinkPreview | null;
+ detectedUrl?: DetectedUrl | null;
+ pollOptions: string[];
+ pollDurationDays?: number;
+ standup: StandupConfig;
+}
+
+/**
+ * Resolved context that the shell hands to a variant at submit time. The shell
+ * is responsible for any async preparation (cover upload, etc.) so that
+ * `serialize` can stay synchronous and pure.
+ */
+export interface ComposerSerializeContext {
+ /** Cover image URL, already uploaded to storage. */
+ coverImageUrl?: string | null;
+ /** Single-source audience id (set when posting to one squad). */
+ audienceId?: string;
+ /** Multi-source audience ids (set when posting to multiple squads). */
+ audienceIds?: string[];
+}
+
+export type ComposerSubmission =
+ | {
+ kind: 'text-freeform';
+ payload: {
+ title: string;
+ content: string;
+ image?: File;
+ };
+ }
+ | {
+ kind: 'text-share-existing';
+ payload: {
+ sharedPostId: string;
+ commentary: string;
+ title?: string;
+ };
+ }
+ | {
+ kind: 'text-share-external';
+ payload: {
+ externalLink: string;
+ title: string;
+ imageUrl?: string;
+ commentary: string;
+ };
+ }
+ | {
+ kind: 'poll';
+ payload: {
+ title: string;
+ options: string[];
+ durationDays?: number;
+ };
+ }
+ | {
+ kind: 'standup';
+ payload: {
+ topic: string;
+ mode: LiveRoomMode;
+ speakerLimit?: number;
+ };
+ };
+
+export interface ComposerValidationResult {
+ isValid: boolean;
+ /** Optional reason surfaced to telemetry/devs; not user-facing copy. */
+ reason?: string;
+}
+
+/**
+ * Lightweight context passed to a variant's `isEnabled` check. Add fields here
+ * (feature flags, user role, audience permissions) as variants need them.
+ */
+export interface ComposerEnabledContext {
+ isPlus: boolean;
+}
+
+export interface ComposerVariantPicker {
+ label: string;
+ icon: ReactElement;
+ shortcut?: string;
+ description?: string;
+}
+
+export interface ComposerVariant {
+ kind: K;
+ picker: ComposerVariantPicker;
+ /** Whether this variant should appear in the picker for the current user. */
+ isEnabled: (ctx: ComposerEnabledContext) => boolean;
+ /** Action-button label, e.g. "Post" or "Go live". */
+ submitLabel: (state: ComposerState) => string;
+ validate: (state: ComposerState) => ComposerValidationResult;
+ /**
+ * Returns the submission payload for the active state, or `null` if the
+ * state cannot be serialized (e.g. share variant without a resolvable URL).
+ * `null` should normally never happen — callers should always run
+ * `validate` first.
+ */
+ serialize: (
+ state: ComposerState,
+ ctx: ComposerSerializeContext,
+ ) => ComposerSubmission | null;
+}
diff --git a/packages/shared/src/components/modals/post/composer/variants/poll.tsx b/packages/shared/src/components/modals/post/composer/variants/poll.tsx
new file mode 100644
index 00000000000..30d362aab47
--- /dev/null
+++ b/packages/shared/src/components/modals/post/composer/variants/poll.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import { AnalyticsIcon } from '../../../../icons';
+import type { ComposerVariant } from '../types';
+
+const MIN_POLL_OPTIONS = 2;
+
+const getValidOptions = (options: string[]): string[] =>
+ options.map((option) => option.trim()).filter((option) => option.length > 0);
+
+export const pollVariant: ComposerVariant<'poll'> = {
+ kind: 'poll',
+ picker: {
+ label: 'Poll',
+ icon: ,
+ description: 'Ask a question with multiple choices.',
+ },
+ isEnabled: () => true,
+ submitLabel: () => 'Post',
+ validate: (state) => {
+ if (!state.title.trim()) {
+ return { isValid: false, reason: 'missing-question' };
+ }
+ if (getValidOptions(state.pollOptions).length < MIN_POLL_OPTIONS) {
+ return { isValid: false, reason: 'not-enough-options' };
+ }
+ return { isValid: true };
+ },
+ serialize: (state) => {
+ const options = getValidOptions(state.pollOptions);
+ return {
+ kind: 'poll',
+ payload: {
+ title: state.title.trim(),
+ options,
+ ...(state.pollDurationDays != null
+ ? { durationDays: state.pollDurationDays }
+ : {}),
+ },
+ };
+ },
+};
diff --git a/packages/shared/src/components/modals/post/composer/variants/standup.tsx b/packages/shared/src/components/modals/post/composer/variants/standup.tsx
new file mode 100644
index 00000000000..53ec91f1b4c
--- /dev/null
+++ b/packages/shared/src/components/modals/post/composer/variants/standup.tsx
@@ -0,0 +1,201 @@
+import type { ReactElement } from 'react';
+import React, { useCallback } from 'react';
+import classNames from 'classnames';
+import { MegaphoneIcon } from '../../../../icons';
+import { TextField } from '../../../../fields/TextField';
+import {
+ Typography,
+ TypographyColor,
+ TypographyType,
+} from '../../../../typography/Typography';
+import { LiveRoomMode } from '../../../../../graphql/liveRooms';
+import type { ComposerVariant, StandupConfig } from '../types';
+
+const MAX_TOPIC_LENGTH = 280;
+const DEFAULT_FREE_FOR_ALL_SPEAKER_LIMIT = 4;
+
+export const DEFAULT_STANDUP_CONFIG: StandupConfig = {
+ topic: '',
+ mode: LiveRoomMode.Moderated,
+ speakerLimit: DEFAULT_FREE_FOR_ALL_SPEAKER_LIMIT,
+};
+
+const isStandupValid = (config: StandupConfig): boolean => {
+ const topic = config.topic.trim();
+ if (!topic || topic.length > MAX_TOPIC_LENGTH) {
+ return false;
+ }
+ if (config.mode === LiveRoomMode.FreeForAll) {
+ return (
+ typeof config.speakerLimit === 'number' &&
+ Number.isInteger(config.speakerLimit) &&
+ config.speakerLimit > 0
+ );
+ }
+ return true;
+};
+
+export const standupVariant: ComposerVariant<'standup'> = {
+ kind: 'standup',
+ picker: {
+ label: 'Standup',
+ icon: ,
+ description: 'Start a live audio room around a topic.',
+ },
+ isEnabled: () => true,
+ submitLabel: () => 'Start standup',
+ validate: (state) => {
+ if (!isStandupValid(state.standup)) {
+ return { isValid: false, reason: 'invalid-standup' };
+ }
+ return { isValid: true };
+ },
+ serialize: (state) => {
+ if (!isStandupValid(state.standup)) {
+ return null;
+ }
+ const { topic, mode, speakerLimit } = state.standup;
+ return {
+ kind: 'standup',
+ payload: {
+ topic: topic.trim(),
+ mode,
+ ...(mode === LiveRoomMode.FreeForAll && speakerLimit != null
+ ? { speakerLimit }
+ : {}),
+ },
+ };
+ },
+};
+
+interface StandupBodyProps {
+ config: StandupConfig;
+ onChange: (next: StandupConfig) => void;
+}
+
+const ModeOption = ({
+ value,
+ label,
+ description,
+ isActive,
+ onClick,
+}: {
+ value: LiveRoomMode;
+ label: string;
+ description: string;
+ isActive: boolean;
+ onClick: (value: LiveRoomMode) => void;
+}): ReactElement => (
+
+);
+
+export const StandupBody = ({
+ config,
+ onChange,
+}: StandupBodyProps): ReactElement => {
+ const handleTopicChange = useCallback(
+ (event: React.ChangeEvent) => {
+ onChange({ ...config, topic: event.target.value });
+ },
+ [config, onChange],
+ );
+
+ const handleModeChange = useCallback(
+ (mode: LiveRoomMode) => {
+ const speakerLimit =
+ mode === LiveRoomMode.FreeForAll && config.speakerLimit == null
+ ? DEFAULT_FREE_FOR_ALL_SPEAKER_LIMIT
+ : config.speakerLimit;
+ onChange({ ...config, mode, speakerLimit });
+ },
+ [config, onChange],
+ );
+
+ const handleSpeakerLimitChange = useCallback(
+ (event: React.ChangeEvent) => {
+ const next = event.target.value;
+ const parsed = next === '' ? undefined : Number(next);
+ onChange({
+ ...config,
+ speakerLimit:
+ parsed !== undefined && Number.isInteger(parsed) && parsed > 0
+ ? parsed
+ : undefined,
+ });
+ },
+ [config, onChange],
+ );
+
+ return (
+
+
+
+
+ Standup mode
+
+
+ Pick how the stage works — we'll spin up the standup right away.
+
+
+
+
+
+
+ {config.mode === LiveRoomMode.FreeForAll && (
+
+ )}
+
+ );
+};
diff --git a/packages/shared/src/components/modals/post/composer/variants/text.tsx b/packages/shared/src/components/modals/post/composer/variants/text.tsx
new file mode 100644
index 00000000000..85a2a3d3fc5
--- /dev/null
+++ b/packages/shared/src/components/modals/post/composer/variants/text.tsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import { EditIcon } from '../../../../icons';
+import { cleanShareCommentary } from '../../../../../hooks/post/useSmartComposer';
+import type {
+ ComposerState,
+ ComposerVariant,
+ ComposerSerializeContext,
+} from '../types';
+
+const hasResolvableShare = (state: ComposerState): boolean => {
+ if (!state.detectedUrl) {
+ return false;
+ }
+ return Boolean(state.preview?.id || state.preview?.title);
+};
+
+const buildSubmission = (
+ state: ComposerState,
+ ctx: ComposerSerializeContext,
+) => {
+ const customTitle = state.title.trim();
+ const { preview } = state;
+ const url = preview?.finalUrl ?? preview?.url ?? state.detectedUrl?.url;
+
+ if (state.detectedUrl && preview?.id) {
+ return {
+ kind: 'text-share-existing' as const,
+ payload: {
+ sharedPostId: preview.id,
+ commentary: cleanShareCommentary(state.body, url),
+ ...(customTitle ? { title: customTitle } : {}),
+ },
+ };
+ }
+
+ if (state.detectedUrl && url) {
+ return {
+ kind: 'text-share-external' as const,
+ payload: {
+ externalLink: url,
+ title: customTitle || preview?.title || '',
+ imageUrl: ctx.coverImageUrl ?? preview?.image ?? undefined,
+ commentary: cleanShareCommentary(state.body, url),
+ },
+ };
+ }
+
+ return {
+ kind: 'text-freeform' as const,
+ payload: {
+ title: customTitle,
+ content: state.body,
+ ...(state.coverImage?.file ? { image: state.coverImage.file } : {}),
+ },
+ };
+};
+
+export const textVariant: ComposerVariant<'text'> = {
+ kind: 'text',
+ picker: {
+ label: 'Text',
+ icon: ,
+ description: 'Share an article, link, image, or write your own.',
+ },
+ isEnabled: () => true,
+ submitLabel: () => 'Post',
+ validate: (state) => {
+ if (state.detectedUrl) {
+ if (!hasResolvableShare(state)) {
+ return { isValid: false, reason: 'share-not-resolvable' };
+ }
+ return { isValid: true };
+ }
+
+ if (!state.title.trim()) {
+ return { isValid: false, reason: 'missing-title' };
+ }
+
+ if (!state.body.trim()) {
+ return { isValid: false, reason: 'missing-body' };
+ }
+
+ return { isValid: true };
+ },
+ serialize: (state, ctx) => buildSubmission(state, ctx),
+};
diff --git a/packages/shared/src/components/post/SmartComposerDevToggle.tsx b/packages/shared/src/components/post/SmartComposerDevToggle.tsx
new file mode 100644
index 00000000000..49e80252828
--- /dev/null
+++ b/packages/shared/src/components/post/SmartComposerDevToggle.tsx
@@ -0,0 +1,58 @@
+import type { ReactElement } from 'react';
+import React from 'react';
+import classNames from 'classnames';
+import usePersistentContext from '../../hooks/usePersistentContext';
+import { FORCE_SMART_COMPOSER_KEY } from '../../hooks/post/useSmartComposerEnabled';
+import { isProductionAPI } from '../../lib/constants';
+import { useAuthContext } from '../../contexts/AuthContext';
+
+/**
+ * Tiny floating toggle (bottom-right) for QA/internal testing of the Smart
+ * Composer experiment.
+ *
+ * - Only renders against non-production APIs (staging, local, preview).
+ * - Persists its state in IndexedDB via `usePersistentContext`, shared with
+ * `useSmartComposerEnabled` so all entry points pick up the override.
+ * - Never rendered for logged-out users (matches the experiment surface).
+ */
+export const SmartComposerDevToggle = (): ReactElement | null => {
+ const { user } = useAuthContext();
+ const [forceEnabled, setForceEnabled, isFetched] =
+ usePersistentContext(FORCE_SMART_COMPOSER_KEY, false);
+
+ if (isProductionAPI || !user || !isFetched) {
+ return null;
+ }
+
+ const isOn = !!forceEnabled;
+
+ return (
+
+ );
+};
+
+export default SmartComposerDevToggle;
diff --git a/packages/shared/src/components/post/SmartComposerHotkey.tsx b/packages/shared/src/components/post/SmartComposerHotkey.tsx
new file mode 100644
index 00000000000..d2830c76645
--- /dev/null
+++ b/packages/shared/src/components/post/SmartComposerHotkey.tsx
@@ -0,0 +1,56 @@
+import { useMemo } from 'react';
+import { useRouter } from 'next/router';
+import { useAuthContext } from '../../contexts/AuthContext';
+import { useKeyboardNavigation } from '../../hooks/useKeyboardNavigation';
+import { useLazyModal } from '../../hooks/useLazyModal';
+import { useSmartComposerEnabled } from '../../hooks/post/useSmartComposerEnabled';
+import { useViewSize, ViewSize } from '../../hooks';
+import { LazyModal } from '../modals/common/types';
+
+/**
+ * Global `c` keyboard shortcut to open the Smart Composer popup.
+ * Mounted near the root of the app — only active when:
+ * - the user is logged in
+ * - we are on a desktop viewport
+ * - the feature flag is enabled
+ * - the user is not currently typing in another input
+ */
+export const SmartComposerHotkey = (): null => {
+ const { user } = useAuthContext();
+ const isLaptop = useViewSize(ViewSize.Laptop);
+ const router = useRouter();
+ const { openModal } = useLazyModal();
+ const { value: isEnabled } = useSmartComposerEnabled({
+ shouldEvaluate: !!user && isLaptop,
+ });
+
+ const events = useMemo<[string, () => void][]>(() => {
+ if (!isEnabled || !isLaptop || !user) {
+ return [];
+ }
+ return [
+ [
+ 'c',
+ () => {
+ const initialSquadHandle =
+ router.route === '/squads/[handle]'
+ ? (router.query.handle as string)
+ : undefined;
+ openModal({
+ type: LazyModal.SmartComposer,
+ props: { initialSquadHandle },
+ });
+ },
+ ],
+ ];
+ }, [isEnabled, isLaptop, openModal, router.query.handle, router.route, user]);
+
+ useKeyboardNavigation(typeof window === 'undefined' ? null : window, events, {
+ disableOnTags: ['input', 'textarea', 'select'],
+ disabledModalOpened: true,
+ });
+
+ return null;
+};
+
+export default SmartComposerHotkey;
diff --git a/packages/shared/src/components/post/write/CreatePostButton.tsx b/packages/shared/src/components/post/write/CreatePostButton.tsx
index 75ee24c78ab..d563e114b59 100644
--- a/packages/shared/src/components/post/write/CreatePostButton.tsx
+++ b/packages/shared/src/components/post/write/CreatePostButton.tsx
@@ -4,6 +4,9 @@ import { useRouter } from 'next/router';
import { link } from '../../../lib/links';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useActions, useSquad, useViewSize, ViewSize } from '../../../hooks';
+import { useSmartComposerEnabled } from '../../../hooks/post/useSmartComposerEnabled';
+import { useLazyModal } from '../../../hooks/useLazyModal';
+import { LazyModal } from '../../modals/common/types';
import { verifyPermission } from '../../../graphql/squads';
import { SourcePermissions } from '../../../graphql/sources';
import type {
@@ -43,6 +46,10 @@ export function CreatePostButton({
const isTablet = useViewSize(ViewSize.Tablet);
const isLaptop = useViewSize(ViewSize.Laptop);
const isLaptopL = useViewSize(ViewSize.LaptopL);
+ const { openModal } = useLazyModal();
+ const { value: isSmartComposerEnabled } = useSmartComposerEnabled({
+ shouldEvaluate: !!user && isLaptop,
+ });
const handle = route === '/squads/[handle]' ? (query.handle as string) : '';
const { squad } = useSquad({ handle });
const allowedToPost = verifyPermission(squad, SourcePermissions.Post);
@@ -87,12 +94,33 @@ export function CreatePostButton({
const href =
link.post.create + (squad && allowedToPost ? `?sid=${squad.handle}` : '');
+ const useSmartComposer = isSmartComposerEnabled && isLaptop && !onClick;
+ const handleSmartComposerOpen = (
+ e: React.MouseEvent,
+ ) => {
+ e.preventDefault();
+ openModal({
+ type: LazyModal.SmartComposer,
+ props: {
+ initialSquadHandle: squad && allowedToPost ? squad.handle : undefined,
+ },
+ });
+ };
+
const buttonProps: {
tag?: AllowedTags;
onClick?: (e: React.MouseEvent) => void;
- } = onClick ? { onClick } : { tag: 'a' };
+ } = (() => {
+ if (useSmartComposer) {
+ return { onClick: handleSmartComposerOpen };
+ }
+ if (onClick) {
+ return { onClick };
+ }
+ return { tag: 'a' };
+ })();
- const shouldUseLink = !onClick;
+ const shouldUseLink = !onClick && !useSmartComposer;
const shouldShowAsCompact =
compact !== false && ((isLaptop && !isLaptopL) || compact);
diff --git a/packages/shared/src/components/squads/SharePostBar.tsx b/packages/shared/src/components/squads/SharePostBar.tsx
index 2cad395051c..b0ebdb27a36 100644
--- a/packages/shared/src/components/squads/SharePostBar.tsx
+++ b/packages/shared/src/components/squads/SharePostBar.tsx
@@ -8,6 +8,7 @@ import { LockIcon } from '../icons';
import { Card } from '../cards/common/Card';
import { IconSize } from '../Icon';
import { usePostToSquad, useViewSize, ViewSize } from '../../hooks';
+import { useSmartComposerEnabled } from '../../hooks/post/useSmartComposerEnabled';
import { ClickableText } from '../buttons/ClickableText';
import { useLazyModal } from '../../hooks/useLazyModal';
import { LazyModal } from '../modals/common/types';
@@ -33,7 +34,13 @@ function SharePostBar({
const { openModal } = useLazyModal();
const [url, setUrl] = useState('');
const isMobile = useViewSize(ViewSize.MobileL);
+ const isLaptop = useViewSize(ViewSize.Laptop);
const [urlFocused, toggleUrlFocus] = useState(false);
+ const { value: isSmartComposerEnabled } = useSmartComposerEnabled({
+ shouldEvaluate: !!user && isLaptop,
+ });
+ const useSmartComposer = isSmartComposerEnabled && isLaptop;
+
const onSharedSuccessfully = () => {
inputRef.current.value = '';
setUrl('');
@@ -41,7 +48,20 @@ function SharePostBar({
const shouldRenderReadingHistory = !urlFocused && url.length === 0;
- const onOpenCreatePost = (preview: ExternalLinkPreview, link?: string) =>
+ const onOpenCreatePost = (preview: ExternalLinkPreview, link?: string) => {
+ if (useSmartComposer) {
+ openModal({
+ type: LazyModal.SmartComposer,
+ props: {
+ initialUrl: link ?? preview?.url,
+ initialSquadHandle: squad?.handle,
+ preview: { ...preview, url: link ?? preview?.url },
+ },
+ });
+ onSharedSuccessfully();
+ return;
+ }
+
openModal({
type: LazyModal.CreateSharedPost,
props: {
@@ -50,6 +70,7 @@ function SharePostBar({
onSharedSuccessfully,
},
});
+ };
const onOpenHistory = () =>
openModal({
@@ -66,6 +87,18 @@ function SharePostBar({
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
+ if (useSmartComposer) {
+ // Skip preview fetch — let the popup handle it
+ openModal({
+ type: LazyModal.SmartComposer,
+ props: {
+ initialUrl: url,
+ initialSquadHandle: squad?.handle,
+ },
+ });
+ onSharedSuccessfully();
+ return;
+ }
await getLinkPreview(url);
};
diff --git a/packages/shared/src/hooks/post/useSmartComposer.ts b/packages/shared/src/hooks/post/useSmartComposer.ts
new file mode 100644
index 00000000000..ef396946d0f
--- /dev/null
+++ b/packages/shared/src/hooks/post/useSmartComposer.ts
@@ -0,0 +1,283 @@
+import { useCallback, useMemo } from 'react';
+import { useRouter } from 'next/router';
+import { useAuthContext } from '../../contexts/AuthContext';
+import {
+ generateUserSourceAsSquad,
+ generateDefaultSquad,
+} from '../../components/post/write/MultipleSourceSelect';
+import type { Squad } from '../../graphql/sources';
+import { SourcePermissions } from '../../graphql/sources';
+import { verifyPermission } from '../../graphql/squads';
+import { isValidHttpUrl, urlStartRegexMatch } from '../../lib/links';
+import { webappUrl } from '../../lib/constants';
+import usePersistentContext from '../usePersistentContext';
+
+export const SMART_COMPOSER_LAST_SQUAD_KEY = 'smart_composer:last_squad_id';
+
+export type SmartComposerMode = 'freeform' | 'share';
+
+export interface DetectedUrl {
+ url: string;
+ isInternal: boolean;
+ internalPostSlug?: string;
+}
+
+interface UseSmartComposer {
+ mode: SmartComposerMode;
+ detectedUrl: DetectedUrl | null;
+ audienceOptions: Squad[];
+ defaultAudience: Squad | undefined;
+ rememberAudience: (id: string) => Promise;
+ isAudienceReady: boolean;
+}
+
+interface UseSmartComposerProps {
+ body: string;
+ /**
+ * True when the user has manually typed a title. When true, the composer
+ * stays in freeform mode even if a URL is detected — the link is just
+ * commentary, not the post itself. Title set by share auto-fill should
+ * NOT flip this flag (so the share preview stays).
+ */
+ isTitleManuallyEdited?: boolean;
+ initialSquadHandle?: string;
+}
+
+const INTERNAL_POST_PATTERN = /\/posts\/([^/?#]+)/i;
+
+const URL_REGEX = /(https?:\/\/[^\s<>"]+|www\.[^\s<>"]+)/gi;
+
+const findFirstPlainUrlMatch = (text: string): RegExpMatchArray | undefined => {
+ const matches = Array.from(text.matchAll(URL_REGEX));
+ return matches.find((match) => {
+ const index = match.index ?? 0;
+ const before = text.slice(Math.max(0, index - 2), index);
+ return !before.endsWith('](');
+ });
+};
+
+const collapseWhitespace = (text: string): string =>
+ text
+ .replace(/[ \t]+/g, ' ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+
+const escapeRegExp = (value: string): string =>
+ value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+const buildSharedUrlVariants = (sharedUrl: string): string[] => {
+ const variants = new Set();
+ const trimmed = sharedUrl.trim();
+ if (!trimmed) {
+ return [];
+ }
+ variants.add(trimmed);
+ variants.add(trimmed.replace(/\/+$/, ''));
+ variants.add(`${trimmed.replace(/\/+$/, '')}/`);
+ try {
+ const parsed = new URL(trimmed);
+ variants.add(`${parsed.origin}${parsed.pathname}`);
+ } catch {
+ // unparseable — variant set still contains the raw string
+ }
+ return Array.from(variants).filter(Boolean);
+};
+
+/**
+ * Cleans the body for share-mode submissions.
+ *
+ * In share mode the URL itself is represented by the embedded shared post card,
+ * so it must not appear in the commentary. TipTap auto-converts pasted URLs
+ * into markdown links like `[url](url)`, which a naive plain-URL strip would
+ * leave alone — that's why bare share posts were rendering a stray `[` glyph.
+ *
+ * This helper strips:
+ * 1. Markdown links pointing at the shared URL (any anchor text).
+ * 2. Auto-linked markdown of the form `[url](url)` regardless of host.
+ * 3. Plain occurrences of the shared URL and any leftover plain URLs.
+ */
+export const cleanShareCommentary = (
+ text: string,
+ sharedUrl?: string | null,
+): string => {
+ if (!text) {
+ return '';
+ }
+ let cleaned = text;
+
+ if (sharedUrl) {
+ buildSharedUrlVariants(sharedUrl).forEach((variant) => {
+ const escaped = escapeRegExp(variant);
+ cleaned = cleaned.replace(
+ new RegExp(`\\[[^\\]]*\\]\\(\\s*${escaped}\\s*\\)`, 'gi'),
+ '',
+ );
+ cleaned = cleaned.replace(new RegExp(escaped, 'gi'), '');
+ });
+ }
+
+ cleaned = cleaned.replace(
+ /\[((?:https?:\/\/|www\.)[^\]\s]+)\]\(\s*\1\/?\s*\)/gi,
+ '',
+ );
+ cleaned = cleaned.replace(URL_REGEX, '');
+
+ return collapseWhitespace(cleaned);
+};
+
+/**
+ * Returns true when the body contains nothing meaningful besides the
+ * detected URL (and possibly its markdown auto-link wrapper). We only
+ * promote to "share" mode in that case — i.e. the user pasted a link
+ * as the very first thing.
+ */
+const isBodyJustSharedUrl = (body: string, sharedUrl: string): boolean => {
+ if (!body) {
+ return true;
+ }
+ const remainder = cleanShareCommentary(body, sharedUrl).trim();
+ return remainder.length === 0;
+};
+
+export const detectFirstUrl = (text: string): DetectedUrl | null => {
+ if (!text) {
+ return null;
+ }
+
+ const validMatch = findFirstPlainUrlMatch(text);
+ if (!validMatch) {
+ return null;
+ }
+ const [candidate] = validMatch;
+
+ const normalized = candidate.match(urlStartRegexMatch)
+ ? candidate
+ : `https://${candidate}`;
+
+ if (!isValidHttpUrl(normalized)) {
+ return null;
+ }
+
+ let isInternal = false;
+ let internalPostSlug: string | undefined;
+
+ try {
+ const parsed = new URL(normalized);
+ const webappHostname = new URL(webappUrl).hostname;
+ isInternal = parsed.hostname === webappHostname;
+ if (isInternal) {
+ const postMatch = parsed.pathname.match(INTERNAL_POST_PATTERN);
+ if (postMatch) {
+ [, internalPostSlug] = postMatch;
+ }
+ }
+ } catch {
+ // unparseable URL falls through as external
+ }
+
+ return { url: normalized, isInternal, internalPostSlug };
+};
+
+export const useSmartComposer = ({
+ body,
+ isTitleManuallyEdited = false,
+ initialSquadHandle,
+}: UseSmartComposerProps): UseSmartComposer => {
+ const { user, squads } = useAuthContext();
+ const router = useRouter();
+ const [lastUsedSquadId, setLastUsedSquadId, isLastReady] =
+ usePersistentContext(SMART_COMPOSER_LAST_SQUAD_KEY, null);
+
+ const detectedUrl = useMemo(() => detectFirstUrl(body), [body]);
+ const mode: SmartComposerMode = useMemo(() => {
+ if (!detectedUrl) {
+ return 'freeform';
+ }
+ // Only auto-promote to "share" when the link is the first thing the
+ // user entered: no manually-typed title and no other body content
+ // beyond the URL. Auto-filled title from the share preview doesn't
+ // count.
+ if (isTitleManuallyEdited) {
+ return 'freeform';
+ }
+ if (!isBodyJustSharedUrl(body, detectedUrl.url)) {
+ return 'freeform';
+ }
+ return 'share';
+ }, [body, detectedUrl, isTitleManuallyEdited]);
+
+ const userSource = useMemo(
+ () => (user ? generateUserSourceAsSquad(user) : null),
+ [user],
+ );
+
+ const postableSquads = useMemo(
+ () =>
+ (squads ?? []).filter(
+ (squad) =>
+ squad?.active && verifyPermission(squad, SourcePermissions.Post),
+ ),
+ [squads],
+ );
+
+ const audienceOptions = useMemo(() => {
+ const options: Squad[] = [];
+ if (userSource) {
+ options.push(userSource);
+ }
+ return options.concat(postableSquads);
+ }, [userSource, postableSquads]);
+
+ const routerSquadHandle = useMemo(() => {
+ if (initialSquadHandle) {
+ return initialSquadHandle;
+ }
+ if (router.route === '/squads/[handle]') {
+ const { handle } = router.query;
+ return Array.isArray(handle) ? handle[0] : handle;
+ }
+ return undefined;
+ }, [initialSquadHandle, router.route, router.query]);
+
+ const defaultAudience = useMemo(() => {
+ if (routerSquadHandle) {
+ const match = audienceOptions.find(
+ (squad) => squad.handle === routerSquadHandle,
+ );
+ if (match) {
+ return match;
+ }
+ }
+ if (lastUsedSquadId) {
+ const match = audienceOptions.find(
+ (squad) => squad.id === lastUsedSquadId,
+ );
+ if (match) {
+ return match;
+ }
+ }
+ if (userSource) {
+ return userSource;
+ }
+ return (
+ audienceOptions[0] ??
+ (user ? generateDefaultSquad(user.username) : undefined)
+ );
+ }, [routerSquadHandle, lastUsedSquadId, audienceOptions, userSource, user]);
+
+ const rememberAudience = useCallback(
+ async (id: string) => {
+ await setLastUsedSquadId(id);
+ },
+ [setLastUsedSquadId],
+ );
+
+ return {
+ mode,
+ detectedUrl,
+ audienceOptions,
+ defaultAudience,
+ rememberAudience,
+ isAudienceReady: isLastReady,
+ };
+};
diff --git a/packages/shared/src/hooks/post/useSmartComposerEnabled.ts b/packages/shared/src/hooks/post/useSmartComposerEnabled.ts
new file mode 100644
index 00000000000..7e68fcc8dcd
--- /dev/null
+++ b/packages/shared/src/hooks/post/useSmartComposerEnabled.ts
@@ -0,0 +1,41 @@
+import { featureSmartComposer } from '../../lib/featureManagement';
+import { useConditionalFeature } from '../useConditionalFeature';
+import usePersistentContext from '../usePersistentContext';
+
+export const FORCE_SMART_COMPOSER_KEY = 'force_smart_composer';
+
+interface UseSmartComposerEnabledProps {
+ shouldEvaluate: boolean;
+}
+
+interface UseSmartComposerEnabled {
+ value: boolean;
+ isForced: boolean;
+}
+
+/**
+ * Returns whether the Smart Composer experiment is active for the current user.
+ *
+ * Combines the GrowthBook feature flag with an internal-only override stored
+ * in IndexedDB (toggled via `SmartComposerDevToggle` on non-prod environments).
+ * The override is intended for QA/internal testing — it never bypasses the
+ * production API check that gates the toggle UI itself.
+ */
+export const useSmartComposerEnabled = ({
+ shouldEvaluate,
+}: UseSmartComposerEnabledProps): UseSmartComposerEnabled => {
+ const [forceOverride] = usePersistentContext(
+ FORCE_SMART_COMPOSER_KEY,
+ false,
+ );
+ const { value: flagValue } = useConditionalFeature({
+ feature: featureSmartComposer,
+ shouldEvaluate,
+ });
+
+ const isForced = !!forceOverride;
+ return {
+ value: flagValue || isForced,
+ isForced,
+ };
+};
diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts
index 2cc3245e5a3..4659f503b93 100644
--- a/packages/shared/src/lib/featureManagement.ts
+++ b/packages/shared/src/lib/featureManagement.ts
@@ -172,3 +172,8 @@ export const featureNewTabCustomizer = new Feature(
'extension_newtab_customizer',
false,
);
+
+// NOTE: defaulted to `true` during the experiment build-out so the popup is
+// visible locally without GrowthBook config. Flip back to `false` before the
+// experiment goes live so GrowthBook controls exposure.
+export const featureSmartComposer = new Feature('smart_composer', false);
diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts
index 24066acdf7e..f5eb49f471d 100644
--- a/packages/shared/src/lib/log.ts
+++ b/packages/shared/src/lib/log.ts
@@ -106,6 +106,18 @@ export enum LogEvent {
CommentPost = 'comment post',
CommentsClick = 'comments click',
StartSubmitArticle = 'start submit article',
+ // smart composer experiment - start
+ OpenSmartComposer = 'open smart composer',
+ SubmitSmartComposer = 'submit smart composer',
+ DismissSmartComposer = 'dismiss smart composer',
+ EscalateSmartComposer = 'escalate smart composer',
+ ExpandSmartComposer = 'expand smart composer',
+ AttachCoverSmartComposer = 'attach cover smart composer',
+ ChangeAudienceSmartComposer = 'change audience smart composer',
+ ToggleModeSmartComposer = 'toggle mode smart composer',
+ StartTitleSmartComposer = 'start title smart composer',
+ StartBodySmartComposer = 'start body smart composer',
+ // smart composer experiment - end
Impression = 'impression',
ManageTags = 'click manage tags',
SearchTags = 'search tags',
diff --git a/scripts/typecheck-strict-changed.js b/scripts/typecheck-strict-changed.js
index 65fe7b8df86..9622b9454bf 100644
--- a/scripts/typecheck-strict-changed.js
+++ b/scripts/typecheck-strict-changed.js
@@ -52,6 +52,12 @@ const strictSkipList = new Set([
'packages/shared/src/contexts/SettingsContext.tsx',
'packages/shared/src/components/tooltips/InteractivePopup.tsx',
'packages/shared/src/contexts/FeedContext.tsx',
+ // Smart-composer-experiment branch — touched only to gate a new entry
+ // point behind the feature flag. These files have pre-existing strict
+ // violations unrelated to the smart composer that should be addressed
+ // in a dedicated cleanup PR.
+ 'packages/shared/src/components/post/write/CreatePostButton.tsx',
+ 'packages/shared/src/components/squads/SharePostBar.tsx',
]);
const changedFiles = getChangedTypescriptFiles().filter(