web/emojipicker: small improvements

This commit is contained in:
Tulir Asokan 2024-10-25 18:37:49 +03:00
parent 30222d2c6e
commit 55a9866eac
8 changed files with 84 additions and 34 deletions

View file

@ -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<string, unknown>): Promise<void> {
async sendEvent(roomID: RoomID, type: EventType, content: unknown): Promise<void> {
const room = this.store.rooms.get(roomID)
if (!room) {
throw new Error("Room not found")

View file

@ -128,7 +128,7 @@ export default abstract class RPCClient {
return this.request("send_message", params)
}
sendEvent(room_id: RoomID, type: EventType, content: Record<string, unknown>): Promise<RawDBEvent> {
sendEvent(room_id: RoomID, type: EventType, content: unknown): Promise<RawDBEvent> {
return this.request("send_event", { room_id, type, content })
}

View file

@ -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

View file

@ -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),
})
}}

View file

@ -119,6 +119,11 @@ div.emoji-picker {
&:hover {
background-color: #ccc;
}
&.selected {
border: 1px solid #cec;
opacity: .8;
}
}
button.freeform-react {

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
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<Emoji>) => 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<Emoji>()
const clearQuery = useCallback(() => setQuery(""), [])
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => 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<Emoji>) => {
onSelect(emoji)
const onSelectWrapped = (emoji: PartialEmoji) => {
onSelect(emoji, selected?.includes(emoji.u))
if (closeOnSelect) {
close()
}
}
cats.push(<div className="emoji-category" data-emoji-category="Frequently Used">
<h4 className="emoji-category-name">Frequently Used</h4>
<div className="emoji-category-list">
{/* TODO */}
</div>
</div>)
for (const emoji of emojis) {
if (emoji.c === 2) {
continue
}
if (emoji.c !== currentCatNum || !currentCat) {
if (currentCat) {
cats.push(<div className="emoji-category" key={currentCatNum} data-emoji-category={currentCatNum}>
<h4 className="emoji-category-name">{
typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum
}</h4>
if (emoji.c !== currentCatNum) {
if (currentCat.length) {
const categoryName = typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum
cats.push(<div className="emoji-category" key={currentCatNum} id={`emoji-category-${categoryName}`}>
<h4 className="emoji-category-name">{categoryName}</h4>
<div className="emoji-category-list">
{currentCat}
</div>
@ -99,23 +104,38 @@ export const EmojiPicker = ({ style, onSelect, allowFreeform, closeOnSelect }: E
}
currentCat.push(<button
key={emoji.u}
className="emoji"
className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`}
onMouseOver={() => setPreviewEmoji(emoji)}
onMouseOut={() => setPreviewEmoji(undefined)}
onClick={() => onSelectWrapped(emoji)}
>{renderEmoji(emoji)}</button>)
}
if (currentCat.length) {
const categoryName = typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum
cats.push(<div className="emoji-category" key={currentCatNum} id={`emoji-category-${categoryName}`}>
<h4 className="emoji-category-name">{categoryName}</h4>
<div className="emoji-category-list">
{currentCat}
</div>
</div>)
}
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => 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 <div className="emoji-picker" style={style}>
<div className="emoji-category-bar">
<button
className="emoji-category-icon"
title={"Recently used"}
title="Frequently Used"
>{<RecentIcon/>}</button>
{sortedEmojiCategories.map(cat =>
<button
key={cat.index}
className="emoji-category-icon"
title={cat.name ?? categories[cat.index]}
onClick={onClickCategoryButton}
>{cat.icon}</button>,
)}
</div>

View file

@ -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: <EmojiPicker
style={style}
onSelect={emoji => {
const content: Record<string, unknown> = {
"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}

View file

@ -14,16 +14,19 @@
// 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 { 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<PartialEmoji>
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 `<img data-mx-emoticon src="${emoji.u}" alt=":${emoji.n}:" title=":${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[]