Skip to content

Commit

Permalink
web/roomview: add support for sending replies
Browse files Browse the repository at this point in the history
  • Loading branch information
tulir committed Oct 13, 2024
1 parent f238bb0 commit 3dc86b2
Show file tree
Hide file tree
Showing 13 changed files with 161 additions and 74 deletions.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
8 changes: 4 additions & 4 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 11 additions & 2 deletions web/src/api/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
EventID,
EventRowID,
EventType,
Mentions,
PaginationResponse,
RPCCommand,
RPCEvent,
Expand All @@ -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()
Expand Down Expand Up @@ -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> {
Expand Down
6 changes: 6 additions & 0 deletions web/src/api/types/mxtypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 19 additions & 14 deletions web/src/ui/MessageComposer.css
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
div.message-composer {
display: flex;
border-top: 1px solid #ccc;
> textarea {
flex: 1;
resize: none;
font-family: sans-serif;
height: auto;
padding: .5rem;
border: none;
outline: none;
}
> button {
padding: .5rem;
border: none;
background: none;

> div.input-area {
display: flex;

> textarea {
flex: 1;
resize: none;
font-family: sans-serif;
height: auto;
padding: .5rem;
border: none;
outline: none;
}

> button {
padding: .5rem;
border: none;
background: none;
}
}
}
67 changes: 48 additions & 19 deletions web/src/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,39 +13,68 @@
//
// 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])
}, [setTextRows, closeReply, replyTo, text, room, client])
const onKeyDown = useCallback((evt: React.KeyboardEvent) => {
if (evt.key === "Enter" && !evt.shiftKey) {
sendMessage(evt)
}
}, [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">
<textarea
autoFocus
rows={text.split("\n").length}
value={text}
onKeyDown={evt => {
if (evt.key === "Enter" && !evt.shiftKey) {
sendMessage(evt)
}
}}
onChange={evt => setText(evt.target.value)}
placeholder="Send a message"
id="message-composer"
/>
<button onClick={sendMessage}>Send</button>
{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>
}

Expand Down
15 changes: 10 additions & 5 deletions web/src/ui/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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>
}

Expand Down
4 changes: 3 additions & 1 deletion web/src/ui/roomlist/RoomList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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) => {
Expand All @@ -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 =>
Expand Down
22 changes: 20 additions & 2 deletions web/src/ui/timeline/ReplyBody.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ blockquote.reply-body {
border-left: 2px solid #aaa;
padding: .25rem .5rem;

&:hover {
&:hover, &.composer {
border-color: black;

> div.message-text {
Expand All @@ -17,7 +17,6 @@ blockquote.reply-body {
-webkit-box-orient: vertical;
overflow: hidden;
color: #666;

}

> div.reply-sender {
Expand All @@ -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;
}
}
}
}
38 changes: 23 additions & 15 deletions web/src/ui/timeline/ReplyBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
return <blockquote className="reply-body">
Reply to {eventID}
</blockquote>
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>
}

Expand Down
Loading

0 comments on commit 3dc86b2

Please sign in to comment.