web/composer: add support for sending stickers

This commit is contained in:
Tulir Asokan 2024-12-21 17:29:06 +02:00
parent 42fa6ac465
commit 70938b2319
12 changed files with 284 additions and 67 deletions

View file

@ -153,7 +153,12 @@ func (h *HiClient) SendMessage(
content.RelatesTo = relatesTo
}
}
return h.send(ctx, roomID, event.EventMessage, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted)
evtType := event.EventMessage
if content.MsgType == "m.sticker" {
content.MsgType = ""
evtType = event.EventSticker
}
return h.send(ctx, roomID, evtType, &event.Content{Parsed: content, Raw: extra}, origText, unencrypted)
}
func (h *HiClient) MarkRead(ctx context.Context, roomID id.RoomID, eventID id.EventID, receiptType event.ReceiptType) error {
@ -287,7 +292,7 @@ func (h *HiClient) send(
var inlineImages []id.ContentURI
mautrixEvt := dbEvt.AsRawMautrix()
dbEvt.LocalContent, inlineImages = h.calculateLocalContent(ctx, dbEvt, mautrixEvt)
if overrideEditSource != "" {
if overrideEditSource != "" && dbEvt.LocalContent != nil {
dbEvt.LocalContent.EditSource = overrideEditSource
}
_, err = h.DB.Event.Insert(ctx, dbEvt)

View file

@ -157,7 +157,7 @@ export interface TextMessageEventContent extends BaseMessageEventContent {
}
export interface MediaMessageEventContent extends BaseMessageEventContent {
msgtype: "m.image" | "m.file" | "m.audio" | "m.video"
msgtype: "m.sticker" | "m.image" | "m.file" | "m.audio" | "m.video"
filename?: string
url?: ContentURI
file?: EncryptedFile

View file

@ -33,6 +33,7 @@ import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import GIFPicker from "../emojipicker/GIFPicker.tsx"
import StickerPicker from "../emojipicker/StickerPicker.tsx"
import { keyToString } from "../keybindings.ts"
import { LeafletPicker } from "../maps/async.tsx"
import { ModalContext } from "../modal/Modal.tsx"
@ -48,6 +49,7 @@ import GIFIcon from "@/icons/gif.svg?react"
import LocationIcon from "@/icons/location.svg?react"
import MoreIcon from "@/icons/more.svg?react"
import SendIcon from "@/icons/send.svg?react"
import StickerIcon from "@/icons/sticker.svg?react"
import "./MessageComposer.css"
export interface ComposerLocationValue {
@ -150,13 +152,16 @@ const MessageComposer = () => {
return
}
const evtContent = evt.content as MessageEventContent
const mediaMsgTypes = ["m.image", "m.audio", "m.video", "m.file"]
const mediaMsgTypes = ["m.sticker", "m.image", "m.audio", "m.video", "m.file"]
if (evt.type === "m.sticker") {
evtContent.msgtype = "m.sticker"
}
const isMedia = mediaMsgTypes.includes(evtContent.msgtype)
&& Boolean(evt.content?.url || evt.content?.file?.url)
rawSetEditing(evt)
setState({
media: isMedia ? evtContent as MediaMessageEventContent : null,
text: (!evt.content.filename || evt.content.filename !== evt.content.body)
text: (evt.content.filename && evt.content.filename !== evt.content.body) || evt.type === "m.sticker"
? (evt.local_content?.edit_source ?? evtContent.body ?? "")
: "",
replyTo: null,
@ -171,6 +176,9 @@ const MessageComposer = () => {
if (!canSend) {
return
}
doSendMessage(state)
})
const doSendMessage = (state: ComposerState) => {
if (editing) {
setState(draftStore.get(room.roomID) ?? emptyComposer)
} else {
@ -233,7 +241,7 @@ const MessageComposer = () => {
relates_to,
mentions,
}).catch(err => window.alert("Failed to send message: " + err))
})
}
const onComposerCaretChange = useEvent((evt: CaretEvent<HTMLTextAreaElement>, newText?: string) => {
const area = evt.currentTarget
if (area.selectionStart <= (autocomplete?.startPos ?? 0)) {
@ -487,11 +495,22 @@ const MessageComposer = () => {
onClose: () => !isMobileDevice && textInput.current?.focus(),
})
})
const openStickerPicker = useEvent(() => {
openModal({
content: <StickerPicker
style={getEmojiPickerStyle()}
room={roomCtx.store}
onSelect={media => doSendMessage({ ...state, media, text: "" })}
/>,
onClose: () => !isMobileDevice && textInput.current?.focus(),
})
})
const openLocationPicker = useEvent(() => {
setState({ location: { lat: 0, long: 0, prec: 1 }, media: null })
})
const Autocompleter = getAutocompleter(autocomplete, client, room)
let mediaDisabledTitle: string | undefined
let stickerDisabledTitle: string | undefined
let locationDisabledTitle: string | undefined
if (state.media) {
mediaDisabledTitle = "You can only attach one file at a time"
@ -503,10 +522,31 @@ const MessageComposer = () => {
mediaDisabledTitle = "Uploading file..."
locationDisabledTitle = "You can't attach a location to a message with a file"
}
if (state.media?.msgtype !== "m.sticker") {
stickerDisabledTitle = mediaDisabledTitle
if (!stickerDisabledTitle && editing) {
stickerDisabledTitle = "You can't edit a message into a sticker"
}
} else if (state.text && !editing) {
stickerDisabledTitle = "You can't attach a sticker to a message with text"
}
const makeAttachmentButtons = (includeText = false) => {
return <>
<button onClick={openEmojiPicker} title="Add emoji"><EmojiIcon/>{includeText && "Emoji"}</button>
<button onClick={openGIFPicker} title="Add gif attachment"><GIFIcon/>{includeText && "GIF"}</button>
<button
onClick={openStickerPicker}
disabled={!!stickerDisabledTitle}
title={stickerDisabledTitle ?? "Add sticker attachment"}
>
<StickerIcon/>{includeText && "Sticker"}
</button>
<button
onClick={openGIFPicker}
disabled={!!mediaDisabledTitle}
title={mediaDisabledTitle ?? "Add gif attachment"}
>
<GIFIcon/>{includeText && "GIF"}
</button>
<button
onClick={openLocationPicker}
disabled={!!locationDisabledTitle}
@ -531,6 +571,7 @@ const MessageComposer = () => {
})
const inlineButtons = state.text === "" || window.innerWidth > 720
const showSendButton = canSend || window.innerWidth > 720
const disableClearMedia = editing && state.media?.msgtype === "m.sticker"
return <>
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
params={autocomplete}
@ -559,7 +600,7 @@ const MessageComposer = () => {
onClose={stopEditing}
/>}
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
{state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>}
{state.media && <ComposerMedia content={state.media} clearMedia={!disableClearMedia && clearMedia}/>}
{state.location && <ComposerLocation
room={room} client={client}
location={state.location} onChange={onChangeLocation} clearLocation={clearMedia}
@ -593,11 +634,10 @@ const MessageComposer = () => {
interface ComposerMediaProps {
content: MediaMessageEventContent
clearMedia: () => void
clearMedia: false | (() => void)
}
const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
// TODO stickers?
const [mediaContent, containerClass, containerStyle] = useMediaContent(
content, "m.room.message", { height: 120, width: 360 },
)
@ -605,7 +645,7 @@ const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
<div className={`media-container ${containerClass}`} style={containerStyle}>
{mediaContent}
</div>
<button onClick={clearMedia}><CloseIcon/></button>
{clearMedia && <button onClick={clearMedia}><CloseIcon/></button>}
</div>
}

View file

@ -16,7 +16,7 @@
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 { CATEGORY_FREQUENTLY_USED, CustomEmojiPack, Emoji, categories } from "@/util/emoji"
import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts"
import renderEmoji from "./renderEmoji.tsx"
@ -27,8 +27,9 @@ interface EmojiGroupProps {
selected?: string[]
pack?: CustomEmojiPack
isWatched?: boolean
onSelect: (emoji?: PartialEmoji) => void
setPreviewEmoji: (emoji?: Emoji) => void
onSelect: (emoji?: Emoji) => void
setPreviewEmoji?: (emoji?: Emoji) => void
imageType: "emoji" | "sticker"
}
export const EmojiGroup = ({
@ -39,6 +40,7 @@ export const EmojiGroup = ({
isWatched,
onSelect,
setPreviewEmoji,
imageType,
}: EmojiGroupProps) => {
const client = use(ClientContext)!
const [isVisible, divRef] = useContentVisibility<HTMLDivElement>(true)
@ -57,8 +59,8 @@ export const EmojiGroup = ({
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])
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) {
@ -86,12 +88,14 @@ export const EmojiGroup = ({
} else {
categoryName = "Unknown category"
}
const itemsPerRow = imageType === "sticker" ? 4 : 8
const rowSize = imageType === "sticker" ? 5 : 2.5
return <div
ref={divRef}
className="emoji-category"
id={`emoji-category-${categoryID}`}
data-category-id={categoryID}
style={{ containIntrinsicHeight: `${1.5 + Math.ceil(emojis.length / 8) * 2.5}rem` }}
style={{ containIntrinsicHeight: `${1.5 + Math.ceil(emojis.length / itemsPerRow) * rowSize}rem` }}
>
<h4 className="emoji-category-name">
{categoryName}
@ -104,11 +108,12 @@ export const EmojiGroup = ({
<div className="emoji-category-list">
{isVisible ? emojis.map((emoji, idx) => <button
key={emoji.u}
className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`}
className={`${imageType} ${selected?.includes(emoji.u) ? "selected" : ""}`}
data-emoji-index={idx}
onMouseOver={onMouseOverEmoji}
onMouseOut={onMouseOutEmoji}
onClick={onClickEmoji}
title={emoji.t}
>{renderEmoji(emoji)}</button>) : null}
</div>
</div>

View file

@ -1,4 +1,4 @@
div.emoji-picker, div.gif-picker {
div.emoji-picker, div.sticker-picker, div.gif-picker {
position: fixed;
background-color: var(--background-color);
width: 22rem;
@ -72,7 +72,7 @@ div.gif-picker {
}
}
div.emoji-picker {
div.emoji-picker, div.sticker-picker {
div.emoji-category-bar {
/*height: 2.5rem;*/
display: flex;
@ -197,6 +197,17 @@ div.emoji-picker {
}
}
button.sticker {
width: 5rem;
height: 5rem;
> img {
object-fit: contain;
width: 100%;
padding: .5rem;
}
}
button.freeform-react {
width: 100%;
padding: .25rem;

View file

@ -13,7 +13,7 @@
//
// 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, { CSSProperties, JSX, use, useCallback, useEffect, useRef, 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 } from "@/api/types"
@ -24,6 +24,7 @@ import ClientContext from "../ClientContext.ts"
import { ModalCloseContext } from "../modal/Modal.tsx"
import { EmojiGroup } from "./EmojiGroup.tsx"
import renderEmoji from "./renderEmoji.tsx"
import useCategoryUnderline from "./useCategoryUnderline.ts"
import FallbackPackIcon from "@/icons/category.svg?react"
import CloseIcon from "@/icons/close.svg?react"
import ActivitiesIcon from "@/icons/emoji-categories/activities.svg?react"
@ -64,8 +65,7 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
const client = use(ClientContext)!
const [query, setQuery] = useState("")
const [previewEmoji, setPreviewEmoji] = useState<Emoji>()
const emojiCategoryBarRef = useRef<HTMLDivElement>(null)
const emojiListRef = useRef<HTMLDivElement>(null)
const [emojiCategoryBarRef, emojiListRef] = useCategoryUnderline()
const watchedEmojiPackKeys = client.store.getEmojiPackKeys().map(roomStateGUIDToString)
const customEmojiPacks = useCustomEmojis(client.store, room)
const emojis = useFilteredEmojis(query, {
@ -91,31 +91,6 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
}, [])
useEffect(() => {
const cats = emojiCategoryBarRef.current
const lists = emojiListRef.current
if (!cats || !lists) {
return
}
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
const catID = entry.target.getAttribute("data-category-id")
const cat = catID && cats.querySelector(`.emoji-category-icon[data-category-id="${catID}"]`)
if (!cat) {
continue
}
if (entry.isIntersecting) {
cat.classList.add("visible")
} else {
cat.classList.remove("visible")
}
}
})
for (const cat of lists.getElementsByClassName("emoji-category")) {
observer.observe(cat)
}
return () => observer.disconnect()
}, [])
return <div className="emoji-picker" style={style}>
<div className="emoji-category-bar" ref={emojiCategoryBarRef}>
<button
@ -175,6 +150,7 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
isWatched={typeof categoryID === "string" && watchedEmojiPackKeys.includes(categoryID)}
onSelect={onSelectWrapped}
setPreviewEmoji={setPreviewEmoji}
imageType="emoji"
/>
})}
{allowFreeform && query && <button

View file

@ -23,7 +23,7 @@ import { GIF, getTrendingGIFs, searchGIF } from "./gifsource.ts"
import CloseIcon from "@/icons/close.svg?react"
import SearchIcon from "@/icons/search.svg?react"
interface GIFPickerProps {
export interface MediaPickerProps {
style: CSSProperties
onSelect: (media: MediaMessageEventContent) => void
room: RoomStateStore
@ -31,7 +31,7 @@ interface GIFPickerProps {
const trendingCache = new Map<string, GIF[]>()
const GIFPicker = ({ style, onSelect, room }: GIFPickerProps) => {
const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
const [query, setQuery] = useState("")
const [results, setResults] = useState<GIF[]>([])
const [error, setError] = useState<unknown>()

View file

@ -0,0 +1,112 @@
// 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, useState } from "react"
import { getMediaURL } from "@/api/media.ts"
import { useCustomEmojis } from "@/api/statestore"
import { roomStateGUIDToString } from "@/api/types"
import { Emoji, useFilteredEmojis } from "@/util/emoji"
import { isMobileDevice } from "@/util/ismobile.ts"
import ClientContext from "../ClientContext.ts"
import { ModalCloseContext } from "../modal/Modal.tsx"
import { EmojiGroup } from "./EmojiGroup.tsx"
import { MediaPickerProps } from "./GIFPicker.tsx"
import useCategoryUnderline from "./useCategoryUnderline.ts"
import FallbackPackIcon from "@/icons/category.svg?react"
import CloseIcon from "@/icons/close.svg?react"
import SearchIcon from "@/icons/search.svg?react"
const StickerPicker = ({ style, onSelect, room }: MediaPickerProps) => {
const client = use(ClientContext)!
const [query, setQuery] = useState("")
const [emojiCategoryBarRef, emojiListRef] = useCategoryUnderline()
const watchedEmojiPackKeys = client.store.getEmojiPackKeys().map(roomStateGUIDToString)
const customEmojiPacks = useCustomEmojis(client.store, room)
const emojis = useFilteredEmojis(query, {
// frequentlyUsed: client.store.frequentlyUsedStickers,
customEmojiPacks,
stickers: true,
})
const clearQuery = useCallback(() => setQuery(""), [])
const close = use(ModalCloseContext)
const onSelectWrapped = useCallback((emoji?: Emoji) => {
if (!emoji) {
return
}
onSelect({
msgtype: "m.sticker",
body: emoji.t,
info: emoji.i,
url: emoji.u,
})
close()
}, [onSelect, close])
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
const onClickCategoryButton = useCallback((evt: React.MouseEvent) => {
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
}, [])
return <div className="sticker-picker" style={style}>
<div className="emoji-category-bar" ref={emojiCategoryBarRef}>
{customEmojiPacks.map(customPack =>
<button
key={customPack.id}
className="emoji-category-icon custom-emoji"
data-category-id={customPack.id}
title={customPack.name}
onClick={onClickCategoryButton}
>
{customPack.icon ? <img src={getMediaURL(customPack.icon)} alt="" /> : <FallbackPackIcon/>}
</button>,
)}
</div>
<div className="emoji-search">
<input
autoFocus={!isMobileDevice}
onChange={onChangeQuery}
value={query}
type="search"
placeholder="Search emojis"
/>
<button onClick={clearQuery} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button>
</div>
<div className="emoji-list">
{/* Chrome is dumb and doesn't allow scrolling without an inner div */}
<div className="emoji-list-inner" ref={emojiListRef}>
{emojis.map(group => {
if (!group?.length) {
return null
}
const categoryID = group[0].c as string
const customPack = customEmojiPacks.find(pack => pack.id === categoryID)
return <EmojiGroup
key={categoryID}
emojis={group}
categoryID={categoryID}
pack={customPack}
isWatched={watchedEmojiPackKeys.includes(categoryID)}
onSelect={onSelectWrapped}
imageType="sticker"
/>
})}
</div>
</div>
</div>
}
export default StickerPicker

View file

@ -0,0 +1,51 @@
// 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 { useEffect, useRef } from "react"
const useCategoryUnderline = () => {
const emojiCategoryBarRef = useRef<HTMLDivElement>(null)
const emojiListRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const cats = emojiCategoryBarRef.current
const lists = emojiListRef.current
if (!cats || !lists) {
return
}
const observer = new IntersectionObserver(entries => {
for (const entry of entries) {
const catID = entry.target.getAttribute("data-category-id")
const cat = catID && cats.querySelector(`.emoji-category-icon[data-category-id="${catID}"]`)
if (!cat) {
continue
}
if (entry.isIntersecting) {
cat.classList.add("visible")
} else {
cat.classList.remove("visible")
}
}
})
for (const cat of lists.getElementsByClassName("emoji-category")) {
observer.observe(cat)
}
return () => observer.disconnect()
}, [])
return [emojiCategoryBarRef, emojiListRef] as const
}
export default useCategoryUnderline

View file

@ -29,7 +29,7 @@ export const useMediaContent = (
const mediaURL = content.file?.url ? getEncryptedMediaURL(content.file.url) : getMediaURL(content.url)
const thumbnailURL = content.info?.thumbnail_file?.url
? getEncryptedMediaURL(content.info.thumbnail_file.url) : getMediaURL(content.info?.thumbnail_url)
if (content.msgtype === "m.image" || evtType === "m.sticker") {
if (content.msgtype === "m.image" || content.msgtype === "m.sticker" || evtType === "m.sticker") {
const style = calculateMediaSize(content.info?.w, content.info?.h, containerSize)
return [<img
onLoad={onLoad}

View file

@ -100,7 +100,7 @@ export const usePrimaryItems = (
const canSend = !didFail && ownPL >= messageSendPL
const canEdit = canSend
&& evt.sender === client.userID
&& evt.type === "m.room.message"
&& (evt.type === "m.room.message" || evt.type === "m.sticker")
&& evt.relation_type !== "m.replace"
&& !evt.redacted_by
const canReact = !didFail && ownPL >= reactPL

View file

@ -14,7 +14,7 @@
// 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 { useMemo, useRef } from "react"
import { ContentURI, EventID, ImagePack, ImagePackUsage, ReactionEventContent } from "@/api/types"
import { ContentURI, EventID, ImagePack, MediaInfo, ReactionEventContent } from "@/api/types"
import data from "./data.json"
export interface EmojiMetadata {
@ -22,6 +22,7 @@ export interface EmojiMetadata {
t: string // Emoji title
n: string // Primary shortcode
s: string[] // Shortcodes without underscores
i?: MediaInfo // Media info for stickers only
}
export interface EmojiText {
@ -71,6 +72,7 @@ function filterAndSort(
query: string,
frequentlyUsed?: Map<string, number>,
customEmojis?: CustomEmojiPack[],
stickers?: true,
): Emoji[] {
const filteredStandardEmojis = emojis
.map(emoji => {
@ -82,7 +84,7 @@ function filterAndSort(
})
.filter(({ matchIndex }) => matchIndex !== -1)
const filteredCustomEmojis = customEmojis
?.flatMap(pack => pack.emojis
?.flatMap(pack => (stickers ? pack.stickers : pack.emojis)
.map(emoji => {
const matchIndex = emoji.s.reduce((minIndex, shortcode) => {
const index = shortcode.indexOf(query)
@ -91,9 +93,12 @@ function filterAndSort(
return { emoji, matchIndex }
})
.filter(({ matchIndex }) => matchIndex !== -1)) ?? []
const allEmojis = filteredCustomEmojis.length
? filteredStandardEmojis.concat(filteredCustomEmojis)
: filteredStandardEmojis
const allEmojis =
filteredStandardEmojis.length
? filteredCustomEmojis.length
? filteredStandardEmojis.concat(filteredCustomEmojis)
: filteredStandardEmojis
: filteredCustomEmojis
return allEmojis
.sort((e1, e2) =>
e1.matchIndex === e2.matchIndex
@ -131,6 +136,7 @@ export interface CustomEmojiPack {
name: string
icon?: ContentURI
emojis: Emoji[]
stickers: Emoji[]
emojiMap: Map<string, Emoji>
}
@ -138,16 +144,16 @@ export function parseCustomEmojiPack(
pack: ImagePack,
id: string,
fallbackName?: string,
usage: ImagePackUsage = "emoticon",
): CustomEmojiPack | null {
try {
if (pack.pack.usage && !pack.pack.usage.includes(usage)) {
return null
}
const name = pack.pack.display_name || fallbackName || "Unnamed pack"
const emojiMap = new Map<string, Emoji>()
const stickers: Emoji[] = []
const emojis: Emoji[] = []
const defaultIsEmoji = !pack.pack.usage || pack.pack.usage.includes("emoticon")
const defaultIsSticker = !pack.pack.usage || pack.pack.usage.includes("sticker")
for (const [shortcode, image] of Object.entries(pack.images)) {
if (!image.url || (image.usage && !image.usage.includes(usage))) {
if (!image.url) {
continue
}
let converted = emojiMap.get(image.url)
@ -160,17 +166,26 @@ export function parseCustomEmojiPack(
n: shortcode,
s: [shortcode.toLowerCase().replaceAll("_", "").replaceAll(" ", "")],
t: image.body || shortcode,
i: image.info,
}
emojiMap.set(image.url, converted)
const isSticker = image.usage ? image.usage.includes("sticker") : defaultIsSticker
const isEmoji = image.usage ? image.usage.includes("emoticon") : defaultIsEmoji
if (isEmoji) {
emojis.push(converted)
}
if (isSticker) {
stickers.push(converted)
}
}
}
const emojis = Array.from(emojiMap.values())
const icon = pack.pack.avatar_url || emojis[0]?.u
const icon = pack.pack.avatar_url || (emojis[0] ?? stickers[0])?.u
return {
id,
name,
icon,
emojis,
stickers,
emojiMap,
}
} catch (err) {
@ -192,6 +207,7 @@ interface filteredAndSortedEmojiCache {
interface useEmojisParams {
frequentlyUsed?: Map<string, number>
customEmojiPacks?: CustomEmojiPack[]
stickers?: true
}
export function useFilteredEmojis(query: string, params: useEmojisParams = {}): Emoji[][] {
@ -225,14 +241,15 @@ export function useFilteredEmojis(query: string, params: useEmojisParams = {}):
query: "",
result: [],
})
const baseEmojis = params.stickers ? [] : emojisByCategory
const categoriesChanged = prev.current.result.length !==
(1 + emojisByCategory.length + (params.customEmojiPacks?.length ?? 0))
(1 + baseEmojis.length + (params.customEmojiPacks?.length ?? 0))
if (prev.current.query !== query || categoriesChanged) {
if (!query.startsWith(prev.current.query) || categoriesChanged) {
prev.current.result = [
frequentlyUsedCategory,
...emojisByCategory,
...(params.customEmojiPacks?.map(pack => pack.emojis) ?? []),
...baseEmojis,
...(params.customEmojiPacks?.map(pack => params.stickers ? pack.stickers : pack.emojis) ?? []),
]
}
if (query !== "") {