From 55a9866eacb5b675d2f49b3cfb3557d39f86f211 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 25 Oct 2024 18:37:49 +0300 Subject: [PATCH] web/emojipicker: small improvements --- web/src/api/client.ts | 2 +- web/src/api/rpc.ts | 2 +- web/src/api/types/mxtypes.ts | 9 +++++ web/src/ui/composer/MessageComposer.tsx | 3 +- web/src/ui/emojipicker/EmojiPicker.css | 5 +++ web/src/ui/emojipicker/EmojiPicker.tsx | 50 +++++++++++++++++-------- web/src/ui/timeline/EventMenu.tsx | 13 +------ web/src/util/emoji/index.ts | 34 ++++++++++++++--- 8 files changed, 84 insertions(+), 34 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6494af9..2cac000 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -75,7 +75,7 @@ export default class Client { await this.rpc.setState(room.roomID, "m.room.pinned_events", "", { pinned: pinnedEvents }) } - async sendEvent(roomID: RoomID, type: EventType, content: Record): Promise { + async sendEvent(roomID: RoomID, type: EventType, content: unknown): Promise { const room = this.store.rooms.get(roomID) if (!room) { throw new Error("Room not found") diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 686e650..ac9b53e 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -128,7 +128,7 @@ export default abstract class RPCClient { return this.request("send_message", params) } - sendEvent(room_id: RoomID, type: EventType, content: Record): Promise { + sendEvent(room_id: RoomID, type: EventType, content: unknown): Promise { return this.request("send_event", { room_id, type, content }) } diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index b4fe57a..95c1481 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -125,6 +125,15 @@ export interface MediaMessageEventContent extends BaseMessageEventContent { info?: MediaInfo } +export interface ReactionEventContent { + "m.relates_to": { + rel_type: "m.annotation" + event_id: EventID + key: string + } + "com.beeper.emoji.shortcode"?: string +} + export interface EncryptedFile { url: ContentURI k: string diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index 2c4fa58..6a3b880 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -25,6 +25,7 @@ import type { RelatesTo, RoomID, } from "@/api/types" +import { emojiToMarkdown } from "@/util/emoji" import useEvent from "@/util/useEvent.ts" import { ClientContext } from "../ClientContext.ts" import EmojiPicker from "../emojipicker/EmojiPicker.tsx" @@ -320,7 +321,7 @@ const MessageComposer = () => { onSelect={emoji => { setState({ text: state.text.slice(0, textInput.current?.selectionStart ?? 0) - + emoji.u + + emojiToMarkdown(emoji) + state.text.slice(textInput.current?.selectionEnd ?? 0), }) }} diff --git a/web/src/ui/emojipicker/EmojiPicker.css b/web/src/ui/emojipicker/EmojiPicker.css index fc40409..52450fb 100644 --- a/web/src/ui/emojipicker/EmojiPicker.css +++ b/web/src/ui/emojipicker/EmojiPicker.css @@ -119,6 +119,11 @@ div.emoji-picker { &:hover { background-color: #ccc; } + + &.selected { + border: 1px solid #cec; + opacity: .8; + } } button.freeform-react { diff --git a/web/src/ui/emojipicker/EmojiPicker.tsx b/web/src/ui/emojipicker/EmojiPicker.tsx index f3734ff..6db4a89 100644 --- a/web/src/ui/emojipicker/EmojiPicker.tsx +++ b/web/src/ui/emojipicker/EmojiPicker.tsx @@ -15,7 +15,7 @@ // along with this program. If not, see . import { CSSProperties, JSX, use, useCallback, useState } from "react" import { getMediaURL } from "@/api/media.ts" -import { Emoji, categories, useFilteredEmojis } from "@/util/emoji" +import { Emoji, PartialEmoji, categories, useFilteredEmojis } from "@/util/emoji" import { ModalCloseContext } from "../modal/Modal.tsx" import CloseIcon from "@/icons/close.svg?react" import ActivitiesIcon from "@/icons/emoji-categories/activities.svg?react" @@ -58,37 +58,42 @@ function renderEmoji(emoji: Emoji): JSX.Element | string { interface EmojiPickerProps { style: CSSProperties - onSelect: (emoji: Partial) => void + onSelect: (emoji: PartialEmoji, isSelected?: boolean) => void allowFreeform?: boolean closeOnSelect?: boolean + selected?: string[] } -export const EmojiPicker = ({ style, onSelect, allowFreeform, closeOnSelect }: EmojiPickerProps) => { +export const EmojiPicker = ({ style, selected, onSelect, allowFreeform, closeOnSelect }: EmojiPickerProps) => { const [query, setQuery] = useState("") const emojis = useFilteredEmojis(query) const [previewEmoji, setPreviewEmoji] = useState() const clearQuery = useCallback(() => setQuery(""), []) - const onChangeQuery = useCallback((evt: React.ChangeEvent) => setQuery(evt.target.value), []) const cats: JSX.Element[] = [] - let currentCat: JSX.Element[] | undefined + let currentCat: JSX.Element[] = [] let currentCatNum: number | string = -1 const close = use(ModalCloseContext) - const onSelectWrapped = (emoji: Partial) => { - onSelect(emoji) + const onSelectWrapped = (emoji: PartialEmoji) => { + onSelect(emoji, selected?.includes(emoji.u)) if (closeOnSelect) { close() } } + cats.push(
+

Frequently Used

+
+ {/* TODO */} +
+
) for (const emoji of emojis) { if (emoji.c === 2) { continue } - if (emoji.c !== currentCatNum || !currentCat) { - if (currentCat) { - cats.push(
-

{ - typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum - }

+ if (emoji.c !== currentCatNum) { + if (currentCat.length) { + const categoryName = typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum + cats.push(
+

{categoryName}

{currentCat}
@@ -99,23 +104,38 @@ export const EmojiPicker = ({ style, onSelect, allowFreeform, closeOnSelect }: E } currentCat.push() } + if (currentCat.length) { + const categoryName = typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum + cats.push(
+

{categoryName}

+
+ {currentCat} +
+
) + } + const onChangeQuery = useCallback((evt: React.ChangeEvent) => setQuery(evt.target.value), []) + const onClickCategoryButton = useCallback((evt: React.MouseEvent) => { + const categoryName = evt.currentTarget.getAttribute("title")! + document.getElementById(`emoji-category-${categoryName}`)?.scrollIntoView({ behavior: "smooth" }) + }, []) return
{sortedEmojiCategories.map(cat => , )}
diff --git a/web/src/ui/timeline/EventMenu.tsx b/web/src/ui/timeline/EventMenu.tsx index f5baa8b..caef962 100644 --- a/web/src/ui/timeline/EventMenu.tsx +++ b/web/src/ui/timeline/EventMenu.tsx @@ -16,6 +16,7 @@ import { CSSProperties, use, useCallback, useRef } from "react" import { useRoomState } from "@/api/statestore" import { MemDBEvent, PowerLevelEventContent } from "@/api/types" +import { emojiToReactionContent } from "@/util/emoji" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { ClientContext } from "../ClientContext.ts" import EmojiPicker from "../emojipicker/EmojiPicker.tsx" @@ -67,17 +68,7 @@ const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => { content: { - const content: Record = { - "m.relates_to": { - rel_type: "m.annotation", - event_id: evt.event_id, - key: emoji.u, - }, - } - if (emoji.u?.startsWith("mxc://") && emoji.n) { - content["com.beeper.emoji.shortcode"] = emoji.n - } - client.sendEvent(evt.room_id, "m.reaction", content) + client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id)) .catch(err => window.alert(`Failed to send reaction: ${err}`)) }} closeOnSelect={true} diff --git a/web/src/util/emoji/index.ts b/web/src/util/emoji/index.ts index 39f403e..c74ed0b 100644 --- a/web/src/util/emoji/index.ts +++ b/web/src/util/emoji/index.ts @@ -14,16 +14,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { useRef } from "react" +import { EventID, ReactionEventContent } from "@/api/types" import data from "./data.json" -export interface Emoji { +export interface PartialEmoji { u: string // Unicode codepoint or custom emoji mxc:// URI - c: number | string // Category number or custom emoji pack name - t: string // Emoji title - n: string // Primary shortcode - s: string[] // Shortcodes without underscores + c?: number | string // Category number or custom emoji pack name + t?: string // Emoji title + n?: string // Primary shortcode + s?: string[] // Shortcodes without underscores } +export type Emoji = Required + export const emojis: Emoji[] = data.e export const categories = data.c @@ -51,6 +54,27 @@ export function search(query: string, sorted = false, prev?: Emoji[]): Emoji[] { return (sorted ? filterAndSort : filter)(prev ?? emojis, query) } +export function emojiToMarkdown(emoji: PartialEmoji): string { + if (emoji.u.startsWith("mxc://")) { + return `:${emoji.n}:` + } + return emoji.u +} + +export function emojiToReactionContent(emoji: PartialEmoji, evtID: EventID): ReactionEventContent { + const content: ReactionEventContent = { + "m.relates_to": { + rel_type: "m.annotation", + event_id: evtID, + key: emoji.u, + }, + } + if (emoji.u?.startsWith("mxc://") && emoji.n) { + content["com.beeper.emoji.shortcode"] = emoji.n + } + return content +} + interface filteredEmojiCache { query: string result: Emoji[]