From 9d39c6a82572f9c0cfc582d9a85155637567e1e2 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 12 Oct 2025 17:10:18 +0800 Subject: [PATCH] :sparkles: Auto complete, better metion parser, sticker placeholder v2 --- assets/i18n/en-US.json | 6 +- lib/models/autocomplete_response.dart | 16 + lib/models/autocomplete_response.freezed.dart | 283 ++++++++++++++++++ lib/models/autocomplete_response.g.dart | 23 ++ lib/route.dart | 2 +- lib/screens/developers/hub.g.dart | 2 +- lib/services/autocomplete_service.dart | 28 ++ lib/widgets/chat/chat_input.dart | 146 +++++++-- lib/widgets/content/markdown.dart | 38 ++- lib/widgets/stickers/picker.dart | 2 +- 10 files changed, 497 insertions(+), 49 deletions(-) create mode 100644 lib/models/autocomplete_response.dart create mode 100644 lib/models/autocomplete_response.freezed.dart create mode 100644 lib/models/autocomplete_response.g.dart create mode 100644 lib/services/autocomplete_service.dart diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 44ab8a33..2f9a2423 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -1215,5 +1215,9 @@ "resend": "Resend", "fileInfoTitle": "File Information", "download": "Download", - "info": "Info" + "info": "Info", + "noStickers": "No Stickers", + "noStickersInPack": "This pack does not contains stickers", + "noStickerPacks": "No Sticker Packs", + "refresh": "Refresh" } diff --git a/lib/models/autocomplete_response.dart b/lib/models/autocomplete_response.dart new file mode 100644 index 00000000..3061ac54 --- /dev/null +++ b/lib/models/autocomplete_response.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'autocomplete_response.freezed.dart'; +part 'autocomplete_response.g.dart'; + +@freezed +sealed class AutocompleteSuggestion with _$AutocompleteSuggestion { + const factory AutocompleteSuggestion({ + required String type, + required String keyword, + required dynamic data, + }) = _AutocompleteSuggestion; + + factory AutocompleteSuggestion.fromJson(Map json) => + _$AutocompleteSuggestionFromJson(json); +} diff --git a/lib/models/autocomplete_response.freezed.dart b/lib/models/autocomplete_response.freezed.dart new file mode 100644 index 00000000..fd0d69e4 --- /dev/null +++ b/lib/models/autocomplete_response.freezed.dart @@ -0,0 +1,283 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'autocomplete_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$AutocompleteSuggestion { + + String get type; String get keyword; dynamic get data; +/// Create a copy of AutocompleteSuggestion +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$AutocompleteSuggestionCopyWith get copyWith => _$AutocompleteSuggestionCopyWithImpl(this as AutocompleteSuggestion, _$identity); + + /// Serializes this AutocompleteSuggestion to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is AutocompleteSuggestion&&(identical(other.type, type) || other.type == type)&&(identical(other.keyword, keyword) || other.keyword == keyword)&&const DeepCollectionEquality().equals(other.data, data)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,type,keyword,const DeepCollectionEquality().hash(data)); + +@override +String toString() { + return 'AutocompleteSuggestion(type: $type, keyword: $keyword, data: $data)'; +} + + +} + +/// @nodoc +abstract mixin class $AutocompleteSuggestionCopyWith<$Res> { + factory $AutocompleteSuggestionCopyWith(AutocompleteSuggestion value, $Res Function(AutocompleteSuggestion) _then) = _$AutocompleteSuggestionCopyWithImpl; +@useResult +$Res call({ + String type, String keyword, dynamic data +}); + + + + +} +/// @nodoc +class _$AutocompleteSuggestionCopyWithImpl<$Res> + implements $AutocompleteSuggestionCopyWith<$Res> { + _$AutocompleteSuggestionCopyWithImpl(this._self, this._then); + + final AutocompleteSuggestion _self; + final $Res Function(AutocompleteSuggestion) _then; + +/// Create a copy of AutocompleteSuggestion +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? type = null,Object? keyword = null,Object? data = freezed,}) { + return _then(_self.copyWith( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,keyword: null == keyword ? _self.keyword : keyword // ignore: cast_nullable_to_non_nullable +as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as dynamic, + )); +} + +} + + +/// Adds pattern-matching-related methods to [AutocompleteSuggestion]. +extension AutocompleteSuggestionPatterns on AutocompleteSuggestion { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _AutocompleteSuggestion value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _AutocompleteSuggestion() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _AutocompleteSuggestion value) $default,){ +final _that = this; +switch (_that) { +case _AutocompleteSuggestion(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _AutocompleteSuggestion value)? $default,){ +final _that = this; +switch (_that) { +case _AutocompleteSuggestion() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String type, String keyword, dynamic data)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _AutocompleteSuggestion() when $default != null: +return $default(_that.type,_that.keyword,_that.data);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String type, String keyword, dynamic data) $default,) {final _that = this; +switch (_that) { +case _AutocompleteSuggestion(): +return $default(_that.type,_that.keyword,_that.data);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String type, String keyword, dynamic data)? $default,) {final _that = this; +switch (_that) { +case _AutocompleteSuggestion() when $default != null: +return $default(_that.type,_that.keyword,_that.data);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _AutocompleteSuggestion implements AutocompleteSuggestion { + const _AutocompleteSuggestion({required this.type, required this.keyword, required this.data}); + factory _AutocompleteSuggestion.fromJson(Map json) => _$AutocompleteSuggestionFromJson(json); + +@override final String type; +@override final String keyword; +@override final dynamic data; + +/// Create a copy of AutocompleteSuggestion +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$AutocompleteSuggestionCopyWith<_AutocompleteSuggestion> get copyWith => __$AutocompleteSuggestionCopyWithImpl<_AutocompleteSuggestion>(this, _$identity); + +@override +Map toJson() { + return _$AutocompleteSuggestionToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AutocompleteSuggestion&&(identical(other.type, type) || other.type == type)&&(identical(other.keyword, keyword) || other.keyword == keyword)&&const DeepCollectionEquality().equals(other.data, data)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,type,keyword,const DeepCollectionEquality().hash(data)); + +@override +String toString() { + return 'AutocompleteSuggestion(type: $type, keyword: $keyword, data: $data)'; +} + + +} + +/// @nodoc +abstract mixin class _$AutocompleteSuggestionCopyWith<$Res> implements $AutocompleteSuggestionCopyWith<$Res> { + factory _$AutocompleteSuggestionCopyWith(_AutocompleteSuggestion value, $Res Function(_AutocompleteSuggestion) _then) = __$AutocompleteSuggestionCopyWithImpl; +@override @useResult +$Res call({ + String type, String keyword, dynamic data +}); + + + + +} +/// @nodoc +class __$AutocompleteSuggestionCopyWithImpl<$Res> + implements _$AutocompleteSuggestionCopyWith<$Res> { + __$AutocompleteSuggestionCopyWithImpl(this._self, this._then); + + final _AutocompleteSuggestion _self; + final $Res Function(_AutocompleteSuggestion) _then; + +/// Create a copy of AutocompleteSuggestion +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? type = null,Object? keyword = null,Object? data = freezed,}) { + return _then(_AutocompleteSuggestion( +type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,keyword: null == keyword ? _self.keyword : keyword // ignore: cast_nullable_to_non_nullable +as String,data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable +as dynamic, + )); +} + + +} + +// dart format on diff --git a/lib/models/autocomplete_response.g.dart b/lib/models/autocomplete_response.g.dart new file mode 100644 index 00000000..45bcf270 --- /dev/null +++ b/lib/models/autocomplete_response.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'autocomplete_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_AutocompleteSuggestion _$AutocompleteSuggestionFromJson( + Map json, +) => _AutocompleteSuggestion( + type: json['type'] as String, + keyword: json['keyword'] as String, + data: json['data'], +); + +Map _$AutocompleteSuggestionToJson( + _AutocompleteSuggestion instance, +) => { + 'type': instance.type, + 'keyword': instance.keyword, + 'data': instance.data, +}; diff --git a/lib/route.dart b/lib/route.dart index e4cc3fb3..272c38b9 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -633,7 +633,7 @@ final routerProvider = Provider((ref) { GoRoute( name: 'accountProfile', - path: '/account/:name', + path: '/accounts/:name', builder: (context, state) { final name = state.pathParameters['name']!; return AccountProfileScreen(name: name); diff --git a/lib/screens/developers/hub.g.dart b/lib/screens/developers/hub.g.dart index 8c6f13ac..611a649c 100644 --- a/lib/screens/developers/hub.g.dart +++ b/lib/screens/developers/hub.g.dart @@ -168,7 +168,7 @@ final developersProvider = @Deprecated('Will be removed in 3.0. Use Ref instead') // ignore: unused_element typedef DevelopersRef = AutoDisposeFutureProviderRef>; -String _$devProjectsHash() => r'87fdcab47cd7d79ab019a5625617abeb1ffa1f39'; +String _$devProjectsHash() => r'715b395bebda785d38691ffee3b88e50b498c91a'; /// See also [devProjects]. @ProviderFor(devProjects) diff --git a/lib/services/autocomplete_service.dart b/lib/services/autocomplete_service.dart new file mode 100644 index 00000000..fb4d808b --- /dev/null +++ b/lib/services/autocomplete_service.dart @@ -0,0 +1,28 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:island/models/autocomplete_response.dart'; +import 'package:island/pods/network.dart'; + +final autocompleteServiceProvider = Provider((ref) { + final dio = ref.watch(apiClientProvider); + return AutocompleteService(dio); +}); + +class AutocompleteService { + final Dio _client; + + AutocompleteService(this._client); + + Future> getSuggestions( + String roomId, + String content, + ) async { + final response = await _client.post( + '/sphere/chat/$roomId/autocomplete', + data: {'content': content}, + ); + + final data = response.data as List; + return data.map((json) => AutocompleteSuggestion.fromJson(json)).toList(); + } +} diff --git a/lib/widgets/chat/chat_input.dart b/lib/widgets/chat/chat_input.dart index 4ef6a091..77e294a1 100644 --- a/lib/widgets/chat/chat_input.dart +++ b/lib/widgets/chat/chat_input.dart @@ -3,14 +3,19 @@ import "package:easy_localization/easy_localization.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_typeahead/flutter_typeahead.dart"; import "package:gap/gap.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:image_picker/image_picker.dart"; +import "package:island/models/account.dart"; +import "package:island/models/autocomplete_response.dart"; import "package:island/models/chat.dart"; import "package:island/models/file.dart"; import "package:island/pods/config.dart"; +import "package:island/services/autocomplete_service.dart"; import "package:island/services/responsive.dart"; import "package:island/widgets/content/attachment_preview.dart"; +import "package:island/widgets/content/cloud_files.dart"; import "package:island/widgets/shared/upload_menu.dart"; import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:pasteboard/pasteboard.dart"; @@ -373,37 +378,118 @@ class ChatInput extends HookConsumerWidget { ], ), Expanded( - child: TextField( - focusNode: inputFocusNode, + child: TypeAheadField( controller: messageController, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - hintMaxLines: 1, - hintText: - (chatRoom.type == 1 && chatRoom.name == null) - ? 'chatDirectMessageHint'.tr( - args: [ - chatRoom.members! - .map((e) => e.account.nick) - .join(', '), - ], - ) - : 'chatMessageHint'.tr(args: [chatRoom.name!]), - border: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 12, - ), - counterText: - messageController.text.length > 1024 - ? '${messageController.text.length}/4096' - : null, - ), - maxLines: 3, - minLines: 1, - onTapOutside: - (_) => FocusManager.instance.primaryFocus?.unfocus(), + focusNode: inputFocusNode, + builder: (context, controller, focusNode) { + return TextField( + focusNode: focusNode, + controller: controller, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + hintMaxLines: 1, + hintText: + (chatRoom.type == 1 && chatRoom.name == null) + ? 'chatDirectMessageHint'.tr( + args: [ + chatRoom.members! + .map((e) => e.account.nick) + .join(', '), + ], + ) + : 'chatMessageHint'.tr( + args: [chatRoom.name!], + ), + border: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + counterText: + messageController.text.length > 1024 + ? '${messageController.text.length}/4096' + : null, + ), + maxLines: 3, + minLines: 1, + onTapOutside: + (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ); + }, + suggestionsCallback: (pattern) async { + // Only trigger on @ or : + final atIndex = pattern.lastIndexOf('@'); + final colonIndex = pattern.lastIndexOf(':'); + final triggerIndex = + atIndex > colonIndex ? atIndex : colonIndex; + if (triggerIndex == -1) return []; + final service = ref.read(autocompleteServiceProvider); + try { + return await service.getSuggestions( + chatRoom.id, + pattern, + ); + } catch (e) { + return []; + } + }, + itemBuilder: (context, suggestion) { + String title = 'unknown'.tr(); + Widget leading = Icon(Symbols.help); + switch (suggestion.type) { + case 'user': + final user = SnAccount.fromJson(suggestion.data); + title = user.nick; + leading = ProfilePictureWidget( + file: user.profile.picture, + radius: 18, + ); + break; + case 'chatroom': + break; + case 'realm': + break; + case 'publisher': + break; + case 'sticker': + break; + default: + } + return ListTile( + leading: leading, + title: Text(title), + subtitle: Text(suggestion.keyword), + dense: true, + ); + }, + onSelected: (suggestion) { + final text = messageController.text; + final atIndex = text.lastIndexOf('@'); + final colonIndex = text.lastIndexOf(':'); + final triggerIndex = + atIndex > colonIndex ? atIndex : colonIndex; + if (triggerIndex == -1) return; + final newText = text.replaceRange( + triggerIndex, + text.length, + suggestion.keyword, + ); + messageController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: triggerIndex + suggestion.keyword.length, + ), + ); + }, + direction: VerticalDirection.up, + hideOnEmpty: true, + hideOnLoading: true, + debounceDuration: const Duration(milliseconds: 500), + loadingBuilder: (context) => const Text('Loading...'), + errorBuilder: (context, error) => const Text('Error!'), + emptyBuilder: (context) => const Text('No items found!'), ), ), IconButton( diff --git a/lib/widgets/content/markdown.dart b/lib/widgets/content/markdown.dart index 87721a03..de84e8b2 100644 --- a/lib/widgets/content/markdown.dart +++ b/lib/widgets/content/markdown.dart @@ -21,6 +21,8 @@ import 'package:url_launcher/url_launcher.dart'; import 'image.dart'; class MarkdownTextContent extends HookConsumerWidget { + static const String stickerRegex = r':([-\w]*\+[-\w]*):'; + final String content; final bool isAutoWarp; final TextScaler? textScaler; @@ -47,7 +49,7 @@ class MarkdownTextContent extends HookConsumerWidget { final baseUrl = ref.watch(serverUrlProvider); final doesEnlargeSticker = useMemoized(() { // Check if content only contains one sticker by matching the sticker pattern - final stickerPattern = RegExp(r':([-\w]+):'); + final stickerPattern = RegExp(stickerRegex); final matches = stickerPattern.allMatches(content); // Content should only contain one sticker and nothing else (except whitespace) @@ -96,16 +98,15 @@ class MarkdownTextContent extends HookConsumerWidget { final url = Uri.tryParse(href); if (url != null) { if (url.scheme == 'solian') { - if (url.host == 'account') { - context.pushNamed( - 'accountProfile', - pathParameters: {'name': url.pathSegments[0]}, - ); - } + final fullPath = ['/', url.host, url.path].join(''); + context.push(fullPath); return; } final whitelistDomains = ['solian.app', 'solsynth.dev']; - if (whitelistDomains.contains(url.host)) { + if (whitelistDomains.any( + (domain) => + url.host == domain || url.host.endsWith('.$domain'), + )) { launchUrl(url, mode: LaunchMode.externalApplication); return; } @@ -212,7 +213,7 @@ class MarkdownTextContent extends HookConsumerWidget { return MarkdownGenerator( generators: [latexGenerator], inlineSyntaxList: [ - _UserNameCardInlineSyntax(), + _MetionInlineSyntax(), _StickerInlineSyntax(), LatexSyntax(isDark), ], @@ -221,16 +222,23 @@ class MarkdownTextContent extends HookConsumerWidget { } } -class _UserNameCardInlineSyntax extends markdown.InlineSyntax { - _UserNameCardInlineSyntax() : super(r'@[a-zA-Z0-9_]+'); +class _MetionInlineSyntax extends markdown.InlineSyntax { + _MetionInlineSyntax() : super(r'@[-a-zA-Z0-9_./]+'); @override bool onMatch(markdown.InlineParser parser, Match match) { final alias = match[0]!; + final parts = alias.substring(1).split('/'); + final typeShortcut = parts.length == 1 ? 'u' : parts.first; + final type = switch (typeShortcut) { + 'u' => 'accounts', + 'r' => 'realms', + 'p' => 'publishers', + "c" => 'chat', + _ => '', + }; final anchor = markdown.Element.text('a', alias) - ..attributes['href'] = Uri.encodeFull( - 'solian://account/${alias.substring(1)}', - ); + ..attributes['href'] = Uri.encodeFull('solian://$type/${parts.last}'); parser.addNode(anchor); return true; @@ -238,7 +246,7 @@ class _UserNameCardInlineSyntax extends markdown.InlineSyntax { } class _StickerInlineSyntax extends markdown.InlineSyntax { - _StickerInlineSyntax() : super(r':([-\w]+):'); + _StickerInlineSyntax() : super(MarkdownTextContent.stickerRegex); @override bool onMatch(markdown.InlineParser parser, Match match) { diff --git a/lib/widgets/stickers/picker.dart b/lib/widgets/stickers/picker.dart index 478dca07..6d235415 100644 --- a/lib/widgets/stickers/picker.dart +++ b/lib/widgets/stickers/picker.dart @@ -247,7 +247,7 @@ class _StickersGrid extends StatelessWidget { itemCount: stickers.length, itemBuilder: (context, index) { final sticker = stickers[index]; - final placeholder = ':${pack.prefix}${sticker.slug}:'; + final placeholder = ':${pack.prefix}+${sticker.slug}:'; return Tooltip( message: placeholder, child: InkWell(