1
0
Fork 0
forked from Mirrors/gomuks

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 }) 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) const room = this.store.rooms.get(roomID)
if (!room) { if (!room) {
throw new Error("Room not found") throw new Error("Room not found")

View file

@ -128,7 +128,7 @@ export default abstract class RPCClient {
return this.request("send_message", params) 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 }) return this.request("send_event", { room_id, type, content })
} }

View file

@ -125,6 +125,15 @@ export interface MediaMessageEventContent extends BaseMessageEventContent {
info?: MediaInfo 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 { export interface EncryptedFile {
url: ContentURI url: ContentURI
k: string k: string

View file

@ -25,6 +25,7 @@ import type {
RelatesTo, RelatesTo,
RoomID, RoomID,
} from "@/api/types" } from "@/api/types"
import { emojiToMarkdown } 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 EmojiPicker from "../emojipicker/EmojiPicker.tsx" import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
@ -320,7 +321,7 @@ const MessageComposer = () => {
onSelect={emoji => { onSelect={emoji => {
setState({ setState({
text: state.text.slice(0, textInput.current?.selectionStart ?? 0) text: state.text.slice(0, textInput.current?.selectionStart ?? 0)
+ emoji.u + emojiToMarkdown(emoji)
+ state.text.slice(textInput.current?.selectionEnd ?? 0), + state.text.slice(textInput.current?.selectionEnd ?? 0),
}) })
}} }}

View file

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

View file

@ -15,7 +15,7 @@
// 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 { CSSProperties, JSX, use, useCallback, useState } from "react" import { CSSProperties, JSX, use, useCallback, useState } from "react"
import { getMediaURL } from "@/api/media.ts" 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 { ModalCloseContext } from "../modal/Modal.tsx"
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"
@ -58,37 +58,42 @@ function renderEmoji(emoji: Emoji): JSX.Element | string {
interface EmojiPickerProps { interface EmojiPickerProps {
style: CSSProperties style: CSSProperties
onSelect: (emoji: Partial<Emoji>) => void onSelect: (emoji: PartialEmoji, isSelected?: boolean) => void
allowFreeform?: boolean allowFreeform?: boolean
closeOnSelect?: 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 [query, setQuery] = useState("")
const emojis = useFilteredEmojis(query) const emojis = useFilteredEmojis(query)
const [previewEmoji, setPreviewEmoji] = useState<Emoji>() const [previewEmoji, setPreviewEmoji] = useState<Emoji>()
const clearQuery = useCallback(() => setQuery(""), []) const clearQuery = useCallback(() => setQuery(""), [])
const onChangeQuery = useCallback((evt: React.ChangeEvent<HTMLInputElement>) => setQuery(evt.target.value), [])
const cats: JSX.Element[] = [] const cats: JSX.Element[] = []
let currentCat: JSX.Element[] | undefined let currentCat: JSX.Element[] = []
let currentCatNum: number | string = -1 let currentCatNum: number | string = -1
const close = use(ModalCloseContext) const close = use(ModalCloseContext)
const onSelectWrapped = (emoji: Partial<Emoji>) => { const onSelectWrapped = (emoji: PartialEmoji) => {
onSelect(emoji) onSelect(emoji, selected?.includes(emoji.u))
if (closeOnSelect) { if (closeOnSelect) {
close() 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) { for (const emoji of emojis) {
if (emoji.c === 2) { if (emoji.c === 2) {
continue continue
} }
if (emoji.c !== currentCatNum || !currentCat) { if (emoji.c !== currentCatNum) {
if (currentCat) { if (currentCat.length) {
cats.push(<div className="emoji-category" key={currentCatNum} data-emoji-category={currentCatNum}> const categoryName = typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum
<h4 className="emoji-category-name">{ cats.push(<div className="emoji-category" key={currentCatNum} id={`emoji-category-${categoryName}`}>
typeof currentCatNum === "number" ? categories[currentCatNum] : currentCatNum <h4 className="emoji-category-name">{categoryName}</h4>
}</h4>
<div className="emoji-category-list"> <div className="emoji-category-list">
{currentCat} {currentCat}
</div> </div>
@ -99,23 +104,38 @@ export const EmojiPicker = ({ style, onSelect, allowFreeform, closeOnSelect }: E
} }
currentCat.push(<button currentCat.push(<button
key={emoji.u} key={emoji.u}
className="emoji" className={`emoji ${selected?.includes(emoji.u) ? "selected" : ""}`}
onMouseOver={() => setPreviewEmoji(emoji)} onMouseOver={() => setPreviewEmoji(emoji)}
onMouseOut={() => setPreviewEmoji(undefined)} onMouseOut={() => setPreviewEmoji(undefined)}
onClick={() => onSelectWrapped(emoji)} onClick={() => onSelectWrapped(emoji)}
>{renderEmoji(emoji)}</button>) >{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}> return <div className="emoji-picker" style={style}>
<div className="emoji-category-bar"> <div className="emoji-category-bar">
<button <button
className="emoji-category-icon" className="emoji-category-icon"
title={"Recently used"} title="Frequently Used"
>{<RecentIcon/>}</button> >{<RecentIcon/>}</button>
{sortedEmojiCategories.map(cat => {sortedEmojiCategories.map(cat =>
<button <button
key={cat.index} key={cat.index}
className="emoji-category-icon" className="emoji-category-icon"
title={cat.name ?? categories[cat.index]} title={cat.name ?? categories[cat.index]}
onClick={onClickCategoryButton}
>{cat.icon}</button>, >{cat.icon}</button>,
)} )}
</div> </div>

View file

@ -16,6 +16,7 @@
import { CSSProperties, use, useCallback, useRef } from "react" import { CSSProperties, use, useCallback, useRef } from "react"
import { useRoomState } from "@/api/statestore" import { useRoomState } from "@/api/statestore"
import { MemDBEvent, PowerLevelEventContent } from "@/api/types" import { MemDBEvent, PowerLevelEventContent } from "@/api/types"
import { emojiToReactionContent } from "@/util/emoji"
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts" import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
import { ClientContext } from "../ClientContext.ts" import { ClientContext } from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx" import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
@ -67,17 +68,7 @@ const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
content: <EmojiPicker content: <EmojiPicker
style={style} style={style}
onSelect={emoji => { onSelect={emoji => {
const content: Record<string, unknown> = { client.sendEvent(evt.room_id, "m.reaction", emojiToReactionContent(emoji, evt.event_id))
"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)
.catch(err => window.alert(`Failed to send reaction: ${err}`)) .catch(err => window.alert(`Failed to send reaction: ${err}`))
}} }}
closeOnSelect={true} closeOnSelect={true}

View file

@ -14,16 +14,19 @@
// 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 { useRef } from "react" import { useRef } from "react"
import { EventID, ReactionEventContent } from "@/api/types"
import data from "./data.json" import data from "./data.json"
export interface Emoji { export interface PartialEmoji {
u: string // Unicode codepoint or custom emoji mxc:// URI u: string // Unicode codepoint or custom emoji mxc:// URI
c: number | string // Category number or custom emoji pack name c?: number | string // Category number or custom emoji pack name
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
} }
export type Emoji = Required<PartialEmoji>
export const emojis: Emoji[] = data.e export const emojis: Emoji[] = data.e
export const categories = data.c 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) 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 { interface filteredEmojiCache {
query: string query: string
result: Emoji[] result: Emoji[]