From 0a13f3cb689e1392a8dcab8e2a5ccdf2d168e99b Mon Sep 17 00:00:00 2001 From: root Date: Fri, 15 May 2026 00:47:21 -0400 Subject: [PATCH] fix+feat: bug fixes, deleted message placeholder, poll display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - IncomingCallNotification: track ring setTimeout ID and clear it on cleanup/dismiss — prevents orphaned callbacks after unmount - RoomTimeline: allow redacted m.room.message, m.room.encrypted and m.sticker events past the early-return filter so they hit the existing RedactedContent renderer showing the trash-icon placeholder New features: - PollContent component: read-only display of m.poll.start and org.matrix.msc3381.poll.start events (both stable Matrix 1.7 and MSC3381 unstable content keys); renders poll question + answer options inside the standard Message bubble; registered both as top-level event renderers and inside EncryptedContent callback so encrypted polls also render after decryption - Deleted message placeholder: m.room.message and m.room.encrypted redacted events now show the existing MessageDeletedContent component (trash icon + italic notice) instead of disappearing entirely — matches Element, FluffyChat, Commet, Nheko behaviour --- .../components/IncomingCallNotification.tsx | 11 +- .../message/content/PollContent.tsx | 90 +++++++++++++ src/app/components/message/content/index.ts | 1 + src/app/features/room/RoomTimeline.tsx | 121 +++++++++++++++++- 4 files changed, 218 insertions(+), 5 deletions(-) create mode 100644 src/app/components/message/content/PollContent.tsx diff --git a/src/app/components/IncomingCallNotification.tsx b/src/app/components/IncomingCallNotification.tsx index ac5c2246e..885afd6fe 100644 --- a/src/app/components/IncomingCallNotification.tsx +++ b/src/app/components/IncomingCallNotification.tsx @@ -42,24 +42,27 @@ function useRingTone(active: boolean) { osc.stop(t + dur); }; + let ringTimer: ReturnType | null = null; const ring = () => { if (cancelled) return; const now = ctx.currentTime; pulse(now, 880, 0.18); pulse(now + 0.28, 880, 0.18); - setTimeout(ring, 2200); + ringTimer = setTimeout(ring, 2200); }; ring(); - stopRef.current = () => { + const stop = () => { cancelled = true; + if (ringTimer !== null) clearTimeout(ringTimer); ctx.close(); }; + stopRef.current = stop; + return () => { - cancelled = true; - ctx.close(); + stop(); stopRef.current = null; }; }, [active]); diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx new file mode 100644 index 000000000..d2527916f --- /dev/null +++ b/src/app/components/message/content/PollContent.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Box, Text } from 'folds'; + +type PollTextValue = Array<{ body: string }> | string; + +function extractText(val: PollTextValue | undefined): string { + if (!val) return ''; + if (typeof val === 'string') return val; + return val[0]?.body ?? ''; +} + +type PollAnswer = { + 'm.id'?: string; + id?: string; + 'm.text'?: PollTextValue; + 'org.matrix.msc3381.poll.answer'?: { body: string }; +}; + +type PollData = { + question?: { body?: string; 'm.text'?: PollTextValue }; + answers?: PollAnswer[]; +}; + +export function PollContent({ content }: { content: Record }) { + const poll = ( + content['m.poll'] ?? content['org.matrix.msc3381.poll.start'] + ) as PollData | undefined; + + if (!poll) { + return ( + + Poll (unreadable format) + + ); + } + + const questionText = + extractText((poll.question as any)?.['m.text']) || + (poll.question as any)?.body || + 'Untitled poll'; + + return ( + + + ◉ Poll + + + {questionText} + + + {(poll.answers ?? []).map((answer, i) => { + const text = + extractText((answer as any)['m.text']) || + (answer as any)['org.matrix.msc3381.poll.answer']?.body || + `Option ${i + 1}`; + const id = answer['m.id'] ?? answer.id ?? String(i); + return ( +
+ {text} +
+ ); + })} +
+ + Open in Element to vote + +
+ ); +} diff --git a/src/app/components/message/content/index.ts b/src/app/components/message/content/index.ts index 6a31ed7ee..a74b9fc0a 100644 --- a/src/app/components/message/content/index.ts +++ b/src/app/components/message/content/index.ts @@ -5,3 +5,4 @@ export * from './AudioContent'; export * from './FileContent'; export * from './FallbackContent'; export * from './EventContent'; +export * from './PollContent'; diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 80a5e2e72..289d1dd33 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -63,6 +63,7 @@ import { MessageNotDecryptedContent, RedactedContent, MSticker, + PollContent, ImageContent, EventContent, } from '../../components/message'; @@ -1217,6 +1218,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli /> ); } + if ( + mEvent.getType() === 'm.poll.start' || + mEvent.getType() === 'org.matrix.msc3381.poll.start' + ) + return ; if (mEvent.getType() === MessageEvent.RoomMessageEncrypted) return ( @@ -1297,6 +1303,112 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); }, + 'org.matrix.msc3381.poll.start': (mEventId, mEvent, item, timelineSet, collapse) => { + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + const highlighted = focusItem?.index === item && focusItem.highlight; + return ( + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {mEvent.isRedacted() ? ( + + ) : ( + + )} + + ); + }, + 'm.poll.start': (mEventId, mEvent, item, timelineSet, collapse) => { + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + const highlighted = focusItem?.index === item && focusItem.highlight; + return ( + + ) + } + hideReadReceipts={hideActivity} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} + accessibleTagColors={accessiblePowerTagColors} + legacyUsernameColor={legacyUsernameColor || direct} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {mEvent.isRedacted() ? ( + + ) : ( + + )} + + ); + }, [StateEvent.RoomMember]: (mEventId, mEvent, item) => { const membershipChanged = isMembershipChanged(mEvent); if (membershipChanged && hideMembershipEvents) return null; @@ -1639,7 +1751,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli return null; } if (mEvent.isRedacted() && !showHiddenEvents) { - return null; + const t = mEvent.getType(); + if ( + t !== MessageEvent.RoomMessage && + t !== MessageEvent.RoomMessageEncrypted && + t !== MessageEvent.Sticker + ) { + return null; + } } if (!newDivider && readUptoEventIdRef.current) {