✨ Auto complete, better metion parser, sticker placeholder v2
This commit is contained in:
@@ -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<AutocompleteSuggestion>(
|
||||
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(
|
||||
|
@@ -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) {
|
||||
|
@@ -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(
|
||||
|
Reference in New Issue
Block a user