fluffychat/lib/pages/chat/input_bar.dart
2021-11-20 10:42:23 +01:00

449 lines
15 KiB
Dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:cached_network_image/cached_network_image.dart';
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';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/utils/platform_infos.dart';
import '../../widgets/avatar.dart';
import '../../widgets/matrix.dart';
class InputBar extends StatelessWidget {
final Room room;
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;
const InputBar({
this.room,
this.minLines,
this.maxLines,
this.keyboardType,
this.onSubmitted,
this.focusNode,
this.controller,
this.decoration,
this.onChanged,
this.autofocus,
this.textInputAction,
Key key,
}) : super(key: key);
List<Map<String, String>> getSuggestions(String text) {
if (controller.selection.baseOffset != controller.selection.extentOffset ||
controller.selection.baseOffset < 0) {
return []; // no entries if there is selected text
}
final searchText =
controller.text.substring(0, controller.selection.baseOffset);
final ret = <Map<String, String>>[];
const maxResults = 30;
final commandMatch = RegExp(r'^\/([\w]*)$').firstMatch(searchText);
if (commandMatch != null) {
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;
}
}
final emojiMatch =
RegExp(r'(?:\s|^):(?:([-\w]+)~)?([-\w]+)$').firstMatch(searchText);
if (emojiMatch != null) {
final packSearch = emojiMatch[1];
final emoteSearch = emojiMatch[2].toLowerCase();
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
if (packSearch == null || packSearch.isEmpty) {
for (final pack in emotePacks.entries) {
for (final emote in pack.value.images.entries) {
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(),
});
}
if (ret.length > maxResults) {
break;
}
}
if (ret.length > maxResults) {
break;
}
}
} else if (emotePacks[packSearch] != null) {
for (final emote in emotePacks[packSearch].images.entries) {
if (emote.key.toLowerCase().contains(emoteSearch)) {
ret.add({
'type': 'emote',
'name': emote.key,
'pack': packSearch,
'pack_avatar_url':
emotePacks[packSearch].pack.avatarUrl?.toString(),
'pack_display_name':
emotePacks[packSearch].pack.displayName ?? packSearch,
'mxc': emote.value.url.toString(),
});
}
if (ret.length > maxResults) {
break;
}
}
}
}
final userMatch = RegExp(r'(?:\s|^)@([-\w]+)$').firstMatch(searchText);
if (userMatch != null) {
final userSearch = userMatch[1].toLowerCase();
for (final user in room.getParticipants()) {
if ((user.displayName != null &&
(user.displayName.toLowerCase().contains(userSearch) ||
slugify(user.displayName.toLowerCase())
.contains(userSearch))) ||
user.id.split(':')[0].toLowerCase().contains(userSearch)) {
ret.add({
'type': 'user',
'mxid': user.id,
'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) {
final roomSearch = roomMatch[1].toLowerCase();
for (final r in room.client.rooms) {
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))))) ||
(r.name != null && r.name.toLowerCase().contains(roomSearch))) {
ret.add({
'type': 'room',
'mxid': (r.canonicalAlias != null && r.canonicalAlias.isNotEmpty)
? r.canonicalAlias
: r.id,
'displayname': r.displayname,
'avatar_url': r.avatar?.toString(),
});
}
if (ret.length > maxResults) {
break;
}
}
}
return ret;
}
String _commandHint(L10n l10n, String command) {
switch (command) {
case 'send':
return l10n.commandHintSend;
case 'me':
return l10n.commandHintMe;
case 'plain':
return l10n.commandHintPlain;
case 'html':
return l10n.commandHintHtml;
case 'react':
return l10n.commandHintReact;
case 'join':
return l10n.commandHintJoin;
case 'leave':
return l10n.commandHintLeave;
case 'op':
return l10n.commandHintOp;
case 'kick':
return l10n.commandHintKick;
case 'ban':
return l10n.commandHintBan;
case 'unban':
return l10n.commandHintUnBan;
case 'invite':
return l10n.commandHintInvite;
case 'myroomnick':
return l10n.commandHintMyRoomNick;
case 'myroomavatar':
return l10n.commandHintMyRoomAvatar;
default:
return '';
}
}
Widget buildSuggestion(
BuildContext context,
Map<String, String> suggestion,
Client client,
) {
const size = 30.0;
const padding = EdgeInsets.all(4.0);
if (suggestion['type'] == 'command') {
final command = suggestion['name'];
return Container(
padding: padding,
height: size + padding.bottom + padding.top,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('/' + command,
style: const TextStyle(fontFamily: 'monospace')),
Text(_commandHint(L10n.of(context), command),
style: Theme.of(context).textTheme.caption),
],
),
);
}
if (suggestion['type'] == 'emote') {
final ratio = MediaQuery.of(context).devicePixelRatio;
final url = Uri.parse(suggestion['mxc'] ?? '')?.getThumbnail(
room.client,
width: size * ratio,
height: size * ratio,
method: ThumbnailMethod.scale,
animated: true,
);
return Container(
padding: padding,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
CachedNetworkImage(
imageUrl: url.toString(),
width: size,
height: size,
),
const SizedBox(width: 6),
Text(suggestion['name']),
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(
mxContent: Uri.tryParse(
suggestion.tryGet<String>('pack_avatar_url') ??
''),
name: suggestion.tryGet<String>('pack_display_name'),
size: size * 0.9,
client: client,
)
: Text(suggestion['pack_display_name']),
),
),
),
],
),
);
}
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>[
Avatar(
mxContent: url,
name: suggestion.tryGet<String>('displayname') ??
suggestion.tryGet<String>('mxid'),
size: size,
client: client,
),
const SizedBox(width: 6),
Text(suggestion['displayname'] ?? suggestion['mxid']),
],
),
);
}
return Container();
}
void insertSuggestion(_, Map<String, String> suggestion) {
final replaceText =
controller.text.substring(0, controller.selection.baseOffset);
var startText = '';
final afterText = replaceText == controller.text
? ''
: controller.text.substring(controller.selection.baseOffset + 1);
var insertText = '';
if (suggestion['type'] == 'command') {
insertText = suggestion['name'] + ' ';
startText = replaceText.replaceAllMapped(
RegExp(r'^(\/[\w]*)$'),
(Match m) => '/' + insertText,
);
}
if (suggestion['type'] == 'emote') {
var isUnique = true;
final insertEmote = suggestion['name'];
final insertPack = suggestion['pack'];
final emotePacks = room.getImagePacks(ImagePackUsage.emoticon);
for (final pack in emotePacks.entries) {
if (pack.key == insertPack) {
continue;
}
for (final emote in pack.value.images.entries) {
if (emote.key == insertEmote) {
isUnique = false;
break;
}
}
if (!isUnique) {
break;
}
}
insertText = ':${isUnique ? '' : insertPack + '~'}$insertEmote: ';
startText = replaceText.replaceAllMapped(
RegExp(r'(\s|^)(:(?:[-\w]+~)?[-\w]+)$'),
(Match m) => '${m[1]}$insertText',
);
}
if (suggestion['type'] == 'user') {
insertText = suggestion['mention'] + ' ';
startText = replaceText.replaceAllMapped(
RegExp(r'(\s|^)(@[-\w]+)$'),
(Match m) => '${m[1]}$insertText',
);
}
if (suggestion['type'] == 'room') {
insertText = suggestion['mxid'] + ' ';
startText = replaceText.replaceAllMapped(
RegExp(r'(\s|^)(#[-\w]+)$'),
(Match m) => '${m[1]}$insertText',
);
}
if (insertText.isNotEmpty && startText.isNotEmpty) {
controller.text = startText + afterText;
controller.selection = TextSelection(
baseOffset: startText.length,
extentOffset: startText.length,
);
}
}
@override
Widget build(BuildContext context) {
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) {
final val = controller.value;
final selection = val.selection.start;
final messageWithoutNewLine =
controller.text.substring(0, val.selection.start) +
'\n' +
controller.text.substring(val.selection.end);
controller.value = TextEditingValue(
text: messageWithoutNewLine,
selection: TextSelection.fromPosition(
TextPosition(offset: selection + 1),
),
);
return null;
}),
SubmitLineIntent: CallbackAction(onInvoke: (i) {
onSubmitted(controller.text);
return null;
}),
},
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,
keyboardType: keyboardType,
textInputAction: textInputAction,
autofocus: autofocus,
onSubmitted: (text) {
// fix for library for now
// it sets the types for the callback incorrectly
onSubmitted(text);
},
//focusNode: focusNode,
controller: controller,
decoration: decoration,
focusNode: focusNode,
onChanged: (text) {
// fix for the library for now
// it sets the types for the callback incorrectly
onChanged(text);
},
textCapitalization: TextCapitalization.sentences,
),
suggestionsCallback: getSuggestions,
itemBuilder: (c, s) =>
buildSuggestion(c, s, Matrix.of(context).client),
onSuggestionSelected: (Map<String, String> suggestion) =>
insertSuggestion(context, suggestion),
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
),
),
);
}
}
class NewLineIntent extends Intent {}
class SubmitLineIntent extends Intent {}