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 imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||||
|
|
||||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
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 [locating, setLocating] = React.useState(false);
|
||||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||||
const handleShareLocation = () => {
|
const handleShareLocation = () => {
|
||||||
@@ -933,88 +942,96 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
<>
|
<>
|
||||||
<IconButton
|
{showFormat && (
|
||||||
variant="SurfaceVariant"
|
<IconButton
|
||||||
size="300"
|
variant="SurfaceVariant"
|
||||||
radii="300"
|
size="300"
|
||||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
radii="300"
|
||||||
aria-pressed={toolbar}
|
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||||
onClick={() => setToolbar(!toolbar)}
|
aria-pressed={toolbar}
|
||||||
>
|
onClick={() => setToolbar(!toolbar)}
|
||||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
>
|
||||||
</IconButton>
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||||
<UseStateProvider initial={undefined}>
|
</IconButton>
|
||||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
)}
|
||||||
<PopOut
|
{(showEmoji || showSticker) && (
|
||||||
offset={16}
|
<UseStateProvider initial={undefined}>
|
||||||
alignOffset={-44}
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||||
position="Top"
|
<PopOut
|
||||||
align="End"
|
offset={16}
|
||||||
anchor={
|
alignOffset={-44}
|
||||||
emojiBoardTab === undefined
|
position="Top"
|
||||||
? undefined
|
align="End"
|
||||||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
anchor={
|
||||||
}
|
emojiBoardTab === undefined
|
||||||
content={
|
? undefined
|
||||||
<React.Suspense fallback={null}>
|
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||||
<EmojiBoard
|
}
|
||||||
tab={emojiBoardTab}
|
content={
|
||||||
onTabChange={setEmojiBoardTab}
|
<React.Suspense fallback={null}>
|
||||||
imagePackRooms={imagePackRooms}
|
<EmojiBoard
|
||||||
returnFocusOnDeactivate={false}
|
tab={emojiBoardTab}
|
||||||
onEmojiSelect={handleEmoticonSelect}
|
onTabChange={setEmojiBoardTab}
|
||||||
onCustomEmojiSelect={handleEmoticonSelect}
|
imagePackRooms={imagePackRooms}
|
||||||
onStickerSelect={handleStickerSelect}
|
returnFocusOnDeactivate={false}
|
||||||
requestClose={() => {
|
onEmojiSelect={handleEmoticonSelect}
|
||||||
setEmojiBoardTab((t) => {
|
onCustomEmojiSelect={handleEmoticonSelect}
|
||||||
if (t) {
|
onStickerSelect={handleStickerSelect}
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
requestClose={() => {
|
||||||
return undefined;
|
setEmojiBoardTab((t) => {
|
||||||
}
|
if (t) {
|
||||||
return t;
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
});
|
return undefined;
|
||||||
}}
|
}
|
||||||
/>
|
return t;
|
||||||
</React.Suspense>
|
});
|
||||||
}
|
}}
|
||||||
>
|
/>
|
||||||
{!hideStickerBtn && (
|
</React.Suspense>
|
||||||
<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
|
|
||||||
}
|
}
|
||||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
>
|
>
|
||||||
<Icon
|
{showSticker && !hideStickerBtn && (
|
||||||
src={Icons.Smile}
|
<IconButton
|
||||||
filled={
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
aria-label="Insert sticker"
|
||||||
}
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||||
/>
|
variant="SurfaceVariant"
|
||||||
</IconButton>
|
size="300"
|
||||||
</PopOut>
|
radii="300"
|
||||||
)}
|
>
|
||||||
</UseStateProvider>
|
<Icon
|
||||||
{!!gifApiKey && (
|
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}>
|
<UseStateProvider initial={false}>
|
||||||
{(gifOpen: boolean, setGifOpen) => (
|
{(gifOpen: boolean, setGifOpen) => (
|
||||||
<PopOut
|
<PopOut
|
||||||
@@ -1093,38 +1110,44 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{locationError}
|
{locationError}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
{showLocation && (
|
||||||
onClick={handleShareLocation}
|
<IconButton
|
||||||
disabled={locating}
|
onClick={handleShareLocation}
|
||||||
aria-label="Share location"
|
disabled={locating}
|
||||||
variant="SurfaceVariant"
|
aria-label="Share location"
|
||||||
size="300"
|
variant="SurfaceVariant"
|
||||||
radii="300"
|
size="300"
|
||||||
title="Share location"
|
radii="300"
|
||||||
>
|
title="Share location"
|
||||||
{locating ? (
|
>
|
||||||
<Spinner variant="Secondary" size="100" />
|
{locating ? (
|
||||||
) : (
|
<Spinner variant="Secondary" size="100" />
|
||||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
) : (
|
||||||
)}
|
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||||
</IconButton>
|
)}
|
||||||
<IconButton
|
</IconButton>
|
||||||
onClick={() => setPollOpen(true)}
|
)}
|
||||||
aria-label="Create poll"
|
{showPoll && (
|
||||||
variant="SurfaceVariant"
|
<IconButton
|
||||||
size="300"
|
onClick={() => setPollOpen(true)}
|
||||||
radii="300"
|
aria-label="Create poll"
|
||||||
title="Create poll"
|
variant="SurfaceVariant"
|
||||||
>
|
size="300"
|
||||||
<Icon src={Icons.OrderList} size="100" />
|
radii="300"
|
||||||
</IconButton>
|
title="Create poll"
|
||||||
<VoiceMessageRecorder
|
>
|
||||||
onSend={handleVoiceSend}
|
<Icon src={Icons.OrderList} size="100" />
|
||||||
onError={(err) => {
|
</IconButton>
|
||||||
setLocationError(err);
|
)}
|
||||||
setTimeout(() => setLocationError(null), 4000);
|
{showVoice && (
|
||||||
}}
|
<VoiceMessageRecorder
|
||||||
/>
|
onSend={handleVoiceSend}
|
||||||
|
onError={(err) => {
|
||||||
|
setLocationError(err);
|
||||||
|
setTimeout(() => setLocationError(null), 4000);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{charCount > 0 && (
|
{charCount > 0 && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
@@ -1141,16 +1164,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{charCount}
|
{charCount}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<IconButton
|
{showSchedule && (
|
||||||
onClick={handleScheduleClick}
|
<IconButton
|
||||||
variant="SurfaceVariant"
|
onClick={handleScheduleClick}
|
||||||
size="300"
|
variant="SurfaceVariant"
|
||||||
radii="300"
|
size="300"
|
||||||
aria-label="Schedule message"
|
radii="300"
|
||||||
title="Schedule message"
|
aria-label="Schedule message"
|
||||||
>
|
title="Schedule message"
|
||||||
<Icon src={Icons.Clock} size="100" />
|
>
|
||||||
</IconButton>
|
<Icon src={Icons.Clock} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { SequenceCard } from '../../../components/sequence-card';
|
|||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import {
|
import {
|
||||||
ChatBackground,
|
ChatBackground,
|
||||||
|
ComposerToolbarSettings,
|
||||||
DateFormat,
|
DateFormat,
|
||||||
MessageLayout,
|
MessageLayout,
|
||||||
MessageSpacing,
|
MessageSpacing,
|
||||||
@@ -855,6 +856,14 @@ function Editor() {
|
|||||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
|
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'composerToolbarButtons',
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
|
||||||
|
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -881,6 +890,90 @@ function Editor() {
|
|||||||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,28 @@ export enum MessageLayout {
|
|||||||
Bubble = 2,
|
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 {
|
export interface Settings {
|
||||||
themeId?: string;
|
themeId?: string;
|
||||||
useSystemTheme: boolean;
|
useSystemTheme: boolean;
|
||||||
@@ -101,6 +123,8 @@ export interface Settings {
|
|||||||
warnOnUnverifiedDevices: boolean;
|
warnOnUnverifiedDevices: boolean;
|
||||||
|
|
||||||
pauseAnimations: boolean;
|
pauseAnimations: boolean;
|
||||||
|
|
||||||
|
composerToolbarButtons: ComposerToolbarSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -166,13 +190,23 @@ const defaultSettings: Settings = {
|
|||||||
warnOnUnverifiedDevices: false,
|
warnOnUnverifiedDevices: false,
|
||||||
|
|
||||||
pauseAnimations: false,
|
pauseAnimations: false,
|
||||||
|
|
||||||
|
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = (): Settings => {
|
export const getSettings = (): Settings => {
|
||||||
try {
|
try {
|
||||||
const settings = localStorage.getItem(STORAGE_KEY);
|
const settings = localStorage.getItem(STORAGE_KEY);
|
||||||
if (settings === null) return defaultSettings;
|
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 {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
return defaultSettings;
|
return defaultSettings;
|
||||||
|
|||||||
Reference in New Issue
Block a user