From d8582a4abec9fb5cc5cca892afe2f2e06521cd24 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 12 Oct 2024 18:15:52 +0300 Subject: [PATCH] web/timeline: add local echoes and send status for messages --- web/src/api/client.ts | 15 +++++++++++ web/src/api/statestore.ts | 37 +++++++++++++++++++++++++-- web/src/api/types/hitypes.ts | 2 ++ web/src/icons/error.svg | 1 + web/src/icons/pending.svg | 1 + web/src/icons/sent.svg | 1 + web/src/ui/MessageComposer.tsx | 2 +- web/src/ui/timeline/TimelineEvent.css | 34 +++++++++++++++++++----- web/src/ui/timeline/TimelineEvent.tsx | 14 ++++++++++ 9 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 web/src/icons/error.svg create mode 100644 web/src/icons/pending.svg create mode 100644 web/src/icons/sent.svg diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 718d6df..c8e05fe 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + 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() } } diff --git a/web/src/api/statestore.ts b/web/src/api/statestore.ts index d95e78b..7c342f5 100644 --- a/web/src/api/statestore.ts +++ b/web/src/api/statestore.ts @@ -27,6 +27,7 @@ import type { MemDBEvent, RawDBEvent, RoomID, + SendCompleteData, SyncCompleteData, SyncRoom, TimelineRowTuple, @@ -82,6 +83,7 @@ export class RoomStateStore { readonly eventsByRowID: Map = new Map() readonly eventsByID: Map = 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) { diff --git a/web/src/api/types/hitypes.ts b/web/src/api/types/hitypes.ts index af717e7..06144be 100644 --- a/web/src/api/types/hitypes.ts +++ b/web/src/api/types/hitypes.ts @@ -86,6 +86,7 @@ export interface BaseDBEvent { relation_type?: RelationType decryption_error?: string + send_error?: string reactions?: Record 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 diff --git a/web/src/icons/error.svg b/web/src/icons/error.svg new file mode 100644 index 0000000..025069e --- /dev/null +++ b/web/src/icons/error.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/pending.svg b/web/src/icons/pending.svg new file mode 100644 index 0000000..3ab8dc4 --- /dev/null +++ b/web/src/icons/pending.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/sent.svg b/web/src/icons/sent.svg new file mode 100644 index 0000000..2bd5231 --- /dev/null +++ b/web/src/icons/sent.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/MessageComposer.tsx b/web/src/ui/MessageComposer.tsx index d4b813d..c0fe161 100644 --- a/web/src/ui/MessageComposer.tsx +++ b/web/src/ui/MessageComposer.tsx @@ -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
diff --git a/web/src/ui/timeline/TimelineEvent.css b/web/src/ui/timeline/TimelineEvent.css index 9d4e6e4..1b3ae4b 100644 --- a/web/src/ui/timeline/TimelineEvent.css +++ b/web/src/ui/timeline/TimelineEvent.css @@ -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 { diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index 2ba923f..8b8e47a 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -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 }) =>
} +const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => { + if (evt.send_error && evt.send_error !== "not sent") { + return
+ } else if (evt.event_id.startsWith("~")) { + return
+ } else { + return
+ } +} + 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) => { {evt.reactions ? : null} + {evt.transaction_id ? : null} }