CI / Build & Quality Checks (pull_request) Successful in 2m22s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m16s
The media gallery and activity log register filtered timeline sets on the room (getOrCreateFilteredTimelineSet); the room re-emits their events as live RoomEvent.Timeline, so every matching event reached "new event in the room" listeners twice for the rest of the session. The timeline window shifted by an extra message per event (loaded messages vanished), and notifications, unread counts, widget feeds and ring handling saw duplicates. isFromFilteredTimelineSet() (unit-tested) identifies emits from a filtered set; the room timeline, roomToUnread, message notifications, clock-skew sampling, call ring/decline handling and widget event feeds ignore them. Verified headless (40 messages, every 5th a file; gallery opened; Bob sends 3 files + 1 text): before, the first rendered message drifted from the room start to "file15"; now it stays put. Fixes #259 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
862 lines
30 KiB
TypeScript
862 lines
30 KiB
TypeScript
import { IconName, IconSrc } from 'folds';
|
|
|
|
import {
|
|
AccountDataEvents,
|
|
EventTimeline,
|
|
EventTimelineSet,
|
|
EventType,
|
|
IContent,
|
|
IMentions,
|
|
IPowerLevelsContent,
|
|
IPushRule,
|
|
IPushRules,
|
|
ISendEventResponse,
|
|
JoinRule,
|
|
MatrixClient,
|
|
MatrixEvent,
|
|
MsgType,
|
|
NotificationCountType,
|
|
RelationType,
|
|
Room,
|
|
RoomMember,
|
|
TimelineEvents,
|
|
IRoomTimelineData,
|
|
} 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,
|
|
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<T extends object>(
|
|
mx: MatrixClient,
|
|
roomId: string,
|
|
eventType: StateEvent,
|
|
content: T,
|
|
stateKey = '',
|
|
): Promise<ISendEventResponse> {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
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<ISendEventResponse> {
|
|
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 keyof AccountDataEvents);
|
|
|
|
export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => {
|
|
const roomIds = new Set<string>();
|
|
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<string> => {
|
|
const allParents = new Set<string>();
|
|
|
|
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<string[]>((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<string>();
|
|
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));
|
|
|
|
const computeNotificationType = (
|
|
mx: MatrixClient,
|
|
roomId: string,
|
|
pushRulesContent: IPushRules | undefined,
|
|
): NotificationType => {
|
|
let roomPushRule: IPushRule | undefined;
|
|
try {
|
|
roomPushRule = mx.getRoomPushRule('global', roomId);
|
|
} catch {
|
|
roomPushRule = undefined;
|
|
}
|
|
|
|
if (!roomPushRule) {
|
|
const overrideRules = pushRulesContent?.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;
|
|
};
|
|
|
|
// #77 — `getNotificationType` is called synchronously for every live timeline
|
|
// event in every room, but its result only ever changes when the `m.push_rules`
|
|
// account-data event updates. matrix-js-sdk replaces the stored account-data
|
|
// `MatrixEvent`'s content wholesale on every `m.push_rules` update (see
|
|
// `store.storeAccountDataEvents`), so keying a cache off that content object's
|
|
// identity naturally invalidates itself on `ClientEvent.AccountData` without
|
|
// needing an explicit listener here. A bursty sync backlog across many rooms
|
|
// then hits this cache instead of re-running `getRoomPushRule` + a linear scan
|
|
// of the override rules per event.
|
|
const notificationTypeCache = new WeakMap<object, Map<string, NotificationType>>();
|
|
// Stable key to use when there is no push-rules account data at all yet.
|
|
const NO_PUSH_RULES_KEY: object = {};
|
|
|
|
export const getNotificationType = (mx: MatrixClient, roomId: string): NotificationType => {
|
|
const pushRulesContent = mx.getAccountData(EventType.PushRules)?.getContent<IPushRules>();
|
|
const cacheKey = pushRulesContent ?? NO_PUSH_RULES_KEY;
|
|
|
|
let roomCache = notificationTypeCache.get(cacheKey);
|
|
if (!roomCache) {
|
|
roomCache = new Map();
|
|
notificationTypeCache.set(cacheKey, roomCache);
|
|
}
|
|
|
|
const cached = roomCache.get(roomId);
|
|
if (cached !== undefined) return cached;
|
|
|
|
const result = computeNotificationType(mx, roomId, pushRulesContent);
|
|
roomCache.set(roomId, result);
|
|
return result;
|
|
};
|
|
|
|
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<string>,
|
|
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<UnreadInfo[]>((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<IconName, IconSrc>,
|
|
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 = '</mx-reply>';
|
|
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 = `<a href="https://matrix.to/#/${encodeURIComponent(
|
|
roomId,
|
|
)}/${encodeURIComponent(eventId)}">In reply to</a>`;
|
|
const userLink = `<a href="https://matrix.to/#/${encodeURIComponent(userId)}">${userId}</a>`;
|
|
|
|
return `<mx-reply><blockquote>${replyToLink}${userLink}<br />${formattedBody}</blockquote></mx-reply>`;
|
|
};
|
|
|
|
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<string> => {
|
|
const creators = new Set<string>();
|
|
|
|
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
|
|
const createContent = createEvent?.getContent<IRoomCreateContent>();
|
|
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<string> = new Set();
|
|
|
|
const r = mx.getRoom(rId);
|
|
if (!r) return [];
|
|
|
|
getAllVersionsRoomCreator(r).forEach((c) => specialUsers.add(c));
|
|
|
|
const powerLevels = getStateEvent(
|
|
r,
|
|
StateEvent.RoomPowerLevels,
|
|
)?.getContent<IPowerLevelsContent>();
|
|
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* [Gitea #259] Whether a RoomEvent.Timeline emit came from a FILTERED timeline
|
|
* set. The media gallery and activity log register filtered sets on the room
|
|
* (getOrCreateFilteredTimelineSet), and the room re-emits their events with
|
|
* liveEvent: true, so listeners that mean "a new event in the room" saw each
|
|
* matching event twice (the timeline window drifted, notifications doubled).
|
|
* The room's main and thread sets have no filter.
|
|
*/
|
|
export const isFromFilteredTimelineSet = (data?: IRoomTimelineData): boolean =>
|
|
!!data?.timeline?.getTimelineSet()?.getFilter();
|