From c921f115216937e76dbaa04e08d9b915656b763d Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 13:35:02 -0400 Subject: [PATCH] feat(calls): 'Missed call from X' line in the timeline (#162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ring event (m.rtc.notification) now renders — only once the ring is over (its lifetime lapsed or the caller hung up) and only if we did not join between the ring and the hang-up — as a red 'Missed call from bob' line with the ring's time, in the same style as the call-membership summaries. Derived entirely from events already in the timeline; nothing is sent; a call we answered shows nothing extra, and a later separate call cannot retro-actively 'answer' an earlier missed one. Verified headless in a DM: ring ignored → line appears once bob hangs up; a second call that alice answers adds no line and leaves the first one in place. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/features/room/RoomTimeline.tsx | 79 ++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) 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 = ( +