From 70938b23191d8c853a8c0324463364bad4a732e6 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 21 Dec 2024 17:29:06 +0200 Subject: [PATCH] web/composer: add support for sending stickers --- pkg/hicli/send.go | 9 +- web/src/api/types/mxtypes.ts | 2 +- web/src/ui/composer/MessageComposer.tsx | 56 +++++++-- web/src/ui/emojipicker/EmojiGroup.tsx | 19 +-- web/src/ui/emojipicker/EmojiPicker.css | 15 ++- web/src/ui/emojipicker/EmojiPicker.tsx | 32 +---- web/src/ui/emojipicker/GIFPicker.tsx | 4 +- web/src/ui/emojipicker/StickerPicker.tsx | 112 ++++++++++++++++++ .../ui/emojipicker/useCategoryUnderline.ts | 51 ++++++++ .../ui/timeline/content/useMediaContent.tsx | 2 +- web/src/ui/timeline/menu/usePrimaryItems.tsx | 2 +- web/src/util/emoji/index.ts | 47 +++++--- 12 files changed, 284 insertions(+), 67 deletions(-) create mode 100644 web/src/ui/emojipicker/StickerPicker.tsx create mode 100644 web/src/ui/emojipicker/useCategoryUnderline.ts diff --git a/pkg/hicli/send.go b/pkg/hicli/send.go index 96580b9..9670fac 100644 --- a/pkg/hicli/send.go +++ b/pkg/hicli/send.go @@ -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) diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 2228124..78e6146 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -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 diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index e74ba21..a931509 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -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, 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: 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 <> - + + + {clearMedia && } } diff --git a/web/src/ui/emojipicker/EmojiGroup.tsx b/web/src/ui/emojipicker/EmojiGroup.tsx index b58d0d7..db7a10c 100644 --- a/web/src/ui/emojipicker/EmojiGroup.tsx +++ b/web/src/ui/emojipicker/EmojiGroup.tsx @@ -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(true) @@ -57,8 +59,8 @@ export const EmojiGroup = ({ const onClickEmoji = useEvent((evt: React.MouseEvent) => onSelect(getEmojiFromAttrs(evt.currentTarget))) const onMouseOverEmoji = useEvent((evt: React.MouseEvent) => - 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) => { 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

{categoryName} @@ -104,11 +108,12 @@ export const EmojiGroup = ({
{isVisible ? emojis.map((emoji, idx) => ) : null}

diff --git a/web/src/ui/emojipicker/EmojiPicker.css b/web/src/ui/emojipicker/EmojiPicker.css index 6a0e9eb..34e2d97 100644 --- a/web/src/ui/emojipicker/EmojiPicker.css +++ b/web/src/ui/emojipicker/EmojiPicker.css @@ -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; diff --git a/web/src/ui/emojipicker/EmojiPicker.tsx b/web/src/ui/emojipicker/EmojiPicker.tsx index b3ef7a7..0a96251 100644 --- a/web/src/ui/emojipicker/EmojiPicker.tsx +++ b/web/src/ui/emojipicker/EmojiPicker.tsx @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -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() - const emojiCategoryBarRef = useRef(null) - const emojiListRef = useRef(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
, + )} +
+
+ + +
+
+ {/* Chrome is dumb and doesn't allow scrolling without an inner div */} +
+ {emojis.map(group => { + if (!group?.length) { + return null + } + const categoryID = group[0].c as string + const customPack = customEmojiPacks.find(pack => pack.id === categoryID) + return + })} +
+
+
+} + +export default StickerPicker diff --git a/web/src/ui/emojipicker/useCategoryUnderline.ts b/web/src/ui/emojipicker/useCategoryUnderline.ts new file mode 100644 index 0000000..a19f1dd --- /dev/null +++ b/web/src/ui/emojipicker/useCategoryUnderline.ts @@ -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 . +import { useEffect, useRef } from "react" + +const useCategoryUnderline = () => { + const emojiCategoryBarRef = useRef(null) + const emojiListRef = useRef(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 diff --git a/web/src/ui/timeline/content/useMediaContent.tsx b/web/src/ui/timeline/content/useMediaContent.tsx index cb355a0..066091a 100644 --- a/web/src/ui/timeline/content/useMediaContent.tsx +++ b/web/src/ui/timeline/content/useMediaContent.tsx @@ -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 [= 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 diff --git a/web/src/util/emoji/index.ts b/web/src/util/emoji/index.ts index 94abedf..5d78377 100644 --- a/web/src/util/emoji/index.ts +++ b/web/src/util/emoji/index.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . 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, 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 } @@ -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() + 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 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 !== "") {