From ceff6f77c744ea2013f8cd3fc071155017a4f278 Mon Sep 17 00:00:00 2001 From: poppingmoon <63451158+poppingmoon@users.noreply.github.com> Date: Sat, 9 Nov 2024 12:17:38 +0900 Subject: [PATCH] feat: preserve hashtags in post form (#464) * test: add test for MfmKeyboard * refactor: split MfmKeyboard * refactor: return Note when post * feat: preserve hashtags * fix: change underline color --- .../notes_create_request_extension.dart | 19 + lib/model/account_settings.dart | 2 + lib/model/account_settings.freezed.dart | 76 +- lib/model/account_settings.g.dart | 7 + .../account_settings_notifier_provider.dart | 10 + .../account_settings_notifier_provider.g.dart | 2 +- .../post_form_hashtags_notifier_provider.dart | 44 + ...ost_form_hashtags_notifier_provider.g.dart | 178 ++++ lib/provider/post_notifier_provider.dart | 19 +- lib/provider/post_notifier_provider.g.dart | 2 +- lib/provider/theme_data_provider.dart | 3 +- lib/provider/theme_data_provider.g.dart | 2 +- lib/view/page/post_page.dart | 77 +- lib/view/page/tag/tag_page.dart | 17 +- lib/view/widget/mention_widget.dart | 1 + lib/view/widget/mfm_keyboard.dart | 996 +++++++++++------- lib/view/widget/post_form.dart | 136 ++- test/view/widget/mfm_keyboard_test.dart | 393 ++++++- test_util/dummy_me_detailed.dart | 1 - test_util/dummy_user_detailed_not_me.dart | 15 + test_util/dummy_user_lite.dart | 2 +- 21 files changed, 1500 insertions(+), 502 deletions(-) create mode 100644 lib/provider/post_form_hashtags_notifier_provider.dart create mode 100644 lib/provider/post_form_hashtags_notifier_provider.g.dart create mode 100644 test_util/dummy_user_detailed_not_me.dart diff --git a/lib/extension/notes_create_request_extension.dart b/lib/extension/notes_create_request_extension.dart index bb420e85..7b819936 100644 --- a/lib/extension/notes_create_request_extension.dart +++ b/lib/extension/notes_create_request_extension.dart @@ -58,4 +58,23 @@ extension NotesCreateRequestExtension on NotesCreateRequest { ) : null, ); + + NotesCreateRequest addHashtags(List? hashtags) { + if (hashtags case final hashtags? when hashtags.isNotEmpty) { + if (text case final text? when text.isNotEmpty) { + if (text.split('\n').last.trim().isEmpty) { + return copyWith( + text: '$text${hashtags.map((tag) => '#$tag').join(' ')}', + ); + } else { + return copyWith( + text: '$text ${hashtags.map((tag) => '#$tag').join(' ')}', + ); + } + } else { + return copyWith(text: hashtags.map((tag) => '#$tag').join(' ')); + } + } + return this; + } } diff --git a/lib/model/account_settings.dart b/lib/model/account_settings.dart index 85959c8a..0e8e482d 100644 --- a/lib/model/account_settings.dart +++ b/lib/model/account_settings.dart @@ -44,6 +44,8 @@ class AccountSettings with _$AccountSettings { // PostForm @Default([]) List hashtags, + @Default(false) bool postFormUseHashtags, + @Default([]) List postFormHashtags, }) = _AccountSettings; factory AccountSettings.fromJson(Map json) => diff --git a/lib/model/account_settings.freezed.dart b/lib/model/account_settings.freezed.dart index 79141d87..0c5f55d6 100644 --- a/lib/model/account_settings.freezed.dart +++ b/lib/model/account_settings.freezed.dart @@ -51,6 +51,8 @@ mixin _$AccountSettings { List get recentlyUsedUsers => throw _privateConstructorUsedError; // PostForm List get hashtags => throw _privateConstructorUsedError; + bool get postFormUseHashtags => throw _privateConstructorUsedError; + List get postFormHashtags => throw _privateConstructorUsedError; /// Serializes this AccountSettings to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -92,7 +94,9 @@ abstract class $AccountSettingsCopyWith<$Res> { List hardMutedWords, List mutedEmojis, List recentlyUsedUsers, - List hashtags}); + List hashtags, + bool postFormUseHashtags, + List postFormHashtags}); } /// @nodoc @@ -134,6 +138,8 @@ class _$AccountSettingsCopyWithImpl<$Res, $Val extends AccountSettings> Object? mutedEmojis = null, Object? recentlyUsedUsers = null, Object? hashtags = null, + Object? postFormUseHashtags = null, + Object? postFormHashtags = null, }) { return _then(_value.copyWith( keepCw: null == keepCw @@ -232,6 +238,14 @@ class _$AccountSettingsCopyWithImpl<$Res, $Val extends AccountSettings> ? _value.hashtags : hashtags // ignore: cast_nullable_to_non_nullable as List, + postFormUseHashtags: null == postFormUseHashtags + ? _value.postFormUseHashtags + : postFormUseHashtags // ignore: cast_nullable_to_non_nullable + as bool, + postFormHashtags: null == postFormHashtags + ? _value.postFormHashtags + : postFormHashtags // ignore: cast_nullable_to_non_nullable + as List, ) as $Val); } } @@ -268,7 +282,9 @@ abstract class _$$AccountSettingsImplCopyWith<$Res> List hardMutedWords, List mutedEmojis, List recentlyUsedUsers, - List hashtags}); + List hashtags, + bool postFormUseHashtags, + List postFormHashtags}); } /// @nodoc @@ -308,6 +324,8 @@ class __$$AccountSettingsImplCopyWithImpl<$Res> Object? mutedEmojis = null, Object? recentlyUsedUsers = null, Object? hashtags = null, + Object? postFormUseHashtags = null, + Object? postFormHashtags = null, }) { return _then(_$AccountSettingsImpl( keepCw: null == keepCw @@ -406,6 +424,14 @@ class __$$AccountSettingsImplCopyWithImpl<$Res> ? _value._hashtags : hashtags // ignore: cast_nullable_to_non_nullable as List, + postFormUseHashtags: null == postFormUseHashtags + ? _value.postFormUseHashtags + : postFormUseHashtags // ignore: cast_nullable_to_non_nullable + as bool, + postFormHashtags: null == postFormHashtags + ? _value._postFormHashtags + : postFormHashtags // ignore: cast_nullable_to_non_nullable + as List, )); } } @@ -437,7 +463,9 @@ class _$AccountSettingsImpl implements _AccountSettings { final List hardMutedWords = const [], final List mutedEmojis = const [], final List recentlyUsedUsers = const [], - final List hashtags = const []}) + final List hashtags = const [], + this.postFormUseHashtags = false, + final List postFormHashtags = const []}) : _pinnedEmojisForReaction = pinnedEmojisForReaction, _pinnedEmojis = pinnedEmojis, _recentlyUsedEmojis = recentlyUsedEmojis, @@ -445,7 +473,8 @@ class _$AccountSettingsImpl implements _AccountSettings { _hardMutedWords = hardMutedWords, _mutedEmojis = mutedEmojis, _recentlyUsedUsers = recentlyUsedUsers, - _hashtags = hashtags; + _hashtags = hashtags, + _postFormHashtags = postFormHashtags; factory _$AccountSettingsImpl.fromJson(Map json) => _$$AccountSettingsImplFromJson(json); @@ -580,9 +609,22 @@ class _$AccountSettingsImpl implements _AccountSettings { return EqualUnmodifiableListView(_hashtags); } + @override + @JsonKey() + final bool postFormUseHashtags; + final List _postFormHashtags; + @override + @JsonKey() + List get postFormHashtags { + if (_postFormHashtags is EqualUnmodifiableListView) + return _postFormHashtags; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_postFormHashtags); + } + @override String toString() { - return 'AccountSettings(keepCw: $keepCw, rememberNoteVisibility: $rememberNoteVisibility, defaultNoteVisibility: $defaultNoteVisibility, defaultNoteLocalOnly: $defaultNoteLocalOnly, rememberRenoteVisibility: $rememberRenoteVisibility, defaultRenoteVisibility: $defaultRenoteVisibility, defaultRenoteLocalOnly: $defaultRenoteLocalOnly, reactionAcceptance: $reactionAcceptance, visibility: $visibility, localOnly: $localOnly, renoteVisibility: $renoteVisibility, renoteLocalOnly: $renoteLocalOnly, pinnedEmojisForReaction: $pinnedEmojisForReaction, pinnedEmojis: $pinnedEmojis, recentlyUsedEmojis: $recentlyUsedEmojis, defaultReaction: $defaultReaction, uploadFolder: $uploadFolder, keepOriginalUploading: $keepOriginalUploading, keepOriginalFilename: $keepOriginalFilename, mutedWords: $mutedWords, hardMutedWords: $hardMutedWords, mutedEmojis: $mutedEmojis, recentlyUsedUsers: $recentlyUsedUsers, hashtags: $hashtags)'; + return 'AccountSettings(keepCw: $keepCw, rememberNoteVisibility: $rememberNoteVisibility, defaultNoteVisibility: $defaultNoteVisibility, defaultNoteLocalOnly: $defaultNoteLocalOnly, rememberRenoteVisibility: $rememberRenoteVisibility, defaultRenoteVisibility: $defaultRenoteVisibility, defaultRenoteLocalOnly: $defaultRenoteLocalOnly, reactionAcceptance: $reactionAcceptance, visibility: $visibility, localOnly: $localOnly, renoteVisibility: $renoteVisibility, renoteLocalOnly: $renoteLocalOnly, pinnedEmojisForReaction: $pinnedEmojisForReaction, pinnedEmojis: $pinnedEmojis, recentlyUsedEmojis: $recentlyUsedEmojis, defaultReaction: $defaultReaction, uploadFolder: $uploadFolder, keepOriginalUploading: $keepOriginalUploading, keepOriginalFilename: $keepOriginalFilename, mutedWords: $mutedWords, hardMutedWords: $hardMutedWords, mutedEmojis: $mutedEmojis, recentlyUsedUsers: $recentlyUsedUsers, hashtags: $hashtags, postFormUseHashtags: $postFormUseHashtags, postFormHashtags: $postFormHashtags)'; } @override @@ -597,11 +639,9 @@ class _$AccountSettingsImpl implements _AccountSettings { other.defaultNoteVisibility == defaultNoteVisibility) && (identical(other.defaultNoteLocalOnly, defaultNoteLocalOnly) || other.defaultNoteLocalOnly == defaultNoteLocalOnly) && - (identical( - other.rememberRenoteVisibility, rememberRenoteVisibility) || + (identical(other.rememberRenoteVisibility, rememberRenoteVisibility) || other.rememberRenoteVisibility == rememberRenoteVisibility) && - (identical( - other.defaultRenoteVisibility, defaultRenoteVisibility) || + (identical(other.defaultRenoteVisibility, defaultRenoteVisibility) || other.defaultRenoteVisibility == defaultRenoteVisibility) && (identical(other.defaultRenoteLocalOnly, defaultRenoteLocalOnly) || other.defaultRenoteLocalOnly == defaultRenoteLocalOnly) && @@ -637,7 +677,11 @@ class _$AccountSettingsImpl implements _AccountSettings { .equals(other._mutedEmojis, _mutedEmojis) && const DeepCollectionEquality() .equals(other._recentlyUsedUsers, _recentlyUsedUsers) && - const DeepCollectionEquality().equals(other._hashtags, _hashtags)); + const DeepCollectionEquality().equals(other._hashtags, _hashtags) && + (identical(other.postFormUseHashtags, postFormUseHashtags) || + other.postFormUseHashtags == postFormUseHashtags) && + const DeepCollectionEquality() + .equals(other._postFormHashtags, _postFormHashtags)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -667,7 +711,9 @@ class _$AccountSettingsImpl implements _AccountSettings { const DeepCollectionEquality().hash(_hardMutedWords), const DeepCollectionEquality().hash(_mutedEmojis), const DeepCollectionEquality().hash(_recentlyUsedUsers), - const DeepCollectionEquality().hash(_hashtags) + const DeepCollectionEquality().hash(_hashtags), + postFormUseHashtags, + const DeepCollectionEquality().hash(_postFormHashtags) ]); /// Create a copy of AccountSettings @@ -712,7 +758,9 @@ abstract class _AccountSettings implements AccountSettings { final List hardMutedWords, final List mutedEmojis, final List recentlyUsedUsers, - final List hashtags}) = _$AccountSettingsImpl; + final List hashtags, + final bool postFormUseHashtags, + final List postFormHashtags}) = _$AccountSettingsImpl; factory _AccountSettings.fromJson(Map json) = _$AccountSettingsImpl.fromJson; @@ -766,6 +814,10 @@ abstract class _AccountSettings implements AccountSettings { List get recentlyUsedUsers; // PostForm @override List get hashtags; + @override + bool get postFormUseHashtags; + @override + List get postFormHashtags; /// Create a copy of AccountSettings /// with the given fields replaced by the non-null parameter values. diff --git a/lib/model/account_settings.g.dart b/lib/model/account_settings.g.dart index f6cb97e7..d3d3c258 100644 --- a/lib/model/account_settings.g.dart +++ b/lib/model/account_settings.g.dart @@ -68,6 +68,11 @@ _$AccountSettingsImpl _$$AccountSettingsImplFromJson( ?.map((e) => e as String) .toList() ?? const [], + postFormUseHashtags: json['postFormUseHashtags'] as bool? ?? false, + postFormHashtags: (json['postFormHashtags'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], ); Map _$$AccountSettingsImplToJson( @@ -109,6 +114,8 @@ Map _$$AccountSettingsImplToJson( val['mutedEmojis'] = instance.mutedEmojis; val['recentlyUsedUsers'] = instance.recentlyUsedUsers; val['hashtags'] = instance.hashtags; + val['postFormUseHashtags'] = instance.postFormUseHashtags; + val['postFormHashtags'] = instance.postFormHashtags; return val; } diff --git a/lib/provider/account_settings_notifier_provider.dart b/lib/provider/account_settings_notifier_provider.dart index 9089850f..c4e8a943 100644 --- a/lib/provider/account_settings_notifier_provider.dart +++ b/lib/provider/account_settings_notifier_provider.dart @@ -165,4 +165,14 @@ class AccountSettingsNotifier extends _$AccountSettingsNotifier { state = state.copyWith(hashtags: hashtags); await _save(); } + + Future setPostFormUseHashtags(bool postFormUseHashtags) async { + state = state.copyWith(postFormUseHashtags: postFormUseHashtags); + await _save(); + } + + Future setPostFormHashtags(List postFormHashtags) async { + state = state.copyWith(postFormHashtags: postFormHashtags); + await _save(); + } } diff --git a/lib/provider/account_settings_notifier_provider.g.dart b/lib/provider/account_settings_notifier_provider.g.dart index 10eb3fdb..0165adf6 100644 --- a/lib/provider/account_settings_notifier_provider.g.dart +++ b/lib/provider/account_settings_notifier_provider.g.dart @@ -7,7 +7,7 @@ part of 'account_settings_notifier_provider.dart'; // ************************************************************************** String _$accountSettingsNotifierHash() => - r'2e096500c468a689d4676b98367036a187552139'; + r'f4aafb2d7072ca2ab428674abd69667529b8f178'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/provider/post_form_hashtags_notifier_provider.dart b/lib/provider/post_form_hashtags_notifier_provider.dart new file mode 100644 index 00000000..ef969785 --- /dev/null +++ b/lib/provider/post_form_hashtags_notifier_provider.dart @@ -0,0 +1,44 @@ +import 'package:collection/collection.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../model/account.dart'; +import 'account_settings_notifier_provider.dart'; + +part 'post_form_hashtags_notifier_provider.g.dart'; + +@Riverpod(keepAlive: true) +class PostFormHashtagsNotifier extends _$PostFormHashtagsNotifier { + @override + List build(Account account) { + return ref.watch( + accountSettingsNotifierProvider(account) + .select((settings) => settings.postFormHashtags), + ); + } + + // ignore: use_setters_to_change_properties + void updateHashtags(List hashtags) { + state = hashtags; + } + + void updateFromString(String text) { + updateHashtags( + text + .split(RegExp(r'\s')) + .map((tag) => tag.trim()) + .map((tag) => tag.startsWith('#') ? tag.substring(1) : tag) + .where((tag) => tag.isNotEmpty) + .toList(), + ); + } + + Future save() async { + if (!state.equals( + ref.read(accountSettingsNotifierProvider(account)).postFormHashtags, + )) { + await ref + .read(accountSettingsNotifierProvider(account).notifier) + .setPostFormHashtags(state); + } + } +} diff --git a/lib/provider/post_form_hashtags_notifier_provider.g.dart b/lib/provider/post_form_hashtags_notifier_provider.g.dart new file mode 100644 index 00000000..073eb308 --- /dev/null +++ b/lib/provider/post_form_hashtags_notifier_provider.g.dart @@ -0,0 +1,178 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_form_hashtags_notifier_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$postFormHashtagsNotifierHash() => + r'c2c120a5f2b3ea5c4e70d8a173901b1275af8988'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$PostFormHashtagsNotifier + extends BuildlessNotifier> { + late final Account account; + + List build( + Account account, + ); +} + +/// See also [PostFormHashtagsNotifier]. +@ProviderFor(PostFormHashtagsNotifier) +const postFormHashtagsNotifierProvider = PostFormHashtagsNotifierFamily(); + +/// See also [PostFormHashtagsNotifier]. +class PostFormHashtagsNotifierFamily extends Family> { + /// See also [PostFormHashtagsNotifier]. + const PostFormHashtagsNotifierFamily(); + + /// See also [PostFormHashtagsNotifier]. + PostFormHashtagsNotifierProvider call( + Account account, + ) { + return PostFormHashtagsNotifierProvider( + account, + ); + } + + @override + PostFormHashtagsNotifierProvider getProviderOverride( + covariant PostFormHashtagsNotifierProvider provider, + ) { + return call( + provider.account, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'postFormHashtagsNotifierProvider'; +} + +/// See also [PostFormHashtagsNotifier]. +class PostFormHashtagsNotifierProvider + extends NotifierProviderImpl> { + /// See also [PostFormHashtagsNotifier]. + PostFormHashtagsNotifierProvider( + Account account, + ) : this._internal( + () => PostFormHashtagsNotifier()..account = account, + from: postFormHashtagsNotifierProvider, + name: r'postFormHashtagsNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$postFormHashtagsNotifierHash, + dependencies: PostFormHashtagsNotifierFamily._dependencies, + allTransitiveDependencies: + PostFormHashtagsNotifierFamily._allTransitiveDependencies, + account: account, + ); + + PostFormHashtagsNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.account, + }) : super.internal(); + + final Account account; + + @override + List runNotifierBuild( + covariant PostFormHashtagsNotifier notifier, + ) { + return notifier.build( + account, + ); + } + + @override + Override overrideWith(PostFormHashtagsNotifier Function() create) { + return ProviderOverride( + origin: this, + override: PostFormHashtagsNotifierProvider._internal( + () => create()..account = account, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + account: account, + ), + ); + } + + @override + NotifierProviderElement> + createElement() { + return _PostFormHashtagsNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is PostFormHashtagsNotifierProvider && + other.account == account; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, account.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin PostFormHashtagsNotifierRef on NotifierProviderRef> { + /// The parameter `account` of this provider. + Account get account; +} + +class _PostFormHashtagsNotifierProviderElement + extends NotifierProviderElement> + with PostFormHashtagsNotifierRef { + _PostFormHashtagsNotifierProviderElement(super.provider); + + @override + Account get account => (origin as PostFormHashtagsNotifierProvider).account; +} +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/provider/post_notifier_provider.dart b/lib/provider/post_notifier_provider.dart index d17e73c2..8c670a06 100644 --- a/lib/provider/post_notifier_provider.dart +++ b/lib/provider/post_notifier_provider.dart @@ -6,6 +6,7 @@ import 'package:misskey_dart/misskey_dart.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../extension/note_extension.dart'; +import '../extension/notes_create_request_extension.dart'; import '../extension/user_extension.dart'; import '../model/account.dart'; import '../util/extract_mentions.dart'; @@ -185,33 +186,41 @@ class PostNotifier extends _$PostNotifier { ref.read(sharedPreferencesProvider).remove(_key); } - Future post({List? fileIds}) async { + Future post({ + List? fileIds, + List? hashtags, + }) async { if (noteId == null) { final response = await ref .read(misskeyProvider(account)) .notes - .create(state.copyWith(fileIds: fileIds)); + .create(state.copyWith(fileIds: fileIds).addHashtags(hashtags)); ref.read(notesNotifierProvider(account).notifier).add(response); reset(); + return response; } else { final endpoints = await ref.read(endpointsProvider(account.host).future); if (endpoints.contains('notes/update')) { await ref.read(misskeyProvider(account)).notes.update( NotesUpdateRequest( noteId: noteId!, - text: state.text, + text: state.addHashtags(hashtags).text, cw: state.cw, fileIds: fileIds, poll: state.poll, ), ); + final note = + state.copyWith(fileIds: fileIds).addHashtags(hashtags).toNote(); + ref.read(notesNotifierProvider(account).notifier).add(note); + return note; } else { final response = await ref.read(misskeyProvider(account)).notes.edit( NotesEditRequest( editId: noteId!, visibility: state.visibility, visibleUserIds: state.visibleUserIds, - text: state.text, + text: state.addHashtags(hashtags).text, cw: state.cw, localOnly: state.localOnly, fileIds: fileIds, @@ -222,9 +231,9 @@ class PostNotifier extends _$PostNotifier { ), ); ref.read(notesNotifierProvider(account).notifier).add(response); + return response; } } - return true; } void setVisibility(NoteVisibility visibility) { diff --git a/lib/provider/post_notifier_provider.g.dart b/lib/provider/post_notifier_provider.g.dart index 56c09215..14c6ace9 100644 --- a/lib/provider/post_notifier_provider.g.dart +++ b/lib/provider/post_notifier_provider.g.dart @@ -6,7 +6,7 @@ part of 'post_notifier_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$postNotifierHash() => r'd8beee3b314a1ae828444dde84d3fff777bacc52'; +String _$postNotifierHash() => r'66977457ed00b5139cbbdd619507993c0f217180'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/provider/theme_data_provider.dart b/lib/provider/theme_data_provider.dart index 6afc6563..3531f94d 100644 --- a/lib/provider/theme_data_provider.dart +++ b/lib/provider/theme_data_provider.dart @@ -41,7 +41,7 @@ ThemeData themeData(Ref ref, Brightness brightness) { dividerColor: colors.divider, canvasColor: colors.bg, scaffoldBackgroundColor: colors.bg, - textTheme: ThemeData.light() + textTheme: ThemeData(brightness: brightness) .textTheme .merge( TextTheme( @@ -63,6 +63,7 @@ ThemeData themeData(Ref ref, Brightness brightness) { : null, displayColor: colors.fg, bodyColor: colors.fg, + decorationColor: colors.fg, ), iconTheme: IconThemeData(color: colors.fg), inputDecorationTheme: InputDecorationTheme( diff --git a/lib/provider/theme_data_provider.g.dart b/lib/provider/theme_data_provider.g.dart index 10179147..ff5cfd8d 100644 --- a/lib/provider/theme_data_provider.g.dart +++ b/lib/provider/theme_data_provider.g.dart @@ -6,7 +6,7 @@ part of 'theme_data_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$themeDataHash() => r'ca83d1e8f17a5c1b567a01b2ddbcbae6cc04d62f'; +String _$themeDataHash() => r'aedd9f4e335949fdb7202c6f73b9319f6ae82783'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/view/page/post_page.dart b/lib/view/page/post_page.dart index 48fc4230..c9bafbfe 100644 --- a/lib/view/page/post_page.dart +++ b/lib/view/page/post_page.dart @@ -17,6 +17,7 @@ import '../../provider/api/channel_notifier_provider.dart'; import '../../provider/api/i_notifier_provider.dart'; import '../../provider/general_settings_notifier_provider.dart'; import '../../provider/misskey_colors_provider.dart'; +import '../../provider/post_form_hashtags_notifier_provider.dart'; import '../../provider/post_notifier_provider.dart'; import '../../provider/timeline_tab_settings_provider.dart'; import '../../util/future_with_dialog.dart'; @@ -40,6 +41,10 @@ class PostPage extends HookConsumerWidget { Account account, ) async { final request = ref.read(postNotifierProvider(account, noteId: noteId)); + final hashtags = + ref.read(accountSettingsNotifierProvider(account)).postFormUseHashtags + ? ref.read(postFormHashtagsNotifierProvider(account)) + : null; final attaches = ref.read(attachesNotifierProvider(account, noteId: noteId)); final hasFiles = attaches.isNotEmpty; @@ -61,7 +66,7 @@ class PostPage extends HookConsumerWidget { final confirmed = await confirmPost( ref, account, - request, + request.addHashtags(hashtags), files: files, ); if (!confirmed) return; @@ -69,13 +74,14 @@ class PostPage extends HookConsumerWidget { } final result = await futureWithDialog( ref.context, - ref - .read(postNotifierProvider(account, noteId: noteId).notifier) - .post(fileIds: files?.map((file) => file.id).toList()), + ref.read(postNotifierProvider(account, noteId: noteId).notifier).post( + fileIds: files?.map((file) => file.id).toList(), + hashtags: hashtags, + ), ); if (!ref.context.mounted) return; - if (result != null) { - if (request.text case final text?) { + if (result case final note?) { + if (note.text case final text?) { final nodes = const MfmParser().parse(text); final hashtags = nodes .extract((node) => node is MfmHashTag) @@ -95,6 +101,9 @@ class PostPage extends HookConsumerWidget { .read(postNotifierProvider(account, noteId: noteId).notifier) .setChannel(channelId); } + unawaited( + ref.read(postFormHashtagsNotifierProvider(account).notifier).save(), + ); ref.invalidate(attachesNotifierProvider(account, noteId: noteId)); ref.context.pop(); } @@ -113,7 +122,14 @@ class PostPage extends HookConsumerWidget { .watch(channelNotifierProvider(account.value, request.channelId!)) .valueOrNull : null; - final note = request.toNote(i: i, channel: channel); + final hashtags = ref.watch(postFormHashtagsNotifierProvider(account.value)); + final useHashtags = ref.watch( + accountSettingsNotifierProvider(account.value) + .select((settings) => settings.postFormUseHashtags), + ); + final note = request + .addHashtags(useHashtags ? hashtags : null) + .toNote(i: i, channel: channel); final canPost = request.canPost || attaches.isNotEmpty; final needsUpload = attaches.any((file) => file is LocalPostFile); final (buttonText, buttonIcon) = switch (request) { @@ -124,19 +140,39 @@ class PostPage extends HookConsumerWidget { _ when request.renoteId != null => (t.misskey.quote, Icons.send), _ => (t.misskey.note, Icons.send), }; - final cwController = - useTextEditingController(text: request.cw, keys: [account.value]); - final controller = - useTextEditingController(text: request.text, keys: [account.value]); + final cwController = useTextEditingController(text: request.cw); + final controller = useTextEditingController(text: request.text); + final hashtagsController = + useTextEditingController(text: hashtags.join(' ')); final cwFocusNode = useFocusNode(); final focusNode = useFocusNode(); + final hashtagsFocusNode = useFocusNode(); final isCwFocused = useState(false); final isFocused = useState(false); + final isHashtagsFocused = useState(false); useEffect( () { - cwFocusNode.addListener(() => isCwFocused.value = cwFocusNode.hasFocus); - focusNode.addListener(() => isFocused.value = focusNode.hasFocus); - return; + void cwFocusNodeCallback() { + isCwFocused.value = cwFocusNode.hasFocus; + } + + void focusNodeCallback() { + isFocused.value = focusNode.hasFocus; + } + + void hashtagsFocusNodeCallback() { + isHashtagsFocused.value = hashtagsFocusNode.hasFocus; + } + + cwFocusNode.addListener(cwFocusNodeCallback); + focusNode.addListener(focusNodeCallback); + hashtagsFocusNode.addListener(hashtagsFocusNodeCallback); + + return () { + cwFocusNode.removeListener(cwFocusNodeCallback); + focusNode.removeListener(focusNodeCallback); + hashtagsFocusNode.removeListener(hashtagsFocusNodeCallback); + }; }, [account.value], ); @@ -191,8 +227,10 @@ class PostPage extends HookConsumerWidget { noteId: noteId, controller: controller, cwController: cwController, + hashtagsController: hashtagsController, focusNode: focusNode, cwFocusNode: cwFocusNode, + hashtagsFocusNode: hashtagsFocusNode, onAccountChanged: (newAccount) => account.value = newAccount, ), @@ -245,6 +283,17 @@ class PostPage extends HookConsumerWidget { ), ), ), + Visibility( + visible: isHashtagsFocused.value, + maintainState: true, + child: TextFieldTapRegion( + onTapOutside: (_) => primaryFocus?.unfocus(), + child: MfmHashtagKeyboard( + account: account.value, + controller: hashtagsController, + ), + ), + ), ], ), floatingActionButton: !isCwFocused.value && !isFocused.value diff --git a/lib/view/page/tag/tag_page.dart b/lib/view/page/tag/tag_page.dart index cfcc5fc4..6e0a7776 100644 --- a/lib/view/page/tag/tag_page.dart +++ b/lib/view/page/tag/tag_page.dart @@ -4,7 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../i18n/strings.g.dart'; import '../../../model/account.dart'; -import '../../../provider/post_notifier_provider.dart'; +import '../../../provider/api/tag_notes_notifier_provider.dart'; +import '../../../provider/post_form_hashtags_notifier_provider.dart'; import 'tag_notes.dart'; import 'tag_users.dart'; @@ -44,11 +45,17 @@ class TagPage extends ConsumerWidget { floatingActionButton: account.isGuest ? null : FloatingActionButton.extended( - onPressed: () { + onPressed: () async { + final hashtags = + ref.read(postFormHashtagsNotifierProvider(account)); ref - .read(postNotifierProvider(account).notifier) - .setText('#$tag '); - context.push('/$account/post'); + .read(postFormHashtagsNotifierProvider(account).notifier) + .updateHashtags([tag]); + await context.push('/$account/post'); + ref + .read(postFormHashtagsNotifierProvider(account).notifier) + .updateHashtags(hashtags); + ref.invalidate(tagNotesNotifierProvider(account, tag)); }, label: Text(t.misskey.postToHashtag), icon: const Icon(Icons.edit), diff --git a/lib/view/widget/mention_widget.dart b/lib/view/widget/mention_widget.dart index 570e90b8..48e0eb7c 100644 --- a/lib/view/widget/mention_widget.dart +++ b/lib/view/widget/mention_widget.dart @@ -58,6 +58,7 @@ class MentionWidget extends ConsumerWidget { : url, cacheManager: ref.watch(cacheManagerProvider), ), + onForegroundImageError: (_, __) {}, ), label: Text.rich( TextSpan( diff --git a/lib/view/widget/mfm_keyboard.dart b/lib/view/widget/mfm_keyboard.dart index 91735190..de6c7e6a 100644 --- a/lib/view/widget/mfm_keyboard.dart +++ b/lib/view/widget/mfm_keyboard.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:misskey_dart/misskey_dart.dart'; import '../../extension/text_editing_controller_extension.dart'; import '../../extension/user_extension.dart'; @@ -36,87 +37,6 @@ enum TagType { final String tag; } -const Map> _mfmFn = { - 'tada': { - 'delay': '0s', - 'speed': '1s', - }, - 'jelly': { - 'speed': '1s', - 'delay': '0s', - }, - 'twitch': { - 'speed': '0.5s', - 'delay': '0s', - }, - 'shake': { - 'speed': '0.5s', - 'delay': '0s', - }, - 'spin': { - 'speed': '1.5s', - 'delay': '0s', - 'x': null, - 'y': null, - 'left': null, - 'alternate': null, - }, - 'jump': { - 'speed': '0.75s', - 'delay': '0s', - }, - 'bounce': { - 'speed': '0.75s', - 'delay': '0s', - }, - 'flip': { - 'v': null, - 'h': null, - }, - 'x2': {}, - 'x3': {}, - 'x4': {}, - 'scale': { - 'x': '1', - 'y': '1', - }, - 'position': { - 'x': '0', - 'y': '0', - }, - 'fg': { - 'color': null, - }, - 'bg': { - 'color': null, - }, - 'border': { - 'color': null, - 'style': 'solid', - 'width': '1', - 'radius': '0', - 'noclip': null, - }, - 'font': { - 'serif': null, - 'monospace': null, - 'cursive': null, - 'fantasy': null, - }, - 'blur': {}, - 'rainbow': { - 'speed': '1s', - }, - 'sparkle': { - 'speed': '1.5s', - }, - 'rotate': { - 'deg': '90', - }, - 'ruby': {}, - 'unixtime': {}, -}; - class MfmKeyboard extends HookConsumerWidget { const MfmKeyboard({ super.key, @@ -148,329 +68,59 @@ class MfmKeyboard extends HookConsumerWidget { } } - List _buildButtons( - WidgetRef ref, - TextEditingController controller, - TagType? tagType, - int tagIndex, - ) { - if (tagType != null) { - final selectionIndex = max(0, controller.selection.start); - final query = controller.text - .substring(tagIndex + tagType.tag.length, selectionIndex); - switch (tagType) { - case TagType.emoji: - if (!RegExp(r':\w+$') - .hasMatch(controller.text.substring(0, tagIndex))) { - final emojis = [ - if (query.isEmpty) - ...ref - .watch(recentlyUsedEmojisNotifierProvider(account)) - .map((emoji) => emoji.replaceAll('@.', '')) - else ...[ - ...ref - .watch(searchCustomEmojisProvider(account.host, query)) - .map((emoji) => ':${emoji.name}:'), - ...ref.watch(searchUnicodeEmojisProvider(query)), - ], - ]; - if (emojis.isNotEmpty) { - return [ - ...emojis.map( - (emoji) => TextButton( - key: ValueKey(emoji), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - ), - onPressed: () => - controller.replace(query.length + 1, emoji), - child: EmojiWidget(account: account, emoji: emoji), - ), - ), - TextButton( - onPressed: () async { - final emoji = await pickEmoji(ref, account); - if (emoji != null) { - controller.replace( - query.length + 1, - emoji.replaceAll('@.', ''), - ); - } - }, - child: Text(t.misskey.more), - ), - ]; - } - } - case TagType.mfmFn: - final periodIndex = query.indexOf('.'); - if (periodIndex < 0 && !_mfmFn.containsKey(query)) { - final fnNames = _mfmFn.keys.where((name) => name.startsWith(query)); - if (fnNames.isNotEmpty) { - return fnNames - .map( - (name) => TextButton( - onPressed: () async { - controller.insert(name.substring(query.length)); - switch (name) { - case 'scale' || 'position' || 'font': - controller.insert('.', ' '); - case 'fg' || 'bg': - final color = await showColorPickerDialog( - ref.context, - Colors.red, - pickersEnabled: { - ColorPickerType.primary: false, - ColorPickerType.accent: false, - ColorPickerType.wheel: true, - }, - ); - if (color != Colors.red) { - controller.insert('.color=${color.hex} '); - } - - case 'unixtime': - final date = await pickDateTime(ref.context); - if (date != null) { - final unixtime = - date.millisecondsSinceEpoch ~/ 1000; - controller.insert(' $unixtime'); - } - default: - controller.insert(' '); - } - }, - child: Text(name), - ), - ) - .toList(); - } - } else { - final requiresPeriod = periodIndex < 0; - final fnName = - requiresPeriod ? query : query.substring(0, periodIndex); - final fnArgs = Map.of(_mfmFn[fnName] ?? {}); - if (fnArgs.isNotEmpty) { - final queryArgNames = requiresPeriod - ? [''] - : query - .substring(periodIndex + 1) - .split(',') - .map((s) => s.split('=').first) - .toList(); - final requiresComma = fnArgs.containsKey(queryArgNames.last); - final argQuery = requiresComma ? '' : queryArgNames.removeLast(); - fnArgs.removeWhere( - (name, _) => - queryArgNames.contains(name) || !name.startsWith(argQuery), - ); - if (fnArgs.isNotEmpty) { - return fnArgs.entries - .map( - (e) => TextButton( - onPressed: () async { - if (requiresPeriod) { - controller.insert('.'); - } - if (requiresComma) { - controller.insert(','); - } - controller.insert(e.key.substring(argQuery.length)); - switch ((fnName, e.key)) { - case (_, 'color'): - final color = await showColorPickerDialog( - ref.context, - Colors.red, - pickersEnabled: { - ColorPickerType.primary: false, - ColorPickerType.accent: false, - ColorPickerType.wheel: true, - }, - ); - if (color != Colors.red) { - controller.insert('=${color.hex}'); - } - case ('border', 'style'): - final style = await showDialog( - context: ref.context, - builder: (context) => SimpleDialog( - children: [ - 'hidden', - 'dotted', - 'dashed', - 'solid', - 'double', - 'groove', - 'ridge', - 'inset', - 'outset', - ] - .map( - (style) => SimpleDialogOption( - onPressed: () => context.pop(style), - child: Text(style), - ), - ) - .toList(), - ), - ); - if (style != null) { - controller.insert('=$style'); - } - default: - if (e.value != null) { - controller.insert('=${e.value}'); - } - } - }, - child: Text(e.key), - ), - ) - .toList(); - } - } - } - case TagType.mention: - final acct = query.split('@'); - final users = query.isEmpty - ? ref - .watch(recentlyUsedUsersNotifierProvider(account)) - .valueOrNull - : ref - .watch( - searchUsersByUsernameProvider( - account, - acct[0], - acct.elementAtOrNull(1), - ), - ) - .valueOrNull; - if (users != null && users.isNotEmpty) { - return users - .map( - (user) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: MentionWidget( - account: account, - username: user.username, - host: user.host ?? account.host, - onTap: () => controller - .insert('${user.acct.substring(query.length + 1)} '), - ), - ), - ) - .toList(); - } - case TagType.hashtag: - final hashtags = query.isEmpty - ? ref.watch( - accountSettingsNotifierProvider(account) - .select((settings) => settings.hashtags), - ) - : ref.watch(searchHashtagsProvider(account, query)).valueOrNull; - if (hashtags != null && hashtags.isNotEmpty) { - return hashtags - .map( - (hashtag) => TextButton( - onPressed: () => controller - .insert('${hashtag.substring(query.length)} '), - child: Text(hashtag), - ), - ) - .toList(); - } - } - } - return [ - TextButton( - onPressed: () => controller.insert(':'), - child: const Text(':'), - ), - TextButton( - onPressed: () => controller.insert(r'$[', ']'), - child: const Text(r'$['), - ), - TextButton( - onPressed: () async { - final user = await selectUser(ref.context, account); - if (user != null) { - controller.insert('${user.acct} '); - } else { - controller.insert('@'); - } - }, - child: const Text('@'), - ), - TextButton( - onPressed: () => controller.insert('#'), - child: const Text('#'), - ), - TextButton( - onPressed: () => controller.insert('
', '
'), - child: const Text('
'), - ), - TextButton( - onPressed: () => controller.insert('', ''), - child: const Text(''), - ), - TextButton( - onPressed: () => controller.insert('', ''), - child: const Text(''), - ), - TextButton( - onPressed: () => controller.insert('', ''), - child: const Text(''), - ), - TextButton( - onPressed: () => controller.insert('> '), - child: const Text('>'), - ), - TextButton( - onPressed: () => controller.insert('`', '`'), - child: const Text('`'), - ), - TextButton( - onPressed: () => controller.insert('```\n', '\n```'), - child: const Text('```'), - ), - TextButton( - onPressed: () => controller.insert('**', '**'), - child: const Text('**'), - ), - TextButton( - onPressed: () => controller.insert('~~', '~~'), - child: const Text('~~'), - ), - TextButton( - onPressed: () => controller.insert('[', ']()'), - child: const Text('[]()'), - ), - TextButton( - onPressed: () => controller.insert('?[', ']()'), - child: const Text('?[]()'), - ), - ]; - } - @override Widget build(BuildContext context, WidgetRef ref) { final tagType = useState(null); - final tagIndex = useState(-1); useEffect( () { - final (type, index) = getLastTag(); - tagType.value = type; - tagIndex.value = index; - controller.addListener(() { - final (type, index) = getLastTag(); + void callback() { + final (type, _) = getLastTag(); tagType.value = type; - tagIndex.value = index; - }); - return; + } + + callback(); + controller.addListener(callback); + return () => controller.removeListener(callback); }, [account, controller], ); + return switch (tagType.value) { + TagType.emoji => MfmEmojiKeyboard( + account: account, + controller: controller, + fallbackBuilder: (context) => + MfmBasicKeyboard(account: account, controller: controller), + ), + TagType.mfmFn => MfmFnKeyboard( + controller: controller, + fallbackBuilder: (context) => + MfmBasicKeyboard(account: account, controller: controller), + ), + TagType.mention => MfmMentionKeyboard( + account: account, + controller: controller, + fallbackBuilder: (context) => + MfmBasicKeyboard(account: account, controller: controller), + ), + TagType.hashtag => MfmHashtagKeyboard( + account: account, + controller: controller, + fallbackBuilder: (context) => + MfmBasicKeyboard(account: account, controller: controller), + ), + null => MfmBasicKeyboard(account: account, controller: controller), + }; + } +} + +class _MfmKeyboardContainer extends StatelessWidget { + const _MfmKeyboardContainer({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { return Container( width: double.maxFinite, height: 40.0, @@ -485,14 +135,560 @@ class MfmKeyboard extends HookConsumerWidget { ), child: ListView( scrollDirection: Axis.horizontal, - children: _buildButtons( - ref, - controller, - tagType.value, - tagIndex.value, - ), + children: children, ), ), ); } } + +class MfmBasicKeyboard extends ConsumerWidget { + const MfmBasicKeyboard({ + super.key, + required this.account, + required this.controller, + }); + + final Account account; + final TextEditingController controller; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return _MfmKeyboardContainer( + children: [ + TextButton( + onPressed: () => controller.insert(':'), + child: const Text(':'), + ), + TextButton( + onPressed: () => controller.insert(r'$[', ']'), + child: const Text(r'$['), + ), + TextButton( + onPressed: () async { + final user = await selectUser(ref.context, account); + if (user != null) { + controller.insert('${user.acct} '); + } else { + controller.insert('@'); + } + }, + child: const Text('@'), + ), + TextButton( + onPressed: () => controller.insert('#'), + child: const Text('#'), + ), + TextButton( + onPressed: () => controller.insert('
', '
'), + child: const Text('
'), + ), + TextButton( + onPressed: () => controller.insert('', ''), + child: const Text(''), + ), + TextButton( + onPressed: () => controller.insert('', ''), + child: const Text(''), + ), + TextButton( + onPressed: () => controller.insert('', ''), + child: const Text(''), + ), + TextButton( + onPressed: () => controller.insert('> '), + child: const Text('>'), + ), + TextButton( + onPressed: () => controller.insert('`', '`'), + child: const Text('`'), + ), + TextButton( + onPressed: () => controller.insert('```\n', '\n```'), + child: const Text('```'), + ), + TextButton( + onPressed: () => controller.insert('**', '**'), + child: const Text('**'), + ), + TextButton( + onPressed: () => controller.insert('~~', '~~'), + child: const Text('~~'), + ), + TextButton( + onPressed: () => controller.insert('[', ']()'), + child: const Text('[]()'), + ), + TextButton( + onPressed: () => controller.insert('?[', ']()'), + child: const Text('?[]()'), + ), + ], + ); + } +} + +class MfmEmojiKeyboard extends HookConsumerWidget { + const MfmEmojiKeyboard({ + super.key, + required this.account, + required this.controller, + this.fallbackBuilder, + }); + + final Account account; + final TextEditingController controller; + final Widget Function(BuildContext context)? fallbackBuilder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final query = useState(''); + final isAfterCloseTag = useState(false); + useEffect( + () { + void callback() { + final selectionIndex = max(0, controller.selection.start); + final textBeforeSelection = + controller.text.substring(0, selectionIndex); + final match = RegExp(r':?(\S*)$').firstMatch(textBeforeSelection); + query.value = match?[1] ?? ''; + final tagIndex = selectionIndex - (match?[0]?.length ?? 0) + 1; + isAfterCloseTag.value = + RegExp(r':\w+$').hasMatch(controller.text.substring(0, tagIndex)); + } + + callback(); + controller.addListener(callback); + return () => controller.removeListener(callback); + }, + [account, controller], + ); + + if (!isAfterCloseTag.value) { + final emojis = [ + if (query.value.isEmpty) + ...ref + .watch(recentlyUsedEmojisNotifierProvider(account)) + .map((emoji) => emoji.replaceAll('@.', '')) + else ...[ + ...ref + .watch(searchCustomEmojisProvider(account.host, query.value)) + .map((emoji) => ':${emoji.name}:'), + ...ref.watch(searchUnicodeEmojisProvider(query.value)), + ], + ]; + if (emojis.isNotEmpty) { + return _MfmKeyboardContainer( + children: [ + ...emojis.map( + (emoji) => TextButton( + key: ValueKey(emoji), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + ), + onPressed: () => + controller.replace(query.value.length + 1, emoji), + child: EmojiWidget(account: account, emoji: emoji), + ), + ), + TextButton( + onPressed: () async { + final emoji = await pickEmoji(ref, account); + if (emoji != null) { + controller.replace( + query.value.length + 1, + emoji.replaceAll('@.', ''), + ); + } + }, + child: Text(t.misskey.more), + ), + ], + ); + } + } + + return fallbackBuilder?.call(context) ?? const SizedBox.shrink(); + } +} + +class MfmFnKeyboard extends HookConsumerWidget { + const MfmFnKeyboard({ + super.key, + required this.controller, + this.fallbackBuilder, + }); + + final TextEditingController controller; + final Widget Function(BuildContext context)? fallbackBuilder; + + static const Map> _mfmFn = { + 'tada': { + 'delay': '0s', + 'speed': '1s', + }, + 'jelly': { + 'speed': '1s', + 'delay': '0s', + }, + 'twitch': { + 'speed': '0.5s', + 'delay': '0s', + }, + 'shake': { + 'speed': '0.5s', + 'delay': '0s', + }, + 'spin': { + 'speed': '1.5s', + 'delay': '0s', + 'x': null, + 'y': null, + 'left': null, + 'alternate': null, + }, + 'jump': { + 'speed': '0.75s', + 'delay': '0s', + }, + 'bounce': { + 'speed': '0.75s', + 'delay': '0s', + }, + 'flip': { + 'v': null, + 'h': null, + }, + 'x2': {}, + 'x3': {}, + 'x4': {}, + 'scale': { + 'x': '1', + 'y': '1', + }, + 'position': { + 'x': '0', + 'y': '0', + }, + 'fg': { + 'color': null, + }, + 'bg': { + 'color': null, + }, + 'border': { + 'color': null, + 'style': 'solid', + 'width': '1', + 'radius': '0', + 'noclip': null, + }, + 'font': { + 'serif': null, + 'monospace': null, + 'cursive': null, + 'fantasy': null, + }, + 'blur': {}, + 'rainbow': { + 'speed': '1s', + }, + 'sparkle': { + 'speed': '1.5s', + }, + 'rotate': { + 'deg': '90', + }, + 'ruby': {}, + 'unixtime': {}, + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final query = useState(''); + final periodIndex = useState(-1); + useEffect( + () { + void callback() { + final selectionIndex = max(0, controller.selection.start); + final textBeforeSelection = + controller.text.substring(0, selectionIndex); + query.value = + RegExp(r'(\$\[)?(\S*)$').firstMatch(textBeforeSelection)?[2] ?? + ''; + periodIndex.value = query.value.indexOf('.'); + } + + callback(); + controller.addListener(callback); + return () => controller.removeListener(callback); + }, + [controller], + ); + + if (periodIndex.value < 0 && !_mfmFn.containsKey(query.value)) { + final fnNames = _mfmFn.keys.where((name) => name.startsWith(query.value)); + if (fnNames.isNotEmpty) { + return _MfmKeyboardContainer( + children: fnNames + .map( + (name) => TextButton( + onPressed: () async { + controller.insert(name.substring(query.value.length)); + switch (name) { + case 'scale' || 'position' || 'font': + controller.insert('.', ' '); + case 'fg' || 'bg': + final color = await showColorPickerDialog( + ref.context, + Colors.red, + pickersEnabled: { + ColorPickerType.primary: false, + ColorPickerType.accent: false, + ColorPickerType.wheel: true, + }, + ); + if (color != Colors.red) { + controller.insert('.color=${color.hex} '); + } + + case 'unixtime': + final date = await pickDateTime(ref.context); + if (date != null) { + final unixtime = date.millisecondsSinceEpoch ~/ 1000; + controller.insert(' $unixtime'); + } + default: + controller.insert(' '); + } + }, + child: Text(name), + ), + ) + .toList(), + ); + } + } else { + final requiresPeriod = periodIndex.value < 0; + final fnName = requiresPeriod + ? query.value + : query.value.substring(0, periodIndex.value); + final fnArgs = Map.of(_mfmFn[fnName] ?? {}); + if (fnArgs.isNotEmpty) { + final queryArgNames = requiresPeriod + ? [''] + : query.value + .substring(periodIndex.value + 1) + .split(',') + .map((s) => s.split('=').first) + .toList(); + final requiresComma = fnArgs.containsKey(queryArgNames.last); + final argQuery = requiresComma ? '' : queryArgNames.removeLast(); + fnArgs.removeWhere( + (name, _) => + queryArgNames.contains(name) || !name.startsWith(argQuery), + ); + if (fnArgs.isNotEmpty) { + return _MfmKeyboardContainer( + children: fnArgs.entries + .map( + (e) => TextButton( + onPressed: () async { + if (requiresPeriod) { + controller.insert('.'); + } + if (requiresComma) { + controller.insert(','); + } + controller.insert(e.key.substring(argQuery.length)); + switch ((fnName, e.key)) { + case (_, 'color'): + final color = await showColorPickerDialog( + ref.context, + Colors.red, + pickersEnabled: { + ColorPickerType.primary: false, + ColorPickerType.accent: false, + ColorPickerType.wheel: true, + }, + ); + if (color != Colors.red) { + controller.insert('=${color.hex}'); + } + case ('border', 'style'): + final style = await showDialog( + context: ref.context, + builder: (context) => SimpleDialog( + children: [ + 'hidden', + 'dotted', + 'dashed', + 'solid', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', + ] + .map( + (style) => SimpleDialogOption( + onPressed: () => context.pop(style), + child: Text(style), + ), + ) + .toList(), + ), + ); + if (style != null) { + controller.insert('=$style'); + } + default: + if (e.value != null) { + controller.insert('=${e.value}'); + } + } + }, + child: Text(e.key), + ), + ) + .toList(), + ); + } + } + } + + return fallbackBuilder?.call(context) ?? const SizedBox.shrink(); + } +} + +class MfmMentionKeyboard extends HookConsumerWidget { + const MfmMentionKeyboard({ + super.key, + required this.account, + required this.controller, + this.fallbackBuilder, + }); + + final Account account; + final TextEditingController controller; + final Widget Function(BuildContext context)? fallbackBuilder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final query = useState(''); + final users = useState([]); + useEffect( + () { + Future callback() async { + final selectionIndex = max(0, controller.selection.start); + final textBeforeSelection = + controller.text.substring(0, selectionIndex); + query.value = + RegExp(r'@?(\S*)$').firstMatch(textBeforeSelection)?[1] ?? ''; + if (query.value case final query when query.isNotEmpty) { + final acct = query.split('@'); + users.value = await ref.read( + searchUsersByUsernameProvider( + account, + acct.first, + acct.elementAtOrNull(1), + ).future, + ); + } + } + + callback(); + controller.addListener(callback); + return () => controller.removeListener(callback); + }, + [account, controller], + ); + final recentlyUsedUsers = + ref.watch(recentlyUsedUsersNotifierProvider(account)).valueOrNull; + + if (query.value.isEmpty ? recentlyUsedUsers : users.value case final users? + when users.isNotEmpty) { + return _MfmKeyboardContainer( + children: users + .map( + (user) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: MentionWidget( + account: account, + username: user.username, + host: user.host ?? account.host, + onTap: () => controller.insert( + '${user.acct.substring(query.value.length + 1)} ', + ), + ), + ), + ) + .toList(), + ); + } + + return fallbackBuilder?.call(context) ?? const SizedBox.shrink(); + } +} + +class MfmHashtagKeyboard extends HookConsumerWidget { + const MfmHashtagKeyboard({ + super.key, + required this.account, + required this.controller, + this.fallbackBuilder, + }); + + final Account account; + final TextEditingController controller; + final Widget Function(BuildContext context)? fallbackBuilder; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final query = useState(''); + final hashtags = useState([]); + useEffect( + () { + Future callback() async { + final selectionIndex = max(0, controller.selection.start); + final textBeforeSelection = + controller.text.substring(0, selectionIndex); + query.value = + RegExp(r'#?(\S*)$').firstMatch(textBeforeSelection)?[1] ?? ''; + if (query.value case final query when query.isNotEmpty) { + hashtags.value = + await ref.read(searchHashtagsProvider(account, query).future); + } else { + hashtags.value = []; + } + } + + callback(); + controller.addListener(callback); + return () => controller.removeListener(callback); + }, + [account, controller], + ); + final history = ref.watch( + accountSettingsNotifierProvider(account) + .select((settings) => settings.hashtags), + ); + + if (query.value.isEmpty ? history : hashtags.value case final hashtags + when hashtags.isNotEmpty) { + return _MfmKeyboardContainer( + children: hashtags + .map( + (hashtag) => TextButton( + onPressed: () => controller + .insert('${hashtag.substring(query.value.length)} '), + child: Text(hashtag), + ), + ) + .toList(), + ); + } + + return fallbackBuilder?.call(context) ?? const SizedBox.shrink(); + } +} diff --git a/lib/view/widget/post_form.dart b/lib/view/widget/post_form.dart index e1aea57e..d566b7c9 100644 --- a/lib/view/widget/post_form.dart +++ b/lib/view/widget/post_form.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -28,6 +29,7 @@ import '../../provider/api/user_notifier_provider.dart'; import '../../provider/general_settings_notifier_provider.dart'; import '../../provider/misskey_colors_provider.dart'; import '../../provider/note_provider.dart'; +import '../../provider/post_form_hashtags_notifier_provider.dart'; import '../../provider/post_notifier_provider.dart'; import '../../provider/timeline_tab_settings_provider.dart'; import '../../util/extract_mentions.dart'; @@ -58,8 +60,10 @@ class PostForm extends HookConsumerWidget { this.noteId, this.controller, this.cwController, + this.hashtagsController, this.focusNode, this.cwFocusNode, + this.hashtagsFocusNode, this.onHide, this.onExpand, this.onAccountChanged, @@ -73,8 +77,10 @@ class PostForm extends HookConsumerWidget { final String? noteId; final TextEditingController? controller; final TextEditingController? cwController; + final TextEditingController? hashtagsController; final FocusNode? focusNode; final FocusNode? cwFocusNode; + final FocusNode? hashtagsFocusNode; final void Function()? onHide; final void Function(Account account)? onExpand; final void Function(Account account)? onAccountChanged; @@ -88,6 +94,10 @@ class PostForm extends HookConsumerWidget { Account account, ) async { final request = ref.read(postNotifierProvider(account, noteId: noteId)); + final hashtags = + ref.read(accountSettingsNotifierProvider(account)).postFormUseHashtags + ? ref.read(postFormHashtagsNotifierProvider(account)) + : null; final attaches = ref.read(attachesNotifierProvider(account, noteId: noteId)); final hasFiles = attaches.isNotEmpty; @@ -109,7 +119,7 @@ class PostForm extends HookConsumerWidget { final confirmed = await confirmPost( ref, account, - request, + request.addHashtags(hashtags), files: files, ); if (!confirmed) return; @@ -117,13 +127,14 @@ class PostForm extends HookConsumerWidget { } final result = await futureWithDialog( ref.context, - ref - .read(postNotifierProvider(account, noteId: noteId).notifier) - .post(fileIds: files?.map((file) => file.id).toList()), + ref.read(postNotifierProvider(account, noteId: noteId).notifier).post( + fileIds: files?.map((file) => file.id).toList(), + hashtags: hashtags, + ), ); if (!ref.context.mounted) return; - if (result != null) { - if (request.text case final text?) { + if (result case final note?) { + if (note.text case final text?) { final nodes = const MfmParser().parse(text); final hashtags = nodes .extract((node) => node is MfmHashTag) @@ -143,6 +154,9 @@ class PostForm extends HookConsumerWidget { .read(postNotifierProvider(account, noteId: noteId).notifier) .setChannel(channelId); } + unawaited( + ref.read(postFormHashtagsNotifierProvider(account).notifier).save(), + ); ref.invalidate(attachesNotifierProvider(account, noteId: noteId)); ref.context.pop(); } @@ -267,14 +281,29 @@ class PostForm extends HookConsumerWidget { ); final useCw = useState(useMemoized(() => request.cw?.isNotEmpty ?? false, [])); - final cwController = this.cwController ?? - useTextEditingController(text: request.cw, keys: [account.value]); - final controller = this.controller ?? - useTextEditingController(text: request.text, keys: [account.value]); + final useHashtags = ref.watch( + accountSettingsNotifierProvider(account.value) + .select((settings) => settings.postFormUseHashtags), + ); + final cwController = + this.cwController ?? useTextEditingController(text: request.cw); + final controller = + this.controller ?? useTextEditingController(text: request.text); + final hashtagsController = this.hashtagsController ?? + useTextEditingController( + text: ref + .watch( + accountSettingsNotifierProvider(account.value) + .select((settings) => settings.postFormHashtags), + ) + .join(' '), + ); final cwFocusNode = this.cwFocusNode ?? useFocusNode(); final focusNode = this.focusNode ?? useFocusNode(); + final hashtagsFocusNode = this.hashtagsFocusNode ?? useFocusNode(); final isCwFocused = useState(false); final isFocused = useState(false); + final isHashtagsFocused = useState(false); ref.listen( postNotifierProvider(account.value, noteId: noteId) .select((request) => request.cw), @@ -295,6 +324,18 @@ class PostForm extends HookConsumerWidget { } }, ); + ref.listen(postFormHashtagsNotifierProvider(account.value), (_, hashtags) { + if (!hashtags.equals( + hashtagsController.text + .split(RegExp(r'\s')) + .map((tag) => tag.trim()) + .map((tag) => tag.startsWith('#') ? tag.substring(1) : tag) + .where((tag) => tag.isNotEmpty) + .toList(), + )) { + hashtagsController.text = hashtags.join(' '); + } + }); useEffect( () { final visibleUserIds = request.visibleUserIds; @@ -326,6 +367,12 @@ class PostForm extends HookConsumerWidget { .setText(controller.text); } + void hashtagsControllerCallback() { + ref + .read(postFormHashtagsNotifierProvider(account.value).notifier) + .updateFromString(hashtagsController.text); + } + void cwFocusNodeCallback() { isCwFocused.value = cwFocusNode.hasFocus; } @@ -334,16 +381,24 @@ class PostForm extends HookConsumerWidget { isFocused.value = focusNode.hasFocus; } + void hashtagsFocusNodeCallback() { + isHashtagsFocused.value = hashtagsFocusNode.hasFocus; + } + cwController.addListener(cwControllerCallback); controller.addListener(controllerCallback); + hashtagsController.addListener(hashtagsControllerCallback); cwFocusNode.addListener(cwFocusNodeCallback); focusNode.addListener(focusNodeCallback); + hashtagsFocusNode.addListener(hashtagsFocusNodeCallback); return () { cwController.removeListener(cwControllerCallback); controller.removeListener(controllerCallback); + hashtagsController.removeListener(hashtagsControllerCallback); cwFocusNode.removeListener(cwFocusNodeCallback); focusNode.removeListener(focusNodeCallback); + hashtagsFocusNode.removeListener(hashtagsFocusNodeCallback); }; }, [account.value], @@ -918,6 +973,8 @@ class PostForm extends HookConsumerWidget { decoration: InputDecoration( hintText: t.misskey.annotation, filled: false, + border: + const OutlineInputBorder(borderSide: BorderSide.none), ), autofocus: true, textInputAction: TextInputAction.next, @@ -939,6 +996,7 @@ class PostForm extends HookConsumerWidget { decoration: InputDecoration( hintText: placeholder, filled: false, + border: const OutlineInputBorder(borderSide: BorderSide.none), ), autofocus: true, minLines: 1, @@ -998,6 +1056,25 @@ class PostForm extends HookConsumerWidget { enableSpellCheck ? const SpellCheckConfiguration() : null, ), ), + if (useHashtags) ...[ + const Divider(height: 0.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Shortcuts( + shortcuts: disablingTextShortcuts, + child: TextField( + controller: hashtagsController, + focusNode: hashtagsFocusNode, + decoration: InputDecoration( + hintText: t.misskey.hashtags, + filled: false, + border: + const OutlineInputBorder(borderSide: BorderSide.none), + ), + ), + ), + ), + ], Padding( padding: const EdgeInsets.all(8.0), child: Row( @@ -1094,12 +1171,28 @@ class PostForm extends HookConsumerWidget { }, icon: const Icon(Icons.alternate_email), ), - // TODO: Preserve hashtags. - // IconButton( - // tooltip: t.misskey.hashtags, - // onPressed: () {}, - // icon: const Icon(Icons.tag), - // ), + IconButton( + tooltip: t.misskey.hashtags, + onPressed: () async { + final value = useHashtags; + await ref + .read( + accountSettingsNotifierProvider( + account.value, + ).notifier, + ) + .setPostFormUseHashtags(!value); + if (!value) { + hashtagsFocusNode.requestFocus(); + } + }, + icon: Icon( + Icons.tag, + color: useHashtags + ? Theme.of(context).colorScheme.primary + : null, + ), + ), IconButton( tooltip: t.misskey.emoji, onPressed: () => pickEmoji( @@ -1209,6 +1302,17 @@ class PostForm extends HookConsumerWidget { ), ), ), + Visibility( + visible: isHashtagsFocused.value, + maintainState: true, + child: TextFieldTapRegion( + onTapOutside: (_) => primaryFocus?.unfocus(), + child: MfmHashtagKeyboard( + account: account.value, + controller: hashtagsController, + ), + ), + ), ], ], ), diff --git a/test/view/widget/mfm_keyboard_test.dart b/test/view/widget/mfm_keyboard_test.dart index 6f1d4693..3988acc4 100644 --- a/test/view/widget/mfm_keyboard_test.dart +++ b/test/view/widget/mfm_keyboard_test.dart @@ -1,11 +1,39 @@ import 'package:aria/model/account.dart'; -import 'package:aria/provider/shared_preferences_provider.dart'; +import 'package:aria/provider/account_settings_notifier_provider.dart'; +import 'package:aria/provider/dio_provider.dart'; import 'package:aria/view/widget/mfm_keyboard.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http_mock_adapter/http_mock_adapter.dart'; -import '../../../test_util/fake_shared_preferences.dart'; +import '../../../test_util/create_overrides.dart'; +import '../../../test_util/dummy_me_detailed.dart'; +import '../../../test_util/dummy_user_detailed_not_me.dart'; + +Future setupWidget( + WidgetTester tester, { + required Account account, + required TextEditingController controller, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: createOverrides(account), + child: MaterialApp( + home: Material( + child: MfmKeyboard( + account: account, + controller: controller, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final container = + ProviderScope.containerOf(tester.element(find.byType(MfmKeyboard))); + return container; +} void main() { group('getLastTag', () { @@ -87,52 +115,329 @@ void main() { }); group('widget', () { - testWidgets('show fallback keyboards', (tester) async { - final controller = TextEditingController(); - await tester.pumpWidget( - MaterialApp( - home: MfmKeyboard( - account: Account.dummy(), - controller: controller, - ), - ), - ); - - expect(find.text(':'), findsOneWidget); - final lastItemFinder = find.text('?[]()'); - await tester.scrollUntilVisible(lastItemFinder, 500.0); - expect(lastItemFinder, findsOneWidget); - await tester.tap(lastItemFinder); - await tester.pumpAndSettle(); - expect(controller.text, '?[]()'); + group('basic', () { + testWidgets('should show a basic keyboard', (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(); + await setupWidget( + tester, + account: account, + controller: controller, + ); + expect(find.text(':'), findsOne); + final lastItemFinder = find.text('?[]()'); + await tester.scrollUntilVisible(lastItemFinder, 500.0); + expect(lastItemFinder, findsOne); + await tester.tap(lastItemFinder); + expect(controller.text, '?[]()'); + }); }); - testWidgets('show emoji keyboards', (tester) async { - final controller = TextEditingController(text: ':'); - controller.selection = const TextSelection.collapsed(offset: 1); - final map = { - '/accountSettings': '{"recentlyUsedEmojis": ["❤️"]}', - }; - await tester.pumpWidget( - ProviderScope( - overrides: [ - sharedPreferencesProvider - .overrideWithValue(FakeSharedPreferences(map)), - ], - child: MaterialApp( - home: MfmKeyboard( - account: Account.dummy(), - controller: controller, - ), + group('emoji', () { + testWidgets('should show recently used emojis', (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(text: ':'); + controller.selection = const TextSelection.collapsed(offset: 1); + final container = await setupWidget( + tester, + account: account, + controller: controller, + ); + await container + .read(accountSettingsNotifierProvider(account).notifier) + .setRecentlyUsedEmojis(['❤️']); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('❤️'))); + expect(controller.text, '❤️'); + }); + + testWidgets('should search emojis', (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(text: ':heart'); + controller.selection = const TextSelection.collapsed(offset: 6); + await setupWidget( + tester, + account: account, + controller: controller, + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('❤️'))); + await tester.pumpAndSettle(); + expect(controller.text, '❤️'); + }); + + testWidgets('should not show an emoji keyboard after a close tag', + (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(text: ':heart:'); + controller.selection = const TextSelection.collapsed(offset: 7); + final container = await setupWidget( + tester, + account: account, + controller: controller, + ); + await container + .read(accountSettingsNotifierProvider(account).notifier) + .setRecentlyUsedEmojis(['❤️']); + await tester.pumpAndSettle(); + expect(find.text(':'), findsOne); + expect(find.byKey(const ValueKey('❤️')), findsNothing); + }); + + testWidgets('should not show an emoji keyboard after a space', + (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(text: ': '); + controller.selection = const TextSelection.collapsed(offset: 2); + final container = await setupWidget( + tester, + account: account, + controller: controller, + ); + await container + .read(accountSettingsNotifierProvider(account).notifier) + .setRecentlyUsedEmojis(['❤️']); + await tester.pumpAndSettle(); + expect(find.text(':'), findsOne); + expect(find.byKey(const ValueKey('❤️')), findsNothing); + }); + }); + + group('MFM fn', () { + testWidgets('should show MFM fn names', (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(text: r'$['); + controller.selection = const TextSelection.collapsed(offset: 2); + await setupWidget( + tester, + account: account, + controller: controller, + ); + await tester.pumpAndSettle(); + expect(find.text('tada'), findsOne); + controller.text = r'$[x'; + controller.selection = const TextSelection.collapsed(offset: 3); + await tester.pumpAndSettle(); + expect(find.text('tada'), findsNothing); + await tester.tap(find.text('x2')); + expect(controller.text, r'$[x2 '); + }); + + testWidgets('should show MFM fn args', (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(text: r'$[spin'); + controller.selection = const TextSelection.collapsed(offset: 6); + await setupWidget( + tester, + account: account, + controller: controller, + ); + await tester.pumpAndSettle(); + expect(find.text('speed'), findsOne); + expect(find.text('x'), findsOne); + controller.text = r'$[spin.'; + controller.selection = const TextSelection.collapsed(offset: 7); + await tester.pumpAndSettle(); + expect(find.text('speed'), findsOne); + expect(find.text('x'), findsOne); + controller.text = r'$[spin.s'; + controller.selection = const TextSelection.collapsed(offset: 8); + await tester.pumpAndSettle(); + expect(find.text('x'), findsNothing); + await tester.tap(find.text('speed')); + await tester.pumpAndSettle(); + expect(controller.text, r'$[spin.speed=1.5s'); + expect(find.text('speed'), findsNothing); + await tester.tap(find.text('x')); + expect(controller.text, r'$[spin.speed=1.5s,x'); + }); + + testWidgets('should not show an MFM fn keyboard after a close tag', + (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(text: r'$[]'); + controller.selection = const TextSelection.collapsed(offset: 3); + await setupWidget( + tester, + account: account, + controller: controller, + ); + await tester.pumpAndSettle(); + expect(find.text(':'), findsOne); + expect(find.text('tada'), findsNothing); + }); + + testWidgets('should not show an MFM fn keyboard after a space', + (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(text: r'$[ '); + controller.selection = const TextSelection.collapsed(offset: 3); + await setupWidget( + tester, + account: account, + controller: controller, + ); + await tester.pumpAndSettle(); + expect(find.text(':'), findsOne); + expect(find.text('tada'), findsNothing); + }); + }); + + group('mention', () { + testWidgets('should show recently used users', (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(); + final container = await setupWidget( + tester, + account: account, + controller: controller, + ); + final dioAdapter = DioAdapter(dio: container.read(dioProvider)); + dioAdapter.onPost( + 'users/show', + (server) => server.reply( + 200, + [ + dummyMeDetailed.copyWith(username: 'testuser').toJson(), + dummyUserDetailedNotMe + .copyWith(username: 'testuser', host: 'misskey2.tld') + .toJson(), + ], ), - ), - ); + data: { + 'userIds': ['testuser', 'testuser@misskey2.tld'], + }, + ); + await container + .read(accountSettingsNotifierProvider(account).notifier) + .setRecentlyUsedUsers(['testuser', 'testuser@misskey2.tld']); + controller.text = '@'; + controller.selection = const TextSelection.collapsed(offset: 1); + await tester.pumpAndSettle(); + expect(find.text('@testuser'), findsOne); + await tester.tap(find.text('@testuser@misskey2.tld')); + expect(controller.text, '@testuser@misskey2.tld '); + }); + + testWidgets('should search users', (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(); + final container = await setupWidget( + tester, + account: account, + controller: controller, + ); + final dioAdapter = DioAdapter(dio: container.read(dioProvider)); + dioAdapter.onPost( + 'users/search-by-username-and-host', + (server) => server.reply( + 200, + [ + dummyMeDetailed.copyWith(username: 'testuser').toJson(), + dummyUserDetailedNotMe + .copyWith(username: 'testuser', host: 'misskey2.tld') + .toJson(), + ], + ), + data: {'username': 'testuser'}, + ); + controller.text = '@testuser'; + controller.selection = const TextSelection.collapsed(offset: 9); + await tester.pumpAndSettle(); + expect(find.text('@testuser'), findsOne); + await tester.tap(find.text('@testuser@misskey2.tld')); + expect(controller.text, '@testuser@misskey2.tld '); + }); + + testWidgets('should not show an mention keyboard after a space', + (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(); + final container = await setupWidget( + tester, + account: account, + controller: controller, + ); + final dioAdapter = DioAdapter(dio: container.read(dioProvider)); + dioAdapter.onPost( + 'users/show', + (server) => server.reply( + 200, + [dummyMeDetailed.copyWith(username: 'testuser').toJson()], + ), + data: { + 'userIds': ['testuser'], + }, + ); + await container + .read(accountSettingsNotifierProvider(account).notifier) + .setRecentlyUsedUsers(['testuser']); + controller.text = '@ '; + controller.selection = const TextSelection.collapsed(offset: 2); + await tester.pumpAndSettle(); + expect(find.text(':'), findsOne); + expect(find.text('@testuser'), findsNothing); + }); + }); + + group('hashtag', () { + testWidgets('should show recently used hashtags', (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(); + final container = await setupWidget( + tester, + account: account, + controller: controller, + ); + await container + .read(accountSettingsNotifierProvider(account).notifier) + .setHashtags(['test']); + controller.text = '#'; + controller.selection = const TextSelection.collapsed(offset: 1); + await tester.pumpAndSettle(); + await tester.tap(find.text('test')); + expect(controller.text, '#test '); + }); + + testWidgets('should search hashtags', (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(); + final container = await setupWidget( + tester, + account: account, + controller: controller, + ); + final dioAdapter = DioAdapter(dio: container.read(dioProvider)); + dioAdapter.onPost( + 'hashtags/search', + (server) => server.reply(200, ['test']), + data: {'query': 't'}, + ); + controller.text = '#t'; + controller.selection = const TextSelection.collapsed(offset: 2); + await tester.pumpAndSettle(); + await tester.tap(find.text('test')); + expect(controller.text, '#test '); + }); - final buttonFinder = find.byKey(const ValueKey('❤️')); - expect(buttonFinder, findsOneWidget); - await tester.tap(buttonFinder); - await tester.pumpAndSettle(); - expect(controller.text, '❤️'); + testWidgets('should not show a hashtag keyboard after a space', + (tester) async { + const account = Account(host: 'misskey.tld', username: 'testuser'); + final controller = TextEditingController(); + final container = await setupWidget( + tester, + account: account, + controller: controller, + ); + await container + .read(accountSettingsNotifierProvider(account).notifier) + .setHashtags(['test']); + controller.text = '# '; + controller.selection = const TextSelection.collapsed(offset: 2); + await tester.pumpAndSettle(); + expect(find.text(':'), findsOne); + expect(find.text('test'), findsNothing); + }); }); }); } diff --git a/test_util/dummy_me_detailed.dart b/test_util/dummy_me_detailed.dart index 3ad54ecb..5d66c0da 100644 --- a/test_util/dummy_me_detailed.dart +++ b/test_util/dummy_me_detailed.dart @@ -13,7 +13,6 @@ final dummyMeDetailed = MeDetailed( followersCount: 0, followingCount: 0, notesCount: 0, - twoFactorEnabled: false, isModerator: false, isAdmin: false, alwaysMarkNsfw: false, diff --git a/test_util/dummy_user_detailed_not_me.dart b/test_util/dummy_user_detailed_not_me.dart new file mode 100644 index 00000000..b5a7ca0d --- /dev/null +++ b/test_util/dummy_user_detailed_not_me.dart @@ -0,0 +1,15 @@ +import 'package:misskey_dart/misskey_dart.dart'; + +final dummyUserDetailedNotMe = UserDetailedNotMe( + id: '', + username: '', + isBot: false, + isCat: false, + createdAt: DateTime(0), + isLocked: false, + isSilenced: false, + isSuspended: false, + followersCount: 0, + followingCount: 0, + notesCount: 0, +); diff --git a/test_util/dummy_user_lite.dart b/test_util/dummy_user_lite.dart index a01d724a..3ca44820 100644 --- a/test_util/dummy_user_lite.dart +++ b/test_util/dummy_user_lite.dart @@ -1,3 +1,3 @@ import 'package:misskey_dart/misskey_dart.dart'; -final dummyUserLite = UserLite(id: '', username: '', avatarUrl: Uri()); +const dummyUserLite = UserLite(id: '', username: '');