feat(composer): dock the emoji and sticker picker inline at the top of the composer on native

This commit is contained in:
heaven 2026-06-04 02:38:42 +03:00
parent aab65b573a
commit 18eddec405
4 changed files with 121 additions and 86 deletions

View file

@ -371,6 +371,7 @@ type EmojiBoardProps = {
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
allowTextCustomEmoji?: boolean; allowTextCustomEmoji?: boolean;
addToRecentEmoji?: boolean; addToRecentEmoji?: boolean;
dock?: boolean;
}; };
export function EmojiBoard({ export function EmojiBoard({
@ -384,6 +385,7 @@ export function EmojiBoard({
onStickerSelect, onStickerSelect,
allowTextCustomEmoji, allowTextCustomEmoji,
addToRecentEmoji = true, addToRecentEmoji = true,
dock,
}: EmojiBoardProps) { }: EmojiBoardProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const { t } = useTranslation(); const { t } = useTranslation();
@ -513,6 +515,7 @@ export function EmojiBoard({
}} }}
> >
<EmojiBoardLayout <EmojiBoardLayout
dock={dock}
header={ header={
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />} {onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}

View file

@ -9,11 +9,12 @@ export const EmojiBoardLayout = as<
header: ReactNode; header: ReactNode;
sidebar?: ReactNode; sidebar?: ReactNode;
children: ReactNode; children: ReactNode;
dock?: boolean;
} }
>(({ className, header, sidebar, children, ...props }, ref) => ( >(({ className, header, sidebar, children, dock, ...props }, ref) => (
<Box <Box
display="InlineFlex" display={dock ? 'Flex' : 'InlineFlex'}
className={classNames(css.Base, className)} className={classNames(css.Base, dock && css.BaseDock, className)}
direction="Row" direction="Row"
{...props} {...props}
ref={ref} ref={ref}

View file

@ -19,6 +19,21 @@ export const Base = style({
overflow: 'hidden', overflow: 'hidden',
}); });
// Docked variant — the board lives inline at the top of the composer on native
// instead of floating as a pop-out. Full-width, capped height, no shadow/round
// corners (the composer pill already clips it); only a hairline parts it from
// the textarea row below.
export const BaseDock = style({
maxWidth: 'unset',
width: '100%',
height: toRem(320),
maxHeight: '46vh',
border: 'none',
borderBottom: `${config.borderWidth.B300} solid rgba(255, 255, 255, 0.08)`,
borderRadius: 0,
boxShadow: 'none',
});
export const Header = style({ export const Header = style({
padding: config.space.S300, padding: config.space.S300,
paddingBottom: 0, paddingBottom: 0,

View file

@ -57,7 +57,7 @@ import {
getMentions, getMentions,
} from '../../components/editor'; } from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { import {
TUploadContent, TUploadContent,
encryptFile, encryptFile,
@ -216,6 +216,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const isOneOnOne = useIsOneOnOne(); const isOneOnOne = useIsOneOnOne();
const commands = useCommands(mx, room); const commands = useCommands(mx, room);
const emojiBtnRef = useRef<HTMLButtonElement>(null); const emojiBtnRef = useRef<HTMLButtonElement>(null);
const screenSize = useScreenSizeContext();
// On native / narrow screens the emoji-sticker board is docked inline at the
// top of the composer instead of floating as a pop-out.
const dockEmojiBoard = mobileOrTablet() || screenSize === ScreenSize.Mobile;
const [emojiBoardTab, setEmojiBoardTab] = useState<EmojiBoardTab | undefined>(undefined);
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
const powerLevels = usePowerLevelsContext(); const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room); const creators = useRoomCreators(room);
@ -601,54 +606,62 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</IconButton> </IconButton>
); );
const emojiButton = ( const closeEmojiBoard = () => {
<UseStateProvider initial={undefined}> setEmojiBoardTab(undefined);
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( if (!mobileOrTablet()) ReactEditor.focus(editor);
<PopOut };
offset={16}
alignOffset={-44} const emojiBoard = (
position="Top" <EmojiBoard
align="End" tab={emojiBoardTab ?? EmojiBoardTab.Emoji}
anchor={ onTabChange={setEmojiBoardTab}
emojiBoardTab === undefined imagePackRooms={imagePackRooms}
? undefined returnFocusOnDeactivate={false}
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined dock={dockEmojiBoard}
} onEmojiSelect={handleEmoticonSelect}
content={ onCustomEmojiSelect={handleEmoticonSelect}
<EmojiBoard onStickerSelect={handleStickerSelect}
tab={emojiBoardTab ?? EmojiBoardTab.Emoji} requestClose={closeEmojiBoard}
onTabChange={setEmojiBoardTab} />
imagePackRooms={imagePackRooms} );
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect} const emojiTriggerButton = (
onCustomEmojiSelect={handleEmoticonSelect} <IconButton
onStickerSelect={handleStickerSelect} ref={emojiBtnRef}
requestClose={() => { aria-pressed={!!emojiBoardTab}
setEmojiBoardTab((tab) => { onClick={() =>
if (tab) { dockEmojiBoard
if (!mobileOrTablet()) ReactEditor.focus(editor); ? setEmojiBoardTab(emojiBoardTab ? undefined : EmojiBoardTab.Emoji)
return undefined; : setEmojiBoardTab(EmojiBoardTab.Emoji)
} }
return tab; variant="SurfaceVariant"
}); fill="None"
}} size="300"
/> radii="300"
} >
> <Icon src={StreamComposerIcons.Smile} />
<IconButton </IconButton>
ref={emojiBtnRef} );
aria-pressed={!!emojiBoardTab}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)} // Native docks the board inline (rendered in the composer's top slot); the
variant="SurfaceVariant" // desktop trigger keeps the floating pop-out anchored to the button.
fill="None" const emojiButton = dockEmojiBoard ? (
size="300" emojiTriggerButton
radii="300" ) : (
> <PopOut
<Icon src={StreamComposerIcons.Smile} /> offset={16}
</IconButton> alignOffset={-44}
</PopOut> position="Top"
)} align="End"
</UseStateProvider> anchor={
emojiBoardTab === undefined
? undefined
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
}
content={emojiBoard}
>
{emojiTriggerButton}
</PopOut>
); );
const sendButton = ( const sendButton = (
@ -785,43 +798,46 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onPaste={handlePaste} onPaste={handlePaste}
top={ top={
replyDraft && ( <>
<div> {dockEmojiBoard && emojiBoardTab !== undefined && emojiBoard}
<Box {replyDraft && (
alignItems="Center" <div>
gap="300" <Box
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} alignItems="Center"
> gap="300"
<IconButton style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
> >
<Icon src={Icons.Cross} size="50" /> <IconButton
</IconButton> onClick={() => setReplyDraft(undefined)}
<Box direction="Row" gap="200" alignItems="Center"> variant="SurfaceVariant"
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />} size="300"
<ReplyLayout radii="300"
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
> >
<Text size="T300" truncate> <Icon src={Icons.Cross} size="50" />
{trimReplyFromBody(replyDraft.body)} </IconButton>
</Text> <Box direction="Row" gap="200" alignItems="Center">
</ReplyLayout> {replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box>
</Box> </Box>
</Box> </div>
</div> )}
) </>
} }
bottom={ bottom={
<Box <Box