diff --git a/commet/lib/client/matrix/matrix_room.dart b/commet/lib/client/matrix/matrix_room.dart index 6af321e96..883e3ff7e 100644 --- a/commet/lib/client/matrix/matrix_room.dart +++ b/commet/lib/client/matrix/matrix_room.dart @@ -862,6 +862,9 @@ class MatrixRoom extends Room { await tl.setReadMarker(public: public); } + @override + String get lastRead => _matrixRoom.fullyRead; + @override RoomVisibility get visibility { switch (_matrixRoom.joinRules) { diff --git a/commet/lib/client/matrix/matrix_timeline.dart b/commet/lib/client/matrix/matrix_timeline.dart index 1e853a78f..c9692e03a 100644 --- a/commet/lib/client/matrix/matrix_timeline.dart +++ b/commet/lib/client/matrix/matrix_timeline.dart @@ -7,6 +7,7 @@ import 'package:commet/client/matrix/timeline_events/matrix_timeline_event.dart' import 'package:commet/client/timeline_events/timeline_event.dart'; import 'package:commet/client/timeline_events/timeline_event_message.dart'; import 'package:commet/client/timeline_events/timeline_event_sticker.dart'; +import 'package:commet/debug/log.dart'; import '../client.dart'; import 'package:matrix/matrix.dart' as matrix; @@ -117,10 +118,30 @@ class MatrixTimeline extends Timeline { @override Future loadMoreFuture() async { if (canLoadFuture) { - var f = _matrixTimeline?.requestFuture(); - + int waitSec = 4; _loadingStatusChangedController.add(null); - await f; + while (true) { + var f = _matrixTimeline?.requestFuture(); + + try { + await f; + _loadingStatusChangedController.add(null); + break; + } on matrix.MatrixException catch (e) { + if (e.error == matrix.MatrixError.M_LIMIT_EXCEEDED) { + Log.i("Rate limited, waiting $waitSec second(s)..."); + await Future.delayed(Duration(seconds: waitSec)); + waitSec *= 2; + continue; + } else { + Log.e(e); + break; + } + } catch (e) { + Log.e(e); + break; + } + } } } diff --git a/commet/lib/client/matrix/timeline_events/matrix_timeline_event.dart b/commet/lib/client/matrix/timeline_events/matrix_timeline_event.dart index 7948e14a9..c4a7d7c8f 100644 --- a/commet/lib/client/matrix/timeline_events/matrix_timeline_event.dart +++ b/commet/lib/client/matrix/timeline_events/matrix_timeline_event.dart @@ -28,6 +28,12 @@ abstract class MatrixTimelineEvent implements TimelineEvent { String get source => const JsonEncoder.withIndent(' ').convert(event.toJson()); + @override + bool get mentionsRoom => event.mentions.room; + + @override + List get mentions => event.mentions.userIds; + @override TimelineEventStatus get status => switch (event.status) { matrix.EventStatus.error => TimelineEventStatus.error, diff --git a/commet/lib/client/matrix_background/matrix_background_events.dart b/commet/lib/client/matrix_background/matrix_background_events.dart index b8e16a444..9a4bf83f5 100644 --- a/commet/lib/client/matrix_background/matrix_background_events.dart +++ b/commet/lib/client/matrix_background/matrix_background_events.dart @@ -67,6 +67,12 @@ class MatrixBackgroundTimelineEventMessage implements TimelineEventMessage { @override String get source => throw UnimplementedError(); + @override + bool get mentionsRoom => throw UnimplementedError(); + + @override + List get mentions => throw UnimplementedError(); + @override TimelineEventStatus get status => throw UnimplementedError(); diff --git a/commet/lib/client/matrix_background/matrix_background_room.dart b/commet/lib/client/matrix_background/matrix_background_room.dart index 67513dafd..b9f2d2364 100644 --- a/commet/lib/client/matrix_background/matrix_background_room.dart +++ b/commet/lib/client/matrix_background/matrix_background_room.dart @@ -350,6 +350,9 @@ class MatrixBackgroundRoom implements Room { throw UnimplementedError(); } + @override + String get lastRead => throw UnimplementedError(); + @override // TODO: implement visibility RoomVisibility get visibility => throw UnimplementedError(); diff --git a/commet/lib/client/room.dart b/commet/lib/client/room.dart index 5a3173a7b..b205a3264 100644 --- a/commet/lib/client/room.dart +++ b/commet/lib/client/room.dart @@ -242,6 +242,8 @@ abstract class Room { Future markAsRead(); + String get lastRead; + @override bool operator ==(Object other) { if (other is! Room) return false; diff --git a/commet/lib/client/timeline.dart b/commet/lib/client/timeline.dart index 3c56c26a5..48e06ab32 100644 --- a/commet/lib/client/timeline.dart +++ b/commet/lib/client/timeline.dart @@ -64,7 +64,7 @@ abstract class Timeline { final Map _eventsDict = {}; late StreamController onEventAdded = StreamController.broadcast(sync: true); - late StreamController onChange = StreamController.broadcast(sync: true); + late StreamController onChange = StreamController.broadcast(sync: false); late StreamController onRemove = StreamController.broadcast(sync: true); late Client client; late Room room; diff --git a/commet/lib/client/timeline_events/timeline_event.dart b/commet/lib/client/timeline_events/timeline_event.dart index 8fbe01f40..944e5750c 100644 --- a/commet/lib/client/timeline_events/timeline_event.dart +++ b/commet/lib/client/timeline_events/timeline_event.dart @@ -9,6 +9,8 @@ abstract class TimelineEvent { String get senderId; DateTime get originServerTs; String get source; + bool get mentionsRoom; + List get mentions; bool get editable; diff --git a/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget_view.dart b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget_view.dart index 03c03ffd1..c3c9f13f6 100644 --- a/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget_view.dart +++ b/commet/lib/ui/molecules/room_timeline_widget/room_timeline_widget_view.dart @@ -82,6 +82,7 @@ class RoomTimelineWidgetViewState extends State { bool isLoadingHistory = false; MessageEffectComponent? effects; + String? lastReadEventId; @override void initState() { @@ -102,11 +103,24 @@ class RoomTimelineWidgetViewState extends State { } } - isLoadingFuture = false; - isLoadingHistory = false; + String? targetEventId = timeline.room.lastRead; // timeline.contextEventId; this.timeline = timeline; - recentItemsCount = timeline.events.length; + var index = timeline.events.indexWhere((i) => i.eventId == targetEventId); + + if (index > 0) { + lastReadEventId = targetEventId; + + isLoadingFuture = false; + isLoadingHistory = false; + + recentItemsCount = index; + } else { + if (timeline.events.length > 1) { + recentItemsCount = 1; + } + } + var receipts = timeline.room.getComponent(); subscriptions = [ timeline.onEventAdded.stream.listen(onEventAdded), @@ -218,16 +232,16 @@ class RoomTimelineWidgetViewState extends State { } void onAfterFirstFrame(_) { - if (timeline.events.isNotEmpty) { - widget.markAsRead?.call(timeline.events.first); - } - if (controller.hasClients) { double extent = controller.position.minScrollExtent; + + if (controller.position.viewportDimension < -extent) { + extent = -controller.position.viewportDimension / 2; + } + controller = ScrollController(initialScrollOffset: extent); scrollViewKey = GlobalKey(); controller.addListener(onScroll); - widget.onAttachedToBottom?.call(); setState(() { firstFrame = false; }); @@ -266,18 +280,24 @@ class RoomTimelineWidgetViewState extends State { if (controller.offset > controller.position.maxScrollExtent - loadingThreshold && !timeline.isLoadingHistory && - timeline.canLoadHistory) { - timeline.loadMoreHistory().then((_) { - WidgetsBinding.instance.addPostFrameCallback((_) => onScroll()); + timeline.canLoadHistory) + setState(() async { + await timeline.loadMoreHistory().then((_) { + WidgetsBinding.instance.addPostFrameCallback((_) => onScroll()); + }); }); - } if (controller.offset < (controller.position.minScrollExtent + loadingThreshold) && !timeline.isLoadingFuture && - timeline.canLoadFuture) { - timeline.loadMoreFuture(); - } + timeline.canLoadFuture) + setState(() async { + await timeline.loadMoreFuture(); + eventKeys = List.from( + timeline.events + .map((e) => (GlobalKey(debugLabel: e.eventId), e.eventId)), + growable: true); + }); } void animateAndSnapToBottom() { @@ -379,6 +399,10 @@ class RoomTimelineWidgetViewState extends State { ), ); })), + // The slivers are split into recent and history events and + // are rendered separately. This prevents the timeline from + // jumping around and allows jumping to specific indices + // with more reliability. SliverList( key: recentItemsKey, // Recent Items @@ -390,7 +414,10 @@ class RoomTimelineWidgetViewState extends State { recentItemsCount - sliverIndex - 1; numBuilds += 1; - var key = eventKeys[timelineIndex]; + var key; + + key = eventKeys[timelineIndex]; + assert( key.$2 == timeline.events[timelineIndex].eventId); @@ -409,6 +436,7 @@ class RoomTimelineWidgetViewState extends State { setReplyingEvent: widget.setReplyingEvent, isThreadTimeline: widget.isThreadTimeline, highlightedEventId: highlightedEventId, + lastReadEventId: lastReadEventId, previewMedia: widget.timeline.room.shouldPreviewMedia, jumpToEvent: jumpToEvent, @@ -439,7 +467,10 @@ class RoomTimelineWidgetViewState extends State { // ignore: avoid_print var timelineIndex = recentItemsCount + sliverIndex; - var key = eventKeys[timelineIndex]; + var key; + + key = eventKeys[timelineIndex]; + assert( key.$2 == timeline.events[timelineIndex].eventId); @@ -460,6 +491,7 @@ class RoomTimelineWidgetViewState extends State { highlightedEventId: highlightedEventId, previewMedia: widget.timeline.room.shouldPreviewMedia, + lastReadEventId: lastReadEventId, jumpToEvent: jumpToEvent, initialIndex: timelineIndex), ); @@ -525,8 +557,8 @@ class RoomTimelineWidgetViewState extends State { ); } - void jumpToEvent(String eventId) async { - if (highlightedEventState?.mounted == true) { + void jumpToEvent(String eventId, {bool highlight = true}) async { + if (highlight && highlightedEventState?.mounted == true) { highlightedEventState!.setHighlighted(false); highlightedEventState = null; } @@ -555,7 +587,7 @@ class RoomTimelineWidgetViewState extends State { var key = eventKeys[index].$1; final state = key.currentState; - if (state is TimelineViewEntryState) { + if (highlight && state is TimelineViewEntryState) { state.setHighlighted(true); highlightedEventState = state; } @@ -586,9 +618,11 @@ class RoomTimelineWidgetViewState extends State { setState(() { recentItemsCount = index; - highlightedEventId = timeline.events[index].eventId; - highlightedEventOffstageIndex = index; - highlightedEventOffstageKey = GlobalKey(); + if (highlight) { + highlightedEventId = timeline.events[index].eventId; + highlightedEventOffstageIndex = index; + highlightedEventOffstageKey = GlobalKey(); + } loading = false; }); } @@ -612,7 +646,7 @@ class RoomTimelineWidgetViewState extends State { var index = timeline.events.indexWhere((i) => i.eventId == event); if (index == -1) { - print("Could not find the event in the timeline view"); + Log.w("Could not find the event in the timeline view"); } if (state is TimelineEventViewWidget) { diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_message.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_message.dart index 6a9e3d650..07e2e219e 100644 --- a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_message.dart +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_message.dart @@ -40,6 +40,7 @@ class TimelineEventViewMessage extends StatefulWidget { this.jumpToEvent, this.readReceipts = const [], this.onReadReceiptsTapped, + this.onDoubleTapMessage, this.detailed = false, this.previewMedia = false, required this.initialIndex}); @@ -56,6 +57,7 @@ class TimelineEventViewMessage extends StatefulWidget { final bool isThreadTimeline; final bool previewMedia; final Function()? onReadReceiptsTapped; + final Function()? onDoubleTapMessage; @override State createState() => @@ -68,6 +70,9 @@ class _TimelineEventViewMessageState extends State late String senderId; late Color senderColor; + late bool mentionsRoom; + late List mentions; + String get messageFailedToDecrypt => Intl.message("Failed to decrypt event", desc: "Placeholde text for when a message fails to decrypt", name: "messageFailedToDecrypt"); @@ -131,6 +136,9 @@ class _TimelineEventViewMessageState extends State formattedContent: formattedContent, timestamp: timestampToString(sentTime), edited: edited, + isMentioningSelf: mentionsRoom || + mentions.contains(widget.timeline!.client.self!.identifier), + onDoubleTapMessage: widget.onDoubleTapMessage, avatarBuilder: (child) { var room = widget.room ?? widget.timeline?.room; @@ -220,6 +228,8 @@ class _TimelineEventViewMessageState extends State } void loadStateFromEvent(TimelineEvent event) { + mentionsRoom = event.mentionsRoom; + mentions = event.mentions; showSender = shouldShowSender(index); var room = widget.room ?? widget.timeline?.room; diff --git a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reply.dart b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reply.dart index 371303b50..15040fd29 100644 --- a/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reply.dart +++ b/commet/lib/ui/molecules/timeline_events/events/timeline_event_view_reply.dart @@ -111,7 +111,7 @@ class _TimelineEventViewReplyState extends State { text: body ?? "Unknown", style: Theme.of(context) .textTheme - .bodyMedium + .bodySmall ?.copyWith( color: material.Theme.of(context) .colorScheme diff --git a/commet/lib/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart b/commet/lib/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart index e55a2bd6b..85d90122e 100644 --- a/commet/lib/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart +++ b/commet/lib/ui/molecules/timeline_events/layouts/timeline_event_layout_message.dart @@ -1,7 +1,5 @@ import 'package:commet/diagnostic/benchmark_values.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; import 'package:intl/intl.dart'; import 'package:tiamat/tiamat.dart' as tiamat; @@ -22,9 +20,11 @@ class TimelineEventLayoutMessage extends StatelessWidget { this.readReceipts, this.onAvatarTapped, this.edited = false, + this.isMentioningSelf = false, this.avatarSize = 32, this.avatarBuilder, - this.showSender = true}); + this.showSender = true, + this.onDoubleTapMessage}); final String senderName; final Color senderColor; final ImageProvider? senderAvatar; @@ -38,9 +38,11 @@ class TimelineEventLayoutMessage extends StatelessWidget { final Widget? readReceipts; final bool showSender; final bool edited; + final bool isMentioningSelf; final String? timestamp; final Function()? onAvatarTapped; final Widget Function(Widget child)? avatarBuilder; + final Function()? onDoubleTapMessage; final double avatarSize; @@ -51,7 +53,7 @@ class TimelineEventLayoutMessage extends StatelessWidget { @override Widget build(BuildContext context) { BenchmarkValues.numTimelineMessageBodyBuilt += 1; - return Padding( + Widget result = Padding( padding: const EdgeInsets.fromLTRB(16, 2, 8, 2), child: Column( children: [ @@ -81,23 +83,26 @@ class TimelineEventLayoutMessage extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (formattedContent != null) - RepaintBoundary(child: formattedContent!), - if (edited) - tiamat.Text.labelLow(messageEditedMarker), - if (attachments != null) attachments!, - if (sticker != null) sticker!, - if (urlPreviews != null) urlPreviews!, - if (reactions != null) - Padding( - padding: - const EdgeInsets.fromLTRB(0, 4, 0, 0), - child: reactions!, - ), - ], + child: GestureDetector( + onDoubleTap: onDoubleTapMessage, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (formattedContent != null) + RepaintBoundary(child: formattedContent!), + if (edited) + tiamat.Text.labelLow(messageEditedMarker), + if (attachments != null) attachments!, + if (sticker != null) sticker!, + if (urlPreviews != null) urlPreviews!, + if (reactions != null) + Padding( + padding: + const EdgeInsets.fromLTRB(0, 4, 0, 0), + child: reactions!, + ), + ], + ), ), ), SizedBox( @@ -116,6 +121,21 @@ class TimelineEventLayoutMessage extends StatelessWidget { ], ), ); + + if (isMentioningSelf) + result = Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: Theme.of(context).colorScheme.tertiary, width: 3)), + color: Theme.of(context).colorScheme.tertiaryContainer), + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 0), + child: result, + ), + ); + + return result; } Widget name() { diff --git a/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart b/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart index a87130f8b..a3dd78e01 100644 --- a/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart +++ b/commet/lib/ui/molecules/timeline_events/timeline_view_entry.dart @@ -24,7 +24,9 @@ import 'package:commet/ui/molecules/timeline_events/timeline_event_menu_dialog.d import 'package:commet/ui/molecules/user_panel.dart'; import 'package:commet/ui/navigation/adaptive_dialog.dart'; import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:tiamat/atoms/context_menu.dart'; +import 'package:tiamat/atoms/text.dart'; import 'package:tiamat/tiamat.dart' as tiamat; class TimelineViewEntry extends StatefulWidget { @@ -39,6 +41,7 @@ class TimelineViewEntry extends StatefulWidget { this.singleEvent = false, this.isThreadTimeline = false, this.previewMedia = false, + this.lastReadEventId, this.highlightedEventId, super.key}); final Timeline timeline; @@ -46,10 +49,11 @@ class TimelineViewEntry extends StatefulWidget { final Function(String eventId)? onEventHovered; final Function(TimelineEvent? event)? setReplyingEvent; final Function(TimelineEvent? event)? setEditingEvent; - final Function(String eventId)? jumpToEvent; + final Function(String eventId, {bool highlight})? jumpToEvent; final bool showDetailed; final bool isThreadTimeline; final String? highlightedEventId; + final String? lastReadEventId; final bool previewMedia; // Should be true if we are showing this event on its own, and not as part of a timeline @@ -92,12 +96,17 @@ class TimelineViewEntryState extends State late DateTime time; bool showDateSeperator = false; + bool showUnreadMarker = false; ThreadsComponent? threads; PollComponent? polls; List readReceipts = []; + String get labelTimelineNewMessagesMarker => Intl.message("New messages", + desc: "Text that is shown below the last read message", + name: "labelTimelineNewMessagesMarker"); + @override void initState() { threads = widget.timeline.room.client.getComponent(); @@ -130,6 +139,7 @@ class TimelineViewEntryState extends State _widgetType = eventToDisplayType(event, polls: polls); showDateSeperator = shouldEventShowDate(eventIndex); + showUnreadMarker = shouldEventShowUnreadMarker(eventIndex); highlighted = event.eventId == widget.highlightedEventId; } @@ -195,6 +205,29 @@ class TimelineViewEntryState extends State return false; } + bool shouldEventShowUnreadMarker(int index) { + final events = widget.timeline.events; + + bool isHidden(event) => + eventToDisplayType(event, polls: polls) == + TimelineEventWidgetDisplayType.hidden; + + if (index == 0 || events.take(index).every(isHidden)) return false; + + final lastReadIndex = + events.indexWhere((e) => e.eventId == widget.lastReadEventId); + + if (lastReadIndex == -1) { + return false; + } + + //if (lastReadIndex > index) return false; + if (lastReadIndex == index + 1) return true; + if (!isHidden(events[lastReadIndex])) return false; + + return events.getRange(lastReadIndex, index).every(isHidden); + } + @override void update(int newIndex) { index = newIndex; @@ -393,9 +426,46 @@ class TimelineViewEntryState extends State ); } + if (showUnreadMarker) + result = Column(children: [ + buildNewMessagesMarker(), + result, + ]); + return result; } + Row buildNewMessagesMarker() { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Divider( + color: ColorScheme.of(context).primaryContainer, + thickness: 2.0, + )), + DecoratedBox( + decoration: BoxDecoration( + color: ColorScheme.of(context).primaryContainer, + borderRadius: BorderRadius.circular(8)), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 15, vertical: 3), + child: tiamat.Text( + labelTimelineNewMessagesMarker, + color: ColorScheme.of(context).onPrimaryContainer, + type: TextType.labelLow, + ), + ), + ), + Expanded( + child: Divider( + color: ColorScheme.of(context).primaryContainer, + thickness: 2.0, + )), + ], + ); + } + Widget? buildEvent() { if (redacted) { return null; @@ -415,7 +485,8 @@ class TimelineViewEntryState extends State detailed: widget.showDetailed || selected, onReadReceiptsTapped: onReadReceiptsTapped, readReceipts: readReceipts, - overrideShowSender: widget.singleEvent || showDateSeperator, + overrideShowSender: + widget.singleEvent || showDateSeperator || showUnreadMarker, jumpToEvent: widget.jumpToEvent, previewMedia: widget.previewMedia, initialIndex: widget.initialIndex); diff --git a/commet/lib/ui/organisms/chat/chat.dart b/commet/lib/ui/organisms/chat/chat.dart index f5575d6a0..67820cff9 100644 --- a/commet/lib/ui/organisms/chat/chat.dart +++ b/commet/lib/ui/organisms/chat/chat.dart @@ -118,7 +118,7 @@ class ChatState extends State { } Future loadTimeline() async { - var t = await room.getTimeline(); + var t = await room.getTimeline(contextEventId: room.lastRead); setState(() { _timeline = t; }); @@ -126,7 +126,9 @@ class ChatState extends State { Future loadThreadTimeline() async { Timeline? timeline = room.timeline; - timeline ??= await room.getTimeline(); + timeline ??= room.lastRead.isNotEmpty + ? await room.getTimeline(contextEventId: room.lastRead) + : await room.getTimeline(); var threadTimeline = await threadsComponent!.getThreadTimeline( roomTimeline: timeline, threadRootEventId: widget.threadId!);