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 { 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
} }

View file

@ -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">

View file

@ -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) {}

View file

@ -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>)}

View file

@ -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;

View file

@ -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

View file

@ -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)