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
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:
@@ -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() ?? '';
|
||||
|
||||
Reference in New Issue
Block a user