diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index fd58a6aa9..eb3f1b919 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -14,6 +14,7 @@ import {
Direction,
EventTimeline,
EventTimelineSet,
+ EventType,
EventTimelineSetHandlerMap,
IContent,
MatrixClient,
@@ -1908,6 +1909,84 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
},
+ // [Gitea #162] A ring (m.rtc.notification) that we neither answered nor
+ // declined, and whose ring window has passed, renders as a local-only
+ // "Missed call from X" line. Derived from events already in the
+ // timeline; nothing is sent. Disappears if our own call membership
+ // follows within the ring window (we picked up).
+ [EventType.RTCNotification]: (mEventId, mEvent, item) => {
+ const senderId = mEvent.getSender() ?? '';
+ if (!senderId || senderId === mx.getUserId()) return null;
+ const content = mEvent.getContent();
+ const mention =
+ content['m.mentions']?.room ||
+ content['m.mentions']?.user_ids?.includes(mx.getSafeUserId());
+ if (!mention) return null;
+ const lifetime = Math.min(
+ typeof content.lifetime === 'number' ? content.lifetime : 30000,
+ 120000,
+ );
+ const liveEvents = room.getLiveTimeline().getEvents();
+ const memberEventsAfter = liveEvents.filter(
+ (ev) => ev.getType() === StateEvent.GroupCallMemberPrefix && ev.getTs() >= mEvent.getTs(),
+ );
+ // The ring is over once its window lapsed or the caller hung up.
+ const callerEndTs = memberEventsAfter
+ .find((ev) => ev.getSender() === senderId && !ev.getContent().application)
+ ?.getTs();
+ if (callerEndTs === undefined && Date.now() < mEvent.getTs() + lifetime) return null;
+ // "Answered" = our own join between the ring and the caller hanging up
+ // (a later, separate call must not retro-actively answer this one).
+ const windowEnd = Math.min(
+ mEvent.getTs() + lifetime + 60_000,
+ callerEndTs !== undefined ? callerEndTs + 5_000 : Infinity,
+ );
+ const answered = memberEventsAfter.some(
+ (ev) =>
+ ev.getSender() === mx.getUserId() &&
+ ev.getTs() <= windowEnd &&
+ !!ev.getContent().application,
+ );
+ if (answered) return null;
+ const highlighted = focusItem?.index === item && focusItem.highlight;
+ const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
+ const timeJSX = (
+
+ );
+ return (
+
+
+
+ Missed {content['m.call.intent'] === 'video' ? 'video ' : ''}call from{' '}
+ {senderName}
+
+
+ }
+ />
+
+ );
+ },
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';