diff --git a/package.json b/package.json index b84c726b3..1774881d0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "build": "vite build", "preview": "vite preview", "lint": "npm run check:eslint && npm run check:prettier", - "check:eslint": "eslint src/* --max-warnings 46", + "check:eslint": "eslint src/* --max-warnings 36", "check:prettier": "prettier --check .", "fix:prettier": "prettier --write .", "typecheck": "tsc --noEmit", diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index 3d82b40f9..a06264e47 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -1,10 +1,9 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMemberName } from '../../../utils/room'; +import { getMemberName, sendRoomEvent } from '../../../utils/room'; import { ParsedPoll, PollResponse, @@ -220,7 +219,7 @@ export function PollContent({ setPending(next); // Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader // accepts both namespaces). - mx.sendEvent(roomId, 'm.poll.response' as any, { + sendRoomEvent(mx, roomId, null, 'm.poll.response', { 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, 'm.selections': Array.from(next), }).catch(() => setPending(null)); @@ -229,7 +228,7 @@ export function PollContent({ const handleEndPoll = () => { if (!roomId || !eventId || ending) return; setEnding(true); - mx.sendEvent(roomId, 'm.poll.end' as any, { + sendRoomEvent(mx, roomId, null, 'm.poll.end', { 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, 'm.poll.end': {}, 'm.text': 'The poll has ended.', diff --git a/src/app/features/room/PollCreator.tsx b/src/app/features/room/PollCreator.tsx index 6e65a4461..9b5993948 100644 --- a/src/app/features/room/PollCreator.tsx +++ b/src/app/features/room/PollCreator.tsx @@ -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 })), diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 62a424115..21cc04d32 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -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( // 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( '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( 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( }); 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( 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( 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( }); 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'); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 9d6b0e6bc..e42b96731 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -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), ); }, diff --git a/src/app/features/room/message/ForwardMessageDialog.tsx b/src/app/features/room/message/ForwardMessageDialog.tsx index 9ec1ad121..0a75c80e5 100644 --- a/src/app/features/room/message/ForwardMessageDialog.tsx +++ b/src/app/features/room/message/ForwardMessageDialog.tsx @@ -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 diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index c7a22cdba..6a8022643 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -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, diff --git a/src/app/features/room/thread/ThreadTimeline.tsx b/src/app/features/room/thread/ThreadTimeline.tsx index 38de126e1..e9cc9848e 100644 --- a/src/app/features/room/thread/ThreadTimeline.tsx +++ b/src/app/features/room/thread/ThreadTimeline.tsx @@ -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), ); }, diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index d94fe4213..a86a2bf10 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -1,9 +1,11 @@ import { IconName, IconSrc } from 'folds'; import { + AccountDataEvents, EventTimeline, EventTimelineSet, EventType, + IContent, IMentions, IPowerLevelsContent, IPushRule, @@ -17,8 +19,10 @@ import { RelationType, Room, RoomMember, + TimelineEvents, } from 'matrix-js-sdk'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; +import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { IRoomCreateContent, @@ -62,10 +66,43 @@ export function sendStateEvent( return mx.sendStateEvent(roomId, eventType as any, content, stateKey); } +// Typed message send for the composer's fire-and-forget sends. The SDK's +// `sendMessage` wants its `RoomMessageEventContent` union, which the composer's +// extensible content (MSC3488 location, MSC3245 voice, `m.mentions`, encrypted +// `file`) doesn't narrow to; the single cast lives here. A failed send already +// shows on the local echo ("Failed to send" with Retry, or the consent prompt), +// so the rejection is swallowed rather than surfacing as an unhandled error. +export function sendRoomMessage( + mx: MatrixClient, + roomId: string, + threadId: string | null, + content: IContent, +): void { + mx.sendMessage(roomId, threadId, content as RoomMessageEventContent).catch(() => undefined); +} + +// Typed send for events whose type isn't one of the SDK's typed timeline events +// (MSC3381 polls, forwarded arbitrary types, the fork's reaction enum) or whose +// content is extensible. Keeps the one cast out of every call site. +export function sendRoomEvent( + mx: MatrixClient, + roomId: string, + threadId: string | null, + eventType: string, + content: IContent, +): Promise { + return mx.sendEvent( + roomId, + threadId, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents], + ); +} + export const getAccountData = ( mx: MatrixClient, eventType: AccountDataEvent | string, -): MatrixEvent | undefined => mx.getAccountData(eventType as any); +): MatrixEvent | undefined => mx.getAccountData(eventType as keyof AccountDataEvents); export const getMDirects = (mDirectEvent: MatrixEvent): Set => { const roomIds = new Set();