forked from Mirrors/gomuks
web/composer: add support for sending stickers
This commit is contained in:
parent
42fa6ac465
commit
70938b2319
12 changed files with 284 additions and 67 deletions
|
@ -153,7 +153,12 @@ func (h *HiClient) SendMessage(
|
||||||
content.RelatesTo = relatesTo
|
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 {
|
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
|
var inlineImages []id.ContentURI
|
||||||
mautrixEvt := dbEvt.AsRawMautrix()
|
mautrixEvt := dbEvt.AsRawMautrix()
|
||||||
dbEvt.LocalContent, inlineImages = h.calculateLocalContent(ctx, dbEvt, mautrixEvt)
|
dbEvt.LocalContent, inlineImages = h.calculateLocalContent(ctx, dbEvt, mautrixEvt)
|
||||||
if overrideEditSource != "" {
|
if overrideEditSource != "" && dbEvt.LocalContent != nil {
|
||||||
dbEvt.LocalContent.EditSource = overrideEditSource
|
dbEvt.LocalContent.EditSource = overrideEditSource
|
||||||
}
|
}
|
||||||
_, err = h.DB.Event.Insert(ctx, dbEvt)
|
_, err = h.DB.Event.Insert(ctx, dbEvt)
|
||||||
|
|
|
@ -157,7 +157,7 @@ export interface TextMessageEventContent extends BaseMessageEventContent {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaMessageEventContent 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
|
filename?: string
|
||||||
url?: ContentURI
|
url?: ContentURI
|
||||||
file?: EncryptedFile
|
file?: EncryptedFile
|
||||||
|
|
|
@ -33,6 +33,7 @@ import useEvent from "@/util/useEvent.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
|
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
|
||||||
import GIFPicker from "../emojipicker/GIFPicker.tsx"
|
import GIFPicker from "../emojipicker/GIFPicker.tsx"
|
||||||
|
import StickerPicker from "../emojipicker/StickerPicker.tsx"
|
||||||
import { keyToString } from "../keybindings.ts"
|
import { keyToString } from "../keybindings.ts"
|
||||||
import { LeafletPicker } from "../maps/async.tsx"
|
import { LeafletPicker } from "../maps/async.tsx"
|
||||||
import { ModalContext } from "../modal/Modal.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 LocationIcon from "@/icons/location.svg?react"
|
||||||
import MoreIcon from "@/icons/more.svg?react"
|
import MoreIcon from "@/icons/more.svg?react"
|
||||||
import SendIcon from "@/icons/send.svg?react"
|
import SendIcon from "@/icons/send.svg?react"
|
||||||
|
import StickerIcon from "@/icons/sticker.svg?react"
|
||||||
import "./MessageComposer.css"
|
import "./MessageComposer.css"
|
||||||
|
|
||||||
export interface ComposerLocationValue {
|
export interface ComposerLocationValue {
|
||||||
|
@ -150,13 +152,16 @@ const MessageComposer = () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const evtContent = evt.content as MessageEventContent
|
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)
|
const isMedia = mediaMsgTypes.includes(evtContent.msgtype)
|
||||||
&& Boolean(evt.content?.url || evt.content?.file?.url)
|
&& Boolean(evt.content?.url || evt.content?.file?.url)
|
||||||
rawSetEditing(evt)
|
rawSetEditing(evt)
|
||||||
setState({
|
setState({
|
||||||
media: isMedia ? evtContent as MediaMessageEventContent : null,
|
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 ?? "")
|
? (evt.local_content?.edit_source ?? evtContent.body ?? "")
|
||||||
: "",
|
: "",
|
||||||
replyTo: null,
|
replyTo: null,
|
||||||
|
@ -171,6 +176,9 @@ const MessageComposer = () => {
|
||||||
if (!canSend) {
|
if (!canSend) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
doSendMessage(state)
|
||||||
|
})
|
||||||
|
const doSendMessage = (state: ComposerState) => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
setState(draftStore.get(room.roomID) ?? emptyComposer)
|
setState(draftStore.get(room.roomID) ?? emptyComposer)
|
||||||
} else {
|
} else {
|
||||||
|
@ -233,7 +241,7 @@ const MessageComposer = () => {
|
||||||
relates_to,
|
relates_to,
|
||||||
mentions,
|
mentions,
|
||||||
}).catch(err => window.alert("Failed to send message: " + err))
|
}).catch(err => window.alert("Failed to send message: " + err))
|
||||||
})
|
}
|
||||||
const onComposerCaretChange = useEvent((evt: CaretEvent<HTMLTextAreaElement>, newText?: string) => {
|
const onComposerCaretChange = useEvent((evt: CaretEvent<HTMLTextAreaElement>, newText?: string) => {
|
||||||
const area = evt.currentTarget
|
const area = evt.currentTarget
|
||||||
if (area.selectionStart <= (autocomplete?.startPos ?? 0)) {
|
if (area.selectionStart <= (autocomplete?.startPos ?? 0)) {
|
||||||
|
@ -487,11 +495,22 @@ const MessageComposer = () => {
|
||||||
onClose: () => !isMobileDevice && textInput.current?.focus(),
|
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(() => {
|
const openLocationPicker = useEvent(() => {
|
||||||
setState({ location: { lat: 0, long: 0, prec: 1 }, media: null })
|
setState({ location: { lat: 0, long: 0, prec: 1 }, media: null })
|
||||||
})
|
})
|
||||||
const Autocompleter = getAutocompleter(autocomplete, client, room)
|
const Autocompleter = getAutocompleter(autocomplete, client, room)
|
||||||
let mediaDisabledTitle: string | undefined
|
let mediaDisabledTitle: string | undefined
|
||||||
|
let stickerDisabledTitle: string | undefined
|
||||||
let locationDisabledTitle: string | undefined
|
let locationDisabledTitle: string | undefined
|
||||||
if (state.media) {
|
if (state.media) {
|
||||||
mediaDisabledTitle = "You can only attach one file at a time"
|
mediaDisabledTitle = "You can only attach one file at a time"
|
||||||
|
@ -503,10 +522,31 @@ const MessageComposer = () => {
|
||||||
mediaDisabledTitle = "Uploading file..."
|
mediaDisabledTitle = "Uploading file..."
|
||||||
locationDisabledTitle = "You can't attach a location to a message with a 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) => {
|
const makeAttachmentButtons = (includeText = false) => {
|
||||||
return <>
|
return <>
|
||||||
<button onClick={openEmojiPicker} title="Add emoji"><EmojiIcon/>{includeText && "Emoji"}</button>
|
<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
|
<button
|
||||||
onClick={openLocationPicker}
|
onClick={openLocationPicker}
|
||||||
disabled={!!locationDisabledTitle}
|
disabled={!!locationDisabledTitle}
|
||||||
|
@ -531,6 +571,7 @@ const MessageComposer = () => {
|
||||||
})
|
})
|
||||||
const inlineButtons = state.text === "" || window.innerWidth > 720
|
const inlineButtons = state.text === "" || window.innerWidth > 720
|
||||||
const showSendButton = canSend || window.innerWidth > 720
|
const showSendButton = canSend || window.innerWidth > 720
|
||||||
|
const disableClearMedia = editing && state.media?.msgtype === "m.sticker"
|
||||||
return <>
|
return <>
|
||||||
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
|
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
|
||||||
params={autocomplete}
|
params={autocomplete}
|
||||||
|
@ -559,7 +600,7 @@ const MessageComposer = () => {
|
||||||
onClose={stopEditing}
|
onClose={stopEditing}
|
||||||
/>}
|
/>}
|
||||||
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
|
{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
|
{state.location && <ComposerLocation
|
||||||
room={room} client={client}
|
room={room} client={client}
|
||||||
location={state.location} onChange={onChangeLocation} clearLocation={clearMedia}
|
location={state.location} onChange={onChangeLocation} clearLocation={clearMedia}
|
||||||
|
@ -593,11 +634,10 @@ const MessageComposer = () => {
|
||||||
|
|
||||||
interface ComposerMediaProps {
|
interface ComposerMediaProps {
|
||||||
content: MediaMessageEventContent
|
content: MediaMessageEventContent
|
||||||
clearMedia: () => void
|
clearMedia: false | (() => void)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
|
const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
|
||||||
// TODO stickers?
|
|
||||||
const [mediaContent, containerClass, containerStyle] = useMediaContent(
|
const [mediaContent, containerClass, containerStyle] = useMediaContent(
|
||||||
content, "m.room.message", { height: 120, width: 360 },
|
content, "m.room.message", { height: 120, width: 360 },
|
||||||
)
|
)
|
||||||
|
@ -605,7 +645,7 @@ const ComposerMedia = ({ content, clearMedia }: ComposerMediaProps) => {
|
||||||
<div className={`media-container ${containerClass}`} style={containerStyle}>
|
<div className={`media-container ${containerClass}`} style={containerStyle}>
|
||||||
{mediaContent}
|
{mediaContent}
|
||||||
</div>
|
</div>
|
||||||
<button onClick={clearMedia}><CloseIcon/></button>
|
{clearMedia && <button onClick={clearMedia}><CloseIcon/></button>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
import React, { use, useCallback } from "react"
|
import React, { use, useCallback } from "react"
|
||||||
import { stringToRoomStateGUID } from "@/api/types"
|
import { stringToRoomStateGUID } from "@/api/types"
|
||||||
import useContentVisibility from "@/util/contentvisibility.ts"
|
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 useEvent from "@/util/useEvent.ts"
|
||||||
import ClientContext from "../ClientContext.ts"
|
import ClientContext from "../ClientContext.ts"
|
||||||
import renderEmoji from "./renderEmoji.tsx"
|
import renderEmoji from "./renderEmoji.tsx"
|
||||||
|
@ -27,8 +27,9 @@ interface EmojiGroupProps {
|
||||||
selected?: string[]
|
selected?: string[]
|
||||||
pack?: CustomEmojiPack
|
pack?: CustomEmojiPack
|
||||||
isWatched?: boolean
|
isWatched?: boolean
|
||||||
onSelect: (emoji?: PartialEmoji) => void
|
onSelect: (emoji?: Emoji) => void
|
||||||
setPreviewEmoji: (emoji?: Emoji) => void
|
setPreviewEmoji?: (emoji?: Emoji) => void
|
||||||
|
imageType: "emoji" | "sticker"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EmojiGroup = ({
|
export const EmojiGroup = ({
|
||||||
|
@ -39,6 +40,7 @@ export const EmojiGroup = ({
|
||||||
isWatched,
|
isWatched,
|
||||||
onSelect,
|
onSelect,
|
||||||
setPreviewEmoji,
|
setPreviewEmoji,
|
||||||
|
imageType,
|
||||||
}: EmojiGroupProps) => {
|
}: EmojiGroupProps) => {
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const [isVisible, divRef] = useContentVisibility<HTMLDivElement>(true)
|
const [isVisible, divRef] = useContentVisibility<HTMLDivElement>(true)
|
||||||
|
@ -57,8 +59,8 @@ export const EmojiGroup = ({
|
||||||
const onClickEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
|
const onClickEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
|
||||||
onSelect(getEmojiFromAttrs(evt.currentTarget)))
|
onSelect(getEmojiFromAttrs(evt.currentTarget)))
|
||||||
const onMouseOverEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
|
const onMouseOverEmoji = useEvent((evt: React.MouseEvent<HTMLButtonElement>) =>
|
||||||
setPreviewEmoji(getEmojiFromAttrs(evt.currentTarget)))
|
setPreviewEmoji?.(getEmojiFromAttrs(evt.currentTarget)))
|
||||||
const onMouseOutEmoji = useCallback(() => setPreviewEmoji(undefined), [setPreviewEmoji])
|
const onMouseOutEmoji = useCallback(() => setPreviewEmoji?.(undefined), [setPreviewEmoji])
|
||||||
const onClickSubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
|
const onClickSubscribePack = useEvent((evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
|
const guid = stringToRoomStateGUID(evt.currentTarget.getAttribute("data-pack-id"))
|
||||||
if (!guid) {
|
if (!guid) {
|
||||||
|
@ -86,12 +88,14 @@ export const EmojiGroup = ({
|
||||||
} else {
|
} else {
|
||||||
categoryName = "Unknown category"
|
categoryName = "Unknown category"
|
||||||
}
|
}
|
||||||
|
const itemsPerRow = imageType === "sticker" ? 4 : 8
|
||||||
|
const rowSize = imageType === "sticker" ? 5 : 2.5
|
||||||
return <div
|
return <div
|
||||||
ref={divRef}
|
ref={divRef}
|
||||||
className="emoji-category"
|
className="emoji-category"
|
||||||
id={`emoji-category-${categoryID}`}
|
id={`emoji-category-${categoryID}`}
|
||||||
data-category-id={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">
|
<h4 className="emoji-category-name">
|
||||||
{categoryName}
|
{categoryName}
|
||||||
|
@ -104,11 +108,12 @@ export const EmojiGroup = ({
|
||||||
<div className="emoji-category-list">
|
<div className="emoji-category-list">
|
||||||
{isVisible ? emojis.map((emoji, idx) => <button
|
{isVisible ? emojis.map((emoji, idx) => <button
|
||||||
key={emoji.u}
|
key={emoji.u}
|
||||||
className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`}
|
className={`${imageType} ${selected?.includes(emoji.u) ? "selected" : ""}`}
|
||||||
data-emoji-index={idx}
|
data-emoji-index={idx}
|
||||||
onMouseOver={onMouseOverEmoji}
|
onMouseOver={onMouseOverEmoji}
|
||||||
onMouseOut={onMouseOutEmoji}
|
onMouseOut={onMouseOutEmoji}
|
||||||
onClick={onClickEmoji}
|
onClick={onClickEmoji}
|
||||||
|
title={emoji.t}
|
||||||
>{renderEmoji(emoji)}</button>) : null}
|
>{renderEmoji(emoji)}</button>) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
div.emoji-picker, div.gif-picker {
|
div.emoji-picker, div.sticker-picker, div.gif-picker {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
background-color: var(--background-color);
|
background-color: var(--background-color);
|
||||||
width: 22rem;
|
width: 22rem;
|
||||||
|
@ -72,7 +72,7 @@ div.gif-picker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
div.emoji-picker {
|
div.emoji-picker, div.sticker-picker {
|
||||||
div.emoji-category-bar {
|
div.emoji-category-bar {
|
||||||
/*height: 2.5rem;*/
|
/*height: 2.5rem;*/
|
||||||
display: flex;
|
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 {
|
button.freeform-react {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: .25rem;
|
padding: .25rem;
|
||||||
|
|
|
@ -13,7 +13,7 @@
|
||||||
//
|
//
|
||||||
// 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 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 { getMediaURL } from "@/api/media.ts"
|
||||||
import { RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
import { RoomStateStore, useCustomEmojis } from "@/api/statestore"
|
||||||
import { roomStateGUIDToString } from "@/api/types"
|
import { roomStateGUIDToString } from "@/api/types"
|
||||||
|
@ -24,6 +24,7 @@ import ClientContext from "../ClientContext.ts"
|
||||||
import { ModalCloseContext } from "../modal/Modal.tsx"
|
import { ModalCloseContext } from "../modal/Modal.tsx"
|
||||||
import { EmojiGroup } from "./EmojiGroup.tsx"
|
import { EmojiGroup } from "./EmojiGroup.tsx"
|
||||||
import renderEmoji from "./renderEmoji.tsx"
|
import renderEmoji from "./renderEmoji.tsx"
|
||||||
|
import useCategoryUnderline from "./useCategoryUnderline.ts"
|
||||||
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"
|
||||||
|
@ -64,8 +65,7 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [previewEmoji, setPreviewEmoji] = useState<Emoji>()
|
const [previewEmoji, setPreviewEmoji] = useState<Emoji>()
|
||||||
const emojiCategoryBarRef = useRef<HTMLDivElement>(null)
|
const [emojiCategoryBarRef, emojiListRef] = useCategoryUnderline()
|
||||||
const emojiListRef = useRef<HTMLDivElement>(null)
|
|
||||||
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, {
|
||||||
|
@ -91,31 +91,6 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
|
||||||
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
|
const categoryID = evt.currentTarget.getAttribute("data-category-id")!
|
||||||
document.getElementById(`emoji-category-${categoryID}`)?.scrollIntoView()
|
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}>
|
return <div className="emoji-picker" style={style}>
|
||||||
<div className="emoji-category-bar" ref={emojiCategoryBarRef}>
|
<div className="emoji-category-bar" ref={emojiCategoryBarRef}>
|
||||||
<button
|
<button
|
||||||
|
@ -175,6 +150,7 @@ const EmojiPicker = ({ style, selected, onSelect, room, allowFreeform, closeOnSe
|
||||||
isWatched={typeof categoryID === "string" && watchedEmojiPackKeys.includes(categoryID)}
|
isWatched={typeof categoryID === "string" && watchedEmojiPackKeys.includes(categoryID)}
|
||||||
onSelect={onSelectWrapped}
|
onSelect={onSelectWrapped}
|
||||||
setPreviewEmoji={setPreviewEmoji}
|
setPreviewEmoji={setPreviewEmoji}
|
||||||
|
imageType="emoji"
|
||||||
/>
|
/>
|
||||||
})}
|
})}
|
||||||
{allowFreeform && query && <button
|
{allowFreeform && query && <button
|
||||||
|
|
|
@ -23,7 +23,7 @@ import { GIF, getTrendingGIFs, searchGIF } from "./gifsource.ts"
|
||||||
import CloseIcon from "@/icons/close.svg?react"
|
import CloseIcon from "@/icons/close.svg?react"
|
||||||
import SearchIcon from "@/icons/search.svg?react"
|
import SearchIcon from "@/icons/search.svg?react"
|
||||||
|
|
||||||
interface GIFPickerProps {
|
export interface MediaPickerProps {
|
||||||
style: CSSProperties
|
style: CSSProperties
|
||||||
onSelect: (media: MediaMessageEventContent) => void
|
onSelect: (media: MediaMessageEventContent) => void
|
||||||
room: RoomStateStore
|
room: RoomStateStore
|
||||||
|
@ -31,7 +31,7 @@ interface GIFPickerProps {
|
||||||
|
|
||||||
const trendingCache = new Map<string, GIF[]>()
|
const trendingCache = new Map<string, GIF[]>()
|
||||||
|
|
||||||
const GIFPicker = ({ style, onSelect, room }: GIFPickerProps) => {
|
const GIFPicker = ({ style, onSelect, room }: MediaPickerProps) => {
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [results, setResults] = useState<GIF[]>([])
|
const [results, setResults] = useState<GIF[]>([])
|
||||||
const [error, setError] = useState<unknown>()
|
const [error, setError] = useState<unknown>()
|
||||||
|
|
112
web/src/ui/emojipicker/StickerPicker.tsx
Normal file
112
web/src/ui/emojipicker/StickerPicker.tsx
Normal 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
|
51
web/src/ui/emojipicker/useCategoryUnderline.ts
Normal file
51
web/src/ui/emojipicker/useCategoryUnderline.ts
Normal 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
|
|
@ -29,7 +29,7 @@ export const useMediaContent = (
|
||||||
const mediaURL = content.file?.url ? getEncryptedMediaURL(content.file.url) : getMediaURL(content.url)
|
const mediaURL = content.file?.url ? getEncryptedMediaURL(content.file.url) : getMediaURL(content.url)
|
||||||
const thumbnailURL = content.info?.thumbnail_file?.url
|
const thumbnailURL = content.info?.thumbnail_file?.url
|
||||||
? getEncryptedMediaURL(content.info.thumbnail_file.url) : getMediaURL(content.info?.thumbnail_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)
|
const style = calculateMediaSize(content.info?.w, content.info?.h, containerSize)
|
||||||
return [<img
|
return [<img
|
||||||
onLoad={onLoad}
|
onLoad={onLoad}
|
||||||
|
|
|
@ -100,7 +100,7 @@ export const usePrimaryItems = (
|
||||||
const canSend = !didFail && ownPL >= messageSendPL
|
const canSend = !didFail && ownPL >= messageSendPL
|
||||||
const canEdit = canSend
|
const canEdit = canSend
|
||||||
&& evt.sender === client.userID
|
&& evt.sender === client.userID
|
||||||
&& evt.type === "m.room.message"
|
&& (evt.type === "m.room.message" || evt.type === "m.sticker")
|
||||||
&& evt.relation_type !== "m.replace"
|
&& evt.relation_type !== "m.replace"
|
||||||
&& !evt.redacted_by
|
&& !evt.redacted_by
|
||||||
const canReact = !didFail && ownPL >= reactPL
|
const canReact = !didFail && ownPL >= reactPL
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
// 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 { useMemo, useRef } from "react"
|
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"
|
import data from "./data.json"
|
||||||
|
|
||||||
export interface EmojiMetadata {
|
export interface EmojiMetadata {
|
||||||
|
@ -22,6 +22,7 @@ export interface EmojiMetadata {
|
||||||
t: string // Emoji title
|
t: string // Emoji title
|
||||||
n: string // Primary shortcode
|
n: string // Primary shortcode
|
||||||
s: string[] // Shortcodes without underscores
|
s: string[] // Shortcodes without underscores
|
||||||
|
i?: MediaInfo // Media info for stickers only
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EmojiText {
|
export interface EmojiText {
|
||||||
|
@ -71,6 +72,7 @@ function filterAndSort(
|
||||||
query: string,
|
query: string,
|
||||||
frequentlyUsed?: Map<string, number>,
|
frequentlyUsed?: Map<string, number>,
|
||||||
customEmojis?: CustomEmojiPack[],
|
customEmojis?: CustomEmojiPack[],
|
||||||
|
stickers?: true,
|
||||||
): Emoji[] {
|
): Emoji[] {
|
||||||
const filteredStandardEmojis = emojis
|
const filteredStandardEmojis = emojis
|
||||||
.map(emoji => {
|
.map(emoji => {
|
||||||
|
@ -82,7 +84,7 @@ function filterAndSort(
|
||||||
})
|
})
|
||||||
.filter(({ matchIndex }) => matchIndex !== -1)
|
.filter(({ matchIndex }) => matchIndex !== -1)
|
||||||
const filteredCustomEmojis = customEmojis
|
const filteredCustomEmojis = customEmojis
|
||||||
?.flatMap(pack => pack.emojis
|
?.flatMap(pack => (stickers ? pack.stickers : pack.emojis)
|
||||||
.map(emoji => {
|
.map(emoji => {
|
||||||
const matchIndex = emoji.s.reduce((minIndex, shortcode) => {
|
const matchIndex = emoji.s.reduce((minIndex, shortcode) => {
|
||||||
const index = shortcode.indexOf(query)
|
const index = shortcode.indexOf(query)
|
||||||
|
@ -91,9 +93,12 @@ function filterAndSort(
|
||||||
return { emoji, matchIndex }
|
return { emoji, matchIndex }
|
||||||
})
|
})
|
||||||
.filter(({ matchIndex }) => matchIndex !== -1)) ?? []
|
.filter(({ matchIndex }) => matchIndex !== -1)) ?? []
|
||||||
const allEmojis = filteredCustomEmojis.length
|
const allEmojis =
|
||||||
|
filteredStandardEmojis.length
|
||||||
|
? filteredCustomEmojis.length
|
||||||
? filteredStandardEmojis.concat(filteredCustomEmojis)
|
? filteredStandardEmojis.concat(filteredCustomEmojis)
|
||||||
: filteredStandardEmojis
|
: filteredStandardEmojis
|
||||||
|
: filteredCustomEmojis
|
||||||
return allEmojis
|
return allEmojis
|
||||||
.sort((e1, e2) =>
|
.sort((e1, e2) =>
|
||||||
e1.matchIndex === e2.matchIndex
|
e1.matchIndex === e2.matchIndex
|
||||||
|
@ -131,6 +136,7 @@ export interface CustomEmojiPack {
|
||||||
name: string
|
name: string
|
||||||
icon?: ContentURI
|
icon?: ContentURI
|
||||||
emojis: Emoji[]
|
emojis: Emoji[]
|
||||||
|
stickers: Emoji[]
|
||||||
emojiMap: Map<string, Emoji>
|
emojiMap: Map<string, Emoji>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -138,16 +144,16 @@ export function parseCustomEmojiPack(
|
||||||
pack: ImagePack,
|
pack: ImagePack,
|
||||||
id: string,
|
id: string,
|
||||||
fallbackName?: string,
|
fallbackName?: string,
|
||||||
usage: ImagePackUsage = "emoticon",
|
|
||||||
): CustomEmojiPack | null {
|
): CustomEmojiPack | null {
|
||||||
try {
|
try {
|
||||||
if (pack.pack.usage && !pack.pack.usage.includes(usage)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const name = pack.pack.display_name || fallbackName || "Unnamed pack"
|
const name = pack.pack.display_name || fallbackName || "Unnamed pack"
|
||||||
const emojiMap = new Map<string, Emoji>()
|
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)) {
|
for (const [shortcode, image] of Object.entries(pack.images)) {
|
||||||
if (!image.url || (image.usage && !image.usage.includes(usage))) {
|
if (!image.url) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
let converted = emojiMap.get(image.url)
|
let converted = emojiMap.get(image.url)
|
||||||
|
@ -160,17 +166,26 @@ export function parseCustomEmojiPack(
|
||||||
n: shortcode,
|
n: shortcode,
|
||||||
s: [shortcode.toLowerCase().replaceAll("_", "").replaceAll(" ", "")],
|
s: [shortcode.toLowerCase().replaceAll("_", "").replaceAll(" ", "")],
|
||||||
t: image.body || shortcode,
|
t: image.body || shortcode,
|
||||||
|
i: image.info,
|
||||||
}
|
}
|
||||||
emojiMap.set(image.url, converted)
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
icon,
|
icon,
|
||||||
emojis,
|
emojis,
|
||||||
|
stickers,
|
||||||
emojiMap,
|
emojiMap,
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -192,6 +207,7 @@ interface filteredAndSortedEmojiCache {
|
||||||
interface useEmojisParams {
|
interface useEmojisParams {
|
||||||
frequentlyUsed?: Map<string, number>
|
frequentlyUsed?: Map<string, number>
|
||||||
customEmojiPacks?: CustomEmojiPack[]
|
customEmojiPacks?: CustomEmojiPack[]
|
||||||
|
stickers?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useFilteredEmojis(query: string, params: useEmojisParams = {}): Emoji[][] {
|
export function useFilteredEmojis(query: string, params: useEmojisParams = {}): Emoji[][] {
|
||||||
|
@ -225,14 +241,15 @@ export function useFilteredEmojis(query: string, params: useEmojisParams = {}):
|
||||||
query: "",
|
query: "",
|
||||||
result: [],
|
result: [],
|
||||||
})
|
})
|
||||||
|
const baseEmojis = params.stickers ? [] : emojisByCategory
|
||||||
const categoriesChanged = prev.current.result.length !==
|
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 (prev.current.query !== query || categoriesChanged) {
|
||||||
if (!query.startsWith(prev.current.query) || categoriesChanged) {
|
if (!query.startsWith(prev.current.query) || categoriesChanged) {
|
||||||
prev.current.result = [
|
prev.current.result = [
|
||||||
frequentlyUsedCategory,
|
frequentlyUsedCategory,
|
||||||
...emojisByCategory,
|
...baseEmojis,
|
||||||
...(params.customEmojiPacks?.map(pack => pack.emojis) ?? []),
|
...(params.customEmojiPacks?.map(pack => params.stickers ? pack.stickers : pack.emojis) ?? []),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (query !== "") {
|
if (query !== "") {
|
||||||
|
|
Loading…
Add table
Reference in a new issue