From 31efbf73b796449cca057ede48ace9c01df66021 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Thu, 18 Sep 2025 06:44:08 +0530 Subject: [PATCH 1/2] Make emojiboard lightweight on low end devices (#2484) * extract emoji search component * extract emoji board tabs component * extract sidebar component * extract no stickers component * create emoji/sticker preview atom * extract component from emoji/sticker item and sidebar buttons * fix image group icon not loading * separate emojis and sticker groups logic * extract layout and emoji group components * add virtualization in emoji board groups * fix scroll to alignment --- src/app/components/emoji-board/EmojiBoard.tsx | 1232 ++++++----------- .../emoji-board/components/Group.tsx | 34 + .../emoji-board/components/Item.tsx | 105 ++ .../emoji-board/components/Layout.tsx | 30 + .../emoji-board/components/NoStickerPacks.tsx | 22 + .../emoji-board/components/Preview.tsx | 53 + .../emoji-board/components/SearchInput.tsx | 51 + .../emoji-board/components/Sidebar.tsx | 130 ++ .../emoji-board/components/Tabs.tsx | 44 + .../emoji-board/components/index.tsx | 8 + .../styles.css.ts} | 71 +- src/app/components/emoji-board/index.ts | 1 + src/app/components/emoji-board/types.ts | 17 + 13 files changed, 973 insertions(+), 825 deletions(-) create mode 100644 src/app/components/emoji-board/components/Group.tsx create mode 100644 src/app/components/emoji-board/components/Item.tsx create mode 100644 src/app/components/emoji-board/components/Layout.tsx create mode 100644 src/app/components/emoji-board/components/NoStickerPacks.tsx create mode 100644 src/app/components/emoji-board/components/Preview.tsx create mode 100644 src/app/components/emoji-board/components/SearchInput.tsx create mode 100644 src/app/components/emoji-board/components/Sidebar.tsx create mode 100644 src/app/components/emoji-board/components/Tabs.tsx create mode 100644 src/app/components/emoji-board/components/index.tsx rename src/app/components/emoji-board/{EmojiBoard.css.tsx => components/styles.css.ts} (83%) create mode 100644 src/app/components/emoji-board/types.ts diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 72a60f2b..3db27e2a 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -2,640 +2,346 @@ import React, { ChangeEventHandler, FocusEventHandler, MouseEventHandler, - UIEventHandler, ReactNode, - memo, + RefObject, useCallback, useEffect, useMemo, useRef, } from 'react'; -import { - Badge, - Box, - Chip, - Icon, - IconButton, - Icons, - Input, - Line, - Scroll, - Text, - Tooltip, - TooltipProvider, - as, - config, - toRem, -} from 'folds'; +import { Box, config, Icons, Scroll } from 'folds'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; -import classNames from 'classnames'; -import { MatrixClient, Room } from 'matrix-js-sdk'; -import { atom, useAtomValue, useSetAtom } from 'jotai'; - -import * as css from './EmojiBoard.css'; -import { EmojiGroupId, IEmoji, IEmojiGroup, emojiGroups, emojis } from '../../plugins/emoji'; -import { IEmojiGroupLabels, useEmojiGroupLabels } from './useEmojiGroupLabels'; -import { IEmojiGroupIcons, useEmojiGroupIcons } from './useEmojiGroupIcons'; +import { Room } from 'matrix-js-sdk'; +import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { IEmoji, emojiGroups, emojis } from '../../plugins/emoji'; +import { useEmojiGroupLabels } from './useEmojiGroupLabels'; +import { useEmojiGroupIcons } from './useEmojiGroupIcons'; import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../hooks/useRecentEmoji'; import { isUserId, mxcUrlToHttp } from '../../utils/matrix'; -import { editableActiveElement, isIntersectingScrollView, targetFromEvent } from '../../utils/dom'; +import { editableActiveElement, targetFromEvent } from '../../utils/dom'; import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearch'; import { useDebounce } from '../../hooks/useDebounce'; import { useThrottle } from '../../hooks/useThrottle'; import { addRecentEmoji } from '../../plugins/recent-emoji'; -import { mobileOrTablet } from '../../utils/user-agent'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji'; import { getEmoticonSearchStr } from '../../plugins/utils'; +import { + SearchInput, + EmojiBoardTabs, + SidebarStack, + SidebarDivider, + Sidebar, + NoStickerPacks, + createPreviewDataAtom, + Preview, + PreviewData, + EmojiItem, + StickerItem, + CustomEmojiItem, + ImageGroupIcon, + GroupIcon, + getEmojiItemInfo, + EmojiGroup, + EmojiBoardLayout, +} from './components'; +import { EmojiBoardTab, EmojiType } from './types'; +import { VirtualTile } from '../virtualizer'; const RECENT_GROUP_ID = 'recent_group'; const SEARCH_GROUP_ID = 'search_group'; -export enum EmojiBoardTab { - Emoji = 'Emoji', - Sticker = 'Sticker', -} - -enum EmojiType { - Emoji = 'emoji', - CustomEmoji = 'customEmoji', - Sticker = 'sticker', -} - -export type EmojiItemInfo = { - type: EmojiType; - data: string; - shortcode: string; - label: string; +type EmojiGroupItem = { + id: string; + name: string; + items: Array; +}; +type StickerGroupItem = { + id: string; + name: string; + items: Array; }; -const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`; +const useGroups = ( + tab: EmojiBoardTab, + imagePacks: ImagePack[] +): [EmojiGroupItem[], StickerGroupItem[]] => { + const mx = useMatrixClient(); -const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => { - const type = element.getAttribute('data-emoji-type') as EmojiType | undefined; - const data = element.getAttribute('data-emoji-data'); - const label = element.getAttribute('title'); - const shortcode = element.getAttribute('data-emoji-shortcode'); + const recentEmojis = useRecentEmoji(mx, 21); + const labels = useEmojiGroupLabels(); - if (type && data && shortcode && label) - return { - type, - data, - shortcode, - label, - }; - return undefined; + const emojiGroupItems = useMemo(() => { + const g: EmojiGroupItem[] = []; + if (tab !== EmojiBoardTab.Emoji) return g; + + g.push({ + id: RECENT_GROUP_ID, + name: 'Recent', + items: recentEmojis, + }); + + imagePacks.forEach((pack) => { + let label = pack.meta.name; + if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + + g.push({ + id: pack.id, + name: label ?? 'Unknown', + items: pack + .getImages(ImageUsage.Emoticon) + .sort((a, b) => a.shortcode.localeCompare(b.shortcode)), + }); + }); + + emojiGroups.forEach((group) => { + g.push({ + id: group.id, + name: labels[group.id], + items: group.emojis, + }); + }); + + return g; + }, [mx, recentEmojis, labels, imagePacks, tab]); + + const stickerGroupItems = useMemo(() => { + const g: StickerGroupItem[] = []; + if (tab !== EmojiBoardTab.Sticker) return g; + + imagePacks.forEach((pack) => { + let label = pack.meta.name; + if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + + g.push({ + id: pack.id, + name: label ?? 'Unknown', + items: pack + .getImages(ImageUsage.Sticker) + .sort((a, b) => a.shortcode.localeCompare(b.shortcode)), + }); + }); + + return g; + }, [mx, imagePacks, tab]); + + return [emojiGroupItems, stickerGroupItems]; }; -const activeGroupIdAtom = atom(undefined); +const useItemRenderer = (tab: EmojiBoardTab) => { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const renderItem = (emoji: IEmoji | PackImageReader, index: number) => { + if ('unicode' in emoji) { + return ; + } + if (tab === EmojiBoardTab.Sticker) { + return ( + + ); + } + return ( + + ); + }; + + return renderItem; +}; + +type EmojiSidebarProps = { + activeGroupAtom: PrimitiveAtom; + packs: ImagePack[]; + onScrollToGroup: (groupId: string) => void; +}; +function EmojiSidebar({ activeGroupAtom, packs, onScrollToGroup }: EmojiSidebarProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); + const usage = ImageUsage.Emoticon; + const labels = useEmojiGroupLabels(); + const icons = useEmojiGroupIcons(); + + const handleScrollToGroup = (groupId: string) => { + setActiveGroupId(groupId); + onScrollToGroup(groupId); + }; -function Sidebar({ children }: { children: ReactNode }) { return ( - - - - {children} - - - - ); -} + + + + + {packs.length > 0 && ( + + + {packs.map((pack) => { + let label = pack.meta.name; + if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; -const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => ( - - {children} - -)); -function SidebarDivider() { - return ; -} + const url = + mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || + pack.meta.avatar; -function Header({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - -function Content({ children }: { children: ReactNode }) { - return {children}; -} - -function Footer({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - -const EmojiBoardLayout = as< - 'div', - { - header: ReactNode; - sidebar?: ReactNode; - footer?: ReactNode; - children: ReactNode; - } ->(({ className, header, sidebar, footer, children, ...props }, ref) => ( - - - {header} - {children} - {footer} - - - {sidebar} - -)); - -function EmojiBoardTabs({ - tab, - onTabChange, -}: { - tab: EmojiBoardTab; - onTabChange: (tab: EmojiBoardTab) => void; -}) { - return ( - - onTabChange(EmojiBoardTab.Sticker)} - > - - Sticker - - - onTabChange(EmojiBoardTab.Emoji)} - > - - Emoji - - - - ); -} - -export function SidebarBtn({ - active, - label, - id, - onItemClick, - children, -}: { - active?: boolean; - label: string; - id: T; - onItemClick: (id: T) => void; - children: ReactNode; -}) { - return ( - - {label} - - } - > - {(ref) => ( - onItemClick(id)} - size="400" - radii="300" - variant="Surface" - > - {children} - + return ( + + ); + })} + )} - + + + {emojiGroups.map((group) => ( + + ))} + + ); } -export const EmojiGroup = as< - 'div', - { - id: string; - label: string; - children: ReactNode; - } ->(({ className, id, label, children, ...props }, ref) => ( - - - {label} - -
- +type StickerSidebarProps = { + activeGroupAtom: PrimitiveAtom; + packs: ImagePack[]; + onScrollToGroup: (groupId: string) => void; +}; +function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSidebarProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom); + const usage = ImageUsage.Sticker; + + const handleScrollToGroup = (groupId: string) => { + setActiveGroupId(groupId); + onScrollToGroup(groupId); + }; + + return ( + + + {packs.map((pack) => { + let label = pack.meta.name; + if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; + + const url = + mxcUrlToHttp(mx, pack.getAvatarUrl(usage) ?? '', useAuthentication) || pack.meta.avatar; + + return ( + + ); + })} + + + ); +} + +type EmojiGroupHolderProps = { + contentScrollRef: RefObject; + previewAtom: PrimitiveAtom; + children?: ReactNode; + onGroupItemClick: MouseEventHandler; +}; +function EmojiGroupHolder({ + contentScrollRef, + previewAtom, + onGroupItemClick, + children, +}: EmojiGroupHolderProps) { + const setPreviewData = useSetAtom(previewAtom); + + const handleEmojiPreview = useCallback( + (element: HTMLButtonElement) => { + const emojiInfo = getEmojiItemInfo(element); + if (!emojiInfo) return; + + setPreviewData({ + key: emojiInfo.data, + shortcode: emojiInfo.shortcode, + }); + }, + [setPreviewData] + ); + + const throttleEmojiHover = useThrottle(handleEmojiPreview, { + wait: 200, + immediate: true, + }); + + const handleEmojiHover: MouseEventHandler = (evt) => { + const targetEl = targetFromEvent(evt.nativeEvent, 'button') as HTMLButtonElement | undefined; + if (!targetEl) return; + throttleEmojiHover(targetEl); + }; + + const handleEmojiFocus: FocusEventHandler = (evt) => { + const targetEl = evt.target as HTMLButtonElement; + handleEmojiPreview(targetEl); + }; + + return ( + + {children} -
-
-)); - -export function EmojiItem({ - label, - type, - data, - shortcode, - children, -}: { - label: string; - type: EmojiType; - data: string; - shortcode: string; - children: ReactNode; -}) { - return ( - - {children} - + ); } -export function StickerItem({ - label, - type, - data, - shortcode, - children, -}: { - label: string; - type: EmojiType; - data: string; - shortcode: string; - children: ReactNode; -}) { - return ( - - {children} - - ); -} - -function RecentEmojiSidebarStack({ onItemClick }: { onItemClick: (id: string) => void }) { - const activeGroupId = useAtomValue(activeGroupIdAtom); - - return ( - - onItemClick(RECENT_GROUP_ID)} - > - - - - ); -} - -function ImagePackSidebarStack({ - mx, - packs, - usage, - onItemClick, - useAuthentication, -}: { - mx: MatrixClient; - packs: ImagePack[]; - usage: ImageUsage; - onItemClick: (id: string) => void; - useAuthentication?: boolean; -}) { - const activeGroupId = useAtomValue(activeGroupIdAtom); - return ( - - {usage === ImageUsage.Emoticon && } - {packs.map((pack) => { - let label = pack.meta.name; - if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name; - return ( - - {label - - ); - })} - - ); -} - -function NativeEmojiSidebarStack({ - groups, - icons, - labels, - onItemClick, -}: { - groups: IEmojiGroup[]; - icons: IEmojiGroupIcons; - labels: IEmojiGroupLabels; - onItemClick: (id: EmojiGroupId) => void; -}) { - const activeGroupId = useAtomValue(activeGroupIdAtom); - return ( - - - {groups.map((group) => ( - - - - ))} - - ); -} - -export function RecentEmojiGroup({ - label, - id, - emojis: recentEmojis, -}: { - label: string; - id: string; - emojis: IEmoji[]; -}) { - return ( - - {recentEmojis.map((emoji) => ( - - {emoji.unicode} - - ))} - - ); -} - -export function SearchEmojiGroup({ - mx, - tab, - label, - id, - emojis: searchResult, - useAuthentication, -}: { - mx: MatrixClient; - tab: EmojiBoardTab; - label: string; - id: string; - emojis: Array; - useAuthentication?: boolean; -}) { - return ( - - {tab === EmojiBoardTab.Emoji - ? searchResult.map((emoji) => - 'unicode' in emoji ? ( - - {emoji.unicode} - - ) : ( - - {emoji.body - - ) - ) - : searchResult.map((emoji) => - 'unicode' in emoji ? null : ( - - {emoji.body - - ) - )} - - ); -} - -export const CustomEmojiGroups = memo( - ({ - mx, - groups, - useAuthentication, - }: { - mx: MatrixClient; - groups: ImagePack[]; - useAuthentication?: boolean; - }) => ( - <> - {groups.map((pack) => ( - - {pack - .getImages(ImageUsage.Emoticon) - .sort((a, b) => a.shortcode.localeCompare(b.shortcode)) - .map((image) => ( - - {image.body - - ))} - - ))} - - ) -); - -export const StickerGroups = memo( - ({ - mx, - groups, - useAuthentication, - }: { - mx: MatrixClient; - groups: ImagePack[]; - useAuthentication?: boolean; - }) => ( - <> - {groups.length === 0 && ( - - - - No Sticker Packs! - - Add stickers from user, room or space settings. - - - - )} - {groups.map((pack) => ( - - {pack - .getImages(ImageUsage.Sticker) - .sort((a, b) => a.shortcode.localeCompare(b.shortcode)) - .map((image) => ( - - {image.body - - ))} - - ))} - - ) -); - -export const NativeEmojiGroups = memo( - ({ groups, labels }: { groups: IEmojiGroup[]; labels: IEmojiGroupLabels }) => ( - <> - {groups.map((emojiGroup) => ( - - {emojiGroup.emojis.map((emoji) => ( - - {emoji.unicode} - - ))} - - ))} - - ) -); +const DefaultEmojiPreview: PreviewData = { key: '🙂', shortcode: 'slight_smile' }; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -644,6 +350,21 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = { }, }; +const VIRTUAL_OVER_SCAN = 2; + +type EmojiBoardProps = { + tab?: EmojiBoardTab; + onTabChange?: (tab: EmojiBoardTab) => void; + imagePackRooms: Room[]; + requestClose: () => void; + returnFocusOnDeactivate?: boolean; + onEmojiSelect?: (unicode: string, shortcode: string) => void; + onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; + onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; + allowTextCustomEmoji?: boolean; + addToRecentEmoji?: boolean; +}; + export function EmojiBoard({ tab = EmojiBoardTab.Emoji, onTabChange, @@ -655,33 +376,22 @@ export function EmojiBoard({ onStickerSelect, allowTextCustomEmoji, addToRecentEmoji = true, -}: { - tab?: EmojiBoardTab; - onTabChange?: (tab: EmojiBoardTab) => void; - imagePackRooms: Room[]; - requestClose: () => void; - returnFocusOnDeactivate?: boolean; - onEmojiSelect?: (unicode: string, shortcode: string) => void; - onCustomEmojiSelect?: (mxc: string, shortcode: string) => void; - onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; - allowTextCustomEmoji?: boolean; - addToRecentEmoji?: boolean; -}) { +}: EmojiBoardProps) { + const mx = useMatrixClient(); + const emojiTab = tab === EmojiBoardTab.Emoji; - const stickerTab = tab === EmojiBoardTab.Sticker; const usage = emojiTab ? ImageUsage.Emoticon : ImageUsage.Sticker; + const previewAtom = useMemo( + () => createPreviewDataAtom(emojiTab ? DefaultEmojiPreview : undefined), + [emojiTab] + ); + const activeGroupIdAtom = useMemo(() => atom(undefined), []); const setActiveGroupId = useSetAtom(activeGroupIdAtom); - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const emojiGroupLabels = useEmojiGroupLabels(); - const emojiGroupIcons = useEmojiGroupIcons(); const imagePacks = useRelevantImagePacks(usage, imagePackRooms); - const recentEmojis = useRecentEmoji(mx, 21); - - const contentScrollRef = useRef(null); - const emojiPreviewRef = useRef(null); - const emojiPreviewTextRef = useRef(null); + const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks); + const groups = emojiTab ? emojiGroupItems : stickerGroupItems; + const renderItem = useItemRenderer(tab); const searchList = useMemo(() => { let list: Array = []; @@ -710,94 +420,73 @@ export function EmojiBoard({ { wait: 200 } ); - const syncActiveGroupId = useCallback(() => { - const targetEl = contentScrollRef.current; - if (!targetEl) return; - const groupEls = Array.from(targetEl.querySelectorAll('div[data-group-id]')) as HTMLElement[]; - const groupEl = groupEls.find((el) => isIntersectingScrollView(targetEl, el)); - const groupId = groupEl?.getAttribute('data-group-id') ?? undefined; - setActiveGroupId(groupId); - }, [setActiveGroupId]); - - const handleOnScroll: UIEventHandler = useThrottle(syncActiveGroupId, { - wait: 500, + const contentScrollRef = useRef(null); + const virtualBaseRef = useRef(null); + const virtualizer = useVirtualizer({ + count: groups.length, + getScrollElement: () => contentScrollRef.current, + estimateSize: () => 40, + overscan: VIRTUAL_OVER_SCAN, }); + const vItems = virtualizer.getVirtualItems(); - const handleScrollToGroup = (groupId: string) => { - setActiveGroupId(groupId); - const groupElement = document.getElementById(getDOMGroupId(groupId)); - groupElement?.scrollIntoView(); - }; - - const handleEmojiClick: MouseEventHandler = (evt) => { + const handleGroupItemClick: MouseEventHandler = (evt) => { const targetEl = targetFromEvent(evt.nativeEvent, 'button'); - if (!targetEl) return; - const emojiInfo = getEmojiItemInfo(targetEl); + const emojiInfo = targetEl && getEmojiItemInfo(targetEl); if (!emojiInfo) return; + if (emojiInfo.type === EmojiType.Emoji) { onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); - if (!evt.altKey && !evt.shiftKey) { - if (addToRecentEmoji) { - addRecentEmoji(mx, emojiInfo.data); - } - requestClose(); + if (!evt.altKey && !evt.shiftKey && addToRecentEmoji) { + addRecentEmoji(mx, emojiInfo.data); } } if (emojiInfo.type === EmojiType.CustomEmoji) { onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode); - if (!evt.altKey && !evt.shiftKey) requestClose(); } if (emojiInfo.type === EmojiType.Sticker) { onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label); - if (!evt.altKey && !evt.shiftKey) requestClose(); } + if (!evt.altKey && !evt.shiftKey) requestClose(); }; - const handleEmojiPreview = useCallback( - (element: HTMLButtonElement) => { - const emojiInfo = getEmojiItemInfo(element); - if (!emojiInfo || !emojiPreviewTextRef.current) return; - if (emojiInfo.type === EmojiType.Emoji && emojiPreviewRef.current) { - emojiPreviewRef.current.textContent = emojiInfo.data; - } else if (emojiInfo.type === EmojiType.CustomEmoji && emojiPreviewRef.current) { - const img = document.createElement('img'); - img.className = css.CustomEmojiImg; - img.setAttribute( - 'src', - mxcUrlToHttp(mx, emojiInfo.data, useAuthentication) || emojiInfo.data - ); - img.setAttribute('alt', emojiInfo.shortcode); - emojiPreviewRef.current.textContent = ''; - emojiPreviewRef.current.appendChild(img); - } - emojiPreviewTextRef.current.textContent = `:${emojiInfo.shortcode}:`; - }, - [mx, useAuthentication] - ); - - const throttleEmojiHover = useThrottle(handleEmojiPreview, { - wait: 200, - immediate: true, - }); - - const handleEmojiHover: MouseEventHandler = (evt) => { - const targetEl = targetFromEvent(evt.nativeEvent, 'button') as HTMLButtonElement | undefined; - if (!targetEl) return; - throttleEmojiHover(targetEl); + const handleTextCustomEmojiSelect = (textEmoji: string) => { + onCustomEmojiSelect?.(textEmoji, textEmoji); + requestClose(); }; - const handleEmojiFocus: FocusEventHandler = (evt) => { - const targetEl = evt.target as HTMLButtonElement; - handleEmojiPreview(targetEl); + const handleScrollToGroup = (groupId: string) => { + const groupIndex = groups.findIndex((group) => group.id === groupId); + virtualizer.scrollToIndex(groupIndex, { align: 'start' }); }; - // Reset scroll top on search and tab change + // sync active sidebar tab with scroll useEffect(() => { - syncActiveGroupId(); - contentScrollRef.current?.scrollTo({ - top: 0, - }); - }, [result, emojiTab, syncActiveGroupId]); + const scrollElement = contentScrollRef.current; + if (scrollElement) { + const scrollTop = scrollElement.offsetTop + scrollElement.scrollTop; + const offsetTop = virtualBaseRef.current?.offsetTop ?? 0; + const inViewVItem = vItems.find((vItem) => scrollTop < offsetTop + vItem.end); + + const group = inViewVItem ? groups[inViewVItem?.index] : undefined; + setActiveGroupId(group?.id); + } + }, [vItems, groups, setActiveGroupId, result?.query]); + + // reset scroll position on search + useEffect(() => { + const scrollElement = contentScrollRef.current; + if (scrollElement) { + scrollElement.scrollTo({ top: 0 }); + } + }, [result?.query]); + + // reset scroll position on tab change + useEffect(() => { + if (groups.length > 0) { + virtualizer.scrollToIndex(0, { align: 'start' }); + } + }, [tab, virtualizer, groups]); return ( - - {onTabChange && } - } - outlined - onClick={() => { - const searchInput = document.querySelector( - '[data-emoji-board-search="true"]' - ); - const textReaction = searchInput?.value.trim(); - if (!textReaction) return; - onCustomEmojiSelect?.(textReaction, textReaction); - requestClose(); - }} - > - React - - ) : ( - - ) - } - onChange={handleOnChange} - autoFocus={!mobileOrTablet()} - /> - - + + {onTabChange && } + + } sidebar={ - - {emojiTab && recentEmojis.length > 0 && ( - - )} - {imagePacks.length > 0 && ( - - )} - {emojiTab && ( - - )} - - } - footer={ emojiTab ? ( -
- - 😃 - - - :smiley: - -
+ ) : ( - imagePacks.length > 0 && ( -
- - :smiley: - -
- ) + ) } > - - + - + {searchedItems.map(renderItem)} + + )} +
- {searchedItems && ( - - )} - {emojiTab && recentEmojis.length > 0 && ( - - )} - {emojiTab && ( - - )} - {stickerTab && ( - - )} - {emojiTab && } - - - + {vItems.map((vItem) => { + const group = groups[vItem.index]; + + return ( + + + {group.items.map(renderItem)} + + + ); + })} +
+ {tab === EmojiBoardTab.Sticker && groups.length === 0 && } +
+ +
); diff --git a/src/app/components/emoji-board/components/Group.tsx b/src/app/components/emoji-board/components/Group.tsx new file mode 100644 index 00000000..cf19c6e0 --- /dev/null +++ b/src/app/components/emoji-board/components/Group.tsx @@ -0,0 +1,34 @@ +import { as, Box, Text } from 'folds'; +import React, { ReactNode } from 'react'; +import classNames from 'classnames'; +import * as css from './styles.css'; + +export const getDOMGroupId = (id: string): string => `EmojiBoardGroup-${id}`; + +export const EmojiGroup = as< + 'div', + { + id: string; + label: string; + children: ReactNode; + } +>(({ className, id, label, children, ...props }, ref) => ( + + + {label} + +
+ + {children} + +
+
+)); diff --git a/src/app/components/emoji-board/components/Item.tsx b/src/app/components/emoji-board/components/Item.tsx new file mode 100644 index 00000000..c3fd3c3a --- /dev/null +++ b/src/app/components/emoji-board/components/Item.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { Box } from 'folds'; +import { MatrixClient } from 'matrix-js-sdk'; +import { EmojiItemInfo, EmojiType } from '../types'; +import * as css from './styles.css'; +import { PackImageReader } from '../../../plugins/custom-emoji'; +import { IEmoji } from '../../../plugins/emoji'; +import { mxcUrlToHttp } from '../../../utils/matrix'; + +export const getEmojiItemInfo = (element: Element): EmojiItemInfo | undefined => { + const label = element.getAttribute('title'); + const type = element.getAttribute('data-emoji-type') as EmojiType | undefined; + const data = element.getAttribute('data-emoji-data'); + const shortcode = element.getAttribute('data-emoji-shortcode'); + + if (type && data && shortcode && label) + return { + type, + data, + shortcode, + label, + }; + return undefined; +}; + +type EmojiItemProps = { + emoji: IEmoji; +}; +export function EmojiItem({ emoji }: EmojiItemProps) { + return ( + + {emoji.unicode} + + ); +} + +type CustomEmojiItemProps = { + mx: MatrixClient; + useAuthentication?: boolean; + image: PackImageReader; +}; +export function CustomEmojiItem({ mx, useAuthentication, image }: CustomEmojiItemProps) { + return ( + + {image.body + + ); +} + +type StickerItemProps = { + mx: MatrixClient; + useAuthentication?: boolean; + image: PackImageReader; +}; + +export function StickerItem({ mx, useAuthentication, image }: StickerItemProps) { + return ( + + {image.body + + ); +} diff --git a/src/app/components/emoji-board/components/Layout.tsx b/src/app/components/emoji-board/components/Layout.tsx new file mode 100644 index 00000000..392d4a31 --- /dev/null +++ b/src/app/components/emoji-board/components/Layout.tsx @@ -0,0 +1,30 @@ +import { as, Box, Line } from 'folds'; +import React, { ReactNode } from 'react'; +import classNames from 'classnames'; +import * as css from './styles.css'; + +export const EmojiBoardLayout = as< + 'div', + { + header: ReactNode; + sidebar?: ReactNode; + children: ReactNode; + } +>(({ className, header, sidebar, children, ...props }, ref) => ( + + + + {header} + + {children} + + + {sidebar} + +)); diff --git a/src/app/components/emoji-board/components/NoStickerPacks.tsx b/src/app/components/emoji-board/components/NoStickerPacks.tsx new file mode 100644 index 00000000..6703362c --- /dev/null +++ b/src/app/components/emoji-board/components/NoStickerPacks.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Box, toRem, config, Icons, Icon, Text } from 'folds'; + +export function NoStickerPacks() { + return ( + + + + No Sticker Packs! + + Add stickers from user, room or space settings. + + + + ); +} diff --git a/src/app/components/emoji-board/components/Preview.tsx b/src/app/components/emoji-board/components/Preview.tsx new file mode 100644 index 00000000..3f5f8d3a --- /dev/null +++ b/src/app/components/emoji-board/components/Preview.tsx @@ -0,0 +1,53 @@ +import { Box, Text } from 'folds'; +import React from 'react'; +import { Atom, atom, useAtomValue } from 'jotai'; +import * as css from './styles.css'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { mxcUrlToHttp } from '../../../utils/matrix'; + +export type PreviewData = { + key: string; + shortcode: string; +}; + +export const createPreviewDataAtom = (initial?: PreviewData) => + atom(initial); + +type PreviewProps = { + previewAtom: Atom; +}; +export function Preview({ previewAtom }: PreviewProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + const { key, shortcode } = useAtomValue(previewAtom) ?? {}; + + if (!shortcode) return null; + + return ( + + {key && ( + + {key.startsWith('mxc://') ? ( + {shortcode} + ) : ( + key + )} + + )} + + :{shortcode}: + + + ); +} diff --git a/src/app/components/emoji-board/components/SearchInput.tsx b/src/app/components/emoji-board/components/SearchInput.tsx new file mode 100644 index 00000000..6de4d977 --- /dev/null +++ b/src/app/components/emoji-board/components/SearchInput.tsx @@ -0,0 +1,51 @@ +import React, { ChangeEventHandler, useRef } from 'react'; +import { Input, Chip, Icon, Icons, Text } from 'folds'; +import { mobileOrTablet } from '../../../utils/user-agent'; + +type SearchInputProps = { + query?: string; + onChange: ChangeEventHandler; + allowTextCustomEmoji?: boolean; + onTextCustomEmojiSelect?: (text: string) => void; +}; +export function SearchInput({ + query, + onChange, + allowTextCustomEmoji, + onTextCustomEmojiSelect, +}: SearchInputProps) { + const inputRef = useRef(null); + + const handleReact = () => { + const textEmoji = inputRef.current?.value.trim(); + if (!textEmoji) return; + onTextCustomEmojiSelect?.(textEmoji); + }; + + return ( + } + outlined + onClick={handleReact} + > + React + + ) : ( + + ) + } + onChange={onChange} + autoFocus={!mobileOrTablet()} + /> + ); +} diff --git a/src/app/components/emoji-board/components/Sidebar.tsx b/src/app/components/emoji-board/components/Sidebar.tsx new file mode 100644 index 00000000..de22b483 --- /dev/null +++ b/src/app/components/emoji-board/components/Sidebar.tsx @@ -0,0 +1,130 @@ +import React, { ReactNode } from 'react'; +import { + Box, + Scroll, + Line, + as, + TooltipProvider, + Tooltip, + Text, + IconButton, + Icon, + IconSrc, + Icons, +} from 'folds'; +import classNames from 'classnames'; +import * as css from './styles.css'; + +export function Sidebar({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + + ); +} + +export const SidebarStack = as<'div'>(({ className, children, ...props }, ref) => ( + + {children} + +)); +export function SidebarDivider() { + return ; +} + +function SidebarBtn({ + active, + label, + id, + onClick, + children, +}: { + active?: boolean; + label: string; + id: T; + onClick: (id: T) => void; + children: ReactNode; +}) { + return ( + + {label} + + } + > + {(ref) => ( + onClick(id)} + size="400" + radii="300" + variant="Surface" + > + {children} + + )} + + ); +} + +type GroupIconProps = { + active: boolean; + id: T; + label: string; + icon: IconSrc; + onClick: (id: T) => void; +}; +export function GroupIcon({ + active, + id, + label, + icon, + onClick, +}: GroupIconProps) { + return ( + + + + ); +} + +type ImageGroupIconProps = { + active: boolean; + id: T; + label: string; + url?: string; + onClick: (id: T) => void; +}; +export function ImageGroupIcon({ + active, + id, + label, + url, + onClick, +}: ImageGroupIconProps) { + return ( + + {url ? ( + {label} + ) : ( + + )} + + ); +} diff --git a/src/app/components/emoji-board/components/Tabs.tsx b/src/app/components/emoji-board/components/Tabs.tsx new file mode 100644 index 00000000..d433354f --- /dev/null +++ b/src/app/components/emoji-board/components/Tabs.tsx @@ -0,0 +1,44 @@ +import React, { CSSProperties } from 'react'; +import { Badge, Box, Text } from 'folds'; +import { EmojiBoardTab } from '../types'; + +const styles: CSSProperties = { + cursor: 'pointer', +}; + +export function EmojiBoardTabs({ + tab, + onTabChange, +}: { + tab: EmojiBoardTab; + onTabChange: (tab: EmojiBoardTab) => void; +}) { + return ( + + onTabChange(EmojiBoardTab.Sticker)} + > + + Sticker + + + onTabChange(EmojiBoardTab.Emoji)} + > + + Emoji + + + + ); +} diff --git a/src/app/components/emoji-board/components/index.tsx b/src/app/components/emoji-board/components/index.tsx new file mode 100644 index 00000000..55506668 --- /dev/null +++ b/src/app/components/emoji-board/components/index.tsx @@ -0,0 +1,8 @@ +export * from './SearchInput'; +export * from './Tabs'; +export * from './Sidebar'; +export * from './NoStickerPacks'; +export * from './Preview'; +export * from './Item'; +export * from './Group'; +export * from './Layout'; diff --git a/src/app/components/emoji-board/EmojiBoard.css.tsx b/src/app/components/emoji-board/components/styles.css.ts similarity index 83% rename from src/app/components/emoji-board/EmojiBoard.css.tsx rename to src/app/components/emoji-board/components/styles.css.ts index ba4ca4e0..c86a08d8 100644 --- a/src/app/components/emoji-board/EmojiBoard.css.tsx +++ b/src/app/components/emoji-board/components/styles.css.ts @@ -1,5 +1,9 @@ import { style } from '@vanilla-extract/css'; -import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; +import { toRem, color, config, DefaultReset, FocusOutline } from 'folds'; + +/** + * Layout + */ export const Base = style({ maxWidth: toRem(432), @@ -13,6 +17,15 @@ export const Base = style({ overflow: 'hidden', }); +export const Header = style({ + padding: config.space.S300, + paddingBottom: 0, +}); + +/** + * Sidebar + */ + export const Sidebar = style({ width: toRem(54), backgroundColor: color.Surface.Container, @@ -29,26 +42,21 @@ export const SidebarStack = style({ backgroundColor: color.Surface.Container, }); -export const NativeEmojiSidebarStack = style({ - position: 'sticky', - bottom: '-67%', - zIndex: 1, -}); - export const SidebarDivider = style({ width: toRem(18), }); -export const Header = style({ - padding: config.space.S300, - paddingBottom: 0, +export const SidebarBtnImg = style({ + width: toRem(24), + height: toRem(24), + objectFit: 'contain', }); -export const EmojiBoardTab = style({ - cursor: 'pointer', -}); +/** + * Preview + */ -export const Footer = style({ +export const Preview = style({ padding: config.space.S200, margin: config.space.S300, marginTop: 0, @@ -59,7 +67,30 @@ export const Footer = style({ color: color.SurfaceVariant.OnContainer, }); +export const PreviewEmoji = style([ + DefaultReset, + { + width: toRem(32), + height: toRem(32), + fontSize: toRem(32), + lineHeight: toRem(32), + }, +]); +export const PreviewImg = style([ + DefaultReset, + { + width: toRem(32), + height: toRem(32), + objectFit: 'contain', + }, +]); + +/** + * Group + */ + export const EmojiGroup = style({ + position: 'relative', padding: `${config.space.S300} 0`, }); @@ -82,15 +113,9 @@ export const EmojiGroupContent = style([ }, ]); -export const EmojiPreview = style([ - DefaultReset, - { - width: toRem(32), - height: toRem(32), - fontSize: toRem(32), - lineHeight: toRem(32), - }, -]); +/** + * Item + */ export const EmojiItem = style([ DefaultReset, diff --git a/src/app/components/emoji-board/index.ts b/src/app/components/emoji-board/index.ts index 430cec07..7b1cce3b 100644 --- a/src/app/components/emoji-board/index.ts +++ b/src/app/components/emoji-board/index.ts @@ -1 +1,2 @@ export * from './EmojiBoard'; +export * from './types'; diff --git a/src/app/components/emoji-board/types.ts b/src/app/components/emoji-board/types.ts new file mode 100644 index 00000000..de94cc56 --- /dev/null +++ b/src/app/components/emoji-board/types.ts @@ -0,0 +1,17 @@ +export enum EmojiBoardTab { + Emoji = 'Emoji', + Sticker = 'Sticker', +} + +export enum EmojiType { + Emoji = 'emoji', + CustomEmoji = 'customEmoji', + Sticker = 'sticker', +} + +export type EmojiItemInfo = { + type: EmojiType; + data: string; + shortcode: string; + label: string; +}; From afc251aa7cafa0d11f531521f1c96ac560b85bde Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Fri, 19 Sep 2025 16:36:05 +0530 Subject: [PATCH 2/2] Add arrow to message bubbles and improve spacing (#2474) * Add arrow to message bubbles and improve spacing * make bubble message avatar smaller * add bubble layout for event content * adjust bubble arrow * fix missing return statement for event content * hide bubble for event content * add new arrow to bubble message * fix avatar username relative alignment * fix types * fix code block header background * revert avatar size and make arrow less sharp * show event messages timestamp to right when bubble is hidden * fix avatar base css * move message header outside bubble * fix event time appears on left in hidden bubles --- .../message/content/EventContent.tsx | 18 +++-- src/app/components/message/layout/Bubble.tsx | 65 ++++++++++++++++--- .../components/message/layout/layout.css.ts | 22 ++++++- src/app/features/room/message/Message.tsx | 12 ++-- src/app/features/room/message/styles.css.ts | 7 ++ src/app/styles/CustomHtml.css.ts | 14 ++-- 6 files changed, 111 insertions(+), 27 deletions(-) diff --git a/src/app/components/message/content/EventContent.tsx b/src/app/components/message/content/EventContent.tsx index 97ff26f7..130ba8c9 100644 --- a/src/app/components/message/content/EventContent.tsx +++ b/src/app/components/message/content/EventContent.tsx @@ -1,6 +1,6 @@ import { Box, Icon, IconSrc } from 'folds'; import React, { ReactNode } from 'react'; -import { CompactLayout, ModernLayout } from '..'; +import { BubbleLayout, CompactLayout, ModernLayout } from '..'; import { MessageLayout } from '../../../state/settings'; export type EventContentProps = { @@ -30,9 +30,15 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon ); - return messageLayout === MessageLayout.Compact ? ( - {msgContentJSX} - ) : ( - {msgContentJSX} - ); + if (messageLayout === MessageLayout.Compact) { + return {msgContentJSX}; + } + if (messageLayout === MessageLayout.Bubble) { + return ( + + {msgContentJSX} + + ); + } + return {msgContentJSX}; } diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 6f8e70da..93ff84bc 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -1,18 +1,63 @@ import React, { ReactNode } from 'react'; -import { Box, as } from 'folds'; +import classNames from 'classnames'; +import { Box, ContainerColor, as, color } from 'folds'; import * as css from './layout.css'; +type BubbleArrowProps = { + variant: ContainerColor; +}; +function BubbleLeftArrow({ variant }: BubbleArrowProps) { + return ( + + + + ); +} + type BubbleLayoutProps = { + hideBubble?: boolean; before?: ReactNode; + header?: ReactNode; }; -export const BubbleLayout = as<'div', BubbleLayoutProps>(({ before, children, ...props }, ref) => ( - - - {before} +export const BubbleLayout = as<'div', BubbleLayoutProps>( + ({ hideBubble, before, header, children, ...props }, ref) => ( + + + {before} + + + {header} + {hideBubble ? ( + children + ) : ( + + + {before ? : null} + {children} + + + )} + - - {children} - - -)); + ) +); diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 43949cef..cc2cd0c6 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -120,6 +120,7 @@ export const CompactHeader = style([ export const AvatarBase = style({ paddingTop: toRem(4), transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)', + display: 'flex', alignSelf: 'start', selectors: { @@ -133,14 +134,31 @@ export const ModernBefore = style({ minWidth: toRem(36), }); -export const BubbleBefore = style([ModernBefore]); +export const BubbleBefore = style({ + minWidth: toRem(36), +}); export const BubbleContent = style({ maxWidth: toRem(800), padding: config.space.S200, backgroundColor: color.SurfaceVariant.Container, color: color.SurfaceVariant.OnContainer, - borderRadius: config.radii.R400, + borderRadius: config.radii.R500, + position: 'relative', +}); + +export const BubbleContentArrowLeft = style({ + borderTopLeftRadius: 0, +}); + +export const BubbleLeftArrow = style({ + width: toRem(9), + height: toRem(8), + + position: 'absolute', + top: 0, + left: toRem(-8), + zIndex: 1, }); export const Username = style({ diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index fbe35770..9324e1c2 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -723,6 +723,7 @@ export const Message = as<'div', MessageProps>( const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const senderId = mEvent.getSender() ?? ''; + const [hover, setHover] = useState(false); const { hoverProps } = useHover({ onHoverChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); @@ -790,7 +791,9 @@ export const Message = as<'div', MessageProps>( ); const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && ( - + ( return ( ( )} {messageLayout === MessageLayout.Bubble && ( - - {headerJSX} + {msgContentJSX} )} diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts index b87cb505..4be501bd 100644 --- a/src/app/features/room/message/styles.css.ts +++ b/src/app/features/room/message/styles.css.ts @@ -4,6 +4,9 @@ import { DefaultReset, config, toRem } from 'folds'; export const MessageBase = style({ position: 'relative', }); +export const MessageBaseBubbleCollapsed = style({ + paddingTop: 0, +}); export const MessageOptionsBase = style([ DefaultReset, @@ -21,6 +24,10 @@ export const MessageOptionsBar = style([ }, ]); +export const BubbleAvatarBase = style({ + paddingTop: 0, +}); + export const MessageAvatar = style({ cursor: 'pointer', }); diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index f717669c..ba7b9214 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -1,6 +1,7 @@ import { style } from '@vanilla-extract/css'; import { recipe } from '@vanilla-extract/recipes'; import { color, config, DefaultReset, toRem } from 'folds'; +import { ContainerColor } from './ContainerColor.css'; export const MarginSpaced = style({ marginBottom: config.space.S200, @@ -92,11 +93,14 @@ export const CodeBlock = style([ overflow: 'hidden', }, ]); -export const CodeBlockHeader = style({ - padding: `0 ${config.space.S200} 0 ${config.space.S300}`, - borderBottomWidth: config.borderWidth.B300, - gap: config.space.S200, -}); +export const CodeBlockHeader = style([ + ContainerColor({ variant: 'Surface' }), + { + padding: `0 ${config.space.S200} 0 ${config.space.S300}`, + borderBottomWidth: config.borderWidth.B300, + gap: config.space.S200, + }, +]); export const CodeBlockInternal = style([ CodeFont, {