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/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 d06c1e0845..563a09f941 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'; @@ -26,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'; @@ -112,16 +112,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, @@ -138,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 { @@ -233,9 +238,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 +251,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/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/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 d0473646cf..0000000000 --- a/lib/bloc/paging/paging_bloc.dart +++ /dev/null @@ -1,273 +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) { - emit(state.copyWith(hasData: await queryHasData())); - - final offset = event.offset; - - final count = await queryCount(); - - emit( - state.copyWith( - map: await queryMap(limit, offset), - count: count, - 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); - - 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/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/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/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/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..b17f9c8bae 100644 --- a/lib/db/dao/message_dao.dart +++ b/lib/db/dao/message_dao.dart @@ -1127,6 +1127,42 @@ class MessageDao extends DatabaseAccessor (_, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => Limit(limit, 0), ); + Future> beforeMessageIdsByConversationId( + MessageOrderInfo anchor, + String conversationId, + int limit, + ) 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, String conversationId, @@ -1144,6 +1180,42 @@ class MessageDao extends DatabaseAccessor ]), ); + Future> afterMessageIdsByConversationId( + MessageOrderInfo anchor, + String conversationId, + int limit, + ) 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( (message, _, _, _, _, _, _, _, _, _, _, _, _, _, em) => 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/db/dao/user_dao.dart b/lib/db/dao/user_dao.dart index ee1eaac359..21ad6256f7 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; }); @@ -84,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/main.dart b/lib/main.dart index 7702a94160..29de4f3a96 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,14 +17,14 @@ 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/home/desktop_shell_layout.dart'; import 'ui/setting/log_page.dart'; import 'utils/app_lifecycle.dart'; 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/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/bloc/message_bloc.dart b/lib/ui/home/bloc/message_bloc.dart deleted file mode 100644 index 2332d3d60e..0000000000 --- a/lib/ui/home/bloc/message_bloc.dart +++ /dev/null @@ -1,616 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:equatable/equatable.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'; -import '../../../db/mixin_database.dart'; -import '../../../enum/message_category.dart'; -import '../../../utils/app_lifecycle.dart'; -import '../../../utils/extension/extension.dart'; -import '../../provider/conversation_provider.dart'; -import '../../provider/mention_cache_provider.dart'; -import '../chat/chat_jump_trace.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 [], - this.center, - this.bottom = const [], - this.conversationId, - this.isLatest = false, - this.isOldest = false, - this.lastReadMessageId, - this.refreshKey, - }) { - // check top, center, bottom didn't has same messageId - assert(() { - final ids = {}; - for (final item in list) { - if (ids.contains(item.messageId)) { - e('MessageState has same messageId: ${item.messageId}'); - } - ids.add(item.messageId); - } - return true; - }()); - } - - final String? conversationId; - final List top; - final MessageItem? center; - final List bottom; - final bool isLatest; - final bool isOldest; - final String? lastReadMessageId; - final Object? refreshKey; - - @override - List get props => [ - conversationId, - top, - center, - bottom, - isLatest, - isOldest, - lastReadMessageId, - refreshKey, - ]; - - MessageItem? get bottomMessage => - bottom.lastOrNull ?? center ?? top.lastOrNull; - - MessageItem? get topMessage => - top.firstOrNull ?? center ?? bottom.firstOrNull; - - bool get isEmpty => top.isEmpty && center == null && bottom.isEmpty; - - List get list => [ - ...top, - ?center, - ...bottom, - ]; - - MessageState copyWith({ - String? conversationId, - List? top, - MessageItem? center, - List? bottom, - bool? isLatest, - bool? isOldest, - String? lastReadMessageId, - Object? refreshKey, - }) => MessageState( - conversationId: conversationId ?? this.conversationId, - top: top ?? this.top, - center: center ?? this.center, - bottom: bottom ?? this.bottom, - isLatest: isLatest ?? this.isLatest, - 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, - ); - - MessageState removeMessage(String messageId) { - if (center?.messageId == messageId) { - return MessageState( - conversationId: conversationId, - top: top, - bottom: bottom, - isLatest: isLatest, - isOldest: isOldest, - lastReadMessageId: lastReadMessageId, - refreshKey: refreshKey, - ); - } - - bool include(MessageItem message) => message.messageId == messageId; - bool exclusive(MessageItem message) => message.messageId != messageId; - - if (top.any(include)) { - return copyWith(top: top.where(exclusive).toList()); - } - - if (bottom.any(include)) { - return copyWith(bottom: bottom.where(exclusive).toList()); - } - - return this; - } -} - -class MessageBloc extends Bloc<_MessageEvent, MessageState> - with SubscribeMixin { - MessageBloc({ - 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), - ); - - 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)); - }); - }), - ); - } - - final ConversationStateNotifier conversationNotifier; - final Database database; - final MentionCache mentionCache; - final AccountServer accountServer; - int limit; - - MessageDao get messageDao => database.messageDao; - - Future _preCacheMention(MessageState state) async { - final set = {...state.top, state.center, ...state.bottom}; - await mentionCache.checkMentionCache( - { - ...set.map((e) => e?.content), - ...set.map((e) => e?.quoteContent), - }.nonNulls.toSet(), - ); - } - - Future _onEvent(_MessageEvent event, Emitter emit) async { - // Avoid value change - 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) { - 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, - ); - await _preCacheMention(messageState); - emit( - _pretreatment( - messageState.copyWith( - refreshKey: Object(), - lastReadMessageId: event.lastReadMessageId, - ), - ), - ); - } else if (event is _MessageDeleteEvent) { - final messageState = state.removeMessage(event.messageId); - emit(_pretreatment(messageState)); - } else { - if (event is _MessageLoadMoreEvent) { - if (event is _MessageLoadAfterEvent) { - if (state.isLatest) return; - final messageState = await _after(conversationId); - await _preCacheMention(messageState); - emit(_pretreatment(messageState)); - } else if (event is _MessageLoadBeforeEvent) { - if (state.isOldest) return; - final messageState = await _before(conversationId); - await _preCacheMention(messageState); - emit(_pretreatment(messageState)); - } - } else if (event is _MessageInsertOrReplaceEvent) { - final result = _insertOrReplace(conversationId, event.data); - if (result != null) { - await _preCacheMention(result); - emit(_pretreatment(result)); - } - } else if (event is _MessageScrollEvent) { - traceChatJump( - 'bloc scroll event target=${shortMessageId(event.messageId)}', - ); - add( - _MessageInitEvent( - centerMessageId: event.messageId, - lastReadMessageId: state.lastReadMessageId, - ), - ); - } - } - } - - void after() => add(_MessageLoadAfterEvent()); - - void before() => add(_MessageLoadBeforeEvent()); - - 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 _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 _resetMessageList( - String conversationId, - int limit, { - String? centerMessageId, - bool forceLatest = false, - }) async { - final conversation = conversationNotifier.state?.conversation; - final _centerMessageId = forceLatest - ? null - : centerMessageId ?? - ((conversation?.unseenMessageCount ?? 0) > 0 - ? conversation?.lastReadMessageId - : null); - - if (centerMessageId != null || forceLatest) { - traceChatJump( - 'reset list requested=${shortMessageId(centerMessageId)} ' - 'resolved=${shortMessageId(_centerMessageId)} ' - 'forceLatest=$forceLatest unseen=${conversation?.unseenMessageCount}', - ); - } - - final state = await _messagesByConversationId( - conversationId, - limit, - centerMessageId: _centerMessageId, - ); - - return state.copyWith( - conversationId: conversationId, - center: state.center, - bottom: state.bottom, - top: state.top, - ); - } - - Future _messagesByConversationId( - 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, - ); - } - - MessageState? _insertOrReplace( - String conversationId, - List list, - ) { - var top = state.top.toList(); - var center = state.center; - var bottom = state.bottom.toList(); - - var jumpToCurrent = false; - for (final item in list) { - if (item.conversationId != conversationId) continue; - - if (item.messageId == center?.messageId) { - center = item; - continue; - } - - final topIndex = top.indexWhere( - (element) => element.messageId == item.messageId, - ); - if (topIndex > -1) { - top[topIndex] = item; - continue; - } - - final bottomIndex = bottom.indexWhere( - (element) => element.messageId == item.messageId, - ); - if (bottomIndex > -1) { - bottom[bottomIndex] = item; - continue; - } - - // if don't have messages or older message after then valid item - if (state.topMessage?.type != MessageCategory.secret && - (state.topMessage?.createdAt.isAfter(item.createdAt) ?? false)) { - continue; - } - - final currentUserSent = item.relationship == UserRelationship.me; - - if (state.isLatest) { - if (center?.createdAt.isBefore(item.createdAt) ?? true) { - bottom = [...bottom, item] - ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); - } else { - top = [item, ...top] - ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); - } - jumpToCurrent = jumpToCurrent || currentUserSent; - } else { - if (currentUserSent && item.status == MessageStatus.sending) { - add(_MessageInitEvent()); - return null; - } - } - } - - final result = state.copyWith(top: top, center: center, bottom: bottom); - - if (jumpToCurrent) { - return result._copyWithJumpCurrentState(); - } - return result; - } - - void scrollTo(String messageId) { - traceChatJump('bloc scrollTo target=${shortMessageId(messageId)}'); - add(_MessageScrollEvent(messageId: messageId)); - } - - void reload() { - add(_MessageInitEvent()); - } - - void jumpToLatestWindow() { - add( - _MessageInitEvent( - lastReadMessageId: state.lastReadMessageId, - forceLatest: true, - ), - ); - } - - MessageState _pretreatment(MessageState messageState) { - List? top; - // check secretMessage - if (messageState.isOldest && conversationNotifier.state?.isBot == false) { - if (messageState.top.firstOrNull?.type == MessageCategory.secret) { - messageState.top.remove(messageState.top.first); - } - - top = [ - MessageItem( - sharedUserIdentityNumber: '', - status: MessageStatus.read, - messageId: '', - conversationId: '', - userIdentityNumber: '', - participantUserId: '', - userId: '', - type: MessageCategory.secret, - createdAt: messageState.topMessage?.createdAt ?? DateTime.now(), - pinned: false, - isVerified: false, - sharedUserIsVerified: false, - ), - ...messageState.top, - ]; - } - final _messageState = messageState.copyWith(top: top); - if (isAppActive) { - accountServer.markRead(conversationNotifier.state!.conversationId); - } - return _messageState; - } -} diff --git a/lib/ui/home/chat/chat_bar.dart b/lib/ui/home/chat/chat_bar.dart index 6e516f8960..2fb15c35ba 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'; @@ -5,7 +7,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 +16,11 @@ 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 '../conversation_info_destination.dart'; +import '../desktop_shell_layout.dart'; +import '../notifier/chat_side_notifier.dart'; class ChatBar extends HookConsumerWidget { const ChatBar({super.key}); @@ -27,15 +28,10 @@ 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 routeMode = ref.watch(navigatorRouteModeProvider); + final chatSideNotifier = context.read(); + useValueListenable(chatSideNotifier); + final chatSideRouteMode = DesktopShellLayout.chatSideRouteModeOf(context); + final routeMode = DesktopShellLayout.mainRouteModeOf(context); final conversation = ref.watch(conversationProvider); @@ -51,7 +47,7 @@ class ChatBar extends HookConsumerWidget { if (inMultiSelectMode) { return; } - chatSideCubit.toggleInfoPage(); + chatSideNotifier.toggleInfoPage(); }, onLongPress: longPressToShareLog ? (details) { @@ -142,12 +138,9 @@ 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(); - } - cubit.replace(ChatSideCubit.searchMessageHistory); + context.read().toggleDestination( + ConversationInfoDestination.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_content_overlays.dart b/lib/ui/home/chat/chat_content_overlays.dart new file mode 100644 index 0000000000..09356f6892 --- /dev/null +++ b/lib/ui/home/chat/chat_content_overlays.dart @@ -0,0 +1,226 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../account/scam_warning_key_value.dart'; +import '../../../account/show_pin_message_key_value.dart'; +import '../../../constants/resources.dart'; +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/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 'chat_jump_buttons.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: () { + context.read().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, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} 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..3f0e489ea7 --- /dev/null +++ b/lib/ui/home/chat/chat_history_viewport.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.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/message/message.dart'; +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'; +import 'chat_timeline_window.dart'; + +@visibleForTesting +void syncMessageGlobalKeys( + Map keysByMessageId, + Set messageIds, { + GlobalKey Function(String messageId) createKey = _messageGlobalKey, +}) { + keysByMessageId.removeWhere( + (messageId, _) => !messageIds.contains(messageId), + ); + for (final messageId in messageIds) { + keysByMessageId.putIfAbsent( + 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}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messageController = context.read(); + final scrollCoordinator = context.read(); + final state = useValueListenable(messageController); + final timelineWindow = ChatTimelineWindow(state); + + final key = ValueKey((state.conversationId, state.refreshKey)); + 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 messageKeysRef = useRef>({}); + 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('|'); + final resetScrollWindow = timelineWindow.resetScrollWindow( + previousConversationId: previousConversationIdRef.value, + previousRefreshKey: previousRefreshKeyRef.value, + ); + + useEffect(() { + scrollCoordinator.viewportKey = viewportKey; + return () => scrollCoordinator.detachViewportKey(viewportKey); + }, [scrollCoordinator, viewportKey]); + + if (!resetScrollWindow) { + scrollCoordinator.captureViewportState(messages, messageKeysRef.value); + } + + useMemoized(() { + syncMessageGlobalKeys(messageKeysRef.value, messageIds); + syncMessageGlobalKeys( + dayTimeKeysRef.value, + messageIds, + createKey: _messageDayTimeKey, + ); + }, [messageIdsKey]); + + final dayTimeEntries = useMemoized( + () => _dayTimeEntries(rows, messageKeysRef.value, dayTimeKeysRef.value), + [rows, messageIdsKey], + ); + + Widget buildMessage(MessageRowModel row) { + final message = row.message; + final showUnreadBar = + !timelineWindow.anchorUnreadSeparator || + message.messageId != state.lastReadMessageId; + return ChatRenderedMessage( + coordinator: scrollCoordinator, + messageId: message.messageId, + child: MessageItemWidget( + key: messageKeysRef.value[message.messageId], + row: row, + message: message, + lastReadMessageId: state.lastReadMessageId, + showUnreadBar: showUnreadBar, + dateTimeKey: row.dateTime == null + ? null + : dayTimeKeysRef.value[message.messageId], + isGroupOrBotGroupConversation: _isGroupOrBotGroupMessage( + message, + isBotGroupConversation, + ), + enableShowAvatar: enableShowAvatar, + ), + ); + } + + useEffect( + () { + traceChatJump( + 'viewport restore-input ' + 'conv=${shortMessageId(state.conversationId)} ' + 'reset=$resetScrollWindow ' + 'unreadAnchor=${timelineWindow.anchorUnreadSeparator} ' + 'anchor=${formatDouble(timelineWindow.scrollAnchor)} ' + 'lastRead=${shortMessageId(state.lastReadMessageId)} ' + 'center=${shortMessageId(state.center?.messageId)} ' + 'top=${state.top.length} bottom=${state.bottom.length} ' + 'messages=${messages.length} latest=${state.isLatest}', + ); + scrollCoordinator.scheduleRestore( + messages: messages, + keysByMessageId: messageKeysRef.value, + reset: resetScrollWindow, + isLatest: state.isLatest, + 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; + 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: viewportKey, + center: key, + controller: scrollCoordinator.scrollController, + anchor: timelineWindow.scrollAnchor, + physics: const ClampingScrollPhysics(), + scrollCacheExtent: const ScrollCacheExtent.viewport( + ChatScrollCoordinator.loadedJumpViewportCount, + ), + slivers: [ + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final actualIndex = rows.top.length - index - 1; + final row = rows.top[actualIndex]; + return buildMessage(row); + }, childCount: rows.top.length), + ), + SliverToBoxAdapter( + key: key, + child: Builder( + builder: (context) { + if (timelineWindow.anchorUnreadSeparator) { + return const UnreadMessageBar(); + } + final row = rows.center; + if (row == null) return const SizedBox(); + return buildMessage(row); + }, + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final row = rows.bottom[index]; + return buildMessage(row); + }, childCount: rows.bottom.length), + ), + const SliverToBoxAdapter(child: SizedBox(height: 10)), + ], + ), + ), + ); + } +} + +bool _isGroupOrBotGroupMessage(MessageItem message, bool isBotGroup) => + message.conversionCategory == ConversationCategory.group || + message.userId != message.conversationOwnerId || + isBotGroup; + +List _dayTimeEntries( + MessageRows 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), + ]; +} 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_menu_handler.dart b/lib/ui/home/chat/chat_menu_handler.dart new file mode 100644 index 0000000000..cb87de7c65 --- /dev/null +++ b/lib/ui/home/chat/chat_menu_handler.dart @@ -0,0 +1,138 @@ +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() { + context.read().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 4fd2702e38..382fd85fa3 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -1,205 +1,34 @@ 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_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; 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/abstract_responsive_navigator.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'; -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 '../desktop_shell_layout.dart'; import '../hook/pin_message.dart'; -import '../route/responsive_navigator.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 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 +41,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.openDestination(initialSidePage); } - }, [initialSidePage, chatSideCubit]); + }, [initialSidePage, chatSideNotifier]); - final navigatorState = - useBlocState( - bloc: chatSideCubit, - ); + useValueListenable(chatSideNotifier); ref.listen(hasSelectedMessageProvider, (previous, hasSelectedMessage) { if (!hasSelectedMessage) return; - chatSideCubit.clear(); + chatSideNotifier.clear(); }); final chatContainerPage = MaterialPage( @@ -244,21 +80,21 @@ class ChatPage extends HookConsumerWidget { final windowHeight = MediaQuery.sizeOf(context).height; final tickerProvider = useSingleTickerProvider(); - final blinkCubit = useBloc( - () => BlinkCubit( - tickerProvider, - context.theme.accent.withValues(alpha: 0.5), - ), - ); + final blinkNotifier = useMemoized(() => BlinkNotifier(tickerProvider)); + useEffect(() => blinkNotifier.dispose, [blinkNotifier]); final pinMessageState = usePinMessageState(conversationId); return MultiProvider( providers: [ - BlocProvider.value(value: blinkCubit), - BlocProvider.value(value: chatSideCubit), - BlocProvider.value(value: searchConversationKeywordCubit), - BlocProvider( - create: (context) => MessageBloc( + ChangeNotifierProvider.value(value: blinkNotifier), + ChangeNotifierProvider.value( + value: chatSideNotifier, + ), + ChangeNotifierProvider.value( + value: searchConversationKeywordNotifier, + ), + ChangeNotifierProvider( + create: (context) => MessageController( accountServer: context.accountServer, database: context.database, conversationNotifier: ref.read(conversationProvider.notifier), @@ -272,46 +108,56 @@ class ChatPage extends HookConsumerWidget { create: (_) => ChatScrollCoordinator(), dispose: (_, coordinator) => coordinator.dispose(), ), + Provider( + create: (context) => ChatTimelineLocation( + blinkNotifier: context.read(), + scrollCoordinator: context.read(), + messageController: context.read(), + chatSideNotifier: context.read(), + ), + ), Provider.value(value: pinMessageState), ], child: DecoratedBox( decoration: BoxDecoration(color: context.theme.primary), child: LayoutBuilder( builder: (context, boxConstraints) { - final routeMode = - boxConstraints.maxWidth < - (kResponsiveNavigationMinWidth + kChatSidePageWidth); - chatSideCubit.updateRouteMode(routeMode); + final routeMode = DesktopShellLayout.useChatSideRouteMode( + boxConstraints.maxWidth, + ); - 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) => chatSideCubit.pop(), - ), - }, - child: _SideRouter( - chatSideCubit: chatSideCubit, - constraints: boxConstraints, - onDidRemovePage: (page) { - chatSideCubit.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(), }, - pages: [ - if (routeMode) chatContainerPage, - ...navigatorState.pages, - ], + actions: { + EscapeIntent: CallbackAction( + onInvoke: (intent) => chatSideNotifier.pop(), + ), + }, + child: ChatSideRouter( + constraints: boxConstraints, + routeMode: routeMode, + onDidRemovePage: (page) { + chatSideNotifier.onPopPage(); + }, + leadingPages: [ + if (routeMode) chatContainerPage, + ], + destinations: chatSideNotifier.state.destinations, + ), ), - ), - ], + ], + ), ), ); }, @@ -321,104 +167,29 @@ class ChatPage extends HookConsumerWidget { } } -class _SideRouter extends StatelessWidget { - const _SideRouter({ - required this.chatSideCubit, - required this.pages, - required this.constraints, - this.onDidRemovePage, - }); - - final ChatSideCubit chatSideCubit; - - final List> pages; - - final DidRemovePageCallback? onDidRemovePage; - - final BoxConstraints constraints; - - @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, - ); - } -} - -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}); @override Widget build(BuildContext context, WidgetRef ref) { - BlocProvider.of(context).limit = + context.read().limit = MediaQuery.sizeOf(context).height ~/ 20; final inMultiSelectMode = ref.watch(hasSelectedMessageProvider); + final latestJumpRequestKey = ref.watch( + chatNavigationIntentProvider.select( + (value) => value.latestJumpRequestKey, + ), + ); + + useEffect(() { + if (latestJumpRequestKey == null) return null; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + unawaited(context.jumpToLatestInChat()); + }); + return null; + }, [latestJumpRequestKey]); return RepaintBoundary( child: FocusableActionDetector( @@ -467,7 +238,7 @@ class ChatContainer extends HookConsumerWidget { onDidRemovePage: (page) {}, pages: [ MaterialPage( - child: _ChatDropOverlay( + child: ChatDropOverlay( enable: !inMultiSelectMode, child: Column( children: [ @@ -480,38 +251,11 @@ 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( - firstChild: const InputContainer(), - secondChild: const SelectionBottomBar(), - crossFadeState: inMultiSelectMode - ? CrossFadeState.showSecond - : CrossFadeState.showFirst, - duration: const Duration(milliseconds: 300), + ChatBottomBarSwitcher( + inMultiSelectMode: inMultiSelectMode, ), ], ), @@ -529,773 +273,26 @@ 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 messageBloc = context.read(); - final scrollCoordinator = context.read(); - final hiddenMessageDayTimeBloc = useBloc(HiddenMessageDayTimeBloc.new); - final state = useBlocState( - when: (state) => state.conversationId != null, - ); - - 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(() { - void syncHiddenDateTime() { - hiddenMessageDayTimeBloc.update( - scrollCoordinator.visibleDateTime.value, - ); - } - - scrollCoordinator.visibleDateTime.addListener(syncHiddenDateTime); - syncHiddenDateTime(); - return () { - scrollCoordinator.visibleDateTime.removeListener(syncHiddenDateTime); - }; - }, [hiddenMessageDayTimeBloc, scrollCoordinator]); - - 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 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)), - ], - ), - ), - _ChatDateOverlay(scrollCoordinator: scrollCoordinator), - ], - ), - ); - } -} - -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(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scrollCoordinator = context.read(); - - final state = useBlocState(); - 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 cubit = context.read(); - if (cubit.state.pages.lastOrNull?.name == - ChatSideCubit.pinMessages) { - return cubit.pop(); - } - - cubit.replace(ChatSideCubit.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 ChatBottomBarSwitcher extends StatelessWidget { + const ChatBottomBarSwitcher({ + required this.inMultiSelectMode, + this.inputContainer = const InputContainer(), + this.selectionBottomBar = const SelectionBottomBar(), + super.key, + }); -class _ChatDragIndicator extends StatelessWidget { - const _ChatDragIndicator(); + final bool inMultiSelectMode; + final Widget inputContainer; + final Widget selectionBottomBar; @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), - ), - ), - ), + Widget build(BuildContext context) => AnimatedCrossFade( + alignment: Alignment.bottomCenter, + firstChild: inputContainer, + secondChild: selectionBottomBar, + crossFadeState: inMultiSelectMode + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: const Duration(milliseconds: 300), ); } - -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 cubit = ref.read(macMenuBarProvider.notifier); - if (conversationId == null) return null; - - final handle = _ConversationHandle(context, conversationId); - Future(() => cubit.attach(handle)); - return () => Future(() => cubit.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 cubit = context.read(); - if (cubit.state.pages.lastOrNull?.name == - ChatSideCubit.searchMessageHistory) { - return cubit.pop(); - } - - cubit.replace(ChatSideCubit.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 d8342612de..29f2cbede2 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'; @@ -14,40 +15,93 @@ 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(_scheduleViewportStateUpdate); + 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); static const _jumpAnimationCurve = Curves.easeOutCubic; static const _maxAnimatedJumpViewportCount = 3.0; static const _maxAnimatedJumpDistance = 800.0; + static const messageFocusAnchor = 0.2; + static const unreadSeparatorAnchor = messageFocusAnchor; static const loadedJumpViewportCount = 2.0; 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); _ChatRestoreRequest? _restoreRequest; String? _animatedRestoreMessageId; + VoidCallback? _animatedRestoreComplete; List _messages = const []; Map _messagesById = const {}; Map _keysByMessageId = const {}; - String? _centerMessageId; - bool _pinnedToBottom = true; + GlobalKey? _viewportKey; + final _renderedMessageIds = {}; + bool _tailFollowEligible = true; + _TailFollowAnchorSnapshot? _tailFollowAnchorSnapshot; bool _animateNextRestore = false; ChatScrollRestoreDirection? _animatedRestoreDirection; bool _restoreScheduled = false; bool _viewportStateUpdateScheduled = false; + bool _viewportStateUpdateIncludesVisibleDate = false; + bool _blockPreloadUntilUserScroll = false; bool _disposed = false; int _programmaticScrollDepth = 0; + int _programmaticBottomScrollDepth = 0; + int _jumpButtonFrozenUpdates = 0; + bool _followTailAfterProgrammaticScroll = false; void captureViewportState( List messages, @@ -55,7 +109,10 @@ class ChatScrollCoordinator { ) { if (!scrollController.hasClients) return; _setMessages(messages, keysByMessageId); - _pinnedToBottom = _isPinnedToBottom(); + _tailFollowEligible = _isTailFollowEligible(); + _tailFollowAnchorSnapshot = _tailFollowEligible + ? _tailFollowAnchorSnapshotFromViewport() + : null; } void updateMessages( @@ -73,9 +130,11 @@ class ChatScrollCoordinator { void animateNextMessageRestore( String messageId, { ChatScrollRestoreDirection? direction, + VoidCallback? onComplete, }) { _animatedRestoreMessageId = messageId; _animatedRestoreDirection = direction; + _animatedRestoreComplete = onComplete; } void animateNextRestore({ChatScrollRestoreDirection? direction}) { @@ -83,28 +142,54 @@ class ChatScrollCoordinator { _animatedRestoreDirection = direction; } + void registerRenderedMessageId(String messageId) { + _renderedMessageIds.add(messageId); + } + + void unregisterRenderedMessageId(String messageId) { + _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, required bool reset, required bool isLatest, + bool hasCenteredAnchor = false, + bool animateLatestReset = false, String? centerMessageId, + String? traceTargetMessageId, }) { final animatedMessageId = reset ? _animatedRestoreMessageId : null; 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; + _animatedRestoreComplete = null; _animateNextRestore = false; + _blockPreloadUntilUserScroll = true; + if (!animated && (hasCenteredAnchor || centerMessageId != null)) { + _resetCenteredOffsetBeforePaint(); + } } - _setMessages( - messages, - keysByMessageId, - updateCenterMessageId: true, - centerMessageId: centerMessageId, - ); + _setMessages(messages, keysByMessageId); _restoreRequest = _ChatRestoreRequest( messages: messages, keysByMessageId: keysByMessageId, @@ -113,7 +198,10 @@ class ChatScrollCoordinator { centerMessageId: centerMessageId, animatedMessageId: animatedMessageId, animatedRestoreDirection: animatedRestoreDirection, + animatedRestoreComplete: animatedRestoreComplete, animated: animated, + hasCenteredAnchor: hasCenteredAnchor, + traceTargetMessageId: traceTargetMessageId, ); if (_restoreScheduled) return; _restoreScheduled = true; @@ -128,6 +216,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, @@ -135,7 +233,11 @@ class ChatScrollCoordinator { required VoidCallback loadBefore, required VoidCallback loadAfter, }) { - updateMessages(messages, keysByMessageId); + _setMessages(messages, keysByMessageId); + _scheduleViewportStateUpdate( + updateVisibleDate: notification is ScrollEndNotification, + ); + _unlockPreloadOnUserScrollIntent(notification); if (notification is! ScrollUpdateNotification) return false; if (_programmaticScrollDepth > 0) { traceChatJump( @@ -147,24 +249,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); @@ -212,7 +355,7 @@ class ChatScrollCoordinator { void dispose() { _disposed = true; scrollController - ..removeListener(_scheduleViewportStateUpdate) + ..removeListener(_scheduleScrollPositionUpdate) ..dispose(); visibleDateTime.dispose(); showJumpToLatest.dispose(); @@ -222,6 +365,8 @@ 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)} ' 'messages=${request.messages.length} ' @@ -234,9 +379,14 @@ class ChatScrollCoordinator { request.keysByMessageId, animated: request.animatedMessageId == request.centerMessageId, animatedDirection: request.animatedRestoreDirection, + onComplete: request.animatedRestoreComplete, )) { return; } + if (request.hasCenteredAnchor) { + _traceTargetAfterRestore(request); + return; + } if (request.isLatest) { unawaited( _jumpToBottom( @@ -245,13 +395,24 @@ class ChatScrollCoordinator { ), ); } + _traceTargetAfterRestore(request); return; } - if (scrollController.position.isScrollingNotifier.value) return; + if (scrollController.position.isScrollingNotifier.value) { + if (_programmaticBottomScrollDepth > 0 && request.isLatest) { + _followTailAfterProgrammaticScroll = true; + } + return; + } - if (_pinnedToBottom) { - unawaited(_jumpToBottom()); + if (_tailFollowEligible) { + unawaited( + _jumpToBottom( + animated: true, + prepareAnimationStart: _restoreTailFollowAnimationStart, + ), + ); } } @@ -260,6 +421,7 @@ class ChatScrollCoordinator { Map keysByMessageId, { bool animated = false, ChatScrollRestoreDirection? animatedDirection, + VoidCallback? onComplete, }) { final geometry = _messageTargetGeometry(messageId, keysByMessageId); if (geometry == null) { @@ -283,13 +445,14 @@ class ChatScrollCoordinator { animated: animated, stageDistant: false, animationDirection: animated ? animatedDirection : null, - ).whenComplete( - () => _traceTargetAfterLayout( + ).whenComplete(() { + _traceTargetAfterLayout( 'restore-after', messageId, keysByMessageId, - ), - ), + ); + onComplete?.call(); + }), ); return true; } @@ -308,7 +471,7 @@ class ChatScrollCoordinator { final target = scrollController.offset + top - - scrollController.position.viewportDimension * 0.3; + scrollController.position.viewportDimension * messageFocusAnchor; return _MessageTargetGeometry( target: target, top: top, @@ -338,17 +501,28 @@ class ChatScrollCoordinator { Future _jumpToBottom({ bool animated = false, ChatScrollRestoreDirection? animationDirection, - }) => _jumpToClamped( - scrollController.position.maxScrollExtent, - animated: animated, - animationDirection: animated ? animationDirection : null, - ); + VoidCallback? prepareAnimationStart, + }) async { + _clearJumpButtonFreeze(); + _programmaticBottomScrollDepth++; + try { + await _jumpToClamped( + scrollController.position.maxScrollExtent, + animated: animated, + animationDirection: animated ? animationDirection : null, + prepareAnimationStart: prepareAnimationStart, + ); + } finally { + _programmaticBottomScrollDepth--; + } + } Future _jumpToClamped( double value, { bool animated = false, bool stageDistant = true, ChatScrollRestoreDirection? animationDirection, + VoidCallback? prepareAnimationStart, }) { if (!scrollController.hasClients) return Future.value(); final position = scrollController.position; @@ -364,64 +538,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); - 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 { @@ -434,7 +608,18 @@ class ChatScrollCoordinator { traceChatJump( 'jump done ${formatScrollMetrics(scrollController.position)}', ); + if (_programmaticScrollDepth == 0 && + _followTailAfterProgrammaticScroll) { + _followTailAfterProgrammaticScroll = false; + unawaited( + _jumpToBottom( + animated: true, + prepareAnimationStart: _restoreTailFollowAnimationStart, + ), + ); + } } + _scheduleViewportStateUpdate(); } } @@ -488,18 +673,23 @@ 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() { 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) { @@ -511,23 +701,83 @@ class ChatScrollCoordinator { return firstVisibleMessageId; } - void _scheduleViewportStateUpdate() { - if (_viewportStateUpdateScheduled || _disposed) return; + _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); + } + + 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 (_jumpButtonFrozenUpdates <= 0) { + final nextShowJumpToLatest = _shouldShowJumpToLatest(); + if (showJumpToLatest.value != nextShowJumpToLatest) { + showJumpToLatest.value = nextShowJumpToLatest; + } + } else { + _jumpButtonFrozenUpdates--; } + if (!updateVisibleDate) return; final firstVisibleMessageId = _firstVisibleMessageId(); final nextDateTime = firstVisibleMessageId == null ? null @@ -539,50 +789,46 @@ class ChatScrollCoordinator { void _setMessages( List messages, - Map keysByMessageId, { - bool updateCenterMessageId = false, - String? centerMessageId, - }) { + Map keysByMessageId, + ) { + final messageWindowChanged = !_hasSameMessageIds(messages); 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; + if (messageWindowChanged) { + _freezeJumpButton(); } + _keysByMessageId = keysByMessageId; } - Iterable _renderedMessageIds() sync* { - yield* _messageIdsInSliver(topSliverKey); - final centerMessageId = _centerMessageId; - if (centerMessageId != null) yield centerMessageId; - yield* _messageIdsInSliver(bottomSliverKey); + 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; } - 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; + void _freezeJumpButton() { + if (_disposed) return; + _jumpButtonFrozenUpdates = _jumpButtonFreezeUpdateCount; } - MessageGlobalKey? _messageGlobalKeyInSubtree(Element element) { - final key = element.widget.key; - if (key is MessageGlobalKey) return key; + void _clearJumpButtonFreeze() { + _jumpButtonFrozenUpdates = 0; + } - 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( @@ -595,7 +841,7 @@ class ChatScrollCoordinator { } RenderBox? get _viewportRender { - final object = viewportKey.currentContext?.findRenderObject(); + final object = _viewportKey?.currentContext?.findRenderObject(); return object is RenderBox ? object : null; } @@ -608,10 +854,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)}', ); } @@ -629,6 +886,26 @@ class ChatScrollCoordinator { }), ); } + + void _traceTargetAfterRestore(_ChatRestoreRequest request) { + final traceTargetMessageId = request.traceTargetMessageId; + if (traceTargetMessageId == null) return; + _traceTargetAfterLayout( + 'restore-anchor-after', + traceTargetMessageId, + request.keysByMessageId, + ); + } +} + +class _TailFollowAnchorSnapshot { + const _TailFollowAnchorSnapshot({ + required this.messageId, + required this.bottom, + }); + + final String messageId; + final double bottom; } class _MessageTargetGeometry { @@ -656,9 +933,12 @@ class _ChatRestoreRequest { required this.reset, required this.isLatest, required this.animated, + required this.hasCenteredAnchor, this.centerMessageId, this.animatedMessageId, this.animatedRestoreDirection, + this.animatedRestoreComplete, + this.traceTargetMessageId, }); final List messages; @@ -666,7 +946,10 @@ class _ChatRestoreRequest { final bool reset; final bool isLatest; final bool animated; + final bool hasCenteredAnchor; final String? centerMessageId; final String? animatedMessageId; final ChatScrollRestoreDirection? animatedRestoreDirection; + final VoidCallback? animatedRestoreComplete; + final String? traceTargetMessageId; } 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_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/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 09073080af..4abeaf28d3 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'; @@ -46,151 +44,43 @@ 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'; +part 'files_preview_model.dart'; + Future showFilesPreviewDialog( BuildContext context, List files, ) async { + void jumpToLatestAfterSend() { + if (!context.mounted) return; + context.completeOutgoingChatSend(); + } + await showMixinDialog( context: context, - child: _FilesPreviewDialog(initialFiles: files.map(_File.auto).toList()), + child: _FilesPreviewDialog( + initialFiles: files.map(_File.auto).toList(), + onFileSent: jumpToLatestAfterSend, + ), ); } -/// 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}); + const _FilesPreviewDialog({ + required this.initialFiles, + required this.onFileSent, + }); final List<_File> initialFiles; + final VoidCallback onFileSent; @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 +139,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 +148,7 @@ class _FilesPreviewDialog extends HookConsumerWidget { ), ); } - quoteMessageCubit.state = null; + onFileSent(); Navigator.pop(context); } else { final zipFilePath = await runLoadBalancer(_archiveFiles, ( @@ -270,12 +160,12 @@ class _FilesPreviewDialog extends HookConsumerWidget { _sendFile( context, _File.normal(zipFilePath), - quoteMessageCubit.state?.messageId, + quoteMessageNotifier.state?.messageId, silent: silent, compress: false, ), ); - quoteMessageCubit.state = null; + onFileSent(); Navigator.pop(context); } } 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 2cd63da545..2f40da14d9 100644 --- a/lib/ui/home/chat/image_editor.dart +++ b/lib/ui/home/chat/image_editor.dart @@ -1,16 +1,15 @@ 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_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'; @@ -21,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, @@ -42,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); @@ -60,9 +61,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( @@ -94,108 +101,41 @@ 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, +class _ImageTransform { + const _ImageTransform({ + required this.bytes, + required this.width, + required this.height, required this.flip, - required this.customDrawLines, - required this.cropRect, - required this.rawImagePath, - required this.imagePath, + required this.rotateDegree, }); - final ImageRotate imageRotate; + final Uint8List bytes; + final int width; + final int height; final bool flip; - final List customDrawLines; - final Rect cropRect; - final String rawImagePath; - final String imagePath; + final int rotateDegree; } -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, +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 _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { - _ImageEditorBloc({ +class _ImageEditorNotifier extends ValueNotifier<_ImageEditorState> { + _ImageEditorNotifier({ required this.path, required this.image, ImageEditorSnapshot? snapshot, @@ -220,10 +160,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 +203,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 +227,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 +272,7 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { } void _notifyCustomDrawUpdated() { - emit( + _setState( state.copyWith( drawLines: [ ..._customDrawLines, @@ -341,7 +290,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 +319,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 +338,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 { @@ -397,21 +346,18 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { 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 { @@ -498,14 +444,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 +484,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 +621,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 +649,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 +876,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 +909,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,51 +929,42 @@ class _CustomDrawingWidget extends HookConsumerWidget { enum _ImageDragArea { topLeft, topRight, bottomLeft, bottomRight, center } -enum ImageRotate { none, quarter, half, threeQuarter } +const _kImageCropHandleSize = 30.0; -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; - } +_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; } - - 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; - } + if (offset.dx > rect.right - _kImageCropHandleSize && + offset.dy < rect.top + _kImageCropHandleSize) { + return _ImageDragArea.topRight; } - - Size apply(Size size) { - if (!_boundRotated) { - return size; - } - return Size(size.height, size.width); + 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; +} - bool get _boundRotated { +extension _ImageDragAreaCursor on _ImageDragArea? { + MouseCursor get cursor { switch (this) { - case ImageRotate.none: - return false; - case ImageRotate.quarter: - case ImageRotate.threeQuarter: - return true; - case ImageRotate.half: - return false; + 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; } } } @@ -1159,16 +1075,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 +1190,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 +1237,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 +1259,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 +1281,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 +1305,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 +1324,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 +1341,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 +1367,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 +1380,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 +1408,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 +1422,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 +1433,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 +1441,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 +1450,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 +1459,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/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 4dde511bc4..c86d677440 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; @@ -21,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'; @@ -38,20 +37,21 @@ 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 'chat_send_outcome.dart'; import 'files_preview.dart'; import 'voice_recorder_bottom_bar.dart'; +part 'input_highlight_controller.dart'; + class InputContainer extends HookConsumerWidget { const InputContainer({super.key}); @@ -61,10 +61,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 +79,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 +175,9 @@ class _InputContainer extends HookConsumerWidget { }; }, []); - final chatSidePageSize = - useBlocStateConverter( - bloc: context.read(), - converter: (state) => state.pages.length, - ); + final chatSidePageSize = useValueListenable( + context.read(), + ).destinations.length; useEffect(() { if (!context.textFieldAutoGainFocus) { // Make sure on iPad/Android Pad the text field not get focused when the chat side @@ -328,7 +327,8 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { child: ActionButton( name: Resources.assetsImagesMicrophoneSvg, color: context.theme.icon, - onTap: () => context.read().startRecording(), + onTap: () => + context.read().startRecording(), ), ), ], @@ -342,32 +342,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; @@ -379,17 +375,26 @@ 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 = ''; - context.providerContainer.read(quoteMessageProvider.notifier).state = null; + textEditingController.clear(); + context.completeOutgoingChatSend(); } class _SendTextField extends HookConsumerWidget { @@ -654,7 +659,7 @@ class _SendActionTypeButton extends HookConsumerWidget { recipientId: conversationState.userId, quoteMessageId: quoteMessage.state?.messageId, ); - quoteMessage.state = null; + context.completeOutgoingChatSend(); }); }, ), @@ -751,9 +756,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, @@ -774,16 +779,21 @@ class _StickerButton extends HookConsumerWidget { @override 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 stickerReadModel = useMemoized( + () => StickerReadModel( + stickerDao: context.database.stickerDao, + stickerAlbumDao: context.database.stickerAlbumDao, ), + [context.database], ); + final stickerAlbums = + useMemoizedStream( + stickerReadModel.systemAddedAlbums, + initialData: const [], + ).data ?? + const []; + final presetStickerGroups = useMemoized( () => [ PresetStickerGroup.store, @@ -794,18 +804,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 +848,9 @@ class _StickerButton extends HookConsumerWidget { tabController: DefaultTabController.of(context), tabLength: tabLength, presetStickerGroups: presetStickerGroups, + stickerAlbums: stickerAlbums, + readModel: stickerReadModel, + onStickerSent: context.completeOutgoingChatSend, ), ), ), @@ -881,101 +886,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 3e484431ff..d756a094da 100644 --- a/lib/ui/home/chat/message_jump.dart +++ b/lib/ui/home/chat/message_jump.dart @@ -1,27 +1,40 @@ -import 'dart:async'; - import 'package:flutter/widgets.dart'; import '../../../utils/extension/extension.dart'; -import '../bloc/blink_cubit.dart'; -import '../bloc/message_bloc.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'; -extension ChatMessageJump on BuildContext { - Future jumpToMessageInChat( +class ChatTimelineLocation { + ChatTimelineLocation({ + required BlinkNotifier blinkNotifier, + required ChatScrollCoordinator scrollCoordinator, + required MessageController messageController, + required ChatSideNotifier chatSideNotifier, + }) : _blinkNotifier = blinkNotifier, + _scrollCoordinator = scrollCoordinator, + _messageController = messageController, + _chatSideNotifier = chatSideNotifier; + + final BlinkNotifier _blinkNotifier; + final ChatScrollCoordinator _scrollCoordinator; + final MessageController _messageController; + final ChatSideNotifier _chatSideNotifier; + + Future jumpToMessage( String messageId, { String? sourceMessageId, + bool closeSideAfterJump = false, + bool chatSideRouteMode = false, }) async { traceChatJump( 'request source=${shortMessageId(sourceMessageId)} ' 'target=${shortMessageId(messageId)}', ); - read().blinkByMessageId(messageId); - - final scrollCoordinator = read(); - final messageBloc = read(); - final handled = await scrollCoordinator.scrollToMessageIfInLoadedWindow( + final handled = await _scrollCoordinator.scrollToMessageIfInLoadedWindow( messageId, animated: true, ); @@ -30,47 +43,54 @@ extension ChatMessageJump on BuildContext { 'handled=$handled', ); if (handled) { + _blinkNotifier.blinkByMessageId(messageId); + _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); return; } traceChatJump('reload-window target=${shortMessageId(messageId)}'); final directionSourceMessageId = - sourceMessageId ?? _currentWindowSourceMessageId(messageBloc.state); - final direction = await _restoreDirectionFromSource( - sourceMessageId: directionSourceMessageId, - targetMessageId: messageId, + sourceMessageId ?? + _currentWindowSourceMessageId(_messageController.state); + final direction = _toScrollDirection( + await _messageController.restoreDirectionFromSource( + sourceMessageId: directionSourceMessageId, + targetMessageId: messageId, + ), ); traceChatJump( 'restore direction source=${shortMessageId(directionSourceMessageId)} ' 'target=${shortMessageId(messageId)} direction=$direction', ); - scrollCoordinator.animateNextMessageRestore( + _scrollCoordinator.animateNextMessageRestore( messageId, direction: direction, + onComplete: () => _blinkNotifier.blinkByMessageId(messageId), ); - messageBloc.scrollTo(messageId); + _messageController.loadAroundMessage(messageId); + _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); } - Future jumpToLatestInChat() async { + Future jumpToLatest({ + bool closeSideAfterJump = false, + bool chatSideRouteMode = false, + }) async { traceChatJump('request latest'); - final scrollCoordinator = read(); - if (await scrollCoordinator.scrollToBottomIfInLoadedWindow( - animated: true, - )) { + if (_messageController.state.isLatest && + await _scrollCoordinator.scrollToBottomIfInLoadedWindow( + animated: true, + )) { traceChatJump('loaded-window latest handled=true'); + _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); return; } traceChatJump('reload-window latest'); - scrollCoordinator.animateNextRestore( + _scrollCoordinator.animateNextRestore( direction: ChatScrollRestoreDirection.towardNewer, ); - read().jumpToLatestWindow(); - } - - void popChatSideRouteIfNeeded() { - if (ModalRoute.of(this)?.canPop != true) return; - unawaited(Navigator.maybePop(this)); + _messageController.loadLatestWindow(); + _closeSideIfNeeded(closeSideAfterJump, chatSideRouteMode); } String? _currentWindowSourceMessageId(MessageState state) { @@ -78,28 +98,37 @@ extension ChatMessageJump on BuildContext { return state.bottomMessage?.messageId ?? state.topMessage?.messageId; } - 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; + ChatScrollRestoreDirection? _toScrollDirection( + MessageWindowDirection? direction, + ) => switch (direction) { + MessageWindowDirection.older => ChatScrollRestoreDirection.towardOlder, + MessageWindowDirection.newer => ChatScrollRestoreDirection.towardNewer, + null => null, + }; - final sourceAfterTarget = sourceInfo.createdAt == targetInfo.createdAt - ? sourceInfo.rowId > targetInfo.rowId - : sourceInfo.createdAt > targetInfo.createdAt; - return sourceAfterTarget - ? ChatScrollRestoreDirection.towardOlder - : ChatScrollRestoreDirection.towardNewer; + void _closeSideIfNeeded(bool closeSideAfterJump, bool chatSideRouteMode) { + if (!closeSideAfterJump) return; + _chatSideNotifier.closeAfterContentJump(routeMode: chatSideRouteMode); } } + +extension ChatMessageJump on BuildContext { + Future jumpToMessageInChat( + String messageId, { + String? sourceMessageId, + bool closeSideAfterJump = false, + }) => _chatHistoryLocation.jumpToMessage( + messageId, + sourceMessageId: sourceMessageId, + closeSideAfterJump: closeSideAfterJump, + chatSideRouteMode: DesktopShellLayout.chatSideRouteModeOf(this), + ); + + Future jumpToLatestInChat({bool closeSideAfterJump = false}) => + _chatHistoryLocation.jumpToLatest( + closeSideAfterJump: closeSideAfterJump, + chatSideRouteMode: DesktopShellLayout.chatSideRouteModeOf(this), + ); + + ChatTimelineLocation get _chatHistoryLocation => read(); +} 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/voice_recorder_bottom_bar.dart b/lib/ui/home/chat/voice_recorder_bottom_bar.dart index cc87f4a983..994a16e406 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'; @@ -22,154 +21,9 @@ import '../../../widgets/toast.dart'; import '../../../widgets/waveform_widget.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/quote_message_provider.dart'; +import 'chat_send_outcome.dart'; -enum RecorderState { idle, recording, recordingStopped } - -class VoiceRecorderCubitState with EquatableMixin { - const VoiceRecorderCubitState({ - required this.state, - this.startTime, - this.recodedData, - }); - - final RecorderState state; - - final DateTime? startTime; - - final RecordedData? recodedData; - - @override - List get props => [startTime, state]; -} - -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 VoiceRecorderCubit extends Cubit { - VoiceRecorderCubit(this.audioMessagePlayService) - : super(const VoiceRecorderCubitState(state: RecorderState.idle)); - - OggOpusRecorder? _recorder; - String? _recorderFilePath; - - final AudioMessagePlayService audioMessagePlayService; - - Completer? _startingCompleter; - - Timer? _timer; - - Future startRecording() async { - 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); - emit( - VoiceRecorderCubitState( - startTime: DateTime.now(), - state: RecorderState.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!, - ); - - emit( - VoiceRecorderCubitState( - state: RecorderState.recordingStopped, - recodedData: recodeData, - ), - ); - return recodeData; - } - - Future cancelAndExitRecordeMode() async { - if (state.state == RecorderState.idle) { - return; - } - if (state.state == RecorderState.recordingStopped) { - emit(const VoiceRecorderCubitState(state: RecorderState.idle)); - return; - } - final result = await stopRecording(isCanceled: true); - emit(const VoiceRecorderCubitState(state: RecorderState.idle)); - try { - await File(result.path).delete(); - } catch (error, stacktrace) { - e('cancelRecording: failed to delete file. $error $stacktrace'); - } - } - - @override - Future close() async { - if (state.state == RecorderState.recording || - state.state == RecorderState.recordingStopped) { - await cancelAndExitRecordeMode(); - } else if (_startingCompleter != null) { - await _startingCompleter?.future; - await cancelAndExitRecordeMode(); - } - _timer?.cancel(); - await super.close(); - } -} +part 'voice_recorder_state.dart'; class VoiceRecorderBarOverlayComposition extends HookConsumerWidget { const VoiceRecorderBarOverlayComposition({ @@ -184,53 +38,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 +106,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 +119,9 @@ class _RecordingInterceptor extends HookConsumerWidget { _showDiscardRecordingWarningAlertOverlay( context, onDiscard: () { - context.read().cancelAndExitRecordeMode(); + context + .read() + .cancelAndExitRecordMode(); }, ); }, @@ -352,24 +212,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 +251,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 +279,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 +288,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 +305,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.'); @@ -485,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, @@ -495,6 +339,7 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { recipientId: conversationItem.userId, quoteMessageId: quoteMessageId, ); + context.completeOutgoingChatSend(); }, ), ], @@ -503,44 +348,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/chat_info_page.dart b/lib/ui/home/chat_slide_page/chat_info_page.dart index b08dbb9b65..db9108e274 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,11 @@ 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 '../chat/chat_page.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'; class ChatInfoPage extends HookConsumerWidget { @@ -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( - ChatSideCubit.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( - ChatSideCubit.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( - ChatSideCubit.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( - ChatSideCubit.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( - ChatSideCubit.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( - ChatSideCubit.circles, + onTap: () => context.read().openDestination( + ConversationInfoDestination.circles, ), ), ), @@ -491,7 +495,7 @@ class ChatInfoPage extends HookConsumerWidget { await accountServer.deleteMessagesByConversationId( conversationId, ); - context.read().reload(); + context.read().reload(); }, ), if (isGroupConversation) @@ -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( - ChatSideCubit.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 5d1aa7993f..cc75b9686f 100644 --- a/lib/ui/home/chat_slide_page/pin_messages_page.dart +++ b/lib/ui/home/chat_slide_page/pin_messages_page.dart @@ -1,7 +1,6 @@ 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:provider/provider.dart'; @@ -20,7 +19,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,11 +46,6 @@ class PinMessagesPage extends HookConsumerWidget { keys: [conversationId], ).data; - final chatSideCubit = useBloc(ChatSideCubit.new); - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), - ); - useEffect(() { if (rawList?.isNotEmpty ?? true) return; scheduleMicrotask(() => Navigator.pop(context)); @@ -62,16 +56,11 @@ class PinMessagesPage extends HookConsumerWidget { final scrollController = useMemoized(ScrollController.new); final listKey = useMemoized(() => GlobalKey(debugLabel: 'PinMessagesPage')); - return MultiProvider( - providers: [ - BlocProvider.value(value: searchConversationKeywordCubit), - 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( @@ -84,7 +73,8 @@ class PinMessagesPage extends HookConsumerWidget { ActionButton( name: Resources.assetsImagesIcCloseSvg, color: context.theme.icon, - onTap: () => context.read().onPopPage(), + onTap: () => + context.read().closeDestination(), ), ], ), @@ -105,9 +95,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 44f1de7115..7b782e0701 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/chat_page.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}); @@ -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().closeDestination(), ), ], ), @@ -159,7 +159,7 @@ class SearchMessagePage extends HookConsumerWidget { if (userMode.value && selectedUser.value == null) { return; } - SearchConversationKeywordCubit.updateKeyword( + SearchConversationKeywordNotifier.updateKeyword( context, keyword, ); @@ -269,36 +269,38 @@ 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(); - 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]; @@ -307,11 +309,10 @@ class _SearchMessageList extends HookConsumerWidget { keyword: keyword, showSender: true, onTap: () async { - await context.jumpToMessageInChat(message.messageId); - final chatSideCubit = context.read(); - if (chatSideCubit.state.routeMode) { - chatSideCubit.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 b2c6c23d69..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,22 +1,15 @@ -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 '../../../../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 '../shared_media_page.dart'; +import 'shared_media_list.dart'; class FilePage extends HookConsumerWidget { const FilePage({ @@ -33,119 +26,20 @@ class FilePage extends HookConsumerWidget { final size = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); final messageDao = context.database.messageDao; - final mediaCubit = useBloc( - () => LoadMorePagingBloc( - 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, - ), - keys: [conversationId], - ); - useEffect( - () => messageDao - .watchInsertOrReplaceMessageStream(conversationId) - .switchMap((value) async* { - for (final item in value) { - yield item; - } - }) - .where( - (event) => [ - MessageCategory.plainData, - MessageCategory.signalData, - ].contains(event.type), - ) - .listen(mediaCubit.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 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: mediaCubit.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 4a72ca2329..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 @@ -1,24 +1,17 @@ -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 '../../../../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 '../../chat/chat_page.dart'; +import '../../desktop_shell_layout.dart'; import '../shared_media_page.dart'; +import 'shared_media_list.dart'; class MediaPage extends HookConsumerWidget { const MediaPage({ @@ -32,135 +25,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 mediaCubit = useBloc( - () => LoadMorePagingBloc( - 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, - ), - keys: [conversationId], + final routeMode = DesktopShellLayout.chatSideRouteModeOf(context); + final columnCount = DesktopShellLayout.chatSideMediaColumnCount( + routeMode: routeMode, ); - 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(mediaCubit.insertOrReplace) - .cancel, - [conversationId], + final pageSize = DesktopShellLayout.chatSideMediaPageSize( + maxHeight: maxHeight, + routeMode: routeMode, ); - 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 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: mediaCubit.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 90feb01768..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,22 +1,15 @@ -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 '../../../../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 '../shared_media_page.dart'; +import 'shared_media_list.dart'; class PostPage extends HookConsumerWidget { const PostPage({ @@ -31,122 +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 mediaCubit = useBloc( - () => LoadMorePagingBloc( - 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, - ), - keys: [conversationId], - ); - useEffect( - () => messageDao - .watchInsertOrReplaceMessageStream(conversationId) - .switchMap((value) async* { - for (final item in value) { - yield item; - } - }) - .where( - (event) => [ - MessageCategory.plainPost, - MessageCategory.signalPost, - ].contains(event.type), - ) - .listen(mediaCubit.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 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: mediaCubit.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..9b6522c2f4 --- /dev/null +++ b/lib/ui/home/chat_slide_page/share_media/shared_media_list.dart @@ -0,0 +1,162 @@ +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 = 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) { + 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..03270388aa --- /dev/null +++ b/lib/ui/home/conversation/conversation_focus.dart @@ -0,0 +1,202 @@ +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/recent_conversation_provider.dart'; +import '../chat/chat_jump_trace.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.requestLatestJump(); + 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); + + 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 = + (!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; + 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/conversation/conversation_hotkey.dart b/lib/ui/home/conversation/conversation_hotkey.dart index 1a988a60f9..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 '../bloc/conversation_list_bloc.dart'; +import '../notifier/conversation_list_controller.dart'; +import 'conversation_focus.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,16 +96,16 @@ 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; } - ConversationStateNotifier.selectConversation(context, nextConversationId); + ConversationFocus.selectConversation(context, nextConversationId); 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 fc255fb450..e83b499930 100644 --- a/lib/ui/home/conversation/conversation_list.dart +++ b/lib/ui/home/conversation/conversation_list.dart @@ -6,9 +6,9 @@ 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 '../../../db/mixin_database.dart' show User; import '../../../enum/message_category.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/hook.dart'; @@ -24,11 +24,11 @@ import '../../../widgets/unread_text.dart'; import '../../provider/conversation_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 '../bloc/conversation_list_bloc.dart'; +import '../desktop_shell_layout.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'; @@ -41,22 +41,25 @@ 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); + useListenable(conversationListController.avatarCache); final conversationId = ref.watch(currentConversationIdProvider); - final routeMode = ref.watch(navigatorRouteModeProvider); - - final itemPositionsListener = conversationListBloc.itemPositionsListener( - slideCategoryState, - ); - final itemScrollController = conversationListBloc.itemScrollController( - slideCategoryState, + final showsConversationSelection = !DesktopShellLayout.mainRouteModeOf( + context, ); + final itemPositionsListener = conversationListController + .itemPositionsListener( + slideCategoryState, + ); + final itemScrollController = conversationListController + .itemScrollController( + slideCategoryState, + ); + if (itemPositionsListener == null || itemScrollController == null) { return const SizedBox(); } @@ -80,21 +83,18 @@ 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 && showsConversationSelection; return ConversationMenuWrapper( conversation: conversation, removeChatFromCircle: true, child: ConversationItemWidget( selected: selected, conversation: conversation, + groupAvatarUsers: conversationListController.avatarCache + .usersFor(conversation.conversationId), onTap: () { - if (selected) { - ref.read(pendingJumpLatestProvider.notifier).state = - Object(); - return; - } - ConversationStateNotifier.selectConversation( + ConversationFocus.selectConversation( context, conversation.conversationId, conversation: conversation, @@ -153,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) { @@ -182,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 c6a0a62277..b7bf5a61b4 100644 --- a/lib/ui/home/conversation/search_list.dart +++ b/lib/ui/home/conversation/search_list.dart @@ -33,15 +33,15 @@ 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'; 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_focus.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)); @@ -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, @@ -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]; @@ -794,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 66761374e7..708dbd4663 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/unseen_conversations_provider.dart'; +import '../desktop_shell_layout.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 = !DesktopShellLayout.mainRouteModeOf( + context, + ); if (unseenConversations == null) { return const SizedBox(); @@ -30,8 +32,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 && showsConversationSelection; return ConversationMenuWrapper( conversation: conversation, removeChatFromCircle: true, @@ -39,11 +41,7 @@ class UnseenConversationList extends HookConsumerWidget { conversation: conversation, selected: selected, onTap: () { - if (selected) { - 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..ab36ee320a --- /dev/null +++ b/lib/ui/home/desktop_shell_layout.dart @@ -0,0 +1,137 @@ +import 'package:flutter/widgets.dart'; + +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); + + 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 1a4903e84d..10df503fdd 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', @@ -333,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, @@ -342,23 +323,15 @@ 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, + routeMode: routeMode, + )) { ref.read(conversationProvider.notifier).unselected(); - responsiveNavigatorNotifier.pushPage( - ResponsiveNavigatorStateNotifier.settingPageNameSet.first, - ); } }); 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/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/blink_notifier.dart b/lib/ui/home/notifier/blink_notifier.dart new file mode 100644 index 0000000000..69e45d2c36 --- /dev/null +++ b/lib/ui/home/notifier/blink_notifier.dart @@ -0,0 +1,67 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; + +class BlinkState extends Equatable { + const BlinkState({this.messageId, this.opacity = 0, this.token = 0}); + + final String? messageId; + final double opacity; + final int token; + + @override + List get props => [messageId, opacity, token]; +} + +class BlinkNotifier extends ValueNotifier { + BlinkNotifier(TickerProvider tickerProvider) + : _animationController = AnimationController( + duration: const Duration(milliseconds: 700), + vsync: tickerProvider, + ), + super(const BlinkState()) { + _animationController + ..addListener(_onUpdate) + ..addStatusListener(_onComplete); + } + + final AnimationController _animationController; + int _token = 0; + + void _onUpdate() { + 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; + value = BlinkState(token: _token); + } + + void blinkByMessageId(String 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 + ..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..b536c22129 --- /dev/null +++ b/lib/ui/home/notifier/chat_side_notifier.dart @@ -0,0 +1,145 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../conversation_info_destination.dart'; + +class ChatSideState extends Equatable { + const ChatSideState({ + this.destinations = const [], + }); + + final List destinations; + + ChatSideState copyWith({ + List? destinations, + }) => ChatSideState( + destinations: destinations ?? this.destinations, + ); + + @override + List get props => [destinations]; +} + +class ChatSideNotifier extends ValueNotifier { + ChatSideNotifier() : super(const ChatSideState()); + + var _disposed = false; + + ChatSideState get state => value; + + set state(ChatSideState newState) { + if (_disposed || newState == value) return; + value = newState; + } + + ConversationInfoDestination? get currentDestination => + state.destinations.isEmpty ? null : state.destinations.last; + + bool isCurrentDestination(ConversationInfoDestination destination) => + currentDestination == destination; + + 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(), + ); + } + + void toggleDestination(ConversationInfoDestination destination) { + if (isCurrentDestination(destination)) { + pop(); + return; + } + openDestination(destination); + } + + void closeAfterContentJump({required bool routeMode}) { + if (routeMode) clear(); + } + + void toggleInfoPage() { + if (state.destinations.isEmpty) { + state = state.copyWith( + destinations: [ConversationInfoDestination.infoPage], + ); + return; + } + clear(); + } + + void clear() { + state = state.copyWith(destinations: []); + } + + @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(ChatSideState state) => + state.destinations.contains( + ConversationInfoDestination.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(); + } +} diff --git a/lib/ui/home/bloc/conversation_list_bloc.dart b/lib/ui/home/notifier/conversation_list_controller.dart similarity index 56% rename from lib/ui/home/bloc/conversation_list_bloc.dart rename to lib/ui/home/notifier/conversation_list_controller.dart index fc9378d5a7..4d55bac1c2 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,21 @@ 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; -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 +36,23 @@ class ConversationListBloc extends Cubit> final SlideCategoryStateNotifier slideCategoryStateNotifier; final Database database; final MentionCache mentionCache; - final Map _map = {}; + late final ConversationAvatarCache avatarCache = ConversationAvatarCache( + database, + ); + 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 +60,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 +78,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 +96,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,17 +105,17 @@ 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) => dao.conversationItemsByCategory(state.type, limit, offset).get(), updateEvent, mentionCache, - () => dao.conversationHasDataByCategory(state.type), + avatarCache, ); case SlideCategoryType.circle: - _map[state] ??= _ConversationListBloc( + _map[state] ??= _ConversationPagingController( limit ?? kDefaultLimit, () => database.conversationDao .conversationsCountByCircleId(state.id!) @@ -118,39 +125,54 @@ class ConversationListBloc extends Cubit> .get(), circleUpdateEvent, mentionCache, - () => - database.conversationDao.conversationHasDataByCircleId(state.id!), + avatarCache, ); 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(); + } + avatarCache.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; @@ -162,7 +184,7 @@ class ConversationListBloc extends Cubit> .allUnseenIgnoreMuteMessageCount() .getSingle(); await updateBadge(count); - addSubscription( + _subscriptions.add( database.conversationDao.allUnseenIgnoreMuteMessageCountEvent .distinct() .asyncBufferMap((event) => updateBadge(event.last)) @@ -171,64 +193,56 @@ 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, - Future Function() queryHasData, - ) : _queryCount = queryCount, - _queryRange = queryRange, - _queryHasData = queryHasData, - _updateEvent = updateEvent, + MentionCache mentionCache, + ConversationAvatarCache avatarCache, + ) : _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)); + unawaited(avatarCache.warm(list)); + return list; + }, ); - final MentionCache mentionCache; - final Future Function() _queryCount; - final Future> Function(int limit, int offset) - _queryRange; - final Future Function() _queryHasData; final Stream _updateEvent; + final ItemScrollController itemScrollController = ItemScrollController(); StreamSubscription? _updateSubscription; - final ItemScrollController itemScrollController = ItemScrollController(); - void activate() { - _updateSubscription ??= _updateEvent.listen( - (_) => add(PagingUpdateEvent()), - ); - 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(); + super.dispose(); } - @override - Future queryCount() => _queryCount(); - - @override - Future> queryRange(int limit, int offset) async { - final list = await _queryRange(limit, offset); - await mentionCache.checkMentionCache(list.map((e) => e.content).toSet()); - - return list; + static Future _warmMentionCache( + MentionCache mentionCache, + List list, + ) async { + try { + await mentionCache.checkMentionCache(list.map((e) => e.content).toSet()); + } catch (error) { + e('conversation mention cache failed: $error'); + } } - - @override - Future queryHasData() => _queryHasData(); } 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/notifier/message_controller.dart b/lib/ui/home/notifier/message_controller.dart new file mode 100644 index 0000000000..bda224c921 --- /dev/null +++ b/lib/ui/home/notifier/message_controller.dart @@ -0,0 +1,701 @@ +import 'dart:async'; + +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 '../../../db/dao/message_dao.dart'; +import '../../../db/database.dart'; +import '../../../db/database_event_bus.dart'; +import '../../../db/mixin_database.dart'; +import '../../../enum/message_category.dart'; +import '../../../utils/app_lifecycle.dart'; +import '../../../utils/extension/extension.dart'; +import '../../provider/conversation_provider.dart'; +import '../../provider/mention_cache_provider.dart'; +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]; +} + +@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 [], + this.center, + this.bottom = const [], + this.conversationId, + this.isLatest = false, + this.isOldest = false, + this.lastReadMessageId, + this.refreshKey, + this.anchor = const LatestMessageWindowAnchor(), + }) { + // check top, center, bottom didn't has same messageId + assert(() { + final ids = {}; + for (final item in list) { + if (ids.contains(item.messageId)) { + e('MessageState has same messageId: ${item.messageId}'); + } + ids.add(item.messageId); + } + return true; + }()); + } + + final String? conversationId; + final List top; + final MessageItem? center; + final List bottom; + final bool isLatest; + final bool isOldest; + final String? lastReadMessageId; + final Object? refreshKey; + final MessageWindowAnchor anchor; + + @override + List get props => [ + conversationId, + top, + center, + bottom, + isLatest, + isOldest, + lastReadMessageId, + refreshKey, + anchor, + ]; + + MessageItem? get bottomMessage => + bottom.lastOrNull ?? center ?? top.lastOrNull; + + MessageItem? get topMessage => + top.firstOrNull ?? center ?? bottom.firstOrNull; + + bool get isEmpty => top.isEmpty && center == null && bottom.isEmpty; + + List get list => [ + ...top, + ?center, + ...bottom, + ]; + + MessageState copyWith({ + String? conversationId, + List? top, + MessageItem? center, + List? bottom, + bool? isLatest, + bool? isOldest, + String? lastReadMessageId, + Object? refreshKey, + MessageWindowAnchor? anchor, + }) => MessageState( + conversationId: conversationId ?? this.conversationId, + top: top ?? this.top, + center: center ?? this.center, + bottom: bottom ?? this.bottom, + isLatest: isLatest ?? this.isLatest, + isOldest: isOldest ?? this.isOldest, + lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId, + refreshKey: refreshKey ?? this.refreshKey, + anchor: anchor ?? this.anchor, + ); + + MessageState removeMessage(String messageId) { + if (center?.messageId == messageId) { + return MessageState( + conversationId: conversationId, + top: top, + bottom: bottom, + isLatest: isLatest, + isOldest: isOldest, + lastReadMessageId: lastReadMessageId, + refreshKey: refreshKey, + anchor: anchor, + ); + } + + bool include(MessageItem message) => message.messageId == messageId; + bool exclusive(MessageItem message) => message.messageId != messageId; + + if (top.any(include)) { + return copyWith(top: top.where(exclusive).toList()); + } + + if (bottom.any(include)) { + return copyWith(bottom: bottom.where(exclusive).toList()); + } + + return this; + } +} + +class MessageController extends ValueNotifier { + MessageController({ + required this.conversationNotifier, + required this.limit, + required this.database, + required this.mentionCache, + required this.accountServer, + }) : super(MessageState()) { + _init( + centerMessageId: conversationNotifier.state?.initIndexMessageId, + lastReadMessageId: conversationNotifier.state?.lastReadMessageId, + ); + + _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 { + final set = {...state.top, state.center, ...state.bottom}; + await mentionCache.checkMentionCache( + { + ...set.map((e) => e?.content), + ...set.map((e) => e?.quoteContent), + }.nonNulls.toSet(), + ); + } + + void _warmMentionCache(MessageState state) { + unawaited( + _preCacheMention(state).catchError((Object error, StackTrace stackTrace) { + e('preCacheMention failed: $error'); + }), + ); + } + + Future _preCacheMentionSafely(MessageState state) async { + try { + await _preCacheMention(state); + } catch (error) { + e('preCacheMention failed: $error'); + } + } + + @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, {bool warmMentionCache = true}) { + if (_disposed) return; + value = nextState; + if (warmMentionCache) { + _warmMentionCache(nextState); + } + } + + void _init({ + String? centerMessageId, + String? lastReadMessageId, + bool forceLatest = false, + MessageWindowAnchor? anchor, + }) { + final generation = ++_generation; + unawaited( + _runInit( + generation, + centerMessageId: centerMessageId, + lastReadMessageId: lastReadMessageId, + forceLatest: forceLatest, + anchor: anchor, + ).catchError((Object error, StackTrace stackTrace) { + e('message init failed: $error $stackTrace'); + }), + ); + } + + Future _runInit( + int generation, { + 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 = resolveMessageWindowLastReadMessageId( + requestedLastReadMessageId: lastReadMessageId, + unseenMessageCount: conversation?.unseenMessageCount, + conversationLastReadMessageId: conversation?.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)} ' + 'unseen=${conversation?.unseenMessageCount} ' + 'forceLatest=$forceLatest limit=$finalLimit', + ); + + final messageState = await _resetMessageList( + conversationId, + finalLimit, + anchor: windowAnchor, + ); + if (!_isCurrent(generation, conversationId)) return; + + final nextState = _pretreatment( + messageState.copyWith( + refreshKey: Object(), + lastReadMessageId: resolvedLastReadMessageId, + ), + ); + traceChatJump( + 'message init loaded ' + 'conv=${shortMessageId(conversationId)} ' + 'lastRead=${shortMessageId(nextState.lastReadMessageId)} ' + '${_formatWindow(nextState)}', + ); + await _preCacheMentionSafely(nextState); + if (!_isCurrent(generation, conversationId)) return; + _emit(nextState, warmMentionCache: false); + } + + 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; + if (conversationId == null || state.conversationId != conversationId) { + return; + } + + _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), + ); + } + + Future _loadAfter(int generation, String conversationId) async { + final messageState = await _after(conversationId); + if (!_isCurrent(generation, conversationId)) return; + _emit(_pretreatment(messageState)); + } + + Future _loadBefore(int generation, String conversationId) async { + final messageState = await _before(conversationId); + if (!_isCurrent(generation, conversationId)) return; + _emit(_pretreatment(messageState)); + } + + Future _before(String conversationId) => + _messageWindowLoader.loadBefore(state, conversationId, limit); + + Future _after(String conversationId) => + _messageWindowLoader.loadAfter(state, conversationId, limit); + + Future _resetMessageList( + String conversationId, + int limit, { + required MessageWindowAnchor anchor, + }) async { + final conversation = conversationNotifier.state?.conversation; + + traceChatJump( + 'reset list ' + 'conv=${shortMessageId(conversationId)} ' + 'anchor=${anchor.debugName} ' + 'resolved=${shortMessageId(anchor.centerMessageId)} ' + 'convLastRead=${shortMessageId(conversation?.lastReadMessageId)} ' + 'unseen=${conversation?.unseenMessageCount}', + ); + + final state = await _messagesByConversationId( + conversationId, + limit, + anchor: anchor, + ); + + return state.copyWith( + conversationId: conversationId, + center: state.center, + bottom: state.bottom, + top: state.top, + ); + } + + Future _messagesByConversationId( + String conversationId, + int limit, { + required MessageWindowAnchor anchor, + }) => _messageWindowLoader.load( + conversationId, + limit, + anchor: anchor, + trace: traceChatJump, + ); + + Future restoreDirectionFromSource({ + required String? sourceMessageId, + required String targetMessageId, + }) => _messageWindowLoader.directionFromSource( + sourceMessageId: sourceMessageId, + targetMessageId: targetMessageId, + ); + + MessageState? _insertOrReplace( + String conversationId, + List list, + ) { + var top = state.top.toList(); + var center = state.center; + var bottom = state.bottom.toList(); + + for (final item in list) { + if (item.conversationId != conversationId) continue; + + if (item.messageId == center?.messageId) { + center = item; + continue; + } + + final topIndex = top.indexWhere( + (element) => element.messageId == item.messageId, + ); + if (topIndex > -1) { + top[topIndex] = item; + continue; + } + + final bottomIndex = bottom.indexWhere( + (element) => element.messageId == item.messageId, + ); + if (bottomIndex > -1) { + bottom[bottomIndex] = item; + continue; + } + + // if don't have messages or older message after then valid item + if (state.topMessage?.type != MessageCategory.secret && + (state.topMessage?.createdAt.isAfter(item.createdAt) ?? false)) { + continue; + } + + final currentUserSent = item.relationship == UserRelationship.me; + if (state.isLatest) { + if (center?.createdAt.isBefore(item.createdAt) ?? true) { + bottom = [...bottom, item] + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + } else { + top = [item, ...top] + ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + } + } else { + if (currentUserSent && item.status == MessageStatus.sending) { + loadLatestWindow(); + return null; + } + } + } + + return state.copyWith( + top: top, + center: center, + bottom: bottom, + ); + } + + 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 loadAroundMessage(String messageId) { + traceChatJump( + 'message loadAround target=${shortMessageId(messageId)}', + ); + _init( + centerMessageId: messageId, + lastReadMessageId: state.lastReadMessageId, + anchor: AroundMessageWindowAnchor( + messageId: messageId, + source: MessageWindowJumpSource.message, + ), + ); + } + + void reload() { + _init(); + } + + void loadLatestWindow() { + _init( + lastReadMessageId: state.lastReadMessageId, + anchor: const LatestMessageWindowAnchor(), + ); + } + + MessageState _pretreatment(MessageState messageState) { + List? top; + // check secretMessage + if (messageState.isOldest && conversationNotifier.state?.isBot == false) { + if (messageState.top.firstOrNull?.type == MessageCategory.secret) { + messageState.top.remove(messageState.top.first); + } + + top = [ + MessageItem( + sharedUserIdentityNumber: '', + status: MessageStatus.read, + messageId: '', + conversationId: '', + userIdentityNumber: '', + participantUserId: '', + userId: '', + type: MessageCategory.secret, + createdAt: messageState.topMessage?.createdAt ?? DateTime.now(), + pinned: false, + isVerified: false, + sharedUserIsVerified: false, + ), + ...messageState.top, + ]; + } + final _messageState = messageState.copyWith(top: top); + if (isAppActive) { + accountServer.markRead(conversationNotifier.state!.conversationId); + } + 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} ' + 'anchor=${state.anchor.debugName}'; +} diff --git a/lib/ui/home/notifier/message_window_loader.dart b/lib/ui/home/notifier/message_window_loader.dart new file mode 100644 index 0000000000..42c79c3759 --- /dev/null +++ b/lib/ui/home/notifier/message_window_loader.dart @@ -0,0 +1,243 @@ +part of 'message_controller.dart'; + +typedef RecentMessagesQuery = + Future> Function(String conversationId, int limit); +typedef MessageOrderInfoQuery = + Future Function(String messageId); +typedef AroundMessageIdsQuery = + Future> Function( + MessageOrderInfo anchor, + 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, + }); + + 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; + 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, { + 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); + + trace?.call('query recent count=${list.length} limit=$limit'); + return MessageState( + top: list.reversed.toList(), + isLatest: true, + isOldest: list.length < limit, + ); + } + + if (resolvedCenterMessageId == null) return recent(); + + final info = await messageOrderInfo(resolvedCenterMessageId); + if (info == null) { + trace?.call( + 'query center missing-order ' + 'target=${shortMessageId(resolvedCenterMessageId)}', + ); + return recent(); + } + + final halfLimit = limit ~/ 2; + trace?.call( + 'query center order target=${shortMessageId(resolvedCenterMessageId)} ' + '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(resolvedCenterMessageId)} ' + '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, + resolvedCenterMessageId, + ...bottomIds, + ]; + final messages = await messagesByIds(messageIds); + final messagesById = { + for (final message in messages) message.messageId: message, + }; + + final bottomList = bottomIds + .map((id) => messagesById[id]) + .nonNulls + .toList(); + var topList = topIds.reversed + .map((id) => messagesById[id]) + .nonNulls + .toList(); + var center = messagesById[resolvedCenterMessageId]; + + final isLatest = bottomIds.length < halfLimit; + final isOldest = topIds.length < halfLimit; + + if (bottomList.isEmpty && center != null) { + topList = [...topList, center]; + center = null; + } + + trace?.call( + 'query centered target=${shortMessageId(resolvedCenterMessageId)} ' + '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, + anchor: resolvedAnchor, + ); + } +} 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 73% rename from lib/ui/home/bloc/search_message_cubit.dart rename to lib/ui/home/notifier/search_message_controller.dart index cb6a9e6a04..fcd869acc1 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'); + if (items.length < limit) { + 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; } } @@ -109,21 +110,24 @@ abstract class SearchMessageCubit extends Cubit { 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(); } } @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 +173,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/home/route/major_navigator.dart b/lib/ui/home/route/major_navigator.dart new file mode 100644 index 0000000000..6b2569a15c --- /dev/null +++ b/lib/ui/home/route/major_navigator.dart @@ -0,0 +1,257 @@ +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'; +import '../desktop_shell_layout.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 state = ref.watch(majorNavigationProvider); + + return LayoutBuilder( + builder: (context, boxConstraints) { + final routeMode = boxConstraints.maxWidth < switchWidth; + 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) { + 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, + ...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 f084fe9f38..0000000000 --- a/lib/ui/home/route/responsive_navigator.dart +++ /dev/null @@ -1,212 +0,0 @@ -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, - 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/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 deleted file mode 100644 index 9b0aa8f494..0000000000 --- a/lib/ui/provider/abstract_responsive_navigator.dart +++ /dev/null @@ -1,84 +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 { - AbstractResponsiveNavigatorStateNotifier(super.initialState); - - void updateRouteMode(bool routeMode) => - Future(() => state = state.copyWith(routeMode: routeMode)); - - void onPopPage() { - final bool = state.pages.isNotEmpty; - if (bool) { - state = 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); - state = 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); - state = state.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(), - ); - - Future replace(String name, {Object? arguments}) async { - popWhere((page) => page.name == name); - await Future.delayed(Duration.zero); - pushPage(name, arguments: arguments); - } - - void clear() => state = state.copyWith(pages: []); -} 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 25d3f71b40..502c35bde9 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,11 @@ 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/bloc/conversation_list_bloc.dart'; -import '../home/bloc/subscriber_mixin.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 'chat_navigation_intent_provider.dart'; +import 'major_navigation_provider.dart'; class ConversationState extends Equatable { const ConversationState({ @@ -50,7 +44,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 +103,7 @@ class ConversationState extends Equatable { App? app, Participant? participant, Object? refreshKey, - String? initialSidePage, + ConversationInfoDestination? initialSidePage, String? keyword, }) => ConversationState( conversationId: conversationId ?? this.conversationId, @@ -140,8 +134,10 @@ class ConversationStateNotifier with SubscriberMixin { ConversationStateNotifier({ required AccountServer accountServer, - required ResponsiveNavigatorStateNotifier responsiveNavigatorStateNotifier, - }) : _responsiveNavigatorStateNotifier = responsiveNavigatorStateNotifier, + required MajorNavigationNotifier majorNavigationNotifier, + required ChatNavigationIntentNotifier chatNavigationIntentNotifier, + }) : _majorNavigationNotifier = majorNavigationNotifier, + _chatNavigationIntentNotifier = chatNavigationIntentNotifier, _accountServer = accountServer, super(null) { addSubscription( @@ -226,7 +222,8 @@ class ConversationStateNotifier } final AccountServer _accountServer; - final ResponsiveNavigatorStateNotifier _responsiveNavigatorStateNotifier; + final MajorNavigationNotifier _majorNavigationNotifier; + final ChatNavigationIntentNotifier _chatNavigationIntentNotifier; late final Database _database = _accountServer.database; late final String _currentUserId = _accountServer.userId; @@ -245,174 +242,21 @@ 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); + void openChatPage() { + _majorNavigationNotifier.openChatPage(); } - 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 requestLatestJump() { + _chatNavigationIntentNotifier.requestLatestJump(); + 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 +280,24 @@ final conversationProvider = throw Exception('accountServer is not ready'); } - final responsiveNavigatorNotifier = ref.watch( - responsiveNavigatorProvider.notifier, + final majorNavigationNotifier = ref.watch( + majorNavigationProvider.notifier, + ); + final chatNavigationIntentNotifier = ref.watch( + chatNavigationIntentProvider.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, + chatNavigationIntentNotifier: chatNavigationIntentNotifier, ); }); diff --git a/lib/ui/provider/major_navigation_provider.dart b/lib/ui/provider/major_navigation_provider.dart new file mode 100644 index 0000000000..a053f216fe --- /dev/null +++ b/lib/ui/provider/major_navigation_provider.dart @@ -0,0 +1,120 @@ +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 [], + }); + + final List entries; + + bool contains(MajorNavigationDestination destination) => + entries.any((entry) => entry.destination == destination); + + MajorNavigationState copyWith({ + List? entries, + }) => MajorNavigationState( + entries: entries ?? this.entries, + ); + + @override + List get props => [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 clear() { + state = state.copyWith(entries: []); + } + + void openChatPage() { + open(MajorNavigationDestination.chatPage); + } + + bool syncSettingCategory(bool isSetting, {required bool routeMode}) { + final openSettingPage = isSetting && !routeMode; + _removeWhere((entry) { + if (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 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(); + 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()); 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/ui/provider/mention_provider.dart b/lib/ui/provider/mention_provider.dart index 2deab6bd1d..51ebc4e482 100644 --- a/lib/ui/provider/mention_provider.dart +++ b/lib/ui/provider/mention_provider.dart @@ -6,14 +6,12 @@ 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'; -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'; @@ -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/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/pending_chat_jump_provider.dart b/lib/ui/provider/pending_chat_jump_provider.dart new file mode 100644 index 0000000000..1098edc0bf --- /dev/null +++ b/lib/ui/provider/pending_chat_jump_provider.dart @@ -0,0 +1,14 @@ +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; +}); 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/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/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/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/appearance_page.dart b/lib/ui/setting/appearance_page.dart index 670adb75ad..345f5560d0 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,21 @@ class _ChatTextSizePreview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final tickerProvider = useSingleTickerProvider(); - final blinkCubit = useMemoized( - () => BlinkCubit( - 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]); + final searchConversationKeywordNotifier = useMemoized( + () => + SearchConversationKeywordNotifier(chatSideNotifier: chatSideNotifier), + [chatSideNotifier], ); - final chatSideCubit = useBloc(ChatSideCubit.new); - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), + useEffect( + () => searchConversationKeywordNotifier.dispose, + [searchConversationKeywordNotifier], ); + final hiddenDateTime = useMemoized(() => ValueNotifier(null)); + useEffect(() => hiddenDateTime.dispose, [hiddenDateTime]); final messageHi = useMemoized( () => _buildFakeTextMessage(context.l10n.sayHi), @@ -271,9 +275,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/ui/setting/setting_page.dart b/lib/ui/setting/setting_page.dart index 3e66506620..65d0aef93a 100644 --- a/lib/ui/setting/setting_page.dart +++ b/lib/ui/setting/setting_page.dart @@ -12,32 +12,20 @@ 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/home.dart'; +import '../home/desktop_shell_layout.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(); - - 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, @@ -50,125 +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, - pageName: - ResponsiveNavigatorStateNotifier.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, - pageName: - ResponsiveNavigatorStateNotifier.accountPage, - title: context.l10n.account, - ), - _Item( - leadingAssetName: - Resources.assetsImagesIcNotificationSvg, - pageName: ResponsiveNavigatorStateNotifier - .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, + 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, + ), + width: 22, + height: 22, ), - width: 22, - height: 22, - ), - ) - : const Arrow(), - color: hasNotificationPermission == false - ? context.theme.red - : context.theme.text, - ), - _Item( - leadingAssetName: - Resources.assetsImagesIcStorageUsageSvg, - pageName: ResponsiveNavigatorStateNotifier - .dataAndStorageUsagePage, - title: context.l10n.dataAndStorageUsage, - ), - _Item( - leadingAssetName: Resources.assetsImagesShieldSvg, - pageName: - ResponsiveNavigatorStateNotifier.securityPage, - title: context.l10n.security, - ), - _Item( - leadingAssetName: Resources.assetsImagesProxySvg, - pageName: - ResponsiveNavigatorStateNotifier.proxyPage, - title: context.l10n.proxy, - ), - _Item( - leadingAssetName: - Resources.assetsImagesIcAppearanceSvg, - pageName: - ResponsiveNavigatorStateNotifier.appearancePage, - title: context.l10n.appearance, - ), - _Item( - leadingAssetName: Resources.assetsImagesIcAboutSvg, - pageName: - ResponsiveNavigatorStateNotifier.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(), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), ); } } @@ -177,7 +164,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,18 +172,18 @@ 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; @override Widget build(BuildContext context, WidgetRef ref) { + final routeMode = DesktopShellLayout.mainRouteModeOf(context); final selected = ref.watch( - responsiveNavigatorProvider.select( + majorNavigationProvider.select( (value) => - !value.routeMode && - value.pages.any((element) => pageName == element.name), + !routeMode && destination != null && value.contains(destination!), ), ); @@ -216,13 +203,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/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/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/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/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/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/image.dart b/lib/utils/image.dart index bcbf65c6fa..11e25cc45e 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,164 @@ 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 response = await _sendImageRequest(resolved, proxyConfig); + if (response.statusCode != HttpStatus.ok) return null; + + final file = File(await generateTempFilePath(TempFileType.networkImage)); + final sink = file.openWrite(); + try { + 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(); + } catch (_) {} + if (file.existsSync()) await file.delete(); + 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) { + throw StateError('NetworkImage is an empty file: $resolved'); + } + return builder.takeBytes(); +} + +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) { + 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 { + final normalizedMimeType = mimeType?.toLowerCase(); + if (normalizedMimeType != null && + normalizedMimeType != ImageType.gif.mimeType) { + return; + } + if (!file.existsSync()) return; + + 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); + } +} + +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/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/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..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/responsive_navigator_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(navigatorRouteModeProvider)) { + if (selected && !DesktopShellLayout.mainRouteModeOf(context)) { 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/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/media_image_pipeline.dart b/lib/widgets/media_image_pipeline.dart new file mode 100644 index 0000000000..b67c86df99 --- /dev/null +++ b/lib/widgets/media_image_pipeline.dart @@ -0,0 +1,248 @@ +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'; + +typedef PlaceholderWidgetBuilder = Widget Function(); + +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'); +} + +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}); + + 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) { + _future = null; + return; + } + 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; + } + + final path = image.file.absolute.path; + if (_checkedImageFiles.contains(path)) { + _future = null; + return; + } + + _future = _normalize(image.file); + } + + Future _normalize(File file) async { + try { + await normalizeGifFileIfNeeded(file, null); + await FileImage(file).evict(); + } finally { + _checkedImageFiles.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/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/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/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/item/quote_message.dart b/lib/widgets/message/item/quote_message.dart index 81dbc4738e..f97a40be26 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'; @@ -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, ); @@ -499,15 +499,15 @@ class _QuoteMessageBase extends HookConsumerWidget { context.jumpToMessageInChat( quoteMessageId, sourceMessageId: messageId, + closeSideAfterJump: true, ), ); - context.popChatSideRouteIfNeeded(); return; } } catch (_) {} context.providerContainer - .read(pendingJumpMessageProvider.notifier) + .read(pendingChatJumpProvider.notifier) .state = messageId; traceChatJump( 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..4da36a7dc7 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,21 @@ 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( - tickerProvider, - context.theme.accent.withValues(alpha: 0.5), - ), - ); + final blinkNotifier = useMemoized(() => BlinkNotifier(tickerProvider)); + useEffect(() => blinkNotifier.dispose, [blinkNotifier]); final scrollController = useMemoized(ScrollController.new); final listKey = useMemoized( @@ -281,8 +284,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..b13749bcc2 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -1,194 +1,143 @@ -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/gestures.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:gal/gal.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide 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:hooks_riverpod/hooks_riverpod.dart' + hide ChangeNotifierProvider, Provider; 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/provider/conversation_provider.dart'; +import '../../ui/home/notifier/blink_notifier.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 '../../ui/provider/user_cache_provider.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_context.dart'; import 'message_day_time.dart'; import 'message_name.dart'; +import 'message_presentation.dart'; +import 'message_rows.dart'; import 'message_style.dart'; -class _MessageContextCubit extends SimpleCubit<_MessageContext> { - _MessageContextCubit(super.initialState); -} +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, - }); +const _pinArrowWidth = 32.0; - final bool isTranscriptPage; - final bool isPinnedPage; - final bool showNip; - final bool isCurrentUser; - final MessageItem message; +@visibleForTesting +bool shouldMarkMentionRead(bool? mentionRead, double visibleFraction) => + mentionRead == false && visibleFraction >= 1; - @override - List get props => [ - isTranscriptPage, - isPinnedPage, - showNip, - isCurrentUser, - message, - ]; -} +void _quickReply(BuildContext context) { + if (context.isPinnedPage) return; + if (context.isTranscriptPage) return; + if (!context.message.type.canReply) return; -bool useIsTranscriptPage() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.isTranscriptPage, - ); + context.read().blinkByMessageId(context.message.messageId); + context.providerContainer.read(quoteMessageProvider.notifier).state = + context.message; +} -bool useIsPinnedPage() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.isPinnedPage, - ); +@visibleForTesting +class MessageQuickReplyDetector extends HookWidget { + const MessageQuickReplyDetector({ + required this.child, + super.key, + }); -bool useShowNip() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.showNip, - ); + final Widget child; -bool useIsCurrentUser() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.isCurrentUser, - ); + @override + Widget build(BuildContext context) { + final lastPrimaryDownTime = useRef(null); + final lastPrimaryDownPosition = useRef(null); -MessageItem useMessage() => - useMessageConverter(converter: (state) => state); + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (event) { + if ((event.buttons & kPrimaryButton) == 0 || + _hitsSelectableText(context, event.position)) { + lastPrimaryDownTime.value = null; + lastPrimaryDownPosition.value = null; + return; + } -T useMessageConverter({required T Function(MessageItem) converter}) => - useBlocStateConverter<_MessageContextCubit, _MessageContext, T>( - converter: (state) => converter(state.message), + 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, ); + } +} -extension MessageContextExtension on BuildContext { - MessageItem get message => read<_MessageContextCubit>().state.message; - - bool get isPinnedPage => read<_MessageContextCubit>().state.isPinnedPage; +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); + } - bool get isTranscriptPage => - read<_MessageContextCubit>().state.isTranscriptPage; + context.visitChildElements(visit); + return hit; } -const _pinArrowWidth = 32.0; +bool _paragraphHitsText(RenderParagraph paragraph, Offset globalPosition) { + final localPosition = paragraph.globalToLocal(globalPosition); + if (!paragraph.paintBounds.contains(localPosition)) return false; -void _quickReply(BuildContext context) { - if (context.isPinnedPage) return; - if (context.isTranscriptPage) return; - if (!context.message.type.canReply) return; + final text = paragraph.text.toPlainText(includeSemanticsLabels: false); + if (text.isEmpty) return false; - doubleTap('_quickReply', const Duration(milliseconds: 300), () { - context.read().blinkByMessageId(context.message.messageId); - context.providerContainer.read(quoteMessageProvider.notifier).state = - context.message; - }); -} - -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 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 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; + final boxes = paragraph.getBoxesForSelection( + TextSelection(baseOffset: index, extentOffset: index + 1), + ); + if (boxes.any((box) => box.toRect().contains(localPosition))) return true; } - return null; + return false; } class MessageItemWidget extends HookConsumerWidget { @@ -197,19 +146,29 @@ class MessageItemWidget extends HookConsumerWidget { super.key, this.prev, this.next, + this.row, this.lastReadMessageId, this.isTranscriptPage = false, this.blink = true, this.isPinnedPage = false, + this.showUnreadBar = true, + this.dateTimeKey, + this.isGroupOrBotGroupConversation, + this.enableShowAvatar, }); 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 bool showUnreadBar; + final Key? dateTimeKey; + final bool? isGroupOrBotGroupConversation; + final bool? enableShowAvatar; static const primaryFontSize = 16.0; static const secondaryFontSize = 14.0; @@ -219,562 +178,119 @@ class MessageItemWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - 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 row = + this.row ?? + MessageRowModel(message: this.message, prev: prev, next: next); + final message = row.message; final isGroupOrBotGroupConversation = - message.conversionCategory == ConversationCategory.group || - message.userId != message.conversationOwnerId || - ref.watch(isBotGroupProvider(message.conversationId)); - - final enableShowAvatar = ref.watch( - settingProvider.select((value) => value.messageShowAvatar), + this.isGroupOrBotGroupConversation ?? + resolveGroupOrBotGroupConversation( + message: message, + isBotGroupConversation: ref.watch( + isBotGroupProvider(message.conversationId), + ), + ); + + final enableShowAvatar = + this.enableShowAvatar ?? + ref.watch(settingProvider.select((value) => value.messageShowAvatar)); + final presentation = MessagePresentation.fromRow( + row: row, + isGroupOrBotGroupConversation: isGroupOrBotGroupConversation, + enableShowAvatar: enableShowAvatar == true, + canShowUnreadBar: showUnreadBar, + lastReadMessageId: lastReadMessageId, ); - final showAvatar = isGroupOrBotGroupConversation && enableShowAvatar; + final datetime = row.dateTime; - final showNip = - !(sameUserNext && sameDayNext) && (!showAvatar || isCurrentUser); - final datetime = sameDayPrev ? null : message.createdAt; - String? userName; - String? userId; - String? userAvatarUrl; - - if (isGroupOrBotGroupConversation && - !isCurrentUser && - (!sameUserPrev || !sameDayPrev)) { - userName = message.userFullName; - userId = message.userId; - 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; - Widget child = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (datetime != null) MessageDayTime(dateTime: datetime), - ColoredBox( - color: blinkColor, - 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) { - request.onShowMenu.addListener(() { - showedMenuCubit.emit(true); - focusNode.requestFocus(); - }); - request.onHideMenu.addListener(() { - showedMenuCubit.emit(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(); - }, + if (datetime != null) + MessageDayTime(key: dateTimeKey, dateTime: datetime), + 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: presentation.userName, + userId: presentation.userId, + userAvatarUrl: presentation.userAvatarUrl, + showAvatar: presentation.showAvatar, + isCurrentUser: presentation.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 && next != null) - const _UnreadMessageBar(), + if (presentation.showUnreadBar) 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, child: MessageContext( isTranscriptPage: isTranscriptPage, isPinnedPage: isPinnedPage, - showNip: showNip, - isCurrentUser: isCurrentUser, + showNip: presentation.showNip, + isCurrentUser: presentation.isCurrentUser, message: message, + highlightEnabled: blink, + menuHighlighted: showedMenu.value, child: Builder( - builder: (context) => GestureDetector( - onTap: () => _quickReply(context), + builder: (context) => MessageQuickReplyDetector( child: Padding( - padding: sameUserPrev + padding: row.sameUserPrev ? EdgeInsets.zero : const EdgeInsets.only(top: 8), child: child, @@ -784,140 +300,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 { - 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, WidgetRef ref) { - _MessageContext newMessageContext() => _MessageContext( - isTranscriptPage: isTranscriptPage, - isPinnedPage: isPinnedPage, - showNip: showNip, - isCurrentUser: isCurrentUser, - message: message, - ); - - final messageContextCubit = useBloc( - () => _MessageContextCubit(newMessageContext()), - ); - - useEffect(() { - messageContextCubit.emit(newMessageContext()); - }, [isTranscriptPage, isPinnedPage, showNip, isCurrentUser, message]); - - return Provider.value(value: messageContextCubit, child: child); - } -} - -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 { @@ -951,18 +333,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, @@ -976,15 +361,12 @@ 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)), ), ], ); - final needShowAvatar = !isCurrentUser && userName != null; + final needShowAvatar = !isCurrentUser && displayName != null; if (!showAvatar || !needShowAvatar) { return Padding( padding: EdgeInsets.only( @@ -1002,12 +384,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, ), ), @@ -1023,8 +410,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/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 new file mode 100644 index 0000000000..d2bc99fed4 --- /dev/null +++ b/lib/widgets/message/message_actions_menu.dart @@ -0,0 +1,416 @@ +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 '../../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/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_action_policy.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 policy = MessageActionPolicy( + message: message, + isTranscriptPage: isTranscriptPage, + isPinnedPage: isPinnedPage, + role: role, + ); + + final addStickerMenuAction = [ + if (policy.canAddSticker) + MenuAction( + image: MenuImage.icon(IconFonts.sticker), + title: context.l10n.addSticker, + callback: () => _onAddSticker(context, message), + ), + if (policy.canAddImageAsSticker) + MenuAction( + image: MenuImage.icon(IconFonts.sticker), + title: context.l10n.addSticker, + callback: () => _onAddImageAsSticker( + context, + message, + isTranscriptPage, + ), + ), + ]; + + final replayAction = [ + if (policy.canReply) + 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 (policy.canForward) + 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 (policy.canSelect) + MenuAction( + image: MenuImage.icon(IconFonts.select), + title: context.l10n.select, + callback: () => ref + .read(messageSelectionProvider) + .selectMessage( + message, + ), + ), + if (policy.canPin) + 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 (policy.canSaveMobile) + MenuAction( + image: MenuImage.icon(IconFonts.download), + title: context.l10n.saveToCameraRoll, + callback: () => saveAs( + context, + context.accountServer, + message, + isTranscriptPage, + ), + ), + if (policy.canSaveDesktop) + MenuAction( + image: MenuImage.icon(IconFonts.download), + title: context.l10n.saveAs, + callback: () => saveAs( + context, + context.accountServer, + message, + isTranscriptPage, + ), + ), + ]; + final deleteActions = [ + if (policy.canRecall) + 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 (policy.canDelete) + 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..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; @@ -88,8 +99,6 @@ class MessageBubble extends HookConsumerWidget { 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, @@ -133,14 +142,30 @@ 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, name: Resources.assetsImagesPinArrowSvg, onTap: () { final message = context.message; - unawaited(context.jumpToMessageInChat(message.messageId)); - context.popChatSideRouteIfNeeded(); + unawaited( + context.jumpToMessageInChat( + message.messageId, + closeSideAfterJump: true, + ), + ); }, ); @@ -215,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_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_context.dart b/lib/widgets/message/message_context.dart new file mode 100644 index 0000000000..8f85deac8a --- /dev/null +++ b/lib/widgets/message/message_context.dart @@ -0,0 +1,150 @@ +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.highlightEnabled, + required this.menuHighlighted, + required this.message, + }); + + final bool isTranscriptPage; + final bool isPinnedPage; + final bool showNip; + final bool isCurrentUser; + final bool highlightEnabled; + final bool menuHighlighted; + final MessageItem message; + + @override + List get props => [ + isTranscriptPage, + isPinnedPage, + showNip, + isCurrentUser, + highlightEnabled, + menuHighlighted, + 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); + +bool useMessageHighlightEnabled() => + _useMessageContextConverter((state) => state.highlightEnabled); + +bool useMessageMenuHighlighted() => + _useMessageContextConverter((state) => state.menuHighlighted); + +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, + this.highlightEnabled = true, + this.menuHighlighted = false, + }); + + MessageContext.fromMessageItem({ + required this.message, + required this.child, + super.key, + 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; + + @override + Widget build(BuildContext context) => _MessageContextScope( + value: _MessageContext( + isTranscriptPage: isTranscriptPage, + isPinnedPage: isPinnedPage, + showNip: showNip, + isCurrentUser: isCurrentUser, + highlightEnabled: highlightEnabled, + menuHighlighted: menuHighlighted, + 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/message/message_day_time.dart b/lib/widgets/message/message_day_time.dart index dc6b581086..57ff2d97e0 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(); @@ -79,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, { @@ -112,6 +126,23 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { super.key, }); + factory MessageDayTimeViewportWidget.chatPage({ + required Widget child, + required ScrollController scrollController, + required List entries, + Key? key, + }) => MessageDayTimeViewportWidget._create( + () { + final result = _CurrentShowingMessages(); + entries.forEach(result.addEntry); + return result; + }, + key: key, + scrollController: scrollController, + reTraversalKey: entries, + child: child, + ); + factory MessageDayTimeViewportWidget.singleList({ required Widget child, required ScrollController scrollController, @@ -123,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; @@ -148,10 +179,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 +260,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 +280,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 +317,8 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { doTraversal(); }); }, [constraints]); - return BlocProvider.value( - value: bloc, + return ChangeNotifierProvider>.value( + value: hiddenDateTime, child: ClipRect( child: Stack( fit: StackFit.expand, @@ -319,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; @@ -334,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/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/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/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/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/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/mixin_image.dart b/lib/widgets/mixin_image.dart index 130aa49de3..bfc5f76503 100644 --- a/lib/widgets/mixin_image.dart +++ b/lib/widgets/mixin_image.dart @@ -1,174 +1,12 @@ -import 'dart:async'; -import 'dart:convert'; import 'dart:io'; -import 'dart:ui' as ui; +import 'dart:typed_data'; -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/proxy.dart'; +import 'media_image_pipeline.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); - -const String cacheImageFolderName = 'cacheimage'; - -Future _loadCacheBuffer( - ImageProvider provider, - ProxyConfig? proxyConfig, -) async { - final key = _getKeyImage(provider); - final md5Key = key != null ? keyToMd5(key) : null; - - 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; - - final _cacheImagesDirectory = Directory( - join((await getTemporaryDirectory()).path, cacheImageFolderName), - ); - Uint8List? data; - - if (_cacheImagesDirectory.existsSync() && md5Key != null) { - try { - data = await File(join(_cacheImagesDirectory.path, md5Key)).readAsBytes(); - } catch (_) { - // Throws an Exception if the asset does not exist. - } - } else { - await _cacheImagesDirectory.create(); - } - - if (data == null) { - data = await _loadBytes(provider, proxyConfig); - if (data != null && md5Key != null) { - await File(join(_cacheImagesDirectory.path, md5Key)).writeAsBytes(data); - } - } - - 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; - } - - 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; -} - -/// 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 +14,9 @@ class MixinImage extends HookWidget { this.errorBuilder, this.width, this.height, - this.cancelToken, this.fit = BoxFit.cover, this.isAntiAlias = false, - this.controller, + this.normalizeGif = false, }); MixinImage.network( @@ -189,10 +26,9 @@ class MixinImage extends HookWidget { this.errorBuilder, this.width, this.height, - this.cancelToken, this.fit = BoxFit.cover, this.isAntiAlias = false, - this.controller, + this.normalizeGif = false, }) : image = NetworkImage(url); MixinImage.file( @@ -203,10 +39,9 @@ class MixinImage extends HookWidget { this.width, this.height, this.fit = BoxFit.cover, - this.cancelToken, this.isAntiAlias = false, - this.controller, - }) : image = FileImage(file); + }) : image = FileImage(file), + normalizeGif = false; MixinImage.asset( String assetName, { @@ -215,11 +50,10 @@ class MixinImage extends HookWidget { this.errorBuilder, this.width, this.height, - this.cancelToken, this.fit = BoxFit.cover, this.isAntiAlias = false, - this.controller, - }) : image = AssetImage(assetName); + }) : image = AssetImage(assetName), + normalizeGif = false; MixinImage.memory( Uint8List bytes, { @@ -228,213 +62,30 @@ class MixinImage extends HookWidget { this.errorBuilder, this.width, this.height, - this.cancelToken, this.fit = BoxFit.cover, this.isAntiAlias = false, - this.controller, - }) : image = MemoryImage(bytes); + }) : image = MemoryImage(bytes), + normalizeGif = false; final ImageProvider image; final PlaceholderWidgetBuilder? placeholder; final ImageErrorWidgetBuilder? errorBuilder; final double? width; final double? height; - final CancellationToken? cancelToken; final BoxFit? fit; final bool isAntiAlias; - final ValueNotifier? controller; + final bool normalizeGif; @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); - } - - 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, - ); - }, - ); - } - - return _AnimatedImageWidget( - codec: codecAsync.data!, - controller: controller!, - placeholder: placeholder ?? () => SizedBox(width: width, height: height), - errorBuilder: errorBuilder, - width: width, - height: height, - fit: fit, - isAntiAlias: isAntiAlias, - ); - } -} - -class _AnimatedImageWidget extends HookWidget { - const _AnimatedImageWidget({ - required this.codec, - required this.controller, - required this.placeholder, - required this.errorBuilder, - this.width, - this.height, - this.fit, - this.isAntiAlias = false, - }); - - 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; - - @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; - } - } - } - - getNextFrame(); - - void onControllerChanged() { - if (controller.value) { - if (codec.frameCount > 1) { - getNextFrame(); - } - } else { - timer.value?.cancel(); - timer.value = null; - } - } - - controller.addListener(onControllerChanged); - - 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; - } - - if (frameInfo.value == null) { - return placeholder?.call() ?? SizedBox(width: width, height: height); - } - - final currentFrame = frameInfo.value; - if (currentFrame == null) { - return placeholder?.call() ?? SizedBox(width: width, height: height); - } - - return RawImage( - image: currentFrame.image, - width: width, - height: height, - fit: fit, - isAntiAlias: isAntiAlias, - ); - } + 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/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/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_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/lib/widgets/sticker_page/sticker_page.dart b/lib/widgets/sticker_page/sticker_page.dart index 2a8e3b59de..5d8f24065a 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,10 +7,9 @@ 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'; +import '../../core/read_model/sticker_read_model.dart'; import '../../db/mixin_database.dart'; import '../../ui/provider/conversation_provider.dart'; import '../../utils/extension/extension.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,18 @@ class StickerPage extends StatelessWidget { required this.tabLength, required this.tabController, required this.presetStickerGroups, + required this.stickerAlbums, + required this.readModel, + this.onStickerSent, super.key, }); final TabController tabController; final int tabLength; final List presetStickerGroups; + final List stickerAlbums; + final StickerReadModel readModel; + final VoidCallback? onStickerSent; @override Widget build(BuildContext context) => Material( @@ -78,26 +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); @@ -115,6 +104,7 @@ class StickerPage extends StatelessWidget { } }, canAddSticker: true, + onStickerSent: onStickerSent, ); case PresetStickerGroup.gif: return const AutomaticKeepAliveClientWidget( @@ -123,21 +113,12 @@ class StickerPage extends StatelessWidget { } } return _StickerAlbumPage( + onStickerSent: onStickerSent, getStickers: () { - final albumId = BlocProvider.of( - context, - ).state[index - presetStickerGroups.length].albumId; - return context.database.stickerDao - .stickerByAlbumId(albumId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance - .watchUpdateStickerStream( - albumIds: [albumId], - ), - ], - duration: kVerySlowThrottleDuration, - ); + final albumId = + stickerAlbums[index - presetStickerGroups.length] + .albumId; + return readModel.albumStickers(albumId); }, ); }), @@ -147,6 +128,7 @@ class StickerPage extends StatelessWidget { tabLength: tabLength, tabController: tabController, presetStickerGroups: presetStickerGroups, + stickerAlbums: stickerAlbums, ), ], ), @@ -161,6 +143,7 @@ class _StickerAlbumPage extends HookConsumerWidget { this.updateUsedAt = true, this.delete, this.canAddSticker = false, + this.onStickerSent, }); final Stream> Function() getStickers; @@ -168,38 +151,40 @@ 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) { - 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, + onStickerSent: onStickerSent, + ); + }, ); } } @@ -257,22 +242,19 @@ class _StickerStoreEmptyPage extends StatelessWidget { class _StickerAlbumPageItem extends HookConsumerWidget { const _StickerAlbumPageItem({ - required this.index, + required this.sticker, required this.updateUsedAt, this.delete, + this.onStickerSent, }); - final int index; + final Sticker sticker; final bool updateUsedAt; final void Function(Sticker)? delete; + final VoidCallback? onStickerSent; @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; @@ -298,6 +280,7 @@ class _StickerAlbumPageItem extends HookConsumerWidget { recipientId: conversationItem.user?.userId, ), ]); + onStickerSent?.call(); }, hoveringDecoration: BoxDecoration( color: context.dynamicColor( @@ -343,11 +326,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 +396,7 @@ class _StickerAlbumBar extends HookConsumerWidget { (index) => _StickerAlbumBarItem( index: index, presetStickerGroups: presetStickerGroups, + stickerAlbums: stickerAlbums, ), ), ), @@ -422,10 +408,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 +452,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..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/chat/chat_page.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: ChatSideCubit.infoPage, + initialChatSidePage: ConversationInfoDestination.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/pubspec.lock b/pubspec.lock index 09f6a5f080..496b8c93ff 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 af7861a07a..8fb92de632 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/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..db1a198711 100644 --- a/test/ui/home/chat/chat_page_test.dart +++ b/test/ui/home/chat/chat_page_test.dart @@ -1,4 +1,5 @@ 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'; @@ -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), + ); + }, + ); } diff --git a/test/ui/home/chat/chat_scroll_coordinator_test.dart b/test/ui/home/chat/chat_scroll_coordinator_test.dart index a085598efc..275aef270a 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,76 @@ 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('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', ( @@ -183,6 +340,84 @@ 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('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 { @@ -272,6 +507,195 @@ 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('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 { + 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 { @@ -310,6 +734,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 { @@ -890,12 +1351,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, + ), + ); + }, ), ], ), @@ -923,9 +1390,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, + ), ), ], ), @@ -935,6 +1406,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', 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/home/chat/message_jump_test.dart b/test/ui/home/chat/message_jump_test.dart deleted file mode 100644 index b018e0af95..0000000000 --- a/test/ui/home/chat/message_jump_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_app/ui/home/chat/message_jump.dart'; -import 'package:flutter_test/flutter_test.dart'; - -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'), - ), - ), - ); - - 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); - }); -} 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/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/ui/home/notifier/chat_side_notifier_test.dart b/test/ui/home/notifier/chat_side_notifier_test.dart new file mode 100644 index 0000000000..6ca4e19ef6 --- /dev/null +++ b/test/ui/home/notifier/chat_side_notifier_test.dart @@ -0,0 +1,51 @@ +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', () { + final notifier = ChatSideNotifier(); + addTearDown(notifier.dispose); + + notifier.toggleDestination(ConversationInfoDestination.pinMessages); + expect(notifier.state.destinations, [ + ConversationInfoDestination.pinMessages, + ]); + + notifier.toggleDestination(ConversationInfoDestination.pinMessages); + expect(notifier.state.destinations, isEmpty); + }); + + test('route-mode content jump closes the side stack', () { + final notifier = ChatSideNotifier(); + addTearDown(notifier.dispose); + + notifier + ..openDestination(ConversationInfoDestination.searchMessageHistory) + ..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); + + notifier.pop(); + + expect(notifier.state.destinations, isEmpty); + }); +} 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/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', + ); + }); +} diff --git a/test/ui/home/notifier/message_window_loader_test.dart b/test/ui/home/notifier/message_window_loader_test.dart new file mode 100644 index 0000000000..2bb73a74ef --- /dev/null +++ b/test/ui/home/notifier/message_window_loader_test.dart @@ -0,0 +1,189 @@ +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'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; + +void main() { + test( + 'centered load hydrates around id window in one item query', + () async { + final calls = []; + 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: (_, _, _) => throw StateError('unexpected before'), + afterMessages: (_, _, _) => throw StateError('unexpected after'), + beforeMessageIds: (_, _, _) { + calls.add('beforeIds'); + return beforeIdsCompleter.future; + }, + afterMessageIds: (_, _, _) { + calls.add('afterIds'); + return afterIdsCompleter.future; + }, + messagesByIds: (ids) { + calls.add('hydrate:${ids.join(',')}'); + return hydrateCompleter.future; + }, + ); + + final future = loader.load( + 'conversation', + 6, + centerMessageId: 'center', + ); + await Future.delayed(Duration.zero); + + expect(calls, unorderedEquals(['beforeIds', 'afterIds'])); + + 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, 'center'); + 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, + ); + }); + + 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( + messageId: 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/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; +} 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(); }); }); 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/ui/provider/major_navigation_provider_test.dart b/test/ui/provider/major_navigation_provider_test.dart new file mode 100644 index 0000000000..9a973c0109 --- /dev/null +++ b/test/ui/provider/major_navigation_provider_test.dart @@ -0,0 +1,32 @@ +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', () { + final notifier = MajorNavigationNotifier() + ..open(MajorNavigationDestination.chatPage); + + final opened = notifier.syncSettingCategory(true, routeMode: true); + + 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); + }); +} 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/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..36205363ae 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,127 @@ 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('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); + + 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( + '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) @@ -24,3 +146,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); +} diff --git a/test/widgets/media_image_pipeline_test.dart b/test/widgets/media_image_pipeline_test.dart new file mode 100644 index 0000000000..9cc807f6b4 --- /dev/null +++ b/test/widgets/media_image_pipeline_test.dart @@ -0,0 +1,84 @@ +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'; + +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, + ); + }); + + 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( + 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 new file mode 100644 index 0000000000..680ca305dd --- /dev/null +++ b/test/widgets/message/message_context_test.dart @@ -0,0 +1,609 @@ +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' 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'; +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/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'; +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' hide User; +import 'package:provider/provider.dart' as provider; + +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); + }); + + 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 { + 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); + }); + + 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)); + }); + + 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('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('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); + 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); + }); + + 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 { + const _MessageIdText(); + + @override + Widget build(BuildContext context) => Text( + useMessageConverter(converter: (message) => message.messageId), + ); +} + +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); +} + +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', + UserRelationship relationship = UserRelationship.friend, + String? quoteId, + String? quoteContent, + bool? mentionRead, +}) => MessageItem( + messageId: id, + conversationId: 'conversation', + type: 'PLAIN_TEXT', + content: 'body', + createdAt: DateTime(2026), + status: MessageStatus.read, + userId: userId, + relationship: relationship, + userIdentityNumber: '0', + isVerified: false, + sharedUserIsVerified: false, + quoteId: quoteId, + quoteContent: quoteContent, + mentionRead: mentionRead, + 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"}'; + +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, + ), + ), + ), + ), + ); + } +} 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); + }); +} 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), + ); + }); +} 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, +);