diff --git a/lib/store/user/selectors.dart b/lib/store/user/selectors.dart index 10a82c391..90c659a3d 100644 --- a/lib/store/user/selectors.dart +++ b/lib/store/user/selectors.dart @@ -35,9 +35,12 @@ List selectKnownUsers(AppState state) { return List.from(latestUsers); } -List roomUsers(AppState state, String? roomId) { +List roomUsers(AppState state, String? roomId) { final room = state.roomStore.rooms[roomId!] ?? Room(id: roomId); - return room.userIds.map((userId) => state.userStore.users[userId]).toList(); + return List.from(room.userIds + .map((userId) => state.userStore.users[userId]) + .where((userValid) => userValid != null) + .toList()); } Map messageUsers({required AppState state, String? roomId}) { @@ -89,7 +92,6 @@ List searchUsersLocal( } return List.from(users.where( - (user) => - (user!.displayName ?? '').contains(searchText) || (user.userId ?? '').contains(searchText), + (user) => (user!.displayName ?? '').contains(searchText) || (user.userId ?? '').contains(searchText), )); } diff --git a/lib/views/home/chat/widgets/chat-input.dart b/lib/views/home/chat/widgets/chat-input.dart index 9d3582585..3024dce22 100644 --- a/lib/views/home/chat/widgets/chat-input.dart +++ b/lib/views/home/chat/widgets/chat-input.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_redux/flutter_redux.dart'; import 'package:flutter_svg/svg.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:redux/redux.dart'; import 'package:syphon/global/algos.dart'; import 'package:syphon/global/assets.dart'; @@ -21,6 +22,9 @@ import 'package:syphon/store/index.dart'; import 'package:syphon/store/rooms/room/model.dart'; import 'package:syphon/store/rooms/selectors.dart'; import 'package:syphon/store/settings/theme-settings/selectors.dart'; +import 'package:syphon/store/user/model.dart'; +import 'package:syphon/store/user/selectors.dart'; +import 'package:syphon/views/home/chat/widgets/chat-mention.dart'; import 'package:syphon/views/widgets/buttons/button-text.dart'; import 'package:syphon/views/widgets/containers/media-card.dart'; import 'package:syphon/views/widgets/lists/list-local-images.dart'; @@ -81,6 +85,7 @@ class ChatInputState extends State { bool sendable = false; bool showAttachments = false; + List mentioned = []; double keyboardHeight = 0; @@ -136,11 +141,40 @@ class ChatInputState extends State { } } - onUpdate(String text, {_Props? props}) { + onUpdateInputText(String text, {_Props? props}) { setState(() { sendable = text.trim().isNotEmpty; }); + final cursorPosition = widget.controller.selection.baseOffset; + final cursorTextPrior = text.substring(0, cursorPosition); + + final RegExp mentionExpanded = RegExp( + r'\B@\w+$', // match all text after an @ symbol but before the end of the line / space + caseSensitive: false, + multiLine: false, + ); + + final match = mentionExpanded.firstMatch(cursorTextPrior); + + // find matches for mentions + if (match != null) { + final mention = cursorTextPrior.substring(match.start, match.end); + final mentionName = mention.substring(1, mention.length); + + final usersMentioned = (props?.users ?? []).where((user) { + return user.displayName!.toLowerCase().contains(mentionName.toLowerCase()); + }).toList(); + + setState(() { + mentioned = usersMentioned; + }); + } else { + setState(() { + mentioned = []; + }); + } + // start an interval for updating typing status if (widget.focusNode.hasFocus && typingNotifier == null) { props!.onSendTyping(typing: true, roomId: props.room.id); @@ -172,6 +206,7 @@ class ChatInputState extends State { }); }); } + widget.onUpdateMessage?.call(text); } @@ -229,6 +264,34 @@ class ChatInputState extends State { onToggleMediaOptions(); } + showDialogForPhotoPermission(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + Strings.titleDialogPhotoPermission, + style: TextStyle(fontWeight: FontWeight.w600), + ), + content: Text(Strings.contentPhotoPermission), + actions: [ + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + }, + child: Text(Strings.buttonCancel), + ), + TextButton( + onPressed: () { + Navigator.of(ctx).pop(); + openAppSettings(); + }, + child: Text(Strings.buttonNext), + ), + ], + ), + ); + } + onAddFile() async { final pickerResult = await FilePicker.platform.pickFiles( type: FileType.any, @@ -258,13 +321,14 @@ class ChatInputState extends State { final double maxInputHeight = replying ? height * 0.45 : height * 0.65; final double maxMediaHeight = keyboardHeight > 0 ? keyboardHeight : height * 0.38; - final imageHeight = - keyboardHeight > 0 ? maxMediaHeight * 0.65 : imageWidth; // 2 images in view + final imageHeight = keyboardHeight > 0 ? maxMediaHeight * 0.65 : imageWidth; // 2 images in view final isSendable = (sendable && !widget.sending) || // account for if editing widget.editing && (widget.editorController?.text.isNotEmpty ?? false); + final bool showMention = mentioned.isNotEmpty; + Color sendButtonColor = const Color(AppColors.blueBubbly); if (widget.mediumType == MediumType.plaintext) { @@ -373,7 +437,20 @@ class ChatInputState extends State { } return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + //////// MENTION DIALOG //////// + Container( + padding: EdgeInsets.symmetric(horizontal: 15, vertical: 5), + child: MentionDialog( + visible: showMention, + width: MediaQuery.of(context).size.width - + Dimensions.buttonSendSize * 2.1, // HACK: fix the width of the dialog + users: mentioned, + controller: widget.controller, + onComplete: () => onUpdateInputText(widget.controller.text, props: props), + )), Visibility( visible: replying, maintainSize: false, @@ -450,101 +527,104 @@ class ChatInputState extends State { ), ), //////// TEXT FIELD //////// - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - constraints: BoxConstraints( - maxHeight: maxInputHeight, - maxWidth: messageInputWidth, - ), - child: Stack( - children: [ - Visibility( - visible: widget.editing, - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ButtonText( - text: Strings.buttonSaveMessageEdit, - size: 18.0, - disabled: widget.sending || !isSendable, - onPressed: () => onSubmit(), - ), - ], - ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + constraints: BoxConstraints( + maxHeight: maxInputHeight, + maxWidth: messageInputWidth, ), - Visibility( - visible: !widget.editing, - child: TextField( - maxLines: null, - autocorrect: props.autocorrectEnabled, - enableSuggestions: props.suggestionsEnabled, - textCapitalization: props.textCapitalization, - keyboardType: TextInputType.multiline, - textInputAction: - widget.enterSend ? TextInputAction.send : TextInputAction.newline, - cursorColor: props.inputCursorColor, - focusNode: widget.focusNode, - controller: widget.controller, - onChanged: (text) => onUpdate(text, props: props), - onSubmitted: !isSendable ? null : (text) => onSubmit(), - style: TextStyle( - height: 1.5, - color: props.inputTextColor, + child: Stack( + children: [ + Visibility( + visible: widget.editing, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ButtonText( + text: Strings.buttonSaveMessageEdit, + size: 18.0, + disabled: widget.sending || !isSendable, + onPressed: () => onSubmit(), + ), + ], + ), ), - decoration: InputDecoration( - filled: true, - hintText: hintText, - suffixIcon: Visibility( - visible: isSendable, - child: IconButton( - color: Theme.of(context).iconTheme.color, - onPressed: () => onToggleMediaOptions(), - icon: Icon( - Icons.add, - size: Dimensions.iconSizeLarge, + Visibility( + visible: !widget.editing, + child: TextField( + maxLines: null, + autocorrect: props.autocorrectEnabled, + enableSuggestions: props.suggestionsEnabled, + textCapitalization: props.textCapitalization, + keyboardType: TextInputType.multiline, + textInputAction: + widget.enterSend ? TextInputAction.send : TextInputAction.newline, + cursorColor: props.inputCursorColor, + focusNode: widget.focusNode, + controller: widget.controller, + onChanged: (text) => onUpdateInputText(text, props: props), + onSubmitted: !isSendable ? null : (text) => onSubmit(), + style: TextStyle( + height: 1.5, + color: props.inputTextColor, + ), + decoration: InputDecoration( + filled: true, + hintText: hintText, + suffixIcon: Visibility( + visible: isSendable, + child: IconButton( + color: Theme.of(context).iconTheme.color, + onPressed: () => onToggleMediaOptions(), + icon: Icon( + Icons.add, + size: Dimensions.iconSizeLarge, + ), + ), ), + fillColor: props.inputColorBackground, + contentPadding: Dimensions.inputContentPadding, + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + width: 1, + ), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(!replying ? DEFAULT_BORDER_RADIUS : 0), + topRight: Radius.circular(!replying ? DEFAULT_BORDER_RADIUS : 0), + bottomLeft: Radius.circular(DEFAULT_BORDER_RADIUS), + bottomRight: Radius.circular(DEFAULT_BORDER_RADIUS), + )), + border: OutlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context).colorScheme.secondary, + width: 1, + ), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(!replying ? DEFAULT_BORDER_RADIUS : 0), + topRight: Radius.circular(!replying ? DEFAULT_BORDER_RADIUS : 0), + bottomLeft: Radius.circular(DEFAULT_BORDER_RADIUS), + bottomRight: Radius.circular(DEFAULT_BORDER_RADIUS), + )), ), ), - fillColor: props.inputColorBackground, - contentPadding: Dimensions.inputContentPadding, - focusedBorder: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.secondary, - width: 1, - ), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(!replying ? DEFAULT_BORDER_RADIUS : 0), - topRight: - Radius.circular(!replying ? DEFAULT_BORDER_RADIUS : 0), - bottomLeft: Radius.circular(DEFAULT_BORDER_RADIUS), - bottomRight: Radius.circular(DEFAULT_BORDER_RADIUS), - )), - border: OutlineInputBorder( - borderSide: BorderSide( - color: Theme.of(context).colorScheme.secondary, - width: 1, - ), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(!replying ? DEFAULT_BORDER_RADIUS : 0), - topRight: - Radius.circular(!replying ? DEFAULT_BORDER_RADIUS : 0), - bottomLeft: Radius.circular(DEFAULT_BORDER_RADIUS), - bottomRight: Radius.circular(DEFAULT_BORDER_RADIUS), - )), ), - ), + ], ), - ], - ), - ), - Container( - width: Dimensions.buttonSendSize, - padding: EdgeInsets.symmetric(vertical: 4), - child: sendButton, + ), + Container( + width: Dimensions.buttonSendSize, + padding: EdgeInsets.symmetric(vertical: 4), + child: sendButton, + ), + ], ), ], ), @@ -586,7 +666,15 @@ class ChatInputState extends State { child: MediaCard( text: Strings.buttonGallery, icon: Icons.photo, - onPress: () => onAddPhoto(), + onPress: () async { + const photosPermission = Permission.photos; + final status = await photosPermission.status; + if (!status.isGranted) { + showDialogForPhotoPermission(context); + } else { + onAddPhoto(); + } + }, ), ), Padding( @@ -633,20 +721,21 @@ class _Props extends Equatable { final bool autocorrectEnabled; final bool suggestionsEnabled; final TextCapitalization textCapitalization; + final List users; final Function onSendTyping; - const _Props({ - required this.room, - required this.inputTextColor, - required this.inputCursorColor, - required this.inputColorBackground, - required this.enterSendEnabled, - required this.autocorrectEnabled, - required this.suggestionsEnabled, - required this.textCapitalization, - required this.onSendTyping, - }); + const _Props( + {required this.room, + required this.inputTextColor, + required this.inputCursorColor, + required this.inputColorBackground, + required this.enterSendEnabled, + required this.autocorrectEnabled, + required this.suggestionsEnabled, + required this.textCapitalization, + required this.onSendTyping, + required this.users}); @override List get props => [ @@ -656,10 +745,10 @@ class _Props extends Equatable { static _Props mapStateToProps(Store store, String roomId) => _Props( room: selectRoom(id: roomId, state: store.state), + users: roomUsers(store.state, roomId), inputTextColor: selectInputTextColor(store.state.settingsStore.themeSettings.themeType), inputCursorColor: selectCursorColor(store.state.settingsStore.themeSettings.themeType), - inputColorBackground: - selectInputBackgroundColor(store.state.settingsStore.themeSettings.themeType), + inputColorBackground: selectInputBackgroundColor(store.state.settingsStore.themeSettings.themeType), enterSendEnabled: store.state.settingsStore.enterSendEnabled, autocorrectEnabled: store.state.settingsStore.autocorrectEnabled, suggestionsEnabled: store.state.settingsStore.suggestionsEnabled, diff --git a/lib/views/home/chat/widgets/chat-mention.dart b/lib/views/home/chat/widgets/chat-mention.dart new file mode 100644 index 000000000..f7ea4a857 --- /dev/null +++ b/lib/views/home/chat/widgets/chat-mention.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:syphon/global/colors.dart'; +import 'package:syphon/global/dimensions.dart'; +import 'package:syphon/store/user/model.dart'; +import 'package:syphon/store/user/selectors.dart'; +import 'package:syphon/views/widgets/avatars/avatar.dart'; + +class MentionDialog extends HookWidget implements PreferredSizeWidget { + const MentionDialog({ + required this.users, + required this.width, + required this.controller, + required this.onComplete, + this.visible = false, + this.height = 200, + }); + + @override + Size get preferredSize => AppBar().preferredSize; + + final List users; + final double width; + final double height; + final TextEditingController controller; + final bool visible; + + final Function onComplete; + + onTab(User user) { + final text = controller.text; + final cursorPos = controller.selection.baseOffset; + final subText = text.substring(0, cursorPos); + + final RegExp mentionExpEnd = RegExp( + r'\B@\w+$', + caseSensitive: false, + multiLine: false, + ); + + controller.text = subText.replaceAll(mentionExpEnd, '${user.userId} ') + text.substring(cursorPos); + + controller.selection = TextSelection.fromPosition( + TextPosition(offset: controller.text.length), // move cursor to end + ); + + onComplete(); + } + + @override + Widget build(BuildContext context) => ConstrainedBox( + constraints: BoxConstraints(maxHeight: height, maxWidth: width), + child: ListView.builder( + itemBuilder: (buildContext, index) { + final String userName = formatUsername(users[index]); + + return Visibility( + visible: visible, + child: Card( + child: ListTile( + onTap: () => onTab(users[index]), + leading: Avatar( + uri: users[index].avatarUri, + alt: userName, + size: Dimensions.avatarSizeMin, + background: AppColors.hashedColor( + userName, + ), + ), + title: Text(userName), + subtitle: Text(users[index].userId!), + ), + )); + }, + itemCount: users.length, + shrinkWrap: true, + scrollDirection: Axis.vertical, + physics: ClampingScrollPhysics(), + padding: EdgeInsets.symmetric(vertical: 5), + )); +}