From 1f558a190d7382514307d4f1fdeb82924288cdae Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 21 Apr 2026 21:27:18 -0400 Subject: [PATCH 1/2] feat(timeline): configurable message grouping threshold Add Discord-style message grouping where consecutive messages from the same sender within a configurable time window collapse the sender header. - Add messageGroupingThreshold to Settings type (default: 2 min) - Replace hardcoded 2-min threshold in useProcessedTimeline with the setting - Wire useSetting in RoomTimeline and pass to useProcessedTimeline - Add MessageGrouping component in Experimental settings with 5 options: 2 min (default), 5 min, 15 min (Discord-style), 30 min, 60 min Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 2 + .../settings/experimental/Experimental.tsx | 2 + .../settings/experimental/MessageGrouping.tsx | 53 +++++++++++++++++++ .../hooks/timeline/useProcessedTimeline.ts | 11 +++- src/app/state/settings.ts | 6 +++ 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/app/features/settings/experimental/MessageGrouping.tsx diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 92f82061b..4c9ad84c8 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -161,6 +161,7 @@ export function RoomTimeline({ ); const [incomingInlineImagesMaxHeight] = useSetting(settingsAtom, 'incomingInlineImagesMaxHeight'); const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly'); + const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const showClientUrlPreview = room.hasEncryptionStateEvent() @@ -784,6 +785,7 @@ export function RoomTimeline({ hideNickAvatarEvents, isReadOnly, hideMemberInReadOnly, + messageGroupingThreshold, }); processedEventsRef.current = processedEvents; diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 330412185..6ffded609 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -10,6 +10,7 @@ import { Sync } from '../general'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; +import { MessageGrouping } from './MessageGrouping'; function PersonaToggle() { const [showPersonaSetting, setShowPersonaSetting] = useSetting( @@ -59,6 +60,7 @@ export function Experimental({ requestBack, requestClose }: Readonly + diff --git a/src/app/features/settings/experimental/MessageGrouping.tsx b/src/app/features/settings/experimental/MessageGrouping.tsx new file mode 100644 index 000000000..95ce8139f --- /dev/null +++ b/src/app/features/settings/experimental/MessageGrouping.tsx @@ -0,0 +1,53 @@ +import { Box, Text, config } from 'folds'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; +import { SequenceCardStyle } from '$features/common-settings/styles.css'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCard } from '$components/sequence-card'; + +const THRESHOLD_OPTIONS: { value: number; label: string }[] = [ + { value: 2, label: '2 min (default)' }, + { value: 5, label: '5 min' }, + { value: 15, label: '15 min (Discord-style)' }, + { value: 30, label: '30 min' }, + { value: 60, label: '60 min' }, +]; + +export function MessageGrouping() { + const [threshold, setThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); + + return ( + + Message Grouping + + setThreshold(Number(e.target.value))} + style={{ + background: 'var(--bg-surface)', + color: 'var(--tc-surface-high)', + border: '1px solid var(--bg-surface-border)', + borderRadius: config.radii.R300, + padding: `${config.space.S100} ${config.space.S200}`, + fontSize: config.fontSize.T300, + cursor: 'pointer', + }} + > + {THRESHOLD_OPTIONS.map(({ value, label }) => ( + + ))} + + } + /> + + + ); +} diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 9609dafc0..c517ba911 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -26,6 +26,12 @@ export interface UseProcessedTimelineOptions { * where every reply legitimately has `threadRootId` set to the root. */ skipThreadFilter?: boolean; + /** + * Minutes of inactivity before a new message from the same sender gets a + * full user header. Defaults to 2 (the original behaviour). Set higher + * (e.g. 15) for Discord-style compact grouping. + */ + messageGroupingThreshold?: number; } export interface ProcessedEvent { @@ -78,6 +84,7 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, + messageGroupingThreshold = 2, }: UseProcessedTimelineOptions): ProcessedEvent[] { return useMemo(() => { let prevEvent: MatrixEvent | undefined; @@ -157,7 +164,8 @@ export function useProcessedTimeline({ let collapsed = false; if (isPrevRendered && !dayDivider && prevEvent !== undefined) { if (isMessageEvent) { - const withinTimeThreshold = minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; + const withinTimeThreshold = + minuteDifference(prevEvent.getTs(), mEvent.getTs()) < messageGroupingThreshold; const senderMatch = prevEvent.getSender() === eventSender; const typeMatch = normalizeMessageType(prevEvent.getType()) === normalizeMessageType(type); @@ -211,5 +219,6 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, + messageGroupingThreshold, ]); } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index b1b744c1f..41562f40f 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -170,6 +170,9 @@ export interface Settings { vcmsgSidebarWidth: number; widgetSidebarWidth: number; + // experimental + messageGroupingThreshold: number; + // furry stuff renderAnimals: boolean; @@ -301,6 +304,9 @@ export const defaultSettings: Settings = { threadRootHeight: 220, vcmsgSidebarWidth: 399, widgetSidebarWidth: 420, + + // experimental + messageGroupingThreshold: 2, // furry stuff renderAnimals: true, From 44ef75cb0bf32fcf1af2129eaf5924178e37b59c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 14 May 2026 15:16:26 -0400 Subject: [PATCH 2/2] fix: backport timeline cleanup --- src/app/features/room/RoomTimeline.tsx | 27 ++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 4c9ad84c8..6d056baa4 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -86,6 +86,24 @@ import { import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer'; import * as css from './RoomTimeline.css'; +function findLastOwnEditableProcessedEvent( + events: ProcessedEvent[], + myUserId: string | null | undefined +): ProcessedEvent | undefined { + for (let i = events.length - 1; i >= 0; i -= 1) { + const event = events[i]; + if (!event) continue; + if ( + event.mEvent.getSender() === myUserId && + event.mEvent.getEffectiveEvent()?.type === 'm.room.message' && + !event.mEvent.isRedacted() + ) { + return event; + } + } + return undefined; +} + const TimelineFloat = as<'div', css.TimelineFloatVariants>( ({ position, className, ...props }, ref) => ( { const myUserId = mx.getUserId(); - const found = [...processedEventsRef.current] - .toReversed() - .find( - (e) => - e.mEvent.getSender() === myUserId && - e.mEvent.getType() === 'm.room.message' && - !e.mEvent.isRedacted() - ); + const found = findLastOwnEditableProcessedEvent(processedEventsRef.current, myUserId); if (found?.mEvent.getId()) actions.handleEdit(found.mEvent.getId()); }; }, [onEditLastMessageRef, mx, actions]);