Compare commits

...

3 Commits

Author SHA1 Message Date
76d8cd943d 💄 Optimize de/encrypting animations 2025-03-04 21:17:17 +08:00
d6f3ffc655 Functional key exchange 2025-03-04 21:08:40 +08:00
5a6b841253 Sending encrypted message 2025-03-03 23:56:45 +08:00
17 changed files with 358 additions and 140 deletions

View File

@ -759,5 +759,7 @@
"accountKeyPairsDescription": "Manage the key pairs which used to encrypt messages.",
"enrollNewKeyPair": "Enroll New One",
"enrollNewKeyPairDescription": "Generate a new key pair.",
"keyPairHasPrivateKey": "With private key"
"keyPairHasPrivateKey": "With private key",
"decrypting": "Decrypting……",
"decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online"
}

View File

@ -757,5 +757,7 @@
"accountKeyPairsDescription": "管理用于加密信息的密钥对。",
"enrollNewKeyPair": "新建密钥对",
"enrollNewKeyPairDescription": "生成一对新密钥对。",
"keyPairHasPrivateKey": "有私钥"
"keyPairHasPrivateKey": "有私钥",
"decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线"
}

View File

@ -756,6 +756,8 @@
"accountKeyPairs": "密鑰對",
"accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
"enrollNewKeyPair": "新建密鑰對",
"enrollNewKeyPairDescription": "生成一對新密鑰對,覆蓋當前的;如果已有一個密鑰將會丟棄舊密鑰的私鑰。",
"keyPairHasPrivateKey": "有私鑰"
"enrollNewKeyPairDescription": "生成一對新密鑰對。",
"keyPairHasPrivateKey": "有私鑰",
"decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線"
}

View File

@ -756,6 +756,8 @@
"accountKeyPairs": "密鑰對",
"accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
"enrollNewKeyPair": "新建密鑰對",
"enrollNewKeyPairDescription": "生成一對新密鑰對,覆蓋當前的;如果已有一個密鑰將會丟棄舊密鑰的私鑰。",
"keyPairHasPrivateKey": "有私鑰"
"enrollNewKeyPairDescription": "生成一對新密鑰對。",
"keyPairHasPrivateKey": "有私鑰",
"decrypting": "解密中……",
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線"
}

View File

@ -1 +1 @@
{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"sn_local_key_pair","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"account_id","getter_name":"accountId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"public_key","getter_name":"publicKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_active","getter_name":"isActive","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_active\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_active\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}}]}
{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"sn_local_key_pair","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"account_id","getter_name":"accountId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"public_key","getter_name":"publicKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_active","getter_name":"isActive","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_active\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_active\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}}]}

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

@ -621,7 +621,7 @@ class $SnLocalKeyPairTable extends SnLocalKeyPair
}
@override
Set<GeneratedColumn> get $primaryKey => const {};
Set<GeneratedColumn> get $primaryKey => {id};
@override
SnLocalKeyPairData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';

View File

@ -47,7 +47,9 @@ final class Schema2 extends i0.VersionedSchema {
entityName: 'sn_local_key_pair',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_5,
_column_6,

View File

@ -10,4 +10,7 @@ class SnLocalKeyPair extends Table {
TextColumn get privateKey => text().nullable()();
BoolColumn get isActive => boolean().withDefault(Constant(false))();
@override
Set<Column<Object>> get primaryKey => {id};
}

View File

@ -35,24 +35,35 @@ class KeyPairProvider {
case 'kex.ack':
ackKeyExchange(event);
break;
case 'key.ask':
case 'kex.ask':
replyAskKeyExchange(event);
break;
}
});
}
Future<String> decryptText(String text, String kpId) async {
Future<String> decryptText(String text, String kpId, {int? kpOwner}) async {
String? publicKey;
final kp = await (_dt.db.snLocalKeyPair.select()
..where((e) => e.id.equals(kpId)))
.getSingleOrNull();
if (kp == null) throw Exception('Key pair not found');
return await RSA.decryptPKCS1v15(text, kp.privateKey!);
if (kp == null) {
if (kpOwner != null) {
final out = await askKeyExchange(kpOwner, kpId);
publicKey = out.publicKey;
}
} else {
publicKey = kp.publicKey;
}
if (publicKey == null) {
throw Exception('Key pair not found');
}
return await RSA.decryptPKCS1v15(text, publicKey);
}
Future<String> encryptText(String text) async {
if (activeKp == null) throw Exception('No active key pair');
return await RSA.encryptPKCS1v15(text, activeKp!.publicKey);
return await RSA.encryptPKCS1v15(text, activeKp!.privateKey!);
}
final Map<String, Completer<SnKeyPair>> _requests = {};
@ -65,7 +76,7 @@ class KeyPairProvider {
_ws.conn?.sink.add(
jsonEncode(WebSocketPackage(
method: 'key.ask',
method: 'kex.ask',
endpoint: 'id',
payload: {
'keypair_id': kpId,
@ -105,12 +116,7 @@ class KeyPairProvider {
publicKey: kpMeta.publicKey,
privateKey: Value(kpMeta.privateKey),
),
onConflict: DoUpdate(
(_) => SnLocalKeyPairCompanion.custom(
publicKey: Constant(kpMeta.publicKey),
privateKey: Constant(kpMeta.privateKey),
),
),
onConflict: DoNothing(),
);
}
@ -208,8 +214,10 @@ class KeyPairProvider {
final kpMeta = SnKeyPair(
id: id,
accountId: _ua.user!.id,
publicKey: kp.publicKey,
privateKey: kp.privateKey,
// This is work as expected
// We need to share private key to let everyone can decode the message
publicKey: kp.privateKey,
privateKey: kp.publicKey,
);
// Save the keypair to the local database

View File

@ -53,12 +53,11 @@ class WebSocketProvider extends ChangeNotifier {
try {
_connectCompleter = Completer<void>();
final clientId = await FlutterUdid.consistentUdid;
final atk = await _sn.getFreshAtk();
final uri = Uri.parse(
kIsWeb
? '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk'
: '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?clientId=${clientId}tk=$atk',
: '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?clientId=${await FlutterUdid.consistentUdid}tk=$atk',
);
isBusy = true;

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

@ -9,6 +9,7 @@ import 'package:popover/popover.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/config.dart';
import 'package:surface/providers/keypair.dart';
import 'package:surface/providers/user_directory.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/screens/account/profile_page.dart';
@ -19,6 +20,7 @@ import 'package:surface/widgets/attachment/attachment_list.dart';
import 'package:surface/widgets/context_menu.dart';
import 'package:surface/widgets/link_preview.dart';
import 'package:surface/widgets/markdown_content.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:swipe_to/swipe_to.dart';
class ChatMessage extends StatelessWidget {
@ -106,15 +108,18 @@ class ChatMessage extends StatelessWidget {
GestureDetector(
child: AccountImage(
content: user?.avatar,
badge:
(user?.badges.isNotEmpty ?? false)
badge: (user?.badges.isNotEmpty ?? false)
? Icon(
kBadgesMeta[user!.badges.first.type]?.$2 ?? Symbols.question_mark,
kBadgesMeta[user!.badges.first.type]?.$2 ??
Symbols.question_mark,
color: kBadgesMeta[user.badges.first.type]?.$3,
fill: 1,
size: 18,
shadows: [
Shadow(offset: Offset(1, 1), blurRadius: 5.0, color: Color.fromARGB(200, 0, 0, 0)),
Shadow(
offset: Offset(1, 1),
blurRadius: 5.0,
color: Color.fromARGB(200, 0, 0, 0)),
],
)
: null,
@ -122,12 +127,13 @@ class ChatMessage extends StatelessWidget {
onTap: () {
if (user == null) return;
showPopover(
backgroundColor: Theme.of(context).colorScheme.surface,
backgroundColor:
Theme.of(context).colorScheme.surface,
context: context,
transition: PopoverTransition.other,
bodyBuilder:
(context) => SizedBox(
width: math.min(400, MediaQuery.of(context).size.width - 10),
bodyBuilder: (context) => SizedBox(
width: math.min(
400, MediaQuery.of(context).size.width - 10),
child: AccountPopoverCard(data: user),
),
direction: PopoverDirection.bottom,
@ -150,12 +156,19 @@ class ChatMessage extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (isCompact) AccountImage(content: user?.avatar, radius: 12).padding(right: 8),
if (isCompact)
AccountImage(
content: user?.avatar, radius: 12)
.padding(right: 8),
Text(
(data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown',
(data.sender.nick?.isNotEmpty ?? false)
? data.sender.nick!
: user?.nick ?? 'unknown',
).bold(),
const Gap(8),
Text(dateFormatter.format(data.createdAt.toLocal())).fontSize(13),
Text(dateFormatter
.format(data.createdAt.toLocal()))
.fontSize(13),
],
).height(21),
if (isCompact) const Gap(8),
@ -164,10 +177,14 @@ class ChatMessage extends StatelessWidget {
Container(
constraints: BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: Theme.of(context).dividerColor, width: 1),
borderRadius: const BorderRadius.all(
Radius.circular(8)),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1),
),
padding: const EdgeInsets.only(left: 4, right: 4, top: 8, bottom: 6),
padding: const EdgeInsets.only(
left: 4, right: 4, top: 8, bottom: 6),
child: ChatMessage(
data: data.preload!.quoteEvent!,
isCompact: true,
@ -204,9 +221,13 @@ class ChatMessage extends StatelessWidget {
bordered: true,
maxHeight: 360,
maxWidth: 480 - 48 - padding.left,
padding: padding.copyWith(top: 8, left: isCompact ? padding.left : 48 + padding.left),
padding: padding.copyWith(
top: 8, left: isCompact ? padding.left : 48 + padding.left),
),
if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(8),
if (!hasMerged && !isCompact)
const Gap(12)
else if (!isCompact)
const Gap(8),
],
),
),
@ -220,7 +241,8 @@ class _ChatMessageText extends StatelessWidget {
final Function(SnChatMessage)? onEdit;
final Function(SnChatMessage)? onDelete;
const _ChatMessageText({required this.data, this.onReply, this.onEdit, this.onDelete});
const _ChatMessageText(
{required this.data, this.onReply, this.onEdit, this.onDelete});
@override
Widget build(BuildContext context) {
@ -234,7 +256,8 @@ class _ChatMessageText extends StatelessWidget {
children: [
SelectionArea(
contextMenuBuilder: (context, editableTextState) {
final List<ContextMenuButtonItem> items = editableTextState.contextMenuButtonItems;
final List<ContextMenuButtonItem> items =
editableTextState.contextMenuButtonItems;
if (onReply != null) {
items.insert(
@ -278,13 +301,18 @@ class _ChatMessageText extends StatelessWidget {
buttonItems: items,
);
},
child: MarkdownTextContent(
child: switch (data.body['algorithm']) {
'rsa' => _ChatDecryptMessage(message: data),
_ => MarkdownTextContent(
content: data.body['text'],
isAutoWarp: true,
isEnlargeSticker: RegExp(r"^:([-\w]+):$").hasMatch(data.body['text'] ?? ''),
isEnlargeSticker:
RegExp(r"^:([-\w]+):$").hasMatch(data.body['text'] ?? ''),
),
},
),
if (data.updatedAt != data.createdAt) Text('messageEditedHint'.tr()).fontSize(13).opacity(0.75),
if (data.updatedAt != data.createdAt)
Text('messageEditedHint'.tr()).fontSize(13).opacity(0.75),
],
);
} else if (data.body['attachments']?.isNotEmpty) {
@ -294,7 +322,7 @@ class _ChatMessageText extends StatelessWidget {
const Gap(4),
Text('messageFileHint'.plural(data.body['attachments']!.length)),
],
).opacity(0.75);
).opacity(0.8);
}
return const SizedBox.shrink();
@ -320,39 +348,102 @@ class _ChatMessageSystemNotify extends StatelessWidget {
case 'messages.edit':
return Row(
children: [
const Icon(Symbols.edit, size: 20),
const Gap(4),
const Icon(Symbols.edit, size: 16),
const Gap(8),
Text('messageEdited'.tr(args: ['#${data.relatedEventId}'])),
],
).opacity(0.75);
).opacity(0.8);
case 'messages.delete':
return Row(
children: [
const Icon(Symbols.delete, size: 20),
const Gap(4),
const Icon(Symbols.delete, size: 16),
const Gap(8),
Text('messageDeleted'.tr(args: ['#${data.relatedEventId}'])),
],
).opacity(0.75);
).opacity(0.8);
case 'calls.start':
return Row(
children: [const Icon(Symbols.call, size: 20), const Gap(4), Text('callMessageStarted'.tr())],
).opacity(0.75);
children: [
const Icon(Symbols.call, size: 16),
const Gap(8),
Text('callMessageStarted'.tr())
],
).opacity(0.8);
case 'calls.end':
return Row(
children: [
const Icon(Symbols.call_end, size: 20),
const Gap(4),
Text('callMessageEnded'.tr(args: [_formatDuration(Duration(seconds: data.body['last']))])),
const Icon(Symbols.call_end, size: 16),
const Gap(8),
Text('callMessageEnded'.tr(
args: [_formatDuration(Duration(seconds: data.body['last']))])),
],
).opacity(0.75);
).opacity(0.8);
default:
return Row(
children: [
const Icon(Symbols.info, size: 20),
const Gap(4),
const Icon(Symbols.info, size: 16),
const Gap(8),
Text('messageUnsupported'.tr(args: [data.type])),
],
).opacity(0.75);
).opacity(0.8);
}
}
}
class _ChatDecryptMessage extends StatelessWidget {
final SnChatMessage message;
const _ChatDecryptMessage({required this.message});
@override
Widget build(BuildContext context) {
final kp = context.read<KeyPairProvider>();
return FutureBuilder(
key: Key('chat-message-encrypted-${message.id}'),
future: kp.decryptText(
message.body['text'],
message.body['keypair_id'],
kpOwner: message.sender.accountId,
),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Row(
children: [
Icon(Symbols.key_off, size: 16),
const Gap(8),
Expanded(
child: Text('decryptingKeyNotFound'.tr()),
),
],
).opacity(0.8);
}
if (!snapshot.hasData) {
return Row(
children: [
Animate(
autoPlay: true,
onPlay: (e) => e.repeat(),
effects: [
RotateEffect(
duration: const Duration(seconds: 3),
begin: 0,
end: -1,
)
],
child: Icon(Symbols.sync, size: 16),
),
const Gap(8),
Expanded(
child: Text('decrypting'.tr()),
),
],
);
}
return MarkdownTextContent(
content: snapshot.data!,
isAutoWarp: true,
isEnlargeSticker: RegExp(r"^:([-\w]+):$").hasMatch(snapshot.data!),
);
},
);
}
}

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,

View File

@ -7,6 +7,8 @@ PODS:
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- fast_rsa (0.6.0):
- FlutterMacOS
- file_picker (0.0.1):
- FlutterMacOS
- file_saver (0.0.1):
@ -23,14 +25,14 @@ PODS:
- Firebase/Messaging (11.8.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 11.8.0)
- firebase_analytics (11.4.3):
- firebase_analytics (11.4.4):
- Firebase/Analytics (= 11.8.0)
- firebase_core
- FlutterMacOS
- firebase_core (3.12.0):
- firebase_core (3.12.1):
- Firebase/CoreOnly (~> 11.8.0)
- FlutterMacOS
- firebase_messaging (15.2.3):
- firebase_messaging (15.2.4):
- Firebase/CoreOnly (~> 11.8.0)
- Firebase/Messaging (~> 11.8.0)
- firebase_core
@ -76,6 +78,8 @@ PODS:
- flutter_inappwebview_macos (0.0.1):
- FlutterMacOS
- OrderedSet (~> 6.0.3)
- flutter_timezone (0.1.0):
- FlutterMacOS
- flutter_udid (0.0.1):
- FlutterMacOS
- SAMKeychain
@ -86,6 +90,8 @@ PODS:
- gal (1.0.0):
- Flutter
- FlutterMacOS
- geolocator_apple (1.2.0):
- FlutterMacOS
- GoogleAppMeasurement (11.8.0):
- GoogleAppMeasurement/AdIdSupport (= 11.8.0)
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
@ -213,6 +219,7 @@ DEPENDENCIES:
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
- croppy (from `Flutter/ephemeral/.symlinks/plugins/croppy/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- fast_rsa (from `Flutter/ephemeral/.symlinks/plugins/fast_rsa/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
@ -220,10 +227,12 @@ DEPENDENCIES:
- firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`)
- firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
- flutter_timezone (from `Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos`)
- flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`)
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
- geolocator_apple (from `Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos`)
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
- in_app_review (from `Flutter/ephemeral/.symlinks/plugins/in_app_review/macos`)
- livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
@ -272,6 +281,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/croppy/macos
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
fast_rsa:
:path: Flutter/ephemeral/.symlinks/plugins/fast_rsa/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
file_saver:
@ -286,6 +297,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos
flutter_inappwebview_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
flutter_timezone:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_timezone/macos
flutter_udid:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos
flutter_webrtc:
@ -294,6 +307,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
gal:
:path: Flutter/ephemeral/.symlinks/plugins/gal/darwin
geolocator_apple:
:path: Flutter/ephemeral/.symlinks/plugins/geolocator_apple/macos
hotkey_manager_macos:
:path: Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos
in_app_review:
@ -338,23 +353,26 @@ SPEC CHECKSUMS:
connectivity_plus: 0a976dfd033b59192912fa3c6c7b54aab5093802
croppy: 25a638bd7d05411d8c697f481568f261037694fc
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
fast_rsa: 47a50bec1042c8c01726007dc0590a078418f997
file_picker: e716a70a9fe5fd9e09ebc922d7541464289443af
file_saver: 44e6fbf666677faf097302460e214e977fdd977b
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
firebase_analytics: 1a71372a9735d7046d2c69db848a8d178f9fb587
firebase_core: 68e1d27035b096239f147a041643e14e156f1481
firebase_messaging: 89b5e0e28413dd878a58d2f286cdc03887b5d467
firebase_analytics: 75b9d9ea8b21ce77898a3a46910e2051e93db8e1
firebase_core: 1b573eac37729348cdc472516991dd7e269ae37e
firebase_messaging: 0620038ea399ceae2218c9087fca00a28f576209
FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b
flutter_timezone: 62400baa441155f2a4144188648f2ff861649747
flutter_udid: 2e7b3da4b5fdfba86a396b97898f5fe8f4ec1a52
flutter_webrtc: d55fd3f5c75b42940b6b4b2cf376a5797398d1f8
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
geolocator_apple: 72a78ae3f3e4ec0db62117bd93e34523f5011d58
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d

View File

@ -481,7 +481,7 @@ class SnLocalKeyPair extends Table
String get actualTableName => $name;
static const String $name = 'sn_local_key_pair';
@override
Set<GeneratedColumn> get $primaryKey => const {};
Set<GeneratedColumn> get $primaryKey => {id};
@override
SnLocalKeyPairData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';