feat(P3-6): configurable composer toolbar buttons
CI / Build & Quality Checks (push) Successful in 10m24s
Trigger Desktop Build / trigger (push) Successful in 7s

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:
2026-06-10 13:20:29 -04:00
parent b7daabe2e0
commit 891f2daf99
3 changed files with 275 additions and 123 deletions
+147 -122
View File
@@ -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>
);
}
+35 -1
View File
@@ -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;