feat(calls): 'Missed call from X' line in the timeline (#162)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 13:35:02 -04:00
co-authored by Claude Opus 5
parent 0e2671891f
commit c921f11521
+79
View File
@@ -14,6 +14,7 @@ import {
Direction,
EventTimeline,
EventTimelineSet,
EventType,
EventTimelineSetHandlerMap,
IContent,
MatrixClient,
@@ -1908,6 +1909,84 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Event>
);
},
// [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 = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
return (
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
messageSpacing={messageSpacing}
canDelete={false}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={Icons.PhoneDown}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" style={{ color: color.Critical.Main }}>
Missed {content['m.call.intent'] === 'video' ? 'video ' : ''}call from{' '}
<b>{senderName}</b>
</Text>
</Box>
}
/>
</Event>
);
},
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';