From 1f558a190d7382514307d4f1fdeb82924288cdae Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 21 Apr 2026 21:27:18 -0400 Subject: [PATCH 1/4] 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/4] 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]); From ffa7b9898182953732dc118753e0f981119fc6ba Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:25:05 -0400 Subject: [PATCH 3/4] chore: add changeset --- .changeset/message-grouping.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/message-grouping.md diff --git a/.changeset/message-grouping.md b/.changeset/message-grouping.md new file mode 100644 index 000000000..c38516112 --- /dev/null +++ b/.changeset/message-grouping.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add configurable message grouping time threshold setting. From 45ae17cba0965a7632707483b4d5cb19ec7fe5f6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 20:14:10 -0400 Subject: [PATCH 4/4] fix(message-grouping): address Copilot review feedback - Add 'Off (no grouping)' option (value 0) to threshold select so users can disable grouping; unknown/custom values now have a valid fallback - Make messageGroupingThreshold required in UseProcessedTimelineOptions to force all callers to explicitly pass the user setting - Plumb messageGroupingThreshold through ThreadDrawer.tsx so thread timelines respect the same grouping setting as the main timeline --- src/app/features/room/ThreadDrawer.tsx | 2 ++ src/app/features/settings/experimental/MessageGrouping.tsx | 1 + src/app/hooks/timeline/useProcessedTimeline.ts | 4 ++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 070cdaf58..eb36b5c9b 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -146,6 +146,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra const [autoplayStickers] = useSetting(settingsAtom, 'autoplayStickers'); const [autoplayEmojis] = useSetting(settingsAtom, 'autoplayEmojis'); const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents'); + const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); const [showTombstoneEvents] = useSetting(settingsAtom, 'showTombstoneEvents'); const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly'); const [showBundledPreview] = useSetting(settingsAtom, 'bundledPreview'); @@ -262,6 +263,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra hideNickAvatarEvents: true, isReadOnly, hideMemberInReadOnly, + messageGroupingThreshold, }); // When the thread's own timeline is empty (server-side threads not yet fetched, diff --git a/src/app/features/settings/experimental/MessageGrouping.tsx b/src/app/features/settings/experimental/MessageGrouping.tsx index 95ce8139f..62b777228 100644 --- a/src/app/features/settings/experimental/MessageGrouping.tsx +++ b/src/app/features/settings/experimental/MessageGrouping.tsx @@ -6,6 +6,7 @@ import { SettingTile } from '$components/setting-tile'; import { SequenceCard } from '$components/sequence-card'; const THRESHOLD_OPTIONS: { value: number; label: string }[] = [ + { value: 0, label: 'Off (no grouping)' }, { value: 2, label: '2 min (default)' }, { value: 5, label: '5 min' }, { value: 15, label: '15 min (Discord-style)' }, diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index c517ba911..4afd2aed7 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -31,7 +31,7 @@ export interface UseProcessedTimelineOptions { * full user header. Defaults to 2 (the original behaviour). Set higher * (e.g. 15) for Discord-style compact grouping. */ - messageGroupingThreshold?: number; + messageGroupingThreshold: number; } export interface ProcessedEvent { @@ -84,7 +84,7 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, - messageGroupingThreshold = 2, + messageGroupingThreshold, }: UseProcessedTimelineOptions): ProcessedEvent[] { return useMemo(() => { let prevEvent: MatrixEvent | undefined;