mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 18:43:41 -05:00
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
|
||||
}
|
||||
}
|
||||
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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>()
|
||||
|
|
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 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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
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 !== "") {
|
||||
|
|
Loading…
Add table
Reference in a new issue