diff --git a/lib/controllers/chat_message_controller.dart b/lib/controllers/chat_message_controller.dart index 312824e..c52d4f4 100644 --- a/lib/controllers/chat_message_controller.dart +++ b/lib/controllers/chat_message_controller.dart @@ -9,6 +9,7 @@ import 'package:provider/provider.dart'; import 'package:surface/database/database.dart'; import 'package:surface/logger.dart'; import 'package:surface/providers/database.dart'; +import 'package:surface/providers/keypair.dart'; import 'package:surface/providers/sn_attachment.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/user_directory.dart'; @@ -25,6 +26,7 @@ class ChatMessageController extends ChangeNotifier { late final WebSocketProvider _ws; late final SnAttachmentProvider _attach; late final DatabaseProvider _dt; + late final KeyPairProvider _kp; StreamSubscription? _wsSubscription; @@ -34,6 +36,7 @@ class ChatMessageController extends ChangeNotifier { _ws = context.read(); _attach = context.read(); _dt = context.read(); + _kp = context.read(); } bool isPending = true; @@ -245,6 +248,24 @@ class ChatMessageController extends ChangeNotifier { } } + Future> _encodeMessageBody( + String text, + bool isEncrypted, + ) async { + if (!isEncrypted || _kp.activeKp == null) { + return { + 'text': text, + 'algorithm': 'plain', + }; + } else { + return { + 'text': await _kp.encryptText(text), + 'algorithm': 'rsa', + 'keypair_id': _kp.activeKp!.id, + }; + } + } + Future sendMessage( String type, String content, { @@ -252,13 +273,13 @@ class ChatMessageController extends ChangeNotifier { int? relatedId, List? attachments, SnChatMessage? editingMessage, + bool isEncrypted = false, }) async { if (channel == null) return; const uuid = Uuid(); final nonce = uuid.v4(); final body = { - 'text': content, - 'algorithm': 'plain', + ...(await _encodeMessageBody(content, isEncrypted)), if (quoteId != null) 'quote_event': quoteId, if (relatedId != null) 'related_event': relatedId, if (attachments != null && attachments.isNotEmpty) diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index a1b6afa..2d7f639 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:developer'; import 'package:dio/dio.dart'; @@ -19,6 +20,7 @@ import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/userinfo.dart'; import 'package:surface/providers/websocket.dart'; import 'package:surface/types/chat.dart'; +import 'package:surface/types/websocket.dart'; import 'package:surface/widgets/chat/call/call_prejoin.dart'; import 'package:surface/widgets/chat/chat_message.dart'; import 'package:surface/widgets/chat/chat_message_input.dart'; @@ -58,6 +60,8 @@ class _ChatRoomScreenState extends State { final GlobalKey _inputGlobalKey = GlobalKey(); late final ChatMessageController _messageController; + bool _isEncrypted = false; + StreamSubscription? _wsSubscription; // TODO fetch user identity and ask them to join the channel or not @@ -91,9 +95,14 @@ class _ChatRoomScreenState extends State { nty.skippableNotifyChannel = _channel!.id; final ws = context.read(); if (_channel != null) { - ws.conn?.sink.add({ - 'channel_id': _channel?.id, - }); + ws.conn?.sink.add( + jsonEncode(WebSocketPackage( + method: 'events.subscribe', + endpoint: 'im', + payload: { + 'channel_id': _channel!.id, + })), + ); } } catch (err) { if (!mounted) return; @@ -247,9 +256,15 @@ class _ChatRoomScreenState extends State { nty.skippableNotifyChannel = null; final ws = context.read(); if (_channel != null) { - ws.conn?.sink.add({ - 'channel_id': _channel?.id, - }); + ws.conn?.sink.add( + jsonEncode(WebSocketPackage( + method: 'events.unsubscribe', + endpoint: 'im', + payload: { + 'channel_id': _channel!.id, + }, + )), + ); } super.dispose(); } @@ -268,6 +283,15 @@ class _ChatRoomScreenState extends State { : _channel?.name ?? 'loading'.tr(), ), actions: [ + IconButton( + onPressed: () { + setState(() => _isEncrypted = !_isEncrypted); + _inputGlobalKey.currentState?.setEncrypted(_isEncrypted); + }, + icon: _isEncrypted + ? const Icon(Symbols.lock) + : const Icon(Symbols.no_encryption), + ), IconButton( icon: _ongoingCall == null ? const Icon(Symbols.call) diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index 3652b5f..fbc6deb 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -28,7 +28,8 @@ class ChatMessageInput extends StatefulWidget { final ChatMessageController controller; final SnChannelMember? otherMember; - const ChatMessageInput({super.key, required this.controller, this.otherMember}); + const ChatMessageInput( + {super.key, required this.controller, this.otherMember}); @override State createState() => ChatMessageInputState(); @@ -38,6 +39,8 @@ class ChatMessageInputState extends State { bool _isBusy = false; double? _progress; + bool _isEncrypted = false; + SnChatMessage? _replyingMessage, _editingMessage; final TextEditingController _contentController = TextEditingController(); @@ -45,12 +48,20 @@ class ChatMessageInputState extends State { final HotKey _pasteHotKey = HotKey( key: PhysicalKeyboardKey.keyV, - modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control], + modifiers: [ + (!kIsWeb && Platform.isMacOS) + ? HotKeyModifier.meta + : HotKeyModifier.control + ], scope: HotKeyScope.inapp, ); final HotKey _newLineHotKey = HotKey( key: PhysicalKeyboardKey.enter, - modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control], + modifiers: [ + (!kIsWeb && Platform.isMacOS) + ? HotKeyModifier.meta + : HotKeyModifier.control + ], scope: HotKeyScope.inapp, ); @@ -83,6 +94,10 @@ class ChatMessageInputState extends State { }); } + void setEncrypted(bool value) { + setState(() => _isEncrypted = value); + } + void setReply(SnChatMessage? value) { setState(() => _replyingMessage = value); } @@ -100,7 +115,8 @@ class ChatMessageInputState extends State { void setEdit(SnChatMessage? value) { _contentController.text = value?.body['text'] ?? ''; _attachments.clear(); - _attachments.addAll(value?.preload?.attachments?.map((e) => PostWriteMedia(e)) ?? []); + _attachments.addAll( + value?.preload?.attachments?.map((e) => PostWriteMedia(e)) ?? []); setState(() => _editingMessage = value); } @@ -139,7 +155,9 @@ class ChatMessageInputState extends State { media.name, 'messaging', null, - mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null, + mimetype: media.raw != null && media.type == SnMediaType.image + ? 'image/png' + : null, ); final item = await attach.chunkedUploadParts( @@ -171,10 +189,14 @@ class ChatMessageInputState extends State { widget.controller.sendMessage( _editingMessage != null ? 'messages.edit' : 'messages.new', _contentController.text, - attachments: _attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), + attachments: _attachments + .where((e) => e.attachment != null) + .map((e) => e.attachment!.rid) + .toList(), relatedId: _editingMessage?.id, quoteId: _replyingMessage?.id, editingMessage: _editingMessage, + isEncrypted: _isEncrypted, ); _contentController.clear(); _attachments.clear(); @@ -232,12 +254,15 @@ class ChatMessageInputState extends State { TweenAnimationBuilder( tween: Tween(begin: 0, end: _progress), duration: Duration(milliseconds: 300), - builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2), + builder: (context, value, _) => + LinearProgressIndicator(value: value, minHeight: 2), ) else if (_isBusy) const LinearProgressIndicator(value: null, minHeight: 2), Padding( - padding: _attachments.isNotEmpty ? const EdgeInsets.only(top: 8) : EdgeInsets.zero, + padding: _attachments.isNotEmpty + ? const EdgeInsets.only(top: 8) + : EdgeInsets.zero, child: PostMediaPendingList( attachments: _attachments, isBusy: _isBusy, @@ -249,9 +274,8 @@ class ChatMessageInputState extends State { }, onUpdateBusy: (state) => setState(() => _isBusy = state), ), - ) - .height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true) - .animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), + ).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate( + const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: _replyingMessage != null @@ -272,7 +296,8 @@ class ChatMessageInputState extends State { const Gap(8), Expanded( child: Text( - _replyingMessage?.body['text'] ?? '${_replyingMessage?.sender.nick}', + _replyingMessage?.body['text'] ?? + '${_replyingMessage?.sender.nick}', maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -289,9 +314,8 @@ class ChatMessageInputState extends State { ).padding(vertical: 8), ) : const SizedBox.shrink(), - ) - .height(_replyingMessage != null ? 38 : 0, animate: true) - .animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), + ).height(_replyingMessage != null ? 38 : 0, animate: true).animate( + const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: _editingMessage != null @@ -312,7 +336,8 @@ class ChatMessageInputState extends State { const Gap(8), Expanded( child: Text( - _editingMessage?.body['text'] ?? '${_editingMessage?.sender.nick}', + _editingMessage?.body['text'] ?? + '${_editingMessage?.sender.nick}', maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -330,30 +355,41 @@ class ChatMessageInputState extends State { ).padding(vertical: 8), ) : const SizedBox.shrink(), - ) - .height(_editingMessage != null ? 38 : 0, animate: true) - .animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), + ).height(_editingMessage != null ? 38 : 0, animate: true).animate( + const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut), Container( - padding: EdgeInsets.symmetric(horizontal: 16), - constraints: BoxConstraints(minHeight: 56, maxHeight: 240), + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 6, + ), + constraints: BoxConstraints(maxHeight: 240), child: Row( - crossAxisAlignment: CrossAxisAlignment.end, + crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: TextField( focusNode: _focusNode, controller: _contentController, decoration: InputDecoration( - isCollapsed: true, + prefixIconConstraints: const BoxConstraints( + minWidth: 24, + maxWidth: 24, + ), + prefixIcon: + _isEncrypted ? Icon(Symbols.lock, size: 18) : null, hintText: widget.otherMember != null ? 'fieldChatMessageDirect'.tr(args: [ '@${ud.getAccountFromCache(widget.otherMember?.accountId)?.name}', ]) - : 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]), + : 'fieldChatMessage'.tr(args: [ + widget.controller.channel?.name ?? 'loading'.tr() + ]), border: InputBorder.none, ), textInputAction: TextInputAction.send, - onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), + onTapOutside: (_) => + FocusManager.instance.primaryFocus?.unfocus(), onSubmitted: (_) { if (_isBusy) return; _sendMessage(); @@ -362,20 +398,18 @@ class ChatMessageInputState extends State { maxLines: null, ), ), - const Gap(8), IconButton( + visualDensity: VisualDensity.compact, icon: Icon( Symbols.mood, color: Theme.of(context).colorScheme.primary, ), - visualDensity: const VisualDensity(horizontal: -4, vertical: -4), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), onPressed: () { _showEmojiPicker(context); }, ), AddPostMediaButton( + visualDensity: VisualDensity.compact, onAdd: (items) { setState(() { _attachments.addAll(items); @@ -383,18 +417,18 @@ class ChatMessageInputState extends State { }, ), IconButton( + visualDensity: VisualDensity.compact, onPressed: _isBusy ? null : _sendMessage, icon: Icon( Symbols.send, color: Theme.of(context).colorScheme.primary, ), - visualDensity: const VisualDensity(horizontal: -4, vertical: -4), - padding: EdgeInsets.zero, constraints: const BoxConstraints(), ), ], ), ), + Gap(MediaQuery.of(context).padding.bottom), ], ); } @@ -405,7 +439,8 @@ class _StickerPicker extends StatelessWidget { final Function? onDismiss; final Function(String)? onInsert; - const _StickerPicker({this.onDismiss, required this.originalText, this.onInsert}); + const _StickerPicker( + {this.onDismiss, required this.originalText, this.onInsert}); @override Widget build(BuildContext context) { @@ -431,8 +466,10 @@ class _StickerPicker extends StatelessWidget { return [ Container( margin: EdgeInsets.only(bottom: 8), - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), - color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: + EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: + Theme.of(context).colorScheme.surfaceContainerHigh, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -444,7 +481,8 @@ class _StickerPicker extends StatelessWidget { ), GridView.builder( physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8), + padding: + const EdgeInsets.only(left: 8, right: 8, bottom: 8), shrinkWrap: true, gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 48, @@ -467,7 +505,8 @@ class _StickerPicker extends StatelessWidget { richMessage: TextSpan( children: [ TextSpan( - text: ':${element.pack.prefix}${element.alias}:\n', + text: + ':${element.pack.prefix}${element.alias}:\n', style: GoogleFonts.robotoMono()), TextSpan(text: element.name).bold(), ], @@ -476,11 +515,15 @@ class _StickerPicker extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8)), - color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: const BorderRadius.all( + Radius.circular(8)), + color: Theme.of(context) + .colorScheme + .surfaceContainerHigh, ), child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), + borderRadius: const BorderRadius.all( + Radius.circular(8)), child: UniversalImage( sn.getAttachmentUrl(element.attachment.rid), width: 48, diff --git a/lib/widgets/post/post_media_pending_list.dart b/lib/widgets/post/post_media_pending_list.dart index f93eaeb..9a6fa1a 100644 --- a/lib/widgets/post/post_media_pending_list.dart +++ b/lib/widgets/post/post_media_pending_list.dart @@ -464,9 +464,14 @@ class _PostMediaPendingItem extends StatelessWidget { } class AddPostMediaButton extends StatelessWidget { + final VisualDensity? visualDensity; final Function(Iterable) onAdd; - const AddPostMediaButton({super.key, required this.onAdd}); + const AddPostMediaButton({ + super.key, + required this.onAdd, + this.visualDensity, + }); void _takeMedia(bool isVideo) async { final picker = ImagePicker(); @@ -550,11 +555,7 @@ class AddPostMediaButton extends StatelessWidget { @override Widget build(BuildContext context) { return PopupMenuButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - style: ButtonStyle( - visualDensity: VisualDensity(horizontal: -4, vertical: -4), - ), + style: ButtonStyle(visualDensity: visualDensity), icon: Icon( Symbols.add_photo_alternate, color: Theme.of(context).colorScheme.primary,