web/composer: add edit support

This commit is contained in:
Tulir Asokan 2024-10-25 03:03:15 +03:00
parent a66c70c241
commit cc0067bb3f
7 changed files with 97 additions and 18 deletions

View file

@ -77,9 +77,15 @@ func (h *HiClient) SendMessage(
if relatesTo.Type == event.RelReplace {
contentCopy := content
content = event.MessageEventContent{
Body: "",
MsgType: contentCopy.MsgType,
URL: contentCopy.URL,
NewContent: &contentCopy,
RelatesTo: relatesTo,
}
if contentCopy.File != nil {
content.URL = contentCopy.File.URL
}
} else {
content.RelatesTo = relatesTo
}

View file

@ -16,7 +16,15 @@
import React, { use, useCallback, useEffect, useLayoutEffect, useReducer, useRef, useState } from "react"
import { ScaleLoader } from "react-spinners"
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 { ClientContext } from "../ClientContext.ts"
import { useRoomContext } from "../roomcontext.ts"
@ -67,6 +75,7 @@ const MessageComposer = () => {
const client = use(ClientContext)!
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)
@ -77,19 +86,48 @@ const MessageComposer = () => {
setState({ replyTo: evt })
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) => {
evt.preventDefault()
if (state.text === "" && !state.media) {
return
}
setState(emptyComposer)
rawSetEditing(null)
setAutocomplete(null)
const mentions: Mentions = {
user_ids: [],
room: false,
}
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)
relates_to = {
"m.in_reply_to": {
@ -229,7 +267,8 @@ const MessageComposer = () => {
}, [state, roomCtx])
// Saving to localStorage could be done in the reducer, but that's not very proper, so do it in an effect.
useEffect(() => {
if (state.uninited) {
roomCtx.isEditing.emit(editing !== null)
if (state.uninited || editing) {
return
}
if (!state.text && !state.media && !state.replyTo) {
@ -237,13 +276,17 @@ const MessageComposer = () => {
} else {
draftStore.set(room.roomID, state)
}
}, [room, state])
}, [roomCtx, room, state, editing])
const openFilePicker = useCallback(() => fileInput.current!.click(), [])
const clearMedia = useCallback(() => setState({ media: null }), [])
const closeReply = useCallback((evt: React.MouseEvent) => {
evt.stopPropagation()
setState({ replyTo: null })
}, [])
const stopEditing = useCallback((evt: React.MouseEvent) => {
evt.stopPropagation()
roomCtx.setEditing(null)
}, [roomCtx])
const Autocompleter = getAutocompleter(autocomplete)
return <div className="message-composer">
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
@ -259,6 +302,13 @@ const MessageComposer = () => {
onClose={closeReply}
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>}
{state.media && <ComposerMedia content={state.media} clearMedia={clearMedia}/>}
<div className="input-area">

View file

@ -15,7 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { RefObject, createContext, createRef, use } from "react"
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) => () => {
console.warn(`${name} called before initialization`)
@ -24,6 +25,8 @@ const noop = (name: string) => () => {
export class RoomContextData {
public timelineBottomRef: RefObject<HTMLDivElement | null> = createRef()
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) {}

View file

@ -16,6 +16,7 @@
import { use, useCallback } 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 { useRoomContext } from "../roomcontext.ts"
import EditIcon from "../../icons/edit.svg?react"
@ -47,11 +48,12 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => {
window.alert("No reactions yet :(")
}, [])
const onClickEdit = useCallback(() => {
window.alert("No edits yet :(")
}, [])
roomCtx.setEditing(evt)
}, [roomCtx, evt])
const onClickMore = useCallback(() => {
window.alert("Nothing here yet :(")
}, [])
const isEditing = useNonNullEventAsState(roomCtx.isEditing)
const plEvent = useRoomState(roomCtx.store, "m.room.power_levels", "")
// We get pins from getPinnedEvents, but use the hook anyway to subscribe to changes
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
return <div className="context-menu">
<button onClick={onClickReact}><ReactIcon/></button>
<button onClick={onClickReply}><ReplyIcon/></button>
<button onClick={onClickEdit}><EditIcon/></button>
<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)
? <button onClick={onClickUnpin}><UnpinIcon/></button>
: <button onClick={onClickPin}><PinIcon/></button>)}

View file

@ -33,6 +33,11 @@ blockquote.reply-body {
color: #666;
}
&.editing > div.reply-sender > span.event-sender::after {
content: " (editing message)";
color: #666;
}
> div.reply-sender {
display: flex;
align-items: center;

View file

@ -26,6 +26,7 @@ interface ReplyBodyProps {
room: RoomStateStore
event: MemDBEvent
isThread: boolean
isEditing?: boolean
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 memberEvtContent = memberEvt?.content as MemberEventContent | undefined
const BodyType = getBodyType(event, true)
return <blockquote
data-reply-to={event.event_id}
className={`reply-body ${onClose ? "composer" : ""} ${isThread ? "thread" : ""}`}
onClick={onClickReply}
>
const classNames = ["reply-body"]
if (onClose) {
classNames.push("composer")
}
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="sender-avatar" title={event.sender}>
<img

View file

@ -70,9 +70,11 @@ export class CachedEventDispatcher<T> extends EventDispatcher<T> {
}
emit(data: T) {
if (!Object.is(this.current, data)) {
this.current = data
super.emit(data)
}
}
listen(listener: (data: T) => void): () => void {
const unlisten = super.listen(listener)