forked from Mirrors/gomuks
web/roomview: add support for sending replies
This commit is contained in:
parent
f238bb0285
commit
3dc86b287a
13 changed files with 161 additions and 74 deletions
2
go.mod
2
go.mod
|
@ -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
4
go.sum
|
@ -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=
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
@ -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 =>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue