1
0
Fork 0
forked from Mirrors/gomuks

web/timeline: add local echoes and send status for messages

This commit is contained in:
Tulir Asokan 2024-10-12 18:15:52 +03:00
parent 8a16b46023
commit d8582a4abe
9 changed files with 97 additions and 10 deletions

View file

@ -39,6 +39,21 @@ export default class Client {
this.store.applySync(ev.data)
} else if (ev.command === "events_decrypted") {
this.store.applyDecrypted(ev.data)
} else if (ev.command === "send_complete") {
this.store.applySendComplete(ev.data)
}
}
async sendMessage(roomID: RoomID, text: string, mediaPath?: string): Promise<void> {
const room = this.store.rooms.get(roomID)
if (!room) {
throw new Error("Room not found")
}
const dbEvent = await this.rpc.sendMessage(roomID, text, mediaPath)
if (!room.eventsByRowID.has(dbEvent.rowid)) {
room.pendingEvents.push(dbEvent.rowid)
room.applyEvent(dbEvent, true)
room.notifyTimelineSubscribers()
}
}

View file

@ -27,6 +27,7 @@ import type {
MemDBEvent,
RawDBEvent,
RoomID,
SendCompleteData,
SyncCompleteData,
SyncRoom,
TimelineRowTuple,
@ -82,6 +83,7 @@ export class RoomStateStore {
readonly eventsByRowID: Map<EventRowID, MemDBEvent> = new Map()
readonly eventsByID: Map<EventID, MemDBEvent> = new Map()
readonly timelineSubscribers: Set<() => void> = new Set()
readonly pendingEvents: EventRowID[] = []
paginating = false
constructor(meta: DBRoom) {
@ -102,7 +104,9 @@ export class RoomStateStore {
}
evt.timeline_rowid = rt.timeline_rowid
return evt
})
}).concat(this.pendingEvents
.map(rowID => this.eventsByRowID.get(rowID))
.filter(evt => !!evt))
for (const sub of this.timelineSubscribers) {
sub()
}
@ -127,9 +131,13 @@ export class RoomStateStore {
this.notifyTimelineSubscribers()
}
applyEvent(evt: RawDBEvent) {
applyEvent(evt: RawDBEvent, pending: boolean = false) {
const memEvt = evt as MemDBEvent
memEvt.mem = true
memEvt.pending = pending
if (pending) {
memEvt.timeline_rowid = 1000000000000000 + memEvt.timestamp
}
if (evt.type === "m.room.encrypted" && evt.decrypted && evt.decrypted_type) {
memEvt.type = evt.decrypted_type
memEvt.encrypted = evt.content as EncryptedEventContent
@ -146,6 +154,21 @@ export class RoomStateStore {
}
this.eventsByRowID.set(memEvt.rowid, memEvt)
this.eventsByID.set(memEvt.event_id, memEvt)
if (!pending) {
const pendingIdx = this.pendingEvents.indexOf(evt.rowid)
if (pendingIdx !== -1) {
this.pendingEvents.splice(pendingIdx, 1)
}
}
}
applySendComplete(evt: RawDBEvent) {
const existingEvt = this.eventsByRowID.get(evt.rowid)
if (existingEvt && !existingEvt.pending) {
return
}
this.applyEvent(evt, true)
this.notifyTimelineSubscribers()
}
applySync(sync: SyncRoom) {
@ -169,6 +192,7 @@ export class RoomStateStore {
}
if (sync.reset) {
this.timeline = sync.timeline
this.pendingEvents.splice(0, this.pendingEvents.length)
} else {
this.timeline.push(...sync.timeline)
}
@ -268,6 +292,15 @@ export class StateStore {
}
}
applySendComplete(data: SendCompleteData) {
const room = this.rooms.get(data.event.room_id)
if (!room) {
// TODO log or something?
return
}
room.applySendComplete(data.event)
}
applyDecrypted(decrypted: EventsDecryptedData) {
const room = this.rooms.get(decrypted.room_id)
if (!room) {

View file

@ -86,6 +86,7 @@ export interface BaseDBEvent {
relation_type?: RelationType
decryption_error?: string
send_error?: string
reactions?: Record<string, number>
last_edit_rowid?: EventRowID
@ -98,6 +99,7 @@ export interface RawDBEvent extends BaseDBEvent {
export interface MemDBEvent extends BaseDBEvent {
mem: true
pending: boolean
encrypted?: EncryptedEventContent
orig_content?: UnknownEventContent
last_edit?: MemDBEvent

1
web/src/icons/error.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M480-280q17 0 28.5-11.5T520-320q0-17-11.5-28.5T480-360q-17 0-28.5 11.5T440-320q0 17 11.5 28.5T480-280Zm-40-160h80v-240h-80v240Zm40 360q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 538 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M280-420q25 0 42.5-17.5T340-480q0-25-17.5-42.5T280-540q-25 0-42.5 17.5T220-480q0 25 17.5 42.5T280-420Zm200 0q25 0 42.5-17.5T540-480q0-25-17.5-42.5T480-540q-25 0-42.5 17.5T420-480q0 25 17.5 42.5T480-420Zm200 0q25 0 42.5-17.5T740-480q0-25-17.5-42.5T680-540q-25 0-42.5 17.5T620-480q0 25 17.5 42.5T680-420ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 713 B

1
web/src/icons/sent.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="m424-296 282-282-56-56-226 226-114-114-56 56 170 170Zm56 216q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 464 B

View file

@ -28,7 +28,7 @@ const MessageComposer = ({ room }: MessageComposerProps) => {
const sendMessage = useCallback((evt: React.FormEvent) => {
evt.preventDefault()
setText("")
client.rpc.sendMessage(room.roomID, text)
client.sendMessage(room.roomID, text)
.catch(err => window.alert("Failed to send message: " + err))
}, [text, room, client])
return <div className="message-composer" onSubmit={sendMessage}>

View file

@ -5,9 +5,9 @@ div.timeline-event {
display: grid;
margin-top: .25rem;
grid-template:
"avatar gap sender" auto
"avatar gap content" auto
/ 2.5rem .25rem 1fr;
"avatar gap sender sender" auto
"avatar gap content status" auto
/ 2.5rem .25rem 1fr 2rem;
> div.sender-avatar {
grid-area: avatar;
@ -48,10 +48,30 @@ div.timeline-event {
overflow: hidden;
}
> div.event-send-status {
grid-area: status;
display: flex;
justify-content: right;
align-items: end;
max-height: 1.25rem;
> svg {
height: 16px;
}
&.error {
color: red;
}
&.sending, &.sent {
color: #ccc;
}
}
&.same-sender {
grid-template:
"timestamp content" auto
/ 2.75rem 1fr;
"timestamp content status" auto
/ 2.75rem 1fr 2rem;
> div.sender-avatar, > div.event-sender-and-time {
display: none;
@ -64,8 +84,8 @@ div.timeline-event {
&.hidden-event {
grid-template:
"timestamp avatar content" auto
/ 2.75rem 1.5rem 1fr;
"timestamp avatar content status" auto
/ 2.75rem 1.5rem 1fr 2rem;
margin-top: 0;
> div.sender-avatar {

View file

@ -22,6 +22,9 @@ import HiddenEvent from "./content/HiddenEvent.tsx"
import MessageBody from "./content/MessageBody.tsx"
import RedactedBody from "./content/RedactedBody.tsx"
import { EventContentProps } from "./content/props.ts"
import ErrorIcon from "../../icons/error.svg?react"
import PendingIcon from "../../icons/pending.svg?react"
import SentIcon from "../../icons/sent.svg?react"
import "./TimelineEvent.css"
export interface TimelineEventProps {
@ -62,6 +65,16 @@ const EventReactions = ({ reactions }: { reactions: Record<string, number> }) =>
</div>
}
const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
if (evt.send_error && evt.send_error !== "not sent") {
return <div className="event-send-status error" title={evt.send_error}><ErrorIcon /></div>
} else if (evt.event_id.startsWith("~")) {
return <div className="event-send-status sending"><PendingIcon /></div>
} else {
return <div className="event-send-status sent"><SentIcon /></div>
}
}
const TimelineEvent = ({ room, evt, prevEvt }: TimelineEventProps) => {
const memberEvt = room.getStateEvent("m.room.member", evt.sender)
const memberEvtContent = memberEvt?.content as MemberEventContent | undefined
@ -102,6 +115,7 @@ const TimelineEvent = ({ room, evt, prevEvt }: TimelineEventProps) => {
<BodyType room={room} event={evt}/>
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
</div>
{evt.transaction_id ? <EventSendStatus evt={evt}/> : null}
</div>
}