refactor(types): typed send helpers replace 16 as any casts (#210)

- sendRoomMessage (composer's fire-and-forget sends: text, location, voice,
  files, GIFs) and sendRoomEvent (polls, poll responses/ends, forwards,
  reactions, edits) in utils/room.ts carry the one cast each needs
  (`keyof TimelineEvents` / `RoomMessageEventContent`, no `any`).
- sendRoomMessage also swallows the rejected promise: a failed send already
  shows on the local echo (Failed to send + Retry, or the consent prompt), so
  it no longer surfaces as an unhandled error in the console.
- getAccountData narrows to `keyof AccountDataEvents`; ForwardMessageDialog's
  guard now narrows `contentToSend` itself (same behaviour).
- `as any` 39 → 23; eslint warnings 46 → 36, ratchet tightened to 36.

Verified in Chromium on a local Synapse: a text message, a quick reaction and
an edit all reach the server with the right content; a consent-blocked send
no longer logs an unhandled MatrixError. 1219 unit tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-24 21:47:44 -04:00
committed by jared
co-authored by Claude Opus 5.5
parent 39589b40f3
commit e39714a6e0
9 changed files with 93 additions and 35 deletions
+2 -1
View File
@@ -21,6 +21,7 @@ import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
import { sendRoomEvent } from '../../utils/room';
interface PollCreatorProps {
roomId: string;
@@ -89,7 +90,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
);
// Pass the thread id explicitly (like the sticker path in RoomInput); the
// legacy 3-arg form always resolves to the main timeline.
await mx.sendEvent(roomId, threadRootId ?? null, 'm.poll.start' as any, {
await sendRoomEvent(mx, roomId, threadRootId ?? null, 'm.poll.start', {
'm.poll': {
question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
+18 -13
View File
@@ -112,7 +112,12 @@ import {
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import {
getMemberName,
getMentionContent,
sendRoomMessage,
trimReplyFromBody,
} from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import {
Command,
@@ -324,7 +329,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
// m.location/m.asset/m.ts blocks so Element and other clients render a
// proper "shared location" pin instead of falling back to plain text.
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
msgtype: 'm.location',
body: `Shared a location: ${geoUri}`,
geo_uri: geoUri,
@@ -332,7 +337,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
'org.matrix.msc3488.asset': { type: 'm.self' },
'org.matrix.msc3488.ts': ts,
'm.ts': ts,
} as any);
});
},
(err) => {
setLocating(false);
@@ -363,19 +368,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (room.hasEncryptionStateEvent()) {
const { encInfo, file: encBlob } = await encryptFile(blob);
const uploadResult = await mx.uploadContent(encBlob);
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: uploadResult.content_uri },
} as any);
});
} else {
const uploadResult = await mx.uploadContent(blob, {
name: 'voice-message.ogg',
type: mimeType,
});
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
url: uploadResult.content_uri,
} as any);
});
}
},
[mx, room, roomId, threadRootId],
@@ -625,7 +630,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
contents.forEach((content) => sendRoomMessage(mx, roomId, threadRootId ?? null, content));
},
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
);
@@ -732,7 +737,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false;
}
}
mx.sendMessage(roomId, threadRootId ?? null, content as any);
sendRoomMessage(mx, roomId, threadRootId ?? null, content);
resetEditor(editor);
resetEditorHistory(editor);
setCharCount(0);
@@ -955,10 +960,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const uploadRes = await mx.uploadContent(encBlob);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: mxcUrl },
} as any);
});
} else {
const uploadRes = await mx.uploadContent(gifFile, {
type: 'image/gif',
@@ -967,10 +972,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
});
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
sendRoomMessage(mx, roomId, threadRootId ?? null, {
...baseContent,
url: mxcUrl,
} as any);
});
}
} catch (e) {
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
+5 -2
View File
@@ -89,6 +89,7 @@ import {
getReactionContent,
isMembershipChanged,
reactionOrEditEvent,
sendRoomEvent,
} from '../../utils/room';
import { getLastEditDiff } from '../../utils/editDiff';
import { tick } from '../../utils/haptics';
@@ -1160,9 +1161,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
tick('reaction', hapticFeedback);
mx.sendEvent(
sendRoomEvent(
mx,
room.roomId,
MessageEvent.Reaction as any,
null,
MessageEvent.Reaction,
getReactionContent(targetEventId, key, rShortcode),
);
},
@@ -36,7 +36,12 @@ import { mxcUrlToHttp } from '../../../utils/matrix';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { UserAvatar } from '../../../components/user-avatar';
import { ThumbnailContent } from '../../../components/message/content/ThumbnailContent';
import { getMemberAvatarMxc, getMemberName, trimReplyFromBody } from '../../../utils/room';
import {
getMemberAvatarMxc,
getMemberName,
sendRoomEvent,
trimReplyFromBody,
} from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import {
recentForwardTargetsAtom,
@@ -349,12 +354,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
// content; unencrypted ones get the plaintext version, or fail outright if
// that couldn't be built — never fall back to sending the encrypted `file`.
const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent;
if (fwdContent.file && !destEncrypted && !contentToSend) {
// Only undefined when a plaintext copy was needed and couldn't be built.
if (!contentToSend) {
return Promise.reject(new Error(plaintextContentError));
}
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, contentToSend);
// Explicit null thread = send to the main timeline.
const sendForward = () => sendRoomEvent(mx, id, null, mEvent.getType(), contentToSend);
// Send the optional comment first so it reads as a note above the
// forwarded content. The room counts as failed if either send rejects.
// Track rooms whose comment already landed so a retry (after the FORWARD
@@ -21,7 +21,15 @@ import {
} from 'folds';
import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { IContent, IMentions, MatrixEvent, MsgType, RelationType, Room } from 'matrix-js-sdk';
import {
EventType,
IContent,
IMentions,
MatrixEvent,
MsgType,
RelationType,
Room,
} from 'matrix-js-sdk';
import { isKeyHotkey } from 'is-hotkey';
import {
AUTOCOMPLETE_PREFIXES,
@@ -55,6 +63,7 @@ import {
getEditedEvent,
getMemberName,
getMentionContent,
sendRoomEvent,
trimReplyFromFormattedBody,
} from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent';
@@ -192,8 +201,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
rel_type: RelationType.Replace,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
return sendRoomEvent(mx, roomId, null, EventType.RoomMessage, content);
}
const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
@@ -241,8 +249,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
return sendRoomEvent(mx, roomId, null, EventType.RoomMessage, content);
}, [
stripTracking,
mx,
@@ -67,6 +67,7 @@ import {
getMemberName,
getReactionContent,
reactionOrEditEvent,
sendRoomEvent,
} from '../../../utils/room';
import { useSetting } from '../../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../../state/settings';
@@ -576,12 +577,12 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
shortcode ||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
tick('reaction', hapticFeedback);
mx.sendEvent(
sendRoomEvent(
mx,
room.roomId,
// A reaction on the root is a main-timeline event, not a thread reply.
isRoot ? null : thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any,
MessageEvent.Reaction,
getReactionContent(targetEventId, key, rShortcode),
);
},