From 678743703cb91d33deeb793578c1c38ec8bb6b79 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Wed, 4 Dec 2024 00:46:25 +0200 Subject: [PATCH] web/timeline: add resend button for failed messages --- pkg/hicli/database/event.go | 5 ++ pkg/hicli/json-commands.go | 8 ++ pkg/hicli/send.go | 118 +++++++++++++++---------- web/src/api/client.ts | 30 ++++--- web/src/api/rpc.ts | 4 + web/src/icons/refresh.svg | 1 + web/src/ui/timeline/menu/EventMenu.tsx | 14 ++- 7 files changed, 120 insertions(+), 60 deletions(-) create mode 100644 web/src/icons/refresh.svg diff --git a/pkg/hicli/database/event.go b/pkg/hicli/database/event.go index 59a8f62..3940614 100644 --- a/pkg/hicli/database/event.go +++ b/pkg/hicli/database/event.go @@ -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) } diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 7028bde..c334b6b 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -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"` diff --git a/pkg/hicli/send.go b/pkg/hicli/send.go index 2c989d9..67e9fdf 100644 --- a/pkg/hicli/send.go +++ b/pkg/hicli/send.go @@ -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) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 63b30e1..3a550df 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + 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 { 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 { @@ -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) { diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index e0be430..d6bc065 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -141,6 +141,10 @@ export default abstract class RPCClient { return this.request("send_event", { room_id, type, content }) } + resendEvent(transaction_id: string): Promise { + return this.request("resend_event", { transaction_id }) + } + reportEvent(room_id: RoomID, event_id: EventID, reason: string): Promise { return this.request("report_event", { room_id, event_id, reason }) } diff --git a/web/src/icons/refresh.svg b/web/src/icons/refresh.svg new file mode 100644 index 0000000..97789a9 --- /dev/null +++ b/web/src/icons/refresh.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/timeline/menu/EventMenu.tsx b/web/src/ui/timeline/menu/EventMenu.tsx index 3f26808..7447e7b 100644 --- a/web/src/ui/timeline/menu/EventMenu.tsx +++ b/web/src/ui/timeline/menu/EventMenu.tsx @@ -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) => { 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
{canReact && } @@ -112,6 +121,7 @@ const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => { onClick={onClickReply} >} {canEdit && } + {didFail && }
}