web/roomview: add support for sending replies

This commit is contained in:
Tulir Asokan 2024-10-13 22:34:17 +03:00
parent f238bb0285
commit 3dc86b287a
13 changed files with 161 additions and 74 deletions

2
go.mod
View file

@ -13,7 +13,7 @@ require (
golang.org/x/crypto v0.27.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.21.1-0.20241013141433-5cccf93cdc6a
maunium.net/go/mautrix v0.21.1-0.20241013193325-8d9caf0d55f4
)
require (

4
go.sum
View file

@ -66,5 +66,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.21.1-0.20241013141433-5cccf93cdc6a h1:faC83lFuKSS9wTrR2pa1mm0JZkUHa5NA6eA0LVQSrDs=
maunium.net/go/mautrix v0.21.1-0.20241013141433-5cccf93cdc6a/go.mod h1:yIs8uVcl3ZiTuDzAYmk/B4/z9dQqegF0rcOWV4ncgko=
maunium.net/go/mautrix v0.21.1-0.20241013193325-8d9caf0d55f4 h1:LCVNEHiOT5N2J8OTC95TEdHqffDXoxnz1awcTh7XmyI=
maunium.net/go/mautrix v0.21.1-0.20241013193325-8d9caf0d55f4/go.mod h1:yIs8uVcl3ZiTuDzAYmk/B4/z9dQqegF0rcOWV4ncgko=

View file

@ -14,7 +14,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 { CachedEventDispatcher } from "../util/eventdispatcher.ts"
import type RPCClient from "./rpc.ts"
import RPCClient, { SendMessageParams } from "./rpc.ts"
import { StateStore } from "./statestore.ts"
import type {
ClientState,
@ -49,12 +49,12 @@ export default class Client {
}
}
async sendMessage(roomID: RoomID, text: string, mediaPath?: string): Promise<void> {
const room = this.store.rooms.get(roomID)
async sendMessage(params: SendMessageParams): Promise<void> {
const room = this.store.rooms.get(params.room_id)
if (!room) {
throw new Error("Room not found")
}
const dbEvent = await this.rpc.sendMessage(roomID, text, mediaPath)
const dbEvent = await this.rpc.sendMessage(params)
if (!room.eventsByRowID.has(dbEvent.rowid)) {
room.pendingEvents.push(dbEvent.rowid)
room.applyEvent(dbEvent, true)

View file

@ -20,6 +20,7 @@ import type {
EventID,
EventRowID,
EventType,
Mentions,
PaginationResponse,
RPCCommand,
RPCEvent,
@ -41,6 +42,14 @@ export class ErrorResponse extends Error {
}
}
export interface SendMessageParams {
room_id: RoomID
text: string
media_path?: string
reply_to?: EventID
mentions?: Mentions
}
export default abstract class RPCClient {
public readonly connect: CachedEventDispatcher<ConnectionEvent> = new CachedEventDispatcher()
public readonly event: EventDispatcher<RPCEvent> = new EventDispatcher()
@ -110,8 +119,8 @@ export default abstract class RPCClient {
}, this.cancelRequest.bind(this, request_id))
}
sendMessage(room_id: RoomID, text: string, media_path?: string): Promise<RawDBEvent> {
return this.request("send_message", { room_id, text, media_path })
sendMessage(params: SendMessageParams): Promise<RawDBEvent> {
return this.request("send_message", params)
}
sendEvent(room_id: RoomID, type: EventType, content: Record<string, unknown>): Promise<RawDBEvent> {

View file

@ -64,11 +64,17 @@ export interface MemberEventContent {
reason?: string
}
export interface Mentions {
user_ids: UserID[]
room: boolean
}
export interface BaseMessageEventContent {
msgtype: string
body: string
formatted_body?: string
format?: "org.matrix.custom.html"
"m.mentions"?: Mentions
}
export interface TextMessageEventContent extends BaseMessageEventContent {

View file

@ -1,6 +1,9 @@
div.message-composer {
display: flex;
border-top: 1px solid #ccc;
> div.input-area {
display: flex;
> textarea {
flex: 1;
resize: none;
@ -10,9 +13,11 @@ div.message-composer {
border: none;
outline: none;
}
> button {
padding: .5rem;
border: none;
background: none;
}
}
}

View file

@ -13,40 +13,69 @@
//
// 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, useCallback, useState } from "react"
import { RoomStateStore } from "../api/statestore.ts"
import React, { use, useCallback, useRef, useState } from "react"
import { RoomStateStore } from "@/api/statestore.ts"
import { MemDBEvent, Mentions } from "@/api/types"
import { ClientContext } from "./ClientContext.ts"
import ReplyBody from "./timeline/ReplyBody.tsx"
import "./MessageComposer.css"
interface MessageComposerProps {
room: RoomStateStore
setTextRows: (rows: number) => void
replyTo: MemDBEvent | null
closeReply: () => void
}
const MessageComposer = ({ room }: MessageComposerProps) => {
const MessageComposer = ({ room, replyTo, setTextRows, closeReply }: MessageComposerProps) => {
const client = use(ClientContext)!
const [text, setText] = useState("")
const textRows = useRef(1)
const sendMessage = useCallback((evt: React.FormEvent) => {
evt.preventDefault()
if (text === "") {
return
}
setText("")
client.sendMessage(room.roomID, text)
setTextRows(1)
textRows.current = 1
closeReply()
const room_id = room.roomID
const mentions: Mentions = {
user_ids: [],
room: false,
}
if (replyTo) {
mentions.user_ids.push(replyTo.sender)
}
client.sendMessage({ room_id, text, reply_to: replyTo?.event_id, mentions })
.catch(err => window.alert("Failed to send message: " + err))
}, [text, room, client])
return <div className="message-composer">
<textarea
autoFocus
rows={text.split("\n").length}
value={text}
onKeyDown={evt => {
}, [setTextRows, closeReply, replyTo, text, room, client])
const onKeyDown = useCallback((evt: React.KeyboardEvent) => {
if (evt.key === "Enter" && !evt.shiftKey) {
sendMessage(evt)
}
}}
onChange={evt => setText(evt.target.value)}
}, [sendMessage])
const onChange = useCallback((evt: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(evt.target.value)
textRows.current = evt.target.value.split("\n").length
setTextRows(textRows.current)
}, [setTextRows])
return <div className="message-composer">
{replyTo && <ReplyBody room={room} event={replyTo} onClose={closeReply}/>}
<div className="input-area">
<textarea
autoFocus
rows={textRows.current}
value={text}
onKeyDown={onKeyDown}
onChange={onChange}
placeholder="Send a message"
id="message-composer"
/>
<button onClick={sendMessage}>Send</button>
</div>
</div>
}
export default MessageComposer

View file

@ -13,9 +13,11 @@
//
// 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 { getMediaURL } from "../api/media.ts"
import { RoomStateStore } from "../api/statestore.ts"
import { useNonNullEventAsState } from "../util/eventdispatcher.ts"
import { useCallback, useState } from "react"
import { getMediaURL } from "@/api/media.ts"
import { RoomStateStore } from "@/api/statestore.ts"
import { MemDBEvent } from "@/api/types"
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
import MessageComposer from "./MessageComposer.tsx"
import TimelineView from "./timeline/TimelineView.tsx"
import "./RoomView.css"
@ -46,10 +48,13 @@ const onKeyDownRoomView = (evt: React.KeyboardEvent) => {
}
const RoomView = ({ room }: RoomViewProps) => {
const [replyTo, setReplyTo] = useState<MemDBEvent | null>(null)
const [textRows, setTextRows] = useState(1)
const closeReply = useCallback(() => setReplyTo(null), [])
return <div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}>
<RoomHeader room={room}/>
<TimelineView room={room}/>
<MessageComposer room={room}/>
<TimelineView room={room} textRows={textRows} replyTo={replyTo} setReplyTo={setReplyTo}/>
<MessageComposer room={room} setTextRows={setTextRows} replyTo={replyTo} closeReply={closeReply}/>
</div>
}

View file

@ -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, useCallback, useState } from "react"
import React, { use, useCallback, useRef, useState } from "react"
import { toSearchableString } from "@/api/statestore.ts"
import type { RoomID } from "@/api/types"
import { useNonNullEventAsState } from "@/util/eventdispatcher.ts"
@ -28,6 +28,7 @@ interface RoomListProps {
const RoomList = ({ setActiveRoom, activeRoomID }: RoomListProps) => {
const roomList = useNonNullEventAsState(use(ClientContext)!.store.roomList)
const roomFilterRef = useRef<HTMLInputElement>(null)
const [roomFilter, setRoomFilter] = useState("")
const [realRoomFilter, setRealRoomFilter] = useState("")
const clickRoom = useCallback((evt: React.MouseEvent) => {
@ -51,6 +52,7 @@ const RoomList = ({ setActiveRoom, activeRoomID }: RoomListProps) => {
className="room-search"
type="text"
placeholder="Search rooms"
ref={roomFilterRef}
/>
<div className="room-list">
{reverseMap(roomList, room =>

View file

@ -3,7 +3,7 @@ blockquote.reply-body {
border-left: 2px solid #aaa;
padding: .25rem .5rem;
&:hover {
&:hover, &.composer {
border-color: black;
> div.message-text {
@ -17,7 +17,6 @@ blockquote.reply-body {
-webkit-box-orient: vertical;
overflow: hidden;
color: #666;
}
> div.reply-sender {
@ -29,5 +28,24 @@ blockquote.reply-body {
height: 1rem;
margin-right: .25rem;
}
> button.close-reply {
display: flex;
margin-left: auto;
align-items: center;
background: none;
border: none;
border-radius: .25rem;
padding: 0;
> svg {
height: 24px;
width: 24px;
}
&:hover {
background-color: #ccc;
}
}
}
}

View file

@ -15,37 +15,45 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { getAvatarURL } from "@/api/media.ts"
import type { RoomStateStore } from "@/api/statestore.ts"
import type { EventID, MemberEventContent } from "@/api/types"
import type { EventID, MemDBEvent, MemberEventContent } from "@/api/types"
import { TextMessageBody } from "./content/MessageBody.tsx"
import CloseButton from "@/icons/close.svg?react"
import "./ReplyBody.css"
interface ReplyBodyProps {
interface BaseReplyBodyProps {
room: RoomStateStore
eventID: EventID
eventID?: EventID
event?: MemDBEvent
onClose?: () => void
}
const ReplyBody = ({ room, eventID }: ReplyBodyProps) => {
const evt = room.eventsByID.get(eventID)
if (!evt) {
type ReplyBodyProps = BaseReplyBodyProps & ({eventID: EventID } | {event: MemDBEvent })
const ReplyBody = ({ room, eventID, event, onClose }: ReplyBodyProps) => {
if (!event) {
event = room.eventsByID.get(eventID!)
if (!event) {
return <blockquote className="reply-body">
Reply to {eventID}
</blockquote>
}
const memberEvt = room.getStateEvent("m.room.member", evt.sender)
}
const memberEvt = room.getStateEvent("m.room.member", event.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
return <blockquote className="reply-body">
return <blockquote className={`reply-body ${onClose ? "composer" : ""}`}>
<div className="reply-sender">
<div className="sender-avatar" title={evt.sender}>
<div className="sender-avatar" title={event.sender}>
<img
className="avatar"
loading="lazy"
src={getAvatarURL(evt.sender, memberEvtContent?.avatar_url)}
src={getAvatarURL(event.sender, memberEvtContent?.avatar_url)}
alt=""
/>
</div>
<span className="event-sender">{memberEvtContent?.displayname ?? evt.sender}</span>
<span className="event-sender">{memberEvtContent?.displayname ?? event.sender}</span>
{onClose && <button className="close-reply" onClick={onClose}><CloseButton/></button>}
</div>
<TextMessageBody room={room} event={evt}/>
<TextMessageBody room={room} event={event}/>
</blockquote>
}

View file

@ -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, useCallback } from "react"
import { getAvatarURL } from "../../api/media.ts"
import { RoomStateStore } from "../../api/statestore.ts"
import { MemDBEvent, MemberEventContent } from "../../api/types"
@ -33,6 +33,7 @@ export interface TimelineEventProps {
room: RoomStateStore
evt: MemDBEvent
prevEvt: MemDBEvent | null
setReplyTo: (evt: MemDBEvent) => void
}
function getBodyType(evt: MemDBEvent): React.FunctionComponent<EventContentProps> {
@ -99,7 +100,8 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
}
}
const TimelineEvent = ({ room, evt, prevEvt }: TimelineEventProps) => {
const TimelineEvent = ({ room, evt, prevEvt, setReplyTo }: TimelineEventProps) => {
const wrappedSetReplyTo = useCallback(() => setReplyTo(evt), [evt, setReplyTo])
const client = use(ClientContext)!
const memberEvt = room.getStateEvent("m.room.member", evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
@ -127,14 +129,14 @@ const TimelineEvent = ({ room, evt, prevEvt }: TimelineEventProps) => {
alt=""
/>
</div>
<div className="event-sender-and-time">
<div className="event-sender-and-time" onClick={wrappedSetReplyTo}>
<span className="event-sender">{memberEvtContent?.displayname ?? evt.sender}</span>
<span className="event-time" title={fullTime}>{shortTime}</span>
{(editEventTS && editTime) ? <span className="event-edited" title={editTime}>
(edited at {formatShortTime(editEventTS)})
</span> : null}
</div>
<div className="event-time-only">
<div className="event-time-only" onClick={wrappedSetReplyTo}>
<span className="event-time" title={editTime ? `${fullTime} - ${editTime}` : fullTime}>{shortTime}</span>
</div>
<div className="event-content">

View file

@ -14,17 +14,20 @@
// 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, useEffect, useRef } from "react"
import { RoomStateStore, useRoomTimeline } from "../../api/statestore.ts"
import { MemDBEvent } from "../../api/types"
import { RoomStateStore, useRoomTimeline } from "@/api/statestore.ts"
import { MemDBEvent } from "@/api/types"
import { ClientContext } from "../ClientContext.ts"
import TimelineEvent from "./TimelineEvent.tsx"
import "./TimelineView.css"
interface TimelineViewProps {
room: RoomStateStore
textRows: number
replyTo: MemDBEvent | null
setReplyTo: (evt: MemDBEvent) => void
}
const TimelineView = ({ room }: TimelineViewProps) => {
const TimelineView = ({ room, textRows, replyTo, setReplyTo }: TimelineViewProps) => {
const timeline = useRoomTimeline(room)
const client = use(ClientContext)!
const loadHistory = useCallback(() => {
@ -61,7 +64,7 @@ const TimelineView = ({ room }: TimelineViewProps) => {
timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current
}
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0
}, [timeline])
}, [textRows, replyTo, timeline])
useEffect(() => {
const topElem = topRef.current
if (!topElem) {
@ -93,7 +96,7 @@ const TimelineView = ({ room }: TimelineViewProps) => {
return null
}
const thisEvt = <TimelineEvent
key={entry.rowid} room={room} evt={entry} prevEvt={prevEvt}
key={entry.rowid} room={room} evt={entry} prevEvt={prevEvt} setReplyTo={setReplyTo}
/>
prevEvt = entry
return thisEvt