web/timeline: add resend button for failed messages

This commit is contained in:
Tulir Asokan 2024-12-04 00:46:25 +02:00
parent 529ffda4ed
commit 678743703c
7 changed files with 120 additions and 60 deletions

View file

@ -34,6 +34,7 @@ const (
getEventByRowID = getEventBaseQuery + `WHERE rowid = $1`
getManyEventsByRowID = getEventBaseQuery + `WHERE rowid IN (%s)`
getEventByID = getEventBaseQuery + `WHERE event_id = $1`
getEventByTransactionID = getEventBaseQuery + `WHERE transaction_id = $1`
getFailedEventsByMegolmSessionID = getEventBaseQuery + `WHERE room_id = $1 AND megolm_session_id = $2 AND decryption_error IS NOT NULL`
insertEventBaseQuery = `
INSERT INTO event (
@ -103,6 +104,10 @@ func (eq *EventQuery) GetByID(ctx context.Context, eventID id.EventID) (*Event,
return eq.QueryOne(ctx, getEventByID, eventID)
}
func (eq *EventQuery) GetByTransactionID(ctx context.Context, txnID string) (*Event, error) {
return eq.QueryOne(ctx, getEventByTransactionID, txnID)
}
func (eq *EventQuery) GetByRowID(ctx context.Context, rowID EventRowID) (*Event, error) {
return eq.QueryOne(ctx, getEventByRowID, rowID)
}

View file

@ -48,6 +48,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
return h.Send(ctx, params.RoomID, params.EventType, params.Content)
})
case "resend_event":
return unmarshalAndCall(req.Data, func(params *resendEventParams) (*database.Event, error) {
return h.Resend(ctx, params.TransactionID)
})
case "report_event":
return unmarshalAndCall(req.Data, func(params *reportEventParams) (bool, error) {
return true, h.Client.ReportEvent(ctx, params.RoomID, params.EventID, params.Reason)
@ -190,6 +194,10 @@ type sendEventParams struct {
Content json.RawMessage `json:"content"`
}
type resendEventParams struct {
TransactionID string `json:"transaction_id"`
}
type reportEventParams struct {
RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"`

View file

@ -215,6 +215,26 @@ func (h *HiClient) Send(
return h.send(ctx, roomID, evtType, content, "")
}
func (h *HiClient) Resend(ctx context.Context, txnID string) (*database.Event, error) {
dbEvt, err := h.DB.Event.GetByTransactionID(ctx, txnID)
if err != nil {
return nil, fmt.Errorf("failed to get event by transaction ID: %w", err)
} else if dbEvt == nil {
return nil, fmt.Errorf("unknown transaction ID")
} else if dbEvt.ID != "" && !strings.HasPrefix(dbEvt.ID.String(), "~") {
return nil, fmt.Errorf("event was already sent successfully")
}
room, err := h.DB.Room.Get(ctx, dbEvt.RoomID)
if err != nil {
return nil, fmt.Errorf("failed to get room metadata: %w", err)
} else if room == nil {
return nil, fmt.Errorf("unknown room")
}
dbEvt.SendError = ""
go h.actuallySend(context.WithoutCancel(ctx), room, dbEvt, event.Type{Type: dbEvt.Type, Class: event.MessageEventType})
return dbEvt, nil
}
func (h *HiClient) send(
ctx context.Context,
roomID id.RoomID,
@ -279,62 +299,64 @@ func (h *HiClient) send(
zerolog.Ctx(ctx).Err(err).Msg("Failed to stop typing while sending message")
}
}()
go func() {
var err error
defer func() {
if dbEvt.SendError != "" {
err2 := h.DB.Event.UpdateSendError(ctx, dbEvt.RowID, dbEvt.SendError)
if err2 != nil {
zerolog.Ctx(ctx).Err(err2).AnErr("send_error", err).
Msg("Failed to update send error in database after sending failed")
}
}
h.EventHandler(&SendComplete{
Event: dbEvt,
Error: err,
})
}()
if dbEvt.Decrypted != nil {
var encryptedContent *event.EncryptedEventContent
encryptedContent, err = h.Encrypt(ctx, room, evtType, dbEvt.Decrypted)
if err != nil {
dbEvt.SendError = fmt.Sprintf("failed to encrypt: %v", err)
zerolog.Ctx(ctx).Err(err).Msg("Failed to encrypt event")
return
}
evtType = event.EventEncrypted
dbEvt.MegolmSessionID = encryptedContent.SessionID
dbEvt.Content, err = json.Marshal(encryptedContent)
if err != nil {
dbEvt.SendError = fmt.Sprintf("failed to marshal encrypted content: %v", err)
zerolog.Ctx(ctx).Err(err).Msg("Failed to marshal encrypted content")
return
}
err = h.DB.Event.UpdateEncryptedContent(ctx, dbEvt)
if err != nil {
dbEvt.SendError = fmt.Sprintf("failed to save event after encryption: %v", err)
zerolog.Ctx(ctx).Err(err).Msg("Failed to save event after encryption")
return
go h.actuallySend(ctx, room, dbEvt, evtType)
return dbEvt, nil
}
func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt *database.Event, evtType event.Type) {
var err error
defer func() {
if dbEvt.SendError != "" {
err2 := h.DB.Event.UpdateSendError(ctx, dbEvt.RowID, dbEvt.SendError)
if err2 != nil {
zerolog.Ctx(ctx).Err(err2).AnErr("send_error", err).
Msg("Failed to update send error in database after sending failed")
}
}
var resp *mautrix.RespSendEvent
resp, err = h.Client.SendMessageEvent(ctx, room.ID, evtType, dbEvt.Content, mautrix.ReqSendEvent{
Timestamp: dbEvt.Timestamp.UnixMilli(),
TransactionID: txnID,
DontEncrypt: true,
h.EventHandler(&SendComplete{
Event: dbEvt,
Error: err,
})
}()
if dbEvt.Decrypted != nil && len(dbEvt.Content) <= 2 {
var encryptedContent *event.EncryptedEventContent
encryptedContent, err = h.Encrypt(ctx, room, evtType, dbEvt.Decrypted)
if err != nil {
dbEvt.SendError = err.Error()
err = fmt.Errorf("failed to send event: %w", err)
dbEvt.SendError = fmt.Sprintf("failed to encrypt: %v", err)
zerolog.Ctx(ctx).Err(err).Msg("Failed to encrypt event")
return
}
dbEvt.ID = resp.EventID
err = h.DB.Event.UpdateID(ctx, dbEvt.RowID, dbEvt.ID)
evtType = event.EventEncrypted
dbEvt.MegolmSessionID = encryptedContent.SessionID
dbEvt.Content, err = json.Marshal(encryptedContent)
if err != nil {
err = fmt.Errorf("failed to update event ID in database: %w", err)
dbEvt.SendError = fmt.Sprintf("failed to marshal encrypted content: %v", err)
zerolog.Ctx(ctx).Err(err).Msg("Failed to marshal encrypted content")
return
}
}()
return dbEvt, nil
err = h.DB.Event.UpdateEncryptedContent(ctx, dbEvt)
if err != nil {
dbEvt.SendError = fmt.Sprintf("failed to save event after encryption: %v", err)
zerolog.Ctx(ctx).Err(err).Msg("Failed to save event after encryption")
return
}
}
var resp *mautrix.RespSendEvent
resp, err = h.Client.SendMessageEvent(ctx, room.ID, evtType, dbEvt.Content, mautrix.ReqSendEvent{
Timestamp: dbEvt.Timestamp.UnixMilli(),
TransactionID: dbEvt.TransactionID,
DontEncrypt: true,
})
if err != nil {
dbEvt.SendError = err.Error()
err = fmt.Errorf("failed to send event: %w", err)
return
}
dbEvt.ID = resp.EventID
err = h.DB.Event.UpdateID(ctx, dbEvt.RowID, dbEvt.ID)
if err != nil {
err = fmt.Errorf("failed to update event ID in database: %w", err)
}
}
func (h *HiClient) Encrypt(ctx context.Context, room *database.Room, evtType event.Type, content any) (encrypted *event.EncryptedEventContent, err error) {

View file

@ -23,6 +23,7 @@ import type {
EventType,
ImagePackRooms,
RPCEvent,
RawDBEvent,
RoomID,
RoomStateGUID,
SyncStatus,
@ -155,17 +156,30 @@ export default class Client {
await this.rpc.setState(room.roomID, "m.room.pinned_events", "", { pinned: pinnedEvents })
}
async resendEvent(txnID: string): Promise<void> {
const dbEvent = await this.rpc.resendEvent(txnID)
const room = this.store.rooms.get(dbEvent.room_id)
room?.applyEvent(dbEvent, true)
room?.notifyTimelineSubscribers()
}
#handleOutgoingEvent(dbEvent: RawDBEvent, room: RoomStateStore) {
if (!room.eventsByRowID.has(dbEvent.rowid)) {
if (!room.pendingEvents.includes(dbEvent.rowid)) {
room.pendingEvents.push(dbEvent.rowid)
}
room.applyEvent(dbEvent, true)
room.notifyTimelineSubscribers()
}
}
async sendEvent(roomID: RoomID, type: EventType, content: unknown): Promise<void> {
const room = this.store.rooms.get(roomID)
if (!room) {
throw new Error("Room not found")
}
const dbEvent = await this.rpc.sendEvent(roomID, type, content)
if (!room.eventsByRowID.has(dbEvent.rowid)) {
room.pendingEvents.push(dbEvent.rowid)
room.applyEvent(dbEvent, true)
room.notifyTimelineSubscribers()
}
this.#handleOutgoingEvent(dbEvent, room)
}
async sendMessage(params: SendMessageParams): Promise<void> {
@ -174,11 +188,7 @@ export default class Client {
throw new Error("Room not found")
}
const dbEvent = await this.rpc.sendMessage(params)
if (!room.eventsByRowID.has(dbEvent.rowid)) {
room.pendingEvents.push(dbEvent.rowid)
room.applyEvent(dbEvent, true)
room.notifyTimelineSubscribers()
}
this.#handleOutgoingEvent(dbEvent, room)
}
async subscribeToEmojiPack(pack: RoomStateGUID, subscribe: boolean = true) {

View file

@ -141,6 +141,10 @@ export default abstract class RPCClient {
return this.request("send_event", { room_id, type, content })
}
resendEvent(transaction_id: string): Promise<RawDBEvent> {
return this.request("resend_event", { transaction_id })
}
reportEvent(room_id: RoomID, event_id: EventID, reason: string): Promise<boolean> {
return this.request("report_event", { room_id, event_id, reason })
}

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-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z"/></svg>

After

Width:  |  Height:  |  Size: 334 B

View file

@ -25,6 +25,7 @@ import EventExtraMenu from "./EventExtraMenu.tsx"
import EditIcon from "@/icons/edit.svg?react"
import MoreIcon from "@/icons/more.svg?react"
import ReactIcon from "@/icons/react.svg?react"
import RefreshIcon from "@/icons/refresh.svg?react"
import ReplyIcon from "@/icons/reply.svg?react"
import "./index.css"
@ -71,6 +72,13 @@ const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
const onClickEdit = useCallback(() => {
roomCtx.setEditing(evt)
}, [roomCtx, evt])
const onClickResend = useCallback(() => {
if (!evt.transaction_id) {
return
}
client.resendEvent(evt.transaction_id)
.catch(err => window.alert(`Failed to resend message: ${err}`))
}, [client, evt.transaction_id])
const onClickMore = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
const moreMenuHeight = 10 * 16
setForceOpen(true)
@ -96,13 +104,14 @@ const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
const evtSendType = isEncrypted ? "m.room.encrypted" : evt.type === "m.sticker" ? "m.sticker" : "m.room.message"
const messageSendPL = pls.events?.[evtSendType] ?? pls.events_default ?? 0
const canSend = ownPL >= messageSendPL
const didFail = !!evt.send_error && evt.send_error !== "not sent" && !!evt.transaction_id
const canSend = !didFail && ownPL >= messageSendPL
const canEdit = canSend
&& evt.sender === userID
&& evt.type === "m.room.message"
&& evt.relation_type !== "m.replace"
&& !evt.redacted_by
const canReact = ownPL >= reactPL
const canReact = !didFail && ownPL >= reactPL
return <div className="event-hover-menu" ref={contextMenuRef}>
{canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}><ReactIcon/></button>}
@ -112,6 +121,7 @@ const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
onClick={onClickReply}
><ReplyIcon/></button>}
{canEdit && <button onClick={onClickEdit} disabled={isPending} title={pendingTitle}><EditIcon/></button>}
{didFail && <button onClick={onClickResend} title="Resend message"><RefreshIcon/></button>}
<button onClick={onClickMore}><MoreIcon/></button>
</div>
}