1
0
Fork 0
forked from Mirrors/gomuks

web/timeline,composer: add emoji picker for composer and reaction sending

This commit is contained in:
Tulir Asokan 2024-10-25 16:58:11 +03:00
parent d18b7a43a1
commit 30222d2c6e
20 changed files with 387 additions and 21 deletions

View file

@ -16,7 +16,7 @@
import { CachedEventDispatcher } from "../util/eventdispatcher.ts"
import RPCClient, { SendMessageParams } from "./rpc.ts"
import { RoomStateStore, StateStore } from "./statestore"
import type { ClientState, EventID, RPCEvent, RoomID, UserID } from "./types"
import type { ClientState, EventID, EventType, RPCEvent, RoomID, UserID } from "./types"
export default class Client {
readonly state = new CachedEventDispatcher<ClientState>()
@ -75,6 +75,19 @@ 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> {
const room = this.store.rooms.get(roomID)
if (!room) {
throw new Error("Room not found")
}
const dbEvent = await this.rpc.sendEvent(roomID, type, content)
if (!room.eventsByRowID.has(dbEvent.rowid)) {
room.pendingEvents.push(dbEvent.rowid)
room.applyEvent(dbEvent, true)
room.notifyTimelineSubscribers()
}
}
async sendMessage(params: SendMessageParams): Promise<void> {
const room = this.store.rooms.get(params.room_id)
if (!room) {

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M280-120v-80h160v-124q-49-11-87.5-41.5T296-442q-75-9-125.5-65.5T120-640v-40q0-33 23.5-56.5T200-760h80v-80h400v80h80q33 0 56.5 23.5T840-680v40q0 76-50.5 132.5T664-442q-18 46-56.5 76.5T520-324v124h160v80H280Zm0-408v-152h-80v40q0 38 22 68.5t58 43.5Zm200 128q50 0 85-35t35-85v-240H360v240q0 50 35 85t85 35Zm200-128q36-13 58-43.5t22-68.5v-40h-80v152Zm-200-52Z"/></svg>

After

Width:  |  Height:  |  Size: 480 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m720-600-32 28q-14 13-33 13t-33-11q-14-11-19-28t1-36l16-50-34-20q-16-9-22.5-26t-1.5-34q5-17 20-26.5t34-9.5h40l12-38q6-19 20.5-30.5T720-880q17 0 31.5 11.5T772-838l12 38h40q19 0 33.5 9.5T878-764q7 18 0 35t-22 25l-36 20 16 50q6 19 1 36.5T818-570q-15 11-33.5 11T752-572l-32-28Zm0-80q17 0 28.5-11.5T760-720q0-17-11.5-28.5T720-760q-17 0-28.5 11.5T680-720q0 17 11.5 28.5T720-680ZM552-244q23 60-15 112T430-80q-33 0-62.5-17T324-142q-83 12-137.5-42.5T142-324q-30-17-46-46.5T80-438q0-61 55.5-98.5T244-552l62 26q20-31 53-50.5t71-21.5v-82h60v90q37 11 61 34.5t41 65.5h88v60h-82q-2 38-20.5 71T528-306l24 62Zm-248 24q0-27 4.5-52.5T322-322q-23 11-49.5 15.5T220-304q0 39 22.5 61.5T304-220Zm-74-164q32 0 56.5-8t63.5-32l-120-50q-29-12-49.5.5T160-434q0 26 17 38t53 12Zm200 224q25 0 40.5-17.5T478-214l-54-136q-19 32-29.5 64T384-228q0 33 11.5 50.5T430-160Zm66-222q10-10 16-26.5t6-34.5q0-32-21-54t-52-22q-18 0-34 6t-27 17l78 36 34 78Zm-174 60Z"/></svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M200-120v-680h360l16 80h224v400H520l-16-80H280v280h-80Zm300-440Zm86 160h134v-240H510l-16-80H280v240h290l16 80Z"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M160-120v-80h640v80H160Zm160-160q-66 0-113-47t-47-113v-400h640q33 0 56.5 23.5T880-760v120q0 33-23.5 56.5T800-560h-80v120q0 66-47 113t-113 47H320Zm0-480h320-400 80Zm400 120h80v-120h-80v120ZM560-360q33 0 56.5-23.5T640-440v-320H400v16l72 58q2 2 8 16v170q0 8-6 14t-14 6H300q-8 0-14-6t-6-14v-170q0-2 8-16l72-58v-16H240v320q0 33 23.5 56.5T320-360h240ZM360-760h40-40Z"/></svg>

After

Width:  |  Height:  |  Size: 486 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-80q-26 0-47-12.5T400-126q-33 0-56.5-23.5T320-206v-142q-59-39-94.5-103T190-590q0-121 84.5-205.5T480-880q121 0 205.5 84.5T770-590q0 77-35.5 140T640-348v142q0 33-23.5 56.5T560-126q-12 21-33 33.5T480-80Zm-80-126h160v-36H400v36Zm0-76h160v-38H400v38Zm-8-118h58v-108l-88-88 42-42 76 76 76-76 42 42-88 88v108h58q54-26 88-76.5T690-590q0-88-61-149t-149-61q-88 0-149 61t-61 149q0 63 34 113.5t88 76.5Zm88-162Zm0-38Z"/></svg>

After

Width:  |  Height:  |  Size: 534 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M360-80v-529q-91-24-145.5-100.5T160-880h80q0 83 53.5 141.5T430-680h100q30 0 56 11t47 32l181 181-56 56-158-158v478h-80v-240h-80v240h-80Zm120-640q-33 0-56.5-23.5T400-800q0-33 23.5-56.5T480-880q33 0 56.5 23.5T560-800q0 33-23.5 56.5T480-720Z"/></svg>

After

Width:  |  Height:  |  Size: 363 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M620-520q25 0 42.5-17.5T680-580q0-25-17.5-42.5T620-640q-25 0-42.5 17.5T560-580q0 25 17.5 42.5T620-520Zm-280 0q25 0 42.5-17.5T400-580q0-25-17.5-42.5T340-640q-25 0-42.5 17.5T280-580q0 25 17.5 42.5T340-520Zm140 260q68 0 123.5-38.5T684-400H276q25 63 80.5 101.5T480-260Zm0 180q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-400Zm0 320q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Z"/></svg>

After

Width:  |  Height:  |  Size: 676 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M120-800v-80h320v80H120Zm120 280v-160H120v-80h320v80H320v160h-80ZM548-96l-56-56 312-312 56 56L548-96Zm32-224q-26 0-43-17t-17-43q0-26 17-43t43-17q26 0 43 17t17 43q0 26-17 43t-43 17Zm200 200q-26 0-43-17t-17-43q0-26 17-43t43-17q26 0 43 17t17 43q0 26-17 43t-43 17ZM620-520q-41 0-70.5-29.5T520-620q0-41 29.5-71.5T620-722q12 0 21.5 1.5T660-716v-124q0-17 11.5-28.5T700-880h140v80H720v180q0 41-29.5 70.5T620-520ZM220-80q-41 0-70.5-30.5T120-182q0-18 7.5-36.5T150-252l42-42-14-14q-15-15-22.5-32.5T148-378q0-41 29.5-70.5T248-478q41 0 70.5 29.5T348-378q0 20-6.5 37.5T320-308l-14 14 28 28 56-56 56 58-56 56 56 56-56 56-56-56-42 42q-15 15-33.5 22.5T220-80Zm28-270 14-14q3-3 4.5-6t1.5-8q0-9-6-14.5t-14-5.5q-8 0-14 5.5t-6 14.5q0 3 1.5 7t4.5 7l14 14Zm-30 190q3 0 8-1.5t8-4.5l44-42-28-28-44 42q-3 3-4.5 7t-1.5 9q0 8 5 13t13 5Z"/></svg>

After

Width:  |  Height:  |  Size: 934 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M400-106v-228l56-160q5-11 14.5-18.5T494-520h292q14 0 24 7.5t14 18.5l56 160v228q0 11-7.5 18.5T854-80h-28q-11 0-18.5-7.5T800-106v-34H480v34q0 11-7.5 18.5T454-80h-28q-11 0-18.5-7.5T400-106Zm80-274h320l-28-80H508l-28 80Zm-20 60v120-120Zm60 100q17 0 28.5-11.5T560-260q0-17-11.5-28.5T520-300q-17 0-28.5 11.5T480-260q0 17 11.5 28.5T520-220Zm240 0q17 0 28.5-11.5T800-260q0-17-11.5-28.5T760-300q-17 0-28.5 11.5T720-260q0 17 11.5 28.5T760-220ZM240-400v-80h80v80h-80Zm200-240v-80h80v80h-80ZM240-240v-80h80v80h-80Zm0 160v-80h80v80h-80ZM80-80v-560h200v-240h400v280h-80v-200H360v240H160v480H80Zm380-120h360v-120H460v120Z"/></svg>

After

Width:  |  Height:  |  Size: 732 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m612-292 56-56-148-148v-184h-80v216l172 172ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-400Zm0 320q133 0 226.5-93.5T800-480q0-133-93.5-226.5T480-800q-133 0-226.5 93.5T160-480q0 133 93.5 226.5T480-160Z"/></svg>

After

Width:  |  Height:  |  Size: 474 B

1
web/src/icons/search.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>

After

Width:  |  Height:  |  Size: 377 B

View file

@ -27,6 +27,8 @@ import type {
} from "@/api/types"
import useEvent from "@/util/useEvent.ts"
import { ClientContext } from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomcontext.ts"
import { ReplyBody } from "../timeline/ReplyBody.tsx"
import { useMediaContent } from "../timeline/content/useMediaContent.tsx"
@ -34,6 +36,7 @@ import type { AutocompleteQuery } from "./Autocompleter.tsx"
import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./getAutocompleter.ts"
import AttachIcon from "@/icons/attach.svg?react"
import CloseIcon from "@/icons/close.svg?react"
import EmojiIcon from "@/icons/emoji-categories/smileys-emotion.svg?react"
import SendIcon from "@/icons/send.svg?react"
import "./MessageComposer.css"
@ -73,12 +76,14 @@ const MessageComposer = () => {
const roomCtx = useRoomContext()
const room = roomCtx.store
const client = use(ClientContext)!
const openModal = use(ModalContext)
const [autocomplete, setAutocomplete] = useState<AutocompleteQuery | null>(null)
const [state, setState] = useReducer(composerReducer, uninitedComposer)
const [editing, rawSetEditing] = useState<MemDBEvent | null>(null)
const [loadingMedia, setLoadingMedia] = useState(false)
const fileInput = useRef<HTMLInputElement>(null)
const textInput = useRef<HTMLTextAreaElement>(null)
const composerRef = useRef<HTMLDivElement>(null)
const textRows = useRef(1)
const typingSentAt = useRef(0)
const replyToEvt = useRoomEvent(room, state.replyTo)
@ -308,8 +313,23 @@ const MessageComposer = () => {
evt.stopPropagation()
roomCtx.setEditing(null)
}, [roomCtx])
const openEmojiPicker = useEvent(() => {
openModal({
content: <EmojiPicker
style={{ bottom: (composerRef.current?.clientHeight ?? 32) + 2, right: "1rem" }}
onSelect={emoji => {
setState({
text: state.text.slice(0, textInput.current?.selectionStart ?? 0)
+ emoji.u
+ state.text.slice(textInput.current?.selectionEnd ?? 0),
})
}}
/>,
onClose: () => textInput.current?.focus(),
})
})
const Autocompleter = getAutocompleter(autocomplete)
return <div className="message-composer">
return <div className="message-composer" ref={composerRef}>
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
params={autocomplete}
room={room}
@ -345,6 +365,7 @@ const MessageComposer = () => {
placeholder="Send a message"
id="message-composer"
/>
<button onClick={openEmojiPicker}><EmojiIcon/></button>
<button
onClick={openFilePicker}
disabled={!!state.media || loadingMedia}

View file

@ -0,0 +1,130 @@
div.emoji-picker {
position: fixed;
background-color: white;
width: 22rem;
height: 30rem;
border-radius: 1rem;
border: 1px solid #ccc;
display: flex;
flex-direction: column;
div.emoji-category-bar {
height: 2.5rem;
display: flex;
justify-content: center;
flex-wrap: wrap;
padding-top: .5rem;
border-bottom: 1px solid #ccc;
> button {
padding-top: .25rem;
width: 2.125rem;
height: 2.5rem;
box-sizing: border-box;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-bottom: 2px solid transparent;
&:hover {
border-bottom: 2px solid green;
}
}
}
div.emoji-search {
display: flex;
align-items: center;
margin: .5rem;
border: 1px solid #ccc;
border-radius: .25rem;
height: 2rem;
> input {
flex: 1;
padding: .5rem;
border: none;
outline: none;
border-radius: .25rem;
}
> button {
width: 2rem;
height: 2rem;
padding: .25rem;
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
}
div.emoji-list {
overflow: scroll;
padding: 0 1rem;
flex: 1;
display: flex;
flex-direction: column;
}
div.emoji-preview {
height: 4.5rem;
border-top: 1px solid #ccc;
display: grid;
grid-template:
"big name" 1fr
"big shortcode" 1fr
/ 5rem 1fr;
> div.big-emoji {
grid-area: big;
font-size: 2.5rem;
display: flex;
justify-content: center;
align-items: center;
}
> div.emoji-name {
grid-area: name;
font-weight: bold;
display: flex;
align-items: end;
}
> div.emoji-shortcode {
grid-area: shortcode;
color: #666;
}
}
div.emoji-category {
width: 100%;
}
div.emoji-category-list {
display: flex;
flex-wrap: wrap;
width: 100%;
}
h4.emoji-category-name {
margin: 0;
}
button.emoji {
font-size: 1.25rem;
padding: 0;
width: 2.5rem;
height: 2.5rem;
content-visibility: auto;
&:hover {
background-color: #ccc;
}
}
button.freeform-react {
width: 100%;
padding: .25rem;
margin-top: auto;
margin-bottom: .25rem;
}
}

View file

@ -0,0 +1,143 @@
// 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 <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 { ModalCloseContext } from "../modal/Modal.tsx"
import CloseIcon from "@/icons/close.svg?react"
import ActivitiesIcon from "@/icons/emoji-categories/activities.svg?react"
import AnimalsNatureIcon from "@/icons/emoji-categories/animals-nature.svg?react"
import FlagsIcon from "@/icons/emoji-categories/flags.svg?react"
import FoodBeverageIcon from "@/icons/emoji-categories/food-beverage.svg?react"
import ObjectsIcon from "@/icons/emoji-categories/objects.svg?react"
import PeopleBodyIcon from "@/icons/emoji-categories/people-body.svg?react"
import SmileysEmotionIcon from "@/icons/emoji-categories/smileys-emotion.svg?react"
import SymbolsIcon from "@/icons/emoji-categories/symbols.svg?react"
import TravelPlacesIcon from "@/icons/emoji-categories/travel-places.svg?react"
import RecentIcon from "@/icons/schedule.svg?react"
import SearchIcon from "@/icons/search.svg?react"
import "./EmojiPicker.css"
interface EmojiCategory {
index: number
name?: string
icon: JSX.Element
}
const sortedEmojiCategories: EmojiCategory[] = [
{ index: 7, icon: <SmileysEmotionIcon/> },
{ index: 6, icon: <PeopleBodyIcon/> },
{ index: 1, icon: <AnimalsNatureIcon/> },
{ index: 4, icon: <FoodBeverageIcon/> },
{ index: 9, icon: <TravelPlacesIcon/> },
{ index: 0, icon: <ActivitiesIcon/> },
{ index: 5, icon: <ObjectsIcon/> },
{ index: 8, icon: <SymbolsIcon/> },
{ index: 3, icon: <FlagsIcon/> },
]
function renderEmoji(emoji: Emoji): JSX.Element | string {
if (emoji.u.startsWith("mxc://")) {
return <img src={getMediaURL(emoji.u)} alt={`:${emoji.n}:`}/>
}
return emoji.u
}
interface EmojiPickerProps {
style: CSSProperties
onSelect: (emoji: Partial<Emoji>) => void
allowFreeform?: boolean
closeOnSelect?: boolean
}
export const EmojiPicker = ({ style, 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 currentCatNum: number | string = -1
const close = use(ModalCloseContext)
const onSelectWrapped = (emoji: Partial<Emoji>) => {
onSelect(emoji)
if (closeOnSelect) {
close()
}
}
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>
<div className="emoji-category-list">
{currentCat}
</div>
</div>)
}
currentCatNum = emoji.c
currentCat = []
}
currentCat.push(<button
key={emoji.u}
className="emoji"
onMouseOver={() => setPreviewEmoji(emoji)}
onMouseOut={() => setPreviewEmoji(undefined)}
onClick={() => onSelectWrapped(emoji)}
>{renderEmoji(emoji)}</button>)
}
return <div className="emoji-picker" style={style}>
<div className="emoji-category-bar">
<button
className="emoji-category-icon"
title={"Recently used"}
>{<RecentIcon/>}</button>
{sortedEmojiCategories.map(cat =>
<button
key={cat.index}
className="emoji-category-icon"
title={cat.name ?? categories[cat.index]}
>{cat.icon}</button>,
)}
</div>
<div className="emoji-search">
<input autoFocus onChange={onChangeQuery} value={query} type="search" placeholder="Search emojis"/>
<button onClick={clearQuery} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button>
</div>
<div className="emoji-list">
{cats}
{allowFreeform && query && <button
className="freeform-react"
onClick={() => onSelectWrapped({ u: query })}
>React with "{query}"</button>}
</div>
{previewEmoji ? <div className="emoji-preview">
<div className="big-emoji">{renderEmoji(previewEmoji)}</div>
<div className="emoji-name">{previewEmoji.t}</div>
<div className="emoji-shortcode">:{previewEmoji.n}:</div>
</div> : <div className="emoji-preview"/>}
</div>
}
export default EmojiPicker

View file

@ -27,9 +27,14 @@ type openModal = (state: ModalState) => void
export const ModalContext = createContext<openModal>(() =>
console.error("Tried to open modal without being inside context"))
export const ModalCloseContext = createContext<() => void>(() => {})
export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
const [state, setState] = useState<ModalState | null>(null)
const onClose = useCallback(() => {
const onClickWrapper = useCallback((evt?: React.MouseEvent) => {
if (evt && evt.target !== evt.currentTarget) {
return
}
setState(null)
state?.onClose?.()
}, [state])
@ -39,9 +44,11 @@ export const ModalWrapper = ({ children }: { children: React.ReactNode }) => {
</ModalContext>
{state && <div
className={`overlay ${state.wrapperClass ?? "modal"} ${state.dimmed ? "dimmed" : ""}`}
onClick={onClose}
onClick={onClickWrapper}
>
<ModalCloseContext value={onClickWrapper}>
{state.content}
</ModalCloseContext>
</div>}
</>
}

View file

@ -13,11 +13,13 @@
//
// 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 { use, useCallback } from "react"
import { CSSProperties, use, useCallback, useRef } from "react"
import { useRoomState } from "@/api/statestore"
import { MemDBEvent, PowerLevelEventContent } from "@/api/types"
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
import { ClientContext } from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomcontext.ts"
import EditIcon from "../../icons/edit.svg?react"
import MoreIcon from "../../icons/more.svg?react"
@ -29,12 +31,15 @@ import "./EventMenu.css"
interface EventHoverMenuProps {
evt: MemDBEvent
setForceOpen: (forceOpen: boolean) => void
}
const EventMenu = ({ evt }: EventHoverMenuProps) => {
const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
const client = use(ClientContext)!
const userID = client.userID
const roomCtx = useRoomContext()
const openModal = use(ModalContext)
const contextMenuRef = useRef<HTMLDivElement>(null)
const onClickReply = useCallback(() => roomCtx.setReplyTo(evt.event_id), [roomCtx, evt.event_id])
const onClickPin = useCallback(() => {
client.pinMessage(roomCtx.store, evt.event_id, true)
@ -45,8 +50,42 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => {
.catch(err => window.alert(`Failed to unpin message: ${err}`))
}, [client, roomCtx, evt.event_id])
const onClickReact = useCallback(() => {
window.alert("No reactions yet :(")
}, [])
const reactionButton = contextMenuRef.current?.getElementsByClassName("reaction-button")?.[0]
if (!reactionButton) {
return
}
const rect = reactionButton.getBoundingClientRect()
const style: CSSProperties = { right: window.innerWidth - rect.right }
const emojiPickerHeight = 30 * 16
if (rect.bottom + emojiPickerHeight > window.innerHeight) {
style.bottom = window.innerHeight - rect.top
} else {
style.top = rect.bottom
}
setForceOpen(true)
openModal({
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)
.catch(err => window.alert(`Failed to send reaction: ${err}`))
}}
closeOnSelect={true}
allowFreeform={true}
/>,
onClose: () => setForceOpen(false),
})
}, [client, evt, setForceOpen, openModal])
const onClickEdit = useCallback(() => {
roomCtx.setEditing(evt)
}, [roomCtx, evt])
@ -61,19 +100,20 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => {
const pins = roomCtx.store.getPinnedEvents()
const ownPL = pls.users?.[userID] ?? pls.users_default ?? 0
const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50
return <div className="context-menu">
<button onClick={onClickReact}><ReactIcon/></button>
return <div className="context-menu" ref={contextMenuRef}>
<button className="reaction-button" onClick={onClickReact}><ReactIcon/></button>
<button
className="reply-button"
disabled={isEditing}
title={isEditing ? "Can't reply to messages while editing a message" : undefined}
onClick={onClickReply}
><ReplyIcon/></button>
{evt.sender === userID && evt.type === "m.room.message" && evt.relation_type !== "m.replace"
&& <button onClick={onClickEdit}><EditIcon/></button>}
&& <button className="edit-button" onClick={onClickEdit}><EditIcon/></button>}
{ownPL >= pinPL && (pins.includes(evt.event_id)
? <button onClick={onClickUnpin}><UnpinIcon/></button>
: <button onClick={onClickPin}><PinIcon/></button>)}
<button onClick={onClickMore}><MoreIcon/></button>
? <button className="unpin-button" onClick={onClickUnpin}><UnpinIcon/></button>
: <button className="pin-button" onClick={onClickPin}><PinIcon/></button>)}
<button className="more-button" onClick={onClickMore}><MoreIcon/></button>
</div>
}

View file

@ -103,7 +103,7 @@ div.timeline-event {
display: none;
}
&:hover > div.context-menu-container {
&:hover > div.context-menu-container, > div.context-menu-container.force-open {
display: block;
}

View file

@ -13,7 +13,7 @@
//
// 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 React, { use } from "react"
import React, { use, useState } from "react"
import { getAvatarURL, getMediaURL } from "@/api/media.ts"
import { useRoomState } from "@/api/statestore"
import { MemDBEvent, MemberEventContent, UnreadType } from "@/api/types"
@ -70,6 +70,7 @@ function isSmallEvent(bodyType: React.FunctionComponent<EventContentProps>): boo
const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => {
const roomCtx = useRoomContext()
const client = use(ClientContext)!
const [forceContextMenuOpen, setForceContextMenuOpen] = useState(false)
const memberEvt = useRoomState(roomCtx.store, "m.room.member", evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
const BodyType = getBodyType(evt)
@ -95,8 +96,8 @@ const TimelineEvent = ({ evt, prevEvt }: TimelineEventProps) => {
const relatesTo = (evt.orig_content ?? evt.content)?.["m.relates_to"]
const replyTo = relatesTo?.["m.in_reply_to"]?.event_id
const mainEvent = <div data-event-id={evt.event_id} className={wrapperClassNames.join(" ")}>
<div className="context-menu-container">
<EventMenu evt={evt}/>
<div className={`context-menu-container ${forceContextMenuOpen ? "force-open" : ""}`}>
<EventMenu evt={evt} setForceOpen={setForceContextMenuOpen}/>
</div>
<div className="sender-avatar" title={evt.sender}>
<img

View file

@ -17,8 +17,8 @@ import { useRef } from "react"
import data from "./data.json"
export interface Emoji {
u: string // Unicode codepoint
c: number // Category number
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