feat(P3-6): configurable composer toolbar buttons
Each button (Format, Emoji, Sticker, GIF, Location, Poll, Voice, Schedule) can be individually hidden in Settings → Editor. All default to on, stored in composerToolbarButtons object. getSettings deep-merges the nested object so new buttons default to true for existing users with saved settings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+147
-122
@@ -209,6 +209,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
||||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||||
const showGif = composerToolbarButtons?.showGif ?? true;
|
||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||
const handleShareLocation = () => {
|
||||
@@ -933,88 +942,96 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}
|
||||
after={
|
||||
<>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||
aria-pressed={toolbar}
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||
<PopOut
|
||||
offset={16}
|
||||
alignOffset={-44}
|
||||
position="Top"
|
||||
align="End"
|
||||
anchor={
|
||||
emojiBoardTab === undefined
|
||||
? undefined
|
||||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||
}
|
||||
content={
|
||||
<React.Suspense fallback={null}>
|
||||
<EmojiBoard
|
||||
tab={emojiBoardTab}
|
||||
onTabChange={setEmojiBoardTab}
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoardTab((t) => {
|
||||
if (t) {
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
return undefined;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
}
|
||||
>
|
||||
{!hideStickerBtn && (
|
||||
<IconButton
|
||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
aria-label="Insert sticker"
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Sticker}
|
||||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
ref={emojiBtnRef}
|
||||
aria-label="Insert emoji"
|
||||
aria-pressed={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
{showFormat && (
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||
aria-pressed={toolbar}
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
)}
|
||||
{(showEmoji || showSticker) && (
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||
<PopOut
|
||||
offset={16}
|
||||
alignOffset={-44}
|
||||
position="Top"
|
||||
align="End"
|
||||
anchor={
|
||||
emojiBoardTab === undefined
|
||||
? undefined
|
||||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||
}
|
||||
content={
|
||||
<React.Suspense fallback={null}>
|
||||
<EmojiBoard
|
||||
tab={emojiBoardTab}
|
||||
onTabChange={setEmojiBoardTab}
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoardTab((t) => {
|
||||
if (t) {
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
return undefined;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Smile}
|
||||
filled={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
{!!gifApiKey && (
|
||||
{showSticker && !hideStickerBtn && (
|
||||
<IconButton
|
||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
aria-label="Insert sticker"
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Sticker}
|
||||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
{showEmoji && (
|
||||
<IconButton
|
||||
ref={emojiBtnRef}
|
||||
aria-label="Insert emoji"
|
||||
aria-pressed={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Smile}
|
||||
filled={
|
||||
hideStickerBtn
|
||||
? !!emojiBoardTab
|
||||
: emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
)}
|
||||
{!!gifApiKey && showGif && (
|
||||
<UseStateProvider initial={false}>
|
||||
{(gifOpen: boolean, setGifOpen) => (
|
||||
<PopOut
|
||||
@@ -1093,38 +1110,44 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{locationError}
|
||||
</Text>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleShareLocation}
|
||||
disabled={locating}
|
||||
aria-label="Share location"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Share location"
|
||||
>
|
||||
{locating ? (
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
) : (
|
||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => setPollOpen(true)}
|
||||
aria-label="Create poll"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Create poll"
|
||||
>
|
||||
<Icon src={Icons.OrderList} size="100" />
|
||||
</IconButton>
|
||||
<VoiceMessageRecorder
|
||||
onSend={handleVoiceSend}
|
||||
onError={(err) => {
|
||||
setLocationError(err);
|
||||
setTimeout(() => setLocationError(null), 4000);
|
||||
}}
|
||||
/>
|
||||
{showLocation && (
|
||||
<IconButton
|
||||
onClick={handleShareLocation}
|
||||
disabled={locating}
|
||||
aria-label="Share location"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Share location"
|
||||
>
|
||||
{locating ? (
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
) : (
|
||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{showPoll && (
|
||||
<IconButton
|
||||
onClick={() => setPollOpen(true)}
|
||||
aria-label="Create poll"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Create poll"
|
||||
>
|
||||
<Icon src={Icons.OrderList} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
{showVoice && (
|
||||
<VoiceMessageRecorder
|
||||
onSend={handleVoiceSend}
|
||||
onError={(err) => {
|
||||
setLocationError(err);
|
||||
setTimeout(() => setLocationError(null), 4000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{charCount > 0 && (
|
||||
<Text
|
||||
size="T200"
|
||||
@@ -1141,16 +1164,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{charCount}
|
||||
</Text>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleScheduleClick}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Schedule message"
|
||||
title="Schedule message"
|
||||
>
|
||||
<Icon src={Icons.Clock} size="100" />
|
||||
</IconButton>
|
||||
{showSchedule && (
|
||||
<IconButton
|
||||
onClick={handleScheduleClick}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Schedule message"
|
||||
title="Schedule message"
|
||||
>
|
||||
<Icon src={Icons.Clock} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={submit}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -37,6 +37,7 @@ import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import {
|
||||
ChatBackground,
|
||||
ComposerToolbarSettings,
|
||||
DateFormat,
|
||||
MessageLayout,
|
||||
MessageSpacing,
|
||||
@@ -855,6 +856,14 @@ function Editor() {
|
||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
|
||||
settingsAtom,
|
||||
'composerToolbarButtons',
|
||||
);
|
||||
|
||||
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
|
||||
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -881,6 +890,90 @@ function Editor() {
|
||||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<Text size="L400" style={{ marginTop: '8px' }}>Composer Toolbar Buttons</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Format Toggle"
|
||||
description="Button to show/hide the text formatting toolbar."
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={composerToolbarButtons?.showFormat ?? true}
|
||||
onChange={() => toggleToolbarButton('showFormat')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Emoji"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={composerToolbarButtons?.showEmoji ?? true}
|
||||
onChange={() => toggleToolbarButton('showEmoji')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Sticker"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={composerToolbarButtons?.showSticker ?? true}
|
||||
onChange={() => toggleToolbarButton('showSticker')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="GIF"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={composerToolbarButtons?.showGif ?? true}
|
||||
onChange={() => toggleToolbarButton('showGif')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Location"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={composerToolbarButtons?.showLocation ?? true}
|
||||
onChange={() => toggleToolbarButton('showLocation')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Poll"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={composerToolbarButtons?.showPoll ?? true}
|
||||
onChange={() => toggleToolbarButton('showPoll')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Voice Message"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={composerToolbarButtons?.showVoice ?? true}
|
||||
onChange={() => toggleToolbarButton('showVoice')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingTile
|
||||
title="Schedule Message"
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={composerToolbarButtons?.showSchedule ?? true}
|
||||
onChange={() => toggleToolbarButton('showSchedule')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,28 @@ export enum MessageLayout {
|
||||
Bubble = 2,
|
||||
}
|
||||
|
||||
export interface ComposerToolbarSettings {
|
||||
showFormat: boolean;
|
||||
showEmoji: boolean;
|
||||
showSticker: boolean;
|
||||
showGif: boolean;
|
||||
showLocation: boolean;
|
||||
showPoll: boolean;
|
||||
showVoice: boolean;
|
||||
showSchedule: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
|
||||
showFormat: true,
|
||||
showEmoji: true,
|
||||
showSticker: true,
|
||||
showGif: true,
|
||||
showLocation: true,
|
||||
showPoll: true,
|
||||
showVoice: true,
|
||||
showSchedule: true,
|
||||
};
|
||||
|
||||
export interface Settings {
|
||||
themeId?: string;
|
||||
useSystemTheme: boolean;
|
||||
@@ -101,6 +123,8 @@ export interface Settings {
|
||||
warnOnUnverifiedDevices: boolean;
|
||||
|
||||
pauseAnimations: boolean;
|
||||
|
||||
composerToolbarButtons: ComposerToolbarSettings;
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -166,13 +190,23 @@ const defaultSettings: Settings = {
|
||||
warnOnUnverifiedDevices: false,
|
||||
|
||||
pauseAnimations: false,
|
||||
|
||||
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
||||
};
|
||||
|
||||
export const getSettings = (): Settings => {
|
||||
try {
|
||||
const settings = localStorage.getItem(STORAGE_KEY);
|
||||
if (settings === null) return defaultSettings;
|
||||
return { ...defaultSettings, ...(JSON.parse(settings) as Settings) };
|
||||
const saved = JSON.parse(settings) as Partial<Settings>;
|
||||
return {
|
||||
...defaultSettings,
|
||||
...saved,
|
||||
composerToolbarButtons: {
|
||||
...DEFAULT_COMPOSER_TOOLBAR,
|
||||
...(saved.composerToolbarButtons ?? {}),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return defaultSettings;
|
||||
|
||||
Reference in New Issue
Block a user