mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/composer: add edit support
This commit is contained in:
parent
a66c70c241
commit
cc0067bb3f
7 changed files with 97 additions and 18 deletions
|
@ -77,9 +77,15 @@ func (h *HiClient) SendMessage(
|
||||||
if relatesTo.Type == event.RelReplace {
|
if relatesTo.Type == event.RelReplace {
|
||||||
contentCopy := content
|
contentCopy := content
|
||||||
content = event.MessageEventContent{
|
content = event.MessageEventContent{
|
||||||
|
Body: "",
|
||||||
|
MsgType: contentCopy.MsgType,
|
||||||
|
URL: contentCopy.URL,
|
||||||
NewContent: &contentCopy,
|
NewContent: &contentCopy,
|
||||||
RelatesTo: relatesTo,
|
RelatesTo: relatesTo,
|
||||||
}
|
}
|
||||||
|
if contentCopy.File != nil {
|
||||||
|
content.URL = contentCopy.File.URL
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
content.RelatesTo = relatesTo
|
content.RelatesTo = relatesTo
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,7 +16,15 @@
|
||||||
import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
|
import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
|
||||||
import { ScaleLoader } from "react-spinners"
|
import { ScaleLoader } from "react-spinners"
|
||||||
import { useRoomEvent } from "@/api/statestore"
|
import { useRoomEvent } from "@/api/statestore"
|
||||||
import type { EventID, MediaMessageEventContent, Mentions, RelatesTo, RoomID } from "@/api/types"
|
import type {
|
||||||
|
EventID,
|
||||||
|
MediaMessageEventContent,
|
||||||
|
MemDBEvent,
|
||||||
|
Mentions,
|
||||||
|
MessageEventContent,
|
||||||
|
RelatesTo,
|
||||||
|
RoomID,
|
||||||
|
} from "@/api/types"
|
||||||
import useEvent from "@/util/useEvent.ts"
|
import useEvent from "@/util/useEvent.ts"
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
import { ClientContext } from "../ClientContext.ts"
|
||||||
import { useRoomContext } from "../roomcontext.ts"
|
import { useRoomContext } from "../roomcontext.ts"
|
||||||
|
@ -67,6 +75,7 @@ const MessageComposer = () => {
|
||||||
const client = use(ClientContext)!
|
const client = use(ClientContext)!
|
||||||
const [autocomplete, setAutocomplete] = useState<AutocompleteQuery | null>(null)
|
const [autocomplete, setAutocomplete] = useState<AutocompleteQuery | null>(null)
|
||||||
const [state, setState] = useReducer(composerReducer, uninitedComposer)
|
const [state, setState] = useReducer(composerReducer, uninitedComposer)
|
||||||
|
const [editing, rawSetEditing] = useState<MemDBEvent | null>(null)
|
||||||
const [loadingMedia, setLoadingMedia] = useState(false)
|
const [loadingMedia, setLoadingMedia] = useState(false)
|
||||||
const fileInput = useRef<HTMLInputElement>(null)
|
const fileInput = useRef<HTMLInputElement>(null)
|
||||||
const textInput = useRef<HTMLTextAreaElement>(null)
|
const textInput = useRef<HTMLTextAreaElement>(null)
|
||||||
|
@ -77,19 +86,48 @@ const MessageComposer = () => {
|
||||||
setState({ replyTo: evt })
|
setState({ replyTo: evt })
|
||||||
textInput.current?.focus()
|
textInput.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
|
roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => {
|
||||||
|
if (evt === null) {
|
||||||
|
rawSetEditing(null)
|
||||||
|
setState(emptyComposer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (state.text || state.media) {
|
||||||
|
// TODO save as draft instead of discarding?
|
||||||
|
const ok = window.confirm("Discard current message to start editing?")
|
||||||
|
if (!ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const evtContent = evt.content as MessageEventContent
|
||||||
|
const mediaMsgTypes = ["m.image", "m.audio", "m.video", "m.file"]
|
||||||
|
const isMedia = mediaMsgTypes.includes(evtContent.msgtype) && (evt.content?.url || evt.content?.file?.url)
|
||||||
|
rawSetEditing(evt)
|
||||||
|
setState({
|
||||||
|
media: isMedia ? evtContent as MediaMessageEventContent : null,
|
||||||
|
text: (!evt.content.filename || evt.content.filename !== evt.content.body) ? (evtContent.body ?? "") : "",
|
||||||
|
})
|
||||||
|
textInput.current?.focus()
|
||||||
|
}, [state])
|
||||||
const sendMessage = useEvent((evt: React.FormEvent) => {
|
const sendMessage = useEvent((evt: React.FormEvent) => {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
if (state.text === "" && !state.media) {
|
if (state.text === "" && !state.media) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setState(emptyComposer)
|
setState(emptyComposer)
|
||||||
|
rawSetEditing(null)
|
||||||
setAutocomplete(null)
|
setAutocomplete(null)
|
||||||
const mentions: Mentions = {
|
const mentions: Mentions = {
|
||||||
user_ids: [],
|
user_ids: [],
|
||||||
room: false,
|
room: false,
|
||||||
}
|
}
|
||||||
let relates_to: RelatesTo | undefined = undefined
|
let relates_to: RelatesTo | undefined = undefined
|
||||||
if (replyToEvt) {
|
if (editing) {
|
||||||
|
relates_to = {
|
||||||
|
rel_type: "m.replace",
|
||||||
|
event_id: editing.event_id,
|
||||||
|
}
|
||||||
|
} else if (replyToEvt) {
|
||||||
mentions.user_ids.push(replyToEvt.sender)
|
mentions.user_ids.push(replyToEvt.sender)
|
||||||
relates_to = {
|
relates_to = {
|
||||||
"m.in_reply_to": {
|
"m.in_reply_to": {
|
||||||
|
@ -229,7 +267,8 @@ const MessageComposer = () => {
|
||||||
}, [state, roomCtx])
|
}, [state, roomCtx])
|
||||||
// Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect.
|
// Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state.uninited) {
|
roomCtx.isEditing.emit(editing !== null)
|
||||||
|
if (state.uninited || editing) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!state.text && !state.media && !state.replyTo) {
|
if (!state.text && !state.media && !state.replyTo) {
|
||||||
|
@ -237,13 +276,17 @@ const MessageComposer = () => {
|
||||||
} else {
|
} else {
|
||||||
draftStore.set(room.roomID, state)
|
draftStore.set(room.roomID, state)
|
||||||
}
|
}
|
||||||
}, [room, state])
|
}, [roomCtx, room, state, editing])
|
||||||
const openFilePicker = useCallback(() => fileInput.current!.click(), [])
|
const openFilePicker = useCallback(() => fileInput.current!.click(), [])
|
||||||
const clearMedia = useCallback(() => setState({ media: null }), [])
|
const clearMedia = useCallback(() => setState({ media: null }), [])
|
||||||
const closeReply = useCallback((evt: React.MouseEvent) => {
|
const closeReply = useCallback((evt: React.MouseEvent) => {
|
||||||
evt.stopPropagation()
|
evt.stopPropagation()
|
||||||
setState({ replyTo: null })
|
setState({ replyTo: null })
|
||||||
}, [])
|
}, [])
|
||||||
|
const stopEditing = useCallback((evt: React.MouseEvent) => {
|
||||||
|
evt.stopPropagation()
|
||||||
|
roomCtx.setEditing(null)
|
||||||
|
}, [roomCtx])
|
||||||
const Autocompleter = getAutocompleter(autocomplete)
|
const Autocompleter = getAutocompleter(autocomplete)
|
||||||
return <div className="message-composer">
|
return <div className="message-composer">
|
||||||
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
|
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
|
||||||
|
@ -259,6 +302,13 @@ const MessageComposer = () => {
|
||||||
onClose={closeReply}
|
onClose={closeReply}
|
||||||
isThread={replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread"}
|
isThread={replyToEvt.content?.["m.relates_to"]?.rel_type === "m.thread"}
|
||||||
/>}
|
/>}
|
||||||
|
{editing && <ReplyBody
|
||||||
|
room={room}
|
||||||
|
event={editing}
|
||||||
|
isEditing={true}
|
||||||
|
isThread={false}
|
||||||
|
onClose={stopEditing}
|
||||||
|
/>}
|
||||||
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
|
{loadingMedia && <div className="composer-media"><ScaleLoader/></div>}
|
||||||
{state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>}
|
{state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>}
|
||||||
<div className="input-area">
|
<div className="input-area">
|
||||||
|
|
|
@ -15,7 +15,8 @@
|
||||||
// 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 { RefObject, createContext, createRef, use } from "react"
|
import { RefObject, createContext, createRef, use } from "react"
|
||||||
import { RoomStateStore } from "@/api/statestore"
|
import { RoomStateStore } from "@/api/statestore"
|
||||||
import { EventID } from "@/api/types"
|
import { EventID, MemDBEvent } from "@/api/types"
|
||||||
|
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
|
||||||
|
|
||||||
const noop = (name: string) => () => {
|
const noop = (name: string) => () => {
|
||||||
console.warn(`${name} called before initialization`)
|
console.warn(`${name} called before initialization`)
|
||||||
|
@ -24,6 +25,8 @@ const noop = (name: string) => () => {
|
||||||
export class RoomContextData {
|
export class RoomContextData {
|
||||||
public timelineBottomRef: RefObject<HTMLDivElement | null> = createRef()
|
public timelineBottomRef: RefObject<HTMLDivElement | null> = createRef()
|
||||||
public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo")
|
public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo")
|
||||||
|
public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing")
|
||||||
|
public isEditing = new NonNullCachedEventDispatcher<boolean>(false)
|
||||||
|
|
||||||
constructor(public store: RoomStateStore) {}
|
constructor(public store: RoomStateStore) {}
|
||||||
|
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
import { use, useCallback } from "react"
|
import { use, useCallback } 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 { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
import { ClientContext } from "../ClientContext.ts"
|
import { ClientContext } from "../ClientContext.ts"
|
||||||
import { useRoomContext } from "../roomcontext.ts"
|
import { useRoomContext } from "../roomcontext.ts"
|
||||||
import EditIcon from "../../icons/edit.svg?react"
|
import EditIcon from "../../icons/edit.svg?react"
|
||||||
|
@ -47,11 +48,12 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => {
|
||||||
window.alert("No reactions yet :(")
|
window.alert("No reactions yet :(")
|
||||||
}, [])
|
}, [])
|
||||||
const onClickEdit = useCallback(() => {
|
const onClickEdit = useCallback(() => {
|
||||||
window.alert("No edits yet :(")
|
roomCtx.setEditing(evt)
|
||||||
}, [])
|
}, [roomCtx, evt])
|
||||||
const onClickMore = useCallback(() => {
|
const onClickMore = useCallback(() => {
|
||||||
window.alert("Nothing here yet :(")
|
window.alert("Nothing here yet :(")
|
||||||
}, [])
|
}, [])
|
||||||
|
const isEditing = useNonNullEventAsState(roomCtx.isEditing)
|
||||||
const plEvent = useRoomState(roomCtx.store, "m.room.power_levels", "")
|
const plEvent = useRoomState(roomCtx.store, "m.room.power_levels", "")
|
||||||
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
|
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
|
||||||
useRoomState(roomCtx.store, "m.room.pinned_events", "")
|
useRoomState(roomCtx.store, "m.room.pinned_events", "")
|
||||||
|
@ -61,8 +63,12 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => {
|
||||||
const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50
|
const pinPL = pls.events?.["m.room.pinned_events"] ?? pls.state_default ?? 50
|
||||||
return <div className="context-menu">
|
return <div className="context-menu">
|
||||||
<button onClick={onClickReact}><ReactIcon/></button>
|
<button onClick={onClickReact}><ReactIcon/></button>
|
||||||
<button onClick={onClickReply}><ReplyIcon/></button>
|
<button
|
||||||
<button onClick={onClickEdit}><EditIcon/></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" && <button onClick={onClickEdit}><EditIcon/></button>}
|
||||||
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
{ownPL >= pinPL && (pins.includes(evt.event_id)
|
||||||
? <button onClick={onClickUnpin}><UnpinIcon/></button>
|
? <button onClick={onClickUnpin}><UnpinIcon/></button>
|
||||||
: <button onClick={onClickPin}><PinIcon/></button>)}
|
: <button onClick={onClickPin}><PinIcon/></button>)}
|
||||||
|
|
|
@ -33,6 +33,11 @@ blockquote.reply-body {
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.editing > div.reply-sender > span.event-sender::after {
|
||||||
|
content: " (editing message)";
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
> div.reply-sender {
|
> div.reply-sender {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
@ -26,6 +26,7 @@ interface ReplyBodyProps {
|
||||||
room: RoomStateStore
|
room: RoomStateStore
|
||||||
event: MemDBEvent
|
event: MemDBEvent
|
||||||
isThread: boolean
|
isThread: boolean
|
||||||
|
isEditing?: boolean
|
||||||
onClose?: (evt: React.MouseEvent) => void
|
onClose?: (evt: React.MouseEvent) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,15 +65,21 @@ const onClickReply = (evt: React.MouseEvent) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReplyBody = ({ room, event, onClose, isThread }: ReplyBodyProps) => {
|
export const ReplyBody = ({ room, event, onClose, isThread, isEditing }: ReplyBodyProps) => {
|
||||||
const memberEvt = useRoomState(room, "m.room.member", event.sender)
|
const memberEvt = useRoomState(room, "m.room.member", event.sender)
|
||||||
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
|
||||||
const BodyType = getBodyType(event, true)
|
const BodyType = getBodyType(event, true)
|
||||||
return <blockquote
|
const classNames = ["reply-body"]
|
||||||
data-reply-to={event.event_id}
|
if (onClose) {
|
||||||
className={`reply-body ${onClose ? "composer" : ""} ${isThread ? "thread" : ""}`}
|
classNames.push("composer")
|
||||||
onClick={onClickReply}
|
}
|
||||||
>
|
if (isThread) {
|
||||||
|
classNames.push("thread")
|
||||||
|
}
|
||||||
|
if (isEditing) {
|
||||||
|
classNames.push("editing")
|
||||||
|
}
|
||||||
|
return <blockquote data-reply-to={event.event_id} className={classNames.join(" ")} onClick={onClickReply}>
|
||||||
<div className="reply-sender">
|
<div className="reply-sender">
|
||||||
<div className="sender-avatar" title={event.sender}>
|
<div className="sender-avatar" title={event.sender}>
|
||||||
<img
|
<img
|
||||||
|
|
|
@ -70,9 +70,11 @@ export class CachedEventDispatcher<T> extends EventDispatcher<T> {
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(data: T) {
|
emit(data: T) {
|
||||||
|
if (!Object.is(this.current, data)) {
|
||||||
this.current = data
|
this.current = data
|
||||||
super.emit(data)
|
super.emit(data)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
listen(listener: (data: T) => void): () => void {
|
listen(listener: (data: T) => void): () => void {
|
||||||
const unlisten = super.listen(listener)
|
const unlisten = super.listen(listener)
|
||||||
|
|
Loading…
Add table
Reference in a new issue