diff --git a/.changeset/add-reorderable-composer-buttons.md b/.changeset/add-reorderable-composer-buttons.md new file mode 100644 index 0000000000..0415e69348 --- /dev/null +++ b/.changeset/add-reorderable-composer-buttons.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add toggleable, reorderable composer trigger buttons (emoji, gif, sticker) with a drag-and-drop settings list. diff --git a/src/app/components/icons/phosphor.tsx b/src/app/components/icons/phosphor.tsx index 9b2bf32b73..fe19f41aef 100644 --- a/src/app/components/icons/phosphor.tsx +++ b/src/app/components/icons/phosphor.tsx @@ -35,6 +35,7 @@ import { DevicesIcon, DotsThreeIcon, DotsThreeOutlineVerticalIcon, + DotsSixVerticalIcon, DownloadIcon, EnvelopeSimpleIcon, EyeIcon, @@ -45,6 +46,7 @@ import { FlowerIcon, FunnelIcon, GearSixIcon, + GifIcon, GlobeIcon, GridFourIcon, HardDrivesIcon, @@ -342,6 +344,7 @@ export { DevicesIcon as Devices, DotsThreeIcon as DotsThree, DotsThreeOutlineVerticalIcon, + DotsSixVerticalIcon, DownloadIcon as Download, EnvelopeSimpleIcon as EnvelopeSimple, EyeIcon as Eye, @@ -352,6 +355,7 @@ export { FlowerIcon as Flower, FunnelIcon as Funnel, GearSixIcon as GearSix, + GifIcon as Gif, GlobeIcon as Globe, GridFourIcon as GridFour, HardDrivesIcon as HardDrives, diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 482b848011..daaae8131e 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1,5 +1,6 @@ import type { KeyboardEventHandler, MouseEvent, RefObject } from 'react'; -import { forwardRef, useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import { forwardRef, Fragment, useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import type { ReactElement } from 'react'; import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; @@ -145,6 +146,7 @@ import { composerIcon, dropzoneIcon, File as FileIcon, + Gif, Image as ImageIcon, ListBullets, MapPinPlusIcon, @@ -154,6 +156,7 @@ import { getPhosphorIconSize, PlusCircle, Smiley, + Sticker, Stop, X, } from '$components/icons/phosphor'; @@ -297,6 +300,11 @@ export const RoomInput = forwardRef( const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [editorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); + const [editorGifButton] = useSetting(settingsAtom, 'editorGifButton'); + const [editorEmojiButton] = useSetting(settingsAtom, 'editorEmojiButton'); + const [editorStickerButton] = useSetting(settingsAtom, 'editorStickerButton'); + const [editorMicButton] = useSetting(settingsAtom, 'editorMicButton'); + const [editorButtonOrder] = useSetting(settingsAtom, 'editorButtonOrder'); const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); @@ -1878,22 +1886,69 @@ export const RoomInput = forwardRef( /> ); const triggers = ( - setEmojiBoardTab(EmojiBoardTab.Emoji)} - onPointerDown={suppressEditorRefocus} - variant="SurfaceVariant" - size="300" - radii="300" - style={{ backgroundColor: 'transparent' }} - title="open emoji board" - aria-label="Open emoji board" - > - {composerIcon(Smiley, { - weight: emojiBoardTab !== undefined ? 'fill' : 'regular', + <> + {editorButtonOrder.map((id) => { + let button: ReactElement | null = null; + if (id === 'gif' && editorGifButton) { + button = ( + setEmojiBoardTab(EmojiBoardTab.Gif)} + onPointerDown={suppressEditorRefocus} + variant="SurfaceVariant" + size="300" + radii="300" + style={{ backgroundColor: 'transparent' }} + title="open gif picker" + aria-label="Open gif picker" + > + {composerIcon(Gif, { + weight: emojiBoardTab === EmojiBoardTab.Gif ? 'fill' : 'regular', + })} + + ); + } else if (id === 'sticker' && editorStickerButton) { + button = ( + setEmojiBoardTab(EmojiBoardTab.Sticker)} + onPointerDown={suppressEditorRefocus} + variant="SurfaceVariant" + size="300" + radii="300" + style={{ backgroundColor: 'transparent' }} + title="open sticker picker" + aria-label="Open sticker picker" + > + {composerIcon(Sticker, { + weight: + emojiBoardTab === EmojiBoardTab.Sticker ? 'fill' : 'regular', + })} + + ); + } else if (id === 'emoji' && editorEmojiButton) { + button = ( + setEmojiBoardTab(EmojiBoardTab.Emoji)} + onPointerDown={suppressEditorRefocus} + variant="SurfaceVariant" + size="300" + radii="300" + style={{ backgroundColor: 'transparent' }} + title="open emoji board" + aria-label="Open emoji board" + > + {composerIcon(Smiley, { + weight: emojiBoardTab !== undefined ? 'fill' : 'regular', + })} + + ); + } + return {button}; })} - + ); if (mobileOrTablet()) { return ( @@ -1943,23 +1998,23 @@ export const RoomInput = forwardRef( showAudioRecorder ? 'Critical' : scheduledTime ? 'Primary' : 'SurfaceVariant' } size="300" - radii={hasContent || showAudioRecorder ? '0' : '300'} + radii={hasContent || showAudioRecorder || !editorMicButton ? '0' : '300'} title={ showAudioRecorder ? 'Stop recording' - : hasContent + : hasContent || !editorMicButton ? 'Send Message' : 'Record audio message' } aria-label={ showAudioRecorder ? 'Stop recording' - : hasContent + : hasContent || !editorMicButton ? 'Send your composed Message' : 'Record audio message' } style={{ backgroundColor: 'transparent' }} - aria-pressed={!hasContent ? showAudioRecorder : undefined} + aria-pressed={!hasContent && editorMicButton ? showAudioRecorder : undefined} onClick={() => { if (showAudioRecorder) { audioRecorderRef.current?.stop(); @@ -1973,6 +2028,7 @@ export const RoomInput = forwardRef( submit(); return; } + if (!editorMicButton) return; if (mobileOrTablet()) return; setShowAudioRecorder(true); }} @@ -1991,6 +2047,7 @@ export const RoomInput = forwardRef( } return; } + if (!editorMicButton) return; if (!mobileOrTablet()) return; micHoldStartRef.current = Date.now(); setShowAudioRecorder(true); @@ -2042,7 +2099,7 @@ export const RoomInput = forwardRef( weight="fill" style={{ color: color.Critical.Main }} /> - ) : hasContent ? ( + ) : hasContent || !editorMicButton ? ( sendBusy ? ( ) : scheduledTime ? ( diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 3d4fbdb3d8..26befb4515 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -4,13 +4,14 @@ import type { KeyboardEventHandler, MouseEventHandler, } from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import dayjs from 'dayjs'; import { useAtomValue, useSetAtom } from 'jotai'; import type { RectCords } from 'folds'; import { Box, Button, + color, config, Header, IconButton, @@ -27,17 +28,25 @@ import { ArrowUp, CaretDown, composerIcon, + DotsSixVerticalIcon, Download, + Gif, Info, menuIcon, Shield, + Smiley, + Sticker, X, } from '$components/icons/phosphor'; import FocusTrap from 'focus-trap-react'; +import { + draggable, + dropTargetForElements, +} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { useSetting } from '$state/hooks/settings'; -import type { DateFormat, MessageSpacing, CaptionPosition } from '$state/settings'; +import type { DateFormat, EditorButtonId, MessageSpacing, CaptionPosition } from '$state/settings'; import { MessageLayout, RightSwipeAction, settingsAtom } from '$state/settings'; import { SettingTile } from '$components/setting-tile'; import { KeySymbol } from '$utils/key-symbol'; @@ -418,10 +427,105 @@ function DateAndTime() { ); } +const EDITOR_BUTTON_META = { + gif: { name: 'Gif', Icon: Gif }, + sticker: { name: 'Sticker', Icon: Sticker }, + emoji: { name: 'Emoji', Icon: Smiley }, +}; + +type EditorButtonOrderRowProps = { + id: EditorButtonId; + index: number; + isDragging: boolean; + onDraggingIndexChange: (index: number | null) => void; + onReorder: (from: number, to: number) => void; +}; + +function EditorButtonOrderRow({ + id, + index, + isDragging, + onDraggingIndexChange, + onReorder, +}: Readonly) { + const rowRef = useRef(null); + const { name, Icon } = EDITOR_BUTTON_META[id]; + + useEffect(() => { + const element = rowRef.current; + if (!element) return undefined; + return draggable({ + element, + getInitialData: () => ({ index }), + onDragStart: () => onDraggingIndexChange(index), + onDrop: () => onDraggingIndexChange(null), + }); + }, [index, onDraggingIndexChange]); + + useEffect(() => { + const element = rowRef.current; + if (!element) return undefined; + return dropTargetForElements({ + element, + getData: () => ({ index }), + onDrop: ({ source }) => { + const from = source.data.index; + if (typeof from !== 'number' || from === index) return; + onReorder(from, index); + }, + }); + }, [index, onReorder]); + + return ( + + + + ); +} + function Editor() { const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [editorOldAddFile, setEditorOldAddFile] = useSetting(settingsAtom, 'editorOldAddFile'); + const [editorMicButton, setEditorMicButton] = useSetting(settingsAtom, 'editorMicButton'); + const [editorEmojiButton, setEditorEmojiButton] = useSetting(settingsAtom, 'editorEmojiButton'); + const [editorGifButton, setEditorGifButton] = useSetting(settingsAtom, 'editorGifButton'); + const [editorStickerButton, setEditorStickerButton] = useSetting( + settingsAtom, + 'editorStickerButton' + ); + const [editorButtonOrder, setEditorButtonOrder] = useSetting(settingsAtom, 'editorButtonOrder'); + const [draggingIndex, setDraggingIndex] = useState(null); + + const handleReorder = useCallback( + (from: number, to: number) => { + if (from === to) return; + setEditorButtonOrder((prev) => { + const next = [...prev]; + const removed = next.splice(from, 1); + next.splice(to, 0, ...removed); + return next; + }); + }, + [setEditorButtonOrder] + ); + const enabledButtons: Record = { + gif: editorGifButton, + sticker: editorStickerButton, + emoji: editorEmojiButton, + }; + const hasEnabledOrder = editorButtonOrder.some((id) => enabledButtons[id]); const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads'); const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -460,6 +564,78 @@ function Editor() { } /> + + } + /> + + + + } + /> + + + } + /> + + + + } + /> + + + + {hasEnabledOrder && ( + + {editorButtonOrder.map((id, index) => + enabledButtons[id] ? ( + + ) : null + )} + + )} + (EDITOR_BUTTON_ORDER_DEFAULT); export const CALL_TONE_IDS = [ 'sable-default', 'classic-soft', @@ -116,6 +121,11 @@ export interface Settings { enterForNewline: boolean; editorToolbar: boolean; editorOldAddFile: boolean; + editorMicButton: boolean; + editorEmojiButton: boolean; + editorGifButton: boolean; + editorStickerButton: boolean; + editorButtonOrder: EditorButtonId[]; composerToolbarOpen: boolean; messageLayout: MessageLayout; messageSpacing: MessageSpacing; @@ -289,6 +299,11 @@ export const defaultSettings: Settings = { enterForNewline: mobileOrTablet(), editorToolbar: false, editorOldAddFile: false, + editorMicButton: true, + editorEmojiButton: true, + editorGifButton: false, + editorStickerButton: false, + editorButtonOrder: [...EDITOR_BUTTON_ORDER_DEFAULT], composerToolbarOpen: false, messageLayout: 0, messageSpacing: '400', @@ -449,6 +464,7 @@ function cloneDefaultSettings(): Settings { })), themeRemoteTweakFavorites: defaultSettings.themeRemoteTweakFavorites.map((x) => ({ ...x })), themeRemoteEnabledTweakFullUrls: [...defaultSettings.themeRemoteEnabledTweakFullUrls], + editorButtonOrder: [...defaultSettings.editorButtonOrder], }; } @@ -568,6 +584,26 @@ function sanitizeStringArray(val: unknown): string[] | undefined { return out; } +function sanitizeEditorButtonOrder(val: unknown): EditorButtonId[] | undefined { + if (!Array.isArray(val)) return undefined; + const out: EditorButtonId[] = []; + const seen = new Set(); + for (const x of val) { + if (typeof x === 'string' && EDITOR_BUTTON_ORDER_VALUES.has(x as EditorButtonId)) { + const id = x as EditorButtonId; + if (!seen.has(id)) { + seen.add(id); + out.push(id); + } + } + } + // Append any missing buttons in default order so the array is always complete. + for (const id of EDITOR_BUTTON_ORDER_DEFAULT) { + if (!seen.has(id)) out.push(id); + } + return out; +} + function sanitizeThemeRemoteFavorites(val: unknown): ThemeRemoteFavorite[] | undefined { if (!Array.isArray(val)) return undefined; const out: ThemeRemoteFavorite[] = []; @@ -688,6 +724,8 @@ function sanitizeSettingsKey(key: keyof Settings, val: unknown): unknown { return sanitizeThemeRemoteTweakFavorites(val); case 'themeRemoteEnabledTweakFullUrls': return sanitizeStringArray(val); + case 'editorButtonOrder': + return sanitizeEditorButtonOrder(val); default: { if (!(key in defaultSettings)) return undefined; const sample = defaultSettings[key];