import { EncryptedAttachmentInfo, decryptAttachment, encryptAttachment, } from 'browser-encrypt-attachment'; import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room, RoomMember, UploadProgress, UploadResponse, } from 'matrix-js-sdk'; import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { MDirectContent } from '../../types/matrix/accountData'; import { getStateEvent } from './room'; import { Membership, StateEvent } from '../../types/matrix/room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName); const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])([^\s:]+):(\S+)$/); const validMxId = (id: string): boolean => !!matchMxId(id); export const getMxIdServer = (userId: string): string | undefined => matchMxId(userId)?.[3]; export const getMxIdLocalPart = (userId: string): string | undefined => matchMxId(userId)?.[2]; export const isUserId = (id: string): boolean => validMxId(id) && id.startsWith('@'); export const isRoomId = (id: string): boolean => id.startsWith('!'); export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#'); export const getCanonicalAliasRoomId = (mx: MatrixClient, alias: string): string | undefined => mx .getRooms() ?.find( (room) => room.getCanonicalAlias() === alias && getStateEvent(room, StateEvent.RoomTombstone) === undefined, )?.roomId; export const getCanonicalAliasOrRoomId = (mx: MatrixClient, roomId: string): string => { const room = mx.getRoom(roomId); if (!room) return roomId; if (getStateEvent(room, StateEvent.RoomTombstone) !== undefined) return roomId; const alias = room.getCanonicalAlias(); if (alias && getCanonicalAliasRoomId(mx, alias) === roomId) { return alias; } return roomId; }; export const getImageInfo = (img: HTMLImageElement, fileOrBlob: File | Blob): IImageInfo => { const info: IImageInfo = {}; info.w = img.width; info.h = img.height; info.mimetype = fileOrBlob.type; info.size = fileOrBlob.size; return info; }; export const getVideoInfo = (video: HTMLVideoElement, fileOrBlob: File | Blob): IVideoInfo => { const info: IVideoInfo = {}; info.duration = Number.isNaN(video.duration) ? undefined : Math.floor(video.duration * 1000); info.w = video.videoWidth; info.h = video.videoHeight; info.mimetype = fileOrBlob.type; info.size = fileOrBlob.size; return info; }; export const getThumbnailContent = (thumbnailInfo: { thumbnail: File | Blob; encInfo: EncryptedAttachmentInfo | undefined; mxc: string; width: number; height: number; }): IThumbnailContent => { const { thumbnail, encInfo, mxc, width, height } = thumbnailInfo; const content: IThumbnailContent = { thumbnail_info: { mimetype: thumbnail.type, size: thumbnail.size, w: width, h: height, }, }; if (encInfo) { content.thumbnail_file = { ...encInfo, url: mxc, }; } else { content.thumbnail_url = mxc; } return content; }; export const encryptFile = async ( file: File | Blob, ): Promise<{ encInfo: EncryptedAttachmentInfo; file: File; originalFile: File | Blob; }> => { const dataBuffer = await file.arrayBuffer(); const encryptedAttachment = await encryptAttachment(dataBuffer); const encFile = new File([encryptedAttachment.data], (file as File).name, { type: file.type, }); return { encInfo: encryptedAttachment.info, file: encFile, originalFile: file, }; }; export const decryptFile = async ( dataBuffer: ArrayBuffer, type: string, encInfo: EncryptedAttachmentInfo, ): Promise => { const dataArray = await decryptAttachment(dataBuffer, encInfo); const blob = new Blob([dataArray], { type }); return blob; }; export type TUploadContent = File | Blob; export type ContentUploadOptions = { name?: string; fileType?: string; hideFilename?: boolean; onPromise?: (promise: Promise) => void; onProgress?: (progress: UploadProgress) => void; onSuccess: (mxc: string) => void; onError: (error: MatrixError) => void; }; // Build a MatrixError defensively from an unexpected upload response. // MatrixError's constructor expects an IErrorJson ({ errcode?, error?, ... }), not a // raw UploadResponse, so we guard the shape and only forward string fields it understands. const matrixErrorFromUploadResponse = (data: UploadResponse): MatrixError => { const errorJson: { errcode?: string; error?: string } = {}; const maybe = data as Partial<{ errcode: unknown; error: unknown }>; if (typeof maybe.errcode === 'string') errorJson.errcode = maybe.errcode; if (typeof maybe.error === 'string') errorJson.error = maybe.error; if (!errorJson.error) errorJson.error = 'Upload failed: missing content_uri in response'; return new MatrixError(errorJson); }; const matrixErrorFromUnknown = (e: unknown): MatrixError => { if (e instanceof MatrixError) return e; const err = e as Partial<{ message: unknown; errcode: unknown }> | null | undefined; const error = typeof err?.message === 'string' ? err.message : 'Upload failed'; const errcode = typeof err?.errcode === 'string' ? err.errcode : undefined; return new MatrixError({ error, errcode }); }; // HTTP statuses that should not be retried — client errors are deterministic // (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry. const isRetryableUploadError = (e: unknown): boolean => { if (e instanceof MatrixError) { const status = e.httpStatus; // No status => network/transport failure (transient): retry. if (typeof status !== 'number') return true; // Retry on rate-limiting and server-side (5xx) errors only. return status === 429 || status >= 500; } // Non-Matrix errors are typically network/transport failures: retry. return true; }; const UPLOAD_MAX_RETRY_COUNT = 3; export const uploadContent = async ( mx: MatrixClient, file: TUploadContent, options: ContentUploadOptions, ) => { const { name, fileType, hideFilename, onProgress, onPromise, onSuccess, onError } = options; const sleepForMs = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); let lastError: MatrixError | undefined; for (let retryCount = 0; retryCount <= UPLOAD_MAX_RETRY_COUNT; retryCount += 1) { const uploadPromise = mx.uploadContent(file, { name, type: fileType, includeFilename: !hideFilename, progressHandler: onProgress, }); onPromise?.(uploadPromise); try { // eslint-disable-next-line no-await-in-loop const data = await uploadPromise; const mxc = data.content_uri; if (mxc) { onSuccess(mxc); return; } // Missing content_uri is not a transient failure — fail immediately. onError(matrixErrorFromUploadResponse(data)); return; } catch (e: unknown) { lastError = matrixErrorFromUnknown(e); if (retryCount === UPLOAD_MAX_RETRY_COUNT || !isRetryableUploadError(e)) { onError(lastError); return; } // Respect server Retry-After header; fall back to capped exponential backoff, // mirroring rateLimitedActions (min(1000 * 2^retryCount, 30_000)ms). const waitMS = (e instanceof MatrixError ? e.getRetryAfterMs() : null) ?? Math.min(1000 * 2 ** retryCount, 30_000); // eslint-disable-next-line no-await-in-loop await sleepForMs(waitMS); } } // Unreachable in practice, but keeps onError guaranteed if the loop exits. if (lastError) onError(lastError); }; export const matrixEventByRecency = (m1: MatrixEvent, m2: MatrixEvent) => m2.getTs() - m1.getTs(); export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) => ev.getSender() === senderId; export const eventWithShortcode = (ev: MatrixEvent) => typeof ev.getContent().shortcode === 'string'; export const getDMRoomFor = (mx: MatrixClient, userId: string): Room | undefined => { const dmLikeRooms = mx .getRooms() .filter( (room) => room.getMyMembership() === Membership.Join && room.hasEncryptionStateEvent() && room.getMembers().length <= 2, ); return dmLikeRooms.find((room) => room.getMember(userId)); }; export const guessDmRoomUserId = (room: Room, myUserId: string): string => { const getOldestMember = (members: RoomMember[]): RoomMember | undefined => { let oldestMemberTs: number | undefined; let oldestMember: RoomMember | undefined; const pickOldestMember = (member: RoomMember) => { if (member.userId === myUserId) return; if ( oldestMemberTs === undefined || (member.events.member && member.events.member.getTs() < oldestMemberTs) ) { oldestMember = member; oldestMemberTs = member.events.member?.getTs(); } }; members.forEach(pickOldestMember); return oldestMember; }; // Pick the joined user who's been here longest (and isn't us), const member = getOldestMember(room.getJoinedMembers()); if (member) return member.userId; // if there are no joined members other than us, use the oldest member const member1 = getOldestMember( room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getMembers() ?? [], ); return member1?.userId ?? myUserId; }; export const addRoomIdToMDirect = async ( mx: MatrixClient, roomId: string, userId: string, ): Promise => { const mDirectsEvent = mx.getAccountData(EventType.Direct); let userIdToRoomIds: MDirectContent = {}; if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); // remove it from the lists of any others users // (it can only be a DM room for one person) Object.keys(userIdToRoomIds).forEach((targetUserId) => { const roomIds = userIdToRoomIds[targetUserId]; if (targetUserId !== userId) { const indexOfRoomId = roomIds.indexOf(roomId); if (indexOfRoomId > -1) { roomIds.splice(indexOfRoomId, 1); } } }); const roomIds = userIdToRoomIds[userId] || []; if (roomIds.indexOf(roomId) === -1) { roomIds.push(roomId); } userIdToRoomIds[userId] = roomIds; await mx.setAccountData(EventType.Direct, userIdToRoomIds); }; export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { const mDirectsEvent = mx.getAccountData(EventType.Direct); let userIdToRoomIds: MDirectContent = {}; if (typeof mDirectsEvent !== 'undefined') userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); Object.keys(userIdToRoomIds).forEach((targetUserId) => { const roomIds = userIdToRoomIds[targetUserId]; const indexOfRoomId = roomIds.indexOf(roomId); if (indexOfRoomId > -1) { roomIds.splice(indexOfRoomId, 1); } }); await mx.setAccountData(EventType.Direct, userIdToRoomIds); }; export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string, useAuthentication?: boolean, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean, ): string | null => { // Build the URL manually so we never add allow_redirect. // The SDK forces allow_redirect=true when useAuthentication=true, but Synapse's // /_matrix/client/v1/media/thumbnail endpoint rejects that parameter with 400. if (!mxcUrl) return null; if (!mxcUrl.startsWith('mxc://')) { return allowDirectLinks ? mxcUrl : null; } const parts = mxcUrl.slice(6).split('/'); if (parts.length !== 2 || !parts[0] || !parts[1]) return null; const [serverName, mediaId] = parts; const isThumbnail = !!(width || height || resizeMethod); const verb = isThumbnail ? 'thumbnail' : 'download'; const prefix = useAuthentication ? `/_matrix/client/v1/media/${verb}` : `/_matrix/media/v3/${verb}`; const url = new URL(`${prefix}/${serverName}/${mediaId}`, mx.getHomeserverUrl()); if (width) url.searchParams.set('width', String(Math.round(width))); if (height) url.searchParams.set('height', String(Math.round(height))); if (resizeMethod) url.searchParams.set('method', resizeMethod); return url.href; }; export const downloadMedia = async (src: string): Promise => { // this request is authenticated by service worker const res = await fetch(src, { method: 'GET' }); if (res.ok) return res.blob(); // On 401/400 fall back to the legacy unauthenticated media path. // 401: SW session missing (race on first load or after SW restart). // 400: allow_redirect=true on a URL that was constructed before this fix was deployed; // Synapse's thumbnail endpoint rejects that parameter with 400. // Requires allow_public_access_to_media_repo: true on the homeserver. if (res.status === 401 || res.status === 400) { const legacyUrl = src .replace('/_matrix/client/v1/media/download/', '/_matrix/media/v3/download/') .replace('/_matrix/client/v1/media/thumbnail/', '/_matrix/media/v3/thumbnail/'); if (legacyUrl !== src) { const legacyRes = await fetch(legacyUrl, { method: 'GET' }); if (legacyRes.ok) return legacyRes.blob(); } } throw new Error(`Media download failed: ${res.status} ${res.statusText}`); }; export const downloadEncryptedMedia = async ( src: string, decryptContent: (buf: ArrayBuffer) => Promise, ): Promise => { const encryptedContent = await downloadMedia(src); const decryptedContent = await decryptContent(await encryptedContent.arrayBuffer()); return decryptedContent; }; export const rateLimitedActions = async ( data: T[], callback: (item: T, index: number) => Promise, maxRetryCount?: number, ) => { let retryCount = 0; let actionInterval = 0; const sleepForMs = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); const performAction = async (dataItem: T, index: number) => { const [err] = await to(callback(dataItem, index)); if (err?.httpStatus === 429) { if (retryCount === maxRetryCount) { return; } // Respect server Retry-After header; fall back to capped exponential backoff. const waitMS = err.getRetryAfterMs() ?? Math.min(1000 * 2 ** retryCount, 30_000); actionInterval = waitMS * 1.5; await sleepForMs(waitMS); retryCount += 1; await performAction(dataItem, index); } }; for (let i = 0; i < data.length; i += 1) { const dataItem = data[i]; retryCount = 0; // eslint-disable-next-line no-await-in-loop await performAction(dataItem, i); if (actionInterval > 0) { // eslint-disable-next-line no-await-in-loop await sleepForMs(actionInterval); } } }; export const knockSupported = (version: string): boolean => { const unsupportedVersion = ['1', '2', '3', '4', '5', '6']; return !unsupportedVersion.includes(version); }; export const restrictedSupported = (version: string): boolean => { const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7']; return !unsupportedVersion.includes(version); }; export const knockRestrictedSupported = (version: string): boolean => { const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; return !unsupportedVersion.includes(version); }; export const creatorsSupported = (version: string): boolean => { const unsupportedVersion = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11']; return !unsupportedVersion.includes(version); }; // Best-effort deletion of a user-owned MXC URI from the homeserver. // Synapse 1.97+ supports DELETE /_matrix/client/v1/media/{server}/{mediaId} for media owners. // Failures are silently ignored — this is cleanup only, not critical path. export const tryDeleteMxcContent = async (mx: MatrixClient, mxcUrl: string): Promise => { try { const path = mxcUrl.replace('mxc://', ''); const token = mx.getAccessToken(); if (!token || !path.includes('/')) return; await fetch(`${mx.getHomeserverUrl()}/_matrix/client/v1/media/${path}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); } catch { // Intentionally swallowed } };