fluffychat/lib/pages/chat/input_bar.dart

467 lines
16 KiB
Dart
Raw Normal View History

2021-10-26 18:50:34 +02:00
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
2021-10-26 18:50:34 +02:00
import 'package:emojis/emoji.dart';
2021-10-26 18:50:34 +02:00
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:matrix/matrix.dart';
import 'package:slugify/slugify.dart';
2021-10-26 18:50:34 +02:00
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
2021-11-09 21:32:16 +01:00
import '../../widgets/avatar.dart';
import '../../widgets/matrix.dart';
import 'command_hints.dart';
2020-05-15 15:28:23 +02:00
class InputBar extends StatelessWidget {
final Room room;
2022-01-29 12:35:03 +01:00
final int? minLines;
final int? maxLines;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final ValueChanged<String>? onSubmitted;
final FocusNode? focusNode;
final TextEditingController? controller;
final InputDecoration? decoration;
final ValueChanged<String>? onChanged;
final bool? autofocus;
2022-02-17 09:18:50 +01:00
final bool readOnly;
2020-05-15 15:28:23 +02:00
2021-10-14 18:09:30 +02:00
const InputBar({
2022-01-29 12:35:03 +01:00
required this.room,
2020-05-15 15:28:23 +02:00
this.minLines,
this.maxLines,
this.keyboardType,
this.onSubmitted,
this.focusNode,
this.controller,
this.decoration,
this.onChanged,
2020-10-04 17:24:05 +02:00
this.autofocus,
2021-08-28 10:35:11 +02:00
this.textInputAction,
2022-02-17 09:18:50 +01:00
this.readOnly = false,
2022-01-29 12:35:03 +01:00
Key? key,
2021-10-14 18:09:30 +02:00
}) : super(key: key);
2020-05-15 15:28:23 +02:00
2022-01-29 12:35:03 +01:00
List<Map<String, String?>> getSuggestions(String text) {
if (controller!.selection.baseOffset !=
controller!.selection.extentOffset ||
controller!.selection.baseOffset < 0) {
2020-05-15 15:28:23 +02:00
return []; // no entries if there is selected text
}
2020-05-15 21:50:44 +02:00
final searchText =
2022-01-29 12:35:03 +01:00
controller!.text.substring(0, controller!.selection.baseOffset);
final List<Map<String, String?>> ret = <Map<String, String?>>[];
const maxResults = 30;
final commandMatch = RegExp(r'^/(\w*)$').firstMatch(searchText);
if (commandMatch != null) {
2022-01-29 12:35:03 +01:00
final commandSearch = commandMatch[1]!.toLowerCase();
for (final command in room.client.commands.keys) {
if (command.contains(commandSearch)) {
ret.add({
'type': 'command',
'name': command,
});
}
if (ret.length > maxResults) return ret;
}
}
2020-05-15 21:50:44 +02:00
final emojiMatch =
RegExp(r'(?:\s|^):(?:([-\w]+)~)?([-\w]+)$').firstMatch(searchText);
2020-05-15 15:28:23 +02:00
if (emojiMatch != null) {
final packSearch = emojiMatch[1];
2022-01-29 12:35:03 +01:00
final emoteSearch = emojiMatch[2]!.toLowerCase();
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
2020-05-15 15:28:23 +02:00
if (packSearch == null || packSearch.isEmpty) {
for (final pack in emotePacks.entries) {
for (final emote in pack.value.images.entries) {
2020-05-15 15:28:23 +02:00
if (emote.key.toLowerCase().contains(emoteSearch)) {
ret.add({
'type': 'emote',
'name': emote.key,
'pack': pack.key,
'pack_avatar_url': pack.value.pack.avatarUrl?.toString(),
'pack_display_name': pack.value.pack.displayName ?? pack.key,
'mxc': emote.value.url.toString(),
2020-05-15 15:28:23 +02:00
});
}
if (ret.length > maxResults) {
2020-05-15 15:28:23 +02:00
break;
}
}
if (ret.length > maxResults) {
2020-05-15 15:28:23 +02:00
break;
}
}
} else if (emotePacks[packSearch] != null) {
2022-01-29 12:35:03 +01:00
for (final emote in emotePacks[packSearch]!.images.entries) {
2020-05-15 15:28:23 +02:00
if (emote.key.toLowerCase().contains(emoteSearch)) {
ret.add({
'type': 'emote',
'name': emote.key,
'pack': packSearch,
'pack_avatar_url':
2022-01-29 12:35:03 +01:00
emotePacks[packSearch]!.pack.avatarUrl?.toString(),
'pack_display_name':
2022-01-29 12:35:03 +01:00
emotePacks[packSearch]!.pack.displayName ?? packSearch,
'mxc': emote.value.url.toString(),
2020-05-15 15:28:23 +02:00
});
}
if (ret.length > maxResults) {
2020-05-15 15:28:23 +02:00
break;
}
}
}
// aside of emote packs, also propose normal (tm) unicode emojis
final matchingUnicodeEmojis = Emoji.all()
.where((element) => [element.name, ...element.keywords]
.any((element) => element.toLowerCase().contains(emoteSearch)))
.toList();
// sort by the index of the search term in the name in order to have
// best matches first
// (thanks for the hint by github.com/nextcloud/circles devs)
matchingUnicodeEmojis.sort((a, b) {
final indexA = a.name.indexOf(emoteSearch);
final indexB = b.name.indexOf(emoteSearch);
if (indexA == -1 || indexB == -1) {
if (indexA == indexB) return 0;
if (indexA == -1) {
return 1;
} else {
return 0;
}
}
return indexA.compareTo(indexB);
});
for (final emoji in matchingUnicodeEmojis) {
ret.add({
'type': 'emoji',
'emoji': emoji.char,
// don't include sub-group names, splitting at `:` hence
'label': '${emoji.char} - ${emoji.name.split(':').first}',
'current_word': ':$emoteSearch',
});
if (ret.length > maxResults) {
break;
}
}
2020-05-15 15:28:23 +02:00
}
final userMatch = RegExp(r'(?:\s|^)@([-\w]+)$').firstMatch(searchText);
if (userMatch != null) {
2022-01-29 12:35:03 +01:00
final userSearch = userMatch[1]!.toLowerCase();
for (final user in room.getParticipants()) {
2020-05-22 12:21:16 +02:00
if ((user.displayName != null &&
2022-01-29 12:35:03 +01:00
(user.displayName!.toLowerCase().contains(userSearch) ||
slugify(user.displayName!.toLowerCase())
.contains(userSearch))) ||
2020-05-22 12:21:16 +02:00
user.id.split(':')[0].toLowerCase().contains(userSearch)) {
ret.add({
'type': 'user',
'mxid': user.id,
2021-07-20 17:54:48 +02:00
'mention': user.mention,
'displayname': user.displayName,
'avatar_url': user.avatarUrl?.toString(),
});
}
if (ret.length > maxResults) {
break;
}
}
}
final roomMatch = RegExp(r'(?:\s|^)#([-\w]+)$').firstMatch(searchText);
if (roomMatch != null) {
2022-01-29 12:35:03 +01:00
final roomSearch = roomMatch[1]!.toLowerCase();
for (final r in room.client.rooms) {
2020-11-21 15:16:32 +01:00
if (r.getState(EventTypes.RoomTombstone) != null) {
continue; // we don't care about tombstoned rooms
}
final state = r.getState(EventTypes.RoomCanonicalAlias);
if ((state != null &&
((state.content['alias'] is String &&
state.content['alias']
.split(':')[0]
.toLowerCase()
.contains(roomSearch)) ||
(state.content['alt_aliases'] is List &&
state.content['alt_aliases'].any((l) =>
l is String &&
l
.split(':')[0]
.toLowerCase()
.contains(roomSearch))))) ||
2022-01-29 12:35:03 +01:00
(r.name.toLowerCase().contains(roomSearch))) {
ret.add({
'type': 'room',
2022-01-29 12:35:03 +01:00
'mxid': (r.canonicalAlias.isNotEmpty) ? r.canonicalAlias : r.id,
'displayname': r.displayname,
'avatar_url': r.avatar?.toString(),
});
}
if (ret.length > maxResults) {
break;
}
}
}
2020-05-15 15:28:23 +02:00
return ret;
}
2021-01-20 20:27:09 +01:00
Widget buildSuggestion(
BuildContext context,
2022-01-29 12:35:03 +01:00
Map<String, String?> suggestion,
Client? client,
2021-01-20 20:27:09 +01:00
) {
const size = 30.0;
const padding = EdgeInsets.all(4.0);
if (suggestion['type'] == 'command') {
2022-01-29 12:35:03 +01:00
final command = suggestion['name']!;
final hint = commandHint(L10n.of(context)!, command);
return Tooltip(
message: hint,
waitDuration: const Duration(days: 1), // don't show on hover
child: Container(
padding: padding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('/' + command,
style: const TextStyle(fontFamily: 'monospace')),
Text(
hint,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.caption,
),
],
),
),
);
}
if (suggestion['type'] == 'emoji') {
final label = suggestion['label']!;
return Tooltip(
message: label,
waitDuration: const Duration(days: 1), // don't show on hover
child: Container(
padding: padding,
child: Text(label, style: const TextStyle(fontFamily: 'monospace')),
),
);
}
2020-05-15 15:28:23 +02:00
if (suggestion['type'] == 'emote') {
return Container(
padding: padding,
2020-05-15 15:28:23 +02:00
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
MxcImage(
uri: suggestion['mxc'] is String
? Uri.parse(suggestion['mxc'] ?? '')
: null,
width: size,
height: size,
),
2021-10-14 18:09:30 +02:00
const SizedBox(width: 6),
2022-01-29 12:35:03 +01:00
Text(suggestion['name']!),
2020-05-15 15:28:23 +02:00
Expanded(
child: Align(
alignment: Alignment.centerRight,
child: Opacity(
opacity: suggestion['pack_avatar_url'] != null ? 0.8 : 0.5,
child: suggestion['pack_avatar_url'] != null
? Avatar(
2021-11-20 10:42:23 +01:00
mxContent: Uri.tryParse(
suggestion.tryGet<String>('pack_avatar_url') ??
''),
name: suggestion.tryGet<String>('pack_display_name'),
size: size * 0.9,
client: client,
)
2022-01-29 12:35:03 +01:00
: Text(suggestion['pack_display_name']!),
2020-05-15 15:28:23 +02:00
),
),
),
],
),
);
}
if (suggestion['type'] == 'user' || suggestion['type'] == 'room') {
final url = Uri.parse(suggestion['avatar_url'] ?? '');
return Container(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
2021-01-20 20:27:09 +01:00
Avatar(
2021-11-20 10:42:23 +01:00
mxContent: url,
name: suggestion.tryGet<String>('displayname') ??
suggestion.tryGet<String>('mxid'),
2021-01-20 20:27:09 +01:00
size: size,
client: client,
),
2021-10-14 18:09:30 +02:00
const SizedBox(width: 6),
2022-01-29 12:35:03 +01:00
Text(suggestion['displayname'] ?? suggestion['mxid']!),
],
),
);
}
2020-05-15 15:28:23 +02:00
return Container();
}
2022-01-29 12:35:03 +01:00
void insertSuggestion(_, Map<String, String?> suggestion) {
final replaceText =
2022-01-29 12:35:03 +01:00
controller!.text.substring(0, controller!.selection.baseOffset);
var startText = '';
2022-01-29 12:35:03 +01:00
final afterText = replaceText == controller!.text
2020-05-22 12:21:16 +02:00
? ''
2022-01-29 12:35:03 +01:00
: controller!.text.substring(controller!.selection.baseOffset + 1);
var insertText = '';
if (suggestion['type'] == 'command') {
2022-01-29 12:35:03 +01:00
insertText = suggestion['name']! + ' ';
startText = replaceText.replaceAllMapped(
RegExp(r'^(/\w*)$'),
(Match m) => '/' + insertText,
);
}
if (suggestion['type'] == 'emoji') {
insertText = suggestion['emoji']! + ' ';
startText = replaceText.replaceAllMapped(
suggestion['current_word']!,
(Match m) => insertText,
);
}
2020-05-15 15:28:23 +02:00
if (suggestion['type'] == 'emote') {
var isUnique = true;
final insertEmote = suggestion['name'];
final insertPack = suggestion['pack'];
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
2020-05-15 15:28:23 +02:00
for (final pack in emotePacks.entries) {
if (pack.key == insertPack) {
continue;
}
for (final emote in pack.value.images.entries) {
2020-05-15 15:28:23 +02:00
if (emote.key == insertEmote) {
isUnique = false;
break;
}
}
if (!isUnique) {
break;
}
}
2022-01-29 12:35:03 +01:00
insertText = ':${isUnique ? '' : insertPack! + '~'}$insertEmote: ';
startText = replaceText.replaceAllMapped(
2020-05-15 15:28:23 +02:00
RegExp(r'(\s|^)(:(?:[-\w]+~)?[-\w]+)$'),
2021-03-04 12:28:06 +01:00
(Match m) => '${m[1]}$insertText',
);
}
if (suggestion['type'] == 'user') {
2022-01-29 12:35:03 +01:00
insertText = suggestion['mention']! + ' ';
startText = replaceText.replaceAllMapped(
RegExp(r'(\s|^)(@[-\w]+)$'),
2021-03-04 12:28:06 +01:00
(Match m) => '${m[1]}$insertText',
);
}
if (suggestion['type'] == 'room') {
2022-01-29 12:35:03 +01:00
insertText = suggestion['mxid']! + ' ';
startText = replaceText.replaceAllMapped(
RegExp(r'(\s|^)(#[-\w]+)$'),
2021-03-04 12:28:06 +01:00
(Match m) => '${m[1]}$insertText',
2020-05-15 15:28:23 +02:00
);
}
if (insertText.isNotEmpty && startText.isNotEmpty) {
2022-01-29 12:35:03 +01:00
controller!.text = startText + afterText;
controller!.selection = TextSelection(
2020-10-03 12:31:29 +02:00
baseOffset: startText.length,
extentOffset: startText.length,
);
2020-05-15 15:28:23 +02:00
}
}
@override
Widget build(BuildContext context) {
2021-08-28 10:35:11 +02:00
final useShortCuts = (PlatformInfos.isWeb ||
PlatformInfos.isDesktop ||
AppConfig.sendOnEnter);
return Shortcuts(
shortcuts: !useShortCuts
? {}
: {
LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.enter):
NewLineIntent(),
LogicalKeySet(LogicalKeyboardKey.enter): SubmitLineIntent(),
},
child: Actions(
actions: !useShortCuts
? {}
: {
NewLineIntent: CallbackAction(onInvoke: (i) {
2022-01-29 12:35:03 +01:00
final val = controller!.value;
final selection = val.selection.start;
final messageWithoutNewLine =
2022-01-29 12:35:03 +01:00
controller!.text.substring(0, val.selection.start) +
'\n' +
2022-01-29 12:35:03 +01:00
controller!.text.substring(val.selection.end);
controller!.value = TextEditingValue(
text: messageWithoutNewLine,
selection: TextSelection.fromPosition(
TextPosition(offset: selection + 1),
),
);
return null;
}),
SubmitLineIntent: CallbackAction(onInvoke: (i) {
2022-01-29 12:35:03 +01:00
onSubmitted!(controller!.text);
return null;
}),
},
2022-01-29 12:35:03 +01:00
child: TypeAheadField<Map<String, String?>>(
direction: AxisDirection.up,
hideOnEmpty: true,
hideOnLoading: true,
keepSuggestionsOnSuggestionSelected: true,
debounceDuration: const Duration(milliseconds: 50),
// show suggestions after 50ms idle time (default is 300)
textFieldConfiguration: TextFieldConfiguration(
minLines: minLines,
maxLines: maxLines,
2022-01-29 12:35:03 +01:00
keyboardType: keyboardType!,
2021-08-28 10:35:11 +02:00
textInputAction: textInputAction,
2022-01-29 12:35:03 +01:00
autofocus: autofocus!,
onSubmitted: (text) {
// fix for library for now
// it sets the types for the callback incorrectly
2022-01-29 12:35:03 +01:00
onSubmitted!(text);
},
controller: controller,
2022-01-29 12:35:03 +01:00
decoration: decoration!,
focusNode: focusNode,
onChanged: (text) {
// fix for the library for now
// it sets the types for the callback incorrectly
2022-01-29 12:35:03 +01:00
onChanged!(text);
},
textCapitalization: TextCapitalization.sentences,
),
suggestionsCallback: getSuggestions,
itemBuilder: (c, s) =>
buildSuggestion(c, s, Matrix.of(context).client),
2022-01-29 12:35:03 +01:00
onSuggestionSelected: (Map<String, String?> suggestion) =>
insertSuggestion(context, suggestion),
2022-01-29 12:35:03 +01:00
errorBuilder: (BuildContext context, Object? error) => Container(),
loadingBuilder: (BuildContext context) => Container(),
// fix loading briefly flickering a dark box
noItemsFoundBuilder: (BuildContext context) =>
Container(), // fix loading briefly showing no suggestions
),
2020-05-15 15:28:23 +02:00
),
);
}
}
class NewLineIntent extends Intent {}
class SubmitLineIntent extends Intent {}