Skip to content

Collapse runs of deleted messages in the timeline#7111

Open
manfrommedan wants to merge 2 commits into
element-hq:developfrom
manfrommedan:feature/collapse-deleted-messages
Open

Collapse runs of deleted messages in the timeline#7111
manfrommedan wants to merge 2 commits into
element-hq:developfrom
manfrommedan:feature/collapse-deleted-messages

Conversation

@manfrommedan

@manfrommedan manfrommedan commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Relates to #4417. The approach here was discussed and agreed with the team on that issue.

Content

Collapses runs of 3 or more consecutive deleted (redacted) messages in the timeline into a single, tappable group, instead of one "Message removed" tile per deletion. It reuses the existing grouped events mechanism (the same one behind the "N room changes" group), so a run renders as a single centred line, "N removed messages", that expands and collapses on tap. On by default, with no setting. Runs of one or two deletions are left as individual tiles, and day separators and read receipts are preserved.

Motivation and context

Element Web has collapsed runs of redacted messages for a long time and it is a real readability win in busy, heavily moderated rooms. Element X had nothing like it, so large deletion runs dominate the scrollback. This is the recurring ask in #4417.

Why this is not a repeat of #4434: that PR hid redacted events outright, which can mask moderation and broke day dividers and read receipts. This one collapses instead of hiding (nothing is removed, the count is shown, the full run is one tap away), a run is broken by any non-redacted item so a day separator is never swallowed, and the run's read receipts are aggregated onto the group the same way state-change groups already do.

The header shows only a count because the Rust SDK surfaces a redaction as a bare MsgLikeKind.Redacted with no redacter, so the app cannot tell who removed the messages; showing the original authors would be misleading. If the SDK later exposes the redacter, this header is the natural place to add "removed by X" like Web.

Screenshots / GIFs

The collapsed state is covered by a Composable Preview, TimelineItemRedactedMessagesGroupPreview, so it shows up in the file diff and the screenshot tests. The header looks like the existing "N room changes" group, with the label "N removed messages".

Tests

  • Open a room with a run of 3 or more consecutive deleted messages.
  • The run renders as a single "N removed messages" header instead of individual "Message removed" tiles.
  • Tap the header: it expands into the individual "Message removed" tiles. Tap again: it collapses.
  • A run of one or two deletions stays as individual tiles.
  • Deletions on different days do not merge across the day boundary; the day separator stays in place.

Tested devices

  • Physical
  • Emulator
  • OS version(s): Android 14 and 16

Checklist

  • I am aware of the etiquette.
  • This PR was made with the help of AI:
    • Yes. In this case, please request a review by Copilot.
    • No.
  • Changes have been tested on an Android device or Android emulator with API 24
  • UI change has been tested on both light and dark themes
  • Accessibility has been taken into account. See https://github.com/element-hq/element-x-android/blob/develop/CONTRIBUTING.md#accessibility
  • Pull request is based on the develop branch
  • Pull request title will be used in the release note, it clearly defines what will change for the user
  • Pull request includes screenshots or videos if containing UI changes
  • You've made a self review of your PR

Collapse a run of three or more consecutive deleted messages into a single
expandable group, reusing the existing state-change grouping (the same way
element-web does it) instead of showing one "Message removed" placeholder per
deletion. The header shows the count only ("N deleted messages"); the Rust SDK
does not expose who performed the redaction, so we don't attribute it.

On by default. Runs shorter than three are left as individual placeholders, and
day dividers and read receipts are preserved (a run is broken by any
non-redacted item, so a day is never emptied).
@manfrommedan manfrommedan requested a review from a team as a code owner June 26, 2026 13:59
@manfrommedan manfrommedan requested review from jmartinesp and removed request for a team June 26, 2026 13:59
@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your contribution! Here are a few things to check in the PR to ensure it's reviewed as quickly as possible:

  • If your pull request adds a feature or modifies the UI, this should have an equivalent pull request in the Element X iOS repo unless it only affects an Android-only behaviour or is behind a disabled feature flag, since we need parity in both clients to consider a feature done. It will also need to be approved by our product and design teams before being merged, so it's usually a good idea to discuss the changes in a Github issue first and then start working on them once the approach has been validated.
  • Your branch should be based on origin/develop, at least when it was created.
  • The title of the PR will be used for release notes, so it needs to describe the change visible to the user.
  • The test pass locally running ./gradlew test.
  • The code quality check suite pass locally running ./gradlew runQualityChecks.
  • If you modified anything related to the UI, including previews, you'll have to run the Record screenshots GH action in your forked repo: that will generate compatible new screenshots. However, given Github Actions limitations, it will prevent the CI from running temporarily, until you upload a new commit after that one. To do so, just pull the latest changes and push an empty commit.

@github-actions github-actions Bot added the Z-Community-PR Issue is solved by a community member's PR label Jun 26, 2026
@manfrommedan

Copy link
Copy Markdown
Contributor Author

A quick note on the failing checks, in case it helps triage.

The unit test job fails on one screenshot test, for the new preview added here, TimelineItemRedactedMessagesGroupPreview. There is no recorded Paparazzi golden for it yet, so it is expected to fail until one is recorded. Happy for it to be picked up by your screenshot recording flow, or if you would prefer I add the PNG to the branch, just point me at the preferred way and I will.

The other snapshot failures in that job (BlockedUsersViewPreview, RoomMemberModerationViewPreview) and the Maestro suite are in areas this PR does not touch, so they look unrelated to this change, likely pre-existing or flaky. Flagging just in case.

@jmartinesp

jmartinesp commented Jun 26, 2026

Copy link
Copy Markdown
Member

Thanks for the PR. I'll test it, but the code seems fine at a first glance. Could you record the screenshot tests? (seems like we wrote both messages at the same time, the way to record screenshots is explained at the last point of the automatic PR-from-fork message above).

Also, next time please try following the provided PR template so it's easier to check whether you tested compatibility or whether the PR was built with AI (I assume it was?).


I'll mark the PR as blocked so I don't forget about adding the strings to localazy, once that's done I'll remove the label.

@manfrommedan

Copy link
Copy Markdown
Contributor Author

I've updated the description to follow the template, including the step by step testing notes.

On the screenshot tests: I'm not set up to record the Paparazzi goldens locally, and I can't run the record workflow from my fork. Would you be able to apply the Record-Screenshots label so the golden for the new preview gets generated on your side? Happy to help in whatever way is easiest for you.

@jmartinesp

Copy link
Copy Markdown
Member

On the screenshot tests: I'm not set up to record the Paparazzi goldens locally, and I can't run the record workflow from my fork. Would you be able to apply the Record-Screenshots label so the golden for the new preview gets generated on your side? Happy to help in whatever way is easiest for you.

Ah, that might be a side-effect of the new rules enforced by #7098. Sadly, we can't run workflows on your fork from our repo or using the GH workflow, so we'll have to fix the issue caused by the checkout action upgrade before you can record those, sorry.

@jmartinesp

Copy link
Copy Markdown
Member

Just to double check, which kind of issue do you have when trying to record screenshots? Github won't let you even try to start the action? Do you have any kind of error log?

@manfrommedan

Copy link
Copy Markdown
Contributor Author

Github does not let me start it at all, so there is no run log to share. Actions are enabled in the fork settings (permissions set to allow all actions), but no workflows are registered for the fork: the REST API repos/<fork>/actions/workflows returns total_count: 0, and triggering it returns HTTP 404: Not Found for recordScreenshots.yml, even though the file is present on the fork's default branch (develop). The Actions tab also does not show the usual "enable workflows" banner, so there is nothing to click to activate them. So it never gets as far as starting the action.

Happy to try again once the checkout action fix lands on your side.

@jmartinesp

Copy link
Copy Markdown
Member

Oh ok. f that's the case then I don't think the checkout changes will help, to be honest, it seems more like a GH hiccup/issue. Worst case scenario, we can merge this to a branch of ours and we run the record screenshots flow there.

The reason why we need to record this using GH is different OS or even Java runtimes render screenshots slightly differently, so if we don't use the same GH runner we end up rewriting tons of screenshots for no good reason.

@manfrommedan

Copy link
Copy Markdown
Contributor Author

Sounds like a plan, no rush at all. Thanks for jumping on it so fast. Just shout if you need anything from me.

@jmartinesp jmartinesp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested the feature and it works quite well, thank you!

Code-wise though, I believe this should be part of the TimelineGrouper implementation, it could even be the last action performed after we've grouped other state/membership events. Unless there is some reason we shouldn't do this?

Comment on lines +316 to +321
// Collapse runs of consecutive deleted messages into a single expandable group instead of
// showing each as its own placeholder, like element-web. Day dividers stay untouched, so a
// day is never emptied.
val visibleTimelineItems = remember(timelineItems) {
timelineItems.collapseRedactedRuns().toImmutableList()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this happen as part of TimelineItemGrouper.group instead? It seems like it would belong there and it's a bit weird to have it as a post-processing and synchronous step in the presenter.

Run the collapse as the final step of TimelineItemGrouper.group() instead of
a separate pass in the presenter, as suggested in review. Kept as its own
finalization step rather than via canBeGrouped() so the group stays all
redacted and the count-only header is preserved.
@manfrommedan

Copy link
Copy Markdown
Contributor Author

Good call, done. It now lives in TimelineItemGrouper as the last thing group() does, right after the state and membership grouping, and the separate pass in the presenter is gone.

The only reason it started out outside the grouper was caution: this was my first change in this area, so I bolted it on after the grouper's output to keep it self contained and not poke at something so central. Now that it works and you're happy with it, putting it in the grouper where it belongs makes more sense.

On whether to just fold redacted into canBeGrouped() instead: the reason I didn't is the header. It reads "N removed messages" only because the group is entirely redacted (isRedactedMessagesGroup()); if redacted events went through the normal path they'd get grouped together with the state and membership changes around them, and that header would break and start mixing unrelated things in. Keeping it as its own step is what keeps that clean.

group() ends with result.collapseRedactedRuns() now, with a couple of tests in TimelineItemGrouperTest. Happy to rework any of it if you see it differently.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

X-Blocked Z-Community-PR Issue is solved by a community member's PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants