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 {
|
||||
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
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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) {}
|
||||
|
||||
|
|
|
@ -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>)}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Add table
Reference in a new issue