Auto complete, better metion parser, sticker placeholder v2

This commit is contained in:
2025-10-12 17:10:18 +08:00
parent 537e49f1a4
commit 9d39c6a825
10 changed files with 497 additions and 49 deletions

View File

@@ -1215,5 +1215,9 @@
"resend": "Resend", "resend": "Resend",
"fileInfoTitle": "File Information", "fileInfoTitle": "File Information",
"download": "Download", "download": "Download",
"info": "Info" "info": "Info",
"noStickers": "No Stickers",
"noStickersInPack": "This pack does not contains stickers",
"noStickerPacks": "No Sticker Packs",
"refresh": "Refresh"
} }

View File

@@ -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<String, dynamic> json) =>
_$AutocompleteSuggestionFromJson(json);
}

View File

@@ -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>(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<AutocompleteSuggestion> get copyWith => _$AutocompleteSuggestionCopyWithImpl<AutocompleteSuggestion>(this as AutocompleteSuggestion, _$identity);
/// Serializes this AutocompleteSuggestion to a JSON map.
Map<String, dynamic> 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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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<String, dynamic> 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<String, dynamic> 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

View File

@@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'autocomplete_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_AutocompleteSuggestion _$AutocompleteSuggestionFromJson(
Map<String, dynamic> json,
) => _AutocompleteSuggestion(
type: json['type'] as String,
keyword: json['keyword'] as String,
data: json['data'],
);
Map<String, dynamic> _$AutocompleteSuggestionToJson(
_AutocompleteSuggestion instance,
) => <String, dynamic>{
'type': instance.type,
'keyword': instance.keyword,
'data': instance.data,
};

View File

@@ -633,7 +633,7 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute( GoRoute(
name: 'accountProfile', name: 'accountProfile',
path: '/account/:name', path: '/accounts/:name',
builder: (context, state) { builder: (context, state) {
final name = state.pathParameters['name']!; final name = state.pathParameters['name']!;
return AccountProfileScreen(name: name); return AccountProfileScreen(name: name);

View File

@@ -168,7 +168,7 @@ final developersProvider =
@Deprecated('Will be removed in 3.0. Use Ref instead') @Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element // ignore: unused_element
typedef DevelopersRef = AutoDisposeFutureProviderRef<List<SnDeveloper>>; typedef DevelopersRef = AutoDisposeFutureProviderRef<List<SnDeveloper>>;
String _$devProjectsHash() => r'87fdcab47cd7d79ab019a5625617abeb1ffa1f39'; String _$devProjectsHash() => r'715b395bebda785d38691ffee3b88e50b498c91a';
/// See also [devProjects]. /// See also [devProjects].
@ProviderFor(devProjects) @ProviderFor(devProjects)

View File

@@ -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<AutocompleteService>((ref) {
final dio = ref.watch(apiClientProvider);
return AutocompleteService(dio);
});
class AutocompleteService {
final Dio _client;
AutocompleteService(this._client);
Future<List<AutocompleteSuggestion>> getSuggestions(
String roomId,
String content,
) async {
final response = await _client.post(
'/sphere/chat/$roomId/autocomplete',
data: {'content': content},
);
final data = response.data as List<dynamic>;
return data.map((json) => AutocompleteSuggestion.fromJson(json)).toList();
}
}

View File

@@ -3,14 +3,19 @@ import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_typeahead/flutter_typeahead.dart";
import "package:gap/gap.dart"; import "package:gap/gap.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:image_picker/image_picker.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/chat.dart";
import "package:island/models/file.dart"; import "package:island/models/file.dart";
import "package:island/pods/config.dart"; import "package:island/pods/config.dart";
import "package:island/services/autocomplete_service.dart";
import "package:island/services/responsive.dart"; import "package:island/services/responsive.dart";
import "package:island/widgets/content/attachment_preview.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:island/widgets/shared/upload_menu.dart";
import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:material_symbols_icons/material_symbols_icons.dart";
import "package:pasteboard/pasteboard.dart"; import "package:pasteboard/pasteboard.dart";
@@ -373,9 +378,13 @@ class ChatInput extends HookConsumerWidget {
], ],
), ),
Expanded( Expanded(
child: TextField( child: TypeAheadField<AutocompleteSuggestion>(
focusNode: inputFocusNode,
controller: messageController, controller: messageController,
focusNode: inputFocusNode,
builder: (context, controller, focusNode) {
return TextField(
focusNode: focusNode,
controller: controller,
keyboardType: TextInputType.multiline, keyboardType: TextInputType.multiline,
decoration: InputDecoration( decoration: InputDecoration(
hintMaxLines: 1, hintMaxLines: 1,
@@ -388,7 +397,9 @@ class ChatInput extends HookConsumerWidget {
.join(', '), .join(', '),
], ],
) )
: 'chatMessageHint'.tr(args: [chatRoom.name!]), : 'chatMessageHint'.tr(
args: [chatRoom.name!],
),
border: InputBorder.none, border: InputBorder.none,
isDense: true, isDense: true,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@@ -403,7 +414,82 @@ class ChatInput extends HookConsumerWidget {
maxLines: 3, maxLines: 3,
minLines: 1, minLines: 1,
onTapOutside: onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) =>
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( IconButton(

View File

@@ -21,6 +21,8 @@ import 'package:url_launcher/url_launcher.dart';
import 'image.dart'; import 'image.dart';
class MarkdownTextContent extends HookConsumerWidget { class MarkdownTextContent extends HookConsumerWidget {
static const String stickerRegex = r':([-\w]*\+[-\w]*):';
final String content; final String content;
final bool isAutoWarp; final bool isAutoWarp;
final TextScaler? textScaler; final TextScaler? textScaler;
@@ -47,7 +49,7 @@ class MarkdownTextContent extends HookConsumerWidget {
final baseUrl = ref.watch(serverUrlProvider); final baseUrl = ref.watch(serverUrlProvider);
final doesEnlargeSticker = useMemoized(() { final doesEnlargeSticker = useMemoized(() {
// Check if content only contains one sticker by matching the sticker pattern // 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); final matches = stickerPattern.allMatches(content);
// Content should only contain one sticker and nothing else (except whitespace) // Content should only contain one sticker and nothing else (except whitespace)
@@ -96,16 +98,15 @@ class MarkdownTextContent extends HookConsumerWidget {
final url = Uri.tryParse(href); final url = Uri.tryParse(href);
if (url != null) { if (url != null) {
if (url.scheme == 'solian') { if (url.scheme == 'solian') {
if (url.host == 'account') { final fullPath = ['/', url.host, url.path].join('');
context.pushNamed( context.push(fullPath);
'accountProfile',
pathParameters: {'name': url.pathSegments[0]},
);
}
return; return;
} }
final whitelistDomains = ['solian.app', 'solsynth.dev']; 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); launchUrl(url, mode: LaunchMode.externalApplication);
return; return;
} }
@@ -212,7 +213,7 @@ class MarkdownTextContent extends HookConsumerWidget {
return MarkdownGenerator( return MarkdownGenerator(
generators: [latexGenerator], generators: [latexGenerator],
inlineSyntaxList: [ inlineSyntaxList: [
_UserNameCardInlineSyntax(), _MetionInlineSyntax(),
_StickerInlineSyntax(), _StickerInlineSyntax(),
LatexSyntax(isDark), LatexSyntax(isDark),
], ],
@@ -221,16 +222,23 @@ class MarkdownTextContent extends HookConsumerWidget {
} }
} }
class _UserNameCardInlineSyntax extends markdown.InlineSyntax { class _MetionInlineSyntax extends markdown.InlineSyntax {
_UserNameCardInlineSyntax() : super(r'@[a-zA-Z0-9_]+'); _MetionInlineSyntax() : super(r'@[-a-zA-Z0-9_./]+');
@override @override
bool onMatch(markdown.InlineParser parser, Match match) { bool onMatch(markdown.InlineParser parser, Match match) {
final alias = match[0]!; 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) final anchor = markdown.Element.text('a', alias)
..attributes['href'] = Uri.encodeFull( ..attributes['href'] = Uri.encodeFull('solian://$type/${parts.last}');
'solian://account/${alias.substring(1)}',
);
parser.addNode(anchor); parser.addNode(anchor);
return true; return true;
@@ -238,7 +246,7 @@ class _UserNameCardInlineSyntax extends markdown.InlineSyntax {
} }
class _StickerInlineSyntax extends markdown.InlineSyntax { class _StickerInlineSyntax extends markdown.InlineSyntax {
_StickerInlineSyntax() : super(r':([-\w]+):'); _StickerInlineSyntax() : super(MarkdownTextContent.stickerRegex);
@override @override
bool onMatch(markdown.InlineParser parser, Match match) { bool onMatch(markdown.InlineParser parser, Match match) {

View File

@@ -247,7 +247,7 @@ class _StickersGrid extends StatelessWidget {
itemCount: stickers.length, itemCount: stickers.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final sticker = stickers[index]; final sticker = stickers[index];
final placeholder = ':${pack.prefix}${sticker.slug}:'; final placeholder = ':${pack.prefix}+${sticker.slug}:';
return Tooltip( return Tooltip(
message: placeholder, message: placeholder,
child: InkWell( child: InkWell(