import { IconName, IconSrc } from 'folds'; import { EventTimeline, EventTimelineSet, EventType, IMentions, IPowerLevelsContent, IPushRule, IPushRules, ISendEventResponse, JoinRule, MatrixClient, MatrixEvent, MsgType, NotificationCountType, RelationType, Room, RoomMember, } from 'matrix-js-sdk'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { IRoomCreateContent, Membership, MessageEvent, NotificationType, RoomToParents, RoomType, StateEvent, UnreadInfo, } from '../../types/matrix/room'; import { getMutedThreads, ThreadNotificationsContent } from './threadNotifications'; import { getMxIdLocalPart } from './matrix'; export const getStateEvent = ( room: Room, eventType: StateEvent, stateKey = '', ): MatrixEvent | undefined => room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getStateEvents(eventType, stateKey) ?? undefined; export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[] => room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getStateEvents(eventType) ?? []; // Typed state-event write helper. // // matrix-js-sdk's `sendStateEvent` typed overload rejects the fork's custom // `StateEvent` enum values (the `io.lotus.*` / `im.ponies.*` / etc. values in // `types/matrix/room.ts`), and casting the event type to `any` at each call site // also collapses the `content` argument to `any`. We centralize the single // `as any` cast here so every call site keeps its own `content` type checked. export function sendStateEvent( mx: MatrixClient, roomId: string, eventType: StateEvent, content: T, stateKey = '', ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any return mx.sendStateEvent(roomId, eventType as any, content, stateKey); } export const getAccountData = ( mx: MatrixClient, eventType: AccountDataEvent | string, ): MatrixEvent | undefined => mx.getAccountData(eventType as any); export const getMDirects = (mDirectEvent: MatrixEvent): Set => { const roomIds = new Set(); const userIdToDirects = mDirectEvent?.getContent(); if (userIdToDirects === undefined) return roomIds; Object.keys(userIdToDirects).forEach((userId) => { const directs = userIdToDirects[userId]; if (Array.isArray(directs)) { directs.forEach((id) => { if (typeof id === 'string') roomIds.add(id); }); } }); return roomIds; }; export const isDirectInvite = (room: Room | null, myUserId: string | null): boolean => { if (!room || !myUserId) return false; const me = room.getMember(myUserId); const memberEvent = me?.events?.member; const content = memberEvent?.getContent(); return content?.is_direct === true; }; export const isSpace = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate); if (!event) return false; return event.getContent().type === RoomType.Space; }; export const isRoom = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate); if (!event) return true; return event.getContent().type !== RoomType.Space; }; export const isUnsupportedRoom = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate); if (!event) return true; // Consider room unsupported if m.room.create event doesn't exist return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space; }; export function isValidChild(mEvent: MatrixEvent): boolean { return ( mEvent.getType() === StateEvent.SpaceChild && Array.isArray(mEvent.getContent<{ via: string[] }>().via) ); } export const getAllParents = (roomToParents: RoomToParents, roomId: string): Set => { const allParents = new Set(); const addAllParentIds = (rId: string) => { if (allParents.has(rId)) return; allParents.add(rId); const parents = roomToParents.get(rId); parents?.forEach((id) => addAllParentIds(id)); }; addAllParentIds(roomId); allParents.delete(roomId); return allParents; }; export const getSpaceChildren = (room: Room) => getStateEvents(room, StateEvent.SpaceChild).reduce((filtered, mEvent) => { const stateKey = mEvent.getStateKey(); if (isValidChild(mEvent) && stateKey) { filtered.push(stateKey); } return filtered; }, []); export const mapParentWithChildren = ( roomToParents: RoomToParents, roomId: string, children: string[], ) => { const allParents = getAllParents(roomToParents, roomId); children.forEach((childId) => { if (allParents.has(childId)) { // Space cycle detected. return; } const parents = roomToParents.get(childId) ?? new Set(); parents.add(roomId); roomToParents.set(childId, parents); }); }; export const getRoomToParents = (mx: MatrixClient): RoomToParents => { const map: RoomToParents = new Map(); mx.getRooms() .filter((room) => isSpace(room)) .forEach((room) => mapParentWithChildren(map, room.roomId, getSpaceChildren(room))); return map; }; export const getOrphanParents = (roomToParents: RoomToParents, roomId: string): string[] => { const parents = getAllParents(roomToParents, roomId); const orphanParents = Array.from(parents).filter( (parentRoomId) => !roomToParents.has(parentRoomId), ); return orphanParents; }; export const isMutedRule = (rule: IPushRule) => // Check for empty actions (new spec) or dont_notify (deprecated) (rule.actions.length === 0 || rule.actions[0] === 'dont_notify') && rule.conditions?.[0]?.kind === 'event_match'; export const findMutedRule = (overrideRules: IPushRule[], roomId: string) => overrideRules.find((rule) => rule.rule_id === roomId && isMutedRule(rule)); export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => { let roomPushRule: IPushRule | undefined; try { roomPushRule = mx.getRoomPushRule('global', roomId); } catch { roomPushRule = undefined; } if (!roomPushRule) { const overrideRules = mx.getAccountData(EventType.PushRules)?.getContent() ?.global?.override; if (!overrideRules) return NotificationType.Default; return findMutedRule(overrideRules, roomId) ? NotificationType.Mute : NotificationType.Default; } if (roomPushRule.actions[0] === 'notify') return NotificationType.AllMessages; return NotificationType.MentionsAndKeywords; }; const NOTIFICATION_EVENT_TYPES = [ 'm.room.create', 'm.room.message', 'm.room.encrypted', 'm.room.member', 'm.sticker', // Polls (MSC3381) are real content the server can count toward a room's total. // Recognizing them keeps a poll-only unread from being walked past by the // read-receipt/tail scans (roomHaveUnread, readReceiptCoversTail). 'm.poll.start', 'org.matrix.msc3381.poll.start', ]; // In-room device-verification requests are sent as m.room.message with this // msgtype (the rest of the flow — start/accept/key/mac/done/cancel — uses its own // event types, already excluded above). They're crypto control messages, not chat. const VERIFICATION_REQUEST_MSGTYPE = 'm.key.verification.request'; export const isNotificationEvent = (mEvent: MatrixEvent) => { const eType = mEvent.getType(); if (!NOTIFICATION_EVENT_TYPES.includes(eType)) { return false; } if (eType === 'm.room.member') return false; // Don't badge/notify a verification request — otherwise a stale one at the tail // of a DM re-lights the room's unread dot on every fresh sync (cache clear). if (eType === 'm.room.message' && mEvent.getContent().msgtype === VERIFICATION_REQUEST_MSGTYPE) { return false; } if (mEvent.isRedacted()) return false; if (mEvent.getRelation()?.rel_type === 'm.replace') return false; return true; }; // In-room device verification is a small burst at the tail of a DM: the // `m.key.verification.request` message plus the `m.key.verification.*` flow // (ready/start/key/mac/done/cancel). A COMPLETED request keeps the server/SDK // Total notification count > 0 forever — it's a plain `m.room.message`, so it // matches the default DM push rule and there's no recency gate — so the DM // re-lights as unread on every fresh sync. export const isVerificationFlowEvent = (mEvent: MatrixEvent): boolean => { // getType() returns the CLEAR type once decrypted; while still encrypted we // can't tell, so this returns false and callers treat that as "not confirmed". const eType = mEvent.getType(); if (eType.startsWith('m.key.verification.')) return true; if (eType === 'm.room.message') { return mEvent.getContent().msgtype === VERIFICATION_REQUEST_MSGTYPE; } return false; }; // True iff the room has any thread carrying a real unread notification. A room's // server/SDK Total INCLUDES its threads, and `markAsRead` clears every thread // unconditionally, so any tail-based suppression must bail when a thread is // genuinely unread — otherwise it would hide (or wrongly ack) a real thread reply. export const roomHasUnreadThread = (room: Room): boolean => room .getThreads() .some( (thread) => room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0, ); // True iff a room's ENTIRE unread span (tail → the user's read receipt) is // nothing but verification-flow events — i.e. the only "unread" is a completed // device verification, not a real message. Conservative: returns false when the // read marker isn't in the loaded timeline (can't confirm the span) or while the // tail is still encrypted (undecryptable → unknown), so it never suppresses or // auto-reads a genuine unread message. export const unreadIsOnlyVerification = (room: Room, userId: string | null): boolean => { if (!userId) return false; if (roomHasUnreadThread(room)) return false; const readUpToId = room.getEventReadUpTo(userId); const liveEvents = room.getLiveTimeline().getEvents(); let sawVerification = false; for (let i = liveEvents.length - 1; i >= 0; i -= 1) { const event = liveEvents[i]; if (!event) return false; if (event.getId() === readUpToId) return sawVerification; if (isNotificationEvent(event) && !isVerificationFlowEvent(event)) return false; if (isVerificationFlowEvent(event)) sawVerification = true; } return false; }; // True iff the user's read receipt already covers the room's entire notifiable // tail: walking from the newest live event, we reach the receipt's event without // crossing any notification-worthy event. In that case there is demonstrably // nothing real left to read, so a lingering Total > 0 is a spurious SDK count. // matrix-js-sdk's `fixNotificationCountOnDecryption` only ever INCREMENTS an // encrypted room's Total, and `addReceipt`'s auto-clear-to-zero only fires when // the tail event is the user's own — so a count inflated in an earlier state // (before a receipt covered the tail, e.g. by a since-corrupted/undecryptable // event) is never decremented and keeps a genuinely-read room lit across cold // starts. Anchoring on the read receipt is safe: a genuine unread would sit AFTER // the receipt and stop the walk at `isNotificationEvent`. Conservative: returns // false when the receipt isn't in the loaded window (can't confirm) or a thread // is genuinely unread. export const readReceiptCoversTail = (room: Room, userId: string | null): boolean => { if (!userId) return false; if (roomHasUnreadThread(room)) return false; const readUpToId = room.getEventReadUpTo(userId); if (!readUpToId) return false; const liveEvents = room.getLiveTimeline().getEvents(); for (let i = liveEvents.length - 1; i >= 0; i -= 1) { const event = liveEvents[i]; if (!event) return false; if (event.getId() === readUpToId) return true; if (isNotificationEvent(event)) return false; } return false; }; export const roomHaveNotification = (room: Room): boolean => { const total = room.getUnreadNotificationCount(NotificationCountType.Total); const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); return total > 0 || highlight > 0; }; export const roomHaveUnread = (mx: MatrixClient, room: Room) => { const userId = mx.getUserId(); if (!userId) return false; const readUpToId = room.getEventReadUpTo(userId); const liveEvents = room.getLiveTimeline().getEvents(); if (liveEvents[liveEvents.length - 1]?.getSender() === userId) { return false; } for (let i = liveEvents.length - 1; i >= 0; i -= 1) { const event = liveEvents[i]; if (!event) return false; if (event.getId() === readUpToId) return false; if (isNotificationEvent(event)) return true; } return true; }; export const getUnreadInfo = ( room: Room, mutedThreads?: Set, mx?: MatrixClient, ): UnreadInfo => { let total = room.getUnreadNotificationCount(NotificationCountType.Total); let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); // Server room totals INCLUDE per-thread notification counts, so subtract any // explicitly muted thread's counts back out (clamped at zero) to keep muted // threads from contributing to the room badge (P4-1). if (mutedThreads && mutedThreads.size > 0) { mutedThreads.forEach((threadId) => { total -= room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Total) ?? 0; highlight -= room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) ?? 0; }); if (total < 0) total = 0; if (highlight < 0) highlight = 0; } const resolvedTotal = highlight > total ? highlight : total; // Suppress a spurious Total when the room isn't really unread. Two safe cases, // both requiring `mx` (backward-compatible for callers/tests without it) and a // highlight-free count (a real mention must never be hidden): // 1. the entire unread span is a completed device verification, or // 2. the user's read receipt already covers the whole notifiable tail (the // SDK re-inflated an encrypted-room count past a receipt that genuinely // covers everything — e.g. a permanently-undecryptable event). if ( mx && resolvedTotal > 0 && highlight === 0 && (unreadIsOnlyVerification(room, mx.getUserId()) || readReceiptCoversTail(room, mx.getUserId())) ) { return { roomId: room.roomId, highlight: 0, total: 0 }; } return { roomId: room.roomId, highlight, total: resolvedTotal, }; }; export const getUnreadInfos = ( mx: MatrixClient, content?: ThreadNotificationsContent, ): UnreadInfo[] => { const unreadInfos = mx.getRooms().reduce((unread, room) => { if (room.isSpaceRoom()) return unread; if (room.getMyMembership() !== 'join') return unread; if (getNotificationType(mx, room.roomId) === NotificationType.Mute) return unread; if (roomHaveNotification(room) || roomHaveUnread(mx, room)) { const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined; const info = getUnreadInfo(room, mutedThreads, mx); // Skip a phantom {0,0} entry: a room whose ONLY unread is a muted thread has // roomHaveNotification true (the server room total includes the muted // thread's count), but getUnreadInfo subtracts it back to zero. Pushing it // would still light the nav row + pollute "unread only" filters. Keep it // only if there's real unread (count > 0) or a genuine unread marker. The // unreadIsOnlyVerification/readReceiptCoversTail guards below mirror the // getUnreadInfo suppression: roomHaveUnread returning `true` here already // implies both are false (they only report `true` once the receipt covers // the tail, exactly where roomHaveUnread returns `false`), so the guards are // defensive insurance against divergence, not load-bearing. if ( info.total > 0 || info.highlight > 0 || (roomHaveUnread(mx, room) && !unreadIsOnlyVerification(room, mx.getUserId()) && !readReceiptCoversTail(room, mx.getUserId())) ) { unread.push(info); } } return unread; }, []); return unreadInfos; }; export const getRoomIconSrc = ( icons: Record, roomType?: string, joinRule?: JoinRule, ): IconSrc => { if (roomType === 'm.server_notice') return icons.Warning; if (roomType === RoomType.Space) { if (joinRule === JoinRule.Public) return icons.SpaceGlobe; if ( joinRule === JoinRule.Invite || joinRule === JoinRule.Knock || joinRule === JoinRule.Private ) { return icons.SpaceLock; } return icons.Space; } if (roomType === RoomType.Call) { if (joinRule === JoinRule.Public) return icons.VolumeHighGlobe; if ( joinRule === JoinRule.Invite || joinRule === JoinRule.Knock || joinRule === JoinRule.Private ) { return icons.VolumeHighLock; } return icons.VolumeHigh; } if (joinRule === JoinRule.Public) return icons.HashGlobe; if ( joinRule === JoinRule.Invite || joinRule === JoinRule.Knock || joinRule === JoinRule.Private ) { return icons.HashLock; } return icons.Hash; }; export const getRoomAvatarUrl = ( mx: MatrixClient, room: Room, size: 32 | 96 = 32, useAuthentication = false, ): string | undefined => { const mxcUrl = room.getMxcAvatarUrl(); return mxcUrl ? (mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined) : undefined; }; export const getDirectRoomAvatarUrl = ( mx: MatrixClient, room: Room, size: 32 | 96 = 32, useAuthentication = false, ): string | undefined => { const mxcUrl = room.getAvatarFallbackMember()?.getMxcAvatarUrl(); if (!mxcUrl) { return getRoomAvatarUrl(mx, room, size, useAuthentication); } return ( mx.mxcUrlToHttp(mxcUrl, size, size, 'crop', undefined, false, useAuthentication) ?? undefined ); }; export const trimReplyFromBody = (body: string): string => { const match = body.match(/^> <.+?> .+\n(>.*\n)*?\n/m); if (!match) return body; return body.slice(match[0].length); }; export const trimReplyFromFormattedBody = (formattedBody: string): string => { const suffix = ''; const i = formattedBody.lastIndexOf(suffix); if (i < 0) { return formattedBody; } return formattedBody.slice(i + suffix.length); }; export const parseReplyBody = (userId: string, body: string) => `> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`; export const parseReplyFormattedBody = ( roomId: string, userId: string, eventId: string, formattedBody: string, ): string => { const replyToLink = `In reply to`; const userLink = `${userId}`; return `
${replyToLink}${userLink}
${formattedBody}
`; }; export const getMemberDisplayName = (room: Room, userId: string): string | undefined => { const member = room.getMember(userId); const name = member?.rawDisplayName; if (name === userId) return undefined; return name; }; export const getMemberName = (room: Room, userId: string): string => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; export const getMemberSearchStr = ( member: RoomMember, query: string, mxIdToName: (mxId: string) => string, ): string[] => [ member.rawDisplayName === member.userId ? mxIdToName(member.userId) : member.rawDisplayName, query.startsWith('@') || query.indexOf(':') > -1 ? member.userId : mxIdToName(member.userId), ]; export const getMemberAvatarMxc = (room: Room, userId: string): string | undefined => { const member = room.getMember(userId); return member?.getMxcAvatarUrl(); }; export const isMembershipChanged = (mEvent: MatrixEvent): boolean => mEvent.getContent().membership !== mEvent.getPrevContent().membership || mEvent.getContent().reason !== mEvent.getPrevContent().reason; export const decryptAllTimelineEvent = async (mx: MatrixClient, timeline: EventTimeline) => { const crypto = mx.getCrypto(); if (!crypto) return; const decryptionPromises = timeline .getEvents() .filter((event) => event.isEncrypted()) .reverse() .map((event) => event.attemptDecryption(crypto as CryptoBackend, { isRetry: true })); await Promise.allSettled(decryptionPromises); }; export const getReactionContent = (eventId: string, key: string, shortcode?: string) => ({ 'm.relates_to': { event_id: eventId, key, rel_type: 'm.annotation', }, shortcode, }); export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) => timelineSet.relations.getChildEventsForEvent( eventId, RelationType.Annotation, EventType.Reaction, ); export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) => timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType); export const getLatestEdit = ( targetEvent: MatrixEvent, editEvents: MatrixEvent[], ): MatrixEvent | undefined => { const eventByTargetSender = (rEvent: MatrixEvent) => rEvent.getSender() === targetEvent.getSender(); return editEvents.sort((m1, m2) => m2.getTs() - m1.getTs()).find(eventByTargetSender); }; export const getEditedEvent = ( mEventId: string, mEvent: MatrixEvent, timelineSet: EventTimelineSet, ): MatrixEvent | undefined => { const edits = getEventEdits(timelineSet, mEventId, mEvent.getType()); return edits && getLatestEdit(mEvent, edits.getRelations()); }; export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => { const content = mEvent.getContent(); const relationType = content['m.relates_to']?.rel_type; return ( mEvent.getSender() === mx.getUserId() && (!relationType || relationType === RelationType.Thread) && mEvent.getType() === MessageEvent.RoomMessage && (content.msgtype === MsgType.Text || content.msgtype === MsgType.Emote || content.msgtype === MsgType.Notice) ); }; /** * Whether the current user can edit the *caption* of a media message. Captions * are authored for image/video only (see msgContent.ts): the caption is the * event `body` when it differs from `filename`. Editing only changes the caption * — the media (url/info/file) is preserved. */ export const canEditCaption = (mx: MatrixClient, mEvent: MatrixEvent) => { const content = mEvent.getContent(); const relationType = content['m.relates_to']?.rel_type; return ( mEvent.getSender() === mx.getUserId() && (!relationType || relationType === RelationType.Thread) && mEvent.getType() === MessageEvent.RoomMessage && (content.msgtype === MsgType.Image || content.msgtype === MsgType.Video) && // Require the MSC2530 filename so a caption has a well-defined empty state // (body === filename) — media from clients that omit filename isn't caption- // editable (and renderCaption never shows a caption for it anyway). typeof content.filename === 'string' ); }; /** Editable as a text message, or has an editable media caption. */ export const canEditEventOrCaption = (mx: MatrixClient, mEvent: MatrixEvent) => canEditEvent(mx, mEvent) || canEditCaption(mx, mEvent); export const getLatestEditableEvt = ( timeline: EventTimeline, canEdit: (mEvent: MatrixEvent) => boolean, ): MatrixEvent | undefined => { const events = timeline.getEvents(); for (let i = events.length - 1; i >= 0; i -= 1) { const evt = events[i]; if (canEdit(evt)) return evt; } return undefined; }; export const reactionOrEditEvent = (mEvent: MatrixEvent) => mEvent.getRelation()?.rel_type === RelationType.Annotation || mEvent.getRelation()?.rel_type === RelationType.Replace; export const getMentionContent = (userIds: string[], room: boolean): IMentions => { const mMentions: IMentions = {}; if (userIds.length > 0) { mMentions.user_ids = userIds; } if (room) { mMentions.room = true; } return mMentions; }; export const getCommonRooms = ( mx: MatrixClient, rooms: string[], otherUserId: string, ): string[] => { const commonRooms: string[] = []; rooms.forEach((roomId) => { const room = mx.getRoom(roomId); if (!room || room.getMyMembership() !== Membership.Join) return; const common = room.hasMembershipState(otherUserId, Membership.Join); if (common) { commonRooms.push(roomId); } }); return commonRooms; }; export const bannedInRooms = (mx: MatrixClient, rooms: string[], otherUserId: string): boolean => rooms.some((roomId) => { const room = mx.getRoom(roomId); if (!room || room.getMyMembership() !== Membership.Join) return false; const banned = room.hasMembershipState(otherUserId, Membership.Ban); return banned; }); export const getAllVersionsRoomCreator = (room: Room): Set => { const creators = new Set(); const createEvent = getStateEvent(room, StateEvent.RoomCreate); const createContent = createEvent?.getContent(); const creator = createEvent?.getSender(); if (typeof creator === 'string') creators.add(creator); if (createContent && Array.isArray(createContent.additional_creators)) { createContent.additional_creators.forEach((c) => { if (typeof c === 'string') creators.add(c); }); } return creators; }; export const guessPerfectParent = ( mx: MatrixClient, roomId: string, parents: string[], ): string | undefined => { if (parents.length === 1) { return parents[0]; } const getSpecialUsers = (rId: string): string[] => { const specialUsers: Set = new Set(); const r = mx.getRoom(rId); if (!r) return []; getAllVersionsRoomCreator(r).forEach((c) => specialUsers.add(c)); const powerLevels = getStateEvent( r, StateEvent.RoomPowerLevels, )?.getContent(); const { users_default: usersDefault, users } = powerLevels ?? {}; const defaultPower = typeof usersDefault === 'number' ? usersDefault : 0; if (typeof users === 'object') Object.keys(users).forEach((userId) => { if (users[userId] > defaultPower) { specialUsers.add(userId); } }); return Array.from(specialUsers); }; let perfectParent: string | undefined; let score = 0; const roomSpecialUsers = getSpecialUsers(roomId); parents.forEach((parentId) => { const parentSpecialUsers = getSpecialUsers(parentId); const matchedUsersCount = parentSpecialUsers.filter((userId) => roomSpecialUsers.includes(userId), ).length; if (matchedUsersCount > score) { score = matchedUsersCount; perfectParent = parentId; } }); return perfectParent; };