web/timeline,composer: add emoji picker for composer and reaction sending
|
@ -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) {
|
||||
|
|
1
web/src/icons/emoji-categories/activities.svg
Normal 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 |
1
web/src/icons/emoji-categories/animals-nature.svg
Normal 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 |
1
web/src/icons/emoji-categories/flags.svg
Normal 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 |
1
web/src/icons/emoji-categories/food-beverage.svg
Normal 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 |
1
web/src/icons/emoji-categories/objects.svg
Normal 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 |
1
web/src/icons/emoji-categories/people-body.svg
Normal 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 |
1
web/src/icons/emoji-categories/smileys-emotion.svg
Normal 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 |
1
web/src/icons/emoji-categories/symbols.svg
Normal 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 |
1
web/src/icons/emoji-categories/travel-places.svg
Normal 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 |
1
web/src/icons/schedule.svg
Normal 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
|
@ -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 |
|
@ -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}
|
||||
|
|
130
web/src/ui/emojipicker/EmojiPicker.css
Normal 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;
|
||||
}
|
||||
}
|
143
web/src/ui/emojipicker/EmojiPicker.tsx
Normal 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
|
|
@ -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}
|
||||
>
|
||||
{state.content}
|
||||
<ModalCloseContext value={onClickWrapper}>
|
||||
{state.content}
|
||||
</ModalCloseContext>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|