mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
web/timeline: add resend button for failed messages
This commit is contained in:
parent
529ffda4ed
commit
678743703c
7 changed files with 120 additions and 60 deletions
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"`
|
||||||
|
|
|
@ -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,62 +299,64 @@ 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)
|
||||||
var err error
|
return dbEvt, nil
|
||||||
defer func() {
|
}
|
||||||
if dbEvt.SendError != "" {
|
|
||||||
err2 := h.DB.Event.UpdateSendError(ctx, dbEvt.RowID, dbEvt.SendError)
|
func (h *HiClient) actuallySend(ctx context.Context, room *database.Room, dbEvt *database.Event, evtType event.Type) {
|
||||||
if err2 != nil {
|
var err error
|
||||||
zerolog.Ctx(ctx).Err(err2).AnErr("send_error", err).
|
defer func() {
|
||||||
Msg("Failed to update send error in database after sending failed")
|
if dbEvt.SendError != "" {
|
||||||
}
|
err2 := h.DB.Event.UpdateSendError(ctx, dbEvt.RowID, dbEvt.SendError)
|
||||||
}
|
if err2 != nil {
|
||||||
h.EventHandler(&SendComplete{
|
zerolog.Ctx(ctx).Err(err2).AnErr("send_error", err).
|
||||||
Event: dbEvt,
|
Msg("Failed to update send error in database after sending failed")
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var resp *mautrix.RespSendEvent
|
h.EventHandler(&SendComplete{
|
||||||
resp, err = h.Client.SendMessageEvent(ctx, room.ID, evtType, dbEvt.Content, mautrix.ReqSendEvent{
|
Event: dbEvt,
|
||||||
Timestamp: dbEvt.Timestamp.UnixMilli(),
|
Error: err,
|
||||||
TransactionID: txnID,
|
|
||||||
DontEncrypt: true,
|
|
||||||
})
|
})
|
||||||
|
}()
|
||||||
|
if dbEvt.Decrypted != nil && len(dbEvt.Content) <= 2 {
|
||||||
|
var encryptedContent *event.EncryptedEventContent
|
||||||
|
encryptedContent, err = h.Encrypt(ctx, room, evtType, dbEvt.Decrypted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
dbEvt.SendError = err.Error()
|
dbEvt.SendError = fmt.Sprintf("failed to encrypt: %v", err)
|
||||||
err = fmt.Errorf("failed to send event: %w", err)
|
zerolog.Ctx(ctx).Err(err).Msg("Failed to encrypt event")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dbEvt.ID = resp.EventID
|
evtType = event.EventEncrypted
|
||||||
err = h.DB.Event.UpdateID(ctx, dbEvt.RowID, dbEvt.ID)
|
dbEvt.MegolmSessionID = encryptedContent.SessionID
|
||||||
|
dbEvt.Content, err = json.Marshal(encryptedContent)
|
||||||
if err != nil {
|
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
|
||||||
}
|
}
|
||||||
}()
|
err = h.DB.Event.UpdateEncryptedContent(ctx, dbEvt)
|
||||||
return dbEvt, nil
|
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) {
|
func (h *HiClient) Encrypt(ctx context.Context, room *database.Room, evtType event.Type, content any) (encrypted *event.EncryptedEventContent, err error) {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
1
web/src/icons/refresh.svg
Normal file
1
web/src/icons/refresh.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-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 |
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue