From f55973c50a5ea7774c57f4deeba0ecaaf5d2dc0f Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:03:02 +0900 Subject: [PATCH 01/27] Optimize chat and conversation paging performance --- lib/bloc/paging/paging_bloc.dart | 3 +- lib/ui/home/bloc/conversation_list_bloc.dart | 14 ++- lib/ui/home/bloc/message_bloc.dart | 110 ++++++------------ lib/ui/home/bloc/message_window_loader.dart | 102 ++++++++++++++++ lib/ui/home/chat/chat_scroll_coordinator.dart | 30 +++-- test/bloc/paging/paging_bloc_test.dart | 60 ++++++++++ .../home/bloc/message_window_loader_test.dart | 68 +++++++++++ .../chat/chat_scroll_coordinator_test.dart | 45 +++++++ 8 files changed, 347 insertions(+), 85 deletions(-) create mode 100644 lib/ui/home/bloc/message_window_loader.dart create mode 100644 test/bloc/paging/paging_bloc_test.dart create mode 100644 test/ui/home/bloc/message_window_loader_test.dart diff --git a/lib/bloc/paging/paging_bloc.dart b/lib/bloc/paging/paging_bloc.dart index d0473646cf..f36fd9eb4e 100644 --- a/lib/bloc/paging/paging_bloc.dart +++ b/lib/bloc/paging/paging_bloc.dart @@ -187,8 +187,6 @@ abstract class PagingBloc extends Bloc> emit(state.copyWith(map: map, initialized: true)); } else if (event is PagingInitEvent) { - emit(state.copyWith(hasData: await queryHasData())); - final offset = event.offset; final count = await queryCount(); @@ -197,6 +195,7 @@ abstract class PagingBloc extends Bloc> state.copyWith( map: await queryMap(limit, offset), count: count, + hasData: count != 0, initialized: true, index: event.index, alignment: event.alignment, diff --git a/lib/ui/home/bloc/conversation_list_bloc.dart b/lib/ui/home/bloc/conversation_list_bloc.dart index fc9378d5a7..8d7549f753 100644 --- a/lib/ui/home/bloc/conversation_list_bloc.dart +++ b/lib/ui/home/bloc/conversation_list_bloc.dart @@ -204,7 +204,9 @@ class _ConversationListBloc extends PagingBloc { _updateSubscription ??= _updateEvent.listen( (_) => add(PagingUpdateEvent()), ); - add(PagingUpdateEvent()); + if (state.initialized) { + add(PagingUpdateEvent()); + } } void deactivate() { @@ -224,11 +226,19 @@ class _ConversationListBloc extends PagingBloc { @override Future> queryRange(int limit, int offset) async { final list = await _queryRange(limit, offset); - await mentionCache.checkMentionCache(list.map((e) => e.content).toSet()); + unawaited(_warmMentionCache(list)); return list; } @override Future queryHasData() => _queryHasData(); + + Future _warmMentionCache(List list) async { + try { + await mentionCache.checkMentionCache(list.map((e) => e.content).toSet()); + } catch (error) { + e('conversation mention cache failed: $error'); + } + } } diff --git a/lib/ui/home/bloc/message_bloc.dart b/lib/ui/home/bloc/message_bloc.dart index 2332d3d60e..713ae46ed7 100644 --- a/lib/ui/home/bloc/message_bloc.dart +++ b/lib/ui/home/bloc/message_bloc.dart @@ -20,6 +20,8 @@ import '../../provider/conversation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../chat/chat_jump_trace.dart'; +part 'message_window_loader.dart'; + abstract class _MessageEvent extends Equatable { @override List get props => []; @@ -280,6 +282,8 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> final MentionCache mentionCache; final AccountServer accountServer; int limit; + late final MessageWindowLoader _messageWindowLoader = + MessageWindowLoader.fromDao(messageDao); MessageDao get messageDao => database.messageDao; @@ -293,6 +297,14 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> ); } + void _warmMentionCache(MessageState state) { + unawaited( + _preCacheMention(state).catchError((Object error, StackTrace stackTrace) { + e('preCacheMention failed: $error'); + }), + ); + } + Future _onEvent(_MessageEvent event, Emitter emit) async { // Avoid value change final finalLimit = limit; @@ -317,36 +329,40 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> centerMessageId: event.centerMessageId, forceLatest: event.forceLatest, ); - await _preCacheMention(messageState); - emit( - _pretreatment( - messageState.copyWith( - refreshKey: Object(), - lastReadMessageId: event.lastReadMessageId, - ), + final nextState = _pretreatment( + messageState.copyWith( + refreshKey: Object(), + lastReadMessageId: event.lastReadMessageId, ), ); + emit(nextState); + _warmMentionCache(nextState); } else if (event is _MessageDeleteEvent) { final messageState = state.removeMessage(event.messageId); - emit(_pretreatment(messageState)); + final nextState = _pretreatment(messageState); + emit(nextState); + _warmMentionCache(nextState); } else { if (event is _MessageLoadMoreEvent) { if (event is _MessageLoadAfterEvent) { if (state.isLatest) return; final messageState = await _after(conversationId); - await _preCacheMention(messageState); - emit(_pretreatment(messageState)); + final nextState = _pretreatment(messageState); + emit(nextState); + _warmMentionCache(nextState); } else if (event is _MessageLoadBeforeEvent) { if (state.isOldest) return; final messageState = await _before(conversationId); - await _preCacheMention(messageState); - emit(_pretreatment(messageState)); + final nextState = _pretreatment(messageState); + emit(nextState); + _warmMentionCache(nextState); } } else if (event is _MessageInsertOrReplaceEvent) { final result = _insertOrReplace(conversationId, event.data); if (result != null) { - await _preCacheMention(result); - emit(_pretreatment(result)); + final nextState = _pretreatment(result); + emit(nextState); + _warmMentionCache(nextState); } } else if (event is _MessageScrollEvent) { traceChatJump( @@ -436,66 +452,12 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> String conversationId, int limit, { String? centerMessageId, - }) async { - Future recentMessages() async { - final list = await messageDao - .messagesByConversationId(conversationId, limit) - .get(); - - traceChatJump('query recent count=${list.length} limit=$limit'); - return MessageState( - top: list.reversed.toList(), - isLatest: true, - isOldest: list.length < limit, - ); - } - - if (centerMessageId == null) return recentMessages(); - - final info = await messageDao.messageOrderInfo(centerMessageId); - if (info == null) { - traceChatJump( - 'query center missing-order target=${shortMessageId(centerMessageId)}', - ); - return recentMessages(); - } - - final _limit = limit ~/ 2; - final bottomList = await messageDao - .afterMessagesByConversationId(info, conversationId, _limit) - .get(); - var topList = - (await messageDao - .beforeMessagesByConversationId(info, conversationId, _limit) - .get()) - .reversed - .toList(); - - final isLatest = bottomList.length < _limit; - final isOldest = topList.length < _limit; - - var center = await messageDao - .messageItemByMessageId(centerMessageId) - .getSingleOrNull(); - - if (bottomList.isEmpty && center != null) { - topList = [...topList, center]; - center = null; - } - - traceChatJump( - 'query centered target=${shortMessageId(centerMessageId)} ' - 'top=${topList.length} center=${center != null} ' - 'bottom=${bottomList.length} isLatest=$isLatest isOldest=$isOldest', - ); - return MessageState( - top: topList, - center: center, - bottom: bottomList, - isLatest: isLatest, - isOldest: isOldest, - ); - } + }) => _messageWindowLoader.load( + conversationId, + limit, + centerMessageId: centerMessageId, + trace: traceChatJump, + ); MessageState? _insertOrReplace( String conversationId, diff --git a/lib/ui/home/bloc/message_window_loader.dart b/lib/ui/home/bloc/message_window_loader.dart new file mode 100644 index 0000000000..a06c4e3370 --- /dev/null +++ b/lib/ui/home/bloc/message_window_loader.dart @@ -0,0 +1,102 @@ +part of 'message_bloc.dart'; + +typedef RecentMessagesQuery = + Future> Function(String conversationId, int limit); +typedef MessageOrderInfoQuery = + Future Function(String messageId); +typedef AroundMessagesQuery = + Future> Function( + MessageOrderInfo anchor, + String conversationId, + int limit, + ); +typedef MessageByIdQuery = Future Function(String messageId); + +class MessageWindowLoader { + const MessageWindowLoader({ + required this.recentMessages, + required this.messageOrderInfo, + required this.beforeMessages, + required this.afterMessages, + required this.messageById, + }); + + factory MessageWindowLoader.fromDao(MessageDao messageDao) => + MessageWindowLoader( + recentMessages: (conversationId, limit) => + messageDao.messagesByConversationId(conversationId, limit).get(), + messageOrderInfo: messageDao.messageOrderInfo, + beforeMessages: (anchor, conversationId, limit) => messageDao + .beforeMessagesByConversationId(anchor, conversationId, limit) + .get(), + afterMessages: (anchor, conversationId, limit) => messageDao + .afterMessagesByConversationId(anchor, conversationId, limit) + .get(), + messageById: (messageId) => + messageDao.messageItemByMessageId(messageId).getSingleOrNull(), + ); + + final RecentMessagesQuery recentMessages; + final MessageOrderInfoQuery messageOrderInfo; + final AroundMessagesQuery beforeMessages; + final AroundMessagesQuery afterMessages; + final MessageByIdQuery messageById; + + Future load( + String conversationId, + int limit, { + String? centerMessageId, + void Function(String message)? trace, + }) async { + Future recent() async { + final list = await recentMessages(conversationId, limit); + + trace?.call('query recent count=${list.length} limit=$limit'); + return MessageState( + top: list.reversed.toList(), + isLatest: true, + isOldest: list.length < limit, + ); + } + + if (centerMessageId == null) return recent(); + + final info = await messageOrderInfo(centerMessageId); + if (info == null) { + trace?.call( + 'query center missing-order target=${shortMessageId(centerMessageId)}', + ); + return recent(); + } + + final halfLimit = limit ~/ 2; + final bottomFuture = afterMessages(info, conversationId, halfLimit); + final topFuture = beforeMessages(info, conversationId, halfLimit); + final centerFuture = messageById(centerMessageId); + + final bottomList = await bottomFuture; + var topList = (await topFuture).reversed.toList(); + var center = await centerFuture; + + final isLatest = bottomList.length < halfLimit; + final isOldest = topList.length < halfLimit; + + if (bottomList.isEmpty && center != null) { + topList = [...topList, center]; + center = null; + } + + trace?.call( + 'query centered target=${shortMessageId(centerMessageId)} ' + 'top=${topList.length} center=${center != null} ' + 'bottom=${bottomList.length} isLatest=$isLatest isOldest=$isOldest', + ); + return MessageState( + top: topList, + center: center, + bottom: bottomList, + isLatest: isLatest, + isOldest: isOldest, + ); + } +} diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index d8342612de..8437dd6fdb 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -16,7 +16,7 @@ class MessageGlobalKey extends GlobalObjectKey> { class ChatScrollCoordinator { ChatScrollCoordinator() { - scrollController.addListener(_scheduleViewportStateUpdate); + scrollController.addListener(_scheduleScrollPositionUpdate); } static const _jumpLatestThreshold = 40.0; @@ -46,6 +46,7 @@ class ChatScrollCoordinator { ChatScrollRestoreDirection? _animatedRestoreDirection; bool _restoreScheduled = false; bool _viewportStateUpdateScheduled = false; + bool _viewportStateUpdateIncludesVisibleDate = false; bool _disposed = false; int _programmaticScrollDepth = 0; @@ -135,7 +136,10 @@ class ChatScrollCoordinator { required VoidCallback loadBefore, required VoidCallback loadAfter, }) { - updateMessages(messages, keysByMessageId); + _setMessages(messages, keysByMessageId); + _scheduleViewportStateUpdate( + updateVisibleDate: notification is ScrollEndNotification, + ); if (notification is! ScrollUpdateNotification) return false; if (_programmaticScrollDepth > 0) { traceChatJump( @@ -212,7 +216,7 @@ class ChatScrollCoordinator { void dispose() { _disposed = true; scrollController - ..removeListener(_scheduleViewportStateUpdate) + ..removeListener(_scheduleScrollPositionUpdate) ..dispose(); visibleDateTime.dispose(); showJumpToLatest.dispose(); @@ -421,6 +425,7 @@ class ChatScrollCoordinator { ); } scrollController.jumpTo(target); + _scheduleViewportStateUpdate(); return Future.value(); } @@ -435,6 +440,7 @@ class ChatScrollCoordinator { 'jump done ${formatScrollMetrics(scrollController.position)}', ); } + _scheduleViewportStateUpdate(); } } @@ -511,23 +517,33 @@ class ChatScrollCoordinator { return firstVisibleMessageId; } - void _scheduleViewportStateUpdate() { - if (_viewportStateUpdateScheduled || _disposed) return; + void _scheduleScrollPositionUpdate() { + _scheduleViewportStateUpdate(updateVisibleDate: false); + } + + void _scheduleViewportStateUpdate({bool updateVisibleDate = true}) { + if (_disposed) return; + _viewportStateUpdateIncludesVisibleDate = + _viewportStateUpdateIncludesVisibleDate || updateVisibleDate; + if (_viewportStateUpdateScheduled) return; _viewportStateUpdateScheduled = true; scheduleMicrotask(() { + final updateVisibleDate = _viewportStateUpdateIncludesVisibleDate; _viewportStateUpdateScheduled = false; + _viewportStateUpdateIncludesVisibleDate = false; if (_disposed) return; - _updateViewportState(); + _updateViewportState(updateVisibleDate: updateVisibleDate); }); } - void _updateViewportState() { + void _updateViewportState({bool updateVisibleDate = true}) { if (!scrollController.hasClients) return; final nextShowJumpToLatest = !_isPinnedToBottom(); if (showJumpToLatest.value != nextShowJumpToLatest) { showJumpToLatest.value = nextShowJumpToLatest; } + if (!updateVisibleDate) return; final firstVisibleMessageId = _firstVisibleMessageId(); final nextDateTime = firstVisibleMessageId == null ? null diff --git a/test/bloc/paging/paging_bloc_test.dart b/test/bloc/paging/paging_bloc_test.dart new file mode 100644 index 0000000000..2c90512428 --- /dev/null +++ b/test/bloc/paging/paging_bloc_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_app/bloc/paging/paging_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +void main() { + test( + 'initial load derives hasData from count without an extra query', + () async { + final bloc = _TrackingPagingBloc( + count: 3, + range: const [1, 2], + ); + addTearDown(bloc.close); + + final state = await bloc.stream.firstWhere((state) => state.initialized); + + expect(state.hasData, true); + expect(state.count, 3); + expect(state.map.values, [1, 2]); + expect(bloc.queryHasDataCalls, 0); + expect(bloc.queryCountCalls, 1); + expect(bloc.queryRangeCalls, 1); + }, + ); +} + +class _TrackingPagingBloc extends PagingBloc { + _TrackingPagingBloc({ + required this.count, + required this.range, + }) : super( + initState: const PagingState(), + itemPositionsListener: ItemPositionsListener.create(), + limit: 2, + ); + + final int count; + final List range; + int queryCountCalls = 0; + int queryRangeCalls = 0; + int queryHasDataCalls = 0; + + @override + Future queryCount() async { + queryCountCalls++; + return count; + } + + @override + Future> queryRange(int limit, int offset) async { + queryRangeCalls++; + return range; + } + + @override + Future queryHasData() async { + queryHasDataCalls++; + return true; + } +} diff --git a/test/ui/home/bloc/message_window_loader_test.dart b/test/ui/home/bloc/message_window_loader_test.dart new file mode 100644 index 0000000000..c40164d5f7 --- /dev/null +++ b/test/ui/home/bloc/message_window_loader_test.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:flutter_app/db/dao/message_dao.dart'; +import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/ui/home/bloc/message_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +void main() { + test( + 'centered loads start before, after, and center queries together', + () async { + final calls = []; + final beforeCompleter = Completer>(); + final afterCompleter = Completer>(); + final centerCompleter = Completer(); + + final loader = MessageWindowLoader( + recentMessages: (_, _) => throw StateError('unexpected recent query'), + messageOrderInfo: (_) async => MessageOrderInfo(rowId: 2, createdAt: 2), + beforeMessages: (_, _, _) { + calls.add('before'); + return beforeCompleter.future; + }, + afterMessages: (_, _, _) { + calls.add('after'); + return afterCompleter.future; + }, + messageById: (_) { + calls.add('center'); + return centerCompleter.future; + }, + ); + + final future = loader.load( + 'conversation', + 6, + centerMessageId: 'center', + ); + await Future.delayed(Duration.zero); + + expect(calls, unorderedEquals(['before', 'after', 'center'])); + + centerCompleter.complete(testMessage(2)); + beforeCompleter.complete([testMessage(1)]); + afterCompleter.complete([testMessage(3)]); + + final state = await future; + + expect(state.top.map((e) => e.messageId), ['1']); + expect(state.center?.messageId, '2'); + expect(state.bottom.map((e) => e.messageId), ['3']); + }, + ); +} + +MessageItem testMessage(int index) => MessageItem( + messageId: '$index', + conversationId: 'conversation', + type: 'PLAIN_TEXT', + createdAt: DateTime(2026, 1, 1, 12, index), + status: MessageStatus.read, + userId: 'user', + userIdentityNumber: '0', + isVerified: false, + sharedUserIsVerified: false, + pinned: false, +); diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index a085598efc..fd3489a4ec 100644 --- a/test/ui/home/chat/chat_scroll_coordinator_test.dart +++ b/test/ui/home/chat/chat_scroll_coordinator_test.dart @@ -183,6 +183,51 @@ void main() { expect(keysByMessageId.lookupCount, lessThan(50)); }); + testWidgets('scroll updates do not resolve visible date geometry', ( + tester, + ) async { + final coordinator = ChatScrollCoordinator(); + final messages = List.generate(1000, testMessage); + final keysByMessageId = CountingKeyMap({ + for (final message in messages) + message.messageId: MessageGlobalKey(message.messageId), + }); + + addTearDown(coordinator.dispose); + await pumpScrollableMessages( + tester, + coordinator, + messages, + keysByMessageId, + cacheExtent: 0, + ); + coordinator.scrollController.jumpTo(80.0 * 500); + await tester.pump(); + + keysByMessageId.lookupCount = 0; + coordinator.handleScrollNotification( + ScrollUpdateNotification( + metrics: FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 80000, + pixels: 80.0 * 500, + viewportDimension: 240, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ), + context: tester.element(find.byType(CustomScrollView)), + scrollDelta: 10, + ), + messages: messages, + keysByMessageId: keysByMessageId, + loadBefore: () {}, + loadAfter: () {}, + ); + await tester.pump(); + + expect(keysByMessageId.lookupCount, 0); + }); + testWidgets('scheduleRestore does not jump while user is scrolling', ( tester, ) async { From cbc2a4f3eedc5a6de07ae0622671d12f55864988 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:27:57 +0900 Subject: [PATCH 02/27] Optimize message window and paging queries --- lib/bloc/paging/paging_bloc.dart | 26 -------- lib/db/dao/conversation_dao.dart | 23 ------- lib/db/dao/message_dao.dart | 40 ++++++++++++ lib/ui/home/bloc/conversation_list_bloc.dart | 9 --- lib/ui/home/bloc/message_window_loader.dart | 62 ++++++++++++------- test/bloc/paging/paging_bloc_test.dart | 10 +-- .../home/bloc/message_window_loader_test.dart | 48 ++++++++------ 7 files changed, 108 insertions(+), 110 deletions(-) diff --git a/lib/bloc/paging/paging_bloc.dart b/lib/bloc/paging/paging_bloc.dart index f36fd9eb4e..1f9f11ed55 100644 --- a/lib/bloc/paging/paging_bloc.dart +++ b/lib/bloc/paging/paging_bloc.dart @@ -243,30 +243,4 @@ abstract class PagingBloc extends Bloc> Future queryCount(); Future> queryRange(int limit, int offset); - - Future queryHasData(); -} - -class AnonymousPagingBloc extends PagingBloc { - AnonymousPagingBloc({ - required super.limit, - required super.initState, - required Future Function() queryCount, - required Future> Function(int limit, int offset) queryRange, - }) : _queryCount = queryCount, - _queryRange = queryRange, - super(itemPositionsListener: ItemPositionsListener.create()); - - final Future Function() _queryCount; - final Future> Function(int limit, int offset) _queryRange; - - @override - Future queryCount() => _queryCount(); - - @override - Future> queryRange(int limit, int offset) => - _queryRange(limit, offset); - - @override - Future queryHasData() async => false; } diff --git a/lib/db/dao/conversation_dao.dart b/lib/db/dao/conversation_dao.dart index 33d321c8aa..7d2d3dec93 100644 --- a/lib/db/dao/conversation_dao.dart +++ b/lib/db/dao/conversation_dao.dart @@ -108,9 +108,6 @@ class ConversationDao extends DatabaseAccessor return predicate; } - Future conversationHasDataByCategory(SlideCategoryType category) => - _conversationHasData(_conversationPredicateByCategory(category)); - Future conversationCountByCategory(SlideCategoryType category) => _baseConversationItemCount( (conversation, owner, circle) => @@ -252,15 +249,6 @@ class ConversationDao extends DatabaseAccessor ) => maxLimit, ); - Future _conversationHasData(Expression predicate) => - db.hasData(db.conversations, [ - innerJoin( - db.users, - db.conversations.ownerId.equalsExp(db.users.userId), - useColumns: false, - ), - ], predicate); - Selectable unseenConversationCountByCategory(SlideCategoryType category) => _baseUnseenConversationCount( @@ -349,17 +337,6 @@ class ConversationDao extends DatabaseAccessor (_, _, _, _, _, _, _, em) => Limit(limit, offset), ); - Future conversationHasDataByCircleId(String circleId) => - db.hasData(db.circleConversations, [ - innerJoin( - db.conversations, - db.circleConversations.conversationId.equalsExp( - db.conversations.conversationId, - ), - useColumns: false, - ), - ], db.circleConversations.circleId.equals(circleId)); - Selectable unseenConversationsByCircleId(String circleId) => _baseConversationItemsByCircleId( (conversation, o, circleConversation, lm, ls, s, p, em) => diff --git a/lib/db/dao/message_dao.dart b/lib/db/dao/message_dao.dart index 67c348752c..8af7d467aa 100644 --- a/lib/db/dao/message_dao.dart +++ b/lib/db/dao/message_dao.dart @@ -1127,6 +1127,26 @@ class MessageDao extends DatabaseAccessor (_, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => Limit(limit, 0), ); + Selectable beforeMessageIdsByConversationId( + MessageOrderInfo anchor, + String conversationId, + int limit, + ) => + (selectOnly(db.messages) + ..addColumns([db.messages.messageId]) + ..where( + db.messages.conversationId.equals(conversationId) & + (db.messages.createdAt.isSmallerThanValue(anchor.createdAt) | + (db.messages.createdAt.equals(anchor.createdAt) & + db.messages.rowId.isSmallerThanValue(anchor.rowId))), + ) + ..orderBy([ + OrderingTerm.desc(db.messages.createdAt), + OrderingTerm.desc(db.messages.rowId), + ]) + ..limit(limit)) + .map((row) => row.read(db.messages.messageId)!); + Selectable afterMessagesByConversationId( MessageOrderInfo anchor, String conversationId, @@ -1144,6 +1164,26 @@ class MessageDao extends DatabaseAccessor ]), ); + Selectable afterMessageIdsByConversationId( + MessageOrderInfo anchor, + String conversationId, + int limit, + ) => + (selectOnly(db.messages) + ..addColumns([db.messages.messageId]) + ..where( + db.messages.conversationId.equals(conversationId) & + (db.messages.createdAt.isBiggerThanValue(anchor.createdAt) | + (db.messages.createdAt.equals(anchor.createdAt) & + db.messages.rowId.isBiggerThanValue(anchor.rowId))), + ) + ..orderBy([ + OrderingTerm.asc(db.messages.createdAt), + OrderingTerm.asc(db.messages.rowId), + ]) + ..limit(limit)) + .map((row) => row.read(db.messages.messageId)!); + Selectable messageItemByMessageId(String messageId) => _baseMessageItems( (message, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => diff --git a/lib/ui/home/bloc/conversation_list_bloc.dart b/lib/ui/home/bloc/conversation_list_bloc.dart index 8d7549f753..00ffb437b0 100644 --- a/lib/ui/home/bloc/conversation_list_bloc.dart +++ b/lib/ui/home/bloc/conversation_list_bloc.dart @@ -105,7 +105,6 @@ class ConversationListBloc extends Cubit> dao.conversationItemsByCategory(state.type, limit, offset).get(), updateEvent, mentionCache, - () => dao.conversationHasDataByCategory(state.type), ); case SlideCategoryType.circle: _map[state] ??= _ConversationListBloc( @@ -118,8 +117,6 @@ class ConversationListBloc extends Cubit> .get(), circleUpdateEvent, mentionCache, - () => - database.conversationDao.conversationHasDataByCircleId(state.id!), ); case SlideCategoryType.setting: return; @@ -178,10 +175,8 @@ class _ConversationListBloc extends PagingBloc { Future> Function(int limit, int offset) queryRange, Stream updateEvent, this.mentionCache, - Future Function() queryHasData, ) : _queryCount = queryCount, _queryRange = queryRange, - _queryHasData = queryHasData, _updateEvent = updateEvent, super( initState: const PagingState(), @@ -193,7 +188,6 @@ class _ConversationListBloc extends PagingBloc { final Future Function() _queryCount; final Future> Function(int limit, int offset) _queryRange; - final Future Function() _queryHasData; final Stream _updateEvent; StreamSubscription? _updateSubscription; @@ -231,9 +225,6 @@ class _ConversationListBloc extends PagingBloc { return list; } - @override - Future queryHasData() => _queryHasData(); - Future _warmMentionCache(List list) async { try { await mentionCache.checkMentionCache(list.map((e) => e.content).toSet()); diff --git a/lib/ui/home/bloc/message_window_loader.dart b/lib/ui/home/bloc/message_window_loader.dart index a06c4e3370..d0d66d21fa 100644 --- a/lib/ui/home/bloc/message_window_loader.dart +++ b/lib/ui/home/bloc/message_window_loader.dart @@ -4,21 +4,24 @@ typedef RecentMessagesQuery = Future> Function(String conversationId, int limit); typedef MessageOrderInfoQuery = Future Function(String messageId); -typedef AroundMessagesQuery = - Future> Function( +typedef AroundMessageIdsQuery = + Future> Function( MessageOrderInfo anchor, String conversationId, int limit, ); -typedef MessageByIdQuery = Future Function(String messageId); +typedef MessagesByIdsQuery = + Future> Function( + List messageIds, + ); class MessageWindowLoader { const MessageWindowLoader({ required this.recentMessages, required this.messageOrderInfo, - required this.beforeMessages, - required this.afterMessages, - required this.messageById, + required this.beforeMessageIds, + required this.afterMessageIds, + required this.messagesByIds, }); factory MessageWindowLoader.fromDao(MessageDao messageDao) => @@ -26,21 +29,21 @@ class MessageWindowLoader { recentMessages: (conversationId, limit) => messageDao.messagesByConversationId(conversationId, limit).get(), messageOrderInfo: messageDao.messageOrderInfo, - beforeMessages: (anchor, conversationId, limit) => messageDao - .beforeMessagesByConversationId(anchor, conversationId, limit) + beforeMessageIds: (anchor, conversationId, limit) => messageDao + .beforeMessageIdsByConversationId(anchor, conversationId, limit) .get(), - afterMessages: (anchor, conversationId, limit) => messageDao - .afterMessagesByConversationId(anchor, conversationId, limit) + afterMessageIds: (anchor, conversationId, limit) => messageDao + .afterMessageIdsByConversationId(anchor, conversationId, limit) .get(), - messageById: (messageId) => - messageDao.messageItemByMessageId(messageId).getSingleOrNull(), + messagesByIds: (messageIds) => + messageDao.messageItemByMessageIds(messageIds).get(), ); final RecentMessagesQuery recentMessages; final MessageOrderInfoQuery messageOrderInfo; - final AroundMessagesQuery beforeMessages; - final AroundMessagesQuery afterMessages; - final MessageByIdQuery messageById; + final AroundMessageIdsQuery beforeMessageIds; + final AroundMessageIdsQuery afterMessageIds; + final MessagesByIdsQuery messagesByIds; Future load( String conversationId, @@ -70,16 +73,29 @@ class MessageWindowLoader { } final halfLimit = limit ~/ 2; - final bottomFuture = afterMessages(info, conversationId, halfLimit); - final topFuture = beforeMessages(info, conversationId, halfLimit); - final centerFuture = messageById(centerMessageId); + final bottomIdsFuture = afterMessageIds(info, conversationId, halfLimit); + final topIdsFuture = beforeMessageIds(info, conversationId, halfLimit); + + final bottomIds = await bottomIdsFuture; + final topIds = await topIdsFuture; + final messageIds = [...topIds.reversed, centerMessageId, ...bottomIds]; + final messages = await messagesByIds(messageIds); + final messagesById = { + for (final message in messages) message.messageId: message, + }; - final bottomList = await bottomFuture; - var topList = (await topFuture).reversed.toList(); - var center = await centerFuture; + final bottomList = bottomIds + .map((id) => messagesById[id]) + .nonNulls + .toList(); + var topList = topIds.reversed + .map((id) => messagesById[id]) + .nonNulls + .toList(); + var center = messagesById[centerMessageId]; - final isLatest = bottomList.length < halfLimit; - final isOldest = topList.length < halfLimit; + final isLatest = bottomIds.length < halfLimit; + final isOldest = topIds.length < halfLimit; if (bottomList.isEmpty && center != null) { topList = [...topList, center]; diff --git a/test/bloc/paging/paging_bloc_test.dart b/test/bloc/paging/paging_bloc_test.dart index 2c90512428..298c5f7a0b 100644 --- a/test/bloc/paging/paging_bloc_test.dart +++ b/test/bloc/paging/paging_bloc_test.dart @@ -4,7 +4,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; void main() { test( - 'initial load derives hasData from count without an extra query', + 'initial load derives hasData from count', () async { final bloc = _TrackingPagingBloc( count: 3, @@ -17,7 +17,6 @@ void main() { expect(state.hasData, true); expect(state.count, 3); expect(state.map.values, [1, 2]); - expect(bloc.queryHasDataCalls, 0); expect(bloc.queryCountCalls, 1); expect(bloc.queryRangeCalls, 1); }, @@ -38,7 +37,6 @@ class _TrackingPagingBloc extends PagingBloc { final List range; int queryCountCalls = 0; int queryRangeCalls = 0; - int queryHasDataCalls = 0; @override Future queryCount() async { @@ -51,10 +49,4 @@ class _TrackingPagingBloc extends PagingBloc { queryRangeCalls++; return range; } - - @override - Future queryHasData() async { - queryHasDataCalls++; - return true; - } } diff --git a/test/ui/home/bloc/message_window_loader_test.dart b/test/ui/home/bloc/message_window_loader_test.dart index c40164d5f7..5adb142709 100644 --- a/test/ui/home/bloc/message_window_loader_test.dart +++ b/test/ui/home/bloc/message_window_loader_test.dart @@ -8,27 +8,27 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; void main() { test( - 'centered loads start before, after, and center queries together', + 'centered load hydrates around id window in one item query', () async { final calls = []; - final beforeCompleter = Completer>(); - final afterCompleter = Completer>(); - final centerCompleter = Completer(); + final beforeIdsCompleter = Completer>(); + final afterIdsCompleter = Completer>(); + final hydrateCompleter = Completer>(); final loader = MessageWindowLoader( recentMessages: (_, _) => throw StateError('unexpected recent query'), messageOrderInfo: (_) async => MessageOrderInfo(rowId: 2, createdAt: 2), - beforeMessages: (_, _, _) { - calls.add('before'); - return beforeCompleter.future; + beforeMessageIds: (_, _, _) { + calls.add('beforeIds'); + return beforeIdsCompleter.future; }, - afterMessages: (_, _, _) { - calls.add('after'); - return afterCompleter.future; + afterMessageIds: (_, _, _) { + calls.add('afterIds'); + return afterIdsCompleter.future; }, - messageById: (_) { - calls.add('center'); - return centerCompleter.future; + messagesByIds: (ids) { + calls.add('hydrate:${ids.join(',')}'); + return hydrateCompleter.future; }, ); @@ -39,23 +39,31 @@ void main() { ); await Future.delayed(Duration.zero); - expect(calls, unorderedEquals(['before', 'after', 'center'])); + expect(calls, unorderedEquals(['beforeIds', 'afterIds'])); - centerCompleter.complete(testMessage(2)); - beforeCompleter.complete([testMessage(1)]); - afterCompleter.complete([testMessage(3)]); + beforeIdsCompleter.complete(['1']); + afterIdsCompleter.complete(['3']); + await Future.delayed(Duration.zero); + + expect(calls, contains('hydrate:1,center,3')); + + hydrateCompleter.complete([ + testMessage(3), + testMessage(1), + testMessage(2, messageId: 'center'), + ]); final state = await future; expect(state.top.map((e) => e.messageId), ['1']); - expect(state.center?.messageId, '2'); + expect(state.center?.messageId, 'center'); expect(state.bottom.map((e) => e.messageId), ['3']); }, ); } -MessageItem testMessage(int index) => MessageItem( - messageId: '$index', +MessageItem testMessage(int index, {String? messageId}) => MessageItem( + messageId: messageId ?? '$index', conversationId: 'conversation', type: 'PLAIN_TEXT', createdAt: DateTime(2026, 1, 1, 12, index), From 36159e89e1669a7ae07aa52e26fb7907505ac7c6 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:40:52 +0900 Subject: [PATCH 03/27] Simplify chat UI state management --- lib/bloc/bloc_converter.dart | 106 ----- lib/bloc/simple_cubit.dart | 8 - lib/bloc/stream_cubit.dart | 8 - lib/ui/home/bloc/blink_cubit.dart | 65 --- lib/ui/home/chat/chat_bar.dart | 27 +- lib/ui/home/chat/chat_page.dart | 447 +++++------------- lib/ui/home/chat/image_editor.dart | 405 ++++++++-------- lib/ui/home/chat/input_container.dart | 65 ++- lib/ui/home/chat/message_jump.dart | 4 +- .../home/chat/voice_recorder_bottom_bar.dart | 205 ++++---- .../home/chat_slide_page/chat_info_page.dart | 32 +- .../chat_slide_page/pin_messages_page.dart | 25 +- .../chat_slide_page/search_message_page.dart | 23 +- .../share_media/media_page.dart | 4 +- lib/ui/home/notifier/blink_notifier.dart | 59 +++ lib/ui/home/notifier/chat_side_notifier.dart | 195 ++++++++ lib/ui/home/route/responsive_navigator.dart | 60 --- lib/ui/landing/landing.dart | 30 +- lib/ui/landing/landing_mobile.dart | 29 +- lib/ui/landing/landing_qrcode.dart | 52 +- lib/ui/landing/{bloc => }/landing_state.dart | 0 .../landing_qrcode_notifier.dart} | 101 ++-- .../abstract_responsive_navigator.dart | 68 ++- lib/ui/setting/appearance_page.dart | 39 +- .../device_transfer_dialog.dart | 79 ++-- lib/widgets/full_screen_portal.dart | 59 --- lib/widgets/menu.dart | 49 +- .../message/item/text/text_message.dart | 16 +- .../message/item/transcript_message.dart | 32 +- lib/widgets/message/message.dart | 98 ++-- lib/widgets/message/message_day_time.dart | 68 ++- .../bloc/cubit/sticker_albums_cubit.dart | 6 - .../bloc/cubit/sticker_cubit.dart | 6 - lib/widgets/sticker_page/sticker_page.dart | 94 ++-- lib/widgets/user/user_dialog.dart | 4 +- .../bloc/conversation_filter_state.dart | 35 -- ...dart => conversation_filter_notifier.dart} | 117 +++-- .../user_selector/conversation_selector.dart | 71 +-- ...dart => landing_qrcode_notifier_test.dart} | 64 +-- 39 files changed, 1321 insertions(+), 1534 deletions(-) delete mode 100644 lib/bloc/bloc_converter.dart delete mode 100644 lib/bloc/simple_cubit.dart delete mode 100644 lib/bloc/stream_cubit.dart delete mode 100644 lib/ui/home/bloc/blink_cubit.dart create mode 100644 lib/ui/home/notifier/blink_notifier.dart create mode 100644 lib/ui/home/notifier/chat_side_notifier.dart rename lib/ui/landing/{bloc => }/landing_state.dart (100%) rename lib/ui/landing/{bloc/landing_cubit.dart => notifier/landing_qrcode_notifier.dart} (82%) delete mode 100644 lib/widgets/full_screen_portal.dart delete mode 100644 lib/widgets/sticker_page/bloc/cubit/sticker_albums_cubit.dart delete mode 100644 lib/widgets/sticker_page/bloc/cubit/sticker_cubit.dart delete mode 100644 lib/widgets/user_selector/bloc/conversation_filter_state.dart rename lib/widgets/user_selector/{bloc/conversation_filter_cubit.dart => conversation_filter_notifier.dart} (55%) rename test/ui/landing/{bloc/landing_qrcode_cubit_test.dart => landing_qrcode_notifier_test.dart} (71%) diff --git a/lib/bloc/bloc_converter.dart b/lib/bloc/bloc_converter.dart deleted file mode 100644 index 4fbf69b519..0000000000 --- a/lib/bloc/bloc_converter.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class BlocConverter, S, T> extends StatefulWidget { - const BlocConverter({ - required this.converter, - super.key, - this.cubit, - this.when, - this.builder, - this.child, - this.listener, - this.immediatelyCallListener = false, - }) : assert(builder != null || child != null), - assert(!(builder != null && child != null)); - - final C? cubit; - final BlocBuilderCondition? when; - final BlocConverterCondition converter; - - final Widget? child; - final BlocWidgetBuilder? builder; - final BlocWidgetListener? listener; - - final bool immediatelyCallListener; - - @override - State createState() => _BlocConverterState(); -} - -typedef BlocConverterCondition = T Function(S state); - -class _BlocConverterState, S, T> - extends State> { - StreamSubscription? _subscription; - late T _previousState; - late T _state; - late C _cubit; - - T _converter(S state) => widget.converter(state); - - @override - void initState() { - super.initState(); - _cubit = widget.cubit ?? context.read(); - _previousState = _converter(_cubit.state); - _state = _previousState; - if (widget.immediatelyCallListener) widget.listener?.call(context, _state); - _subscribe(); - } - - @override - void didUpdateWidget(BlocConverter oldWidget) { - super.didUpdateWidget(oldWidget); - final oldCubit = oldWidget.cubit ?? context.read(); - final currentBloc = widget.cubit ?? oldCubit; - if (oldCubit != currentBloc) { - if (_subscription != null) { - _unsubscribe(); - _cubit = widget.cubit ?? context.read(); - _previousState = _converter(_cubit.state); - _state = _previousState; - } - _subscribe(); - } else { - final state = _converter(_cubit.state); - if (_previousState != state) { - _previousState = _state; - _state = state; - } - } - } - - @override - Widget build(BuildContext context) => - widget.builder?.call(context, _state) ?? widget.child!; - - @override - void dispose() { - _unsubscribe(); - super.dispose(); - } - - void _subscribe() { - _subscription = _cubit.stream.map(_converter).listen((state) { - if (_previousState == state) return; - - if (widget.when?.call(_previousState, state) ?? true) { - widget.listener?.call(context, state); - _state = state; - - if (widget.builder != null) setState(() {}); - } - _previousState = state; - }); - } - - void _unsubscribe() { - if (_subscription != null) { - _subscription?.cancel(); - _subscription = null; - } - } -} diff --git a/lib/bloc/simple_cubit.dart b/lib/bloc/simple_cubit.dart deleted file mode 100644 index f0ef645345..0000000000 --- a/lib/bloc/simple_cubit.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SimpleCubit extends Cubit { - SimpleCubit(super.state); - - @override - void emit(State state) => super.emit(state); -} diff --git a/lib/bloc/stream_cubit.dart b/lib/bloc/stream_cubit.dart deleted file mode 100644 index 25debcbb52..0000000000 --- a/lib/bloc/stream_cubit.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'subscribe_mixin.dart'; - -class StreamCubit extends Cubit with SubscribeMixin { - StreamCubit(super.state, Stream stream) { - addSubscription(stream.distinct().listen(emit)); - } -} diff --git a/lib/ui/home/bloc/blink_cubit.dart b/lib/ui/home/bloc/blink_cubit.dart deleted file mode 100644 index 284655bc1d..0000000000 --- a/lib/ui/home/bloc/blink_cubit.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/material.dart'; - -import '../../../bloc/subscribe_mixin.dart'; - -class BlinkState extends Equatable { - const BlinkState({this.color = Colors.transparent, this.messageId}); - - final Color color; - final String? messageId; - - @override - List get props => [color, messageId]; - - BlinkState copyWith({Color? color, String? messageId}) => BlinkState( - color: color ?? this.color, - messageId: messageId ?? this.messageId, - ); -} - -class BlinkCubit extends Cubit with SubscribeMixin { - BlinkCubit(this.tickerProvider, this.blinkColor) : super(const BlinkState()) { - animationController - ..addListener(_onUpdate) - ..addStatusListener(_onComplete); - } - - final TickerProvider tickerProvider; - final Color blinkColor; - - late final AnimationController animationController = AnimationController( - duration: const Duration(milliseconds: 600), - vsync: tickerProvider, - ); - - late final colorTween = ColorTween( - begin: Colors.transparent, - end: blinkColor, - ); - - void _onUpdate() { - emit(state.copyWith(color: colorTween.evaluate(animationController))); - } - - void _onComplete(AnimationStatus status) { - if (status != AnimationStatus.completed) return; - animationController.reverse(); - } - - void blinkByMessageId(String messageId) { - emit(BlinkState(messageId: messageId)); - animationController - ..reset() - ..forward(); - } - - @override - Future close() { - animationController - ..removeListener(_onUpdate) - ..removeStatusListener(_onComplete); - return super.close(); - } -} diff --git a/lib/ui/home/chat/chat_bar.dart b/lib/ui/home/chat/chat_bar.dart index 6e516f8960..ca13cf0db0 100644 --- a/lib/ui/home/chat/chat_bar.dart +++ b/lib/ui/home/chat/chat_bar.dart @@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../constants/resources.dart'; import '../../../db/database_event_bus.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../utils/logger.dart'; import '../../../utils/web_view/web_view_interface.dart'; import '../../../widgets/action_button.dart'; @@ -15,11 +14,10 @@ import '../../../widgets/conversation/badges_widget.dart'; import '../../../widgets/high_light_text.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/window/move_window.dart'; -import '../../provider/abstract_responsive_navigator.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/message_selection_provider.dart'; import '../../provider/responsive_navigator_provider.dart'; -import 'chat_page.dart'; +import '../notifier/chat_side_notifier.dart'; class ChatBar extends HookConsumerWidget { const ChatBar({super.key}); @@ -27,13 +25,8 @@ class ChatBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final actionColor = context.theme.icon; - final chatSideCubit = context.read(); - - final chatSideRouteMode = - useBlocStateConverter( - bloc: chatSideCubit, - converter: (state) => state.routeMode, - ); + final chatSideNotifier = context.read(); + final chatSideRouteMode = useValueListenable(chatSideNotifier).routeMode; final routeMode = ref.watch(navigatorRouteModeProvider); @@ -51,7 +44,7 @@ class ChatBar extends HookConsumerWidget { if (inMultiSelectMode) { return; } - chatSideCubit.toggleInfoPage(); + chatSideNotifier.toggleInfoPage(); }, onLongPress: longPressToShareLog ? (details) { @@ -142,12 +135,12 @@ class ChatBar extends HookConsumerWidget { name: Resources.assetsImagesIcSearchSvg, color: actionColor, onTap: () { - final cubit = context.read(); - if (cubit.state.pages.lastOrNull?.name == - ChatSideCubit.searchMessageHistory) { - return cubit.pop(); + final notifier = context.read(); + if (notifier.state.pages.lastOrNull?.name == + ChatSideNotifier.searchMessageHistory) { + return notifier.pop(); } - cubit.replace(ChatSideCubit.searchMessageHistory); + notifier.replace(ChatSideNotifier.searchMessageHistory); }, ), ), @@ -160,7 +153,7 @@ class ChatBar extends HookConsumerWidget { : ActionButton( name: Resources.assetsImagesIcScreenSvg, color: actionColor, - onTap: chatSideCubit.toggleInfoPage, + onTap: chatSideNotifier.toggleInfoPage, ), ), ), diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 4fd2702e38..36a302f0d6 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -8,14 +8,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; +import 'package:hooks_riverpod/hooks_riverpod.dart' + hide ChangeNotifierProvider, Provider; import 'package:provider/provider.dart'; import 'package:super_context_menu/super_context_menu.dart'; import '../../../account/scam_warning_key_value.dart'; import '../../../account/show_pin_message_key_value.dart'; -import '../../../bloc/simple_cubit.dart'; -import '../../../bloc/subscribe_mixin.dart'; import '../../../constants/resources.dart'; import '../../../db/database_event_bus.dart'; import '../../../db/mixin_database.dart' hide Offset; @@ -37,25 +36,15 @@ import '../../../widgets/message/message_day_time.dart'; import '../../../widgets/pin_bubble.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/window/menus.dart'; -import '../../provider/abstract_responsive_navigator.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/message_selection_provider.dart'; import '../../provider/pending_jump_message_provider.dart'; -import '../bloc/blink_cubit.dart'; import '../bloc/message_bloc.dart'; -import '../chat_slide_page/chat_info_page.dart'; -import '../chat_slide_page/circle_manager_page.dart'; -import '../chat_slide_page/disappear_message_page.dart'; -import '../chat_slide_page/group_participants_page.dart'; -import '../chat_slide_page/groups_in_common_page.dart'; -import '../chat_slide_page/pin_messages_page.dart'; -import '../chat_slide_page/search_message_page.dart'; -import '../chat_slide_page/shared_apps_page.dart'; -import '../chat_slide_page/shared_media_page.dart'; import '../home.dart'; import '../hook/pin_message.dart'; -import '../route/responsive_navigator.dart'; +import '../notifier/blink_notifier.dart'; +import '../notifier/chat_side_notifier.dart'; import 'chat_bar.dart'; import 'chat_scroll_coordinator.dart'; import 'files_preview.dart'; @@ -63,143 +52,6 @@ import 'input_container.dart'; import 'message_jump.dart'; import 'selection_bottom_bar.dart'; -class ChatSideCubit extends AbstractResponsiveNavigatorCubit { - ChatSideCubit() : super(const ResponsiveNavigatorState()); - - static const infoPage = 'infoPage'; - static const circles = 'circles'; - static const searchMessageHistory = 'searchMessageHistory'; - static const sharedMedia = 'sharedMedia'; - static const participants = 'participants'; - static const pinMessages = 'pinMessages'; - static const sharedApps = 'sharedApps'; - static const groupsInCommon = 'groupsInCommon'; - static const disappearMessages = 'disappearMessages'; - - @override - MaterialPage route(String name, Object? arguments) { - switch (name) { - case infoPage: - return const MaterialPage( - key: ValueKey(infoPage), - name: infoPage, - child: _ChatSidePageBuilder(ChatInfoPage.new), - ); - case circles: - return const MaterialPage( - key: ValueKey(circles), - name: circles, - child: _ChatSidePageBuilder(CircleManagerPage.new), - ); - case searchMessageHistory: - return const MaterialPage( - key: ValueKey(searchMessageHistory), - name: searchMessageHistory, - child: _ChatSidePageBuilder(SearchMessagePage.new), - ); - case sharedMedia: - return const MaterialPage( - key: ValueKey(sharedMedia), - name: sharedMedia, - child: _ChatSidePageBuilder(SharedMediaPage.new), - ); - case participants: - return const MaterialPage( - key: ValueKey(participants), - name: participants, - child: _ChatSidePageBuilder(GroupParticipantsPage.new), - ); - case pinMessages: - return const MaterialPage( - key: ValueKey(pinMessages), - name: pinMessages, - child: _ChatSidePageBuilder(PinMessagesPage.new), - ); - case sharedApps: - return const MaterialPage( - key: ValueKey(sharedApps), - name: sharedApps, - child: _ChatSidePageBuilder(SharedAppsPage.new), - ); - case groupsInCommon: - return const MaterialPage( - key: ValueKey(groupsInCommon), - name: groupsInCommon, - child: _ChatSidePageBuilder(GroupsInCommonPage.new), - ); - case disappearMessages: - return const MaterialPage( - key: ValueKey(disappearMessages), - name: disappearMessages, - child: _ChatSidePageBuilder(DisappearMessagePage.new), - ); - default: - throw ArgumentError('Invalid route'); - } - } - - void toggleInfoPage() { - if (state.pages.isEmpty) { - return emit(state.copyWith(pages: [route(ChatSideCubit.infoPage, null)])); - } - return clear(); - } -} - -class SearchConversationKeywordCubit extends SimpleCubit<(String?, String)> - with SubscribeMixin { - SearchConversationKeywordCubit({required ChatSideCubit chatSideCubit}) - : super(const (null, '')) { - addSubscription( - chatSideCubit.stream - .map( - (event) => event.pages.any( - (element) => element.name == ChatSideCubit.searchMessageHistory, - ), - ) - .distinct() - .listen((event) => emit(const (null, ''))), - ); - } - - static void updateKeyword(BuildContext context, String keyword) { - final cubit = context.read(); - cubit.emit((cubit.state.$1, keyword)); - } - - static void updateSelectedUser(BuildContext context, String? userId) { - final cubit = context.read(); - cubit.emit((userId, cubit.state.$2)); - } -} - -class _ChatSidePageBuilder extends HookConsumerWidget { - const _ChatSidePageBuilder(this.builder); - - final Widget Function(ConversationState conversationState) builder; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final conversationId = useMemoized( - () => ref.read(lastConversationIdProvider), - [], - ); - - final filter = useCallback( - (state) => state?.conversationId == conversationId, - [conversationId], - ); - - final conversationState = ref.watch(filterLastConversationProvider(filter)); - - if (conversationId == null || conversationState == null) { - return const SizedBox(); - } - - return builder(conversationState); - } -} - class ChatPage extends HookConsumerWidget { const ChatPage({super.key}); @@ -212,27 +64,34 @@ class ChatPage extends HookConsumerWidget { ), ); - final chatSideCubit = useBloc(ChatSideCubit.new, keys: [conversationId]); + final chatSideNotifier = useMemoized( + ChatSideNotifier.new, + [conversationId], + ); + useEffect(() => chatSideNotifier.dispose, [chatSideNotifier]); - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), - keys: [conversationId], + final searchConversationKeywordNotifier = useMemoized( + () => SearchConversationKeywordNotifier( + chatSideNotifier: chatSideNotifier, + ), + [conversationId, chatSideNotifier], + ); + useEffect( + () => searchConversationKeywordNotifier.dispose, + [searchConversationKeywordNotifier], ); useEffect(() { if (initialSidePage != null) { - chatSideCubit.pushPage(initialSidePage); + chatSideNotifier.pushPage(initialSidePage); } - }, [initialSidePage, chatSideCubit]); + }, [initialSidePage, chatSideNotifier]); - final navigatorState = - useBlocState( - bloc: chatSideCubit, - ); + final navigatorState = useValueListenable(chatSideNotifier); ref.listen(hasSelectedMessageProvider, (previous, hasSelectedMessage) { if (!hasSelectedMessage) return; - chatSideCubit.clear(); + chatSideNotifier.clear(); }); final chatContainerPage = MaterialPage( @@ -244,19 +103,24 @@ class ChatPage extends HookConsumerWidget { final windowHeight = MediaQuery.sizeOf(context).height; final tickerProvider = useSingleTickerProvider(); - final blinkCubit = useBloc( - () => BlinkCubit( + final blinkNotifier = useMemoized( + () => BlinkNotifier( tickerProvider, context.theme.accent.withValues(alpha: 0.5), ), ); + useEffect(() => blinkNotifier.dispose, [blinkNotifier]); final pinMessageState = usePinMessageState(conversationId); return MultiProvider( providers: [ - BlocProvider.value(value: blinkCubit), - BlocProvider.value(value: chatSideCubit), - BlocProvider.value(value: searchConversationKeywordCubit), + ChangeNotifierProvider.value(value: blinkNotifier), + ChangeNotifierProvider.value( + value: chatSideNotifier, + ), + ChangeNotifierProvider.value( + value: searchConversationKeywordNotifier, + ), BlocProvider( create: (context) => MessageBloc( accountServer: context.accountServer, @@ -281,7 +145,7 @@ class ChatPage extends HookConsumerWidget { final routeMode = boxConstraints.maxWidth < (kResponsiveNavigationMinWidth + kChatSidePageWidth); - chatSideCubit.updateRouteMode(routeMode); + chatSideNotifier.updateRouteMode(routeMode); return _ChatMenuHandler( child: Row( @@ -296,14 +160,14 @@ class ChatPage extends HookConsumerWidget { }, actions: { EscapeIntent: CallbackAction( - onInvoke: (intent) => chatSideCubit.pop(), + onInvoke: (intent) => chatSideNotifier.pop(), ), }, child: _SideRouter( - chatSideCubit: chatSideCubit, constraints: boxConstraints, + routeMode: routeMode, onDidRemovePage: (page) { - chatSideCubit.onPopPage(); + chatSideNotifier.onPopPage(); }, pages: [ if (routeMode) chatContainerPage, @@ -323,34 +187,30 @@ class ChatPage extends HookConsumerWidget { class _SideRouter extends StatelessWidget { const _SideRouter({ - required this.chatSideCubit, required this.pages, required this.constraints, + required this.routeMode, this.onDidRemovePage, }); - final ChatSideCubit chatSideCubit; - final List> pages; final DidRemovePageCallback? onDidRemovePage; final BoxConstraints constraints; + final bool routeMode; @override - Widget build(BuildContext context) { - final routeMode = chatSideCubit.state.routeMode; - return routeMode - ? SizedBox( - width: constraints.maxWidth, - child: Navigator(pages: pages, onDidRemovePage: onDidRemovePage), - ) - : _AnimatedChatSlide( - constraints: constraints, - pages: pages, - onDidRemovePage: onDidRemovePage, - ); - } + Widget build(BuildContext context) => routeMode + ? SizedBox( + width: constraints.maxWidth, + child: Navigator(pages: pages, onDidRemovePage: onDidRemovePage), + ) + : _AnimatedChatSlide( + constraints: constraints, + pages: pages, + onDidRemovePage: onDidRemovePage, + ); } class _AnimatedChatSlide extends HookConsumerWidget { @@ -551,7 +411,6 @@ class _List extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final messageBloc = context.read(); final scrollCoordinator = context.read(); - final hiddenMessageDayTimeBloc = useBloc(HiddenMessageDayTimeBloc.new); final state = useBlocState( when: (state) => state.conversationId != null, ); @@ -590,20 +449,6 @@ class _List extends HookConsumerWidget { syncMessageGlobalKeys(messageKeysRef.value, messageIds); }, [messageIdsKey]); - useEffect(() { - void syncHiddenDateTime() { - hiddenMessageDayTimeBloc.update( - scrollCoordinator.visibleDateTime.value, - ); - } - - scrollCoordinator.visibleDateTime.addListener(syncHiddenDateTime); - syncHiddenDateTime(); - return () { - scrollCoordinator.visibleDateTime.removeListener(syncHiddenDateTime); - }; - }, [hiddenMessageDayTimeBloc, scrollCoordinator]); - useEffect( () { scrollCoordinator.scheduleRestore( @@ -625,115 +470,85 @@ class _List extends HookConsumerWidget { ], ); - return BlocProvider.value( - value: hiddenMessageDayTimeBloc, - child: Stack( - fit: StackFit.expand, - children: [ - NotificationListener( - onNotification: (notification) => - scrollCoordinator.handleScrollNotification( - notification, - messages: messages, - keysByMessageId: messageKeysRef.value, - loadBefore: messageBloc.before, - loadAfter: messageBloc.after, - ), - child: ClampingCustomScrollView( - key: scrollCoordinator.viewportKey, - center: key, - controller: scrollCoordinator.scrollController, - anchor: 0.3, - physics: const ClampingScrollPhysics(), - scrollCacheExtent: const ScrollCacheExtent.viewport( - ChatScrollCoordinator.loadedJumpViewportCount, - ), - slivers: [ - SliverList( - key: scrollCoordinator.topSliverKey, - delegate: SliverChildBuilderDelegate(( - context, - index, - ) { - final actualIndex = top.length - index - 1; - final messageItem = top[actualIndex]; - return MessageItemWidget( - key: messageKeysRef.value[messageItem.messageId], - prev: top.getOrNull(actualIndex - 1), - message: messageItem, - next: - top.getOrNull(actualIndex + 1) ?? - center ?? - bottom.lastOrNull, - lastReadMessageId: state.lastReadMessageId, - ); - }, childCount: top.length), - ), - SliverToBoxAdapter( - key: key, - child: Builder( - builder: (context) { - if (center == null) return const SizedBox(); - return MessageItemWidget( - key: messageKeysRef.value[center.messageId], - prev: top.lastOrNull, - message: center, - next: bottom.firstOrNull, - lastReadMessageId: state.lastReadMessageId, - ); - }, - ), - ), - SliverList( - key: scrollCoordinator.bottomSliverKey, - delegate: SliverChildBuilderDelegate(( - context, - index, - ) { - final messageItem = bottom[index]; - return MessageItemWidget( - key: messageKeysRef.value[messageItem.messageId], - prev: - bottom.getOrNull(index - 1) ?? - center ?? - top.lastOrNull, - message: messageItem, - next: bottom.getOrNull(index + 1), - lastReadMessageId: state.lastReadMessageId, - ); - }, childCount: bottom.length), - ), - const SliverToBoxAdapter(child: SizedBox(height: 10)), - ], + return MessageDayTimeViewportWidget.chatPage( + key: key, + topKey: scrollCoordinator.topSliverKey, + bottomKey: scrollCoordinator.bottomSliverKey, + center: center, + centerKey: center == null ? null : messageKeysRef.value[center.messageId], + scrollController: scrollCoordinator.scrollController, + child: NotificationListener( + onNotification: (notification) => + scrollCoordinator.handleScrollNotification( + notification, + messages: messages, + keysByMessageId: messageKeysRef.value, + loadBefore: messageBloc.before, + loadAfter: messageBloc.after, ), + child: ClampingCustomScrollView( + key: scrollCoordinator.viewportKey, + center: key, + controller: scrollCoordinator.scrollController, + anchor: 0.3, + physics: const ClampingScrollPhysics(), + scrollCacheExtent: const ScrollCacheExtent.viewport( + ChatScrollCoordinator.loadedJumpViewportCount, ), - _ChatDateOverlay(scrollCoordinator: scrollCoordinator), - ], + slivers: [ + SliverList( + key: scrollCoordinator.topSliverKey, + delegate: SliverChildBuilderDelegate((context, index) { + final actualIndex = top.length - index - 1; + final messageItem = top[actualIndex]; + return MessageItemWidget( + key: messageKeysRef.value[messageItem.messageId], + prev: top.getOrNull(actualIndex - 1), + message: messageItem, + next: + top.getOrNull(actualIndex + 1) ?? + center ?? + bottom.lastOrNull, + lastReadMessageId: state.lastReadMessageId, + ); + }, childCount: top.length), + ), + SliverToBoxAdapter( + key: key, + child: Builder( + builder: (context) { + if (center == null) return const SizedBox(); + return MessageItemWidget( + key: messageKeysRef.value[center.messageId], + prev: top.lastOrNull, + message: center, + next: bottom.firstOrNull, + lastReadMessageId: state.lastReadMessageId, + ); + }, + ), + ), + SliverList( + key: scrollCoordinator.bottomSliverKey, + delegate: SliverChildBuilderDelegate((context, index) { + final messageItem = bottom[index]; + return MessageItemWidget( + key: messageKeysRef.value[messageItem.messageId], + prev: bottom.getOrNull(index - 1) ?? center ?? top.lastOrNull, + message: messageItem, + next: bottom.getOrNull(index + 1), + lastReadMessageId: state.lastReadMessageId, + ); + }, childCount: bottom.length), + ), + const SliverToBoxAdapter(child: SizedBox(height: 10)), + ], + ), ), ); } } -class _ChatDateOverlay extends StatelessWidget { - const _ChatDateOverlay({required this.scrollCoordinator}); - - final ChatScrollCoordinator scrollCoordinator; - - @override - Widget build(BuildContext context) => ValueListenableBuilder( - valueListenable: scrollCoordinator.visibleDateTime, - builder: (context, dateTime, child) { - if (dateTime == null) return const SizedBox(); - return IgnorePointer( - child: Align( - alignment: Alignment.topCenter, - child: MessageDayTimeChip(dateTime: dateTime), - ), - ); - }, - ); -} - class _JumpCurrentButton extends HookConsumerWidget { const _JumpCurrentButton(); @@ -937,13 +752,13 @@ class _PinMessagesBanner extends HookConsumerWidget { visible: currentPinMessageIds.isNotEmpty, child: InteractiveDecoratedBox( onTap: () { - final cubit = context.read(); - if (cubit.state.pages.lastOrNull?.name == - ChatSideCubit.pinMessages) { - return cubit.pop(); + final notifier = context.read(); + if (notifier.state.pages.lastOrNull?.name == + ChatSideNotifier.pinMessages) { + return notifier.pop(); } - cubit.replace(ChatSideCubit.pinMessages); + notifier.replace(ChatSideNotifier.pinMessages); }, child: Container( height: 34, @@ -1264,18 +1079,18 @@ class _ConversationHandle extends ConversationMenuHandle { @override void showSearch() { - final cubit = context.read(); - if (cubit.state.pages.lastOrNull?.name == - ChatSideCubit.searchMessageHistory) { - return cubit.pop(); + final notifier = context.read(); + if (notifier.state.pages.lastOrNull?.name == + ChatSideNotifier.searchMessageHistory) { + return notifier.pop(); } - cubit.replace(ChatSideCubit.searchMessageHistory); + notifier.replace(ChatSideNotifier.searchMessageHistory); } @override void toggleSideBar() { - context.read().toggleInfoPage(); + context.read().toggleInfoPage(); } @override diff --git a/lib/ui/home/chat/image_editor.dart b/lib/ui/home/chat/image_editor.dart index 2cd63da545..c6f652651b 100644 --- a/lib/ui/home/chat/image_editor.dart +++ b/lib/ui/home/chat/image_editor.dart @@ -5,12 +5,11 @@ import 'dart:ui' as ui; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider; import 'package:image/image.dart' as img; +import 'package:provider/provider.dart'; -import '../../../bloc/subscribe_mixin.dart'; import '../../../constants/resources.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/file.dart'; @@ -60,9 +59,15 @@ class _ImageEditorDialog extends HookConsumerWidget { assert(false, 'image is null'); return const SizedBox(); } - return BlocProvider<_ImageEditorBloc>( - create: (context) => - _ImageEditorBloc(path: path, image: uiImage, snapshot: snapshot), + final editorNotifier = useMemoized( + () => + _ImageEditorNotifier(path: path, image: uiImage, snapshot: snapshot), + [path, uiImage, snapshot], + ); + useEffect(() => editorNotifier.dispose, [editorNotifier]); + + return ChangeNotifierProvider<_ImageEditorNotifier>.value( + value: editorNotifier, child: BackdropFilter( filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: ColoredBox( @@ -194,8 +199,8 @@ class _ImageEditorState extends Equatable with EquatableMixin { ); } -class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { - _ImageEditorBloc({ +class _ImageEditorNotifier extends ValueNotifier<_ImageEditorState> { + _ImageEditorNotifier({ required this.path, required this.image, ImageEditorSnapshot? snapshot, @@ -220,10 +225,19 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { _customDrawLines.addAll(snapshot.customDrawLines); setCropRect(snapshot.cropRect); _notifyCustomDrawUpdated(); - emit(state.copyWith(rotate: snapshot.imageRotate, flip: snapshot.flip)); + _setState( + state.copyWith(rotate: snapshot.imageRotate, flip: snapshot.flip), + ); } } + _ImageEditorState get state => value; + + void _setState(_ImageEditorState state) { + if (state == value) return; + value = state; + } + final String path; final ui.Image image; @@ -254,18 +268,18 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { } } - emit(state.copyWith(rotate: next())); + _setState(state.copyWith(rotate: next())); } void flip() { - emit(state.copyWith(flip: !state.flip)); + _setState(state.copyWith(flip: !state.flip)); } void enterDrawMode(DrawMode mode) { _backupDrawLines ..clear() ..addAll(_customDrawLines); - emit(state.copyWith(drawMode: mode)); + _setState(state.copyWith(drawMode: mode)); } void exitDrawingMode({bool applyTempDraw = false}) { @@ -278,7 +292,7 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { _backupDrawLines.clear(); _notifyCustomDrawUpdated(); } - emit(state.copyWith(drawMode: DrawMode.none)); + _setState(state.copyWith(drawMode: DrawMode.none)); } void startDrawEvent(Offset position) { @@ -323,7 +337,7 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { } void _notifyCustomDrawUpdated() { - emit( + _setState( state.copyWith( drawLines: [ ..._customDrawLines, @@ -341,7 +355,7 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { } void setCustomDrawColor(Color color) { - emit(state.copyWith(drawColor: color)); + _setState(state.copyWith(drawColor: color)); } void redoDraw() { @@ -370,7 +384,7 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { void setCropRatio(double? ratio) { if (ratio == null) { - emit( + _setState( state.copyWith( cropRect: Rect.fromLTWH( 0, @@ -389,7 +403,7 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { final height = width / ratio; final x = (image.width.toDouble() - width) / 2; final y = (image.height.toDouble() - height) / 2; - emit(state.copyWith(cropRect: Rect.fromLTWH(x, y, width, height))); + _setState(state.copyWith(cropRect: Rect.fromLTWH(x, y, width, height))); } Future _flipAndRotateImage(ui.Image image) async { @@ -498,14 +512,14 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { } void setCropRect(Rect cropRect) { - emit(state.copyWith(cropRect: cropRect)); + _setState(state.copyWith(cropRect: cropRect)); } void reset() { _redoDrawLines.addAll(_customDrawLines); _customDrawLines.clear(); _notifyCustomDrawUpdated(); - emit( + _setState( state.copyWith( cropRect: Rect.fromLTWH( 0, @@ -538,20 +552,12 @@ class _Preview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isFlip = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.flip, - ); - - final rotate = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, ImageRotate>( - converter: (state) => state.rotate, - ); - - final drawMode = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, DrawMode>( - converter: (state) => state.drawMode, - ); + final editorState = useValueListenable( + context.read<_ImageEditorNotifier>(), + ); + final isFlip = editorState.flip; + final rotate = editorState.rotate; + final drawMode = editorState.drawMode; final transformedViewPortSize = rotate.apply(viewPortSize); final scale = math.min( @@ -683,10 +689,9 @@ class _CropRectWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final cropRect = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, Rect>( - converter: (state) => state.cropRect, - ); + final cropRect = useValueListenable( + context.read<_ImageEditorNotifier>(), + ).cropRect; final transformedRect = useMemoized(() { if (cropRect.isEmpty || cropRect.isInfinite) { @@ -712,94 +717,88 @@ class _CropRectWidget extends HookConsumerWidget { cacheTransformedRect.value = transformedRect; }, [transformedRect]); + final hoverDragArea = useState<_ImageDragArea?>(null); + return Stack( fit: StackFit.expand, children: [ - GestureDetector( - onPanStart: (details) { - final offset = details.localPosition; - const cornerSize = 30.0; - - if (!transformedRect.contains(offset)) { - trackingRectCorner.value = null; - return; - } - if (offset.dx < transformedRect.left + cornerSize && - offset.dy < transformedRect.top + cornerSize) { - trackingRectCorner.value = _ImageDragArea.topLeft; - } else if (offset.dx > transformedRect.right - cornerSize && - offset.dy < transformedRect.top + cornerSize) { - trackingRectCorner.value = _ImageDragArea.topRight; - } else if (offset.dx < transformedRect.left + cornerSize && - offset.dy > transformedRect.bottom - cornerSize) { - trackingRectCorner.value = _ImageDragArea.bottomLeft; - } else if (offset.dx > transformedRect.right - cornerSize && - offset.dy > transformedRect.bottom - cornerSize) { - trackingRectCorner.value = _ImageDragArea.bottomRight; - } else { - trackingRectCorner.value = _ImageDragArea.center; - } + MouseRegion( + cursor: hoverDragArea.value.cursor, + onHover: (event) { + hoverDragArea.value = _hitTestImageDragArea( + transformedRect, + event.localPosition, + ); }, - onPanUpdate: (details) { - final corner = trackingRectCorner.value; - if (corner == null) { - return; - } - final delta = details.delta; - final imageRect = Offset.zero & rotate.apply(scaledImageSize); - // Use cacheTransformedRect to track mouse/gesture change. - // Cannot use transformedRect because transformedRect update by build method - // which may slow than gesture update. - final currentCropRect = cacheTransformedRect.value; - Rect cropRect; - switch (corner) { - case _ImageDragArea.topLeft: - cropRect = Rect.fromPoints( - currentCropRect.topLeft + delta, - currentCropRect.bottomRight, - ).ensureInside(imageRect); - case _ImageDragArea.topRight: - cropRect = Rect.fromPoints( - currentCropRect.bottomLeft, - currentCropRect.topRight + delta, - ).ensureInside(imageRect); - case _ImageDragArea.bottomLeft: - cropRect = Rect.fromPoints( - currentCropRect.bottomLeft + delta, - currentCropRect.topRight, - ).ensureInside(imageRect); - case _ImageDragArea.bottomRight: - cropRect = Rect.fromPoints( - currentCropRect.topLeft, - currentCropRect.bottomRight + delta, - ).ensureInside(imageRect); - case _ImageDragArea.center: - cropRect = currentCropRect - .shift(delta) - .ensureShiftInside(imageRect); - } - - if (cropRect.isEmpty) { - return; - } - cacheTransformedRect.value = cropRect; - final rect = transformInsideRect( - cropRect.flipHorizontalInParent(imageRect, isFlip), - imageRect, - rotate.radius, - ).scaled(1 / scale); - context.read<_ImageEditorBloc>().setCropRect(rect); - }, - onPanEnd: (details) { - trackingRectCorner.value = null; - }, - child: CustomPaint( - painter: _CropShadowOverlayPainter( - cropRect: cacheTransformedRect.value, - overlayColor: Colors.black.withValues(alpha: 0.4), - lineColor: Colors.white, + onExit: (_) => hoverDragArea.value = null, + child: GestureDetector( + onPanStart: (details) { + trackingRectCorner.value = _hitTestImageDragArea( + transformedRect, + details.localPosition, + ); + }, + onPanUpdate: (details) { + final corner = trackingRectCorner.value; + if (corner == null) { + return; + } + final delta = details.delta; + final imageRect = Offset.zero & rotate.apply(scaledImageSize); + // Use cacheTransformedRect to track mouse/gesture change. + // Cannot use transformedRect because transformedRect update by build method + // which may slow than gesture update. + final currentCropRect = cacheTransformedRect.value; + Rect cropRect; + switch (corner) { + case _ImageDragArea.topLeft: + cropRect = Rect.fromPoints( + currentCropRect.topLeft + delta, + currentCropRect.bottomRight, + ).ensureInside(imageRect); + case _ImageDragArea.topRight: + cropRect = Rect.fromPoints( + currentCropRect.bottomLeft, + currentCropRect.topRight + delta, + ).ensureInside(imageRect); + case _ImageDragArea.bottomLeft: + cropRect = Rect.fromPoints( + currentCropRect.bottomLeft + delta, + currentCropRect.topRight, + ).ensureInside(imageRect); + case _ImageDragArea.bottomRight: + cropRect = Rect.fromPoints( + currentCropRect.topLeft, + currentCropRect.bottomRight + delta, + ).ensureInside(imageRect); + case _ImageDragArea.center: + cropRect = currentCropRect + .shift(delta) + .ensureShiftInside(imageRect); + } + + if (cropRect.isEmpty) { + return; + } + cacheTransformedRect.value = cropRect; + final rect = transformInsideRect( + cropRect.flipHorizontalInParent(imageRect, isFlip), + imageRect, + rotate.radius, + ).scaled(1 / scale); + context.read<_ImageEditorNotifier>().setCropRect(rect); + }, + onPanEnd: (details) { + trackingRectCorner.value = null; + }, + child: CustomPaint( + painter: _CropShadowOverlayPainter( + cropRect: cacheTransformedRect.value, + overlayColor: Colors.black.withValues(alpha: 0.4), + lineColor: Colors.white, + ), + child: const SizedBox.expand(), ), - child: const SizedBox.expand(), ), ), ], @@ -945,14 +944,8 @@ class _CustomDrawingWidget extends HookConsumerWidget { final scaledImageSize = Size(image.width * scale, image.height * scale); - final editorBloc = context.read<_ImageEditorBloc>(); - - final lines = - useBlocStateConverter< - _ImageEditorBloc, - _ImageEditorState, - List - >(bloc: editorBloc, converter: (state) => state.drawLines); + final editorNotifier = context.read<_ImageEditorNotifier>(); + final lines = useValueListenable(editorNotifier).drawLines; Offset screenToImage(Offset position) { final center = viewPortSize.center(Offset.zero); @@ -984,12 +977,12 @@ class _CustomDrawingWidget extends HookConsumerWidget { return GestureDetector( behavior: HitTestBehavior.opaque, onPanStart: (details) { - editorBloc.startDrawEvent(screenToImage(details.localPosition)); + editorNotifier.startDrawEvent(screenToImage(details.localPosition)); }, onPanUpdate: (details) { - editorBloc.updateDrawEvent(screenToImage(details.localPosition)); + editorNotifier.updateDrawEvent(screenToImage(details.localPosition)); }, - onPanEnd: (details) => editorBloc.endDrawEvent(), + onPanEnd: (details) => editorNotifier.endDrawEvent(), child: OverflowBox( maxWidth: scaledImageSize.width, maxHeight: scaledImageSize.height, @@ -1004,6 +997,46 @@ class _CustomDrawingWidget extends HookConsumerWidget { enum _ImageDragArea { topLeft, topRight, bottomLeft, bottomRight, center } +const _kImageCropHandleSize = 30.0; + +_ImageDragArea? _hitTestImageDragArea(Rect rect, Offset offset) { + if (!rect.contains(offset)) return null; + if (offset.dx < rect.left + _kImageCropHandleSize && + offset.dy < rect.top + _kImageCropHandleSize) { + return _ImageDragArea.topLeft; + } + if (offset.dx > rect.right - _kImageCropHandleSize && + offset.dy < rect.top + _kImageCropHandleSize) { + return _ImageDragArea.topRight; + } + if (offset.dx < rect.left + _kImageCropHandleSize && + offset.dy > rect.bottom - _kImageCropHandleSize) { + return _ImageDragArea.bottomLeft; + } + if (offset.dx > rect.right - _kImageCropHandleSize && + offset.dy > rect.bottom - _kImageCropHandleSize) { + return _ImageDragArea.bottomRight; + } + return _ImageDragArea.center; +} + +extension _ImageDragAreaCursor on _ImageDragArea? { + MouseCursor get cursor { + switch (this) { + case _ImageDragArea.topLeft: + case _ImageDragArea.bottomRight: + return SystemMouseCursors.resizeUpLeftDownRight; + case _ImageDragArea.topRight: + case _ImageDragArea.bottomLeft: + return SystemMouseCursors.resizeUpRightDownLeft; + case _ImageDragArea.center: + return SystemMouseCursors.move; + case null: + return SystemMouseCursors.basic; + } + } +} + enum ImageRotate { none, quarter, half, threeQuarter } extension _ImageRotateExt on ImageRotate { @@ -1159,16 +1192,15 @@ class _NormalColorTile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentColor = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, Color>( - converter: (state) => state.drawColor, - ); + final currentColor = useValueListenable( + context.read<_ImageEditorNotifier>(), + ).drawColor; return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: InkResponse( radius: 18, onTap: () { - context.read<_ImageEditorBloc>().setCustomDrawColor(color); + context.read<_ImageEditorNotifier>().setCustomDrawColor(color); }, child: SizedBox( width: 28, @@ -1275,7 +1307,7 @@ class _CustomColorBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final initialHue = useMemoized(() { - final color = context.read<_ImageEditorBloc>().state.drawColor; + final color = context.read<_ImageEditorNotifier>().state.drawColor; return HSLColor.fromColor(color).hue; }); const maxSlideOffset = 256.0 - 12 - 9.0; @@ -1322,7 +1354,7 @@ class _CustomColorBar extends HookConsumerWidget { } final color = sliderOffsetToColor(sliderOffset.value); onColorSelected(color); - context.read<_ImageEditorBloc>().setCustomDrawColor(color); + context.read<_ImageEditorNotifier>().setCustomDrawColor(color); }, child: Material( color: Colors.white, @@ -1344,17 +1376,16 @@ class _ResetButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final canReset = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.canReset, - ); + final canReset = useValueListenable( + context.read<_ImageEditorNotifier>(), + ).canReset; return SizedBox( height: 38, child: canReset ? TextButton( child: Text(context.l10n.reset), onPressed: () { - context.read<_ImageEditorBloc>().reset(); + context.read<_ImageEditorNotifier>().reset(); }, ) : null, @@ -1367,10 +1398,9 @@ class _OperationButtons extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final drawMode = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, DrawMode>( - converter: (state) => state.drawMode, - ); + final drawMode = useValueListenable( + context.read<_ImageEditorNotifier>(), + ).drawMode; return Column( children: [ if (drawMode != DrawMode.none) @@ -1392,29 +1422,15 @@ class _NormalOperationBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final imageEditorBloc = context.read<_ImageEditorBloc>(); + final imageEditorNotifier = context.read<_ImageEditorNotifier>(); + final editorState = useValueListenable(imageEditorNotifier); - final rotated = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.rotate != ImageRotate.none, - ); - final flipped = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.flip, - ); - final hasCustomDraw = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.drawLines.isNotEmpty, - ); + final rotated = editorState.rotate != ImageRotate.none; + final flipped = editorState.flip; + final hasCustomDraw = editorState.drawLines.isNotEmpty; final hasCrop = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) { - final width = imageEditorBloc.image.width; - final height = imageEditorBloc.image.height; - return state.cropRect.width.round() != width || - state.cropRect.height.round() != height; - }, - ); + editorState.cropRect.width.round() != imageEditorNotifier.image.width || + editorState.cropRect.height.round() != imageEditorNotifier.image.height; return Material( borderRadius: const BorderRadius.all(Radius.circular(8)), color: context.theme.stickerPlaceholderColor, @@ -1425,7 +1441,7 @@ class _NormalOperationBar extends HookConsumerWidget { children: [ TextButton( onPressed: () async { - if (imageEditorBloc.state.canReset) { + if (editorState.canReset) { final result = await showConfirmMixinDialog( context, context.l10n.editImageClearWarning, @@ -1442,13 +1458,13 @@ class _NormalOperationBar extends HookConsumerWidget { ActionButton( color: rotated ? context.theme.accent : context.theme.icon, name: Resources.assetsImagesEditImageRotateSvg, - onTap: imageEditorBloc.rotate, + onTap: imageEditorNotifier.rotate, ), const SizedBox(width: 4), ActionButton( color: flipped ? context.theme.accent : context.theme.icon, name: Resources.assetsImagesEditImageFlipSvg, - onTap: imageEditorBloc.flip, + onTap: imageEditorNotifier.flip, ), const SizedBox(width: 4), CustomPopupMenuButton( @@ -1468,9 +1484,9 @@ class _NormalOperationBar extends HookConsumerWidget { ], onSelected: (value) { if (value == 0) { - imageEditorBloc.setCropRatio(null); + imageEditorNotifier.setCropRatio(null); } else { - imageEditorBloc.setCropRatio(value); + imageEditorNotifier.setCropRatio(value); } }, color: hasCrop ? context.theme.accent : context.theme.icon, @@ -1481,15 +1497,13 @@ class _NormalOperationBar extends HookConsumerWidget { color: hasCustomDraw ? context.theme.accent : context.theme.icon, name: Resources.assetsImagesEditImageDrawSvg, onTap: () { - context.read<_ImageEditorBloc>().enterDrawMode(DrawMode.brush); + imageEditorNotifier.enterDrawMode(DrawMode.brush); }, ), TextButton( onPressed: () async { showToastLoading(); - final snapshot = await context - .read<_ImageEditorBloc>() - .takeSnapshot(); + final snapshot = await imageEditorNotifier.takeSnapshot(); if (snapshot == null) { showToastFailed(null); return; @@ -1511,18 +1525,11 @@ class _DrawOperationBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final drawMode = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, DrawMode>( - converter: (state) => state.drawMode, - ); - final canRedo = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.canRedo, - ); - final canUndo = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.drawLines.isNotEmpty, - ); + final imageEditorNotifier = context.read<_ImageEditorNotifier>(); + final editorState = useValueListenable(imageEditorNotifier); + final drawMode = editorState.drawMode; + final canRedo = editorState.canRedo; + final canUndo = editorState.drawLines.isNotEmpty; return Material( borderRadius: const BorderRadius.all(Radius.circular(8)), color: context.theme.stickerPlaceholderColor, @@ -1532,9 +1539,7 @@ class _DrawOperationBar extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ TextButton( - onPressed: () { - context.read<_ImageEditorBloc>().exitDrawingMode(); - }, + onPressed: imageEditorNotifier.exitDrawingMode, child: Text( context.l10n.cancel, style: TextStyle(color: context.theme.text), @@ -1545,9 +1550,7 @@ class _DrawOperationBar extends HookConsumerWidget { ? context.theme.icon : context.theme.icon.withValues(alpha: 0.2), name: Resources.assetsImagesEditImageUndoSvg, - onTap: () { - context.read<_ImageEditorBloc>().undoDraw(); - }, + onTap: imageEditorNotifier.undoDraw, ), const SizedBox(width: 4), ActionButton( @@ -1555,9 +1558,7 @@ class _DrawOperationBar extends HookConsumerWidget { ? context.theme.icon : context.theme.icon.withValues(alpha: 0.2), name: Resources.assetsImagesEditImageRedoSvg, - onTap: () { - context.read<_ImageEditorBloc>().redoDraw(); - }, + onTap: imageEditorNotifier.redoDraw, ), const SizedBox(width: 4), ActionButton( @@ -1566,7 +1567,7 @@ class _DrawOperationBar extends HookConsumerWidget { : context.theme.icon, name: Resources.assetsImagesEditImageDrawSvg, onTap: () { - context.read<_ImageEditorBloc>().enterDrawMode(DrawMode.brush); + imageEditorNotifier.enterDrawMode(DrawMode.brush); }, ), ActionButton( @@ -1575,14 +1576,12 @@ class _DrawOperationBar extends HookConsumerWidget { : context.theme.icon, name: Resources.assetsImagesEditImageEraseSvg, onTap: () { - context.read<_ImageEditorBloc>().enterDrawMode(DrawMode.eraser); + imageEditorNotifier.enterDrawMode(DrawMode.eraser); }, ), TextButton( onPressed: () { - context.read<_ImageEditorBloc>().exitDrawingMode( - applyTempDraw: true, - ); + imageEditorNotifier.exitDrawingMode(applyTempDraw: true); }, child: Text(context.l10n.done), ), diff --git a/lib/ui/home/chat/input_container.dart b/lib/ui/home/chat/input_container.dart index 4dde511bc4..b817950683 100644 --- a/lib/ui/home/chat/input_container.dart +++ b/lib/ui/home/chat/input_container.dart @@ -8,7 +8,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider; @@ -38,17 +37,15 @@ import '../../../widgets/hover_overlay.dart'; import '../../../widgets/mention_panel.dart'; import '../../../widgets/menu.dart'; import '../../../widgets/message/item/quote_message.dart'; -import '../../../widgets/sticker_page/bloc/cubit/sticker_albums_cubit.dart'; import '../../../widgets/sticker_page/sticker_page.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; -import '../../provider/abstract_responsive_navigator.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/mention_provider.dart'; import '../../provider/quote_message_provider.dart'; import '../../provider/recall_message_reedit_provider.dart'; -import 'chat_page.dart'; +import '../notifier/chat_side_notifier.dart'; import 'files_preview.dart'; import 'voice_recorder_bottom_bar.dart'; @@ -61,10 +58,11 @@ class InputContainer extends HookConsumerWidget { final hasParticipant = ref.watch(currentConversationHasParticipantProvider); - final voiceRecorderCubit = useBloc( - () => VoiceRecorderCubit(context.audioMessageService), - keys: [conversationId], + final voiceRecorderNotifier = useMemoized( + () => VoiceRecorderNotifier(context.audioMessageService), + [conversationId], ); + useEffect(() => voiceRecorderNotifier.dispose, [voiceRecorderNotifier]); if (!hasParticipant) { return Container( @@ -78,8 +76,8 @@ class InputContainer extends HookConsumerWidget { ); } - return BlocProvider.value( - value: voiceRecorderCubit, + return ChangeNotifierProvider.value( + value: voiceRecorderNotifier, child: LayoutBuilder( builder: (context, constraints) => VoiceRecorderBarOverlayComposition( layoutWidth: constraints.maxWidth, @@ -174,11 +172,9 @@ class _InputContainer extends HookConsumerWidget { }; }, []); - final chatSidePageSize = - useBlocStateConverter( - bloc: context.read(), - converter: (state) => state.pages.length, - ); + final chatSidePageSize = useValueListenable( + context.read(), + ).pages.length; useEffect(() { if (!context.textFieldAutoGainFocus) { // Make sure on iPad/Android Pad the text field not get focused when the chat side @@ -328,7 +324,8 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { child: ActionButton( name: Resources.assetsImagesMicrophoneSvg, color: context.theme.icon, - onTap: () => context.read().startRecording(), + onTap: () => + context.read().startRecording(), ), ), ], @@ -751,9 +748,9 @@ class _AnimatedSendTypeButton extends StatelessWidget { final position = renderBox.localToGlobal( renderBox.paintBounds.topCenter, ); - context.sendMenuPosition(position); + context.menuPosition = position; } else { - context.sendMenuPosition(details.globalPosition); + context.menuPosition = details.globalPosition; } }, color: context.theme.icon, @@ -775,14 +772,17 @@ class _StickerButton extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final key = useMemoized(GlobalKey.new); - final stickerAlbumsCubit = useBloc( - () => StickerAlbumsCubit( - context.database.stickerAlbumDao.systemAddedAlbums().watchWithStream( - eventStreams: [DataBaseEventBus.instance.updateStickerStream], - duration: kVerySlowThrottleDuration, - ), - ), - ); + final stickerAlbums = + useMemoizedStream( + () => context.database.stickerAlbumDao + .systemAddedAlbums() + .watchWithStream( + eventStreams: [DataBaseEventBus.instance.updateStickerStream], + duration: kVerySlowThrottleDuration, + ), + initialData: const [], + ).data ?? + const []; final presetStickerGroups = useMemoized( () => [ @@ -794,18 +794,10 @@ class _StickerButton extends HookConsumerWidget { ], ); - final tabLength = - useBlocStateConverter, int>( - bloc: stickerAlbumsCubit, - converter: (state) => state.length + presetStickerGroups.length, - keys: [presetStickerGroups], - ); + final tabLength = stickerAlbums.length + presetStickerGroups.length; - return MultiProvider( - providers: [ - BlocProvider.value(value: stickerAlbumsCubit), - ChangeNotifierProvider.value(value: textEditingController), - ], + return ChangeNotifierProvider.value( + value: textEditingController, child: DefaultTabController( length: tabLength, initialIndex: 1, @@ -846,6 +838,7 @@ class _StickerButton extends HookConsumerWidget { tabController: DefaultTabController.of(context), tabLength: tabLength, presetStickerGroups: presetStickerGroups, + stickerAlbums: stickerAlbums, ), ), ), diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index 3e484431ff..1de84f9190 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import '../../../utils/extension/extension.dart'; -import '../bloc/blink_cubit.dart'; import '../bloc/message_bloc.dart'; +import '../notifier/blink_notifier.dart'; import 'chat_jump_trace.dart'; import 'chat_scroll_coordinator.dart'; @@ -17,7 +17,7 @@ extension ChatMessageJump on BuildContext { 'request source=${shortMessageId(sourceMessageId)} ' 'target=${shortMessageId(messageId)}', ); - read().blinkByMessageId(messageId); + read().blinkByMessageId(messageId); final scrollCoordinator = read(); final messageBloc = read(); diff --git a/lib/ui/home/chat/voice_recorder_bottom_bar.dart b/lib/ui/home/chat/voice_recorder_bottom_bar.dart index cc87f4a983..b8ee3e209c 100644 --- a/lib/ui/home/chat/voice_recorder_bottom_bar.dart +++ b/lib/ui/home/chat/voice_recorder_bottom_bar.dart @@ -3,16 +3,15 @@ import 'dart:io'; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider; import 'package:ogg_opus_player/ogg_opus_player.dart'; +import 'package:provider/provider.dart'; import '../../../constants/resources.dart'; import '../../../utils/audio_message_player/audio_message_service.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/file.dart'; -import '../../../utils/hook.dart'; import '../../../utils/load_balancer_utils.dart'; import '../../../utils/logger.dart'; import '../../../utils/system/audio_session.dart'; @@ -23,23 +22,23 @@ import '../../../widgets/waveform_widget.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/quote_message_provider.dart'; -enum RecorderState { idle, recording, recordingStopped } +enum VoiceRecorderStatus { idle, recording, recordingStopped } -class VoiceRecorderCubitState with EquatableMixin { - const VoiceRecorderCubitState({ - required this.state, +class VoiceRecorderState with EquatableMixin { + const VoiceRecorderState({ + required this.status, this.startTime, - this.recodedData, + this.recordedData, }); - final RecorderState state; + final VoiceRecorderStatus status; final DateTime? startTime; - final RecordedData? recodedData; + final RecordedData? recordedData; @override - List get props => [startTime, state]; + List get props => [startTime, status, recordedData]; } class RecordedData with EquatableMixin { @@ -53,9 +52,9 @@ class RecordedData with EquatableMixin { List get props => [waveform, duration, path]; } -class VoiceRecorderCubit extends Cubit { - VoiceRecorderCubit(this.audioMessagePlayService) - : super(const VoiceRecorderCubitState(state: RecorderState.idle)); +class VoiceRecorderNotifier extends ValueNotifier { + VoiceRecorderNotifier(this.audioMessagePlayService) + : super(const VoiceRecorderState(status: VoiceRecorderStatus.idle)); OggOpusRecorder? _recorder; String? _recorderFilePath; @@ -65,8 +64,17 @@ class VoiceRecorderCubit extends Cubit { Completer? _startingCompleter; Timer? _timer; + var _disposed = false; + + VoiceRecorderState get state => value; + + void _setState(VoiceRecorderState state) { + if (_disposed) return; + value = state; + } Future startRecording() async { + if (_disposed) return; if (_startingCompleter != null && !_startingCompleter!.isCompleted) { d('startRecording: waiting for previous startRecording to complete'); return; @@ -87,10 +95,10 @@ class VoiceRecorderCubit extends Cubit { _recorder = OggOpusRecorder(path); _recorder?.start(); _timer = Timer(const Duration(seconds: 60), stopRecording); - emit( - VoiceRecorderCubitState( + _setState( + VoiceRecorderState( startTime: DateTime.now(), - state: RecorderState.recording, + status: VoiceRecorderStatus.recording, ), ); _startingCompleter!.complete(); @@ -131,25 +139,25 @@ class VoiceRecorderCubit extends Cubit { path!, ); - emit( - VoiceRecorderCubitState( - state: RecorderState.recordingStopped, - recodedData: recodeData, + _setState( + VoiceRecorderState( + status: VoiceRecorderStatus.recordingStopped, + recordedData: recodeData, ), ); return recodeData; } - Future cancelAndExitRecordeMode() async { - if (state.state == RecorderState.idle) { + Future cancelAndExitRecordMode() async { + if (state.status == VoiceRecorderStatus.idle) { return; } - if (state.state == RecorderState.recordingStopped) { - emit(const VoiceRecorderCubitState(state: RecorderState.idle)); + if (state.status == VoiceRecorderStatus.recordingStopped) { + _setState(const VoiceRecorderState(status: VoiceRecorderStatus.idle)); return; } final result = await stopRecording(isCanceled: true); - emit(const VoiceRecorderCubitState(state: RecorderState.idle)); + _setState(const VoiceRecorderState(status: VoiceRecorderStatus.idle)); try { await File(result.path).delete(); } catch (error, stacktrace) { @@ -158,16 +166,30 @@ class VoiceRecorderCubit extends Cubit { } @override - Future close() async { - if (state.state == RecorderState.recording || - state.state == RecorderState.recordingStopped) { - await cancelAndExitRecordeMode(); - } else if (_startingCompleter != null) { + void dispose() { + _disposed = true; + _timer?.cancel(); + unawaited(_disposeRecording()); + super.dispose(); + } + + Future _disposeRecording() async { + if (_startingCompleter != null && !_startingCompleter!.isCompleted) { await _startingCompleter?.future; - await cancelAndExitRecordeMode(); } - _timer?.cancel(); - await super.close(); + if (_recorder != null || _recorderFilePath != null) { + final result = await stopRecording(isCanceled: true); + try { + await File(result.path).delete(); + } catch (error, stacktrace) { + e('disposeRecording: failed to delete file. $error $stacktrace'); + } + return; + } + if (state.status == VoiceRecorderStatus.recording || + state.status == VoiceRecorderStatus.recordingStopped) { + await cancelAndExitRecordMode(); + } } } @@ -184,53 +206,60 @@ class VoiceRecorderBarOverlayComposition extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final voiceRecorderNotifier = context.read(); final isRecorderMode = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - bool - >(converter: (state) => state.state != RecorderState.idle); + useValueListenable(voiceRecorderNotifier).status != + VoiceRecorderStatus.idle; final link = useMemoized(LayerLink.new); final overlay = Navigator.of(context).overlay ?? Overlay.of(context); final recorderBottomBarEntry = useRef(null); - final voiceRecorderCubit = context.read(); - useEffect(() { - recorderBottomBarEntry.value?.remove(); + final previousEntry = recorderBottomBarEntry.value; + if (previousEntry?.mounted ?? false) { + previousEntry?.remove(); + } recorderBottomBarEntry.value = null; if (!isRecorderMode) { - return; + return null; } final entry = OverlayEntry( - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider.value( - value: voiceRecorderCubit, - ), - ], - child: _RecordingInterceptor( - child: UnconstrainedBox( - child: CompositedTransformFollower( - link: link, - showWhenUnlinked: false, - targetAnchor: Alignment.bottomCenter, - followerAnchor: Alignment.bottomCenter, - child: SizedBox( - width: layoutWidth, - child: const Material(child: VoiceRecorderBottomBar()), + builder: (context) => + ChangeNotifierProvider.value( + value: voiceRecorderNotifier, + child: _RecordingInterceptor( + child: UnconstrainedBox( + child: CompositedTransformFollower( + link: link, + showWhenUnlinked: false, + targetAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.bottomCenter, + child: SizedBox( + width: layoutWidth, + child: const Material(child: VoiceRecorderBottomBar()), + ), + ), ), ), ), - ), - ), ); recorderBottomBarEntry.value = entry; + var disposed = false; WidgetsBinding.instance.scheduleFrameCallback((timeStamp) { + if (disposed) return; overlay.insert(entry); }); + return () { + disposed = true; + if (entry.mounted) { + entry.remove(); + } + if (recorderBottomBarEntry.value == entry) { + recorderBottomBarEntry.value = null; + } + }; }, [isRecorderMode, layoutWidth]); return CompositedTransformTarget(link: link, child: child); @@ -245,11 +274,8 @@ class _RecordingInterceptor extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isRecording = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - bool - >(converter: (state) => state.state == RecorderState.recording); + useValueListenable(context.read()).status == + VoiceRecorderStatus.recording; return Stack( fit: StackFit.expand, children: [ @@ -261,7 +287,9 @@ class _RecordingInterceptor extends HookConsumerWidget { _showDiscardRecordingWarningAlertOverlay( context, onDiscard: () { - context.read().cancelAndExitRecordeMode(); + context + .read() + .cancelAndExitRecordMode(); }, ); }, @@ -352,24 +380,11 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final startTime = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - DateTime? - >(converter: (state) => state.startTime); - final isRecording = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - bool - >(converter: (state) => state.state == RecorderState.recording); - final recordedResult = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - RecordedData? - >(converter: (state) => state.recodedData); + final recorderNotifier = context.read(); + final recorderState = useValueListenable(recorderNotifier); + final startTime = recorderState.startTime; + final isRecording = recorderState.status == VoiceRecorderStatus.recording; + final recordedResult = recorderState.recordedData; useEffect(() { if (recordedResult == null) { @@ -404,8 +419,8 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { onTap: () async { final path = recordedResult?.path; await context - .read() - .cancelAndExitRecordeMode(); + .read() + .cancelAndExitRecordMode(); if (path != null) { try { await File(path).delete(); @@ -432,8 +447,7 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { name: Resources.assetsImagesRecordStopSvg, color: context.theme.accent, onTap: () async { - final recorderCubit = context.read(); - await recorderCubit.stopRecording(); + await recorderNotifier.stopRecording(); }, ) else @@ -442,7 +456,7 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { color: context.theme.icon, onTap: () async { final path = recordedResult?.path; - await context.read().startRecording(); + await recorderNotifier.startRecording(); if (path != null) { try { await File(path).delete(); @@ -459,20 +473,19 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { final conversationItem = ref.read(conversationProvider); final accountServer = context.accountServer; - final recorderCubit = context.read(); - final RecordedData result; - if (recorderCubit.state.state == RecorderState.recording) { - result = await recorderCubit.stopRecording(); + if (recorderNotifier.state.status == + VoiceRecorderStatus.recording) { + result = await recorderNotifier.stopRecording(); } else { if (recordedResult == null) { - e('result is null. ${recorderCubit.state}'); + e('result is null. ${recorderNotifier.state}'); return; } result = recordedResult; } - await recorderCubit.cancelAndExitRecordeMode(); + await recorderNotifier.cancelAndExitRecordMode(); final audioFile = File(result.path); if (!audioFile.existsSync()) { e('audio file does not exist.'); diff --git a/lib/ui/home/chat_slide_page/chat_info_page.dart b/lib/ui/home/chat_slide_page/chat_info_page.dart index b08dbb9b65..34db86f79a 100644 --- a/lib/ui/home/chat_slide_page/chat_info_page.dart +++ b/lib/ui/home/chat_slide_page/chat_info_page.dart @@ -23,7 +23,7 @@ import '../../../widgets/user_selector/conversation_selector.dart'; import '../../provider/conversation_provider.dart'; import '../bloc/message_bloc.dart'; import '../chat/chat_bar.dart'; -import '../chat/chat_page.dart'; +import '../notifier/chat_side_notifier.dart'; import 'shared_apps_page.dart'; class ChatInfoPage extends HookConsumerWidget { @@ -95,7 +95,7 @@ class ChatInfoPage extends HookConsumerWidget { ActionButton( name: Resources.assetsImagesIcCloseSvg, color: context.theme.icon, - onTap: () => context.read().onPopPage(), + onTap: () => context.read().onPopPage(), ), ], backgroundColor: context.theme.popUp, @@ -150,8 +150,8 @@ class ChatInfoPage extends HookConsumerWidget { CellGroup( child: CellItem( title: Text(context.l10n.groupParticipants), - onTap: () => context.read().pushPage( - ChatSideCubit.participants, + onTap: () => context.read().pushPage( + ChatSideNotifier.participants, ), ), ), @@ -227,16 +227,16 @@ class ChatInfoPage extends HookConsumerWidget { children: [ CellItem( title: Text(context.l10n.sharedMedia), - onTap: () => context.read().pushPage( - ChatSideCubit.sharedMedia, + onTap: () => context.read().pushPage( + ChatSideNotifier.sharedMedia, ), ), if (conversationState.userId != null) _SharedApps(userId: conversationState.userId!), CellItem( title: Text(context.l10n.searchConversation, maxLines: 1), - onTap: () => context.read().pushPage( - ChatSideCubit.searchMessageHistory, + onTap: () => context.read().pushPage( + ChatSideNotifier.searchMessageHistory, ), ), ], @@ -258,8 +258,8 @@ class ChatInfoPage extends HookConsumerWidget { trailing: canModifyExpireIn ? const Arrow() : null, onTap: !canModifyExpireIn ? null - : () => context.read().pushPage( - ChatSideCubit.disappearMessages, + : () => context.read().pushPage( + ChatSideNotifier.disappearMessages, ), ), ), @@ -392,8 +392,8 @@ class ChatInfoPage extends HookConsumerWidget { CellGroup( child: CellItem( title: Text(context.l10n.groupsInCommon), - onTap: () => context.read().pushPage( - ChatSideCubit.groupsInCommon, + onTap: () => context.read().pushPage( + ChatSideNotifier.groupsInCommon, ), ), ), @@ -411,8 +411,8 @@ class ChatInfoPage extends HookConsumerWidget { CellGroup( child: CellItem( title: Text(context.l10n.editConversations), - onTap: () => context.read().pushPage( - ChatSideCubit.circles, + onTap: () => context.read().pushPage( + ChatSideNotifier.circles, ), ), ), @@ -711,8 +711,8 @@ class _SharedApps extends HookConsumerWidget { : CellItem( title: Text(context.l10n.shareApps), trailing: OverlappedAppIcons(apps: data), - onTap: () => context.read().pushPage( - ChatSideCubit.sharedApps, + onTap: () => context.read().pushPage( + ChatSideNotifier.sharedApps, ), ), ); diff --git a/lib/ui/home/chat_slide_page/pin_messages_page.dart b/lib/ui/home/chat_slide_page/pin_messages_page.dart index 5d1aa7993f..1264afddb6 100644 --- a/lib/ui/home/chat_slide_page/pin_messages_page.dart +++ b/lib/ui/home/chat_slide_page/pin_messages_page.dart @@ -1,9 +1,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; +import 'package:hooks_riverpod/hooks_riverpod.dart' + hide ChangeNotifierProvider, Provider; import 'package:provider/provider.dart'; import '../../../blaze/vo/pin_message_minimal.dart'; @@ -20,7 +20,7 @@ import '../../../widgets/message/item/audio_message.dart'; import '../../../widgets/message/message.dart'; import '../../../widgets/message/message_day_time.dart'; import '../../provider/conversation_provider.dart'; -import '../chat/chat_page.dart'; +import '../notifier/chat_side_notifier.dart'; class PinMessagesPage extends HookConsumerWidget { const PinMessagesPage(this.conversationState, {super.key}); @@ -47,9 +47,16 @@ class PinMessagesPage extends HookConsumerWidget { keys: [conversationId], ).data; - final chatSideCubit = useBloc(ChatSideCubit.new); - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), + final chatSideNotifier = useMemoized(ChatSideNotifier.new); + useEffect(() => chatSideNotifier.dispose, [chatSideNotifier]); + final searchConversationKeywordNotifier = useMemoized( + () => + SearchConversationKeywordNotifier(chatSideNotifier: chatSideNotifier), + [chatSideNotifier], + ); + useEffect( + () => searchConversationKeywordNotifier.dispose, + [searchConversationKeywordNotifier], ); useEffect(() { @@ -64,7 +71,9 @@ class PinMessagesPage extends HookConsumerWidget { return MultiProvider( providers: [ - BlocProvider.value(value: searchConversationKeywordCubit), + ChangeNotifierProvider.value( + value: searchConversationKeywordNotifier, + ), Provider( create: (_) => AudioMessagesPlayAgent( list, @@ -84,7 +93,7 @@ class PinMessagesPage extends HookConsumerWidget { ActionButton( name: Resources.assetsImagesIcCloseSvg, color: context.theme.icon, - onTap: () => context.read().onPopPage(), + onTap: () => context.read().onPopPage(), ), ], ), diff --git a/lib/ui/home/chat_slide_page/search_message_page.dart b/lib/ui/home/chat_slide_page/search_message_page.dart index 44f1de7115..7ffd51dd44 100644 --- a/lib/ui/home/chat_slide_page/search_message_page.dart +++ b/lib/ui/home/chat_slide_page/search_message_page.dart @@ -24,9 +24,9 @@ import '../../../widgets/search_text_field.dart'; import '../../provider/conversation_provider.dart'; import '../bloc/conversation_list_bloc.dart'; import '../bloc/search_message_cubit.dart'; -import '../chat/chat_page.dart'; import '../chat/message_jump.dart'; import '../conversation/search_list.dart'; +import '../notifier/chat_side_notifier.dart'; class SearchMessagePage extends HookConsumerWidget { const SearchMessagePage(this.conversationState, {super.key}); @@ -77,7 +77,7 @@ class SearchMessagePage extends HookConsumerWidget { }, [userMode.value, selectedUser.value]); useEffect(() { - SearchConversationKeywordCubit.updateSelectedUser( + SearchConversationKeywordNotifier.updateSelectedUser( context, selectedUser.value?.userId, ); @@ -92,7 +92,7 @@ class SearchMessagePage extends HookConsumerWidget { ActionButton( name: Resources.assetsImagesIcCloseSvg, color: context.theme.icon, - onTap: () => context.read().onPopPage(), + onTap: () => context.read().onPopPage(), ), ], ), @@ -159,7 +159,7 @@ class SearchMessagePage extends HookConsumerWidget { if (userMode.value && selectedUser.value == null) { return; } - SearchConversationKeywordCubit.updateKeyword( + SearchConversationKeywordNotifier.updateKeyword( context, keyword, ); @@ -269,15 +269,16 @@ class _SearchMessageList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final (_, initKeyword) = useMemoized( - () => context.read().state, + () => context.read().value, ); + final keywordNotifier = context.read(); + final keywordStream = useValueNotifierConvertSteam(keywordNotifier); final keyword = useMemoizedStream( - () => context - .read() - .stream + () => keywordStream .map((event) => event.$2.trim()) .debounceTime(const Duration(milliseconds: 150)), + keys: [keywordStream], ).data ?? initKeyword.trim(); @@ -308,9 +309,9 @@ class _SearchMessageList extends HookConsumerWidget { showSender: true, onTap: () async { await context.jumpToMessageInChat(message.messageId); - final chatSideCubit = context.read(); - if (chatSideCubit.state.routeMode) { - chatSideCubit.clear(); + final chatSideNotifier = context.read(); + if (chatSideNotifier.state.routeMode) { + chatSideNotifier.clear(); } }, ); diff --git a/lib/ui/home/chat_slide_page/share_media/media_page.dart b/lib/ui/home/chat_slide_page/share_media/media_page.dart index 4a72ca2329..58ddcca884 100644 --- a/lib/ui/home/chat_slide_page/share_media/media_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/media_page.dart @@ -17,7 +17,7 @@ import '../../../../widgets/message/item/image/image_message.dart'; import '../../../../widgets/message/item/video/video_message.dart'; import '../../../../widgets/message/message.dart'; import '../../../../widgets/near_edge_scroll_listener.dart'; -import '../../chat/chat_page.dart'; +import '../../notifier/chat_side_notifier.dart'; import '../shared_media_page.dart'; class MediaPage extends HookConsumerWidget { @@ -33,7 +33,7 @@ class MediaPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final column = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); - final routeMode = context.read().state.routeMode; + final routeMode = context.read().state.routeMode; final size = column * (routeMode ? 4 : 3); final messageDao = context.database.messageDao; diff --git a/lib/ui/home/notifier/blink_notifier.dart b/lib/ui/home/notifier/blink_notifier.dart new file mode 100644 index 0000000000..417b3ee715 --- /dev/null +++ b/lib/ui/home/notifier/blink_notifier.dart @@ -0,0 +1,59 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +class BlinkState extends Equatable { + const BlinkState({this.color = Colors.transparent, this.messageId}); + + final Color color; + final String? messageId; + + @override + List get props => [color, messageId]; + + BlinkState copyWith({Color? color, String? messageId}) => BlinkState( + color: color ?? this.color, + messageId: messageId ?? this.messageId, + ); +} + +class BlinkNotifier extends ValueNotifier { + BlinkNotifier(TickerProvider tickerProvider, Color blinkColor) + : _animationController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: tickerProvider, + ), + _colorTween = ColorTween(begin: Colors.transparent, end: blinkColor), + super(const BlinkState()) { + _animationController + ..addListener(_onUpdate) + ..addStatusListener(_onComplete); + } + + final AnimationController _animationController; + final ColorTween _colorTween; + + void _onUpdate() { + value = value.copyWith(color: _colorTween.evaluate(_animationController)); + } + + void _onComplete(AnimationStatus status) { + if (status != AnimationStatus.completed) return; + _animationController.reverse(); + } + + void blinkByMessageId(String messageId) { + value = BlinkState(messageId: messageId); + _animationController + ..reset() + ..forward(); + } + + @override + void dispose() { + _animationController + ..removeListener(_onUpdate) + ..removeStatusListener(_onComplete) + ..dispose(); + super.dispose(); + } +} diff --git a/lib/ui/home/notifier/chat_side_notifier.dart b/lib/ui/home/notifier/chat_side_notifier.dart new file mode 100644 index 0000000000..fa9256ce33 --- /dev/null +++ b/lib/ui/home/notifier/chat_side_notifier.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:provider/provider.dart'; + +import '../../provider/abstract_responsive_navigator.dart'; +import '../../provider/conversation_provider.dart'; +import '../chat_slide_page/chat_info_page.dart'; +import '../chat_slide_page/circle_manager_page.dart'; +import '../chat_slide_page/disappear_message_page.dart'; +import '../chat_slide_page/group_participants_page.dart'; +import '../chat_slide_page/groups_in_common_page.dart'; +import '../chat_slide_page/pin_messages_page.dart'; +import '../chat_slide_page/search_message_page.dart'; +import '../chat_slide_page/shared_apps_page.dart'; +import '../chat_slide_page/shared_media_page.dart'; + +class ChatSideNotifier extends ValueNotifier + with ResponsiveNavigatorController { + ChatSideNotifier() : super(const ResponsiveNavigatorState()); + + static const infoPage = 'infoPage'; + static const circles = 'circles'; + static const searchMessageHistory = 'searchMessageHistory'; + static const sharedMedia = 'sharedMedia'; + static const participants = 'participants'; + static const pinMessages = 'pinMessages'; + static const sharedApps = 'sharedApps'; + static const groupsInCommon = 'groupsInCommon'; + static const disappearMessages = 'disappearMessages'; + + var _disposed = false; + + ResponsiveNavigatorState get state => value; + + set state(ResponsiveNavigatorState newState) { + if (_disposed || newState == value) return; + value = newState; + } + + @override + ResponsiveNavigatorState get navigatorState => state; + + @override + set navigatorState(ResponsiveNavigatorState value) => state = value; + + @override + MaterialPage route(String name, Object? arguments) { + switch (name) { + case infoPage: + return const MaterialPage( + key: ValueKey(infoPage), + name: infoPage, + child: _ChatSidePageBuilder(ChatInfoPage.new), + ); + case circles: + return const MaterialPage( + key: ValueKey(circles), + name: circles, + child: _ChatSidePageBuilder(CircleManagerPage.new), + ); + case searchMessageHistory: + return const MaterialPage( + key: ValueKey(searchMessageHistory), + name: searchMessageHistory, + child: _ChatSidePageBuilder(SearchMessagePage.new), + ); + case sharedMedia: + return const MaterialPage( + key: ValueKey(sharedMedia), + name: sharedMedia, + child: _ChatSidePageBuilder(SharedMediaPage.new), + ); + case participants: + return const MaterialPage( + key: ValueKey(participants), + name: participants, + child: _ChatSidePageBuilder(GroupParticipantsPage.new), + ); + case pinMessages: + return const MaterialPage( + key: ValueKey(pinMessages), + name: pinMessages, + child: _ChatSidePageBuilder(PinMessagesPage.new), + ); + case sharedApps: + return const MaterialPage( + key: ValueKey(sharedApps), + name: sharedApps, + child: _ChatSidePageBuilder(SharedAppsPage.new), + ); + case groupsInCommon: + return const MaterialPage( + key: ValueKey(groupsInCommon), + name: groupsInCommon, + child: _ChatSidePageBuilder(GroupsInCommonPage.new), + ); + case disappearMessages: + return const MaterialPage( + key: ValueKey(disappearMessages), + name: disappearMessages, + child: _ChatSidePageBuilder(DisappearMessagePage.new), + ); + default: + throw ArgumentError('Invalid route'); + } + } + + void toggleInfoPage() { + if (state.pages.isEmpty) { + state = state.copyWith(pages: [route(ChatSideNotifier.infoPage, null)]); + return; + } + clear(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +class SearchConversationKeywordNotifier + extends ValueNotifier<(String?, String)> { + SearchConversationKeywordNotifier({ + required ChatSideNotifier chatSideNotifier, + }) : super(const (null, '')) { + _searchPageVisible = _isSearchPageVisible(chatSideNotifier.value); + _chatSideListener = () { + final visible = _isSearchPageVisible(chatSideNotifier.value); + if (visible == _searchPageVisible) return; + _searchPageVisible = visible; + value = const (null, ''); + }; + chatSideNotifier.addListener(_chatSideListener!); + _chatSideNotifier = chatSideNotifier; + } + + ChatSideNotifier? _chatSideNotifier; + VoidCallback? _chatSideListener; + var _searchPageVisible = false; + + static bool _isSearchPageVisible(ResponsiveNavigatorState state) => + state.pages.any( + (element) => element.name == ChatSideNotifier.searchMessageHistory, + ); + + static void updateKeyword(BuildContext context, String keyword) { + final notifier = context.read(); + notifier.value = (notifier.value.$1, keyword); + } + + static void updateSelectedUser(BuildContext context, String? userId) { + final notifier = context.read(); + notifier.value = (userId, notifier.value.$2); + } + + @override + void dispose() { + final chatSideNotifier = _chatSideNotifier; + final chatSideListener = _chatSideListener; + if (chatSideNotifier != null && chatSideListener != null) { + chatSideNotifier.removeListener(chatSideListener); + } + super.dispose(); + } +} + +class _ChatSidePageBuilder extends HookConsumerWidget { + const _ChatSidePageBuilder(this.builder); + + final Widget Function(ConversationState conversationState) builder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conversationId = useMemoized( + () => ref.read(lastConversationIdProvider), + [], + ); + + final filter = useCallback( + (state) => state?.conversationId == conversationId, + [conversationId], + ); + + final conversationState = ref.watch(filterLastConversationProvider(filter)); + + if (conversationId == null || conversationState == null) { + return const SizedBox(); + } + + return builder(conversationState); + } +} diff --git a/lib/ui/home/route/responsive_navigator.dart b/lib/ui/home/route/responsive_navigator.dart index f084fe9f38..773f2f0682 100644 --- a/lib/ui/home/route/responsive_navigator.dart +++ b/lib/ui/home/route/responsive_navigator.dart @@ -1,69 +1,9 @@ -import 'dart:math'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../provider/abstract_responsive_navigator.dart'; import '../../provider/responsive_navigator_provider.dart'; -abstract class AbstractResponsiveNavigatorCubit - extends Cubit { - AbstractResponsiveNavigatorCubit(super.initialState); - - void updateRouteMode(bool routeMode) => - emit(state.copyWith(routeMode: routeMode)); - - void onPopPage() { - final bool = state.pages.isNotEmpty; - if (bool) { - emit(state.copyWith(pages: state.pages.toList()..removeLast())); - } - } - - MaterialPage route(String name, Object? arguments); - - void pushPage(String name, {Object? arguments}) { - final page = route(name, arguments); - var index = -1; - index = state.pages.indexWhere( - (element) => - page.child.key != null && element.child.key == page.child.key, - ); - if (state.pages.isNotEmpty && index == state.pages.length - 1) return; - if (index != -1) state.pages.removeRange(max(index, 0), state.pages.length); - emit(state.copyWith(pages: state.pages.toList()..add(page))); - } - - void popUntil(bool Function(MaterialPage page) test) { - final index = state.pages.indexWhere(test); - if (index == -1) return; - - List? list; - list = index == 0 ? [] : state.pages.toList() - ..sublist(0, index); - emit(state.copyWith(pages: list)); - } - - void popWhere(bool Function(MaterialPage page) test) => - emit(state.copyWith(pages: state.pages.toList()..removeWhere(test))); - - void pop() => emit( - state.copyWith( - pages: state.pages.sublist(0, max(state.pages.length - 1, 0)).toList(), - ), - ); - - Future replace(String name, {Object? arguments}) async { - popWhere((page) => page.name == name); - await Future.delayed(Duration.zero); - pushPage(name, arguments: arguments); - } - - void clear() => emit(state.copyWith(pages: [])); -} - class ResponsiveNavigator extends HookConsumerWidget { const ResponsiveNavigator({ required this.leftPage, diff --git a/lib/ui/landing/landing.dart b/lib/ui/landing/landing.dart index da41d9bdd2..cb293f7149 100644 --- a/lib/ui/landing/landing.dart +++ b/lib/ui/landing/landing.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:mixin_logger/mixin_logger.dart'; +import 'package:provider/provider.dart' as provider; import '../../account/account_key_value.dart'; import '../../crypto/signal/signal_database.dart'; @@ -24,12 +24,6 @@ import 'landing_qrcode.dart'; enum LandingMode { qrcode, mobile } -class LandingModeCubit extends Cubit { - LandingModeCubit() : super(LandingMode.qrcode); - - void changeMode(LandingMode mode) => emit(mode); -} - class LandingPage extends HookConsumerWidget { const LandingPage({super.key}); @@ -39,8 +33,11 @@ class LandingPage extends HookConsumerWidget { accountServerProvider.select((value) => value.hasError), ); - final modeCubit = useBloc(LandingModeCubit.new); - final mode = useBlocState(bloc: modeCubit); + final modeNotifier = useMemoized( + () => ValueNotifier(LandingMode.qrcode), + ); + useEffect(() => modeNotifier.dispose, [modeNotifier]); + final mode = useValueListenable(modeNotifier); Widget child; switch (mode) { @@ -52,8 +49,8 @@ class LandingPage extends HookConsumerWidget { if (accountServerHasError) { child = const _LoginFailed(); } - return BlocProvider.value( - value: modeCubit, + return provider.ChangeNotifierProvider>.value( + value: modeNotifier, child: LandingScaffold(child: child), ); } @@ -228,7 +225,11 @@ class LandingModeSwitchButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mode = useBlocState(); + final modeNotifier = provider.Provider.of>( + context, + listen: false, + ); + final mode = useValueListenable(modeNotifier); final String buttonText; switch (mode) { case LandingMode.qrcode: @@ -238,12 +239,11 @@ class LandingModeSwitchButton extends HookConsumerWidget { } return TextButton( onPressed: () { - final modeCubit = context.read(); switch (mode) { case LandingMode.qrcode: - modeCubit.changeMode(LandingMode.mobile); + modeNotifier.value = LandingMode.mobile; case LandingMode.mobile: - modeCubit.changeMode(LandingMode.qrcode); + modeNotifier.value = LandingMode.qrcode; } }, child: Text( diff --git a/lib/ui/landing/landing_mobile.dart b/lib/ui/landing/landing_mobile.dart index 4487949347..96a0a63a61 100644 --- a/lib/ui/landing/landing_mobile.dart +++ b/lib/ui/landing/landing_mobile.dart @@ -1,15 +1,16 @@ import 'dart:async'; import 'dart:convert'; +import 'package:dio/dio.dart'; import 'package:ed25519_edwards/ed25519_edwards.dart' as ed; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:pin_code_fields/pin_code_fields.dart'; +import 'package:provider/provider.dart' as provider; import '../../account/session_key_value.dart'; import '../../constants/resources.dart'; @@ -27,7 +28,6 @@ import '../../widgets/user/captcha_web_view_dialog.dart'; import '../../widgets/user/phone_number_input.dart'; import '../../widgets/user/verification_dialog.dart'; import '../provider/multi_auth_provider.dart'; -import 'bloc/landing_cubit.dart'; import 'landing.dart'; class LoginWithMobileWidget extends HookConsumerWidget { @@ -42,13 +42,20 @@ class LoginWithMobileWidget extends HookConsumerWidget { if (userAgent == null || deviceId == null) { return const Center(child: CircularProgressIndicator()); } - return BlocProvider( - create: (_) => LandingMobileCubit( - context.multiAuthChangeNotifier, - locale, - userAgent: userAgent, - deviceId: deviceId, + final client = useMemoized( + () => Client( + dioOptions: BaseOptions( + headers: { + 'Accept-Language': locale.languageCode, + 'User-Agent': userAgent, + 'Mixin-Device-Id': deviceId, + }, + ), ), + [locale, userAgent, deviceId], + ); + return provider.Provider.value( + value: client, child: Navigator( onDidRemovePage: (page) {}, pages: const [MaterialPage(child: _PhoneNumberInputScene())], @@ -169,7 +176,7 @@ class _CodeInputScene extends HookConsumerWidget { sessionSecret: sessionSecret, pin: '', ); - final client = context.read().client; + final client = provider.Provider.of(context, listen: false); final response = await client.accountApi.create( verification.value.id, accountRequest, @@ -309,8 +316,8 @@ Future _requestVerificationCode({ hCaptchaResponse: captcha?.$1 == CaptchaType.hCaptcha ? captcha?.$2 : null, ); try { - final cubit = context.read(); - final response = await cubit.client.accountApi.verification(request); + final client = provider.Provider.of(context, listen: false); + final response = await client.accountApi.verification(request); return response.data; } on MixinApiError catch (error) { final mixinError = error.error! as MixinError; diff --git a/lib/ui/landing/landing_qrcode.dart b/lib/ui/landing/landing_qrcode.dart index e033370345..a70c1eb5dd 100644 --- a/lib/ui/landing/landing_qrcode.dart +++ b/lib/ui/landing/landing_qrcode.dart @@ -1,34 +1,32 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:provider/provider.dart' as provider; import '../../constants/resources.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../../utils/platform.dart'; import '../../widgets/qr_code.dart'; -import 'bloc/landing_cubit.dart'; -import 'bloc/landing_state.dart'; import 'landing.dart'; +import 'landing_state.dart'; +import 'notifier/landing_qrcode_notifier.dart'; class LandingQrCodeWidget extends HookConsumerWidget { const LandingQrCodeWidget({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final locale = useMemoized(() => Localizations.localeOf(context)); + final locale = Localizations.localeOf(context); + final multiAuthChangeNotifier = context.multiAuthChangeNotifier; - final landingCubit = useBloc( - () => LandingQrCodeCubit(context.multiAuthChangeNotifier, locale), + final landingNotifier = useMemoized( + () => LandingQrCodeNotifier(multiAuthChangeNotifier, locale), + [multiAuthChangeNotifier, locale], ); + useEffect(() => landingNotifier.dispose, [landingNotifier]); - final status = - useBlocStateConverter( - bloc: landingCubit, - converter: (state) => state.status, - ); + final status = useValueListenable(landingNotifier).status; final Widget child; if (status == LandingStatus.init) { @@ -61,7 +59,10 @@ class LandingQrCodeWidget extends HookConsumerWidget { ], ); } - return BlocProvider.value(value: landingCubit, child: child); + return provider.ChangeNotifierProvider.value( + value: landingNotifier, + child: child, + ); } } @@ -70,20 +71,14 @@ class _QrCode extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final url = - useBlocStateConverter( - converter: (state) => state.authUrl, - ); - - final visible = - useBlocStateConverter( - converter: (state) => state.status == LandingStatus.needReload, - ); - - final errorMessage = - useBlocStateConverter( - converter: (state) => state.errorMessage, - ); + final landingNotifier = provider.Provider.of( + context, + listen: false, + ); + final state = useValueListenable(landingNotifier); + final url = state.authUrl; + final visible = state.status == LandingStatus.needReload; + final errorMessage = state.errorMessage; Widget? qrCode; @@ -111,8 +106,7 @@ class _QrCode extends HookConsumerWidget { visible: visible, child: _Retry( errorMessage: errorMessage, - onTap: () => - context.read().requestAuthUrl(), + onTap: landingNotifier.requestAuthUrl, ), ), ], diff --git a/lib/ui/landing/bloc/landing_state.dart b/lib/ui/landing/landing_state.dart similarity index 100% rename from lib/ui/landing/bloc/landing_state.dart rename to lib/ui/landing/landing_state.dart diff --git a/lib/ui/landing/bloc/landing_cubit.dart b/lib/ui/landing/notifier/landing_qrcode_notifier.dart similarity index 82% rename from lib/ui/landing/bloc/landing_cubit.dart rename to lib/ui/landing/notifier/landing_qrcode_notifier.dart index 35f81408fd..32844d3dd1 100644 --- a/lib/ui/landing/bloc/landing_cubit.dart +++ b/lib/ui/landing/notifier/landing_qrcode_notifier.dart @@ -1,9 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:ui'; -import 'package:bloc/bloc.dart'; import 'package:dio/dio.dart'; import 'package:ed25519_edwards/ed25519_edwards.dart' as ed; import 'package:flutter/material.dart'; @@ -19,28 +17,7 @@ import '../../../utils/logger.dart'; import '../../../utils/platform.dart'; import '../../../utils/system/package_info.dart'; import '../../provider/multi_auth_provider.dart'; -import 'landing_state.dart'; - -class LandingCubit extends Cubit { - LandingCubit( - this.multiAuthChangeNotifier, - Locale locale, - T initialState, { - String? userAgent, - String? deviceId, - }) : client = Client( - dioOptions: BaseOptions( - headers: { - 'Accept-Language': locale.languageCode, - 'User-Agent': ?userAgent, - 'Mixin-Device-Id': ?deviceId, - }, - ), - ), - super(initialState); - final Client client; - final MultiAuthStateNotifier multiAuthChangeNotifier; -} +import '../landing_state.dart'; typedef ProvisioningIdLoader = Future> Function(); typedef ProvisioningLoader = @@ -51,8 +28,8 @@ typedef ProvisioningVerifier = signal.ECKeyPair keyPair, ); -class LandingQrCodeCubit extends LandingCubit { - LandingQrCodeCubit( +class LandingQrCodeNotifier extends ValueNotifier { + LandingQrCodeNotifier( MultiAuthStateNotifier multiAuthChangeNotifier, Locale locale, { this.autoStart = true, @@ -63,9 +40,13 @@ class LandingQrCodeCubit extends LandingCubit { this.expirationTickLimit = 60, this.pollingFailureLimit = 3, String Function()? expiredMessageBuilder, - }) : super( - multiAuthChangeNotifier, - locale, + }) : client = Client( + dioOptions: BaseOptions( + headers: {'Accept-Language': locale.languageCode}, + ), + ), + multiAuthChangeNotifier = multiAuthChangeNotifier, + super( LandingState( status: multiAuthChangeNotifier.current != null ? LandingStatus.provisioning @@ -90,6 +71,8 @@ class LandingQrCodeCubit extends LandingCubit { requestAuthUrl(); } + final Client client; + final MultiAuthStateNotifier multiAuthChangeNotifier; final bool autoStart; late final ProvisioningIdLoader _provisioningIdLoader; late final ProvisioningLoader _provisioningLoader; @@ -102,27 +85,33 @@ class LandingQrCodeCubit extends LandingCubit { StreamSubscription? _periodicSubscription; int _requestVersion = 0; int _pollingFailureCount = 0; + var _disposed = false; + + void _setState(LandingState state) { + if (_disposed) return; + value = state; + } void _cancelPeriodicSubscription() { - final periodicSubscription = _periodicSubscription; + unawaited(_periodicSubscription?.cancel()); _periodicSubscription = null; - unawaited(periodicSubscription?.cancel()); } Future requestAuthUrl({bool isAutoRefresh = false}) async { + if (_disposed) return; _cancelPeriodicSubscription(); final requestVersion = ++_requestVersion; _pollingFailureCount = 0; - if (state.authUrl == null) { - emit( - state.copyWith( + if (value.authUrl == null) { + _setState( + value.copyWith( status: LandingStatus.init, clearErrorMessage: true, ), ); - } else if (state.status == LandingStatus.needReload) { - emit( - state.copyWith( + } else if (value.status == LandingStatus.needReload) { + _setState( + value.copyWith( status: LandingStatus.ready, clearErrorMessage: true, ), @@ -140,14 +129,15 @@ class LandingQrCodeCubit extends LandingCubit { base64Encode(keyPair.publicKey.serialize()), ); - emit( - state.copyWith( + _setState( + value.copyWith( authUrl: 'mixin://device/auth?id=${rsp.data.deviceId}&pub_key=$pubKey', status: LandingStatus.ready, clearErrorMessage: true, ), ); + if (_disposed || requestVersion != _requestVersion) return; _periodicSubscription = _pollingStreamFactory() .asyncBufferMap( @@ -162,8 +152,8 @@ class LandingQrCodeCubit extends LandingCubit { } catch (error, stack) { if (requestVersion != _requestVersion) return; e('requestAuthUrl failed: $error $stack'); - emit( - state.needReload( + _setState( + value.needReload( isAutoRefresh ? 'Failed to refresh QR code: $error' : 'Failed to request auth: $error', @@ -198,7 +188,7 @@ class LandingQrCodeCubit extends LandingCubit { if (_pollingFailureCount >= pollingFailureLimit) { _cancelPeriodicSubscription(); w('landing qr polling failed, entering retry state'); - emit(state.needReload(_expiredMessageBuilder())); + _setState(value.needReload(_expiredMessageBuilder())); } return; } @@ -207,7 +197,7 @@ class LandingQrCodeCubit extends LandingCubit { _cancelPeriodicSubscription(); if (requestVersion != _requestVersion) return; - emit(state.copyWith(status: LandingStatus.provisioning)); + _setState(value.copyWith(status: LandingStatus.provisioning)); try { final (acount, privateKey) = await _verify(secret, keyPair); @@ -217,7 +207,7 @@ class LandingQrCodeCubit extends LandingCubit { ); } catch (error, stack) { if (requestVersion != _requestVersion) return; - emit(state.needReload('Failed to verify: $error')); + _setState(value.needReload('Failed to verify: $error')); e('_verify: $error $stack'); } } @@ -271,23 +261,10 @@ class LandingQrCodeCubit extends LandingCubit { } @override - Future close() async { - await _periodicSubscription?.cancel(); - await super.close(); + void dispose() { + _disposed = true; + _requestVersion += 1; + _cancelPeriodicSubscription(); + super.dispose(); } } - -class LandingMobileCubit extends LandingCubit { - LandingMobileCubit( - MultiAuthStateNotifier multiAuthChangeNotifier, - Locale locale, { - required String deviceId, - required String userAgent, - }) : super( - multiAuthChangeNotifier, - locale, - null, - deviceId: deviceId, - userAgent: userAgent, - ); -} diff --git a/lib/ui/provider/abstract_responsive_navigator.dart b/lib/ui/provider/abstract_responsive_navigator.dart index 9b0aa8f494..8244fd96f5 100644 --- a/lib/ui/provider/abstract_responsive_navigator.dart +++ b/lib/ui/provider/abstract_responsive_navigator.dart @@ -30,48 +30,70 @@ class ResponsiveNavigatorState extends Equatable { } abstract class AbstractResponsiveNavigatorStateNotifier - extends DistinctStateNotifier { + extends DistinctStateNotifier + with ResponsiveNavigatorController { AbstractResponsiveNavigatorStateNotifier(super.initialState); - void updateRouteMode(bool routeMode) => - Future(() => state = state.copyWith(routeMode: routeMode)); + @override + ResponsiveNavigatorState get navigatorState => state; + + @override + set navigatorState(ResponsiveNavigatorState value) => state = value; +} + +mixin ResponsiveNavigatorController { + ResponsiveNavigatorState get navigatorState; + + set navigatorState(ResponsiveNavigatorState value); + + MaterialPage route(String name, Object? arguments); + + void updateRouteMode(bool routeMode) { + if (navigatorState.routeMode == routeMode) return; + Future( + () => navigatorState = navigatorState.copyWith(routeMode: routeMode), + ); + } void onPopPage() { - final bool = state.pages.isNotEmpty; - if (bool) { - state = state.copyWith(pages: state.pages.toList()..removeLast()); + if (navigatorState.pages.isNotEmpty) { + navigatorState = navigatorState.copyWith( + pages: navigatorState.pages.toList()..removeLast(), + ); } } - MaterialPage route(String name, Object? arguments); - void pushPage(String name, {Object? arguments}) { final page = route(name, arguments); - var index = -1; - index = state.pages.indexWhere( + final pages = navigatorState.pages.toList(); + final index = pages.indexWhere( (element) => page.child.key != null && element.child.key == page.child.key, ); - if (state.pages.isNotEmpty && index == state.pages.length - 1) return; - if (index != -1) state.pages.removeRange(max(index, 0), state.pages.length); - state = state.copyWith(pages: state.pages.toList()..add(page)); + if (pages.isNotEmpty && index == pages.length - 1) return; + if (index != -1) pages.removeRange(max(index, 0), pages.length); + navigatorState = navigatorState.copyWith(pages: [...pages, page]); } void popUntil(bool Function(MaterialPage page) test) { - final index = state.pages.indexWhere(test); + final index = navigatorState.pages.indexWhere(test); if (index == -1) return; - List? list; - list = index == 0 ? [] : state.pages.toList() - ..sublist(0, index); - state = state.copyWith(pages: list); + final list = index == 0 + ? [] + : navigatorState.pages.sublist(0, index); + navigatorState = navigatorState.copyWith(pages: list); } void popWhere(bool Function(MaterialPage page) test) => - state = state.copyWith(pages: state.pages.toList()..removeWhere(test)); - - void pop() => state = state.copyWith( - pages: state.pages.sublist(0, max(state.pages.length - 1, 0)).toList(), + navigatorState = navigatorState.copyWith( + pages: navigatorState.pages.toList()..removeWhere(test), + ); + + void pop() => navigatorState = navigatorState.copyWith( + pages: navigatorState.pages + .sublist(0, max(navigatorState.pages.length - 1, 0)) + .toList(), ); Future replace(String name, {Object? arguments}) async { @@ -80,5 +102,5 @@ abstract class AbstractResponsiveNavigatorStateNotifier pushPage(name, arguments: arguments); } - void clear() => state = state.copyWith(pages: []); + void clear() => navigatorState = navigatorState.copyWith(pages: []); } diff --git a/lib/ui/setting/appearance_page.dart b/lib/ui/setting/appearance_page.dart index 670adb75ad..305e89e0ee 100644 --- a/lib/ui/setting/appearance_page.dart +++ b/lib/ui/setting/appearance_page.dart @@ -1,8 +1,8 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; +import 'package:hooks_riverpod/hooks_riverpod.dart' + hide ChangeNotifierProvider, Provider; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; @@ -10,15 +10,14 @@ import 'package:uuid/uuid.dart'; import '../../constants/resources.dart'; import '../../db/mixin_database.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/cell.dart'; import '../../widgets/message/item/text/text_message.dart'; import '../../widgets/message/message.dart'; import '../../widgets/message/message_day_time.dart'; import '../../widgets/radio.dart'; -import '../home/bloc/blink_cubit.dart'; -import '../home/chat/chat_page.dart'; +import '../home/notifier/blink_notifier.dart'; +import '../home/notifier/chat_side_notifier.dart'; import '../provider/setting_provider.dart'; class AppearancePage extends StatelessWidget { @@ -251,16 +250,26 @@ class _ChatTextSizePreview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tickerProvider = useSingleTickerProvider(); - final blinkCubit = useMemoized( - () => BlinkCubit( + final blinkNotifier = useMemoized( + () => BlinkNotifier( tickerProvider, context.theme.accent.withValues(alpha: 0.5), ), ); - final chatSideCubit = useBloc(ChatSideCubit.new); - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), + useEffect(() => blinkNotifier.dispose, [blinkNotifier]); + final chatSideNotifier = useMemoized(ChatSideNotifier.new); + useEffect(() => chatSideNotifier.dispose, [chatSideNotifier]); + final searchConversationKeywordNotifier = useMemoized( + () => + SearchConversationKeywordNotifier(chatSideNotifier: chatSideNotifier), + [chatSideNotifier], ); + useEffect( + () => searchConversationKeywordNotifier.dispose, + [searchConversationKeywordNotifier], + ); + final hiddenDateTime = useMemoized(() => ValueNotifier(null)); + useEffect(() => hiddenDateTime.dispose, [hiddenDateTime]); final messageHi = useMemoized( () => _buildFakeTextMessage(context.l10n.sayHi), @@ -271,9 +280,13 @@ class _ChatTextSizePreview extends HookConsumerWidget { return MultiProvider( providers: [ - BlocProvider.value(value: searchConversationKeywordCubit), - Provider.value(value: blinkCubit), - BlocProvider(create: (_) => HiddenMessageDayTimeBloc()), + ChangeNotifierProvider.value( + value: searchConversationKeywordNotifier, + ), + ChangeNotifierProvider.value(value: blinkNotifier), + ChangeNotifierProvider>.value( + value: hiddenDateTime, + ), ], child: IgnorePointer( child: Container( diff --git a/lib/utils/device_transfer/device_transfer_dialog.dart b/lib/utils/device_transfer/device_transfer_dialog.dart index caa376ac94..7ed6c7617c 100644 --- a/lib/utils/device_transfer/device_transfer_dialog.dart +++ b/lib/utils/device_transfer/device_transfer_dialog.dart @@ -1,8 +1,8 @@ -import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:provider/provider.dart' as provider; import '../../constants/resources.dart'; import '../../widgets/app_bar.dart'; @@ -11,7 +11,6 @@ import '../../widgets/cell.dart'; import '../../widgets/dialog.dart'; import '../event_bus.dart'; import '../extension/extension.dart'; -import '../hook.dart'; import '../logger.dart'; import 'device_transfer_widget.dart'; @@ -47,25 +46,22 @@ enum _DeviceTransferPageType { } } -class _NavigatorState with EquatableMixin { - _NavigatorState(this.pages); +class _DeviceTransferNavigator extends ChangeNotifier { + final List<_DeviceTransferPageType> _pages = [ + _DeviceTransferPageType.deviceTransfer, + ]; - final List<_DeviceTransferPageType> pages; - - @override - List get props => [pages]; -} - -class _NavigatorCubit extends Cubit<_NavigatorState> { - _NavigatorCubit() - : super(_NavigatorState([_DeviceTransferPageType.deviceTransfer])); + List<_DeviceTransferPageType> get pages => _pages; void push(_DeviceTransferPageType page) { - emit(_NavigatorState([...state.pages, page])); + _pages.add(page); + notifyListeners(); } void pop() { - emit(_NavigatorState([...state.pages]..removeLast())); + if (_pages.length <= 1) return; + _pages.removeLast(); + notifyListeners(); } } @@ -74,14 +70,15 @@ class _Navigator extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final cubit = useBloc(_NavigatorCubit.new); - final pages = useBlocState<_NavigatorCubit, _NavigatorState>(bloc: cubit); - return BlocProvider.value( - value: cubit, + final navigator = useMemoized(_DeviceTransferNavigator.new); + useEffect(() => navigator.dispose, [navigator]); + useListenable(navigator); + return provider.ChangeNotifierProvider<_DeviceTransferNavigator>.value( + value: navigator, child: AnimatedSize( duration: const Duration(milliseconds: 150), alignment: Alignment.topCenter, - child: pages.pages.lastOrNull?.build() ?? const SizedBox.shrink(), + child: navigator.pages.lastOrNull?.build() ?? const SizedBox.shrink(), ), ); } @@ -110,9 +107,10 @@ class _DeviceTransferPage extends StatelessWidget { child: CellItem( title: const Text('sync from other device'), onTap: () { - context.read<_NavigatorCubit>().push( - _DeviceTransferPageType.restore, - ); + provider.Provider.of<_DeviceTransferNavigator>( + context, + listen: false, + ).push(_DeviceTransferPageType.restore); }, ), ), @@ -122,9 +120,10 @@ class _DeviceTransferPage extends StatelessWidget { child: CellItem( title: const Text('sync to other device'), onTap: () { - context.read<_NavigatorCubit>().push( - _DeviceTransferPageType.backup, - ); + provider.Provider.of<_DeviceTransferNavigator>( + context, + listen: false, + ).push(_DeviceTransferPageType.backup); }, ), ), @@ -140,17 +139,19 @@ class _DialogBackButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final canPopup = - useBlocStateConverter<_NavigatorCubit, _NavigatorState, bool>( - converter: (state) => state.pages.length > 1, - ); + final navigator = provider.Provider.of<_DeviceTransferNavigator>( + context, + listen: false, + ); + useListenable(navigator); + final canPopup = navigator.pages.length > 1; return !canPopup ? const SizedBox.shrink() : Center( child: MixinBackButton( onTap: () { onTapped?.call(); - context.read<_NavigatorCubit>().pop(); + navigator.pop(); }, ), ); @@ -194,9 +195,10 @@ class _RestorePage extends StatelessWidget { trailing: null, onTap: () { EventBus.instance.fire(DeviceTransferCommand.pullToRemote); - context.read<_NavigatorCubit>().push( - _DeviceTransferPageType.restoreWaitingConnect, - ); + provider.Provider.of<_DeviceTransferNavigator>( + context, + listen: false, + ).push(_DeviceTransferPageType.restoreWaitingConnect); }, ), ), @@ -302,9 +304,10 @@ class _BackupPage extends StatelessWidget { trailing: null, onTap: () { EventBus.instance.fire(DeviceTransferCommand.pushToRemote); - context.read<_NavigatorCubit>().push( - _DeviceTransferPageType.backupWaitingConnect, - ); + provider.Provider.of<_DeviceTransferNavigator>( + context, + listen: false, + ).push(_DeviceTransferPageType.backupWaitingConnect); }, ), ), diff --git a/lib/widgets/full_screen_portal.dart b/lib/widgets/full_screen_portal.dart deleted file mode 100644 index 1b3cb2d521..0000000000 --- a/lib/widgets/full_screen_portal.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_portal/flutter_portal.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../bloc/simple_cubit.dart'; -import '../utils/hook.dart'; -import 'menu.dart'; - -class FullScreenVisibleCubit extends SimpleCubit { - FullScreenVisibleCubit(super.state); -} - -class FullScreenPortal extends HookConsumerWidget { - const FullScreenPortal({ - required this.builder, - required this.portalBuilder, - super.key, - this.duration = const Duration(milliseconds: 100), - this.curve = Curves.easeOut, - }); - - final WidgetBuilder builder; - final WidgetBuilder portalBuilder; - - final Duration duration; - final Curve curve; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final visibleBloc = useBloc( - () => FullScreenVisibleCubit(false), - ); - final visible = useBlocState( - bloc: visibleBloc, - ); - return BlocProvider.value( - value: visibleBloc, - child: Barrier( - duration: duration, - visible: visible, - onClose: () => visibleBloc.emit(false), - child: PortalTarget( - closeDuration: duration, - visible: visible, - portalFollower: TweenAnimationBuilder( - duration: duration, - tween: Tween(begin: 0, end: visible ? 1 : 0), - curve: curve, - builder: (context, progress, child) => - Opacity(opacity: progress, child: child), - child: visible ? Builder(builder: portalBuilder) : const SizedBox(), - ), - child: Builder(builder: builder), - ), - ), - ); - } -} diff --git a/lib/widgets/menu.dart b/lib/widgets/menu.dart index a26e4a6b69..af6fdacff5 100644 --- a/lib/widgets/menu.dart +++ b/lib/widgets/menu.dart @@ -9,7 +9,8 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; +import 'package:hooks_riverpod/hooks_riverpod.dart' + hide ChangeNotifierProvider, Provider; import 'package:mixin_logger/mixin_logger.dart'; import 'package:provider/provider.dart'; import 'package:super_context_menu/src/default_builder/group_intrinsic_width.dart'; @@ -19,10 +20,8 @@ import 'package:super_context_menu/src/scaffold/desktop/menu_widget_builder.dart import 'package:super_context_menu/src/scaffold/mobile/menu_widget_builder.dart'; import 'package:super_context_menu/super_context_menu.dart'; -import '../bloc/simple_cubit.dart'; import '../constants/resources.dart'; import '../utils/extension/extension.dart'; -import '../utils/hook.dart'; import '../utils/platform.dart'; import 'action_button.dart'; import 'hover_overlay.dart'; @@ -43,14 +42,11 @@ class MenusWithSeparator extends Menu { ); } -class _OffsetCubit extends SimpleCubit { - _OffsetCubit(super.state); -} - extension ContextMenuPortalEntrySender on BuildContext { - void sendMenuPosition(Offset offset) => read<_OffsetCubit>().emit(offset); + set menuPosition(Offset offset) => + read>().value = offset; - void closeMenu() => read<_OffsetCubit?>()?.emit(null); + void closeMenu() => read?>()?.value = null; } typedef CustomPopupMenuItemBuilder = @@ -94,16 +90,16 @@ class CustomPopupMenuButton extends HookConsumerWidget { onTapUp: (details) { d('onTapUp: $alignment'); if (alignment == null) { - context.sendMenuPosition(details.globalPosition); + context.menuPosition = details.globalPosition; return; } final renderBox = context.findRenderObject() as RenderBox?; if (renderBox != null) { var position = alignment!.withinRect(renderBox.paintBounds); position = renderBox.localToGlobal(position); - context.sendMenuPosition(position); + context.menuPosition = position; } else { - context.sendMenuPosition(details.globalPosition); + context.menuPosition = details.globalPosition; } }, child: child, @@ -148,15 +144,10 @@ class ContextMenuPortalEntry extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final offsetCubit = useBloc(() => _OffsetCubit(null)); - final offset = useBlocState<_OffsetCubit, Offset?>( - bloc: offsetCubit, - when: (state) => state != null, - ); - final visible = useBlocStateConverter<_OffsetCubit, Offset?, bool>( - bloc: offsetCubit, - converter: (state) => state != null, - ); + final offsetNotifier = useMemoized(() => ValueNotifier(null)); + useEffect(() => offsetNotifier.dispose, [offsetNotifier]); + final offset = useValueListenable(offsetNotifier); + final visible = offset != null; useEffect(() { showedMenu?.call(visible); @@ -164,18 +155,18 @@ class ContextMenuPortalEntry extends HookConsumerWidget { useEffect(() { if (!enable) { - offsetCubit.emit(null); + offsetNotifier.value = null; } - }, [enable]); + }, [enable, offsetNotifier]); if (!enable) { return child; } - return Provider.value( - value: offsetCubit, + return ChangeNotifierProvider.value( + value: offsetNotifier, child: Barrier( visible: visible, - onClose: () => offsetCubit.emit(null), + onClose: () => offsetNotifier.value = null, child: PortalTarget( visible: visible, portalFollower: Builder( @@ -195,14 +186,14 @@ class ContextMenuPortalEntry extends HookConsumerWidget { if (!interactive) { return; } - offsetCubit.emit(event.globalPosition); + offsetNotifier.value = event.globalPosition; }, onLongPress: (details) { if (!interactive) { return; } if (Platform.isAndroid || Platform.isIOS) { - offsetCubit.emit(details.globalPosition); + offsetNotifier.value = details.globalPosition; } }, onTap: onTap, @@ -210,7 +201,7 @@ class ContextMenuPortalEntry extends HookConsumerWidget { onKeyEvent: (node, key) { final show = offset != null && visible; if (show && key.logicalKey == LogicalKeyboardKey.escape) { - offsetCubit.emit(null); + offsetNotifier.value = null; return KeyEventResult.handled; } return KeyEventResult.ignored; diff --git a/lib/widgets/message/item/text/text_message.dart b/lib/widgets/message/item/text/text_message.dart index ae590957f9..ca2a319958 100644 --- a/lib/widgets/message/item/text/text_message.dart +++ b/lib/widgets/message/item/text/text_message.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart' hide SelectableRegion; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../../../ui/home/chat/chat_page.dart'; +import '../../../../ui/home/notifier/chat_side_notifier.dart'; import '../../../../ui/provider/conversation_provider.dart'; import '../../../../ui/provider/keyword_provider.dart'; import '../../../../ui/provider/mention_cache_provider.dart'; @@ -53,18 +53,18 @@ class MessageTextWidget extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final userId = useMessageConverter(converter: (state) => state.userId); + final keywordNotifier = context.read(); var keyword = - useBlocStateConverter< - SearchConversationKeywordCubit, - (String?, String), - String - >( - converter: (state) { + useListenableConverter, String>( + keywordNotifier, + converter: (notifier) { + final state = notifier.value; if (state.$1 == null || state.$1 == userId) return state.$2; return ''; }, keys: [userId], - ); + ).data ?? + ''; final globalKeyword = ref.watch(trimmedKeywordProvider); final conversationKeyword = ref.watch( diff --git a/lib/widgets/message/item/transcript_message.dart b/lib/widgets/message/item/transcript_message.dart index e6a8c0c821..173ded171c 100644 --- a/lib/widgets/message/item/transcript_message.dart +++ b/lib/widgets/message/item/transcript_message.dart @@ -2,18 +2,18 @@ import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; +import 'package:hooks_riverpod/hooks_riverpod.dart' + hide ChangeNotifierProvider, Provider; import 'package:provider/provider.dart'; import '../../../blaze/vo/transcript_minimal.dart'; import '../../../constants/resources.dart'; import '../../../db/database_event_bus.dart'; import '../../../db/mixin_database.dart'; -import '../../../ui/home/bloc/blink_cubit.dart'; -import '../../../ui/home/chat/chat_page.dart'; +import '../../../ui/home/notifier/blink_notifier.dart'; +import '../../../ui/home/notifier/chat_side_notifier.dart'; import '../../../utils/audio_message_player/audio_message_service.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/hook.dart'; @@ -255,18 +255,26 @@ class TranscriptPage extends HookConsumerWidget { final list = useMemoizedStream(watchMessages).data ?? []; - final chatSideCubit = useBloc(ChatSideCubit.new); - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), + final chatSideNotifier = useMemoized(ChatSideNotifier.new); + useEffect(() => chatSideNotifier.dispose, [chatSideNotifier]); + final searchConversationKeywordNotifier = useMemoized( + () => + SearchConversationKeywordNotifier(chatSideNotifier: chatSideNotifier), + [chatSideNotifier], + ); + useEffect( + () => searchConversationKeywordNotifier.dispose, + [searchConversationKeywordNotifier], ); final tickerProvider = useSingleTickerProvider(); - final blinkCubit = useBloc( - () => BlinkCubit( + final blinkNotifier = useMemoized( + () => BlinkNotifier( tickerProvider, context.theme.accent.withValues(alpha: 0.5), ), ); + useEffect(() => blinkNotifier.dispose, [blinkNotifier]); final scrollController = useMemoized(ScrollController.new); final listKey = useMemoized( @@ -281,8 +289,10 @@ class TranscriptPage extends HookConsumerWidget { ), child: MultiProvider( providers: [ - BlocProvider.value(value: searchConversationKeywordCubit), - BlocProvider.value(value: blinkCubit), + ChangeNotifierProvider.value( + value: searchConversationKeywordNotifier, + ), + ChangeNotifierProvider.value(value: blinkNotifier), Provider.value(value: vlcService), Provider( create: (_) => AudioMessagesPlayAgent( diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index 36f752235b..131d20820d 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -11,25 +11,24 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:gal/gal.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; +import 'package:hooks_riverpod/hooks_riverpod.dart' + hide ChangeNotifierProvider, Provider; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:open_file/open_file.dart'; import 'package:provider/provider.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:super_context_menu/super_context_menu.dart'; import 'package:visibility_detector/visibility_detector.dart'; import '../../account/account_server.dart'; import '../../blaze/vo/pin_message_minimal.dart'; -import '../../bloc/simple_cubit.dart'; import '../../constants/icon_fonts.dart'; import '../../constants/resources.dart'; import '../../db/dao/sticker_dao.dart'; import '../../db/mixin_database.dart' hide Message, Offset; import '../../enum/media_status.dart'; import '../../enum/message_category.dart'; -import '../../ui/home/bloc/blink_cubit.dart'; import '../../ui/home/chat/message_jump.dart'; +import '../../ui/home/notifier/blink_notifier.dart'; import '../../ui/provider/conversation_provider.dart'; import '../../ui/provider/is_bot_group_provider.dart'; import '../../ui/provider/message_selection_provider.dart'; @@ -80,10 +79,6 @@ import 'message_day_time.dart'; import 'message_name.dart'; import 'message_style.dart'; -class _MessageContextCubit extends SimpleCubit<_MessageContext> { - _MessageContextCubit(super.initialState); -} - class _MessageContext with EquatableMixin { _MessageContext({ required this.isTranscriptPage, @@ -110,40 +105,40 @@ class _MessageContext with EquatableMixin { } bool useIsTranscriptPage() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.isTranscriptPage, - ); + _useMessageContextConverter((state) => state.isTranscriptPage); bool useIsPinnedPage() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.isPinnedPage, - ); + _useMessageContextConverter((state) => state.isPinnedPage); -bool useShowNip() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.showNip, - ); +bool useShowNip() => _useMessageContextConverter((state) => state.showNip); bool useIsCurrentUser() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.isCurrentUser, - ); + _useMessageContextConverter((state) => state.isCurrentUser); MessageItem useMessage() => useMessageConverter(converter: (state) => state); T useMessageConverter({required T Function(MessageItem) converter}) => - useBlocStateConverter<_MessageContextCubit, _MessageContext, T>( - converter: (state) => converter(state.message), - ); + _useMessageContextConverter((state) => converter(state.message)); + +T _useMessageContextConverter(T Function(_MessageContext) converter) { + final notifier = useContext().read>(); + return useListenableConverter, T>( + notifier, + converter: (notifier) => converter(notifier.value), + ).data + as T; +} extension MessageContextExtension on BuildContext { - MessageItem get message => read<_MessageContextCubit>().state.message; + _MessageContext get _messageContext => + read>().value; - bool get isPinnedPage => read<_MessageContextCubit>().state.isPinnedPage; + MessageItem get message => _messageContext.message; - bool get isTranscriptPage => - read<_MessageContextCubit>().state.isTranscriptPage; + bool get isPinnedPage => _messageContext.isPinnedPage; + + bool get isTranscriptPage => _messageContext.isTranscriptPage; } const _pinArrowWidth = 32.0; @@ -154,7 +149,7 @@ void _quickReply(BuildContext context) { if (!context.message.type.canReply) return; doubleTap('_quickReply', const Duration(milliseconds: 300), () { - context.read().blinkByMessageId(context.message.messageId); + context.read().blinkByMessageId(context.message.messageId); context.providerContainer.read(quoteMessageProvider.notifier).state = context.message; }); @@ -255,29 +250,19 @@ class MessageItemWidget extends HookConsumerWidget { userAvatarUrl = message.avatarUrl; } - final showedMenuCubit = useBloc(() => SimpleCubit(false)); + final showedMenu = useState(false); final focusNode = useFocusScopeNode( debugLabel: 'message_item_${message.messageId}', ); - final blinkCubit = context.read(); - - final blinkColor = - useMemoizedStream( - () => Rx.combineLatest2( - blinkCubit.stream.startWith(blinkCubit.state), - showedMenuCubit.stream.startWith(showedMenuCubit.state), - (blinkState, showedMenu) { - if (showedMenu) return context.theme.listSelected; - if (blinkState.messageId == message.messageId && blink) { - return blinkState.color; - } - return Colors.transparent; - }, - ), - keys: [message.messageId], - ).data ?? - Colors.transparent; + final blinkNotifier = context.read(); + final blinkState = useValueListenable(blinkNotifier); + + final blinkColor = showedMenu.value + ? context.theme.listSelected + : blinkState.messageId == message.messageId && blink + ? blinkState.color + : Colors.transparent; Widget child = Column( mainAxisSize: MainAxisSize.min, @@ -317,11 +302,11 @@ class MessageItemWidget extends HookConsumerWidget { isVerified: message.isVerified, buildMenus: (request) { request.onShowMenu.addListener(() { - showedMenuCubit.emit(true); + showedMenu.value = true; focusNode.requestFocus(); }); request.onHideMenu.addListener(() { - showedMenuCubit.emit(false); + showedMenu.value = false; }); final enable = !ref.read(hasSelectedMessageProvider); @@ -867,15 +852,20 @@ class MessageContext extends HookConsumerWidget { message: message, ); - final messageContextCubit = useBloc( - () => _MessageContextCubit(newMessageContext()), + final messageContext = useMemoized( + () => ValueNotifier(newMessageContext()), ); + useEffect(() => messageContext.dispose, [messageContext]); useEffect(() { - messageContextCubit.emit(newMessageContext()); + messageContext.value = newMessageContext(); + return null; }, [isTranscriptPage, isPinnedPage, showNip, isCurrentUser, message]); - return Provider.value(value: messageContextCubit, child: child); + return ChangeNotifierProvider>.value( + value: messageContext, + child: child, + ); } } diff --git a/lib/widgets/message/message_day_time.dart b/lib/widgets/message/message_day_time.dart index dc6b581086..eaa3668006 100644 --- a/lib/widgets/message/message_day_time.dart +++ b/lib/widgets/message/message_day_time.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart' + hide ChangeNotifierProvider, Provider; +import 'package:provider/provider.dart'; import '../../db/mixin_database.dart' as db; import '../../ui/provider/minute_timer_provider.dart'; import '../../utils/datetime_format_utils.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../../utils/logger.dart'; import 'message.dart'; import 'message_style.dart'; @@ -19,11 +19,8 @@ class MessageDayTime extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final hide = - useBlocStateConverter( - converter: (state) => isSameDay(state, dateTime), - keys: [dateTime], - ); + final hiddenDateTime = context.read>(); + final hide = isSameDay(useValueListenable(hiddenDateTime), dateTime); return Center( child: Opacity( opacity: hide ? 0 : 1, @@ -66,12 +63,6 @@ class MessageDayTimeChip extends HookConsumerWidget { } } -class HiddenMessageDayTimeBloc extends Cubit { - HiddenMessageDayTimeBloc() : super(null); - - void update(DateTime? dateTime) => emit(dateTime); -} - class _CurrentShowingMessages { _CurrentShowingMessages(); @@ -112,6 +103,37 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { super.key, }); + factory MessageDayTimeViewportWidget.chatPage({ + required Widget child, + required ScrollController scrollController, + required GlobalKey topKey, + required GlobalKey bottomKey, + required db.MessageItem? center, + required GlobalKey? centerKey, + Key? key, + }) => MessageDayTimeViewportWidget._create( + () { + final result = _CurrentShowingMessages(); + + topKey.currentContext!.visitChildElements( + (element) => result.dumpKeyedSubtree(element, reverse: true), + ); + + final centerContext = centerKey?.currentContext; + if (center != null && centerContext != null && centerContext is Element) { + result.dumpKeyedSubtree(centerContext); + } + + bottomKey.currentContext!.visitChildElements(result.dumpKeyedSubtree); + + return result; + }, + key: key, + scrollController: scrollController, + reTraversalKey: centerKey, + child: child, + ); + factory MessageDayTimeViewportWidget.singleList({ required Widget child, required ScrollController scrollController, @@ -148,10 +170,8 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final dateTime = useState(null); final dateTimeTopOffset = useState(0); - - final bloc = useBloc( - HiddenMessageDayTimeBloc.new, - ); + final hiddenDateTime = useMemoized(() => ValueNotifier(null)); + useEffect(() => hiddenDateTime.dispose, [hiddenDateTime]); void doTraversal() { final result = _traversalCurrentShowingMessageElements(); @@ -231,7 +251,7 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { if (offset.dy < render.size.height / 2) { // up firstInScreenIndex = closestToTopDayTimeIndex; - bloc.update(items[closestToTopDayTimeIndex].createdAt); + hiddenDateTime.value = items[closestToTopDayTimeIndex].createdAt; dateTimeTopOffset.value = 0; } else { // down @@ -251,16 +271,16 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { } return true; }()); - bloc.update(null); + hiddenDateTime.value = null; dateTimeTopOffset.value = offset.dy - render.size.height * 1.5; } else { firstInScreenIndex = -1; dateTimeTopOffset.value = 0; - bloc.update(null); + hiddenDateTime.value = null; } } } else { - bloc.update(null); + hiddenDateTime.value = null; dateTimeTopOffset.value = 0; } @@ -288,8 +308,8 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { doTraversal(); }); }, [constraints]); - return BlocProvider.value( - value: bloc, + return ChangeNotifierProvider>.value( + value: hiddenDateTime, child: ClipRect( child: Stack( fit: StackFit.expand, diff --git a/lib/widgets/sticker_page/bloc/cubit/sticker_albums_cubit.dart b/lib/widgets/sticker_page/bloc/cubit/sticker_albums_cubit.dart deleted file mode 100644 index 5c0bad6107..0000000000 --- a/lib/widgets/sticker_page/bloc/cubit/sticker_albums_cubit.dart +++ /dev/null @@ -1,6 +0,0 @@ -import '../../../../bloc/stream_cubit.dart'; -import '../../../../db/mixin_database.dart'; - -class StickerAlbumsCubit extends StreamCubit> { - StickerAlbumsCubit(Stream> stream) : super([], stream); -} diff --git a/lib/widgets/sticker_page/bloc/cubit/sticker_cubit.dart b/lib/widgets/sticker_page/bloc/cubit/sticker_cubit.dart deleted file mode 100644 index 3cd3004b7d..0000000000 --- a/lib/widgets/sticker_page/bloc/cubit/sticker_cubit.dart +++ /dev/null @@ -1,6 +0,0 @@ -import '../../../../bloc/stream_cubit.dart'; -import '../../../../db/mixin_database.dart'; - -class StickerCubit extends StreamCubit> { - StickerCubit(Stream> stream) : super([], stream); -} diff --git a/lib/widgets/sticker_page/sticker_page.dart b/lib/widgets/sticker_page/sticker_page.dart index 2a8e3b59de..bc41d25656 100644 --- a/lib/widgets/sticker_page/sticker_page.dart +++ b/lib/widgets/sticker_page/sticker_page.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,7 +7,6 @@ import 'package:mixin_logger/mixin_logger.dart'; import 'package:super_context_menu/super_context_menu.dart'; import '../../account/account_key_value.dart'; -import '../../bloc/bloc_converter.dart'; import '../../constants/icon_fonts.dart'; import '../../constants/resources.dart'; import '../../db/database_event_bus.dart'; @@ -22,8 +20,6 @@ import '../interactive_decorated_box.dart'; import '../menu.dart'; import '../toast.dart'; import 'add_sticker_dialog.dart'; -import 'bloc/cubit/sticker_albums_cubit.dart'; -import 'bloc/cubit/sticker_cubit.dart'; import 'emoji_page.dart'; import 'giphy_page.dart'; import 'sticker_item.dart'; @@ -36,12 +32,14 @@ class StickerPage extends StatelessWidget { required this.tabLength, required this.tabController, required this.presetStickerGroups, + required this.stickerAlbums, super.key, }); final TabController tabController; final int tabLength; final List presetStickerGroups; + final List stickerAlbums; @override Widget build(BuildContext context) => Material( @@ -124,9 +122,9 @@ class StickerPage extends StatelessWidget { } return _StickerAlbumPage( getStickers: () { - final albumId = BlocProvider.of( - context, - ).state[index - presetStickerGroups.length].albumId; + final albumId = + stickerAlbums[index - presetStickerGroups.length] + .albumId; return context.database.stickerDao .stickerByAlbumId(albumId) .watchWithStream( @@ -147,6 +145,7 @@ class StickerPage extends StatelessWidget { tabLength: tabLength, tabController: tabController, presetStickerGroups: presetStickerGroups, + stickerAlbums: stickerAlbums, ), ], ), @@ -171,35 +170,35 @@ class _StickerAlbumPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final stickerCubit = useBloc(() => StickerCubit(getStickers())); - - final itemCount = useBlocStateConverter, int>( - bloc: stickerCubit, - converter: (state) => state.length, - ); + final stickers = + useMemoizedStream( + getStickers, + initialData: const [], + keys: [getStickers], + ).data ?? + const []; + final itemCount = stickers.length; final controller = useMemoized(ScrollController.new); - return BlocProvider.value( - value: stickerCubit, - child: GridView.builder( - controller: controller, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), - itemCount: canAddSticker ? itemCount + 1 : itemCount, - itemBuilder: (context, index) { - if (canAddSticker && index == 0) { - return const _AddStickerWidget(); - } - return _StickerAlbumPageItem( - index: canAddSticker ? index - 1 : index, - updateUsedAt: updateUsedAt, - delete: delete, - ); - }, + return GridView.builder( + controller: controller, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 8, + crossAxisSpacing: 8, ), + itemCount: canAddSticker ? itemCount + 1 : itemCount, + itemBuilder: (context, index) { + if (canAddSticker && index == 0) { + return const _AddStickerWidget(); + } + final stickerIndex = canAddSticker ? index - 1 : index; + return _StickerAlbumPageItem( + sticker: stickers[stickerIndex], + updateUsedAt: updateUsedAt, + delete: delete, + ); + }, ); } } @@ -257,22 +256,17 @@ class _StickerStoreEmptyPage extends StatelessWidget { class _StickerAlbumPageItem extends HookConsumerWidget { const _StickerAlbumPageItem({ - required this.index, + required this.sticker, required this.updateUsedAt, this.delete, }); - final int index; + final Sticker sticker; final bool updateUsedAt; final void Function(Sticker)? delete; @override Widget build(BuildContext context, WidgetRef ref) { - final sticker = useBlocStateConverter, Sticker>( - converter: (state) => state[index], - keys: [index], - ); - Widget widget = InteractiveDecoratedBox( onTap: () async { final accountServer = context.accountServer; @@ -343,11 +337,13 @@ class _StickerAlbumBar extends HookConsumerWidget { required this.tabLength, required this.tabController, required this.presetStickerGroups, + required this.stickerAlbums, }); final int tabLength; final TabController tabController; final List presetStickerGroups; + final List stickerAlbums; @override Widget build(BuildContext context, WidgetRef ref) { @@ -411,6 +407,7 @@ class _StickerAlbumBar extends HookConsumerWidget { (index) => _StickerAlbumBarItem( index: index, presetStickerGroups: presetStickerGroups, + stickerAlbums: stickerAlbums, ), ), ), @@ -422,10 +419,12 @@ class _StickerAlbumBarItem extends StatelessWidget { const _StickerAlbumBarItem({ required this.index, required this.presetStickerGroups, + required this.stickerAlbums, }); final int index; final List presetStickerGroups; + final List stickerAlbums; @override Widget build(BuildContext context) => SizedBox.fromSize( @@ -464,15 +463,10 @@ class _StickerAlbumBarItem extends StatelessWidget { ); } - return BlocConverter< - StickerAlbumsCubit, - List, - String - >( - converter: (state) => - state[index - presetStickerGroups.length].iconUrl, - builder: (context, iconUrl) => - StickerGroupIcon(iconUrl: iconUrl, size: 28), + return StickerGroupIcon( + iconUrl: + stickerAlbums[index - presetStickerGroups.length].iconUrl, + size: 28, ); }, ), diff --git a/lib/widgets/user/user_dialog.dart b/lib/widgets/user/user_dialog.dart index 7874622da1..fac0559d0d 100644 --- a/lib/widgets/user/user_dialog.dart +++ b/lib/widgets/user/user_dialog.dart @@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../constants/resources.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; -import '../../ui/home/chat/chat_page.dart'; +import '../../ui/home/notifier/chat_side_notifier.dart'; import '../../ui/provider/conversation_provider.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; @@ -383,7 +383,7 @@ class _UserProfileButtonBar extends StatelessWidget { await ConversationStateNotifier.selectUser( context, user.userId, - initialChatSidePage: ChatSideCubit.infoPage, + initialChatSidePage: ChatSideNotifier.infoPage, ); Navigator.pop(context); }, diff --git a/lib/widgets/user_selector/bloc/conversation_filter_state.dart b/lib/widgets/user_selector/bloc/conversation_filter_state.dart deleted file mode 100644 index 0f634d1ece..0000000000 --- a/lib/widgets/user_selector/bloc/conversation_filter_state.dart +++ /dev/null @@ -1,35 +0,0 @@ -part of 'conversation_filter_cubit.dart'; - -class ConversationFilterState extends Equatable { - const ConversationFilterState({ - this.recentConversations = const [], - this.friends = const [], - this.bots = const [], - this.keyword, - }); - - final List recentConversations; - final List friends; - final List bots; - final String? keyword; - - Set get appIds => { - ...recentConversations.map((e) => e.ownerId).nonNulls, - ...[...bots, ...friends].map((e) => e.userId), - }; - - @override - List get props => [friends, bots, keyword, recentConversations]; - - ConversationFilterState copyWith({ - List? recentConversations, - List? friends, - List? bots, - String? keyword, - }) => ConversationFilterState( - recentConversations: recentConversations ?? this.recentConversations, - friends: friends ?? this.friends, - bots: bots ?? this.bots, - keyword: keyword ?? this.keyword, - ); -} diff --git a/lib/widgets/user_selector/bloc/conversation_filter_cubit.dart b/lib/widgets/user_selector/conversation_filter_notifier.dart similarity index 55% rename from lib/widgets/user_selector/bloc/conversation_filter_cubit.dart rename to lib/widgets/user_selector/conversation_filter_notifier.dart index ec9ec9b0ae..f053e528de 100644 --- a/lib/widgets/user_selector/bloc/conversation_filter_cubit.dart +++ b/lib/widgets/user_selector/conversation_filter_notifier.dart @@ -1,5 +1,6 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; +import 'dart:async'; + +import 'package:flutter/foundation.dart'; import '../../../account/account_server.dart'; import '../../../db/dao/conversation_dao.dart'; @@ -7,16 +8,15 @@ import '../../../db/mixin_database.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/sort.dart'; -part 'conversation_filter_state.dart'; - -class ConversationFilterCubit extends Cubit { - ConversationFilterCubit( +class ConversationFilterNotifier + extends ValueNotifier { + ConversationFilterNotifier( this.accountServer, this.onlyContact, this.filteredIds, this.afterInit, ) : super(const ConversationFilterState()) { - _init(); + unawaited(_init()); } final AccountServer accountServer; @@ -24,20 +24,21 @@ class ConversationFilterCubit extends Cubit { final Function(ConversationFilterState) afterInit; final Iterable filteredIds; - late List conversations; - late List friends; - late List bots; + var _conversations = []; + var _friends = []; + var _bots = []; + var _disposed = false; Future _init() async { var contactConversationIds = {}; var botConversationIds = {}; if (onlyContact) { - conversations = []; + _conversations = []; } else { - conversations = await accountServer.database.conversationDao + _conversations = await accountServer.database.conversationDao .conversationItems() .get(); - contactConversationIds = conversations + contactConversationIds = _conversations .where( (element) => element.isContactConversation && element.ownerId != null, @@ -45,15 +46,15 @@ class ConversationFilterCubit extends Cubit { .map((e) => e.ownerId) .nonNulls .toSet(); - botConversationIds = conversations + botConversationIds = _conversations .where((element) => element.isBotConversation) .map((e) => e.appId) .nonNulls .toSet(); } - friends = []; - bots = []; + _friends = []; + _bots = []; final Iterable users = await accountServer.database.userDao .notInFriends([ @@ -65,36 +66,39 @@ class ConversationFilterCubit extends Cubit { e, ) { if (e.isBot) { - bots.add(e); + _bots.add(e); } else { - friends.add(e); + _friends.add(e); } }); + if (_disposed) return; _filterList(); - afterInit(state); + if (_disposed) return; + afterInit(value); } set keyword(String keyword) { - emit(state.copyWith(keyword: keyword)); + if (_disposed) return; + value = value.copyWith(keyword: keyword); _filterList(); } void _filterList() { - if (state.keyword?.isEmpty ?? true) { - return emit( - state.copyWith( - recentConversations: conversations, - friends: friends, - bots: bots, - keyword: state.keyword, - ), + if (_disposed) return; + if (value.keyword?.isEmpty ?? true) { + value = value.copyWith( + recentConversations: _conversations, + friends: _friends, + bots: _bots, + keyword: value.keyword, ); + return; } - final keyword = state.keyword!.toLowerCase(); + final keyword = value.keyword!.toLowerCase(); final recentConversations = - conversations + _conversations .where( (element) => element.isGroupConversation ? element.groupName != null && @@ -126,16 +130,51 @@ class ConversationFilterCubit extends Cubit { return e.identityNumber.indexOf(keyword); }); - final filterFriends = friends.where(where).toList()..sort(sort); - final filterBots = bots.where(where).toList()..sort(sort); + final filterFriends = _friends.where(where).toList()..sort(sort); + final filterBots = _bots.where(where).toList()..sort(sort); - emit( - state.copyWith( - recentConversations: recentConversations, - friends: filterFriends, - bots: filterBots, - keyword: state.keyword, - ), + value = value.copyWith( + recentConversations: recentConversations, + friends: filterFriends, + bots: filterBots, + keyword: value.keyword, ); } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +class ConversationFilterState { + const ConversationFilterState({ + this.recentConversations = const [], + this.friends = const [], + this.bots = const [], + this.keyword, + }); + + final List recentConversations; + final List friends; + final List bots; + final String? keyword; + + Set get appIds => { + ...recentConversations.map((e) => e.ownerId).nonNulls, + ...[...bots, ...friends].map((e) => e.userId), + }; + + ConversationFilterState copyWith({ + List? recentConversations, + List? friends, + List? bots, + String? keyword, + }) => ConversationFilterState( + recentConversations: recentConversations ?? this.recentConversations, + friends: friends ?? this.friends, + bots: bots ?? this.bots, + keyword: keyword ?? this.keyword, + ); } diff --git a/lib/widgets/user_selector/conversation_selector.dart b/lib/widgets/user_selector/conversation_selector.dart index 7cebfb588f..332111a446 100644 --- a/lib/widgets/user_selector/conversation_selector.dart +++ b/lib/widgets/user_selector/conversation_selector.dart @@ -8,7 +8,6 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' as sdk; import 'package:mixin_logger/mixin_logger.dart'; import 'package:sliver_tools/sliver_tools.dart'; -import '../../bloc/simple_cubit.dart'; import '../../constants/brightness_theme_data.dart'; import '../../constants/constants.dart'; import '../../constants/resources.dart'; @@ -24,7 +23,7 @@ import '../conversation/badges_widget.dart'; import '../dialog.dart'; import '../high_light_text.dart'; import '../interactive_decorated_box.dart'; -import 'bloc/conversation_filter_cubit.dart'; +import 'conversation_filter_notifier.dart'; String _getConversationName(dynamic item) { if (item is ConversationItem) return item.validName; @@ -165,9 +164,11 @@ class _ConversationSelector extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selector = useBloc(() => SimpleCubit>(const [])); + final selector = useMemoized(() => ValueNotifier>(const [])); + useEffect(() => selector.dispose, [selector]); + void selectItem(dynamic item) { - final list = [...selector.state]; + final list = [...selector.value]; if (list.contains(item)) { list.remove(item); } else { @@ -177,12 +178,13 @@ class _ConversationSelector extends HookConsumerWidget { } list.add(item); } - selector.emit(list); + selector.value = list; } - final conversationFilterCubit = useBloc( - () => ConversationFilterCubit( - useContext().accountServer, + final accountServer = context.accountServer; + final conversationFilterNotifier = useMemoized( + () => ConversationFilterNotifier( + accountServer, onlyContact, filteredIds, (state) { @@ -206,13 +208,16 @@ class _ConversationSelector extends HookConsumerWidget { }); }, ), - keys: [useContext().accountServer, onlyContact, filteredIds], + [accountServer, onlyContact, filteredIds], + ); + useEffect( + () => conversationFilterNotifier.dispose, + [conversationFilterNotifier], ); - final conversationFilterState = - useBlocState( - bloc: conversationFilterCubit, - ); + final conversationFilterState = useValueListenable( + conversationFilterNotifier, + ); final appMap = useMemoizedFuture( () async { @@ -226,19 +231,22 @@ class _ConversationSelector extends HookConsumerWidget { ).requireData; useEffect( - () => selector.stream.listen((event) { - if (event.isNotEmpty && singleSelect) { - final item = event.first; + () { + void handleSelection() { + final selection = selector.value; + if (selection.isEmpty || !singleSelect) return; + final item = selection.first; Navigator.pop(context, [ ConversationSelector.init(item, context, appMap), ]); } - }).cancel, - [selector.stream], - ); - final selected = useBlocState>, List>( - bloc: selector, + + selector.addListener(handleSelection); + return () => selector.removeListener(handleSelection); + }, + [selector, singleSelect, appMap], ); + final selected = useValueListenable(selector); const boxDecoration = BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(8)), @@ -322,7 +330,9 @@ class _ConversationSelector extends HookConsumerWidget { ], ), ), - _FilterTextField(conversationFilterCubit: conversationFilterCubit), + _FilterTextField( + conversationFilterNotifier: conversationFilterNotifier, + ), AnimatedSize( alignment: Alignment.topCenter, duration: const Duration(milliseconds: 200), @@ -467,21 +477,14 @@ class _ConversationSelector extends HookConsumerWidget { } class _FilterTextField extends HookConsumerWidget { - const _FilterTextField({required this.conversationFilterCubit}); + const _FilterTextField({required this.conversationFilterNotifier}); - final ConversationFilterCubit conversationFilterCubit; + final ConversationFilterNotifier conversationFilterNotifier; @override Widget build(BuildContext context, WidgetRef ref) { - final isTextEmpty = - useMemoizedStream( - () => conversationFilterCubit.stream - .map((event) => event.keyword?.isEmpty ?? true) - .distinct(), - keys: [conversationFilterCubit], - ).data ?? - conversationFilterCubit.state.keyword?.isEmpty ?? - true; + final filterState = useValueListenable(conversationFilterNotifier); + final isTextEmpty = filterState.keyword?.isEmpty ?? true; return Container( height: 32, padding: const EdgeInsets.symmetric(horizontal: 16), @@ -494,7 +497,7 @@ class _FilterTextField extends HookConsumerWidget { child: Stack( children: [ TextField( - onChanged: (string) => conversationFilterCubit.keyword = string, + onChanged: (string) => conversationFilterNotifier.keyword = string, style: TextStyle(color: context.theme.text, fontSize: 14), inputFormatters: [ LengthLimitingTextInputFormatter(kDefaultTextInputLimit), diff --git a/test/ui/landing/bloc/landing_qrcode_cubit_test.dart b/test/ui/landing/landing_qrcode_notifier_test.dart similarity index 71% rename from test/ui/landing/bloc/landing_qrcode_cubit_test.dart rename to test/ui/landing/landing_qrcode_notifier_test.dart index 333ff3c0e7..8da1fcc947 100644 --- a/test/ui/landing/bloc/landing_qrcode_cubit_test.dart +++ b/test/ui/landing/landing_qrcode_notifier_test.dart @@ -1,22 +1,22 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; -import 'package:flutter_app/ui/landing/bloc/landing_cubit.dart'; -import 'package:flutter_app/ui/landing/bloc/landing_state.dart'; +import 'package:flutter_app/ui/landing/landing_state.dart'; +import 'package:flutter_app/ui/landing/notifier/landing_qrcode_notifier.dart'; import 'package:flutter_app/ui/provider/multi_auth_provider.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; void main() { - group('LandingQrCodeCubit', () { + group('LandingQrCodeNotifier', () { test('older auth url request must not override newer qr code', () async { - final notifier = MultiAuthStateNotifier(const MultiAuthState()); + final authNotifier = MultiAuthStateNotifier(const MultiAuthState()); final first = Completer>(); final second = Completer>(); var requestCount = 0; - final cubit = LandingQrCodeCubit( - notifier, + final notifier = LandingQrCodeNotifier( + authNotifier, const Locale('en'), autoStart: false, provisioningIdLoader: () { @@ -26,8 +26,8 @@ void main() { pollingStreamFactory: () => const Stream.empty(), ); - unawaited(cubit.requestAuthUrl()); - unawaited(cubit.requestAuthUrl()); + unawaited(notifier.requestAuthUrl()); + unawaited(notifier.requestAuthUrl()); second.complete( MixinResponse(ProvisioningId(deviceId: 'new', expiredAt: null)), @@ -39,15 +39,15 @@ void main() { ); await pumpEventQueue(); - expect(cubit.state.status, LandingStatus.ready); - expect(cubit.state.authUrl, contains('id=new')); - expect(cubit.state.authUrl, isNot(contains('id=old'))); + expect(notifier.value.status, LandingStatus.ready); + expect(notifier.value.authUrl, contains('id=new')); + expect(notifier.value.authUrl, isNot(contains('id=old'))); - await cubit.close(); + notifier.dispose(); }); test('expiration automatically refreshes qr code once', () async { - final notifier = MultiAuthStateNotifier(const MultiAuthState()); + final authNotifier = MultiAuthStateNotifier(const MultiAuthState()); final pollingStreams = [ StreamController(), StreamController(), @@ -55,8 +55,8 @@ void main() { var pollingIndex = 0; var requestCount = 0; - final cubit = LandingQrCodeCubit( - notifier, + final notifier = LandingQrCodeNotifier( + authNotifier, const Locale('en'), autoStart: false, expirationTickLimit: 0, @@ -83,29 +83,29 @@ void main() { pollingStreamFactory: () => pollingStreams[pollingIndex++].stream, ); - await cubit.requestAuthUrl(); + await notifier.requestAuthUrl(); pollingStreams[0].add(0); await pumpEventQueue(); pollingStreams[0].add(1); await pumpEventQueue(); expect(requestCount, 2); - expect(cubit.state.status, LandingStatus.ready); - expect(cubit.state.authUrl, contains('id=second')); + expect(notifier.value.status, LandingStatus.ready); + expect(notifier.value.authUrl, contains('id=second')); - await cubit.close(); + notifier.dispose(); for (final controller in pollingStreams) { await controller.close(); } }); test('failed auto refresh moves qr code to retry state', () async { - final notifier = MultiAuthStateNotifier(const MultiAuthState()); + final authNotifier = MultiAuthStateNotifier(const MultiAuthState()); final pollingController = StreamController(); var requestCount = 0; - final cubit = LandingQrCodeCubit( - notifier, + final notifier = LandingQrCodeNotifier( + authNotifier, const Locale('en'), autoStart: false, expirationTickLimit: 0, @@ -132,23 +132,23 @@ void main() { pollingStreamFactory: () => pollingController.stream, ); - await cubit.requestAuthUrl(); + await notifier.requestAuthUrl(); pollingController.add(1); await pumpEventQueue(); - expect(cubit.state.status, LandingStatus.needReload); - expect(cubit.state.authUrl, contains('id=first')); + expect(notifier.value.status, LandingStatus.needReload); + expect(notifier.value.authUrl, contains('id=first')); - await cubit.close(); + notifier.dispose(); await pollingController.close(); }); test('polling failure threshold surfaces retry state', () async { - final notifier = MultiAuthStateNotifier(const MultiAuthState()); + final authNotifier = MultiAuthStateNotifier(const MultiAuthState()); final pollingController = StreamController(); - final cubit = LandingQrCodeCubit( - notifier, + final notifier = LandingQrCodeNotifier( + authNotifier, const Locale('en'), autoStart: false, pollingFailureLimit: 1, @@ -159,13 +159,13 @@ void main() { expiredMessageBuilder: () => 'expired', ); - await cubit.requestAuthUrl(); + await notifier.requestAuthUrl(); pollingController.add(0); await pumpEventQueue(); - expect(cubit.state.status, LandingStatus.needReload); + expect(notifier.value.status, LandingStatus.needReload); - await cubit.close(); + notifier.dispose(); await pollingController.close(); }); }); From 5184cfb1d484b367c095fa7e8822523a05a9034e Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:01:21 +0900 Subject: [PATCH 04/27] Fix repeated conversation latest jump --- lib/ui/home/chat/message_jump.dart | 13 +++++++++---- lib/ui/home/conversation/conversation_list.dart | 6 +++--- .../home/conversation/unseen_conversation_list.dart | 6 +++--- test/ui/home/chat/message_jump_test.dart | 12 ++++++++++++ 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index 1de84f9190..e22252bc57 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -8,6 +8,9 @@ import '../notifier/blink_notifier.dart'; import 'chat_jump_trace.dart'; import 'chat_scroll_coordinator.dart'; +bool shouldUseLoadedLatestWindowForLatestJump(MessageState state) => + state.isLatest; + extension ChatMessageJump on BuildContext { Future jumpToMessageInChat( String messageId, { @@ -54,9 +57,11 @@ extension ChatMessageJump on BuildContext { Future jumpToLatestInChat() async { traceChatJump('request latest'); final scrollCoordinator = read(); - if (await scrollCoordinator.scrollToBottomIfInLoadedWindow( - animated: true, - )) { + final messageBloc = read(); + if (shouldUseLoadedLatestWindowForLatestJump(messageBloc.state) && + await scrollCoordinator.scrollToBottomIfInLoadedWindow( + animated: true, + )) { traceChatJump('loaded-window latest handled=true'); return; } @@ -65,7 +70,7 @@ extension ChatMessageJump on BuildContext { scrollCoordinator.animateNextRestore( direction: ChatScrollRestoreDirection.towardNewer, ); - read().jumpToLatestWindow(); + messageBloc.jumpToLatestWindow(); } void popChatSideRouteIfNeeded() { diff --git a/lib/ui/home/conversation/conversation_list.dart b/lib/ui/home/conversation/conversation_list.dart index fc255fb450..379e387304 100644 --- a/lib/ui/home/conversation/conversation_list.dart +++ b/lib/ui/home/conversation/conversation_list.dart @@ -80,8 +80,8 @@ class ConversationList extends HookConsumerWidget { itemBuilder: (context, index) { final conversation = pagingState.map[index]; if (conversation == null) return const SizedBox(height: 80); - final selected = - conversation.conversationId == conversationId && !routeMode; + final current = conversation.conversationId == conversationId; + final selected = current && !routeMode; return ConversationMenuWrapper( conversation: conversation, removeChatFromCircle: true, @@ -89,7 +89,7 @@ class ConversationList extends HookConsumerWidget { selected: selected, conversation: conversation, onTap: () { - if (selected) { + if (current) { ref.read(pendingJumpLatestProvider.notifier).state = Object(); return; diff --git a/lib/ui/home/conversation/unseen_conversation_list.dart b/lib/ui/home/conversation/unseen_conversation_list.dart index 66761374e7..d133c34f01 100644 --- a/lib/ui/home/conversation/unseen_conversation_list.dart +++ b/lib/ui/home/conversation/unseen_conversation_list.dart @@ -30,8 +30,8 @@ class UnseenConversationList extends HookConsumerWidget { return ScrollablePositionedList.builder( itemBuilder: (context, index) { final conversation = unseenConversations[index]; - final selected = - conversation.conversationId == currentConversationId && !routeMode; + final current = conversation.conversationId == currentConversationId; + final selected = current && !routeMode; return ConversationMenuWrapper( conversation: conversation, removeChatFromCircle: true, @@ -39,7 +39,7 @@ class UnseenConversationList extends HookConsumerWidget { conversation: conversation, selected: selected, onTap: () { - if (selected) { + if (current) { ref.read(pendingJumpLatestProvider.notifier).state = Object(); return; } diff --git a/test/ui/home/chat/message_jump_test.dart b/test/ui/home/chat/message_jump_test.dart index b018e0af95..0449cdace9 100644 --- a/test/ui/home/chat/message_jump_test.dart +++ b/test/ui/home/chat/message_jump_test.dart @@ -1,8 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:flutter_app/ui/home/bloc/message_bloc.dart'; import 'package:flutter_app/ui/home/chat/message_jump.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test('latest jump only uses loaded window when message state is latest', () { + expect( + shouldUseLoadedLatestWindowForLatestJump(MessageState(isLatest: true)), + isTrue, + ); + expect( + shouldUseLoadedLatestWindowForLatestJump(MessageState()), + isFalse, + ); + }); + testWidgets('popChatSideRouteIfNeeded keeps non-route side pages open', ( tester, ) async { From 7fbd81324555db9d466a99584db9702d7bed1ab5 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:38:29 +0900 Subject: [PATCH 05/27] Remove remaining bloc state layer --- lib/app.dart | 27 +- lib/bloc/custom_bloc_observer.dart | 10 - lib/bloc/paging/load_more_paging_state.dart | 91 ---- lib/bloc/paging/paging_bloc.dart | 246 ----------- lib/bloc/subscribe_mixin.dart | 33 -- lib/main.dart | 13 +- lib/ui/home/chat/chat_page.dart | 27 +- lib/ui/home/chat/files_preview.dart | 10 +- lib/ui/home/chat/message_jump.dart | 15 +- lib/ui/home/chat/selection_bottom_bar.dart | 12 +- .../home/chat_slide_page/chat_info_page.dart | 4 +- .../chat_slide_page/search_message_page.dart | 21 +- .../share_media/file_page.dart | 32 +- .../share_media/media_page.dart | 32 +- .../share_media/post_page.dart | 32 +- .../conversation/conversation_hotkey.dart | 12 +- .../home/conversation/conversation_list.dart | 25 +- lib/ui/home/conversation/search_list.dart | 28 +- lib/ui/home/hook/pin_message.dart | 2 +- .../conversation_list_controller.dart} | 154 +++---- .../notifier/load_more_paging_controller.dart | 86 ++++ .../message_controller.dart} | 400 +++++++++--------- .../message_window_loader.dart | 2 +- lib/ui/home/notifier/paging_controller.dart | 226 ++++++++++ .../search_message_controller.dart} | 54 +-- .../{bloc => notifier}/subscriber_mixin.dart | 0 lib/ui/provider/conversation_provider.dart | 6 +- lib/ui/provider/mention_provider.dart | 2 +- lib/ui/provider/multi_auth_provider.dart | 13 +- lib/ui/provider/setting_provider.dart | 13 +- lib/utils/hook.dart | 52 --- ...ted_bloc.dart => local_state_storage.dart} | 65 ++- pubspec.lock | 36 +- pubspec.yaml | 5 +- test/bloc/paging/paging_bloc_test.dart | 52 --- test/ui/home/chat/message_jump_test.dart | 2 +- .../load_more_paging_controller_test.dart} | 32 +- .../message_window_loader_test.dart | 2 +- .../home/notifier/paging_controller_test.dart | 56 +++ 39 files changed, 892 insertions(+), 1038 deletions(-) delete mode 100644 lib/bloc/custom_bloc_observer.dart delete mode 100644 lib/bloc/paging/load_more_paging_state.dart delete mode 100644 lib/bloc/paging/paging_bloc.dart delete mode 100644 lib/bloc/subscribe_mixin.dart rename lib/ui/home/{bloc/conversation_list_bloc.dart => notifier/conversation_list_controller.dart} (61%) create mode 100644 lib/ui/home/notifier/load_more_paging_controller.dart rename lib/ui/home/{bloc/message_bloc.dart => notifier/message_controller.dart} (62%) rename lib/ui/home/{bloc => notifier}/message_window_loader.dart (99%) create mode 100644 lib/ui/home/notifier/paging_controller.dart rename lib/ui/home/{bloc/search_message_cubit.dart => notifier/search_message_controller.dart} (76%) rename lib/ui/home/{bloc => notifier}/subscriber_mixin.dart (100%) rename lib/utils/{hydrated_bloc.dart => local_state_storage.dart} (64%) delete mode 100644 test/bloc/paging/paging_bloc_test.dart rename test/{bloc/paging/load_more_paging_state_test.dart => ui/home/notifier/load_more_paging_controller_test.dart} (50%) rename test/ui/home/{bloc => notifier}/message_window_loader_test.dart (96%) create mode 100644 test/ui/home/notifier/paging_controller_test.dart diff --git a/lib/app.dart b/lib/app.dart index d06c1e0845..9eedb17cfc 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,11 +3,10 @@ import 'dart:io'; import 'package:drift/isolate.dart'; import 'package:drift/native.dart'; import 'package:flutter/material.dart' hide AnimatedTheme; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' - hide Consumer, FutureProvider, Provider; + hide ChangeNotifierProvider, Consumer, FutureProvider, Provider; import 'package:provider/provider.dart'; import 'account/account_key_value.dart'; @@ -15,9 +14,9 @@ import 'account/notification_service.dart'; import 'constants/brightness_theme_data.dart'; import 'constants/resources.dart'; import 'generated/l10n.dart'; -import 'ui/home/bloc/conversation_list_bloc.dart'; import 'ui/home/conversation/conversation_page.dart'; import 'ui/home/home.dart'; +import 'ui/home/notifier/conversation_list_controller.dart'; import 'ui/landing/landing.dart'; import 'ui/landing/landing_failed.dart'; import 'ui/provider/account_server_provider.dart'; @@ -112,16 +111,12 @@ class _Providers extends HookConsumerWidget { if (!asyncAccountServer.hasValue) return app; final accountServer = asyncAccountServer.requireValue; - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => ConversationListBloc( - ref.read(slideCategoryStateProvider.notifier), - accountServer.database, - ref.read(mentionCacheProvider), - ), - ), - ], + return ChangeNotifierProvider( + create: (context) => ConversationListController( + ref.read(slideCategoryStateProvider.notifier), + accountServer.database, + ref.read(mentionCacheProvider), + ), child: Provider( create: (context) => NotificationService(context: context), lazy: false, @@ -233,9 +228,9 @@ class _Home extends HookConsumerWidget { } if (deviceId.toLowerCase() != currentDeviceId.toLowerCase()) { - final multiAuthCubit = context.multiAuthChangeNotifier; + final multiAuthNotifier = context.multiAuthChangeNotifier; await accountServer.signOutAndClear(); - multiAuthCubit.signOut(); + multiAuthNotifier.signOut(); } } catch (e) { w('checkDeviceId error: $e'); @@ -246,7 +241,7 @@ class _Home extends HookConsumerWidget { }, [accountServer]); if (accountServer != null) { - BlocProvider.of(context) + context.read() ..limit = MediaQuery.sizeOf(context).height ~/ (ConversationPage.conversationItemHeight / 1.75) diff --git a/lib/bloc/custom_bloc_observer.dart b/lib/bloc/custom_bloc_observer.dart deleted file mode 100644 index cc9609cd7b..0000000000 --- a/lib/bloc/custom_bloc_observer.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:bloc/bloc.dart'; -import '../utils/logger.dart'; - -class CustomBlocObserver extends BlocObserver { - @override - void onError(BlocBase bloc, Object error, StackTrace stackTrace) { - super.onError(bloc, error, stackTrace); - w('Bloc Error: bloc: $bloc, error: $error, stackTrace: $stackTrace'); - } -} diff --git a/lib/bloc/paging/load_more_paging_state.dart b/lib/bloc/paging/load_more_paging_state.dart deleted file mode 100644 index c864893169..0000000000 --- a/lib/bloc/paging/load_more_paging_state.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -class _LoadMorePagingEvent extends Equatable { - @override - List get props => []; -} - -class _LoadMorePagingInitEvent extends _LoadMorePagingEvent {} - -class _LoadMorePagingLoadMoreEvent extends _LoadMorePagingEvent {} - -class _LoadMorePagingInsertOrReplaceEvent extends _LoadMorePagingEvent { - _LoadMorePagingInsertOrReplaceEvent(this.item); - - final T item; - - @override - List get props => [item]; -} - -class LoadMorePagingState extends Equatable { - const LoadMorePagingState({this.list = const []}); - - final List list; - - @override - List get props => [list]; - - LoadMorePagingState copyWith({List? list}) => - LoadMorePagingState(list: list ?? this.list); -} - -class LoadMorePagingBloc - extends Bloc<_LoadMorePagingEvent, LoadMorePagingState> { - LoadMorePagingBloc({ - required this.reloadData, - required this.loadMoreData, - required this.isSameKey, - }) : super(LoadMorePagingState()) { - on<_LoadMorePagingEvent>(_onEvent); - add(_LoadMorePagingInitEvent()); - } - - final Future> Function() reloadData; - final Future> Function(List) loadMoreData; - final bool Function(T, T) isSameKey; - bool _isReloading = false; - bool _isLoadingMore = false; - - Future _onEvent( - _LoadMorePagingEvent event, - Emitter> emit, - ) async { - if (event is _LoadMorePagingInitEvent) { - if (_isReloading) return; - _isReloading = true; - try { - emit(LoadMorePagingState(list: await reloadData())); - } finally { - _isReloading = false; - } - } else if (event is _LoadMorePagingLoadMoreEvent) { - if (_isReloading || _isLoadingMore) return; - _isLoadingMore = true; - try { - emit(state.copyWith(list: await loadMoreData(state.list))); - } finally { - _isLoadingMore = false; - } - } else if (event is _LoadMorePagingInsertOrReplaceEvent) { - if (state.list.isEmpty) return; - final index = state.list.indexWhere( - (element) => isSameKey(element, event.item), - ); - List list; - if (index == -1) { - list = [event.item, ...state.list]; - } else { - list = state.list.toList(); - list[index] = event.item; - } - emit(state.copyWith(list: list)); - } - } - - void loadMore() => add(_LoadMorePagingLoadMoreEvent()); - - void insertOrReplace(T item) => - add(_LoadMorePagingInsertOrReplaceEvent(item)); -} diff --git a/lib/bloc/paging/paging_bloc.dart b/lib/bloc/paging/paging_bloc.dart deleted file mode 100644 index 1f9f11ed55..0000000000 --- a/lib/bloc/paging/paging_bloc.dart +++ /dev/null @@ -1,246 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:equatable/equatable.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; - -import '../subscribe_mixin.dart'; - -class PagingState extends Equatable { - const PagingState({ - this.map = const {}, - this.count = 0, - this.initialized = false, - this.index = 0, - this.alignment = 0, - this.hasData = false, - }); - - final Map map; - final int count; - final bool initialized; - final int index; - final double alignment; - final bool hasData; - - @override - List get props => [ - map, - count, - initialized, - index, - alignment, - hasData, - ]; - - PagingState copyWith({ - Map? map, - int? count, - bool? initialized, - int? index, - double? alignment, - bool? hasData, - }) => PagingState( - map: map ?? this.map, - count: count ?? this.count, - initialized: initialized ?? this.initialized, - index: index ?? this.index, - alignment: alignment ?? this.alignment, - hasData: hasData ?? this.hasData, - ); - - @override - bool get stringify => true; -} - -abstract class PagingEvent extends Equatable {} - -class PagingInitEvent extends PagingEvent { - PagingInitEvent({this.offset = 0, this.index = 0, this.alignment = 0}); - - final int offset; - final int index; - final double alignment; - - @override - List get props => [offset, index, alignment]; -} - -class PagingItemPositionEvent extends PagingEvent { - PagingItemPositionEvent(this.itemPositions); - - final List itemPositions; - - @override - List get props => [itemPositions]; -} - -class PagingUpdateEvent extends PagingEvent { - @override - List get props => []; -} - -abstract class PagingBloc extends Bloc> - with SubscribeMixin { - PagingBloc({ - required this.itemPositionsListener, - required this.limit, - required PagingState initState, - this.jumpTo, - int offset = 0, - int index = 0, - double alignment = 0, - }) : super(initState) { - on((event, emit) async { - await _onEvent(event, emit); - }, transformer: restartable()); - on((event, emit) async { - await _onEvent(event, emit); - }, transformer: restartable()); - on((event, emit) async { - await _onEvent(event, emit); - }, transformer: restartable()); - - itemPositionsListener.itemPositions.addListener(onItemPositions); - add(PagingInitEvent(offset: offset, index: index, alignment: alignment)); - } - - final ItemPositionsListener itemPositionsListener; - final void Function({int index, double alignment})? jumpTo; - - int limit; - List lastItemPositions = []; - - void onItemPositions() { - final list = itemPositionsListener.itemPositions.value - .map((e) => e.index) - .toList(); - if (list.isEmpty) return; - - add(PagingItemPositionEvent(list)); - } - - @override - Future close() async { - itemPositionsListener.itemPositions.removeListener(onItemPositions); - await super.close(); - } - - Future _onEvent(PagingEvent event, Emitter> emit) async { - final prefetchDistance = limit ~/ 2; - - if (event is PagingUpdateEvent) { - final count = await queryCount(); - emit( - state.copyWith( - count: count, - hasData: count != 0, - map: count == 0 ? {} : state.map, - ), - ); - - if (count != 0) { - emit( - state.copyWith( - map: await queryMap( - max(state.map.length, limit), - state.map.isNotEmpty ? state.map.keys.reduce(min) : 0, - ), - initialized: true, - ), - ); - } - } else if (event is PagingItemPositionEvent) { - lastItemPositions = event.itemPositions; - if (!state.initialized || - state.map.isEmpty || - expectMinListRange( - event.itemPositions.first - prefetchDistance, - event.itemPositions.last + prefetchDistance, - )) { - return; - } - - final expectStart = event.itemPositions.first - limit; - final expectEnd = event.itemPositions.last + limit; - - final result = differenceMaxExpectRange(expectStart, expectEnd); - - var map = state.map; - // If the diff range has only one direction and is greater than the limit - if (result.length > 1 && (result.first.$2 - result.first.$1) > limit) { - map = await queryMap( - event.itemPositions.length + limit, - event.itemPositions.first - prefetchDistance, - ); - } else { - for (final (start, end) in result) { - map = {...map, ...await queryMap(end - start, start)}; - } - - if (map.length > expectEnd - expectStart) { - map.removeWhere((key, value) => key < expectStart || key > expectEnd); - } - } - - emit(state.copyWith(map: map, initialized: true)); - } else if (event is PagingInitEvent) { - final offset = event.offset; - - final count = await queryCount(); - - emit( - state.copyWith( - map: await queryMap(limit, offset), - count: count, - hasData: count != 0, - initialized: true, - index: event.index, - alignment: event.alignment, - ), - ); - - jumpTo?.call(index: event.index, alignment: event.alignment); - } - } - - Future> queryMap(int limit, int _offset) async { - final offset = max(_offset, 0); - final list = await queryRange(limit, max(offset, 0)); - return Map.fromIterables( - List.generate(min(limit, list.length), (index) => offset + index), - list, - ); - } - - bool expectMinListRange(int _start, int _end) { - final start = max(_start, 0); - final end = max(min(_end, state.count - 1), 0); - return state.map[start] != null && state.map[end] != null; - } - - List<(int, int)> differenceMaxExpectRange(int _start, int _end) { - final start = max(_start, 0); - final end = min(_end, state.count); - final loadedIndexes = state.map.keys.toList(); - - (int, int)? before; - (int, int)? after; - - if (start < loadedIndexes.first) { - before = (start, min(loadedIndexes.first, end)); - } - - if (loadedIndexes.last < end) { - after = (max(loadedIndexes.last, start), end); - } - - return [?before, ?after]; - } - - Future queryCount(); - - Future> queryRange(int limit, int offset); -} diff --git a/lib/bloc/subscribe_mixin.dart b/lib/bloc/subscribe_mixin.dart deleted file mode 100644 index b7f33720bc..0000000000 --- a/lib/bloc/subscribe_mixin.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; - -// mixin SubscribeCubitMixin on Cubit { -// List subscriptions = []; -// -// void addSubscription(StreamSubscription streamSubscription) => -// subscriptions.add(streamSubscription); -// -// @override -// Future close() async { -// await Future.wait(subscriptions.map((e) => e?.cancel())); -// subscriptions.clear(); -// await super.close(); -// } -// } -// -mixin SubscribeMixin on BlocBase { - List subscriptions = []; - - void addSubscription(StreamSubscription? streamSubscription) => - subscriptions.add(streamSubscription); - - @override - Future close() async { - await Future.wait( - subscriptions.where((element) => element != null).map((e) => e!.cancel()), - ); - subscriptions.clear(); - await super.close(); - } -} diff --git a/lib/main.dart b/lib/main.dart index 7702a94160..e94447175a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,14 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; +import 'dart:ui'; import 'package:ansicolor/ansicolor.dart'; import 'package:drift/drift.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:isolate/isolate.dart'; import 'package:overlay_support/overlay_support.dart'; import 'package:protocol_handler/protocol_handler.dart'; @@ -18,7 +17,6 @@ import 'package:window_manager/window_manager.dart'; import 'package:window_size/window_size.dart'; import 'app.dart'; -import 'bloc/custom_bloc_observer.dart'; import 'ui/home/home.dart'; import 'ui/setting/log_page.dart'; import 'utils/app_lifecycle.dart'; @@ -26,6 +24,7 @@ import 'utils/event_bus.dart'; import 'utils/file.dart'; import 'utils/load_balancer_utils.dart'; import 'utils/local_notification_center.dart'; +import 'utils/local_state_storage.dart'; import 'utils/logger.dart'; import 'utils/platform.dart'; import 'utils/system/system_fonts.dart'; @@ -87,13 +86,7 @@ Future _runApp(List args) async { }; Hive.init(mixinDocumentsDirectory.path); - - HydratedBloc.storage = await HydratedStorage.build( - storageDirectory: HydratedStorageDirectory(mixinDocumentsDirectory.path), - ); - if (kDebugMode) { - Bloc.observer = CustomBlocObserver(); - } + await LocalStateStorage.init(mixinDocumentsDirectory.path); runApp(const ProviderScope(child: OverlaySupport.global(child: App()))); diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 36a302f0d6..28e801489d 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -5,7 +5,6 @@ import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' @@ -40,11 +39,11 @@ import '../../provider/conversation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/message_selection_provider.dart'; import '../../provider/pending_jump_message_provider.dart'; -import '../bloc/message_bloc.dart'; import '../home.dart'; import '../hook/pin_message.dart'; import '../notifier/blink_notifier.dart'; import '../notifier/chat_side_notifier.dart'; +import '../notifier/message_controller.dart'; import 'chat_bar.dart'; import 'chat_scroll_coordinator.dart'; import 'files_preview.dart'; @@ -121,8 +120,8 @@ class ChatPage extends HookConsumerWidget { ChangeNotifierProvider.value( value: searchConversationKeywordNotifier, ), - BlocProvider( - create: (context) => MessageBloc( + ChangeNotifierProvider( + create: (context) => MessageController( accountServer: context.accountServer, database: context.database, conversationNotifier: ref.read(conversationProvider.notifier), @@ -275,7 +274,7 @@ class ChatContainer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - BlocProvider.of(context).limit = + context.read().limit = MediaQuery.sizeOf(context).height ~/ 20; final inMultiSelectMode = ref.watch(hasSelectedMessageProvider); @@ -409,11 +408,9 @@ class _List extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final messageBloc = context.read(); + final messageController = context.read(); final scrollCoordinator = context.read(); - final state = useBlocState( - when: (state) => state.conversationId != null, - ); + final state = useValueListenable(messageController); final key = ValueKey((state.conversationId, state.refreshKey)); final top = state.top; @@ -483,8 +480,8 @@ class _List extends HookConsumerWidget { notification, messages: messages, keysByMessageId: messageKeysRef.value, - loadBefore: messageBloc.before, - loadAfter: messageBloc.after, + loadBefore: messageController.before, + loadAfter: messageController.after, ), child: ClampingCustomScrollView( key: scrollCoordinator.viewportKey, @@ -556,7 +553,7 @@ class _JumpCurrentButton extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final scrollCoordinator = context.read(); - final state = useBlocState(); + final state = useValueListenable(context.read()); final showJumpToLatest = useValueListenable( scrollCoordinator.showJumpToLatest, ); @@ -997,12 +994,12 @@ class _ChatMenuHandler extends HookConsumerWidget { final conversationId = ref.watch(currentConversationIdProvider); useEffect(() { - final cubit = ref.read(macMenuBarProvider.notifier); + final menuBarNotifier = ref.read(macMenuBarProvider.notifier); if (conversationId == null) return null; final handle = _ConversationHandle(context, conversationId); - Future(() => cubit.attach(handle)); - return () => Future(() => cubit.unAttach(handle)); + Future(() => menuBarNotifier.attach(handle)); + return () => Future(() => menuBarNotifier.unAttach(handle)); }, [conversationId]); return child; diff --git a/lib/ui/home/chat/files_preview.dart b/lib/ui/home/chat/files_preview.dart index 09073080af..44398c3685 100644 --- a/lib/ui/home/chat/files_preview.dart +++ b/lib/ui/home/chat/files_preview.dart @@ -190,7 +190,7 @@ class _FilesPreviewDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final files = useState(initialFiles); - final quoteMessageCubit = ref.watch(quoteMessageProvider.notifier); + final quoteMessageNotifier = ref.watch(quoteMessageProvider.notifier); final hasMedia = useMemoized( () => files.value.indexWhere((e) => e.isMedia) != -1, @@ -249,7 +249,7 @@ class _FilesPreviewDialog extends HookConsumerWidget { _sendFile( context, file, - quoteMessageCubit.state?.messageId, + quoteMessageNotifier.state?.messageId, silent: silent, compress: currentTab.value == _TabType.image, imageCaption: isOneImage @@ -258,7 +258,7 @@ class _FilesPreviewDialog extends HookConsumerWidget { ), ); } - quoteMessageCubit.state = null; + quoteMessageNotifier.state = null; Navigator.pop(context); } else { final zipFilePath = await runLoadBalancer(_archiveFiles, ( @@ -270,12 +270,12 @@ class _FilesPreviewDialog extends HookConsumerWidget { _sendFile( context, _File.normal(zipFilePath), - quoteMessageCubit.state?.messageId, + quoteMessageNotifier.state?.messageId, silent: silent, compress: false, ), ); - quoteMessageCubit.state = null; + quoteMessageNotifier.state = null; Navigator.pop(context); } } diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index e22252bc57..1b96473f20 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -3,8 +3,8 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; import '../../../utils/extension/extension.dart'; -import '../bloc/message_bloc.dart'; import '../notifier/blink_notifier.dart'; +import '../notifier/message_controller.dart'; import 'chat_jump_trace.dart'; import 'chat_scroll_coordinator.dart'; @@ -23,7 +23,7 @@ extension ChatMessageJump on BuildContext { read().blinkByMessageId(messageId); final scrollCoordinator = read(); - final messageBloc = read(); + final messageController = read(); final handled = await scrollCoordinator.scrollToMessageIfInLoadedWindow( messageId, animated: true, @@ -38,7 +38,8 @@ extension ChatMessageJump on BuildContext { traceChatJump('reload-window target=${shortMessageId(messageId)}'); final directionSourceMessageId = - sourceMessageId ?? _currentWindowSourceMessageId(messageBloc.state); + sourceMessageId ?? + _currentWindowSourceMessageId(messageController.state); final direction = await _restoreDirectionFromSource( sourceMessageId: directionSourceMessageId, targetMessageId: messageId, @@ -51,14 +52,14 @@ extension ChatMessageJump on BuildContext { messageId, direction: direction, ); - messageBloc.scrollTo(messageId); + messageController.scrollTo(messageId); } Future jumpToLatestInChat() async { traceChatJump('request latest'); final scrollCoordinator = read(); - final messageBloc = read(); - if (shouldUseLoadedLatestWindowForLatestJump(messageBloc.state) && + final messageController = read(); + if (shouldUseLoadedLatestWindowForLatestJump(messageController.state) && await scrollCoordinator.scrollToBottomIfInLoadedWindow( animated: true, )) { @@ -70,7 +71,7 @@ extension ChatMessageJump on BuildContext { scrollCoordinator.animateNextRestore( direction: ChatScrollRestoreDirection.towardNewer, ); - messageBloc.jumpToLatestWindow(); + messageController.jumpToLatestWindow(); } void popChatSideRouteIfNeeded() { diff --git a/lib/ui/home/chat/selection_bottom_bar.dart b/lib/ui/home/chat/selection_bottom_bar.dart index dd711a2a02..573e0abd89 100644 --- a/lib/ui/home/chat/selection_bottom_bar.dart +++ b/lib/ui/home/chat/selection_bottom_bar.dart @@ -46,8 +46,8 @@ class SelectionBottomBar extends HookConsumerWidget { ); if (result == null || result.isEmpty) return; - final cubit = ref.read(messageSelectionProvider); - final messageIds = cubit.selectedMessageIds; + final selection = ref.read(messageSelectionProvider); + final messageIds = selection.selectedMessageIds; await runWithLoading( () => context.accountServer.sendTranscriptMessage( @@ -57,7 +57,7 @@ class SelectionBottomBar extends HookConsumerWidget { recipientId: result.first.userId, ), ); - cubit.clearSelection(); + selection.clearSelection(); }, ), _Button( @@ -73,8 +73,8 @@ class SelectionBottomBar extends HookConsumerWidget { ); if (result == null || result.isEmpty) return; - final cubit = ref.read(messageSelectionProvider); - final messageIds = cubit.selectedMessageIds; + final selection = ref.read(messageSelectionProvider); + final messageIds = selection.selectedMessageIds; await runWithLoading(() async { for (final id in messageIds) { @@ -86,7 +86,7 @@ class SelectionBottomBar extends HookConsumerWidget { ); } }); - cubit.clearSelection(); + selection.clearSelection(); }, ), _Button( diff --git a/lib/ui/home/chat_slide_page/chat_info_page.dart b/lib/ui/home/chat_slide_page/chat_info_page.dart index 34db86f79a..e602f82fef 100644 --- a/lib/ui/home/chat_slide_page/chat_info_page.dart +++ b/lib/ui/home/chat_slide_page/chat_info_page.dart @@ -21,9 +21,9 @@ import '../../../widgets/toast.dart'; import '../../../widgets/user/user_dialog.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; import '../../provider/conversation_provider.dart'; -import '../bloc/message_bloc.dart'; import '../chat/chat_bar.dart'; import '../notifier/chat_side_notifier.dart'; +import '../notifier/message_controller.dart'; import 'shared_apps_page.dart'; class ChatInfoPage extends HookConsumerWidget { @@ -491,7 +491,7 @@ class ChatInfoPage extends HookConsumerWidget { await accountServer.deleteMessagesByConversationId( conversationId, ); - context.read().reload(); + context.read().reload(); }, ), if (isGroupConversation) diff --git a/lib/ui/home/chat_slide_page/search_message_page.dart b/lib/ui/home/chat_slide_page/search_message_page.dart index 7ffd51dd44..9ae9258a37 100644 --- a/lib/ui/home/chat_slide_page/search_message_page.dart +++ b/lib/ui/home/chat_slide_page/search_message_page.dart @@ -22,11 +22,11 @@ import '../../../widgets/high_light_text.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/search_text_field.dart'; import '../../provider/conversation_provider.dart'; -import '../bloc/conversation_list_bloc.dart'; -import '../bloc/search_message_cubit.dart'; import '../chat/message_jump.dart'; import '../conversation/search_list.dart'; import '../notifier/chat_side_notifier.dart'; +import '../notifier/conversation_list_controller.dart'; +import '../notifier/search_message_controller.dart'; class SearchMessagePage extends HookConsumerWidget { const SearchMessagePage(this.conversationState, {super.key}); @@ -282,24 +282,25 @@ class _SearchMessageList extends HookConsumerWidget { ).data ?? initKeyword.trim(); - final searchMessageCubit = useBloc( - () => SearchMessageCubit.conversation( + final searchMessageController = useMemoized( + () => SearchMessageController.conversation( database: context.database, keyword: keyword, - limit: context.read().limit, + limit: context.read().limit, categories: categories, userId: selectedUserId, conversationId: conversationId, ), - keys: [keyword, categories, selectedUserId, conversationId], + [keyword, categories, selectedUserId, conversationId], ); + useEffect(() => searchMessageController.dispose, [ + searchMessageController, + ]); - final pageState = useBlocState( - bloc: searchMessageCubit, - ); + final pageState = useValueListenable(searchMessageController); return ScrollablePositionedList.builder( - itemPositionsListener: searchMessageCubit.itemPositionsListener, + itemPositionsListener: searchMessageController.itemPositionsListener, itemCount: pageState.items.length, itemBuilder: (context, index) { final message = pageState.items[index]; diff --git a/lib/ui/home/chat_slide_page/share_media/file_page.dart b/lib/ui/home/chat_slide_page/share_media/file_page.dart index b2c6c23d69..f587c91edf 100644 --- a/lib/ui/home/chat_slide_page/share_media/file_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/file_page.dart @@ -7,15 +7,14 @@ import 'package:intl/intl.dart'; import 'package:rxdart/rxdart.dart'; import 'package:sliver_tools/sliver_tools.dart'; -import '../../../../bloc/paging/load_more_paging_state.dart'; import '../../../../constants/resources.dart'; import '../../../../db/mixin_database.dart'; import '../../../../enum/message_category.dart'; import '../../../../utils/extension/extension.dart'; -import '../../../../utils/hook.dart'; import '../../../../widgets/message/item/file_message.dart'; import '../../../../widgets/message/message.dart'; import '../../../../widgets/near_edge_scroll_listener.dart'; +import '../../notifier/load_more_paging_controller.dart'; import '../shared_media_page.dart'; class FilePage extends HookConsumerWidget { @@ -33,8 +32,8 @@ class FilePage extends HookConsumerWidget { final size = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); final messageDao = context.database.messageDao; - final mediaCubit = useBloc( - () => LoadMorePagingBloc( + final mediaController = useMemoized( + () => LoadMorePagingController( reloadData: () => messageDao.fileMessages(conversationId, size, 0).get(), loadMoreData: (list) async { @@ -49,8 +48,9 @@ class FilePage extends HookConsumerWidget { }, isSameKey: (a, b) => a.messageId == b.messageId, ), - keys: [conversationId], + [conversationId], ); + useEffect(() => mediaController.dispose, [mediaController]); useEffect( () => messageDao .watchInsertOrReplaceMessageStream(conversationId) @@ -65,23 +65,15 @@ class FilePage extends HookConsumerWidget { MessageCategory.signalData, ].contains(event.type), ) - .listen(mediaCubit.insertOrReplace) + .listen(mediaController.insertOrReplace) .cancel, [conversationId], ); - final map = - useBlocStateConverter< - LoadMorePagingBloc, - LoadMorePagingState, - Map> - >( - bloc: mediaCubit, - converter: (state) => - groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }), - ); + final state = useValueListenable(mediaController); + final map = groupBy(state.list, (messageItem) { + final local = messageItem.createdAt.toLocal(); + return DateTime(local.year, local.month, local.day); + }); final scrollController = useScrollController(); @@ -111,7 +103,7 @@ class FilePage extends HookConsumerWidget { } return NearEdgeScrollListener( - onNearEnd: mediaCubit.loadMore, + onNearEnd: mediaController.loadMore, child: CustomScrollView( controller: scrollController, slivers: map.entries diff --git a/lib/ui/home/chat_slide_page/share_media/media_page.dart b/lib/ui/home/chat_slide_page/share_media/media_page.dart index 58ddcca884..86b10a8c6a 100644 --- a/lib/ui/home/chat_slide_page/share_media/media_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/media_page.dart @@ -7,17 +7,16 @@ import 'package:intl/intl.dart'; import 'package:rxdart/rxdart.dart'; import 'package:sliver_tools/sliver_tools.dart'; -import '../../../../bloc/paging/load_more_paging_state.dart'; import '../../../../constants/resources.dart'; import '../../../../db/mixin_database.dart'; import '../../../../enum/message_category.dart'; import '../../../../utils/extension/extension.dart'; -import '../../../../utils/hook.dart'; import '../../../../widgets/message/item/image/image_message.dart'; import '../../../../widgets/message/item/video/video_message.dart'; import '../../../../widgets/message/message.dart'; import '../../../../widgets/near_edge_scroll_listener.dart'; import '../../notifier/chat_side_notifier.dart'; +import '../../notifier/load_more_paging_controller.dart'; import '../shared_media_page.dart'; class MediaPage extends HookConsumerWidget { @@ -38,8 +37,8 @@ class MediaPage extends HookConsumerWidget { final messageDao = context.database.messageDao; - final mediaCubit = useBloc( - () => LoadMorePagingBloc( + final mediaController = useMemoized( + () => LoadMorePagingController( reloadData: () => messageDao.mediaMessages(conversationId, size, 0).get(), loadMoreData: (list) async { @@ -54,8 +53,9 @@ class MediaPage extends HookConsumerWidget { }, isSameKey: (a, b) => a.messageId == b.messageId, ), - keys: [conversationId], + [conversationId], ); + useEffect(() => mediaController.dispose, [mediaController]); useEffect( () => messageDao .watchInsertOrReplaceMessageStream(conversationId) @@ -72,23 +72,15 @@ class MediaPage extends HookConsumerWidget { MessageCategory.signalVideo, ].contains(event.type), ) - .listen(mediaCubit.insertOrReplace) + .listen(mediaController.insertOrReplace) .cancel, [conversationId], ); - final map = - useBlocStateConverter< - LoadMorePagingBloc, - LoadMorePagingState, - Map> - >( - bloc: mediaCubit, - converter: (state) => - groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }), - ); + final state = useValueListenable(mediaController); + final map = groupBy(state.list, (messageItem) { + final local = messageItem.createdAt.toLocal(); + return DateTime(local.year, local.month, local.day); + }); final scrollController = useScrollController(); @@ -118,7 +110,7 @@ class MediaPage extends HookConsumerWidget { } return NearEdgeScrollListener( - onNearEnd: mediaCubit.loadMore, + onNearEnd: mediaController.loadMore, child: CustomScrollView( controller: scrollController, slivers: map.entries diff --git a/lib/ui/home/chat_slide_page/share_media/post_page.dart b/lib/ui/home/chat_slide_page/share_media/post_page.dart index 90feb01768..c9da000b10 100644 --- a/lib/ui/home/chat_slide_page/share_media/post_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/post_page.dart @@ -7,15 +7,14 @@ import 'package:intl/intl.dart'; import 'package:rxdart/rxdart.dart'; import 'package:sliver_tools/sliver_tools.dart'; -import '../../../../bloc/paging/load_more_paging_state.dart'; import '../../../../constants/resources.dart'; import '../../../../db/mixin_database.dart'; import '../../../../enum/message_category.dart'; import '../../../../utils/extension/extension.dart'; -import '../../../../utils/hook.dart'; import '../../../../widgets/message/item/post_message.dart'; import '../../../../widgets/message/message.dart'; import '../../../../widgets/near_edge_scroll_listener.dart'; +import '../../notifier/load_more_paging_controller.dart'; import '../shared_media_page.dart'; class PostPage extends HookConsumerWidget { @@ -34,8 +33,8 @@ class PostPage extends HookConsumerWidget { final messageDao = context.database.messageDao; - final mediaCubit = useBloc( - () => LoadMorePagingBloc( + final mediaController = useMemoized( + () => LoadMorePagingController( reloadData: () => messageDao.postMessages(conversationId, size, 0).get(), loadMoreData: (list) async { @@ -50,8 +49,9 @@ class PostPage extends HookConsumerWidget { }, isSameKey: (a, b) => a.messageId == b.messageId, ), - keys: [conversationId], + [conversationId], ); + useEffect(() => mediaController.dispose, [mediaController]); useEffect( () => messageDao .watchInsertOrReplaceMessageStream(conversationId) @@ -66,23 +66,15 @@ class PostPage extends HookConsumerWidget { MessageCategory.signalPost, ].contains(event.type), ) - .listen(mediaCubit.insertOrReplace) + .listen(mediaController.insertOrReplace) .cancel, [conversationId], ); - final map = - useBlocStateConverter< - LoadMorePagingBloc, - LoadMorePagingState, - Map> - >( - bloc: mediaCubit, - converter: (state) => - groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }), - ); + final state = useValueListenable(mediaController); + final map = groupBy(state.list, (messageItem) { + final local = messageItem.createdAt.toLocal(); + return DateTime(local.year, local.month, local.day); + }); final scrollController = useScrollController(); @@ -112,7 +104,7 @@ class PostPage extends HookConsumerWidget { } return NearEdgeScrollListener( - onNearEnd: mediaCubit.loadMore, + onNearEnd: mediaController.loadMore, child: CustomScrollView( controller: scrollController, slivers: map.entries diff --git a/lib/ui/home/conversation/conversation_hotkey.dart b/lib/ui/home/conversation/conversation_hotkey.dart index 1a988a60f9..b34995879c 100644 --- a/lib/ui/home/conversation/conversation_hotkey.dart +++ b/lib/ui/home/conversation/conversation_hotkey.dart @@ -8,7 +8,7 @@ import '../../provider/conversation_unseen_filter_enabled.dart'; import '../../provider/slide_category_provider.dart'; import '../../provider/unseen_conversations_provider.dart'; -import '../bloc/conversation_list_bloc.dart'; +import '../notifier/conversation_list_controller.dart'; class ConversationHotKey extends StatelessWidget { const ConversationHotKey({required this.child, super.key}); @@ -51,7 +51,7 @@ class PreviousConversationIntent extends Intent { void _navigationConversation(BuildContext context, {required bool forward}) { final category = context.providerContainer.read(slideCategoryStateProvider); - final conversationListBloc = context.read(); + final conversationListController = context.read(); if (category.type == SlideCategoryType.setting) return; @@ -83,7 +83,7 @@ void _navigationConversation(BuildContext context, {required bool forward}) { nextConversationId = unseenConversations[nextIndex].conversationId; } else { var currentConversationIndex = -1; - conversationListBloc.state.map.forEach((key, value) { + conversationListController.state.map.forEach((key, value) { if (value.conversationId == currentConversationId) { currentConversationIndex = key; } @@ -96,7 +96,7 @@ void _navigationConversation(BuildContext context, {required bool forward}) { : currentConversationIndex - 1; final nextConversation = - conversationListBloc.state.map[nextConversationIndex]; + conversationListController.state.map[nextConversationIndex]; if (nextConversation == null) return; nextConversationId = nextConversation.conversationId; } @@ -105,7 +105,7 @@ void _navigationConversation(BuildContext context, {required bool forward}) { if (nextConversationIndex == null) return; - final itemPositions = conversationListBloc + final itemPositions = conversationListController .itemPositionsListener(category) ?.itemPositions .value; @@ -128,7 +128,7 @@ void _navigationConversation(BuildContext context, {required bool forward}) { break; } } - final itemScrollController = conversationListBloc.itemScrollController( + final itemScrollController = conversationListController.itemScrollController( category, ); if (itemScrollController == null) return; diff --git a/lib/ui/home/conversation/conversation_list.dart b/lib/ui/home/conversation/conversation_list.dart index 379e387304..d5e192ae54 100644 --- a/lib/ui/home/conversation/conversation_list.dart +++ b/lib/ui/home/conversation/conversation_list.dart @@ -6,7 +6,6 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide Key, User; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../blaze/vo/pin_message_minimal.dart'; -import '../../../bloc/paging/paging_bloc.dart'; import '../../../constants/resources.dart'; import '../../../db/dao/conversation_dao.dart'; import '../../../enum/message_category.dart'; @@ -27,7 +26,7 @@ import '../../provider/minute_timer_provider.dart'; import '../../provider/pending_jump_message_provider.dart'; import '../../provider/responsive_navigator_provider.dart'; import '../../provider/slide_category_provider.dart'; -import '../bloc/conversation_list_bloc.dart'; +import '../notifier/conversation_list_controller.dart'; import 'audio_player_bar.dart'; import 'conversation_page.dart'; import 'menu_wrapper.dart'; @@ -41,21 +40,21 @@ class ConversationList extends HookConsumerWidget { final slideCategoryState = (key! as PageStorageKey).value; - final conversationListBloc = context.read(); - final pagingState = - useBlocState>( - bloc: conversationListBloc, - ); + final conversationListController = context + .read(); + final pagingState = useValueListenable(conversationListController); final conversationId = ref.watch(currentConversationIdProvider); final routeMode = ref.watch(navigatorRouteModeProvider); - final itemPositionsListener = conversationListBloc.itemPositionsListener( - slideCategoryState, - ); - final itemScrollController = conversationListBloc.itemScrollController( - slideCategoryState, - ); + final itemPositionsListener = conversationListController + .itemPositionsListener( + slideCategoryState, + ); + final itemScrollController = conversationListController + .itemScrollController( + slideCategoryState, + ); if (itemPositionsListener == null || itemScrollController == null) { return const SizedBox(); diff --git a/lib/ui/home/conversation/search_list.dart b/lib/ui/home/conversation/search_list.dart index c6a0a62277..66f509a01e 100644 --- a/lib/ui/home/conversation/search_list.dart +++ b/lib/ui/home/conversation/search_list.dart @@ -40,8 +40,8 @@ import '../../provider/mention_cache_provider.dart'; import '../../provider/minute_timer_provider.dart'; import '../../provider/search_mao_user_provider.dart'; import '../../provider/slide_category_provider.dart'; -import '../bloc/conversation_list_bloc.dart'; -import '../bloc/search_message_cubit.dart'; +import '../notifier/conversation_list_controller.dart'; +import '../notifier/search_message_controller.dart'; import 'conversation_page.dart'; import 'menu_wrapper.dart'; import 'unseen_conversation_list.dart'; @@ -63,9 +63,9 @@ class SearchList extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final keyword = useMemoizedStream(() { - final keywordCubit = ref.watch(keywordProvider.notifier); - return keywordCubit.stream - .startWith(keywordCubit.state) + final keywordNotifier = ref.watch(keywordProvider.notifier); + return keywordNotifier.stream + .startWith(keywordNotifier.state) .map((event) => event.trim()) .distinct() .debounceTime(const Duration(milliseconds: 150)); @@ -705,19 +705,20 @@ class _SearchMessageList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final searchMessageCubit = useBloc( - () => SearchMessageCubit.slideCategory( + final searchMessageController = useMemoized( + () => SearchMessageController.slideCategory( database: context.database, category: categoryState, keyword: keyword, - limit: context.read().limit, + limit: context.read().limit, ), - keys: [keyword, categoryState], + [keyword, categoryState], ); + useEffect(() => searchMessageController.dispose, [ + searchMessageController, + ]); - final pageState = useBlocState( - bloc: searchMessageCubit, - ); + final pageState = useValueListenable(searchMessageController); final child = pageState.initializing ? Center( @@ -729,7 +730,8 @@ class _SearchMessageList extends HookConsumerWidget { : pageState.items.isEmpty ? const SearchEmptyWidget() : ScrollablePositionedList.builder( - itemPositionsListener: searchMessageCubit.itemPositionsListener, + itemPositionsListener: + searchMessageController.itemPositionsListener, itemCount: pageState.items.length, itemBuilder: (context, index) { final message = pageState.items[index]; diff --git a/lib/ui/home/hook/pin_message.dart b/lib/ui/home/hook/pin_message.dart index 733a9bc352..71bdf06b9e 100644 --- a/lib/ui/home/hook/pin_message.dart +++ b/lib/ui/home/hook/pin_message.dart @@ -20,7 +20,7 @@ class PinMessageState extends Equatable { List get props => [messageIds, lastMessage]; } -extension PinMessageCubitExtension on BuildContext { +extension PinMessageStateExtension on BuildContext { List get currentPinMessageIds => read().messageIds; List get watchCurrentPinMessageIds => diff --git a/lib/ui/home/bloc/conversation_list_bloc.dart b/lib/ui/home/notifier/conversation_list_controller.dart similarity index 61% rename from lib/ui/home/bloc/conversation_list_bloc.dart rename to lib/ui/home/notifier/conversation_list_controller.dart index 00ffb437b0..2b38f46154 100644 --- a/lib/ui/home/bloc/conversation_list_bloc.dart +++ b/lib/ui/home/notifier/conversation_list_controller.dart @@ -1,12 +1,10 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter_app_icon_badge/flutter_app_icon_badge.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:rxdart/rxdart.dart' hide ThrottleExtensions; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; -import '../../../bloc/paging/paging_bloc.dart'; -import '../../../bloc/subscribe_mixin.dart'; import '../../../db/dao/conversation_dao.dart'; import '../../../db/database.dart'; import '../../../db/database_event_bus.dart'; @@ -15,19 +13,20 @@ import '../../../utils/logger.dart'; import '../../../utils/platform.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/slide_category_provider.dart'; +import 'paging_controller.dart'; const kDefaultLimit = 15; -class ConversationListBloc extends Cubit> - with SubscribeMixin { - ConversationListBloc( +class ConversationListController + extends ValueNotifier> { + ConversationListController( this.slideCategoryStateNotifier, this.database, this.mentionCache, ) : super(const PagingState()) { - addSubscription( + _subscriptions.add( slideCategoryStateNotifier.stream.distinct().listen( - (event) => _switchBloc(event, _limit), + (event) => _switchController(event, _limit), ), ); _initBadge(); @@ -36,13 +35,20 @@ class ConversationListBloc extends Cubit> final SlideCategoryStateNotifier slideCategoryStateNotifier; final Database database; final MentionCache mentionCache; - final Map _map = {}; + final Map _map = {}; + final List _subscriptions = []; int? _limit; + VoidCallback? _activeListener; + SlideCategoryState? _activeState; + _ConversationPagingController? _activeController; + var _disposed = false; + + PagingState get state => value; int get limit { if (_limit == null) { - w('conversation list bloc: limit is null'); + w('conversation list controller: limit is null'); return kDefaultLimit; } return _limit!; @@ -50,19 +56,16 @@ class ConversationListBloc extends Cubit> set limit(int limit) { if (limit <= 0) { - w('conversation list bloc: ignore limit <= 0'); + w('conversation list controller: ignore limit <= 0'); return; } _limit = limit; - _map.values.forEach((element) { - element.limit = limit; - }); + for (final controller in _map.values) { + controller.limit = limit; + } } - StreamSubscription? streamSubscription; - SlideCategoryState? _activeState; - ItemPositionsListener? itemPositionsListener( SlideCategoryState slideCategoryState, ) => _map[slideCategoryState]?.itemPositionsListener; @@ -71,16 +74,16 @@ class ConversationListBloc extends Cubit> SlideCategoryState slideCategoryState, ) => _map[slideCategoryState]?.itemScrollController; - void init() => _switchBloc(slideCategoryStateNotifier.state, _limit); + void init() => _switchController(slideCategoryStateNotifier.state, _limit); - late Stream updateEvent = Rx.merge([ + late final Stream updateEvent = Rx.merge([ DataBaseEventBus.instance.updateConversationIdStream, DataBaseEventBus.instance.updateUserIdsStream, DataBaseEventBus.instance.insertOrReplaceMessageIdsStream, DataBaseEventBus.instance.updateMessageMentionStream, ]).throttleTime(kDefaultThrottleDuration).asBroadcastStream(); - late Stream circleUpdateEvent = Rx.merge([ + late final Stream circleUpdateEvent = Rx.merge([ DataBaseEventBus.instance.updateConversationIdStream, DataBaseEventBus.instance.updateUserIdsStream, DataBaseEventBus.instance.insertOrReplaceMessageIdsStream, @@ -89,7 +92,7 @@ class ConversationListBloc extends Cubit> DataBaseEventBus.instance.updateCircleConversationStream, ]).throttleTime(kDefaultThrottleDuration).asBroadcastStream(); - void _switchBloc(SlideCategoryState state, int? limit) { + void _switchController(SlideCategoryState state, int? limit) { final dao = database.conversationDao; switch (state.type) { @@ -98,7 +101,7 @@ class ConversationListBloc extends Cubit> case SlideCategoryType.groups: case SlideCategoryType.bots: case SlideCategoryType.strangers: - _map[state] ??= _ConversationListBloc( + _map[state] ??= _ConversationPagingController( limit ?? kDefaultLimit, () => dao.conversationCountByCategory(state.type), (limit, offset) => @@ -107,7 +110,7 @@ class ConversationListBloc extends Cubit> mentionCache, ); case SlideCategoryType.circle: - _map[state] ??= _ConversationListBloc( + _map[state] ??= _ConversationPagingController( limit ?? kDefaultLimit, () => database.conversationDao .conversationsCountByCircleId(state.id!) @@ -121,33 +124,48 @@ class ConversationListBloc extends Cubit> case SlideCategoryType.setting: return; } - final previous = _activeState; - if (previous != null && previous != state) { - _map[previous]?.deactivate(); - } - final bloc = _map[state]!..activate(); + if (_activeState != null && _activeState != state) { + _activeController?.deactivate(); + } _activeState = state; - emit(bloc.state); - streamSubscription?.cancel(); - streamSubscription = bloc.stream.listen(emit); + final controller = _map[state]!..activate(); + _listenTo(controller); + value = controller.value; + } + + void _listenTo(_ConversationPagingController controller) { + if (_activeController != null && _activeListener != null) { + _activeController!.removeListener(_activeListener!); + } + + _activeController = controller; + _activeListener = () { + if (!_disposed) value = controller.value; + }; + controller.addListener(_activeListener!); } @override - Future close() async { - await streamSubscription?.cancel(); - _map[_activeState]?.deactivate(); - await Future.wait(_map.values.map((e) => e.close())); - await super.close(); + void dispose() { + _disposed = true; + if (_activeController != null && _activeListener != null) { + _activeController!.removeListener(_activeListener!); + } + _activeController?.deactivate(); + for (final subscription in _subscriptions) { + unawaited(subscription?.cancel()); + } + for (final controller in _map.values) { + controller.dispose(); + } + super.dispose(); } Future _initBadge() async { Future updateBadge(int count) async { - if (!kPlatformIsDarwin) { - // not work on other platform. - return; - } + if (!kPlatformIsDarwin) return; if (count == 0) { await FlutterAppIconBadge.removeBadge(); return; @@ -159,7 +177,7 @@ class ConversationListBloc extends Cubit> .allUnseenIgnoreMuteMessageCount() .getSingle(); await updateBadge(count); - addSubscription( + _subscriptions.add( database.conversationDao.allUnseenIgnoreMuteMessageCountEvent .distinct() .asyncBufferMap((event) => updateBadge(event.last)) @@ -168,64 +186,50 @@ class ConversationListBloc extends Cubit> } } -class _ConversationListBloc extends PagingBloc { - _ConversationListBloc( +class _ConversationPagingController extends PagingController { + _ConversationPagingController( int limit, Future Function() queryCount, Future> Function(int limit, int offset) queryRange, Stream updateEvent, - this.mentionCache, - ) : _queryCount = queryCount, - _queryRange = queryRange, - _updateEvent = updateEvent, + MentionCache mentionCache, + ) : _updateEvent = updateEvent, super( - initState: const PagingState(), itemPositionsListener: ItemPositionsListener.create(), limit: limit, + queryCount: queryCount, + queryRange: (limit, offset) async { + final list = await queryRange(limit, offset); + unawaited(_warmMentionCache(mentionCache, list)); + return list; + }, ); - final MentionCache mentionCache; - final Future Function() _queryCount; - final Future> Function(int limit, int offset) - _queryRange; final Stream _updateEvent; + final ItemScrollController itemScrollController = ItemScrollController(); StreamSubscription? _updateSubscription; - final ItemScrollController itemScrollController = ItemScrollController(); - void activate() { - _updateSubscription ??= _updateEvent.listen( - (_) => add(PagingUpdateEvent()), - ); - if (state.initialized) { - add(PagingUpdateEvent()); - } + _updateSubscription ??= _updateEvent.listen((_) => update()); + if (value.initialized) update(); } void deactivate() { - _updateSubscription?.cancel(); + unawaited(_updateSubscription?.cancel()); _updateSubscription = null; } @override - Future close() async { + void dispose() { deactivate(); - await super.close(); - } - - @override - Future queryCount() => _queryCount(); - - @override - Future> queryRange(int limit, int offset) async { - final list = await _queryRange(limit, offset); - unawaited(_warmMentionCache(list)); - - return list; + super.dispose(); } - Future _warmMentionCache(List list) async { + static Future _warmMentionCache( + MentionCache mentionCache, + List list, + ) async { try { await mentionCache.checkMentionCache(list.map((e) => e.content).toSet()); } catch (error) { diff --git a/lib/ui/home/notifier/load_more_paging_controller.dart b/lib/ui/home/notifier/load_more_paging_controller.dart new file mode 100644 index 0000000000..315d4b6666 --- /dev/null +++ b/lib/ui/home/notifier/load_more_paging_controller.dart @@ -0,0 +1,86 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; + +import '../../../utils/logger.dart'; + +class LoadMorePagingState extends Equatable { + const LoadMorePagingState({this.list = const []}); + + final List list; + + @override + List get props => [list]; + + LoadMorePagingState copyWith({List? list}) => + LoadMorePagingState(list: list ?? this.list); +} + +class LoadMorePagingController + extends ValueNotifier> { + LoadMorePagingController({ + required this.reloadData, + required this.loadMoreData, + required this.isSameKey, + }) : super(LoadMorePagingState()) { + _reload(); + } + + final Future> Function() reloadData; + final Future> Function(List) loadMoreData; + final bool Function(T, T) isSameKey; + + var _disposed = false; + var _isReloading = false; + var _isLoadingMore = false; + + void _setState(LoadMorePagingState state) { + if (_disposed) { + i('load more paging controller: disposed, ignore'); + return; + } + value = state; + } + + Future _reload() async { + if (_isReloading) return; + _isReloading = true; + try { + _setState(LoadMorePagingState(list: await reloadData())); + } finally { + _isReloading = false; + } + } + + void loadMore() { + _loadMore(); + } + + Future _loadMore() async { + if (_isReloading || _isLoadingMore) return; + _isLoadingMore = true; + try { + _setState(value.copyWith(list: await loadMoreData(value.list))); + } finally { + _isLoadingMore = false; + } + } + + void insertOrReplace(T item) { + if (value.list.isEmpty) return; + final index = value.list.indexWhere((element) => isSameKey(element, item)); + List list; + if (index == -1) { + list = [item, ...value.list]; + } else { + list = value.list.toList(); + list[index] = item; + } + _setState(value.copyWith(list: list)); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/ui/home/bloc/message_bloc.dart b/lib/ui/home/notifier/message_controller.dart similarity index 62% rename from lib/ui/home/bloc/message_bloc.dart rename to lib/ui/home/notifier/message_controller.dart index 713ae46ed7..05809e4b4d 100644 --- a/lib/ui/home/bloc/message_bloc.dart +++ b/lib/ui/home/notifier/message_controller.dart @@ -1,14 +1,12 @@ import 'dart:async'; -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:mixin_logger/mixin_logger.dart'; import 'package:rxdart/rxdart.dart'; import '../../../account/account_server.dart'; -import '../../../bloc/subscribe_mixin.dart'; import '../../../db/dao/message_dao.dart'; import '../../../db/database.dart'; import '../../../db/database_event_bus.dart'; @@ -22,62 +20,6 @@ import '../chat/chat_jump_trace.dart'; part 'message_window_loader.dart'; -abstract class _MessageEvent extends Equatable { - @override - List get props => []; -} - -class _MessageInitEvent extends _MessageEvent { - _MessageInitEvent({ - this.centerMessageId, - this.lastReadMessageId, - this.forceLatest = false, - }); - - final String? centerMessageId; - final String? lastReadMessageId; - final bool forceLatest; - - @override - List get props => [centerMessageId, lastReadMessageId, forceLatest]; - - @override - final stringify = true; -} - -class _MessageScrollEvent extends _MessageEvent { - _MessageScrollEvent({required this.messageId}); - - final String messageId; - - @override - List get props => [messageId]; -} - -class _MessageLoadMoreEvent extends _MessageEvent {} - -class _MessageLoadAfterEvent extends _MessageLoadMoreEvent {} - -class _MessageLoadBeforeEvent extends _MessageLoadMoreEvent {} - -class _MessageInsertOrReplaceEvent extends _MessageEvent { - _MessageInsertOrReplaceEvent(this.data); - - final List data; - - @override - List get props => [data]; -} - -class _MessageDeleteEvent extends _MessageEvent { - _MessageDeleteEvent(this.messageId); - - final String messageId; - - @override - List get props => [messageId]; -} - class MessageState extends Equatable { MessageState({ this.top = const [], @@ -194,97 +136,76 @@ class MessageState extends Equatable { } } -class MessageBloc extends Bloc<_MessageEvent, MessageState> - with SubscribeMixin { - MessageBloc({ +class MessageController extends ValueNotifier { + MessageController({ required this.conversationNotifier, required this.limit, required this.database, required this.mentionCache, required this.accountServer, }) : super(MessageState()) { - on<_MessageInitEvent>( - _onEvent, - transformer: restartable(), - ); - on<_MessageLoadAfterEvent>( - _onEvent, - transformer: droppable(), - ); - on<_MessageLoadBeforeEvent>( - _onEvent, - transformer: droppable(), - ); - on<_MessageInsertOrReplaceEvent>( - _onEvent, - transformer: restartable(), - ); - on<_MessageDeleteEvent>( - _onEvent, - transformer: sequential(), - ); - on<_MessageScrollEvent>( - _onEvent, - transformer: restartable(), - ); - add( - _MessageInitEvent( - centerMessageId: conversationNotifier.state?.initIndexMessageId, - lastReadMessageId: conversationNotifier.state?.lastReadMessageId, - ), - ); - addSubscription( - conversationNotifier.stream - .where((event) => event?.conversationId != null) - .map( - (event) => ( - event?.conversationId, - event?.initIndexMessageId, - event?.lastReadMessageId, - event?.refreshKey, - ), - ) - .distinct() - .asyncMap( - (event) async => _MessageInitEvent( - centerMessageId: event.$2, - lastReadMessageId: event.$3, - ), - ) - .listen(add), + _init( + centerMessageId: conversationNotifier.state?.initIndexMessageId, + lastReadMessageId: conversationNotifier.state?.lastReadMessageId, ); - addSubscription( - conversationNotifier.stream - .startWith(conversationNotifier.state) - .map((event) => event?.conversationId) - .distinct() - .switchMap((conversationId) { - if (conversationId == null) { - return const Stream>.empty(); - } - return messageDao.watchInsertOrReplaceMessageStream(conversationId); - }) - .listen((state) => add(_MessageInsertOrReplaceEvent(state))), - ); - - addSubscription( - DataBaseEventBus.instance.deleteMessageIdStream.listen((messageIds) { - messageIds.forEach((messageId) { - add(_MessageDeleteEvent(messageId)); - }); - }), - ); + _subscriptions + ..add( + conversationNotifier.stream + .where((event) => event?.conversationId != null) + .map( + (event) => ( + event?.conversationId, + event?.initIndexMessageId, + event?.lastReadMessageId, + event?.refreshKey, + ), + ) + .distinct() + .listen( + (event) => _init( + centerMessageId: event.$2, + lastReadMessageId: event.$3, + ), + ), + ) + ..add( + conversationNotifier.stream + .startWith(conversationNotifier.state) + .map((event) => event?.conversationId) + .distinct() + .switchMap((conversationId) { + if (conversationId == null) { + return const Stream>.empty(); + } + return messageDao.watchInsertOrReplaceMessageStream( + conversationId, + ); + }) + .listen(_insertOrReplaceCurrentConversation), + ) + ..add( + DataBaseEventBus.instance.deleteMessageIdStream.listen((messageIds) { + messageIds.forEach(_deleteMessage); + }), + ); } final ConversationStateNotifier conversationNotifier; final Database database; final MentionCache mentionCache; final AccountServer accountServer; + final List _subscriptions = []; int limit; + var _generation = 0; + var _loadAfterInFlight = false; + var _loadBeforeInFlight = false; + var _disposed = false; late final MessageWindowLoader _messageWindowLoader = MessageWindowLoader.fromDao(messageDao); + MessageState get state => value; + MessageDao get messageDao => database.messageDao; Future _preCacheMention(MessageState state) async { @@ -305,82 +226,125 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> ); } - Future _onEvent(_MessageEvent event, Emitter emit) async { - // Avoid value change - final finalLimit = limit; + @override + void dispose() { + _disposed = true; + _subscriptions + ..forEach((subscription) => unawaited(subscription?.cancel())) + ..clear(); + super.dispose(); + } + bool _isCurrent(int generation, String conversationId) => + !_disposed && + generation == _generation && + conversationNotifier.state?.conversationId == conversationId; + + void _emit(MessageState nextState) { + if (_disposed) return; + value = nextState; + _warmMentionCache(nextState); + } + + void _init({ + String? centerMessageId, + String? lastReadMessageId, + bool forceLatest = false, + }) { + final generation = ++_generation; + unawaited( + _runInit( + generation, + centerMessageId: centerMessageId, + lastReadMessageId: lastReadMessageId, + forceLatest: forceLatest, + ).catchError((Object error, StackTrace stackTrace) { + e('message init failed: $error $stackTrace'); + }), + ); + } + + Future _runInit( + int generation, { + String? centerMessageId, + String? lastReadMessageId, + bool forceLatest = false, + }) async { + final finalLimit = limit; final conversationId = conversationNotifier.state?.conversationId; if (conversationId == null) return; - // If the conversationId has changed, then events other than init are ignored - if (event is! _MessageInitEvent && state.conversationId != conversationId) { + + if (centerMessageId != null || forceLatest) { + traceChatJump( + 'message init center=${shortMessageId(centerMessageId)} ' + 'forceLatest=$forceLatest limit=$finalLimit', + ); + } + + final messageState = await _resetMessageList( + conversationId, + finalLimit, + centerMessageId: centerMessageId, + forceLatest: forceLatest, + ); + if (!_isCurrent(generation, conversationId)) return; + + final nextState = _pretreatment( + messageState.copyWith( + refreshKey: Object(), + lastReadMessageId: lastReadMessageId, + ), + ); + _emit(nextState); + } + + void after() { + if (_loadAfterInFlight || state.isLatest) return; + final conversationId = conversationNotifier.state?.conversationId; + if (conversationId == null || state.conversationId != conversationId) { return; } - if (event is _MessageInitEvent) { - if (event.centerMessageId != null || event.forceLatest) { - traceChatJump( - 'bloc init center=${shortMessageId(event.centerMessageId)} ' - 'forceLatest=${event.forceLatest} limit=$finalLimit', - ); - } - final messageState = await _resetMessageList( - conversationId, - finalLimit, - centerMessageId: event.centerMessageId, - forceLatest: event.forceLatest, - ); - final nextState = _pretreatment( - messageState.copyWith( - refreshKey: Object(), - lastReadMessageId: event.lastReadMessageId, - ), - ); - emit(nextState); - _warmMentionCache(nextState); - } else if (event is _MessageDeleteEvent) { - final messageState = state.removeMessage(event.messageId); - final nextState = _pretreatment(messageState); - emit(nextState); - _warmMentionCache(nextState); - } else { - if (event is _MessageLoadMoreEvent) { - if (event is _MessageLoadAfterEvent) { - if (state.isLatest) return; - final messageState = await _after(conversationId); - final nextState = _pretreatment(messageState); - emit(nextState); - _warmMentionCache(nextState); - } else if (event is _MessageLoadBeforeEvent) { - if (state.isOldest) return; - final messageState = await _before(conversationId); - final nextState = _pretreatment(messageState); - emit(nextState); - _warmMentionCache(nextState); - } - } else if (event is _MessageInsertOrReplaceEvent) { - final result = _insertOrReplace(conversationId, event.data); - if (result != null) { - final nextState = _pretreatment(result); - emit(nextState); - _warmMentionCache(nextState); - } - } else if (event is _MessageScrollEvent) { - traceChatJump( - 'bloc scroll event target=${shortMessageId(event.messageId)}', - ); - add( - _MessageInitEvent( - centerMessageId: event.messageId, - lastReadMessageId: state.lastReadMessageId, - ), - ); - } + _loadAfterInFlight = true; + final generation = _generation; + unawaited( + _loadAfter(generation, conversationId) + .catchError((Object error, StackTrace stackTrace) { + e('message load after failed: $error $stackTrace'); + }) + .whenComplete(() => _loadAfterInFlight = false), + ); + } + + void before() { + if (_loadBeforeInFlight || state.isOldest) return; + final conversationId = conversationNotifier.state?.conversationId; + if (conversationId == null || state.conversationId != conversationId) { + return; } + + _loadBeforeInFlight = true; + final generation = _generation; + unawaited( + _loadBefore(generation, conversationId) + .catchError((Object error, StackTrace stackTrace) { + e('message load before failed: $error $stackTrace'); + }) + .whenComplete(() => _loadBeforeInFlight = false), + ); } - void after() => add(_MessageLoadAfterEvent()); + Future _loadAfter(int generation, String conversationId) async { + final messageState = await _after(conversationId); + if (!_isCurrent(generation, conversationId)) return; + _emit(_pretreatment(messageState)); + } - void before() => add(_MessageLoadBeforeEvent()); + Future _loadBefore(int generation, String conversationId) async { + final messageState = await _before(conversationId); + if (!_isCurrent(generation, conversationId)) return; + _emit(_pretreatment(messageState)); + } Future _before(String conversationId) async { final topMessageId = state.topMessage?.messageId; @@ -511,7 +475,7 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> jumpToCurrent = jumpToCurrent || currentUserSent; } else { if (currentUserSent && item.status == MessageStatus.sending) { - add(_MessageInitEvent()); + _init(); return null; } } @@ -525,21 +489,43 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> return result; } + void _insertOrReplaceCurrentConversation(List list) { + final conversationId = conversationNotifier.state?.conversationId; + if (conversationId == null || state.conversationId != conversationId) { + return; + } + + final result = _insertOrReplace(conversationId, list); + if (result != null) { + _emit(_pretreatment(result)); + } + } + + void _deleteMessage(String messageId) { + final conversationId = conversationNotifier.state?.conversationId; + if (conversationId == null || state.conversationId != conversationId) { + return; + } + + _emit(_pretreatment(state.removeMessage(messageId))); + } + void scrollTo(String messageId) { - traceChatJump('bloc scrollTo target=${shortMessageId(messageId)}'); - add(_MessageScrollEvent(messageId: messageId)); + traceChatJump('message scrollTo target=${shortMessageId(messageId)}'); + _init( + centerMessageId: messageId, + lastReadMessageId: state.lastReadMessageId, + ); } void reload() { - add(_MessageInitEvent()); + _init(); } void jumpToLatestWindow() { - add( - _MessageInitEvent( - lastReadMessageId: state.lastReadMessageId, - forceLatest: true, - ), + _init( + lastReadMessageId: state.lastReadMessageId, + forceLatest: true, ); } diff --git a/lib/ui/home/bloc/message_window_loader.dart b/lib/ui/home/notifier/message_window_loader.dart similarity index 99% rename from lib/ui/home/bloc/message_window_loader.dart rename to lib/ui/home/notifier/message_window_loader.dart index d0d66d21fa..92415a9606 100644 --- a/lib/ui/home/bloc/message_window_loader.dart +++ b/lib/ui/home/notifier/message_window_loader.dart @@ -1,4 +1,4 @@ -part of 'message_bloc.dart'; +part of 'message_controller.dart'; typedef RecentMessagesQuery = Future> Function(String conversationId, int limit); diff --git a/lib/ui/home/notifier/paging_controller.dart b/lib/ui/home/notifier/paging_controller.dart new file mode 100644 index 0000000000..8da34c3057 --- /dev/null +++ b/lib/ui/home/notifier/paging_controller.dart @@ -0,0 +1,226 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +class PagingState extends Equatable { + const PagingState({ + this.map = const {}, + this.count = 0, + this.initialized = false, + this.index = 0, + this.alignment = 0, + this.hasData = false, + }); + + final Map map; + final int count; + final bool initialized; + final int index; + final double alignment; + final bool hasData; + + @override + List get props => [ + map, + count, + initialized, + index, + alignment, + hasData, + ]; + + PagingState copyWith({ + Map? map, + int? count, + bool? initialized, + int? index, + double? alignment, + bool? hasData, + }) => PagingState( + map: map ?? this.map, + count: count ?? this.count, + initialized: initialized ?? this.initialized, + index: index ?? this.index, + alignment: alignment ?? this.alignment, + hasData: hasData ?? this.hasData, + ); + + @override + bool get stringify => true; +} + +class PagingController extends ValueNotifier> { + PagingController({ + required this.itemPositionsListener, + required this.limit, + required this.queryCount, + required this.queryRange, + this.jumpTo, + int offset = 0, + int index = 0, + double alignment = 0, + }) : super(PagingState()) { + itemPositionsListener.itemPositions.addListener(_onItemPositions); + init(offset: offset, index: index, alignment: alignment); + } + + final ItemPositionsListener itemPositionsListener; + final void Function({int index, double alignment})? jumpTo; + final Future Function() queryCount; + final Future> Function(int limit, int offset) queryRange; + + int limit; + List lastItemPositions = []; + var _generation = 0; + var _disposed = false; + + void init({int offset = 0, int index = 0, double alignment = 0}) { + final generation = ++_generation; + unawaited(_init(generation, offset, index, alignment)); + } + + void update() { + final generation = ++_generation; + unawaited(_update(generation)); + } + + void _onItemPositions() { + final list = itemPositionsListener.itemPositions.value + .map((e) => e.index) + .toList(); + if (list.isEmpty) return; + + lastItemPositions = list; + final generation = ++_generation; + unawaited(_loadAround(generation, list)); + } + + @override + void dispose() { + _disposed = true; + itemPositionsListener.itemPositions.removeListener(_onItemPositions); + super.dispose(); + } + + Future _update(int generation) async { + final count = await queryCount(); + _setState( + value.copyWith( + count: count, + hasData: count != 0, + map: count == 0 ? {} : value.map, + ), + generation, + ); + + if (count == 0) return; + _setState( + value.copyWith( + map: await queryMap( + max(value.map.length, limit), + value.map.isNotEmpty ? value.map.keys.reduce(min) : 0, + ), + initialized: true, + ), + generation, + ); + } + + Future _loadAround(int generation, List itemPositions) async { + final prefetchDistance = limit ~/ 2; + if (!value.initialized || + value.map.isEmpty || + expectMinListRange( + itemPositions.first - prefetchDistance, + itemPositions.last + prefetchDistance, + )) { + return; + } + + final expectStart = itemPositions.first - limit; + final expectEnd = itemPositions.last + limit; + final result = differenceMaxExpectRange(expectStart, expectEnd); + + var map = value.map; + if (result.length > 1 && (result.first.$2 - result.first.$1) > limit) { + map = await queryMap( + itemPositions.length + limit, + itemPositions.first - prefetchDistance, + ); + } else { + for (final (start, end) in result) { + map = {...map, ...await queryMap(end - start, start)}; + } + + if (map.length > expectEnd - expectStart) { + map.removeWhere((key, value) => key < expectStart || key > expectEnd); + } + } + + _setState(value.copyWith(map: map, initialized: true), generation); + } + + Future _init( + int generation, + int offset, + int index, + double alignment, + ) async { + final count = await queryCount(); + _setState( + value.copyWith( + map: await queryMap(limit, offset), + count: count, + hasData: count != 0, + initialized: true, + index: index, + alignment: alignment, + ), + generation, + ); + + jumpTo?.call(index: index, alignment: alignment); + } + + Future> queryMap(int limit, int offset) async { + final safeOffset = max(offset, 0); + final list = await queryRange(limit, safeOffset); + return Map.fromIterables( + List.generate(min(limit, list.length), (index) => safeOffset + index), + list, + ); + } + + bool expectMinListRange(int start, int end) { + final safeStart = max(start, 0); + final safeEnd = max(min(end, value.count - 1), 0); + return value.map[safeStart] != null && value.map[safeEnd] != null; + } + + List<(int, int)> differenceMaxExpectRange(int start, int end) { + final safeStart = max(start, 0); + final safeEnd = min(end, value.count); + final loadedIndexes = value.map.keys.toList(); + + (int, int)? before; + (int, int)? after; + + if (safeStart < loadedIndexes.first) { + before = (safeStart, min(loadedIndexes.first, safeEnd)); + } + + if (loadedIndexes.last < safeEnd) { + after = (max(loadedIndexes.last, safeStart), safeEnd); + } + + return [?before, ?after]; + } + + void _setState(PagingState state, int generation) { + if (_disposed || generation != _generation) return; + value = state; + } +} diff --git a/lib/ui/home/bloc/search_message_cubit.dart b/lib/ui/home/notifier/search_message_controller.dart similarity index 76% rename from lib/ui/home/bloc/search_message_cubit.dart rename to lib/ui/home/notifier/search_message_controller.dart index cb6a9e6a04..183273085f 100644 --- a/lib/ui/home/bloc/search_message_cubit.dart +++ b/lib/ui/home/notifier/search_message_controller.dart @@ -1,5 +1,5 @@ -import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../db/dao/message_dao.dart'; @@ -20,8 +20,9 @@ class SearchMessageState with EquatableMixin { List get props => [items, loading]; } -abstract class SearchMessageCubit extends Cubit { - SearchMessageCubit({ +abstract class SearchMessageController + extends ValueNotifier { + SearchMessageController({ required this.database, required this.keyword, required this.limit, @@ -30,14 +31,14 @@ abstract class SearchMessageCubit extends Cubit { itemPositionsListener.itemPositions.addListener(_onItemPosition); } - factory SearchMessageCubit.conversation({ + factory SearchMessageController.conversation({ required Database database, required String keyword, required String conversationId, required String? userId, required List? categories, required int limit, - }) => _ConversationSearchMessageCubit( + }) => _ConversationSearchMessageController( database: database, keyword: keyword, conversationId: conversationId, @@ -46,12 +47,12 @@ abstract class SearchMessageCubit extends Cubit { limit: limit, ); - factory SearchMessageCubit.slideCategory({ + factory SearchMessageController.slideCategory({ required Database database, required String keyword, required SlideCategoryState category, required int limit, - }) => _SlideCategorySearchMessageCubit( + }) => _SlideCategorySearchMessageController( database: database, keyword: keyword, category: category, @@ -62,42 +63,42 @@ abstract class SearchMessageCubit extends Cubit { ItemPositionsListener.create(); final Database database; - final String keyword; - final int limit; var _hasMore = true; + var _disposed = false; - @override - void emit(SearchMessageState state) { - if (isClosed) { - i('search message cubit: closed, ignore'); + SearchMessageState get state => value; + + void _setState(SearchMessageState state) { + if (_disposed) { + i('search message controller: disposed, ignore'); return; } - super.emit(state); + value = state; } Future _load() async { if (state.loading) { - w('search message cubit: loading, ignore'); + w('search message controller: loading, ignore'); return; } if (!_hasMore) { return; } final lastMessageId = state.items.lastOrNull?.messageId; - emit(SearchMessageState(state.items, true)); + _setState(SearchMessageState(state.items, true)); try { final items = await _doFuzzySearch(lastMessageId); if (items.isEmpty) { - d('search message cubit: no more data $lastMessageId'); + d('search message controller: no more data $lastMessageId'); _hasMore = false; } - emit(SearchMessageState([...state.items, ...items], false)); + _setState(SearchMessageState([...state.items, ...items], false)); } catch (error, stacktrace) { - e('search message cubit: load error', error, stacktrace); - emit(SearchMessageState(state.items, false)); + e('search message controller: load error', error, stacktrace); + _setState(SearchMessageState(state.items, false)); _hasMore = false; } } @@ -116,14 +117,15 @@ abstract class SearchMessageCubit extends Cubit { } @override - Future close() { + void dispose() { + _disposed = true; itemPositionsListener.itemPositions.removeListener(_onItemPosition); - return super.close(); + super.dispose(); } } -class _ConversationSearchMessageCubit extends SearchMessageCubit { - _ConversationSearchMessageCubit({ +class _ConversationSearchMessageController extends SearchMessageController { + _ConversationSearchMessageController({ required this.conversationId, required super.database, required super.keyword, @@ -169,8 +171,8 @@ class _ConversationSearchMessageCubit extends SearchMessageCubit { } } -class _SlideCategorySearchMessageCubit extends SearchMessageCubit { - _SlideCategorySearchMessageCubit({ +class _SlideCategorySearchMessageController extends SearchMessageController { + _SlideCategorySearchMessageController({ required this.category, required super.database, required super.keyword, diff --git a/lib/ui/home/bloc/subscriber_mixin.dart b/lib/ui/home/notifier/subscriber_mixin.dart similarity index 100% rename from lib/ui/home/bloc/subscriber_mixin.dart rename to lib/ui/home/notifier/subscriber_mixin.dart diff --git a/lib/ui/provider/conversation_provider.dart b/lib/ui/provider/conversation_provider.dart index 25d3f71b40..1e870986f7 100644 --- a/lib/ui/provider/conversation_provider.dart +++ b/lib/ui/provider/conversation_provider.dart @@ -18,8 +18,8 @@ import '../../utils/extension/extension.dart'; import '../../utils/local_notification_center.dart'; import '../../utils/rivepod.dart'; import '../../widgets/toast.dart'; -import '../home/bloc/conversation_list_bloc.dart'; -import '../home/bloc/subscriber_mixin.dart'; +import '../home/notifier/conversation_list_controller.dart'; +import '../home/notifier/subscriber_mixin.dart'; import 'account_server_provider.dart'; import 'is_bot_group_provider.dart'; import 'recent_conversation_provider.dart'; @@ -399,7 +399,7 @@ class ConversationStateNotifier String conversationId, ) async { final conversations = context - .read() + .read() .state .map .values diff --git a/lib/ui/provider/mention_provider.dart b/lib/ui/provider/mention_provider.dart index 2deab6bd1d..5d766da5e2 100644 --- a/lib/ui/provider/mention_provider.dart +++ b/lib/ui/provider/mention_provider.dart @@ -13,7 +13,7 @@ import '../../utils/extension/extension.dart'; import '../../utils/reg_exp_utils.dart'; import '../../utils/rivepod.dart'; import '../../widgets/mention_panel.dart'; -import '../home/bloc/subscriber_mixin.dart'; +import '../home/notifier/subscriber_mixin.dart'; import 'conversation_provider.dart'; import 'database_provider.dart'; import 'multi_auth_provider.dart'; diff --git a/lib/ui/provider/multi_auth_provider.dart b/lib/ui/provider/multi_auth_provider.dart index 7066e6dba9..454f6ad641 100644 --- a/lib/ui/provider/multi_auth_provider.dart +++ b/lib/ui/provider/multi_auth_provider.dart @@ -5,11 +5,10 @@ import 'dart:convert'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:mixin_logger/mixin_logger.dart'; -import '../../utils/hydrated_bloc.dart'; +import '../../utils/local_state_storage.dart'; import '../../utils/rivepod.dart'; class AuthState extends Equatable { @@ -123,14 +122,14 @@ class MultiAuthStateNotifier extends DistinctStateNotifier { @override @protected set state(MultiAuthState value) { - final hydratedJson = toHydratedJson(state.toMap()); - HydratedBloc.storage.write(_kMultiAuthCubitKey, hydratedJson); + final storedJson = toStoredJson(value.toMap()); + LocalStateStorage.write(_multiAuthStorageKey, storedJson); super.state = value; } } @Deprecated('Use multiAuthNotifierProvider instead') -const _kMultiAuthCubitKey = 'MultiAuthCubit'; +const _multiAuthStorageKey = 'MultiAuthCubit'; final multiAuthStateNotifierProvider = StateNotifierProvider.autoDispose(( @@ -138,9 +137,9 @@ final multiAuthStateNotifierProvider = ) { ref.keepAlive(); - final oldJson = HydratedBloc.storage.read(_kMultiAuthCubitKey); + final oldJson = LocalStateStorage.read(_multiAuthStorageKey); if (oldJson != null) { - final multiAuthState = fromHydratedJson( + final multiAuthState = fromStoredJson( oldJson, MultiAuthState.fromMap, ); diff --git a/lib/ui/provider/setting_provider.dart b/lib/ui/provider/setting_provider.dart index 7d971b2f5d..9416bbd458 100644 --- a/lib/ui/provider/setting_provider.dart +++ b/lib/ui/provider/setting_provider.dart @@ -4,9 +4,8 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; -import '../../utils/hydrated_bloc.dart'; +import '../../utils/local_state_storage.dart'; import '../../utils/logger.dart'; @Deprecated('Use settingProvider instead') @@ -274,8 +273,8 @@ class SettingChangeNotifier extends ChangeNotifier { @override void notifyListeners() { - HydratedBloc.storage.write( - _kSettingCubitKey, + LocalStateStorage.write( + _settingStorageKey, _SettingState( brightness: _brightness, messageShowAvatar: _messageShowAvatar, @@ -293,16 +292,16 @@ class SettingChangeNotifier extends ChangeNotifier { } @Deprecated('Use SettingChangeNotifier instead') -const _kSettingCubitKey = 'SettingCubit'; +const _settingStorageKey = 'SettingCubit'; final settingProvider = ChangeNotifierProvider.autoDispose((ref) { ref.keepAlive(); //migrate - final oldJson = HydratedBloc.storage.read(_kSettingCubitKey); + final oldJson = LocalStateStorage.read(_settingStorageKey); if (oldJson != null) { - final settingState = fromHydratedJson(oldJson, _SettingState.fromMap); + final settingState = fromStoredJson(oldJson, _SettingState.fromMap); if (settingState == null) return SettingChangeNotifier(); return SettingChangeNotifier( brightness: settingState.brightness, diff --git a/lib/utils/hook.dart b/lib/utils/hook.dart index 46f545fdfa..aa21b1255d 100644 --- a/lib/utils/hook.dart +++ b/lib/utils/hook.dart @@ -1,10 +1,8 @@ import 'dart:async'; -import 'package:bloc/bloc.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:protocol_handler/protocol_handler.dart'; -import 'package:provider/provider.dart'; import 'package:rxdart/rxdart.dart'; import '../app.dart'; @@ -42,56 +40,6 @@ AsyncSnapshot _logSnapshotError(AsyncSnapshot snapshot) { return snapshot; } -T useBloc( - T Function() valueBuilder, { - List keys = const [], -}) { - final sink = useMemoized(valueBuilder, keys); - useEffect(() => sink.close, keys); - return sink; -} - -S useBlocState, S>({ - B? bloc, - List keys = const [], - bool preserveState = false, - bool Function(S state)? when, -}) { - final (stream, initialData) = useMemoized(() { - final b = bloc ?? useContext().read(); - var stream = b.stream; - if (when != null) stream = stream.where(when); - return (stream.distinct(), b.state); - }, [bloc ?? useContext().read(), ...keys]); - return useStream( - stream, - initialData: initialData, - preserveState: preserveState, - ).data - as S; -} - -T useBlocStateConverter, S, T>({ - required T Function(S) converter, - B? bloc, - List keys = const [], - bool preserveState = false, - bool Function(T)? when, -}) { - final (stream, initialData) = useMemoized(() { - final b = bloc ?? useContext().read(); - var stream = b.stream.map(converter); - if (when != null) stream = stream.where(when); - return (stream.distinct(), converter(b.state)); - }, [bloc ?? useContext().read(), ...keys]); - return useStream( - stream, - initialData: initialData, - preserveState: preserveState, - ).data - as T; -} - Stream useValueNotifierConvertSteam(ValueNotifier valueNotifier) { final streamController = useStreamController(keys: [valueNotifier]); useEffect(() { diff --git a/lib/utils/hydrated_bloc.dart b/lib/utils/local_state_storage.dart similarity index 64% rename from lib/utils/hydrated_bloc.dart rename to lib/utils/local_state_storage.dart index 3a3402d2fa..1e50e47048 100644 --- a/lib/utils/hydrated_bloc.dart +++ b/lib/utils/local_state_storage.dart @@ -1,8 +1,47 @@ -// ignore_for_file: avoid_catching_errors +import 'dart:convert'; +import 'dart:io'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'package:hive_ce/hive.dart'; +import 'package:synchronized/synchronized.dart'; -T? fromHydratedJson( +class LocalStateStorage { + LocalStateStorage._(); + + static final _lock = Lock(); + static late final HiveInterface _hive; + static late final Box _box; + + static Future init(String directory) async { + _hive = Hive..init(directory); + _box = await _hive.openBox('hydrated_box'); + await _migrateOldStateJson(directory); + } + + static dynamic read(String key) => _box.isOpen ? _box.get(key) : null; + + static Future write(String key, dynamic value) async { + if (!_box.isOpen) return; + await _lock.synchronized(() => _box.put(key, value)); + } + + static Future _migrateOldStateJson(String directory) async { + final file = File('$directory/.hydrated_bloc.json'); + if (!file.existsSync()) return; + + try { + final storageJson = json.decode(await file.readAsString()); + final cache = (storageJson as Map).cast(); + for (final entry in cache.entries) { + try { + await _box.put(entry.key, json.decode(entry.value)); + } catch (_) {} + } + } catch (_) {} + await file.delete(); + } +} + +T? fromStoredJson( dynamic json, T Function(Map) fromJson, ) { @@ -11,7 +50,7 @@ T? fromHydratedJson( return fromJson(castJson ?? {}); } -Map? toHydratedJson(Map state) => +Map? toStoredJson(Map state) => _cast>(_traverseWrite(state).value); dynamic _traverseRead(dynamic value) { @@ -109,20 +148,12 @@ _Traversed _traverseWrite(Object? value) { if (traversedComplexJson is! NIL) { return _Traversed.complex(traversedComplexJson); } - try { - final dynamic customJson = _toEncodable(value); - final dynamic traversedCustomJson = _traverseJson(customJson); - if (traversedCustomJson is NIL) { - throw HydratedUnsupportedError(value); - } - return _Traversed.complex(traversedCustomJson); - } on HydratedCyclicError catch (e, s) { - Error.throwWithStackTrace(HydratedUnsupportedError(value, cause: e), s); - } on HydratedUnsupportedError { - rethrow; // do not stack `HydratedUnsupportedError` - } catch (e, s) { - Error.throwWithStackTrace(HydratedUnsupportedError(value, cause: e), s); + final dynamic customJson = _toEncodable(value); + final dynamic traversedCustomJson = _traverseJson(customJson); + if (traversedCustomJson is NIL) { + throw ArgumentError.value(value, 'value', 'unsupported json value'); } + return _Traversed.complex(traversedCustomJson); } T? _cast(dynamic x) => x is T ? x : null; diff --git a/pubspec.lock b/pubspec.lock index aba1720aac..eb329aa801 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,22 +89,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" - bloc: - dependency: "direct main" - description: - name: bloc - sha256: e03b235924e4f509c27b5d6b2f949200e0a91149a9818b4f65eeb56662b75413 - url: "https://pub.dev" - source: hosted - version: "9.2.1" - bloc_concurrency: - dependency: "direct main" - description: - name: bloc_concurrency - sha256: "86b7b17a0a78f77fca0d7c030632b59b593b22acea2d96972588f40d4ef53a94" - url: "https://pub.dev" - source: hosted - version: "0.3.0" blurhash_dart: dependency: "direct main" description: @@ -659,14 +643,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - flutter_bloc: - dependency: "direct main" - description: - name: flutter_bloc - sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 - url: "https://pub.dev" - source: hosted - version: "9.1.1" flutter_highlight: dependency: transitive description: @@ -868,7 +844,7 @@ packages: source: hosted version: "2.2.3" hive_ce: - dependency: transitive + dependency: "direct main" description: name: hive_ce sha256: "8e9980e68643afb1e765d3af32b47996552a64e190d03faf622cea07c1294418" @@ -947,14 +923,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.0" - hydrated_bloc: - dependency: "direct main" - description: - name: hydrated_bloc - sha256: "1444375ee775d25e1c2ad175f8fe3b2041b3d5db63b27ca939a927020c90a208" - url: "https://pub.dev" - source: hosted - version: "11.0.0" image: dependency: "direct main" description: @@ -2390,4 +2358,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.0 <4.0.0" - flutter: ">=3.44.0" + flutter: ">=3.44.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 7856ccab4e..3c286403bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,8 +25,6 @@ dependencies: async: ^2.13.1 audio_session: ^0.2.3 auto_size_text: ^3.0.0 - bloc: ^9.2.1 - bloc_concurrency: ^0.3.0 blurhash_dart: ^1.0.2 bring_window_to_front: git: @@ -68,7 +66,6 @@ dependencies: sdk: flutter flutter_animate: ^4.4.0 flutter_app_icon_badge: ^2.0.0 - flutter_bloc: ^9.1.1 flutter_hooks: ^0.21.3 flutter_local_notifications: ^22.0.1 flutter_localizations: @@ -81,11 +78,11 @@ dependencies: ref: b42e314284a09c9f0f9fe1e0e76e0c563402449e github: ^9.25.1 hive: ^2.2.3 + hive_ce: ^2.13.2 hive_flutter: ^1.0.0 hooks_riverpod: ^2.5.1 http: ^1.6.0 http_client_helper: ^3.0.0 - hydrated_bloc: ^11.0.0 image: ^4.8.0 image_picker: ^1.2.2 intl: ^0.20.2 diff --git a/test/bloc/paging/paging_bloc_test.dart b/test/bloc/paging/paging_bloc_test.dart deleted file mode 100644 index 298c5f7a0b..0000000000 --- a/test/bloc/paging/paging_bloc_test.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter_app/bloc/paging/paging_bloc.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; - -void main() { - test( - 'initial load derives hasData from count', - () async { - final bloc = _TrackingPagingBloc( - count: 3, - range: const [1, 2], - ); - addTearDown(bloc.close); - - final state = await bloc.stream.firstWhere((state) => state.initialized); - - expect(state.hasData, true); - expect(state.count, 3); - expect(state.map.values, [1, 2]); - expect(bloc.queryCountCalls, 1); - expect(bloc.queryRangeCalls, 1); - }, - ); -} - -class _TrackingPagingBloc extends PagingBloc { - _TrackingPagingBloc({ - required this.count, - required this.range, - }) : super( - initState: const PagingState(), - itemPositionsListener: ItemPositionsListener.create(), - limit: 2, - ); - - final int count; - final List range; - int queryCountCalls = 0; - int queryRangeCalls = 0; - - @override - Future queryCount() async { - queryCountCalls++; - return count; - } - - @override - Future> queryRange(int limit, int offset) async { - queryRangeCalls++; - return range; - } -} diff --git a/test/ui/home/chat/message_jump_test.dart b/test/ui/home/chat/message_jump_test.dart index 0449cdace9..c1f308b0a4 100644 --- a/test/ui/home/chat/message_jump_test.dart +++ b/test/ui/home/chat/message_jump_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter_app/ui/home/bloc/message_bloc.dart'; import 'package:flutter_app/ui/home/chat/message_jump.dart'; +import 'package:flutter_app/ui/home/notifier/message_controller.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/test/bloc/paging/load_more_paging_state_test.dart b/test/ui/home/notifier/load_more_paging_controller_test.dart similarity index 50% rename from test/bloc/paging/load_more_paging_state_test.dart rename to test/ui/home/notifier/load_more_paging_controller_test.dart index 2e91dd37a1..706cbaa2fa 100644 --- a/test/bloc/paging/load_more_paging_state_test.dart +++ b/test/ui/home/notifier/load_more_paging_controller_test.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter_app/bloc/paging/load_more_paging_state.dart'; +import 'package:flutter_app/ui/home/notifier/load_more_paging_controller.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -9,7 +9,7 @@ void main() { final completeLoad = Completer>(); var loadMoreCallCount = 0; - final bloc = LoadMorePagingBloc( + final controller = LoadMorePagingController( reloadData: () async => [1], loadMoreData: (list) { loadMoreCallCount += 1; @@ -20,11 +20,11 @@ void main() { }, isSameKey: (a, b) => a == b, ); - addTearDown(bloc.close); + addTearDown(controller.dispose); - await bloc.stream.firstWhere((state) => state.list.length == 1); + await waitForState(controller, (state) => state.list.length == 1); - bloc + controller ..loadMore() ..loadMore(); @@ -34,10 +34,30 @@ void main() { expect(loadMoreCallCount, 1); completeLoad.complete([1, 2]); - final loaded = await bloc.stream.firstWhere( + final loaded = await waitForState( + controller, (state) => state.list.length == 2, ); expect(loaded.list, [1, 2]); }); } + +Future> waitForState( + LoadMorePagingController controller, + bool Function(LoadMorePagingState) test, +) { + final current = controller.value; + if (test(current)) return Future.value(current); + + final completer = Completer>(); + void listener() { + final state = controller.value; + if (!test(state)) return; + controller.removeListener(listener); + completer.complete(state); + } + + controller.addListener(listener); + return completer.future; +} diff --git a/test/ui/home/bloc/message_window_loader_test.dart b/test/ui/home/notifier/message_window_loader_test.dart similarity index 96% rename from test/ui/home/bloc/message_window_loader_test.dart rename to test/ui/home/notifier/message_window_loader_test.dart index 5adb142709..399add28ca 100644 --- a/test/ui/home/bloc/message_window_loader_test.dart +++ b/test/ui/home/notifier/message_window_loader_test.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter_app/db/dao/message_dao.dart'; import 'package:flutter_app/db/mixin_database.dart'; -import 'package:flutter_app/ui/home/bloc/message_bloc.dart'; +import 'package:flutter_app/ui/home/notifier/message_controller.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; diff --git a/test/ui/home/notifier/paging_controller_test.dart b/test/ui/home/notifier/paging_controller_test.dart new file mode 100644 index 0000000000..d751fd6076 --- /dev/null +++ b/test/ui/home/notifier/paging_controller_test.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:flutter_app/ui/home/notifier/paging_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +void main() { + test('initial load derives hasData from count', () async { + var queryCountCalls = 0; + var queryRangeCalls = 0; + + final controller = PagingController( + itemPositionsListener: ItemPositionsListener.create(), + limit: 2, + queryCount: () async { + queryCountCalls++; + return 3; + }, + queryRange: (limit, offset) async { + queryRangeCalls++; + return const [1, 2]; + }, + ); + addTearDown(controller.dispose); + + final state = await waitForPagingState( + controller, + (state) => state.initialized, + ); + + expect(state.hasData, true); + expect(state.count, 3); + expect(state.map.values, [1, 2]); + expect(queryCountCalls, 1); + expect(queryRangeCalls, 1); + }); +} + +Future> waitForPagingState( + PagingController controller, + bool Function(PagingState) test, +) { + final current = controller.value; + if (test(current)) return Future.value(current); + + final completer = Completer>(); + void listener() { + final state = controller.value; + if (!test(state)) return; + controller.removeListener(listener); + completer.complete(state); + } + + controller.addListener(listener); + return completer.future; +} From 827a45f42c0be83bfe23ac28f68b09f2532f7488 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:51:36 +0900 Subject: [PATCH 06/27] Refactor desktop chat navigation and rendering --- lib/account/notification_service.dart | 3 +- lib/db/dao/participant_dao.dart | 41 + lib/main.dart | 2 +- lib/ui/home/chat/chat_bar.dart | 20 +- lib/ui/home/chat/chat_content_overlays.dart | 362 +++++++ lib/ui/home/chat/chat_drop_overlay.dart | 70 ++ lib/ui/home/chat/chat_history_viewport.dart | 332 +++++++ lib/ui/home/chat/chat_menu_handler.dart | 141 +++ lib/ui/home/chat/chat_page.dart | 890 +----------------- lib/ui/home/chat/chat_scroll_coordinator.dart | 109 ++- lib/ui/home/chat/chat_side_router.dart | 193 ++++ lib/ui/home/chat/input_container.dart | 2 +- lib/ui/home/chat/message_jump.dart | 127 ++- .../home/chat_slide_page/chat_info_page.dart | 40 +- .../group_participants_page.dart | 3 +- .../groups_in_common_page.dart | 3 +- .../chat_slide_page/pin_messages_page.dart | 10 +- .../chat_slide_page/search_message_page.dart | 11 +- .../share_media/file_page.dart | 128 +-- .../share_media/media_page.dart | 155 +-- .../share_media/post_page.dart | 129 +-- .../share_media/shared_media_list.dart | 159 ++++ .../chat_slide_page/shared_media_page.dart | 7 +- .../home/conversation/audio_player_bar.dart | 3 +- .../conversation_avatar_cache.dart | 78 ++ .../home/conversation/conversation_focus.dart | 201 ++++ .../conversation/conversation_hotkey.dart | 4 +- .../home/conversation/conversation_list.dart | 24 +- lib/ui/home/conversation/search_list.dart | 10 +- .../unseen_conversation_list.dart | 16 +- .../home/conversation_info_destination.dart | 11 + lib/ui/home/desktop_shell_layout.dart | 89 ++ lib/ui/home/home.dart | 81 +- lib/ui/home/left_rail_controller.dart | 9 + lib/ui/home/notifier/chat_side_notifier.dart | 225 ++--- .../conversation_list_controller.dart | 9 + lib/ui/home/route/major_navigator.dart | 244 +++++ lib/ui/home/route/responsive_navigator.dart | 152 --- .../abstract_responsive_navigator.dart | 106 --- lib/ui/provider/conversation_provider.dart | 199 +--- .../provider/major_navigation_provider.dart | 134 +++ .../provider/pending_chat_jump_provider.dart | 41 + .../pending_jump_message_provider.dart | 25 - .../responsive_navigator_provider.dart | 158 ---- lib/ui/setting/account_page.dart | 8 +- lib/ui/setting/setting_page.dart | 53 +- lib/ui/setting/storage_page.dart | 8 +- lib/ui/setting/storage_usage_list_page.dart | 8 +- lib/utils/uri_utils.dart | 8 +- .../actions/command_palette_action.dart | 6 +- .../actions/create_conversation_action.dart | 4 +- lib/widgets/avatar_view/avatar_view.dart | 10 +- lib/widgets/cell.dart | 4 +- .../conversation/conversation_dialog.dart | 6 +- lib/widgets/message/item/quote_message.dart | 11 +- lib/widgets/message/message.dart | 662 ++----------- lib/widgets/message/message_actions_menu.dart | 438 +++++++++ lib/widgets/message/message_bubble.dart | 8 +- lib/widgets/message/message_content.dart | 108 +++ lib/widgets/message/message_day_time.dart | 63 +- lib/widgets/message/message_file_actions.dart | 53 ++ .../send_message_dialog.dart | 3 +- lib/widgets/search_bar.dart | 4 +- lib/widgets/user/user_dialog.dart | 10 +- macos/Podfile | 6 - macos/Podfile.lock | 2 +- macos/Runner.xcodeproj/project.pbxproj | 4 +- test/db/participant_dao_test.dart | 73 ++ test/ui/home/chat/chat_page_test.dart | 2 +- .../chat/chat_scroll_coordinator_test.dart | 26 +- test/ui/home/chat/message_jump_test.dart | 61 +- test/ui/home/desktop_shell_layout_test.dart | 78 ++ .../notifier/chat_side_notifier_test.dart | 41 + .../major_navigation_provider_test.dart | 26 + .../message/message_day_time_test.dart | 37 + 75 files changed, 3647 insertions(+), 2940 deletions(-) create mode 100644 lib/ui/home/chat/chat_content_overlays.dart create mode 100644 lib/ui/home/chat/chat_drop_overlay.dart create mode 100644 lib/ui/home/chat/chat_history_viewport.dart create mode 100644 lib/ui/home/chat/chat_menu_handler.dart create mode 100644 lib/ui/home/chat/chat_side_router.dart create mode 100644 lib/ui/home/chat_slide_page/share_media/shared_media_list.dart create mode 100644 lib/ui/home/conversation/conversation_avatar_cache.dart create mode 100644 lib/ui/home/conversation/conversation_focus.dart create mode 100644 lib/ui/home/conversation_info_destination.dart create mode 100644 lib/ui/home/desktop_shell_layout.dart create mode 100644 lib/ui/home/left_rail_controller.dart create mode 100644 lib/ui/home/route/major_navigator.dart delete mode 100644 lib/ui/home/route/responsive_navigator.dart delete mode 100644 lib/ui/provider/abstract_responsive_navigator.dart create mode 100644 lib/ui/provider/major_navigation_provider.dart create mode 100644 lib/ui/provider/pending_chat_jump_provider.dart delete mode 100644 lib/ui/provider/pending_jump_message_provider.dart delete mode 100644 lib/ui/provider/responsive_navigator_provider.dart create mode 100644 lib/widgets/message/message_actions_menu.dart create mode 100644 lib/widgets/message/message_content.dart create mode 100644 lib/widgets/message/message_file_actions.dart create mode 100644 test/db/participant_dao_test.dart create mode 100644 test/ui/home/desktop_shell_layout_test.dart create mode 100644 test/ui/home/notifier/chat_side_notifier_test.dart create mode 100644 test/ui/provider/major_navigation_provider_test.dart create mode 100644 test/widgets/message/message_day_time_test.dart diff --git a/lib/account/notification_service.dart b/lib/account/notification_service.dart index 272e4ef5ec..5b52797540 100644 --- a/lib/account/notification_service.dart +++ b/lib/account/notification_service.dart @@ -10,6 +10,7 @@ import '../db/extension/conversation.dart'; import '../enum/message_category.dart'; import '../generated/l10n.dart'; +import '../ui/home/conversation/conversation_focus.dart'; import '../ui/provider/conversation_provider.dart'; import '../ui/provider/mention_cache_provider.dart'; import '../ui/provider/slide_category_provider.dart'; @@ -198,7 +199,7 @@ class NotificationService { final conversationId = event.queryParameters[_keyConversationId] ?? event.host; - ConversationStateNotifier.selectConversation( + ConversationFocus.selectConversation( context, conversationId, initIndexMessageId: event.path, diff --git a/lib/db/dao/participant_dao.dart b/lib/db/dao/participant_dao.dart index 02e5e1b4aa..771cd7f086 100644 --- a/lib/db/dao/participant_dao.dart +++ b/lib/db/dao/participant_dao.dart @@ -54,6 +54,47 @@ class ParticipantDao extends DatabaseAccessor return query.get(); } + Future>> participantsAvatarByConversationIds( + Iterable conversationIds, + ) async { + final ids = conversationIds.toSet().toList(growable: false); + if (ids.isEmpty) return const {}; + + final expandedIds = List.filled(ids.length, '?').join(','); + final rows = await customSelect( + ''' +SELECT * +FROM ( + SELECT + participant.conversation_id, + user.*, + ROW_NUMBER() OVER ( + PARTITION BY participant.conversation_id + ORDER BY participant.created_at ASC + ) AS avatar_rank + FROM participants AS participant + INNER JOIN users AS user ON participant.user_id = user.user_id + WHERE participant.conversation_id IN ($expandedIds) +) +WHERE avatar_rank <= 4 +ORDER BY conversation_id ASC, avatar_rank ASC +''', + variables: [for (final id in ids) Variable(id)], + readsFrom: {db.participants, db.users}, + ).get(); + + final result = {for (final id in ids) id: []}; + for (final row in rows) { + final conversationId = row.read('conversation_id'); + result + .putIfAbsent(conversationId, () => []) + .add( + await db.users.mapFromRow(row), + ); + } + return result; + } + Future findJoinedConversationId(String userId) async => _joinedConversationId(userId).getSingleOrNull(); diff --git a/lib/main.dart b/lib/main.dart index e94447175a..29de4f3a96 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,7 +17,7 @@ import 'package:window_manager/window_manager.dart'; import 'package:window_size/window_size.dart'; import 'app.dart'; -import 'ui/home/home.dart'; +import 'ui/home/desktop_shell_layout.dart'; import 'ui/setting/log_page.dart'; import 'utils/app_lifecycle.dart'; import 'utils/event_bus.dart'; diff --git a/lib/ui/home/chat/chat_bar.dart b/lib/ui/home/chat/chat_bar.dart index ca13cf0db0..694c1c4a18 100644 --- a/lib/ui/home/chat/chat_bar.dart +++ b/lib/ui/home/chat/chat_bar.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -15,8 +17,9 @@ import '../../../widgets/high_light_text.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/window/move_window.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/major_navigation_provider.dart'; import '../../provider/message_selection_provider.dart'; -import '../../provider/responsive_navigator_provider.dart'; +import '../conversation_info_destination.dart'; import '../notifier/chat_side_notifier.dart'; class ChatBar extends HookConsumerWidget { @@ -26,9 +29,10 @@ class ChatBar extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final actionColor = context.theme.icon; final chatSideNotifier = context.read(); - final chatSideRouteMode = useValueListenable(chatSideNotifier).routeMode; + useValueListenable(chatSideNotifier); + final chatSideRouteMode = chatSideNotifier.isRouteMode; - final routeMode = ref.watch(navigatorRouteModeProvider); + final routeMode = ref.watch(majorNavigationRouteModeProvider); final conversation = ref.watch(conversationProvider); @@ -136,11 +140,11 @@ class ChatBar extends HookConsumerWidget { color: actionColor, onTap: () { final notifier = context.read(); - if (notifier.state.pages.lastOrNull?.name == - ChatSideNotifier.searchMessageHistory) { - return notifier.pop(); - } - notifier.replace(ChatSideNotifier.searchMessageHistory); + unawaited( + notifier.toggleDestination( + ConversationInfoDestination.searchMessageHistory, + ), + ); }, ), ), diff --git a/lib/ui/home/chat/chat_content_overlays.dart b/lib/ui/home/chat/chat_content_overlays.dart new file mode 100644 index 0000000000..3b06f1dbb6 --- /dev/null +++ b/lib/ui/home/chat/chat_content_overlays.dart @@ -0,0 +1,362 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:super_context_menu/super_context_menu.dart'; + +import '../../../account/scam_warning_key_value.dart'; +import '../../../account/show_pin_message_key_value.dart'; +import '../../../constants/resources.dart'; +import '../../../db/database_event_bus.dart'; +import '../../../db/mixin_database.dart' hide Offset; +import '../../../utils/extension/extension.dart'; +import '../../../utils/hook.dart'; +import '../../../widgets/action_button.dart'; +import '../../../widgets/animated_visibility.dart'; +import '../../../widgets/high_light_text.dart'; +import '../../../widgets/interactive_decorated_box.dart'; +import '../../../widgets/menu.dart'; +import '../../../widgets/message/message_bubble.dart'; +import '../../../widgets/pin_bubble.dart'; +import '../../provider/conversation_provider.dart'; +import '../conversation_info_destination.dart'; +import '../hook/pin_message.dart'; +import '../notifier/chat_side_notifier.dart'; +import 'chat_history_viewport.dart'; +import 'message_jump.dart'; + +class ChatContentOverlays extends StatelessWidget { + const ChatContentOverlays({super.key}); + + @override + Widget build(BuildContext context) => const Stack( + children: [ + RepaintBoundary(child: ChatHistoryViewport()), + Positioned(left: 6, right: 6, bottom: 6, child: _BottomBanner()), + Positioned( + bottom: 16, + right: 16, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [_JumpMentionButton(), JumpCurrentButton()], + ), + ), + _PinMessagesBanner(), + ], + ); +} + +class _BottomBanner extends HookConsumerWidget { + const _BottomBanner(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final (userId, isScam) = ref.watch( + conversationProvider.select( + (value) => (value?.userId, (value?.user?.isScam ?? 0) > 0), + ), + ); + + final showScamWarning = + useMemoizedStream( + () { + if (userId == null || !isScam) return Stream.value(false); + return ScamWarningKeyValue.instance.watch(userId); + }, + initialData: false, + keys: [userId], + ).data ?? + false; + + return AnimatedVisibility( + visible: showScamWarning, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + color: context.messageBubbleColor(false), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.15), + offset: Offset(0, 2), + blurRadius: 10, + ), + ], + ), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only( + left: 8, + right: 16, + top: 8, + bottom: 8, + ), + child: SvgPicture.asset( + Resources.assetsImagesTriangleWarningSvg, + colorFilter: ColorFilter.mode( + context.theme.red, + BlendMode.srcIn, + ), + width: 26, + height: 26, + ), + ), + Expanded( + child: Text( + context.l10n.scamWarning, + style: TextStyle(color: context.theme.text, fontSize: 14), + ), + ), + ActionButton( + name: Resources.assetsImagesIcCloseSvg, + color: context.theme.icon, + size: 20, + onTap: () { + if (userId == null) return; + ScamWarningKeyValue.instance.dismiss(userId); + }, + ), + ], + ), + ), + ); + } +} + +class _PinMessagesBanner extends HookConsumerWidget { + const _PinMessagesBanner(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentPinMessageIds = context.watchCurrentPinMessageIds; + final lastMessage = context.lastMessage; + + final showLastPinMessage = lastMessage?.isNotEmpty ?? false; + + return Positioned( + top: 12, + right: 16, + left: 10, + height: 64, + child: AnimatedVisibility( + visible: showLastPinMessage || currentPinMessageIds.isNotEmpty, + child: Row( + children: [ + Expanded( + child: AnimatedVisibility( + visible: showLastPinMessage, + child: PinMessageBubble( + child: Row( + children: [ + ActionButton( + name: Resources.assetsImagesIcCloseSvg, + color: context.theme.icon, + size: 20, + onTap: () { + final conversationId = ref.read( + currentConversationIdProvider, + ); + if (conversationId == null) return; + ShowPinMessageKeyValue.instance.dismiss( + conversationId, + ); + }, + ), + const SizedBox(width: 4), + Expanded( + child: InteractiveDecoratedBox( + cursor: SystemMouseCursors.click, + onTap: () { + final messageId = currentPinMessageIds.firstOrNull; + if (messageId == null) return; + unawaited(context.jumpToMessageInChat(messageId)); + }, + child: CustomText( + (lastMessage ?? '').overflow, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + AnimatedVisibility( + visible: currentPinMessageIds.isNotEmpty, + child: InteractiveDecoratedBox( + onTap: () { + final notifier = context.read(); + unawaited( + notifier.toggleDestination( + ConversationInfoDestination.pinMessages, + ), + ); + }, + child: Container( + height: 34, + width: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.messageBubbleColor(false), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.15), + offset: Offset(0, 2), + blurRadius: 10, + ), + ], + ), + alignment: Alignment.center, + child: SvgPicture.asset( + Resources.assetsImagesChatPinSvg, + width: 34, + height: 34, + colorFilter: ColorFilter.mode( + context.theme.text, + BlendMode.srcIn, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _JumpMentionButton extends HookConsumerWidget { + const _JumpMentionButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conversationId = ref.watch(currentConversationIdProvider); + final messageMentions = + useMemoizedStream(() { + if (conversationId == null) return Stream.value([]); + return context.database.messageMentionDao + .unreadMentionMessageByConversationId(conversationId) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateMessageMention( + conversationIds: [conversationId], + ), + ], + duration: kSlowThrottleDuration, + ); + }, keys: [conversationId]).data ?? + []; + + if (messageMentions.isEmpty) return const SizedBox(); + + return CustomContextMenuWidget( + hitTestBehavior: HitTestBehavior.translucent, + desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), + menuProvider: (request) => Menu( + children: [ + MenuAction( + title: context.l10n.clear, + callback: () { + for (final mention in messageMentions) { + context.accountServer.markMentionRead( + mention.messageId, + mention.conversationId, + ); + } + }, + ), + ], + ), + child: InteractiveDecoratedBox( + onTap: () async { + if (messageMentions.isEmpty) return; + + final mention = messageMentions.first; + await context.jumpToMessageInChat(mention.messageId); + await context.accountServer.markMentionRead( + mention.messageId, + mention.conversationId, + ); + }, + child: SizedBox( + height: 52, + width: 40, + child: Stack( + children: [ + Positioned( + top: 12, + child: Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: context.messageBubbleColor(false), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.15), + offset: Offset(0, 2), + blurRadius: 10, + ), + ], + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + '@', + style: TextStyle( + fontSize: 17, + height: 1, + color: context.theme.text, + ), + ), + ), + ), + Container( + width: 40, + alignment: Alignment.topCenter, + child: Container( + constraints: const BoxConstraints( + minWidth: 20, + maxWidth: 40, + maxHeight: 20, + minHeight: 20, + ), + decoration: BoxDecoration( + color: context.theme.accent, + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + children: [ + // Use column + spacer to vertical center the text. + // since the width is not fixed, we can't use center widget. + const Spacer(), + Text( + messageMentions.length > 99 + ? '99+' + : '${messageMentions.length}', + style: const TextStyle( + fontSize: 14, + height: 1, + color: Colors.white, + ), + textAlign: TextAlign.center, + maxLines: 1, + ), + const Spacer(), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/home/chat/chat_drop_overlay.dart b/lib/ui/home/chat/chat_drop_overlay.dart new file mode 100644 index 0000000000..b64896ee87 --- /dev/null +++ b/lib/ui/home/chat/chat_drop_overlay.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../../../utils/extension/extension.dart'; +import '../../../widgets/dash_path_border.dart'; +import 'files_preview.dart'; + +class ChatDropOverlay extends HookWidget { + const ChatDropOverlay({required this.child, required this.enable, super.key}); + + final Widget child; + final bool enable; + + @override + Widget build(BuildContext context) { + final dragging = useState(false); + final dialogEnabled = useState(true); + return DropTarget( + onDragEntered: (_) => dragging.value = true, + onDragExited: (_) => dragging.value = false, + onDragDone: (details) async { + final files = details.files.where((xFile) { + final file = File(xFile.path); + return file.existsSync(); + }).toList(); + if (files.isEmpty) return; + + dialogEnabled.value = false; + await showFilesPreviewDialog( + context, + files.map((file) => file.withMineType()).toList(), + ); + dialogEnabled.value = true; + }, + enable: enable && dialogEnabled.value, + child: Stack( + children: [child, if (dragging.value) const _ChatDragIndicator()], + ), + ); + } +} + +class _ChatDragIndicator extends StatelessWidget { + const _ChatDragIndicator(); + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration(color: context.theme.popUp), + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: context.theme.listSelected, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: DashPathBorder.all( + borderSide: BorderSide(color: context.theme.divider), + dashArray: CircularIntervalList([4, 4]), + ), + ), + child: Center( + child: Text( + context.l10n.dragAndDropFileHere, + style: TextStyle(fontSize: 14, color: context.theme.text), + ), + ), + ), + ); +} diff --git a/lib/ui/home/chat/chat_history_viewport.dart b/lib/ui/home/chat/chat_history_viewport.dart new file mode 100644 index 0000000000..e4b0024c2a --- /dev/null +++ b/lib/ui/home/chat/chat_history_viewport.dart @@ -0,0 +1,332 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../constants/resources.dart'; +import '../../../db/mixin_database.dart' hide Offset; +import '../../../utils/extension/extension.dart'; +import '../../../widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart'; +import '../../../widgets/interactive_decorated_box.dart'; +import '../../../widgets/message/message.dart'; +import '../../../widgets/message/message_bubble.dart'; +import '../../../widgets/message/message_day_time.dart'; +import '../../provider/pending_chat_jump_provider.dart'; +import '../notifier/message_controller.dart'; +import 'chat_scroll_coordinator.dart'; +import 'message_jump.dart'; + +@visibleForTesting +void syncMessageGlobalKeys( + Map keysByMessageId, + Set messageIds, +) { + keysByMessageId.removeWhere( + (messageId, _) => !messageIds.contains(messageId), + ); + for (final messageId in messageIds) { + keysByMessageId.putIfAbsent( + messageId, + () => MessageGlobalKey(messageId), + ); + } +} + +class ChatHistoryViewport extends HookConsumerWidget { + const ChatHistoryViewport({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messageController = context.read(); + final scrollCoordinator = context.read(); + final state = useValueListenable(messageController); + + final key = ValueKey((state.conversationId, state.refreshKey)); + final top = state.top; + final center = state.center; + final bottom = state.bottom; + final messages = state.list; + final rows = useMemoized( + () => _ChatHistoryRows.from(top: top, center: center, bottom: bottom), + [top, center, bottom], + ); + + final messageKeysRef = useRef>({}); + final dayTimeKeysRef = useRef>({}); + final previousConversationIdRef = useRef(null); + final previousRefreshKeyRef = useRef(null); + + final messageIds = messages.map((e) => e.messageId).toSet(); + final messageIdsKey = messages.map((e) => e.messageId).join('|'); + final resetScrollWindow = + previousConversationIdRef.value != state.conversationId || + previousRefreshKeyRef.value != state.refreshKey; + + if (!resetScrollWindow) { + scrollCoordinator.captureViewportState(messages, messageKeysRef.value); + } + + ref.listen(pendingChatJumpProvider, (previous, next) { + if (next == null || !next.isCommand) return; + scheduleMicrotask(() async { + if (!context.mounted) return; + final messageId = next.messageId; + if (messageId == null) { + await context.jumpToLatestInChat(); + } else { + await context.jumpToMessageInChat(messageId); + } + if (!context.mounted) return; + ref.read(pendingChatJumpProvider.notifier).state = null; + }); + }); + + useMemoized(() { + syncMessageGlobalKeys(messageKeysRef.value, messageIds); + syncMessageGlobalKeys(dayTimeKeysRef.value, messageIds); + }, [messageIdsKey]); + + final dayTimeEntries = useMemoized( + () => _dayTimeEntries(rows, messageKeysRef.value, dayTimeKeysRef.value), + [rows, messageIdsKey], + ); + + useEffect( + () { + scrollCoordinator.scheduleRestore( + messages: messages, + keysByMessageId: messageKeysRef.value, + reset: resetScrollWindow, + isLatest: state.isLatest, + centerMessageId: center?.messageId, + ); + previousConversationIdRef.value = state.conversationId; + previousRefreshKeyRef.value = state.refreshKey; + return null; + }, + [ + state.conversationId, + state.refreshKey, + messageIdsKey, + state.isLatest, + ], + ); + + return MessageDayTimeViewportWidget.chatPage( + key: key, + entries: dayTimeEntries, + scrollController: scrollCoordinator.scrollController, + child: NotificationListener( + onNotification: (notification) => + scrollCoordinator.handleScrollNotification( + notification, + messages: messages, + keysByMessageId: messageKeysRef.value, + loadBefore: messageController.before, + loadAfter: messageController.after, + ), + child: ClampingCustomScrollView( + key: scrollCoordinator.viewportKey, + center: key, + controller: scrollCoordinator.scrollController, + anchor: 0.3, + physics: const ClampingScrollPhysics(), + scrollCacheExtent: const ScrollCacheExtent.viewport( + ChatScrollCoordinator.loadedJumpViewportCount, + ), + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final actualIndex = top.length - index - 1; + final row = rows.top[actualIndex]; + final messageItem = row.message; + return ChatRenderedMessage( + coordinator: scrollCoordinator, + messageId: messageItem.messageId, + child: MessageItemWidget( + key: messageKeysRef.value[messageItem.messageId], + row: row, + message: messageItem, + lastReadMessageId: state.lastReadMessageId, + dateTimeKey: row.dateTime == null + ? null + : dayTimeKeysRef.value[messageItem.messageId], + ), + ); + }, childCount: top.length), + ), + SliverToBoxAdapter( + key: key, + child: Builder( + builder: (context) { + final row = rows.center; + if (row == null) return const SizedBox(); + final center = row.message; + return ChatRenderedMessage( + coordinator: scrollCoordinator, + messageId: center.messageId, + child: MessageItemWidget( + key: messageKeysRef.value[center.messageId], + row: row, + message: center, + lastReadMessageId: state.lastReadMessageId, + dateTimeKey: row.dateTime == null + ? null + : dayTimeKeysRef.value[center.messageId], + ), + ); + }, + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final row = rows.bottom[index]; + final messageItem = row.message; + return ChatRenderedMessage( + coordinator: scrollCoordinator, + messageId: messageItem.messageId, + child: MessageItemWidget( + key: messageKeysRef.value[messageItem.messageId], + row: row, + message: messageItem, + lastReadMessageId: state.lastReadMessageId, + dateTimeKey: row.dateTime == null + ? null + : dayTimeKeysRef.value[messageItem.messageId], + ), + ); + }, childCount: bottom.length), + ), + const SliverToBoxAdapter(child: SizedBox(height: 10)), + ], + ), + ), + ); + } +} + +List _dayTimeEntries( + _ChatHistoryRows rows, + Map messageKeys, + Map dayTimeKeys, +) { + Iterable mapRows( + Iterable rows, + ) => rows.map((row) { + final messageId = row.message.messageId; + return MessageDayTimeViewportEntry( + message: row.message, + messageKey: messageKeys[messageId]!, + dayTimeKey: row.dateTime == null ? null : dayTimeKeys[messageId], + ); + }); + + return [ + ...mapRows(rows.top), + if (rows.center != null) ...mapRows([rows.center!]), + ...mapRows(rows.bottom), + ]; +} + +class _ChatHistoryRows { + const _ChatHistoryRows({ + required this.top, + required this.bottom, + this.center, + }); + + factory _ChatHistoryRows.from({ + required List top, + required MessageItem? center, + required List bottom, + }) => _ChatHistoryRows( + top: [ + for (var index = 0; index < top.length; index++) + MessageRowModel( + message: top[index], + prev: top.getOrNull(index - 1), + next: top.getOrNull(index + 1) ?? center ?? bottom.lastOrNull, + ), + ], + center: center == null + ? null + : MessageRowModel( + message: center, + prev: top.lastOrNull, + next: bottom.firstOrNull, + ), + bottom: [ + for (var index = 0; index < bottom.length; index++) + MessageRowModel( + message: bottom[index], + prev: bottom.getOrNull(index - 1) ?? center ?? top.lastOrNull, + next: bottom.getOrNull(index + 1), + ), + ], + ); + + final List top; + final MessageRowModel? center; + final List bottom; +} + +class JumpCurrentButton extends HookConsumerWidget { + const JumpCurrentButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scrollCoordinator = context.read(); + + final state = useValueListenable(context.read()); + final showJumpToLatest = useValueListenable( + scrollCoordinator.showJumpToLatest, + ); + + final enable = (!state.isEmpty && !state.isLatest) || showJumpToLatest; + + final pendingJumpController = ref.read(pendingChatJumpProvider.notifier); + + if (!enable) { + Future(() => pendingJumpController.state = null); + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InteractiveDecoratedBox( + onTap: () async { + final messageId = pendingJumpController.state?.returnMessageId; + if (messageId != null) { + await context.jumpToMessageInChat(messageId); + pendingJumpController.state = null; + return; + } + await context.jumpToLatestInChat(); + }, + child: Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: context.messageBubbleColor(false), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.15), + offset: Offset(0, 2), + blurRadius: 10, + ), + ], + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: SvgPicture.asset( + Resources.assetsImagesJumpCurrentArrowSvg, + colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), + ), + ), + ), + ); + } +} diff --git a/lib/ui/home/chat/chat_menu_handler.dart b/lib/ui/home/chat/chat_menu_handler.dart new file mode 100644 index 0000000000..0a6739ecc9 --- /dev/null +++ b/lib/ui/home/chat/chat_menu_handler.dart @@ -0,0 +1,141 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../utils/extension/extension.dart'; +import '../../../widgets/conversation/mute_dialog.dart'; +import '../../../widgets/dialog.dart'; +import '../../../widgets/toast.dart'; +import '../../../widgets/window/menus.dart'; +import '../../provider/conversation_provider.dart'; +import '../conversation_info_destination.dart'; +import '../notifier/chat_side_notifier.dart'; + +class ChatMenuHandler extends HookConsumerWidget { + const ChatMenuHandler({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conversationId = ref.watch(currentConversationIdProvider); + + useEffect(() { + final menuBarNotifier = ref.read(macMenuBarProvider.notifier); + if (conversationId == null) return null; + + final handle = _ConversationHandle(context, conversationId); + Future(() => menuBarNotifier.attach(handle)); + return () => Future(() => menuBarNotifier.unAttach(handle)); + }, [conversationId]); + + return child; + } +} + +class _ConversationHandle extends ConversationMenuHandle { + _ConversationHandle(this.context, this.conversationId); + + final BuildContext context; + final String conversationId; + + @override + Future delete() async { + final name = + context.providerContainer.read(currentConversationNameProvider) ?? ''; + assert(name.isNotEmpty, 'name is empty'); + final ret = await showConfirmMixinDialog( + context, + context.l10n.conversationDeleteTitle(name), + description: context.l10n.deleteChatDescription, + ); + if (ret == null) return; + await context.accountServer.deleteMessagesByConversationId(conversationId); + await context.database.conversationDao.deleteConversation(conversationId); + if (context.providerContainer.read(currentConversationIdProvider) == + conversationId) { + context.providerContainer + .read(conversationProvider.notifier) + .unselected(); + } + } + + @override + Stream get isMuted => context.providerContainer + .read(conversationProvider.notifier) + .stream + .map((event) => event?.conversation?.isMute == true); + + @override + Stream get isPinned => context.providerContainer + .read(conversationProvider.notifier) + .stream + .map((event) => event?.conversation?.pinTime != null); + + @override + Future mute() async { + final result = await showMixinDialog( + context: context, + child: const MuteDialog(), + ); + if (result == null) return; + final conversationState = context.providerContainer.read( + conversationProvider, + ); + if (conversationState == null) return; + + final isGroupConversation = conversationState.isGroup == true; + await runFutureWithToast( + context.accountServer.muteConversation( + result, + conversationId: isGroupConversation ? conversationId : null, + userId: isGroupConversation + ? null + : conversationState.conversation?.ownerId, + ), + ); + } + + @override + void pin() { + runFutureWithToast(context.accountServer.pin(conversationId)); + } + + @override + void showSearch() { + final notifier = context.read(); + unawaited( + notifier.toggleDestination( + ConversationInfoDestination.searchMessageHistory, + ), + ); + } + + @override + void toggleSideBar() { + context.read().toggleInfoPage(); + } + + @override + void unPin() { + runFutureWithToast(context.accountServer.unpin(conversationId)); + } + + @override + void unmute() { + final conversationState = context.providerContainer.read( + conversationProvider, + ); + if (conversationState == null) return; + + final isGroup = conversationState.isGroup == true; + runFutureWithToast( + context.accountServer.unMuteConversation( + conversationId: isGroup ? conversationId : null, + userId: isGroup ? null : conversationState.conversation?.ownerId, + ), + ); + } +} diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 28e801489d..1bd2a000c5 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -1,54 +1,28 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider, Provider; import 'package:provider/provider.dart'; -import 'package:super_context_menu/super_context_menu.dart'; -import '../../../account/scam_warning_key_value.dart'; -import '../../../account/show_pin_message_key_value.dart'; import '../../../constants/resources.dart'; -import '../../../db/database_event_bus.dart'; -import '../../../db/mixin_database.dart' hide Offset; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; -import '../../../widgets/action_button.dart'; import '../../../widgets/actions/actions.dart'; -import '../../../widgets/animated_visibility.dart'; -import '../../../widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart'; -import '../../../widgets/conversation/mute_dialog.dart'; -import '../../../widgets/dash_path_border.dart'; -import '../../../widgets/dialog.dart'; -import '../../../widgets/high_light_text.dart'; -import '../../../widgets/interactive_decorated_box.dart'; -import '../../../widgets/menu.dart'; -import '../../../widgets/message/message.dart'; -import '../../../widgets/message/message_bubble.dart'; -import '../../../widgets/message/message_day_time.dart'; -import '../../../widgets/pin_bubble.dart'; -import '../../../widgets/toast.dart'; -import '../../../widgets/window/menus.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/message_selection_provider.dart'; -import '../../provider/pending_jump_message_provider.dart'; -import '../home.dart'; +import '../desktop_shell_layout.dart'; import '../hook/pin_message.dart'; import '../notifier/blink_notifier.dart'; import '../notifier/chat_side_notifier.dart'; import '../notifier/message_controller.dart'; import 'chat_bar.dart'; +import 'chat_content_overlays.dart'; +import 'chat_drop_overlay.dart'; +import 'chat_menu_handler.dart'; import 'chat_scroll_coordinator.dart'; -import 'files_preview.dart'; +import 'chat_side_router.dart'; import 'input_container.dart'; -import 'message_jump.dart'; import 'selection_bottom_bar.dart'; class ChatPage extends HookConsumerWidget { @@ -82,11 +56,11 @@ class ChatPage extends HookConsumerWidget { useEffect(() { if (initialSidePage != null) { - chatSideNotifier.pushPage(initialSidePage); + chatSideNotifier.openDestination(initialSidePage); } }, [initialSidePage, chatSideNotifier]); - final navigatorState = useValueListenable(chatSideNotifier); + useValueListenable(chatSideNotifier); ref.listen(hasSelectedMessageProvider, (previous, hasSelectedMessage) { if (!hasSelectedMessage) return; @@ -141,12 +115,12 @@ class ChatPage extends HookConsumerWidget { decoration: BoxDecoration(color: context.theme.primary), child: LayoutBuilder( builder: (context, boxConstraints) { - final routeMode = - boxConstraints.maxWidth < - (kResponsiveNavigationMinWidth + kChatSidePageWidth); + final routeMode = DesktopShellLayout.useChatSideRouteMode( + boxConstraints.maxWidth, + ); chatSideNotifier.updateRouteMode(routeMode); - return _ChatMenuHandler( + return ChatMenuHandler( child: Row( children: [ if (!routeMode) Expanded(child: chatContainerPage.child), @@ -162,16 +136,16 @@ class ChatPage extends HookConsumerWidget { onInvoke: (intent) => chatSideNotifier.pop(), ), }, - child: _SideRouter( + child: ChatSideRouter( constraints: boxConstraints, routeMode: routeMode, onDidRemovePage: (page) { chatSideNotifier.onPopPage(); }, - pages: [ + leadingPages: [ if (routeMode) chatContainerPage, - ...navigatorState.pages, ], + destinations: chatSideNotifier.state.destinations, ), ), ], @@ -184,91 +158,6 @@ class ChatPage extends HookConsumerWidget { } } -class _SideRouter extends StatelessWidget { - const _SideRouter({ - required this.pages, - required this.constraints, - required this.routeMode, - this.onDidRemovePage, - }); - - final List> pages; - - final DidRemovePageCallback? onDidRemovePage; - - final BoxConstraints constraints; - final bool routeMode; - - @override - Widget build(BuildContext context) => routeMode - ? SizedBox( - width: constraints.maxWidth, - child: Navigator(pages: pages, onDidRemovePage: onDidRemovePage), - ) - : _AnimatedChatSlide( - constraints: constraints, - pages: pages, - onDidRemovePage: onDidRemovePage, - ); -} - -class _AnimatedChatSlide extends HookConsumerWidget { - const _AnimatedChatSlide({ - required this.pages, - required this.constraints, - required this.onDidRemovePage, - }); - - final List> pages; - - final DidRemovePageCallback? onDidRemovePage; - - final BoxConstraints constraints; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useAnimationController( - duration: const Duration(milliseconds: 300), - ); - - final _pages = useState(>[]); - - useEffect(() { - if (pages.isNotEmpty) { - _pages.value = pages; - controller.forward(); - } else { - controller.reverse().whenComplete(() { - _pages.value = pages; - }); - } - }, [pages, controller]); - - return AnimatedBuilder( - animation: controller, - builder: (context, child) => SizedBox( - width: - kChatSidePageWidth * Curves.easeInOut.transform(controller.value), - height: constraints.maxHeight, - child: controller.value != 0 ? child : null, - ), - child: ClipRect( - child: OverflowBox( - alignment: AlignmentDirectional.centerStart, - maxHeight: constraints.maxHeight, - minHeight: constraints.maxHeight, - maxWidth: kChatSidePageWidth, - minWidth: kChatSidePageWidth, - child: Navigator( - pages: _pages.value, - onDidRemovePage: onDidRemovePage, - ), - ), - ), - ); - } -} - class ChatContainer extends HookConsumerWidget { const ChatContainer({super.key}); @@ -326,7 +215,7 @@ class ChatContainer extends HookConsumerWidget { onDidRemovePage: (page) {}, pages: [ MaterialPage( - child: _ChatDropOverlay( + child: ChatDropOverlay( enable: !inMultiSelectMode, child: Column( children: [ @@ -339,29 +228,7 @@ class ChatContainer extends HookConsumerWidget { ), ), ), - child: const Stack( - children: [ - RepaintBoundary(child: _List()), - Positioned( - left: 6, - right: 6, - bottom: 6, - child: _BottomBanner(), - ), - Positioned( - bottom: 16, - right: 16, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _JumpMentionButton(), - _JumpCurrentButton(), - ], - ), - ), - _PinMessagesBanner(), - ], - ), + child: const ChatContentOverlays(), ), ), AnimatedCrossFade( @@ -386,728 +253,3 @@ class ChatContainer extends HookConsumerWidget { ); } } - -@visibleForTesting -void syncMessageGlobalKeys( - Map keysByMessageId, - Set messageIds, -) { - keysByMessageId.removeWhere( - (messageId, _) => !messageIds.contains(messageId), - ); - for (final messageId in messageIds) { - keysByMessageId.putIfAbsent( - messageId, - () => MessageGlobalKey(messageId), - ); - } -} - -class _List extends HookConsumerWidget { - const _List(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final messageController = context.read(); - final scrollCoordinator = context.read(); - final state = useValueListenable(messageController); - - final key = ValueKey((state.conversationId, state.refreshKey)); - final top = state.top; - final center = state.center; - final bottom = state.bottom; - final messages = state.list; - - final messageKeysRef = useRef>({}); - final previousConversationIdRef = useRef(null); - final previousRefreshKeyRef = useRef(null); - - final messageIds = messages.map((e) => e.messageId).toSet(); - final messageIdsKey = messages.map((e) => e.messageId).join('|'); - final resetScrollWindow = - previousConversationIdRef.value != state.conversationId || - previousRefreshKeyRef.value != state.refreshKey; - - if (!resetScrollWindow) { - scrollCoordinator.captureViewportState(messages, messageKeysRef.value); - } - - ref.listen(pendingJumpLatestProvider, (previous, next) { - if (next == null) return; - scheduleMicrotask(() async { - if (!context.mounted) return; - await context.jumpToLatestInChat(); - if (!context.mounted) return; - ref.read(pendingJumpLatestProvider.notifier).state = null; - }); - }); - - useMemoized(() { - syncMessageGlobalKeys(messageKeysRef.value, messageIds); - }, [messageIdsKey]); - - useEffect( - () { - scrollCoordinator.scheduleRestore( - messages: messages, - keysByMessageId: messageKeysRef.value, - reset: resetScrollWindow, - isLatest: state.isLatest, - centerMessageId: center?.messageId, - ); - previousConversationIdRef.value = state.conversationId; - previousRefreshKeyRef.value = state.refreshKey; - return null; - }, - [ - state.conversationId, - state.refreshKey, - messageIdsKey, - state.isLatest, - ], - ); - - return MessageDayTimeViewportWidget.chatPage( - key: key, - topKey: scrollCoordinator.topSliverKey, - bottomKey: scrollCoordinator.bottomSliverKey, - center: center, - centerKey: center == null ? null : messageKeysRef.value[center.messageId], - scrollController: scrollCoordinator.scrollController, - child: NotificationListener( - onNotification: (notification) => - scrollCoordinator.handleScrollNotification( - notification, - messages: messages, - keysByMessageId: messageKeysRef.value, - loadBefore: messageController.before, - loadAfter: messageController.after, - ), - child: ClampingCustomScrollView( - key: scrollCoordinator.viewportKey, - center: key, - controller: scrollCoordinator.scrollController, - anchor: 0.3, - physics: const ClampingScrollPhysics(), - scrollCacheExtent: const ScrollCacheExtent.viewport( - ChatScrollCoordinator.loadedJumpViewportCount, - ), - slivers: [ - SliverList( - key: scrollCoordinator.topSliverKey, - delegate: SliverChildBuilderDelegate((context, index) { - final actualIndex = top.length - index - 1; - final messageItem = top[actualIndex]; - return MessageItemWidget( - key: messageKeysRef.value[messageItem.messageId], - prev: top.getOrNull(actualIndex - 1), - message: messageItem, - next: - top.getOrNull(actualIndex + 1) ?? - center ?? - bottom.lastOrNull, - lastReadMessageId: state.lastReadMessageId, - ); - }, childCount: top.length), - ), - SliverToBoxAdapter( - key: key, - child: Builder( - builder: (context) { - if (center == null) return const SizedBox(); - return MessageItemWidget( - key: messageKeysRef.value[center.messageId], - prev: top.lastOrNull, - message: center, - next: bottom.firstOrNull, - lastReadMessageId: state.lastReadMessageId, - ); - }, - ), - ), - SliverList( - key: scrollCoordinator.bottomSliverKey, - delegate: SliverChildBuilderDelegate((context, index) { - final messageItem = bottom[index]; - return MessageItemWidget( - key: messageKeysRef.value[messageItem.messageId], - prev: bottom.getOrNull(index - 1) ?? center ?? top.lastOrNull, - message: messageItem, - next: bottom.getOrNull(index + 1), - lastReadMessageId: state.lastReadMessageId, - ); - }, childCount: bottom.length), - ), - const SliverToBoxAdapter(child: SizedBox(height: 10)), - ], - ), - ), - ); - } -} - -class _JumpCurrentButton extends HookConsumerWidget { - const _JumpCurrentButton(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scrollCoordinator = context.read(); - - final state = useValueListenable(context.read()); - final showJumpToLatest = useValueListenable( - scrollCoordinator.showJumpToLatest, - ); - - final enable = (!state.isEmpty && !state.isLatest) || showJumpToLatest; - - final pendingJumpMessageController = ref.read( - pendingJumpMessageProvider.notifier, - ); - - if (!enable) { - Future(() => pendingJumpMessageController.state = null); - return const SizedBox(); - } - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: InteractiveDecoratedBox( - onTap: () async { - final messageId = pendingJumpMessageController.state; - if (messageId != null) { - await context.jumpToMessageInChat(messageId); - pendingJumpMessageController.state = null; - return; - } - await context.jumpToLatestInChat(); - }, - child: Container( - height: 40, - width: 40, - decoration: BoxDecoration( - color: context.messageBubbleColor(false), - boxShadow: const [ - BoxShadow( - color: Color.fromRGBO(0, 0, 0, 0.15), - offset: Offset(0, 2), - blurRadius: 10, - ), - ], - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: SvgPicture.asset( - Resources.assetsImagesJumpCurrentArrowSvg, - colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), - ), - ), - ), - ); - } -} - -class _BottomBanner extends HookConsumerWidget { - const _BottomBanner(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final (userId, isScam) = ref.watch( - conversationProvider.select( - (value) => (value?.userId, (value?.user?.isScam ?? 0) > 0), - ), - ); - - final showScamWarning = - useMemoizedStream( - () { - if (userId == null || !isScam) return Stream.value(false); - return ScamWarningKeyValue.instance.watch(userId); - }, - initialData: false, - keys: [userId], - ).data ?? - false; - - return AnimatedVisibility( - visible: showScamWarning, - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4)), - color: context.messageBubbleColor(false), - boxShadow: const [ - BoxShadow( - color: Color.fromRGBO(0, 0, 0, 0.15), - offset: Offset(0, 2), - blurRadius: 10, - ), - ], - ), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only( - left: 8, - right: 16, - top: 8, - bottom: 8, - ), - child: SvgPicture.asset( - Resources.assetsImagesTriangleWarningSvg, - colorFilter: ColorFilter.mode( - context.theme.red, - BlendMode.srcIn, - ), - width: 26, - height: 26, - ), - ), - Expanded( - child: Text( - context.l10n.scamWarning, - style: TextStyle(color: context.theme.text, fontSize: 14), - ), - ), - ActionButton( - name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, - size: 20, - onTap: () { - if (userId == null) return; - ScamWarningKeyValue.instance.dismiss(userId); - }, - ), - ], - ), - ), - ); - } -} - -class _PinMessagesBanner extends HookConsumerWidget { - const _PinMessagesBanner(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final currentPinMessageIds = context.watchCurrentPinMessageIds; - final lastMessage = context.lastMessage; - - final showLastPinMessage = lastMessage?.isNotEmpty ?? false; - - return Positioned( - top: 12, - right: 16, - left: 10, - height: 64, - child: AnimatedVisibility( - visible: showLastPinMessage || currentPinMessageIds.isNotEmpty, - child: Row( - children: [ - Expanded( - child: AnimatedVisibility( - visible: showLastPinMessage, - child: PinMessageBubble( - child: Row( - children: [ - ActionButton( - name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, - size: 20, - onTap: () { - final conversationId = ref.read( - currentConversationIdProvider, - ); - if (conversationId == null) return; - ShowPinMessageKeyValue.instance.dismiss( - conversationId, - ); - }, - ), - const SizedBox(width: 4), - Expanded( - child: InteractiveDecoratedBox( - cursor: SystemMouseCursors.click, - onTap: () { - final messageId = currentPinMessageIds.firstOrNull; - if (messageId == null) return; - unawaited(context.jumpToMessageInChat(messageId)); - }, - child: CustomText( - (lastMessage ?? '').overflow, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 8), - AnimatedVisibility( - visible: currentPinMessageIds.isNotEmpty, - child: InteractiveDecoratedBox( - onTap: () { - final notifier = context.read(); - if (notifier.state.pages.lastOrNull?.name == - ChatSideNotifier.pinMessages) { - return notifier.pop(); - } - - notifier.replace(ChatSideNotifier.pinMessages); - }, - child: Container( - height: 34, - width: 34, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: context.messageBubbleColor(false), - boxShadow: const [ - BoxShadow( - color: Color.fromRGBO(0, 0, 0, 0.15), - offset: Offset(0, 2), - blurRadius: 10, - ), - ], - ), - alignment: Alignment.center, - child: SvgPicture.asset( - Resources.assetsImagesChatPinSvg, - width: 34, - height: 34, - colorFilter: ColorFilter.mode( - context.theme.text, - BlendMode.srcIn, - ), - ), - ), - ), - ), - ], - ), - ), - ); - } -} - -class _JumpMentionButton extends HookConsumerWidget { - const _JumpMentionButton(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final conversationId = ref.watch(currentConversationIdProvider); - final messageMentions = - useMemoizedStream(() { - if (conversationId == null) return Stream.value([]); - return context.database.messageMentionDao - .unreadMentionMessageByConversationId(conversationId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateMessageMention( - conversationIds: [conversationId], - ), - ], - duration: kSlowThrottleDuration, - ); - }, keys: [conversationId]).data ?? - []; - - if (messageMentions.isEmpty) return const SizedBox(); - - return CustomContextMenuWidget( - hitTestBehavior: HitTestBehavior.translucent, - desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), - menuProvider: (request) => Menu( - children: [ - MenuAction( - title: context.l10n.clear, - callback: () { - for (final mention in messageMentions) { - context.accountServer.markMentionRead( - mention.messageId, - mention.conversationId, - ); - } - }, - ), - ], - ), - child: InteractiveDecoratedBox( - onTap: () async { - if (messageMentions.isEmpty) return; - - final mention = messageMentions.first; - await context.jumpToMessageInChat(mention.messageId); - await context.accountServer.markMentionRead( - mention.messageId, - mention.conversationId, - ); - }, - child: SizedBox( - height: 52, - width: 40, - child: Stack( - children: [ - Positioned( - top: 12, - child: Container( - height: 40, - width: 40, - decoration: BoxDecoration( - color: context.messageBubbleColor(false), - boxShadow: const [ - BoxShadow( - color: Color.fromRGBO(0, 0, 0, 0.15), - offset: Offset(0, 2), - blurRadius: 10, - ), - ], - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - '@', - style: TextStyle( - fontSize: 17, - height: 1, - color: context.theme.text, - ), - ), - ), - ), - Container( - width: 40, - alignment: Alignment.topCenter, - child: Container( - constraints: const BoxConstraints( - minWidth: 20, - maxWidth: 40, - maxHeight: 20, - minHeight: 20, - ), - decoration: BoxDecoration( - color: context.theme.accent, - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Column( - children: [ - // Use column + spacer to vertical center the text. - // since the width is not fixed, we can't use center widget. - const Spacer(), - Text( - messageMentions.length > 99 - ? '99+' - : '${messageMentions.length}', - style: const TextStyle( - fontSize: 14, - height: 1, - color: Colors.white, - ), - textAlign: TextAlign.center, - maxLines: 1, - ), - const Spacer(), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -class _ChatDropOverlay extends HookConsumerWidget { - const _ChatDropOverlay({required this.child, required this.enable}); - - final Widget child; - - final bool enable; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final dragging = useState(false); - final enable = useState(true); - return DropTarget( - onDragEntered: (_) => dragging.value = true, - onDragExited: (_) => dragging.value = false, - onDragDone: (details) async { - final files = details.files.where((xFile) { - final file = File(xFile.path); - return file.existsSync(); - }).toList(); - if (files.isEmpty) { - return; - } - enable.value = false; - await showFilesPreviewDialog( - context, - files.map((e) => e.withMineType()).toList(), - ); - enable.value = true; - }, - enable: this.enable && enable.value, - child: Stack( - children: [child, if (dragging.value) const _ChatDragIndicator()], - ), - ); - } -} - -class _ChatDragIndicator extends StatelessWidget { - const _ChatDragIndicator(); - - @override - Widget build(BuildContext context) => DecoratedBox( - decoration: BoxDecoration(color: context.theme.popUp), - child: Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: context.theme.listSelected, - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: DashPathBorder.all( - borderSide: BorderSide(color: context.theme.divider), - dashArray: CircularIntervalList([4, 4]), - ), - ), - child: Center( - child: Text( - context.l10n.dragAndDropFileHere, - style: TextStyle(fontSize: 14, color: context.theme.text), - ), - ), - ), - ); -} - -class _ChatMenuHandler extends HookConsumerWidget { - const _ChatMenuHandler({required this.child}); - - final Widget child; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final conversationId = ref.watch(currentConversationIdProvider); - - useEffect(() { - final menuBarNotifier = ref.read(macMenuBarProvider.notifier); - if (conversationId == null) return null; - - final handle = _ConversationHandle(context, conversationId); - Future(() => menuBarNotifier.attach(handle)); - return () => Future(() => menuBarNotifier.unAttach(handle)); - }, [conversationId]); - - return child; - } -} - -class _ConversationHandle extends ConversationMenuHandle { - _ConversationHandle(this.context, this.conversationId); - - final BuildContext context; - final String conversationId; - - @override - Future delete() async { - final name = - context.providerContainer.read(currentConversationNameProvider) ?? ''; - assert(name.isNotEmpty, 'name is empty'); - final ret = await showConfirmMixinDialog( - context, - context.l10n.conversationDeleteTitle(name), - description: context.l10n.deleteChatDescription, - ); - if (ret == null) return; - await context.accountServer.deleteMessagesByConversationId(conversationId); - await context.database.conversationDao.deleteConversation(conversationId); - if (context.providerContainer.read(currentConversationIdProvider) == - conversationId) { - context.providerContainer - .read(conversationProvider.notifier) - .unselected(); - } - } - - @override - Stream get isMuted => context.providerContainer - .read(conversationProvider.notifier) - .stream - .map((event) => event?.conversation?.isMute == true); - - @override - Stream get isPinned => context.providerContainer - .read(conversationProvider.notifier) - .stream - .map((event) => event?.conversation?.pinTime != null); - - @override - Future mute() async { - final result = await showMixinDialog( - context: context, - child: const MuteDialog(), - ); - if (result == null) return; - final conversationState = context.providerContainer.read( - conversationProvider, - ); - if (conversationState == null) return; - - final isGroupConversation = conversationState.isGroup == true; - await runFutureWithToast( - context.accountServer.muteConversation( - result, - conversationId: isGroupConversation ? conversationId : null, - userId: isGroupConversation - ? null - : conversationState.conversation?.ownerId, - ), - ); - } - - @override - void pin() { - runFutureWithToast(context.accountServer.pin(conversationId)); - } - - @override - void showSearch() { - final notifier = context.read(); - if (notifier.state.pages.lastOrNull?.name == - ChatSideNotifier.searchMessageHistory) { - return notifier.pop(); - } - - notifier.replace(ChatSideNotifier.searchMessageHistory); - } - - @override - void toggleSideBar() { - context.read().toggleInfoPage(); - } - - @override - void unPin() { - runFutureWithToast(context.accountServer.unpin(conversationId)); - } - - @override - void unmute() { - final conversationState = context.providerContainer.read( - conversationProvider, - ); - if (conversationState == null) return; - - final isGroup = conversationState.isGroup == true; - runFutureWithToast( - context.accountServer.unMuteConversation( - conversationId: isGroup ? conversationId : null, - userId: isGroup ? null : conversationState.conversation?.ownerId, - ), - ); - } -} diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index 8437dd6fdb..c95615566f 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -14,6 +14,50 @@ class MessageGlobalKey extends GlobalObjectKey> { String get messageId => value as String; } +class ChatRenderedMessage extends StatefulWidget { + const ChatRenderedMessage({ + required this.coordinator, + required this.messageId, + required this.child, + super.key, + }); + + final ChatScrollCoordinator coordinator; + final String messageId; + final Widget child; + + @override + State createState() => _ChatRenderedMessageState(); +} + +class _ChatRenderedMessageState extends State { + @override + void initState() { + super.initState(); + widget.coordinator.registerRenderedMessageId(widget.messageId); + } + + @override + void didUpdateWidget(covariant ChatRenderedMessage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.messageId == widget.messageId && + oldWidget.coordinator == widget.coordinator) { + return; + } + oldWidget.coordinator.unregisterRenderedMessageId(oldWidget.messageId); + widget.coordinator.registerRenderedMessageId(widget.messageId); + } + + @override + void dispose() { + widget.coordinator.unregisterRenderedMessageId(widget.messageId); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} + class ChatScrollCoordinator { ChatScrollCoordinator() { scrollController.addListener(_scheduleScrollPositionUpdate); @@ -30,8 +74,6 @@ class ChatScrollCoordinator { final scrollController = ScrollController(); final viewportKey = GlobalKey(debugLabel: 'chat scroll viewport'); - final topSliverKey = GlobalKey(debugLabel: 'chat top sliver'); - final bottomSliverKey = GlobalKey(debugLabel: 'chat bottom sliver'); final visibleDateTime = ValueNotifier(null); final showJumpToLatest = ValueNotifier(false); @@ -40,7 +82,7 @@ class ChatScrollCoordinator { List _messages = const []; Map _messagesById = const {}; Map _keysByMessageId = const {}; - String? _centerMessageId; + final _renderedMessageIds = {}; bool _pinnedToBottom = true; bool _animateNextRestore = false; ChatScrollRestoreDirection? _animatedRestoreDirection; @@ -84,6 +126,14 @@ class ChatScrollCoordinator { _animatedRestoreDirection = direction; } + void registerRenderedMessageId(String messageId) { + _renderedMessageIds.add(messageId); + } + + void unregisterRenderedMessageId(String messageId) { + _renderedMessageIds.remove(messageId); + } + void scheduleRestore({ required List messages, required Map keysByMessageId, @@ -100,12 +150,7 @@ class ChatScrollCoordinator { _animatedRestoreDirection = null; _animateNextRestore = false; } - _setMessages( - messages, - keysByMessageId, - updateCenterMessageId: true, - centerMessageId: centerMessageId, - ); + _setMessages(messages, keysByMessageId); _restoreRequest = _ChatRestoreRequest( messages: messages, keysByMessageId: keysByMessageId, @@ -505,7 +550,7 @@ class ChatScrollCoordinator { String? firstVisibleMessageId; double? firstVisibleTop; - for (final messageId in _renderedMessageIds()) { + for (final messageId in _currentRenderedMessageIds()) { final geometry = _messageTargetGeometry(messageId, _keysByMessageId); if (geometry == null || geometry.bottom <= 0) continue; if (firstVisibleTop == null || geometry.top < firstVisibleTop) { @@ -555,50 +600,24 @@ class ChatScrollCoordinator { void _setMessages( List messages, - Map keysByMessageId, { - bool updateCenterMessageId = false, - String? centerMessageId, - }) { + Map keysByMessageId, + ) { if (!identical(_messages, messages)) { _messages = messages; _messagesById = { for (final message in messages) message.messageId: message, }; + _renderedMessageIds.removeWhere((id) => !_messagesById.containsKey(id)); } _keysByMessageId = keysByMessageId; - if (updateCenterMessageId) { - _centerMessageId = centerMessageId; - } } - Iterable _renderedMessageIds() sync* { - yield* _messageIdsInSliver(topSliverKey); - final centerMessageId = _centerMessageId; - if (centerMessageId != null) yield centerMessageId; - yield* _messageIdsInSliver(bottomSliverKey); - } - - Iterable _messageIdsInSliver(GlobalKey sliverKey) sync* { - final context = sliverKey.currentContext; - if (context is! Element) return; - - final messageIds = []; - context.visitChildElements((element) { - final key = _messageGlobalKeyInSubtree(element); - if (key != null) messageIds.add(key.messageId); - }); - yield* messageIds; - } - - MessageGlobalKey? _messageGlobalKeyInSubtree(Element element) { - final key = element.widget.key; - if (key is MessageGlobalKey) return key; - - MessageGlobalKey? result; - element.visitChildElements((child) { - result ??= _messageGlobalKeyInSubtree(child); - }); - return result; + Iterable _currentRenderedMessageIds() sync* { + for (final messageId in _renderedMessageIds) { + if (_keysByMessageId[messageId]?.currentContext != null) { + yield messageId; + } + } } RenderBox? _messageRender( diff --git a/lib/ui/home/chat/chat_side_router.dart b/lib/ui/home/chat/chat_side_router.dart new file mode 100644 index 0000000000..59e7d852ed --- /dev/null +++ b/lib/ui/home/chat/chat_side_router.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../provider/conversation_provider.dart'; +import '../chat_slide_page/chat_info_page.dart'; +import '../chat_slide_page/circle_manager_page.dart'; +import '../chat_slide_page/disappear_message_page.dart'; +import '../chat_slide_page/group_participants_page.dart'; +import '../chat_slide_page/groups_in_common_page.dart'; +import '../chat_slide_page/pin_messages_page.dart'; +import '../chat_slide_page/search_message_page.dart'; +import '../chat_slide_page/shared_apps_page.dart'; +import '../chat_slide_page/shared_media_page.dart'; +import '../conversation_info_destination.dart'; +import '../desktop_shell_layout.dart'; + +class ChatSideRouter extends StatelessWidget { + const ChatSideRouter({ + required this.destinations, + required this.constraints, + required this.routeMode, + super.key, + this.leadingPages = const [], + this.onDidRemovePage, + }); + + final List destinations; + final List> leadingPages; + final DidRemovePageCallback? onDidRemovePage; + final BoxConstraints constraints; + final bool routeMode; + + @override + Widget build(BuildContext context) { + final pages = [ + ...leadingPages, + ...destinations.map(_pageFor), + ]; + return routeMode + ? SizedBox( + width: constraints.maxWidth, + child: Navigator(pages: pages, onDidRemovePage: onDidRemovePage), + ) + : _AnimatedChatSlide( + constraints: constraints, + pages: pages, + onDidRemovePage: onDidRemovePage, + ); + } +} + +MaterialPage _pageFor(ConversationInfoDestination destination) { + switch (destination) { + case ConversationInfoDestination.infoPage: + return const MaterialPage( + key: ValueKey(ConversationInfoDestination.infoPage), + name: 'infoPage', + child: _ChatSidePageBuilder(ChatInfoPage.new), + ); + case ConversationInfoDestination.circles: + return const MaterialPage( + key: ValueKey(ConversationInfoDestination.circles), + name: 'circles', + child: _ChatSidePageBuilder(CircleManagerPage.new), + ); + case ConversationInfoDestination.searchMessageHistory: + return const MaterialPage( + key: ValueKey(ConversationInfoDestination.searchMessageHistory), + name: 'searchMessageHistory', + child: _ChatSidePageBuilder(SearchMessagePage.new), + ); + case ConversationInfoDestination.sharedMedia: + return const MaterialPage( + key: ValueKey(ConversationInfoDestination.sharedMedia), + name: 'sharedMedia', + child: _ChatSidePageBuilder(SharedMediaPage.new), + ); + case ConversationInfoDestination.participants: + return const MaterialPage( + key: ValueKey(ConversationInfoDestination.participants), + name: 'participants', + child: _ChatSidePageBuilder(GroupParticipantsPage.new), + ); + case ConversationInfoDestination.pinMessages: + return const MaterialPage( + key: ValueKey(ConversationInfoDestination.pinMessages), + name: 'pinMessages', + child: _ChatSidePageBuilder(PinMessagesPage.new), + ); + case ConversationInfoDestination.sharedApps: + return const MaterialPage( + key: ValueKey(ConversationInfoDestination.sharedApps), + name: 'sharedApps', + child: _ChatSidePageBuilder(SharedAppsPage.new), + ); + case ConversationInfoDestination.groupsInCommon: + return const MaterialPage( + key: ValueKey(ConversationInfoDestination.groupsInCommon), + name: 'groupsInCommon', + child: _ChatSidePageBuilder(GroupsInCommonPage.new), + ); + case ConversationInfoDestination.disappearMessages: + return const MaterialPage( + key: ValueKey(ConversationInfoDestination.disappearMessages), + name: 'disappearMessages', + child: _ChatSidePageBuilder(DisappearMessagePage.new), + ); + } +} + +class _ChatSidePageBuilder extends HookConsumerWidget { + const _ChatSidePageBuilder(this.builder); + + final Widget Function(ConversationState conversationState) builder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conversationId = useMemoized( + () => ref.read(lastConversationIdProvider), + [], + ); + + final filter = useCallback( + (state) => state?.conversationId == conversationId, + [conversationId], + ); + + final conversationState = ref.watch(filterLastConversationProvider(filter)); + + if (conversationId == null || conversationState == null) { + return const SizedBox(); + } + + return builder(conversationState); + } +} + +class _AnimatedChatSlide extends HookWidget { + const _AnimatedChatSlide({ + required this.pages, + required this.constraints, + required this.onDidRemovePage, + }); + + final List> pages; + final DidRemovePageCallback? onDidRemovePage; + final BoxConstraints constraints; + + @override + Widget build(BuildContext context) { + final controller = useAnimationController( + duration: const Duration(milliseconds: 300), + ); + + final displayedPages = useState(>[]); + + useEffect(() { + if (pages.isNotEmpty) { + displayedPages.value = pages; + controller.forward(); + } else { + controller.reverse().whenComplete(() { + displayedPages.value = pages; + }); + } + return null; + }, [pages, controller]); + + return AnimatedBuilder( + animation: controller, + builder: (context, child) => SizedBox( + width: + kChatSidePageWidth * Curves.easeInOut.transform(controller.value), + height: constraints.maxHeight, + child: controller.value != 0 ? child : null, + ), + child: ClipRect( + child: OverflowBox( + alignment: AlignmentDirectional.centerStart, + maxHeight: constraints.maxHeight, + minHeight: constraints.maxHeight, + maxWidth: kChatSidePageWidth, + minWidth: kChatSidePageWidth, + child: Navigator( + pages: displayedPages.value, + onDidRemovePage: onDidRemovePage, + ), + ), + ), + ); + } +} diff --git a/lib/ui/home/chat/input_container.dart b/lib/ui/home/chat/input_container.dart index b817950683..fff4534858 100644 --- a/lib/ui/home/chat/input_container.dart +++ b/lib/ui/home/chat/input_container.dart @@ -174,7 +174,7 @@ class _InputContainer extends HookConsumerWidget { final chatSidePageSize = useValueListenable( context.read(), - ).pages.length; + ).destinations.length; useEffect(() { if (!context.textFieldAutoGainFocus) { // Make sure on iPad/Android Pad the text field not get focused when the chat side diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index 1b96473f20..c7c4fb87c4 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -1,9 +1,8 @@ -import 'dart:async'; - import 'package:flutter/widgets.dart'; import '../../../utils/extension/extension.dart'; import '../notifier/blink_notifier.dart'; +import '../notifier/chat_side_notifier.dart'; import '../notifier/message_controller.dart'; import 'chat_jump_trace.dart'; import 'chat_scroll_coordinator.dart'; @@ -11,8 +10,69 @@ import 'chat_scroll_coordinator.dart'; bool shouldUseLoadedLatestWindowForLatestJump(MessageState state) => state.isLatest; -extension ChatMessageJump on BuildContext { - Future jumpToMessageInChat( +enum ChatJumpKind { message, latest } + +class ChatJumpTarget { + const ChatJumpTarget.message( + this.messageId, { + this.sourceMessageId, + this.closeSideAfterJump = false, + }) : kind = ChatJumpKind.message; + + const ChatJumpTarget.latest({this.closeSideAfterJump = false}) + : kind = ChatJumpKind.latest, + messageId = null, + sourceMessageId = null; + + final ChatJumpKind kind; + final String? messageId; + final String? sourceMessageId; + final bool closeSideAfterJump; +} + +class ChatHistoryLocation { + ChatHistoryLocation({ + required BlinkNotifier blinkNotifier, + required ChatScrollCoordinator scrollCoordinator, + required MessageController messageController, + required ChatSideNotifier chatSideNotifier, + required Future Function({ + required String? sourceMessageId, + required String targetMessageId, + }) + restoreDirectionFromSource, + }) : _blinkNotifier = blinkNotifier, + _scrollCoordinator = scrollCoordinator, + _messageController = messageController, + _chatSideNotifier = chatSideNotifier, + _restoreDirectionFromSource = restoreDirectionFromSource; + + final BlinkNotifier _blinkNotifier; + final ChatScrollCoordinator _scrollCoordinator; + final MessageController _messageController; + final ChatSideNotifier _chatSideNotifier; + final Future Function({ + required String? sourceMessageId, + required String targetMessageId, + }) + _restoreDirectionFromSource; + + Future jumpTo(ChatJumpTarget target) async { + switch (target.kind) { + case ChatJumpKind.message: + await _jumpToMessage( + target.messageId!, + sourceMessageId: target.sourceMessageId, + ); + case ChatJumpKind.latest: + await _jumpToLatest(); + } + if (target.closeSideAfterJump) { + _chatSideNotifier.closeAfterContentJump(); + } + } + + Future _jumpToMessage( String messageId, { String? sourceMessageId, }) async { @@ -20,11 +80,9 @@ extension ChatMessageJump on BuildContext { 'request source=${shortMessageId(sourceMessageId)} ' 'target=${shortMessageId(messageId)}', ); - read().blinkByMessageId(messageId); + _blinkNotifier.blinkByMessageId(messageId); - final scrollCoordinator = read(); - final messageController = read(); - final handled = await scrollCoordinator.scrollToMessageIfInLoadedWindow( + final handled = await _scrollCoordinator.scrollToMessageIfInLoadedWindow( messageId, animated: true, ); @@ -39,7 +97,7 @@ extension ChatMessageJump on BuildContext { traceChatJump('reload-window target=${shortMessageId(messageId)}'); final directionSourceMessageId = sourceMessageId ?? - _currentWindowSourceMessageId(messageController.state); + _currentWindowSourceMessageId(_messageController.state); final direction = await _restoreDirectionFromSource( sourceMessageId: directionSourceMessageId, targetMessageId: messageId, @@ -48,19 +106,17 @@ extension ChatMessageJump on BuildContext { 'restore direction source=${shortMessageId(directionSourceMessageId)} ' 'target=${shortMessageId(messageId)} direction=$direction', ); - scrollCoordinator.animateNextMessageRestore( + _scrollCoordinator.animateNextMessageRestore( messageId, direction: direction, ); - messageController.scrollTo(messageId); + _messageController.scrollTo(messageId); } - Future jumpToLatestInChat() async { + Future _jumpToLatest() async { traceChatJump('request latest'); - final scrollCoordinator = read(); - final messageController = read(); - if (shouldUseLoadedLatestWindowForLatestJump(messageController.state) && - await scrollCoordinator.scrollToBottomIfInLoadedWindow( + if (shouldUseLoadedLatestWindowForLatestJump(_messageController.state) && + await _scrollCoordinator.scrollToBottomIfInLoadedWindow( animated: true, )) { traceChatJump('loaded-window latest handled=true'); @@ -68,21 +124,46 @@ extension ChatMessageJump on BuildContext { } traceChatJump('reload-window latest'); - scrollCoordinator.animateNextRestore( + _scrollCoordinator.animateNextRestore( direction: ChatScrollRestoreDirection.towardNewer, ); - messageController.jumpToLatestWindow(); - } - - void popChatSideRouteIfNeeded() { - if (ModalRoute.of(this)?.canPop != true) return; - unawaited(Navigator.maybePop(this)); + _messageController.jumpToLatestWindow(); } String? _currentWindowSourceMessageId(MessageState state) { if (state.center != null) return state.center!.messageId; return state.bottomMessage?.messageId ?? state.topMessage?.messageId; } +} + +extension ChatMessageJump on BuildContext { + Future jumpToChatTarget(ChatJumpTarget target) => + _chatHistoryLocation.jumpTo(target); + + Future jumpToMessageInChat( + String messageId, { + String? sourceMessageId, + bool closeSideAfterJump = false, + }) => jumpToChatTarget( + ChatJumpTarget.message( + messageId, + sourceMessageId: sourceMessageId, + closeSideAfterJump: closeSideAfterJump, + ), + ); + + Future jumpToLatestInChat({bool closeSideAfterJump = false}) => + jumpToChatTarget( + ChatJumpTarget.latest(closeSideAfterJump: closeSideAfterJump), + ); + + ChatHistoryLocation get _chatHistoryLocation => ChatHistoryLocation( + blinkNotifier: read(), + scrollCoordinator: read(), + messageController: read(), + chatSideNotifier: read(), + restoreDirectionFromSource: _restoreDirectionFromSource, + ); Future _restoreDirectionFromSource({ required String? sourceMessageId, diff --git a/lib/ui/home/chat_slide_page/chat_info_page.dart b/lib/ui/home/chat_slide_page/chat_info_page.dart index e602f82fef..db9108e274 100644 --- a/lib/ui/home/chat_slide_page/chat_info_page.dart +++ b/lib/ui/home/chat_slide_page/chat_info_page.dart @@ -22,6 +22,8 @@ import '../../../widgets/user/user_dialog.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; import '../../provider/conversation_provider.dart'; import '../chat/chat_bar.dart'; +import '../conversation/conversation_focus.dart'; +import '../conversation_info_destination.dart'; import '../notifier/chat_side_notifier.dart'; import '../notifier/message_controller.dart'; import 'shared_apps_page.dart'; @@ -95,7 +97,7 @@ class ChatInfoPage extends HookConsumerWidget { ActionButton( name: Resources.assetsImagesIcCloseSvg, color: context.theme.icon, - onTap: () => context.read().onPopPage(), + onTap: () => context.read().closeDestination(), ), ], backgroundColor: context.theme.popUp, @@ -150,8 +152,8 @@ class ChatInfoPage extends HookConsumerWidget { CellGroup( child: CellItem( title: Text(context.l10n.groupParticipants), - onTap: () => context.read().pushPage( - ChatSideNotifier.participants, + onTap: () => context.read().openDestination( + ConversationInfoDestination.participants, ), ), ), @@ -227,17 +229,19 @@ class ChatInfoPage extends HookConsumerWidget { children: [ CellItem( title: Text(context.l10n.sharedMedia), - onTap: () => context.read().pushPage( - ChatSideNotifier.sharedMedia, - ), + onTap: () => + context.read().openDestination( + ConversationInfoDestination.sharedMedia, + ), ), if (conversationState.userId != null) _SharedApps(userId: conversationState.userId!), CellItem( title: Text(context.l10n.searchConversation, maxLines: 1), - onTap: () => context.read().pushPage( - ChatSideNotifier.searchMessageHistory, - ), + onTap: () => + context.read().openDestination( + ConversationInfoDestination.searchMessageHistory, + ), ), ], ), @@ -258,8 +262,8 @@ class ChatInfoPage extends HookConsumerWidget { trailing: canModifyExpireIn ? const Arrow() : null, onTap: !canModifyExpireIn ? null - : () => context.read().pushPage( - ChatSideNotifier.disappearMessages, + : () => context.read().openDestination( + ConversationInfoDestination.disappearMessages, ), ), ), @@ -392,8 +396,8 @@ class ChatInfoPage extends HookConsumerWidget { CellGroup( child: CellItem( title: Text(context.l10n.groupsInCommon), - onTap: () => context.read().pushPage( - ChatSideNotifier.groupsInCommon, + onTap: () => context.read().openDestination( + ConversationInfoDestination.groupsInCommon, ), ), ), @@ -411,8 +415,8 @@ class ChatInfoPage extends HookConsumerWidget { CellGroup( child: CellItem( title: Text(context.l10n.editConversations), - onTap: () => context.read().pushPage( - ChatSideNotifier.circles, + onTap: () => context.read().openDestination( + ConversationInfoDestination.circles, ), ), ), @@ -510,7 +514,7 @@ class ChatInfoPage extends HookConsumerWidget { accountServer.exitGroup(conversationId), ); - await ConversationStateNotifier.selectConversation( + await ConversationFocus.selectConversation( context, conversationId, ); @@ -711,8 +715,8 @@ class _SharedApps extends HookConsumerWidget { : CellItem( title: Text(context.l10n.shareApps), trailing: OverlappedAppIcons(apps: data), - onTap: () => context.read().pushPage( - ChatSideNotifier.sharedApps, + onTap: () => context.read().openDestination( + ConversationInfoDestination.sharedApps, ), ), ); diff --git a/lib/ui/home/chat_slide_page/group_participants_page.dart b/lib/ui/home/chat_slide_page/group_participants_page.dart index cfd35d9e79..e35921bb2f 100644 --- a/lib/ui/home/chat_slide_page/group_participants_page.dart +++ b/lib/ui/home/chat_slide_page/group_participants_page.dart @@ -22,6 +22,7 @@ import '../../../widgets/toast.dart'; import '../../../widgets/user/user_dialog.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; import '../../provider/conversation_provider.dart'; +import '../conversation/conversation_focus.dart'; import 'group_invite/group_invite_dialog.dart'; /// The participants of group. @@ -257,7 +258,7 @@ class _ParticipantMenuEntry extends HookConsumerWidget { participant.fullName ?? '?', ), callback: () { - ConversationStateNotifier.selectUser( + ConversationFocus.selectUser( context, participant.userId, ); diff --git a/lib/ui/home/chat_slide_page/groups_in_common_page.dart b/lib/ui/home/chat_slide_page/groups_in_common_page.dart index 0e85937f4f..2305e6a223 100644 --- a/lib/ui/home/chat_slide_page/groups_in_common_page.dart +++ b/lib/ui/home/chat_slide_page/groups_in_common_page.dart @@ -11,6 +11,7 @@ import '../../../widgets/app_bar.dart'; import '../../../widgets/avatar_view/avatar_view.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../provider/conversation_provider.dart'; +import '../conversation/conversation_focus.dart'; import '../conversation/conversation_page.dart'; class GroupsInCommonPage extends HookConsumerWidget { @@ -100,7 +101,7 @@ class _GroupConversationItemWidget extends StatelessWidget { height: ConversationPage.conversationItemHeight, child: InteractiveDecoratedBox( onTap: () { - ConversationStateNotifier.selectConversation( + ConversationFocus.selectConversation( context, group.conversationId, ); diff --git a/lib/ui/home/chat_slide_page/pin_messages_page.dart b/lib/ui/home/chat_slide_page/pin_messages_page.dart index 1264afddb6..3e5573fa7a 100644 --- a/lib/ui/home/chat_slide_page/pin_messages_page.dart +++ b/lib/ui/home/chat_slide_page/pin_messages_page.dart @@ -93,7 +93,8 @@ class PinMessagesPage extends HookConsumerWidget { ActionButton( name: Resources.assetsImagesIcCloseSvg, color: context.theme.icon, - onTap: () => context.read().onPopPage(), + onTap: () => + context.read().closeDestination(), ), ], ), @@ -114,9 +115,12 @@ class PinMessagesPage extends HookConsumerWidget { final messageItem = list[index]; return MessageItemWidget( key: ValueKey(messageItem.messageId), - prev: list.getOrNull(index + 1), + row: MessageRowModel( + message: messageItem, + prev: list.getOrNull(index + 1), + next: list.getOrNull(index - 1), + ), message: messageItem, - next: list.getOrNull(index - 1), blink: false, isPinnedPage: true, ); diff --git a/lib/ui/home/chat_slide_page/search_message_page.dart b/lib/ui/home/chat_slide_page/search_message_page.dart index 9ae9258a37..7b782e0701 100644 --- a/lib/ui/home/chat_slide_page/search_message_page.dart +++ b/lib/ui/home/chat_slide_page/search_message_page.dart @@ -92,7 +92,7 @@ class SearchMessagePage extends HookConsumerWidget { ActionButton( name: Resources.assetsImagesIcCloseSvg, color: context.theme.icon, - onTap: () => context.read().onPopPage(), + onTap: () => context.read().closeDestination(), ), ], ), @@ -309,11 +309,10 @@ class _SearchMessageList extends HookConsumerWidget { keyword: keyword, showSender: true, onTap: () async { - await context.jumpToMessageInChat(message.messageId); - final chatSideNotifier = context.read(); - if (chatSideNotifier.state.routeMode) { - chatSideNotifier.clear(); - } + await context.jumpToMessageInChat( + message.messageId, + closeSideAfterJump: true, + ); }, ); }, diff --git a/lib/ui/home/chat_slide_page/share_media/file_page.dart b/lib/ui/home/chat_slide_page/share_media/file_page.dart index f587c91edf..c7db0af44d 100644 --- a/lib/ui/home/chat_slide_page/share_media/file_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/file_page.dart @@ -1,11 +1,6 @@ -import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:intl/intl.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:sliver_tools/sliver_tools.dart'; import '../../../../constants/resources.dart'; import '../../../../db/mixin_database.dart'; @@ -13,9 +8,8 @@ import '../../../../enum/message_category.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../widgets/message/item/file_message.dart'; import '../../../../widgets/message/message.dart'; -import '../../../../widgets/near_edge_scroll_listener.dart'; -import '../../notifier/load_more_paging_controller.dart'; import '../shared_media_page.dart'; +import 'shared_media_list.dart'; class FilePage extends HookConsumerWidget { const FilePage({ @@ -32,112 +26,20 @@ class FilePage extends HookConsumerWidget { final size = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); final messageDao = context.database.messageDao; - final mediaController = useMemoized( - () => LoadMorePagingController( - reloadData: () => - messageDao.fileMessages(conversationId, size, 0).get(), - loadMoreData: (list) async { - if (list.isEmpty) return []; - final last = list.last; - final info = await messageDao.messageOrderInfo(last.messageId); - if (info == null) return []; - final items = await messageDao - .fileMessagesBefore(info, conversationId, size) - .get(); - return [...list, ...items]; - }, - isSameKey: (a, b) => a.messageId == b.messageId, - ), - [conversationId], - ); - useEffect(() => mediaController.dispose, [mediaController]); - useEffect( - () => messageDao - .watchInsertOrReplaceMessageStream(conversationId) - .switchMap((value) async* { - for (final item in value) { - yield item; - } - }) - .where( - (event) => [ - MessageCategory.plainData, - MessageCategory.signalData, - ].contains(event.type), - ) - .listen(mediaController.insertOrReplace) - .cancel, - [conversationId], - ); - final state = useValueListenable(mediaController); - final map = groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }); - - final scrollController = useScrollController(); - - if (map.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SvgPicture.asset( - Resources.assetsImagesEmptyFileSvg, - colorFilter: ColorFilter.mode( - context.theme.secondaryText.withValues(alpha: 0.4), - BlendMode.srcIn, - ), - ), - const SizedBox(height: 24), - Text( - context.l10n.noFiles, - style: TextStyle( - fontSize: 12, - color: context.theme.secondaryText, - ), - ), - ], - ), - ); - } - - return NearEdgeScrollListener( - onNearEnd: mediaController.loadMore, - child: CustomScrollView( - controller: scrollController, - slivers: map.entries - .map( - (e) => MultiSliver( - pushPinnedChildren: true, - children: [ - SliverPinnedHeader( - child: Container( - color: context.theme.primary, - padding: const EdgeInsets.all(10), - child: Text( - DateFormat.yMMMd().format(e.key.toLocal()), - style: TextStyle( - fontSize: 14, - color: context.theme.secondaryText, - ), - ), - ), - ), - SliverList( - delegate: SliverChildBuilderDelegate(( - context, - index, - ) { - final message = e.value[index]; - return _Item(message: message); - }, childCount: e.value.length), - ), - ], - ), - ) - .toList(), - ), + return SharedMediaList( + conversationId: conversationId, + pageSize: size, + categories: const { + MessageCategory.plainData, + MessageCategory.signalData, + }, + emptyAsset: Resources.assetsImagesEmptyFileSvg, + emptyText: context.l10n.noFiles, + reloadData: (pageSize) => + messageDao.fileMessages(conversationId, pageSize, 0).get(), + loadBefore: (info, pageSize) => + messageDao.fileMessagesBefore(info, conversationId, pageSize).get(), + itemBuilder: (context, message) => _Item(message: message), ); } } diff --git a/lib/ui/home/chat_slide_page/share_media/media_page.dart b/lib/ui/home/chat_slide_page/share_media/media_page.dart index 86b10a8c6a..40a88050da 100644 --- a/lib/ui/home/chat_slide_page/share_media/media_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/media_page.dart @@ -1,11 +1,6 @@ -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:intl/intl.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:sliver_tools/sliver_tools.dart'; import '../../../../constants/resources.dart'; import '../../../../db/mixin_database.dart'; @@ -14,10 +9,10 @@ import '../../../../utils/extension/extension.dart'; import '../../../../widgets/message/item/image/image_message.dart'; import '../../../../widgets/message/item/video/video_message.dart'; import '../../../../widgets/message/message.dart'; -import '../../../../widgets/near_edge_scroll_listener.dart'; +import '../../desktop_shell_layout.dart'; import '../../notifier/chat_side_notifier.dart'; -import '../../notifier/load_more_paging_controller.dart'; import '../shared_media_page.dart'; +import 'shared_media_list.dart'; class MediaPage extends HookConsumerWidget { const MediaPage({ @@ -31,128 +26,36 @@ class MediaPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final column = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); - final routeMode = context.read().state.routeMode; - final size = column * (routeMode ? 4 : 3); - - final messageDao = context.database.messageDao; - - final mediaController = useMemoized( - () => LoadMorePagingController( - reloadData: () => - messageDao.mediaMessages(conversationId, size, 0).get(), - loadMoreData: (list) async { - if (list.isEmpty) return []; - final last = list.last; - final info = await messageDao.messageOrderInfo(last.messageId); - if (info == null) return []; - final items = await messageDao - .mediaMessagesBefore(info, conversationId, size) - .get(); - return [...list, ...items]; - }, - isSameKey: (a, b) => a.messageId == b.messageId, - ), - [conversationId], + final chatSideNotifier = context.read(); + final columnCount = DesktopShellLayout.chatSideMediaColumnCount( + routeMode: chatSideNotifier.isRouteMode, ); - useEffect(() => mediaController.dispose, [mediaController]); - useEffect( - () => messageDao - .watchInsertOrReplaceMessageStream(conversationId) - .switchMap((value) async* { - for (final item in value) { - yield item; - } - }) - .where( - (event) => [ - MessageCategory.plainImage, - MessageCategory.signalImage, - MessageCategory.plainVideo, - MessageCategory.signalVideo, - ].contains(event.type), - ) - .listen(mediaController.insertOrReplace) - .cancel, - [conversationId], + final pageSize = DesktopShellLayout.chatSideMediaPageSize( + maxHeight: maxHeight, + routeMode: chatSideNotifier.isRouteMode, ); - final state = useValueListenable(mediaController); - final map = groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }); - - final scrollController = useScrollController(); - - if (map.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SvgPicture.asset( - Resources.assetsImagesEmptyImageSvg, - colorFilter: ColorFilter.mode( - context.theme.secondaryText.withValues(alpha: 0.4), - BlendMode.srcIn, - ), - ), - const SizedBox(height: 24), - Text( - context.l10n.noMedia, - style: TextStyle( - fontSize: 12, - color: context.theme.secondaryText, - ), - ), - ], - ), - ); - } - - return NearEdgeScrollListener( - onNearEnd: mediaController.loadMore, - child: CustomScrollView( - controller: scrollController, - slivers: map.entries - .map( - (e) => MultiSliver( - pushPinnedChildren: true, - children: [ - SliverPinnedHeader( - child: Container( - color: context.theme.primary, - padding: const EdgeInsets.all(10), - child: Text( - DateFormat.yMMMd().format(e.key.toLocal()), - style: TextStyle( - fontSize: 14, - color: context.theme.secondaryText, - ), - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 10), - sliver: SliverGrid( - delegate: SliverChildBuilderDelegate(( - context, - index, - ) { - final message = e.value[index]; - return _Item(message: message); - }, childCount: e.value.length), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: routeMode ? 4 : 3, - mainAxisSpacing: 5, - crossAxisSpacing: 5, - ), - ), - ), - ], - ), - ) - .toList(), + final messageDao = context.database.messageDao; + return SharedMediaList( + conversationId: conversationId, + pageSize: pageSize, + categories: const { + MessageCategory.plainImage, + MessageCategory.signalImage, + MessageCategory.plainVideo, + MessageCategory.signalVideo, + }, + emptyAsset: Resources.assetsImagesEmptyImageSvg, + emptyText: context.l10n.noMedia, + reloadData: (pageSize) => + messageDao.mediaMessages(conversationId, pageSize, 0).get(), + loadBefore: (info, pageSize) => + messageDao.mediaMessagesBefore(info, conversationId, pageSize).get(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: columnCount, + mainAxisSpacing: 5, + crossAxisSpacing: 5, ), + itemBuilder: (context, message) => _Item(message: message), ); } } diff --git a/lib/ui/home/chat_slide_page/share_media/post_page.dart b/lib/ui/home/chat_slide_page/share_media/post_page.dart index c9da000b10..53755e22cd 100644 --- a/lib/ui/home/chat_slide_page/share_media/post_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/post_page.dart @@ -1,11 +1,6 @@ -import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:intl/intl.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:sliver_tools/sliver_tools.dart'; import '../../../../constants/resources.dart'; import '../../../../db/mixin_database.dart'; @@ -13,9 +8,8 @@ import '../../../../enum/message_category.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../widgets/message/item/post_message.dart'; import '../../../../widgets/message/message.dart'; -import '../../../../widgets/near_edge_scroll_listener.dart'; -import '../../notifier/load_more_paging_controller.dart'; import '../shared_media_page.dart'; +import 'shared_media_list.dart'; class PostPage extends HookConsumerWidget { const PostPage({ @@ -30,115 +24,22 @@ class PostPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final size = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); - final messageDao = context.database.messageDao; - final mediaController = useMemoized( - () => LoadMorePagingController( - reloadData: () => - messageDao.postMessages(conversationId, size, 0).get(), - loadMoreData: (list) async { - if (list.isEmpty) return []; - final last = list.last; - final info = await messageDao.messageOrderInfo(last.messageId); - if (info == null) return []; - final items = await messageDao - .postMessagesBefore(info, conversationId, size) - .get(); - return [...list, ...items]; - }, - isSameKey: (a, b) => a.messageId == b.messageId, - ), - [conversationId], - ); - useEffect(() => mediaController.dispose, [mediaController]); - useEffect( - () => messageDao - .watchInsertOrReplaceMessageStream(conversationId) - .switchMap((value) async* { - for (final item in value) { - yield item; - } - }) - .where( - (event) => [ - MessageCategory.plainPost, - MessageCategory.signalPost, - ].contains(event.type), - ) - .listen(mediaController.insertOrReplace) - .cancel, - [conversationId], - ); - final state = useValueListenable(mediaController); - final map = groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }); - - final scrollController = useScrollController(); - - if (map.isEmpty) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SvgPicture.asset( - Resources.assetsImagesEmptyFileSvg, - colorFilter: ColorFilter.mode( - context.theme.secondaryText.withValues(alpha: 0.4), - BlendMode.srcIn, - ), - ), - const SizedBox(height: 24), - Text( - context.l10n.noPosts, - style: TextStyle( - fontSize: 12, - color: context.theme.secondaryText, - ), - ), - ], - ), - ); - } - - return NearEdgeScrollListener( - onNearEnd: mediaController.loadMore, - child: CustomScrollView( - controller: scrollController, - slivers: map.entries - .map( - (e) => MultiSliver( - pushPinnedChildren: true, - children: [ - SliverPinnedHeader( - child: Container( - color: context.theme.primary, - padding: const EdgeInsets.all(10), - child: Text( - DateFormat.yMMMd().format(e.key.toLocal()), - style: TextStyle( - fontSize: 14, - color: context.theme.secondaryText, - ), - ), - ), - ), - SliverList( - delegate: SliverChildBuilderDelegate(( - context, - index, - ) { - final message = e.value[index]; - return _Item(message: message); - }, childCount: e.value.length), - ), - ], - ), - ) - .toList(), - ), + return SharedMediaList( + conversationId: conversationId, + pageSize: size, + categories: const { + MessageCategory.plainPost, + MessageCategory.signalPost, + }, + emptyAsset: Resources.assetsImagesEmptyFileSvg, + emptyText: context.l10n.noPosts, + reloadData: (pageSize) => + messageDao.postMessages(conversationId, pageSize, 0).get(), + loadBefore: (info, pageSize) => + messageDao.postMessagesBefore(info, conversationId, pageSize).get(), + itemBuilder: (context, message) => _Item(message: message), ); } } diff --git a/lib/ui/home/chat_slide_page/share_media/shared_media_list.dart b/lib/ui/home/chat_slide_page/share_media/shared_media_list.dart new file mode 100644 index 0000000000..15ac5964eb --- /dev/null +++ b/lib/ui/home/chat_slide_page/share_media/shared_media_list.dart @@ -0,0 +1,159 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +import '../../../../db/dao/message_dao.dart'; +import '../../../../db/mixin_database.dart'; +import '../../../../utils/extension/extension.dart'; +import '../../../../widgets/near_edge_scroll_listener.dart'; +import '../../notifier/load_more_paging_controller.dart'; + +typedef SharedMediaInitialLoader = + Future> Function(int pageSize); +typedef SharedMediaBeforeLoader = + Future> Function(MessageOrderInfo anchor, int pageSize); +typedef SharedMediaItemBuilder = + Widget Function(BuildContext context, MessageItem message); + +class SharedMediaList extends HookConsumerWidget { + const SharedMediaList({ + required this.conversationId, + required this.pageSize, + required this.categories, + required this.emptyAsset, + required this.emptyText, + required this.reloadData, + required this.loadBefore, + required this.itemBuilder, + super.key, + this.gridDelegate, + }); + + final String conversationId; + final int pageSize; + final Set categories; + final String emptyAsset; + final String emptyText; + final SharedMediaInitialLoader reloadData; + final SharedMediaBeforeLoader loadBefore; + final SharedMediaItemBuilder itemBuilder; + final SliverGridDelegate? gridDelegate; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messageDao = context.database.messageDao; + final mediaController = useMemoized( + () => LoadMorePagingController( + reloadData: () => reloadData(pageSize), + loadMoreData: (list) async { + if (list.isEmpty) return []; + final info = await messageDao.messageOrderInfo(list.last.messageId); + if (info == null) return []; + return [...list, ...await loadBefore(info, pageSize)]; + }, + isSameKey: (a, b) => a.messageId == b.messageId, + ), + [conversationId, pageSize], + ); + useEffect(() => mediaController.dispose, [mediaController]); + useEffect( + () => messageDao + .watchInsertOrReplaceMessageStream(conversationId) + .switchMap((value) async* { + for (final item in value) { + yield item; + } + }) + .where((event) => categories.contains(event.type)) + .listen(mediaController.insertOrReplace) + .cancel, + [conversationId, mediaController], + ); + + final state = useValueListenable(mediaController); + final grouped = groupBy(state.list, (messageItem) { + final local = messageItem.createdAt.toLocal(); + return DateTime(local.year, local.month, local.day); + }); + final scrollController = useScrollController(); + + if (grouped.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + emptyAsset, + colorFilter: ColorFilter.mode( + context.theme.secondaryText.withValues(alpha: 0.4), + BlendMode.srcIn, + ), + ), + const SizedBox(height: 24), + Text( + emptyText, + style: TextStyle( + fontSize: 12, + color: context.theme.secondaryText, + ), + ), + ], + ), + ); + } + + return NearEdgeScrollListener( + onNearEnd: mediaController.loadMore, + child: CustomScrollView( + controller: scrollController, + slivers: grouped.entries + .map( + (entry) => MultiSliver( + pushPinnedChildren: true, + children: [ + SliverPinnedHeader( + child: Container( + color: context.theme.primary, + padding: const EdgeInsets.all(10), + child: Text( + DateFormat.yMMMd().format(entry.key.toLocal()), + style: TextStyle( + fontSize: 14, + color: context.theme.secondaryText, + ), + ), + ), + ), + if (gridDelegate == null) + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => + itemBuilder(context, entry.value[index]), + childCount: entry.value.length, + ), + ) + else + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 10), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) => + itemBuilder(context, entry.value[index]), + childCount: entry.value.length, + ), + gridDelegate: gridDelegate!, + ), + ), + ], + ), + ) + .toList(), + ), + ); + } +} diff --git a/lib/ui/home/chat_slide_page/shared_media_page.dart b/lib/ui/home/chat_slide_page/shared_media_page.dart index 983313033e..5ca5b17225 100644 --- a/lib/ui/home/chat_slide_page/shared_media_page.dart +++ b/lib/ui/home/chat_slide_page/shared_media_page.dart @@ -102,7 +102,12 @@ class ShareMediaItemMenuWrapper extends StatelessWidget { image: MenuImage.icon(IconFonts.positionToChat), title: context.l10n.locateToChat, callback: () { - unawaited(context.jumpToMessageInChat(messageId)); + unawaited( + context.jumpToMessageInChat( + messageId, + closeSideAfterJump: true, + ), + ); }, ), ], diff --git a/lib/ui/home/conversation/audio_player_bar.dart b/lib/ui/home/conversation/audio_player_bar.dart index 314bc0c626..736f5fe1b0 100644 --- a/lib/ui/home/conversation/audio_player_bar.dart +++ b/lib/ui/home/conversation/audio_player_bar.dart @@ -14,6 +14,7 @@ import '../../../widgets/action_button.dart'; import '../../../widgets/avatar_view/avatar_view.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../provider/conversation_provider.dart'; +import 'conversation_focus.dart'; class AudioPlayerBar extends HookConsumerWidget { const AudioPlayerBar({super.key}); @@ -53,7 +54,7 @@ class AudioPlayerBar extends HookConsumerWidget { if (conversationItem == null) { return; } - ConversationStateNotifier.selectConversation( + ConversationFocus.selectConversation( context, conversationItem.conversationId, conversation: conversationItem, diff --git a/lib/ui/home/conversation/conversation_avatar_cache.dart b/lib/ui/home/conversation/conversation_avatar_cache.dart new file mode 100644 index 0000000000..09f5646ca8 --- /dev/null +++ b/lib/ui/home/conversation/conversation_avatar_cache.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' + show ConversationCategory; + +import '../../../db/dao/conversation_dao.dart'; +import '../../../db/dao/participant_dao.dart'; +import '../../../db/database.dart'; +import '../../../db/database_event_bus.dart'; +import '../../../db/mixin_database.dart' hide Offset; +import '../../../utils/logger.dart'; + +class ConversationAvatarCache extends ChangeNotifier { + ConversationAvatarCache(this.database) { + _participantSubscription = DataBaseEventBus + .instance + .updateParticipantIdStream + .listen(_handleParticipantUpdate); + } + + final Database database; + final Map> _avatars = {}; + final Set _loadedGroupIds = {}; + StreamSubscription>? _participantSubscription; + var _disposed = false; + + List? usersFor(String conversationId) => _avatars[conversationId]; + + Future warm(Iterable conversations) async { + final missingGroupIds = conversations + .where( + (conversation) => conversation.category == ConversationCategory.group, + ) + .map((conversation) => conversation.conversationId) + .where((conversationId) => !_avatars.containsKey(conversationId)) + .toSet(); + if (missingGroupIds.isEmpty) return; + await _refresh(missingGroupIds); + } + + Future _refresh(Iterable conversationIds) async { + final ids = conversationIds.toSet(); + if (ids.isEmpty) return; + _loadedGroupIds.addAll(ids); + + try { + final rows = await database.participantDao + .participantsAvatarByConversationIds(ids); + var changed = false; + for (final id in ids) { + final users = rows[id] ?? const []; + if (listEquals(_avatars[id], users)) continue; + _avatars[id] = users; + changed = true; + } + if (changed && !_disposed) notifyListeners(); + } catch (error, stackTrace) { + e('conversation avatar cache failed: $error, $stackTrace'); + } + } + + void _handleParticipantUpdate(List participants) { + final changedGroupIds = participants + .map((participant) => participant.conversationId) + .where(_loadedGroupIds.contains) + .toSet(); + if (changedGroupIds.isEmpty) return; + unawaited(_refresh(changedGroupIds)); + } + + @override + void dispose() { + _disposed = true; + unawaited(_participantSubscription?.cancel()); + super.dispose(); + } +} diff --git a/lib/ui/home/conversation/conversation_focus.dart b/lib/ui/home/conversation/conversation_focus.dart new file mode 100644 index 0000000000..942ab6bbb6 --- /dev/null +++ b/lib/ui/home/conversation/conversation_focus.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide User; + +import '../../../crypto/uuid/uuid.dart'; +import '../../../db/dao/conversation_dao.dart'; +import '../../../db/mixin_database.dart'; +import '../../../utils/extension/extension.dart'; +import '../../../utils/local_notification_center.dart'; +import '../../../widgets/toast.dart'; +import '../../provider/conversation_provider.dart'; +import '../../provider/is_bot_group_provider.dart'; +import '../../provider/pending_chat_jump_provider.dart'; +import '../../provider/recent_conversation_provider.dart'; +import '../conversation_info_destination.dart'; +import '../notifier/conversation_list_controller.dart'; + +class ConversationFocus { + const ConversationFocus._(); + + static Future selectConversation( + BuildContext context, + String conversationId, { + ConversationItem? conversation, + String? initIndexMessageId, + ConversationInfoDestination? initialChatSidePage, + String? keyword, + bool sync = false, + bool checkCurrentUserExist = false, + }) async { + context.providerContainer.read(isBotGroupProvider(conversationId)); + + final accountServer = context.accountServer; + final database = context.database; + final conversationNotifier = context.providerContainer.read( + conversationProvider.notifier, + ); + final state = conversationNotifier.state; + + if (state?.conversationId == conversationId && + initIndexMessageId == null && + initialChatSidePage == null && + keyword == null) { + conversationNotifier.openChatPage(); + context.providerContainer.read(pendingChatJumpProvider.notifier).state = + const PendingChatJump.commandLatest(); + return; + } + + ConversationItem? selectedConversation; + String? lastReadMessageId; + bool? hasUnreadMessage; + if (state?.conversationId == conversationId) { + selectedConversation = state?.conversation; + + hasUnreadMessage = (selectedConversation?.unseenMessageCount ?? 0) > 0; + if (hasUnreadMessage) { + lastReadMessageId = state?.lastReadMessageId; + } + } + + selectedConversation = + conversation ?? + selectedConversation ?? + await _conversationItem(context, conversationId); + + if (selectedConversation == null && sync) { + showToastLoading(); + await accountServer.refreshConversation( + conversationId, + checkCurrentUserExist: checkCurrentUserExist, + ); + selectedConversation = await _conversationItem(context, conversationId); + } + + hasUnreadMessage ??= (selectedConversation?.unseenMessageCount ?? 0) > 0; + + if (selectedConversation == null) { + return showToastFailed(null); + } + + final selectedHasUnreadMessage = hasUnreadMessage; + final selectedInitIndexMessageId = + initIndexMessageId ?? + (selectedHasUnreadMessage + ? selectedConversation.lastReadMessageId + : null); + + lastReadMessageId = + lastReadMessageId ?? + (selectedHasUnreadMessage ? selectedInitIndexMessageId : null); + + final ownerId = selectedConversation.ownerId; + + final appFuture = + (!selectedConversation.isGroupConversation && ownerId != null) + ? database.appDao.findAppById(ownerId) + : null; + + final participantFuture = + selectedConversation.category == ConversationCategory.contact + ? Future.value() + : database.participantDao + .participantById(conversationId, accountServer.userId) + .getSingleOrNull(); + + final conversationState = ConversationState( + conversationId: conversationId, + conversation: selectedConversation, + initIndexMessageId: selectedInitIndexMessageId, + lastReadMessageId: lastReadMessageId, + userId: selectedConversation.isGroupConversation + ? null + : selectedConversation.ownerId, + app: await appFuture, + initialSidePage: initialChatSidePage, + refreshKey: Object(), + participant: await participantFuture, + keyword: keyword, + ); + + Toast.dismiss(); + conversationNotifier.focus(conversationState); + + unawaited(dismissByConversationId(conversationId)); + context.providerContainer + .read(recentConversationIDsProvider.notifier) + .add(conversationId); + } + + static Future selectUser( + BuildContext context, + String userId, { + User? user, + ConversationInfoDestination? initialChatSidePage, + }) async { + final accountServer = context.accountServer; + final database = context.database; + final conversationNotifier = context.providerContainer.read( + conversationProvider.notifier, + ); + + final conversationId = generateConversationId(userId, accountServer.userId); + + context.providerContainer.read(isBotGroupProvider(conversationId)); + + final conversation = await _conversationItem(context, conversationId); + if (conversation != null) { + return selectConversation( + context, + conversationId, + conversation: conversation, + initialChatSidePage: initialChatSidePage, + ); + } + + final selectedUser = + user ?? await database.userDao.userById(userId).getSingleOrNull(); + + if (selectedUser == null) { + return showToastFailed(ToastError(context.l10n.userNotFound)); + } + + final app = await database.appDao.findAppById(userId); + + conversationNotifier.focus( + ConversationState( + conversationId: conversationId, + userId: userId, + user: selectedUser, + app: app, + initialSidePage: initialChatSidePage, + refreshKey: Object(), + ), + ); + + unawaited(dismissByConversationId(conversationId)); + } + + static Future _conversationItem( + BuildContext context, + String conversationId, + ) async { + final conversations = context + .read() + .state + .map + .values + .cast() + .toList(); + + return conversations.firstWhere( + (element) => element?.conversationId == conversationId, + orElse: () => null, + ) ?? + await context.database.conversationDao + .conversationItem(conversationId) + .getSingleOrNull(); + } +} diff --git a/lib/ui/home/conversation/conversation_hotkey.dart b/lib/ui/home/conversation/conversation_hotkey.dart index b34995879c..e85247961a 100644 --- a/lib/ui/home/conversation/conversation_hotkey.dart +++ b/lib/ui/home/conversation/conversation_hotkey.dart @@ -6,9 +6,9 @@ import '../../../utils/platform.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/conversation_unseen_filter_enabled.dart'; import '../../provider/slide_category_provider.dart'; - import '../../provider/unseen_conversations_provider.dart'; import '../notifier/conversation_list_controller.dart'; +import 'conversation_focus.dart'; class ConversationHotKey extends StatelessWidget { const ConversationHotKey({required this.child, super.key}); @@ -101,7 +101,7 @@ void _navigationConversation(BuildContext context, {required bool forward}) { nextConversationId = nextConversation.conversationId; } - ConversationStateNotifier.selectConversation(context, nextConversationId); + ConversationFocus.selectConversation(context, nextConversationId); if (nextConversationIndex == null) return; diff --git a/lib/ui/home/conversation/conversation_list.dart b/lib/ui/home/conversation/conversation_list.dart index d5e192ae54..5106dd4478 100644 --- a/lib/ui/home/conversation/conversation_list.dart +++ b/lib/ui/home/conversation/conversation_list.dart @@ -8,6 +8,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../blaze/vo/pin_message_minimal.dart'; import '../../../constants/resources.dart'; import '../../../db/dao/conversation_dao.dart'; +import '../../../db/mixin_database.dart' show User; import '../../../enum/message_category.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/hook.dart'; @@ -21,13 +22,13 @@ import '../../../widgets/message/item/system_message.dart'; import '../../../widgets/message_status_icon.dart'; import '../../../widgets/unread_text.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/major_navigation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/minute_timer_provider.dart'; -import '../../provider/pending_jump_message_provider.dart'; -import '../../provider/responsive_navigator_provider.dart'; import '../../provider/slide_category_provider.dart'; import '../notifier/conversation_list_controller.dart'; import 'audio_player_bar.dart'; +import 'conversation_focus.dart'; import 'conversation_page.dart'; import 'menu_wrapper.dart'; import 'network_status.dart'; @@ -43,9 +44,12 @@ class ConversationList extends HookConsumerWidget { final conversationListController = context .read(); final pagingState = useValueListenable(conversationListController); + useListenable(conversationListController.avatarCache); final conversationId = ref.watch(currentConversationIdProvider); - final routeMode = ref.watch(navigatorRouteModeProvider); + final showsConversationSelection = ref.watch( + majorNavigationShowsConversationSelectionProvider, + ); final itemPositionsListener = conversationListController .itemPositionsListener( @@ -80,20 +84,17 @@ class ConversationList extends HookConsumerWidget { final conversation = pagingState.map[index]; if (conversation == null) return const SizedBox(height: 80); final current = conversation.conversationId == conversationId; - final selected = current && !routeMode; + final selected = current && showsConversationSelection; return ConversationMenuWrapper( conversation: conversation, removeChatFromCircle: true, child: ConversationItemWidget( selected: selected, conversation: conversation, + groupAvatarUsers: conversationListController.avatarCache + .usersFor(conversation.conversationId), onTap: () { - if (current) { - ref.read(pendingJumpLatestProvider.notifier).state = - Object(); - return; - } - ConversationStateNotifier.selectConversation( + ConversationFocus.selectConversation( context, conversation.conversationId, conversation: conversation, @@ -152,11 +153,13 @@ class ConversationItemWidget extends StatelessWidget { required this.onTap, super.key, this.selected = false, + this.groupAvatarUsers, }); final bool selected; final ConversationItem conversation; final VoidCallback onTap; + final List? groupAvatarUsers; @override Widget build(BuildContext context) { @@ -181,6 +184,7 @@ class ConversationItemWidget extends StatelessWidget { children: [ ConversationAvatarWidget( conversation: conversation, + groupAvatarUsers: groupAvatarUsers, size: ConversationPage.conversationItemAvatarSize, ), const SizedBox(width: 12), diff --git a/lib/ui/home/conversation/search_list.dart b/lib/ui/home/conversation/search_list.dart index 66f509a01e..b7bf5a61b4 100644 --- a/lib/ui/home/conversation/search_list.dart +++ b/lib/ui/home/conversation/search_list.dart @@ -33,7 +33,6 @@ import '../../../widgets/message/item/system_message.dart'; import '../../../widgets/mixin_image.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/user/user_dialog.dart'; -import '../../provider/conversation_provider.dart'; import '../../provider/conversation_unseen_filter_enabled.dart'; import '../../provider/keyword_provider.dart'; import '../../provider/mention_cache_provider.dart'; @@ -42,6 +41,7 @@ import '../../provider/search_mao_user_provider.dart'; import '../../provider/slide_category_provider.dart'; import '../notifier/conversation_list_controller.dart'; import '../notifier/search_message_controller.dart'; +import 'conversation_focus.dart'; import 'conversation_page.dart'; import 'menu_wrapper.dart'; import 'unseen_conversation_list.dart'; @@ -250,7 +250,7 @@ class SearchList extends HookConsumerWidget { ), keyword: keyword, onTap: () async { - await ConversationStateNotifier.selectUser( + await ConversationFocus.selectUser( context, user.userId, user: user, @@ -377,7 +377,7 @@ class SearchList extends HookConsumerWidget { ), keyword: keyword, onTap: () async { - await ConversationStateNotifier.selectConversation( + await ConversationFocus.selectConversation( context, conversation.conversationId, ); @@ -506,7 +506,7 @@ class _SearchMaoUserWidget extends StatelessWidget { await showUserDialog(context, maoUser.user.userId); return; } - await ConversationStateNotifier.selectUser( + await ConversationFocus.selectUser( context, maoUser.user.userId, user: maoUser.user, @@ -796,7 +796,7 @@ Future Function() _searchMessageItemOnTap( BuildContext context, SearchMessageDetailItem message, ) => () async { - await ConversationStateNotifier.selectConversation( + await ConversationFocus.selectConversation( context, message.conversationId, initIndexMessageId: message.messageId, diff --git a/lib/ui/home/conversation/unseen_conversation_list.dart b/lib/ui/home/conversation/unseen_conversation_list.dart index d133c34f01..39f5c5f2fe 100644 --- a/lib/ui/home/conversation/unseen_conversation_list.dart +++ b/lib/ui/home/conversation/unseen_conversation_list.dart @@ -3,9 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../provider/conversation_provider.dart'; -import '../../provider/pending_jump_message_provider.dart'; -import '../../provider/responsive_navigator_provider.dart'; +import '../../provider/major_navigation_provider.dart'; import '../../provider/unseen_conversations_provider.dart'; +import 'conversation_focus.dart'; import 'conversation_list.dart'; import 'menu_wrapper.dart'; import 'search_list.dart'; @@ -19,7 +19,9 @@ class UnseenConversationList extends HookConsumerWidget { final currentConversationId = ref.watch(currentConversationIdProvider); - final routeMode = ref.watch(navigatorRouteModeProvider); + final showsConversationSelection = ref.watch( + majorNavigationShowsConversationSelectionProvider, + ); if (unseenConversations == null) { return const SizedBox(); @@ -31,7 +33,7 @@ class UnseenConversationList extends HookConsumerWidget { itemBuilder: (context, index) { final conversation = unseenConversations[index]; final current = conversation.conversationId == currentConversationId; - final selected = current && !routeMode; + final selected = current && showsConversationSelection; return ConversationMenuWrapper( conversation: conversation, removeChatFromCircle: true, @@ -39,11 +41,7 @@ class UnseenConversationList extends HookConsumerWidget { conversation: conversation, selected: selected, onTap: () { - if (current) { - ref.read(pendingJumpLatestProvider.notifier).state = Object(); - return; - } - ConversationStateNotifier.selectConversation( + ConversationFocus.selectConversation( context, conversation.conversationId, conversation: conversation, diff --git a/lib/ui/home/conversation_info_destination.dart b/lib/ui/home/conversation_info_destination.dart new file mode 100644 index 0000000000..23beb5cf17 --- /dev/null +++ b/lib/ui/home/conversation_info_destination.dart @@ -0,0 +1,11 @@ +enum ConversationInfoDestination { + infoPage, + circles, + searchMessageHistory, + sharedMedia, + participants, + pinMessages, + sharedApps, + groupsInCommon, + disappearMessages, +} diff --git a/lib/ui/home/desktop_shell_layout.dart b/lib/ui/home/desktop_shell_layout.dart new file mode 100644 index 0000000000..8c4e7b94e0 --- /dev/null +++ b/lib/ui/home/desktop_shell_layout.dart @@ -0,0 +1,89 @@ +enum DesktopShellLayoutMode { drawer, compactRail, fullRail } + +// chat category list min width +const kSlidePageMinWidth = 64.0; +// chat category and chat list max width +const kSlidePageMaxWidth = 176.0; +// chat page min width, message list, setting page etc. +const kResponsiveNavigationMinWidth = 320.0; +// conversation list fixed width, conversation list, setting list etc. +const kConversationListWidth = 300.0; +// chat side page fixed width, chat info page etc. +const kChatSidePageWidth = 300.0; + +class DesktopShellLayout { + const DesktopShellLayout({ + required this.mode, + required this.slideWidth, + required this.availableSlideWidth, + required this.autoCollapse, + required this.userCollapse, + }); + + static const mainRouteSwitchWidth = + kResponsiveNavigationMinWidth + kConversationListWidth; + static const chatSideRouteSwitchWidth = + kResponsiveNavigationMinWidth + kChatSidePageWidth; + + final DesktopShellLayoutMode mode; + final double slideWidth; + final double availableSlideWidth; + final bool autoCollapse; + final bool userCollapse; + + bool get hasDrawer => mode == DesktopShellLayoutMode.drawer; + + bool get collapse => + mode == DesktopShellLayoutMode.drawer || + mode == DesktopShellLayoutMode.compactRail; + + bool get showCollapseControl => !autoCollapse; + + double get slideMaxWidth => + collapse ? kSlidePageMinWidth : availableSlideWidth; + + static DesktopShellLayout resolve({ + required double maxWidth, + required bool userCollapse, + required bool isPhone, + }) { + final availableSlideWidth = (maxWidth - kResponsiveNavigationMinWidth) + .clamp(kSlidePageMinWidth, kSlidePageMaxWidth); + + final autoCollapse = availableSlideWidth < kSlidePageMaxWidth; + final collapse = userCollapse || autoCollapse; + final hasDrawer = availableSlideWidth <= kSlidePageMinWidth || isPhone; + final mode = hasDrawer + ? DesktopShellLayoutMode.drawer + : collapse + ? DesktopShellLayoutMode.compactRail + : DesktopShellLayoutMode.fullRail; + + return DesktopShellLayout( + mode: mode, + slideWidth: switch (mode) { + DesktopShellLayoutMode.drawer => 0, + DesktopShellLayoutMode.compactRail => kSlidePageMinWidth, + DesktopShellLayoutMode.fullRail => kSlidePageMaxWidth, + }, + availableSlideWidth: availableSlideWidth, + autoCollapse: autoCollapse, + userCollapse: userCollapse, + ); + } + + static bool useMainRouteMode(double width) => width < mainRouteSwitchWidth; + + static bool useChatSideRouteMode(double width) => + width < chatSideRouteSwitchWidth; + + static int chatSideMediaColumnCount({required bool routeMode}) => + routeMode ? 4 : 3; + + static int chatSideMediaPageSize({ + required double maxHeight, + required bool routeMode, + }) => + (maxHeight / 90 * 2).toInt() * + chatSideMediaColumnCount(routeMode: routeMode); +} diff --git a/lib/ui/home/home.dart b/lib/ui/home/home.dart index 1a4903e84d..b31a529d6d 100644 --- a/lib/ui/home/home.dart +++ b/lib/ui/home/home.dart @@ -21,8 +21,8 @@ import '../../widgets/protocol_handler.dart'; import '../../widgets/toast.dart'; import '../landing/landing.dart'; import '../provider/conversation_provider.dart'; +import '../provider/major_navigation_provider.dart'; import '../provider/multi_auth_provider.dart'; -import '../provider/responsive_navigator_provider.dart'; import '../provider/setting_provider.dart'; import '../provider/slide_category_provider.dart'; import '../setting/setting_page.dart'; @@ -30,20 +30,11 @@ import '../setting/setting_page.dart'; import 'command_palette_wrapper.dart'; import 'conversation/conversation_hotkey.dart'; import 'conversation/conversation_page.dart'; -import 'route/responsive_navigator.dart'; +import 'desktop_shell_layout.dart'; +import 'left_rail_controller.dart'; +import 'route/major_navigator.dart'; import 'slide_page.dart'; -// chat category list min width -const kSlidePageMinWidth = 64.0; -// chat category and chat list max width -const kSlidePageMaxWidth = 176.0; -// chat page min width, message list, setting page etc. -const kResponsiveNavigationMinWidth = 320.0; -// conversation list fixed width, conversation list, setting list etc. -const kConversationListWidth = 300.0; -// chat side page fixed width, chat info page etc. -const kChatSidePageWidth = 300.0; - final _conversationPageKey = GlobalKey(); class HomePage extends HookConsumerWidget { @@ -220,10 +211,6 @@ class _SetupNameWidget extends HookConsumerWidget { } } -class HasDrawerValueNotifier extends ValueNotifier { - HasDrawerValueNotifier(super.value); -} - class _HomePage extends HookConsumerWidget { const _HomePage({required this.constraints}); @@ -231,33 +218,25 @@ class _HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final maxWidth = constraints.maxWidth; - final clampSlideWidth = (maxWidth - kResponsiveNavigationMinWidth).clamp( - kSlidePageMinWidth, - kSlidePageMaxWidth, - ); - final userCollapse = ref.watch( settingProvider.select((value) => value.collapsedSidebar), ); - final autoCollapse = clampSlideWidth < kSlidePageMaxWidth; - final collapse = userCollapse || autoCollapse; - - var targetWidth = collapse ? kSlidePageMinWidth : kSlidePageMaxWidth; - if (clampSlideWidth <= kSlidePageMinWidth || kPlatformIsIphone) { - targetWidth = 0; - } + final shellLayout = DesktopShellLayout.resolve( + maxWidth: constraints.maxWidth, + userCollapse: userCollapse, + isPhone: kPlatformIsIphone, + ); - final hasDrawerValueNotifier = useMemoized( - () => HasDrawerValueNotifier(targetWidth == 0), + final leftRailController = useMemoized( + () => LeftRailController(shellLayout), ); - final hasDrawer = useListenable(hasDrawerValueNotifier); + final hasDrawer = useListenable(leftRailController); return MultiProvider( providers: [ - ChangeNotifierProvider.value(value: hasDrawerValueNotifier), + ChangeNotifierProvider.value(value: leftRailController), Provider( create: (context) => AudioMessagePlayService(context.accountServer), dispose: (context, service) => service.dispose(), @@ -266,7 +245,7 @@ class _HomePage extends HookConsumerWidget { child: Scaffold( backgroundColor: context.theme.primary, drawerEnableOpenDragGesture: false, - drawer: hasDrawer.value && targetWidth == 0 + drawer: hasDrawer.value && shellLayout.hasDrawer ? Drawer( child: Container( width: kSlidePageMaxWidth, @@ -280,9 +259,9 @@ class _HomePage extends HookConsumerWidget { child: Row( children: [ TweenAnimationBuilder( - tween: Tween(end: targetWidth), + tween: Tween(end: shellLayout.slideWidth), duration: const Duration(milliseconds: 200), - onEnd: () => hasDrawerValueNotifier.value = targetWidth == 0, + onEnd: () => leftRailController.sync(shellLayout), builder: (context, value, child) => SizedBox( width: value, child: value == 0 ? null : child, @@ -290,14 +269,15 @@ class _HomePage extends HookConsumerWidget { child: OverflowBox( alignment: Alignment.centerLeft, minWidth: kSlidePageMinWidth, - maxWidth: collapse ? kSlidePageMinWidth : clampSlideWidth, - child: SlidePage(showCollapse: !autoCollapse), + maxWidth: shellLayout.slideMaxWidth, + child: SlidePage( + showCollapse: shellLayout.showCollapseControl, + ), ), ), Expanded( - child: ResponsiveNavigator( - switchWidth: - kResponsiveNavigationMinWidth + kConversationListWidth, + child: MajorNavigator( + switchWidth: DesktopShellLayout.mainRouteSwitchWidth, leftPage: MaterialPage( key: const ValueKey('center'), name: 'center', @@ -342,23 +322,12 @@ class _CenterPage extends HookConsumerWidget { ref.listen(slideCategoryStateProvider, (previous, next) { final isSetting = next.type == SlideCategoryType.setting; - final responsiveNavigatorNotifier = context.providerContainer.read( - responsiveNavigatorProvider.notifier, + final majorNavigationNotifier = context.providerContainer.read( + majorNavigationProvider.notifier, ); - responsiveNavigatorNotifier.popWhere((page) { - if (responsiveNavigatorNotifier.state.routeMode) return true; - - return ResponsiveNavigatorStateNotifier.settingPageNameSet.contains( - page.name, - ); - }); - - if (isSetting && !responsiveNavigatorNotifier.state.routeMode) { + if (majorNavigationNotifier.syncSettingCategory(isSetting)) { ref.read(conversationProvider.notifier).unselected(); - responsiveNavigatorNotifier.pushPage( - ResponsiveNavigatorStateNotifier.settingPageNameSet.first, - ); } }); diff --git a/lib/ui/home/left_rail_controller.dart b/lib/ui/home/left_rail_controller.dart new file mode 100644 index 0000000000..9ff5e4232b --- /dev/null +++ b/lib/ui/home/left_rail_controller.dart @@ -0,0 +1,9 @@ +import 'package:flutter/foundation.dart'; + +import 'desktop_shell_layout.dart'; + +class LeftRailController extends ValueNotifier { + LeftRailController(DesktopShellLayout layout) : super(layout.hasDrawer); + + void sync(DesktopShellLayout layout) => value = layout.hasDrawer; +} diff --git a/lib/ui/home/notifier/chat_side_notifier.dart b/lib/ui/home/notifier/chat_side_notifier.dart index fa9256ce33..b667c6dfbd 100644 --- a/lib/ui/home/notifier/chat_side_notifier.dart +++ b/lib/ui/home/notifier/chat_side_notifier.dart @@ -1,119 +1,121 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; import 'package:provider/provider.dart'; -import '../../provider/abstract_responsive_navigator.dart'; -import '../../provider/conversation_provider.dart'; -import '../chat_slide_page/chat_info_page.dart'; -import '../chat_slide_page/circle_manager_page.dart'; -import '../chat_slide_page/disappear_message_page.dart'; -import '../chat_slide_page/group_participants_page.dart'; -import '../chat_slide_page/groups_in_common_page.dart'; -import '../chat_slide_page/pin_messages_page.dart'; -import '../chat_slide_page/search_message_page.dart'; -import '../chat_slide_page/shared_apps_page.dart'; -import '../chat_slide_page/shared_media_page.dart'; - -class ChatSideNotifier extends ValueNotifier - with ResponsiveNavigatorController { - ChatSideNotifier() : super(const ResponsiveNavigatorState()); - - static const infoPage = 'infoPage'; - static const circles = 'circles'; - static const searchMessageHistory = 'searchMessageHistory'; - static const sharedMedia = 'sharedMedia'; - static const participants = 'participants'; - static const pinMessages = 'pinMessages'; - static const sharedApps = 'sharedApps'; - static const groupsInCommon = 'groupsInCommon'; - static const disappearMessages = 'disappearMessages'; +import '../conversation_info_destination.dart'; + +class ChatSideState extends Equatable { + const ChatSideState({ + this.destinations = const [], + this.routeMode = false, + }); + + final List destinations; + final bool routeMode; + + ChatSideState copyWith({ + List? destinations, + bool? routeMode, + }) => ChatSideState( + destinations: destinations ?? this.destinations, + routeMode: routeMode ?? this.routeMode, + ); + + @override + List get props => [destinations, routeMode]; +} + +class ChatSideNotifier extends ValueNotifier { + ChatSideNotifier() : super(const ChatSideState()); var _disposed = false; - ResponsiveNavigatorState get state => value; + ChatSideState get state => value; - set state(ResponsiveNavigatorState newState) { + set state(ChatSideState newState) { if (_disposed || newState == value) return; value = newState; } - @override - ResponsiveNavigatorState get navigatorState => state; + ConversationInfoDestination? get currentDestination => + state.destinations.isEmpty ? null : state.destinations.last; - @override - set navigatorState(ResponsiveNavigatorState value) => state = value; + bool get isRouteMode => state.routeMode; - @override - MaterialPage route(String name, Object? arguments) { - switch (name) { - case infoPage: - return const MaterialPage( - key: ValueKey(infoPage), - name: infoPage, - child: _ChatSidePageBuilder(ChatInfoPage.new), - ); - case circles: - return const MaterialPage( - key: ValueKey(circles), - name: circles, - child: _ChatSidePageBuilder(CircleManagerPage.new), - ); - case searchMessageHistory: - return const MaterialPage( - key: ValueKey(searchMessageHistory), - name: searchMessageHistory, - child: _ChatSidePageBuilder(SearchMessagePage.new), - ); - case sharedMedia: - return const MaterialPage( - key: ValueKey(sharedMedia), - name: sharedMedia, - child: _ChatSidePageBuilder(SharedMediaPage.new), - ); - case participants: - return const MaterialPage( - key: ValueKey(participants), - name: participants, - child: _ChatSidePageBuilder(GroupParticipantsPage.new), - ); - case pinMessages: - return const MaterialPage( - key: ValueKey(pinMessages), - name: pinMessages, - child: _ChatSidePageBuilder(PinMessagesPage.new), - ); - case sharedApps: - return const MaterialPage( - key: ValueKey(sharedApps), - name: sharedApps, - child: _ChatSidePageBuilder(SharedAppsPage.new), - ); - case groupsInCommon: - return const MaterialPage( - key: ValueKey(groupsInCommon), - name: groupsInCommon, - child: _ChatSidePageBuilder(GroupsInCommonPage.new), - ); - case disappearMessages: - return const MaterialPage( - key: ValueKey(disappearMessages), - name: disappearMessages, - child: _ChatSidePageBuilder(DisappearMessagePage.new), - ); - default: - throw ArgumentError('Invalid route'); + bool isCurrentDestination(ConversationInfoDestination destination) => + currentDestination == destination; + + void updateRouteMode(bool routeMode) { + if (state.routeMode == routeMode) return; + Future(() => state = state.copyWith(routeMode: routeMode)); + } + + void openDestination(ConversationInfoDestination destination) { + final destinations = state.destinations.toList(); + final index = destinations.indexOf(destination); + if (destinations.isNotEmpty && index == destinations.length - 1) return; + if (index != -1) destinations.removeRange(index, destinations.length); + state = state.copyWith(destinations: [...destinations, destination]); + } + + void onPopPage() { + if (state.destinations.isEmpty) return; + state = state.copyWith( + destinations: state.destinations.toList()..removeLast(), + ); + } + + void closeDestination() => onPopPage(); + + void pop() { + if (state.destinations.isEmpty) return; + state = state.copyWith( + destinations: state.destinations + .sublist(0, state.destinations.length - 1) + .toList(), + ); + } + + Future replaceDestination( + ConversationInfoDestination destination, + ) async { + state = state.copyWith( + destinations: state.destinations + .where((item) => item != destination) + .toList(), + ); + await Future.delayed(Duration.zero); + openDestination(destination); + } + + Future toggleDestination( + ConversationInfoDestination destination, + ) async { + if (isCurrentDestination(destination)) { + pop(); + return; } + await replaceDestination(destination); + } + + void closeAfterContentJump() { + if (isRouteMode) clear(); } void toggleInfoPage() { - if (state.pages.isEmpty) { - state = state.copyWith(pages: [route(ChatSideNotifier.infoPage, null)]); + if (state.destinations.isEmpty) { + state = state.copyWith( + destinations: [ConversationInfoDestination.infoPage], + ); return; } clear(); } + void clear() { + state = state.copyWith(destinations: []); + } + @override void dispose() { _disposed = true; @@ -141,9 +143,9 @@ class SearchConversationKeywordNotifier VoidCallback? _chatSideListener; var _searchPageVisible = false; - static bool _isSearchPageVisible(ResponsiveNavigatorState state) => - state.pages.any( - (element) => element.name == ChatSideNotifier.searchMessageHistory, + static bool _isSearchPageVisible(ChatSideState state) => + state.destinations.contains( + ConversationInfoDestination.searchMessageHistory, ); static void updateKeyword(BuildContext context, String keyword) { @@ -166,30 +168,3 @@ class SearchConversationKeywordNotifier super.dispose(); } } - -class _ChatSidePageBuilder extends HookConsumerWidget { - const _ChatSidePageBuilder(this.builder); - - final Widget Function(ConversationState conversationState) builder; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final conversationId = useMemoized( - () => ref.read(lastConversationIdProvider), - [], - ); - - final filter = useCallback( - (state) => state?.conversationId == conversationId, - [conversationId], - ); - - final conversationState = ref.watch(filterLastConversationProvider(filter)); - - if (conversationId == null || conversationState == null) { - return const SizedBox(); - } - - return builder(conversationState); - } -} diff --git a/lib/ui/home/notifier/conversation_list_controller.dart b/lib/ui/home/notifier/conversation_list_controller.dart index 2b38f46154..4d55bac1c2 100644 --- a/lib/ui/home/notifier/conversation_list_controller.dart +++ b/lib/ui/home/notifier/conversation_list_controller.dart @@ -13,6 +13,7 @@ import '../../../utils/logger.dart'; import '../../../utils/platform.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/slide_category_provider.dart'; +import '../conversation/conversation_avatar_cache.dart'; import 'paging_controller.dart'; const kDefaultLimit = 15; @@ -35,6 +36,9 @@ class ConversationListController final SlideCategoryStateNotifier slideCategoryStateNotifier; final Database database; final MentionCache mentionCache; + late final ConversationAvatarCache avatarCache = ConversationAvatarCache( + database, + ); final Map _map = {}; final List _subscriptions = []; @@ -108,6 +112,7 @@ class ConversationListController dao.conversationItemsByCategory(state.type, limit, offset).get(), updateEvent, mentionCache, + avatarCache, ); case SlideCategoryType.circle: _map[state] ??= _ConversationPagingController( @@ -120,6 +125,7 @@ class ConversationListController .get(), circleUpdateEvent, mentionCache, + avatarCache, ); case SlideCategoryType.setting: return; @@ -160,6 +166,7 @@ class ConversationListController for (final controller in _map.values) { controller.dispose(); } + avatarCache.dispose(); super.dispose(); } @@ -193,6 +200,7 @@ class _ConversationPagingController extends PagingController { Future> Function(int limit, int offset) queryRange, Stream updateEvent, MentionCache mentionCache, + ConversationAvatarCache avatarCache, ) : _updateEvent = updateEvent, super( itemPositionsListener: ItemPositionsListener.create(), @@ -201,6 +209,7 @@ class _ConversationPagingController extends PagingController { queryRange: (limit, offset) async { final list = await queryRange(limit, offset); unawaited(_warmMentionCache(mentionCache, list)); + unawaited(avatarCache.warm(list)); return list; }, ); diff --git a/lib/ui/home/route/major_navigator.dart b/lib/ui/home/route/major_navigator.dart new file mode 100644 index 0000000000..c90c8133df --- /dev/null +++ b/lib/ui/home/route/major_navigator.dart @@ -0,0 +1,244 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../provider/major_navigation_provider.dart'; +import '../../setting/about_page.dart'; +import '../../setting/account_delete_page.dart'; +import '../../setting/account_page.dart'; +import '../../setting/appearance_page.dart'; +import '../../setting/backup_page.dart'; +import '../../setting/edit_profile_page.dart'; +import '../../setting/notification_page.dart'; +import '../../setting/proxy_page.dart'; +import '../../setting/security_page.dart'; +import '../../setting/storage_page.dart'; +import '../../setting/storage_usage_detail_page.dart'; +import '../../setting/storage_usage_list_page.dart'; +import '../chat/chat_page.dart'; + +class MajorNavigator extends HookConsumerWidget { + const MajorNavigator({ + required this.leftPage, + required this.rightEmptyPage, + required this.switchWidth, + super.key, + }); + + final MaterialPage leftPage; + final MaterialPage rightEmptyPage; + final double switchWidth; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final notifier = ref.watch(majorNavigationProvider.notifier); + final state = ref.watch(majorNavigationProvider); + + return LayoutBuilder( + builder: (context, boxConstraints) { + final routeMode = boxConstraints.maxWidth < switchWidth; + notifier.updateRouteMode(routeMode); + return Row( + children: [ + if (!routeMode) leftPage.child, + Expanded( + child: ClipRect( + child: Navigator( + transitionDelegate: WithoutAnimationDelegate( + routeWithoutAnimation: {leftPage.name, rightEmptyPage.name}, + ), + onDidRemovePage: (page) {}, + pages: [ + if (routeMode) leftPage, + if (!routeMode && state.entries.isEmpty) rightEmptyPage, + ...state.entries.map(_pageFor), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +MaterialPage _pageFor(MajorNavigationEntry entry) { + switch (entry.destination) { + case MajorNavigationDestination.chatPage: + return MaterialPage( + key: const ValueKey(MajorNavigationDestination.chatPage), + name: MajorNavigationDestination.chatPage.name, + child: const ChatPage(), + ); + case MajorNavigationDestination.editProfilePage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.editProfilePage), + name: 'editProfilePage', + child: EditProfilePage(key: ValueKey('editProfilePage')), + ); + case MajorNavigationDestination.notificationPage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.notificationPage), + name: 'notificationPage', + child: NotificationPage(key: ValueKey('notificationPage')), + ); + case MajorNavigationDestination.chatBackupPage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.chatBackupPage), + name: 'chatBackupPage', + child: BackupPage(key: ValueKey('chatBackupPage')), + ); + case MajorNavigationDestination.dataAndStorageUsagePage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.dataAndStorageUsagePage), + name: 'dataAndStorageUsagePage', + child: StoragePage(key: ValueKey('dataAndStorageUsagePage')), + ); + case MajorNavigationDestination.aboutPage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.aboutPage), + name: 'aboutPage', + child: AboutPage(key: ValueKey('aboutPage')), + ); + case MajorNavigationDestination.storageUsage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.storageUsage), + name: 'storageUsage', + child: StorageUsageListPage(key: ValueKey('storageUsage')), + ); + case MajorNavigationDestination.storageUsageDetail: + final arguments = entry.arguments; + if (arguments == null || arguments is! (String, String)) { + throw ArgumentError('Invalid route'); + } + return MaterialPage( + key: const ValueKey(MajorNavigationDestination.storageUsageDetail), + name: 'storageUsageDetail', + child: StorageUsageDetailPage( + key: const ValueKey('storageUsageDetail'), + name: arguments.$1, + conversationId: arguments.$2, + ), + ); + case MajorNavigationDestination.appearancePage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.appearancePage), + name: 'appearancePage', + child: AppearancePage(key: ValueKey('appearancePage')), + ); + case MajorNavigationDestination.accountPage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.accountPage), + name: 'accountPage', + child: AccountPage(key: ValueKey('accountPage')), + ); + case MajorNavigationDestination.accountDeletePage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.accountDeletePage), + name: 'accountDeletePage', + child: AccountDeletePage(key: ValueKey('accountDeletePage')), + ); + case MajorNavigationDestination.proxyPage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.proxyPage), + name: 'proxyPage', + child: ProxyPage(key: ValueKey('proxyPage')), + ); + case MajorNavigationDestination.securityPage: + return const MaterialPage( + key: ValueKey(MajorNavigationDestination.securityPage), + name: 'securityPage', + child: SecurityPage(key: ValueKey('securityPage')), + ); + } +} + +class WithoutAnimationDelegate extends TransitionDelegate { + const WithoutAnimationDelegate({required this.routeWithoutAnimation}) + : super(); + + final Set routeWithoutAnimation; + + @override + Iterable resolve({ + required List newPageRouteHistory, + required Map + locationToExitingPageRoute, + required Map> + pageRouteToPagelessRoutes, + }) { + final results = []; + + void handleExitingRoute(RouteTransitionRecord? location, bool isLast) { + final exitingPageRoute = locationToExitingPageRoute[location]; + if (exitingPageRoute == null) return; + if (exitingPageRoute.isWaitingForExitingDecision) { + final hasPagelessRoute = pageRouteToPagelessRoutes.containsKey( + exitingPageRoute, + ); + final isLastExitingPageRoute = + isLast && !locationToExitingPageRoute.containsKey(exitingPageRoute); + if (isLastExitingPageRoute && !hasPagelessRoute) { + exitingPageRoute.markForPop(exitingPageRoute.route.currentResult); + } else { + exitingPageRoute.markForComplete( + exitingPageRoute.route.currentResult, + ); + } + if (hasPagelessRoute) { + final pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute]; + for (final pagelessRoute in pagelessRoutes!) { + if (pagelessRoute.isWaitingForExitingDecision) { + if (isLastExitingPageRoute && + pagelessRoute == pagelessRoutes.last) { + pagelessRoute.markForPop(pagelessRoute.route.currentResult); + } else { + pagelessRoute.markForComplete( + pagelessRoute.route.currentResult, + ); + } + } + } + } + } + results.add(exitingPageRoute); + handleExitingRoute(exitingPageRoute, isLast); + } + + handleExitingRoute(null, newPageRouteHistory.isEmpty); + + for (final pageRoute in newPageRouteHistory) { + final isLastIteration = newPageRouteHistory.last == pageRoute; + if (pageRoute.isWaitingForEnteringDecision) { + if (!locationToExitingPageRoute.containsKey(pageRoute) && + isLastIteration && + showAnimation(pageRoute, locationToExitingPageRoute)) { + pageRoute.markForPush(); + } else { + pageRoute.markForAdd(); + } + } + results.add(pageRoute); + handleExitingRoute(pageRoute, isLastIteration); + } + return results; + } + + bool showAnimation( + RouteTransitionRecord pageRoute, + Map + locationToExitingPageRoute, + ) { + final routes = + { + pageRoute, + ...locationToExitingPageRoute.keys, + ...locationToExitingPageRoute.values, + } + .map((e) => e?.route.settings.name) + .where((element) => element != null) + .toSet(); + return !setEquals(routeWithoutAnimation, routes); + } +} diff --git a/lib/ui/home/route/responsive_navigator.dart b/lib/ui/home/route/responsive_navigator.dart deleted file mode 100644 index 773f2f0682..0000000000 --- a/lib/ui/home/route/responsive_navigator.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../../provider/responsive_navigator_provider.dart'; - -class ResponsiveNavigator extends HookConsumerWidget { - const ResponsiveNavigator({ - required this.leftPage, - required this.rightEmptyPage, - required this.switchWidth, - super.key, - }); - - final MaterialPage leftPage; - final MaterialPage rightEmptyPage; - final double switchWidth; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final responsiveNavigatorNotifier = ref.watch( - responsiveNavigatorProvider.notifier, - ); - final responsiveNavigatorState = ref.watch(responsiveNavigatorProvider); - - return LayoutBuilder( - builder: (context, boxConstraints) { - final routeMode = boxConstraints.maxWidth < switchWidth; - responsiveNavigatorNotifier.updateRouteMode(routeMode); - return Row( - children: [ - if (!routeMode) leftPage.child, - Expanded( - child: ClipRect( - child: Navigator( - transitionDelegate: WithoutAnimationDelegate( - routeWithoutAnimation: {leftPage.name, rightEmptyPage.name}, - ), - onDidRemovePage: (page) {}, - pages: [ - if (routeMode) leftPage, - if (!routeMode && responsiveNavigatorState.pages.isEmpty) - rightEmptyPage, - ...responsiveNavigatorState.pages, - ], - ), - ), - ), - ], - ); - }, - ); - } -} - -class WithoutAnimationDelegate extends TransitionDelegate { - /// Creates a default transition delegate. - const WithoutAnimationDelegate({required this.routeWithoutAnimation}) - : super(); - - final Set routeWithoutAnimation; - - @override - Iterable resolve({ - required List newPageRouteHistory, - required Map - locationToExitingPageRoute, - required Map> - pageRouteToPagelessRoutes, - }) { - final results = []; - // This method will handle the exiting route and its corresponding pageless - // route at this location. It will also recursively check if there is any - // other exiting routes above it and handle them accordingly. - void handleExitingRoute(RouteTransitionRecord? location, bool isLast) { - final exitingPageRoute = locationToExitingPageRoute[location]; - if (exitingPageRoute == null) return; - if (exitingPageRoute.isWaitingForExitingDecision) { - final hasPagelessRoute = pageRouteToPagelessRoutes.containsKey( - exitingPageRoute, - ); - final isLastExitingPageRoute = - isLast && !locationToExitingPageRoute.containsKey(exitingPageRoute); - if (isLastExitingPageRoute && !hasPagelessRoute) { - exitingPageRoute.markForPop(exitingPageRoute.route.currentResult); - } else { - exitingPageRoute.markForComplete( - exitingPageRoute.route.currentResult, - ); - } - if (hasPagelessRoute) { - final pagelessRoutes = pageRouteToPagelessRoutes[exitingPageRoute]; - for (final pagelessRoute in pagelessRoutes!) { - // It is possible that a pageless route that belongs to an exiting - // page-based route does not require exiting decision. This can - // happen if the page list is updated right after a Navigator.pop. - if (pagelessRoute.isWaitingForExitingDecision) { - if (isLastExitingPageRoute && - pagelessRoute == pagelessRoutes.last) { - pagelessRoute.markForPop(pagelessRoute.route.currentResult); - } else { - pagelessRoute.markForComplete( - pagelessRoute.route.currentResult, - ); - } - } - } - } - } - results.add(exitingPageRoute); - - // It is possible there is another exiting route above this exitingPageRoute. - handleExitingRoute(exitingPageRoute, isLast); - } - - // Handles exiting route in the beginning of list. - handleExitingRoute(null, newPageRouteHistory.isEmpty); - - for (final pageRoute in newPageRouteHistory) { - final isLastIteration = newPageRouteHistory.last == pageRoute; - if (pageRoute.isWaitingForEnteringDecision) { - if (!locationToExitingPageRoute.containsKey(pageRoute) && - isLastIteration && - showAnimation(pageRoute, locationToExitingPageRoute)) { - pageRoute.markForPush(); - } else { - pageRoute.markForAdd(); - } - } - results.add(pageRoute); - handleExitingRoute(pageRoute, isLastIteration); - } - return results; - } - - bool showAnimation( - RouteTransitionRecord pageRoute, - Map - locationToExitingPageRoute, - ) { - final routes = - { - pageRoute, - ...locationToExitingPageRoute.keys, - ...locationToExitingPageRoute.values, - } - .map((e) => e?.route.settings.name) - .where((element) => element != null) - .toSet(); - return !setEquals(routeWithoutAnimation, routes); - } -} diff --git a/lib/ui/provider/abstract_responsive_navigator.dart b/lib/ui/provider/abstract_responsive_navigator.dart deleted file mode 100644 index 8244fd96f5..0000000000 --- a/lib/ui/provider/abstract_responsive_navigator.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:math'; - -import 'package:equatable/equatable.dart'; -import 'package:flutter/material.dart'; - -import '../../utils/rivepod.dart'; - -class ResponsiveNavigatorState extends Equatable { - const ResponsiveNavigatorState({ - this.pages = const [], - this.routeMode = false, - }); - - final List pages; - final bool routeMode; - - @override - List get props => [ - routeMode, - ...pages.map((e) => (e.key, e.name, e.arguments, e.child.key)), - ]; - - ResponsiveNavigatorState copyWith({ - List? pages, - bool? routeMode, - }) => ResponsiveNavigatorState( - pages: pages ?? this.pages, - routeMode: routeMode ?? this.routeMode, - ); -} - -abstract class AbstractResponsiveNavigatorStateNotifier - extends DistinctStateNotifier - with ResponsiveNavigatorController { - AbstractResponsiveNavigatorStateNotifier(super.initialState); - - @override - ResponsiveNavigatorState get navigatorState => state; - - @override - set navigatorState(ResponsiveNavigatorState value) => state = value; -} - -mixin ResponsiveNavigatorController { - ResponsiveNavigatorState get navigatorState; - - set navigatorState(ResponsiveNavigatorState value); - - MaterialPage route(String name, Object? arguments); - - void updateRouteMode(bool routeMode) { - if (navigatorState.routeMode == routeMode) return; - Future( - () => navigatorState = navigatorState.copyWith(routeMode: routeMode), - ); - } - - void onPopPage() { - if (navigatorState.pages.isNotEmpty) { - navigatorState = navigatorState.copyWith( - pages: navigatorState.pages.toList()..removeLast(), - ); - } - } - - void pushPage(String name, {Object? arguments}) { - final page = route(name, arguments); - final pages = navigatorState.pages.toList(); - final index = pages.indexWhere( - (element) => - page.child.key != null && element.child.key == page.child.key, - ); - if (pages.isNotEmpty && index == pages.length - 1) return; - if (index != -1) pages.removeRange(max(index, 0), pages.length); - navigatorState = navigatorState.copyWith(pages: [...pages, page]); - } - - void popUntil(bool Function(MaterialPage page) test) { - final index = navigatorState.pages.indexWhere(test); - if (index == -1) return; - - final list = index == 0 - ? [] - : navigatorState.pages.sublist(0, index); - navigatorState = navigatorState.copyWith(pages: list); - } - - void popWhere(bool Function(MaterialPage page) test) => - navigatorState = navigatorState.copyWith( - pages: navigatorState.pages.toList()..removeWhere(test), - ); - - void pop() => navigatorState = navigatorState.copyWith( - pages: navigatorState.pages - .sublist(0, max(navigatorState.pages.length - 1, 0)) - .toList(), - ); - - Future replace(String name, {Object? arguments}) async { - popWhere((page) => page.name == name); - await Future.delayed(Duration.zero); - pushPage(name, arguments: arguments); - } - - void clear() => navigatorState = navigatorState.copyWith(pages: []); -} diff --git a/lib/ui/provider/conversation_provider.dart b/lib/ui/provider/conversation_provider.dart index 1e870986f7..ff67c176a8 100644 --- a/lib/ui/provider/conversation_provider.dart +++ b/lib/ui/provider/conversation_provider.dart @@ -1,13 +1,9 @@ -import 'dart:async'; - import 'package:equatable/equatable.dart'; -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide User; import 'package:rxdart/rxdart.dart'; import '../../account/account_server.dart'; -import '../../crypto/uuid/uuid.dart'; import '../../db/dao/conversation_dao.dart'; import '../../db/database.dart'; import '../../db/database_event_bus.dart'; @@ -17,13 +13,10 @@ import '../../utils/app_lifecycle.dart'; import '../../utils/extension/extension.dart'; import '../../utils/local_notification_center.dart'; import '../../utils/rivepod.dart'; -import '../../widgets/toast.dart'; -import '../home/notifier/conversation_list_controller.dart'; +import '../home/conversation_info_destination.dart'; import '../home/notifier/subscriber_mixin.dart'; import 'account_server_provider.dart'; -import 'is_bot_group_provider.dart'; -import 'recent_conversation_provider.dart'; -import 'responsive_navigator_provider.dart'; +import 'major_navigation_provider.dart'; class ConversationState extends Equatable { const ConversationState({ @@ -50,7 +43,7 @@ class ConversationState extends Equatable { final Participant? participant; final Object refreshKey; - final String? initialSidePage; + final ConversationInfoDestination? initialSidePage; final String? keyword; bool get isLoaded => conversation != null || user != null; @@ -109,7 +102,7 @@ class ConversationState extends Equatable { App? app, Participant? participant, Object? refreshKey, - String? initialSidePage, + ConversationInfoDestination? initialSidePage, String? keyword, }) => ConversationState( conversationId: conversationId ?? this.conversationId, @@ -140,8 +133,8 @@ class ConversationStateNotifier with SubscriberMixin { ConversationStateNotifier({ required AccountServer accountServer, - required ResponsiveNavigatorStateNotifier responsiveNavigatorStateNotifier, - }) : _responsiveNavigatorStateNotifier = responsiveNavigatorStateNotifier, + required MajorNavigationNotifier majorNavigationNotifier, + }) : _majorNavigationNotifier = majorNavigationNotifier, _accountServer = accountServer, super(null) { addSubscription( @@ -226,7 +219,7 @@ class ConversationStateNotifier } final AccountServer _accountServer; - final ResponsiveNavigatorStateNotifier _responsiveNavigatorStateNotifier; + final MajorNavigationNotifier _majorNavigationNotifier; late final Database _database = _accountServer.database; late final String _currentUserId = _accountServer.userId; @@ -245,174 +238,16 @@ class ConversationStateNotifier void unselected() { state = null; - _responsiveNavigatorStateNotifier.clear(); + _majorNavigationNotifier.clear(); } - static Future selectConversation( - BuildContext context, - String conversationId, { - ConversationItem? conversation, - String? initIndexMessageId, - String? initialChatSidePage, - String? keyword, - bool sync = false, - bool checkCurrentUserExist = false, - }) async { - context.providerContainer.read(isBotGroupProvider(conversationId)); - - final accountServer = context.accountServer; - final database = context.database; - final conversationNotifier = context.providerContainer.read( - conversationProvider.notifier, - ); - final state = conversationNotifier.state; - - ConversationItem? _conversation; - String? lastReadMessageId; - bool? hasUnreadMessage; - if (state?.conversationId == conversationId) { - _conversation = state?.conversation; - - hasUnreadMessage = (_conversation?.unseenMessageCount ?? 0) > 0; - if (hasUnreadMessage) { - lastReadMessageId = state?.lastReadMessageId; - } - } - - _conversation = - conversation ?? - _conversation ?? - await _conversationItem(context, conversationId); - - if (_conversation == null && sync) { - showToastLoading(); - await context.accountServer.refreshConversation( - conversationId, - checkCurrentUserExist: checkCurrentUserExist, - ); - _conversation = await _conversationItem(context, conversationId); - } - - hasUnreadMessage ??= (_conversation?.unseenMessageCount ?? 0) > 0; - - if (_conversation == null) { - return showToastFailed(null); - } - - final _initIndexMessageId = - initIndexMessageId ?? - (hasUnreadMessage ? _conversation.lastReadMessageId : null); - - lastReadMessageId = - lastReadMessageId ?? (hasUnreadMessage ? _initIndexMessageId : null); - - final ownerId = _conversation.ownerId; - - final appFuture = (!_conversation.isGroupConversation && ownerId != null) - ? database.appDao.findAppById(ownerId) - : null; - - final participantFuture = database.participantDao - .participantById(conversationId, accountServer.userId) - .getSingleOrNull(); - - final conversationState = ConversationState( - conversationId: conversationId, - conversation: _conversation, - initIndexMessageId: _initIndexMessageId, - lastReadMessageId: lastReadMessageId, - userId: _conversation.isGroupConversation ? null : _conversation.ownerId, - app: await appFuture, - initialSidePage: initialChatSidePage, - refreshKey: Object(), - participant: await participantFuture, - keyword: keyword, - ); - - Toast.dismiss(); - - conversationNotifier.state = conversationState; - - conversationNotifier._responsiveNavigatorStateNotifier.pushPage( - ResponsiveNavigatorStateNotifier.chatPage, - ); - - unawaited(dismissByConversationId(conversationId)); - context.providerContainer - .read(recentConversationIDsProvider.notifier) - .add(conversationId); - } - - static Future selectUser( - BuildContext context, - String userId, { - User? user, - String? initialChatSidePage, - }) async { - final accountServer = context.accountServer; - final database = context.database; - final conversationNotifier = context.providerContainer.read( - conversationProvider.notifier, - ); - - final conversationId = generateConversationId(userId, accountServer.userId); - - context.providerContainer.read(isBotGroupProvider(conversationId)); - - final conversation = await _conversationItem(context, conversationId); - if (conversation != null) { - return selectConversation( - context, - conversationId, - conversation: conversation, - initialChatSidePage: initialChatSidePage, - ); - } - - final _user = - user ?? await database.userDao.userById(userId).getSingleOrNull(); - - if (_user == null) { - return showToastFailed(ToastError(context.l10n.userNotFound)); - } - - final app = await database.appDao.findAppById(userId); - - conversationNotifier.state = ConversationState( - conversationId: conversationId, - userId: userId, - user: _user, - app: app, - initialSidePage: initialChatSidePage, - refreshKey: Object(), - ); - - conversationNotifier._responsiveNavigatorStateNotifier.pushPage( - ResponsiveNavigatorStateNotifier.chatPage, - ); - - unawaited(dismissByConversationId(conversationId)); + void openChatPage() { + _majorNavigationNotifier.openChatPage(); } - static Future _conversationItem( - BuildContext context, - String conversationId, - ) async { - final conversations = context - .read() - .state - .map - .values - .cast() - .toList(); - - return conversations.firstWhere( - (element) => element?.conversationId == conversationId, - orElse: () => null, - ) ?? - await context.database.conversationDao - .conversationItem(conversationId) - .getSingleOrNull(); + void focus(ConversationState nextState) { + state = nextState; + openChatPage(); } } @@ -436,20 +271,20 @@ final conversationProvider = throw Exception('accountServer is not ready'); } - final responsiveNavigatorNotifier = ref.watch( - responsiveNavigatorProvider.notifier, + final majorNavigationNotifier = ref.watch( + majorNavigationProvider.notifier, ); ref ..listen(accountServerProvider, (previous, next) => keepAlive.close()) ..listen( - responsiveNavigatorProvider.notifier, + majorNavigationProvider.notifier, (previous, next) => keepAlive.close(), ); return ConversationStateNotifier( accountServer: accountServerAsync.requireValue, - responsiveNavigatorStateNotifier: responsiveNavigatorNotifier, + majorNavigationNotifier: majorNavigationNotifier, ); }); diff --git a/lib/ui/provider/major_navigation_provider.dart b/lib/ui/provider/major_navigation_provider.dart new file mode 100644 index 0000000000..42ae8c8bc7 --- /dev/null +++ b/lib/ui/provider/major_navigation_provider.dart @@ -0,0 +1,134 @@ +import 'package:equatable/equatable.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../utils/rivepod.dart'; + +enum MajorNavigationDestination { + chatPage, + editProfilePage, + accountPage, + accountDeletePage, + notificationPage, + chatBackupPage, + dataAndStorageUsagePage, + appearancePage, + aboutPage, + storageUsage, + storageUsageDetail, + proxyPage, + securityPage, +} + +class MajorNavigationEntry extends Equatable { + const MajorNavigationEntry(this.destination, {this.arguments}); + + final MajorNavigationDestination destination; + final Object? arguments; + + @override + List get props => [destination, arguments]; +} + +class MajorNavigationState extends Equatable { + const MajorNavigationState({ + this.entries = const [], + this.routeMode = false, + }); + + final List entries; + final bool routeMode; + + bool get showsConversationSelection => !routeMode; + + bool contains(MajorNavigationDestination destination) => + entries.any((entry) => entry.destination == destination); + + MajorNavigationState copyWith({ + List? entries, + bool? routeMode, + }) => MajorNavigationState( + entries: entries ?? this.entries, + routeMode: routeMode ?? this.routeMode, + ); + + @override + List get props => [routeMode, entries]; +} + +class MajorNavigationNotifier + extends DistinctStateNotifier { + MajorNavigationNotifier() : super(const MajorNavigationState()); + + static const settingDestinations = { + MajorNavigationDestination.editProfilePage, + MajorNavigationDestination.notificationPage, + MajorNavigationDestination.chatBackupPage, + MajorNavigationDestination.dataAndStorageUsagePage, + MajorNavigationDestination.appearancePage, + MajorNavigationDestination.aboutPage, + MajorNavigationDestination.storageUsage, + MajorNavigationDestination.storageUsageDetail, + MajorNavigationDestination.accountPage, + MajorNavigationDestination.accountDeletePage, + MajorNavigationDestination.proxyPage, + MajorNavigationDestination.securityPage, + }; + + void updateRouteMode(bool routeMode) { + if (state.routeMode == routeMode) return; + Future(() => state = state.copyWith(routeMode: routeMode)); + } + + void clear() { + state = state.copyWith(entries: []); + } + + void openChatPage() { + open(MajorNavigationDestination.chatPage); + } + + bool syncSettingCategory(bool isSetting) { + final openSettingPage = isSetting && !state.routeMode; + _removeWhere((entry) { + if (state.routeMode) return true; + return settingDestinations.contains(entry.destination); + }); + if (openSettingPage) { + open(settingDestinations.first); + } + return openSettingPage; + } + + void openSetting(MajorNavigationDestination destination) { + _removeWhere((entry) => settingDestinations.contains(entry.destination)); + open(destination); + } + + void open(MajorNavigationDestination destination, {Object? arguments}) { + final entry = MajorNavigationEntry(destination, arguments: arguments); + final entries = state.entries.toList(); + final index = entries.indexWhere( + (item) => item.destination == destination, + ); + if (entries.isNotEmpty && index == entries.length - 1) return; + if (index != -1) entries.removeRange(index, entries.length); + state = state.copyWith(entries: [...entries, entry]); + } + + void _removeWhere(bool Function(MajorNavigationEntry entry) test) { + state = state.copyWith(entries: state.entries.toList()..removeWhere(test)); + } +} + +final majorNavigationProvider = + StateNotifierProvider.autoDispose< + MajorNavigationNotifier, + MajorNavigationState + >((ref) => MajorNavigationNotifier()); + +final majorNavigationRouteModeProvider = majorNavigationProvider.select( + (value) => value.routeMode, +); + +final majorNavigationShowsConversationSelectionProvider = + majorNavigationProvider.select((value) => value.showsConversationSelection); diff --git a/lib/ui/provider/pending_chat_jump_provider.dart b/lib/ui/provider/pending_chat_jump_provider.dart new file mode 100644 index 0000000000..7594ea09b6 --- /dev/null +++ b/lib/ui/provider/pending_chat_jump_provider.dart @@ -0,0 +1,41 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'conversation_provider.dart'; + +final pendingChatJumpProvider = StateProvider.autoDispose(( + ref, +) { + final keepAlive = ref.keepAlive(); + + ref.listen(currentConversationIdProvider, (previous, next) { + keepAlive.close(); + ref.invalidateSelf(); + }); + + return null; +}); + +class PendingChatJump { + const PendingChatJump.commandMessage(this.messageId) + : kind = PendingChatJumpKind.command; + + const PendingChatJump.commandLatest() + : kind = PendingChatJumpKind.command, + messageId = null; + + const PendingChatJump.returnToMessage(this.messageId) + : kind = PendingChatJumpKind.returnTarget; + + final PendingChatJumpKind kind; + + final String? messageId; + + bool get isLatest => messageId == null; + + bool get isCommand => kind == PendingChatJumpKind.command; + + String? get returnMessageId => + kind == PendingChatJumpKind.returnTarget ? messageId : null; +} + +enum PendingChatJumpKind { command, returnTarget } diff --git a/lib/ui/provider/pending_jump_message_provider.dart b/lib/ui/provider/pending_jump_message_provider.dart deleted file mode 100644 index 251cb51e58..0000000000 --- a/lib/ui/provider/pending_jump_message_provider.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'conversation_provider.dart'; - -final pendingJumpMessageProvider = StateProvider.autoDispose((ref) { - final keepAlive = ref.keepAlive(); - - ref.listen(currentConversationIdProvider, (previous, next) { - keepAlive.close(); - ref.invalidateSelf(); - }); - - return null; -}); - -final pendingJumpLatestProvider = StateProvider.autoDispose((ref) { - final keepAlive = ref.keepAlive(); - - ref.listen(currentConversationIdProvider, (previous, next) { - keepAlive.close(); - ref.invalidateSelf(); - }); - - return null; -}); diff --git a/lib/ui/provider/responsive_navigator_provider.dart b/lib/ui/provider/responsive_navigator_provider.dart deleted file mode 100644 index 94e1c0c156..0000000000 --- a/lib/ui/provider/responsive_navigator_provider.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../home/chat/chat_page.dart'; -import '../setting/about_page.dart'; -import '../setting/account_delete_page.dart'; -import '../setting/account_page.dart'; -import '../setting/appearance_page.dart'; -import '../setting/backup_page.dart'; -import '../setting/edit_profile_page.dart'; -import '../setting/notification_page.dart'; -import '../setting/proxy_page.dart'; -import '../setting/security_page.dart'; -import '../setting/storage_page.dart'; -import '../setting/storage_usage_detail_page.dart'; -import '../setting/storage_usage_list_page.dart'; -import 'abstract_responsive_navigator.dart'; - -class ResponsiveNavigatorStateNotifier - extends AbstractResponsiveNavigatorStateNotifier { - ResponsiveNavigatorStateNotifier() : super(const ResponsiveNavigatorState()); - - final _chatPageKey = GlobalKey(); - - static const chatPage = 'chatPage'; - static const editProfilePage = 'editProfilePage'; - static const accountPage = 'accountPage'; - static const accountDeletePage = 'accountDeletePage'; - static const storagePage = 'storagePage'; - static const notificationPage = 'notificationPage'; - static const chatBackupPage = 'chatBackupPage'; - static const dataAndStorageUsagePage = 'dataAndStorageUsagePage'; - static const appearancePage = 'appearancePage'; - static const aboutPage = 'aboutPage'; - static const storageUsage = 'storageUsage'; - static const storageUsageDetail = 'storageUsageDetail'; - static const proxyPage = 'proxyPage'; - static const securityPage = 'securityPage'; - - static const settingPageNameSet = { - editProfilePage, - notificationPage, - chatBackupPage, - dataAndStorageUsagePage, - appearancePage, - aboutPage, - storageUsage, - storageUsageDetail, - accountPage, - accountDeletePage, - proxyPage, - securityPage, - }; - - @override - MaterialPage route(String name, Object? arguments) { - switch (name) { - case chatPage: - return MaterialPage( - key: const ValueKey(chatPage), - name: chatPage, - child: ChatPage(key: _chatPageKey), - ); - case editProfilePage: - return const MaterialPage( - key: ValueKey(editProfilePage), - name: editProfilePage, - child: EditProfilePage(key: ValueKey(editProfilePage)), - ); - case notificationPage: - return const MaterialPage( - key: ValueKey(notificationPage), - name: notificationPage, - child: NotificationPage(key: ValueKey(notificationPage)), - ); - case chatBackupPage: - return const MaterialPage( - key: ValueKey(chatBackupPage), - name: chatBackupPage, - child: BackupPage(key: ValueKey(chatBackupPage)), - ); - case dataAndStorageUsagePage: - return const MaterialPage( - key: ValueKey(dataAndStorageUsagePage), - name: dataAndStorageUsagePage, - child: StoragePage(key: ValueKey(dataAndStorageUsagePage)), - ); - case aboutPage: - return const MaterialPage( - key: ValueKey(aboutPage), - name: aboutPage, - child: AboutPage(key: ValueKey(aboutPage)), - ); - case storageUsage: - return const MaterialPage( - key: ValueKey(storageUsage), - name: storageUsage, - child: StorageUsageListPage(key: ValueKey(storageUsage)), - ); - case storageUsageDetail: - if (arguments == null || arguments is! (String, String)) { - throw ArgumentError('Invalid route'); - } - - return MaterialPage( - key: const ValueKey(storageUsageDetail), - name: storageUsageDetail, - child: StorageUsageDetailPage( - key: const ValueKey(storageUsageDetail), - name: arguments.$1, - conversationId: arguments.$2, - ), - ); - case appearancePage: - return const MaterialPage( - key: ValueKey(appearancePage), - name: appearancePage, - child: AppearancePage(key: ValueKey(appearancePage)), - ); - case accountPage: - return const MaterialPage( - key: ValueKey(accountPage), - name: accountPage, - child: AccountPage(key: ValueKey(accountPage)), - ); - case accountDeletePage: - return const MaterialPage( - key: ValueKey(accountDeletePage), - name: accountDeletePage, - child: AccountDeletePage(key: ValueKey(accountDeletePage)), - ); - case proxyPage: - return const MaterialPage( - key: ValueKey(proxyPage), - name: proxyPage, - child: ProxyPage(key: ValueKey(proxyPage)), - ); - case securityPage: - return const MaterialPage( - key: ValueKey(securityPage), - name: securityPage, - child: SecurityPage(key: ValueKey(securityPage)), - ); - default: - throw ArgumentError('Invalid route'); - } - } -} - -final responsiveNavigatorProvider = - StateNotifierProvider.autoDispose< - ResponsiveNavigatorStateNotifier, - ResponsiveNavigatorState - >((ref) => ResponsiveNavigatorStateNotifier()); - -final navigatorRouteModeProvider = responsiveNavigatorProvider.select( - (value) => value.routeMode, -); diff --git a/lib/ui/setting/account_page.dart b/lib/ui/setting/account_page.dart index c0cde327a8..8a343b8651 100644 --- a/lib/ui/setting/account_page.dart +++ b/lib/ui/setting/account_page.dart @@ -5,7 +5,7 @@ import '../../utils/extension/extension.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/cell.dart'; import '../../widgets/user/change_number_dialog.dart'; -import '../provider/responsive_navigator_provider.dart'; +import '../provider/major_navigation_provider.dart'; class AccountPage extends HookConsumerWidget { const AccountPage({super.key}); @@ -32,9 +32,9 @@ class AccountPage extends HookConsumerWidget { child: CellItem( title: Text(context.l10n.deleteMyAccount), onTap: () => ref - .read(responsiveNavigatorProvider.notifier) - .pushPage( - ResponsiveNavigatorStateNotifier.accountDeletePage, + .read(majorNavigationProvider.notifier) + .open( + MajorNavigationDestination.accountDeletePage, ), ), ), diff --git a/lib/ui/setting/setting_page.dart b/lib/ui/setting/setting_page.dart index 3e66506620..6fe6c85fde 100644 --- a/lib/ui/setting/setting_page.dart +++ b/lib/ui/setting/setting_page.dart @@ -19,16 +19,16 @@ import '../../widgets/cell.dart'; import '../../widgets/conversation/badges_widget.dart'; import '../../widgets/high_light_text.dart'; import '../../widgets/toast.dart'; -import '../home/home.dart'; +import '../home/left_rail_controller.dart'; +import '../provider/major_navigation_provider.dart'; import '../provider/multi_auth_provider.dart'; -import '../provider/responsive_navigator_provider.dart'; class SettingPage extends HookConsumerWidget { const SettingPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final hasDrawer = context.watch(); + final hasDrawer = context.watch(); Widget? leading; if (hasDrawer.value) { @@ -70,8 +70,7 @@ class SettingPage extends HookConsumerWidget { CellGroup( child: _Item( leadingAssetName: Resources.assetsImagesIcProfileSvg, - pageName: - ResponsiveNavigatorStateNotifier.editProfilePage, + destination: MajorNavigationDestination.editProfilePage, title: context.l10n.editProfile, ), ), @@ -84,15 +83,15 @@ class SettingPage extends HookConsumerWidget { _Item( leadingAssetName: Resources.assetsImagesAccountSvg, - pageName: - ResponsiveNavigatorStateNotifier.accountPage, + destination: + MajorNavigationDestination.accountPage, title: context.l10n.account, ), _Item( leadingAssetName: Resources.assetsImagesIcNotificationSvg, - pageName: ResponsiveNavigatorStateNotifier - .notificationPage, + destination: + MajorNavigationDestination.notificationPage, title: context.l10n.notifications, trailing: hasNotificationPermission == false ? Padding( @@ -115,33 +114,31 @@ class SettingPage extends HookConsumerWidget { _Item( leadingAssetName: Resources.assetsImagesIcStorageUsageSvg, - pageName: ResponsiveNavigatorStateNotifier + destination: MajorNavigationDestination .dataAndStorageUsagePage, title: context.l10n.dataAndStorageUsage, ), _Item( leadingAssetName: Resources.assetsImagesShieldSvg, - pageName: - ResponsiveNavigatorStateNotifier.securityPage, + destination: + MajorNavigationDestination.securityPage, title: context.l10n.security, ), _Item( leadingAssetName: Resources.assetsImagesProxySvg, - pageName: - ResponsiveNavigatorStateNotifier.proxyPage, + destination: MajorNavigationDestination.proxyPage, title: context.l10n.proxy, ), _Item( leadingAssetName: Resources.assetsImagesIcAppearanceSvg, - pageName: - ResponsiveNavigatorStateNotifier.appearancePage, + destination: + MajorNavigationDestination.appearancePage, title: context.l10n.appearance, ), _Item( leadingAssetName: Resources.assetsImagesIcAboutSvg, - pageName: - ResponsiveNavigatorStateNotifier.aboutPage, + destination: MajorNavigationDestination.aboutPage, title: context.l10n.about, ), ], @@ -177,7 +174,7 @@ class _Item extends HookConsumerWidget { const _Item({ required this.title, this.leadingAssetName, - this.pageName, + this.destination, this.color, this.onTap, this.trailing = const Arrow(), @@ -185,7 +182,7 @@ class _Item extends HookConsumerWidget { final String? leadingAssetName; final String title; - final String? pageName; + final MajorNavigationDestination? destination; final Color? color; final VoidCallback? onTap; final Widget? trailing; @@ -193,10 +190,11 @@ class _Item extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final selected = ref.watch( - responsiveNavigatorProvider.select( + majorNavigationProvider.select( (value) => !value.routeMode && - value.pages.any((element) => pageName == element.name), + destination != null && + value.contains(destination!), ), ); @@ -216,13 +214,10 @@ class _Item extends HookConsumerWidget { color: color ?? context.theme.text, selected: selected, onTap: () { - if (onTap == null && pageName != null) { - context.providerContainer.read(responsiveNavigatorProvider.notifier) - ..popWhere( - (page) => ResponsiveNavigatorStateNotifier.settingPageNameSet - .contains(page.name), - ) - ..pushPage(pageName!); + if (onTap == null && destination != null) { + context.providerContainer + .read(majorNavigationProvider.notifier) + .openSetting(destination!); return; } diff --git a/lib/ui/setting/storage_page.dart b/lib/ui/setting/storage_page.dart index 3006339476..dd3597cb1d 100644 --- a/lib/ui/setting/storage_page.dart +++ b/lib/ui/setting/storage_page.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../utils/extension/extension.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/cell.dart'; -import '../provider/responsive_navigator_provider.dart'; +import '../provider/major_navigation_provider.dart'; import '../provider/setting_provider.dart'; class StoragePage extends HookConsumerWidget { @@ -95,9 +95,9 @@ class StoragePage extends HookConsumerWidget { child: CellItem( title: Text(context.l10n.storageUsage), onTap: () => ref - .read(responsiveNavigatorProvider.notifier) - .pushPage( - ResponsiveNavigatorStateNotifier.storageUsage, + .read(majorNavigationProvider.notifier) + .open( + MajorNavigationDestination.storageUsage, ), ), ), diff --git a/lib/ui/setting/storage_usage_list_page.dart b/lib/ui/setting/storage_usage_list_page.dart index 4730c0ec76..956c33574b 100644 --- a/lib/ui/setting/storage_usage_list_page.dart +++ b/lib/ui/setting/storage_usage_list_page.dart @@ -11,7 +11,7 @@ import '../../utils/hook.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/avatar_view/avatar_view.dart'; import '../../widgets/cell.dart'; -import '../provider/responsive_navigator_provider.dart'; +import '../provider/major_navigation_provider.dart'; class StorageUsageListPage extends HookConsumerWidget { const StorageUsageListPage({super.key}); @@ -120,9 +120,9 @@ class _Item extends HookConsumerWidget { ], ), onTap: () => ref - .read(responsiveNavigatorProvider.notifier) - .pushPage( - ResponsiveNavigatorStateNotifier.storageUsageDetail, + .read(majorNavigationProvider.notifier) + .open( + MajorNavigationDestination.storageUsageDetail, arguments: ( conversationValidName(item.name, item.fullName), item.conversationId, diff --git a/lib/utils/uri_utils.dart b/lib/utils/uri_utils.dart index 11cf5a8300..3700203881 100644 --- a/lib/utils/uri_utils.dart +++ b/lib/utils/uri_utils.dart @@ -6,7 +6,7 @@ import '../constants/constants.dart'; import '../crypto/uuid/uuid.dart'; import '../db/mixin_database.dart' hide User; import '../enum/encrypt_category.dart'; -import '../ui/provider/conversation_provider.dart'; +import '../ui/home/conversation/conversation_focus.dart'; import '../widgets/conversation/conversation_dialog.dart'; import '../widgets/message/item/action_card/action_card_data.dart'; import '../widgets/message/item/transfer/transfer_page.dart'; @@ -85,7 +85,7 @@ Future openUri( return false; } - await ConversationStateNotifier.selectConversation( + await ConversationFocus.selectConversation( context, conversation.conversationId, conversation: conversation, @@ -291,12 +291,12 @@ Future _selectConversation( showToastFailed(null); return false; } else { - await ConversationStateNotifier.selectUser(context, userId); + await ConversationFocus.selectUser(context, userId); return true; } } - await ConversationStateNotifier.selectConversation( + await ConversationFocus.selectConversation( context, conversationId, sync: true, diff --git a/lib/widgets/actions/command_palette_action.dart b/lib/widgets/actions/command_palette_action.dart index 1762b0bf83..54ef88f2f8 100644 --- a/lib/widgets/actions/command_palette_action.dart +++ b/lib/widgets/actions/command_palette_action.dart @@ -10,9 +10,9 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../../constants/resources.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; +import '../../ui/home/conversation/conversation_focus.dart'; import '../../ui/home/conversation/search_list.dart'; import '../../ui/home/intent.dart'; -import '../../ui/provider/conversation_provider.dart'; import '../../ui/provider/recent_conversation_provider.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; @@ -179,9 +179,9 @@ _NavigationState _useNavigationState({ final item = items[selectedIndex.value]; if (item.type == 'GROUP' || item.type == 'CONTACT') { - ConversationStateNotifier.selectConversation(context, item.id); + ConversationFocus.selectConversation(context, item.id); } else { - ConversationStateNotifier.selectUser(context, item.id); + ConversationFocus.selectUser(context, item.id); } Navigator.pop(context); }, [items]); diff --git a/lib/widgets/actions/create_conversation_action.dart b/lib/widgets/actions/create_conversation_action.dart index abb8c67db0..8773d45338 100644 --- a/lib/widgets/actions/create_conversation_action.dart +++ b/lib/widgets/actions/create_conversation_action.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../ui/provider/conversation_provider.dart'; +import '../../ui/home/conversation/conversation_focus.dart'; import '../../utils/extension/extension.dart'; import '../user_selector/conversation_selector.dart'; import 'actions.dart'; @@ -23,6 +23,6 @@ class CreateConversationAction extends Action { } final userId = list.first.userId!; - await ConversationStateNotifier.selectUser(context, userId); + await ConversationFocus.selectUser(context, userId); } } diff --git a/lib/widgets/avatar_view/avatar_view.dart b/lib/widgets/avatar_view/avatar_view.dart index e65163e94b..e945fbec63 100644 --- a/lib/widgets/avatar_view/avatar_view.dart +++ b/lib/widgets/avatar_view/avatar_view.dart @@ -23,6 +23,7 @@ class ConversationAvatarWidget extends HookConsumerWidget { this.groupIconUrl, this.avatarUrl, this.category, + this.groupAvatarUsers, }); final ConversationItem? conversation; @@ -32,6 +33,7 @@ class ConversationAvatarWidget extends HookConsumerWidget { final String? groupIconUrl; final String? avatarUrl; final ConversationCategory? category; + final List? groupAvatarUsers; final double size; @override @@ -49,7 +51,8 @@ class ConversationAvatarWidget extends HookConsumerWidget { final list = useMemoizedStream( () { - if (_category == ConversationCategory.group) { + if (groupAvatarUsers == null && + _category == ConversationCategory.group) { return context.database.participantDao .participantsAvatar(_conversationId!) .watchWithStream( @@ -63,10 +66,11 @@ class ConversationAvatarWidget extends HookConsumerWidget { } return const Stream>.empty(); }, - keys: [_conversationId, _category], + keys: [_conversationId, _category, groupAvatarUsers != null], initialData: [], ).data ?? []; + final users = groupAvatarUsers ?? list; return SizedBox.fromSize( size: Size.square(size), @@ -78,7 +82,7 @@ class ConversationAvatarWidget extends HookConsumerWidget { avatarUrl: _avatarUrl ?? _groupIconUrl ?? '', size: size, ) - : AvatarPuzzlesWidget(list, size), + : AvatarPuzzlesWidget(users, size), ), ); } diff --git a/lib/widgets/cell.dart b/lib/widgets/cell.dart index 93aedce980..20c3d4fbc7 100644 --- a/lib/widgets/cell.dart +++ b/lib/widgets/cell.dart @@ -3,7 +3,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/resources.dart'; -import '../ui/provider/responsive_navigator_provider.dart'; +import '../ui/provider/major_navigation_provider.dart'; import '../utils/extension/extension.dart'; import 'interactive_decorated_box.dart'; @@ -79,7 +79,7 @@ class CellItem extends HookConsumerWidget { final dynamicColor = color ?? context.theme.text; final backgroundColor = _CellItemStyle.of(context).backgroundColor; var selectedBackgroundColor = backgroundColor; - if (selected && !ref.watch(navigatorRouteModeProvider)) { + if (selected && !ref.watch(majorNavigationRouteModeProvider)) { selectedBackgroundColor = Color.alphaBlend( context.dynamicColor( const Color.fromRGBO(0, 0, 0, 0.05), diff --git a/lib/widgets/conversation/conversation_dialog.dart b/lib/widgets/conversation/conversation_dialog.dart index 79bd9d9a12..27399ef507 100644 --- a/lib/widgets/conversation/conversation_dialog.dart +++ b/lib/widgets/conversation/conversation_dialog.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide User; import '../../db/mixin_database.dart'; -import '../../ui/provider/conversation_provider.dart'; +import '../../ui/home/conversation/conversation_focus.dart'; import '../../utils/extension/extension.dart'; import '../avatar_view/avatar_view.dart'; import '../buttons.dart'; @@ -32,7 +32,7 @@ Future showConversationDialog( ); if (existed) { showToast(context.l10n.groupAlreadyIn); - await ConversationStateNotifier.selectConversation( + await ConversationFocus.selectConversation( context, conversationResponse.conversationId, ); @@ -134,7 +134,7 @@ class _ConversationInfo extends HookConsumerWidget { DialogAddOrJoinButton( onTap: () => runFutureWithToast(() async { await context.accountServer.joinGroup(code); - await ConversationStateNotifier.selectConversation( + await ConversationFocus.selectConversation( context, conversationResponse.conversationId, ); diff --git a/lib/widgets/message/item/quote_message.dart b/lib/widgets/message/item/quote_message.dart index 81dbc4738e..098db032af 100644 --- a/lib/widgets/message/item/quote_message.dart +++ b/lib/widgets/message/item/quote_message.dart @@ -15,7 +15,7 @@ import '../../../enum/message_category.dart'; import '../../../ui/home/chat/chat_jump_trace.dart'; import '../../../ui/home/chat/message_jump.dart'; import '../../../ui/provider/mention_cache_provider.dart'; -import '../../../ui/provider/pending_jump_message_provider.dart'; +import '../../../ui/provider/pending_chat_jump_provider.dart'; import '../../../ui/provider/user_cache_provider.dart'; import '../../../utils/color_utils.dart'; import '../../../utils/extension/extension.dart'; @@ -499,17 +499,18 @@ class _QuoteMessageBase extends HookConsumerWidget { context.jumpToMessageInChat( quoteMessageId, sourceMessageId: messageId, + closeSideAfterJump: true, ), ); - context.popChatSideRouteIfNeeded(); return; } } catch (_) {} context.providerContainer - .read(pendingJumpMessageProvider.notifier) - .state = - messageId; + .read(pendingChatJumpProvider.notifier) + .state = messageId == null + ? null + : PendingChatJump.returnToMessage(messageId); traceChatJump( 'quote tap source=${shortMessageId(messageId)} ' 'target=${shortMessageId(quoteMessageId)}', diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index 131d20820d..c913c00cf8 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -1,84 +1,42 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' - hide SelectableRegion, SelectableRegionState; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:gal/gal.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider, Provider; -import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; -import 'package:open_file/open_file.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide Key; import 'package:provider/provider.dart'; import 'package:super_context_menu/super_context_menu.dart'; import 'package:visibility_detector/visibility_detector.dart'; -import '../../account/account_server.dart'; -import '../../blaze/vo/pin_message_minimal.dart'; -import '../../constants/icon_fonts.dart'; import '../../constants/resources.dart'; -import '../../db/dao/sticker_dao.dart'; import '../../db/mixin_database.dart' hide Message, Offset; -import '../../enum/media_status.dart'; import '../../enum/message_category.dart'; -import '../../ui/home/chat/message_jump.dart'; import '../../ui/home/notifier/blink_notifier.dart'; -import '../../ui/provider/conversation_provider.dart'; import '../../ui/provider/is_bot_group_provider.dart'; import '../../ui/provider/message_selection_provider.dart'; import '../../ui/provider/quote_message_provider.dart'; -import '../../ui/provider/recall_message_reedit_provider.dart'; import '../../ui/provider/setting_provider.dart'; import '../../utils/datetime_format_utils.dart'; import '../../utils/double_tap_util.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/file.dart'; import '../../utils/hook.dart'; -import '../../utils/logger.dart'; -import '../../utils/platform.dart'; -import '../../utils/system/clipboard.dart'; import '../avatar_view/avatar_view.dart'; import '../interactive_decorated_box.dart'; import '../menu.dart'; -import '../qr_code.dart'; -import '../sticker_page/add_sticker_dialog.dart'; -import '../toast.dart'; import '../user/user_dialog.dart'; -import '../user_selector/conversation_selector.dart'; -import 'item/action/action_message.dart'; -import 'item/action_card/action_card_data.dart'; -import 'item/action_card/action_message.dart'; -import 'item/audio_message.dart'; -import 'item/contact_message_widget.dart'; -import 'item/file_message.dart'; -import 'item/image/image_message.dart'; -import 'item/location/location_message_widget.dart'; import 'item/pin_message.dart'; -import 'item/post_message.dart'; -import 'item/recall_message.dart'; import 'item/secret_message.dart'; -import 'item/sticker_message.dart'; import 'item/stranger_message.dart'; import 'item/system_message.dart'; -import 'item/text/selectable.dart'; -import 'item/text/text_message.dart'; -import 'item/transcript_message.dart'; -import 'item/transfer/inscription_message/inscription_message.dart'; -import 'item/transfer/safe_transfer_message.dart'; -import 'item/transfer/transfer_message.dart'; -import 'item/unknown_message.dart'; -import 'item/video/video_message.dart'; -import 'item/waiting_message.dart'; +import 'message_actions_menu.dart'; +import 'message_content.dart'; import 'message_day_time.dart'; import 'message_name.dart'; import 'message_style.dart'; +export 'message_file_actions.dart'; + class _MessageContext with EquatableMixin { _MessageContext({ required this.isTranscriptPage, @@ -104,6 +62,33 @@ class _MessageContext with EquatableMixin { ]; } +class MessageRowModel { + MessageRowModel({ + required this.message, + this.prev, + this.next, + }) : sameDayPrev = isSameDay(prev?.createdAt, message.createdAt), + sameDayNext = isSameDay(next?.createdAt, message.createdAt), + prevIsSystem = prev?.type.isSystem ?? false, + prevIsPin = prev?.type.isPin ?? false, + sameUserNext = next?.userId == message.userId { + sameUserPrev = + !prevIsSystem && !prevIsPin && prev?.userId == message.userId; + dateTime = sameDayPrev ? null : message.createdAt; + } + + final MessageItem message; + final MessageItem? prev; + final MessageItem? next; + final bool sameDayPrev; + final bool sameDayNext; + final bool prevIsSystem; + final bool prevIsPin; + late final bool sameUserPrev; + final bool sameUserNext; + late final DateTime? dateTime; +} + bool useIsTranscriptPage() => _useMessageContextConverter((state) => state.isTranscriptPage); @@ -155,56 +140,29 @@ void _quickReply(BuildContext context) { }); } -SelectedContent? _findSelectedContent(BuildContext context) { - SelectableRegionState? findSelectableRegionState(BuildContext context) { - if (context is! Element) { - return null; - } - if (context.widget is SelectableRegion) { - return (context as StatefulElement).state as SelectableRegionState; - } - SelectableRegionState? find; - context.visitChildren((element) { - if (find != null) { - return; - } - final result = findSelectableRegionState(element); - if (result != null) { - find = result; - } - }); - return find; - } - - final selectableRegion = findSelectableRegionState(context); - final status = selectableRegion?.selectable?.value.status; - final content = selectableRegion?.selectable?.getSelectedContent(); - d('status: $status, content: $content'); - if (status == SelectionStatus.uncollapsed && content != null) { - return content; - } - return null; -} - class MessageItemWidget extends HookConsumerWidget { const MessageItemWidget({ required this.message, super.key, this.prev, this.next, + this.row, this.lastReadMessageId, this.isTranscriptPage = false, this.blink = true, this.isPinnedPage = false, + this.dateTimeKey, }); final MessageItem message; final MessageItem? prev; final MessageItem? next; + final MessageRowModel? row; final String? lastReadMessageId; final bool isTranscriptPage; final bool blink; final bool isPinnedPage; + final Key? dateTimeKey; static const primaryFontSize = 16.0; static const secondaryFontSize = 14.0; @@ -214,17 +172,12 @@ class MessageItemWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final row = + this.row ?? + MessageRowModel(message: this.message, prev: prev, next: next); + final message = row.message; final isCurrentUser = message.relationship == UserRelationship.me; - final sameDayPrev = isSameDay(prev?.createdAt, message.createdAt); - final prevIsSystem = prev?.type.isSystem ?? false; - final prevIsPin = prev?.type.isPin ?? false; - final sameUserPrev = - !prevIsSystem && !prevIsPin && prev?.userId == message.userId; - - final sameDayNext = isSameDay(next?.createdAt, message.createdAt); - final sameUserNext = next?.userId == message.userId; - final isGroupOrBotGroupConversation = message.conversionCategory == ConversationCategory.group || message.userId != message.conversationOwnerId || @@ -236,15 +189,16 @@ class MessageItemWidget extends HookConsumerWidget { final showAvatar = isGroupOrBotGroupConversation && enableShowAvatar; final showNip = - !(sameUserNext && sameDayNext) && (!showAvatar || isCurrentUser); - final datetime = sameDayPrev ? null : message.createdAt; + !(row.sameUserNext && row.sameDayNext) && + (!showAvatar || isCurrentUser); + final datetime = row.dateTime; String? userName; String? userId; String? userAvatarUrl; if (isGroupOrBotGroupConversation && !isCurrentUser && - (!sameUserPrev || !sameDayPrev)) { + (!row.sameUserPrev || !row.sameDayPrev)) { userName = message.userFullName; userId = message.userId; userAvatarUrl = message.avatarUrl; @@ -268,7 +222,8 @@ class MessageItemWidget extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (datetime != null) MessageDayTime(dateTime: datetime), + if (datetime != null) + MessageDayTime(key: dateTimeKey, dateTime: datetime), ColoredBox( color: blinkColor, child: Builder( @@ -300,435 +255,23 @@ class MessageItemWidget extends HookConsumerWidget { pinArrowWidth: isPinnedPage ? _pinArrowWidth : 0, isBot: message.isBot, isVerified: message.isVerified, - buildMenus: (request) { - request.onShowMenu.addListener(() { - showedMenu.value = true; - focusNode.requestFocus(); - }); - request.onHideMenu.addListener(() { - showedMenu.value = false; - }); - - final enable = !ref.read(hasSelectedMessageProvider); - - if (!enable) return null; - - final role = ref.read( - conversationProvider.select((value) => value?.role), - ); - - final pinEnabled = - !isTranscriptPage && - message.type.canReply && - const [ - MessageStatus.delivered, - MessageStatus.read, - MessageStatus.sent, - ].contains(message.status) && - role != null; - final enableReply = - !isTranscriptPage && - message.type.canReply && - !isPinnedPage; - final enableForward = - !isTranscriptPage && message.canForward; - final enableSelect = !isTranscriptPage; - final enableSaveMobile = - kPlatformIsMobile && - (message.type.isImage || message.type.isVideo); - final enableSaveDesktop = - kPlatformIsDesktop && - message.mediaStatus == MediaStatus.done && - message.mediaUrl?.isNotEmpty == true && - (message.type.isData || - message.type.isImage || - message.type.isVideo || - message.type.isAudio); - final enableRecall = !isTranscriptPage && message.canRecall; - - final enableDelete = !isTranscriptPage && !isPinnedPage; - - final addStickerMenuAction = [ - if (message.type.isSticker) - MenuAction( - image: MenuImage.icon(IconFonts.sticker), - title: context.l10n.addSticker, - callback: () => _onAddSticker(context), - ), - if (message.type.isImage && message.canForward) - MenuAction( - image: MenuImage.icon(IconFonts.sticker), - title: context.l10n.addSticker, - callback: () => _onAddImageAsSticker(context), - ), - ]; - - final replayAction = [ - if (enableReply) - MenuAction( - image: MenuImage.icon(IconFonts.reply), - title: context.l10n.reply, - callback: () => - context.providerContainer - .read(quoteMessageProvider.notifier) - .state = - message, - ), - ]; - - final messageActions = [ - if (isPinnedPage) - MenuAction( - image: MenuImage.icon(IconFonts.positionToChat), - title: context.l10n.locateToChat, - callback: () { - unawaited( - context.jumpToMessageInChat(message.messageId), - ); - context.popChatSideRouteIfNeeded(); - }, - ), - if (enableForward) - MenuAction( - image: MenuImage.icon(IconFonts.forward), - title: context.l10n.forward, - callback: () async { - final result = await showConversationSelector( - context: context, - singleSelect: true, - title: context.l10n.forward, - onlyContact: false, - ); - if (result == null || result.isEmpty) return; - await context.accountServer.forwardMessage( - message.messageId, - result.first.encryptCategory!, - conversationId: result.first.conversationId, - recipientId: result.first.userId, - ); - }, - ), - if (enableSelect) - MenuAction( - image: MenuImage.icon(IconFonts.select), - title: context.l10n.select, - callback: () => ref - .read(messageSelectionProvider) - .selectMessage(message), - ), - if (pinEnabled) - MenuAction( - image: MenuImage.icon( - message.pinned ? IconFonts.unPin : IconFonts.pin, - ), - title: message.pinned - ? context.l10n.unpin - : context.l10n.pinTitle, - callback: () async { - final pinMessageMinimal = PinMessageMinimal( - messageId: message.messageId, - type: message.type, - content: message.type.isText - ? message.content - : null, - ); - if (message.pinned) { - await context.accountServer.unpinMessage( - conversationId: message.conversationId, - pinMessageMinimals: [pinMessageMinimal], - ); - return; - } - await context.accountServer.pinMessage( - conversationId: message.conversationId, - pinMessageMinimals: [pinMessageMinimal], - ); - }, - ), - ]; - - final copyActions = []; - if (message.type.isPost) { - copyActions.add( - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copy, - callback: () { - Clipboard.setData( - ClipboardData(text: message.content ?? ''), - ); - }, - ), - ); - } else if (message.type.isImage) { - copyActions.add( - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copyImage, - callback: () { - copyFile( - context.accountServer.convertMessageAbsolutePath( - message, - isTranscriptPage, - ), - ); - }, - ), - ); - if (!message.caption.isNullOrBlank()) { - final selectedContent = _findSelectedContent(context); - if (selectedContent != null) { - copyActions.add( - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copySelectedText, - callback: () { - Clipboard.setData( - ClipboardData( - text: selectedContent.plainText, - ), - ); - }, - ), - ); - } else { - copyActions.add( - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copyText, - callback: () { - Clipboard.setData( - ClipboardData(text: message.caption ?? ''), - ); - }, - ), - ); - } - } - } else if (message.type.isText) { - final selectedContent = _findSelectedContent(context); - copyActions - ..add( - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: selectedContent == null - ? context.l10n.copy - : context.l10n.copySelectedText, - callback: () { - if (selectedContent != null) { - Clipboard.setData( - ClipboardData( - text: selectedContent.plainText, - ), - ); - } else { - Clipboard.setData( - ClipboardData(text: message.content ?? ''), - ); - } - }, - ), - ) - ..add( - MenuAction( - image: MenuImage.icon(Icons.qr_code), - title: context.l10n.generateQrcode, - callback: () { - final content = - selectedContent?.plainText ?? - message.content ?? - ''; - showQrCodeDialog(context, content); - }, - ), - ); - } else if (message.type.isAppCard) { - final selectedContent = _findSelectedContent(context); - if (selectedContent != null) { - copyActions.add( - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copySelectedText, - callback: () { - String text; - try { - final data = AppCardData.fromJson( - jsonDecode(message.content!) - as Map, - ); - text = data.generateCopyTextWithBreakLine( - selectedContent.plainText, - ); - } catch (error) { - e('ActionCard decode error: $error'); - text = selectedContent.plainText; - } - Clipboard.setData(ClipboardData(text: text)); - }, - ), - ); - } - } - - final saveActions = [ - if (enableSaveMobile) - MenuAction( - image: MenuImage.icon(IconFonts.download), - title: context.l10n.saveToCameraRoll, - callback: () => saveAs( - context, - context.accountServer, - message, - isTranscriptPage, - ), - ), - if (enableSaveDesktop) - MenuAction( - image: MenuImage.icon(IconFonts.download), - title: context.l10n.saveAs, - callback: () => saveAs( - context, - context.accountServer, - message, - isTranscriptPage, - ), - ), - ]; - final deleteActions = [ - if (enableRecall) - MenuAction( - image: MenuImage.icon(IconFonts.recall), - title: context.l10n.deleteForEveryone, - callback: () async { - String? content; - if (message.type.isText) { - content = message.content; - } - await context.accountServer.sendRecallMessage([ - message.messageId, - ], conversationId: message.conversationId); - if (content != null) { - context.providerContainer - .read(recallMessageNotifierProvider) - .onRecalled(message.messageId, content); - } - }, - ), - if (enableDelete) - MenuAction( - image: MenuImage.icon(IconFonts.delete), - title: context.l10n.deleteForMe, - callback: () => context.accountServer.deleteMessage( - message.messageId, - ), - ), - ]; - - final devActions = [ - if (!kReleaseMode) - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: 'Copy message', - callback: () => Clipboard.setData( - ClipboardData(text: message.toString()), - ), - ), - ]; - - return MenusWithSeparator( - childrens: [ - replayAction, - copyActions, - messageActions, - saveActions, - addStickerMenuAction, - deleteActions, - devActions, - ], - ); - }, - builder: (context) { - if (message.type.isIllegalMessageCategory || - message.status == MessageStatus.unknown) { - return const UnknownMessage(); - } - - if (message.status == MessageStatus.failed) { - return const WaitingMessage(); - } - - if (message.type.isTranscript) { - return const TranscriptMessageWidget(); - } - - if (message.type.isLocation) { - return const LocationMessageWidget(); - } - - if (message.type.isPost) { - return const PostMessage(); - } - - if (message.type == MessageCategory.systemAccountSnapshot) { - return const TransferMessage(); - } - - if (message.type == MessageCategory.systemSafeSnapshot) { - return const SafeTransferMessage(); - } - - if (message.type.isContact) { - return const ContactMessageWidget(); - } - - if (message.type == MessageCategory.appButtonGroup) { - return const ActionMessage(); - } - - if (message.type == MessageCategory.appCard) { - return const ActionCardMessage(); - } - - if (message.type.isData) { - return const FileMessage(); - } - - if (message.type.isText) { - return const TextMessage(); - } - - if (message.type.isSticker) { - return const StickerMessageWidget(); - } - - if (message.type.isImage) { - return const ImageMessageWidget(); - } - - if (message.type.isVideo || message.type.isLive) { - return const VideoMessageWidget(); - } - - if (message.type.isAudio) { - return const AudioMessage(); - } - - if (message.type.isRecall) { - return const RecallMessage(); - } - - if (message.type == MessageCategory.systemSafeInscription) { - return const InscriptionMessage(); - } - - return const UnknownMessage(); - }, + buildMenus: (request) => buildMessageActionsMenu( + context: context, + ref: ref, + request: request, + message: message, + isTranscriptPage: isTranscriptPage, + isPinnedPage: isPinnedPage, + showedMenu: showedMenu, + focusNode: focusNode, + ), + builder: (_) => MessageContent(message: message), ), ); }, ), ), - if (message.messageId == lastReadMessageId && next != null) + if (message.messageId == lastReadMessageId && row.next != null) const _UnreadMessageBar(), ], ); @@ -759,7 +302,7 @@ class MessageItemWidget extends HookConsumerWidget { builder: (context) => GestureDetector( onTap: () => _quickReply(context), child: Padding( - padding: sameUserPrev + padding: row.sameUserPrev ? EdgeInsets.zero : const EdgeInsets.only(top: 8), child: child, @@ -769,50 +312,6 @@ class MessageItemWidget extends HookConsumerWidget { ), ); } - - Future _onAddImageAsSticker(BuildContext context) async { - await showAddStickerDialog( - context, - filepath: context.accountServer.convertMessageAbsolutePath( - message, - isTranscriptPage, - ), - ); - } - - Future _onAddSticker(BuildContext context) async { - showToastLoading(); - try { - final accountServer = context.accountServer; - - final mixinResponse = await accountServer.client.accountApi.addSticker( - StickerRequest(stickerId: message.stickerId), - ); - - final database = context.database; - - final personalAlbum = await database.stickerAlbumDao - .personalAlbum() - .getSingleOrNull(); - if (personalAlbum == null) { - unawaited(accountServer.refreshSticker(force: true)); - } else { - final data = mixinResponse.data; - await database.mixinDatabase.transaction(() async { - await database.stickerDao.insert(data.asStickersCompanion); - await database.stickerRelationshipDao.insert( - StickerRelationship( - albumId: personalAlbum.albumId, - stickerId: data.stickerId, - ), - ); - }); - } - showToastSuccessful(); - } catch (_) { - showToastFailed(ToastError(context.l10n.addStickerFailed)); - } - } } class MessageContext extends HookConsumerWidget { @@ -869,47 +368,6 @@ class MessageContext extends HookConsumerWidget { } } -Future saveAs( - BuildContext context, - AccountServer accountServer, - MessageItem message, - bool isTranscriptPage, -) async { - final path = accountServer.convertMessageAbsolutePath( - message, - isTranscriptPage, - ); - if (Platform.isAndroid || Platform.isIOS) { - if (message.type.isImage || message.type.isVideo) { - try { - if (message.type.isImage) { - await Gal.putImage(path); - } else { - await Gal.putVideo(path); - } - showToastSuccessful(); - } catch (error, s) { - d('save file error: $error, stack: $s'); - return showToastFailed(error); - } - } else { - await OpenFile.open(path); - } - } else { - try { - final result = await saveFileToSystem( - context, - path, - suggestName: message.mediaName, - ); - if (result) return showToastSuccessful(); - } catch (error, s) { - d('save file error: $error, stack: $s'); - return showToastFailed(error); - } - } -} - class _MessageBubbleMargin extends HookConsumerWidget { const _MessageBubbleMargin({ required this.isCurrentUser, diff --git a/lib/widgets/message/message_actions_menu.dart b/lib/widgets/message/message_actions_menu.dart new file mode 100644 index 0000000000..12cc546ff7 --- /dev/null +++ b/lib/widgets/message/message_actions_menu.dart @@ -0,0 +1,438 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' + hide SelectableRegion, SelectableRegionState; +import 'package:flutter/rendering.dart' show SelectedContent, SelectionStatus; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; +import 'package:super_context_menu/super_context_menu.dart'; + +import '../../blaze/vo/pin_message_minimal.dart'; +import '../../constants/icon_fonts.dart'; +import '../../db/dao/sticker_dao.dart'; +import '../../db/mixin_database.dart' hide Message; +import '../../enum/media_status.dart'; +import '../../ui/home/chat/message_jump.dart'; +import '../../ui/provider/conversation_provider.dart'; +import '../../ui/provider/message_selection_provider.dart'; +import '../../ui/provider/quote_message_provider.dart'; +import '../../ui/provider/recall_message_reedit_provider.dart'; +import '../../utils/extension/extension.dart'; +import '../../utils/logger.dart'; +import '../../utils/platform.dart'; +import '../../utils/system/clipboard.dart'; +import '../menu.dart'; +import '../qr_code.dart'; +import '../sticker_page/add_sticker_dialog.dart'; +import '../toast.dart'; +import '../user_selector/conversation_selector.dart'; +import 'item/action_card/action_card_data.dart'; +import 'item/text/selectable.dart'; +import 'message_file_actions.dart'; + +Menu? buildMessageActionsMenu({ + required BuildContext context, + required WidgetRef ref, + required MenuRequest request, + required MessageItem message, + required bool isTranscriptPage, + required bool isPinnedPage, + required ValueNotifier showedMenu, + required FocusNode focusNode, +}) { + request.onShowMenu.addListener(() { + showedMenu.value = true; + focusNode.requestFocus(); + }); + request.onHideMenu.addListener(() { + showedMenu.value = false; + }); + + if (ref.read(hasSelectedMessageProvider)) return null; + + final role = ref.read(conversationProvider.select((value) => value?.role)); + + final pinEnabled = + !isTranscriptPage && + message.type.canReply && + const [ + MessageStatus.delivered, + MessageStatus.read, + MessageStatus.sent, + ].contains(message.status) && + role != null; + final enableReply = + !isTranscriptPage && message.type.canReply && !isPinnedPage; + final enableForward = !isTranscriptPage && message.canForward; + final enableSelect = !isTranscriptPage; + final enableSaveMobile = + kPlatformIsMobile && (message.type.isImage || message.type.isVideo); + final enableSaveDesktop = + kPlatformIsDesktop && + message.mediaStatus == MediaStatus.done && + message.mediaUrl?.isNotEmpty == true && + (message.type.isData || + message.type.isImage || + message.type.isVideo || + message.type.isAudio); + final enableRecall = !isTranscriptPage && message.canRecall; + + final enableDelete = !isTranscriptPage && !isPinnedPage; + + final addStickerMenuAction = [ + if (message.type.isSticker) + MenuAction( + image: MenuImage.icon(IconFonts.sticker), + title: context.l10n.addSticker, + callback: () => _onAddSticker(context, message), + ), + if (message.type.isImage && message.canForward) + MenuAction( + image: MenuImage.icon(IconFonts.sticker), + title: context.l10n.addSticker, + callback: () => _onAddImageAsSticker( + context, + message, + isTranscriptPage, + ), + ), + ]; + + final replayAction = [ + if (enableReply) + MenuAction( + image: MenuImage.icon(IconFonts.reply), + title: context.l10n.reply, + callback: () => + context.providerContainer + .read(quoteMessageProvider.notifier) + .state = + message, + ), + ]; + + final messageActions = [ + if (isPinnedPage) + MenuAction( + image: MenuImage.icon(IconFonts.positionToChat), + title: context.l10n.locateToChat, + callback: () { + unawaited( + context.jumpToMessageInChat( + message.messageId, + closeSideAfterJump: true, + ), + ); + }, + ), + if (enableForward) + MenuAction( + image: MenuImage.icon(IconFonts.forward), + title: context.l10n.forward, + callback: () async { + final result = await showConversationSelector( + context: context, + singleSelect: true, + title: context.l10n.forward, + onlyContact: false, + ); + if (result == null || result.isEmpty) return; + await context.accountServer.forwardMessage( + message.messageId, + result.first.encryptCategory!, + conversationId: result.first.conversationId, + recipientId: result.first.userId, + ); + }, + ), + if (enableSelect) + MenuAction( + image: MenuImage.icon(IconFonts.select), + title: context.l10n.select, + callback: () => ref + .read(messageSelectionProvider) + .selectMessage( + message, + ), + ), + if (pinEnabled) + MenuAction( + image: MenuImage.icon(message.pinned ? IconFonts.unPin : IconFonts.pin), + title: message.pinned ? context.l10n.unpin : context.l10n.pinTitle, + callback: () async { + final pinMessageMinimal = PinMessageMinimal( + messageId: message.messageId, + type: message.type, + content: message.type.isText ? message.content : null, + ); + if (message.pinned) { + await context.accountServer.unpinMessage( + conversationId: message.conversationId, + pinMessageMinimals: [pinMessageMinimal], + ); + return; + } + await context.accountServer.pinMessage( + conversationId: message.conversationId, + pinMessageMinimals: [pinMessageMinimal], + ); + }, + ), + ]; + + final copyActions = []; + if (message.type.isPost) { + copyActions.add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: context.l10n.copy, + callback: () { + Clipboard.setData(ClipboardData(text: message.content ?? '')); + }, + ), + ); + } else if (message.type.isImage) { + copyActions.add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: context.l10n.copyImage, + callback: () { + copyFile( + context.accountServer.convertMessageAbsolutePath( + message, + isTranscriptPage, + ), + ); + }, + ), + ); + if (!message.caption.isNullOrBlank()) { + final selectedContent = _findSelectedContent(context); + if (selectedContent != null) { + copyActions.add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: context.l10n.copySelectedText, + callback: () { + Clipboard.setData( + ClipboardData(text: selectedContent.plainText), + ); + }, + ), + ); + } else { + copyActions.add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: context.l10n.copyText, + callback: () { + Clipboard.setData(ClipboardData(text: message.caption ?? '')); + }, + ), + ); + } + } + } else if (message.type.isText) { + final selectedContent = _findSelectedContent(context); + copyActions + ..add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: selectedContent == null + ? context.l10n.copy + : context.l10n.copySelectedText, + callback: () { + Clipboard.setData( + ClipboardData( + text: selectedContent?.plainText ?? message.content ?? '', + ), + ); + }, + ), + ) + ..add( + MenuAction( + image: MenuImage.icon(Icons.qr_code), + title: context.l10n.generateQrcode, + callback: () { + final content = selectedContent?.plainText ?? message.content ?? ''; + showQrCodeDialog(context, content); + }, + ), + ); + } else if (message.type.isAppCard) { + final selectedContent = _findSelectedContent(context); + if (selectedContent != null) { + copyActions.add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: context.l10n.copySelectedText, + callback: () { + String text; + try { + final data = AppCardData.fromJson( + jsonDecode(message.content!) as Map, + ); + text = data.generateCopyTextWithBreakLine( + selectedContent.plainText, + ); + } catch (error) { + e('ActionCard decode error: $error'); + text = selectedContent.plainText; + } + Clipboard.setData(ClipboardData(text: text)); + }, + ), + ); + } + } + + final saveActions = [ + if (enableSaveMobile) + MenuAction( + image: MenuImage.icon(IconFonts.download), + title: context.l10n.saveToCameraRoll, + callback: () => saveAs( + context, + context.accountServer, + message, + isTranscriptPage, + ), + ), + if (enableSaveDesktop) + MenuAction( + image: MenuImage.icon(IconFonts.download), + title: context.l10n.saveAs, + callback: () => saveAs( + context, + context.accountServer, + message, + isTranscriptPage, + ), + ), + ]; + final deleteActions = [ + if (enableRecall) + MenuAction( + image: MenuImage.icon(IconFonts.recall), + title: context.l10n.deleteForEveryone, + callback: () async { + String? content; + if (message.type.isText) { + content = message.content; + } + await context.accountServer.sendRecallMessage([ + message.messageId, + ], conversationId: message.conversationId); + if (content != null) { + context.providerContainer + .read(recallMessageNotifierProvider) + .onRecalled(message.messageId, content); + } + }, + ), + if (enableDelete) + MenuAction( + image: MenuImage.icon(IconFonts.delete), + title: context.l10n.deleteForMe, + callback: () => context.accountServer.deleteMessage(message.messageId), + ), + ]; + + final devActions = [ + if (!kReleaseMode) + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: 'Copy message', + callback: () => + Clipboard.setData(ClipboardData(text: message.toString())), + ), + ]; + + return MenusWithSeparator( + childrens: [ + replayAction, + copyActions, + messageActions, + saveActions, + addStickerMenuAction, + deleteActions, + devActions, + ], + ); +} + +SelectedContent? _findSelectedContent(BuildContext context) { + SelectableRegionState? findSelectableRegionState(BuildContext context) { + if (context is! Element) return null; + if (context.widget is SelectableRegion) { + return (context as StatefulElement).state as SelectableRegionState; + } + SelectableRegionState? find; + context.visitChildren((element) { + if (find != null) return; + find = findSelectableRegionState(element); + }); + return find; + } + + final selectableRegion = findSelectableRegionState(context); + final status = selectableRegion?.selectable?.value.status; + final content = selectableRegion?.selectable?.getSelectedContent(); + d('status: $status, content: $content'); + if (status == SelectionStatus.uncollapsed && content != null) { + return content; + } + return null; +} + +Future _onAddImageAsSticker( + BuildContext context, + MessageItem message, + bool isTranscriptPage, +) async { + await showAddStickerDialog( + context, + filepath: context.accountServer.convertMessageAbsolutePath( + message, + isTranscriptPage, + ), + ); +} + +Future _onAddSticker(BuildContext context, MessageItem message) async { + showToastLoading(); + try { + final accountServer = context.accountServer; + + final mixinResponse = await accountServer.client.accountApi.addSticker( + StickerRequest(stickerId: message.stickerId), + ); + + final database = context.database; + + final personalAlbum = await database.stickerAlbumDao + .personalAlbum() + .getSingleOrNull(); + if (personalAlbum == null) { + unawaited(accountServer.refreshSticker(force: true)); + } else { + final data = mixinResponse.data; + await database.mixinDatabase.transaction(() async { + await database.stickerDao.insert(data.asStickersCompanion); + await database.stickerRelationshipDao.insert( + StickerRelationship( + albumId: personalAlbum.albumId, + stickerId: data.stickerId, + ), + ); + }); + } + showToastSuccessful(); + } catch (_) { + showToastFailed(ToastError(context.l10n.addStickerFailed)); + } +} diff --git a/lib/widgets/message/message_bubble.dart b/lib/widgets/message/message_bubble.dart index a63fa96369..3739dc3a1e 100644 --- a/lib/widgets/message/message_bubble.dart +++ b/lib/widgets/message/message_bubble.dart @@ -139,8 +139,12 @@ class MessageBubble extends HookConsumerWidget { name: Resources.assetsImagesPinArrowSvg, onTap: () { final message = context.message; - unawaited(context.jumpToMessageInChat(message.messageId)); - context.popChatSideRouteIfNeeded(); + unawaited( + context.jumpToMessageInChat( + message.messageId, + closeSideAfterJump: true, + ), + ); }, ); diff --git a/lib/widgets/message/message_content.dart b/lib/widgets/message/message_content.dart new file mode 100644 index 0000000000..3913026c00 --- /dev/null +++ b/lib/widgets/message/message_content.dart @@ -0,0 +1,108 @@ +import 'package:flutter/widgets.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +import '../../db/extension/message_category.dart'; +import '../../db/mixin_database.dart' hide Message; +import '../../enum/message_category.dart'; +import 'item/action/action_message.dart'; +import 'item/action_card/action_message.dart'; +import 'item/audio_message.dart'; +import 'item/contact_message_widget.dart'; +import 'item/file_message.dart'; +import 'item/image/image_message.dart'; +import 'item/location/location_message_widget.dart'; +import 'item/post_message.dart'; +import 'item/recall_message.dart'; +import 'item/sticker_message.dart'; +import 'item/text/text_message.dart'; +import 'item/transcript_message.dart'; +import 'item/transfer/inscription_message/inscription_message.dart'; +import 'item/transfer/safe_transfer_message.dart'; +import 'item/transfer/transfer_message.dart'; +import 'item/unknown_message.dart'; +import 'item/video/video_message.dart'; +import 'item/waiting_message.dart'; + +class MessageContent extends StatelessWidget { + const MessageContent({required this.message, super.key}); + + final MessageItem message; + + @override + Widget build(BuildContext context) { + if (message.type.isIllegalMessageCategory || + message.status == MessageStatus.unknown) { + return const UnknownMessage(); + } + + if (message.status == MessageStatus.failed) { + return const WaitingMessage(); + } + + if (message.type.isTranscript) { + return const TranscriptMessageWidget(); + } + + if (message.type.isLocation) { + return const LocationMessageWidget(); + } + + if (message.type.isPost) { + return const PostMessage(); + } + + if (message.type == MessageCategory.systemAccountSnapshot) { + return const TransferMessage(); + } + + if (message.type == MessageCategory.systemSafeSnapshot) { + return const SafeTransferMessage(); + } + + if (message.type.isContact) { + return const ContactMessageWidget(); + } + + if (message.type == MessageCategory.appButtonGroup) { + return const ActionMessage(); + } + + if (message.type == MessageCategory.appCard) { + return const ActionCardMessage(); + } + + if (message.type.isData) { + return const FileMessage(); + } + + if (message.type.isText) { + return const TextMessage(); + } + + if (message.type.isSticker) { + return const StickerMessageWidget(); + } + + if (message.type.isImage) { + return const ImageMessageWidget(); + } + + if (message.type.isVideo || message.type.isLive) { + return const VideoMessageWidget(); + } + + if (message.type.isAudio) { + return const AudioMessage(); + } + + if (message.type.isRecall) { + return const RecallMessage(); + } + + if (message.type == MessageCategory.systemSafeInscription) { + return const InscriptionMessage(); + } + + return const UnknownMessage(); + } +} diff --git a/lib/widgets/message/message_day_time.dart b/lib/widgets/message/message_day_time.dart index eaa3668006..57ff2d97e0 100644 --- a/lib/widgets/message/message_day_time.dart +++ b/lib/widgets/message/message_day_time.dart @@ -70,30 +70,53 @@ class _CurrentShowingMessages { final List elements = []; final List dayTimeElements = []; + void addEntry(MessageDayTimeViewportEntry entry) { + final itemElement = entry.messageKey.currentContext; + if (itemElement is! Element) return; + final dayTimeElement = entry.dayTimeKey?.currentContext; + items.add(entry.message); + elements.add(itemElement); + dayTimeElements.add(dayTimeElement is Element ? dayTimeElement : null); + } + void dumpKeyedSubtree(Element element, {bool reverse = false}) { - final item = element.descendantFirstOf( + final item = element.descendantFirstOfOrNull( (e) => e.widget is MessageItemWidget, ); + if (item == null) return; final widget = item.widget as MessageItemWidget; + final message = widget.row?.message ?? widget.message; + final prev = widget.row?.prev ?? widget.prev; - final dayTimeElement = - !isSameDay(widget.message.createdAt, widget.prev?.createdAt) - ? element.descendantFirstOf( + final dayTimeElement = !isSameDay(message.createdAt, prev?.createdAt) + ? element.descendantFirstOfOrNull( (e) => e.widget is MessageDayTimeChip, ) : null; if (!reverse) { - items.add(widget.message); + items.add(message); elements.add(item); dayTimeElements.add(dayTimeElement); } else { - items.insert(0, widget.message); + items.insert(0, message); elements.insert(0, item); dayTimeElements.insert(0, dayTimeElement); } } } +class MessageDayTimeViewportEntry { + const MessageDayTimeViewportEntry({ + required this.message, + required this.messageKey, + this.dayTimeKey, + }); + + final db.MessageItem message; + final GlobalKey messageKey; + final GlobalKey? dayTimeKey; +} + class MessageDayTimeViewportWidget extends HookConsumerWidget { const MessageDayTimeViewportWidget._create( this._traversalCurrentShowingMessageElements, { @@ -106,31 +129,17 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { factory MessageDayTimeViewportWidget.chatPage({ required Widget child, required ScrollController scrollController, - required GlobalKey topKey, - required GlobalKey bottomKey, - required db.MessageItem? center, - required GlobalKey? centerKey, + required List entries, Key? key, }) => MessageDayTimeViewportWidget._create( () { final result = _CurrentShowingMessages(); - - topKey.currentContext!.visitChildElements( - (element) => result.dumpKeyedSubtree(element, reverse: true), - ); - - final centerContext = centerKey?.currentContext; - if (center != null && centerContext != null && centerContext is Element) { - result.dumpKeyedSubtree(centerContext); - } - - bottomKey.currentContext!.visitChildElements(result.dumpKeyedSubtree); - + entries.forEach(result.addEntry); return result; }, key: key, scrollController: scrollController, - reTraversalKey: centerKey, + reTraversalKey: entries, child: child, ); @@ -145,8 +154,8 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { () { final result = _CurrentShowingMessages(); (listKey.currentContext! as Element) - .descendantFirstOf((e) => e.widget is SliverList) - .visitChildElements((e) { + .descendantFirstOfOrNull((e) => e.widget is SliverList) + ?.visitChildElements((e) { result.dumpKeyedSubtree(e, reverse: reverse); }); return result; @@ -339,7 +348,7 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { } extension _ElementExt on Element { - Element descendantFirstOf(bool Function(Element e) predicate) { + Element? descendantFirstOfOrNull(bool Function(Element e) predicate) { Element? dump(Element element) { if (predicate(element)) { return element; @@ -354,6 +363,6 @@ extension _ElementExt on Element { return child; } - return dump(this)!; + return dump(this); } } diff --git a/lib/widgets/message/message_file_actions.dart b/lib/widgets/message/message_file_actions.dart new file mode 100644 index 0000000000..afa5d324bd --- /dev/null +++ b/lib/widgets/message/message_file_actions.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:gal/gal.dart'; +import 'package:open_file/open_file.dart'; + +import '../../account/account_server.dart'; +import '../../db/mixin_database.dart'; +import '../../utils/extension/extension.dart'; +import '../../utils/file.dart'; +import '../../utils/logger.dart'; +import '../toast.dart'; + +Future saveAs( + BuildContext context, + AccountServer accountServer, + MessageItem message, + bool isTranscriptPage, +) async { + final path = accountServer.convertMessageAbsolutePath( + message, + isTranscriptPage, + ); + if (Platform.isAndroid || Platform.isIOS) { + if (message.type.isImage || message.type.isVideo) { + try { + if (message.type.isImage) { + await Gal.putImage(path); + } else { + await Gal.putVideo(path); + } + showToastSuccessful(); + } catch (error, stackTrace) { + d('save file error: $error, stack: $stackTrace'); + return showToastFailed(error); + } + } else { + await OpenFile.open(path); + } + } else { + try { + final result = await saveFileToSystem( + context, + path, + suggestName: message.mediaName, + ); + if (result) return showToastSuccessful(); + } catch (error, stackTrace) { + d('save file error: $error, stack: $stackTrace'); + return showToastFailed(error); + } + } +} diff --git a/lib/widgets/message/send_message_dialog/send_message_dialog.dart b/lib/widgets/message/send_message_dialog/send_message_dialog.dart index 7f2da605c0..86bac9dc6d 100644 --- a/lib/widgets/message/send_message_dialog/send_message_dialog.dart +++ b/lib/widgets/message/send_message_dialog/send_message_dialog.dart @@ -9,6 +9,7 @@ import '../../../crypto/uuid/uuid.dart'; import '../../../db/dao/sticker_dao.dart'; import '../../../db/mixin_database.dart'; import '../../../enum/encrypt_category.dart'; +import '../../../ui/home/conversation/conversation_focus.dart'; import '../../../ui/provider/conversation_provider.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/hook.dart'; @@ -150,7 +151,7 @@ Future _sendMessageToUserId( context.accountServer.userId, userId, ); - await ConversationStateNotifier.selectUser(context, userId); + await ConversationFocus.selectUser(context, userId); final conversation = context.providerContainer.read(conversationProvider); if (conversation == null) { return false; diff --git a/lib/widgets/search_bar.dart b/lib/widgets/search_bar.dart index c162d0d5bb..ed2d87c698 100644 --- a/lib/widgets/search_bar.dart +++ b/lib/widgets/search_bar.dart @@ -8,7 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/constants.dart'; import '../constants/resources.dart'; -import '../ui/home/home.dart'; +import '../ui/home/left_rail_controller.dart'; import '../ui/provider/conversation_unseen_filter_enabled.dart'; import '../ui/provider/keyword_provider.dart'; import '../utils/extension/extension.dart'; @@ -34,7 +34,7 @@ class SearchBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final hasDrawer = context.watch(); + final hasDrawer = context.watch(); Widget? leading; if (hasDrawer.value) { diff --git a/lib/widgets/user/user_dialog.dart b/lib/widgets/user/user_dialog.dart index fac0559d0d..152f065053 100644 --- a/lib/widgets/user/user_dialog.dart +++ b/lib/widgets/user/user_dialog.dart @@ -6,8 +6,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../constants/resources.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; -import '../../ui/home/notifier/chat_side_notifier.dart'; -import '../../ui/provider/conversation_provider.dart'; +import '../../ui/home/conversation/conversation_focus.dart'; +import '../../ui/home/conversation_info_destination.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../../utils/logger.dart'; @@ -370,7 +370,7 @@ class _UserProfileButtonBar extends StatelessWidget { // skip self. return; } - await ConversationStateNotifier.selectUser(context, user.userId); + await ConversationFocus.selectUser(context, user.userId); Navigator.pop(context); }, color: context.theme.icon, @@ -380,10 +380,10 @@ class _UserProfileButtonBar extends StatelessWidget { name: Resources.assetsImagesInformationSvg, size: 30, onTap: () async { - await ConversationStateNotifier.selectUser( + await ConversationFocus.selectUser( context, user.userId, - initialChatSidePage: ChatSideNotifier.infoPage, + initialChatSidePage: ConversationInfoDestination.infoPage, ); Navigator.pop(context); }, diff --git a/macos/Podfile b/macos/Podfile index 36be88b185..0405e7dda5 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -38,11 +38,5 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) - target.build_configurations.each do |config| - next unless config.name == 'Profile' - - # ogg_opus_player ships an arm64-only static library for macOS. - config.build_settings['ARCHS'] = 'arm64' - end end end diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 7c14637806..9f58019cbf 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -222,6 +222,6 @@ SPEC CHECKSUMS: window_manager: b729e31d38fb04905235df9ea896128991cad99e window_size: 4bd15034e6e3d0720fd77928a7c42e5492cfece9 -PODFILE CHECKSUM: 05d986538853d579a789a69095f4e3f0adfd7f8e +PODFILE CHECKSUM: 79f215ba0ed5f5732c2fc484c6279e46c5c42059 COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 9276d479fe..ebab3dc57d 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -470,7 +470,6 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ARCHS = arm64; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -517,7 +516,6 @@ isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { - ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; diff --git a/test/db/participant_dao_test.dart b/test/db/participant_dao_test.dart new file mode 100644 index 0000000000..36e6395e99 --- /dev/null +++ b/test/db/participant_dao_test.dart @@ -0,0 +1,73 @@ +@TestOn('linux || mac-os') +library; + +import 'package:drift/native.dart'; +import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' + show ConversationCategory, ConversationStatus; + +void main() { + test( + 'loads group avatar users for multiple conversations in one query', + () async { + final database = MixinDatabase(NativeDatabase.memory()); + addTearDown(database.close); + + final now = DateTime(2026); + await database.batch((batch) { + batch + ..insertAll(database.users, [ + for (var index = 0; index < 8; index++) + User( + userId: 'u$index', + identityNumber: '700$index', + fullName: 'User $index', + ), + ]) + ..insertAll(database.conversations, [ + Conversation( + conversationId: 'c1', + ownerId: 'u0', + category: ConversationCategory.group, + createdAt: now, + status: ConversationStatus.success, + ), + Conversation( + conversationId: 'c2', + ownerId: 'u5', + category: ConversationCategory.group, + createdAt: now, + status: ConversationStatus.success, + ), + ]) + ..insertAll(database.participants, [ + for (var index = 0; index < 6; index++) + Participant( + conversationId: 'c1', + userId: 'u$index', + createdAt: now.add(Duration(minutes: index)), + ), + for (var index = 6; index < 8; index++) + Participant( + conversationId: 'c2', + userId: 'u$index', + createdAt: now.add(Duration(minutes: index)), + ), + ]); + }); + + final avatars = await database.participantDao + .participantsAvatarByConversationIds(['c1', 'c2', 'missing']); + + expect(avatars['c1']?.map((user) => user.userId), [ + 'u0', + 'u1', + 'u2', + 'u3', + ]); + expect(avatars['c2']?.map((user) => user.userId), ['u6', 'u7']); + expect(avatars['missing'], isEmpty); + }, + ); +} diff --git a/test/ui/home/chat/chat_page_test.dart b/test/ui/home/chat/chat_page_test.dart index 1663aa5096..0a3fa6d5fb 100644 --- a/test/ui/home/chat/chat_page_test.dart +++ b/test/ui/home/chat/chat_page_test.dart @@ -1,5 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_app/ui/home/chat/chat_page.dart'; +import 'package:flutter_app/ui/home/chat/chat_history_viewport.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index fd3489a4ec..bc84b92b10 100644 --- a/test/ui/home/chat/chat_scroll_coordinator_test.dart +++ b/test/ui/home/chat/chat_scroll_coordinator_test.dart @@ -935,12 +935,18 @@ Future pumpScrollableMessages( : ScrollCacheExtent.pixels(cacheExtent), slivers: [ SliverList.builder( - key: coordinator.topSliverKey, itemCount: messages.length, - itemBuilder: (context, index) => SizedBox( - key: keysByMessageId[messages[index].messageId], - height: itemHeight, - ), + itemBuilder: (context, index) { + final messageId = messages[index].messageId; + return ChatRenderedMessage( + coordinator: coordinator, + messageId: messageId, + child: SizedBox( + key: keysByMessageId[messageId], + height: itemHeight, + ), + ); + }, ), ], ), @@ -968,9 +974,13 @@ Future pumpFullyBuiltScrollableMessages( child: Column( children: [ for (final message in messages) - SizedBox( - key: keysByMessageId[message.messageId], - height: 80, + ChatRenderedMessage( + coordinator: coordinator, + messageId: message.messageId, + child: SizedBox( + key: keysByMessageId[message.messageId], + height: 80, + ), ), ], ), diff --git a/test/ui/home/chat/message_jump_test.dart b/test/ui/home/chat/message_jump_test.dart index c1f308b0a4..f4a1e21db8 100644 --- a/test/ui/home/chat/message_jump_test.dart +++ b/test/ui/home/chat/message_jump_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_app/ui/home/chat/message_jump.dart'; import 'package:flutter_app/ui/home/notifier/message_controller.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -15,58 +14,16 @@ void main() { ); }); - testWidgets('popChatSideRouteIfNeeded keeps non-route side pages open', ( - tester, - ) async { - final routeContextKey = GlobalKey(); - - await tester.pumpWidget( - MaterialApp( - home: Builder( - key: routeContextKey, - builder: (context) => const Text('side-page'), - ), - ), + test('chat jump target carries side close policy', () { + const target = ChatJumpTarget.message( + 'message', + sourceMessageId: 'source', + closeSideAfterJump: true, ); - routeContextKey.currentContext!.popChatSideRouteIfNeeded(); - await tester.pumpAndSettle(); - - expect(find.text('side-page'), findsOneWidget); - }); - - testWidgets('popChatSideRouteIfNeeded pops route-mode side pages', ( - tester, - ) async { - final routeContextKey = GlobalKey(); - - await tester.pumpWidget( - MaterialApp( - home: Builder( - builder: (context) => TextButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => Builder( - key: routeContextKey, - builder: (_) => const Text('route-side-page'), - ), - ), - ); - }, - child: const Text('open'), - ), - ), - ), - ); - - await tester.tap(find.text('open')); - await tester.pumpAndSettle(); - - routeContextKey.currentContext!.popChatSideRouteIfNeeded(); - await tester.pumpAndSettle(); - - expect(find.text('route-side-page'), findsNothing); - expect(find.text('open'), findsOneWidget); + expect(target.kind, ChatJumpKind.message); + expect(target.messageId, 'message'); + expect(target.sourceMessageId, 'source'); + expect(target.closeSideAfterJump, isTrue); }); } diff --git a/test/ui/home/desktop_shell_layout_test.dart b/test/ui/home/desktop_shell_layout_test.dart new file mode 100644 index 0000000000..69ae6bb66a --- /dev/null +++ b/test/ui/home/desktop_shell_layout_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_app/ui/home/desktop_shell_layout.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DesktopShellLayout', () { + test('uses the full rail when the shell has enough width', () { + final layout = DesktopShellLayout.resolve( + maxWidth: 900, + userCollapse: false, + isPhone: false, + ); + + expect(layout.mode, DesktopShellLayoutMode.fullRail); + expect(layout.slideWidth, kSlidePageMaxWidth); + expect(layout.showCollapseControl, isTrue); + expect(layout.hasDrawer, isFalse); + }); + + test('auto collapses the rail before switching to drawer mode', () { + final layout = DesktopShellLayout.resolve( + maxWidth: kResponsiveNavigationMinWidth + 120, + userCollapse: false, + isPhone: false, + ); + + expect(layout.mode, DesktopShellLayoutMode.compactRail); + expect(layout.slideWidth, kSlidePageMinWidth); + expect(layout.showCollapseControl, isFalse); + }); + + test('uses drawer mode on phone and at the minimum rail width', () { + final narrow = DesktopShellLayout.resolve( + maxWidth: kResponsiveNavigationMinWidth, + userCollapse: false, + isPhone: false, + ); + final phone = DesktopShellLayout.resolve( + maxWidth: 900, + userCollapse: false, + isPhone: true, + ); + + expect(narrow.mode, DesktopShellLayoutMode.drawer); + expect(narrow.slideWidth, 0); + expect(phone.mode, DesktopShellLayoutMode.drawer); + expect(phone.slideWidth, 0); + }); + + test('keeps route and media sizing policy in one place', () { + expect( + DesktopShellLayout.useMainRouteMode( + DesktopShellLayout.mainRouteSwitchWidth - 1, + ), + isTrue, + ); + expect( + DesktopShellLayout.useChatSideRouteMode( + DesktopShellLayout.chatSideRouteSwitchWidth, + ), + isFalse, + ); + expect( + DesktopShellLayout.chatSideMediaPageSize( + maxHeight: 450, + routeMode: false, + ), + 30, + ); + expect( + DesktopShellLayout.chatSideMediaPageSize( + maxHeight: 450, + routeMode: true, + ), + 40, + ); + }); + }); +} diff --git a/test/ui/home/notifier/chat_side_notifier_test.dart b/test/ui/home/notifier/chat_side_notifier_test.dart new file mode 100644 index 0000000000..cce3a85d3e --- /dev/null +++ b/test/ui/home/notifier/chat_side_notifier_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_app/ui/home/conversation_info_destination.dart'; +import 'package:flutter_app/ui/home/notifier/chat_side_notifier.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('toggles destinations without storing pages in state', () async { + final notifier = ChatSideNotifier(); + addTearDown(notifier.dispose); + + await notifier.toggleDestination(ConversationInfoDestination.pinMessages); + expect(notifier.state.destinations, [ + ConversationInfoDestination.pinMessages, + ]); + + await notifier.toggleDestination(ConversationInfoDestination.pinMessages); + expect(notifier.state.destinations, isEmpty); + }); + + test('route-mode content jump closes the side stack', () async { + final notifier = ChatSideNotifier(); + addTearDown(notifier.dispose); + + notifier + ..openDestination(ConversationInfoDestination.searchMessageHistory) + ..updateRouteMode(true); + await Future.delayed(Duration.zero); + + notifier.closeAfterContentJump(); + + expect(notifier.state.destinations, isEmpty); + }); + + test('empty stack pop is ignored', () { + final notifier = ChatSideNotifier(); + addTearDown(notifier.dispose); + + notifier.pop(); + + expect(notifier.state.destinations, isEmpty); + }); +} diff --git a/test/ui/provider/major_navigation_provider_test.dart b/test/ui/provider/major_navigation_provider_test.dart new file mode 100644 index 0000000000..4f4837962f --- /dev/null +++ b/test/ui/provider/major_navigation_provider_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_app/ui/provider/major_navigation_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('setting navigation keeps one setting destination in the stack', () { + final notifier = MajorNavigationNotifier() + ..openSetting(MajorNavigationDestination.editProfilePage) + ..openSetting(MajorNavigationDestination.aboutPage); + + expect(notifier.state.entries, [ + const MajorNavigationEntry(MajorNavigationDestination.aboutPage), + ]); + }); + + test('syncing setting category clears route-mode pages', () async { + final notifier = MajorNavigationNotifier() + ..open(MajorNavigationDestination.chatPage) + ..updateRouteMode(true); + await Future.delayed(Duration.zero); + + final opened = notifier.syncSettingCategory(true); + + expect(opened, isFalse); + expect(notifier.state.entries, isEmpty); + }); +} diff --git a/test/widgets/message/message_day_time_test.dart b/test/widgets/message/message_day_time_test.dart new file mode 100644 index 0000000000..10c25d13a6 --- /dev/null +++ b/test/widgets/message/message_day_time_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_app/widgets/message/message_day_time.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + testWidgets('date traversal skips non-message sliver children', ( + tester, + ) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: SizedBox( + height: 200, + child: MessageDayTimeViewportWidget.chatPage( + entries: const [], + scrollController: scrollController, + child: CustomScrollView( + controller: scrollController, + slivers: const [ + SliverToBoxAdapter(child: SizedBox(height: 20)), + ], + ), + ), + ), + ), + ), + ); + + await tester.pump(); + + expect(tester.takeException(), isNull); + }); +} From f1fc2cfdb9829b8c6c45eed4d228ad05fe2ce9d5 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:41:20 +0900 Subject: [PATCH 07/27] Refactor chat navigation and render hot paths --- lib/ui/home/chat/chat_bar.dart | 14 +- lib/ui/home/chat/chat_content_overlays.dart | 7 +- lib/ui/home/chat/chat_history_viewport.dart | 93 +++----- lib/ui/home/chat/chat_menu_handler.dart | 7 +- lib/ui/home/chat/chat_page.dart | 60 ++--- lib/ui/home/chat/chat_scroll_coordinator.dart | 15 +- lib/ui/home/chat/files_preview.dart | 127 +---------- lib/ui/home/chat/files_preview_model.dart | 124 +++++++++++ lib/ui/home/chat/image_editor.dart | 151 +------------ lib/ui/home/chat/image_editor_model.dart | 142 ++++++++++++ lib/ui/home/chat/input_container.dart | 97 +------- .../home/chat/input_highlight_controller.dart | 94 ++++++++ lib/ui/home/chat/message_jump.dart | 73 +++--- .../home/chat/voice_recorder_bottom_bar.dart | 209 +----------------- lib/ui/home/chat/voice_recorder_state.dart | 208 +++++++++++++++++ .../share_media/media_page.dart | 7 +- .../home/conversation/conversation_focus.dart | 5 +- .../home/conversation/conversation_list.dart | 6 +- .../unseen_conversation_list.dart | 6 +- lib/ui/home/desktop_shell_layout.dart | 48 ++++ lib/ui/home/home.dart | 6 +- lib/ui/home/notifier/chat_side_notifier.dart | 35 +-- lib/ui/home/notifier/message_controller.dart | 2 + lib/ui/home/route/major_navigator.dart | 41 ++-- lib/ui/provider/conversation_provider.dart | 10 + .../provider/major_navigation_provider.dart | 26 +-- .../provider/pending_chat_jump_provider.dart | 21 +- lib/ui/setting/setting_page.dart | 6 +- lib/widgets/cell.dart | 4 +- lib/widgets/message/message.dart | 42 ++++ lib/widgets/message/message_bubble.dart | 64 +++--- .../notifier/chat_side_notifier_test.dart | 13 +- .../major_navigation_provider_test.dart | 8 +- 33 files changed, 886 insertions(+), 885 deletions(-) create mode 100644 lib/ui/home/chat/files_preview_model.dart create mode 100644 lib/ui/home/chat/image_editor_model.dart create mode 100644 lib/ui/home/chat/input_highlight_controller.dart create mode 100644 lib/ui/home/chat/voice_recorder_state.dart diff --git a/lib/ui/home/chat/chat_bar.dart b/lib/ui/home/chat/chat_bar.dart index 694c1c4a18..2fb15c35ba 100644 --- a/lib/ui/home/chat/chat_bar.dart +++ b/lib/ui/home/chat/chat_bar.dart @@ -17,9 +17,9 @@ import '../../../widgets/high_light_text.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/window/move_window.dart'; import '../../provider/conversation_provider.dart'; -import '../../provider/major_navigation_provider.dart'; import '../../provider/message_selection_provider.dart'; import '../conversation_info_destination.dart'; +import '../desktop_shell_layout.dart'; import '../notifier/chat_side_notifier.dart'; class ChatBar extends HookConsumerWidget { @@ -30,9 +30,8 @@ class ChatBar extends HookConsumerWidget { final actionColor = context.theme.icon; final chatSideNotifier = context.read(); useValueListenable(chatSideNotifier); - final chatSideRouteMode = chatSideNotifier.isRouteMode; - - final routeMode = ref.watch(majorNavigationRouteModeProvider); + final chatSideRouteMode = DesktopShellLayout.chatSideRouteModeOf(context); + final routeMode = DesktopShellLayout.mainRouteModeOf(context); final conversation = ref.watch(conversationProvider); @@ -139,11 +138,8 @@ class ChatBar extends HookConsumerWidget { name: Resources.assetsImagesIcSearchSvg, color: actionColor, onTap: () { - final notifier = context.read(); - unawaited( - notifier.toggleDestination( - ConversationInfoDestination.searchMessageHistory, - ), + context.read().toggleDestination( + ConversationInfoDestination.searchMessageHistory, ); }, ), diff --git a/lib/ui/home/chat/chat_content_overlays.dart b/lib/ui/home/chat/chat_content_overlays.dart index 3b06f1dbb6..ee934bbb21 100644 --- a/lib/ui/home/chat/chat_content_overlays.dart +++ b/lib/ui/home/chat/chat_content_overlays.dart @@ -190,11 +190,8 @@ class _PinMessagesBanner extends HookConsumerWidget { visible: currentPinMessageIds.isNotEmpty, child: InteractiveDecoratedBox( onTap: () { - final notifier = context.read(); - unawaited( - notifier.toggleDestination( - ConversationInfoDestination.pinMessages, - ), + context.read().toggleDestination( + ConversationInfoDestination.pinMessages, ); }, child: Container( diff --git a/lib/ui/home/chat/chat_history_viewport.dart b/lib/ui/home/chat/chat_history_viewport.dart index e4b0024c2a..fa4a8e6b57 100644 --- a/lib/ui/home/chat/chat_history_viewport.dart +++ b/lib/ui/home/chat/chat_history_viewport.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -7,7 +5,6 @@ import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../constants/resources.dart'; -import '../../../db/mixin_database.dart' hide Offset; import '../../../utils/extension/extension.dart'; import '../../../widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart'; import '../../../widgets/interactive_decorated_box.dart'; @@ -22,19 +19,25 @@ import 'message_jump.dart'; @visibleForTesting void syncMessageGlobalKeys( Map keysByMessageId, - Set messageIds, -) { + Set messageIds, { + GlobalKey Function(String messageId) createKey = _messageGlobalKey, +}) { keysByMessageId.removeWhere( (messageId, _) => !messageIds.contains(messageId), ); for (final messageId in messageIds) { keysByMessageId.putIfAbsent( messageId, - () => MessageGlobalKey(messageId), + () => createKey(messageId), ); } } +GlobalKey _messageGlobalKey(String messageId) => MessageGlobalKey(messageId); + +GlobalKey _messageDayTimeKey(String messageId) => + GlobalKey(debugLabel: 'message day time $messageId'); + class ChatHistoryViewport extends HookConsumerWidget { const ChatHistoryViewport({super.key}); @@ -50,7 +53,7 @@ class ChatHistoryViewport extends HookConsumerWidget { final bottom = state.bottom; final messages = state.list; final rows = useMemoized( - () => _ChatHistoryRows.from(top: top, center: center, bottom: bottom), + () => MessageRows.from(top: top, center: center, bottom: bottom), [top, center, bottom], ); @@ -58,6 +61,10 @@ class ChatHistoryViewport extends HookConsumerWidget { final dayTimeKeysRef = useRef>({}); final previousConversationIdRef = useRef(null); final previousRefreshKeyRef = useRef(null); + final viewportKey = useMemoized( + () => GlobalKey(debugLabel: 'chat scroll viewport'), + [scrollCoordinator], + ); final messageIds = messages.map((e) => e.messageId).toSet(); final messageIdsKey = messages.map((e) => e.messageId).join('|'); @@ -65,28 +72,22 @@ class ChatHistoryViewport extends HookConsumerWidget { previousConversationIdRef.value != state.conversationId || previousRefreshKeyRef.value != state.refreshKey; + useEffect(() { + scrollCoordinator.viewportKey = viewportKey; + return () => scrollCoordinator.detachViewportKey(viewportKey); + }, [scrollCoordinator, viewportKey]); + if (!resetScrollWindow) { scrollCoordinator.captureViewportState(messages, messageKeysRef.value); } - ref.listen(pendingChatJumpProvider, (previous, next) { - if (next == null || !next.isCommand) return; - scheduleMicrotask(() async { - if (!context.mounted) return; - final messageId = next.messageId; - if (messageId == null) { - await context.jumpToLatestInChat(); - } else { - await context.jumpToMessageInChat(messageId); - } - if (!context.mounted) return; - ref.read(pendingChatJumpProvider.notifier).state = null; - }); - }); - useMemoized(() { syncMessageGlobalKeys(messageKeysRef.value, messageIds); - syncMessageGlobalKeys(dayTimeKeysRef.value, messageIds); + syncMessageGlobalKeys( + dayTimeKeysRef.value, + messageIds, + createKey: _messageDayTimeKey, + ); }, [messageIdsKey]); final dayTimeEntries = useMemoized( @@ -129,7 +130,7 @@ class ChatHistoryViewport extends HookConsumerWidget { loadAfter: messageController.after, ), child: ClampingCustomScrollView( - key: scrollCoordinator.viewportKey, + key: viewportKey, center: key, controller: scrollCoordinator.scrollController, anchor: 0.3, @@ -209,7 +210,7 @@ class ChatHistoryViewport extends HookConsumerWidget { } List _dayTimeEntries( - _ChatHistoryRows rows, + MessageRows rows, Map messageKeys, Map dayTimeKeys, ) { @@ -231,48 +232,6 @@ List _dayTimeEntries( ]; } -class _ChatHistoryRows { - const _ChatHistoryRows({ - required this.top, - required this.bottom, - this.center, - }); - - factory _ChatHistoryRows.from({ - required List top, - required MessageItem? center, - required List bottom, - }) => _ChatHistoryRows( - top: [ - for (var index = 0; index < top.length; index++) - MessageRowModel( - message: top[index], - prev: top.getOrNull(index - 1), - next: top.getOrNull(index + 1) ?? center ?? bottom.lastOrNull, - ), - ], - center: center == null - ? null - : MessageRowModel( - message: center, - prev: top.lastOrNull, - next: bottom.firstOrNull, - ), - bottom: [ - for (var index = 0; index < bottom.length; index++) - MessageRowModel( - message: bottom[index], - prev: bottom.getOrNull(index - 1) ?? center ?? top.lastOrNull, - next: bottom.getOrNull(index + 1), - ), - ], - ); - - final List top; - final MessageRowModel? center; - final List bottom; -} - class JumpCurrentButton extends HookConsumerWidget { const JumpCurrentButton({super.key}); diff --git a/lib/ui/home/chat/chat_menu_handler.dart b/lib/ui/home/chat/chat_menu_handler.dart index 0a6739ecc9..cb87de7c65 100644 --- a/lib/ui/home/chat/chat_menu_handler.dart +++ b/lib/ui/home/chat/chat_menu_handler.dart @@ -105,11 +105,8 @@ class _ConversationHandle extends ConversationMenuHandle { @override void showSearch() { - final notifier = context.read(); - unawaited( - notifier.toggleDestination( - ConversationInfoDestination.searchMessageHistory, - ), + context.read().toggleDestination( + ConversationInfoDestination.searchMessageHistory, ); } diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 1bd2a000c5..c4dd4f4bfa 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -118,37 +118,39 @@ class ChatPage extends HookConsumerWidget { final routeMode = DesktopShellLayout.useChatSideRouteMode( boxConstraints.maxWidth, ); - chatSideNotifier.updateRouteMode(routeMode); - - return ChatMenuHandler( - child: Row( - children: [ - if (!routeMode) Expanded(child: chatContainerPage.child), - if (!routeMode) - Container(width: 1, color: context.theme.divider), - FocusableActionDetector( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.escape): - EscapeIntent(), - }, - actions: { - EscapeIntent: CallbackAction( - onInvoke: (intent) => chatSideNotifier.pop(), - ), - }, - child: ChatSideRouter( - constraints: boxConstraints, - routeMode: routeMode, - onDidRemovePage: (page) { - chatSideNotifier.onPopPage(); + + return DesktopShellLayout.chatSideRouteMode( + routeMode: routeMode, + child: ChatMenuHandler( + child: Row( + children: [ + if (!routeMode) Expanded(child: chatContainerPage.child), + if (!routeMode) + Container(width: 1, color: context.theme.divider), + FocusableActionDetector( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): + EscapeIntent(), + }, + actions: { + EscapeIntent: CallbackAction( + onInvoke: (intent) => chatSideNotifier.pop(), + ), }, - leadingPages: [ - if (routeMode) chatContainerPage, - ], - destinations: chatSideNotifier.state.destinations, + child: ChatSideRouter( + constraints: boxConstraints, + routeMode: routeMode, + onDidRemovePage: (page) { + chatSideNotifier.onPopPage(); + }, + leadingPages: [ + if (routeMode) chatContainerPage, + ], + destinations: chatSideNotifier.state.destinations, + ), ), - ), - ], + ], + ), ), ); }, diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index c95615566f..c2119be89a 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -73,7 +73,6 @@ class ChatScrollCoordinator { static const loadedJumpViewportCount = 2.0; final scrollController = ScrollController(); - final viewportKey = GlobalKey(debugLabel: 'chat scroll viewport'); final visibleDateTime = ValueNotifier(null); final showJumpToLatest = ValueNotifier(false); @@ -82,6 +81,7 @@ class ChatScrollCoordinator { List _messages = const []; Map _messagesById = const {}; Map _keysByMessageId = const {}; + GlobalKey? _viewportKey; final _renderedMessageIds = {}; bool _pinnedToBottom = true; bool _animateNextRestore = false; @@ -134,6 +134,17 @@ class ChatScrollCoordinator { _renderedMessageIds.remove(messageId); } + GlobalKey get viewportKey => + _viewportKey ??= GlobalKey(debugLabel: 'chat scroll viewport'); + + set viewportKey(GlobalKey key) => _viewportKey = key; + + void detachViewportKey(GlobalKey key) { + if (_viewportKey == key) { + _viewportKey = null; + } + } + void scheduleRestore({ required List messages, required Map keysByMessageId, @@ -630,7 +641,7 @@ class ChatScrollCoordinator { } RenderBox? get _viewportRender { - final object = viewportKey.currentContext?.findRenderObject(); + final object = _viewportKey?.currentContext?.findRenderObject(); return object is RenderBox ? object : null; } diff --git a/lib/ui/home/chat/files_preview.dart b/lib/ui/home/chat/files_preview.dart index 44398c3685..ef33e7b493 100644 --- a/lib/ui/home/chat/files_preview.dart +++ b/lib/ui/home/chat/files_preview.dart @@ -1,5 +1,3 @@ -// ignore_for_file: parameter_assignments - import 'dart:async'; import 'dart:io'; @@ -49,6 +47,8 @@ import '../../provider/quote_message_provider.dart'; import 'image_caption_input.dart'; import 'image_editor.dart'; +part 'files_preview_model.dart'; + Future showFilesPreviewDialog( BuildContext context, List files, @@ -59,129 +59,6 @@ Future showFilesPreviewDialog( ); } -/// We need this view object to keep the value of file#length. -sealed class _File { - const _File({required this.file}); - - factory _File.normal(String path) => _NormalFile._(file: File(path).xFile); - - factory _File.image(File file, [ImageEditorSnapshot? snapshot]) => - _ImageFile._(file: file.xFile, imageEditorSnapshot: snapshot); - - factory _File.auto(XFile file) { - if (file.mimeType == null) { - e('mimeType is null'); - file = file.withMineType(); - } - if (file.isImage) { - return _ImageFile._(file: file); - } else if (kPlatformIsDarwin && file.isVideo) { - return _VideoFile._(file: file); - } else { - return _NormalFile._(file: file); - } - } - - final XFile file; - - bool get isImage => false; - - bool get isVideo => false; - - bool get isMedia => isImage || isVideo; - - String get path => file.path; - - String? get mimeType => file.mimeType; -} - -class _NormalFile extends _File { - const _NormalFile._({required super.file}); -} - -class _ImageFile extends _File { - const _ImageFile._({required super.file, this.imageEditorSnapshot}); - - final ImageEditorSnapshot? imageEditorSnapshot; - - @override - bool get isImage => true; -} - -class _VideoMetadata { - _VideoMetadata({ - required this.width, - required this.height, - required this.duration, - }); - - final int width; - final int height; - final int duration; -} - -class _VideoFile extends _File { - _VideoFile._({required super.file}) { - _loadMetadata(); - } - - static Future _videoBlurHash( - (RootIsolateToken token, String videoPath) params, - ) async { - final (token, videoPath) = params; - BackgroundIsolateBinaryMessenger.ensureInitialized(token); - final bytes = await customVideoCompress.getByteThumbnail(videoPath); - final decodedImage = image.decodeImage(bytes!)!; - return BlurHash.encode( - image.copyResize(decodedImage, width: 100, maintainAspect: true), - ).hash; - } - - final _metadataCompleter = Completer<_VideoMetadata?>(); - - final _blurHashCompleter = Completer(); - - Future _loadMetadata() async { - try { - final mediaInfo = await customVideoCompress.getMediaInfo(file.path); - final duration = mediaInfo.duration ?? 0.0; - _metadataCompleter.complete( - _VideoMetadata( - width: mediaInfo.width ?? 0, - height: mediaInfo.height ?? 0, - duration: duration.floor(), - ), - ); - } catch (error, stackTrace) { - e('failed to load video metadata', error, stackTrace); - _metadataCompleter.complete(null); - } - - final stopwatch = Stopwatch()..start(); - try { - final hash = await compute(_videoBlurHash, ( - ServicesBinding.rootIsolateToken!, - file.path, - )); - _blurHashCompleter.complete(hash); - } catch (error, stackTrace) { - e('failed to load video thumbnail', error, stackTrace); - _blurHashCompleter.complete(null); - } - - d('thumbnail cost: ${stopwatch.elapsedMilliseconds}ms'); - } - - @override - bool get isVideo => true; -} - -typedef _ImageEditedCallback = void Function(_File, ImageEditorSnapshot); - -const _kDefaultArchiveName = 'Archive.zip'; - -enum _TabType { image, files, zip } - class _FilesPreviewDialog extends HookConsumerWidget { const _FilesPreviewDialog({required this.initialFiles}); diff --git a/lib/ui/home/chat/files_preview_model.dart b/lib/ui/home/chat/files_preview_model.dart new file mode 100644 index 0000000000..9088b78339 --- /dev/null +++ b/lib/ui/home/chat/files_preview_model.dart @@ -0,0 +1,124 @@ +part of 'files_preview.dart'; + +/// We need this view object to keep the value of file#length. +sealed class _File { + const _File({required this.file}); + + factory _File.normal(String path) => _NormalFile._(file: File(path).xFile); + + factory _File.image(File file, [ImageEditorSnapshot? snapshot]) => + _ImageFile._(file: file.xFile, imageEditorSnapshot: snapshot); + + factory _File.auto(XFile file) { + final resolvedFile = file.mimeType == null ? file.withMineType() : file; + if (file.mimeType == null) { + e('mimeType is null'); + } + if (resolvedFile.isImage) { + return _ImageFile._(file: resolvedFile); + } else if (kPlatformIsDarwin && resolvedFile.isVideo) { + return _VideoFile._(file: resolvedFile); + } else { + return _NormalFile._(file: resolvedFile); + } + } + + final XFile file; + + bool get isImage => false; + + bool get isVideo => false; + + bool get isMedia => isImage || isVideo; + + String get path => file.path; + + String? get mimeType => file.mimeType; +} + +class _NormalFile extends _File { + const _NormalFile._({required super.file}); +} + +class _ImageFile extends _File { + const _ImageFile._({required super.file, this.imageEditorSnapshot}); + + final ImageEditorSnapshot? imageEditorSnapshot; + + @override + bool get isImage => true; +} + +class _VideoMetadata { + _VideoMetadata({ + required this.width, + required this.height, + required this.duration, + }); + + final int width; + final int height; + final int duration; +} + +class _VideoFile extends _File { + _VideoFile._({required super.file}) { + _loadMetadata(); + } + + static Future _videoBlurHash( + (RootIsolateToken token, String videoPath) params, + ) async { + final (token, videoPath) = params; + BackgroundIsolateBinaryMessenger.ensureInitialized(token); + final bytes = await customVideoCompress.getByteThumbnail(videoPath); + final decodedImage = image.decodeImage(bytes!)!; + return BlurHash.encode( + image.copyResize(decodedImage, width: 100, maintainAspect: true), + ).hash; + } + + final _metadataCompleter = Completer<_VideoMetadata?>(); + + final _blurHashCompleter = Completer(); + + Future _loadMetadata() async { + try { + final mediaInfo = await customVideoCompress.getMediaInfo(file.path); + final duration = mediaInfo.duration ?? 0.0; + _metadataCompleter.complete( + _VideoMetadata( + width: mediaInfo.width ?? 0, + height: mediaInfo.height ?? 0, + duration: duration.floor(), + ), + ); + } catch (error, stackTrace) { + e('failed to load video metadata', error, stackTrace); + _metadataCompleter.complete(null); + } + + final stopwatch = Stopwatch()..start(); + try { + final hash = await compute(_videoBlurHash, ( + ServicesBinding.rootIsolateToken!, + file.path, + )); + _blurHashCompleter.complete(hash); + } catch (error, stackTrace) { + e('failed to load video thumbnail', error, stackTrace); + _blurHashCompleter.complete(null); + } + + d('thumbnail cost: ${stopwatch.elapsedMilliseconds}ms'); + } + + @override + bool get isVideo => true; +} + +typedef _ImageEditedCallback = void Function(_File, ImageEditorSnapshot); + +const _kDefaultArchiveName = 'Archive.zip'; + +enum _TabType { image, files, zip } diff --git a/lib/ui/home/chat/image_editor.dart b/lib/ui/home/chat/image_editor.dart index c6f652651b..fc9708555c 100644 --- a/lib/ui/home/chat/image_editor.dart +++ b/lib/ui/home/chat/image_editor.dart @@ -20,6 +20,8 @@ import '../../../widgets/dialog.dart'; import '../../../widgets/menu.dart'; import '../../../widgets/toast.dart'; +part 'image_editor_model.dart'; + Future showImageEditor( BuildContext context, { required String path, @@ -99,106 +101,6 @@ class _ImageEditorDialog extends HookConsumerWidget { } } -class CustomDrawLine extends Equatable { - const CustomDrawLine(this.path, this.color, this.width, this.eraser); - - final Path path; - final Color color; - final double width; - final bool eraser; - - @override - List get props => [path, color, width, eraser]; -} - -enum DrawMode { none, brush, eraser } - -class ImageEditorSnapshot { - ImageEditorSnapshot({ - required this.imageRotate, - required this.flip, - required this.customDrawLines, - required this.cropRect, - required this.rawImagePath, - required this.imagePath, - }); - - final ImageRotate imageRotate; - final bool flip; - final List customDrawLines; - final Rect cropRect; - final String rawImagePath; - final String imagePath; -} - -class _ImageEditorState extends Equatable with EquatableMixin { - const _ImageEditorState({ - required this.rotate, - required this.flip, - required this.drawLines, - required this.drawColor, - required this.drawMode, - required this.canRedo, - required this.cropRect, - required this.image, - }); - - final ImageRotate rotate; - - final bool flip; - - final DrawMode drawMode; - - final List drawLines; - - /// Crop area of the image. zero means no crop. - final Rect cropRect; - - final Color drawColor; - - final bool canRedo; - - final ui.Image image; - - bool get canReset => - rotate != ImageRotate.none || - drawLines.isNotEmpty || - flip || - cropRect.width.round() != image.width || - cropRect.height.round() != image.height; - - @override - List get props => [ - rotate, - flip, - drawLines, - drawColor, - drawMode, - canRedo, - cropRect, - image, - ]; - - _ImageEditorState copyWith({ - ImageRotate? rotate, - bool? flip, - List? drawLines, - Color? drawColor, - DrawMode? drawMode, - bool? canRedo, - Rect? cropRect, - }) => _ImageEditorState( - rotate: rotate ?? this.rotate, - flip: flip ?? this.flip, - drawLines: drawLines ?? this.drawLines, - drawColor: drawColor ?? this.drawColor, - drawMode: drawMode ?? this.drawMode, - canRedo: canRedo ?? this.canRedo, - cropRect: cropRect ?? this.cropRect, - image: image, - ); -} - class _ImageEditorNotifier extends ValueNotifier<_ImageEditorState> { _ImageEditorNotifier({ required this.path, @@ -1037,55 +939,6 @@ extension _ImageDragAreaCursor on _ImageDragArea? { } } -enum ImageRotate { none, quarter, half, threeQuarter } - -extension _ImageRotateExt on ImageRotate { - double get radius { - switch (this) { - case ImageRotate.none: - return 0; - case ImageRotate.quarter: - return math.pi / 2; - case ImageRotate.half: - return math.pi; - case ImageRotate.threeQuarter: - return math.pi * 3 / 2; - } - } - - double get degree { - switch (this) { - case ImageRotate.none: - return 0; - case ImageRotate.quarter: - return 90; - case ImageRotate.half: - return 180; - case ImageRotate.threeQuarter: - return 270; - } - } - - Size apply(Size size) { - if (!_boundRotated) { - return size; - } - return Size(size.height, size.width); - } - - bool get _boundRotated { - switch (this) { - case ImageRotate.none: - return false; - case ImageRotate.quarter: - case ImageRotate.threeQuarter: - return true; - case ImageRotate.half: - return false; - } - } -} - Offset _rotate(Offset position, Offset center, double radius) => Offset( (position.dx - center.dx) * math.cos(radius) - (position.dy - center.dy) * math.sin(radius) + diff --git a/lib/ui/home/chat/image_editor_model.dart b/lib/ui/home/chat/image_editor_model.dart new file mode 100644 index 0000000000..1b7c778e68 --- /dev/null +++ b/lib/ui/home/chat/image_editor_model.dart @@ -0,0 +1,142 @@ +part of 'image_editor.dart'; + +class CustomDrawLine extends Equatable { + const CustomDrawLine(this.path, this.color, this.width, this.eraser); + + final Path path; + final Color color; + final double width; + final bool eraser; + + @override + List get props => [path, color, width, eraser]; +} + +enum DrawMode { none, brush, eraser } + +class ImageEditorSnapshot { + ImageEditorSnapshot({ + required this.imageRotate, + required this.flip, + required this.customDrawLines, + required this.cropRect, + required this.rawImagePath, + required this.imagePath, + }); + + final ImageRotate imageRotate; + final bool flip; + final List customDrawLines; + final Rect cropRect; + final String rawImagePath; + final String imagePath; +} + +class _ImageEditorState extends Equatable with EquatableMixin { + const _ImageEditorState({ + required this.rotate, + required this.flip, + required this.drawLines, + required this.drawColor, + required this.drawMode, + required this.canRedo, + required this.cropRect, + required this.image, + }); + + final ImageRotate rotate; + final bool flip; + final DrawMode drawMode; + final List drawLines; + final Rect cropRect; + final Color drawColor; + final bool canRedo; + final ui.Image image; + + bool get canReset => + rotate != ImageRotate.none || + drawLines.isNotEmpty || + flip || + cropRect.width.round() != image.width || + cropRect.height.round() != image.height; + + @override + List get props => [ + rotate, + flip, + drawLines, + drawColor, + drawMode, + canRedo, + cropRect, + image, + ]; + + _ImageEditorState copyWith({ + ImageRotate? rotate, + bool? flip, + List? drawLines, + Color? drawColor, + DrawMode? drawMode, + bool? canRedo, + Rect? cropRect, + }) => _ImageEditorState( + rotate: rotate ?? this.rotate, + flip: flip ?? this.flip, + drawLines: drawLines ?? this.drawLines, + drawColor: drawColor ?? this.drawColor, + drawMode: drawMode ?? this.drawMode, + canRedo: canRedo ?? this.canRedo, + cropRect: cropRect ?? this.cropRect, + image: image, + ); +} + +enum ImageRotate { none, quarter, half, threeQuarter } + +extension _ImageRotateExt on ImageRotate { + double get radius { + switch (this) { + case ImageRotate.none: + return 0; + case ImageRotate.quarter: + return math.pi / 2; + case ImageRotate.half: + return math.pi; + case ImageRotate.threeQuarter: + return math.pi * 3 / 2; + } + } + + double get degree { + switch (this) { + case ImageRotate.none: + return 0; + case ImageRotate.quarter: + return 90; + case ImageRotate.half: + return 180; + case ImageRotate.threeQuarter: + return 270; + } + } + + Size apply(Size size) { + if (!_boundRotated) { + return size; + } + return Size(size.height, size.width); + } + + bool get _boundRotated { + switch (this) { + case ImageRotate.none: + return false; + case ImageRotate.quarter: + case ImageRotate.threeQuarter: + return true; + case ImageRotate.half: + return false; + } + } +} diff --git a/lib/ui/home/chat/input_container.dart b/lib/ui/home/chat/input_container.dart index fff4534858..8e90aed5f7 100644 --- a/lib/ui/home/chat/input_container.dart +++ b/lib/ui/home/chat/input_container.dart @@ -49,6 +49,8 @@ import '../notifier/chat_side_notifier.dart'; import 'files_preview.dart'; import 'voice_recorder_bottom_bar.dart'; +part 'input_highlight_controller.dart'; + class InputContainer extends HookConsumerWidget { const InputContainer({super.key}); @@ -874,101 +876,6 @@ class _StickerPagePositionedLayoutDelegate extends SingleChildLayoutDelegate { ) => position != oldDelegate.position; } -class MentionTextMatcher extends TextMatcher implements EquatableMixin { - MentionTextMatcher(this.mentionCache, this.highlightTextStyle) - : super.regExp( - regExp: mentionNumberRegExp, - matchBuilder: (span, displayString, linkString) { - if (displayString != linkString) return TextSpan(text: linkString); - - final identityNumber = linkString.substring(1); - final user = mentionCache.identityNumberCache(identityNumber); - final valid = user != null; - - if (displayString != linkString) return TextSpan(text: linkString); - - return TextSpan( - text: displayString, - style: valid - ? (span.style ?? const TextStyle()).merge( - highlightTextStyle, - ) - : span.style, - ); - }, - ); - - final MentionCache mentionCache; - final TextStyle highlightTextStyle; - - @override - List get props => [mentionCache, highlightTextStyle]; - - @override - bool? get stringify => true; -} - -class _HighlightTextEditingController extends TextEditingController { - _HighlightTextEditingController({ - required this.highlightTextStyle, - required this.mentionCache, - String? initialText, - }) : super(text: initialText) { - mentionsStreamController.stream - .distinct() - .asyncBufferMap( - (event) => mentionCache.checkMentionCache(event.toSet()), - ) - .distinct(mapEquals) - .listen((event) => notifyListeners()); - } - - final TextStyle highlightTextStyle; - final MentionCache mentionCache; - final mentionsStreamController = StreamController(); - - @override - Future dispose() async { - await mentionsStreamController.close(); - return super.dispose(); - } - - @override - TextSpan buildTextSpan({ - required BuildContext context, - required bool withComposing, - TextStyle? style, - }) { - mentionsStreamController.add(text); - if (!value.isComposingRangeValid || !withComposing) { - return _buildTextSpan(text, style); - } - - return TextSpan( - style: style, - children: [ - _buildTextSpan(value.composing.textBefore(value.text), style), - _buildTextSpan( - value.composing.textInside(value.text), - style?.merge(const TextStyle(decoration: TextDecoration.underline)), - ), - _buildTextSpan(value.composing.textAfter(value.text), style), - ], - ); - } - - TextSpan _buildTextSpan(String text, TextStyle? style) => TextSpan( - style: style, - children: TextMatcher.applyTextMatchers( - [TextSpan(text: text, style: style)], - [ - MentionTextMatcher(mentionCache, highlightTextStyle), - EmojiTextMatcher(), - ], - ).toList(), - ); -} - class _SendMessageIntent extends Intent { const _SendMessageIntent(); } diff --git a/lib/ui/home/chat/input_highlight_controller.dart b/lib/ui/home/chat/input_highlight_controller.dart new file mode 100644 index 0000000000..dc095333af --- /dev/null +++ b/lib/ui/home/chat/input_highlight_controller.dart @@ -0,0 +1,94 @@ +part of 'input_container.dart'; + +class MentionTextMatcher extends TextMatcher implements EquatableMixin { + MentionTextMatcher(this.mentionCache, this.highlightTextStyle) + : super.regExp( + regExp: mentionNumberRegExp, + matchBuilder: (span, displayString, linkString) { + if (displayString != linkString) return TextSpan(text: linkString); + + final identityNumber = linkString.substring(1); + final user = mentionCache.identityNumberCache(identityNumber); + final valid = user != null; + + return TextSpan( + text: displayString, + style: valid + ? (span.style ?? const TextStyle()).merge( + highlightTextStyle, + ) + : span.style, + ); + }, + ); + + final MentionCache mentionCache; + final TextStyle highlightTextStyle; + + @override + List get props => [mentionCache, highlightTextStyle]; + + @override + bool? get stringify => true; +} + +class _HighlightTextEditingController extends TextEditingController { + _HighlightTextEditingController({ + required this.highlightTextStyle, + required this.mentionCache, + String? initialText, + }) : super(text: initialText) { + mentionsStreamController.stream + .distinct() + .asyncBufferMap( + (event) => mentionCache.checkMentionCache(event.toSet()), + ) + .distinct(mapEquals) + .listen((event) => notifyListeners()); + } + + final TextStyle highlightTextStyle; + final MentionCache mentionCache; + final mentionsStreamController = StreamController(); + + @override + Future dispose() async { + await mentionsStreamController.close(); + return super.dispose(); + } + + @override + TextSpan buildTextSpan({ + required BuildContext context, + required bool withComposing, + TextStyle? style, + }) { + mentionsStreamController.add(text); + if (!value.isComposingRangeValid || !withComposing) { + return _buildTextSpan(text, style); + } + + return TextSpan( + style: style, + children: [ + _buildTextSpan(value.composing.textBefore(value.text), style), + _buildTextSpan( + value.composing.textInside(value.text), + style?.merge(const TextStyle(decoration: TextDecoration.underline)), + ), + _buildTextSpan(value.composing.textAfter(value.text), style), + ], + ); + } + + TextSpan _buildTextSpan(String text, TextStyle? style) => TextSpan( + style: style, + children: TextMatcher.applyTextMatchers( + [TextSpan(text: text, style: style)], + [ + MentionTextMatcher(mentionCache, highlightTextStyle), + EmojiTextMatcher(), + ], + ).toList(), + ); +} diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index c7c4fb87c4..79cc3176a0 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -1,6 +1,8 @@ import 'package:flutter/widgets.dart'; +import '../../../db/dao/message_dao.dart'; import '../../../utils/extension/extension.dart'; +import '../desktop_shell_layout.dart'; import '../notifier/blink_notifier.dart'; import '../notifier/chat_side_notifier.dart'; import '../notifier/message_controller.dart'; @@ -36,26 +38,21 @@ class ChatHistoryLocation { required ChatScrollCoordinator scrollCoordinator, required MessageController messageController, required ChatSideNotifier chatSideNotifier, - required Future Function({ - required String? sourceMessageId, - required String targetMessageId, - }) - restoreDirectionFromSource, + required bool chatSideRouteMode, + required MessageDao messageDao, }) : _blinkNotifier = blinkNotifier, _scrollCoordinator = scrollCoordinator, _messageController = messageController, _chatSideNotifier = chatSideNotifier, - _restoreDirectionFromSource = restoreDirectionFromSource; + _chatSideRouteMode = chatSideRouteMode, + _messageDao = messageDao; final BlinkNotifier _blinkNotifier; final ChatScrollCoordinator _scrollCoordinator; final MessageController _messageController; final ChatSideNotifier _chatSideNotifier; - final Future Function({ - required String? sourceMessageId, - required String targetMessageId, - }) - _restoreDirectionFromSource; + final bool _chatSideRouteMode; + final MessageDao _messageDao; Future jumpTo(ChatJumpTarget target) async { switch (target.kind) { @@ -68,7 +65,7 @@ class ChatHistoryLocation { await _jumpToLatest(); } if (target.closeSideAfterJump) { - _chatSideNotifier.closeAfterContentJump(); + _chatSideNotifier.closeAfterContentJump(routeMode: _chatSideRouteMode); } } @@ -134,6 +131,30 @@ class ChatHistoryLocation { if (state.center != null) return state.center!.messageId; return state.bottomMessage?.messageId ?? state.topMessage?.messageId; } + + Future _restoreDirectionFromSource({ + required String? sourceMessageId, + required String targetMessageId, + }) async { + if (sourceMessageId == null || sourceMessageId == targetMessageId) { + return null; + } + + final results = await Future.wait([ + _messageDao.messageOrderInfo(sourceMessageId), + _messageDao.messageOrderInfo(targetMessageId), + ]); + final sourceInfo = results[0]; + final targetInfo = results[1]; + if (sourceInfo == null || targetInfo == null) return null; + + final sourceAfterTarget = sourceInfo.createdAt == targetInfo.createdAt + ? sourceInfo.rowId > targetInfo.rowId + : sourceInfo.createdAt > targetInfo.createdAt; + return sourceAfterTarget + ? ChatScrollRestoreDirection.towardOlder + : ChatScrollRestoreDirection.towardNewer; + } } extension ChatMessageJump on BuildContext { @@ -162,31 +183,7 @@ extension ChatMessageJump on BuildContext { scrollCoordinator: read(), messageController: read(), chatSideNotifier: read(), - restoreDirectionFromSource: _restoreDirectionFromSource, + chatSideRouteMode: DesktopShellLayout.chatSideRouteModeOf(this), + messageDao: database.messageDao, ); - - Future _restoreDirectionFromSource({ - required String? sourceMessageId, - required String targetMessageId, - }) async { - if (sourceMessageId == null || sourceMessageId == targetMessageId) { - return null; - } - - final messageDao = database.messageDao; - final results = await Future.wait([ - messageDao.messageOrderInfo(sourceMessageId), - messageDao.messageOrderInfo(targetMessageId), - ]); - final sourceInfo = results[0]; - final targetInfo = results[1]; - if (sourceInfo == null || targetInfo == null) return null; - - final sourceAfterTarget = sourceInfo.createdAt == targetInfo.createdAt - ? sourceInfo.rowId > targetInfo.rowId - : sourceInfo.createdAt > targetInfo.createdAt; - return sourceAfterTarget - ? ChatScrollRestoreDirection.towardOlder - : ChatScrollRestoreDirection.towardNewer; - } } diff --git a/lib/ui/home/chat/voice_recorder_bottom_bar.dart b/lib/ui/home/chat/voice_recorder_bottom_bar.dart index b8ee3e209c..3af9a59e94 100644 --- a/lib/ui/home/chat/voice_recorder_bottom_bar.dart +++ b/lib/ui/home/chat/voice_recorder_bottom_bar.dart @@ -22,176 +22,7 @@ import '../../../widgets/waveform_widget.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/quote_message_provider.dart'; -enum VoiceRecorderStatus { idle, recording, recordingStopped } - -class VoiceRecorderState with EquatableMixin { - const VoiceRecorderState({ - required this.status, - this.startTime, - this.recordedData, - }); - - final VoiceRecorderStatus status; - - final DateTime? startTime; - - final RecordedData? recordedData; - - @override - List get props => [startTime, status, recordedData]; -} - -class RecordedData with EquatableMixin { - RecordedData(this.waveform, this.duration, this.path); - - final List waveform; - final Duration duration; - final String path; - - @override - List get props => [waveform, duration, path]; -} - -class VoiceRecorderNotifier extends ValueNotifier { - VoiceRecorderNotifier(this.audioMessagePlayService) - : super(const VoiceRecorderState(status: VoiceRecorderStatus.idle)); - - OggOpusRecorder? _recorder; - String? _recorderFilePath; - - final AudioMessagePlayService audioMessagePlayService; - - Completer? _startingCompleter; - - Timer? _timer; - var _disposed = false; - - VoiceRecorderState get state => value; - - void _setState(VoiceRecorderState state) { - if (_disposed) return; - value = state; - } - - Future startRecording() async { - if (_disposed) return; - if (_startingCompleter != null && !_startingCompleter!.isCompleted) { - d('startRecording: waiting for previous startRecording to complete'); - return; - } - assert(_recorder == null, 'Recorder already started'); - audioMessagePlayService.stop(); - _startingCompleter = Completer(); - final path = await generateTempFilePath(TempFileType.voiceRecord); - _recorderFilePath = path; - final file = File(path); - assert(!file.existsSync(), 'file already exists.'); - if (file.existsSync()) { - await file.delete(); - } - await file.create(recursive: true); - d('start recode voice, path : $path'); - await AudioSession.instance.activeRecord(); - _recorder = OggOpusRecorder(path); - _recorder?.start(); - _timer = Timer(const Duration(seconds: 60), stopRecording); - _setState( - VoiceRecorderState( - startTime: DateTime.now(), - status: VoiceRecorderStatus.recording, - ), - ); - _startingCompleter!.complete(); - } - - Future stopRecording({bool isCanceled = false}) async { - if (_timer?.isActive == true) { - _timer?.cancel(); - _timer = null; - } - - assert(_recorder != null, 'recorder is null.'); - assert(_recorderFilePath != null, 'recorder file path is null.'); - final path = _recorderFilePath; - final recorder = _recorder; - - _recorder = null; - _recorderFilePath = null; - - List? waveform; - double? duration; - - await recorder?.stop(); - - if (!isCanceled) { - waveform = await recorder?.getWaveformData(); - duration = await recorder?.duration(); - } - - recorder?.dispose(); - await AudioSession.instance.deactivate(); - - final recodeData = RecordedData( - waveform ?? const [], - duration == null - ? Duration.zero - : Duration(milliseconds: (duration * 1000).round()), - path!, - ); - - _setState( - VoiceRecorderState( - status: VoiceRecorderStatus.recordingStopped, - recordedData: recodeData, - ), - ); - return recodeData; - } - - Future cancelAndExitRecordMode() async { - if (state.status == VoiceRecorderStatus.idle) { - return; - } - if (state.status == VoiceRecorderStatus.recordingStopped) { - _setState(const VoiceRecorderState(status: VoiceRecorderStatus.idle)); - return; - } - final result = await stopRecording(isCanceled: true); - _setState(const VoiceRecorderState(status: VoiceRecorderStatus.idle)); - try { - await File(result.path).delete(); - } catch (error, stacktrace) { - e('cancelRecording: failed to delete file. $error $stacktrace'); - } - } - - @override - void dispose() { - _disposed = true; - _timer?.cancel(); - unawaited(_disposeRecording()); - super.dispose(); - } - - Future _disposeRecording() async { - if (_startingCompleter != null && !_startingCompleter!.isCompleted) { - await _startingCompleter?.future; - } - if (_recorder != null || _recorderFilePath != null) { - final result = await stopRecording(isCanceled: true); - try { - await File(result.path).delete(); - } catch (error, stacktrace) { - e('disposeRecording: failed to delete file. $error $stacktrace'); - } - return; - } - if (state.status == VoiceRecorderStatus.recording || - state.status == VoiceRecorderStatus.recordingStopped) { - await cancelAndExitRecordMode(); - } - } -} +part 'voice_recorder_state.dart'; class VoiceRecorderBarOverlayComposition extends HookConsumerWidget { const VoiceRecorderBarOverlayComposition({ @@ -516,44 +347,6 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { } } -class _Player { - _Player(this.path); - - final String path; - - final isPlaying = ValueNotifier(false); - - double get position => _player?.currentPosition ?? 0.0; - - OggOpusPlayer? _player; - - Future start() async { - await AudioSession.instance.activePlayback(); - final player = OggOpusPlayer(path); - player.state.addListener(() { - final state = player.state.value; - isPlaying.value = state == PlayerState.playing; - if (state == PlayerState.ended) { - stop(); - } - }); - player.play(); - _player = player; - } - - Future stop() async { - _player?.pause(); - _player?.dispose(); - _player = null; - await AudioSession.instance.deactivate(); - isPlaying.value = false; - } - - void dispose() { - stop(); - } -} - class _RecordedResultPreviewLayout extends HookConsumerWidget { const _RecordedResultPreviewLayout({required this.result}); diff --git a/lib/ui/home/chat/voice_recorder_state.dart b/lib/ui/home/chat/voice_recorder_state.dart new file mode 100644 index 0000000000..ca6190a3d3 --- /dev/null +++ b/lib/ui/home/chat/voice_recorder_state.dart @@ -0,0 +1,208 @@ +part of 'voice_recorder_bottom_bar.dart'; + +enum VoiceRecorderStatus { idle, recording, recordingStopped } + +class VoiceRecorderState with EquatableMixin { + const VoiceRecorderState({ + required this.status, + this.startTime, + this.recordedData, + }); + + final VoiceRecorderStatus status; + final DateTime? startTime; + final RecordedData? recordedData; + + @override + List get props => [startTime, status, recordedData]; +} + +class RecordedData with EquatableMixin { + RecordedData(this.waveform, this.duration, this.path); + + final List waveform; + final Duration duration; + final String path; + + @override + List get props => [waveform, duration, path]; +} + +class VoiceRecorderNotifier extends ValueNotifier { + VoiceRecorderNotifier(this.audioMessagePlayService) + : super(const VoiceRecorderState(status: VoiceRecorderStatus.idle)); + + OggOpusRecorder? _recorder; + String? _recorderFilePath; + + final AudioMessagePlayService audioMessagePlayService; + + Completer? _startingCompleter; + + Timer? _timer; + var _disposed = false; + + VoiceRecorderState get state => value; + + void _setState(VoiceRecorderState state) { + if (_disposed) return; + value = state; + } + + Future startRecording() async { + if (_disposed) return; + if (_startingCompleter != null && !_startingCompleter!.isCompleted) { + d('startRecording: waiting for previous startRecording to complete'); + return; + } + assert(_recorder == null, 'Recorder already started'); + audioMessagePlayService.stop(); + _startingCompleter = Completer(); + final path = await generateTempFilePath(TempFileType.voiceRecord); + _recorderFilePath = path; + final file = File(path); + assert(!file.existsSync(), 'file already exists.'); + if (file.existsSync()) { + await file.delete(); + } + await file.create(recursive: true); + d('start recode voice, path : $path'); + await AudioSession.instance.activeRecord(); + _recorder = OggOpusRecorder(path); + _recorder?.start(); + _timer = Timer(const Duration(seconds: 60), stopRecording); + _setState( + VoiceRecorderState( + startTime: DateTime.now(), + status: VoiceRecorderStatus.recording, + ), + ); + _startingCompleter!.complete(); + } + + Future stopRecording({bool isCanceled = false}) async { + if (_timer?.isActive == true) { + _timer?.cancel(); + _timer = null; + } + + assert(_recorder != null, 'recorder is null.'); + assert(_recorderFilePath != null, 'recorder file path is null.'); + final path = _recorderFilePath; + final recorder = _recorder; + + _recorder = null; + _recorderFilePath = null; + + List? waveform; + double? duration; + + await recorder?.stop(); + + if (!isCanceled) { + waveform = await recorder?.getWaveformData(); + duration = await recorder?.duration(); + } + + recorder?.dispose(); + await AudioSession.instance.deactivate(); + + final recodeData = RecordedData( + waveform ?? const [], + duration == null + ? Duration.zero + : Duration(milliseconds: (duration * 1000).round()), + path!, + ); + + _setState( + VoiceRecorderState( + status: VoiceRecorderStatus.recordingStopped, + recordedData: recodeData, + ), + ); + return recodeData; + } + + Future cancelAndExitRecordMode() async { + if (state.status == VoiceRecorderStatus.idle) { + return; + } + if (state.status == VoiceRecorderStatus.recordingStopped) { + _setState(const VoiceRecorderState(status: VoiceRecorderStatus.idle)); + return; + } + final result = await stopRecording(isCanceled: true); + _setState(const VoiceRecorderState(status: VoiceRecorderStatus.idle)); + try { + await File(result.path).delete(); + } catch (error, stacktrace) { + e('cancelRecording: failed to delete file. $error $stacktrace'); + } + } + + @override + void dispose() { + _disposed = true; + _timer?.cancel(); + unawaited(_disposeRecording()); + super.dispose(); + } + + Future _disposeRecording() async { + if (_startingCompleter != null && !_startingCompleter!.isCompleted) { + await _startingCompleter?.future; + } + if (_recorder != null || _recorderFilePath != null) { + final result = await stopRecording(isCanceled: true); + try { + await File(result.path).delete(); + } catch (error, stacktrace) { + e('disposeRecording: failed to delete file. $error $stacktrace'); + } + return; + } + if (state.status == VoiceRecorderStatus.recording || + state.status == VoiceRecorderStatus.recordingStopped) { + await cancelAndExitRecordMode(); + } + } +} + +class _Player { + _Player(this.path); + + final String path; + + final isPlaying = ValueNotifier(false); + + double get position => _player?.currentPosition ?? 0.0; + + OggOpusPlayer? _player; + + Future start() async { + await AudioSession.instance.activePlayback(); + final player = OggOpusPlayer(path); + player.state.addListener(() { + final state = player.state.value; + isPlaying.value = state == PlayerState.playing; + if (state == PlayerState.ended) { + stop(); + } + }); + player.play(); + _player = player; + } + + Future stop() async { + _player?.pause(); + _player?.dispose(); + _player = null; + await AudioSession.instance.deactivate(); + isPlaying.value = false; + } + + void dispose() { + stop(); + } +} diff --git a/lib/ui/home/chat_slide_page/share_media/media_page.dart b/lib/ui/home/chat_slide_page/share_media/media_page.dart index 40a88050da..0c02546b98 100644 --- a/lib/ui/home/chat_slide_page/share_media/media_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/media_page.dart @@ -10,7 +10,6 @@ import '../../../../widgets/message/item/image/image_message.dart'; import '../../../../widgets/message/item/video/video_message.dart'; import '../../../../widgets/message/message.dart'; import '../../desktop_shell_layout.dart'; -import '../../notifier/chat_side_notifier.dart'; import '../shared_media_page.dart'; import 'shared_media_list.dart'; @@ -26,13 +25,13 @@ class MediaPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final chatSideNotifier = context.read(); + final routeMode = DesktopShellLayout.chatSideRouteModeOf(context); final columnCount = DesktopShellLayout.chatSideMediaColumnCount( - routeMode: chatSideNotifier.isRouteMode, + routeMode: routeMode, ); final pageSize = DesktopShellLayout.chatSideMediaPageSize( maxHeight: maxHeight, - routeMode: chatSideNotifier.isRouteMode, + routeMode: routeMode, ); final messageDao = context.database.messageDao; return SharedMediaList( diff --git a/lib/ui/home/conversation/conversation_focus.dart b/lib/ui/home/conversation/conversation_focus.dart index 942ab6bbb6..448b851a17 100644 --- a/lib/ui/home/conversation/conversation_focus.dart +++ b/lib/ui/home/conversation/conversation_focus.dart @@ -11,7 +11,6 @@ import '../../../utils/local_notification_center.dart'; import '../../../widgets/toast.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/is_bot_group_provider.dart'; -import '../../provider/pending_chat_jump_provider.dart'; import '../../provider/recent_conversation_provider.dart'; import '../conversation_info_destination.dart'; import '../notifier/conversation_list_controller.dart'; @@ -42,9 +41,7 @@ class ConversationFocus { initIndexMessageId == null && initialChatSidePage == null && keyword == null) { - conversationNotifier.openChatPage(); - context.providerContainer.read(pendingChatJumpProvider.notifier).state = - const PendingChatJump.commandLatest(); + conversationNotifier.openLatest(); return; } diff --git a/lib/ui/home/conversation/conversation_list.dart b/lib/ui/home/conversation/conversation_list.dart index 5106dd4478..e83b499930 100644 --- a/lib/ui/home/conversation/conversation_list.dart +++ b/lib/ui/home/conversation/conversation_list.dart @@ -22,10 +22,10 @@ import '../../../widgets/message/item/system_message.dart'; import '../../../widgets/message_status_icon.dart'; import '../../../widgets/unread_text.dart'; import '../../provider/conversation_provider.dart'; -import '../../provider/major_navigation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/minute_timer_provider.dart'; import '../../provider/slide_category_provider.dart'; +import '../desktop_shell_layout.dart'; import '../notifier/conversation_list_controller.dart'; import 'audio_player_bar.dart'; import 'conversation_focus.dart'; @@ -47,8 +47,8 @@ class ConversationList extends HookConsumerWidget { useListenable(conversationListController.avatarCache); final conversationId = ref.watch(currentConversationIdProvider); - final showsConversationSelection = ref.watch( - majorNavigationShowsConversationSelectionProvider, + final showsConversationSelection = !DesktopShellLayout.mainRouteModeOf( + context, ); final itemPositionsListener = conversationListController diff --git a/lib/ui/home/conversation/unseen_conversation_list.dart b/lib/ui/home/conversation/unseen_conversation_list.dart index 39f5c5f2fe..708dbd4663 100644 --- a/lib/ui/home/conversation/unseen_conversation_list.dart +++ b/lib/ui/home/conversation/unseen_conversation_list.dart @@ -3,8 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../provider/conversation_provider.dart'; -import '../../provider/major_navigation_provider.dart'; import '../../provider/unseen_conversations_provider.dart'; +import '../desktop_shell_layout.dart'; import 'conversation_focus.dart'; import 'conversation_list.dart'; import 'menu_wrapper.dart'; @@ -19,8 +19,8 @@ class UnseenConversationList extends HookConsumerWidget { final currentConversationId = ref.watch(currentConversationIdProvider); - final showsConversationSelection = ref.watch( - majorNavigationShowsConversationSelectionProvider, + final showsConversationSelection = !DesktopShellLayout.mainRouteModeOf( + context, ); if (unseenConversations == null) { diff --git a/lib/ui/home/desktop_shell_layout.dart b/lib/ui/home/desktop_shell_layout.dart index 8c4e7b94e0..ab36ee320a 100644 --- a/lib/ui/home/desktop_shell_layout.dart +++ b/lib/ui/home/desktop_shell_layout.dart @@ -1,3 +1,5 @@ +import 'package:flutter/widgets.dart'; + enum DesktopShellLayoutMode { drawer, compactRail, fullRail } // chat category list min width @@ -86,4 +88,50 @@ class DesktopShellLayout { }) => (maxHeight / 90 * 2).toInt() * chatSideMediaColumnCount(routeMode: routeMode); + + static Widget mainRouteMode({ + required bool routeMode, + required Widget child, + }) => _MainRouteMode(routeMode: routeMode, child: child); + + static Widget chatSideRouteMode({ + required bool routeMode, + required Widget child, + }) => _ChatSideRouteMode(routeMode: routeMode, child: child); + + static bool mainRouteModeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_MainRouteMode>()?.routeMode ?? + false; + + static bool chatSideRouteModeOf(BuildContext context) => + context + .dependOnInheritedWidgetOfExactType<_ChatSideRouteMode>() + ?.routeMode ?? + false; +} + +class _MainRouteMode extends InheritedWidget { + const _MainRouteMode({ + required this.routeMode, + required super.child, + }); + + final bool routeMode; + + @override + bool updateShouldNotify(_MainRouteMode oldWidget) => + routeMode != oldWidget.routeMode; +} + +class _ChatSideRouteMode extends InheritedWidget { + const _ChatSideRouteMode({ + required this.routeMode, + required super.child, + }); + + final bool routeMode; + + @override + bool updateShouldNotify(_ChatSideRouteMode oldWidget) => + routeMode != oldWidget.routeMode; } diff --git a/lib/ui/home/home.dart b/lib/ui/home/home.dart index b31a529d6d..10df503fdd 100644 --- a/lib/ui/home/home.dart +++ b/lib/ui/home/home.dart @@ -313,6 +313,7 @@ class _CenterPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final routeMode = DesktopShellLayout.mainRouteModeOf(context); final isSetting = ref.watch( slideCategoryStateProvider.select( (value) => value.type == SlideCategoryType.setting, @@ -326,7 +327,10 @@ class _CenterPage extends HookConsumerWidget { majorNavigationProvider.notifier, ); - if (majorNavigationNotifier.syncSettingCategory(isSetting)) { + if (majorNavigationNotifier.syncSettingCategory( + isSetting, + routeMode: routeMode, + )) { ref.read(conversationProvider.notifier).unselected(); } }); diff --git a/lib/ui/home/notifier/chat_side_notifier.dart b/lib/ui/home/notifier/chat_side_notifier.dart index b667c6dfbd..b536c22129 100644 --- a/lib/ui/home/notifier/chat_side_notifier.dart +++ b/lib/ui/home/notifier/chat_side_notifier.dart @@ -7,22 +7,18 @@ import '../conversation_info_destination.dart'; class ChatSideState extends Equatable { const ChatSideState({ this.destinations = const [], - this.routeMode = false, }); final List destinations; - final bool routeMode; ChatSideState copyWith({ List? destinations, - bool? routeMode, }) => ChatSideState( destinations: destinations ?? this.destinations, - routeMode: routeMode ?? this.routeMode, ); @override - List get props => [destinations, routeMode]; + List get props => [destinations]; } class ChatSideNotifier extends ValueNotifier { @@ -40,16 +36,9 @@ class ChatSideNotifier extends ValueNotifier { ConversationInfoDestination? get currentDestination => state.destinations.isEmpty ? null : state.destinations.last; - bool get isRouteMode => state.routeMode; - bool isCurrentDestination(ConversationInfoDestination destination) => currentDestination == destination; - void updateRouteMode(bool routeMode) { - if (state.routeMode == routeMode) return; - Future(() => state = state.copyWith(routeMode: routeMode)); - } - void openDestination(ConversationInfoDestination destination) { final destinations = state.destinations.toList(); final index = destinations.indexOf(destination); @@ -76,30 +65,16 @@ class ChatSideNotifier extends ValueNotifier { ); } - Future replaceDestination( - ConversationInfoDestination destination, - ) async { - state = state.copyWith( - destinations: state.destinations - .where((item) => item != destination) - .toList(), - ); - await Future.delayed(Duration.zero); - openDestination(destination); - } - - Future toggleDestination( - ConversationInfoDestination destination, - ) async { + void toggleDestination(ConversationInfoDestination destination) { if (isCurrentDestination(destination)) { pop(); return; } - await replaceDestination(destination); + openDestination(destination); } - void closeAfterContentJump() { - if (isRouteMode) clear(); + void closeAfterContentJump({required bool routeMode}) { + if (routeMode) clear(); } void toggleInfoPage() { diff --git a/lib/ui/home/notifier/message_controller.dart b/lib/ui/home/notifier/message_controller.dart index 05809e4b4d..2d2a4477b4 100644 --- a/lib/ui/home/notifier/message_controller.dart +++ b/lib/ui/home/notifier/message_controller.dart @@ -159,6 +159,7 @@ class MessageController extends ValueNotifier { event?.initIndexMessageId, event?.lastReadMessageId, event?.refreshKey, + event?.forceLatestKey, ), ) .distinct() @@ -166,6 +167,7 @@ class MessageController extends ValueNotifier { (event) => _init( centerMessageId: event.$2, lastReadMessageId: event.$3, + forceLatest: event.$5 != null, ), ), ) diff --git a/lib/ui/home/route/major_navigator.dart b/lib/ui/home/route/major_navigator.dart index c90c8133df..503643f339 100644 --- a/lib/ui/home/route/major_navigator.dart +++ b/lib/ui/home/route/major_navigator.dart @@ -16,6 +16,7 @@ import '../../setting/storage_page.dart'; import '../../setting/storage_usage_detail_page.dart'; import '../../setting/storage_usage_list_page.dart'; import '../chat/chat_page.dart'; +import '../desktop_shell_layout.dart'; class MajorNavigator extends HookConsumerWidget { const MajorNavigator({ @@ -31,32 +32,36 @@ class MajorNavigator extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final notifier = ref.watch(majorNavigationProvider.notifier); final state = ref.watch(majorNavigationProvider); return LayoutBuilder( builder: (context, boxConstraints) { final routeMode = boxConstraints.maxWidth < switchWidth; - notifier.updateRouteMode(routeMode); - return Row( - children: [ - if (!routeMode) leftPage.child, - Expanded( - child: ClipRect( - child: Navigator( - transitionDelegate: WithoutAnimationDelegate( - routeWithoutAnimation: {leftPage.name, rightEmptyPage.name}, + return DesktopShellLayout.mainRouteMode( + routeMode: routeMode, + child: Row( + children: [ + if (!routeMode) leftPage.child, + Expanded( + child: ClipRect( + child: Navigator( + transitionDelegate: WithoutAnimationDelegate( + routeWithoutAnimation: { + leftPage.name, + rightEmptyPage.name, + }, + ), + onDidRemovePage: (page) {}, + pages: [ + if (routeMode) leftPage, + if (!routeMode && state.entries.isEmpty) rightEmptyPage, + ...state.entries.map(_pageFor), + ], ), - onDidRemovePage: (page) {}, - pages: [ - if (routeMode) leftPage, - if (!routeMode && state.entries.isEmpty) rightEmptyPage, - ...state.entries.map(_pageFor), - ], ), ), - ), - ], + ], + ), ); }, ); diff --git a/lib/ui/provider/conversation_provider.dart b/lib/ui/provider/conversation_provider.dart index ff67c176a8..da97132b28 100644 --- a/lib/ui/provider/conversation_provider.dart +++ b/lib/ui/provider/conversation_provider.dart @@ -31,6 +31,7 @@ class ConversationState extends Equatable { this.participant, this.initialSidePage, this.keyword, + this.forceLatestKey, }); final String conversationId; @@ -45,6 +46,7 @@ class ConversationState extends Equatable { final ConversationInfoDestination? initialSidePage; final String? keyword; + final Object? forceLatestKey; bool get isLoaded => conversation != null || user != null; @@ -90,6 +92,7 @@ class ConversationState extends Equatable { initialSidePage, participant, keyword, + forceLatestKey, ]; ConversationState copyWith({ @@ -104,6 +107,7 @@ class ConversationState extends Equatable { Object? refreshKey, ConversationInfoDestination? initialSidePage, String? keyword, + Object? forceLatestKey, }) => ConversationState( conversationId: conversationId ?? this.conversationId, userId: userId ?? this.userId, @@ -116,6 +120,7 @@ class ConversationState extends Equatable { initialSidePage: initialSidePage ?? this.initialSidePage, participant: participant ?? this.participant, keyword: keyword ?? this.keyword, + forceLatestKey: forceLatestKey ?? this.forceLatestKey, ); } @@ -245,6 +250,11 @@ class ConversationStateNotifier _majorNavigationNotifier.openChatPage(); } + void openLatest() { + state = state?.copyWith(forceLatestKey: Object()); + openChatPage(); + } + void focus(ConversationState nextState) { state = nextState; openChatPage(); diff --git a/lib/ui/provider/major_navigation_provider.dart b/lib/ui/provider/major_navigation_provider.dart index 42ae8c8bc7..175fc00a09 100644 --- a/lib/ui/provider/major_navigation_provider.dart +++ b/lib/ui/provider/major_navigation_provider.dart @@ -32,27 +32,21 @@ class MajorNavigationEntry extends Equatable { class MajorNavigationState extends Equatable { const MajorNavigationState({ this.entries = const [], - this.routeMode = false, }); final List entries; - final bool routeMode; - - bool get showsConversationSelection => !routeMode; bool contains(MajorNavigationDestination destination) => entries.any((entry) => entry.destination == destination); MajorNavigationState copyWith({ List? entries, - bool? routeMode, }) => MajorNavigationState( entries: entries ?? this.entries, - routeMode: routeMode ?? this.routeMode, ); @override - List get props => [routeMode, entries]; + List get props => [entries]; } class MajorNavigationNotifier @@ -74,11 +68,6 @@ class MajorNavigationNotifier MajorNavigationDestination.securityPage, }; - void updateRouteMode(bool routeMode) { - if (state.routeMode == routeMode) return; - Future(() => state = state.copyWith(routeMode: routeMode)); - } - void clear() { state = state.copyWith(entries: []); } @@ -87,10 +76,10 @@ class MajorNavigationNotifier open(MajorNavigationDestination.chatPage); } - bool syncSettingCategory(bool isSetting) { - final openSettingPage = isSetting && !state.routeMode; + bool syncSettingCategory(bool isSetting, {required bool routeMode}) { + final openSettingPage = isSetting && !routeMode; _removeWhere((entry) { - if (state.routeMode) return true; + if (routeMode) return true; return settingDestinations.contains(entry.destination); }); if (openSettingPage) { @@ -125,10 +114,3 @@ final majorNavigationProvider = MajorNavigationNotifier, MajorNavigationState >((ref) => MajorNavigationNotifier()); - -final majorNavigationRouteModeProvider = majorNavigationProvider.select( - (value) => value.routeMode, -); - -final majorNavigationShowsConversationSelectionProvider = - majorNavigationProvider.select((value) => value.showsConversationSelection); diff --git a/lib/ui/provider/pending_chat_jump_provider.dart b/lib/ui/provider/pending_chat_jump_provider.dart index 7594ea09b6..983aaefc11 100644 --- a/lib/ui/provider/pending_chat_jump_provider.dart +++ b/lib/ui/provider/pending_chat_jump_provider.dart @@ -16,26 +16,9 @@ final pendingChatJumpProvider = StateProvider.autoDispose(( }); class PendingChatJump { - const PendingChatJump.commandMessage(this.messageId) - : kind = PendingChatJumpKind.command; - - const PendingChatJump.commandLatest() - : kind = PendingChatJumpKind.command, - messageId = null; - - const PendingChatJump.returnToMessage(this.messageId) - : kind = PendingChatJumpKind.returnTarget; - - final PendingChatJumpKind kind; + const PendingChatJump.returnToMessage(this.messageId); final String? messageId; - bool get isLatest => messageId == null; - - bool get isCommand => kind == PendingChatJumpKind.command; - - String? get returnMessageId => - kind == PendingChatJumpKind.returnTarget ? messageId : null; + String? get returnMessageId => messageId; } - -enum PendingChatJumpKind { command, returnTarget } diff --git a/lib/ui/setting/setting_page.dart b/lib/ui/setting/setting_page.dart index 6fe6c85fde..4e6b0eb091 100644 --- a/lib/ui/setting/setting_page.dart +++ b/lib/ui/setting/setting_page.dart @@ -19,6 +19,7 @@ import '../../widgets/cell.dart'; import '../../widgets/conversation/badges_widget.dart'; import '../../widgets/high_light_text.dart'; import '../../widgets/toast.dart'; +import '../home/desktop_shell_layout.dart'; import '../home/left_rail_controller.dart'; import '../provider/major_navigation_provider.dart'; import '../provider/multi_auth_provider.dart'; @@ -189,12 +190,11 @@ class _Item extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final routeMode = DesktopShellLayout.mainRouteModeOf(context); final selected = ref.watch( majorNavigationProvider.select( (value) => - !value.routeMode && - destination != null && - value.contains(destination!), + !routeMode && destination != null && value.contains(destination!), ), ); diff --git a/lib/widgets/cell.dart b/lib/widgets/cell.dart index 20c3d4fbc7..6fe9116992 100644 --- a/lib/widgets/cell.dart +++ b/lib/widgets/cell.dart @@ -3,7 +3,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/resources.dart'; -import '../ui/provider/major_navigation_provider.dart'; +import '../ui/home/desktop_shell_layout.dart'; import '../utils/extension/extension.dart'; import 'interactive_decorated_box.dart'; @@ -79,7 +79,7 @@ class CellItem extends HookConsumerWidget { final dynamicColor = color ?? context.theme.text; final backgroundColor = _CellItemStyle.of(context).backgroundColor; var selectedBackgroundColor = backgroundColor; - if (selected && !ref.watch(majorNavigationRouteModeProvider)) { + if (selected && !DesktopShellLayout.mainRouteModeOf(context)) { selectedBackgroundColor = Color.alphaBlend( context.dynamicColor( const Color.fromRGBO(0, 0, 0, 0.05), diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index c913c00cf8..caaf36b691 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -89,6 +89,48 @@ class MessageRowModel { late final DateTime? dateTime; } +class MessageRows { + const MessageRows({ + required this.top, + required this.bottom, + this.center, + }); + + factory MessageRows.from({ + required List top, + required MessageItem? center, + required List bottom, + }) => MessageRows( + top: [ + for (var index = 0; index < top.length; index++) + MessageRowModel( + message: top[index], + prev: top.getOrNull(index - 1), + next: top.getOrNull(index + 1) ?? center ?? bottom.lastOrNull, + ), + ], + center: center == null + ? null + : MessageRowModel( + message: center, + prev: top.lastOrNull, + next: bottom.firstOrNull, + ), + bottom: [ + for (var index = 0; index < bottom.length; index++) + MessageRowModel( + message: bottom[index], + prev: bottom.getOrNull(index - 1) ?? center ?? top.lastOrNull, + next: bottom.getOrNull(index + 1), + ), + ], + ); + + final List top; + final MessageRowModel? center; + final List bottom; +} + bool useIsTranscriptPage() => _useMessageContextConverter((state) => state.isTranscriptPage); diff --git a/lib/widgets/message/message_bubble.dart b/lib/widgets/message/message_bubble.dart index 3739dc3a1e..a9ac0f0430 100644 --- a/lib/widgets/message/message_bubble.dart +++ b/lib/widgets/message/message_bubble.dart @@ -82,40 +82,42 @@ class MessageBubble extends HookConsumerWidget { if (hasQuoteMessage) { final constraintQuoteWidthToMessage = messageType.isVideo || messageType.isImage || messageType.isLive; - _child = IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SizedBox( - // constraint width to 0, the ancestor IntrinsicWidth will relayout - // and get the correct width. - width: constraintQuoteWidthToMessage ? 0 : null, - child: MessageBubbleNipPadding( - currentUser: isCurrentUser, - child: HookBuilder( - builder: (context) { - final quoteContent = useMessageConverter( - converter: (state) => state.quoteContent, - ); - final messageId = useMessageConverter( - converter: (state) => state.messageId, - ); - - return QuoteMessage( - messageId: messageId, - quoteMessageId: quoteId, - quoteContent: quoteContent, - isTranscriptPage: isTranscriptPage, - ); - }, - ), + Widget quotedChild = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: constraintQuoteWidthToMessage + ? CrossAxisAlignment.stretch + : CrossAxisAlignment.start, + children: [ + SizedBox( + width: constraintQuoteWidthToMessage ? 0 : null, + child: MessageBubbleNipPadding( + currentUser: isCurrentUser, + child: HookBuilder( + builder: (context) { + final quoteContent = useMessageConverter( + converter: (state) => state.quoteContent, + ); + final messageId = useMessageConverter( + converter: (state) => state.messageId, + ); + + return QuoteMessage( + messageId: messageId, + quoteMessageId: quoteId, + quoteContent: quoteContent, + isTranscriptPage: isTranscriptPage, + ); + }, ), ), - _child, - ], - ), + ), + _child, + ], ); + if (constraintQuoteWidthToMessage) { + quotedChild = IntrinsicWidth(child: quotedChild); + } + _child = quotedChild; } final clipper = BubbleClipper(currentUser: isCurrentUser, showNip: showNip); diff --git a/test/ui/home/notifier/chat_side_notifier_test.dart b/test/ui/home/notifier/chat_side_notifier_test.dart index cce3a85d3e..6f01e86767 100644 --- a/test/ui/home/notifier/chat_side_notifier_test.dart +++ b/test/ui/home/notifier/chat_side_notifier_test.dart @@ -3,29 +3,26 @@ import 'package:flutter_app/ui/home/notifier/chat_side_notifier.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test('toggles destinations without storing pages in state', () async { + test('toggles destinations without storing pages in state', () { final notifier = ChatSideNotifier(); addTearDown(notifier.dispose); - await notifier.toggleDestination(ConversationInfoDestination.pinMessages); + notifier.toggleDestination(ConversationInfoDestination.pinMessages); expect(notifier.state.destinations, [ ConversationInfoDestination.pinMessages, ]); - await notifier.toggleDestination(ConversationInfoDestination.pinMessages); + notifier.toggleDestination(ConversationInfoDestination.pinMessages); expect(notifier.state.destinations, isEmpty); }); - test('route-mode content jump closes the side stack', () async { + test('route-mode content jump closes the side stack', () { final notifier = ChatSideNotifier(); addTearDown(notifier.dispose); notifier ..openDestination(ConversationInfoDestination.searchMessageHistory) - ..updateRouteMode(true); - await Future.delayed(Duration.zero); - - notifier.closeAfterContentJump(); + ..closeAfterContentJump(routeMode: true); expect(notifier.state.destinations, isEmpty); }); diff --git a/test/ui/provider/major_navigation_provider_test.dart b/test/ui/provider/major_navigation_provider_test.dart index 4f4837962f..bf89040f1c 100644 --- a/test/ui/provider/major_navigation_provider_test.dart +++ b/test/ui/provider/major_navigation_provider_test.dart @@ -12,13 +12,11 @@ void main() { ]); }); - test('syncing setting category clears route-mode pages', () async { + test('syncing setting category clears route-mode pages', () { final notifier = MajorNavigationNotifier() - ..open(MajorNavigationDestination.chatPage) - ..updateRouteMode(true); - await Future.delayed(Duration.zero); + ..open(MajorNavigationDestination.chatPage); - final opened = notifier.syncSettingCategory(true); + final opened = notifier.syncSettingCategory(true, routeMode: true); expect(opened, isFalse); expect(notifier.state.entries, isEmpty); From 3febbd2cc0ddd756075badeb7846fd00c8b962ef Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:40:19 +0900 Subject: [PATCH 08/27] Optimize chat rendering and message window loading --- lib/ui/home/chat/chat_history_viewport.dart | 84 +++++----- lib/ui/home/chat/image_editor.dart | 62 +++++-- lib/ui/home/chat/message_jump.dart | 45 ++--- .../chat_slide_page/pin_messages_page.dart | 32 +--- .../share_media/shared_media_list.dart | 11 +- .../home/conversation/conversation_focus.dart | 22 +-- lib/ui/home/notifier/message_controller.dart | 40 ++--- .../home/notifier/message_window_loader.dart | 78 +++++++++ .../notifier/search_message_controller.dart | 6 +- .../provider/pending_chat_jump_provider.dart | 12 +- lib/widgets/message/item/quote_message.dart | 7 +- lib/widgets/message/message.dart | 157 +++++++++++++----- .../notifier/message_window_loader_test.dart | 67 ++++++++ .../widgets/message/message_context_test.dart | 74 +++++++++ 14 files changed, 471 insertions(+), 226 deletions(-) create mode 100644 test/widgets/message/message_context_test.dart diff --git a/lib/ui/home/chat/chat_history_viewport.dart b/lib/ui/home/chat/chat_history_viewport.dart index fa4a8e6b57..6fa6999a23 100644 --- a/lib/ui/home/chat/chat_history_viewport.dart +++ b/lib/ui/home/chat/chat_history_viewport.dart @@ -3,15 +3,19 @@ import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../../../constants/resources.dart'; +import '../../../db/mixin_database.dart' hide Offset; import '../../../utils/extension/extension.dart'; import '../../../widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/message/message.dart'; import '../../../widgets/message/message_bubble.dart'; import '../../../widgets/message/message_day_time.dart'; +import '../../provider/is_bot_group_provider.dart'; import '../../provider/pending_chat_jump_provider.dart'; +import '../../provider/setting_provider.dart'; import '../notifier/message_controller.dart'; import 'chat_scroll_coordinator.dart'; import 'message_jump.dart'; @@ -52,6 +56,12 @@ class ChatHistoryViewport extends HookConsumerWidget { final center = state.center; final bottom = state.bottom; final messages = state.list; + final conversationId = state.conversationId; + final isBotGroupConversation = + conversationId != null && ref.watch(isBotGroupProvider(conversationId)); + final enableShowAvatar = ref.watch( + settingProvider.select((value) => value.messageShowAvatar), + ); final rows = useMemoized( () => MessageRows.from(top: top, center: center, bottom: bottom), [top, center, bottom], @@ -95,6 +105,28 @@ class ChatHistoryViewport extends HookConsumerWidget { [rows, messageIdsKey], ); + Widget buildMessage(MessageRowModel row) { + final message = row.message; + return ChatRenderedMessage( + coordinator: scrollCoordinator, + messageId: message.messageId, + child: MessageItemWidget( + key: messageKeysRef.value[message.messageId], + row: row, + message: message, + lastReadMessageId: state.lastReadMessageId, + dateTimeKey: row.dateTime == null + ? null + : dayTimeKeysRef.value[message.messageId], + isGroupOrBotGroupConversation: _isGroupOrBotGroupMessage( + message, + isBotGroupConversation, + ), + enableShowAvatar: enableShowAvatar, + ), + ); + } + useEffect( () { scrollCoordinator.scheduleRestore( @@ -143,20 +175,7 @@ class ChatHistoryViewport extends HookConsumerWidget { delegate: SliverChildBuilderDelegate((context, index) { final actualIndex = top.length - index - 1; final row = rows.top[actualIndex]; - final messageItem = row.message; - return ChatRenderedMessage( - coordinator: scrollCoordinator, - messageId: messageItem.messageId, - child: MessageItemWidget( - key: messageKeysRef.value[messageItem.messageId], - row: row, - message: messageItem, - lastReadMessageId: state.lastReadMessageId, - dateTimeKey: row.dateTime == null - ? null - : dayTimeKeysRef.value[messageItem.messageId], - ), - ); + return buildMessage(row); }, childCount: top.length), ), SliverToBoxAdapter( @@ -165,40 +184,14 @@ class ChatHistoryViewport extends HookConsumerWidget { builder: (context) { final row = rows.center; if (row == null) return const SizedBox(); - final center = row.message; - return ChatRenderedMessage( - coordinator: scrollCoordinator, - messageId: center.messageId, - child: MessageItemWidget( - key: messageKeysRef.value[center.messageId], - row: row, - message: center, - lastReadMessageId: state.lastReadMessageId, - dateTimeKey: row.dateTime == null - ? null - : dayTimeKeysRef.value[center.messageId], - ), - ); + return buildMessage(row); }, ), ), SliverList( delegate: SliverChildBuilderDelegate((context, index) { final row = rows.bottom[index]; - final messageItem = row.message; - return ChatRenderedMessage( - coordinator: scrollCoordinator, - messageId: messageItem.messageId, - child: MessageItemWidget( - key: messageKeysRef.value[messageItem.messageId], - row: row, - message: messageItem, - lastReadMessageId: state.lastReadMessageId, - dateTimeKey: row.dateTime == null - ? null - : dayTimeKeysRef.value[messageItem.messageId], - ), - ); + return buildMessage(row); }, childCount: bottom.length), ), const SliverToBoxAdapter(child: SizedBox(height: 10)), @@ -209,6 +202,11 @@ class ChatHistoryViewport extends HookConsumerWidget { } } +bool _isGroupOrBotGroupMessage(MessageItem message, bool isBotGroup) => + message.conversionCategory == ConversationCategory.group || + message.userId != message.conversationOwnerId || + isBotGroup; + List _dayTimeEntries( MessageRows rows, Map messageKeys, @@ -257,7 +255,7 @@ class JumpCurrentButton extends HookConsumerWidget { padding: const EdgeInsets.only(top: 8), child: InteractiveDecoratedBox( onTap: () async { - final messageId = pendingJumpController.state?.returnMessageId; + final messageId = pendingJumpController.state; if (messageId != null) { await context.jumpToMessageInChat(messageId); pendingJumpController.state = null; diff --git a/lib/ui/home/chat/image_editor.dart b/lib/ui/home/chat/image_editor.dart index fc9708555c..2f40da14d9 100644 --- a/lib/ui/home/chat/image_editor.dart +++ b/lib/ui/home/chat/image_editor.dart @@ -1,9 +1,9 @@ import 'dart:io'; import 'dart:math' as math; -import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider; @@ -43,7 +43,7 @@ class _ImageEditorDialog extends HookConsumerWidget { final boundaryKey = useMemoized(GlobalKey.new); final image = useMemoizedFuture( () async { - final bytes = File(path).readAsBytesSync(); + final bytes = await File(path).readAsBytes(); final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); final codec = await PaintingBinding.instance .instantiateImageCodecWithSize(buffer); @@ -101,6 +101,39 @@ class _ImageEditorDialog extends HookConsumerWidget { } } +class _ImageTransform { + const _ImageTransform({ + required this.bytes, + required this.width, + required this.height, + required this.flip, + required this.rotateDegree, + }); + + final Uint8List bytes; + final int width; + final int height; + final bool flip; + final int rotateDegree; +} + +Uint8List _encodeTransformedPng(_ImageTransform transform) { + var imgImage = img.Image.fromBytes( + width: transform.width, + height: transform.height, + bytes: transform.bytes.buffer, + order: img.ChannelOrder.rgba, + ); + + if (transform.flip) { + img.flipHorizontal(imgImage); + } + if (transform.rotateDegree != 0) { + imgImage = img.copyRotate(imgImage, angle: transform.rotateDegree); + } + return Uint8List.fromList(img.encodePng(imgImage)); +} + class _ImageEditorNotifier extends ValueNotifier<_ImageEditorState> { _ImageEditorNotifier({ required this.path, @@ -313,21 +346,18 @@ class _ImageEditorNotifier extends ValueNotifier<_ImageEditorState> { if (bytes == null) { return null; } - var imgImage = img.Image.fromBytes( - width: image.width, - height: image.height, - bytes: bytes.buffer, - order: img.ChannelOrder.rgba, + return compute( + _encodeTransformedPng, + _ImageTransform( + bytes: bytes, + width: image.width, + height: image.height, + flip: state.flip, + rotateDegree: state.rotate == ImageRotate.none + ? 0 + : (360 - state.rotate.degree).toInt(), + ), ); - - if (state.flip) { - img.flipHorizontal(imgImage); - } - if (state.rotate != ImageRotate.none) { - imgImage = img.copyRotate(imgImage, angle: 360 - state.rotate.degree); - } - final data = img.encodePng(imgImage); - return Uint8List.fromList(data); } Future takeSnapshot() async { diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index 79cc3176a0..9906693af9 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; -import '../../../db/dao/message_dao.dart'; import '../../../utils/extension/extension.dart'; import '../desktop_shell_layout.dart'; import '../notifier/blink_notifier.dart'; @@ -39,20 +38,17 @@ class ChatHistoryLocation { required MessageController messageController, required ChatSideNotifier chatSideNotifier, required bool chatSideRouteMode, - required MessageDao messageDao, }) : _blinkNotifier = blinkNotifier, _scrollCoordinator = scrollCoordinator, _messageController = messageController, _chatSideNotifier = chatSideNotifier, - _chatSideRouteMode = chatSideRouteMode, - _messageDao = messageDao; + _chatSideRouteMode = chatSideRouteMode; final BlinkNotifier _blinkNotifier; final ChatScrollCoordinator _scrollCoordinator; final MessageController _messageController; final ChatSideNotifier _chatSideNotifier; final bool _chatSideRouteMode; - final MessageDao _messageDao; Future jumpTo(ChatJumpTarget target) async { switch (target.kind) { @@ -95,9 +91,11 @@ class ChatHistoryLocation { final directionSourceMessageId = sourceMessageId ?? _currentWindowSourceMessageId(_messageController.state); - final direction = await _restoreDirectionFromSource( - sourceMessageId: directionSourceMessageId, - targetMessageId: messageId, + final direction = _toScrollDirection( + await _messageController.restoreDirectionFromSource( + sourceMessageId: directionSourceMessageId, + targetMessageId: messageId, + ), ); traceChatJump( 'restore direction source=${shortMessageId(directionSourceMessageId)} ' @@ -132,29 +130,13 @@ class ChatHistoryLocation { return state.bottomMessage?.messageId ?? state.topMessage?.messageId; } - Future _restoreDirectionFromSource({ - required String? sourceMessageId, - required String targetMessageId, - }) async { - if (sourceMessageId == null || sourceMessageId == targetMessageId) { - return null; - } - - final results = await Future.wait([ - _messageDao.messageOrderInfo(sourceMessageId), - _messageDao.messageOrderInfo(targetMessageId), - ]); - final sourceInfo = results[0]; - final targetInfo = results[1]; - if (sourceInfo == null || targetInfo == null) return null; - - final sourceAfterTarget = sourceInfo.createdAt == targetInfo.createdAt - ? sourceInfo.rowId > targetInfo.rowId - : sourceInfo.createdAt > targetInfo.createdAt; - return sourceAfterTarget - ? ChatScrollRestoreDirection.towardOlder - : ChatScrollRestoreDirection.towardNewer; - } + ChatScrollRestoreDirection? _toScrollDirection( + MessageWindowDirection? direction, + ) => switch (direction) { + MessageWindowDirection.older => ChatScrollRestoreDirection.towardOlder, + MessageWindowDirection.newer => ChatScrollRestoreDirection.towardNewer, + null => null, + }; } extension ChatMessageJump on BuildContext { @@ -184,6 +166,5 @@ extension ChatMessageJump on BuildContext { messageController: read(), chatSideNotifier: read(), chatSideRouteMode: DesktopShellLayout.chatSideRouteModeOf(this), - messageDao: database.messageDao, ); } diff --git a/lib/ui/home/chat_slide_page/pin_messages_page.dart b/lib/ui/home/chat_slide_page/pin_messages_page.dart index 3e5573fa7a..cc75b9686f 100644 --- a/lib/ui/home/chat_slide_page/pin_messages_page.dart +++ b/lib/ui/home/chat_slide_page/pin_messages_page.dart @@ -2,8 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' - hide ChangeNotifierProvider, Provider; +import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; import 'package:provider/provider.dart'; import '../../../blaze/vo/pin_message_minimal.dart'; @@ -47,18 +46,6 @@ class PinMessagesPage extends HookConsumerWidget { keys: [conversationId], ).data; - final chatSideNotifier = useMemoized(ChatSideNotifier.new); - useEffect(() => chatSideNotifier.dispose, [chatSideNotifier]); - final searchConversationKeywordNotifier = useMemoized( - () => - SearchConversationKeywordNotifier(chatSideNotifier: chatSideNotifier), - [chatSideNotifier], - ); - useEffect( - () => searchConversationKeywordNotifier.dispose, - [searchConversationKeywordNotifier], - ); - useEffect(() { if (rawList?.isNotEmpty ?? true) return; scheduleMicrotask(() => Navigator.pop(context)); @@ -69,18 +56,11 @@ class PinMessagesPage extends HookConsumerWidget { final scrollController = useMemoized(ScrollController.new); final listKey = useMemoized(() => GlobalKey(debugLabel: 'PinMessagesPage')); - return MultiProvider( - providers: [ - ChangeNotifierProvider.value( - value: searchConversationKeywordNotifier, - ), - Provider( - create: (_) => AudioMessagesPlayAgent( - list, - (m) => context.accountServer.convertMessageAbsolutePath(m, true), - ), - ), - ], + return Provider( + create: (_) => AudioMessagesPlayAgent( + list, + (m) => context.accountServer.convertMessageAbsolutePath(m, true), + ), child: Scaffold( backgroundColor: context.theme.popUp, appBar: MixinAppBar( diff --git a/lib/ui/home/chat_slide_page/share_media/shared_media_list.dart b/lib/ui/home/chat_slide_page/share_media/shared_media_list.dart index 15ac5964eb..9b6522c2f4 100644 --- a/lib/ui/home/chat_slide_page/share_media/shared_media_list.dart +++ b/lib/ui/home/chat_slide_page/share_media/shared_media_list.dart @@ -76,10 +76,13 @@ class SharedMediaList extends HookConsumerWidget { ); final state = useValueListenable(mediaController); - final grouped = groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }); + final grouped = useMemoized( + () => groupBy(state.list, (messageItem) { + final local = messageItem.createdAt.toLocal(); + return DateTime(local.year, local.month, local.day); + }), + [state.list], + ); final scrollController = useScrollController(); if (grouped.isEmpty) { diff --git a/lib/ui/home/conversation/conversation_focus.dart b/lib/ui/home/conversation/conversation_focus.dart index 448b851a17..e1391b95d2 100644 --- a/lib/ui/home/conversation/conversation_focus.dart +++ b/lib/ui/home/conversation/conversation_focus.dart @@ -179,20 +179,12 @@ class ConversationFocus { BuildContext context, String conversationId, ) async { - final conversations = context - .read() - .state - .map - .values - .cast() - .toList(); - - return conversations.firstWhere( - (element) => element?.conversationId == conversationId, - orElse: () => null, - ) ?? - await context.database.conversationDao - .conversationItem(conversationId) - .getSingleOrNull(); + final conversations = context.read().state.map; + for (final conversation in conversations.values) { + if (conversation.conversationId == conversationId) return conversation; + } + return context.database.conversationDao + .conversationItem(conversationId) + .getSingleOrNull(); } } diff --git a/lib/ui/home/notifier/message_controller.dart b/lib/ui/home/notifier/message_controller.dart index 2d2a4477b4..250711b4d5 100644 --- a/lib/ui/home/notifier/message_controller.dart +++ b/lib/ui/home/notifier/message_controller.dart @@ -348,35 +348,11 @@ class MessageController extends ValueNotifier { _emit(_pretreatment(messageState)); } - Future _before(String conversationId) async { - final topMessageId = state.topMessage?.messageId; - assert(topMessageId != null); - final info = await messageDao.messageOrderInfo(topMessageId!); - final list = await messageDao - .beforeMessagesByConversationId(info!, conversationId, limit) - .get(); - - final isOldest = list.length < limit; - return state.copyWith( - top: [...list.reversed, ...state.top], - isOldest: isOldest, - ); - } + Future _before(String conversationId) => + _messageWindowLoader.loadBefore(state, conversationId, limit); - Future _after(String conversationId) async { - final bottomMessageId = state.bottomMessage?.messageId; - assert(bottomMessageId != null); - final info = await messageDao.messageOrderInfo(bottomMessageId!); - final list = await messageDao - .afterMessagesByConversationId(info!, conversationId, limit) - .get(); - - final isLatest = list.length < limit ? true : null; - return state.copyWith( - bottom: [...state.bottom, ...list], - isLatest: isLatest, - ); - } + Future _after(String conversationId) => + _messageWindowLoader.loadAfter(state, conversationId, limit); Future _resetMessageList( String conversationId, @@ -425,6 +401,14 @@ class MessageController extends ValueNotifier { trace: traceChatJump, ); + Future restoreDirectionFromSource({ + required String? sourceMessageId, + required String targetMessageId, + }) => _messageWindowLoader.directionFromSource( + sourceMessageId: sourceMessageId, + targetMessageId: targetMessageId, + ); + MessageState? _insertOrReplace( String conversationId, List list, diff --git a/lib/ui/home/notifier/message_window_loader.dart b/lib/ui/home/notifier/message_window_loader.dart index 92415a9606..04baa6b09e 100644 --- a/lib/ui/home/notifier/message_window_loader.dart +++ b/lib/ui/home/notifier/message_window_loader.dart @@ -10,15 +10,25 @@ typedef AroundMessageIdsQuery = String conversationId, int limit, ); +typedef AroundMessagesQuery = + Future> Function( + MessageOrderInfo anchor, + String conversationId, + int limit, + ); typedef MessagesByIdsQuery = Future> Function( List messageIds, ); +enum MessageWindowDirection { older, newer } + class MessageWindowLoader { const MessageWindowLoader({ required this.recentMessages, required this.messageOrderInfo, + required this.beforeMessages, + required this.afterMessages, required this.beforeMessageIds, required this.afterMessageIds, required this.messagesByIds, @@ -29,6 +39,12 @@ class MessageWindowLoader { recentMessages: (conversationId, limit) => messageDao.messagesByConversationId(conversationId, limit).get(), messageOrderInfo: messageDao.messageOrderInfo, + beforeMessages: (anchor, conversationId, limit) => messageDao + .beforeMessagesByConversationId(anchor, conversationId, limit) + .get(), + afterMessages: (anchor, conversationId, limit) => messageDao + .afterMessagesByConversationId(anchor, conversationId, limit) + .get(), beforeMessageIds: (anchor, conversationId, limit) => messageDao .beforeMessageIdsByConversationId(anchor, conversationId, limit) .get(), @@ -41,10 +57,72 @@ class MessageWindowLoader { final RecentMessagesQuery recentMessages; final MessageOrderInfoQuery messageOrderInfo; + final AroundMessagesQuery beforeMessages; + final AroundMessagesQuery afterMessages; final AroundMessageIdsQuery beforeMessageIds; final AroundMessageIdsQuery afterMessageIds; final MessagesByIdsQuery messagesByIds; + Future loadBefore( + MessageState state, + String conversationId, + int limit, + ) async { + final topMessageId = state.topMessage?.messageId; + if (topMessageId == null) return state.copyWith(isOldest: true); + + final info = await messageOrderInfo(topMessageId); + if (info == null) return state.copyWith(isOldest: true); + + final messages = await beforeMessages(info, conversationId, limit); + return state.copyWith( + top: [...messages.reversed, ...state.top], + isOldest: messages.length < limit, + ); + } + + Future loadAfter( + MessageState state, + String conversationId, + int limit, + ) async { + final bottomMessageId = state.bottomMessage?.messageId; + if (bottomMessageId == null) return state.copyWith(isLatest: true); + + final info = await messageOrderInfo(bottomMessageId); + if (info == null) return state.copyWith(isLatest: true); + + final messages = await afterMessages(info, conversationId, limit); + return state.copyWith( + bottom: [...state.bottom, ...messages], + isLatest: messages.length < limit ? true : null, + ); + } + + Future directionFromSource({ + required String? sourceMessageId, + required String targetMessageId, + }) async { + if (sourceMessageId == null || sourceMessageId == targetMessageId) { + return null; + } + + final results = await Future.wait([ + messageOrderInfo(sourceMessageId), + messageOrderInfo(targetMessageId), + ]); + final sourceInfo = results[0]; + final targetInfo = results[1]; + if (sourceInfo == null || targetInfo == null) return null; + + final sourceAfterTarget = sourceInfo.createdAt == targetInfo.createdAt + ? sourceInfo.rowId > targetInfo.rowId + : sourceInfo.createdAt > targetInfo.createdAt; + return sourceAfterTarget + ? MessageWindowDirection.older + : MessageWindowDirection.newer; + } + Future load( String conversationId, int limit, { diff --git a/lib/ui/home/notifier/search_message_controller.dart b/lib/ui/home/notifier/search_message_controller.dart index 183273085f..fcd869acc1 100644 --- a/lib/ui/home/notifier/search_message_controller.dart +++ b/lib/ui/home/notifier/search_message_controller.dart @@ -91,7 +91,7 @@ abstract class SearchMessageController _setState(SearchMessageState(state.items, true)); try { final items = await _doFuzzySearch(lastMessageId); - if (items.isEmpty) { + if (items.length < limit) { d('search message controller: no more data $lastMessageId'); _hasMore = false; } @@ -110,7 +110,9 @@ abstract class SearchMessageController if (itemPositionValue.isEmpty) { return; } - final lastIndex = itemPositionValue.last.index; + final lastIndex = itemPositionValue + .map((position) => position.index) + .reduce((a, b) => a > b ? a : b); if (lastIndex >= state.items.length - 4) { _load(); } diff --git a/lib/ui/provider/pending_chat_jump_provider.dart b/lib/ui/provider/pending_chat_jump_provider.dart index 983aaefc11..1098edc0bf 100644 --- a/lib/ui/provider/pending_chat_jump_provider.dart +++ b/lib/ui/provider/pending_chat_jump_provider.dart @@ -2,9 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'conversation_provider.dart'; -final pendingChatJumpProvider = StateProvider.autoDispose(( - ref, -) { +final pendingChatJumpProvider = StateProvider.autoDispose((ref) { final keepAlive = ref.keepAlive(); ref.listen(currentConversationIdProvider, (previous, next) { @@ -14,11 +12,3 @@ final pendingChatJumpProvider = StateProvider.autoDispose(( return null; }); - -class PendingChatJump { - const PendingChatJump.returnToMessage(this.messageId); - - final String? messageId; - - String? get returnMessageId => messageId; -} diff --git a/lib/widgets/message/item/quote_message.dart b/lib/widgets/message/item/quote_message.dart index 098db032af..2371c052be 100644 --- a/lib/widgets/message/item/quote_message.dart +++ b/lib/widgets/message/item/quote_message.dart @@ -507,10 +507,9 @@ class _QuoteMessageBase extends HookConsumerWidget { } catch (_) {} context.providerContainer - .read(pendingChatJumpProvider.notifier) - .state = messageId == null - ? null - : PendingChatJump.returnToMessage(messageId); + .read(pendingChatJumpProvider.notifier) + .state = + messageId; traceChatJump( 'quote tap source=${shortMessageId(messageId)} ' 'target=${shortMessageId(quoteMessageId)}', diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index caaf36b691..d399b8dc3d 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -5,7 +5,6 @@ import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider, Provider; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide Key; -import 'package:provider/provider.dart'; import 'package:super_context_menu/super_context_menu.dart'; import 'package:visibility_detector/visibility_detector.dart'; @@ -20,7 +19,6 @@ import '../../ui/provider/setting_provider.dart'; import '../../utils/datetime_format_utils.dart'; import '../../utils/double_tap_util.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../avatar_view/avatar_view.dart'; import '../interactive_decorated_box.dart'; import '../menu.dart'; @@ -106,7 +104,7 @@ class MessageRows { MessageRowModel( message: top[index], prev: top.getOrNull(index - 1), - next: top.getOrNull(index + 1) ?? center ?? bottom.lastOrNull, + next: top.getOrNull(index + 1) ?? center ?? bottom.firstOrNull, ), ], center: center == null @@ -149,17 +147,12 @@ T useMessageConverter({required T Function(MessageItem) converter}) => _useMessageContextConverter((state) => converter(state.message)); T _useMessageContextConverter(T Function(_MessageContext) converter) { - final notifier = useContext().read>(); - return useListenableConverter, T>( - notifier, - converter: (notifier) => converter(notifier.value), - ).data - as T; + final context = useContext(); + return converter(_MessageContextScope.watch(context)); } extension MessageContextExtension on BuildContext { - _MessageContext get _messageContext => - read>().value; + _MessageContext get _messageContext => _MessageContextScope.read(this); MessageItem get message => _messageContext.message; @@ -194,6 +187,8 @@ class MessageItemWidget extends HookConsumerWidget { this.blink = true, this.isPinnedPage = false, this.dateTimeKey, + this.isGroupOrBotGroupConversation, + this.enableShowAvatar, }); final MessageItem message; @@ -205,6 +200,8 @@ class MessageItemWidget extends HookConsumerWidget { final bool blink; final bool isPinnedPage; final Key? dateTimeKey; + final bool? isGroupOrBotGroupConversation; + final bool? enableShowAvatar; static const primaryFontSize = 16.0; static const secondaryFontSize = 14.0; @@ -221,14 +218,16 @@ class MessageItemWidget extends HookConsumerWidget { final isCurrentUser = message.relationship == UserRelationship.me; final isGroupOrBotGroupConversation = - message.conversionCategory == ConversationCategory.group || - message.userId != message.conversationOwnerId || - ref.watch(isBotGroupProvider(message.conversationId)); + this.isGroupOrBotGroupConversation ?? + (message.conversionCategory == ConversationCategory.group || + message.userId != message.conversationOwnerId || + ref.watch(isBotGroupProvider(message.conversationId))); - final enableShowAvatar = ref.watch( - settingProvider.select((value) => value.messageShowAvatar), - ); - final showAvatar = isGroupOrBotGroupConversation && enableShowAvatar; + final enableShowAvatar = + this.enableShowAvatar ?? + ref.watch(settingProvider.select((value) => value.messageShowAvatar)); + final showAvatar = + isGroupOrBotGroupConversation && enableShowAvatar == true; final showNip = !(row.sameUserNext && row.sameDayNext) && @@ -251,23 +250,16 @@ class MessageItemWidget extends HookConsumerWidget { debugLabel: 'message_item_${message.messageId}', ); - final blinkNotifier = context.read(); - final blinkState = useValueListenable(blinkNotifier); - - final blinkColor = showedMenu.value - ? context.theme.listSelected - : blinkState.messageId == message.messageId && blink - ? blinkState.color - : Colors.transparent; - Widget child = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (datetime != null) MessageDayTime(key: dateTimeKey, dateTime: datetime), - ColoredBox( - color: blinkColor, + _MessageBlinkBackground( + messageId: message.messageId, + enabled: blink, + menuColor: showedMenu.value ? context.theme.listSelected : null, child: Builder( builder: (context) { if (message.type == MessageCategory.systemConversation) { @@ -356,7 +348,66 @@ class MessageItemWidget extends HookConsumerWidget { } } -class MessageContext extends HookConsumerWidget { +class _MessageBlinkBackground extends StatefulWidget { + const _MessageBlinkBackground({ + required this.messageId, + required this.enabled, + required this.child, + this.menuColor, + }); + + final String messageId; + final bool enabled; + final Color? menuColor; + final Widget child; + + @override + State<_MessageBlinkBackground> createState() => + _MessageBlinkBackgroundState(); +} + +class _MessageBlinkBackgroundState extends State<_MessageBlinkBackground> { + BlinkNotifier? _notifier; + BlinkState _blinkState = const BlinkState(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final notifier = context.read(); + if (identical(_notifier, notifier)) return; + _notifier?.removeListener(_onBlinkChanged); + _notifier = notifier..addListener(_onBlinkChanged); + _blinkState = notifier.value; + } + + void _onBlinkChanged() { + final next = _notifier!.value; + final shouldRebuild = + widget.enabled && + (_blinkState.messageId == widget.messageId || + next.messageId == widget.messageId); + _blinkState = next; + if (shouldRebuild && mounted) setState(() {}); + } + + @override + void dispose() { + _notifier?.removeListener(_onBlinkChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = + widget.menuColor ?? + (widget.enabled && _blinkState.messageId == widget.messageId + ? _blinkState.color + : Colors.transparent); + return ColoredBox(color: color, child: widget.child); + } +} + +class MessageContext extends StatelessWidget { const MessageContext({ required this.isTranscriptPage, required this.isPinnedPage, @@ -384,30 +435,46 @@ class MessageContext extends HookConsumerWidget { final Widget child; @override - Widget build(BuildContext context, WidgetRef ref) { - _MessageContext newMessageContext() => _MessageContext( + Widget build(BuildContext context) => _MessageContextScope( + value: _MessageContext( isTranscriptPage: isTranscriptPage, isPinnedPage: isPinnedPage, showNip: showNip, isCurrentUser: isCurrentUser, message: message, - ); + ), + child: child, + ); +} - final messageContext = useMemoized( - () => ValueNotifier(newMessageContext()), - ); - useEffect(() => messageContext.dispose, [messageContext]); +class _MessageContextScope extends InheritedWidget { + const _MessageContextScope({ + required this.value, + required super.child, + }); - useEffect(() { - messageContext.value = newMessageContext(); - return null; - }, [isTranscriptPage, isPinnedPage, showNip, isCurrentUser, message]); + final _MessageContext value; - return ChangeNotifierProvider>.value( - value: messageContext, - child: child, - ); + static _MessageContext watch(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType<_MessageContextScope>(); + assert(scope != null, 'No MessageContext found in widget tree.'); + return scope!.value; } + + static _MessageContext read(BuildContext context) { + final scope = + context + .getElementForInheritedWidgetOfExactType<_MessageContextScope>() + ?.widget + as _MessageContextScope?; + assert(scope != null, 'No MessageContext found in widget tree.'); + return scope!.value; + } + + @override + bool updateShouldNotify(_MessageContextScope oldWidget) => + value != oldWidget.value; } class _MessageBubbleMargin extends HookConsumerWidget { diff --git a/test/ui/home/notifier/message_window_loader_test.dart b/test/ui/home/notifier/message_window_loader_test.dart index 399add28ca..699f3f836e 100644 --- a/test/ui/home/notifier/message_window_loader_test.dart +++ b/test/ui/home/notifier/message_window_loader_test.dart @@ -18,6 +18,8 @@ void main() { final loader = MessageWindowLoader( recentMessages: (_, _) => throw StateError('unexpected recent query'), messageOrderInfo: (_) async => MessageOrderInfo(rowId: 2, createdAt: 2), + beforeMessages: (_, _, _) => throw StateError('unexpected before'), + afterMessages: (_, _, _) => throw StateError('unexpected after'), beforeMessageIds: (_, _, _) { calls.add('beforeIds'); return beforeIdsCompleter.future; @@ -60,6 +62,71 @@ void main() { expect(state.bottom.map((e) => e.messageId), ['3']); }, ); + + test('loadBefore and loadAfter keep MessageState ordering', () async { + final loader = MessageWindowLoader( + recentMessages: (_, _) => throw StateError('unexpected recent query'), + messageOrderInfo: (messageId) async => MessageOrderInfo( + rowId: int.parse(messageId), + createdAt: int.parse(messageId), + ), + beforeMessages: (_, _, _) async => [ + testMessage(2, messageId: '2'), + testMessage(1, messageId: '1'), + ], + afterMessages: (_, _, _) async => [ + testMessage(4, messageId: '4'), + testMessage(5, messageId: '5'), + ], + beforeMessageIds: (_, _, _) => throw StateError('unexpected before ids'), + afterMessageIds: (_, _, _) => throw StateError('unexpected after ids'), + messagesByIds: (_) => throw StateError('unexpected hydrate'), + ); + + final state = MessageState( + center: testMessage(3, messageId: '3'), + ); + + final before = await loader.loadBefore(state, 'conversation', 3); + expect(before.top.map((e) => e.messageId), ['1', '2']); + expect(before.center?.messageId, '3'); + expect(before.isOldest, isTrue); + + final after = await loader.loadAfter(before, 'conversation', 3); + expect(after.bottom.map((e) => e.messageId), ['4', '5']); + expect(after.isLatest, isTrue); + }); + + test('directionFromSource compares message order', () async { + final loader = MessageWindowLoader( + recentMessages: (_, _) => throw StateError('unexpected recent query'), + messageOrderInfo: (messageId) async => switch (messageId) { + 'older' => MessageOrderInfo(rowId: 1, createdAt: 1), + 'newer' => MessageOrderInfo(rowId: 2, createdAt: 1), + _ => null, + }, + beforeMessages: (_, _, _) => throw StateError('unexpected before'), + afterMessages: (_, _, _) => throw StateError('unexpected after'), + beforeMessageIds: (_, _, _) => throw StateError('unexpected before'), + afterMessageIds: (_, _, _) => throw StateError('unexpected after'), + messagesByIds: (_) => throw StateError('unexpected hydrate'), + ); + + expect( + await loader.directionFromSource( + sourceMessageId: 'newer', + targetMessageId: 'older', + ), + MessageWindowDirection.older, + ); + expect( + await loader.directionFromSource( + sourceMessageId: 'older', + targetMessageId: 'newer', + ), + MessageWindowDirection.newer, + ); + }); } MessageItem testMessage(int index, {String? messageId}) => MessageItem( diff --git a/test/widgets/message/message_context_test.dart b/test/widgets/message/message_context_test.dart new file mode 100644 index 0000000000..e3289b8ca0 --- /dev/null +++ b/test/widgets/message/message_context_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/widgets/message/message.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +void main() { + test('MessageRows links top tail to first bottom row without center', () { + final rows = MessageRows.from( + top: [ + testMessage('top', userId: 'same'), + ], + center: null, + bottom: [ + testMessage('bottom-first', userId: 'same'), + testMessage('bottom-last', userId: 'other'), + ], + ); + + expect(rows.top.single.sameUserNext, isTrue); + expect(rows.bottom.first.sameUserPrev, isTrue); + }); + + testWidgets('MessageContext updates const children through inherited state', ( + tester, + ) async { + final message = ValueNotifier(testMessage('1')); + addTearDown(message.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ValueListenableBuilder( + valueListenable: message, + builder: (context, value, child) => MessageContext.fromMessageItem( + message: value, + child: child!, + ), + child: const _MessageIdText(), + ), + ), + ); + + expect(find.text('1'), findsOneWidget); + + message.value = testMessage('2'); + await tester.pump(); + + expect(find.text('2'), findsOneWidget); + }); +} + +class _MessageIdText extends HookWidget { + const _MessageIdText(); + + @override + Widget build(BuildContext context) => Text( + useMessageConverter(converter: (message) => message.messageId), + ); +} + +MessageItem testMessage(String id, {String userId = 'user'}) => MessageItem( + messageId: id, + conversationId: 'conversation', + type: 'PLAIN_TEXT', + createdAt: DateTime(2026), + status: MessageStatus.read, + userId: userId, + userIdentityNumber: '0', + isVerified: false, + sharedUserIsVerified: false, + pinned: false, +); From 07c283426a10a4a6cd20571c021cf3b0ee669de0 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:29:23 +0900 Subject: [PATCH 09/27] Improve media image handling and avatar refresh --- lib/account/send_message_helper.dart | 22 +- lib/app.dart | 124 ++--- lib/db/dao/user_dao.dart | 6 +- lib/ui/provider/user_cache_provider.dart | 35 +- lib/utils/attachment/attachment_util.dart | 11 +- lib/utils/cache_client.dart | 7 +- lib/utils/file.dart | 1 + lib/utils/image.dart | 113 ++++- .../message/item/image/image_message.dart | 1 + lib/widgets/message/message.dart | 21 +- lib/widgets/message/message_name.dart | 6 +- lib/widgets/mixin_image.dart | 467 ++++++------------ .../ui/provider/user_cache_provider_test.dart | 137 +++++ test/utils/image_test.dart | 91 ++++ 14 files changed, 625 insertions(+), 417 deletions(-) create mode 100644 test/ui/provider/user_cache_provider_test.dart diff --git a/lib/account/send_message_helper.dart b/lib/account/send_message_helper.dart index f426427519..3b92e735ca 100644 --- a/lib/account/send_message_helper.dart +++ b/lib/account/send_message_helper.dart @@ -32,7 +32,6 @@ import '../utils/logger.dart'; import '../utils/reg_exp_utils.dart'; import '../widgets/message/item/action_card/action_card_data.dart'; import '../widgets/message/send_message_dialog/attachment_extra.dart'; -import '../widgets/mixin_image.dart'; import 'show_pin_message_key_value.dart'; const jpegMimeType = 'image/jpeg'; @@ -1370,13 +1369,18 @@ class SendMessageHelper { } (Uint8List, ImageType, int, int)? result; + File? downloadedImage; try { - final data = await downloadImage(url); - if (data != null) { - result = await compressWithIsolate(data); + downloadedImage = await downloadImageFile(url); + if (downloadedImage != null) { + result = await compressFileWithIsolate(downloadedImage); } } catch (error, stacktrace) { e('failed to download image: $error $stacktrace'); + } finally { + if (downloadedImage?.existsSync() == true) { + await downloadedImage!.delete(); + } } if (result == null) { e('failed to get send image bytes. $url'); @@ -1457,13 +1461,13 @@ class SendMessageHelper { var mediaSize = await attachment.length(); if (attachment.lengthSync() == 0) { - Uint8List? sendImageBytes; + File? downloadedImage; try { - sendImageBytes = await downloadImage(message.mediaUrl!); + downloadedImage = await downloadImageFile(message.mediaUrl!); } catch (error, stacktrace) { e('reUploadGiphyGif: failed to download image: $error $stacktrace'); } - if (sendImageBytes == null) { + if (downloadedImage == null) { e( 'reUploadGiphyGif: failed to get send image bytes. ${message.mediaUrl}', ); @@ -1473,7 +1477,9 @@ class SendMessageHelper { ); return; } - await attachment.writeAsBytes(sendImageBytes); + await downloadedImage.copy(attachment.path); + await downloadedImage.delete(); + await normalizeGifFileIfNeeded(attachment, message.mediaMimeType); thumbImage = await attachment.encodeBlurHash(); mediaSize = await attachment.length(); diff --git a/lib/app.dart b/lib/app.dart index 9eedb17cfc..563a09f941 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -25,6 +25,7 @@ import 'ui/provider/mention_cache_provider.dart'; import 'ui/provider/multi_auth_provider.dart'; import 'ui/provider/setting_provider.dart'; import 'ui/provider/slide_category_provider.dart'; +import 'utils/app_lifecycle.dart'; import 'utils/extension/extension.dart'; import 'utils/logger.dart'; import 'utils/platform.dart'; @@ -133,66 +134,75 @@ class _App extends HookConsumerWidget { final Widget home; @override - Widget build(BuildContext context, WidgetRef ref) => WindowShortcuts( - child: GlobalMoveWindow( - child: MaterialApp( - title: 'Mixin', - navigatorObservers: [rootRouteObserver], - debugShowCheckedModeBanner: false, - localizationsDelegates: const [ - Localization.delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: [...Localization.delegate.supportedLocales], - theme: ThemeData( - colorScheme: ColorScheme.light( - primary: lightBrightnessThemeData.text, - ), - textSelectionTheme: TextSelectionThemeData( - cursorColor: lightBrightnessThemeData.accent, - ), - useMaterial3: true, - ).withFallbackFonts(), - darkTheme: ThemeData( - colorScheme: ColorScheme.dark( - primary: darkBrightnessThemeData.text, - ), - textSelectionTheme: TextSelectionThemeData( - cursorColor: darkBrightnessThemeData.accent, - ), - useMaterial3: true, - ).withFallbackFonts(), - themeMode: ref.watch(settingProvider).themeMode, - builder: (context, child) { - try { - context.accountServer.language = Localizations.localeOf( - context, - ).languageCode; - } catch (_) {} - final mediaQueryData = MediaQuery.of(context); - return BrightnessObserver( - lightThemeData: lightBrightnessThemeData, - darkThemeData: darkBrightnessThemeData, - forceBrightness: ref.watch(settingProvider).brightness, - child: MediaQuery( - data: mediaQueryData.copyWith( - // Different linux distro change the value, e.g. 1.2 - textScaler: Platform.isLinux - ? TextScaler.noScaling - : mediaQueryData.textScaler, + Widget build(BuildContext context, WidgetRef ref) { + final appActive = useValueListenable(appActiveListener); + + return TickerMode( + enabled: appActive, + child: WindowShortcuts( + child: GlobalMoveWindow( + child: MaterialApp( + title: 'Mixin', + navigatorObservers: [rootRouteObserver], + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + Localization.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: [...Localization.delegate.supportedLocales], + theme: ThemeData( + colorScheme: ColorScheme.light( + primary: lightBrightnessThemeData.text, + ), + textSelectionTheme: TextSelectionThemeData( + cursorColor: lightBrightnessThemeData.accent, + ), + useMaterial3: true, + ).withFallbackFonts(), + darkTheme: ThemeData( + colorScheme: ColorScheme.dark( + primary: darkBrightnessThemeData.text, ), - child: SystemTrayWidget( - child: TextInputActionHandler(child: AuthGuard(child: child!)), + textSelectionTheme: TextSelectionThemeData( + cursorColor: darkBrightnessThemeData.accent, ), - ), - ); - }, - home: MixinAppActions(child: MacosMenuBar(child: home)), + useMaterial3: true, + ).withFallbackFonts(), + themeMode: ref.watch(settingProvider).themeMode, + builder: (context, child) { + try { + context.accountServer.language = Localizations.localeOf( + context, + ).languageCode; + } catch (_) {} + final mediaQueryData = MediaQuery.of(context); + return BrightnessObserver( + lightThemeData: lightBrightnessThemeData, + darkThemeData: darkBrightnessThemeData, + forceBrightness: ref.watch(settingProvider).brightness, + child: MediaQuery( + data: mediaQueryData.copyWith( + // Different linux distro change the value, e.g. 1.2 + textScaler: Platform.isLinux + ? TextScaler.noScaling + : mediaQueryData.textScaler, + ), + child: SystemTrayWidget( + child: TextInputActionHandler( + child: AuthGuard(child: child!), + ), + ), + ), + ); + }, + home: MixinAppActions(child: MacosMenuBar(child: home)), + ), + ), ), - ), - ); + ); + } } class _Home extends HookConsumerWidget { diff --git a/lib/db/dao/user_dao.dart b/lib/db/dao/user_dao.dart index ee1eaac359..f66bac9477 100644 --- a/lib/db/dao/user_dao.dart +++ b/lib/db/dao/user_dao.dart @@ -40,7 +40,7 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { into( db.users, ).simpleInsert(user, updateIfConflict: updateIfConflict).then((value) { - if (value > 0) { + if (updateIfConflict || value > 0) { DataBaseEventBus.instance.updateUsers([user.userId]); } return value; @@ -48,9 +48,7 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { Future insertSdkUser(sdk.User user) => into(db.users).insertOnConflictUpdate(user.asDbUser).then((value) { - if (value > 0) { - DataBaseEventBus.instance.updateUsers([user.userId]); - } + DataBaseEventBus.instance.updateUsers([user.userId]); return value; }); diff --git a/lib/ui/provider/user_cache_provider.dart b/lib/ui/provider/user_cache_provider.dart index 430f060918..cbcc37be72 100644 --- a/lib/ui/provider/user_cache_provider.dart +++ b/lib/ui/provider/user_cache_provider.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../db/dao/user_dao.dart'; +import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; import '../../utils/rivepod.dart'; import 'database_provider.dart'; @@ -9,21 +12,29 @@ class _UserCacheState extends DistinctStateNotifier { _UserCacheState(String userId, UserDao? userDao) : super(null) { if (userDao == null) return; - userDao.userById(userId).getSingle().then((value) => state = value); + Future load() async { + state = await userDao.userById(userId).getSingleOrNull(); + } + + unawaited(load()); + _subscription = DataBaseEventBus.instance + .watchUpdateUserStream([userId]) + .listen((_) => unawaited(load())); + } + + StreamSubscription>? _subscription; + + @override + void dispose() { + unawaited(_subscription?.cancel()); + super.dispose(); } } -// !!! Only query once in 10 minutes !!! final userCacheProvider = StateNotifierProvider.autoDispose - .family<_UserCacheState, User?, String>((ref, userId) { - // Minimize frequent calls to isBotGroup by keeping it alive for 10 minutes - final keepAlive = ref.keepAlive(); - ref.onDispose( - () => Future.delayed(const Duration(minutes: 10), keepAlive.close), - ); - - return _UserCacheState( + .family<_UserCacheState, User?, String>( + (ref, userId) => _UserCacheState( userId, ref.watch(databaseProvider.select((value) => value.value?.userDao)), - ); - }); + ), + ); diff --git a/lib/utils/attachment/attachment_util.dart b/lib/utils/attachment/attachment_util.dart index 52cc058a65..08d801403f 100644 --- a/lib/utils/attachment/attachment_util.dart +++ b/lib/utils/attachment/attachment_util.dart @@ -22,6 +22,7 @@ import '../../widgets/toast.dart'; import '../crypto_util.dart'; import '../extension/extension.dart'; import '../file.dart'; +import '../image.dart'; import '../load_balancer_utils.dart'; import '../logger.dart'; import '../property/setting_property.dart'; @@ -263,15 +264,16 @@ class AttachmentUtil extends AttachmentUtilBase with ChangeNotifier { attachmentId = content; } + final mediaMimeType = + attachmentMessage?.mimeType ?? + message?.mediaMimeType ?? + transcriptMessage?.mediaMimeType; final file = getAttachmentFile( category, conversationId, messageId, message?.name ?? transcriptMessage?.mediaName, - mimeType: - attachmentMessage?.mimeType ?? - message?.mediaMimeType ?? - transcriptMessage?.mediaMimeType, + mimeType: mediaMimeType, isTranscript: message == null, ); final path = file.absolute.path; @@ -329,6 +331,7 @@ class AttachmentUtil extends AttachmentUtilBase with ChangeNotifier { (count, total) => notifyListeners(), ); + await normalizeGifFileIfNeeded(file, mediaMimeType); final fileSize = await file.length(); if (attachmentMessage != null) { diff --git a/lib/utils/cache_client.dart b/lib/utils/cache_client.dart index d7eb8ae73c..5bed002d51 100644 --- a/lib/utils/cache_client.dart +++ b/lib/utils/cache_client.dart @@ -1,12 +1,13 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart'; import 'package:mixin_logger/mixin_logger.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; -import '../widgets/mixin_image.dart'; import 'proxy.dart'; class CacheClient extends BaseClient { @@ -20,7 +21,7 @@ class CacheClient extends BaseClient { @override Future get(Uri url, {Map? headers}) async { - final cacheKey = keyToMd5(url.toString()); + final cacheKey = _keyToMd5(url.toString()); final cache = await _loadCache(cacheKey); if (cache != null) { @@ -72,3 +73,5 @@ class CacheClient extends BaseClient { await file.writeAsBytes(bytes); } } + +String _keyToMd5(String key) => md5.convert(utf8.encode(key)).toString(); diff --git a/lib/utils/file.dart b/lib/utils/file.dart index 1be809dc95..869eccc736 100644 --- a/lib/utils/file.dart +++ b/lib/utils/file.dart @@ -113,6 +113,7 @@ Future initMixinDocumentsDirectory() async { enum TempFileType { pasteboardImage('mixin_paste_board_image', '.png'), editImage('image_edit', '.png'), + networkImage('network_image', '.img'), voiceRecord('voice_record', '.ogg'), logZip('log', '.zip'), // video thumbnail diff --git a/lib/utils/image.dart b/lib/utils/image.dart index bcbf65c6fa..cd781e11a5 100644 --- a/lib/utils/image.dart +++ b/lib/utils/image.dart @@ -1,11 +1,19 @@ +import 'dart:io'; import 'dart:typed_data'; +import 'package:http/http.dart' as http; import 'package:image/image.dart'; +import 'file.dart'; import 'load_balancer_utils.dart'; +import 'proxy.dart'; const _kMaxDimension = 1920; const _kQuality = 85; +const _kMinGifFrameDelayCentiseconds = 2; +const _kDefaultGifFrameDelayCentiseconds = 10; +// ponytail: remote images above this are too risky to decode in memory. +const _kMaxDownloadedImageBytes = 32 * 1024 * 1024; enum ImageType { gif, jpeg, png } @@ -29,9 +37,13 @@ extension ImageTypeExtension on ImageType { var image = decoder?.decode(data); if (image == null) return null; - // Re-encoding are larger then original image. if (decoder is GifDecoder) { - return (data, ImageType.gif, image.width, image.height); + return ( + normalizeGifFrameDurations(data), + ImageType.gif, + image.width, + image.height, + ); } if (image.width > _kMaxDimension || image.height > _kMaxDimension) { @@ -77,3 +89,100 @@ extension ImageTypeExtension on ImageType { Future<(Uint8List, ImageType, int, int)?> compressWithIsolate(Uint8List data) => runLoadBalancer(compressImage, data); + +Future<(Uint8List, ImageType, int, int)?> compressFileWithIsolate(File file) => + runLoadBalancer((path) { + final data = File(path).readAsBytesSync(); + return compressImage(data); + }, file.path); + +Future downloadImageFile( + String url, { + ProxyConfig? proxyConfig, + int maxBytes = _kMaxDownloadedImageBytes, +}) async { + final resolved = Uri.base.resolve(url); + final client = await createRHttpClient(proxyConfig: proxyConfig); + final response = await client.send(http.Request('GET', resolved)); + if (response.statusCode != HttpStatus.ok) return null; + + final contentLength = response.contentLength; + if (contentLength != null && contentLength > maxBytes) { + throw StateError('NetworkImage is too large: $resolved'); + } + + final file = File(await generateTempFilePath(TempFileType.networkImage)); + final sink = file.openWrite(); + var received = 0; + try { + await for (final chunk in response.stream) { + received += chunk.length; + if (received > maxBytes) { + throw StateError('NetworkImage is too large: $resolved'); + } + sink.add(chunk); + } + await sink.close(); + } catch (_) { + try { + await sink.close(); + } catch (_) {} + if (file.existsSync()) await file.delete(); + rethrow; + } + + if (received == 0) { + await file.delete(); + throw StateError('NetworkImage is an empty file: $resolved'); + } + + return file; +} + +Uint8List normalizeGifBytesIfNeeded(Uint8List data) { + if (!_isGifBytes(data)) return data; + return normalizeGifFrameDurations(data); +} + +Uint8List normalizeGifFrameDurations(Uint8List data) { + if (!_isGifBytes(data)) return data; + + Uint8List? normalized; + for (var index = 0; index <= data.length - 8; index++) { + if (data[index] != 0x21 || + data[index + 1] != 0xF9 || + data[index + 2] != 0x04 || + data[index + 7] != 0x00) { + continue; + } + + final delay = data[index + 4] | (data[index + 5] << 8); + if (delay >= _kMinGifFrameDelayCentiseconds) continue; + + normalized ??= Uint8List.fromList(data); + normalized[index + 4] = _kDefaultGifFrameDelayCentiseconds; + normalized[index + 5] = 0; + } + + return normalized ?? data; +} + +Future normalizeGifFileIfNeeded(File file, String? mimeType) async { + if (mimeType?.toLowerCase() != ImageType.gif.mimeType) return; + if (!file.existsSync()) return; + + final data = await file.readAsBytes(); + final normalized = normalizeGifFrameDurations(data); + if (!identical(normalized, data)) { + await file.writeAsBytes(normalized, flush: true); + } +} + +bool _isGifBytes(Uint8List data) => + data.length >= 6 && + data[0] == 0x47 && + data[1] == 0x49 && + data[2] == 0x46 && + data[3] == 0x38 && + (data[4] == 0x37 || data[4] == 0x39) && + data[5] == 0x61; diff --git a/lib/widgets/message/item/image/image_message.dart b/lib/widgets/message/item/image/image_message.dart index 0bb33f6b05..0c3a12be74 100644 --- a/lib/widgets/message/item/image/image_message.dart +++ b/lib/widgets/message/item/image/image_message.dart @@ -189,6 +189,7 @@ class MessageImage extends HookConsumerWidget { isTranscriptPage, ), ), + placeholder: () => thumbWidget, errorBuilder: (_, _, _) => thumbWidget, ), Center( diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index d399b8dc3d..825c75eec8 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -16,6 +16,7 @@ import '../../ui/provider/is_bot_group_provider.dart'; import '../../ui/provider/message_selection_provider.dart'; import '../../ui/provider/quote_message_provider.dart'; import '../../ui/provider/setting_provider.dart'; +import '../../ui/provider/user_cache_provider.dart'; import '../../utils/datetime_format_utils.dart'; import '../../utils/double_tap_util.dart'; import '../../utils/extension/extension.dart'; @@ -508,18 +509,21 @@ class _MessageBubbleMargin extends HookConsumerWidget { converter: (m) => m.userIdentityNumber, ); final membership = useMessageConverter(converter: (m) => m.membership); + final user = userId == null ? null : ref.watch(userCacheProvider(userId!)); + final displayName = user?.fullName ?? userName; + final displayAvatarUrl = user?.avatarUrl ?? userAvatarUrl; final messageColumn = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (userName != null && userId != null) + if (displayName != null && userId != null) Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: MessageName( - userName: userName!, + userName: displayName, userId: userId!, userIdentityNumber: userIdentityNumber, membership: membership, @@ -541,7 +545,7 @@ class _MessageBubbleMargin extends HookConsumerWidget { ], ); - final needShowAvatar = !isCurrentUser && userName != null; + final needShowAvatar = !isCurrentUser && displayName != null; if (!showAvatar || !needShowAvatar) { return Padding( padding: EdgeInsets.only( @@ -559,12 +563,17 @@ class _MessageBubbleMargin extends HookConsumerWidget { children: [ const SizedBox(width: 8), InteractiveDecoratedBox( - onTap: () => showUserDialog(context, userId), + onTap: () async { + final uid = userId; + if (uid == null) return; + await showUserDialog(context, uid); + if (context.mounted) ref.invalidate(userCacheProvider(uid)); + }, cursor: SystemMouseCursors.click, child: AvatarWidget( userId: userId, - name: userName, - avatarUrl: userAvatarUrl, + name: displayName, + avatarUrl: displayAvatarUrl, size: 32, ), ), diff --git a/lib/widgets/message/message_name.dart b/lib/widgets/message/message_name.dart index aac3a6fad8..727df5c0a3 100644 --- a/lib/widgets/message/message_name.dart +++ b/lib/widgets/message/message_name.dart @@ -3,6 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../../ui/provider/setting_provider.dart'; +import '../../ui/provider/user_cache_provider.dart'; import '../../utils/color_utils.dart'; import '../../utils/extension/extension.dart'; import '../conversation/badges_widget.dart'; @@ -75,7 +76,10 @@ class MessageName extends ConsumerWidget { return Align( alignment: Alignment.centerLeft, child: InteractiveDecoratedBox( - onTap: () => showUserDialog(context, userId), + onTap: () async { + await showUserDialog(context, userId); + if (context.mounted) ref.invalidate(userCacheProvider(userId)); + }, cursor: SystemMouseCursors.click, child: Padding( padding: const EdgeInsets.only(left: 10, bottom: 2), diff --git a/lib/widgets/mixin_image.dart b/lib/widgets/mixin_image.dart index 130aa49de3..0b3ad7bee2 100644 --- a/lib/widgets/mixin_image.dart +++ b/lib/widgets/mixin_image.dart @@ -1,174 +1,89 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'dart:ui' as ui; -import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:http_client_helper/http_client_helper.dart'; -import 'package:path/path.dart'; -import 'package:path_provider/path_provider.dart'; import '../utils/extension/extension.dart'; -import '../utils/hook.dart'; -import '../utils/logger.dart'; +import '../utils/image.dart'; import '../utils/proxy.dart'; typedef PlaceholderWidgetBuilder = Widget Function(); -// min frameDuration -// see also: -// https://nullsleep.tumblr.com/post/16524517190/animated-gif-minimum-frame-delay-browser -// https://qiita.com/razokulover/items/34962844e314bb4bfd04 -const _defaultFrameDuration = Duration(milliseconds: 100); -const _minFrameDuration = Duration(milliseconds: 20); +final _normalizedGifFiles = {}; -const String cacheImageFolderName = 'cacheimage'; +@immutable +class ProxyNetworkImage extends ImageProvider { + const ProxyNetworkImage(this.url, {this.scale = 1.0, this.proxyConfig}); -Future _loadCacheBuffer( - ImageProvider provider, - ProxyConfig? proxyConfig, -) async { - final key = _getKeyImage(provider); - final md5Key = key != null ? keyToMd5(key) : null; + final String url; + final double scale; + final ProxyConfig? proxyConfig; - final _cacheImagesDirectory = Directory( - join((await getTemporaryDirectory()).path, cacheImageFolderName), - ); - ui.ImmutableBuffer? data; - - if (_cacheImagesDirectory.existsSync() && md5Key != null) { - try { - data = await ui.ImmutableBuffer.fromFilePath( - join(_cacheImagesDirectory.path, md5Key), - ); - } catch (error, stackTrace) { - e('Failed to load cache image', error, stackTrace); - } - } else { - await _cacheImagesDirectory.create(); - } - - if (data == null) { - final (buffer, bytes) = await _loadBuffer(provider, proxyConfig); - data = buffer; - if (bytes != null && md5Key != null) { - await File(join(_cacheImagesDirectory.path, md5Key)).writeAsBytes(bytes); - } - } - - return data; -} - -String? _getKeyImage(Object provider) => - provider is NetworkImage ? provider.url : null; - -Future<(ui.ImmutableBuffer?, Uint8List?)> _loadBuffer( - ImageProvider provider, - ProxyConfig? proxyConfig, -) async { - if (provider is NetworkImage) { - final resolved = Uri.base.resolve(provider.url); - final Uint8List bytes; - - final response = await _tryGetResponse(resolved, proxyConfig); - if (response.statusCode != HttpStatus.ok) { - return (null, null); - } - - bytes = response.bodyBytes; - - if (bytes.lengthInBytes == 0) { - throw StateError('NetworkImage is an empty file: $resolved'); - } - return (await ui.ImmutableBuffer.fromUint8List(bytes), bytes); - } else if (provider is AssetImage) { - return (await ui.ImmutableBuffer.fromAsset(provider.assetName), null); - } else if (provider is FileImage) { - return (await ui.ImmutableBuffer.fromFilePath(provider.file.path), null); - } else if (provider is MemoryImage) { - return ( - await ui.ImmutableBuffer.fromUint8List(provider.bytes), - provider.bytes, - ); - } - return (null, null); -} - -Future _tryGetResponse(Uri resolved, ProxyConfig? proxy) async { - final client = await createRHttpClient(proxyConfig: proxy); - final response = await client.get(resolved); - return response; -} - -Future _loadCacheBytes( - Object provider, - ProxyConfig? proxyConfig, -) async { - final key = _getKeyImage(provider); - final md5Key = key != null ? keyToMd5(key) : null; + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); - final _cacheImagesDirectory = Directory( - join((await getTemporaryDirectory()).path, cacheImageFolderName), + @override + ImageStreamCompleter loadImage( + ProxyNetworkImage key, + ImageDecoderCallback decode, + ) => _load(key, (buffer) => decode(buffer)); + + ImageStreamCompleter _load( + ProxyNetworkImage key, + Future Function(ui.ImmutableBuffer buffer) decode, + ) => MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: key.scale, + debugLabel: key.url, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Image key', key), + ], ); - Uint8List? data; - if (_cacheImagesDirectory.existsSync() && md5Key != null) { + Future _loadAsync( + ProxyNetworkImage key, + Future Function(ui.ImmutableBuffer buffer) decode, + ) async { try { - data = await File(join(_cacheImagesDirectory.path, md5Key)).readAsBytes(); - } catch (_) { - // Throws an Exception if the asset does not exist. - } - } else { - await _cacheImagesDirectory.create(); - } + final resolved = Uri.base.resolve(key.url); + final client = await createRHttpClient(proxyConfig: key.proxyConfig); + final response = await client.get(resolved); + if (response.statusCode != HttpStatus.ok) { + throw NetworkImageLoadException( + statusCode: response.statusCode, + uri: resolved, + ); + } - if (data == null) { - data = await _loadBytes(provider, proxyConfig); - if (data != null && md5Key != null) { - await File(join(_cacheImagesDirectory.path, md5Key)).writeAsBytes(data); + final bytes = normalizeGifBytesIfNeeded(response.bodyBytes); + if (bytes.lengthInBytes == 0) { + throw StateError('NetworkImage is an empty file: $resolved'); + } + return decode(await ui.ImmutableBuffer.fromUint8List(bytes)); + } catch (_) { + scheduleMicrotask(() { + PaintingBinding.instance.imageCache.evict(key); + }); + rethrow; } } - return data; -} - -Future _loadBytes(Object provider, ProxyConfig? proxyConfig) async { - if (provider is NetworkImage) { - final resolved = Uri.base.resolve(provider.url); - final response = await _tryGetResponse(resolved, proxyConfig); - if (response.statusCode != HttpStatus.ok) { - return null; - } + @override + bool operator ==(Object other) => + other is ProxyNetworkImage && + other.url == url && + other.scale == scale && + other.proxyConfig == proxyConfig; - final bytes = response.bodyBytes; - if (bytes.lengthInBytes == 0) { - throw StateError('NetworkImage is an empty file: $resolved'); - } - return bytes; - } else if (provider is AssetImage) { - final data = await provider.bundle?.load(provider.assetName); - return data?.buffer.asUint8List(); - } else if (provider is FileImage) { - return provider.file.readAsBytes(); - } else if (provider is MemoryImage) { - return provider.bytes; - } - return null; + @override + int get hashCode => Object.hash(url, scale, proxyConfig); } -/// download image from network to cache. return the cache image file. -/// [url] is the image url. -Future downloadImage(String url) async => - _loadCacheBytes(NetworkImage(url), null); - -/// get md5 from key -String keyToMd5(String key) => md5.convert(utf8.encode(key)).toString(); - -class MixinImage extends HookWidget { +class MixinImage extends StatelessWidget { const MixinImage({ required this.image, super.key, @@ -176,10 +91,8 @@ class MixinImage extends HookWidget { this.errorBuilder, this.width, this.height, - this.cancelToken, this.fit = BoxFit.cover, this.isAntiAlias = false, - this.controller, }); MixinImage.network( @@ -189,10 +102,8 @@ class MixinImage extends HookWidget { this.errorBuilder, this.width, this.height, - this.cancelToken, this.fit = BoxFit.cover, this.isAntiAlias = false, - this.controller, }) : image = NetworkImage(url); MixinImage.file( @@ -203,9 +114,7 @@ class MixinImage extends HookWidget { this.width, this.height, this.fit = BoxFit.cover, - this.cancelToken, this.isAntiAlias = false, - this.controller, }) : image = FileImage(file); MixinImage.asset( @@ -215,10 +124,8 @@ class MixinImage extends HookWidget { this.errorBuilder, this.width, this.height, - this.cancelToken, this.fit = BoxFit.cover, this.isAntiAlias = false, - this.controller, }) : image = AssetImage(assetName); MixinImage.memory( @@ -228,10 +135,8 @@ class MixinImage extends HookWidget { this.errorBuilder, this.width, this.height, - this.cancelToken, this.fit = BoxFit.cover, this.isAntiAlias = false, - this.controller, }) : image = MemoryImage(bytes); final ImageProvider image; @@ -239,202 +144,122 @@ class MixinImage extends HookWidget { final ImageErrorWidgetBuilder? errorBuilder; final double? width; final double? height; - final CancellationToken? cancelToken; final BoxFit? fit; final bool isAntiAlias; - final ValueNotifier? controller; @override Widget build(BuildContext context) { - final proxyUrl = context.database.settingProperties.activatedProxy; - - final codecAsync = useMemoizedFuture( - () async { - final buffer = await _loadCacheBuffer(image, proxyUrl); - if (buffer == null) { - throw Exception('Failed to load image buffer'); - } - return PaintingBinding.instance.instantiateImageCodecWithSize(buffer); - }, - null, - keys: [image, proxyUrl], - ); - - if (codecAsync.error != null) { - return errorBuilder?.call( - context, - codecAsync.error!, - codecAsync.stackTrace, - ) ?? - SizedBox(width: width, height: height); - } + final resolvedImage = _resolveImage(context); - if (codecAsync.data == null) { - return placeholder?.call() ?? SizedBox(width: width, height: height); - } - - if (controller == null) { - return HookBuilder( - builder: (context) { - final controller = useImagePlaying(context); - - return _AnimatedImageWidget( - codec: codecAsync.data!, - controller: controller, - placeholder: - placeholder ?? () => SizedBox(width: width, height: height), - errorBuilder: errorBuilder, - width: width, - height: height, - fit: fit, - isAntiAlias: isAntiAlias, - ); - }, - ); - } + Widget fallback() => + placeholder?.call() ?? SizedBox(width: width, height: height); - return _AnimatedImageWidget( - codec: codecAsync.data!, - controller: controller!, - placeholder: placeholder ?? () => SizedBox(width: width, height: height), - errorBuilder: errorBuilder, + Widget imageView() => Image( + image: resolvedImage, width: width, height: height, fit: fit, isAntiAlias: isAntiAlias, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return fallback(); + }, + errorBuilder: (context, error, stackTrace) => + errorBuilder?.call(context, error, stackTrace) ?? fallback(), + ); + + return _LazyNormalizedGif( + image: resolvedImage, + placeholder: fallback, + childBuilder: imageView, ); } + + ImageProvider _resolveImage(BuildContext context) { + final image = this.image; + final proxyConfig = context.database.settingProperties.activatedProxy; + if (image is NetworkImage) { + return ProxyNetworkImage( + image.url, + scale: image.scale, + proxyConfig: proxyConfig, + ); + } + return image; + } } -class _AnimatedImageWidget extends HookWidget { - const _AnimatedImageWidget({ - required this.codec, - required this.controller, +class _LazyNormalizedGif extends StatefulWidget { + const _LazyNormalizedGif({ + required this.image, required this.placeholder, - required this.errorBuilder, - this.width, - this.height, - this.fit, - this.isAntiAlias = false, + required this.childBuilder, }); - final ui.Codec codec; - final ValueNotifier controller; - final PlaceholderWidgetBuilder? placeholder; - final ImageErrorWidgetBuilder? errorBuilder; - final double? width; - final double? height; - final BoxFit? fit; - final bool isAntiAlias; + final ImageProvider image; + final Widget Function() placeholder; + final Widget Function() childBuilder; @override - Widget build(BuildContext context) { - final frameInfo = useState(null); - final timer = useRef(null); - final error = useState<(Object, StackTrace?)?>(null); - final isImageLoading = useState(false); - - useEffect(() { - timer.value?.cancel(); - timer.value = null; - frameInfo.value?.image.dispose(); - frameInfo.value = null; - error.value = null; - isImageLoading.value = false; - }, [codec]); - - useEffect(() { - Future getNextFrame() async { - if (isImageLoading.value || !context.mounted) return; - - try { - error.value = null; - isImageLoading.value = true; - - final frame = await codec.getNextFrame(); - if (!context.mounted) return; - - final oldFrame = frameInfo.value; - oldFrame?.image.dispose(); - - frameInfo.value = frame; - - final shouldContinueAnimation = - codec.frameCount > 1 && controller.value; - - if (shouldContinueAnimation) { - var duration = frame.duration; - if (duration < _minFrameDuration) { - duration = _defaultFrameDuration; - } - - timer.value?.cancel(); - timer.value = Timer(duration, () { - SchedulerBinding.instance.scheduleFrameCallback((_) { - getNextFrame(); - }); - }); - } - } catch (e, s) { - error.value = (e, s); - } finally { - if (context.mounted) { - isImageLoading.value = false; - } - } - } + State<_LazyNormalizedGif> createState() => _LazyNormalizedGifState(); +} - getNextFrame(); +class _LazyNormalizedGifState extends State<_LazyNormalizedGif> { + Future? _future; - void onControllerChanged() { - if (controller.value) { - if (codec.frameCount > 1) { - getNextFrame(); - } - } else { - timer.value?.cancel(); - timer.value = null; - } - } + @override + void initState() { + super.initState(); + _start(); + } - controller.addListener(onControllerChanged); + @override + void didUpdateWidget(covariant _LazyNormalizedGif oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.image != widget.image) { + _start(); + } + } - return () { - timer.value?.cancel(); - timer.value = null; - frameInfo.value?.image.dispose(); - if (context.mounted) { - frameInfo.value = null; - } - controller.removeListener(onControllerChanged); - }; - }, [codec, controller]); - - switch (error.value) { - case (final Object e, final StackTrace? stack): - final widget = errorBuilder?.call(context, e, stack); - if (widget != null) { - return widget; - } - default: - break; + void _start() { + final image = widget.image; + if (image is! FileImage || + !image.file.path.toLowerCase().endsWith('.gif')) { + _future = null; + return; } - if (frameInfo.value == null) { - return placeholder?.call() ?? SizedBox(width: width, height: height); + final path = image.file.absolute.path; + if (_normalizedGifFiles.contains(path)) { + _future = null; + return; } - final currentFrame = frameInfo.value; - if (currentFrame == null) { - return placeholder?.call() ?? SizedBox(width: width, height: height); + _future = _normalize(image.file); + } + + Future _normalize(File file) async { + try { + await normalizeGifFileIfNeeded(file, ImageType.gif.mimeType); + await FileImage(file).evict(); + } finally { + _normalizedGifFiles.add(file.absolute.path); } + } - return RawImage( - image: currentFrame.image, - width: width, - height: height, - fit: fit, - isAntiAlias: isAntiAlias, + @override + Widget build(BuildContext context) { + final future = _future; + if (future == null) return widget.childBuilder(); + + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return widget.childBuilder(); + } + return widget.placeholder(); + }, ); } } diff --git a/test/ui/provider/user_cache_provider_test.dart b/test/ui/provider/user_cache_provider_test.dart new file mode 100644 index 0000000000..ef9c3a02be --- /dev/null +++ b/test/ui/provider/user_cache_provider_test.dart @@ -0,0 +1,137 @@ +@TestOn('linux || mac-os') +library; + +import 'dart:async'; + +import 'package:drift/native.dart'; +import 'package:flutter_app/db/database.dart'; +import 'package:flutter_app/db/fts_database.dart'; +import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/ui/provider/database_provider.dart'; +import 'package:flutter_app/ui/provider/user_cache_provider.dart'; +import 'package:flutter_app/utils/event_bus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _TestDatabaseOpener extends DatabaseOpener { + _TestDatabaseOpener(Database database) { + state = AsyncValue.data(database); + } +} + +void main() { + setUpAll(EventBus.initialize); + + test('updates cached user when user table changes', () async { + final database = Database( + MixinDatabase(NativeDatabase.memory()), + FtsDatabase(NativeDatabase.memory()), + ); + addTearDown(database.dispose); + + await database.userDao.insert( + const User(userId: 'sender', identityNumber: '7001', fullName: 'Sender'), + ); + + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWith((ref) => _TestDatabaseOpener(database)), + ], + ); + addTearDown(container.dispose); + + await pumpEventQueue(); + expect(container.read(userCacheProvider('sender'))?.avatarUrl, isNull); + + final updated = Completer(); + final subscription = container.listen( + userCacheProvider('sender'), + (_, user) { + final avatarUrl = user?.avatarUrl; + if (avatarUrl != null && !updated.isCompleted) { + updated.complete(avatarUrl); + } + }, + fireImmediately: true, + ); + addTearDown(subscription.close); + + await database.userDao.insert( + const User( + userId: 'sender', + identityNumber: '7001', + fullName: 'Sender', + avatarUrl: 'https://example.com/avatar.png', + ), + ); + + await expectLater( + updated.future.timeout(const Duration(seconds: 1)), + completion('https://example.com/avatar.png'), + ); + }); + + test('reloads fresh user after listeners detach', () async { + final database = Database( + MixinDatabase(NativeDatabase.memory()), + FtsDatabase(NativeDatabase.memory()), + ); + addTearDown(database.dispose); + + await database.userDao.insert( + const User(userId: 'sender', identityNumber: '7001', fullName: 'Sender'), + ); + + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWith((ref) => _TestDatabaseOpener(database)), + ], + ); + addTearDown(container.dispose); + final databaseSubscription = container.listen( + databaseProvider, + (_, _) {}, + fireImmediately: true, + ); + addTearDown(databaseSubscription.close); + + final subscription = container.listen( + userCacheProvider('sender'), + (_, _) {}, + fireImmediately: true, + ); + await pumpEventQueue(); + expect(container.read(userCacheProvider('sender'))?.avatarUrl, isNull); + + subscription.close(); + await pumpEventQueue(); + + await database.mixinDatabase + .into(database.mixinDatabase.users) + .insertOnConflictUpdate( + const User( + userId: 'sender', + identityNumber: '7001', + fullName: 'Sender', + avatarUrl: 'https://example.com/avatar.png', + ), + ); + + final fresh = Completer(); + container.listen( + userCacheProvider('sender'), + (_, user) { + final avatarUrl = user?.avatarUrl; + if (avatarUrl != null && !fresh.isCompleted) { + fresh.complete(avatarUrl); + } + }, + fireImmediately: true, + ); + + await expectLater( + fresh.future.timeout(const Duration(seconds: 1)), + completion('https://example.com/avatar.png'), + ); + }); +} diff --git a/test/utils/image_test.dart b/test/utils/image_test.dart index afbcb11243..a3ebc13a43 100644 --- a/test/utils/image_test.dart +++ b/test/utils/image_test.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:typed_data'; import 'package:flutter_app/utils/image.dart'; @@ -5,6 +6,85 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:image/image.dart' as img; void main() { + test('downloadImageFile rejects oversized streaming responses', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + final subscription = server.listen((request) async { + request.response.add([1, 2]); + request.response.add([3, 4]); + await request.response.close(); + }); + addTearDown(subscription.cancel); + + await expectLater( + downloadImageFile( + 'http://${server.address.host}:${server.port}', + maxBytes: 3, + ), + throwsStateError, + ); + }); + + test('compressImage normalizes short animated GIF frame durations', () { + final encoded = _gifWithFrameDurations(10, 50); + final result = compressImage(encoded); + + expect(result, isNotNull); + + final (bytes, type, _, _) = result!; + final decoded = img.decodeGif(bytes); + + expect(type, ImageType.gif); + expect(decoded, isNotNull); + expect(decoded!.frames.map((frame) => frame.frameDuration), [100, 50]); + }); + + test('normalizeGifFrameDurations keeps normal animated GIF bytes', () { + final encoded = _gifWithFrameDurations(20, 50); + + final normalized = normalizeGifFrameDurations(encoded); + + expect(identical(normalized, encoded), isTrue); + }); + + test('normalizeGifBytesIfNeeded normalizes GIF bytes', () { + final encoded = _gifWithFrameDurations(10, 50); + + final normalized = normalizeGifBytesIfNeeded(encoded); + final decoded = img.decodeGif(normalized); + + expect(decoded, isNotNull); + expect(decoded!.frames.map((frame) => frame.frameDuration), [100, 50]); + }); + + test('normalizeGifBytesIfNeeded keeps non-GIF bytes', () { + final encoded = Uint8List.fromList([0x89, 0x50, 0x4e, 0x47]); + + final normalized = normalizeGifBytesIfNeeded(encoded); + + expect(identical(normalized, encoded), isTrue); + }); + + test('normalizeGifFileIfNeeded rewrites cached GIF files', () async { + final file = File( + '${Directory.systemTemp.path}/mixin-gif-${DateTime.now().microsecondsSinceEpoch}.gif', + ); + await file.writeAsBytes(_gifWithFrameDurations(10, 50)); + + try { + await normalizeGifFileIfNeeded(file, ImageType.gif.mimeType); + + final decoded = img.decodeGif(await file.readAsBytes()); + + expect(decoded, isNotNull); + expect(decoded!.frames.map((frame) => frame.frameDuration), [100, 50]); + } finally { + if (file.existsSync()) { + file.deleteSync(); + } + } + }); + test('compressImage preserves transparency for alpha images', () { final source = img.Image(width: 2, height: 1, numChannels: 4) ..setPixelRgba(0, 0, 0, 0, 0, 0) @@ -24,3 +104,14 @@ void main() { expect(decoded.getPixel(1, 0).a.toInt(), 255); }); } + +Uint8List _gifWithFrameDurations(int firstFrameMs, int secondFrameMs) { + final first = img.Image(width: 1, height: 1, numChannels: 4) + ..setPixelRgba(0, 0, 0, 0, 0, 255) + ..frameDuration = firstFrameMs; + final second = img.Image(width: 1, height: 1, numChannels: 4) + ..setPixelRgba(0, 0, 255, 255, 255, 255) + ..frameDuration = secondFrameMs; + first.addFrame(second); + return img.encodeGif(first); +} From 31706db0293b1100e07bde15393322beed5ad208 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 18:24:54 +0900 Subject: [PATCH 10/27] Tighten chat viewport and image handling --- lib/ui/home/chat/chat_content_overlays.dart | 62 ++++++++ lib/ui/home/chat/chat_history_viewport.dart | 64 --------- lib/ui/home/chat/chat_page.dart | 1 + lib/ui/home/chat/message_jump.dart | 88 ++++-------- lib/ui/home/notifier/chat_side_notifier.dart | 3 +- lib/ui/home/notifier/message_controller.dart | 8 +- lib/utils/image.dart | 82 ++++++++--- lib/widgets/message/message.dart | 72 +--------- lib/widgets/message/message_rows.dart | 72 ++++++++++ lib/widgets/mixin_image.dart | 17 +-- macos/Podfile | 6 + macos/Podfile.lock | 133 +----------------- macos/Runner.xcodeproj/project.pbxproj | 44 +----- test/ui/home/chat/message_jump_test.dart | 29 ---- .../notifier/chat_side_notifier_test.dart | 3 +- test/utils/image_test.dart | 19 +++ 16 files changed, 270 insertions(+), 433 deletions(-) create mode 100644 lib/widgets/message/message_rows.dart delete mode 100644 test/ui/home/chat/message_jump_test.dart diff --git a/lib/ui/home/chat/chat_content_overlays.dart b/lib/ui/home/chat/chat_content_overlays.dart index ee934bbb21..6e10fde55b 100644 --- a/lib/ui/home/chat/chat_content_overlays.dart +++ b/lib/ui/home/chat/chat_content_overlays.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:super_context_menu/super_context_menu.dart'; @@ -20,10 +21,13 @@ import '../../../widgets/menu.dart'; import '../../../widgets/message/message_bubble.dart'; import '../../../widgets/pin_bubble.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/pending_chat_jump_provider.dart'; import '../conversation_info_destination.dart'; import '../hook/pin_message.dart'; import '../notifier/chat_side_notifier.dart'; +import '../notifier/message_controller.dart'; import 'chat_history_viewport.dart'; +import 'chat_scroll_coordinator.dart'; import 'message_jump.dart'; class ChatContentOverlays extends StatelessWidget { @@ -47,6 +51,64 @@ class ChatContentOverlays extends StatelessWidget { ); } +class JumpCurrentButton extends HookConsumerWidget { + const JumpCurrentButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scrollCoordinator = context.read(); + + final state = useValueListenable(context.read()); + final showJumpToLatest = useValueListenable( + scrollCoordinator.showJumpToLatest, + ); + + final enable = (!state.isEmpty && !state.isLatest) || showJumpToLatest; + + final pendingJumpController = ref.read(pendingChatJumpProvider.notifier); + + if (!enable) { + Future(() => pendingJumpController.state = null); + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InteractiveDecoratedBox( + onTap: () async { + final messageId = pendingJumpController.state; + if (messageId != null) { + await context.jumpToMessageInChat(messageId); + pendingJumpController.state = null; + return; + } + await context.jumpToLatestInChat(); + }, + child: Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: context.messageBubbleColor(false), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.15), + offset: Offset(0, 2), + blurRadius: 10, + ), + ], + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: SvgPicture.asset( + Resources.assetsImagesJumpCurrentArrowSvg, + colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), + ), + ), + ), + ); + } +} + class _BottomBanner extends HookConsumerWidget { const _BottomBanner(); diff --git a/lib/ui/home/chat/chat_history_viewport.dart b/lib/ui/home/chat/chat_history_viewport.dart index 6fa6999a23..6a7e35f3bd 100644 --- a/lib/ui/home/chat/chat_history_viewport.dart +++ b/lib/ui/home/chat/chat_history_viewport.dart @@ -1,24 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; -import '../../../constants/resources.dart'; import '../../../db/mixin_database.dart' hide Offset; import '../../../utils/extension/extension.dart'; import '../../../widgets/clamping_custom_scroll_view/clamping_custom_scroll_view.dart'; -import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/message/message.dart'; -import '../../../widgets/message/message_bubble.dart'; import '../../../widgets/message/message_day_time.dart'; import '../../provider/is_bot_group_provider.dart'; -import '../../provider/pending_chat_jump_provider.dart'; import '../../provider/setting_provider.dart'; import '../notifier/message_controller.dart'; import 'chat_scroll_coordinator.dart'; -import 'message_jump.dart'; @visibleForTesting void syncMessageGlobalKeys( @@ -229,61 +223,3 @@ List _dayTimeEntries( ...mapRows(rows.bottom), ]; } - -class JumpCurrentButton extends HookConsumerWidget { - const JumpCurrentButton({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scrollCoordinator = context.read(); - - final state = useValueListenable(context.read()); - final showJumpToLatest = useValueListenable( - scrollCoordinator.showJumpToLatest, - ); - - final enable = (!state.isEmpty && !state.isLatest) || showJumpToLatest; - - final pendingJumpController = ref.read(pendingChatJumpProvider.notifier); - - if (!enable) { - Future(() => pendingJumpController.state = null); - return const SizedBox(); - } - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: InteractiveDecoratedBox( - onTap: () async { - final messageId = pendingJumpController.state; - if (messageId != null) { - await context.jumpToMessageInChat(messageId); - pendingJumpController.state = null; - return; - } - await context.jumpToLatestInChat(); - }, - child: Container( - height: 40, - width: 40, - decoration: BoxDecoration( - color: context.messageBubbleColor(false), - boxShadow: const [ - BoxShadow( - color: Color.fromRGBO(0, 0, 0, 0.15), - offset: Offset(0, 2), - blurRadius: 10, - ), - ], - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: SvgPicture.asset( - Resources.assetsImagesJumpCurrentArrowSvg, - colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), - ), - ), - ), - ); - } -} diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index c4dd4f4bfa..05cb6309fd 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -118,6 +118,7 @@ class ChatPage extends HookConsumerWidget { final routeMode = DesktopShellLayout.useChatSideRouteMode( boxConstraints.maxWidth, ); + chatSideNotifier.routeMode = routeMode; return DesktopShellLayout.chatSideRouteMode( routeMode: routeMode, diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index 9906693af9..4ea1082c32 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -1,73 +1,32 @@ import 'package:flutter/widgets.dart'; import '../../../utils/extension/extension.dart'; -import '../desktop_shell_layout.dart'; import '../notifier/blink_notifier.dart'; import '../notifier/chat_side_notifier.dart'; import '../notifier/message_controller.dart'; import 'chat_jump_trace.dart'; import 'chat_scroll_coordinator.dart'; -bool shouldUseLoadedLatestWindowForLatestJump(MessageState state) => - state.isLatest; - -enum ChatJumpKind { message, latest } - -class ChatJumpTarget { - const ChatJumpTarget.message( - this.messageId, { - this.sourceMessageId, - this.closeSideAfterJump = false, - }) : kind = ChatJumpKind.message; - - const ChatJumpTarget.latest({this.closeSideAfterJump = false}) - : kind = ChatJumpKind.latest, - messageId = null, - sourceMessageId = null; - - final ChatJumpKind kind; - final String? messageId; - final String? sourceMessageId; - final bool closeSideAfterJump; -} - -class ChatHistoryLocation { - ChatHistoryLocation({ +class _ChatHistoryLocation { + _ChatHistoryLocation({ required BlinkNotifier blinkNotifier, required ChatScrollCoordinator scrollCoordinator, required MessageController messageController, required ChatSideNotifier chatSideNotifier, - required bool chatSideRouteMode, }) : _blinkNotifier = blinkNotifier, _scrollCoordinator = scrollCoordinator, _messageController = messageController, - _chatSideNotifier = chatSideNotifier, - _chatSideRouteMode = chatSideRouteMode; + _chatSideNotifier = chatSideNotifier; final BlinkNotifier _blinkNotifier; final ChatScrollCoordinator _scrollCoordinator; final MessageController _messageController; final ChatSideNotifier _chatSideNotifier; - final bool _chatSideRouteMode; - Future jumpTo(ChatJumpTarget target) async { - switch (target.kind) { - case ChatJumpKind.message: - await _jumpToMessage( - target.messageId!, - sourceMessageId: target.sourceMessageId, - ); - case ChatJumpKind.latest: - await _jumpToLatest(); - } - if (target.closeSideAfterJump) { - _chatSideNotifier.closeAfterContentJump(routeMode: _chatSideRouteMode); - } - } - - Future _jumpToMessage( + Future jumpToMessage( String messageId, { String? sourceMessageId, + bool closeSideAfterJump = false, }) async { traceChatJump( 'request source=${shortMessageId(sourceMessageId)} ' @@ -84,6 +43,7 @@ class ChatHistoryLocation { 'handled=$handled', ); if (handled) { + _closeSideIfNeeded(closeSideAfterJump); return; } @@ -105,16 +65,18 @@ class ChatHistoryLocation { messageId, direction: direction, ); - _messageController.scrollTo(messageId); + _messageController.loadAroundMessage(messageId); + _closeSideIfNeeded(closeSideAfterJump); } - Future _jumpToLatest() async { + Future jumpToLatest({bool closeSideAfterJump = false}) async { traceChatJump('request latest'); - if (shouldUseLoadedLatestWindowForLatestJump(_messageController.state) && + if (_messageController.state.isLatest && await _scrollCoordinator.scrollToBottomIfInLoadedWindow( animated: true, )) { traceChatJump('loaded-window latest handled=true'); + _closeSideIfNeeded(closeSideAfterJump); return; } @@ -122,7 +84,8 @@ class ChatHistoryLocation { _scrollCoordinator.animateNextRestore( direction: ChatScrollRestoreDirection.towardNewer, ); - _messageController.jumpToLatestWindow(); + _messageController.loadLatestWindow(); + _closeSideIfNeeded(closeSideAfterJump); } String? _currentWindowSourceMessageId(MessageState state) { @@ -137,34 +100,33 @@ class ChatHistoryLocation { MessageWindowDirection.newer => ChatScrollRestoreDirection.towardNewer, null => null, }; + + void _closeSideIfNeeded(bool closeSideAfterJump) { + if (!closeSideAfterJump) return; + _chatSideNotifier.closeAfterContentJump(); + } } extension ChatMessageJump on BuildContext { - Future jumpToChatTarget(ChatJumpTarget target) => - _chatHistoryLocation.jumpTo(target); - Future jumpToMessageInChat( String messageId, { String? sourceMessageId, bool closeSideAfterJump = false, - }) => jumpToChatTarget( - ChatJumpTarget.message( - messageId, - sourceMessageId: sourceMessageId, - closeSideAfterJump: closeSideAfterJump, - ), + }) => _chatHistoryLocation.jumpToMessage( + messageId, + sourceMessageId: sourceMessageId, + closeSideAfterJump: closeSideAfterJump, ); Future jumpToLatestInChat({bool closeSideAfterJump = false}) => - jumpToChatTarget( - ChatJumpTarget.latest(closeSideAfterJump: closeSideAfterJump), + _chatHistoryLocation.jumpToLatest( + closeSideAfterJump: closeSideAfterJump, ); - ChatHistoryLocation get _chatHistoryLocation => ChatHistoryLocation( + _ChatHistoryLocation get _chatHistoryLocation => _ChatHistoryLocation( blinkNotifier: read(), scrollCoordinator: read(), messageController: read(), chatSideNotifier: read(), - chatSideRouteMode: DesktopShellLayout.chatSideRouteModeOf(this), ); } diff --git a/lib/ui/home/notifier/chat_side_notifier.dart b/lib/ui/home/notifier/chat_side_notifier.dart index b536c22129..f48d672fb0 100644 --- a/lib/ui/home/notifier/chat_side_notifier.dart +++ b/lib/ui/home/notifier/chat_side_notifier.dart @@ -25,6 +25,7 @@ class ChatSideNotifier extends ValueNotifier { ChatSideNotifier() : super(const ChatSideState()); var _disposed = false; + bool routeMode = false; ChatSideState get state => value; @@ -73,7 +74,7 @@ class ChatSideNotifier extends ValueNotifier { openDestination(destination); } - void closeAfterContentJump({required bool routeMode}) { + void closeAfterContentJump() { if (routeMode) clear(); } diff --git a/lib/ui/home/notifier/message_controller.dart b/lib/ui/home/notifier/message_controller.dart index 250711b4d5..1a44cac094 100644 --- a/lib/ui/home/notifier/message_controller.dart +++ b/lib/ui/home/notifier/message_controller.dart @@ -496,8 +496,10 @@ class MessageController extends ValueNotifier { _emit(_pretreatment(state.removeMessage(messageId))); } - void scrollTo(String messageId) { - traceChatJump('message scrollTo target=${shortMessageId(messageId)}'); + void loadAroundMessage(String messageId) { + traceChatJump( + 'message loadAround target=${shortMessageId(messageId)}', + ); _init( centerMessageId: messageId, lastReadMessageId: state.lastReadMessageId, @@ -508,7 +510,7 @@ class MessageController extends ValueNotifier { _init(); } - void jumpToLatestWindow() { + void loadLatestWindow() { _init( lastReadMessageId: state.lastReadMessageId, forceLatest: true, diff --git a/lib/utils/image.dart b/lib/utils/image.dart index cd781e11a5..dd62cd79c4 100644 --- a/lib/utils/image.dart +++ b/lib/utils/image.dart @@ -102,27 +102,23 @@ Future downloadImageFile( int maxBytes = _kMaxDownloadedImageBytes, }) async { final resolved = Uri.base.resolve(url); - final client = await createRHttpClient(proxyConfig: proxyConfig); - final response = await client.send(http.Request('GET', resolved)); + final response = await _sendImageRequest(resolved, proxyConfig); if (response.statusCode != HttpStatus.ok) return null; - final contentLength = response.contentLength; - if (contentLength != null && contentLength > maxBytes) { - throw StateError('NetworkImage is too large: $resolved'); - } - final file = File(await generateTempFilePath(TempFileType.networkImage)); final sink = file.openWrite(); - var received = 0; try { - await for (final chunk in response.stream) { - received += chunk.length; - if (received > maxBytes) { - throw StateError('NetworkImage is too large: $resolved'); - } - sink.add(chunk); - } + final received = await _readImageStream( + response, + resolved, + maxBytes, + sink.add, + ); await sink.close(); + if (received == 0) { + await file.delete(); + throw StateError('NetworkImage is an empty file: $resolved'); + } } catch (_) { try { await sink.close(); @@ -131,12 +127,64 @@ Future downloadImageFile( rethrow; } + return file; +} + +Future downloadImageBytes( + String url, { + ProxyConfig? proxyConfig, + int maxBytes = _kMaxDownloadedImageBytes, +}) async { + final resolved = Uri.base.resolve(url); + final response = await _sendImageRequest(resolved, proxyConfig); + if (response.statusCode != HttpStatus.ok) { + throw HttpException( + 'NetworkImage HTTP ${response.statusCode}', + uri: resolved, + ); + } + + final builder = BytesBuilder(copy: false); + final received = await _readImageStream( + response, + resolved, + maxBytes, + builder.add, + ); if (received == 0) { - await file.delete(); throw StateError('NetworkImage is an empty file: $resolved'); } + return builder.takeBytes(); +} - return file; +Future _sendImageRequest( + Uri resolved, + ProxyConfig? proxyConfig, +) async { + final client = await createRHttpClient(proxyConfig: proxyConfig); + return client.send(http.Request('GET', resolved)); +} + +Future _readImageStream( + http.StreamedResponse response, + Uri resolved, + int maxBytes, + void Function(List chunk) add, +) async { + final contentLength = response.contentLength; + if (contentLength != null && contentLength > maxBytes) { + throw StateError('NetworkImage is too large: $resolved'); + } + + var received = 0; + await for (final chunk in response.stream) { + received += chunk.length; + if (received > maxBytes) { + throw StateError('NetworkImage is too large: $resolved'); + } + add(chunk); + } + return received; } Uint8List normalizeGifBytesIfNeeded(Uint8List data) { diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index 825c75eec8..f48e4d8266 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -17,7 +17,6 @@ import '../../ui/provider/message_selection_provider.dart'; import '../../ui/provider/quote_message_provider.dart'; import '../../ui/provider/setting_provider.dart'; import '../../ui/provider/user_cache_provider.dart'; -import '../../utils/datetime_format_utils.dart'; import '../../utils/double_tap_util.dart'; import '../../utils/extension/extension.dart'; import '../avatar_view/avatar_view.dart'; @@ -32,9 +31,11 @@ import 'message_actions_menu.dart'; import 'message_content.dart'; import 'message_day_time.dart'; import 'message_name.dart'; +import 'message_rows.dart'; import 'message_style.dart'; export 'message_file_actions.dart'; +export 'message_rows.dart'; class _MessageContext with EquatableMixin { _MessageContext({ @@ -61,75 +62,6 @@ class _MessageContext with EquatableMixin { ]; } -class MessageRowModel { - MessageRowModel({ - required this.message, - this.prev, - this.next, - }) : sameDayPrev = isSameDay(prev?.createdAt, message.createdAt), - sameDayNext = isSameDay(next?.createdAt, message.createdAt), - prevIsSystem = prev?.type.isSystem ?? false, - prevIsPin = prev?.type.isPin ?? false, - sameUserNext = next?.userId == message.userId { - sameUserPrev = - !prevIsSystem && !prevIsPin && prev?.userId == message.userId; - dateTime = sameDayPrev ? null : message.createdAt; - } - - final MessageItem message; - final MessageItem? prev; - final MessageItem? next; - final bool sameDayPrev; - final bool sameDayNext; - final bool prevIsSystem; - final bool prevIsPin; - late final bool sameUserPrev; - final bool sameUserNext; - late final DateTime? dateTime; -} - -class MessageRows { - const MessageRows({ - required this.top, - required this.bottom, - this.center, - }); - - factory MessageRows.from({ - required List top, - required MessageItem? center, - required List bottom, - }) => MessageRows( - top: [ - for (var index = 0; index < top.length; index++) - MessageRowModel( - message: top[index], - prev: top.getOrNull(index - 1), - next: top.getOrNull(index + 1) ?? center ?? bottom.firstOrNull, - ), - ], - center: center == null - ? null - : MessageRowModel( - message: center, - prev: top.lastOrNull, - next: bottom.firstOrNull, - ), - bottom: [ - for (var index = 0; index < bottom.length; index++) - MessageRowModel( - message: bottom[index], - prev: bottom.getOrNull(index - 1) ?? center ?? top.lastOrNull, - next: bottom.getOrNull(index + 1), - ), - ], - ); - - final List top; - final MessageRowModel? center; - final List bottom; -} - bool useIsTranscriptPage() => _useMessageContextConverter((state) => state.isTranscriptPage); diff --git a/lib/widgets/message/message_rows.dart b/lib/widgets/message/message_rows.dart new file mode 100644 index 0000000000..8122887949 --- /dev/null +++ b/lib/widgets/message/message_rows.dart @@ -0,0 +1,72 @@ +import '../../db/mixin_database.dart'; +import '../../utils/datetime_format_utils.dart'; +import '../../utils/extension/extension.dart'; + +class MessageRowModel { + MessageRowModel({ + required this.message, + this.prev, + this.next, + }) : sameDayPrev = isSameDay(prev?.createdAt, message.createdAt), + sameDayNext = isSameDay(next?.createdAt, message.createdAt), + prevIsSystem = prev?.type.isSystem ?? false, + prevIsPin = prev?.type.isPin ?? false, + sameUserNext = next?.userId == message.userId { + sameUserPrev = + !prevIsSystem && !prevIsPin && prev?.userId == message.userId; + dateTime = sameDayPrev ? null : message.createdAt; + } + + final MessageItem message; + final MessageItem? prev; + final MessageItem? next; + final bool sameDayPrev; + final bool sameDayNext; + final bool prevIsSystem; + final bool prevIsPin; + late final bool sameUserPrev; + final bool sameUserNext; + late final DateTime? dateTime; +} + +class MessageRows { + const MessageRows({ + required this.top, + required this.bottom, + this.center, + }); + + factory MessageRows.from({ + required List top, + required MessageItem? center, + required List bottom, + }) => MessageRows( + top: [ + for (var index = 0; index < top.length; index++) + MessageRowModel( + message: top[index], + prev: top.getOrNull(index - 1), + next: top.getOrNull(index + 1) ?? center ?? bottom.firstOrNull, + ), + ], + center: center == null + ? null + : MessageRowModel( + message: center, + prev: top.lastOrNull, + next: bottom.firstOrNull, + ), + bottom: [ + for (var index = 0; index < bottom.length; index++) + MessageRowModel( + message: bottom[index], + prev: bottom.getOrNull(index - 1) ?? center ?? top.lastOrNull, + next: bottom.getOrNull(index + 1), + ), + ], + ); + + final List top; + final MessageRowModel? center; + final List bottom; +} diff --git a/lib/widgets/mixin_image.dart b/lib/widgets/mixin_image.dart index 0b3ad7bee2..5d8e16cc05 100644 --- a/lib/widgets/mixin_image.dart +++ b/lib/widgets/mixin_image.dart @@ -49,20 +49,9 @@ class ProxyNetworkImage extends ImageProvider { Future Function(ui.ImmutableBuffer buffer) decode, ) async { try { - final resolved = Uri.base.resolve(key.url); - final client = await createRHttpClient(proxyConfig: key.proxyConfig); - final response = await client.get(resolved); - if (response.statusCode != HttpStatus.ok) { - throw NetworkImageLoadException( - statusCode: response.statusCode, - uri: resolved, - ); - } - - final bytes = normalizeGifBytesIfNeeded(response.bodyBytes); - if (bytes.lengthInBytes == 0) { - throw StateError('NetworkImage is an empty file: $resolved'); - } + final bytes = normalizeGifBytesIfNeeded( + await downloadImageBytes(key.url, proxyConfig: key.proxyConfig), + ); return decode(await ui.ImmutableBuffer.fromUint8List(bytes)); } catch (_) { scheduleMicrotask(() { diff --git a/macos/Podfile b/macos/Podfile index 0405e7dda5..36be88b185 100644 --- a/macos/Podfile +++ b/macos/Podfile @@ -38,5 +38,11 @@ end post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_macos_build_settings(target) + target.build_configurations.each do |config| + next unless config.name == 'Profile' + + # ogg_opus_player ships an arm64-only static library for macOS. + config.build_settings['ARCHS'] = 'arm64' + end end end diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 9f58019cbf..19fbe68c7d 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,227 +1,96 @@ PODS: - - audio_session (0.0.1): - - FlutterMacOS - desktop_drop (0.0.1): - FlutterMacOS - desktop_keep_screen_on (0.0.1): - FlutterMacOS - - desktop_webview_window (0.0.1): - - FlutterMacOS - - device_info_plus (0.0.1): - - FlutterMacOS - - file_picker (0.0.1): - - FlutterMacOS - - file_selector_macos (0.0.1): - - FlutterMacOS - flutter_app_icon_badge (0.0.1): - FlutterMacOS - - flutter_local_notifications (0.0.1): - - FlutterMacOS - FlutterMacOS (1.0.0) - irondash_engine_context (0.0.1): - FlutterMacOS - - local_auth_darwin (0.0.1): - - Flutter - - FlutterMacOS - mixin_logger (0.0.1): - FlutterMacOS - - network_info_plus (0.0.1): - - FlutterMacOS - objective_c (0.0.1): - FlutterMacOS - ogg_opus_player (0.0.1): - Flutter - FlutterMacOS - - open_file_mac (1.0.3): - - FlutterMacOS - - package_info_plus (0.0.1): - - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - PLCrashReporter (1.11.2) - protocol_handler_macos (0.0.1): - FlutterMacOS - rhttp (0.0.1): - FlutterMacOS - - screen_retriever_macos (0.0.1): - - FlutterMacOS - - sqlite3 (3.52.0): - - sqlite3/common (= 3.52.0) - - sqlite3/common (3.52.0) - - sqlite3/dbstatvtab (3.52.0): - - sqlite3/common - - sqlite3/fts5 (3.52.0): - - sqlite3/common - - sqlite3/math (3.52.0): - - sqlite3/common - - sqlite3/perf-threadsafe (3.52.0): - - sqlite3/common - - sqlite3/rtree (3.52.0): - - sqlite3/common - - sqlite3/session (3.52.0): - - sqlite3/common - - sqlite3_flutter_libs (0.0.1): - - Flutter - - FlutterMacOS - - sqlite3 (~> 3.52.0) - - sqlite3/dbstatvtab - - sqlite3/fts5 - - sqlite3/math - - sqlite3/perf-threadsafe - - sqlite3/rtree - - sqlite3/session - super_native_extensions (0.0.1): - FlutterMacOS - - url_launcher_macos (0.0.1): - - FlutterMacOS - video_compress (0.3.0): - FlutterMacOS - - video_player_avfoundation (0.0.1): - - Flutter - - FlutterMacOS - - webview_flutter_wkwebview (0.0.1): - - Flutter - - FlutterMacOS - - window_manager (0.5.0): - - FlutterMacOS - window_size (0.0.2): - FlutterMacOS DEPENDENCIES: - - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) - desktop_keep_screen_on (from `Flutter/ephemeral/.symlinks/plugins/desktop_keep_screen_on/macos`) - - desktop_webview_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos`) - - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_app_icon_badge (from `Flutter/ephemeral/.symlinks/plugins/flutter_app_icon_badge/macos`) - - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - irondash_engine_context (from `Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos`) - - local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`) - mixin_logger (from `Flutter/ephemeral/.symlinks/plugins/mixin_logger/macos`) - - network_info_plus (from `Flutter/ephemeral/.symlinks/plugins/network_info_plus/macos`) - objective_c (from `Flutter/ephemeral/.symlinks/plugins/objective_c/macos`) - ogg_opus_player (from `Flutter/ephemeral/.symlinks/plugins/ogg_opus_player/darwin`) - - open_file_mac (from `Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos`) - - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - PLCrashReporter - protocol_handler_macos (from `Flutter/ephemeral/.symlinks/plugins/protocol_handler_macos/macos`) - rhttp (from `Flutter/ephemeral/.symlinks/plugins/rhttp/macos`) - - screen_retriever_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos`) - - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) - super_native_extensions (from `Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos`) - - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - video_compress (from `Flutter/ephemeral/.symlinks/plugins/video_compress/macos`) - - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) - - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) - - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) SPEC REPOS: trunk: - PLCrashReporter - - sqlite3 EXTERNAL SOURCES: - audio_session: - :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos desktop_drop: :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos desktop_keep_screen_on: :path: Flutter/ephemeral/.symlinks/plugins/desktop_keep_screen_on/macos - desktop_webview_window: - :path: Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos - device_info_plus: - :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos - file_picker: - :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos - file_selector_macos: - :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos flutter_app_icon_badge: :path: Flutter/ephemeral/.symlinks/plugins/flutter_app_icon_badge/macos - flutter_local_notifications: - :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos FlutterMacOS: :path: Flutter/ephemeral irondash_engine_context: :path: Flutter/ephemeral/.symlinks/plugins/irondash_engine_context/macos - local_auth_darwin: - :path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin mixin_logger: :path: Flutter/ephemeral/.symlinks/plugins/mixin_logger/macos - network_info_plus: - :path: Flutter/ephemeral/.symlinks/plugins/network_info_plus/macos objective_c: :path: Flutter/ephemeral/.symlinks/plugins/objective_c/macos ogg_opus_player: :path: Flutter/ephemeral/.symlinks/plugins/ogg_opus_player/darwin - open_file_mac: - :path: Flutter/ephemeral/.symlinks/plugins/open_file_mac/macos - package_info_plus: - :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin protocol_handler_macos: :path: Flutter/ephemeral/.symlinks/plugins/protocol_handler_macos/macos rhttp: :path: Flutter/ephemeral/.symlinks/plugins/rhttp/macos - screen_retriever_macos: - :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever_macos/macos - sqlite3_flutter_libs: - :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin super_native_extensions: :path: Flutter/ephemeral/.symlinks/plugins/super_native_extensions/macos - url_launcher_macos: - :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos video_compress: :path: Flutter/ephemeral/.symlinks/plugins/video_compress/macos - video_player_avfoundation: - :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin - webview_flutter_wkwebview: - :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin - window_manager: - :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos window_size: :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: - audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43 desktop_keep_screen_on: bfedcf75be8bd8b4a21db3dcf48667427087792c - desktop_webview_window: 07ec5b575166556b3b9c96c5b7c962b2c044b452 - device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 - file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a - file_selector_macos: 9e9e068e90ebee155097d00e89ae91edb2374db7 flutter_app_icon_badge: e05568a57ac0d98a0e7bc35c5b9ae9b118109b1e - flutter_local_notifications: 1fc7ffb10a83d6a2eeeeddb152d43f1944b0aad0 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 irondash_engine_context: 893c7d96d20ce361d7e996f39d360c4c2f9869ba - local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb mixin_logger: 6b31328b08f546a8defd32cd910370562fc48405 - network_info_plus: 21d1cd6a015ccb2fdff06a1fbfa88d54b4e92f61 objective_c: 2f927c775f7ad0d1ee8f78b4b0a5ddf03b2548d7 ogg_opus_player: 954784304f4e2722780018c4abf284e4f93cddf5 - open_file_mac: 76f06c8597551249bdb5e8fd8827a98eae0f4585 - package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 PLCrashReporter: 499c53b0104f95c302d94fd723ebb03c56d9bac8 protocol_handler_macos: f9cd7b13bcaf6b0425f7410cbe52376cb843a936 rhttp: 08d791d6373089de796318f4cfb172cbcf34e78b - screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933 - sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 - sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab super_native_extensions: c2795d6d9aedf4a79fae25cb6160b71b50549189 - url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd video_compress: 752b161da855df2492dd1a8fa899743cc8fe9534 - video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 - webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d - window_manager: b729e31d38fb04905235df9ea896128991cad99e window_size: 4bd15034e6e3d0720fd77928a7c42e5492cfece9 -PODFILE CHECKSUM: 79f215ba0ed5f5732c2fc484c6279e46c5c42059 +PODFILE CHECKSUM: 05d986538853d579a789a69095f4e3f0adfd7f8e COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index ebab3dc57d..3aedbb9efd 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 60; objects = { /* Begin PBXAggregateTarget section */ @@ -247,7 +247,7 @@ ); mainGroup = 33CC10E42044A3C60003C045; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; @@ -358,68 +358,32 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/audio_session/audio_session.framework", "${BUILT_PRODUCTS_DIR}/desktop_drop/desktop_drop.framework", "${BUILT_PRODUCTS_DIR}/desktop_keep_screen_on/desktop_keep_screen_on.framework", - "${BUILT_PRODUCTS_DIR}/desktop_webview_window/desktop_webview_window.framework", - "${BUILT_PRODUCTS_DIR}/device_info_plus/device_info_plus.framework", - "${BUILT_PRODUCTS_DIR}/file_picker/file_picker.framework", - "${BUILT_PRODUCTS_DIR}/file_selector_macos/file_selector_macos.framework", "${BUILT_PRODUCTS_DIR}/flutter_app_icon_badge/flutter_app_icon_badge.framework", - "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", "${BUILT_PRODUCTS_DIR}/irondash_engine_context/irondash_engine_context.framework", - "${BUILT_PRODUCTS_DIR}/local_auth_darwin/local_auth_darwin.framework", "${BUILT_PRODUCTS_DIR}/mixin_logger/mixin_logger.framework", - "${BUILT_PRODUCTS_DIR}/network_info_plus/network_info_plus.framework", "${BUILT_PRODUCTS_DIR}/objective_c/objective_c.framework", "${BUILT_PRODUCTS_DIR}/ogg_opus_player/ogg_opus_player.framework", - "${BUILT_PRODUCTS_DIR}/open_file_mac/open_file_mac.framework", - "${BUILT_PRODUCTS_DIR}/package_info_plus/package_info_plus.framework", - "${BUILT_PRODUCTS_DIR}/path_provider_foundation/path_provider_foundation.framework", "${BUILT_PRODUCTS_DIR}/protocol_handler_macos/protocol_handler_macos.framework", "${BUILT_PRODUCTS_DIR}/rhttp/rhttp.framework", - "${BUILT_PRODUCTS_DIR}/screen_retriever_macos/screen_retriever_macos.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3/sqlite3.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3_flutter_libs/sqlite3_flutter_libs.framework", "${BUILT_PRODUCTS_DIR}/super_native_extensions/super_native_extensions.framework", - "${BUILT_PRODUCTS_DIR}/url_launcher_macos/url_launcher_macos.framework", "${BUILT_PRODUCTS_DIR}/video_compress/video_compress.framework", - "${BUILT_PRODUCTS_DIR}/video_player_avfoundation/video_player_avfoundation.framework", - "${BUILT_PRODUCTS_DIR}/webview_flutter_wkwebview/webview_flutter_wkwebview.framework", - "${BUILT_PRODUCTS_DIR}/window_manager/window_manager.framework", "${BUILT_PRODUCTS_DIR}/window_size/window_size.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/audio_session.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/desktop_drop.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/desktop_keep_screen_on.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/desktop_webview_window.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/device_info_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_selector_macos.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_app_icon_badge.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/irondash_engine_context.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/local_auth_darwin.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/mixin_logger.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/network_info_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/objective_c.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ogg_opus_player.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/open_file_mac.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_foundation.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/protocol_handler_macos.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/rhttp.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/screen_retriever_macos.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3_flutter_libs.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/super_native_extensions.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_macos.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_compress.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_player_avfoundation.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/webview_flutter_wkwebview.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/window_manager.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/window_size.framework", ); runOnlyForDeploymentPostprocessing = 0; @@ -470,6 +434,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ARCHS = arm64; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -516,6 +481,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; buildSettings = { + ARCHS = arm64; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; @@ -768,7 +734,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/test/ui/home/chat/message_jump_test.dart b/test/ui/home/chat/message_jump_test.dart deleted file mode 100644 index f4a1e21db8..0000000000 --- a/test/ui/home/chat/message_jump_test.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter_app/ui/home/chat/message_jump.dart'; -import 'package:flutter_app/ui/home/notifier/message_controller.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('latest jump only uses loaded window when message state is latest', () { - expect( - shouldUseLoadedLatestWindowForLatestJump(MessageState(isLatest: true)), - isTrue, - ); - expect( - shouldUseLoadedLatestWindowForLatestJump(MessageState()), - isFalse, - ); - }); - - test('chat jump target carries side close policy', () { - const target = ChatJumpTarget.message( - 'message', - sourceMessageId: 'source', - closeSideAfterJump: true, - ); - - expect(target.kind, ChatJumpKind.message); - expect(target.messageId, 'message'); - expect(target.sourceMessageId, 'source'); - expect(target.closeSideAfterJump, isTrue); - }); -} diff --git a/test/ui/home/notifier/chat_side_notifier_test.dart b/test/ui/home/notifier/chat_side_notifier_test.dart index 6f01e86767..ce4b100ad5 100644 --- a/test/ui/home/notifier/chat_side_notifier_test.dart +++ b/test/ui/home/notifier/chat_side_notifier_test.dart @@ -22,7 +22,8 @@ void main() { notifier ..openDestination(ConversationInfoDestination.searchMessageHistory) - ..closeAfterContentJump(routeMode: true); + ..routeMode = true + ..closeAfterContentJump(); expect(notifier.state.destinations, isEmpty); }); diff --git a/test/utils/image_test.dart b/test/utils/image_test.dart index a3ebc13a43..25b861fce4 100644 --- a/test/utils/image_test.dart +++ b/test/utils/image_test.dart @@ -25,6 +25,25 @@ void main() { ); }); + test('downloadImageBytes rejects oversized streaming responses', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(server.close); + final subscription = server.listen((request) async { + request.response.add([1, 2]); + request.response.add([3, 4]); + await request.response.close(); + }); + addTearDown(subscription.cancel); + + await expectLater( + downloadImageBytes( + 'http://${server.address.host}:${server.port}', + maxBytes: 3, + ), + throwsStateError, + ); + }); + test('compressImage normalizes short animated GIF frame durations', () { final encoded = _gifWithFrameDurations(10, 50); final result = compressImage(encoded); From 14faf65c3f8a1fdd7d1d0d22cabfe00165728439 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:26:07 +0900 Subject: [PATCH 11/27] Refactor chat render responsibilities --- lib/ui/home/chat/chat_content_overlays.dart | 199 +---------------- lib/ui/home/chat/chat_jump_buttons.dart | 207 ++++++++++++++++++ lib/ui/home/chat/chat_page.dart | 9 + lib/ui/home/chat/message_jump.dart | 12 +- lib/ui/home/notifier/chat_side_notifier.dart | 6 +- lib/widgets/media_image_pipeline.dart | 146 ++++++++++++ lib/widgets/message/message.dart | 130 +---------- .../message/message_action_policy.dart | 59 +++++ lib/widgets/message/message_actions_menu.dart | 56 ++--- lib/widgets/message/message_bubble.dart | 62 +++--- lib/widgets/message/message_context.dart | 133 +++++++++++ lib/widgets/mixin_image.dart | 145 +----------- .../widgets/message/message_context_test.dart | 93 +++++++- 13 files changed, 707 insertions(+), 550 deletions(-) create mode 100644 lib/ui/home/chat/chat_jump_buttons.dart create mode 100644 lib/widgets/media_image_pipeline.dart create mode 100644 lib/widgets/message/message_action_policy.dart create mode 100644 lib/widgets/message/message_context.dart diff --git a/lib/ui/home/chat/chat_content_overlays.dart b/lib/ui/home/chat/chat_content_overlays.dart index 6e10fde55b..09356f6892 100644 --- a/lib/ui/home/chat/chat_content_overlays.dart +++ b/lib/ui/home/chat/chat_content_overlays.dart @@ -1,33 +1,26 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:super_context_menu/super_context_menu.dart'; import '../../../account/scam_warning_key_value.dart'; import '../../../account/show_pin_message_key_value.dart'; import '../../../constants/resources.dart'; -import '../../../db/database_event_bus.dart'; -import '../../../db/mixin_database.dart' hide Offset; import '../../../utils/extension/extension.dart'; import '../../../utils/hook.dart'; import '../../../widgets/action_button.dart'; import '../../../widgets/animated_visibility.dart'; import '../../../widgets/high_light_text.dart'; import '../../../widgets/interactive_decorated_box.dart'; -import '../../../widgets/menu.dart'; import '../../../widgets/message/message_bubble.dart'; import '../../../widgets/pin_bubble.dart'; import '../../provider/conversation_provider.dart'; -import '../../provider/pending_chat_jump_provider.dart'; import '../conversation_info_destination.dart'; import '../hook/pin_message.dart'; import '../notifier/chat_side_notifier.dart'; -import '../notifier/message_controller.dart'; import 'chat_history_viewport.dart'; -import 'chat_scroll_coordinator.dart'; +import 'chat_jump_buttons.dart'; import 'message_jump.dart'; class ChatContentOverlays extends StatelessWidget { @@ -43,7 +36,7 @@ class ChatContentOverlays extends StatelessWidget { right: 16, child: Column( mainAxisSize: MainAxisSize.min, - children: [_JumpMentionButton(), JumpCurrentButton()], + children: [JumpMentionButton(), JumpCurrentButton()], ), ), _PinMessagesBanner(), @@ -51,64 +44,6 @@ class ChatContentOverlays extends StatelessWidget { ); } -class JumpCurrentButton extends HookConsumerWidget { - const JumpCurrentButton({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scrollCoordinator = context.read(); - - final state = useValueListenable(context.read()); - final showJumpToLatest = useValueListenable( - scrollCoordinator.showJumpToLatest, - ); - - final enable = (!state.isEmpty && !state.isLatest) || showJumpToLatest; - - final pendingJumpController = ref.read(pendingChatJumpProvider.notifier); - - if (!enable) { - Future(() => pendingJumpController.state = null); - return const SizedBox(); - } - - return Padding( - padding: const EdgeInsets.only(top: 8), - child: InteractiveDecoratedBox( - onTap: () async { - final messageId = pendingJumpController.state; - if (messageId != null) { - await context.jumpToMessageInChat(messageId); - pendingJumpController.state = null; - return; - } - await context.jumpToLatestInChat(); - }, - child: Container( - height: 40, - width: 40, - decoration: BoxDecoration( - color: context.messageBubbleColor(false), - boxShadow: const [ - BoxShadow( - color: Color.fromRGBO(0, 0, 0, 0.15), - offset: Offset(0, 2), - blurRadius: 10, - ), - ], - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: SvgPicture.asset( - Resources.assetsImagesJumpCurrentArrowSvg, - colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), - ), - ), - ), - ); - } -} - class _BottomBanner extends HookConsumerWidget { const _BottomBanner(); @@ -289,133 +224,3 @@ class _PinMessagesBanner extends HookConsumerWidget { ); } } - -class _JumpMentionButton extends HookConsumerWidget { - const _JumpMentionButton(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final conversationId = ref.watch(currentConversationIdProvider); - final messageMentions = - useMemoizedStream(() { - if (conversationId == null) return Stream.value([]); - return context.database.messageMentionDao - .unreadMentionMessageByConversationId(conversationId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateMessageMention( - conversationIds: [conversationId], - ), - ], - duration: kSlowThrottleDuration, - ); - }, keys: [conversationId]).data ?? - []; - - if (messageMentions.isEmpty) return const SizedBox(); - - return CustomContextMenuWidget( - hitTestBehavior: HitTestBehavior.translucent, - desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), - menuProvider: (request) => Menu( - children: [ - MenuAction( - title: context.l10n.clear, - callback: () { - for (final mention in messageMentions) { - context.accountServer.markMentionRead( - mention.messageId, - mention.conversationId, - ); - } - }, - ), - ], - ), - child: InteractiveDecoratedBox( - onTap: () async { - if (messageMentions.isEmpty) return; - - final mention = messageMentions.first; - await context.jumpToMessageInChat(mention.messageId); - await context.accountServer.markMentionRead( - mention.messageId, - mention.conversationId, - ); - }, - child: SizedBox( - height: 52, - width: 40, - child: Stack( - children: [ - Positioned( - top: 12, - child: Container( - height: 40, - width: 40, - decoration: BoxDecoration( - color: context.messageBubbleColor(false), - boxShadow: const [ - BoxShadow( - color: Color.fromRGBO(0, 0, 0, 0.15), - offset: Offset(0, 2), - blurRadius: 10, - ), - ], - shape: BoxShape.circle, - ), - alignment: Alignment.center, - child: Text( - '@', - style: TextStyle( - fontSize: 17, - height: 1, - color: context.theme.text, - ), - ), - ), - ), - Container( - width: 40, - alignment: Alignment.topCenter, - child: Container( - constraints: const BoxConstraints( - minWidth: 20, - maxWidth: 40, - maxHeight: 20, - minHeight: 20, - ), - decoration: BoxDecoration( - color: context.theme.accent, - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Column( - children: [ - // Use column + spacer to vertical center the text. - // since the width is not fixed, we can't use center widget. - const Spacer(), - Text( - messageMentions.length > 99 - ? '99+' - : '${messageMentions.length}', - style: const TextStyle( - fontSize: 14, - height: 1, - color: Colors.white, - ), - textAlign: TextAlign.center, - maxLines: 1, - ), - const Spacer(), - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/ui/home/chat/chat_jump_buttons.dart b/lib/ui/home/chat/chat_jump_buttons.dart new file mode 100644 index 0000000000..cac0a09f5a --- /dev/null +++ b/lib/ui/home/chat/chat_jump_buttons.dart @@ -0,0 +1,207 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:super_context_menu/super_context_menu.dart'; + +import '../../../constants/resources.dart'; +import '../../../db/database_event_bus.dart'; +import '../../../db/mixin_database.dart' hide Offset; +import '../../../utils/extension/extension.dart'; +import '../../../utils/hook.dart'; +import '../../../widgets/interactive_decorated_box.dart'; +import '../../../widgets/menu.dart'; +import '../../../widgets/message/message_bubble.dart'; +import '../../provider/conversation_provider.dart'; +import '../../provider/pending_chat_jump_provider.dart'; +import '../notifier/message_controller.dart'; +import 'chat_scroll_coordinator.dart'; +import 'message_jump.dart'; + +class JumpCurrentButton extends HookConsumerWidget { + const JumpCurrentButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scrollCoordinator = context.read(); + + final state = useValueListenable(context.read()); + final showJumpToLatest = useValueListenable( + scrollCoordinator.showJumpToLatest, + ); + + final enable = (!state.isEmpty && !state.isLatest) || showJumpToLatest; + + final pendingJumpController = ref.read(pendingChatJumpProvider.notifier); + + if (!enable) { + Future(() => pendingJumpController.state = null); + return const SizedBox(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: InteractiveDecoratedBox( + onTap: () async { + final messageId = pendingJumpController.state; + if (messageId != null) { + await context.jumpToMessageInChat(messageId); + pendingJumpController.state = null; + return; + } + await context.jumpToLatestInChat(); + }, + child: Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: context.messageBubbleColor(false), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.15), + offset: Offset(0, 2), + blurRadius: 10, + ), + ], + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: SvgPicture.asset( + Resources.assetsImagesJumpCurrentArrowSvg, + colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), + ), + ), + ), + ); + } +} + +class JumpMentionButton extends HookConsumerWidget { + const JumpMentionButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final conversationId = ref.watch(currentConversationIdProvider); + final messageMentions = + useMemoizedStream(() { + if (conversationId == null) return Stream.value([]); + return context.database.messageMentionDao + .unreadMentionMessageByConversationId(conversationId) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateMessageMention( + conversationIds: [conversationId], + ), + ], + duration: kSlowThrottleDuration, + ); + }, keys: [conversationId]).data ?? + []; + + if (messageMentions.isEmpty) return const SizedBox(); + + return CustomContextMenuWidget( + hitTestBehavior: HitTestBehavior.translucent, + desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), + menuProvider: (request) => Menu( + children: [ + MenuAction( + title: context.l10n.clear, + callback: () { + for (final mention in messageMentions) { + context.accountServer.markMentionRead( + mention.messageId, + mention.conversationId, + ); + } + }, + ), + ], + ), + child: InteractiveDecoratedBox( + onTap: () async { + if (messageMentions.isEmpty) return; + + final mention = messageMentions.first; + await context.jumpToMessageInChat(mention.messageId); + await context.accountServer.markMentionRead( + mention.messageId, + mention.conversationId, + ); + }, + child: SizedBox( + height: 52, + width: 40, + child: Stack( + children: [ + Positioned( + top: 12, + child: Container( + height: 40, + width: 40, + decoration: BoxDecoration( + color: context.messageBubbleColor(false), + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.15), + offset: Offset(0, 2), + blurRadius: 10, + ), + ], + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + '@', + style: TextStyle( + fontSize: 17, + height: 1, + color: context.theme.text, + ), + ), + ), + ), + Container( + width: 40, + alignment: Alignment.topCenter, + child: Container( + constraints: const BoxConstraints( + minWidth: 20, + maxWidth: 40, + maxHeight: 20, + minHeight: 20, + ), + decoration: BoxDecoration( + color: context.theme.accent, + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Column( + children: [ + const Spacer(), + Text( + messageMentions.length > 99 + ? '99+' + : '${messageMentions.length}', + style: const TextStyle( + fontSize: 14, + height: 1, + color: Colors.white, + ), + textAlign: TextAlign.center, + maxLines: 1, + ), + const Spacer(), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 05cb6309fd..38c23b091e 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -23,6 +23,7 @@ import 'chat_menu_handler.dart'; import 'chat_scroll_coordinator.dart'; import 'chat_side_router.dart'; import 'input_container.dart'; +import 'message_jump.dart'; import 'selection_bottom_bar.dart'; class ChatPage extends HookConsumerWidget { @@ -109,6 +110,14 @@ class ChatPage extends HookConsumerWidget { create: (_) => ChatScrollCoordinator(), dispose: (_, coordinator) => coordinator.dispose(), ), + Provider( + create: (context) => MessageViewportController( + blinkNotifier: context.read(), + scrollCoordinator: context.read(), + messageController: context.read(), + chatSideNotifier: context.read(), + ), + ), Provider.value(value: pinMessageState), ], child: DecoratedBox( diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index 4ea1082c32..e08d15409d 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -7,8 +7,8 @@ import '../notifier/message_controller.dart'; import 'chat_jump_trace.dart'; import 'chat_scroll_coordinator.dart'; -class _ChatHistoryLocation { - _ChatHistoryLocation({ +class MessageViewportController { + MessageViewportController({ required BlinkNotifier blinkNotifier, required ChatScrollCoordinator scrollCoordinator, required MessageController messageController, @@ -123,10 +123,6 @@ extension ChatMessageJump on BuildContext { closeSideAfterJump: closeSideAfterJump, ); - _ChatHistoryLocation get _chatHistoryLocation => _ChatHistoryLocation( - blinkNotifier: read(), - scrollCoordinator: read(), - messageController: read(), - chatSideNotifier: read(), - ); + MessageViewportController get _chatHistoryLocation => + read(); } diff --git a/lib/ui/home/notifier/chat_side_notifier.dart b/lib/ui/home/notifier/chat_side_notifier.dart index f48d672fb0..1428ee060c 100644 --- a/lib/ui/home/notifier/chat_side_notifier.dart +++ b/lib/ui/home/notifier/chat_side_notifier.dart @@ -25,7 +25,7 @@ class ChatSideNotifier extends ValueNotifier { ChatSideNotifier() : super(const ChatSideState()); var _disposed = false; - bool routeMode = false; + bool _routeMode = false; ChatSideState get state => value; @@ -75,9 +75,11 @@ class ChatSideNotifier extends ValueNotifier { } void closeAfterContentJump() { - if (routeMode) clear(); + if (_routeMode) clear(); } + set routeMode(bool routeMode) => _routeMode = routeMode; + void toggleInfoPage() { if (state.destinations.isEmpty) { state = state.copyWith( diff --git a/lib/widgets/media_image_pipeline.dart b/lib/widgets/media_image_pipeline.dart new file mode 100644 index 0000000000..6c62f4030e --- /dev/null +++ b/lib/widgets/media_image_pipeline.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../utils/image.dart'; +import '../utils/proxy.dart'; + +final _normalizedGifFiles = {}; + +@immutable +class ProxyNetworkImage extends ImageProvider { + const ProxyNetworkImage(this.url, {this.scale = 1.0, this.proxyConfig}); + + final String url; + final double scale; + final ProxyConfig? proxyConfig; + + @override + Future obtainKey(ImageConfiguration configuration) => + SynchronousFuture(this); + + @override + ImageStreamCompleter loadImage( + ProxyNetworkImage key, + ImageDecoderCallback decode, + ) => _load(key, (buffer) => decode(buffer)); + + ImageStreamCompleter _load( + ProxyNetworkImage key, + Future Function(ui.ImmutableBuffer buffer) decode, + ) => MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: key.scale, + debugLabel: key.url, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Image key', key), + ], + ); + + Future _loadAsync( + ProxyNetworkImage key, + Future Function(ui.ImmutableBuffer buffer) decode, + ) async { + try { + final bytes = normalizeGifBytesIfNeeded( + await downloadImageBytes(key.url, proxyConfig: key.proxyConfig), + ); + return decode(await ui.ImmutableBuffer.fromUint8List(bytes)); + } catch (_) { + scheduleMicrotask(() { + PaintingBinding.instance.imageCache.evict(key); + }); + rethrow; + } + } + + @override + bool operator ==(Object other) => + other is ProxyNetworkImage && + other.url == url && + other.scale == scale && + other.proxyConfig == proxyConfig; + + @override + int get hashCode => Object.hash(url, scale, proxyConfig); +} + +class NormalizedGifImageGate extends StatefulWidget { + const NormalizedGifImageGate({ + required this.image, + required this.placeholder, + required this.childBuilder, + super.key, + }); + + final ImageProvider image; + final Widget Function() placeholder; + final Widget Function() childBuilder; + + @override + State createState() => _NormalizedGifImageGateState(); +} + +class _NormalizedGifImageGateState extends State { + Future? _future; + + @override + void initState() { + super.initState(); + _start(); + } + + @override + void didUpdateWidget(covariant NormalizedGifImageGate oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.image != widget.image) { + _start(); + } + } + + void _start() { + final image = widget.image; + if (image is! FileImage || + !image.file.path.toLowerCase().endsWith('.gif')) { + _future = null; + return; + } + + final path = image.file.absolute.path; + if (_normalizedGifFiles.contains(path)) { + _future = null; + return; + } + + _future = _normalize(image.file); + } + + Future _normalize(File file) async { + try { + await normalizeGifFileIfNeeded(file, ImageType.gif.mimeType); + await FileImage(file).evict(); + } finally { + _normalizedGifFiles.add(file.absolute.path); + } + } + + @override + Widget build(BuildContext context) { + final future = _future; + if (future == null) return widget.childBuilder(); + + return FutureBuilder( + future: future, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return widget.childBuilder(); + } + return widget.placeholder(); + }, + ); + } +} diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index f48e4d8266..97ccfd0fd9 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -1,4 +1,3 @@ -import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; @@ -29,71 +28,16 @@ import 'item/stranger_message.dart'; import 'item/system_message.dart'; import 'message_actions_menu.dart'; import 'message_content.dart'; +import 'message_context.dart'; import 'message_day_time.dart'; import 'message_name.dart'; import 'message_rows.dart'; import 'message_style.dart'; +export 'message_context.dart'; export 'message_file_actions.dart'; export 'message_rows.dart'; -class _MessageContext with EquatableMixin { - _MessageContext({ - required this.isTranscriptPage, - required this.isPinnedPage, - required this.showNip, - required this.isCurrentUser, - required this.message, - }); - - final bool isTranscriptPage; - final bool isPinnedPage; - final bool showNip; - final bool isCurrentUser; - final MessageItem message; - - @override - List get props => [ - isTranscriptPage, - isPinnedPage, - showNip, - isCurrentUser, - message, - ]; -} - -bool useIsTranscriptPage() => - _useMessageContextConverter((state) => state.isTranscriptPage); - -bool useIsPinnedPage() => - _useMessageContextConverter((state) => state.isPinnedPage); - -bool useShowNip() => _useMessageContextConverter((state) => state.showNip); - -bool useIsCurrentUser() => - _useMessageContextConverter((state) => state.isCurrentUser); - -MessageItem useMessage() => - useMessageConverter(converter: (state) => state); - -T useMessageConverter({required T Function(MessageItem) converter}) => - _useMessageContextConverter((state) => converter(state.message)); - -T _useMessageContextConverter(T Function(_MessageContext) converter) { - final context = useContext(); - return converter(_MessageContextScope.watch(context)); -} - -extension MessageContextExtension on BuildContext { - _MessageContext get _messageContext => _MessageContextScope.read(this); - - MessageItem get message => _messageContext.message; - - bool get isPinnedPage => _messageContext.isPinnedPage; - - bool get isTranscriptPage => _messageContext.isTranscriptPage; -} - const _pinArrowWidth = 32.0; void _quickReply(BuildContext context) { @@ -340,76 +284,6 @@ class _MessageBlinkBackgroundState extends State<_MessageBlinkBackground> { } } -class MessageContext extends StatelessWidget { - const MessageContext({ - required this.isTranscriptPage, - required this.isPinnedPage, - required this.showNip, - required this.isCurrentUser, - required this.message, - required this.child, - super.key, - }); - - MessageContext.fromMessageItem({ - required this.message, - required this.child, - super.key, - this.isTranscriptPage = false, - this.isPinnedPage = false, - this.showNip = false, - }) : isCurrentUser = message.relationship == UserRelationship.me; - - final bool isTranscriptPage; - final bool isPinnedPage; - final bool showNip; - final bool isCurrentUser; - final MessageItem message; - final Widget child; - - @override - Widget build(BuildContext context) => _MessageContextScope( - value: _MessageContext( - isTranscriptPage: isTranscriptPage, - isPinnedPage: isPinnedPage, - showNip: showNip, - isCurrentUser: isCurrentUser, - message: message, - ), - child: child, - ); -} - -class _MessageContextScope extends InheritedWidget { - const _MessageContextScope({ - required this.value, - required super.child, - }); - - final _MessageContext value; - - static _MessageContext watch(BuildContext context) { - final scope = context - .dependOnInheritedWidgetOfExactType<_MessageContextScope>(); - assert(scope != null, 'No MessageContext found in widget tree.'); - return scope!.value; - } - - static _MessageContext read(BuildContext context) { - final scope = - context - .getElementForInheritedWidgetOfExactType<_MessageContextScope>() - ?.widget - as _MessageContextScope?; - assert(scope != null, 'No MessageContext found in widget tree.'); - return scope!.value; - } - - @override - bool updateShouldNotify(_MessageContextScope oldWidget) => - value != oldWidget.value; -} - class _MessageBubbleMargin extends HookConsumerWidget { const _MessageBubbleMargin({ required this.isCurrentUser, diff --git a/lib/widgets/message/message_action_policy.dart b/lib/widgets/message/message_action_policy.dart new file mode 100644 index 0000000000..6314812752 --- /dev/null +++ b/lib/widgets/message/message_action_policy.dart @@ -0,0 +1,59 @@ +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +import '../../db/mixin_database.dart' hide Message; +import '../../enum/media_status.dart'; +import '../../utils/extension/extension.dart'; +import '../../utils/platform.dart'; + +class MessageActionPolicy { + const MessageActionPolicy({ + required this.message, + required this.isTranscriptPage, + required this.isPinnedPage, + required this.role, + }); + + static const _pinStatuses = [ + MessageStatus.delivered, + MessageStatus.read, + MessageStatus.sent, + ]; + + final MessageItem message; + final bool isTranscriptPage; + final bool isPinnedPage; + final Object? role; + + bool get canPin => + !isTranscriptPage && + message.type.canReply && + _pinStatuses.contains(message.status) && + role != null; + + bool get canReply => + !isTranscriptPage && message.type.canReply && !isPinnedPage; + + bool get canForward => !isTranscriptPage && message.canForward; + + bool get canSelect => !isTranscriptPage; + + bool get canSaveMobile => + kPlatformIsMobile && (message.type.isImage || message.type.isVideo); + + bool get canSaveDesktop => + kPlatformIsDesktop && + message.mediaStatus == MediaStatus.done && + message.mediaUrl?.isNotEmpty == true && + (message.type.isData || + message.type.isImage || + message.type.isVideo || + message.type.isAudio); + + bool get canRecall => !isTranscriptPage && message.canRecall; + + bool get canDelete => !isTranscriptPage && !isPinnedPage; + + bool get canAddSticker => message.type.isSticker; + + bool get canAddImageAsSticker => message.type.isImage && message.canForward; +} diff --git a/lib/widgets/message/message_actions_menu.dart b/lib/widgets/message/message_actions_menu.dart index 12cc546ff7..d2bc99fed4 100644 --- a/lib/widgets/message/message_actions_menu.dart +++ b/lib/widgets/message/message_actions_menu.dart @@ -14,7 +14,6 @@ import '../../blaze/vo/pin_message_minimal.dart'; import '../../constants/icon_fonts.dart'; import '../../db/dao/sticker_dao.dart'; import '../../db/mixin_database.dart' hide Message; -import '../../enum/media_status.dart'; import '../../ui/home/chat/message_jump.dart'; import '../../ui/provider/conversation_provider.dart'; import '../../ui/provider/message_selection_provider.dart'; @@ -22,7 +21,6 @@ import '../../ui/provider/quote_message_provider.dart'; import '../../ui/provider/recall_message_reedit_provider.dart'; import '../../utils/extension/extension.dart'; import '../../utils/logger.dart'; -import '../../utils/platform.dart'; import '../../utils/system/clipboard.dart'; import '../menu.dart'; import '../qr_code.dart'; @@ -31,6 +29,7 @@ import '../toast.dart'; import '../user_selector/conversation_selector.dart'; import 'item/action_card/action_card_data.dart'; import 'item/text/selectable.dart'; +import 'message_action_policy.dart'; import 'message_file_actions.dart'; Menu? buildMessageActionsMenu({ @@ -54,42 +53,21 @@ Menu? buildMessageActionsMenu({ if (ref.read(hasSelectedMessageProvider)) return null; final role = ref.read(conversationProvider.select((value) => value?.role)); - - final pinEnabled = - !isTranscriptPage && - message.type.canReply && - const [ - MessageStatus.delivered, - MessageStatus.read, - MessageStatus.sent, - ].contains(message.status) && - role != null; - final enableReply = - !isTranscriptPage && message.type.canReply && !isPinnedPage; - final enableForward = !isTranscriptPage && message.canForward; - final enableSelect = !isTranscriptPage; - final enableSaveMobile = - kPlatformIsMobile && (message.type.isImage || message.type.isVideo); - final enableSaveDesktop = - kPlatformIsDesktop && - message.mediaStatus == MediaStatus.done && - message.mediaUrl?.isNotEmpty == true && - (message.type.isData || - message.type.isImage || - message.type.isVideo || - message.type.isAudio); - final enableRecall = !isTranscriptPage && message.canRecall; - - final enableDelete = !isTranscriptPage && !isPinnedPage; + final policy = MessageActionPolicy( + message: message, + isTranscriptPage: isTranscriptPage, + isPinnedPage: isPinnedPage, + role: role, + ); final addStickerMenuAction = [ - if (message.type.isSticker) + if (policy.canAddSticker) MenuAction( image: MenuImage.icon(IconFonts.sticker), title: context.l10n.addSticker, callback: () => _onAddSticker(context, message), ), - if (message.type.isImage && message.canForward) + if (policy.canAddImageAsSticker) MenuAction( image: MenuImage.icon(IconFonts.sticker), title: context.l10n.addSticker, @@ -102,7 +80,7 @@ Menu? buildMessageActionsMenu({ ]; final replayAction = [ - if (enableReply) + if (policy.canReply) MenuAction( image: MenuImage.icon(IconFonts.reply), title: context.l10n.reply, @@ -128,7 +106,7 @@ Menu? buildMessageActionsMenu({ ); }, ), - if (enableForward) + if (policy.canForward) MenuAction( image: MenuImage.icon(IconFonts.forward), title: context.l10n.forward, @@ -148,7 +126,7 @@ Menu? buildMessageActionsMenu({ ); }, ), - if (enableSelect) + if (policy.canSelect) MenuAction( image: MenuImage.icon(IconFonts.select), title: context.l10n.select, @@ -158,7 +136,7 @@ Menu? buildMessageActionsMenu({ message, ), ), - if (pinEnabled) + if (policy.canPin) MenuAction( image: MenuImage.icon(message.pinned ? IconFonts.unPin : IconFonts.pin), title: message.pinned ? context.l10n.unpin : context.l10n.pinTitle, @@ -291,7 +269,7 @@ Menu? buildMessageActionsMenu({ } final saveActions = [ - if (enableSaveMobile) + if (policy.canSaveMobile) MenuAction( image: MenuImage.icon(IconFonts.download), title: context.l10n.saveToCameraRoll, @@ -302,7 +280,7 @@ Menu? buildMessageActionsMenu({ isTranscriptPage, ), ), - if (enableSaveDesktop) + if (policy.canSaveDesktop) MenuAction( image: MenuImage.icon(IconFonts.download), title: context.l10n.saveAs, @@ -315,7 +293,7 @@ Menu? buildMessageActionsMenu({ ), ]; final deleteActions = [ - if (enableRecall) + if (policy.canRecall) MenuAction( image: MenuImage.icon(IconFonts.recall), title: context.l10n.deleteForEveryone, @@ -334,7 +312,7 @@ Menu? buildMessageActionsMenu({ } }, ), - if (enableDelete) + if (policy.canDelete) MenuAction( image: MenuImage.icon(IconFonts.delete), title: context.l10n.deleteForMe, diff --git a/lib/widgets/message/message_bubble.dart b/lib/widgets/message/message_bubble.dart index a9ac0f0430..996347b77b 100644 --- a/lib/widgets/message/message_bubble.dart +++ b/lib/widgets/message/message_bubble.dart @@ -82,42 +82,38 @@ class MessageBubble extends HookConsumerWidget { if (hasQuoteMessage) { final constraintQuoteWidthToMessage = messageType.isVideo || messageType.isImage || messageType.isLive; - Widget quotedChild = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: constraintQuoteWidthToMessage - ? CrossAxisAlignment.stretch - : CrossAxisAlignment.start, - children: [ - SizedBox( - width: constraintQuoteWidthToMessage ? 0 : null, - child: MessageBubbleNipPadding( - currentUser: isCurrentUser, - child: HookBuilder( - builder: (context) { - final quoteContent = useMessageConverter( - converter: (state) => state.quoteContent, - ); - final messageId = useMessageConverter( - converter: (state) => state.messageId, - ); - - return QuoteMessage( - messageId: messageId, - quoteMessageId: quoteId, - quoteContent: quoteContent, - isTranscriptPage: isTranscriptPage, - ); - }, + _child = IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: constraintQuoteWidthToMessage ? 0 : null, + child: MessageBubbleNipPadding( + currentUser: isCurrentUser, + child: HookBuilder( + builder: (context) { + final quoteContent = useMessageConverter( + converter: (state) => state.quoteContent, + ); + final messageId = useMessageConverter( + converter: (state) => state.messageId, + ); + + return QuoteMessage( + messageId: messageId, + quoteMessageId: quoteId, + quoteContent: quoteContent, + isTranscriptPage: isTranscriptPage, + ); + }, + ), ), ), - ), - _child, - ], + _child, + ], + ), ); - if (constraintQuoteWidthToMessage) { - quotedChild = IntrinsicWidth(child: quotedChild); - } - _child = quotedChild; } final clipper = BubbleClipper(currentUser: isCurrentUser, showNip: showNip); diff --git a/lib/widgets/message/message_context.dart b/lib/widgets/message/message_context.dart new file mode 100644 index 0000000000..fa675e9fde --- /dev/null +++ b/lib/widgets/message/message_context.dart @@ -0,0 +1,133 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +import '../../db/mixin_database.dart' hide Message; + +class _MessageContext with EquatableMixin { + _MessageContext({ + required this.isTranscriptPage, + required this.isPinnedPage, + required this.showNip, + required this.isCurrentUser, + required this.message, + }); + + final bool isTranscriptPage; + final bool isPinnedPage; + final bool showNip; + final bool isCurrentUser; + final MessageItem message; + + @override + List get props => [ + isTranscriptPage, + isPinnedPage, + showNip, + isCurrentUser, + message, + ]; +} + +bool useIsTranscriptPage() => + _useMessageContextConverter((state) => state.isTranscriptPage); + +bool useIsPinnedPage() => + _useMessageContextConverter((state) => state.isPinnedPage); + +bool useShowNip() => _useMessageContextConverter((state) => state.showNip); + +bool useIsCurrentUser() => + _useMessageContextConverter((state) => state.isCurrentUser); + +MessageItem useMessage() => + useMessageConverter(converter: (state) => state); + +T useMessageConverter({required T Function(MessageItem) converter}) => + _useMessageContextConverter((state) => converter(state.message)); + +T _useMessageContextConverter(T Function(_MessageContext) converter) { + final context = useContext(); + return converter(_MessageContextScope.watch(context)); +} + +extension MessageContextExtension on BuildContext { + _MessageContext get _messageContext => _MessageContextScope.read(this); + + MessageItem get message => _messageContext.message; + + bool get isPinnedPage => _messageContext.isPinnedPage; + + bool get isTranscriptPage => _messageContext.isTranscriptPage; +} + +class MessageContext extends StatelessWidget { + const MessageContext({ + required this.isTranscriptPage, + required this.isPinnedPage, + required this.showNip, + required this.isCurrentUser, + required this.message, + required this.child, + super.key, + }); + + MessageContext.fromMessageItem({ + required this.message, + required this.child, + super.key, + this.isTranscriptPage = false, + this.isPinnedPage = false, + this.showNip = false, + }) : isCurrentUser = message.relationship == UserRelationship.me; + + final bool isTranscriptPage; + final bool isPinnedPage; + final bool showNip; + final bool isCurrentUser; + final MessageItem message; + final Widget child; + + @override + Widget build(BuildContext context) => _MessageContextScope( + value: _MessageContext( + isTranscriptPage: isTranscriptPage, + isPinnedPage: isPinnedPage, + showNip: showNip, + isCurrentUser: isCurrentUser, + message: message, + ), + child: child, + ); +} + +class _MessageContextScope extends InheritedWidget { + const _MessageContextScope({ + required this.value, + required super.child, + }); + + final _MessageContext value; + + static _MessageContext watch(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType<_MessageContextScope>(); + assert(scope != null, 'No MessageContext found in widget tree.'); + return scope!.value; + } + + static _MessageContext read(BuildContext context) { + final scope = + context + .getElementForInheritedWidgetOfExactType<_MessageContextScope>() + ?.widget + as _MessageContextScope?; + assert(scope != null, 'No MessageContext found in widget tree.'); + return scope!.value; + } + + @override + bool updateShouldNotify(_MessageContextScope oldWidget) => + value != oldWidget.value; +} diff --git a/lib/widgets/mixin_image.dart b/lib/widgets/mixin_image.dart index 5d8e16cc05..7b77f9f708 100644 --- a/lib/widgets/mixin_image.dart +++ b/lib/widgets/mixin_image.dart @@ -1,77 +1,13 @@ -import 'dart:async'; import 'dart:io'; -import 'dart:ui' as ui; +import 'dart:typed_data'; -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import '../utils/extension/extension.dart'; -import '../utils/image.dart'; -import '../utils/proxy.dart'; +import 'media_image_pipeline.dart'; typedef PlaceholderWidgetBuilder = Widget Function(); -final _normalizedGifFiles = {}; - -@immutable -class ProxyNetworkImage extends ImageProvider { - const ProxyNetworkImage(this.url, {this.scale = 1.0, this.proxyConfig}); - - final String url; - final double scale; - final ProxyConfig? proxyConfig; - - @override - Future obtainKey(ImageConfiguration configuration) => - SynchronousFuture(this); - - @override - ImageStreamCompleter loadImage( - ProxyNetworkImage key, - ImageDecoderCallback decode, - ) => _load(key, (buffer) => decode(buffer)); - - ImageStreamCompleter _load( - ProxyNetworkImage key, - Future Function(ui.ImmutableBuffer buffer) decode, - ) => MultiFrameImageStreamCompleter( - codec: _loadAsync(key, decode), - scale: key.scale, - debugLabel: key.url, - informationCollector: () => [ - DiagnosticsProperty('Image provider', this), - DiagnosticsProperty('Image key', key), - ], - ); - - Future _loadAsync( - ProxyNetworkImage key, - Future Function(ui.ImmutableBuffer buffer) decode, - ) async { - try { - final bytes = normalizeGifBytesIfNeeded( - await downloadImageBytes(key.url, proxyConfig: key.proxyConfig), - ); - return decode(await ui.ImmutableBuffer.fromUint8List(bytes)); - } catch (_) { - scheduleMicrotask(() { - PaintingBinding.instance.imageCache.evict(key); - }); - rethrow; - } - } - - @override - bool operator ==(Object other) => - other is ProxyNetworkImage && - other.url == url && - other.scale == scale && - other.proxyConfig == proxyConfig; - - @override - int get hashCode => Object.hash(url, scale, proxyConfig); -} - class MixinImage extends StatelessWidget { const MixinImage({ required this.image, @@ -157,7 +93,7 @@ class MixinImage extends StatelessWidget { errorBuilder?.call(context, error, stackTrace) ?? fallback(), ); - return _LazyNormalizedGif( + return NormalizedGifImageGate( image: resolvedImage, placeholder: fallback, childBuilder: imageView, @@ -177,78 +113,3 @@ class MixinImage extends StatelessWidget { return image; } } - -class _LazyNormalizedGif extends StatefulWidget { - const _LazyNormalizedGif({ - required this.image, - required this.placeholder, - required this.childBuilder, - }); - - final ImageProvider image; - final Widget Function() placeholder; - final Widget Function() childBuilder; - - @override - State<_LazyNormalizedGif> createState() => _LazyNormalizedGifState(); -} - -class _LazyNormalizedGifState extends State<_LazyNormalizedGif> { - Future? _future; - - @override - void initState() { - super.initState(); - _start(); - } - - @override - void didUpdateWidget(covariant _LazyNormalizedGif oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.image != widget.image) { - _start(); - } - } - - void _start() { - final image = widget.image; - if (image is! FileImage || - !image.file.path.toLowerCase().endsWith('.gif')) { - _future = null; - return; - } - - final path = image.file.absolute.path; - if (_normalizedGifFiles.contains(path)) { - _future = null; - return; - } - - _future = _normalize(image.file); - } - - Future _normalize(File file) async { - try { - await normalizeGifFileIfNeeded(file, ImageType.gif.mimeType); - await FileImage(file).evict(); - } finally { - _normalizedGifFiles.add(file.absolute.path); - } - } - - @override - Widget build(BuildContext context) { - final future = _future; - if (future == null) return widget.childBuilder(); - - return FutureBuilder( - future: future, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return widget.childBuilder(); - } - return widget.placeholder(); - }, - ); - } -} diff --git a/test/widgets/message/message_context_test.dart b/test/widgets/message/message_context_test.dart index e3289b8ca0..83350a0660 100644 --- a/test/widgets/message/message_context_test.dart +++ b/test/widgets/message/message_context_test.dart @@ -1,8 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter_app/constants/brightness_theme_data.dart'; import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/ui/provider/database_provider.dart'; +import 'package:flutter_app/ui/provider/mention_cache_provider.dart'; +import 'package:flutter_app/ui/provider/setting_provider.dart'; +import 'package:flutter_app/widgets/brightness_observer.dart'; +import 'package:flutter_app/widgets/message/item/quote_message.dart'; import 'package:flutter_app/widgets/message/message.dart'; +import 'package:flutter_app/widgets/message/message_action_policy.dart'; +import 'package:flutter_app/widgets/message/message_bubble.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; void main() { @@ -22,6 +31,29 @@ void main() { expect(rows.bottom.first.sameUserPrev, isTrue); }); + test('MessageActionPolicy keeps chat-only actions out of pinned views', () { + final message = testMessage('1'); + + final chatPolicy = MessageActionPolicy( + message: message, + isTranscriptPage: false, + isPinnedPage: false, + role: Object(), + ); + expect(chatPolicy.canReply, isTrue); + expect(chatPolicy.canPin, isTrue); + expect(chatPolicy.canDelete, isTrue); + + final pinnedPolicy = MessageActionPolicy( + message: message, + isTranscriptPage: false, + isPinnedPage: true, + role: Object(), + ); + expect(pinnedPolicy.canReply, isFalse); + expect(pinnedPolicy.canDelete, isFalse); + }); + testWidgets('MessageContext updates const children through inherited state', ( tester, ) async { @@ -49,6 +81,51 @@ void main() { expect(find.text('2'), findsOneWidget); }); + + testWidgets('MessageBubble stretches short quote to message width', ( + tester, + ) async { + const bodyKey = ValueKey('body'); + final message = testMessage( + '1', + quoteId: 'quoted', + quoteContent: _quoteContent, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + databaseProvider.overrideWith((ref) => DatabaseOpener()), + mentionCacheProvider.overrideWithValue(MentionCache(null)), + settingProvider.overrideWith((ref) => SettingChangeNotifier()), + ], + child: MaterialApp( + home: BrightnessData( + value: 0, + brightnessThemeData: lightBrightnessThemeData, + child: Align( + alignment: Alignment.topLeft, + child: MessageContext.fromMessageItem( + message: message, + child: const SizedBox( + width: 400, + child: MessageBubble( + child: SizedBox(key: bodyKey, width: 260, height: 20), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final quoteWidth = tester.getSize(find.byType(QuoteMessage)).width; + final bodyWidth = tester.getSize(find.byKey(bodyKey)).width; + + expect(quoteWidth, greaterThanOrEqualTo(bodyWidth)); + }); } class _MessageIdText extends HookWidget { @@ -60,15 +137,29 @@ class _MessageIdText extends HookWidget { ); } -MessageItem testMessage(String id, {String userId = 'user'}) => MessageItem( +MessageItem testMessage( + String id, { + String userId = 'user', + String? quoteId, + String? quoteContent, +}) => MessageItem( messageId: id, conversationId: 'conversation', type: 'PLAIN_TEXT', + content: 'body', createdAt: DateTime(2026), status: MessageStatus.read, userId: userId, userIdentityNumber: '0', isVerified: false, sharedUserIsVerified: false, + quoteId: quoteId, + quoteContent: quoteContent, pinned: false, ); + +const _quoteContent = + '{"message_id":"quoted","conversation_id":"conversation",' + '"user_id":"00000000-0000-4000-8000-000000000001",' + '"user_full_name":"KC","user_identity_number":"1","type":"PLAIN_TEXT",' + '"content":"q","createdAt":1767225600000,"status":"READ"}'; From 25ee6e13dc24a3f82d0098fa046866f9ec624c97 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:37:01 +0900 Subject: [PATCH 12/27] Unify current conversation latest jump --- lib/ui/home/chat/chat_page.dart | 22 ++++++++++++++++++++ lib/ui/home/notifier/message_controller.dart | 2 -- test/ui/home/chat/chat_page_test.dart | 14 +++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 38c23b091e..653e15de66 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -26,6 +28,14 @@ import 'input_container.dart'; import 'message_jump.dart'; import 'selection_bottom_bar.dart'; +void handleForceLatestChatJump( + Object? forceLatestKey, + Future Function() jumpToLatest, +) { + if (forceLatestKey == null) return; + unawaited(jumpToLatest()); +} + class ChatPage extends HookConsumerWidget { const ChatPage({super.key}); @@ -179,6 +189,18 @@ class ChatContainer extends HookConsumerWidget { MediaQuery.sizeOf(context).height ~/ 20; final inMultiSelectMode = ref.watch(hasSelectedMessageProvider); + final forceLatestKey = ref.watch( + conversationProvider.select((value) => value?.forceLatestKey), + ); + + useEffect(() { + if (forceLatestKey == null) return null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + handleForceLatestChatJump(forceLatestKey, context.jumpToLatestInChat); + }); + return null; + }, [forceLatestKey]); return RepaintBoundary( child: FocusableActionDetector( diff --git a/lib/ui/home/notifier/message_controller.dart b/lib/ui/home/notifier/message_controller.dart index 1a44cac094..3313f8189d 100644 --- a/lib/ui/home/notifier/message_controller.dart +++ b/lib/ui/home/notifier/message_controller.dart @@ -159,7 +159,6 @@ class MessageController extends ValueNotifier { event?.initIndexMessageId, event?.lastReadMessageId, event?.refreshKey, - event?.forceLatestKey, ), ) .distinct() @@ -167,7 +166,6 @@ class MessageController extends ValueNotifier { (event) => _init( centerMessageId: event.$2, lastReadMessageId: event.$3, - forceLatest: event.$5 != null, ), ), ) diff --git a/test/ui/home/chat/chat_page_test.dart b/test/ui/home/chat/chat_page_test.dart index 0a3fa6d5fb..29fb32248c 100644 --- a/test/ui/home/chat/chat_page_test.dart +++ b/test/ui/home/chat/chat_page_test.dart @@ -1,8 +1,22 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_app/ui/home/chat/chat_history_viewport.dart'; +import 'package:flutter_app/ui/home/chat/chat_page.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + test( + 'handleForceLatestChatJump delegates non-null signals to latest jump', + () { + var jumpCount = 0; + + handleForceLatestChatJump(null, () async => jumpCount++); + expect(jumpCount, 0); + + handleForceLatestChatJump(Object(), () async => jumpCount++); + expect(jumpCount, 1); + }, + ); + test( 'syncMessageGlobalKeys preserves current keys and removes stale keys', () { From cf2ad4ffc2f052ec6355dfa95847cf46c68a4bed Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:31:44 +0900 Subject: [PATCH 13/27] Fix chat media rendering edge cases --- lib/ui/home/chat/chat_page.dart | 21 ++---- lib/ui/home/chat/input_container.dart | 65 ++++++++++--------- lib/ui/home/chat/message_jump.dart | 28 ++++---- .../home/conversation/conversation_focus.dart | 2 +- lib/ui/home/notifier/chat_side_notifier.dart | 7 +- lib/ui/provider/conversation_provider.dart | 14 ++-- lib/utils/image.dart | 20 +++++- lib/widgets/media_image_pipeline.dart | 28 ++++++-- lib/widgets/message/item/quote_message.dart | 2 +- lib/widgets/message/message_context.dart | 3 - lib/widgets/message/message_layout.dart | 42 ++---------- lib/widgets/mixin_image.dart | 19 ++++-- lib/widgets/sticker_page/sticker_item.dart | 1 + test/ui/home/chat/chat_page_test.dart | 14 ---- .../notifier/chat_side_notifier_test.dart | 16 ++++- test/utils/image_test.dart | 21 ++++++ test/widgets/media_image_pipeline_test.dart | 37 +++++++++++ test/widgets/message/message_layout_test.dart | 37 +++++++++++ 18 files changed, 238 insertions(+), 139 deletions(-) create mode 100644 test/widgets/media_image_pipeline_test.dart create mode 100644 test/widgets/message/message_layout_test.dart diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 653e15de66..3ce2c87601 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -28,14 +28,6 @@ import 'input_container.dart'; import 'message_jump.dart'; import 'selection_bottom_bar.dart'; -void handleForceLatestChatJump( - Object? forceLatestKey, - Future Function() jumpToLatest, -) { - if (forceLatestKey == null) return; - unawaited(jumpToLatest()); -} - class ChatPage extends HookConsumerWidget { const ChatPage({super.key}); @@ -121,7 +113,7 @@ class ChatPage extends HookConsumerWidget { dispose: (_, coordinator) => coordinator.dispose(), ), Provider( - create: (context) => MessageViewportController( + create: (context) => ChatTimelineLocation( blinkNotifier: context.read(), scrollCoordinator: context.read(), messageController: context.read(), @@ -137,7 +129,6 @@ class ChatPage extends HookConsumerWidget { final routeMode = DesktopShellLayout.useChatSideRouteMode( boxConstraints.maxWidth, ); - chatSideNotifier.routeMode = routeMode; return DesktopShellLayout.chatSideRouteMode( routeMode: routeMode, @@ -189,18 +180,18 @@ class ChatContainer extends HookConsumerWidget { MediaQuery.sizeOf(context).height ~/ 20; final inMultiSelectMode = ref.watch(hasSelectedMessageProvider); - final forceLatestKey = ref.watch( - conversationProvider.select((value) => value?.forceLatestKey), + final latestJumpRequestKey = ref.watch( + conversationProvider.select((value) => value?.latestJumpRequestKey), ); useEffect(() { - if (forceLatestKey == null) return null; + if (latestJumpRequestKey == null) return null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted) return; - handleForceLatestChatJump(forceLatestKey, context.jumpToLatestInChat); + unawaited(context.jumpToLatestInChat()); }); return null; - }, [forceLatestKey]); + }, [latestJumpRequestKey]); return RepaintBoundary( child: FocusableActionDetector( diff --git a/lib/ui/home/chat/input_container.dart b/lib/ui/home/chat/input_container.dart index 8e90aed5f7..293d92c3b4 100644 --- a/lib/ui/home/chat/input_container.dart +++ b/lib/ui/home/chat/input_container.dart @@ -341,32 +341,28 @@ void showMaxLengthReachedToast(BuildContext context) => void _sendPostMessage( BuildContext context, TextEditingController textEditingController, -) { - final text = textEditingController.value.text.trim(); - if (text.isEmpty) return; - - final conversationItem = context.providerContainer.read(conversationProvider); - if (conversationItem == null) return; - if (text.length > kMaxTextLength) { - showMaxLengthReachedToast(context); - return; - } - - context.accountServer.sendPostMessage( - text, - conversationItem.encryptCategory, - conversationId: conversationItem.conversationId, - recipientId: conversationItem.userId, - ); - - textEditingController.text = ''; - context.providerContainer.read(quoteMessageProvider.notifier).state = null; -} +) => _sendTextMessage( + context, + textEditingController, + post: true, +); void _sendMessage( BuildContext context, TextEditingController textEditingController, { bool silent = false, +}) => _sendTextMessage( + context, + textEditingController, + post: false, + silent: silent, +); + +void _sendTextMessage( + BuildContext context, + TextEditingController textEditingController, { + required bool post, + bool silent = false, }) { final text = textEditingController.value.text.trim(); if (text.isEmpty) return; @@ -378,16 +374,25 @@ void _sendMessage( return; } - context.accountServer.sendTextMessage( - text, - conversationItem.encryptCategory, - conversationId: conversationItem.conversationId, - recipientId: conversationItem.userId, - quoteMessageId: context.providerContainer.read(quoteMessageIdProvider), - silent: silent, - ); + if (post) { + context.accountServer.sendPostMessage( + text, + conversationItem.encryptCategory, + conversationId: conversationItem.conversationId, + recipientId: conversationItem.userId, + ); + } else { + context.accountServer.sendTextMessage( + text, + conversationItem.encryptCategory, + conversationId: conversationItem.conversationId, + recipientId: conversationItem.userId, + quoteMessageId: context.providerContainer.read(quoteMessageIdProvider), + silent: silent, + ); + } - textEditingController.text = ''; + textEditingController.clear(); context.providerContainer.read(quoteMessageProvider.notifier).state = null; } diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index e08d15409d..9c498f3c95 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -1,14 +1,15 @@ import 'package:flutter/widgets.dart'; import '../../../utils/extension/extension.dart'; +import '../desktop_shell_layout.dart'; import '../notifier/blink_notifier.dart'; import '../notifier/chat_side_notifier.dart'; import '../notifier/message_controller.dart'; import 'chat_jump_trace.dart'; import 'chat_scroll_coordinator.dart'; -class MessageViewportController { - MessageViewportController({ +class ChatTimelineLocation { + ChatTimelineLocation({ required BlinkNotifier blinkNotifier, required ChatScrollCoordinator scrollCoordinator, required MessageController messageController, @@ -27,6 +28,7 @@ class MessageViewportController { String messageId, { String? sourceMessageId, bool closeSideAfterJump = false, + bool chatSideRouteMode = false, }) async { traceChatJump( 'request source=${shortMessageId(sourceMessageId)} ' @@ -43,7 +45,7 @@ class MessageViewportController { 'handled=$handled', ); if (handled) { - _closeSideIfNeeded(closeSideAfterJump); + _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); return; } @@ -66,17 +68,20 @@ class MessageViewportController { direction: direction, ); _messageController.loadAroundMessage(messageId); - _closeSideIfNeeded(closeSideAfterJump); + _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); } - Future jumpToLatest({bool closeSideAfterJump = false}) async { + Future jumpToLatest({ + bool closeSideAfterJump = false, + bool chatSideRouteMode = false, + }) async { traceChatJump('request latest'); if (_messageController.state.isLatest && await _scrollCoordinator.scrollToBottomIfInLoadedWindow( animated: true, )) { traceChatJump('loaded-window latest handled=true'); - _closeSideIfNeeded(closeSideAfterJump); + _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); return; } @@ -85,7 +90,7 @@ class MessageViewportController { direction: ChatScrollRestoreDirection.towardNewer, ); _messageController.loadLatestWindow(); - _closeSideIfNeeded(closeSideAfterJump); + _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); } String? _currentWindowSourceMessageId(MessageState state) { @@ -101,9 +106,9 @@ class MessageViewportController { null => null, }; - void _closeSideIfNeeded(bool closeSideAfterJump) { + void _closeSideIfNeeded(bool closeSideAfterJump, bool chatSideRouteMode) { if (!closeSideAfterJump) return; - _chatSideNotifier.closeAfterContentJump(); + _chatSideNotifier.closeAfterContentJump(routeMode: chatSideRouteMode); } } @@ -116,13 +121,14 @@ extension ChatMessageJump on BuildContext { messageId, sourceMessageId: sourceMessageId, closeSideAfterJump: closeSideAfterJump, + chatSideRouteMode: DesktopShellLayout.chatSideRouteModeOf(this), ); Future jumpToLatestInChat({bool closeSideAfterJump = false}) => _chatHistoryLocation.jumpToLatest( closeSideAfterJump: closeSideAfterJump, + chatSideRouteMode: DesktopShellLayout.chatSideRouteModeOf(this), ); - MessageViewportController get _chatHistoryLocation => - read(); + ChatTimelineLocation get _chatHistoryLocation => read(); } diff --git a/lib/ui/home/conversation/conversation_focus.dart b/lib/ui/home/conversation/conversation_focus.dart index e1391b95d2..b0830ae818 100644 --- a/lib/ui/home/conversation/conversation_focus.dart +++ b/lib/ui/home/conversation/conversation_focus.dart @@ -41,7 +41,7 @@ class ConversationFocus { initIndexMessageId == null && initialChatSidePage == null && keyword == null) { - conversationNotifier.openLatest(); + conversationNotifier.requestLatestJump(); return; } diff --git a/lib/ui/home/notifier/chat_side_notifier.dart b/lib/ui/home/notifier/chat_side_notifier.dart index 1428ee060c..b536c22129 100644 --- a/lib/ui/home/notifier/chat_side_notifier.dart +++ b/lib/ui/home/notifier/chat_side_notifier.dart @@ -25,7 +25,6 @@ class ChatSideNotifier extends ValueNotifier { ChatSideNotifier() : super(const ChatSideState()); var _disposed = false; - bool _routeMode = false; ChatSideState get state => value; @@ -74,12 +73,10 @@ class ChatSideNotifier extends ValueNotifier { openDestination(destination); } - void closeAfterContentJump() { - if (_routeMode) clear(); + void closeAfterContentJump({required bool routeMode}) { + if (routeMode) clear(); } - set routeMode(bool routeMode) => _routeMode = routeMode; - void toggleInfoPage() { if (state.destinations.isEmpty) { state = state.copyWith( diff --git a/lib/ui/provider/conversation_provider.dart b/lib/ui/provider/conversation_provider.dart index da97132b28..91e663a64b 100644 --- a/lib/ui/provider/conversation_provider.dart +++ b/lib/ui/provider/conversation_provider.dart @@ -31,7 +31,7 @@ class ConversationState extends Equatable { this.participant, this.initialSidePage, this.keyword, - this.forceLatestKey, + this.latestJumpRequestKey, }); final String conversationId; @@ -46,7 +46,7 @@ class ConversationState extends Equatable { final ConversationInfoDestination? initialSidePage; final String? keyword; - final Object? forceLatestKey; + final Object? latestJumpRequestKey; bool get isLoaded => conversation != null || user != null; @@ -92,7 +92,7 @@ class ConversationState extends Equatable { initialSidePage, participant, keyword, - forceLatestKey, + latestJumpRequestKey, ]; ConversationState copyWith({ @@ -107,7 +107,7 @@ class ConversationState extends Equatable { Object? refreshKey, ConversationInfoDestination? initialSidePage, String? keyword, - Object? forceLatestKey, + Object? latestJumpRequestKey, }) => ConversationState( conversationId: conversationId ?? this.conversationId, userId: userId ?? this.userId, @@ -120,7 +120,7 @@ class ConversationState extends Equatable { initialSidePage: initialSidePage ?? this.initialSidePage, participant: participant ?? this.participant, keyword: keyword ?? this.keyword, - forceLatestKey: forceLatestKey ?? this.forceLatestKey, + latestJumpRequestKey: latestJumpRequestKey ?? this.latestJumpRequestKey, ); } @@ -250,8 +250,8 @@ class ConversationStateNotifier _majorNavigationNotifier.openChatPage(); } - void openLatest() { - state = state?.copyWith(forceLatestKey: Object()); + void requestLatestJump() { + state = state?.copyWith(latestJumpRequestKey: Object()); openChatPage(); } diff --git a/lib/utils/image.dart b/lib/utils/image.dart index dd62cd79c4..11e25cc45e 100644 --- a/lib/utils/image.dart +++ b/lib/utils/image.dart @@ -216,10 +216,26 @@ Uint8List normalizeGifFrameDurations(Uint8List data) { } Future normalizeGifFileIfNeeded(File file, String? mimeType) async { - if (mimeType?.toLowerCase() != ImageType.gif.mimeType) return; + final normalizedMimeType = mimeType?.toLowerCase(); + if (normalizedMimeType != null && + normalizedMimeType != ImageType.gif.mimeType) { + return; + } if (!file.existsSync()) return; - final data = await file.readAsBytes(); + final opened = await file.open(); + late final Uint8List data; + try { + if (normalizedMimeType == null) { + final header = await opened.read(6); + if (!_isGifBytes(Uint8List.fromList(header))) return; + await opened.setPosition(0); + } + data = await opened.read(await file.length()); + } finally { + await opened.close(); + } + final normalized = normalizeGifFrameDurations(data); if (!identical(normalized, data)) { await file.writeAsBytes(normalized, flush: true); diff --git a/lib/widgets/media_image_pipeline.dart b/lib/widgets/media_image_pipeline.dart index 6c62f4030e..f0c2e7d5df 100644 --- a/lib/widgets/media_image_pipeline.dart +++ b/lib/widgets/media_image_pipeline.dart @@ -8,7 +8,19 @@ import 'package:flutter/widgets.dart'; import '../utils/image.dart'; import '../utils/proxy.dart'; -final _normalizedGifFiles = {}; +final _checkedImageFiles = {}; + +bool shouldUseMediaImagePipeline( + String url, + ProxyConfig? proxyConfig, { + bool normalizeGif = false, +}) => normalizeGif || proxyConfig != null || isLikelyGifUrl(url); + +@visibleForTesting +bool isLikelyGifUrl(String url) { + final path = Uri.tryParse(url)?.path ?? url.split('?').first; + return path.toLowerCase().endsWith('.gif'); +} @immutable class ProxyNetworkImage extends ImageProvider { @@ -104,14 +116,18 @@ class _NormalizedGifImageGateState extends State { void _start() { final image = widget.image; - if (image is! FileImage || - !image.file.path.toLowerCase().endsWith('.gif')) { + if (image is! FileImage) { + _future = null; + return; + } + final fileName = image.file.uri.pathSegments.last.toLowerCase(); + if (!fileName.endsWith('.gif') && fileName.contains('.')) { _future = null; return; } final path = image.file.absolute.path; - if (_normalizedGifFiles.contains(path)) { + if (_checkedImageFiles.contains(path)) { _future = null; return; } @@ -121,10 +137,10 @@ class _NormalizedGifImageGateState extends State { Future _normalize(File file) async { try { - await normalizeGifFileIfNeeded(file, ImageType.gif.mimeType); + await normalizeGifFileIfNeeded(file, null); await FileImage(file).evict(); } finally { - _normalizedGifFiles.add(file.absolute.path); + _checkedImageFiles.add(file.absolute.path); } } diff --git a/lib/widgets/message/item/quote_message.dart b/lib/widgets/message/item/quote_message.dart index 2371c052be..f97a40be26 100644 --- a/lib/widgets/message/item/quote_message.dart +++ b/lib/widgets/message/item/quote_message.dart @@ -418,8 +418,8 @@ class _QuoteImage extends HookWidget { return; } if (mediaUrl == null) { + final messageDao = context.database.messageDao; scheduleMicrotask(() async { - final messageDao = context.database.messageDao; final messageItem = await messageDao.findMessageItemByMessageId( quoteMessageId, ); diff --git a/lib/widgets/message/message_context.dart b/lib/widgets/message/message_context.dart index fa675e9fde..fe36012f97 100644 --- a/lib/widgets/message/message_context.dart +++ b/lib/widgets/message/message_context.dart @@ -41,9 +41,6 @@ bool useShowNip() => _useMessageContextConverter((state) => state.showNip); bool useIsCurrentUser() => _useMessageContextConverter((state) => state.isCurrentUser); -MessageItem useMessage() => - useMessageConverter(converter: (state) => state); - T useMessageConverter({required T Function(MessageItem) converter}) => _useMessageContextConverter((state) => converter(state.message)); diff --git a/lib/widgets/message/message_layout.dart b/lib/widgets/message/message_layout.dart index fbd148e9cb..2c223224d1 100644 --- a/lib/widgets/message/message_layout.dart +++ b/lib/widgets/message/message_layout.dart @@ -9,15 +9,13 @@ class MessageLayout extends MultiChildRenderObjectWidget { required Widget dateAndStatus, super.key, this.spacing = 0.0, - this.clipBehavior = Clip.none, }) : super(children: [content, dateAndStatus]); final double spacing; - final Clip clipBehavior; @override RenderObject createRenderObject(BuildContext context) => - _RenderMessageLayout(spacing: spacing, clipBehavior: clipBehavior); + _RenderMessageLayout(spacing: spacing); @override void updateRenderObject( @@ -25,9 +23,7 @@ class MessageLayout extends MultiChildRenderObjectWidget { // ignore: library_private_types_in_public_api _RenderMessageLayout renderObject, ) { - renderObject - ..spacing = spacing - ..clipBehavior = clipBehavior; + renderObject.spacing = spacing; } } @@ -38,9 +34,7 @@ class _RenderMessageLayout extends RenderBox _RenderMessageLayout({ List? children, double spacing = 0.0, - Clip clipBehavior = Clip.none, - }) : _spacing = spacing, - _clipBehavior = clipBehavior { + }) : _spacing = spacing { addAll(children); } @@ -53,17 +47,6 @@ class _RenderMessageLayout extends RenderBox markNeedsLayout(); } - Clip get clipBehavior => _clipBehavior; - Clip _clipBehavior = Clip.none; - - set clipBehavior(Clip value) { - if (value != _clipBehavior) { - _clipBehavior = value; - markNeedsPaint(); - markNeedsSemanticsUpdate(); - } - } - RenderBox get contentChild => firstChild!; RenderBox get statusChild => lastChild!; @@ -109,8 +92,6 @@ class _RenderMessageLayout extends RenderBox double? computeDistanceToActualBaseline(TextBaseline baseline) => defaultComputeDistanceToHighestActualBaseline(baseline); - final bool _hasVisualOverflow = false; - @override Size computeDryLayout(BoxConstraints constraints) => _computeDryLayout(constraints); @@ -210,23 +191,8 @@ class _RenderMessageLayout extends RenderBox defaultHitTestChildren(result, position: position); @override - void paint(PaintingContext context, Offset offset) { - if (_hasVisualOverflow && clipBehavior != Clip.none) { - _clipRectLayer = context.pushClipRect( - needsCompositing, - offset, - Offset.zero & size, - defaultPaint, - clipBehavior: clipBehavior, - oldLayer: _clipRectLayer, - ); - } else { - _clipRectLayer = null; + void paint(PaintingContext context, Offset offset) => defaultPaint(context, offset); - } - } - - ClipRectLayer? _clipRectLayer; bool _calculateRenderParagraphLastLineHasSpace(double widthLimit) { final renderParagraph = contentChild.findRenderObject(); diff --git a/lib/widgets/mixin_image.dart b/lib/widgets/mixin_image.dart index 7b77f9f708..bd07e50ffe 100644 --- a/lib/widgets/mixin_image.dart +++ b/lib/widgets/mixin_image.dart @@ -18,6 +18,7 @@ class MixinImage extends StatelessWidget { this.height, this.fit = BoxFit.cover, this.isAntiAlias = false, + this.normalizeGif = false, }); MixinImage.network( @@ -29,6 +30,7 @@ class MixinImage extends StatelessWidget { this.height, this.fit = BoxFit.cover, this.isAntiAlias = false, + this.normalizeGif = false, }) : image = NetworkImage(url); MixinImage.file( @@ -40,7 +42,8 @@ class MixinImage extends StatelessWidget { this.height, this.fit = BoxFit.cover, this.isAntiAlias = false, - }) : image = FileImage(file); + }) : image = FileImage(file), + normalizeGif = false; MixinImage.asset( String assetName, { @@ -51,7 +54,8 @@ class MixinImage extends StatelessWidget { this.height, this.fit = BoxFit.cover, this.isAntiAlias = false, - }) : image = AssetImage(assetName); + }) : image = AssetImage(assetName), + normalizeGif = false; MixinImage.memory( Uint8List bytes, { @@ -62,7 +66,8 @@ class MixinImage extends StatelessWidget { this.height, this.fit = BoxFit.cover, this.isAntiAlias = false, - }) : image = MemoryImage(bytes); + }) : image = MemoryImage(bytes), + normalizeGif = false; final ImageProvider image; final PlaceholderWidgetBuilder? placeholder; @@ -71,6 +76,7 @@ class MixinImage extends StatelessWidget { final double? height; final BoxFit? fit; final bool isAntiAlias; + final bool normalizeGif; @override Widget build(BuildContext context) { @@ -103,7 +109,12 @@ class MixinImage extends StatelessWidget { ImageProvider _resolveImage(BuildContext context) { final image = this.image; final proxyConfig = context.database.settingProperties.activatedProxy; - if (image is NetworkImage) { + if (image is NetworkImage && + shouldUseMediaImagePipeline( + image.url, + proxyConfig, + normalizeGif: normalizeGif, + )) { return ProxyNetworkImage( image.url, scale: image.scale, diff --git a/lib/widgets/sticker_page/sticker_item.dart b/lib/widgets/sticker_page/sticker_item.dart index 41ffc0ad8b..59a86ea3cc 100644 --- a/lib/widgets/sticker_page/sticker_item.dart +++ b/lib/widgets/sticker_page/sticker_item.dart @@ -106,6 +106,7 @@ class StickerItem extends HookConsumerWidget { height: height, width: width, fit: BoxFit.contain, + normalizeGif: assetType?.toLowerCase() == 'gif', errorBuilder: (context, error, stackTrace) { _triggerRefreshJob(context, stickerId); return errorWidget ?? const SizedBox(); diff --git a/test/ui/home/chat/chat_page_test.dart b/test/ui/home/chat/chat_page_test.dart index 29fb32248c..0a3fa6d5fb 100644 --- a/test/ui/home/chat/chat_page_test.dart +++ b/test/ui/home/chat/chat_page_test.dart @@ -1,22 +1,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_app/ui/home/chat/chat_history_viewport.dart'; -import 'package:flutter_app/ui/home/chat/chat_page.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - test( - 'handleForceLatestChatJump delegates non-null signals to latest jump', - () { - var jumpCount = 0; - - handleForceLatestChatJump(null, () async => jumpCount++); - expect(jumpCount, 0); - - handleForceLatestChatJump(Object(), () async => jumpCount++); - expect(jumpCount, 1); - }, - ); - test( 'syncMessageGlobalKeys preserves current keys and removes stale keys', () { diff --git a/test/ui/home/notifier/chat_side_notifier_test.dart b/test/ui/home/notifier/chat_side_notifier_test.dart index ce4b100ad5..6ca4e19ef6 100644 --- a/test/ui/home/notifier/chat_side_notifier_test.dart +++ b/test/ui/home/notifier/chat_side_notifier_test.dart @@ -22,12 +22,24 @@ void main() { notifier ..openDestination(ConversationInfoDestination.searchMessageHistory) - ..routeMode = true - ..closeAfterContentJump(); + ..closeAfterContentJump(routeMode: true); expect(notifier.state.destinations, isEmpty); }); + test('wide content jump keeps the side stack', () { + final notifier = ChatSideNotifier(); + addTearDown(notifier.dispose); + + notifier + ..openDestination(ConversationInfoDestination.searchMessageHistory) + ..closeAfterContentJump(routeMode: false); + + expect(notifier.state.destinations, [ + ConversationInfoDestination.searchMessageHistory, + ]); + }); + test('empty stack pop is ignored', () { final notifier = ChatSideNotifier(); addTearDown(notifier.dispose); diff --git a/test/utils/image_test.dart b/test/utils/image_test.dart index 25b861fce4..17d6b2d410 100644 --- a/test/utils/image_test.dart +++ b/test/utils/image_test.dart @@ -104,6 +104,27 @@ void main() { } }); + test('normalizeGifFileIfNeeded detects GIF bytes when mime type is missing', ( + ) async { + final file = File( + '${Directory.systemTemp.path}/mixin-gif-no-ext-${DateTime.now().microsecondsSinceEpoch}', + ); + await file.writeAsBytes(_gifWithFrameDurations(10, 50)); + + try { + await normalizeGifFileIfNeeded(file, null); + + final decoded = img.decodeGif(await file.readAsBytes()); + + expect(decoded, isNotNull); + expect(decoded!.frames.map((frame) => frame.frameDuration), [100, 50]); + } finally { + if (file.existsSync()) { + file.deleteSync(); + } + } + }); + test('compressImage preserves transparency for alpha images', () { final source = img.Image(width: 2, height: 1, numChannels: 4) ..setPixelRgba(0, 0, 0, 0, 0, 0) diff --git a/test/widgets/media_image_pipeline_test.dart b/test/widgets/media_image_pipeline_test.dart new file mode 100644 index 0000000000..4aa4ac9a64 --- /dev/null +++ b/test/widgets/media_image_pipeline_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_app/utils/proxy.dart'; +import 'package:flutter_app/widgets/media_image_pipeline.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('uses custom media pipeline only for proxy or likely gif urls', () { + expect( + shouldUseMediaImagePipeline('https://example.com/a.png', null), + false, + ); + expect( + shouldUseMediaImagePipeline('https://example.com/a.gif?x=1', null), + true, + ); + expect( + shouldUseMediaImagePipeline( + 'https://example.com/no-extension', + null, + normalizeGif: true, + ), + true, + ); + expect( + shouldUseMediaImagePipeline( + 'https://example.com/a.png', + ProxyConfig( + type: ProxyType.http, + host: '127.0.0.1', + port: 8080, + id: 'test', + ), + ), + true, + ); + }); + +} diff --git a/test/widgets/message/message_layout_test.dart b/test/widgets/message/message_layout_test.dart new file mode 100644 index 0000000000..930ffd02b6 --- /dev/null +++ b/test/widgets/message/message_layout_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_app/widgets/message/message_layout.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('MessageLayout paints its content and status children', ( + tester, + ) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: MessageLayout( + spacing: 4, + content: const SizedBox( + width: 20, + height: 20, + child: ColoredBox(color: Colors.red), + ), + dateAndStatus: const SizedBox( + width: 10, + height: 10, + child: ColoredBox(color: Colors.blue), + ), + ), + ), + ), + ); + + expect( + find.byType(MessageLayout), + paints + ..rect(color: Colors.red) + ..rect(color: Colors.blue), + ); + }); +} From 7ec6f2ea0c0b3d04df28d9fabc5f2516a749ccf0 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:32:49 +0900 Subject: [PATCH 14/27] Stabilize chat jump highlighting --- lib/ui/home/chat/chat_page.dart | 7 +- lib/ui/home/chat/chat_scroll_coordinator.dart | 19 +- lib/ui/home/chat/message_jump.dart | 4 +- lib/ui/home/notifier/blink_notifier.dart | 38 ++-- lib/ui/setting/appearance_page.dart | 7 +- lib/widgets/media_image_pipeline.dart | 9 +- .../message/item/transcript_message.dart | 7 +- lib/widgets/message/message.dart | 180 ++++++------------ lib/widgets/message/message_bubble.dart | 169 ++++++++++++++++ lib/widgets/message/message_context.dart | 20 ++ .../chat/chat_scroll_coordinator_test.dart | 37 ++++ .../ui/home/notifier/blink_notifier_test.dart | 53 ++++++ test/widgets/media_image_pipeline_test.dart | 18 ++ .../widgets/message/message_context_test.dart | 134 +++++++++++++ 14 files changed, 543 insertions(+), 159 deletions(-) create mode 100644 test/ui/home/notifier/blink_notifier_test.dart diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 3ce2c87601..fe29365363 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -79,12 +79,7 @@ class ChatPage extends HookConsumerWidget { final windowHeight = MediaQuery.sizeOf(context).height; final tickerProvider = useSingleTickerProvider(); - final blinkNotifier = useMemoized( - () => BlinkNotifier( - tickerProvider, - context.theme.accent.withValues(alpha: 0.5), - ), - ); + final blinkNotifier = useMemoized(() => BlinkNotifier(tickerProvider)); useEffect(() => blinkNotifier.dispose, [blinkNotifier]); final pinMessageState = usePinMessageState(conversationId); diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index c2119be89a..cb37205c71 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -78,6 +78,7 @@ class ChatScrollCoordinator { _ChatRestoreRequest? _restoreRequest; String? _animatedRestoreMessageId; + VoidCallback? _animatedRestoreComplete; List _messages = const []; Map _messagesById = const {}; Map _keysByMessageId = const {}; @@ -116,9 +117,11 @@ class ChatScrollCoordinator { void animateNextMessageRestore( String messageId, { ChatScrollRestoreDirection? direction, + VoidCallback? onComplete, }) { _animatedRestoreMessageId = messageId; _animatedRestoreDirection = direction; + _animatedRestoreComplete = onComplete; } void animateNextRestore({ChatScrollRestoreDirection? direction}) { @@ -154,11 +157,13 @@ class ChatScrollCoordinator { }) { final animatedMessageId = reset ? _animatedRestoreMessageId : null; final animatedRestoreDirection = reset ? _animatedRestoreDirection : null; + final animatedRestoreComplete = reset ? _animatedRestoreComplete : null; final animated = reset && (animatedMessageId != null || _animateNextRestore); if (reset) { _animatedRestoreMessageId = null; _animatedRestoreDirection = null; + _animatedRestoreComplete = null; _animateNextRestore = false; } _setMessages(messages, keysByMessageId); @@ -170,6 +175,7 @@ class ChatScrollCoordinator { centerMessageId: centerMessageId, animatedMessageId: animatedMessageId, animatedRestoreDirection: animatedRestoreDirection, + animatedRestoreComplete: animatedRestoreComplete, animated: animated, ); if (_restoreScheduled) return; @@ -294,6 +300,7 @@ class ChatScrollCoordinator { request.keysByMessageId, animated: request.animatedMessageId == request.centerMessageId, animatedDirection: request.animatedRestoreDirection, + onComplete: request.animatedRestoreComplete, )) { return; } @@ -320,6 +327,7 @@ class ChatScrollCoordinator { Map keysByMessageId, { bool animated = false, ChatScrollRestoreDirection? animatedDirection, + VoidCallback? onComplete, }) { final geometry = _messageTargetGeometry(messageId, keysByMessageId); if (geometry == null) { @@ -343,13 +351,14 @@ class ChatScrollCoordinator { animated: animated, stageDistant: false, animationDirection: animated ? animatedDirection : null, - ).whenComplete( - () => _traceTargetAfterLayout( + ).whenComplete(() { + _traceTargetAfterLayout( 'restore-after', messageId, keysByMessageId, - ), - ), + ); + onComplete?.call(); + }), ); return true; } @@ -705,6 +714,7 @@ class _ChatRestoreRequest { this.centerMessageId, this.animatedMessageId, this.animatedRestoreDirection, + this.animatedRestoreComplete, }); final List messages; @@ -715,4 +725,5 @@ class _ChatRestoreRequest { final String? centerMessageId; final String? animatedMessageId; final ChatScrollRestoreDirection? animatedRestoreDirection; + final VoidCallback? animatedRestoreComplete; } diff --git a/lib/ui/home/chat/message_jump.dart b/lib/ui/home/chat/message_jump.dart index 9c498f3c95..d756a094da 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -34,8 +34,6 @@ class ChatTimelineLocation { 'request source=${shortMessageId(sourceMessageId)} ' 'target=${shortMessageId(messageId)}', ); - _blinkNotifier.blinkByMessageId(messageId); - final handled = await _scrollCoordinator.scrollToMessageIfInLoadedWindow( messageId, animated: true, @@ -45,6 +43,7 @@ class ChatTimelineLocation { 'handled=$handled', ); if (handled) { + _blinkNotifier.blinkByMessageId(messageId); _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); return; } @@ -66,6 +65,7 @@ class ChatTimelineLocation { _scrollCoordinator.animateNextMessageRestore( messageId, direction: direction, + onComplete: () => _blinkNotifier.blinkByMessageId(messageId), ); _messageController.loadAroundMessage(messageId); _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); diff --git a/lib/ui/home/notifier/blink_notifier.dart b/lib/ui/home/notifier/blink_notifier.dart index 417b3ee715..69e45d2c36 100644 --- a/lib/ui/home/notifier/blink_notifier.dart +++ b/lib/ui/home/notifier/blink_notifier.dart @@ -2,27 +2,22 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; class BlinkState extends Equatable { - const BlinkState({this.color = Colors.transparent, this.messageId}); + const BlinkState({this.messageId, this.opacity = 0, this.token = 0}); - final Color color; final String? messageId; + final double opacity; + final int token; @override - List get props => [color, messageId]; - - BlinkState copyWith({Color? color, String? messageId}) => BlinkState( - color: color ?? this.color, - messageId: messageId ?? this.messageId, - ); + List get props => [messageId, opacity, token]; } class BlinkNotifier extends ValueNotifier { - BlinkNotifier(TickerProvider tickerProvider, Color blinkColor) + BlinkNotifier(TickerProvider tickerProvider) : _animationController = AnimationController( - duration: const Duration(milliseconds: 600), + duration: const Duration(milliseconds: 700), vsync: tickerProvider, ), - _colorTween = ColorTween(begin: Colors.transparent, end: blinkColor), super(const BlinkState()) { _animationController ..addListener(_onUpdate) @@ -30,24 +25,37 @@ class BlinkNotifier extends ValueNotifier { } final AnimationController _animationController; - final ColorTween _colorTween; + int _token = 0; void _onUpdate() { - value = value.copyWith(color: _colorTween.evaluate(_animationController)); + final messageId = value.messageId; + if (messageId == null) return; + value = BlinkState( + messageId: messageId, + opacity: _opacityAt(_animationController.value), + token: value.token, + ); } void _onComplete(AnimationStatus status) { if (status != AnimationStatus.completed) return; - _animationController.reverse(); + value = BlinkState(token: _token); } void blinkByMessageId(String messageId) { - value = BlinkState(messageId: messageId); + _token++; + value = BlinkState(messageId: messageId, opacity: 1, token: _token); _animationController ..reset() ..forward(); } + double _opacityAt(double value) { + const hold = 5 / 7; + if (value <= hold) return 1; + return 1 - ((value - hold) / (1 - hold)).clamp(0.0, 1.0); + } + @override void dispose() { _animationController diff --git a/lib/ui/setting/appearance_page.dart b/lib/ui/setting/appearance_page.dart index 305e89e0ee..345f5560d0 100644 --- a/lib/ui/setting/appearance_page.dart +++ b/lib/ui/setting/appearance_page.dart @@ -250,12 +250,7 @@ class _ChatTextSizePreview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tickerProvider = useSingleTickerProvider(); - final blinkNotifier = useMemoized( - () => BlinkNotifier( - tickerProvider, - context.theme.accent.withValues(alpha: 0.5), - ), - ); + final blinkNotifier = useMemoized(() => BlinkNotifier(tickerProvider)); useEffect(() => blinkNotifier.dispose, [blinkNotifier]); final chatSideNotifier = useMemoized(ChatSideNotifier.new); useEffect(() => chatSideNotifier.dispose, [chatSideNotifier]); diff --git a/lib/widgets/media_image_pipeline.dart b/lib/widgets/media_image_pipeline.dart index f0c2e7d5df..47003fbb9a 100644 --- a/lib/widgets/media_image_pipeline.dart +++ b/lib/widgets/media_image_pipeline.dart @@ -120,7 +120,14 @@ class _NormalizedGifImageGateState extends State { _future = null; return; } - final fileName = image.file.uri.pathSegments.last.toLowerCase(); + final filePathSegments = image.file.uri.pathSegments; + final fileName = filePathSegments.isEmpty + ? '' + : filePathSegments.last.toLowerCase(); + if (fileName.isEmpty) { + _future = null; + return; + } if (!fileName.endsWith('.gif') && fileName.contains('.')) { _future = null; return; diff --git a/lib/widgets/message/item/transcript_message.dart b/lib/widgets/message/item/transcript_message.dart index 173ded171c..4da36a7dc7 100644 --- a/lib/widgets/message/item/transcript_message.dart +++ b/lib/widgets/message/item/transcript_message.dart @@ -268,12 +268,7 @@ class TranscriptPage extends HookConsumerWidget { ); final tickerProvider = useSingleTickerProvider(); - final blinkNotifier = useMemoized( - () => BlinkNotifier( - tickerProvider, - context.theme.accent.withValues(alpha: 0.5), - ), - ); + final blinkNotifier = useMemoized(() => BlinkNotifier(tickerProvider)); useEffect(() => blinkNotifier.dispose, [blinkNotifier]); final scrollController = useMemoized(ScrollController.new); diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index 97ccfd0fd9..01847449ee 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -40,6 +40,10 @@ export 'message_rows.dart'; const _pinArrowWidth = 32.0; +@visibleForTesting +bool shouldMarkMentionRead(bool? mentionRead, double visibleFraction) => + mentionRead == false && visibleFraction >= 1; + void _quickReply(BuildContext context) { if (context.isPinnedPage) return; if (context.isTranscriptPage) return; @@ -133,73 +137,68 @@ class MessageItemWidget extends HookConsumerWidget { children: [ if (datetime != null) MessageDayTime(key: dateTimeKey, dateTime: datetime), - _MessageBlinkBackground( - messageId: message.messageId, - enabled: blink, - menuColor: showedMenu.value ? context.theme.listSelected : null, - child: Builder( - builder: (context) { - if (message.type == MessageCategory.systemConversation) { - return const SystemMessage(); - } - - if (message.type.isPin) { - return const PinMessageWidget(); - } - - if (message.type == MessageCategory.secret) { - return const SecretMessage(); - } - - if (message.type == MessageCategory.stranger) { - return const StrangerMessage(); - } - - return _MessageSelectionWrapper( - message: message, - child: _MessageBubbleMargin( - userName: userName, - userId: userId, - userAvatarUrl: userAvatarUrl, - showAvatar: showAvatar, - isCurrentUser: isCurrentUser, - pinArrowWidth: isPinnedPage ? _pinArrowWidth : 0, - isBot: message.isBot, - isVerified: message.isVerified, - buildMenus: (request) => buildMessageActionsMenu( - context: context, - ref: ref, - request: request, - message: message, - isTranscriptPage: isTranscriptPage, - isPinnedPage: isPinnedPage, - showedMenu: showedMenu, - focusNode: focusNode, - ), - builder: (_) => MessageContent(message: message), + Builder( + builder: (context) { + if (message.type == MessageCategory.systemConversation) { + return const SystemMessage(); + } + + if (message.type.isPin) { + return const PinMessageWidget(); + } + + if (message.type == MessageCategory.secret) { + return const SecretMessage(); + } + + if (message.type == MessageCategory.stranger) { + return const StrangerMessage(); + } + + return _MessageSelectionWrapper( + message: message, + child: _MessageBubbleMargin( + userName: userName, + userId: userId, + userAvatarUrl: userAvatarUrl, + showAvatar: showAvatar, + isCurrentUser: isCurrentUser, + pinArrowWidth: isPinnedPage ? _pinArrowWidth : 0, + isBot: message.isBot, + isVerified: message.isVerified, + buildMenus: (request) => buildMessageActionsMenu( + context: context, + ref: ref, + request: request, + message: message, + isTranscriptPage: isTranscriptPage, + isPinnedPage: isPinnedPage, + showedMenu: showedMenu, + focusNode: focusNode, ), - ); - }, - ), + builder: (_) => MessageContent(message: message), + ), + ); + }, ), if (message.messageId == lastReadMessageId && row.next != null) const _UnreadMessageBar(), ], ); - if (message.mentionRead == false) { - child = VisibilityDetector( - onVisibilityChanged: (info) { - if (info.visibleFraction < 1) return; - context.accountServer.markMentionRead( - message.messageId, - message.conversationId, - ); - }, - key: ValueKey(message.messageId), - child: child, - ); - } + child = VisibilityDetector( + onVisibilityChanged: (info) { + if (!shouldMarkMentionRead(message.mentionRead, info.visibleFraction)) { + return; + } + context.accountServer.markMentionRead( + message.messageId, + message.conversationId, + ); + }, + key: ValueKey('message_visibility_${message.messageId}'), + child: child, + ); return FocusScope( node: focusNode, @@ -209,6 +208,8 @@ class MessageItemWidget extends HookConsumerWidget { showNip: showNip, isCurrentUser: isCurrentUser, message: message, + highlightEnabled: blink, + menuHighlighted: showedMenu.value, child: Builder( builder: (context) => GestureDetector( onTap: () => _quickReply(context), @@ -225,65 +226,6 @@ class MessageItemWidget extends HookConsumerWidget { } } -class _MessageBlinkBackground extends StatefulWidget { - const _MessageBlinkBackground({ - required this.messageId, - required this.enabled, - required this.child, - this.menuColor, - }); - - final String messageId; - final bool enabled; - final Color? menuColor; - final Widget child; - - @override - State<_MessageBlinkBackground> createState() => - _MessageBlinkBackgroundState(); -} - -class _MessageBlinkBackgroundState extends State<_MessageBlinkBackground> { - BlinkNotifier? _notifier; - BlinkState _blinkState = const BlinkState(); - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final notifier = context.read(); - if (identical(_notifier, notifier)) return; - _notifier?.removeListener(_onBlinkChanged); - _notifier = notifier..addListener(_onBlinkChanged); - _blinkState = notifier.value; - } - - void _onBlinkChanged() { - final next = _notifier!.value; - final shouldRebuild = - widget.enabled && - (_blinkState.messageId == widget.messageId || - next.messageId == widget.messageId); - _blinkState = next; - if (shouldRebuild && mounted) setState(() {}); - } - - @override - void dispose() { - _notifier?.removeListener(_onBlinkChanged); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final color = - widget.menuColor ?? - (widget.enabled && _blinkState.messageId == widget.messageId - ? _blinkState.color - : Colors.transparent); - return ColoredBox(color: color, child: widget.child); - } -} - class _MessageBubbleMargin extends HookConsumerWidget { const _MessageBubbleMargin({ required this.isCurrentUser, diff --git a/lib/widgets/message/message_bubble.dart b/lib/widgets/message/message_bubble.dart index 996347b77b..bed2240a08 100644 --- a/lib/widgets/message/message_bubble.dart +++ b/lib/widgets/message/message_bubble.dart @@ -6,9 +6,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:provider/provider.dart' as provider; import '../../constants/resources.dart'; import '../../ui/home/chat/message_jump.dart'; +import '../../ui/home/notifier/blink_notifier.dart'; import '../../utils/extension/extension.dart'; import '../action_button.dart'; import '../toast.dart'; @@ -52,6 +54,8 @@ class MessageBubble extends HookConsumerWidget { final showNip = useShowNip(); final isCurrentUser = useIsCurrentUser(); final isPinnedPage = useIsPinnedPage(); + final highlightEnabled = useMessageHighlightEnabled(); + final menuHighlighted = useMessageMenuHighlighted(); final isDisappearingMessage = useMessageConverter( converter: (message) => message.expireIn != null && message.expireIn! > 0, ); @@ -67,6 +71,13 @@ class MessageBubble extends HookConsumerWidget { ); final messageType = useMessageConverter(converter: (state) => state.type); + final highlightMedia = + !showBubble && + !hasQuoteMessage && + (messageType.isImage || + messageType.isVideo || + messageType.isLive || + messageType.isSticker); var _child = child; @@ -131,6 +142,18 @@ class MessageBubble extends HookConsumerWidget { ); } + if (highlightEnabled || menuHighlighted) { + _child = _MessageBubbleHighlight( + messageId: context.message.messageId, + enabled: highlightEnabled, + clipper: clipper, + currentUser: isCurrentUser, + media: highlightMedia, + menuHighlighted: menuHighlighted, + child: _child, + ); + } + if (isPinnedPage) { final pinArrow = ActionButton( size: 16, @@ -217,6 +240,152 @@ class MessageBubble extends HookConsumerWidget { } } +class _MessageBubbleHighlight extends StatefulWidget { + const _MessageBubbleHighlight({ + required this.messageId, + required this.enabled, + required this.clipper, + required this.currentUser, + required this.media, + required this.menuHighlighted, + required this.child, + }); + + final String messageId; + final bool enabled; + final CustomClipper clipper; + final bool currentUser; + final bool media; + final bool menuHighlighted; + final Widget child; + + @override + State<_MessageBubbleHighlight> createState() => + _MessageBubbleHighlightState(); +} + +class _MessageBubbleHighlightState extends State<_MessageBubbleHighlight> { + BlinkNotifier? _notifier; + BlinkState _blinkState = const BlinkState(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final notifier = provider.Provider.of( + context, + listen: false, + ); + if (identical(_notifier, notifier)) return; + _notifier?.removeListener(_onBlinkChanged); + notifier?.addListener(_onBlinkChanged); + _notifier = notifier; + _blinkState = notifier?.value ?? const BlinkState(); + } + + void _onBlinkChanged() { + final next = _notifier!.value; + final shouldRebuild = + widget.enabled && + (_blinkState.messageId == widget.messageId || + next.messageId == widget.messageId); + _blinkState = next; + if (shouldRebuild && mounted) setState(() {}); + } + + @override + void dispose() { + _notifier?.removeListener(_onBlinkChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final opacity = widget.menuHighlighted + ? 1.0 + : widget.enabled && _blinkState.messageId == widget.messageId + ? _blinkState.opacity + : 0.0; + return CustomPaint( + foregroundPainter: opacity <= 0 + ? null + : _MessageBubbleHighlightPainter( + clipper: widget.clipper, + color: _highlightColor( + context, + currentUser: widget.currentUser, + media: widget.media, + ), + maxOpacity: _highlightOpacity( + context, + currentUser: widget.currentUser, + media: widget.media, + ), + opacity: opacity, + ), + child: widget.child, + ); + } +} + +Color _highlightColor( + BuildContext context, { + required bool currentUser, + required bool media, +}) { + if (media || currentUser) return Colors.black; + return Color.lerp(Colors.black, Colors.white, context.brightnessValue)!; +} + +double _highlightOpacity( + BuildContext context, { + required bool currentUser, + required bool media, +}) { + if (media) return 0.2; + final light = currentUser ? 0.16 : 0.13; + final dark = currentUser ? 0.18 : 0.12; + return light + (dark - light) * context.brightnessValue; +} + +@visibleForTesting +double messageHighlightOpacityForTesting( + BuildContext context, { + required bool currentUser, + required bool media, +}) => _highlightOpacity(context, currentUser: currentUser, media: media); + +class _MessageBubbleHighlightPainter extends CustomPainter with EquatableMixin { + _MessageBubbleHighlightPainter({ + required this.clipper, + required this.color, + required this.maxOpacity, + required this.opacity, + }); + + final CustomClipper clipper; + final Color color; + final double maxOpacity; + final double opacity; + + @override + void paint(Canvas canvas, Size size) { + final clip = clipper.getClip(size); + canvas.drawPath( + clip, + Paint() + ..color = color.withValues(alpha: maxOpacity * opacity) + ..style = PaintingStyle.fill, + ); + } + + @override + bool shouldRepaint(covariant _MessageBubbleHighlightPainter oldDelegate) => + this != oldDelegate; + + @override + List get props => [clipper, color, maxOpacity, opacity]; +} + class MessageBubbleNipPadding extends StatelessWidget { const MessageBubbleNipPadding({ required this.currentUser, diff --git a/lib/widgets/message/message_context.dart b/lib/widgets/message/message_context.dart index fe36012f97..8f85deac8a 100644 --- a/lib/widgets/message/message_context.dart +++ b/lib/widgets/message/message_context.dart @@ -11,6 +11,8 @@ class _MessageContext with EquatableMixin { required this.isPinnedPage, required this.showNip, required this.isCurrentUser, + required this.highlightEnabled, + required this.menuHighlighted, required this.message, }); @@ -18,6 +20,8 @@ class _MessageContext with EquatableMixin { final bool isPinnedPage; final bool showNip; final bool isCurrentUser; + final bool highlightEnabled; + final bool menuHighlighted; final MessageItem message; @override @@ -26,6 +30,8 @@ class _MessageContext with EquatableMixin { isPinnedPage, showNip, isCurrentUser, + highlightEnabled, + menuHighlighted, message, ]; } @@ -41,6 +47,12 @@ bool useShowNip() => _useMessageContextConverter((state) => state.showNip); bool useIsCurrentUser() => _useMessageContextConverter((state) => state.isCurrentUser); +bool useMessageHighlightEnabled() => + _useMessageContextConverter((state) => state.highlightEnabled); + +bool useMessageMenuHighlighted() => + _useMessageContextConverter((state) => state.menuHighlighted); + T useMessageConverter({required T Function(MessageItem) converter}) => _useMessageContextConverter((state) => converter(state.message)); @@ -68,6 +80,8 @@ class MessageContext extends StatelessWidget { required this.message, required this.child, super.key, + this.highlightEnabled = true, + this.menuHighlighted = false, }); MessageContext.fromMessageItem({ @@ -77,12 +91,16 @@ class MessageContext extends StatelessWidget { this.isTranscriptPage = false, this.isPinnedPage = false, this.showNip = false, + this.highlightEnabled = true, + this.menuHighlighted = false, }) : isCurrentUser = message.relationship == UserRelationship.me; final bool isTranscriptPage; final bool isPinnedPage; final bool showNip; final bool isCurrentUser; + final bool highlightEnabled; + final bool menuHighlighted; final MessageItem message; final Widget child; @@ -93,6 +111,8 @@ class MessageContext extends StatelessWidget { isPinnedPage: isPinnedPage, showNip: showNip, isCurrentUser: isCurrentUser, + highlightEnabled: highlightEnabled, + menuHighlighted: menuHighlighted, message: message, ), child: child, diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index bc84b92b10..7315315c33 100644 --- a/test/ui/home/chat/chat_scroll_coordinator_test.dart +++ b/test/ui/home/chat/chat_scroll_coordinator_test.dart @@ -355,6 +355,43 @@ void main() { expect(coordinator.trackingScrollController.jumpCount, 0); }); + testWidgets('scheduleRestore completes explicit message jumps after scroll', ( + tester, + ) async { + final coordinator = TrackingChatScrollCoordinator(); + final messages = List.generate(20, testMessage); + final keysByMessageId = { + for (final message in messages) message.messageId: GlobalKey(), + }; + final targetMessageId = messages[8].messageId; + var completed = false; + + addTearDown(coordinator.dispose); + await pumpScrollableMessages( + tester, + coordinator, + messages, + keysByMessageId, + ); + + coordinator + ..animateNextMessageRestore( + targetMessageId, + onComplete: () => completed = true, + ) + ..scheduleRestore( + messages: messages, + keysByMessageId: keysByMessageId, + reset: true, + isLatest: false, + centerMessageId: targetMessageId, + ); + await tester.pump(); + await tester.pump(); + + expect(completed, true); + }); + testWidgets('scheduleRestore does not stage explicit message jumps', ( tester, ) async { diff --git a/test/ui/home/notifier/blink_notifier_test.dart b/test/ui/home/notifier/blink_notifier_test.dart new file mode 100644 index 0000000000..c6ce280aea --- /dev/null +++ b/test/ui/home/notifier/blink_notifier_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_app/ui/home/notifier/blink_notifier.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('BlinkNotifier holds then fades the target highlight', ( + tester, + ) async { + await tester.pumpWidget(const SizedBox()); + + final notifier = BlinkNotifier(tester); + addTearDown(notifier.dispose); + + notifier.blinkByMessageId('message'); + await tester.pump(); + + expect(notifier.value.messageId, 'message'); + expect(notifier.value.opacity, 1); + + await tester.pump(const Duration(milliseconds: 500)); + expect(notifier.value.messageId, 'message'); + expect(notifier.value.opacity, 1); + + await tester.pump(const Duration(milliseconds: 101)); + expect(notifier.value.messageId, 'message'); + expect(notifier.value.opacity, closeTo(0.5, 0.05)); + + await tester.pump(const Duration(milliseconds: 100)); + expect(notifier.value.messageId, isNull); + expect(notifier.value.opacity, 0); + }); + + testWidgets('nullable provider lookup finds BlinkNotifier', (tester) async { + final notifier = BlinkNotifier(tester); + addTearDown(notifier.dispose); + BlinkNotifier? found; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: notifier, + child: Builder( + builder: (context) { + found = Provider.of(context, listen: false); + return const SizedBox(); + }, + ), + ), + ); + + expect(found, same(notifier)); + }); +} diff --git a/test/widgets/media_image_pipeline_test.dart b/test/widgets/media_image_pipeline_test.dart index 4aa4ac9a64..2278b87c9d 100644 --- a/test/widgets/media_image_pipeline_test.dart +++ b/test/widgets/media_image_pipeline_test.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; import 'package:flutter_app/utils/proxy.dart'; import 'package:flutter_app/widgets/media_image_pipeline.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -34,4 +37,19 @@ void main() { ); }); + testWidgets('skips file images with empty paths', (tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: NormalizedGifImageGate( + image: FileImage(File.fromUri(Uri())), + placeholder: () => const SizedBox.shrink(), + childBuilder: () => const SizedBox(key: Key('child')), + ), + ), + ); + + expect(find.byKey(const Key('child')), findsOneWidget); + expect(tester.takeException(), isNull); + }); } diff --git a/test/widgets/message/message_context_test.dart b/test/widgets/message/message_context_test.dart index 83350a0660..7bb98c50b8 100644 --- a/test/widgets/message/message_context_test.dart +++ b/test/widgets/message/message_context_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_app/constants/brightness_theme_data.dart'; import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/ui/home/notifier/blink_notifier.dart'; import 'package:flutter_app/ui/provider/database_provider.dart'; import 'package:flutter_app/ui/provider/mention_cache_provider.dart'; import 'package:flutter_app/ui/provider/setting_provider.dart'; @@ -13,6 +14,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; +import 'package:provider/provider.dart' as provider; void main() { test('MessageRows links top tail to first bottom row without center', () { @@ -126,6 +128,80 @@ void main() { expect(quoteWidth, greaterThanOrEqualTo(bodyWidth)); }); + + testWidgets('current user blink is visible enough', (tester) async { + final notifier = BlinkNotifier(tester)..blinkByMessageId('1'); + + await tester.pumpWidget( + _MessageTestScope( + blinkNotifier: notifier, + child: MessageContext.fromMessageItem( + message: testMessage( + '1', + userId: 'me', + relationship: UserRelationship.me, + ), + child: const MessageBubble(child: SizedBox(width: 80, height: 20)), + ), + ), + ); + await tester.pump(); + + final highlightPainters = tester + .widgetList(find.byType(CustomPaint)) + .where( + (paint) => + paint.foregroundPainter.runtimeType.toString() == + '_MessageBubbleHighlightPainter', + ) + .toList(); + final opacity = messageHighlightOpacityForTesting( + tester.element(find.byType(MessageBubble)), + currentUser: true, + media: false, + ); + + expect(highlightPainters, isNotEmpty); + expect(opacity, greaterThanOrEqualTo(0.16)); + + await tester.pumpWidget(const SizedBox.shrink()); + notifier.dispose(); + }); + + testWidgets('blink keeps bubble content mounted', (tester) async { + final notifier = BlinkNotifier(tester); + final mountCount = ValueNotifier(0); + addTearDown(mountCount.dispose); + + await tester.pumpWidget( + _MessageTestScope( + blinkNotifier: notifier, + child: MessageContext.fromMessageItem( + message: testMessage('1'), + child: MessageBubble(child: _MountCounter(count: mountCount)), + ), + ), + ); + await tester.pump(); + + expect(mountCount.value, 1); + + notifier.blinkByMessageId('1'); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + + expect(mountCount.value, 1); + + await tester.pumpWidget(const SizedBox.shrink()); + notifier.dispose(); + }); + + test('mention read marker only fires for fully visible unread mentions', () { + expect(shouldMarkMentionRead(false, 1), isTrue); + expect(shouldMarkMentionRead(false, 0.99), isFalse); + expect(shouldMarkMentionRead(true, 1), isFalse); + expect(shouldMarkMentionRead(null, 1), isFalse); + }); } class _MessageIdText extends HookWidget { @@ -137,11 +213,33 @@ class _MessageIdText extends HookWidget { ); } +class _MountCounter extends StatefulWidget { + const _MountCounter({required this.count}); + + final ValueNotifier count; + + @override + State<_MountCounter> createState() => _MountCounterState(); +} + +class _MountCounterState extends State<_MountCounter> { + @override + void initState() { + super.initState(); + widget.count.value++; + } + + @override + Widget build(BuildContext context) => const SizedBox(width: 80, height: 20); +} + MessageItem testMessage( String id, { String userId = 'user', + UserRelationship relationship = UserRelationship.friend, String? quoteId, String? quoteContent, + bool? mentionRead, }) => MessageItem( messageId: id, conversationId: 'conversation', @@ -150,11 +248,13 @@ MessageItem testMessage( createdAt: DateTime(2026), status: MessageStatus.read, userId: userId, + relationship: relationship, userIdentityNumber: '0', isVerified: false, sharedUserIsVerified: false, quoteId: quoteId, quoteContent: quoteContent, + mentionRead: mentionRead, pinned: false, ); @@ -163,3 +263,37 @@ const _quoteContent = '"user_id":"00000000-0000-4000-8000-000000000001",' '"user_full_name":"KC","user_identity_number":"1","type":"PLAIN_TEXT",' '"content":"q","createdAt":1767225600000,"status":"READ"}'; + +class _MessageTestScope extends StatelessWidget { + const _MessageTestScope({required this.child, this.blinkNotifier}); + + final Widget child; + final BlinkNotifier? blinkNotifier; + + @override + Widget build(BuildContext context) { + final blinkNotifier = this.blinkNotifier; + return ProviderScope( + overrides: [ + databaseProvider.overrideWith((ref) => DatabaseOpener()), + mentionCacheProvider.overrideWithValue(MentionCache(null)), + settingProvider.overrideWith((ref) => SettingChangeNotifier()), + ], + child: MaterialApp( + home: BrightnessData( + value: 0, + brightnessThemeData: lightBrightnessThemeData, + child: Align( + alignment: Alignment.topLeft, + child: blinkNotifier == null + ? child + : provider.ChangeNotifierProvider.value( + value: blinkNotifier, + child: child, + ), + ), + ), + ), + ); + } +} From 422df2d6854f90f3d43b33bdc60240b272d369e5 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:37:17 +0900 Subject: [PATCH 15/27] Format image tests --- test/utils/image_test.dart | 40 ++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/test/utils/image_test.dart b/test/utils/image_test.dart index 17d6b2d410..36205363ae 100644 --- a/test/utils/image_test.dart +++ b/test/utils/image_test.dart @@ -104,26 +104,28 @@ void main() { } }); - test('normalizeGifFileIfNeeded detects GIF bytes when mime type is missing', ( - ) async { - final file = File( - '${Directory.systemTemp.path}/mixin-gif-no-ext-${DateTime.now().microsecondsSinceEpoch}', - ); - await file.writeAsBytes(_gifWithFrameDurations(10, 50)); - - try { - await normalizeGifFileIfNeeded(file, null); - - final decoded = img.decodeGif(await file.readAsBytes()); - - expect(decoded, isNotNull); - expect(decoded!.frames.map((frame) => frame.frameDuration), [100, 50]); - } finally { - if (file.existsSync()) { - file.deleteSync(); + test( + 'normalizeGifFileIfNeeded detects GIF bytes when mime type is missing', + () async { + final file = File( + '${Directory.systemTemp.path}/mixin-gif-no-ext-${DateTime.now().microsecondsSinceEpoch}', + ); + await file.writeAsBytes(_gifWithFrameDurations(10, 50)); + + try { + await normalizeGifFileIfNeeded(file, null); + + final decoded = img.decodeGif(await file.readAsBytes()); + + expect(decoded, isNotNull); + expect(decoded!.frames.map((frame) => frame.frameDuration), [100, 50]); + } finally { + if (file.existsSync()) { + file.deleteSync(); + } } - } - }); + }, + ); test('compressImage preserves transparency for alpha images', () { final source = img.Image(width: 2, height: 1, numChannels: 4) From 6f2d3f8b9209edcd35bce7c35036bb50d994737b Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:57:15 +0900 Subject: [PATCH 16/27] Improve chat scroll anchoring and settings navigation --- lib/db/dao/message_dao.dart | 96 +++++--- lib/ui/home/chat/chat_history_viewport.dart | 53 +++- lib/ui/home/chat/chat_scroll_coordinator.dart | 87 ++++++- .../home/conversation/conversation_focus.dart | 12 + lib/ui/home/notifier/message_controller.dart | 48 +++- .../home/notifier/message_window_loader.dart | 70 ++++-- lib/ui/home/route/major_navigator.dart | 10 +- .../provider/major_navigation_provider.dart | 4 + lib/ui/setting/setting_page.dart | 227 +++++++++--------- lib/widgets/message/message.dart | 12 +- .../chat/chat_scroll_coordinator_test.dart | 120 +++++++++ .../notifier/message_window_loader_test.dart | 46 ++++ .../major_navigation_provider_test.dart | 8 + 13 files changed, 597 insertions(+), 196 deletions(-) diff --git a/lib/db/dao/message_dao.dart b/lib/db/dao/message_dao.dart index 8af7d467aa..b17f9c8bae 100644 --- a/lib/db/dao/message_dao.dart +++ b/lib/db/dao/message_dao.dart @@ -1127,25 +1127,41 @@ class MessageDao extends DatabaseAccessor (_, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => Limit(limit, 0), ); - Selectable beforeMessageIdsByConversationId( + Future> beforeMessageIdsByConversationId( MessageOrderInfo anchor, String conversationId, int limit, - ) => - (selectOnly(db.messages) - ..addColumns([db.messages.messageId]) - ..where( - db.messages.conversationId.equals(conversationId) & - (db.messages.createdAt.isSmallerThanValue(anchor.createdAt) | - (db.messages.createdAt.equals(anchor.createdAt) & - db.messages.rowId.isSmallerThanValue(anchor.rowId))), - ) - ..orderBy([ - OrderingTerm.desc(db.messages.createdAt), - OrderingTerm.desc(db.messages.rowId), - ]) - ..limit(limit)) - .map((row) => row.read(db.messages.messageId)!); + ) async { + final sameTime = + await (selectOnly(db.messages) + ..addColumns([db.messages.messageId]) + ..where( + db.messages.conversationId.equals(conversationId) & + db.messages.createdAt.equals(anchor.createdAt) & + db.messages.rowId.isSmallerThanValue(anchor.rowId), + ) + ..orderBy([OrderingTerm.desc(db.messages.rowId)]) + ..limit(limit)) + .map((row) => row.read(db.messages.messageId)!) + .get(); + if (sameTime.length >= limit) return sameTime; + + final older = + await (selectOnly(db.messages) + ..addColumns([db.messages.messageId]) + ..where( + db.messages.conversationId.equals(conversationId) & + db.messages.createdAt.isSmallerThanValue(anchor.createdAt), + ) + ..orderBy([ + OrderingTerm.desc(db.messages.createdAt), + OrderingTerm.desc(db.messages.rowId), + ]) + ..limit(limit - sameTime.length)) + .map((row) => row.read(db.messages.messageId)!) + .get(); + return [...sameTime, ...older]; + } Selectable afterMessagesByConversationId( MessageOrderInfo anchor, @@ -1164,25 +1180,41 @@ class MessageDao extends DatabaseAccessor ]), ); - Selectable afterMessageIdsByConversationId( + Future> afterMessageIdsByConversationId( MessageOrderInfo anchor, String conversationId, int limit, - ) => - (selectOnly(db.messages) - ..addColumns([db.messages.messageId]) - ..where( - db.messages.conversationId.equals(conversationId) & - (db.messages.createdAt.isBiggerThanValue(anchor.createdAt) | - (db.messages.createdAt.equals(anchor.createdAt) & - db.messages.rowId.isBiggerThanValue(anchor.rowId))), - ) - ..orderBy([ - OrderingTerm.asc(db.messages.createdAt), - OrderingTerm.asc(db.messages.rowId), - ]) - ..limit(limit)) - .map((row) => row.read(db.messages.messageId)!); + ) async { + final sameTime = + await (selectOnly(db.messages) + ..addColumns([db.messages.messageId]) + ..where( + db.messages.conversationId.equals(conversationId) & + db.messages.createdAt.equals(anchor.createdAt) & + db.messages.rowId.isBiggerThanValue(anchor.rowId), + ) + ..orderBy([OrderingTerm.asc(db.messages.rowId)]) + ..limit(limit)) + .map((row) => row.read(db.messages.messageId)!) + .get(); + if (sameTime.length >= limit) return sameTime; + + final newer = + await (selectOnly(db.messages) + ..addColumns([db.messages.messageId]) + ..where( + db.messages.conversationId.equals(conversationId) & + db.messages.createdAt.isBiggerThanValue(anchor.createdAt), + ) + ..orderBy([ + OrderingTerm.asc(db.messages.createdAt), + OrderingTerm.asc(db.messages.rowId), + ]) + ..limit(limit - sameTime.length)) + .map((row) => row.read(db.messages.messageId)!) + .get(); + return [...sameTime, ...newer]; + } Selectable messageItemByMessageId(String messageId) => _baseMessageItems( diff --git a/lib/ui/home/chat/chat_history_viewport.dart b/lib/ui/home/chat/chat_history_viewport.dart index 6a7e35f3bd..9fdfa260e8 100644 --- a/lib/ui/home/chat/chat_history_viewport.dart +++ b/lib/ui/home/chat/chat_history_viewport.dart @@ -12,6 +12,7 @@ import '../../../widgets/message/message_day_time.dart'; import '../../provider/is_bot_group_provider.dart'; import '../../provider/setting_provider.dart'; import '../notifier/message_controller.dart'; +import 'chat_jump_trace.dart'; import 'chat_scroll_coordinator.dart'; @visibleForTesting @@ -50,6 +51,10 @@ class ChatHistoryViewport extends HookConsumerWidget { final center = state.center; final bottom = state.bottom; final messages = state.list; + final anchorUnreadSeparator = + center != null && + center.messageId == state.lastReadMessageId && + bottom.isNotEmpty; final conversationId = state.conversationId; final isBotGroupConversation = conversationId != null && ref.watch(isBotGroupProvider(conversationId)); @@ -57,8 +62,19 @@ class ChatHistoryViewport extends HookConsumerWidget { settingProvider.select((value) => value.messageShowAvatar), ); final rows = useMemoized( - () => MessageRows.from(top: top, center: center, bottom: bottom), - [top, center, bottom], + () { + final centerMessage = center; + final renderTop = anchorUnreadSeparator && centerMessage != null + ? [...top, centerMessage] + : top; + final renderCenter = anchorUnreadSeparator ? null : centerMessage; + return MessageRows.from( + top: renderTop, + center: renderCenter, + bottom: bottom, + ); + }, + [top, center, bottom, anchorUnreadSeparator], ); final messageKeysRef = useRef>({}); @@ -101,6 +117,9 @@ class ChatHistoryViewport extends HookConsumerWidget { Widget buildMessage(MessageRowModel row) { final message = row.message; + final showUnreadBar = + !anchorUnreadSeparator || + message.messageId != state.lastReadMessageId; return ChatRenderedMessage( coordinator: scrollCoordinator, messageId: message.messageId, @@ -109,6 +128,7 @@ class ChatHistoryViewport extends HookConsumerWidget { row: row, message: message, lastReadMessageId: state.lastReadMessageId, + showUnreadBar: showUnreadBar, dateTimeKey: row.dateTime == null ? null : dayTimeKeysRef.value[message.messageId], @@ -123,12 +143,28 @@ class ChatHistoryViewport extends HookConsumerWidget { useEffect( () { + final restoreAnchor = anchorUnreadSeparator + ? ChatScrollCoordinator.unreadSeparatorAnchor + : ChatScrollCoordinator.messageFocusAnchor; + traceChatJump( + 'viewport restore-input ' + 'conv=${shortMessageId(state.conversationId)} ' + 'reset=$resetScrollWindow ' + 'unreadAnchor=$anchorUnreadSeparator ' + 'anchor=${formatDouble(restoreAnchor)} ' + 'lastRead=${shortMessageId(state.lastReadMessageId)} ' + 'center=${shortMessageId(center?.messageId)} ' + 'top=${top.length} bottom=${bottom.length} ' + 'messages=${messages.length} latest=${state.isLatest}', + ); scrollCoordinator.scheduleRestore( messages: messages, keysByMessageId: messageKeysRef.value, reset: resetScrollWindow, isLatest: state.isLatest, - centerMessageId: center?.messageId, + hasCenteredAnchor: anchorUnreadSeparator, + centerMessageId: anchorUnreadSeparator ? null : center?.messageId, + traceTargetMessageId: center?.messageId ?? state.lastReadMessageId, ); previousConversationIdRef.value = state.conversationId; previousRefreshKeyRef.value = state.refreshKey; @@ -159,7 +195,9 @@ class ChatHistoryViewport extends HookConsumerWidget { key: viewportKey, center: key, controller: scrollCoordinator.scrollController, - anchor: 0.3, + anchor: anchorUnreadSeparator + ? ChatScrollCoordinator.unreadSeparatorAnchor + : ChatScrollCoordinator.messageFocusAnchor, physics: const ClampingScrollPhysics(), scrollCacheExtent: const ScrollCacheExtent.viewport( ChatScrollCoordinator.loadedJumpViewportCount, @@ -167,15 +205,18 @@ class ChatHistoryViewport extends HookConsumerWidget { slivers: [ SliverList( delegate: SliverChildBuilderDelegate((context, index) { - final actualIndex = top.length - index - 1; + final actualIndex = rows.top.length - index - 1; final row = rows.top[actualIndex]; return buildMessage(row); - }, childCount: top.length), + }, childCount: rows.top.length), ), SliverToBoxAdapter( key: key, child: Builder( builder: (context) { + if (anchorUnreadSeparator) { + return const UnreadMessageBar(); + } final row = rows.center; if (row == null) return const SizedBox(); return buildMessage(row); diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index cb37205c71..b1a917e92f 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import '../../../db/mixin_database.dart' hide Offset; import 'chat_jump_trace.dart'; @@ -70,6 +71,8 @@ class ChatScrollCoordinator { static const _jumpAnimationCurve = Curves.easeOutCubic; static const _maxAnimatedJumpViewportCount = 3.0; static const _maxAnimatedJumpDistance = 800.0; + static const messageFocusAnchor = 0.3; + static const unreadSeparatorAnchor = 0.36; static const loadedJumpViewportCount = 2.0; final scrollController = ScrollController(); @@ -90,6 +93,7 @@ class ChatScrollCoordinator { bool _restoreScheduled = false; bool _viewportStateUpdateScheduled = false; bool _viewportStateUpdateIncludesVisibleDate = false; + bool _blockPreloadUntilUserScroll = false; bool _disposed = false; int _programmaticScrollDepth = 0; @@ -153,7 +157,9 @@ class ChatScrollCoordinator { required Map keysByMessageId, required bool reset, required bool isLatest, + bool hasCenteredAnchor = false, String? centerMessageId, + String? traceTargetMessageId, }) { final animatedMessageId = reset ? _animatedRestoreMessageId : null; final animatedRestoreDirection = reset ? _animatedRestoreDirection : null; @@ -165,6 +171,10 @@ class ChatScrollCoordinator { _animatedRestoreDirection = null; _animatedRestoreComplete = null; _animateNextRestore = false; + _blockPreloadUntilUserScroll = true; + if (!animated && (hasCenteredAnchor || centerMessageId != null)) { + _resetCenteredOffsetBeforePaint(); + } } _setMessages(messages, keysByMessageId); _restoreRequest = _ChatRestoreRequest( @@ -177,6 +187,7 @@ class ChatScrollCoordinator { animatedRestoreDirection: animatedRestoreDirection, animatedRestoreComplete: animatedRestoreComplete, animated: animated, + traceTargetMessageId: traceTargetMessageId, ); if (_restoreScheduled) return; _restoreScheduled = true; @@ -191,6 +202,16 @@ class ChatScrollCoordinator { }); } + void _resetCenteredOffsetBeforePaint() { + if (!scrollController.hasClients) return; + if (scrollController.offset.abs() <= _jumpToTolerance) return; + traceChatJump( + 'restore prepaint-zero ' + '${formatScrollMetrics(scrollController.position)}', + ); + scrollController.position.correctPixels(0); + } + bool handleScrollNotification( ScrollNotification notification, { required List messages, @@ -202,6 +223,7 @@ class ChatScrollCoordinator { _scheduleViewportStateUpdate( updateVisibleDate: notification is ScrollEndNotification, ); + _unlockPreloadOnUserScrollIntent(notification); if (notification is! ScrollUpdateNotification) return false; if (_programmaticScrollDepth > 0) { traceChatJump( @@ -213,24 +235,65 @@ class ChatScrollCoordinator { } final scrollDelta = notification.scrollDelta; if (scrollDelta == null || scrollDelta == 0) return false; + if (_blockPreloadUntilUserScroll) { + if (notification.dragDetails == null) { + traceChatJump( + 'preload blocked delta=${formatDouble(scrollDelta)} ' + '${formatScrollMetrics(notification.metrics)}', + ); + return false; + } + traceChatJump( + 'preload unblocked by drag update ' + '${formatScrollMetrics(notification.metrics)}', + ); + _blockPreloadUntilUserScroll = false; + } final threshold = notification.metrics.viewportDimension * _preloadViewportCount; if (scrollDelta > 0) { if (notification.metrics.maxScrollExtent - notification.metrics.pixels <= threshold) { + traceChatJump( + 'preload after delta=${formatDouble(scrollDelta)} ' + 'threshold=${formatDouble(threshold)} ' + '${formatScrollMetrics(notification.metrics)}', + ); loadAfter(); } } else { if ((notification.metrics.minScrollExtent - notification.metrics.pixels) .abs() <= threshold) { + traceChatJump( + 'preload before delta=${formatDouble(scrollDelta)} ' + 'threshold=${formatDouble(threshold)} ' + '${formatScrollMetrics(notification.metrics)}', + ); loadBefore(); } } return false; } + void _unlockPreloadOnUserScrollIntent(ScrollNotification notification) { + if (!_blockPreloadUntilUserScroll || _programmaticScrollDepth > 0) return; + if (notification is UserScrollNotification && + notification.direction != ScrollDirection.idle) { + traceChatJump( + 'preload unblocked by user direction=${notification.direction}', + ); + _blockPreloadUntilUserScroll = false; + return; + } + if (notification is ScrollStartNotification && + notification.dragDetails != null) { + traceChatJump('preload unblocked by drag start'); + _blockPreloadUntilUserScroll = false; + } + } + Future scrollToBottom({bool animated = false}) { if (!scrollController.hasClients) return Future.value(); final future = _jumpToBottom(animated: animated); @@ -288,6 +351,7 @@ class ChatScrollCoordinator { traceChatJump( 'restore reset=${request.reset} latest=${request.isLatest} ' 'center=${shortMessageId(request.centerMessageId)} ' + 'traceTarget=${shortMessageId(request.traceTargetMessageId)} ' 'animated=${request.animated} ' 'animatedMessage=${shortMessageId(request.animatedMessageId)} ' 'messages=${request.messages.length} ' @@ -312,6 +376,14 @@ class ChatScrollCoordinator { ), ); } + final traceTargetMessageId = request.traceTargetMessageId; + if (traceTargetMessageId != null) { + _traceTargetAfterLayout( + 'restore-anchor-after', + traceTargetMessageId, + request.keysByMessageId, + ); + } return; } @@ -377,7 +449,7 @@ class ChatScrollCoordinator { final target = scrollController.offset + top - - scrollController.position.viewportDimension * 0.3; + scrollController.position.viewportDimension * messageFocusAnchor; return _MessageTargetGeometry( target: target, top: top, @@ -663,10 +735,21 @@ class ChatScrollCoordinator { void trace(String step) { if (_disposed || !scrollController.hasClients) return; final geometry = _messageTargetGeometry(messageId, keysByMessageId); + final viewportDimension = scrollController.position.viewportDimension; + final focusY = viewportDimension * messageFocusAnchor; + final unreadY = viewportDimension * unreadSeparatorAnchor; traceChatJump( '$phase $step target=${shortMessageId(messageId)} ' 'rendered=${geometry != null} ' '${geometry?.format() ?? ''} ' + 'focusY=${formatDouble(focusY)} ' + 'topDelta=${formatDouble( + geometry == null ? null : geometry.top - focusY, + )} ' + 'unreadY=${formatDouble(unreadY)} ' + 'bottomDelta=${formatDouble( + geometry == null ? null : geometry.bottom - unreadY, + )} ' '${formatScrollMetrics(scrollController.position)}', ); } @@ -715,6 +798,7 @@ class _ChatRestoreRequest { this.animatedMessageId, this.animatedRestoreDirection, this.animatedRestoreComplete, + this.traceTargetMessageId, }); final List messages; @@ -726,4 +810,5 @@ class _ChatRestoreRequest { final String? animatedMessageId; final ChatScrollRestoreDirection? animatedRestoreDirection; final VoidCallback? animatedRestoreComplete; + final String? traceTargetMessageId; } diff --git a/lib/ui/home/conversation/conversation_focus.dart b/lib/ui/home/conversation/conversation_focus.dart index b0830ae818..03270388aa 100644 --- a/lib/ui/home/conversation/conversation_focus.dart +++ b/lib/ui/home/conversation/conversation_focus.dart @@ -12,6 +12,7 @@ import '../../../widgets/toast.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/is_bot_group_provider.dart'; import '../../provider/recent_conversation_provider.dart'; +import '../chat/chat_jump_trace.dart'; import '../conversation_info_destination.dart'; import '../notifier/conversation_list_controller.dart'; @@ -88,6 +89,17 @@ class ConversationFocus { lastReadMessageId ?? (selectedHasUnreadMessage ? selectedInitIndexMessageId : null); + traceChatJump( + 'focus conversation ' + 'conv=${shortMessageId(conversationId)} ' + 'explicitInit=${shortMessageId(initIndexMessageId)} ' + 'selectedInit=${shortMessageId(selectedInitIndexMessageId)} ' + 'lastRead=${shortMessageId(lastReadMessageId)} ' + 'convLastRead=${shortMessageId(selectedConversation.lastReadMessageId)} ' + 'unseen=${selectedConversation.unseenMessageCount} ' + 'same=${state?.conversationId == conversationId}', + ); + final ownerId = selectedConversation.ownerId; final appFuture = diff --git a/lib/ui/home/notifier/message_controller.dart b/lib/ui/home/notifier/message_controller.dart index 3313f8189d..3a5d6c7fd9 100644 --- a/lib/ui/home/notifier/message_controller.dart +++ b/lib/ui/home/notifier/message_controller.dart @@ -273,13 +273,18 @@ class MessageController extends ValueNotifier { final finalLimit = limit; final conversationId = conversationNotifier.state?.conversationId; if (conversationId == null) return; + final conversation = conversationNotifier.state?.conversation; - if (centerMessageId != null || forceLatest) { - traceChatJump( - 'message init center=${shortMessageId(centerMessageId)} ' - 'forceLatest=$forceLatest limit=$finalLimit', - ); - } + traceChatJump( + 'message init ' + 'conv=${shortMessageId(conversationId)} ' + 'requestedCenter=${shortMessageId(centerMessageId)} ' + 'inputLastRead=${shortMessageId(lastReadMessageId)} ' + 'stateLastRead=${shortMessageId(state.lastReadMessageId)} ' + 'convLastRead=${shortMessageId(conversation?.lastReadMessageId)} ' + 'unseen=${conversation?.unseenMessageCount} ' + 'forceLatest=$forceLatest limit=$finalLimit', + ); final messageState = await _resetMessageList( conversationId, @@ -295,6 +300,12 @@ class MessageController extends ValueNotifier { lastReadMessageId: lastReadMessageId, ), ); + traceChatJump( + 'message init loaded ' + 'conv=${shortMessageId(conversationId)} ' + 'lastRead=${shortMessageId(nextState.lastReadMessageId)} ' + '${_formatWindow(nextState)}', + ); _emit(nextState); } @@ -366,13 +377,14 @@ class MessageController extends ValueNotifier { ? conversation?.lastReadMessageId : null); - if (centerMessageId != null || forceLatest) { - traceChatJump( - 'reset list requested=${shortMessageId(centerMessageId)} ' - 'resolved=${shortMessageId(_centerMessageId)} ' - 'forceLatest=$forceLatest unseen=${conversation?.unseenMessageCount}', - ); - } + traceChatJump( + 'reset list ' + 'conv=${shortMessageId(conversationId)} ' + 'requested=${shortMessageId(centerMessageId)} ' + 'resolved=${shortMessageId(_centerMessageId)} ' + 'convLastRead=${shortMessageId(conversation?.lastReadMessageId)} ' + 'forceLatest=$forceLatest unseen=${conversation?.unseenMessageCount}', + ); final state = await _messagesByConversationId( conversationId, @@ -547,4 +559,14 @@ class MessageController extends ValueNotifier { } return _messageState; } + + String _formatWindow(MessageState state) => + 'top=${state.top.length} ' + 'topFirst=${shortMessageId(state.top.firstOrNull?.messageId)} ' + 'topLast=${shortMessageId(state.top.lastOrNull?.messageId)} ' + 'center=${shortMessageId(state.center?.messageId)} ' + 'bottom=${state.bottom.length} ' + 'bottomFirst=${shortMessageId(state.bottom.firstOrNull?.messageId)} ' + 'bottomLast=${shortMessageId(state.bottom.lastOrNull?.messageId)} ' + 'latest=${state.isLatest} oldest=${state.isOldest}'; } diff --git a/lib/ui/home/notifier/message_window_loader.dart b/lib/ui/home/notifier/message_window_loader.dart index 04baa6b09e..d406abc774 100644 --- a/lib/ui/home/notifier/message_window_loader.dart +++ b/lib/ui/home/notifier/message_window_loader.dart @@ -34,26 +34,43 @@ class MessageWindowLoader { required this.messagesByIds, }); - factory MessageWindowLoader.fromDao(MessageDao messageDao) => - MessageWindowLoader( - recentMessages: (conversationId, limit) => - messageDao.messagesByConversationId(conversationId, limit).get(), - messageOrderInfo: messageDao.messageOrderInfo, - beforeMessages: (anchor, conversationId, limit) => messageDao - .beforeMessagesByConversationId(anchor, conversationId, limit) - .get(), - afterMessages: (anchor, conversationId, limit) => messageDao - .afterMessagesByConversationId(anchor, conversationId, limit) - .get(), - beforeMessageIds: (anchor, conversationId, limit) => messageDao - .beforeMessageIdsByConversationId(anchor, conversationId, limit) - .get(), - afterMessageIds: (anchor, conversationId, limit) => messageDao - .afterMessageIdsByConversationId(anchor, conversationId, limit) - .get(), - messagesByIds: (messageIds) => - messageDao.messageItemByMessageIds(messageIds).get(), - ); + factory MessageWindowLoader.fromDao(MessageDao messageDao) { + Future> messagesByIds(List ids) async { + if (ids.isEmpty) return const []; + final messages = await messageDao.messageItemByMessageIds(ids).get(); + final messagesById = { + for (final message in messages) message.messageId: message, + }; + return ids.map((id) => messagesById[id]).nonNulls.toList(); + } + + return MessageWindowLoader( + recentMessages: (conversationId, limit) => + messageDao.messagesByConversationId(conversationId, limit).get(), + messageOrderInfo: messageDao.messageOrderInfo, + beforeMessages: (anchor, conversationId, limit) async { + final ids = await messageDao.beforeMessageIdsByConversationId( + anchor, + conversationId, + limit, + ); + return messagesByIds(ids); + }, + afterMessages: (anchor, conversationId, limit) async { + final ids = await messageDao.afterMessageIdsByConversationId( + anchor, + conversationId, + limit, + ); + return messagesByIds(ids); + }, + beforeMessageIds: (anchor, conversationId, limit) => messageDao + .beforeMessageIdsByConversationId(anchor, conversationId, limit), + afterMessageIds: (anchor, conversationId, limit) => messageDao + .afterMessageIdsByConversationId(anchor, conversationId, limit), + messagesByIds: messagesByIds, + ); + } final RecentMessagesQuery recentMessages; final MessageOrderInfoQuery messageOrderInfo; @@ -151,11 +168,24 @@ class MessageWindowLoader { } final halfLimit = limit ~/ 2; + trace?.call( + 'query center order target=${shortMessageId(centerMessageId)} ' + 'createdAt=${info.createdAt} rowId=${info.rowId} half=$halfLimit', + ); final bottomIdsFuture = afterMessageIds(info, conversationId, halfLimit); final topIdsFuture = beforeMessageIds(info, conversationId, halfLimit); final bottomIds = await bottomIdsFuture; final topIds = await topIdsFuture; + trace?.call( + 'query center ids target=${shortMessageId(centerMessageId)} ' + 'top=${topIds.length} ' + 'topFirst=${shortMessageId(topIds.firstOrNull)} ' + 'topLast=${shortMessageId(topIds.lastOrNull)} ' + 'bottom=${bottomIds.length} ' + 'bottomFirst=${shortMessageId(bottomIds.firstOrNull)} ' + 'bottomLast=${shortMessageId(bottomIds.lastOrNull)}', + ); final messageIds = [...topIds.reversed, centerMessageId, ...bottomIds]; final messages = await messagesByIds(messageIds); final messagesById = { diff --git a/lib/ui/home/route/major_navigator.dart b/lib/ui/home/route/major_navigator.dart index 503643f339..6b2569a15c 100644 --- a/lib/ui/home/route/major_navigator.dart +++ b/lib/ui/home/route/major_navigator.dart @@ -51,7 +51,15 @@ class MajorNavigator extends HookConsumerWidget { rightEmptyPage.name, }, ), - onDidRemovePage: (page) {}, + onDidRemovePage: (page) { + final key = page.key; + if (key is! ValueKey) { + return; + } + ref + .read(majorNavigationProvider.notifier) + .remove(key.value); + }, pages: [ if (routeMode) leftPage, if (!routeMode && state.entries.isEmpty) rightEmptyPage, diff --git a/lib/ui/provider/major_navigation_provider.dart b/lib/ui/provider/major_navigation_provider.dart index 175fc00a09..a053f216fe 100644 --- a/lib/ui/provider/major_navigation_provider.dart +++ b/lib/ui/provider/major_navigation_provider.dart @@ -93,6 +93,10 @@ class MajorNavigationNotifier open(destination); } + void remove(MajorNavigationDestination destination) { + _removeWhere((entry) => entry.destination == destination); + } + void open(MajorNavigationDestination destination, {Object? arguments}) { final entry = MajorNavigationEntry(destination, arguments: arguments); final entries = state.entries.toList(); diff --git a/lib/ui/setting/setting_page.dart b/lib/ui/setting/setting_page.dart index 4e6b0eb091..65d0aef93a 100644 --- a/lib/ui/setting/setting_page.dart +++ b/lib/ui/setting/setting_page.dart @@ -12,15 +12,12 @@ import '../../utils/app_lifecycle.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../../utils/local_notification_center.dart'; -import '../../widgets/action_button.dart'; -import '../../widgets/app_bar.dart'; import '../../widgets/avatar_view/avatar_view.dart'; import '../../widgets/cell.dart'; import '../../widgets/conversation/badges_widget.dart'; import '../../widgets/high_light_text.dart'; import '../../widgets/toast.dart'; import '../home/desktop_shell_layout.dart'; -import '../home/left_rail_controller.dart'; import '../provider/major_navigation_provider.dart'; import '../provider/multi_auth_provider.dart'; @@ -29,16 +26,6 @@ class SettingPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final hasDrawer = context.watch(); - - Widget? leading; - if (hasDrawer.value) { - leading = ActionButton( - onTapUp: (event) => Scaffold.of(context).openDrawer(), - child: Icon(Icons.menu, size: 20, color: context.theme.icon), - ); - } - final appActive = useValueListenable(appActiveListener); final hasNotificationPermission = useMemoizedFuture( requestNotificationPermission, @@ -51,122 +38,124 @@ class SettingPage extends HookConsumerWidget { authProvider.select((value) => value?.account.hasPin == true), ); - return Column( - children: [ - MixinAppBar( - leading: AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - child: leading ?? const SizedBox(), - ), - ), - Expanded( - child: SingleChildScrollView( - controller: controller, - child: Column( - children: [ - const _UserProfile(), - const SizedBox(height: 24), - Column( - children: [ - CellGroup( - child: _Item( - leadingAssetName: Resources.assetsImagesIcProfileSvg, - destination: MajorNavigationDestination.editProfilePage, - title: context.l10n.editProfile, + return ColoredBox( + color: context.theme.background, + child: Column( + children: [ + const SizedBox(height: 64), + Expanded( + child: SingleChildScrollView( + controller: controller, + child: Column( + children: [ + const _UserProfile(), + const SizedBox(height: 24), + Column( + children: [ + CellGroup( + child: _Item( + leadingAssetName: Resources.assetsImagesIcProfileSvg, + destination: + MajorNavigationDestination.editProfilePage, + title: context.l10n.editProfile, + ), ), - ), - CellGroup( - child: Column( - children: [ - if (Platform.isIOS && - userHasPin && - AccountKeyValue.instance.primarySessionId == null) + CellGroup( + child: Column( + children: [ + if (Platform.isIOS && + userHasPin && + AccountKeyValue.instance.primarySessionId == + null) + _Item( + leadingAssetName: + Resources.assetsImagesAccountSvg, + destination: + MajorNavigationDestination.accountPage, + title: context.l10n.account, + ), _Item( leadingAssetName: - Resources.assetsImagesAccountSvg, + Resources.assetsImagesIcNotificationSvg, destination: - MajorNavigationDestination.accountPage, - title: context.l10n.account, - ), - _Item( - leadingAssetName: - Resources.assetsImagesIcNotificationSvg, - destination: - MajorNavigationDestination.notificationPage, - title: context.l10n.notifications, - trailing: hasNotificationPermission == false - ? Padding( - padding: const EdgeInsets.all(4), - child: SvgPicture.asset( - Resources.assetsImagesTriangleWarningSvg, - colorFilter: ColorFilter.mode( - context.theme.red, - BlendMode.srcIn, + MajorNavigationDestination.notificationPage, + title: context.l10n.notifications, + trailing: hasNotificationPermission == false + ? Padding( + padding: const EdgeInsets.all(4), + child: SvgPicture.asset( + Resources + .assetsImagesTriangleWarningSvg, + colorFilter: ColorFilter.mode( + context.theme.red, + BlendMode.srcIn, + ), + width: 22, + height: 22, ), - width: 22, - height: 22, - ), - ) - : const Arrow(), - color: hasNotificationPermission == false - ? context.theme.red - : context.theme.text, - ), - _Item( - leadingAssetName: - Resources.assetsImagesIcStorageUsageSvg, - destination: MajorNavigationDestination - .dataAndStorageUsagePage, - title: context.l10n.dataAndStorageUsage, - ), - _Item( - leadingAssetName: Resources.assetsImagesShieldSvg, - destination: - MajorNavigationDestination.securityPage, - title: context.l10n.security, - ), - _Item( - leadingAssetName: Resources.assetsImagesProxySvg, - destination: MajorNavigationDestination.proxyPage, - title: context.l10n.proxy, - ), - _Item( - leadingAssetName: - Resources.assetsImagesIcAppearanceSvg, - destination: - MajorNavigationDestination.appearancePage, - title: context.l10n.appearance, - ), - _Item( - leadingAssetName: Resources.assetsImagesIcAboutSvg, - destination: MajorNavigationDestination.aboutPage, - title: context.l10n.about, - ), - ], + ) + : const Arrow(), + color: hasNotificationPermission == false + ? context.theme.red + : context.theme.text, + ), + _Item( + leadingAssetName: + Resources.assetsImagesIcStorageUsageSvg, + destination: MajorNavigationDestination + .dataAndStorageUsagePage, + title: context.l10n.dataAndStorageUsage, + ), + _Item( + leadingAssetName: Resources.assetsImagesShieldSvg, + destination: + MajorNavigationDestination.securityPage, + title: context.l10n.security, + ), + _Item( + leadingAssetName: Resources.assetsImagesProxySvg, + destination: MajorNavigationDestination.proxyPage, + title: context.l10n.proxy, + ), + _Item( + leadingAssetName: + Resources.assetsImagesIcAppearanceSvg, + destination: + MajorNavigationDestination.appearancePage, + title: context.l10n.appearance, + ), + _Item( + leadingAssetName: + Resources.assetsImagesIcAboutSvg, + destination: MajorNavigationDestination.aboutPage, + title: context.l10n.about, + ), + ], + ), ), + ], + ), + CellGroup( + child: _Item( + leadingAssetName: Resources.assetsImagesIcSignOutSvg, + title: context.l10n.signOut, + onTap: () async { + final succeed = await runFutureWithToast( + context.accountServer.signOutAndClear(), + ); + if (!succeed) return; + context.multiAuthChangeNotifier.signOut(); + }, + color: context.theme.red, + trailing: const SizedBox(), ), - ], - ), - CellGroup( - child: _Item( - leadingAssetName: Resources.assetsImagesIcSignOutSvg, - title: context.l10n.signOut, - onTap: () async { - final succeed = await runFutureWithToast( - context.accountServer.signOutAndClear(), - ); - if (!succeed) return; - context.multiAuthChangeNotifier.signOut(); - }, - color: context.theme.red, - trailing: const SizedBox(), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), ); } } diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index 01847449ee..8a73760500 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -67,6 +67,7 @@ class MessageItemWidget extends HookConsumerWidget { this.isTranscriptPage = false, this.blink = true, this.isPinnedPage = false, + this.showUnreadBar = true, this.dateTimeKey, this.isGroupOrBotGroupConversation, this.enableShowAvatar, @@ -80,6 +81,7 @@ class MessageItemWidget extends HookConsumerWidget { final bool isTranscriptPage; final bool blink; final bool isPinnedPage; + final bool showUnreadBar; final Key? dateTimeKey; final bool? isGroupOrBotGroupConversation; final bool? enableShowAvatar; @@ -181,8 +183,10 @@ class MessageItemWidget extends HookConsumerWidget { ); }, ), - if (message.messageId == lastReadMessageId && row.next != null) - const _UnreadMessageBar(), + if (showUnreadBar && + message.messageId == lastReadMessageId && + row.next != null) + const UnreadMessageBar(), ], ); @@ -337,8 +341,8 @@ class _MessageBubbleMargin extends HookConsumerWidget { } } -class _UnreadMessageBar extends StatelessWidget { - const _UnreadMessageBar(); +class UnreadMessageBar extends StatelessWidget { + const UnreadMessageBar({super.key}); @override Widget build(BuildContext context) => Container( diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index 7315315c33..3a1e919045 100644 --- a/test/ui/home/chat/chat_scroll_coordinator_test.dart +++ b/test/ui/home/chat/chat_scroll_coordinator_test.dart @@ -73,6 +73,93 @@ void main() { }, ); + testWidgets('handleScrollNotification waits for user scroll after reset', ( + tester, + ) async { + final coordinator = ChatScrollCoordinator(); + var loadAfterCount = 0; + + addTearDown(coordinator.dispose); + final context = await pumpNotificationContext(tester); + final metrics = FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 5000, + pixels: 3500, + viewportDimension: 600, + axisDirection: AxisDirection.down, + devicePixelRatio: 1, + ); + + coordinator + ..scheduleRestore( + messages: const [], + keysByMessageId: const {}, + reset: true, + isLatest: false, + ) + ..handleScrollNotification( + ScrollUpdateNotification( + metrics: metrics, + context: context, + scrollDelta: 1, + ), + messages: const [], + keysByMessageId: const {}, + loadBefore: () {}, + loadAfter: () => loadAfterCount++, + ); + + expect(loadAfterCount, 0); + + coordinator + ..handleScrollNotification( + ScrollEndNotification(metrics: metrics, context: context), + messages: const [], + keysByMessageId: const {}, + loadBefore: () {}, + loadAfter: () => loadAfterCount++, + ) + ..handleScrollNotification( + ScrollUpdateNotification( + metrics: metrics, + context: context, + scrollDelta: 1, + ), + messages: const [], + keysByMessageId: const {}, + loadBefore: () {}, + loadAfter: () => loadAfterCount++, + ); + + expect(loadAfterCount, 0); + + coordinator + ..handleScrollNotification( + UserScrollNotification( + metrics: metrics, + context: context, + direction: ScrollDirection.reverse, + ), + messages: const [], + keysByMessageId: const {}, + loadBefore: () {}, + loadAfter: () => loadAfterCount++, + ) + ..handleScrollNotification( + ScrollUpdateNotification( + metrics: metrics, + context: context, + scrollDelta: 1, + ), + messages: const [], + keysByMessageId: const {}, + loadBefore: () {}, + loadAfter: () => loadAfterCount++, + ); + + expect(loadAfterCount, 1); + }); + testWidgets('scheduleRestore skips no-op jump after data loads', ( tester, ) async { @@ -106,6 +193,39 @@ void main() { expect(coordinator.trackingScrollController.jumpCount, 0); }); + testWidgets('scheduleRestore resets centered windows before first paint', ( + tester, + ) async { + final coordinator = TrackingChatScrollCoordinator(); + final messages = List.generate(20, testMessage); + final keysByMessageId = { + for (final message in messages) message.messageId: GlobalKey(), + }; + + addTearDown(coordinator.dispose); + await pumpScrollableMessages( + tester, + coordinator, + messages, + keysByMessageId, + ); + coordinator.scrollController.jumpTo(300); + await tester.pump(); + + coordinator.trackingScrollController.jumpCount = 0; + coordinator.trackingScrollController.jumpOffsets.clear(); + coordinator.scheduleRestore( + messages: messages, + keysByMessageId: keysByMessageId, + reset: true, + isLatest: false, + centerMessageId: messages[8].messageId, + ); + + expect(coordinator.trackingScrollController.jumpOffsets, isEmpty); + expect(coordinator.scrollController.offset, 0); + }); + testWidgets( 'handleScrollNotification ignores idle notifications', ( diff --git a/test/ui/home/notifier/message_window_loader_test.dart b/test/ui/home/notifier/message_window_loader_test.dart index 699f3f836e..2bb73a74ef 100644 --- a/test/ui/home/notifier/message_window_loader_test.dart +++ b/test/ui/home/notifier/message_window_loader_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:drift/native.dart'; import 'package:flutter_app/db/dao/message_dao.dart'; import 'package:flutter_app/db/mixin_database.dart'; import 'package:flutter_app/ui/home/notifier/message_controller.dart'; @@ -127,6 +128,51 @@ void main() { MessageWindowDirection.newer, ); }); + + test('message id range queries keep same-timestamp rowid ordering', () async { + final database = MixinDatabase(NativeDatabase.memory()); + addTearDown(database.close); + + final createdAt = DateTime(2026); + Future insert(String id, DateTime time) => database + .into(database.messages) + .insert( + Message( + messageId: id, + conversationId: 'conversation', + userId: 'user', + category: 'PLAIN_TEXT', + status: MessageStatus.read, + createdAt: time, + ), + ); + + await insert('older', createdAt.subtract(const Duration(milliseconds: 1))); + await insert('same-before', createdAt); + await insert('anchor', createdAt); + await insert('same-after', createdAt); + await insert('newer', createdAt.add(const Duration(milliseconds: 1))); + + final anchor = await database.messageDao.messageOrderInfo('anchor'); + expect(anchor, isNotNull); + + expect( + await database.messageDao.beforeMessageIdsByConversationId( + anchor!, + 'conversation', + 2, + ), + ['same-before', 'older'], + ); + expect( + await database.messageDao.afterMessageIdsByConversationId( + anchor, + 'conversation', + 2, + ), + ['same-after', 'newer'], + ); + }); } MessageItem testMessage(int index, {String? messageId}) => MessageItem( diff --git a/test/ui/provider/major_navigation_provider_test.dart b/test/ui/provider/major_navigation_provider_test.dart index bf89040f1c..9a973c0109 100644 --- a/test/ui/provider/major_navigation_provider_test.dart +++ b/test/ui/provider/major_navigation_provider_test.dart @@ -21,4 +21,12 @@ void main() { expect(opened, isFalse); expect(notifier.state.entries, isEmpty); }); + + test('removing popped route clears navigation state', () { + final notifier = MajorNavigationNotifier() + ..openSetting(MajorNavigationDestination.editProfilePage) + ..remove(MajorNavigationDestination.editProfilePage); + + expect(notifier.state.entries, isEmpty); + }); } From 4009ed5704d54875b11f58bee65033ab90e62fa3 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:12:48 +0900 Subject: [PATCH 17/27] Keep unread anchor restore from jumping to latest --- lib/ui/home/chat/chat_scroll_coordinator.dart | 27 ++++++++++---- .../chat/chat_scroll_coordinator_test.dart | 37 +++++++++++++++++++ 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index b1a917e92f..27d014322e 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -187,6 +187,7 @@ class ChatScrollCoordinator { animatedRestoreDirection: animatedRestoreDirection, animatedRestoreComplete: animatedRestoreComplete, animated: animated, + hasCenteredAnchor: hasCenteredAnchor, traceTargetMessageId: traceTargetMessageId, ); if (_restoreScheduled) return; @@ -351,6 +352,7 @@ class ChatScrollCoordinator { traceChatJump( 'restore reset=${request.reset} latest=${request.isLatest} ' 'center=${shortMessageId(request.centerMessageId)} ' + 'centerAnchor=${request.hasCenteredAnchor} ' 'traceTarget=${shortMessageId(request.traceTargetMessageId)} ' 'animated=${request.animated} ' 'animatedMessage=${shortMessageId(request.animatedMessageId)} ' @@ -368,6 +370,10 @@ class ChatScrollCoordinator { )) { return; } + if (request.hasCenteredAnchor) { + _traceTargetAfterRestore(request); + return; + } if (request.isLatest) { unawaited( _jumpToBottom( @@ -376,14 +382,7 @@ class ChatScrollCoordinator { ), ); } - final traceTargetMessageId = request.traceTargetMessageId; - if (traceTargetMessageId != null) { - _traceTargetAfterLayout( - 'restore-anchor-after', - traceTargetMessageId, - request.keysByMessageId, - ); - } + _traceTargetAfterRestore(request); return; } @@ -767,6 +766,16 @@ class ChatScrollCoordinator { }), ); } + + void _traceTargetAfterRestore(_ChatRestoreRequest request) { + final traceTargetMessageId = request.traceTargetMessageId; + if (traceTargetMessageId == null) return; + _traceTargetAfterLayout( + 'restore-anchor-after', + traceTargetMessageId, + request.keysByMessageId, + ); + } } class _MessageTargetGeometry { @@ -794,6 +803,7 @@ class _ChatRestoreRequest { required this.reset, required this.isLatest, required this.animated, + required this.hasCenteredAnchor, this.centerMessageId, this.animatedMessageId, this.animatedRestoreDirection, @@ -806,6 +816,7 @@ class _ChatRestoreRequest { final bool reset; final bool isLatest; final bool animated; + final bool hasCenteredAnchor; final String? centerMessageId; final String? animatedMessageId; final ChatScrollRestoreDirection? animatedRestoreDirection; diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index 3a1e919045..c9abb4504b 100644 --- a/test/ui/home/chat/chat_scroll_coordinator_test.dart +++ b/test/ui/home/chat/chat_scroll_coordinator_test.dart @@ -226,6 +226,43 @@ void main() { expect(coordinator.scrollController.offset, 0); }); + testWidgets('scheduleRestore keeps centered unread anchors off bottom jump', ( + tester, + ) async { + final coordinator = TrackingChatScrollCoordinator(); + final messages = List.generate(20, testMessage); + final keysByMessageId = { + for (final message in messages) message.messageId: GlobalKey(), + }; + + addTearDown(coordinator.dispose); + await pumpScrollableMessages( + tester, + coordinator, + messages, + keysByMessageId, + ); + coordinator.scrollController.jumpTo(300); + await tester.pump(); + + coordinator.trackingScrollController + ..jumpCount = 0 + ..jumpOffsets.clear(); + coordinator.scheduleRestore( + messages: messages, + keysByMessageId: keysByMessageId, + reset: true, + isLatest: true, + hasCenteredAnchor: true, + traceTargetMessageId: messages[8].messageId, + ); + await tester.pump(); + + expect(coordinator.trackingScrollController.jumpCount, 0); + expect(coordinator.trackingScrollController.jumpOffsets, isEmpty); + expect(coordinator.scrollController.offset, 0); + }); + testWidgets( 'handleScrollNotification ignores idle notifications', ( From 6c27b890cb3ca37a2c6f0fb2a370d96cb13ef78e Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:54:19 +0900 Subject: [PATCH 18/27] Use native tail-follow scrolling for new chat messages --- lib/ui/home/chat/chat_history_viewport.dart | 8 + lib/ui/home/chat/chat_scroll_coordinator.dart | 246 +++++++++++++----- lib/ui/home/notifier/message_controller.dart | 181 ++++++++++--- .../home/notifier/message_window_loader.dart | 33 ++- .../chat/chat_scroll_coordinator_test.dart | 229 ++++++++++++++++ 5 files changed, 583 insertions(+), 114 deletions(-) diff --git a/lib/ui/home/chat/chat_history_viewport.dart b/lib/ui/home/chat/chat_history_viewport.dart index 9fdfa260e8..5441dbace7 100644 --- a/lib/ui/home/chat/chat_history_viewport.dart +++ b/lib/ui/home/chat/chat_history_viewport.dart @@ -91,6 +91,9 @@ class ChatHistoryViewport extends HookConsumerWidget { final resetScrollWindow = previousConversationIdRef.value != state.conversationId || previousRefreshKeyRef.value != state.refreshKey; + final resetCurrentConversation = + previousConversationIdRef.value == state.conversationId && + previousRefreshKeyRef.value != state.refreshKey; useEffect(() { scrollCoordinator.viewportKey = viewportKey; @@ -163,6 +166,11 @@ class ChatHistoryViewport extends HookConsumerWidget { reset: resetScrollWindow, isLatest: state.isLatest, hasCenteredAnchor: anchorUnreadSeparator, + animateLatestReset: + resetCurrentConversation && + state.isLatest && + center == null && + !anchorUnreadSeparator, centerMessageId: anchorUnreadSeparator ? null : center?.messageId, traceTargetMessageId: center?.messageId ?? state.lastReadMessageId, ); diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index 27d014322e..13d88b25a6 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -64,7 +64,9 @@ class ChatScrollCoordinator { scrollController.addListener(_scheduleScrollPositionUpdate); } - static const _jumpLatestThreshold = 40.0; + static const _tailFollowThreshold = 50.0; + static const _showJumpToLatestThreshold = 96.0; + static const _jumpButtonFreezeUpdateCount = 1; static const _preloadViewportCount = 3.0; static const _jumpToTolerance = 0.5; static const _jumpAnimationDuration = Duration(milliseconds: 300); @@ -87,7 +89,8 @@ class ChatScrollCoordinator { Map _keysByMessageId = const {}; GlobalKey? _viewportKey; final _renderedMessageIds = {}; - bool _pinnedToBottom = true; + bool _tailFollowEligible = true; + _TailFollowAnchorSnapshot? _tailFollowAnchorSnapshot; bool _animateNextRestore = false; ChatScrollRestoreDirection? _animatedRestoreDirection; bool _restoreScheduled = false; @@ -96,6 +99,7 @@ class ChatScrollCoordinator { bool _blockPreloadUntilUserScroll = false; bool _disposed = false; int _programmaticScrollDepth = 0; + int _jumpButtonFrozenUpdates = 0; void captureViewportState( List messages, @@ -103,7 +107,10 @@ class ChatScrollCoordinator { ) { if (!scrollController.hasClients) return; _setMessages(messages, keysByMessageId); - _pinnedToBottom = _isPinnedToBottom(); + _tailFollowEligible = _isTailFollowEligible(); + _tailFollowAnchorSnapshot = _tailFollowEligible + ? _tailFollowAnchorSnapshotFromViewport() + : null; } void updateMessages( @@ -158,6 +165,7 @@ class ChatScrollCoordinator { required bool reset, required bool isLatest, bool hasCenteredAnchor = false, + bool animateLatestReset = false, String? centerMessageId, String? traceTargetMessageId, }) { @@ -165,7 +173,10 @@ class ChatScrollCoordinator { final animatedRestoreDirection = reset ? _animatedRestoreDirection : null; final animatedRestoreComplete = reset ? _animatedRestoreComplete : null; final animated = - reset && (animatedMessageId != null || _animateNextRestore); + reset && + (animatedMessageId != null || + _animateNextRestore || + animateLatestReset); if (reset) { _animatedRestoreMessageId = null; _animatedRestoreDirection = null; @@ -388,8 +399,13 @@ class ChatScrollCoordinator { if (scrollController.position.isScrollingNotifier.value) return; - if (_pinnedToBottom) { - unawaited(_jumpToBottom()); + if (_tailFollowEligible) { + unawaited( + _jumpToBottom( + animated: true, + prepareAnimationStart: _restoreTailFollowAnimationStart, + ), + ); } } @@ -478,17 +494,23 @@ class ChatScrollCoordinator { Future _jumpToBottom({ bool animated = false, ChatScrollRestoreDirection? animationDirection, - }) => _jumpToClamped( - scrollController.position.maxScrollExtent, - animated: animated, - animationDirection: animated ? animationDirection : null, - ); + VoidCallback? prepareAnimationStart, + }) { + _clearJumpButtonFreeze(); + return _jumpToClamped( + scrollController.position.maxScrollExtent, + animated: animated, + animationDirection: animated ? animationDirection : null, + prepareAnimationStart: prepareAnimationStart, + ); + } Future _jumpToClamped( double value, { bool animated = false, bool stageDistant = true, ChatScrollRestoreDirection? animationDirection, + VoidCallback? prepareAnimationStart, }) { if (!scrollController.hasClients) return Future.value(); final position = scrollController.position; @@ -504,65 +526,64 @@ class ChatScrollCoordinator { 'distance=${formatDouble(target - position.pixels)} ' '${formatScrollMetrics(position)}', ); - if (animationDirection == null && - (target - position.pixels).abs() <= _jumpToTolerance) { - if (animated) { - return _runProgrammaticScroll( - () => scrollController.animateTo( - target, - duration: _jumpAnimationDuration, - curve: _jumpAnimationCurve, - ), - ); + if (!animated) { + if ((target - position.pixels).abs() <= _jumpToTolerance) { + return Future.value(); } + scrollController.jumpTo(target); + _scheduleViewportStateUpdate(); return Future.value(); } - if (animated) { - return _runProgrammaticScroll( - () { - final maxAnimatedDistance = _maxAnimatedDistance(position); - if (maxAnimatedDistance <= 0) { - scrollController.jumpTo(target); - return Future.value(); - } - final distance = target - position.pixels; - if (animationDirection != null) { - _jumpToDirectionalAnimationStart( - target, - animationDirection, - position, - ); - return scrollController.animateTo( - target, - duration: _jumpAnimationDuration, - curve: _jumpAnimationCurve, - ); - } - if (stageDistant && distance.abs() > maxAnimatedDistance) { - final stagedTarget = target - maxAnimatedDistance * distance.sign; - traceChatJump( - 'jump staged target=${formatDouble(stagedTarget)} ' - 'final=${formatDouble(target)} ' - 'maxAnimated=${formatDouble(maxAnimatedDistance)}', - ); - scrollController.jumpTo( - stagedTarget.clamp( - position.minScrollExtent, - position.maxScrollExtent, - ), - ); - } + return _runProgrammaticScroll( + () { + prepareAnimationStart?.call(); + final position = scrollController.position; + final maxAnimatedDistance = _maxAnimatedDistance(position); + if (maxAnimatedDistance <= 0) { + scrollController.jumpTo(target); + return Future.value(); + } + final distance = target - position.pixels; + if (animationDirection == null && distance.abs() <= _jumpToTolerance) { return scrollController.animateTo( target, duration: _jumpAnimationDuration, curve: _jumpAnimationCurve, ); - }, - ); - } - scrollController.jumpTo(target); - _scheduleViewportStateUpdate(); - return Future.value(); + } + if (animationDirection != null) { + _jumpToDirectionalAnimationStart( + target, + animationDirection, + position, + ); + return scrollController.animateTo( + target, + duration: _jumpAnimationDuration, + curve: _jumpAnimationCurve, + ); + } + if (stageDistant && distance.abs() > maxAnimatedDistance) { + final stagedTarget = target - maxAnimatedDistance * distance.sign; + traceChatJump( + 'jump staged target=${formatDouble(stagedTarget)} ' + 'final=${formatDouble(target)} ' + 'maxAnimated=${formatDouble(maxAnimatedDistance)}', + ); + scrollController.jumpTo( + stagedTarget.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ), + ); + } + return scrollController.animateTo( + target, + duration: _jumpAnimationDuration, + curve: _jumpAnimationCurve, + ); + }, + ); } Future _runProgrammaticScroll(Future Function() scroll) async { @@ -630,11 +651,16 @@ class ChatScrollCoordinator { return math.min(viewportDimension * 0.75, maxAnimatedDistance); } - bool _isPinnedToBottom() { - if (!scrollController.hasClients) return true; + bool _isTailFollowEligible() => _distanceToBottom() <= _tailFollowThreshold; + + bool _shouldShowJumpToLatest() => + _distanceToBottom() > _showJumpToLatestThreshold; + + double _distanceToBottom() { + if (!scrollController.hasClients) return 0; final position = scrollController.position; - if (!position.hasContentDimensions) return true; - return position.maxScrollExtent - position.pixels <= _jumpLatestThreshold; + if (!position.hasContentDimensions) return 0; + return math.max(0, position.maxScrollExtent - position.pixels); } String? _firstVisibleMessageId() { @@ -653,6 +679,52 @@ class ChatScrollCoordinator { return firstVisibleMessageId; } + _TailFollowAnchorSnapshot? _tailFollowAnchorSnapshotFromViewport() { + _TailFollowAnchorSnapshot? snapshot; + final viewportHeight = scrollController.position.viewportDimension; + for (final messageId in _currentRenderedMessageIds()) { + final geometry = _messageTargetGeometry(messageId, _keysByMessageId); + if (geometry == null || + geometry.bottom <= 0 || + geometry.top >= viewportHeight) { + continue; + } + if (snapshot == null || geometry.bottom > snapshot.bottom) { + snapshot = _TailFollowAnchorSnapshot( + messageId: messageId, + bottom: geometry.bottom, + ); + } + } + return snapshot; + } + + void _restoreTailFollowAnimationStart() { + final snapshot = _tailFollowAnchorSnapshot; + _tailFollowAnchorSnapshot = null; + if (snapshot == null || !scrollController.hasClients) return; + final geometry = _messageTargetGeometry( + snapshot.messageId, + _keysByMessageId, + ); + if (geometry == null) return; + + final delta = geometry.bottom - snapshot.bottom; + if (delta.abs() <= _jumpToTolerance) return; + final position = scrollController.position; + final start = (position.pixels + delta).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((start - position.pixels).abs() <= _jumpToTolerance) return; + traceChatJump( + 'tail-follow start anchor=${shortMessageId(snapshot.messageId)} ' + 'delta=${formatDouble(delta)} start=${formatDouble(start)} ' + '${formatScrollMetrics(position)}', + ); + scrollController.jumpTo(start); + } + void _scheduleScrollPositionUpdate() { _scheduleViewportStateUpdate(updateVisibleDate: false); } @@ -674,9 +746,13 @@ class ChatScrollCoordinator { void _updateViewportState({bool updateVisibleDate = true}) { if (!scrollController.hasClients) return; - final nextShowJumpToLatest = !_isPinnedToBottom(); - if (showJumpToLatest.value != nextShowJumpToLatest) { - showJumpToLatest.value = nextShowJumpToLatest; + if (_jumpButtonFrozenUpdates <= 0) { + final nextShowJumpToLatest = _shouldShowJumpToLatest(); + if (showJumpToLatest.value != nextShowJumpToLatest) { + showJumpToLatest.value = nextShowJumpToLatest; + } + } else { + _jumpButtonFrozenUpdates--; } if (!updateVisibleDate) return; @@ -693,6 +769,7 @@ class ChatScrollCoordinator { List messages, Map keysByMessageId, ) { + final messageWindowChanged = !_hasSameMessageIds(messages); if (!identical(_messages, messages)) { _messages = messages; _messagesById = { @@ -700,9 +777,30 @@ class ChatScrollCoordinator { }; _renderedMessageIds.removeWhere((id) => !_messagesById.containsKey(id)); } + if (messageWindowChanged) { + _freezeJumpButton(); + } _keysByMessageId = keysByMessageId; } + bool _hasSameMessageIds(List messages) { + if (identical(_messages, messages)) return true; + if (_messages.length != messages.length) return false; + for (var i = 0; i < messages.length; i++) { + if (_messages[i].messageId != messages[i].messageId) return false; + } + return true; + } + + void _freezeJumpButton() { + if (_disposed) return; + _jumpButtonFrozenUpdates = _jumpButtonFreezeUpdateCount; + } + + void _clearJumpButtonFreeze() { + _jumpButtonFrozenUpdates = 0; + } + Iterable _currentRenderedMessageIds() sync* { for (final messageId in _renderedMessageIds) { if (_keysByMessageId[messageId]?.currentContext != null) { @@ -778,6 +876,16 @@ class ChatScrollCoordinator { } } +class _TailFollowAnchorSnapshot { + const _TailFollowAnchorSnapshot({ + required this.messageId, + required this.bottom, + }); + + final String messageId; + final double bottom; +} + class _MessageTargetGeometry { const _MessageTargetGeometry({ required this.target, diff --git a/lib/ui/home/notifier/message_controller.dart b/lib/ui/home/notifier/message_controller.dart index 3a5d6c7fd9..36bf1b074e 100644 --- a/lib/ui/home/notifier/message_controller.dart +++ b/lib/ui/home/notifier/message_controller.dart @@ -20,6 +20,87 @@ import '../chat/chat_jump_trace.dart'; part 'message_window_loader.dart'; +enum MessageWindowJumpSource { + conversation, + message, + quote, + pin, + mention, + search, + restore, +} + +sealed class MessageWindowAnchor extends Equatable { + const MessageWindowAnchor(); + + String? get centerMessageId => null; + + String get debugName; +} + +final class LatestMessageWindowAnchor extends MessageWindowAnchor { + const LatestMessageWindowAnchor(); + + @override + String get debugName => 'latest'; + + @override + List get props => const []; +} + +final class UnreadMessageWindowAnchor extends MessageWindowAnchor { + const UnreadMessageWindowAnchor({required this.lastReadMessageId}); + + final String lastReadMessageId; + + @override + String get centerMessageId => lastReadMessageId; + + @override + String get debugName => 'unread'; + + @override + List get props => [lastReadMessageId]; +} + +final class AroundMessageWindowAnchor extends MessageWindowAnchor { + const AroundMessageWindowAnchor({ + required this.messageId, + required this.source, + }); + + final String messageId; + final MessageWindowJumpSource source; + + @override + String get centerMessageId => messageId; + + @override + String get debugName => 'message:$source'; + + @override + List get props => [messageId, source]; +} + +final class RestoreMessageWindowAnchor extends MessageWindowAnchor { + const RestoreMessageWindowAnchor({ + required this.messageId, + required this.offset, + }); + + final String messageId; + final double offset; + + @override + String get centerMessageId => messageId; + + @override + String get debugName => 'restore'; + + @override + List get props => [messageId, offset]; +} + class MessageState extends Equatable { MessageState({ this.top = const [], @@ -30,6 +111,7 @@ class MessageState extends Equatable { this.isOldest = false, this.lastReadMessageId, this.refreshKey, + this.anchor = const LatestMessageWindowAnchor(), }) { // check top, center, bottom didn't has same messageId assert(() { @@ -52,6 +134,7 @@ class MessageState extends Equatable { final bool isOldest; final String? lastReadMessageId; final Object? refreshKey; + final MessageWindowAnchor anchor; @override List get props => [ @@ -63,6 +146,7 @@ class MessageState extends Equatable { isOldest, lastReadMessageId, refreshKey, + anchor, ]; MessageItem? get bottomMessage => @@ -88,6 +172,7 @@ class MessageState extends Equatable { bool? isOldest, String? lastReadMessageId, Object? refreshKey, + MessageWindowAnchor? anchor, }) => MessageState( conversationId: conversationId ?? this.conversationId, top: top ?? this.top, @@ -97,15 +182,7 @@ class MessageState extends Equatable { isOldest: isOldest ?? this.isOldest, lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId, refreshKey: refreshKey ?? this.refreshKey, - ); - - MessageState _copyWithJumpCurrentState() => MessageState( - top: list.toList(), - refreshKey: Object(), - conversationId: conversationId, - isLatest: isLatest, - isOldest: isOldest, - lastReadMessageId: lastReadMessageId, + anchor: anchor ?? this.anchor, ); MessageState removeMessage(String messageId) { @@ -118,6 +195,7 @@ class MessageState extends Equatable { isOldest: isOldest, lastReadMessageId: lastReadMessageId, refreshKey: refreshKey, + anchor: anchor, ); } @@ -250,6 +328,7 @@ class MessageController extends ValueNotifier { String? centerMessageId, String? lastReadMessageId, bool forceLatest = false, + MessageWindowAnchor? anchor, }) { final generation = ++_generation; unawaited( @@ -258,6 +337,7 @@ class MessageController extends ValueNotifier { centerMessageId: centerMessageId, lastReadMessageId: lastReadMessageId, forceLatest: forceLatest, + anchor: anchor, ).catchError((Object error, StackTrace stackTrace) { e('message init failed: $error $stackTrace'); }), @@ -269,16 +349,29 @@ class MessageController extends ValueNotifier { String? centerMessageId, String? lastReadMessageId, bool forceLatest = false, + MessageWindowAnchor? anchor, }) async { final finalLimit = limit; final conversationId = conversationNotifier.state?.conversationId; if (conversationId == null) return; final conversation = conversationNotifier.state?.conversation; + final windowAnchor = + anchor ?? + _resolveWindowAnchor( + requestedCenterMessageId: centerMessageId, + forceLatest: forceLatest, + ); + final resolvedLastReadMessageId = + lastReadMessageId ?? + conversation?.lastReadMessageId ?? + state.lastReadMessageId; traceChatJump( 'message init ' 'conv=${shortMessageId(conversationId)} ' 'requestedCenter=${shortMessageId(centerMessageId)} ' + 'anchor=${windowAnchor.debugName} ' + 'anchorCenter=${shortMessageId(windowAnchor.centerMessageId)} ' 'inputLastRead=${shortMessageId(lastReadMessageId)} ' 'stateLastRead=${shortMessageId(state.lastReadMessageId)} ' 'convLastRead=${shortMessageId(conversation?.lastReadMessageId)} ' @@ -289,15 +382,14 @@ class MessageController extends ValueNotifier { final messageState = await _resetMessageList( conversationId, finalLimit, - centerMessageId: centerMessageId, - forceLatest: forceLatest, + anchor: windowAnchor, ); if (!_isCurrent(generation, conversationId)) return; final nextState = _pretreatment( messageState.copyWith( refreshKey: Object(), - lastReadMessageId: lastReadMessageId, + lastReadMessageId: resolvedLastReadMessageId, ), ); traceChatJump( @@ -309,6 +401,26 @@ class MessageController extends ValueNotifier { _emit(nextState); } + MessageWindowAnchor _resolveWindowAnchor({ + required String? requestedCenterMessageId, + required bool forceLatest, + }) { + if (forceLatest) return const LatestMessageWindowAnchor(); + if (requestedCenterMessageId != null) { + return AroundMessageWindowAnchor( + messageId: requestedCenterMessageId, + source: MessageWindowJumpSource.conversation, + ); + } + final conversation = conversationNotifier.state?.conversation; + final lastReadMessageId = conversation?.lastReadMessageId; + if ((conversation?.unseenMessageCount ?? 0) > 0 && + lastReadMessageId != null) { + return UnreadMessageWindowAnchor(lastReadMessageId: lastReadMessageId); + } + return const LatestMessageWindowAnchor(); + } + void after() { if (_loadAfterInFlight || state.isLatest) return; final conversationId = conversationNotifier.state?.conversationId; @@ -366,30 +478,23 @@ class MessageController extends ValueNotifier { Future _resetMessageList( String conversationId, int limit, { - String? centerMessageId, - bool forceLatest = false, + required MessageWindowAnchor anchor, }) async { final conversation = conversationNotifier.state?.conversation; - final _centerMessageId = forceLatest - ? null - : centerMessageId ?? - ((conversation?.unseenMessageCount ?? 0) > 0 - ? conversation?.lastReadMessageId - : null); traceChatJump( 'reset list ' 'conv=${shortMessageId(conversationId)} ' - 'requested=${shortMessageId(centerMessageId)} ' - 'resolved=${shortMessageId(_centerMessageId)} ' + 'anchor=${anchor.debugName} ' + 'resolved=${shortMessageId(anchor.centerMessageId)} ' 'convLastRead=${shortMessageId(conversation?.lastReadMessageId)} ' - 'forceLatest=$forceLatest unseen=${conversation?.unseenMessageCount}', + 'unseen=${conversation?.unseenMessageCount}', ); final state = await _messagesByConversationId( conversationId, limit, - centerMessageId: _centerMessageId, + anchor: anchor, ); return state.copyWith( @@ -403,11 +508,11 @@ class MessageController extends ValueNotifier { Future _messagesByConversationId( String conversationId, int limit, { - String? centerMessageId, + required MessageWindowAnchor anchor, }) => _messageWindowLoader.load( conversationId, limit, - centerMessageId: centerMessageId, + anchor: anchor, trace: traceChatJump, ); @@ -427,7 +532,6 @@ class MessageController extends ValueNotifier { var center = state.center; var bottom = state.bottom.toList(); - var jumpToCurrent = false; for (final item in list) { if (item.conversationId != conversationId) continue; @@ -468,21 +572,19 @@ class MessageController extends ValueNotifier { top = [item, ...top] ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); } - jumpToCurrent = jumpToCurrent || currentUserSent; } else { if (currentUserSent && item.status == MessageStatus.sending) { - _init(); + loadLatestWindow(); return null; } } } - final result = state.copyWith(top: top, center: center, bottom: bottom); - - if (jumpToCurrent) { - return result._copyWithJumpCurrentState(); - } - return result; + return state.copyWith( + top: top, + center: center, + bottom: bottom, + ); } void _insertOrReplaceCurrentConversation(List list) { @@ -513,6 +615,10 @@ class MessageController extends ValueNotifier { _init( centerMessageId: messageId, lastReadMessageId: state.lastReadMessageId, + anchor: AroundMessageWindowAnchor( + messageId: messageId, + source: MessageWindowJumpSource.message, + ), ); } @@ -523,7 +629,7 @@ class MessageController extends ValueNotifier { void loadLatestWindow() { _init( lastReadMessageId: state.lastReadMessageId, - forceLatest: true, + anchor: const LatestMessageWindowAnchor(), ); } @@ -568,5 +674,6 @@ class MessageController extends ValueNotifier { 'bottom=${state.bottom.length} ' 'bottomFirst=${shortMessageId(state.bottom.firstOrNull?.messageId)} ' 'bottomLast=${shortMessageId(state.bottom.lastOrNull?.messageId)} ' - 'latest=${state.isLatest} oldest=${state.isOldest}'; + 'latest=${state.isLatest} oldest=${state.isOldest} ' + 'anchor=${state.anchor.debugName}'; } diff --git a/lib/ui/home/notifier/message_window_loader.dart b/lib/ui/home/notifier/message_window_loader.dart index d406abc774..42c79c3759 100644 --- a/lib/ui/home/notifier/message_window_loader.dart +++ b/lib/ui/home/notifier/message_window_loader.dart @@ -144,8 +144,19 @@ class MessageWindowLoader { String conversationId, int limit, { String? centerMessageId, + MessageWindowAnchor? anchor, void Function(String message)? trace, }) async { + final resolvedAnchor = + anchor ?? + (centerMessageId == null + ? const LatestMessageWindowAnchor() + : AroundMessageWindowAnchor( + messageId: centerMessageId, + source: MessageWindowJumpSource.conversation, + )); + final resolvedCenterMessageId = resolvedAnchor.centerMessageId; + Future recent() async { final list = await recentMessages(conversationId, limit); @@ -157,19 +168,20 @@ class MessageWindowLoader { ); } - if (centerMessageId == null) return recent(); + if (resolvedCenterMessageId == null) return recent(); - final info = await messageOrderInfo(centerMessageId); + final info = await messageOrderInfo(resolvedCenterMessageId); if (info == null) { trace?.call( - 'query center missing-order target=${shortMessageId(centerMessageId)}', + 'query center missing-order ' + 'target=${shortMessageId(resolvedCenterMessageId)}', ); return recent(); } final halfLimit = limit ~/ 2; trace?.call( - 'query center order target=${shortMessageId(centerMessageId)} ' + 'query center order target=${shortMessageId(resolvedCenterMessageId)} ' 'createdAt=${info.createdAt} rowId=${info.rowId} half=$halfLimit', ); final bottomIdsFuture = afterMessageIds(info, conversationId, halfLimit); @@ -178,7 +190,7 @@ class MessageWindowLoader { final bottomIds = await bottomIdsFuture; final topIds = await topIdsFuture; trace?.call( - 'query center ids target=${shortMessageId(centerMessageId)} ' + 'query center ids target=${shortMessageId(resolvedCenterMessageId)} ' 'top=${topIds.length} ' 'topFirst=${shortMessageId(topIds.firstOrNull)} ' 'topLast=${shortMessageId(topIds.lastOrNull)} ' @@ -186,7 +198,11 @@ class MessageWindowLoader { 'bottomFirst=${shortMessageId(bottomIds.firstOrNull)} ' 'bottomLast=${shortMessageId(bottomIds.lastOrNull)}', ); - final messageIds = [...topIds.reversed, centerMessageId, ...bottomIds]; + final messageIds = [ + ...topIds.reversed, + resolvedCenterMessageId, + ...bottomIds, + ]; final messages = await messagesByIds(messageIds); final messagesById = { for (final message in messages) message.messageId: message, @@ -200,7 +216,7 @@ class MessageWindowLoader { .map((id) => messagesById[id]) .nonNulls .toList(); - var center = messagesById[centerMessageId]; + var center = messagesById[resolvedCenterMessageId]; final isLatest = bottomIds.length < halfLimit; final isOldest = topIds.length < halfLimit; @@ -211,7 +227,7 @@ class MessageWindowLoader { } trace?.call( - 'query centered target=${shortMessageId(centerMessageId)} ' + 'query centered target=${shortMessageId(resolvedCenterMessageId)} ' 'top=${topList.length} center=${center != null} ' 'bottom=${bottomList.length} isLatest=$isLatest isOldest=$isOldest', ); @@ -221,6 +237,7 @@ class MessageWindowLoader { bottom: bottomList, isLatest: isLatest, isOldest: isOldest, + anchor: resolvedAnchor, ); } } diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index c9abb4504b..bb928f9ef5 100644 --- a/test/ui/home/chat/chat_scroll_coordinator_test.dart +++ b/test/ui/home/chat/chat_scroll_coordinator_test.dart @@ -385,6 +385,39 @@ void main() { expect(keysByMessageId.lookupCount, 0); }); + testWidgets('jump-to-latest button uses wider threshold than tail follow', ( + tester, + ) async { + final coordinator = ChatScrollCoordinator(); + final messages = List.generate(20, testMessage); + final keysByMessageId = { + for (final message in messages) + message.messageId: MessageGlobalKey(message.messageId), + }; + + addTearDown(coordinator.dispose); + await pumpFullyBuiltScrollableMessages( + tester, + coordinator, + messages, + keysByMessageId, + ); + coordinator.updateMessages(messages, keysByMessageId); + await tester.pump(); + + coordinator.scrollController.jumpTo( + coordinator.scrollController.position.maxScrollExtent - 60, + ); + await tester.pump(); + expect(coordinator.showJumpToLatest.value, false); + + coordinator.scrollController.jumpTo( + coordinator.scrollController.position.maxScrollExtent - 120, + ); + await tester.pump(); + expect(coordinator.showJumpToLatest.value, true); + }); + testWidgets('scheduleRestore does not jump while user is scrolling', ( tester, ) async { @@ -474,6 +507,144 @@ void main() { expect(coordinator.trackingScrollController.jumpCount, 0); }); + testWidgets('scheduleRestore animates tail-follow message appends', ( + tester, + ) async { + final coordinator = TrackingChatScrollCoordinator(); + final messages = List.generate(20, testMessage); + final nextMessages = [...messages, testMessage(100)]; + final keysByMessageId = { + for (final message in nextMessages) message.messageId: GlobalKey(), + }; + + addTearDown(coordinator.dispose); + await pumpFullyBuiltScrollableMessages( + tester, + coordinator, + messages, + keysByMessageId, + ); + coordinator.scrollController.jumpTo( + coordinator.scrollController.position.maxScrollExtent, + ); + await tester.pump(); + coordinator.captureViewportState(messages, keysByMessageId); + + await pumpFullyBuiltScrollableMessages( + tester, + coordinator, + nextMessages, + keysByMessageId, + ); + coordinator.trackingScrollController + ..animateCount = 0 + ..jumpCount = 0; + coordinator.scheduleRestore( + messages: nextMessages, + keysByMessageId: keysByMessageId, + reset: false, + isLatest: true, + ); + await tester.pump(); + + expect(coordinator.trackingScrollController.animateCount, 1); + expect(coordinator.trackingScrollController.jumpCount, 0); + }); + + testWidgets( + 'scheduleRestore restores tail-follow start in centered viewports', + (tester) async { + final coordinator = TrackingChatScrollCoordinator(); + final centerKey = GlobalKey(); + final top = List.generate(8, testMessage); + final bottom = List.generate(8, (index) => testMessage(index + 100)); + final nextBottom = [...bottom, testMessage(200)]; + final messages = [...top, ...bottom]; + final nextMessages = [...top, ...nextBottom]; + final keysByMessageId = { + for (final message in nextMessages) message.messageId: GlobalKey(), + }; + + addTearDown(coordinator.dispose); + await pumpCenteredScrollableMessages( + tester, + coordinator, + centerKey: centerKey, + top: top, + bottom: bottom, + keysByMessageId: keysByMessageId, + ); + coordinator.scrollController.jumpTo( + coordinator.scrollController.position.maxScrollExtent, + ); + await tester.pump(); + coordinator.captureViewportState(messages, keysByMessageId); + + await pumpCenteredScrollableMessages( + tester, + coordinator, + centerKey: centerKey, + top: top, + bottom: nextBottom, + keysByMessageId: keysByMessageId, + ); + coordinator.scrollController.jumpTo( + coordinator.scrollController.position.maxScrollExtent, + ); + await tester.pump(); + coordinator.trackingScrollController + ..animateCount = 0 + ..jumpCount = 0 + ..jumpOffsets.clear() + ..animateOffsets.clear(); + coordinator.scheduleRestore( + messages: nextMessages, + keysByMessageId: keysByMessageId, + reset: false, + isLatest: true, + ); + await tester.pump(); + + expect(coordinator.trackingScrollController.animateCount, 1); + expect(coordinator.trackingScrollController.jumpCount, 1); + expect( + coordinator.trackingScrollController.jumpOffsets.single, + lessThan(coordinator.trackingScrollController.animateOffsets.single), + ); + }, + ); + + testWidgets('scheduleRestore animates requested latest resets', ( + tester, + ) async { + final coordinator = TrackingChatScrollCoordinator(); + final messages = List.generate(20, testMessage); + final keysByMessageId = { + for (final message in messages) message.messageId: GlobalKey(), + }; + + addTearDown(coordinator.dispose); + await pumpFullyBuiltScrollableMessages( + tester, + coordinator, + messages, + keysByMessageId, + ); + coordinator.trackingScrollController + ..animateCount = 0 + ..jumpCount = 0; + coordinator.scheduleRestore( + messages: messages, + keysByMessageId: keysByMessageId, + reset: true, + isLatest: true, + animateLatestReset: true, + ); + await tester.pump(); + + expect(coordinator.trackingScrollController.animateCount, 1); + }); + testWidgets('scheduleRestore animates explicit message jumps', ( tester, ) async { @@ -1184,6 +1355,64 @@ Future pumpFullyBuiltScrollableMessages( ); } +Future pumpCenteredScrollableMessages( + WidgetTester tester, + ChatScrollCoordinator coordinator, { + required GlobalKey centerKey, + required List top, + required List bottom, + required Map keysByMessageId, + double itemHeight = 80, + double viewportHeight = 240, +}) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 320, + height: viewportHeight, + child: CustomScrollView( + key: coordinator.viewportKey, + center: centerKey, + controller: coordinator.scrollController, + anchor: ChatScrollCoordinator.messageFocusAnchor, + slivers: [ + SliverList.builder( + itemCount: top.length, + itemBuilder: (context, index) { + final message = top[top.length - index - 1]; + return ChatRenderedMessage( + coordinator: coordinator, + messageId: message.messageId, + child: SizedBox( + key: keysByMessageId[message.messageId], + height: itemHeight, + ), + ); + }, + ), + SliverToBoxAdapter(key: centerKey, child: const SizedBox()), + SliverList.builder( + itemCount: bottom.length, + itemBuilder: (context, index) { + final message = bottom[index]; + return ChatRenderedMessage( + coordinator: coordinator, + messageId: message.messageId, + child: SizedBox( + key: keysByMessageId[message.messageId], + height: itemHeight, + ), + ); + }, + ), + ], + ), + ), + ), + ); +} + MessageItem testMessage(int index) => MessageItem( messageId: '$index', conversationId: 'conversation', From 6e7f39da26dc576b41621cc589ca53b1690c2a34 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:20:49 +0900 Subject: [PATCH 19/27] Fix double-tap quick reply --- lib/utils/double_tap_util.dart | 26 -------------- lib/widgets/message/message.dart | 32 +++++++++++------ .../widgets/message/message_context_test.dart | 34 +++++++++++++++++++ 3 files changed, 55 insertions(+), 37 deletions(-) delete mode 100644 lib/utils/double_tap_util.dart diff --git a/lib/utils/double_tap_util.dart b/lib/utils/double_tap_util.dart deleted file mode 100644 index cbb5aa5064..0000000000 --- a/lib/utils/double_tap_util.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -Map _timers = {}; - -void doubleTap(String tag, Duration duration, VoidCallback onExecute) { - final timer = _timers[tag]; - - if (duration == Duration.zero) { - timer?.cancel(); - _timers.remove(tag); - onExecute(); - } else { - if (timer != null && timer.isActive) { - onExecute(); - } - - _timers.remove(tag); - timer?.cancel(); - - _timers[tag] = Timer(duration, () { - _timers.remove(tag); - timer?.cancel(); - }); - } -} diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index 8a73760500..12566711ff 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -16,7 +16,6 @@ import '../../ui/provider/message_selection_provider.dart'; import '../../ui/provider/quote_message_provider.dart'; import '../../ui/provider/setting_provider.dart'; import '../../ui/provider/user_cache_provider.dart'; -import '../../utils/double_tap_util.dart'; import '../../utils/extension/extension.dart'; import '../avatar_view/avatar_view.dart'; import '../interactive_decorated_box.dart'; @@ -49,11 +48,26 @@ void _quickReply(BuildContext context) { if (context.isTranscriptPage) return; if (!context.message.type.canReply) return; - doubleTap('_quickReply', const Duration(milliseconds: 300), () { - context.read().blinkByMessageId(context.message.messageId); - context.providerContainer.read(quoteMessageProvider.notifier).state = - context.message; + context.read().blinkByMessageId(context.message.messageId); + context.providerContainer.read(quoteMessageProvider.notifier).state = + context.message; +} + +@visibleForTesting +class MessageQuickReplyDetector extends StatelessWidget { + const MessageQuickReplyDetector({ + required this.child, + super.key, }); + + final Widget child; + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.translucent, + onDoubleTap: () => _quickReply(context), + child: child, + ); } class MessageItemWidget extends HookConsumerWidget { @@ -215,8 +229,7 @@ class MessageItemWidget extends HookConsumerWidget { highlightEnabled: blink, menuHighlighted: showedMenu.value, child: Builder( - builder: (context) => GestureDetector( - onTap: () => _quickReply(context), + builder: (context) => MessageQuickReplyDetector( child: Padding( padding: row.sameUserPrev ? EdgeInsets.zero @@ -289,10 +302,7 @@ class _MessageBubbleMargin extends HookConsumerWidget { hitTestBehavior: HitTestBehavior.translucent, menuProvider: buildMenus, desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), - child: GestureDetector( - onTap: () => _quickReply(context), - child: Builder(builder: builder), - ), + child: MessageQuickReplyDetector(child: Builder(builder: builder)), ), ], ); diff --git a/test/widgets/message/message_context_test.dart b/test/widgets/message/message_context_test.dart index 7bb98c50b8..788a6027eb 100644 --- a/test/widgets/message/message_context_test.dart +++ b/test/widgets/message/message_context_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_app/db/mixin_database.dart'; import 'package:flutter_app/ui/home/notifier/blink_notifier.dart'; import 'package:flutter_app/ui/provider/database_provider.dart'; import 'package:flutter_app/ui/provider/mention_cache_provider.dart'; +import 'package:flutter_app/ui/provider/quote_message_provider.dart'; import 'package:flutter_app/ui/provider/setting_provider.dart'; import 'package:flutter_app/widgets/brightness_observer.dart'; import 'package:flutter_app/widgets/message/item/quote_message.dart'; @@ -168,6 +169,39 @@ void main() { notifier.dispose(); }); + testWidgets('double tapping a message starts a quote reply', (tester) async { + final blinkNotifier = BlinkNotifier(tester); + + await tester.pumpWidget( + _MessageTestScope( + blinkNotifier: blinkNotifier, + child: MessageContext.fromMessageItem( + message: testMessage('1'), + child: const MessageQuickReplyDetector( + child: MessageBubble(child: SizedBox(width: 80, height: 20)), + ), + ), + ), + ); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageQuickReplyDetector)), + ); + expect(container.read(quoteMessageProvider), isNull); + + await tester.tap(find.byType(MessageQuickReplyDetector)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.byType(MessageQuickReplyDetector)); + await tester.pump(); + + expect(container.read(quoteMessageProvider)?.messageId, '1'); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpWidget(const SizedBox.shrink()); + blinkNotifier.dispose(); + }); + testWidgets('blink keeps bubble content mounted', (tester) async { final notifier = BlinkNotifier(tester); final mountCount = ValueNotifier(0); From 5d9507c22bdd1a9f2187ac8d03b8d173463067a8 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:14:51 +0900 Subject: [PATCH 20/27] Keep chat input attached to bottom --- lib/ui/home/chat/chat_page.dart | 34 ++++++++++++++---- test/ui/home/chat/chat_page_test.dart | 51 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index fe29365363..b711340e66 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -251,13 +251,8 @@ class ChatContainer extends HookConsumerWidget { child: const ChatContentOverlays(), ), ), - AnimatedCrossFade( - firstChild: const InputContainer(), - secondChild: const SelectionBottomBar(), - crossFadeState: inMultiSelectMode - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 300), + ChatBottomBarSwitcher( + inMultiSelectMode: inMultiSelectMode, ), ], ), @@ -273,3 +268,28 @@ class ChatContainer extends HookConsumerWidget { ); } } + +@visibleForTesting +class ChatBottomBarSwitcher extends StatelessWidget { + const ChatBottomBarSwitcher({ + required this.inMultiSelectMode, + this.inputContainer = const InputContainer(), + this.selectionBottomBar = const SelectionBottomBar(), + super.key, + }); + + final bool inMultiSelectMode; + final Widget inputContainer; + final Widget selectionBottomBar; + + @override + Widget build(BuildContext context) => AnimatedCrossFade( + alignment: Alignment.bottomCenter, + firstChild: inputContainer, + secondChild: selectionBottomBar, + crossFadeState: inMultiSelectMode + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 300), + ); +} diff --git a/test/ui/home/chat/chat_page_test.dart b/test/ui/home/chat/chat_page_test.dart index 0a3fa6d5fb..db1a198711 100644 --- a/test/ui/home/chat/chat_page_test.dart +++ b/test/ui/home/chat/chat_page_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_app/ui/home/chat/chat_history_viewport.dart'; +import 'package:flutter_app/ui/home/chat/chat_page.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -19,4 +20,54 @@ void main() { expect(keysByMessageId['new'], isA()); }, ); + + testWidgets( + 'ChatBottomBarSwitcher keeps the input attached to the bottom while shrinking', + (tester) async { + const viewportKey = Key('viewport'); + const inputKey = Key('input'); + + Future pumpBottomBar(double inputHeight) => tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + key: viewportKey, + width: 320, + height: 240, + child: Column( + children: [ + const Expanded(child: SizedBox()), + ChatBottomBarSwitcher( + inMultiSelectMode: false, + inputContainer: SizedBox( + key: inputKey, + width: 320, + height: inputHeight, + ), + selectionBottomBar: const SizedBox(width: 320, height: 80), + ), + ], + ), + ), + ), + ); + + await pumpBottomBar(140); + await tester.pumpAndSettle(); + + final viewportBottom = tester.getBottomLeft(find.byKey(viewportKey)).dy; + expect( + tester.getBottomLeft(find.byKey(inputKey)).dy, + moreOrLessEquals(viewportBottom), + ); + + await pumpBottomBar(56); + await tester.pump(const Duration(milliseconds: 100)); + + expect( + tester.getBottomLeft(find.byKey(inputKey)).dy, + moreOrLessEquals(viewportBottom), + ); + }, + ); } From 2fb0d9de3223afd72275b7f0ba4c4766fd7423bf Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 24 Jun 2026 15:53:30 +0900 Subject: [PATCH 21/27] Align unread chat anchor with message focus --- lib/ui/home/chat/chat_scroll_coordinator.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index 13d88b25a6..8965ea4177 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -73,8 +73,8 @@ class ChatScrollCoordinator { static const _jumpAnimationCurve = Curves.easeOutCubic; static const _maxAnimatedJumpViewportCount = 3.0; static const _maxAnimatedJumpDistance = 800.0; - static const messageFocusAnchor = 0.3; - static const unreadSeparatorAnchor = 0.36; + static const messageFocusAnchor = 0.2; + static const unreadSeparatorAnchor = messageFocusAnchor; static const loadedJumpViewportCount = 2.0; final scrollController = ScrollController(); From 8c7a1a2536971738203d76cd802b32763fce9f3a Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:18:42 +0900 Subject: [PATCH 22/27] Scroll to latest after outgoing chat sends --- lib/ui/home/chat/files_preview.dart | 19 +++++++++++++++++-- lib/ui/home/chat/input_container.dart | 4 ++++ .../home/chat/voice_recorder_bottom_bar.dart | 2 ++ lib/widgets/sticker_page/sticker_page.dart | 11 +++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/lib/ui/home/chat/files_preview.dart b/lib/ui/home/chat/files_preview.dart index ef33e7b493..91bc436b29 100644 --- a/lib/ui/home/chat/files_preview.dart +++ b/lib/ui/home/chat/files_preview.dart @@ -46,6 +46,7 @@ import '../../provider/conversation_provider.dart'; import '../../provider/quote_message_provider.dart'; import 'image_caption_input.dart'; import 'image_editor.dart'; +import 'message_jump.dart'; part 'files_preview_model.dart'; @@ -53,16 +54,28 @@ Future showFilesPreviewDialog( BuildContext context, List files, ) async { + void jumpToLatestAfterSend() { + if (!context.mounted) return; + unawaited(context.jumpToLatestInChat()); + } + await showMixinDialog( context: context, - child: _FilesPreviewDialog(initialFiles: files.map(_File.auto).toList()), + child: _FilesPreviewDialog( + initialFiles: files.map(_File.auto).toList(), + onFileSent: jumpToLatestAfterSend, + ), ); } class _FilesPreviewDialog extends HookConsumerWidget { - const _FilesPreviewDialog({required this.initialFiles}); + const _FilesPreviewDialog({ + required this.initialFiles, + required this.onFileSent, + }); final List<_File> initialFiles; + final VoidCallback onFileSent; @override Widget build(BuildContext context, WidgetRef ref) { @@ -136,6 +149,7 @@ class _FilesPreviewDialog extends HookConsumerWidget { ); } quoteMessageNotifier.state = null; + onFileSent(); Navigator.pop(context); } else { final zipFilePath = await runLoadBalancer(_archiveFiles, ( @@ -153,6 +167,7 @@ class _FilesPreviewDialog extends HookConsumerWidget { ), ); quoteMessageNotifier.state = null; + onFileSent(); Navigator.pop(context); } } diff --git a/lib/ui/home/chat/input_container.dart b/lib/ui/home/chat/input_container.dart index 293d92c3b4..6c91c77607 100644 --- a/lib/ui/home/chat/input_container.dart +++ b/lib/ui/home/chat/input_container.dart @@ -47,6 +47,7 @@ import '../../provider/quote_message_provider.dart'; import '../../provider/recall_message_reedit_provider.dart'; import '../notifier/chat_side_notifier.dart'; import 'files_preview.dart'; +import 'message_jump.dart'; import 'voice_recorder_bottom_bar.dart'; part 'input_highlight_controller.dart'; @@ -392,6 +393,7 @@ void _sendTextMessage( ); } + unawaited(context.jumpToLatestInChat()); textEditingController.clear(); context.providerContainer.read(quoteMessageProvider.notifier).state = null; } @@ -658,6 +660,7 @@ class _SendActionTypeButton extends HookConsumerWidget { recipientId: conversationState.userId, quoteMessageId: quoteMessage.state?.messageId, ); + unawaited(context.jumpToLatestInChat()); quoteMessage.state = null; }); }, @@ -846,6 +849,7 @@ class _StickerButton extends HookConsumerWidget { tabLength: tabLength, presetStickerGroups: presetStickerGroups, stickerAlbums: stickerAlbums, + onStickerSent: () => unawaited(context.jumpToLatestInChat()), ), ), ), diff --git a/lib/ui/home/chat/voice_recorder_bottom_bar.dart b/lib/ui/home/chat/voice_recorder_bottom_bar.dart index 3af9a59e94..9732bd1dc1 100644 --- a/lib/ui/home/chat/voice_recorder_bottom_bar.dart +++ b/lib/ui/home/chat/voice_recorder_bottom_bar.dart @@ -21,6 +21,7 @@ import '../../../widgets/toast.dart'; import '../../../widgets/waveform_widget.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/quote_message_provider.dart'; +import 'message_jump.dart'; part 'voice_recorder_state.dart'; @@ -339,6 +340,7 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { recipientId: conversationItem.userId, quoteMessageId: quoteMessageId, ); + unawaited(context.jumpToLatestInChat()); }, ), ], diff --git a/lib/widgets/sticker_page/sticker_page.dart b/lib/widgets/sticker_page/sticker_page.dart index bc41d25656..5b6bc7e8e7 100644 --- a/lib/widgets/sticker_page/sticker_page.dart +++ b/lib/widgets/sticker_page/sticker_page.dart @@ -33,6 +33,7 @@ class StickerPage extends StatelessWidget { required this.tabController, required this.presetStickerGroups, required this.stickerAlbums, + this.onStickerSent, super.key, }); @@ -40,6 +41,7 @@ class StickerPage extends StatelessWidget { final int tabLength; final List presetStickerGroups; final List stickerAlbums; + final VoidCallback? onStickerSent; @override Widget build(BuildContext context) => Material( @@ -85,6 +87,7 @@ class StickerPage extends StatelessWidget { duration: kVerySlowThrottleDuration, ), updateUsedAt: false, + onStickerSent: onStickerSent, ); case PresetStickerGroup.favorite: return _StickerAlbumPage( @@ -113,6 +116,7 @@ class StickerPage extends StatelessWidget { } }, canAddSticker: true, + onStickerSent: onStickerSent, ); case PresetStickerGroup.gif: return const AutomaticKeepAliveClientWidget( @@ -121,6 +125,7 @@ class StickerPage extends StatelessWidget { } } return _StickerAlbumPage( + onStickerSent: onStickerSent, getStickers: () { final albumId = stickerAlbums[index - presetStickerGroups.length] @@ -160,6 +165,7 @@ class _StickerAlbumPage extends HookConsumerWidget { this.updateUsedAt = true, this.delete, this.canAddSticker = false, + this.onStickerSent, }); final Stream> Function() getStickers; @@ -167,6 +173,7 @@ class _StickerAlbumPage extends HookConsumerWidget { final bool updateUsedAt; final void Function(Sticker)? delete; final bool canAddSticker; + final VoidCallback? onStickerSent; @override Widget build(BuildContext context, WidgetRef ref) { @@ -197,6 +204,7 @@ class _StickerAlbumPage extends HookConsumerWidget { sticker: stickers[stickerIndex], updateUsedAt: updateUsedAt, delete: delete, + onStickerSent: onStickerSent, ); }, ); @@ -259,11 +267,13 @@ class _StickerAlbumPageItem extends HookConsumerWidget { required this.sticker, required this.updateUsedAt, this.delete, + this.onStickerSent, }); final Sticker sticker; final bool updateUsedAt; final void Function(Sticker)? delete; + final VoidCallback? onStickerSent; @override Widget build(BuildContext context, WidgetRef ref) { @@ -292,6 +302,7 @@ class _StickerAlbumPageItem extends HookConsumerWidget { recipientId: conversationItem.user?.userId, ), ]); + onStickerSent?.call(); }, hoveringDecoration: BoxDecoration( color: context.dynamicColor( From 60244e130a517a785a141a9a15a8e6d3693e0c10 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:10:44 +0900 Subject: [PATCH 23/27] Refactor chat UI state ownership --- lib/core/read_model/mention_read_model.dart | 76 +++++++++++++++++ lib/core/read_model/sticker_read_model.dart | 53 ++++++++++++ lib/ui/home/chat/chat_history_viewport.dart | 74 +++++----------- lib/ui/home/chat/chat_page.dart | 5 +- lib/ui/home/chat/chat_send_outcome.dart | 35 ++++++++ lib/ui/home/chat/chat_timeline_window.dart | 64 ++++++++++++++ lib/ui/home/chat/files_preview.dart | 6 +- lib/ui/home/chat/input_container.dart | 27 +++--- .../home/chat/voice_recorder_bottom_bar.dart | 5 +- .../chat_navigation_intent_provider.dart | 31 +++++++ lib/ui/provider/conversation_provider.dart | 15 ++-- lib/ui/provider/mention_provider.dart | 80 +++++------------ lib/widgets/media_image_pipeline.dart | 79 +++++++++++++++++ lib/widgets/message/message.dart | 55 +++++------- lib/widgets/message/message_presentation.dart | 67 +++++++++++++++ lib/widgets/mixin_image.dart | 57 +++---------- lib/widgets/sticker_page/sticker_page.dart | 34 ++------ test/ui/home/chat/chat_send_outcome_test.dart | 34 ++++++++ .../home/chat/chat_timeline_window_test.dart | 85 +++++++++++++++++++ .../chat_navigation_intent_provider_test.dart | 15 ++++ test/widgets/media_image_pipeline_test.dart | 29 +++++++ .../message/message_presentation_test.dart | 71 ++++++++++++++++ 22 files changed, 755 insertions(+), 242 deletions(-) create mode 100644 lib/core/read_model/mention_read_model.dart create mode 100644 lib/core/read_model/sticker_read_model.dart create mode 100644 lib/ui/home/chat/chat_send_outcome.dart create mode 100644 lib/ui/home/chat/chat_timeline_window.dart create mode 100644 lib/ui/provider/chat_navigation_intent_provider.dart create mode 100644 lib/widgets/message/message_presentation.dart create mode 100644 test/ui/home/chat/chat_send_outcome_test.dart create mode 100644 test/ui/home/chat/chat_timeline_window_test.dart create mode 100644 test/ui/provider/chat_navigation_intent_provider_test.dart create mode 100644 test/widgets/message/message_presentation_test.dart diff --git a/lib/core/read_model/mention_read_model.dart b/lib/core/read_model/mention_read_model.dart new file mode 100644 index 0000000000..6578fe37d1 --- /dev/null +++ b/lib/core/read_model/mention_read_model.dart @@ -0,0 +1,76 @@ +import 'package:rxdart/rxdart.dart'; + +import '../../db/dao/user_dao.dart'; +import '../../db/database_event_bus.dart'; +import '../../db/mixin_database.dart'; + +const kMentionReadModelThrottleDuration = Duration(seconds: 3); + +class MentionReadModel { + MentionReadModel({ + required UserDao userDao, + DataBaseEventBus? eventBus, + }) : _userDao = userDao, + _eventBus = eventBus ?? DataBaseEventBus.instance; + + final UserDao _userDao; + final DataBaseEventBus _eventBus; + + Stream> friends() => _watch( + events: [_eventBus.updateUserIdsStream], + fetch: () => _userDao.friends().get(), + ); + + Stream> groupParticipants({ + required String conversationId, + required String currentUserId, + }) => _watch( + events: [ + _eventBus.watchUpdateParticipantStream(conversationIds: [conversationId]), + ], + fetch: () async { + final users = await _userDao.groupParticipants(conversationId).get(); + return users.where((user) => user.userId != currentUserId).toList(); + }, + ); + + Stream> searchBotGroupUsers({ + required String currentUserId, + required String conversationId, + required String keyword, + }) => _watch( + events: [ + _eventBus.updateUserIdsStream, + _eventBus.insertOrReplaceMessageIdsStream, + _eventBus.deleteMessageIdStream, + ], + fetch: () => _userDao + .fuzzySearchBotGroupUser( + currentUserId: currentUserId, + conversationId: conversationId, + keyword: keyword, + ) + .get(), + ); + + Stream> searchGroupUsers({ + required String currentUserId, + required String conversationId, + required String keyword, + }) => _watch( + events: [ + _eventBus.watchUpdateParticipantStream(conversationIds: [conversationId]), + ], + fetch: () => _userDao + .fuzzySearchGroupUser(currentUserId, conversationId, keyword) + .get(), + ); + + Stream> _watch({ + required Iterable> events, + required Future> Function() fetch, + }) => Rx.merge(events) + .throttleTime(kMentionReadModelThrottleDuration) + .startWith(null) + .asyncMap((_) => fetch()); +} diff --git a/lib/core/read_model/sticker_read_model.dart b/lib/core/read_model/sticker_read_model.dart new file mode 100644 index 0000000000..b15b1f597e --- /dev/null +++ b/lib/core/read_model/sticker_read_model.dart @@ -0,0 +1,53 @@ +import 'package:rxdart/rxdart.dart'; + +import '../../db/dao/sticker_album_dao.dart'; +import '../../db/dao/sticker_dao.dart'; +import '../../db/database_event_bus.dart'; +import '../../db/mixin_database.dart'; + +const kStickerReadModelThrottleDuration = Duration(seconds: 3); + +class StickerReadModel { + StickerReadModel({ + required StickerDao stickerDao, + required StickerAlbumDao stickerAlbumDao, + DataBaseEventBus? eventBus, + }) : _stickerDao = stickerDao, + _stickerAlbumDao = stickerAlbumDao, + _eventBus = eventBus ?? DataBaseEventBus.instance; + + final StickerDao _stickerDao; + final StickerAlbumDao _stickerAlbumDao; + final DataBaseEventBus _eventBus; + + Stream> systemAddedAlbums() => _watchStickerChanges( + fetch: () => _stickerAlbumDao.systemAddedAlbums().get(), + ); + + Stream> recentUsedStickers() => _watchStickerChanges( + fetch: () => _stickerDao.recentUsedStickers().get(), + ); + + Stream> personalStickers() => _watchStickerChanges( + fetch: () => _stickerDao.personalStickers().get(), + ); + + Stream> albumStickers(String albumId) => _watchStickerChanges( + albumIds: [albumId], + fetch: () => _stickerDao.stickerByAlbumId(albumId).get(), + ); + + Stream> _watchStickerChanges({ + required Future> Function() fetch, + Iterable albumIds = const [], + }) { + final events = albumIds.isEmpty + ? _eventBus.updateStickerStream + : _eventBus.watchUpdateStickerStream(albumIds: albumIds); + return events + .map((_) {}) + .throttleTime(kStickerReadModelThrottleDuration) + .startWith(null) + .asyncMap((_) => fetch()); + } +} diff --git a/lib/ui/home/chat/chat_history_viewport.dart b/lib/ui/home/chat/chat_history_viewport.dart index 5441dbace7..3f0e489ea7 100644 --- a/lib/ui/home/chat/chat_history_viewport.dart +++ b/lib/ui/home/chat/chat_history_viewport.dart @@ -14,6 +14,7 @@ import '../../provider/setting_provider.dart'; import '../notifier/message_controller.dart'; import 'chat_jump_trace.dart'; import 'chat_scroll_coordinator.dart'; +import 'chat_timeline_window.dart'; @visibleForTesting void syncMessageGlobalKeys( @@ -45,38 +46,17 @@ class ChatHistoryViewport extends HookConsumerWidget { final messageController = context.read(); final scrollCoordinator = context.read(); final state = useValueListenable(messageController); + final timelineWindow = ChatTimelineWindow(state); final key = ValueKey((state.conversationId, state.refreshKey)); - final top = state.top; - final center = state.center; - final bottom = state.bottom; - final messages = state.list; - final anchorUnreadSeparator = - center != null && - center.messageId == state.lastReadMessageId && - bottom.isNotEmpty; + final messages = timelineWindow.messages; + final rows = timelineWindow.rows; final conversationId = state.conversationId; final isBotGroupConversation = conversationId != null && ref.watch(isBotGroupProvider(conversationId)); final enableShowAvatar = ref.watch( settingProvider.select((value) => value.messageShowAvatar), ); - final rows = useMemoized( - () { - final centerMessage = center; - final renderTop = anchorUnreadSeparator && centerMessage != null - ? [...top, centerMessage] - : top; - final renderCenter = anchorUnreadSeparator ? null : centerMessage; - return MessageRows.from( - top: renderTop, - center: renderCenter, - bottom: bottom, - ); - }, - [top, center, bottom, anchorUnreadSeparator], - ); - final messageKeysRef = useRef>({}); final dayTimeKeysRef = useRef>({}); final previousConversationIdRef = useRef(null); @@ -88,12 +68,10 @@ class ChatHistoryViewport extends HookConsumerWidget { final messageIds = messages.map((e) => e.messageId).toSet(); final messageIdsKey = messages.map((e) => e.messageId).join('|'); - final resetScrollWindow = - previousConversationIdRef.value != state.conversationId || - previousRefreshKeyRef.value != state.refreshKey; - final resetCurrentConversation = - previousConversationIdRef.value == state.conversationId && - previousRefreshKeyRef.value != state.refreshKey; + final resetScrollWindow = timelineWindow.resetScrollWindow( + previousConversationId: previousConversationIdRef.value, + previousRefreshKey: previousRefreshKeyRef.value, + ); useEffect(() { scrollCoordinator.viewportKey = viewportKey; @@ -121,7 +99,7 @@ class ChatHistoryViewport extends HookConsumerWidget { Widget buildMessage(MessageRowModel row) { final message = row.message; final showUnreadBar = - !anchorUnreadSeparator || + !timelineWindow.anchorUnreadSeparator || message.messageId != state.lastReadMessageId; return ChatRenderedMessage( coordinator: scrollCoordinator, @@ -146,18 +124,15 @@ class ChatHistoryViewport extends HookConsumerWidget { useEffect( () { - final restoreAnchor = anchorUnreadSeparator - ? ChatScrollCoordinator.unreadSeparatorAnchor - : ChatScrollCoordinator.messageFocusAnchor; traceChatJump( 'viewport restore-input ' 'conv=${shortMessageId(state.conversationId)} ' 'reset=$resetScrollWindow ' - 'unreadAnchor=$anchorUnreadSeparator ' - 'anchor=${formatDouble(restoreAnchor)} ' + 'unreadAnchor=${timelineWindow.anchorUnreadSeparator} ' + 'anchor=${formatDouble(timelineWindow.scrollAnchor)} ' 'lastRead=${shortMessageId(state.lastReadMessageId)} ' - 'center=${shortMessageId(center?.messageId)} ' - 'top=${top.length} bottom=${bottom.length} ' + 'center=${shortMessageId(state.center?.messageId)} ' + 'top=${state.top.length} bottom=${state.bottom.length} ' 'messages=${messages.length} latest=${state.isLatest}', ); scrollCoordinator.scheduleRestore( @@ -165,14 +140,13 @@ class ChatHistoryViewport extends HookConsumerWidget { keysByMessageId: messageKeysRef.value, reset: resetScrollWindow, isLatest: state.isLatest, - hasCenteredAnchor: anchorUnreadSeparator, - animateLatestReset: - resetCurrentConversation && - state.isLatest && - center == null && - !anchorUnreadSeparator, - centerMessageId: anchorUnreadSeparator ? null : center?.messageId, - traceTargetMessageId: center?.messageId ?? state.lastReadMessageId, + hasCenteredAnchor: timelineWindow.anchorUnreadSeparator, + animateLatestReset: timelineWindow.animateLatestReset( + previousConversationId: previousConversationIdRef.value, + previousRefreshKey: previousRefreshKeyRef.value, + ), + centerMessageId: timelineWindow.restoreCenterMessageId, + traceTargetMessageId: timelineWindow.traceTargetMessageId, ); previousConversationIdRef.value = state.conversationId; previousRefreshKeyRef.value = state.refreshKey; @@ -203,9 +177,7 @@ class ChatHistoryViewport extends HookConsumerWidget { key: viewportKey, center: key, controller: scrollCoordinator.scrollController, - anchor: anchorUnreadSeparator - ? ChatScrollCoordinator.unreadSeparatorAnchor - : ChatScrollCoordinator.messageFocusAnchor, + anchor: timelineWindow.scrollAnchor, physics: const ClampingScrollPhysics(), scrollCacheExtent: const ScrollCacheExtent.viewport( ChatScrollCoordinator.loadedJumpViewportCount, @@ -222,7 +194,7 @@ class ChatHistoryViewport extends HookConsumerWidget { key: key, child: Builder( builder: (context) { - if (anchorUnreadSeparator) { + if (timelineWindow.anchorUnreadSeparator) { return const UnreadMessageBar(); } final row = rows.center; @@ -235,7 +207,7 @@ class ChatHistoryViewport extends HookConsumerWidget { delegate: SliverChildBuilderDelegate((context, index) { final row = rows.bottom[index]; return buildMessage(row); - }, childCount: bottom.length), + }, childCount: rows.bottom.length), ), const SliverToBoxAdapter(child: SizedBox(height: 10)), ], diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index b711340e66..382fd85fa3 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -10,6 +10,7 @@ import 'package:provider/provider.dart'; import '../../../constants/resources.dart'; import '../../../utils/extension/extension.dart'; import '../../../widgets/actions/actions.dart'; +import '../../provider/chat_navigation_intent_provider.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/message_selection_provider.dart'; @@ -176,7 +177,9 @@ class ChatContainer extends HookConsumerWidget { final inMultiSelectMode = ref.watch(hasSelectedMessageProvider); final latestJumpRequestKey = ref.watch( - conversationProvider.select((value) => value?.latestJumpRequestKey), + chatNavigationIntentProvider.select( + (value) => value.latestJumpRequestKey, + ), ); useEffect(() { diff --git a/lib/ui/home/chat/chat_send_outcome.dart b/lib/ui/home/chat/chat_send_outcome.dart new file mode 100644 index 0000000000..47e8707a34 --- /dev/null +++ b/lib/ui/home/chat/chat_send_outcome.dart @@ -0,0 +1,35 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import '../../../utils/extension/extension.dart'; +import '../../provider/quote_message_provider.dart'; +import 'message_jump.dart'; + +@visibleForTesting +class ChatSendOutcome { + const ChatSendOutcome({ + required Future Function() jumpToLatest, + required VoidCallback clearQuote, + }) : _jumpToLatest = jumpToLatest, + _clearQuote = clearQuote; + + final Future Function() _jumpToLatest; + final VoidCallback _clearQuote; + + void complete({bool clearQuote = true}) { + if (clearQuote) _clearQuote(); + unawaited(_jumpToLatest()); + } +} + +extension ChatSendOutcomeBuildContext on BuildContext { + void completeOutgoingChatSend({bool clearQuote = true}) { + if (!mounted) return; + ChatSendOutcome( + clearQuote: () => + providerContainer.read(quoteMessageProvider.notifier).state = null, + jumpToLatest: jumpToLatestInChat, + ).complete(clearQuote: clearQuote); + } +} diff --git a/lib/ui/home/chat/chat_timeline_window.dart b/lib/ui/home/chat/chat_timeline_window.dart new file mode 100644 index 0000000000..fdf82059dc --- /dev/null +++ b/lib/ui/home/chat/chat_timeline_window.dart @@ -0,0 +1,64 @@ +import '../../../db/mixin_database.dart'; +import '../../../widgets/message/message_rows.dart'; +import '../notifier/message_controller.dart'; +import 'chat_scroll_coordinator.dart'; + +class ChatTimelineWindow { + ChatTimelineWindow(this.state) + : anchorUnreadSeparator = + state.center != null && + state.center!.messageId == state.lastReadMessageId && + state.bottom.isNotEmpty { + final centerMessage = state.center; + final renderTop = anchorUnreadSeparator && centerMessage != null + ? [...state.top, centerMessage] + : state.top; + rows = MessageRows.from( + top: renderTop, + center: anchorUnreadSeparator ? null : centerMessage, + bottom: state.bottom, + ); + } + + final MessageState state; + final bool anchorUnreadSeparator; + late final MessageRows rows; + + List get messages => state.list; + + double get scrollAnchor => anchorUnreadSeparator + ? ChatScrollCoordinator.unreadSeparatorAnchor + : ChatScrollCoordinator.messageFocusAnchor; + + String? get restoreCenterMessageId => + anchorUnreadSeparator ? null : state.center?.messageId; + + String? get traceTargetMessageId => + state.center?.messageId ?? state.lastReadMessageId; + + bool resetScrollWindow({ + required String? previousConversationId, + required Object? previousRefreshKey, + }) => + previousConversationId != state.conversationId || + previousRefreshKey != state.refreshKey; + + bool resetCurrentConversation({ + required String? previousConversationId, + required Object? previousRefreshKey, + }) => + previousConversationId == state.conversationId && + previousRefreshKey != state.refreshKey; + + bool animateLatestReset({ + required String? previousConversationId, + required Object? previousRefreshKey, + }) => + resetCurrentConversation( + previousConversationId: previousConversationId, + previousRefreshKey: previousRefreshKey, + ) && + state.isLatest && + state.center == null && + !anchorUnreadSeparator; +} diff --git a/lib/ui/home/chat/files_preview.dart b/lib/ui/home/chat/files_preview.dart index 91bc436b29..4abeaf28d3 100644 --- a/lib/ui/home/chat/files_preview.dart +++ b/lib/ui/home/chat/files_preview.dart @@ -44,9 +44,9 @@ import '../../../widgets/menu.dart'; import '../../../widgets/mixin_image.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/quote_message_provider.dart'; +import 'chat_send_outcome.dart'; import 'image_caption_input.dart'; import 'image_editor.dart'; -import 'message_jump.dart'; part 'files_preview_model.dart'; @@ -56,7 +56,7 @@ Future showFilesPreviewDialog( ) async { void jumpToLatestAfterSend() { if (!context.mounted) return; - unawaited(context.jumpToLatestInChat()); + context.completeOutgoingChatSend(); } await showMixinDialog( @@ -148,7 +148,6 @@ class _FilesPreviewDialog extends HookConsumerWidget { ), ); } - quoteMessageNotifier.state = null; onFileSent(); Navigator.pop(context); } else { @@ -166,7 +165,6 @@ class _FilesPreviewDialog extends HookConsumerWidget { compress: false, ), ); - quoteMessageNotifier.state = null; onFileSent(); Navigator.pop(context); } diff --git a/lib/ui/home/chat/input_container.dart b/lib/ui/home/chat/input_container.dart index 6c91c77607..c86d677440 100644 --- a/lib/ui/home/chat/input_container.dart +++ b/lib/ui/home/chat/input_container.dart @@ -20,7 +20,7 @@ import 'package:super_context_menu/super_context_menu.dart'; import '../../../constants/constants.dart'; import '../../../constants/icon_fonts.dart'; import '../../../constants/resources.dart'; -import '../../../db/database_event_bus.dart'; +import '../../../core/read_model/sticker_read_model.dart'; import '../../../db/mixin_database.dart' hide Offset; import '../../../enum/encrypt_category.dart'; import '../../../utils/app_lifecycle.dart'; @@ -46,8 +46,8 @@ import '../../provider/mention_provider.dart'; import '../../provider/quote_message_provider.dart'; import '../../provider/recall_message_reedit_provider.dart'; import '../notifier/chat_side_notifier.dart'; +import 'chat_send_outcome.dart'; import 'files_preview.dart'; -import 'message_jump.dart'; import 'voice_recorder_bottom_bar.dart'; part 'input_highlight_controller.dart'; @@ -393,9 +393,8 @@ void _sendTextMessage( ); } - unawaited(context.jumpToLatestInChat()); textEditingController.clear(); - context.providerContainer.read(quoteMessageProvider.notifier).state = null; + context.completeOutgoingChatSend(); } class _SendTextField extends HookConsumerWidget { @@ -660,8 +659,7 @@ class _SendActionTypeButton extends HookConsumerWidget { recipientId: conversationState.userId, quoteMessageId: quoteMessage.state?.messageId, ); - unawaited(context.jumpToLatestInChat()); - quoteMessage.state = null; + context.completeOutgoingChatSend(); }); }, ), @@ -781,15 +779,17 @@ class _StickerButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final key = useMemoized(GlobalKey.new); + final stickerReadModel = useMemoized( + () => StickerReadModel( + stickerDao: context.database.stickerDao, + stickerAlbumDao: context.database.stickerAlbumDao, + ), + [context.database], + ); final stickerAlbums = useMemoizedStream( - () => context.database.stickerAlbumDao - .systemAddedAlbums() - .watchWithStream( - eventStreams: [DataBaseEventBus.instance.updateStickerStream], - duration: kVerySlowThrottleDuration, - ), + stickerReadModel.systemAddedAlbums, initialData: const [], ).data ?? const []; @@ -849,7 +849,8 @@ class _StickerButton extends HookConsumerWidget { tabLength: tabLength, presetStickerGroups: presetStickerGroups, stickerAlbums: stickerAlbums, - onStickerSent: () => unawaited(context.jumpToLatestInChat()), + readModel: stickerReadModel, + onStickerSent: context.completeOutgoingChatSend, ), ), ), diff --git a/lib/ui/home/chat/voice_recorder_bottom_bar.dart b/lib/ui/home/chat/voice_recorder_bottom_bar.dart index 9732bd1dc1..994a16e406 100644 --- a/lib/ui/home/chat/voice_recorder_bottom_bar.dart +++ b/lib/ui/home/chat/voice_recorder_bottom_bar.dart @@ -21,7 +21,7 @@ import '../../../widgets/toast.dart'; import '../../../widgets/waveform_widget.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/quote_message_provider.dart'; -import 'message_jump.dart'; +import 'chat_send_outcome.dart'; part 'voice_recorder_state.dart'; @@ -330,7 +330,6 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { if (conversationItem == null) return; final quoteMessageId = ref.read(quoteMessageIdProvider); - ref.read(quoteMessageProvider.notifier).state = null; await accountServer.sendAudioMessage( audioFile.xFile, result.duration, @@ -340,7 +339,7 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { recipientId: conversationItem.userId, quoteMessageId: quoteMessageId, ); - unawaited(context.jumpToLatestInChat()); + context.completeOutgoingChatSend(); }, ), ], diff --git a/lib/ui/provider/chat_navigation_intent_provider.dart b/lib/ui/provider/chat_navigation_intent_provider.dart new file mode 100644 index 0000000000..d9f578da82 --- /dev/null +++ b/lib/ui/provider/chat_navigation_intent_provider.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../utils/rivepod.dart'; + +class ChatNavigationIntentState extends Equatable { + const ChatNavigationIntentState({this.latestJumpRequestKey}); + + final Object? latestJumpRequestKey; + + @override + List get props => [latestJumpRequestKey]; +} + +class ChatNavigationIntentNotifier + extends DistinctStateNotifier { + ChatNavigationIntentNotifier() : super(const ChatNavigationIntentState()); + + void requestLatestJump() { + final latestJumpRequestKey = Object(); + state = ChatNavigationIntentState( + latestJumpRequestKey: latestJumpRequestKey, + ); + } +} + +final chatNavigationIntentProvider = + StateNotifierProvider.autoDispose< + ChatNavigationIntentNotifier, + ChatNavigationIntentState + >((ref) => ChatNavigationIntentNotifier()); diff --git a/lib/ui/provider/conversation_provider.dart b/lib/ui/provider/conversation_provider.dart index 91e663a64b..502c35bde9 100644 --- a/lib/ui/provider/conversation_provider.dart +++ b/lib/ui/provider/conversation_provider.dart @@ -16,6 +16,7 @@ import '../../utils/rivepod.dart'; import '../home/conversation_info_destination.dart'; import '../home/notifier/subscriber_mixin.dart'; import 'account_server_provider.dart'; +import 'chat_navigation_intent_provider.dart'; import 'major_navigation_provider.dart'; class ConversationState extends Equatable { @@ -31,7 +32,6 @@ class ConversationState extends Equatable { this.participant, this.initialSidePage, this.keyword, - this.latestJumpRequestKey, }); final String conversationId; @@ -46,7 +46,6 @@ class ConversationState extends Equatable { final ConversationInfoDestination? initialSidePage; final String? keyword; - final Object? latestJumpRequestKey; bool get isLoaded => conversation != null || user != null; @@ -92,7 +91,6 @@ class ConversationState extends Equatable { initialSidePage, participant, keyword, - latestJumpRequestKey, ]; ConversationState copyWith({ @@ -107,7 +105,6 @@ class ConversationState extends Equatable { Object? refreshKey, ConversationInfoDestination? initialSidePage, String? keyword, - Object? latestJumpRequestKey, }) => ConversationState( conversationId: conversationId ?? this.conversationId, userId: userId ?? this.userId, @@ -120,7 +117,6 @@ class ConversationState extends Equatable { initialSidePage: initialSidePage ?? this.initialSidePage, participant: participant ?? this.participant, keyword: keyword ?? this.keyword, - latestJumpRequestKey: latestJumpRequestKey ?? this.latestJumpRequestKey, ); } @@ -139,7 +135,9 @@ class ConversationStateNotifier ConversationStateNotifier({ required AccountServer accountServer, required MajorNavigationNotifier majorNavigationNotifier, + required ChatNavigationIntentNotifier chatNavigationIntentNotifier, }) : _majorNavigationNotifier = majorNavigationNotifier, + _chatNavigationIntentNotifier = chatNavigationIntentNotifier, _accountServer = accountServer, super(null) { addSubscription( @@ -225,6 +223,7 @@ class ConversationStateNotifier final AccountServer _accountServer; final MajorNavigationNotifier _majorNavigationNotifier; + final ChatNavigationIntentNotifier _chatNavigationIntentNotifier; late final Database _database = _accountServer.database; late final String _currentUserId = _accountServer.userId; @@ -251,7 +250,7 @@ class ConversationStateNotifier } void requestLatestJump() { - state = state?.copyWith(latestJumpRequestKey: Object()); + _chatNavigationIntentNotifier.requestLatestJump(); openChatPage(); } @@ -284,6 +283,9 @@ final conversationProvider = final majorNavigationNotifier = ref.watch( majorNavigationProvider.notifier, ); + final chatNavigationIntentNotifier = ref.watch( + chatNavigationIntentProvider.notifier, + ); ref ..listen(accountServerProvider, (previous, next) => keepAlive.close()) @@ -295,6 +297,7 @@ final conversationProvider = return ConversationStateNotifier( accountServer: accountServerAsync.requireValue, majorNavigationNotifier: majorNavigationNotifier, + chatNavigationIntentNotifier: chatNavigationIntentNotifier, ); }); diff --git a/lib/ui/provider/mention_provider.dart b/lib/ui/provider/mention_provider.dart index 5d766da5e2..51ebc4e482 100644 --- a/lib/ui/provider/mention_provider.dart +++ b/lib/ui/provider/mention_provider.dart @@ -6,10 +6,8 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:rxdart/rxdart.dart'; -import '../../db/dao/user_dao.dart'; -import '../../db/database_event_bus.dart'; +import '../../core/read_model/mention_read_model.dart'; import '../../db/mixin_database.dart'; -import '../../utils/extension/extension.dart'; import '../../utils/reg_exp_utils.dart'; import '../../utils/rivepod.dart'; import '../../widgets/mention_panel.dart'; @@ -39,7 +37,7 @@ class MentionState extends Equatable { class MentionStateNotifier extends DistinctStateNotifier with SubscriberMixin { MentionStateNotifier({ - required this.userDao, + required this.readModel, required this.multiAuthChangeNotifier, required this.textEditingValueStream, required String conversationId, @@ -69,72 +67,40 @@ class MentionStateNotifier extends DistinctStateNotifier } if (keyword.isEmpty) { if (isBot) { - return userDao - .friends() - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateUserIdsStream, - ], - duration: kVerySlowThrottleDuration, - ) - .map((value) => _resultToMentionState(keyword, value)); + return readModel.friends().map( + (value) => _resultToMentionState(keyword, value), + ); } if (isGroup ?? false) { - return userDao - .groupParticipants(conversationId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateParticipantStream( - conversationIds: [conversationId], - ), - ], - duration: kVerySlowThrottleDuration, + return readModel + .groupParticipants( + conversationId: conversationId, + currentUserId: + multiAuthChangeNotifier.current?.userId ?? '', ) .map( - (value) => _resultToMentionState( - keyword, - value..removeWhere( - (element) => - element.userId == - multiAuthChangeNotifier.current!.userId, - ), - ), + (value) => _resultToMentionState(keyword, value), ); } } if (isBot) { - return userDao - .fuzzySearchBotGroupUser( + return readModel + .searchBotGroupUsers( currentUserId: multiAuthChangeNotifier.current?.userId ?? '', conversationId: conversationId, keyword: keyword, ) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateUserIdsStream, - DataBaseEventBus.instance.insertOrReplaceMessageIdsStream, - DataBaseEventBus.instance.deleteMessageIdStream, - ], - duration: kVerySlowThrottleDuration, - ) .map((value) => _resultToMentionState(keyword, value)); } if (isGroup ?? false) { - return userDao - .fuzzySearchGroupUser( - multiAuthChangeNotifier.current?.userId ?? '', - conversationId, - keyword, - ) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateParticipantStream( - conversationIds: [conversationId], - ), - ], - duration: kVerySlowThrottleDuration, + return readModel + .searchGroupUsers( + currentUserId: + multiAuthChangeNotifier.current?.userId ?? '', + conversationId: conversationId, + keyword: keyword, ) .map((value) => _resultToMentionState(keyword, value)); } @@ -145,7 +111,7 @@ class MentionStateNotifier extends DistinctStateNotifier } MentionStateNotifier.idle({ - required this.userDao, + required this.readModel, required this.multiAuthChangeNotifier, required this.textEditingValueStream, }) : super(const MentionState()); @@ -202,7 +168,7 @@ class MentionStateNotifier extends DistinctStateNotifier } } - final UserDao userDao; + final MentionReadModel readModel; final MultiAuthStateNotifier multiAuthChangeNotifier; final Stream textEditingValueStream; @@ -247,14 +213,14 @@ final mentionProvider = StateNotifierProvider.autoDispose if (conversationId == null) { return MentionStateNotifier.idle( - userDao: userDao, + readModel: MentionReadModel(userDao: userDao), multiAuthChangeNotifier: authStateNotifier, textEditingValueStream: stream, ); } return MentionStateNotifier( - userDao: userDao, + readModel: MentionReadModel(userDao: userDao), multiAuthChangeNotifier: authStateNotifier, textEditingValueStream: stream, conversationId: conversationId, diff --git a/lib/widgets/media_image_pipeline.dart b/lib/widgets/media_image_pipeline.dart index 47003fbb9a..b67c86df99 100644 --- a/lib/widgets/media_image_pipeline.dart +++ b/lib/widgets/media_image_pipeline.dart @@ -8,6 +8,8 @@ import 'package:flutter/widgets.dart'; import '../utils/image.dart'; import '../utils/proxy.dart'; +typedef PlaceholderWidgetBuilder = Widget Function(); + final _checkedImageFiles = {}; bool shouldUseMediaImagePipeline( @@ -22,6 +24,83 @@ bool isLikelyGifUrl(String url) { return path.toLowerCase().endsWith('.gif'); } +ImageProvider resolveMediaImageProvider({ + required ImageProvider image, + required ProxyConfig? proxyConfig, + bool normalizeGif = false, +}) { + if (image is NetworkImage && + shouldUseMediaImagePipeline( + image.url, + proxyConfig, + normalizeGif: normalizeGif, + )) { + return ProxyNetworkImage( + image.url, + scale: image.scale, + proxyConfig: proxyConfig, + ); + } + return image; +} + +class MediaImagePipeline extends StatelessWidget { + const MediaImagePipeline({ + required this.image, + super.key, + this.proxyConfig, + this.placeholder, + this.errorBuilder, + this.width, + this.height, + this.fit = BoxFit.cover, + this.isAntiAlias = false, + this.normalizeGif = false, + }); + + final ImageProvider image; + final ProxyConfig? proxyConfig; + final PlaceholderWidgetBuilder? placeholder; + final ImageErrorWidgetBuilder? errorBuilder; + final double? width; + final double? height; + final BoxFit? fit; + final bool isAntiAlias; + final bool normalizeGif; + + @override + Widget build(BuildContext context) { + final resolvedImage = resolveMediaImageProvider( + image: image, + proxyConfig: proxyConfig, + normalizeGif: normalizeGif, + ); + + Widget fallback() => + placeholder?.call() ?? SizedBox(width: width, height: height); + + Widget imageView() => Image( + image: resolvedImage, + width: width, + height: height, + fit: fit, + isAntiAlias: isAntiAlias, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return fallback(); + }, + errorBuilder: (context, error, stackTrace) => + errorBuilder?.call(context, error, stackTrace) ?? fallback(), + ); + + return NormalizedGifImageGate( + image: resolvedImage, + placeholder: fallback, + childBuilder: imageView, + ); + } +} + @immutable class ProxyNetworkImage extends ImageProvider { const ProxyNetworkImage(this.url, {this.scale = 1.0, this.proxyConfig}); diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index 12566711ff..f35f3a7f87 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -3,7 +3,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider, Provider; -import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide Key; import 'package:super_context_menu/super_context_menu.dart'; import 'package:visibility_detector/visibility_detector.dart'; @@ -30,6 +29,7 @@ import 'message_content.dart'; import 'message_context.dart'; import 'message_day_time.dart'; import 'message_name.dart'; +import 'message_presentation.dart'; import 'message_rows.dart'; import 'message_style.dart'; @@ -112,35 +112,27 @@ class MessageItemWidget extends HookConsumerWidget { this.row ?? MessageRowModel(message: this.message, prev: prev, next: next); final message = row.message; - final isCurrentUser = message.relationship == UserRelationship.me; final isGroupOrBotGroupConversation = this.isGroupOrBotGroupConversation ?? - (message.conversionCategory == ConversationCategory.group || - message.userId != message.conversationOwnerId || - ref.watch(isBotGroupProvider(message.conversationId))); + resolveGroupOrBotGroupConversation( + message: message, + isBotGroupConversation: ref.watch( + isBotGroupProvider(message.conversationId), + ), + ); final enableShowAvatar = this.enableShowAvatar ?? ref.watch(settingProvider.select((value) => value.messageShowAvatar)); - final showAvatar = - isGroupOrBotGroupConversation && enableShowAvatar == true; - - final showNip = - !(row.sameUserNext && row.sameDayNext) && - (!showAvatar || isCurrentUser); + final presentation = MessagePresentation.fromRow( + row: row, + isGroupOrBotGroupConversation: isGroupOrBotGroupConversation, + enableShowAvatar: enableShowAvatar == true, + canShowUnreadBar: showUnreadBar, + lastReadMessageId: lastReadMessageId, + ); final datetime = row.dateTime; - String? userName; - String? userId; - String? userAvatarUrl; - - if (isGroupOrBotGroupConversation && - !isCurrentUser && - (!row.sameUserPrev || !row.sameDayPrev)) { - userName = message.userFullName; - userId = message.userId; - userAvatarUrl = message.avatarUrl; - } final showedMenu = useState(false); final focusNode = useFocusScopeNode( @@ -174,11 +166,11 @@ class MessageItemWidget extends HookConsumerWidget { return _MessageSelectionWrapper( message: message, child: _MessageBubbleMargin( - userName: userName, - userId: userId, - userAvatarUrl: userAvatarUrl, - showAvatar: showAvatar, - isCurrentUser: isCurrentUser, + userName: presentation.userName, + userId: presentation.userId, + userAvatarUrl: presentation.userAvatarUrl, + showAvatar: presentation.showAvatar, + isCurrentUser: presentation.isCurrentUser, pinArrowWidth: isPinnedPage ? _pinArrowWidth : 0, isBot: message.isBot, isVerified: message.isVerified, @@ -197,10 +189,7 @@ class MessageItemWidget extends HookConsumerWidget { ); }, ), - if (showUnreadBar && - message.messageId == lastReadMessageId && - row.next != null) - const UnreadMessageBar(), + if (presentation.showUnreadBar) const UnreadMessageBar(), ], ); @@ -223,8 +212,8 @@ class MessageItemWidget extends HookConsumerWidget { child: MessageContext( isTranscriptPage: isTranscriptPage, isPinnedPage: isPinnedPage, - showNip: showNip, - isCurrentUser: isCurrentUser, + showNip: presentation.showNip, + isCurrentUser: presentation.isCurrentUser, message: message, highlightEnabled: blink, menuHighlighted: showedMenu.value, diff --git a/lib/widgets/message/message_presentation.dart b/lib/widgets/message/message_presentation.dart new file mode 100644 index 0000000000..565a39c5f9 --- /dev/null +++ b/lib/widgets/message/message_presentation.dart @@ -0,0 +1,67 @@ +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +import '../../db/mixin_database.dart'; +import 'message_rows.dart'; + +class MessagePresentation { + const MessagePresentation({ + required this.isCurrentUser, + required this.isGroupOrBotGroupConversation, + required this.showAvatar, + required this.showNip, + required this.showUnreadBar, + this.userName, + this.userId, + this.userAvatarUrl, + }); + + factory MessagePresentation.fromRow({ + required MessageRowModel row, + required bool isGroupOrBotGroupConversation, + required bool enableShowAvatar, + required bool canShowUnreadBar, + required String? lastReadMessageId, + }) { + final message = row.message; + final isCurrentUser = message.relationship == UserRelationship.me; + final showAvatar = isGroupOrBotGroupConversation && enableShowAvatar; + final showNip = + !(row.sameUserNext && row.sameDayNext) && + (!showAvatar || isCurrentUser); + final showSender = + isGroupOrBotGroupConversation && + !isCurrentUser && + (!row.sameUserPrev || !row.sameDayPrev); + + return MessagePresentation( + isCurrentUser: isCurrentUser, + isGroupOrBotGroupConversation: isGroupOrBotGroupConversation, + showAvatar: showAvatar, + showNip: showNip, + showUnreadBar: + canShowUnreadBar && + message.messageId == lastReadMessageId && + row.next != null, + userName: showSender ? message.userFullName : null, + userId: showSender ? message.userId : null, + userAvatarUrl: showSender ? message.avatarUrl : null, + ); + } + + final bool isCurrentUser; + final bool isGroupOrBotGroupConversation; + final bool showAvatar; + final bool showNip; + final bool showUnreadBar; + final String? userName; + final String? userId; + final String? userAvatarUrl; +} + +bool resolveGroupOrBotGroupConversation({ + required MessageItem message, + required bool isBotGroupConversation, +}) => + message.conversionCategory == ConversationCategory.group || + message.userId != message.conversationOwnerId || + isBotGroupConversation; diff --git a/lib/widgets/mixin_image.dart b/lib/widgets/mixin_image.dart index bd07e50ffe..bfc5f76503 100644 --- a/lib/widgets/mixin_image.dart +++ b/lib/widgets/mixin_image.dart @@ -6,8 +6,6 @@ import 'package:flutter/widgets.dart'; import '../utils/extension/extension.dart'; import 'media_image_pipeline.dart'; -typedef PlaceholderWidgetBuilder = Widget Function(); - class MixinImage extends StatelessWidget { const MixinImage({ required this.image, @@ -79,48 +77,15 @@ class MixinImage extends StatelessWidget { final bool normalizeGif; @override - Widget build(BuildContext context) { - final resolvedImage = _resolveImage(context); - - Widget fallback() => - placeholder?.call() ?? SizedBox(width: width, height: height); - - Widget imageView() => Image( - image: resolvedImage, - width: width, - height: height, - fit: fit, - isAntiAlias: isAntiAlias, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded || frame != null) return child; - return fallback(); - }, - errorBuilder: (context, error, stackTrace) => - errorBuilder?.call(context, error, stackTrace) ?? fallback(), - ); - - return NormalizedGifImageGate( - image: resolvedImage, - placeholder: fallback, - childBuilder: imageView, - ); - } - - ImageProvider _resolveImage(BuildContext context) { - final image = this.image; - final proxyConfig = context.database.settingProperties.activatedProxy; - if (image is NetworkImage && - shouldUseMediaImagePipeline( - image.url, - proxyConfig, - normalizeGif: normalizeGif, - )) { - return ProxyNetworkImage( - image.url, - scale: image.scale, - proxyConfig: proxyConfig, - ); - } - return image; - } + Widget build(BuildContext context) => MediaImagePipeline( + image: image, + proxyConfig: context.database.settingProperties.activatedProxy, + placeholder: placeholder, + errorBuilder: errorBuilder, + width: width, + height: height, + fit: fit, + isAntiAlias: isAntiAlias, + normalizeGif: normalizeGif, + ); } diff --git a/lib/widgets/sticker_page/sticker_page.dart b/lib/widgets/sticker_page/sticker_page.dart index 5b6bc7e8e7..5d8f24065a 100644 --- a/lib/widgets/sticker_page/sticker_page.dart +++ b/lib/widgets/sticker_page/sticker_page.dart @@ -9,7 +9,7 @@ import 'package:super_context_menu/super_context_menu.dart'; import '../../account/account_key_value.dart'; import '../../constants/icon_fonts.dart'; import '../../constants/resources.dart'; -import '../../db/database_event_bus.dart'; +import '../../core/read_model/sticker_read_model.dart'; import '../../db/mixin_database.dart'; import '../../ui/provider/conversation_provider.dart'; import '../../utils/extension/extension.dart'; @@ -33,6 +33,7 @@ class StickerPage extends StatelessWidget { required this.tabController, required this.presetStickerGroups, required this.stickerAlbums, + required this.readModel, this.onStickerSent, super.key, }); @@ -41,6 +42,7 @@ class StickerPage extends StatelessWidget { final int tabLength; final List presetStickerGroups; final List stickerAlbums; + final StickerReadModel readModel; final VoidCallback? onStickerSent; @override @@ -78,27 +80,13 @@ class StickerPage extends StatelessWidget { ); case PresetStickerGroup.recent: return _StickerAlbumPage( - getStickers: () => context.database.stickerDao - .recentUsedStickers() - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateStickerStream, - ], - duration: kVerySlowThrottleDuration, - ), + getStickers: readModel.recentUsedStickers, updateUsedAt: false, onStickerSent: onStickerSent, ); case PresetStickerGroup.favorite: return _StickerAlbumPage( - getStickers: () => context.database.stickerDao - .personalStickers() - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateStickerStream, - ], - duration: kVerySlowThrottleDuration, - ), + getStickers: readModel.personalStickers, delete: (sticker) { final ctx = Navigator.of(context).context; showToastLoading(context: ctx); @@ -130,17 +118,7 @@ class StickerPage extends StatelessWidget { final albumId = stickerAlbums[index - presetStickerGroups.length] .albumId; - return context.database.stickerDao - .stickerByAlbumId(albumId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance - .watchUpdateStickerStream( - albumIds: [albumId], - ), - ], - duration: kVerySlowThrottleDuration, - ); + return readModel.albumStickers(albumId); }, ); }), diff --git a/test/ui/home/chat/chat_send_outcome_test.dart b/test/ui/home/chat/chat_send_outcome_test.dart new file mode 100644 index 0000000000..c21227b539 --- /dev/null +++ b/test/ui/home/chat/chat_send_outcome_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_app/ui/home/chat/chat_send_outcome.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('complete clears quote and requests latest', () async { + var clearedQuote = false; + var requestedLatest = false; + + ChatSendOutcome( + clearQuote: () => clearedQuote = true, + jumpToLatest: () async => requestedLatest = true, + ).complete(); + + await Future.delayed(Duration.zero); + + expect(clearedQuote, true); + expect(requestedLatest, true); + }); + + test('complete can preserve quote while requesting latest', () async { + var clearedQuote = false; + var requestedLatest = false; + + ChatSendOutcome( + clearQuote: () => clearedQuote = true, + jumpToLatest: () async => requestedLatest = true, + ).complete(clearQuote: false); + + await Future.delayed(Duration.zero); + + expect(clearedQuote, false); + expect(requestedLatest, true); + }); +} diff --git a/test/ui/home/chat/chat_timeline_window_test.dart b/test/ui/home/chat/chat_timeline_window_test.dart new file mode 100644 index 0000000000..67394122a0 --- /dev/null +++ b/test/ui/home/chat/chat_timeline_window_test.dart @@ -0,0 +1,85 @@ +import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/ui/home/chat/chat_scroll_coordinator.dart'; +import 'package:flutter_app/ui/home/chat/chat_timeline_window.dart'; +import 'package:flutter_app/ui/home/notifier/message_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +void main() { + test('unread center is rendered in top and replaced by separator anchor', () { + final center = testMessage(10); + final window = ChatTimelineWindow( + MessageState( + conversationId: 'conversation', + top: [testMessage(1)], + center: center, + bottom: [testMessage(11)], + lastReadMessageId: center.messageId, + ), + ); + + expect(window.anchorUnreadSeparator, true); + expect(window.scrollAnchor, ChatScrollCoordinator.unreadSeparatorAnchor); + expect(window.restoreCenterMessageId, isNull); + expect(window.rows.center, isNull); + expect(window.rows.top.map((row) => row.message.messageId), ['1', '10']); + }); + + test('ordinary center remains the restore target', () { + final center = testMessage(10); + final window = ChatTimelineWindow( + MessageState( + conversationId: 'conversation', + top: [testMessage(1)], + center: center, + bottom: [testMessage(11)], + lastReadMessageId: '2', + ), + ); + + expect(window.anchorUnreadSeparator, false); + expect(window.scrollAnchor, ChatScrollCoordinator.messageFocusAnchor); + expect(window.restoreCenterMessageId, center.messageId); + expect(window.rows.center?.message.messageId, center.messageId); + }); + + test('latest reset animation is limited to same conversation refreshes', () { + final refreshKey = Object(); + final window = ChatTimelineWindow( + MessageState( + conversationId: 'conversation', + bottom: [testMessage(11)], + isLatest: true, + refreshKey: refreshKey, + ), + ); + + expect( + window.animateLatestReset( + previousConversationId: 'conversation', + previousRefreshKey: Object(), + ), + true, + ); + expect( + window.animateLatestReset( + previousConversationId: 'other', + previousRefreshKey: refreshKey, + ), + false, + ); + }); +} + +MessageItem testMessage(int index) => MessageItem( + messageId: '$index', + conversationId: 'conversation', + type: 'PLAIN_TEXT', + createdAt: DateTime(2026, 1, 1, 12, index), + status: MessageStatus.read, + userId: 'user', + userIdentityNumber: '0', + isVerified: false, + sharedUserIsVerified: false, + pinned: false, +); diff --git a/test/ui/provider/chat_navigation_intent_provider_test.dart b/test/ui/provider/chat_navigation_intent_provider_test.dart new file mode 100644 index 0000000000..e72442cd39 --- /dev/null +++ b/test/ui/provider/chat_navigation_intent_provider_test.dart @@ -0,0 +1,15 @@ +import 'package:flutter_app/ui/provider/chat_navigation_intent_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('requestLatestJump emits a fresh one-shot key each time', () { + final notifier = ChatNavigationIntentNotifier()..requestLatestJump(); + final firstKey = notifier.state.latestJumpRequestKey; + + notifier.requestLatestJump(); + + expect(firstKey, isNotNull); + expect(notifier.state.latestJumpRequestKey, isNotNull); + expect(notifier.state.latestJumpRequestKey, isNot(same(firstKey))); + }); +} diff --git a/test/widgets/media_image_pipeline_test.dart b/test/widgets/media_image_pipeline_test.dart index 2278b87c9d..9cc807f6b4 100644 --- a/test/widgets/media_image_pipeline_test.dart +++ b/test/widgets/media_image_pipeline_test.dart @@ -37,6 +37,35 @@ void main() { ); }); + test('resolves network image provider only when pipeline is needed', () { + const png = NetworkImage('https://example.com/a.png'); + expect( + resolveMediaImageProvider(image: png, proxyConfig: null), + same(png), + ); + + expect( + resolveMediaImageProvider( + image: const NetworkImage('https://example.com/a.gif'), + proxyConfig: null, + ), + isA(), + ); + + expect( + resolveMediaImageProvider( + image: const NetworkImage('https://example.com/a.png'), + proxyConfig: ProxyConfig( + type: ProxyType.http, + host: '127.0.0.1', + port: 8080, + id: 'test', + ), + ), + isA(), + ); + }); + testWidgets('skips file images with empty paths', (tester) async { await tester.pumpWidget( Directionality( diff --git a/test/widgets/message/message_presentation_test.dart b/test/widgets/message/message_presentation_test.dart new file mode 100644 index 0000000000..f3c7abfbc4 --- /dev/null +++ b/test/widgets/message/message_presentation_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/widgets/message/message_presentation.dart'; +import 'package:flutter_app/widgets/message/message_rows.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +void main() { + test('shows sender for first incoming group message', () { + final message = testMessage(1, userId: 'alice', userFullName: 'Alice'); + final presentation = MessagePresentation.fromRow( + row: MessageRowModel(message: message), + isGroupOrBotGroupConversation: true, + enableShowAvatar: true, + canShowUnreadBar: false, + lastReadMessageId: null, + ); + + expect(presentation.isCurrentUser, false); + expect(presentation.showAvatar, true); + expect(presentation.userName, 'Alice'); + expect(presentation.userId, 'alice'); + expect(presentation.showNip, false); + }); + + test('hides repeated sender in the same incoming run', () { + final previous = testMessage(1, userId: 'alice', userFullName: 'Alice'); + final message = testMessage(2, userId: 'alice', userFullName: 'Alice'); + final next = testMessage(3, userId: 'alice', userFullName: 'Alice'); + final presentation = MessagePresentation.fromRow( + row: MessageRowModel(message: message, prev: previous, next: next), + isGroupOrBotGroupConversation: true, + enableShowAvatar: true, + canShowUnreadBar: false, + lastReadMessageId: null, + ); + + expect(presentation.userName, isNull); + expect(presentation.showNip, false); + }); + + test('marks unread bar only when the row has a newer message', () { + final message = testMessage(2); + final presentation = MessagePresentation.fromRow( + row: MessageRowModel(message: message, next: testMessage(3)), + isGroupOrBotGroupConversation: false, + enableShowAvatar: false, + canShowUnreadBar: true, + lastReadMessageId: message.messageId, + ); + + expect(presentation.showUnreadBar, true); + }); +} + +MessageItem testMessage( + int index, { + String userId = 'user', + String userFullName = 'User', +}) => MessageItem( + messageId: '$index', + conversationId: 'conversation', + type: 'PLAIN_TEXT', + createdAt: DateTime(2026, 1, 1, 12, index), + status: MessageStatus.read, + userId: userId, + userFullName: userFullName, + userIdentityNumber: '0', + isVerified: false, + sharedUserIsVerified: false, + pinned: false, +); From 8916d8fd5e506b7dd532f8b5d781cc94040c3236 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:20:48 +0900 Subject: [PATCH 24/27] Fix mention cache prewarm lifetime --- lib/db/dao/user_dao.dart | 30 +++ lib/ui/home/notifier/message_controller.dart | 18 +- lib/ui/provider/mention_cache_provider.dart | 196 ++++++++++-------- lib/widgets/mention_panel.dart | 10 +- .../provider/mention_cache_provider_test.dart | 88 ++++++++ .../widgets/message/message_context_test.dart | 70 ++++++- 6 files changed, 313 insertions(+), 99 deletions(-) diff --git a/lib/db/dao/user_dao.dart b/lib/db/dao/user_dao.dart index f66bac9477..21ad6256f7 100644 --- a/lib/db/dao/user_dao.dart +++ b/lib/db/dao/user_dao.dart @@ -82,6 +82,36 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { ..where((tbl) => tbl.userId.equals(userId)) ..limit(1); + Future> mentionUsersByUserIds( + Iterable userIds, + ) async { + final ids = userIds.toSet().toList(); + if (ids.isEmpty) return const []; + + final result = []; + for (var offset = 0; offset < ids.length; offset += 800) { + final chunk = ids.skip(offset).take(800).toList(); + result.addAll( + await (selectOnly(db.users) + ..addColumns([ + db.users.userId, + db.users.identityNumber, + db.users.fullName, + ]) + ..where(db.users.userId.isIn(chunk))) + .map( + (row) => MentionUser( + userId: row.read(db.users.userId)!, + identityNumber: row.read(db.users.identityNumber)!, + fullName: row.read(db.users.fullName), + ), + ) + .get(), + ); + } + return result; + } + Selectable userFullNameByUserId(String userId) => (selectOnly(db.users) ..addColumns([db.users.fullName]) diff --git a/lib/ui/home/notifier/message_controller.dart b/lib/ui/home/notifier/message_controller.dart index 36bf1b074e..cbbd88f54b 100644 --- a/lib/ui/home/notifier/message_controller.dart +++ b/lib/ui/home/notifier/message_controller.dart @@ -304,6 +304,14 @@ class MessageController extends ValueNotifier { ); } + Future _preCacheMentionSafely(MessageState state) async { + try { + await _preCacheMention(state); + } catch (error) { + e('preCacheMention failed: $error'); + } + } + @override void dispose() { _disposed = true; @@ -318,10 +326,12 @@ class MessageController extends ValueNotifier { generation == _generation && conversationNotifier.state?.conversationId == conversationId; - void _emit(MessageState nextState) { + void _emit(MessageState nextState, {bool warmMentionCache = true}) { if (_disposed) return; value = nextState; - _warmMentionCache(nextState); + if (warmMentionCache) { + _warmMentionCache(nextState); + } } void _init({ @@ -398,7 +408,9 @@ class MessageController extends ValueNotifier { 'lastRead=${shortMessageId(nextState.lastReadMessageId)} ' '${_formatWindow(nextState)}', ); - _emit(nextState); + await _preCacheMentionSafely(nextState); + if (!_isCurrent(generation, conversationId)) return; + _emit(nextState, warmMentionCache: false); } MessageWindowAnchor _resolveWindowAnchor({ diff --git a/lib/ui/provider/mention_cache_provider.dart b/lib/ui/provider/mention_cache_provider.dart index 4c7db9a721..1520a127f2 100644 --- a/lib/ui/provider/mention_cache_provider.dart +++ b/lib/ui/provider/mention_cache_provider.dart @@ -1,103 +1,64 @@ +import 'dart:async'; + import 'package:ecache/ecache.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mixin_logger/mixin_logger.dart'; import '../../db/dao/user_dao.dart'; +import '../../db/database_event_bus.dart'; +import '../../db/mixin_database.dart'; import '../../utils/extension/extension.dart'; import '../../utils/reg_exp_utils.dart'; import 'database_provider.dart'; class MentionCache { - MentionCache(this._userDao); + MentionCache(this._userDao, {DataBaseEventBus? eventBus}) { + final userDao = _userDao; + if (userDao == null) return; + + _updateUserSubscription = (eventBus ?? DataBaseEventBus.instance) + .updateUserIdsStream + .listen(( + userIds, + ) { + unawaited( + _refreshUsers(userIds).catchError(( + Object error, + StackTrace stackTrace, + ) { + e('mention user cache refresh failed: $error'); + }), + ); + }); + } final UserDao? _userDao; - final _contentMentionLruCache = LruCache>( + StreamSubscription>? _updateUserSubscription; + + final _contentMentionLruCache = LruCache>( storage: SimpleStorage(), capacity: 1024 * 4, ); final _userLruCache = LruCache( storage: SimpleStorage(), - capacity: 1024, + capacity: 1024 * 8, ); + final _cachedUserIds = {}; Map mentionCache(String? content) { if (content == null) return {}; - return _contentMentionLruCache.get(content.hashCode) ?? {}; + return _cachedIdentityNumbers(_identityNumbersForContent(content)); } Future> checkMentionCache( Set _contents, ) async { - final userDao = _userDao; - if (userDao == null) return {}; - - final map = {}; - final noCacheContents = {}; - - final contents = _contents.nonNulls.toSet(); - - for (final element in contents) { - final cache = _contentMentionLruCache.get(element.hashCode); - if (cache != null) { - map.addAll(cache); - } else { - noCacheContents.add(element); - } - } - - if (noCacheContents.isEmpty) return map; - - final noCacheContentUserIdMap = Map.fromEntries( - noCacheContents.map( - (e) => MapEntry( - e, - mentionNumberRegExp.allMatchesAndSort(e).map((e) => e[1]!), - ), - ), - ); - - var userNumbers = { - for (final item in noCacheContentUserIdMap.values) ...item, + final userNumbers = { + for (final content in _contents.nonNulls) + ..._identityNumbersForContent(content), }; - - if (userNumbers.isEmpty) { - for (final e in noCacheContents) { - _contentMentionLruCache.set(e.hashCode, {}); - } - } else { - userNumbers = userNumbers - .where((element) => _userLruCache.get(element) == null) - .toSet(); - - final list = await userDao - .userByIdentityNumbers(userNumbers.toList()) - .get(); - - list.where((element) => element.fullName?.isNotEmpty ?? false).forEach(( - element, - ) { - _userLruCache.set(element.identityNumber, element); - map[element.identityNumber] = element; - }); - - for (final element in noCacheContentUserIdMap.entries) { - element.value.forEach((element) { - if (map[element] != null) return; - final mentionUser = _userLruCache.get(element); - if (mentionUser == null) return; - map[element] = mentionUser; - }); - - _contentMentionLruCache.set( - element.key.hashCode, - Map.fromEntries( - map.entries.where((entry) => element.value.contains(entry.key)), - ), - ); - } - } - - return map; + return checkIdentityNumbers(userNumbers); } String? replaceMention(String? s, Map _mentionMap) { @@ -119,39 +80,90 @@ class MentionCache { MentionUser? identityNumberCache(String identityNumber) => _userLruCache.get(identityNumber); + void cacheUsers(Iterable users) { + cacheMentionUsers(users.map(_mentionUserFromUser)); + } + + void cacheMentionUsers(Iterable users) { + for (final user in users) { + if (user.fullName?.isNotEmpty != true) { + _userLruCache.remove(user.identityNumber); + _cachedUserIds.remove(user.userId); + continue; + } + _userLruCache.set(user.identityNumber, user); + _cachedUserIds.add(user.userId); + } + } + + Future _refreshUsers(Iterable userIds) async { + final userDao = _userDao; + if (userDao == null) return; + + final cachedUserIds = userIds.where(_cachedUserIds.contains).toSet(); + if (cachedUserIds.isEmpty) return; + + cacheMentionUsers(await userDao.mentionUsersByUserIds(cachedUserIds)); + } + + void dispose() { + unawaited(_updateUserSubscription?.cancel()); + _updateUserSubscription = null; + } + Future> checkIdentityNumbers( Set identityNumbers, ) async { - final userDao = _userDao; - if (userDao == null) return {}; - final toChecks = identityNumbers .where((element) => _userLruCache.get(element) == null) .toSet(); - final mentionUsers = identityNumbers.map(_userLruCache.get).nonNulls; - final map = Map.fromIterables( - mentionUsers.map((e) => e.identityNumber), - mentionUsers, - ); + final map = _cachedIdentityNumbers(identityNumbers); - if (toChecks.isEmpty) return map; + final userDao = _userDao; + if (toChecks.isEmpty || userDao == null) return map; final list = await userDao.userByIdentityNumbers(toChecks.toList()).get(); - list.where((element) => element.fullName?.isNotEmpty ?? false).forEach(( - element, - ) { - _userLruCache.set(element.identityNumber, element); - map[element.identityNumber] = element; - }); + cacheMentionUsers(list); + map.addAll(_cachedIdentityNumbers(toChecks)); return map; } + + List _identityNumbersForContent(String content) { + final cache = _contentMentionLruCache.get(content); + if (cache != null) return cache; + + final identityNumbers = mentionNumberRegExp + .allMatchesAndSort(content) + .map((e) => e[1]!) + .toSet() + .toList(growable: false); + _contentMentionLruCache.set(content, identityNumbers); + return identityNumbers; + } + + Map _cachedIdentityNumbers( + Iterable identityNumbers, + ) => Map.fromEntries( + identityNumbers + .map(_userLruCache.get) + .nonNulls + .map((user) => MapEntry(user.identityNumber, user)), + ); + + MentionUser _mentionUserFromUser(User user) => MentionUser( + userId: user.userId, + identityNumber: user.identityNumber, + fullName: user.fullName, + ); } -final mentionCacheProvider = Provider.autoDispose( - (ref) => MentionCache( +final mentionCacheProvider = Provider((ref) { + final mentionCache = MentionCache( ref.watch(databaseProvider.select((value) => value.valueOrNull?.userDao)), - ), -); + ); + ref.onDispose(mentionCache.dispose); + return mentionCache; +}); diff --git a/lib/widgets/mention_panel.dart b/lib/widgets/mention_panel.dart index ca8f131ee7..ea62168411 100644 --- a/lib/widgets/mention_panel.dart +++ b/lib/widgets/mention_panel.dart @@ -10,6 +10,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../db/mixin_database.dart' hide Offset; import '../ui/home/intent.dart'; import '../ui/provider/conversation_provider.dart'; +import '../ui/provider/mention_cache_provider.dart'; import '../ui/provider/mention_provider.dart'; import '../utils/extension/extension.dart'; import '../utils/platform.dart'; @@ -84,7 +85,7 @@ class MentionPanelPortalEntry extends HookConsumerWidget { ListSelectionSelectedIntent: CallbackAction( onInvoke: (intent) { final state = ref.read(mentionProviderInstance); - _select(state.users[state.index]); + _select(state.users[state.index], ref.read(mentionCacheProvider)); }, ), }, @@ -113,7 +114,8 @@ class MentionPanelPortalEntry extends HookConsumerWidget { child: _MentionPanel( mentionState: mentionState, scrollController: scrollController, - onSelect: _select, + onSelect: (user) => + _select(user, ref.read(mentionCacheProvider)), ), ), ), @@ -123,7 +125,9 @@ class MentionPanelPortalEntry extends HookConsumerWidget { ); } - void _select(User user) { + void _select(User user, MentionCache mentionCache) { + mentionCache.cacheUsers([user]); + final selectionOffset = max(textEditingController.selection.baseOffset, 0); final text = textEditingController.text; diff --git a/test/ui/provider/mention_cache_provider_test.dart b/test/ui/provider/mention_cache_provider_test.dart index 1fbd6b51e5..db8f802a39 100644 --- a/test/ui/provider/mention_cache_provider_test.dart +++ b/test/ui/provider/mention_cache_provider_test.dart @@ -1,5 +1,10 @@ +import 'package:drift/native.dart'; +import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/ui/provider/database_provider.dart'; import 'package:flutter_app/ui/provider/mention_cache_provider.dart'; +import 'package:flutter_app/utils/event_bus.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; void main() { test('replaceMention preserves empty content', () { @@ -7,4 +12,87 @@ void main() { expect(mentionCache.replaceMention('', {}), ''); }); + + test( + 'checkMentionCache reflects user name updates for cached content', + () async { + EventBus.initialize(); + + final database = MixinDatabase(NativeDatabase.memory()); + addTearDown(database.close); + + final mentionCache = MentionCache(database.userDao); + addTearDown(mentionCache.dispose); + + const content = 'hello @7001'; + + await database.userDao.insert( + const User(userId: 'user-1', identityNumber: '7001', fullName: 'Alice'), + ); + + expect( + (await mentionCache.checkMentionCache({content}))['7001']?.fullName, + 'Alice', + ); + + await database.userDao.insert( + const User(userId: 'user-1', identityNumber: '7001', fullName: 'Bob'), + ); + await pumpEventQueue(); + + expect( + (await mentionCache.checkMentionCache({content}))['7001']?.fullName, + 'Bob', + ); + + await database.userDao.insert( + const User(userId: 'user-1', identityNumber: '7001', fullName: ''), + ); + await pumpEventQueue(); + + expect((await mentionCache.checkMentionCache({content}))['7001'], isNull); + }, + ); + + test('cacheUsers makes mention users synchronously available', () { + final mentionCache = MentionCache(null) + ..cacheUsers([ + const User( + userId: 'user-1', + identityNumber: '7001', + fullName: 'Alice', + ), + ]); + + expect(mentionCache.identityNumberCache('7001')?.fullName, 'Alice'); + expect(mentionCache.mentionCache('hello @7001')['7001']?.fullName, 'Alice'); + }); + + test( + 'mentionCacheProvider preserves cache across read-only consumers', + () async { + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWith((ref) => DatabaseOpener()), + ], + ); + addTearDown(container.dispose); + + final first = container.read(mentionCacheProvider) + ..cacheUsers([ + const User( + userId: 'user-1', + identityNumber: '7001', + fullName: 'Alice', + ), + ]); + + await container.pump(); + + final second = container.read(mentionCacheProvider); + + expect(identical(first, second), isTrue); + expect(second.mentionCache('hello @7001')['7001']?.fullName, 'Alice'); + }, + ); } diff --git a/test/widgets/message/message_context_test.dart b/test/widgets/message/message_context_test.dart index 788a6027eb..9f224f9acf 100644 --- a/test/widgets/message/message_context_test.dart +++ b/test/widgets/message/message_context_test.dart @@ -6,7 +6,9 @@ import 'package:flutter_app/ui/provider/database_provider.dart'; import 'package:flutter_app/ui/provider/mention_cache_provider.dart'; import 'package:flutter_app/ui/provider/quote_message_provider.dart'; import 'package:flutter_app/ui/provider/setting_provider.dart'; +import 'package:flutter_app/utils/hook.dart'; import 'package:flutter_app/widgets/brightness_observer.dart'; +import 'package:flutter_app/widgets/high_light_text.dart'; import 'package:flutter_app/widgets/message/item/quote_message.dart'; import 'package:flutter_app/widgets/message/message.dart'; import 'package:flutter_app/widgets/message/message_action_policy.dart'; @@ -14,7 +16,7 @@ import 'package:flutter_app/widgets/message/message_bubble.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide User; import 'package:provider/provider.dart' as provider; void main() { @@ -236,6 +238,42 @@ void main() { expect(shouldMarkMentionRead(true, 1), isFalse); expect(shouldMarkMentionRead(null, 1), isFalse); }); + + testWidgets( + 'prewarmed mention provider renders display name on first frame', + ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + databaseProvider.overrideWith((ref) => DatabaseOpener()), + settingProvider.overrideWith((ref) => SettingChangeNotifier()), + ], + ); + addTearDown(container.dispose); + + container.read(mentionCacheProvider).cacheUsers([ + const User(userId: 'user-1', identityNumber: '7001', fullName: 'Alice'), + ]); + await container.pump(); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: BrightnessData( + value: 0, + brightnessThemeData: lightBrightnessThemeData, + child: _MentionTextHarness(content: 'hello @7001'), + ), + ), + ), + ); + + expect(_richTextPlainText(tester), isNot(contains('@7001'))); + expect(_richTextPlainText(tester), contains('@Alice')); + }, + ); } class _MessageIdText extends HookWidget { @@ -267,6 +305,36 @@ class _MountCounterState extends State<_MountCounter> { Widget build(BuildContext context) => const SizedBox(width: 80, height: 20); } +class _MentionTextHarness extends HookConsumerWidget { + const _MentionTextHarness({required this.content}); + + final String content; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mentionCache = ref.read(mentionCacheProvider); + final mentionMap = useMemoizedFuture( + () => mentionCache.checkMentionCache({content}), + mentionCache.mentionCache(content), + keys: [content], + ).requireData; + + return CustomText( + content, + textMatchers: [ + MentionTextMatcher(context, mentionMap), + ], + ); + } +} + +String _richTextPlainText(WidgetTester tester) => tester + .widgetList( + find.byWidgetPredicate((widget) => widget is RichText), + ) + .map((widget) => widget.text.toPlainText()) + .join('\n'); + MessageItem testMessage( String id, { String userId = 'user', From 8dd27d8f40bdadf528597c39e834b08e7422d731 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:21:30 +0900 Subject: [PATCH 25/27] Fix unread separator state without unread messages --- lib/ui/home/notifier/message_controller.dart | 20 ++++++++++---- .../notifier/message_controller_test.dart | 26 +++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 test/ui/home/notifier/message_controller_test.dart diff --git a/lib/ui/home/notifier/message_controller.dart b/lib/ui/home/notifier/message_controller.dart index cbbd88f54b..bda224c921 100644 --- a/lib/ui/home/notifier/message_controller.dart +++ b/lib/ui/home/notifier/message_controller.dart @@ -101,6 +101,16 @@ final class RestoreMessageWindowAnchor extends MessageWindowAnchor { List get props => [messageId, offset]; } +@visibleForTesting +String? resolveMessageWindowLastReadMessageId({ + required String? requestedLastReadMessageId, + required int? unseenMessageCount, + required String? conversationLastReadMessageId, +}) { + if (requestedLastReadMessageId != null) return requestedLastReadMessageId; + return (unseenMessageCount ?? 0) > 0 ? conversationLastReadMessageId : null; +} + class MessageState extends Equatable { MessageState({ this.top = const [], @@ -371,10 +381,11 @@ class MessageController extends ValueNotifier { requestedCenterMessageId: centerMessageId, forceLatest: forceLatest, ); - final resolvedLastReadMessageId = - lastReadMessageId ?? - conversation?.lastReadMessageId ?? - state.lastReadMessageId; + final resolvedLastReadMessageId = resolveMessageWindowLastReadMessageId( + requestedLastReadMessageId: lastReadMessageId, + unseenMessageCount: conversation?.unseenMessageCount, + conversationLastReadMessageId: conversation?.lastReadMessageId, + ); traceChatJump( 'message init ' @@ -575,7 +586,6 @@ class MessageController extends ValueNotifier { } final currentUserSent = item.relationship == UserRelationship.me; - if (state.isLatest) { if (center?.createdAt.isBefore(item.createdAt) ?? true) { bottom = [...bottom, item] diff --git a/test/ui/home/notifier/message_controller_test.dart b/test/ui/home/notifier/message_controller_test.dart new file mode 100644 index 0000000000..4bf5cb030e --- /dev/null +++ b/test/ui/home/notifier/message_controller_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_app/ui/home/notifier/message_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('does not expose a database read point as an unread boundary', () { + expect( + resolveMessageWindowLastReadMessageId( + requestedLastReadMessageId: null, + unseenMessageCount: 0, + conversationLastReadMessageId: 'read-point', + ), + isNull, + ); + }); + + test('uses the conversation read point when there are unread messages', () { + expect( + resolveMessageWindowLastReadMessageId( + requestedLastReadMessageId: null, + unseenMessageCount: 1, + conversationLastReadMessageId: 'read-point', + ), + 'read-point', + ); + }); +} From 57b2fe7c3778dee5f102e6c9f47884689330dfa0 Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Thu, 25 Jun 2026 15:54:06 +0900 Subject: [PATCH 26/27] Keep latest scroll pinned during inserts --- lib/ui/home/chat/chat_scroll_coordinator.dart | 38 +++++++++++--- .../chat/chat_scroll_coordinator_test.dart | 51 +++++++++++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/lib/ui/home/chat/chat_scroll_coordinator.dart b/lib/ui/home/chat/chat_scroll_coordinator.dart index 8965ea4177..29f2cbede2 100644 --- a/lib/ui/home/chat/chat_scroll_coordinator.dart +++ b/lib/ui/home/chat/chat_scroll_coordinator.dart @@ -99,7 +99,9 @@ class ChatScrollCoordinator { bool _blockPreloadUntilUserScroll = false; bool _disposed = false; int _programmaticScrollDepth = 0; + int _programmaticBottomScrollDepth = 0; int _jumpButtonFrozenUpdates = 0; + bool _followTailAfterProgrammaticScroll = false; void captureViewportState( List messages, @@ -397,7 +399,12 @@ class ChatScrollCoordinator { return; } - if (scrollController.position.isScrollingNotifier.value) return; + if (scrollController.position.isScrollingNotifier.value) { + if (_programmaticBottomScrollDepth > 0 && request.isLatest) { + _followTailAfterProgrammaticScroll = true; + } + return; + } if (_tailFollowEligible) { unawaited( @@ -495,14 +502,19 @@ class ChatScrollCoordinator { bool animated = false, ChatScrollRestoreDirection? animationDirection, VoidCallback? prepareAnimationStart, - }) { + }) async { _clearJumpButtonFreeze(); - return _jumpToClamped( - scrollController.position.maxScrollExtent, - animated: animated, - animationDirection: animated ? animationDirection : null, - prepareAnimationStart: prepareAnimationStart, - ); + _programmaticBottomScrollDepth++; + try { + await _jumpToClamped( + scrollController.position.maxScrollExtent, + animated: animated, + animationDirection: animated ? animationDirection : null, + prepareAnimationStart: prepareAnimationStart, + ); + } finally { + _programmaticBottomScrollDepth--; + } } Future _jumpToClamped( @@ -596,6 +608,16 @@ class ChatScrollCoordinator { traceChatJump( 'jump done ${formatScrollMetrics(scrollController.position)}', ); + if (_programmaticScrollDepth == 0 && + _followTailAfterProgrammaticScroll) { + _followTailAfterProgrammaticScroll = false; + unawaited( + _jumpToBottom( + animated: true, + prepareAnimationStart: _restoreTailFollowAnimationStart, + ), + ); + } } _scheduleViewportStateUpdate(); } diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index bb928f9ef5..275aef270a 100644 --- a/test/ui/home/chat/chat_scroll_coordinator_test.dart +++ b/test/ui/home/chat/chat_scroll_coordinator_test.dart @@ -551,6 +551,57 @@ void main() { expect(coordinator.trackingScrollController.jumpCount, 0); }); + testWidgets('latest request follows new bottom during animation', ( + tester, + ) async { + final coordinator = ChatScrollCoordinator(); + final messages = List.generate(20, testMessage); + final nextMessages = [...messages, testMessage(100)]; + final keysByMessageId = { + for (final message in nextMessages) message.messageId: GlobalKey(), + }; + + addTearDown(coordinator.dispose); + await pumpFullyBuiltScrollableMessages( + tester, + coordinator, + messages, + keysByMessageId, + ); + coordinator.scrollController.jumpTo( + coordinator.scrollController.position.maxScrollExtent - 120, + ); + await tester.pump(); + coordinator.captureViewportState(messages, keysByMessageId); + + unawaited(coordinator.scrollToBottomIfInLoadedWindow(animated: true)); + await tester.pump(const Duration(milliseconds: 100)); + expect( + coordinator.scrollController.position.isScrollingNotifier.value, + true, + ); + + await pumpFullyBuiltScrollableMessages( + tester, + coordinator, + nextMessages, + keysByMessageId, + ); + coordinator.scheduleRestore( + messages: nextMessages, + keysByMessageId: keysByMessageId, + reset: false, + isLatest: true, + ); + + await tester.pumpAndSettle(); + + expect( + coordinator.scrollController.offset, + coordinator.scrollController.position.maxScrollExtent, + ); + }); + testWidgets( 'scheduleRestore restores tail-follow start in centered viewports', (tester) async { From d97de2a6b882a68f874f2f892a7c780681015bbc Mon Sep 17 00:00:00 2001 From: YeungKC <11473691+YeungKC@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:55:00 +0900 Subject: [PATCH 27/27] Fix quick reply double tap hit testing --- lib/widgets/message/message.dart | 82 ++++++- .../widgets/message/message_context_test.dart | 210 +++++++++++++++++- 2 files changed, 285 insertions(+), 7 deletions(-) diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index f35f3a7f87..b13749bcc2 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -1,4 +1,6 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' @@ -54,7 +56,7 @@ void _quickReply(BuildContext context) { } @visibleForTesting -class MessageQuickReplyDetector extends StatelessWidget { +class MessageQuickReplyDetector extends HookWidget { const MessageQuickReplyDetector({ required this.child, super.key, @@ -63,11 +65,79 @@ class MessageQuickReplyDetector extends StatelessWidget { final Widget child; @override - Widget build(BuildContext context) => GestureDetector( - behavior: HitTestBehavior.translucent, - onDoubleTap: () => _quickReply(context), - child: child, - ); + Widget build(BuildContext context) { + final lastPrimaryDownTime = useRef(null); + final lastPrimaryDownPosition = useRef(null); + + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + if ((event.buttons & kPrimaryButton) == 0 || + _hitsSelectableText(context, event.position)) { + lastPrimaryDownTime.value = null; + lastPrimaryDownPosition.value = null; + return; + } + + final previousTime = lastPrimaryDownTime.value; + final previousPosition = lastPrimaryDownPosition.value; + lastPrimaryDownTime.value = event.timeStamp; + lastPrimaryDownPosition.value = event.position; + if (previousTime == null || previousPosition == null) return; + + final withinTimeout = + event.timeStamp - previousTime <= kDoubleTapTimeout; + final withinSlop = + (event.position - previousPosition).distance <= kDoubleTapSlop; + if (!withinTimeout || !withinSlop) return; + + lastPrimaryDownTime.value = null; + lastPrimaryDownPosition.value = null; + _quickReply(context); + }, + child: child, + ); + } +} + +bool _hitsSelectableText(BuildContext context, Offset globalPosition) { + var hit = false; + + void visit(Element element) { + if (hit) return; + final renderObject = element.renderObject; + if (renderObject is RenderParagraph && + renderObject.registrar != null && + renderObject.attached && + _paragraphHitsText(renderObject, globalPosition)) { + hit = true; + return; + } + element.visitChildren(visit); + } + + context.visitChildElements(visit); + return hit; +} + +bool _paragraphHitsText(RenderParagraph paragraph, Offset globalPosition) { + final localPosition = paragraph.globalToLocal(globalPosition); + if (!paragraph.paintBounds.contains(localPosition)) return false; + + final text = paragraph.text.toPlainText(includeSemanticsLabels: false); + if (text.isEmpty) return false; + + final offset = paragraph.getPositionForOffset(localPosition).offset; + for (final index in {offset, offset - 1}) { + if (index < 0 || index >= text.length) continue; + if (text.substring(index, index + 1).trim().isEmpty) continue; + + final boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: index, extentOffset: index + 1), + ); + if (boxes.any((box) => box.toRect().contains(localPosition))) return true; + } + return false; } class MessageItemWidget extends HookConsumerWidget { diff --git a/test/widgets/message/message_context_test.dart b/test/widgets/message/message_context_test.dart index 9f224f9acf..680ca305dd 100644 --- a/test/widgets/message/message_context_test.dart +++ b/test/widgets/message/message_context_test.dart @@ -1,6 +1,7 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_app/constants/brightness_theme_data.dart'; -import 'package:flutter_app/db/mixin_database.dart'; +import 'package:flutter_app/db/mixin_database.dart' hide Offset; import 'package:flutter_app/ui/home/notifier/blink_notifier.dart'; import 'package:flutter_app/ui/provider/database_provider.dart'; import 'package:flutter_app/ui/provider/mention_cache_provider.dart'; @@ -10,6 +11,9 @@ import 'package:flutter_app/utils/hook.dart'; import 'package:flutter_app/widgets/brightness_observer.dart'; import 'package:flutter_app/widgets/high_light_text.dart'; import 'package:flutter_app/widgets/message/item/quote_message.dart'; +import 'package:flutter_app/widgets/message/item/text/selectable.dart' + as message_selectable; +import 'package:flutter_app/widgets/message/item/text/text_message.dart'; import 'package:flutter_app/widgets/message/message.dart'; import 'package:flutter_app/widgets/message/message_action_policy.dart'; import 'package:flutter_app/widgets/message/message_bubble.dart'; @@ -204,6 +208,210 @@ void main() { blinkNotifier.dispose(); }); + testWidgets('double tapping tappable bubble content starts a quote reply', ( + tester, + ) async { + const bodyKey = ValueKey('tappable-body'); + final blinkNotifier = BlinkNotifier(tester); + + await tester.pumpWidget( + _MessageTestScope( + blinkNotifier: blinkNotifier, + child: MessageContext.fromMessageItem( + message: testMessage('1'), + child: MessageQuickReplyDetector( + child: MessageBubble( + child: GestureDetector( + key: bodyKey, + behavior: HitTestBehavior.opaque, + onTap: () {}, + child: const SizedBox(width: 80, height: 20), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageQuickReplyDetector)), + ); + expect(container.read(quoteMessageProvider), isNull); + + await tester.tap(find.byKey(bodyKey)); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tap(find.byKey(bodyKey)); + await tester.pump(); + + expect(container.read(quoteMessageProvider)?.messageId, '1'); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpWidget(const SizedBox.shrink()); + blinkNotifier.dispose(); + }); + + testWidgets('double tapping selectable text does not start a quote reply', ( + tester, + ) async { + final blinkNotifier = BlinkNotifier(tester); + final selectedText = ValueNotifier(null); + final focusNode = FocusNode(debugLabel: 'test_selection_focus'); + addTearDown(focusNode.dispose); + addTearDown(selectedText.dispose); + + await tester.pumpWidget( + _MessageTestScope( + blinkNotifier: blinkNotifier, + child: MessageContext.fromMessageItem( + message: testMessage('1'), + child: MessageQuickReplyDetector( + child: MessageBubble( + child: message_selectable.SelectableRegion( + focusNode: focusNode, + selectionControls: desktopTextSelectionHandleControls, + contextMenuBuilder: (context, state) => const SizedBox(), + onSelectionChanged: (content) => + selectedText.value = content?.plainText, + child: const CustomText('hello world'), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageQuickReplyDetector)), + ); + + final text = find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText() == 'hello world', + ); + + final firstWordPosition = + tester.getRect(text).centerLeft + const Offset(12, 0); + + await tester.tapAt(firstWordPosition, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(firstWordPosition, kind: PointerDeviceKind.mouse); + await tester.pump(); + + expect(container.read(quoteMessageProvider), isNull); + expect(selectedText.value, 'hello'); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpWidget(const SizedBox.shrink()); + blinkNotifier.dispose(); + }); + + testWidgets('double tapping outside selectable text starts a quote reply', ( + tester, + ) async { + const outsideTextKey = ValueKey('outside-selectable-text'); + final blinkNotifier = BlinkNotifier(tester); + final focusNode = FocusNode(debugLabel: 'test_selection_focus'); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + _MessageTestScope( + blinkNotifier: blinkNotifier, + child: MessageContext.fromMessageItem( + message: testMessage('1'), + child: MessageQuickReplyDetector( + child: MessageBubble( + child: MessageSelectionArea( + focusNode: focusNode, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + CustomText('hello world'), + SizedBox( + key: outsideTextKey, + width: 48, + height: 24, + ), + ], + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageQuickReplyDetector)), + ); + expect(container.read(quoteMessageProvider), isNull); + + final outsideTextPosition = tester.getCenter(find.byKey(outsideTextKey)); + + await tester.tapAt(outsideTextPosition, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(outsideTextPosition, kind: PointerDeviceKind.mouse); + await tester.pump(); + + expect(container.read(quoteMessageProvider)?.messageId, '1'); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpWidget(const SizedBox.shrink()); + blinkNotifier.dispose(); + }); + + testWidgets('double tapping selectable line end starts a quote reply', ( + tester, + ) async { + final blinkNotifier = BlinkNotifier(tester); + + await tester.pumpWidget( + _MessageTestScope( + blinkNotifier: blinkNotifier, + child: MessageContext.fromMessageItem( + message: testMessage('1'), + child: const MessageQuickReplyDetector( + child: MessageBubble( + child: MessageSelectionArea( + child: SizedBox( + width: 240, + child: CustomText('first line\nx'), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(MessageQuickReplyDetector)), + ); + final text = find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText() == 'first line\nx', + ); + final textRect = tester.getRect(text); + final secondLineEnd = Offset( + textRect.right - 8, + textRect.top + textRect.height * 0.75, + ); + + await tester.tapAt(secondLineEnd, kind: PointerDeviceKind.mouse); + await tester.pump(const Duration(milliseconds: 50)); + await tester.tapAt(secondLineEnd, kind: PointerDeviceKind.mouse); + await tester.pump(); + + expect(container.read(quoteMessageProvider)?.messageId, '1'); + + await tester.pump(const Duration(milliseconds: 50)); + await tester.pumpWidget(const SizedBox.shrink()); + blinkNotifier.dispose(); + }); + testWidgets('blink keeps bubble content mounted', (tester) async { final notifier = BlinkNotifier(tester); final mountCount = ValueNotifier(0);