- 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>
139 lines
4.1 KiB
Dart
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;
|
|
}
|