mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/emojipicker: make rendering lazier
This commit is contained in:
parent
2de87fa645
commit
11dad8541f
7 changed files with 250 additions and 160 deletions
114
web/src/ui/emojipicker/EmojiGroup.tsx
Normal file
114
web/src/ui/emojipicker/EmojiGroup.tsx
Normal 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>
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
25
web/src/ui/emojipicker/renderEmoji.tsx
Normal file
25
web/src/ui/emojipicker/renderEmoji.tsx
Normal 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
|
||||||
|
}
|
|
@ -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" : ""}`}
|
||||||
|
|
39
web/src/util/contentvisibility.ts
Normal file
39
web/src/util/contentvisibility.ts
Normal 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]
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue