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 projectTimeline(List entries) { final shown = [ 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 = []; 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; }