forked from Mirrors/gomuks
web/timeline: add local echoes and send status for messages
This commit is contained in:
parent
8a16b46023
commit
d8582a4abe
9 changed files with 97 additions and 10 deletions
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
1
web/src/icons/error.svg
Normal 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 |
1
web/src/icons/pending.svg
Normal file
1
web/src/icons/pending.svg
Normal 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
1
web/src/icons/sent.svg
Normal 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 |
|
@ -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}>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue