Files
tajniak81andClaude Opus 5.5 4271d2ab17 Mobile: replies, reactions and threads
- Replies: Reply on a message's long press puts it above the composer;
  sent replies quote the message they answer (sender and first line),
  with the reply fallback left out of the body.
- Reactions: six quick ones and any other on long press, chips under the
  message with counts, the user's own highlighted; tapping a chip adds or
  takes back a reaction, a long press lists who reacted.
- Threads: Reply in thread opens a thread view (the root, then its
  replies, older ones fetched from /relations, new ones live from the room
  timeline) with a composer that sends into the thread. Thread replies
  are hidden from the main timeline; the root shows "N replies". Room info
  lists the room's threads (/threads).

The composer moved out of the room screen into widgets/composer.dart so
the room and thread views share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:00:57 +02:00

139 lines
4.1 KiB
Dart

import 'package:matrix/matrix.dart';
/// How an event shows in the timeline.
enum EntryKind {
/// A bubble: a message, sticker, or an encrypted event.
message,
/// A one-line notice in the middle: a join, a rename, encryption turned
/// on.
notice,
/// Not shown: edits, reactions, redactions, call signalling, state
/// nobody needs to read.
hidden,
}
/// The state events shown as notices. Everything else in a room's state
/// (power levels, join rules, ACLs, widgets) is hidden: it is what the room
/// info screen is for.
const noticeStateTypes = {
EventTypes.RoomMember,
EventTypes.RoomName,
EventTypes.RoomTopic,
EventTypes.RoomAvatar,
EventTypes.RoomCreate,
EventTypes.Encryption,
EventTypes.RoomTombstone,
};
/// [inThread] is set for a thread's own view; in the room's main timeline
/// a thread's replies are hidden behind the "N replies" under its root.
EntryKind kindOf(Event event, {bool inThread = false}) {
if (event.relationshipType == RelationshipTypes.edit) return EntryKind.hidden;
if (!inThread && event.relationshipType == RelationshipTypes.thread) {
return EntryKind.hidden;
}
switch (event.type) {
case EventTypes.Message:
case EventTypes.Sticker:
case EventTypes.Encrypted:
return EntryKind.message;
}
if (noticeStateTypes.contains(event.type)) {
if (event.type == EventTypes.RoomMember && _isProfileOnlyChange(event)) {
// A display name or avatar change of somebody already in the room is
// noise in a busy room; the bubble shows the new name anyway.
return EntryKind.hidden;
}
return EntryKind.notice;
}
return EntryKind.hidden;
}
bool _isProfileOnlyChange(Event event) {
final prev = event.prevContent;
return prev != null &&
prev['membership'] == 'join' &&
event.content['membership'] == 'join';
}
/// One row of the timeline, as the screen draws it.
sealed class TimelineItem {
const TimelineItem();
}
class DayItem extends TimelineItem {
const DayItem(this.day);
final DateTime day;
}
class EventItem extends TimelineItem {
const EventItem(
this.index, {
required this.kind,
required this.firstInGroup,
required this.lastInGroup,
});
/// The position of the event in the list given to [projectTimeline].
final int index;
final EntryKind kind;
/// Starts a run of messages from one sender: shows the name.
final bool firstInGroup;
/// Ends a run: shows the avatar and the time.
final bool lastInGroup;
}
/// What [projectTimeline] needs to know about an event.
typedef Entry = ({String sender, DateTime time, EntryKind kind});
/// Messages from one sender this close together are drawn as one group.
const groupGap = Duration(minutes: 5);
/// Turns events, newest first as the SDK keeps them, into rows, newest first
/// for a reversed list: hidden events dropped, a [DayItem] above the first
/// event of each day, and runs of messages from one sender grouped.
List<TimelineItem> projectTimeline(List<Entry> entries) {
final shown = <int>[
for (var i = 0; i < entries.length; i++)
if (entries[i].kind != EntryKind.hidden) i,
];
bool sameGroup(int a, int b) {
final x = entries[a], y = entries[b];
return x.kind == EntryKind.message &&
y.kind == EntryKind.message &&
x.sender == y.sender &&
_sameDay(x.time, y.time) &&
x.time.difference(y.time).abs() <= groupGap;
}
final items = <TimelineItem>[];
for (var n = 0; n < shown.length; n++) {
final i = shown[n];
final newer = n > 0 ? shown[n - 1] : null;
final older = n + 1 < shown.length ? shown[n + 1] : null;
items.add(
EventItem(
i,
kind: entries[i].kind,
firstInGroup: older == null || !sameGroup(i, older),
lastInGroup: newer == null || !sameGroup(i, newer),
),
);
final time = entries[i].time.toLocal();
if (older == null || !_sameDay(entries[older].time, entries[i].time)) {
items.add(DayItem(DateTime(time.year, time.month, time.day)));
}
}
return items;
}
bool _sameDay(DateTime a, DateTime b) {
final x = a.toLocal(), y = b.toLocal();
return x.year == y.year && x.month == y.month && x.day == y.day;
}