From 85d942eca182cf30d65f2151df03ce80ce70040b Mon Sep 17 00:00:00 2001 From: Terence ZAFINDRATAFA Date: Thu, 21 Mar 2024 20:11:15 +0100 Subject: [PATCH 1/2] TW-1399: counter update on scroll --- lib/pages/chat/chat.dart | 70 ++++++-- lib/pages/chat/chat_event_list.dart | 248 ++++++++++++++++------------ pubspec.lock | 2 +- pubspec.yaml | 1 + 4 files changed, 193 insertions(+), 128 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 5f6d3ebdc6..245b37154f 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -9,6 +9,7 @@ import 'package:fluffychat/utils/extension/basic_event_extension.dart'; import 'package:fluffychat/utils/extension/event_status_custom_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart'; import 'package:fluffychat/widgets/mixins/twake_context_menu_mixin.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:fluffychat/utils/extension/global_key_extension.dart'; import 'package:universal_html/html.dart' as html; @@ -139,8 +140,6 @@ class ChatController extends State Timer? _timestampTimer; - String _markerReadLocation = ''; - String? unreadReceivedMessageLocation; List? get shareFiles => widget.shareFiles; @@ -178,6 +177,9 @@ class ChatController extends State final ValueNotifier stickyTimestampNotifier = ValueNotifier(null); + final ValueNotifier lastScrollDirection = + ValueNotifier(ScrollDirection.idle); + final ValueNotifier openingChatViewStateNotifier = ValueNotifier(ViewEventListInitial()); @@ -258,27 +260,35 @@ class ChatController extends State (e) => e.eventId == event.eventId, ); - String? _findUnreadReceivedMessageLocation() { + String? _findUnreadReceivedMessageId(String fullyRead) { + final unreadEvents = findUnreadReceivedMessages(fullyRead); + + Logs().d( + "Chat::getFirstUnreadEvent(): Last unread event ${unreadEvents.last}", + ); + + return unreadEvents.isEmpty ? null : unreadEvents.last.eventId; + } + + List findUnreadReceivedMessages(String fullyRead) { final events = timeline!.events; - if (_markerReadLocation != '' && _markerReadLocation.isNotEmpty) { + if (fullyRead != '' && fullyRead.isNotEmpty) { final lastIndexReadEvent = events.indexWhere( - (event) => event.eventId == _markerReadLocation, + (event) => event.eventId == fullyRead, ); if (lastIndexReadEvent > 0) { final afterFullyRead = events.getRange(0, lastIndexReadEvent); final unreadEvents = afterFullyRead .where((event) => event.senderId != client.userID) .toList(); - if (unreadEvents.isEmpty) return null; - Logs().d( - "Chat::getFirstUnreadEvent(): Last unread event ${unreadEvents.last}", - ); - return unreadEvents.last.eventId; + if (unreadEvents.isEmpty) return []; + + return unreadEvents; } } else { - return null; + return []; } - return null; + return []; } void recreateChat() async { @@ -345,6 +355,12 @@ class ChatController extends State if (!mounted) { return; } + + if (lastScrollDirection.value != + scrollController.position.userScrollDirection) { + lastScrollDirection.value = scrollController.position.userScrollDirection; + } + if (!scrollController.hasClients) return; if (timeline?.allowNewEvent == false || scrollController.position.pixels > 0) { @@ -397,8 +413,7 @@ class ChatController extends State } void _initUnreadLocation(String fullyRead) { - _markerReadLocation = fullyRead; - unreadReceivedMessageLocation = _findUnreadReceivedMessageLocation(); + unreadReceivedMessageLocation = _findUnreadReceivedMessageId(fullyRead); scrollToEventId(fullyRead); } @@ -458,9 +473,13 @@ class ChatController extends State Logs().d('Set read marker...', eventId); // ignore: unawaited_futures - _setReadMarkerFuture = timeline.setReadMarker(eventId: eventId).then((_) { - _setReadMarkerFuture = null; - }); + if (eventId != null) { + _setReadMarkerFuture = timeline.setReadMarker(eventId: eventId).then((_) { + _setReadMarkerFuture = null; + setState(() {}); + }); + } + if (eventId == null || eventId == timeline.room.lastEvent?.eventId) { Matrix.of(context).backgroundPush?.cancelNotification(roomId!); } @@ -1768,6 +1787,23 @@ class ChatController extends State } } + void updateReceipt({ + required int index, + required Event event, + }) { + final fullyRead = room?.fullyRead; + if (fullyRead == null) return; + final unreadMessagesIds = findUnreadReceivedMessages(fullyRead) + .map( + (e) => e.eventId, + ) + .toList(); + + if (roomId != null && unreadMessagesIds.contains(event.eventId)) { + setReadMarker(eventId: event.eventId); + } + } + @override void initState() { _initializePinnedEvents(); diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 1514143825..48dac543e4 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -5,6 +5,7 @@ import 'package:fluffychat/pages/chat_draft/draft_chat_empty_widget.dart'; import 'package:fluffychat/presentation/model/search/presentation_search.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:inview_notifier_list/inview_notifier_list.dart'; @@ -16,6 +17,7 @@ import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message/message.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart'; +import 'package:visibility_detector/visibility_detector.dart'; class ChatEventList extends StatelessWidget { final ChatController controller; @@ -83,119 +85,145 @@ class ChatEventList extends StatelessWidget { child: SelectionTextContainer( chatController: controller, focusNode: controller.selectionFocusNode, - child: InViewNotifierListCustom( - isInViewPortCondition: controller.isInViewPortCondition, - listViewCustom: ListView.custom( - padding: EdgeInsets.only( - top: 16, - bottom: 8.0, - left: horizontalPadding, - right: horizontalPadding, - ), - reverse: true, - controller: controller.scrollController, - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - childrenDelegate: SliverChildBuilderDelegate( - (BuildContext context, int index) { - // Footer to display typing indicator and read receipts: - if (index == 0) { - if (controller.timeline!.isRequestingFuture) { - return const Center( - child: - CircularProgressIndicator.adaptive(strokeWidth: 2), - ); - } - if (controller.timeline!.canRequestFuture) { - Center( - child: OutlinedButton( - style: OutlinedButton.styleFrom( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, + child: ValueListenableBuilder( + valueListenable: controller.lastScrollDirection, + builder: (context, lastScrollDirection, _) => + InViewNotifierListCustom( + isInViewPortCondition: controller.isInViewPortCondition, + listViewCustom: ListView.custom( + padding: EdgeInsets.only( + top: 16, + bottom: 8.0, + left: horizontalPadding, + right: horizontalPadding, + ), + reverse: true, + controller: controller.scrollController, + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + childrenDelegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + // Footer to display typing indicator and read receipts: + if (index == 0) { + if (controller.timeline!.isRequestingFuture) { + return const Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, ), - onPressed: controller.requestFuture, - child: Text(L10n.of(context)!.loadMore), - ), - ); - } - return const SizedBox.shrink(); - } - // Request history button or progress indicator: - if (index == events.length + 1) { - if (controller.timeline!.isRequestingHistory) { - return const Center( - child: - CircularProgressIndicator.adaptive(strokeWidth: 2), - ); + ); + } + if (controller.timeline!.canRequestFuture) { + Center( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + backgroundColor: + Theme.of(context).scaffoldBackgroundColor, + ), + onPressed: controller.requestFuture, + child: Text(L10n.of(context)!.loadMore), + ), + ); + } + return const SizedBox.shrink(); } - if (controller.timeline!.canRequestHistory) { - return Center( - child: IconButton( - onPressed: controller.requestHistory, - icon: const Icon(Icons.refresh_outlined), - ), - ); + // Request history button or progress indicator: + if (index == events.length + 1) { + if (controller.timeline!.isRequestingHistory) { + return const Center( + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ); + } + if (controller.timeline!.canRequestHistory) { + return Center( + child: IconButton( + onPressed: controller.requestHistory, + icon: const Icon(Icons.refresh_outlined), + ), + ); + } + return const SizedBox.shrink(); } - return const SizedBox.shrink(); - } - final currentEventIndex = index - 1; - final event = controller.timeline!.events[currentEventIndex]; - final previousEvent = currentEventIndex > 0 - ? controller.timeline!.events[currentEventIndex - 1] - : null; - final nextEvent = index < controller.timeline!.events.length - ? controller.timeline!.events[currentEventIndex + 1] - : null; - return AutoScrollTag( - key: ValueKey(event.eventId), - index: index, - controller: controller.scrollController, - highlightColor: LinagoraRefColors.material().primary[99], - child: event.isVisibleInGui - ? Message( - key: ValueKey(event.eventId), - event, - onSwipe: (direction) => - controller.replyAction(replyTo: event), - onAvatarTap: (Event event) => - controller.onContactTap( - contactPresentationSearch: event - .senderFromMemoryOrFallback - .toContactPresentationSearch(), - context: context, - path: 'rooms', - ), - onSelect: controller.onSelectMessage, - selectMode: controller.selectMode, - scrollToEventId: (String eventId) => - controller.scrollToEventId(eventId), - selected: controller.selectedEvents - .any((e) => e.eventId == event.eventId), - timeline: controller.timeline!, - previousEvent: previousEvent, - nextEvent: nextEvent, - onHover: (isHover, event) => - controller.onHover(isHover, index, event), - isHoverNotifier: controller.focusHover, - listHorizontalActionMenu: controller - .listHorizontalActionMenuBuilder(event), - onMenuAction: controller.handleHorizontalActionMenu, - hideKeyboardChatScreen: - controller.onHideKeyboardAndEmoji, - markedUnreadLocation: - controller.unreadReceivedMessageLocation, - timestampCallback: (event) { - controller.handleDisplayStickyTimestamp( - event.originServerTs, - ); - }, - onLongPress: controller.onSelectMessage, - ) - : const SizedBox(), - ); - }, - childCount: events.length + 2, - findChildIndexCallback: (key) => - controller.findChildIndexCallback(key, thisEventsKeyMap), + final currentEventIndex = index - 1; + final event = + controller.timeline!.events[currentEventIndex]; + final previousEvent = currentEventIndex > 0 + ? controller.timeline!.events[currentEventIndex - 1] + : null; + final nextEvent = index < controller.timeline!.events.length + ? controller.timeline!.events[currentEventIndex + 1] + : null; + + return VisibilityDetector( + key: Key(event.eventId), + onVisibilityChanged: (visibilityInfo) { + final visiblePercentage = + visibilityInfo.visibleFraction * 100; + + if (lastScrollDirection == ScrollDirection.forward && + visiblePercentage == 100) { + controller.updateReceipt( + event: event, + index: index, + ); + } + }, + child: AutoScrollTag( + key: ValueKey(event.eventId), + index: index, + controller: controller.scrollController, + highlightColor: + LinagoraRefColors.material().primary[99], + child: event.isVisibleInGui + ? Message( + key: ValueKey(event.eventId), + event, + onSwipe: (direction) => + controller.replyAction(replyTo: event), + onAvatarTap: (Event event) => + controller.onContactTap( + contactPresentationSearch: event + .senderFromMemoryOrFallback + .toContactPresentationSearch(), + context: context, + path: 'rooms', + ), + onSelect: controller.onSelectMessage, + selectMode: controller.selectMode, + scrollToEventId: (String eventId) => + controller.scrollToEventId(eventId), + selected: controller.selectedEvents + .any((e) => e.eventId == event.eventId), + timeline: controller.timeline!, + previousEvent: previousEvent, + nextEvent: nextEvent, + onHover: (isHover, event) => + controller.onHover(isHover, index, event), + isHoverNotifier: controller.focusHover, + listHorizontalActionMenu: controller + .listHorizontalActionMenuBuilder(event), + onMenuAction: + controller.handleHorizontalActionMenu, + hideKeyboardChatScreen: + controller.onHideKeyboardAndEmoji, + markedUnreadLocation: + controller.unreadReceivedMessageLocation, + timestampCallback: (event) { + controller.handleDisplayStickyTimestamp( + event.originServerTs, + ); + }, + onLongPress: controller.onSelectMessage, + ) + : const SizedBox(), + ), + ); + }, + childCount: events.length + 2, + findChildIndexCallback: (key) => + controller.findChildIndexCallback(key, thisEventsKeyMap), + ), ), ), ), diff --git a/pubspec.lock b/pubspec.lock index 7bc5d12c23..7c86b71da6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2919,7 +2919,7 @@ packages: source: git version: "1.0.0" visibility_detector: - dependency: transitive + dependency: "direct main" description: name: visibility_detector sha256: "15c54a459ec2c17b4705450483f3d5a2858e733aee893dcee9d75fd04814940d" diff --git a/pubspec.yaml b/pubspec.yaml index 8e890cb937..eef49a18ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -173,6 +173,7 @@ dependencies: flutter_slidable: ^3.0.1 skeletonizer: 1.1.0 flutter_portal: 1.1.4 + visibility_detector: ^0.3.3 dev_dependencies: build_runner: ^2.3.3 From d74bd7c2622822fc4877fa24ea7a1912f3fbb30c Mon Sep 17 00:00:00 2001 From: Terence Zafindratafa Date: Mon, 25 Mar 2024 06:00:42 +0100 Subject: [PATCH 2/2] fixup! TW-1399: counter update on scroll --- lib/pages/chat/chat.dart | 52 ++++++++++++++--------------- lib/pages/chat/chat_event_list.dart | 11 ++++-- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 245b37154f..fee3c255a9 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -177,8 +177,9 @@ class ChatController extends State final ValueNotifier stickyTimestampNotifier = ValueNotifier(null); - final ValueNotifier lastScrollDirection = - ValueNotifier(ScrollDirection.idle); + final ValueNotifier isScrollingForward = ValueNotifier(false); + + double _lastScrollOffset = 0; final ValueNotifier openingChatViewStateNotifier = ValueNotifier(ViewEventListInitial()); @@ -263,11 +264,12 @@ class ChatController extends State String? _findUnreadReceivedMessageId(String fullyRead) { final unreadEvents = findUnreadReceivedMessages(fullyRead); + final lastUnread = unreadEvents.isEmpty ? null : unreadEvents.last.eventId; Logs().d( - "Chat::getFirstUnreadEvent(): Last unread event ${unreadEvents.last}", + "Chat::getFirstUnreadEvent(): Last unread event ${lastUnread}", ); - - return unreadEvents.isEmpty ? null : unreadEvents.last.eventId; + + return lastUnread; } List findUnreadReceivedMessages(String fullyRead) { @@ -355,13 +357,14 @@ class ChatController extends State if (!mounted) { return; } + if (!scrollController.hasClients) return; - if (lastScrollDirection.value != - scrollController.position.userScrollDirection) { - lastScrollDirection.value = scrollController.position.userScrollDirection; - } + if (_lastScrollOffset == 0) _lastScrollOffset = scrollController.offset; + + isScrollingForward.value = scrollController.position.userScrollDirection == + ScrollDirection.forward || + _lastScrollOffset < scrollController.offset; - if (!scrollController.hasClients) return; if (timeline?.allowNewEvent == false || scrollController.position.pixels > 0) { showScrollDownButtonNotifier.value = true; @@ -419,17 +422,12 @@ class ChatController extends State void _tryLoadTimeline() async { _updateOpeningChatViewStateNotifier(ViewEventListLoading()); - loadTimelineFuture = _getTimeline( - onJumpToMessage: (event) { - scrollToEventId(event); - }, - ); + loadTimelineFuture = _getTimeline(); try { await loadTimelineFuture; await _tryRequestHistory(); final fullyRead = room?.fullyRead; if (fullyRead == null || fullyRead.isEmpty || fullyRead == '') { - setReadMarker(); return; } if (room?.hasNewMessages == true) { @@ -437,7 +435,7 @@ class ChatController extends State } if (timeline != null && timeline!.events.any((event) => event.eventId == fullyRead)) { - setReadMarker(); + setReadMarker(eventId: fullyRead); return; } if (!mounted) return; @@ -473,12 +471,10 @@ class ChatController extends State Logs().d('Set read marker...', eventId); // ignore: unawaited_futures - if (eventId != null) { - _setReadMarkerFuture = timeline.setReadMarker(eventId: eventId).then((_) { - _setReadMarkerFuture = null; - setState(() {}); - }); - } + _setReadMarkerFuture = timeline.setReadMarker(eventId: eventId).then((_) { + _setReadMarkerFuture = null; + setState(() {}); + }); if (eventId == null || eventId == timeline.room.lastEvent?.eventId) { Matrix.of(context).backgroundPush?.cancelNotification(roomId!); @@ -855,7 +851,6 @@ class ChatController extends State Logs().v('Chat::requestFuture(): Requesting future...'); try { await timeline.requestFuture(historyCount: _loadHistoryCount); - setReadMarker(eventId: timeline.events.first.eventId); } catch (err) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -933,6 +928,7 @@ class ChatController extends State Future scrollToEventId(String eventId, {bool highlight = false}) async { final eventIndex = timeline!.events.indexWhere((e) => e.eventId == eventId); + if (eventIndex == -1) { timeline = null; loadTimelineFuture = _getTimeline(eventContextId: eventId).onError( @@ -1676,9 +1672,13 @@ class ChatController extends State _defaultEventCountDisplay; if (allMembershipEvents || canRequestHistory) { + final notificationCount = room?.notificationCount ?? 0; + final historyCount = notificationCount > _defaultEventCountDisplay + ? notificationCount + : _defaultEventCountDisplay; + try { - await requestHistory(historyCount: _defaultEventCountDisplay) - .then((response) { + await requestHistory(historyCount: historyCount).then((response) { Logs().d( 'Chat::_tryRequestHistory():: Try request history success', ); diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 48dac543e4..2cfa359e8d 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -27,6 +27,8 @@ class ChatEventList extends StatelessWidget { required this.controller, }) : super(key: key); + static const _visiblePercentage = 80; + @override Widget build(BuildContext context) { final horizontalPadding = TwakeThemes.isColumnMode(context) ? 8.0 : 0.0; @@ -86,7 +88,7 @@ class ChatEventList extends StatelessWidget { chatController: controller, focusNode: controller.selectionFocusNode, child: ValueListenableBuilder( - valueListenable: controller.lastScrollDirection, + valueListenable: controller.isScrollingForward, builder: (context, lastScrollDirection, _) => InViewNotifierListCustom( isInViewPortCondition: controller.isInViewPortCondition, @@ -161,8 +163,11 @@ class ChatEventList extends StatelessWidget { final visiblePercentage = visibilityInfo.visibleFraction * 100; - if (lastScrollDirection == ScrollDirection.forward && - visiblePercentage == 100) { + final scrollCondition = + lastScrollDirection || previousEvent == null; + + if (scrollCondition && + visiblePercentage >= _visiblePercentage) { controller.updateReceipt( event: event, index: index,