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` getEventByRowID = getEventBaseQuery + `WHERE rowid = $1`
getManyEventsByRowID = getEventBaseQuery + `WHERE rowid IN (%s)` getManyEventsByRowID = getEventBaseQuery + `WHERE rowid IN (%s)`
getEventByID = getEventBaseQuery + `WHERE event_id = $1` 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` getFailedEventsByMegolmSessionID = getEventBaseQuery + `WHERE room_id = $1 AND megolm_session_id = $2 AND decryption_error IS NOT NULL`
insertEventBaseQuery = ` insertEventBaseQuery = `
INSERT INTO event ( INSERT INTO event (
@ -103,6 +104,10 @@ func (eq *EventQuery) GetByID(ctx context.Context, eventID id.EventID) (*Event,
return eq.QueryOne(ctx, getEventByID, eventID) 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) { func (eq *EventQuery) GetByRowID(ctx context.Context, rowID EventRowID) (*Event, error) {
return eq.QueryOne(ctx, getEventByRowID, rowID) 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 unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) {
return h.Send(ctx, params.RoomID, params.EventType, params.Content) 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": case "report_event":
return unmarshalAndCall(req.Data, func(params *reportEventParams) (bool, error) { return unmarshalAndCall(req.Data, func(params *reportEventParams) (bool, error) {
return true, h.Client.ReportEvent(ctx, params.RoomID, params.EventID, params.Reason) return true, h.Client.ReportEvent(ctx, params.RoomID, params.EventID, params.Reason)
@ -190,6 +194,10 @@ type sendEventParams struct {
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
} }
type resendEventParams struct {
TransactionID string `json:"transaction_id"`
}
type reportEventParams struct { type reportEventParams struct {
RoomID id.RoomID `json:"room_id"` RoomID id.RoomID `json:"room_id"`
EventID id.EventID `json:"event_id"` EventID id.EventID `json:"event_id"`

View file

@ -215,6 +215,26 @@ func (h *HiClient) Send(
return h.send(ctx, roomID, evtType, content, "") 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( func (h *HiClient) send(
ctx context.Context, ctx context.Context,
roomID id.RoomID, roomID id.RoomID,
@ -279,7 +299,11 @@ func (h *HiClient) send(
zerolog.Ctx(ctx).Err(err).Msg("Failed to stop typing while sending message") zerolog.Ctx(ctx).Err(err).Msg("Failed to stop typing while sending message")
} }
}() }()
go func() { 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 var err error
defer func() { defer func() {
if dbEvt.SendError != "" { if dbEvt.SendError != "" {
@ -294,7 +318,7 @@ func (h *HiClient) send(
Error: err, Error: err,
}) })
}() }()
if dbEvt.Decrypted != nil { if dbEvt.Decrypted != nil && len(dbEvt.Content) <= 2 {
var encryptedContent *event.EncryptedEventContent var encryptedContent *event.EncryptedEventContent
encryptedContent, err = h.Encrypt(ctx, room, evtType, dbEvt.Decrypted) encryptedContent, err = h.Encrypt(ctx, room, evtType, dbEvt.Decrypted)
if err != nil { if err != nil {
@ -320,7 +344,7 @@ func (h *HiClient) send(
var resp *mautrix.RespSendEvent var resp *mautrix.RespSendEvent
resp, err = h.Client.SendMessageEvent(ctx, room.ID, evtType, dbEvt.Content, mautrix.ReqSendEvent{ resp, err = h.Client.SendMessageEvent(ctx, room.ID, evtType, dbEvt.Content, mautrix.ReqSendEvent{
Timestamp: dbEvt.Timestamp.UnixMilli(), Timestamp: dbEvt.Timestamp.UnixMilli(),
TransactionID: txnID, TransactionID: dbEvt.TransactionID,
DontEncrypt: true, DontEncrypt: true,
}) })
if err != nil { if err != nil {
@ -333,8 +357,6 @@ func (h *HiClient) send(
if err != nil { if err != nil {
err = fmt.Errorf("failed to update event ID in database: %w", err) err = fmt.Errorf("failed to update event ID in database: %w", err)
} }
}()
return dbEvt, nil
} }
func (h *HiClient) Encrypt(ctx context.Context, room *database.Room, evtType event.Type, content any) (encrypted *event.EncryptedEventContent, err error) { 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, EventType,
ImagePackRooms, ImagePackRooms,
RPCEvent, RPCEvent,
RawDBEvent,
RoomID, RoomID,
RoomStateGUID, RoomStateGUID,
SyncStatus, SyncStatus,
@ -155,17 +156,30 @@ export default class Client {
await this.rpc.setState(room.roomID, "m.room.pinned_events", "", { pinned: pinnedEvents }) 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> { async sendEvent(roomID: RoomID, type: EventType, content: unknown): Promise<void> {
const room = this.store.rooms.get(roomID) const room = this.store.rooms.get(roomID)
if (!room) { if (!room) {
throw new Error("Room not found") throw new Error("Room not found")
} }
const dbEvent = await this.rpc.sendEvent(roomID, type, content) const dbEvent = await this.rpc.sendEvent(roomID, type, content)
if (!room.eventsByRowID.has(dbEvent.rowid)) { this.#handleOutgoingEvent(dbEvent, room)
room.pendingEvents.push(dbEvent.rowid)
room.applyEvent(dbEvent, true)
room.notifyTimelineSubscribers()
}
} }
async sendMessage(params: SendMessageParams): Promise<void> { async sendMessage(params: SendMessageParams): Promise<void> {
@ -174,11 +188,7 @@ export default class Client {
throw new Error("Room not found") throw new Error("Room not found")
} }
const dbEvent = await this.rpc.sendMessage(params) const dbEvent = await this.rpc.sendMessage(params)
if (!room.eventsByRowID.has(dbEvent.rowid)) { this.#handleOutgoingEvent(dbEvent, room)
room.pendingEvents.push(dbEvent.rowid)
room.applyEvent(dbEvent, true)
room.notifyTimelineSubscribers()
}
} }
async subscribeToEmojiPack(pack: RoomStateGUID, subscribe: boolean = true) { 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 }) 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> { reportEvent(room_id: RoomID, event_id: EventID, reason: string): Promise<boolean> {
return this.request("report_event", { room_id, event_id, reason }) 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 EditIcon from "@/icons/edit.svg?react"
import MoreIcon from "@/icons/more.svg?react" import MoreIcon from "@/icons/more.svg?react"
import ReactIcon from "@/icons/react.svg?react" import ReactIcon from "@/icons/react.svg?react"
import RefreshIcon from "@/icons/refresh.svg?react"
import ReplyIcon from "@/icons/reply.svg?react" import ReplyIcon from "@/icons/reply.svg?react"
import "./index.css" import "./index.css"
@ -71,6 +72,13 @@ const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
const onClickEdit = useCallback(() => { const onClickEdit = useCallback(() => {
roomCtx.setEditing(evt) roomCtx.setEditing(evt)
}, [roomCtx, 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 onClickMore = useCallback((mevt: React.MouseEvent<HTMLButtonElement>) => {
const moreMenuHeight = 10 * 16 const moreMenuHeight = 10 * 16
setForceOpen(true) 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 evtSendType = isEncrypted ? "m.room.encrypted" : evt.type === "m.sticker" ? "m.sticker" : "m.room.message"
const messageSendPL = pls.events?.[evtSendType] ?? pls.events_default ?? 0 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 const canEdit = canSend
&& evt.sender === userID && evt.sender === userID
&& evt.type === "m.room.message" && evt.type === "m.room.message"
&& evt.relation_type !== "m.replace" && evt.relation_type !== "m.replace"
&& !evt.redacted_by && !evt.redacted_by
const canReact = ownPL >= reactPL const canReact = !didFail && ownPL >= reactPL
return <div className="event-hover-menu" ref={contextMenuRef}> return <div className="event-hover-menu" ref={contextMenuRef}>
{canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}><ReactIcon/></button>} {canReact && <button disabled={isPending} title={pendingTitle} onClick={onClickReact}><ReactIcon/></button>}
@ -112,6 +121,7 @@ const EventMenu = ({ evt, setForceOpen }: EventHoverMenuProps) => {
onClick={onClickReply} onClick={onClickReply}
><ReplyIcon/></button>} ><ReplyIcon/></button>}
{canEdit && <button onClick={onClickEdit} disabled={isPending} title={pendingTitle}><EditIcon/></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> <button onClick={onClickMore}><MoreIcon/></button>
</div> </div>
} }