mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/emojipicker: small improvements
This commit is contained in:
parent
30222d2c6e
commit
55a9866eac
8 changed files with 84 additions and 34 deletions
|
@ -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")
|
||||||
|
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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),
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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[]
|
||||||
|
|
Loading…
Add table
Reference in a new issue