From 11dad8541f971b33d7de8478e8ce6f1edb494537 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 26 Oct 2024 22:26:49 +0300 Subject: [PATCH] web/emojipicker: make rendering lazier --- web/src/ui/emojipicker/EmojiGroup.tsx | 114 +++++++++++++++++ web/src/ui/emojipicker/EmojiPicker.css | 1 + web/src/ui/emojipicker/EmojiPicker.tsx | 163 +++++-------------------- web/src/ui/emojipicker/renderEmoji.tsx | 25 ++++ web/src/ui/roomlist/Entry.tsx | 19 +-- web/src/util/contentvisibility.ts | 39 ++++++ web/src/util/emoji/index.ts | 49 ++++++-- 7 files changed, 250 insertions(+), 160 deletions(-) create mode 100644 web/src/ui/emojipicker/EmojiGroup.tsx create mode 100644 web/src/ui/emojipicker/renderEmoji.tsx create mode 100644 web/src/util/contentvisibility.ts diff --git a/web/src/ui/emojipicker/EmojiGroup.tsx b/web/src/ui/emojipicker/EmojiGroup.tsx new file mode 100644 index 0000000..dc39da0 --- /dev/null +++ b/web/src/ui/emojipicker/EmojiGroup.tsx @@ -0,0 +1,114 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import React, { use, useCallback } from "react" +import { stringToRoomStateGUID } from "@/api/types" +import useContentVisibility from "@/util/contentvisibility.ts" +import { CATEGORY_FREQUENTLY_USED, CustomEmojiPack, Emoji, PartialEmoji, categories } from "@/util/emoji" +import useEvent from "@/util/useEvent.ts" +import { ClientContext } from "../ClientContext.ts" +import renderEmoji from "./renderEmoji.tsx" + +interface EmojiGroupProps { + emojis: Emoji[] + categoryID: number | string + selected?: string[] + pack?: CustomEmojiPack + isWatched?: boolean + onSelect: (emoji?: PartialEmoji) => void + setPreviewEmoji: (emoji?: Emoji) => void +} + +export const EmojiGroup = ({ + emojis, + categoryID, + selected, + pack, + isWatched, + onSelect, + setPreviewEmoji, +}: EmojiGroupProps) => { + const client = use(ClientContext)! + const [isVisible, divRef] = useContentVisibility(true) + + const getEmojiFromAttrs = (elem: HTMLButtonElement) => { + const idx = elem.getAttribute("data-emoji-index") + if (!idx) { + return + } + const emoji = emojis[+idx] + if (!emoji) { + return + } + return emoji + } + const onClickEmoji = useEvent((evt: React.MouseEvent) => + onSelect(getEmojiFromAttrs(evt.currentTarget))) + const onMouseOverEmoji = useEvent((evt: React.MouseEvent) => + setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget))) + const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), [setPreviewEmoji]) + const onClickSubscribePack = useEvent((evt: React.MouseEvent) => { + const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id")) + if (!guid) { + return + } + client.subscribeToEmojiPack(guid, true) + .catch(err => window.alert(`Failed to subscribe to emoji pack: ${err}`)) + }) + const onClickUnsubscribePack = useEvent((evt: React.MouseEvent) => { + const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id")) + if (!guid) { + return + } + client.subscribeToEmojiPack(guid, false) + .catch(err => window.alert(`Failed to unsubscribe from emoji pack: ${err}`)) + }) + + let categoryName: string + if (typeof categoryID === "number") { + categoryName = categories[categoryID] + } else if (categoryID === CATEGORY_FREQUENTLY_USED) { + categoryName = CATEGORY_FREQUENTLY_USED + } else if (pack) { + categoryName = pack.name + } else { + categoryName = "Unknown category" + } + return
+

+ {categoryName} + {pack && } +

+
+ {isVisible ? emojis.map((emoji, idx) => ) : null} +
+
+} diff --git a/web/src/ui/emojipicker/EmojiPicker.css b/web/src/ui/emojipicker/EmojiPicker.css index 8e293f9..9f8a756 100644 --- a/web/src/ui/emojipicker/EmojiPicker.css +++ b/web/src/ui/emojipicker/EmojiPicker.css @@ -104,6 +104,7 @@ div.emoji-picker { div.emoji-category { width: 100%; content-visibility: auto; + contain: size; } div.emoji-category-list { diff --git a/web/src/ui/emojipicker/EmojiPicker.tsx b/web/src/ui/emojipicker/EmojiPicker.tsx index d031a24..0667f70 100644 --- a/web/src/ui/emojipicker/EmojiPicker.tsx +++ b/web/src/ui/emojipicker/EmojiPicker.tsx @@ -13,14 +13,16 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { CSSProperties, JSX, use, useCallback, useState } from "react" +import React, { CSSProperties, JSX, use, useCallback, useState } from "react" import { getMediaURL } from "@/api/media.ts" import { RoomStateStore, useCustomEmojis } from "@/api/statestore" -import { roomStateGUIDToString, stringToRoomStateGUID } from "@/api/types" +import { roomStateGUIDToString } from "@/api/types" import { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji" import useEvent from "@/util/useEvent.ts" import { ClientContext } from "../ClientContext.ts" import { ModalCloseContext } from "../modal/Modal.tsx" +import { EmojiGroup } from "./EmojiGroup.tsx" +import renderEmoji from "./renderEmoji.tsx" import FallbackPackIcon from "@/icons/category.svg?react" import CloseIcon from "@/icons/close.svg?react" import ActivitiesIcon from "@/icons/emoji-categories/activities.svg?react" @@ -36,13 +38,7 @@ import RecentIcon from "@/icons/schedule.svg?react" import SearchIcon from "@/icons/search.svg?react" import "./EmojiPicker.css" -interface EmojiCategory { - index: number - name?: string - icon: JSX.Element -} - -const sortedEmojiCategories: EmojiCategory[] = [ +const sortedEmojiCategories: {index: number, icon: JSX.Element}[] = [ { index: 7, icon: }, { index: 6, icon: }, { index: 1, icon: }, @@ -54,13 +50,6 @@ const sortedEmojiCategories: EmojiCategory[] = [ { index: 3, icon: }, ] -function renderEmoji(emoji: Emoji): JSX.Element | string { - if (emoji.u.startsWith("mxc://")) { - return {`:${emoji.n}:`}/ - } - return emoji.u -} - interface EmojiPickerProps { style: CSSProperties onSelect: (emoji: PartialEmoji, isSelected?: boolean) => void @@ -73,16 +62,16 @@ interface EmojiPickerProps { export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSelect }: EmojiPickerProps) => { const client = use(ClientContext)! const [query, setQuery] = useState("") + const [previewEmoji, setPreviewEmoji] = useState() const watchedEmojiPackKeys = client.store.getEmojiPackKeys().map(roomStateGUIDToString) const customEmojiPacks = useCustomEmojis(client.store, room) const emojis = useFilteredEmojis(query, { frequentlyUsed: client.store.frequentlyUsedEmoji, customEmojiPacks, }) - const [previewEmoji, setPreviewEmoji] = useState() const clearQuery = useCallback(() => setQuery(""), []) - const close = use(ModalCloseContext) - const onSelectWrapped = (emoji?: PartialEmoji) => { + const close = closeOnSelect ? use(ModalCloseContext) : null + const onSelectWrapped = useCallback((emoji?: PartialEmoji) => { if (!emoji) { return } @@ -91,121 +80,13 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl client.incrementFrequentlyUsedEmoji(emoji.u) .catch(err => console.error("Failed to increment frequently used emoji", err)) } - if (closeOnSelect) { - close() - } - } - const getEmojiFromAttrs = (elem: HTMLButtonElement) => { - const groupIdx = elem.getAttribute("data-emoji-group-index") - if (!groupIdx) { - return - } - const idx = elem.getAttribute("data-emoji-index") - if (!idx) { - return - } - const emoji = emojis[+groupIdx]?.[+idx] - if (!emoji) { - return - } - return emoji - } - const onClickEmoji = useEvent((evt: React.MouseEvent) => - onSelectWrapped(getEmojiFromAttrs(evt.currentTarget))) - const onMouseOverEmoji = useEvent((evt: React.MouseEvent) => - setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget))) - const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), []) + close?.() + }, [onSelect, selected, client, close]) const onClickFreeformReact = useEvent(() => onSelectWrapped({ u: query })) - const onClickSubscribePack = useEvent((evt: React.MouseEvent) => { - const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id")) - if (!guid) { - return - } - client.subscribeToEmojiPack(guid, true) - .catch(err => window.alert(`Failed to subscribe to emoji pack: ${err}`)) - }) - const onClickUnsubscribePack = useEvent((evt: React.MouseEvent) => { - const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id")) - if (!guid) { - return - } - client.subscribeToEmojiPack(guid, false) - .catch(err => window.alert(`Failed to unsubscribe from emoji pack: ${err}`)) - }) - - const renderedCats: JSX.Element[] = [] - let currentCatRender: JSX.Element[] = [] - let currentCatNum: number | string = -1 - const renderCurrentCategory = () => { - if (!currentCatRender.length) { - return - } - let categoryName: string - let headerExtra: JSX.Element | null = null - if (typeof currentCatNum === "number") { - categoryName = categories[currentCatNum] - } else if (currentCatNum === CATEGORY_FREQUENTLY_USED) { - categoryName = CATEGORY_FREQUENTLY_USED - } else { - const customPack = customEmojiPacks.find(pack => pack.id === currentCatNum) - categoryName = customPack?.name ?? "Unknown name" - if (customPack && customPack.id !== "personal") { - if (watchedEmojiPackKeys.includes(customPack.id)) { - headerExtra = - } else { - headerExtra = - } - } - } - renderedCats.push(
-

{categoryName}{headerExtra}

-
- {currentCatRender} -
-
) - currentCatRender = [] - currentCatNum = -1 - } - for (let catIdx = 0; catIdx < emojis.length; catIdx++) { - const cat = emojis[catIdx] - for (let emojiIdx = 0; emojiIdx < cat.length; emojiIdx++) { - const emoji = cat[emojiIdx] - if (emoji.c === 2) { - continue - } - if (emoji.c !== currentCatNum) { - renderCurrentCategory() - currentCatNum = emoji.c - } - currentCatRender.push() - } - renderCurrentCategory() - } const onChangeQuery = useCallback((evt: React.ChangeEvent) => setQuery(evt.target.value), []) const onClickCategoryButton = useCallback((evt: React.MouseEvent) => { const categoryID = evt.currentTarget.getAttribute("data-category-id")! - document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView({ behavior: "smooth" }) + document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView() }, []) return
@@ -214,13 +95,13 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl data-category-id={CATEGORY_FREQUENTLY_USED} title={CATEGORY_FREQUENTLY_USED} onClick={onClickCategoryButton} - >{} + > {sortedEmojiCategories.map(cat => , )} @@ -243,7 +124,23 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl
- {renderedCats} + {emojis.map(group => { + if (!group?.length) { + return null + } + const categoryID = group[0].c + const customPack = customEmojiPacks.find(pack => pack.id === categoryID) + return + })} {allowFreeform && query &&