web/emojipicker: make rendering lazier

This commit is contained in:
Tulir Asokan 2024-10-26 22:26:49 +03:00
parent 2de87fa645
commit 11dad8541f
7 changed files with 250 additions and 160 deletions

View file

@ -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 <https://www.gnu.org/licenses/>.
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<HTMLDivElement>(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<HTMLButtonElement>) =>
onSelect(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOverEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), [setPreviewEmoji])
const onClickSubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 <div
ref={divRef}
className="emoji-category"
id={`emoji-category-${categoryID}`}
style={{ containIntrinsicHeight: `${1.5 + Math.ceil(emojis.length / 8) * 2.5}rem` }}
>
<h4 className="emoji-category-name">
{categoryName}
{pack && <button
className="emoji-category-add"
onClick={isWatched ? onClickUnsubscribePack : onClickSubscribePack}
data-pack-id={categoryID}
>{isWatched ? "Unsubscribe" : "Subscribe"}</button>}
</h4>
<div className="emoji-category-list">
{isVisible ? emojis.map((emoji, idx) => <button
key={emoji.u}
className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`}
data-emoji-index={idx}
onMouseOver={onMouseOverEmoji}
onMouseOut={onMouseOutEmoji}
onClick={onClickEmoji}
>{renderEmoji(emoji)}</button>) : null}
</div>
</div>
}

View file

@ -104,6 +104,7 @@ div.emoji-picker {
div.emoji-category { div.emoji-category {
width: 100%; width: 100%;
content-visibility: auto; content-visibility: auto;
contain: size;
} }
div.emoji-category-list { div.emoji-category-list {

View file

@ -13,14 +13,16 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CSSProperties, JSX, use, useCallback, useState } from "react" import React, { CSSProperties, JSX, use, useCallback, useState } from "react"
import { getMediaURL } from "@/api/media.ts" import { getMediaURL } from "@/api/media.ts"
import { RoomStateStore, useCustomEmojis } from "@/api/statestore" 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 { CATEGORY_FREQUENTLY_USED, Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji"
import useEvent from "@/util/useEvent.ts" import useEvent from "@/util/useEvent.ts"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
import { ModalCloseContext } from "../modal/Modal.tsx" import { ModalCloseContext } from "../modal/Modal.tsx"
import { EmojiGroup } from "./EmojiGroup.tsx"
import renderEmoji from "./renderEmoji.tsx"
import FallbackPackIcon from "@/icons/category.svg?react" import FallbackPackIcon from "@/icons/category.svg?react"
import CloseIcon from "@/icons/close.svg?react" import CloseIcon from "@/icons/close.svg?react"
import ActivitiesIcon from "@/icons/emoji-categories/activities.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 SearchIcon from "@/icons/search.svg?react"
import "./EmojiPicker.css" import "./EmojiPicker.css"
interface EmojiCategory { const sortedEmojiCategories: {index: number, icon: JSX.Element}[] = [
index: number
name?: string
icon: JSX.Element
}
const sortedEmojiCategories: EmojiCategory[] = [
{ index: 7, icon: <SmileysEmotionIcon/> }, { index: 7, icon: <SmileysEmotionIcon/> },
{ index: 6, icon: <PeopleBodyIcon/> }, { index: 6, icon: <PeopleBodyIcon/> },
{ index: 1, icon: <AnimalsNatureIcon/> }, { index: 1, icon: <AnimalsNatureIcon/> },
@ -54,13 +50,6 @@ const sortedEmojiCategories: EmojiCategory[] = [
{ index: 3, icon: <FlagsIcon/> }, { index: 3, icon: <FlagsIcon/> },
] ]
function renderEmoji(emoji: Emoji): JSX.Element | string {
if (emoji.u.startsWith("mxc://")) {
return <img loading="lazy" src={getMediaURL(emoji.u)} alt={`:${emoji.n}:`}/>
}
return emoji.u
}
interface EmojiPickerProps { interface EmojiPickerProps {
style: CSSProperties style: CSSProperties
onSelect: (emoji: PartialEmoji, isSelected?: boolean) => void onSelect: (emoji: PartialEmoji, isSelected?: boolean) => void
@ -73,16 +62,16 @@ interface EmojiPickerProps {
export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSelect }: EmojiPickerProps) => { export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSelect }: EmojiPickerProps) => {
const client = use(ClientContext)! const client = use(ClientContext)!
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [previewEmoji, setPreviewEmoji] = useState<Emoji>()
const watchedEmojiPackKeys = client.store.getEmojiPackKeys().map(roomStateGUIDToString) const watchedEmojiPackKeys = client.store.getEmojiPackKeys().map(roomStateGUIDToString)
const customEmojiPacks = useCustomEmojis(client.store, room) const customEmojiPacks = useCustomEmojis(client.store, room)
const emojis = useFilteredEmojis(query, { const emojis = useFilteredEmojis(query, {
frequentlyUsed: client.store.frequentlyUsedEmoji, frequentlyUsed: client.store.frequentlyUsedEmoji,
customEmojiPacks, customEmojiPacks,
}) })
const [previewEmoji, setPreviewEmoji] = useState<Emoji>()
const clearQuery = useCallback(() => setQuery(""), []) const clearQuery = useCallback(() => setQuery(""), [])
const close = use(ModalCloseContext) const close = closeOnSelect ? use(ModalCloseContext) : null
const onSelectWrapped = (emoji?: PartialEmoji) => { const onSelectWrapped = useCallback((emoji?: PartialEmoji) => {
if (!emoji) { if (!emoji) {
return return
} }
@ -91,121 +80,13 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl
client.incrementFrequentlyUsedEmoji(emoji.u) client.incrementFrequentlyUsedEmoji(emoji.u)
.catch(err => console.error("Failed to increment frequently used emoji", err)) .catch(err => console.error("Failed to increment frequently used emoji", err))
} }
if (closeOnSelect) { close?.()
close() }, [onSelect, selected, client, 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<HTMLButtonElement>) =>
onSelectWrapped(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOverEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), [])
const onClickFreeformReact = useEvent(() => onSelectWrapped({ u: query })) const onClickFreeformReact = useEvent(() => onSelectWrapped({ u: query }))
const onClickSubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 = <button
className="emoji-category-add"
onClick={onClickUnsubscribePack}
data-pack-id={customPack.id}
>Unsubscribe</button>
} else {
headerExtra = <button
className="emoji-category-add"
onClick={onClickSubscribePack}
data-pack-id={customPack.id}
>Subscribe</button>
}
}
}
renderedCats.push(<div
className="emoji-category"
key={currentCatNum}
id={`emoji-category-${currentCatNum}`}
style={{ containIntrinsicHeight: `${1.5 + Math.ceil(currentCatRender.length / 8) * 2.5}rem` }}
>
<h4 className="emoji-category-name">{categoryName}{headerExtra}</h4>
<div className="emoji-category-list">
{currentCatRender}
</div>
</div>)
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(<button
key={`${emoji.c}-${emoji.u}`}
className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`}
data-emoji-group-index={catIdx}
data-emoji-index={emojiIdx}
onMouseOver={onMouseOverEmoji}
onMouseOut={onMouseOutEmoji}
onClick={onClickEmoji}
>{renderEmoji(emoji)}</button>)
}
renderCurrentCategory()
}
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), []) const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => { const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
const categoryID = evt.currentTarget.getAttribute("data-category-id")! const categoryID = evt.currentTarget.getAttribute("data-category-id")!
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView({ behavior: "smooth" }) document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
}, []) }, [])
return <div className="emoji-picker" style={style}> return <div className="emoji-picker" style={style}>
<div className="emoji-category-bar"> <div className="emoji-category-bar">
@ -214,13 +95,13 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl
data-category-id={CATEGORY_FREQUENTLY_USED} data-category-id={CATEGORY_FREQUENTLY_USED}
title={CATEGORY_FREQUENTLY_USED} title={CATEGORY_FREQUENTLY_USED}
onClick={onClickCategoryButton} onClick={onClickCategoryButton}
>{<RecentIcon/>}</button> ><RecentIcon/></button>
{sortedEmojiCategories.map(cat => {sortedEmojiCategories.map(cat =>
<button <button
key={cat.index} key={cat.index}
className="emoji-category-icon" className="emoji-category-icon"
data-category-id={cat.index} data-category-id={cat.index}
title={cat.name ?? categories[cat.index]} title={categories[cat.index]}
onClick={onClickCategoryButton} onClick={onClickCategoryButton}
>{cat.icon}</button>, >{cat.icon}</button>,
)} )}
@ -243,7 +124,23 @@ export const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, cl
</button> </button>
</div> </div>
<div className="emoji-list"> <div className="emoji-list">
{renderedCats} {emojis.map(group => {
if (!group?.length) {
return null
}
const categoryID = group[0].c
const customPack = customEmojiPacks.find(pack => pack.id === categoryID)
return <EmojiGroup
key={categoryID}
emojis={group}
categoryID={categoryID}
selected={selected}
pack={customPack}
isWatched={typeof categoryID === "string" && watchedEmojiPackKeys.includes(categoryID)}
onSelect={onSelectWrapped}
setPreviewEmoji={setPreviewEmoji}
/>
})}
{allowFreeform && query && <button {allowFreeform && query && <button
className="freeform-react" className="freeform-react"
onClick={onClickFreeformReact} onClick={onClickFreeformReact}

View file

@ -0,0 +1,25 @@
// 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 <https://www.gnu.org/licenses/>.
import { JSX } from "react"
import { getMediaURL } from "@/api/media.ts"
import { Emoji } from "@/util/emoji"
export default function renderEmoji(emoji: Emoji): JSX.Element | string {
if (emoji.u.startsWith("mxc://")) {
return <img loading="lazy" src={getMediaURL(emoji.u)} alt={`:${emoji.n}:`}/>
}
return emoji.u
}

View file

@ -13,10 +13,11 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { memo, use, useLayoutEffect, useRef, useState } from "react" import { memo, use } from "react"
import { getAvatarURL } from "@/api/media.ts" import { getAvatarURL } from "@/api/media.ts"
import type { RoomListEntry } from "@/api/statestore" import type { RoomListEntry } from "@/api/statestore"
import type { MemDBEvent, MemberEventContent } from "@/api/types" import type { MemDBEvent, MemberEventContent } from "@/api/types"
import useContentVisibility from "@/util/contentvisibility.ts"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
export interface RoomListEntryProps { export interface RoomListEntryProps {
@ -77,21 +78,7 @@ const EntryInner = ({ room }: InnerProps) => {
} }
const Entry = ({ room, setActiveRoom, isActive, hidden }: RoomListEntryProps) => { const Entry = ({ room, setActiveRoom, isActive, hidden }: RoomListEntryProps) => {
const [isVisible, setVisible] = useState(false) const [isVisible, divRef] = useContentVisibility<HTMLDivElement>()
const divRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const div = divRef.current
if (!div) {
return
}
const listener = (evt: unknown) => {
if (!(evt as ContentVisibilityAutoStateChangeEvent).skipped) {
setVisible(true)
}
}
div.addEventListener("contentvisibilityautostatechange", listener)
return () => div.removeEventListener("contentvisibilityautostatechange", listener)
}, [])
return <div return <div
ref={divRef} ref={divRef}
className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`} className={`room-entry ${isActive ? "active" : ""} ${hidden ? "hidden" : ""}`}

View file

@ -0,0 +1,39 @@
// 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 <https://www.gnu.org/licenses/>.
import { RefObject, useLayoutEffect, useRef, useState } from "react"
export default function useContentVisibilit<T extends HTMLElement>(
allowRevert = false,
): [boolean, RefObject<T | null>] {
const ref = useRef<T>(null)
const [isVisible, setVisible] = useState(false)
useLayoutEffect(() => {
const element = ref.current
if (!element) {
return
}
const listener = (evt: unknown) => {
if (!(evt as ContentVisibilityAutoStateChangeEvent).skipped) {
setVisible(true)
} else if (allowRevert) {
setVisible(false)
}
}
element.addEventListener("contentvisibilityautostatechange", listener)
return () => element.removeEventListener("contentvisibilityautostatechange", listener)
}, [allowRevert])
return [isVisible, ref]
}

View file

@ -33,14 +33,35 @@ export type Emoji = EmojiText & EmojiMetadata
export const emojis: Emoji[] = data.e export const emojis: Emoji[] = data.e
export const emojiMap = new Map<string, Emoji>() export const emojiMap = new Map<string, Emoji>()
export const emojisByCategory: Emoji[][] = []
export const categories = data.c export const categories = data.c
export const CATEGORY_FREQUENTLY_USED = "Frequently Used" export const CATEGORY_FREQUENTLY_USED = "Frequently Used"
for (const emoji of emojis) { function initEmojiMaps() {
let building: Emoji[] = []
let buildingCat: number = -1
for (const emoji of emojis) {
emojiMap.set(emoji.u, emoji) emojiMap.set(emoji.u, emoji)
if (emoji.c === 2) {
continue
}
if (emoji.c !== buildingCat) {
if (building.length) {
emojisByCategory.push(building)
}
buildingCat = emoji.c as number
building = []
}
building.push(emoji)
}
if (building.length) {
emojisByCategory.push(building)
}
} }
initEmojiMaps()
function filter(emojis: Emoji[], query: string): Emoji[] { function filter(emojis: Emoji[], query: string): Emoji[] {
return emojis.filter(emoji => emoji.s.some(shortcode => shortcode.includes(query))) return emojis.filter(emoji => emoji.s.some(shortcode => shortcode.includes(query)))
} }
@ -187,16 +208,22 @@ export function useFilteredEmojis(query: string, params: useEmojisParams = {}):
.filter(emoji => emoji !== undefined)) .filter(emoji => emoji !== undefined))
.filter((_emoji, index) => index < 24) .filter((_emoji, index) => index < 24)
}, [params.frequentlyUsed]) }, [params.frequentlyUsed])
const allPacks = [frequentlyUsedCategory, emojis, ...(params.customEmojiPacks?.map(pack => pack.emojis) ?? [])] const prev = useRef<filteredEmojiCache>({
const prev = useRef<filteredEmojiCache>({ query: "", result: allPacks }) query: "",
if (!query) { result: [],
prev.current.query = "" })
prev.current.result = allPacks const categoriesChanged = prev.current.result.length !==
} else if (prev.current.query !== query) { (1 + emojisByCategory.length + (params.customEmojiPacks?.length ?? 0))
if (query.startsWith(prev.current.query) && allPacks.length === prev.current.result.length) { if (prev.current.query !== query || categoriesChanged) {
if (!query.startsWith(prev.current.query) || categoriesChanged) {
prev.current.result = [
frequentlyUsedCategory,
...emojisByCategory,
...(params.customEmojiPacks?.map(pack => pack.emojis) ?? []),
]
}
if (query !== "") {
prev.current.result = prev.current.result.map(pack => filter(pack, query)) prev.current.result = prev.current.result.map(pack => filter(pack, query))
} else {
prev.current.result = allPacks.map(pack => filter(pack, query))
} }
prev.current.query = query prev.current.query = query
} }