diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 97735cb..aed2bf6 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -71,6 +71,8 @@ PODS: - GoogleUtilities/UserDefaults (~> 7.8) - nanopb (< 2.30911.0, >= 2.30908.0) - Flutter (1.0.0) + - flutter_keyboard_visibility (0.0.1): + - Flutter - flutter_secure_storage (6.0.0): - Flutter - flutter_webrtc (0.11.3): @@ -136,6 +138,8 @@ PODS: - FlutterMacOS - permission_handler_apple (9.3.0): - Flutter + - pointer_interceptor_ios (0.0.1): + - Flutter - PromisesObjC (2.4.0) - protocol_handler_ios (0.0.1): - Flutter @@ -174,6 +178,7 @@ DEPENDENCIES: - firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) + - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`) - gal (from `.symlinks/plugins/gal/darwin`) @@ -187,6 +192,7 @@ DEPENDENCIES: - pasteboard (from `.symlinks/plugins/pasteboard/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`) - protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`) - screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`) - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) @@ -229,6 +235,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/firebase_messaging/ios" Flutter: :path: Flutter + flutter_keyboard_visibility: + :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" flutter_secure_storage: :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_webrtc: @@ -255,6 +263,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" + pointer_interceptor_ios: + :path: ".symlinks/plugins/pointer_interceptor_ios/ios" protocol_handler_ios: :path: ".symlinks/plugins/protocol_handler_ios/ios" screen_brightness_ios: @@ -288,6 +298,7 @@ SPEC CHECKSUMS: FirebaseInstallations: 913cf60d0400ebd5d6b63a28b290372ab44590dd FirebaseMessaging: 7b5d8033e183ab59eb5b852a53201559e976d366 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1 @@ -304,6 +315,7 @@ SPEC CHECKSUMS: pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + pointer_interceptor_ios: 508241697ff0947f853c061945a8b822463947c1 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990 screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625 diff --git a/lib/models/stickers.dart b/lib/models/stickers.dart index 21c88bb..8976d0c 100644 --- a/lib/models/stickers.dart +++ b/lib/models/stickers.dart @@ -1,5 +1,7 @@ +import 'package:get/get.dart'; import 'package:solian/models/account.dart'; import 'package:solian/models/attachment.dart'; +import 'package:solian/services.dart'; class Sticker { int id; @@ -30,6 +32,14 @@ class Sticker { required this.account, }); + String get textPlaceholder => '${pack?.prefix}$alias'.camelCase!; + String get textWarpedPlaceholder => ':$textPlaceholder:'; + + String get imageUrl => ServiceFinder.buildUrl( + 'files', + '/attachments/$attachmentId', + ); + factory Sticker.fromJson(Map json) => Sticker( id: json['id'], createdAt: DateTime.parse(json['created_at']), diff --git a/lib/providers/stickers.dart b/lib/providers/stickers.dart index 3057cf5..0173f08 100644 --- a/lib/providers/stickers.dart +++ b/lib/providers/stickers.dart @@ -5,7 +5,7 @@ import 'package:solian/services.dart'; class StickerProvider extends GetxController { final RxMap aliasImageMapping = RxMap(); - final RxMap> availableStickers = RxMap(); + final RxList availableStickers = RxList.empty(growable: true); Future refreshAvailableStickers() async { final client = ServiceFinder.configureClient('files'); @@ -20,16 +20,9 @@ class StickerProvider extends GetxController { for (final pack in out) { for (final sticker in (pack.stickers ?? List.empty())) { sticker.pack = pack; - final imageUrl = ServiceFinder.buildUrl( - 'files', - '/attachments/${sticker.attachmentId}', - ); aliasImageMapping['${pack.prefix}${sticker.alias}'.camelCase!] = - imageUrl; - if (availableStickers[pack.prefix] == null) { - availableStickers[pack.prefix] = List.empty(growable: true); - } - availableStickers[pack.prefix]!.add(sticker); + sticker.imageUrl; + availableStickers.add(sticker); } } } diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index 02876e5..d890e46 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -1,17 +1,35 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; import 'package:solian/exts.dart'; import 'package:solian/models/account.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/models/event.dart'; +import 'package:solian/platform.dart'; import 'package:solian/providers/attachment_uploader.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/stickers.dart'; import 'package:solian/widgets/attachments/attachment_editor.dart'; import 'package:solian/widgets/chat/chat_event.dart'; import 'package:badges/badges.dart' as badges; import 'package:uuid/uuid.dart'; +class ChatMessageSuggestion { + final String type; + final Widget leading; + final String display; + final String content; + + ChatMessageSuggestion({ + required this.type, + required this.leading, + required this.display, + required this.content, + }); +} + class ChatMessageInput extends StatefulWidget { final Event? edit; final Event? reply; @@ -37,8 +55,8 @@ class ChatMessageInput extends StatefulWidget { } class _ChatMessageInputState extends State { - final TextEditingController _textController = TextEditingController(); - final FocusNode _focusNode = FocusNode(); + TextEditingController _textController = TextEditingController(); + FocusNode _focusNode = FocusNode(); final List _attachments = List.empty(growable: true); @@ -156,7 +174,7 @@ class _ChatMessageInputState extends State { widget.onSent(message); } - resetInput(); + _resetInput(); if (_editTo != null) { resp = await client.put( @@ -175,7 +193,7 @@ class _ChatMessageInputState extends State { } } - void resetInput() { + void _resetInput() { if (widget.onReset != null) widget.onReset!(); _editTo = null; _replyTo = null; @@ -184,7 +202,7 @@ class _ChatMessageInputState extends State { setState(() {}); } - void syncWidget() { + void _syncWidget() { if (widget.edit != null && widget.edit!.type.startsWith('messages')) { final body = EventMessageBody.fromJson(widget.edit!.body); _editTo = widget.edit!; @@ -197,9 +215,44 @@ class _ChatMessageInputState extends State { setState(() {}); } + Widget _buildSuggestion(ChatMessageSuggestion suggestion) { + return ListTile( + leading: suggestion.leading, + title: Text(suggestion.display), + subtitle: Text(suggestion.content), + ); + } + + void _insertSuggestion(ChatMessageSuggestion suggestion) { + final replaceText = + _textController.text.substring(0, _textController.selection.baseOffset); + var startText = ''; + final afterText = replaceText == _textController.text + ? '' + : _textController.text + .substring(_textController.selection.baseOffset + 1); + var insertText = ''; + + if (suggestion.type == 'emotes') { + insertText = '${suggestion.content} '; + startText = replaceText.replaceFirstMapped( + RegExp(r':(?:([a-z0-9_+-]+)~)?([a-z0-9_+-]+)$'), + (Match m) => insertText, + ); + } + + if (insertText.isNotEmpty && startText.isNotEmpty) { + _textController.text = startText + afterText; + _textController.selection = TextSelection( + baseOffset: startText.length, + extentOffset: startText.length, + ); + } + } + @override void didUpdateWidget(covariant ChatMessageInput oldWidget) { - syncWidget(); + _syncWidget(); super.didUpdateWidget(oldWidget); } @@ -207,7 +260,7 @@ class _ChatMessageInputState extends State { Widget build(BuildContext context) { final notifyBannerActions = [ TextButton( - onPressed: resetInput, + onPressed: _resetInput, child: Text('cancel'.tr), ) ]; @@ -251,21 +304,74 @@ class _ChatMessageInputState extends State { mainAxisAlignment: MainAxisAlignment.start, children: [ Expanded( - child: TextField( + child: TypeAheadField( + direction: VerticalDirection.up, + hideOnEmpty: true, + hideOnLoading: true, controller: _textController, focusNode: _focusNode, - maxLines: null, - autocorrect: true, - keyboardType: TextInputType.text, - decoration: InputDecoration.collapsed( - hintText: widget.placeholder ?? - 'messageInputPlaceholder'.trParams( - {'channel': '#${widget.channel.alias}'}, - ), - ), - onSubmitted: (_) => _sendMessage(), - onTapOutside: (_) => - FocusManager.instance.primaryFocus?.unfocus(), + hideOnSelect: false, + debounceDuration: const Duration(milliseconds: 50), + onSelected: (value) { + _insertSuggestion(value); + }, + itemBuilder: (context, item) { + return _buildSuggestion(item); + }, + builder: (context, controller, focusNode) { + return TextField( + controller: _textController, + focusNode: _focusNode, + maxLines: null, + autocorrect: true, + keyboardType: TextInputType.text, + decoration: InputDecoration.collapsed( + hintText: widget.placeholder ?? + 'messageInputPlaceholder'.trParams({ + 'channel': '#${widget.channel.alias}', + }), + ), + onSubmitted: (_) => _sendMessage(), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), + ); + }, + suggestionsCallback: (search) { + final searchText = _textController.text + .substring(0, _textController.selection.baseOffset); + + final emojiMatch = + RegExp(r':(?:([a-z0-9_+-]+)~)?([a-z0-9_+-]+)$') + .firstMatch(searchText); + if (emojiMatch != null) { + final StickerProvider stickers = Get.find(); + final emoteSearch = emojiMatch[2]!; + return stickers.availableStickers + .where((x) => + x.textWarpedPlaceholder.contains(emoteSearch)) + .map( + (x) => ChatMessageSuggestion( + type: 'emotes', + leading: PlatformInfo.canCacheImage + ? CachedNetworkImage( + imageUrl: x.imageUrl, + width: 28, + height: 28, + ) + : Image.network( + x.imageUrl, + width: 28, + height: 28, + ), + display: x.name, + content: x.textWarpedPlaceholder, + ), + ) + .toList(); + } + + return null; + }, ), ), IconButton( diff --git a/pubspec.lock b/pubspec.lock index a416f2f..06e75bf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -566,6 +566,54 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + flutter_keyboard_visibility: + dependency: transitive + description: + name: flutter_keyboard_visibility + sha256: "98664be7be0e3ffca00de50f7f6a287ab62c763fc8c762e0a21584584a3ff4f8" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_web: + dependency: transitive + description: + name: flutter_keyboard_visibility_web + sha256: d3771a2e752880c79203f8d80658401d0c998e4183edca05a149f5098ce6e3d1 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -675,6 +723,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_typeahead: + dependency: "direct main" + description: + name: flutter_typeahead + sha256: d64712c65db240b1057559b952398ebb6e498077baeebf9b0731dade62438a6d + url: "https://pub.dev" + source: hosted + version: "5.2.0" flutter_web_plugins: dependency: "direct main" description: flutter @@ -1288,6 +1344,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointer_interceptor: + dependency: transitive + description: + name: pointer_interceptor + sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523" + url: "https://pub.dev" + source: hosted + version: "0.10.1+2" + pointer_interceptor_ios: + dependency: transitive + description: + name: pointer_interceptor_ios + sha256: a6906772b3205b42c44614fcea28f818b1e5fdad73a4ca742a7bd49818d9c917 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + pointer_interceptor_platform_interface: + dependency: transitive + description: + name: pointer_interceptor_platform_interface + sha256: "0597b0560e14354baeb23f8375cd612e8bd4841bf8306ecb71fcd0bb78552506" + url: "https://pub.dev" + source: hosted + version: "0.10.0+1" + pointer_interceptor_web: + dependency: transitive + description: + name: pointer_interceptor_web + sha256: "7a7087782110f8c1827170660b09f8aa893e0e9a61431dbbe2ac3fc482e8c044" + url: "https://pub.dev" + source: hosted + version: "0.10.2+1" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2e29217..d88e6db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,7 @@ dependencies: avatar_stack: ^1.2.0 async: ^2.11.0 field_suggestion: ^0.2.5 + flutter_typeahead: ^5.2.0 dev_dependencies: flutter_test: