✨ Auto complete, better metion parser, sticker placeholder v2
This commit is contained in:
@@ -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"
|
||||||
}
|
}
|
||||||
|
16
lib/models/autocomplete_response.dart
Normal file
16
lib/models/autocomplete_response.dart
Normal 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);
|
||||||
|
}
|
283
lib/models/autocomplete_response.freezed.dart
Normal file
283
lib/models/autocomplete_response.freezed.dart
Normal 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
|
23
lib/models/autocomplete_response.g.dart
Normal file
23
lib/models/autocomplete_response.g.dart
Normal 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,
|
||||||
|
};
|
@@ -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);
|
||||||
|
@@ -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)
|
||||||
|
28
lib/services/autocomplete_service.dart
Normal file
28
lib/services/autocomplete_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
@@ -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,37 +378,118 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TypeAheadField<AutocompleteSuggestion>(
|
||||||
focusNode: inputFocusNode,
|
|
||||||
controller: messageController,
|
controller: messageController,
|
||||||
keyboardType: TextInputType.multiline,
|
focusNode: inputFocusNode,
|
||||||
decoration: InputDecoration(
|
builder: (context, controller, focusNode) {
|
||||||
hintMaxLines: 1,
|
return TextField(
|
||||||
hintText:
|
focusNode: focusNode,
|
||||||
(chatRoom.type == 1 && chatRoom.name == null)
|
controller: controller,
|
||||||
? 'chatDirectMessageHint'.tr(
|
keyboardType: TextInputType.multiline,
|
||||||
args: [
|
decoration: InputDecoration(
|
||||||
chatRoom.members!
|
hintMaxLines: 1,
|
||||||
.map((e) => e.account.nick)
|
hintText:
|
||||||
.join(', '),
|
(chatRoom.type == 1 && chatRoom.name == null)
|
||||||
],
|
? 'chatDirectMessageHint'.tr(
|
||||||
)
|
args: [
|
||||||
: 'chatMessageHint'.tr(args: [chatRoom.name!]),
|
chatRoom.members!
|
||||||
border: InputBorder.none,
|
.map((e) => e.account.nick)
|
||||||
isDense: true,
|
.join(', '),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
],
|
||||||
horizontal: 12,
|
)
|
||||||
vertical: 12,
|
: 'chatMessageHint'.tr(
|
||||||
),
|
args: [chatRoom.name!],
|
||||||
counterText:
|
),
|
||||||
messageController.text.length > 1024
|
border: InputBorder.none,
|
||||||
? '${messageController.text.length}/4096'
|
isDense: true,
|
||||||
: null,
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
),
|
horizontal: 12,
|
||||||
maxLines: 3,
|
vertical: 12,
|
||||||
minLines: 1,
|
),
|
||||||
onTapOutside:
|
counterText:
|
||||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
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(
|
IconButton(
|
||||||
|
@@ -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) {
|
||||||
|
@@ -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(
|
||||||
|
Reference in New Issue
Block a user