Sending encrypted message

This commit is contained in:
LittleSheep 2025-03-03 23:56:45 +08:00
parent cb2de52bee
commit 5a6b841253
4 changed files with 142 additions and 53 deletions

View File

@ -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<WebSocketProvider>();
_attach = context.read<SnAttachmentProvider>();
_dt = context.read<DatabaseProvider>();
_kp = context.read<KeyPairProvider>();
}
bool isPending = true;
@ -245,6 +248,24 @@ class ChatMessageController extends ChangeNotifier {
}
}
Future<Map<String, dynamic>> _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<void> sendMessage(
String type,
String content, {
@ -252,13 +273,13 @@ class ChatMessageController extends ChangeNotifier {
int? relatedId,
List<String>? 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)

View File

@ -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<ChatRoomScreen> {
final GlobalKey<ChatMessageInputState> _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<ChatRoomScreen> {
nty.skippableNotifyChannel = _channel!.id;
final ws = context.read<WebSocketProvider>();
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<ChatRoomScreen> {
nty.skippableNotifyChannel = null;
final ws = context.read<WebSocketProvider>();
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<ChatRoomScreen> {
: _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)

View File

@ -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<ChatMessageInput> createState() => ChatMessageInputState();
@ -38,6 +39,8 @@ class ChatMessageInputState extends State<ChatMessageInput> {
bool _isBusy = false;
double? _progress;
bool _isEncrypted = false;
SnChatMessage? _replyingMessage, _editingMessage;
final TextEditingController _contentController = TextEditingController();
@ -45,12 +48,20 @@ class ChatMessageInputState extends State<ChatMessageInput> {
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<ChatMessageInput> {
});
}
void setEncrypted(bool value) {
setState(() => _isEncrypted = value);
}
void setReply(SnChatMessage? value) {
setState(() => _replyingMessage = value);
}
@ -100,7 +115,8 @@ class ChatMessageInputState extends State<ChatMessageInput> {
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<ChatMessageInput> {
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<ChatMessageInput> {
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<ChatMessageInput> {
TweenAnimationBuilder<double>(
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<ChatMessageInput> {
},
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<ChatMessageInput> {
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<ChatMessageInput> {
).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<ChatMessageInput> {
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<ChatMessageInput> {
).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<ChatMessageInput> {
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<ChatMessageInput> {
},
),
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 <Widget>[
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,

View File

@ -464,9 +464,14 @@ class _PostMediaPendingItem extends StatelessWidget {
}
class AddPostMediaButton extends StatelessWidget {
final VisualDensity? visualDensity;
final Function(Iterable<PostWriteMedia>) 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,