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

@@ -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(

View File

@@ -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) {

View File

@@ -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(