diff --git a/assets/translations/en.json b/assets/translations/en.json index ebce122..5ab56e8 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -242,6 +242,7 @@ "realmMemberAddDescription": "Add new member to this realm.", "realmMemberAdded": "Realm member has been added.", "fieldChatMessage": "Message in {}", + "fieldChatMessageDirect": "Message with {}", "eventResourceTag": "Event {}", "messageDelete": "Delete message {}", "messageDeleteDescription": "Are you sure you want to delete this message? This operation is irreversible. You will leave a record of the deleted message.", @@ -402,5 +403,8 @@ "accountDeletion": "Delete Account", "accountDeletionDescription": "Are you sure you want to delete this account? This operation is irreversible, all resources (posts, chat channels, publishers, etc) belonging to this account will be permanently deleted. Be careful and think twice!", "accountDeletionActionDescription": "Delete your Solarpass account.", - "accountDeletionSubmitted": "Account deletion request has been sent, you can check your inbox and follow the instructions in the email to complete the deletion operation." + "accountDeletionSubmitted": "Account deletion request has been sent, you can check your inbox and follow the instructions in the email to complete the deletion operation.", + "channelNewChannel": "New Channel", + "channelNewDirectMessage": "New Direct Message", + "channelDirectMessageDescription": "Direct Message with {}" } diff --git a/assets/translations/zh.json b/assets/translations/zh.json index fadf81a..91cc45d 100644 --- a/assets/translations/zh.json +++ b/assets/translations/zh.json @@ -242,6 +242,7 @@ "realmMemberAddDescription": "给当前领域添加新成员。", "realmMemberAdded": "领域成员已添加。", "fieldChatMessage": "在 {} 中发消息", + "fieldChatMessageDirect": "给 {} 发消息", "eventResourceTag": "消息 {}", "messageDelete": "删除消息 {}", "messageDeleteDescription": "你确定要删除这个消息吗?该操作不可撤销。同时您将留下一条删除消息的记录。", @@ -402,5 +403,8 @@ "accountDeletion": "删除帐户", "accountDeletionDescription": "你确定要删除这个帐户吗?该操作不可撤销,其隶属于该帐户的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!", "accountDeletionActionDescription": "删除你的 Solarpass 帐户。", - "accountDeletionSubmitted": "帐户删除申请已发出,你可以检查你的收件箱并根据邮件内的指示完成删除操作。" + "accountDeletionSubmitted": "帐户删除申请已发出,你可以检查你的收件箱并根据邮件内的指示完成删除操作。", + "channelNewChannel": "新建频道", + "channelNewDirectMessage": "发起私信", + "channelDirectMessageDescription": "与 {} 的私聊" } diff --git a/lib/screens/chat.dart b/lib/screens/chat.dart index d441f8c..1cb4d78 100644 --- a/lib/screens/chat.dart +++ b/lib/screens/chat.dart @@ -1,5 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_expandable_fab/flutter_expandable_fab.dart'; +import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -7,9 +9,14 @@ import 'package:surface/providers/channel.dart'; import 'package:surface/providers/user_directory.dart'; import 'package:surface/types/chat.dart'; import 'package:surface/widgets/account/account_image.dart'; +import 'package:surface/widgets/account/account_select.dart'; import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/loading_indicator.dart'; +import 'package:uuid/uuid.dart'; + +import '../providers/sn_network.dart'; +import '../providers/userinfo.dart'; class ChatScreen extends StatefulWidget { const ChatScreen({super.key}); @@ -19,6 +26,8 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State { + final _fabKey = GlobalKey(); + bool _isBusy = true; List? _channels; @@ -30,17 +39,29 @@ class _ChatScreenState extends State { final lastMessages = await chan.getLastMessages(channels); _lastMessages = {for (final val in lastMessages) val.channelId: val}; channels.sort((a, b) { - if (_lastMessages!.containsKey(a.id) && - _lastMessages!.containsKey(b.id)) { - return _lastMessages![b.id]! - .createdAt - .compareTo(_lastMessages![a.id]!.createdAt); + if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) { + return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt); } if (_lastMessages!.containsKey(a.id)) return -1; if (_lastMessages!.containsKey(b.id)) return 1; return 0; }); + if (!mounted) return; + final ud = context.read(); + for (final channel in channels) { + if (channel.type == 1) { + await ud.listAccount( + channel.members + ?.cast() + .map((ele) => ele?.accountId) + .where((ele) => ele != null) + .toSet() ?? + {}, + ); + } + } + if (mounted) setState(() => _channels = channels); }) ..onError((err) { @@ -54,6 +75,32 @@ class _ChatScreenState extends State { }); } + void _newDirectMessage() async { + final user = await showModalBottomSheet( + context: context, + builder: (context) => AccountSelect(title: 'channelNewDirectMessage'.tr()), + ); + if (user == null) return; + if (!mounted) return; + + try { + const uuid = Uuid(); + final sn = context.read(); + final ua = context.read(); + final resp = await sn.client.post('/cgi/im/channels/global/dm', data: { + 'alias': uuid.v4().replaceAll('-', '').substring(0, 12), + 'name': 'DM', + 'description': 'A direct message channel between @${ua.user?.name} and @${user.name}', + 'related_user': user.id, + }); + _fabKey.currentState!.toggle(); + _refreshChannels(); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } + } + @override void initState() { super.initState(); @@ -63,19 +110,67 @@ class _ChatScreenState extends State { @override Widget build(BuildContext context) { final ud = context.read(); + final ua = context.read(); return Scaffold( appBar: AppBar( leading: AutoAppBarLeading(), title: Text('screenChat').tr(), ), - floatingActionButton: FloatingActionButton( - child: const Icon(Symbols.chat_add_on), - onPressed: () { - GoRouter.of(context).pushNamed('chatManage').then((value) { - if (value != null && context.mounted) _refreshChannels(); - }); - }, + floatingActionButtonLocation: ExpandableFab.location, + floatingActionButton: ExpandableFab( + key: _fabKey, + distance: 75, + type: ExpandableFabType.up, + childrenAnimation: ExpandableFabAnimation.none, + overlayStyle: ExpandableFabOverlayStyle( + color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()), + ), + openButtonBuilder: RotateFloatingActionButtonBuilder( + child: const Icon(Symbols.add, size: 28), + fabSize: ExpandableFabSize.regular, + foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, + backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, + shape: const CircleBorder(), + ), + closeButtonBuilder: DefaultFloatingActionButtonBuilder( + child: const Icon(Symbols.close, size: 28), + fabSize: ExpandableFabSize.regular, + foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor, + backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor, + shape: const CircleBorder(), + ), + children: [ + Row( + children: [ + Text('channelNewChannel').tr(), + const Gap(20), + FloatingActionButton( + heroTag: null, + tooltip: 'channelNewChannel'.tr(), + onPressed: () { + _fabKey.currentState!.toggle(); + GoRouter.of(context).pushNamed('chatManage').then((value) { + if (value != null && context.mounted) _refreshChannels(); + }); + }, + child: const Icon(Symbols.chat_add_on), + ), + ], + ), + Row( + children: [ + Text('channelNewDirectMessage').tr(), + const Gap(20), + FloatingActionButton( + heroTag: null, + tooltip: 'channelNewDirectMessage'.tr(), + onPressed: _newDirectMessage, + child: const Icon(Symbols.communication), + ), + ], + ), + ], ), body: Column( children: [ @@ -88,6 +183,46 @@ class _ChatScreenState extends State { itemBuilder: (context, idx) { final channel = _channels![idx]; final lastMessage = _lastMessages?[channel.id]; + + if (channel.type == 1) { + final otherMember = channel.members?.cast().firstWhere( + (ele) => ele?.accountId != ua.user?.id, + orElse: () => null, + ); + + return ListTile( + title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name), + subtitle: lastMessage != null + ? Text( + '${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ) + : Text( + 'channelDirectMessageDescription'.tr(args: [ + '@${ud.getAccountFromCache(otherMember?.accountId)?.name}', + ]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: AccountImage( + content: ud.getAccountFromCache(otherMember?.accountId)?.avatar, + ), + onTap: () { + GoRouter.of(context).pushNamed( + 'chatRoom', + pathParameters: { + 'scope': channel.realm?.alias ?? 'global', + 'alias': channel.alias, + }, + ).then((value) { + if (value == true) _refreshChannels(); + }); + }, + ); + } + return ListTile( title: Text(channel.name), subtitle: lastMessage != null diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart index 0b014e6..5f9d097 100644 --- a/lib/screens/chat/room.dart +++ b/lib/screens/chat/room.dart @@ -23,9 +23,13 @@ import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/loading_indicator.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; +import '../../providers/user_directory.dart'; +import '../../providers/userinfo.dart'; + class ChatRoomScreen extends StatefulWidget { final String scope; final String alias; + const ChatRoomScreen({super.key, required this.scope, required this.alias}); @override @@ -37,6 +41,7 @@ class _ChatRoomScreenState extends State { bool _isCalling = false; SnChannel? _channel; + SnChannelMember? _otherMember; SnChatCall? _ongoingCall; final GlobalKey _inputGlobalKey = GlobalKey(); @@ -50,6 +55,24 @@ class _ChatRoomScreenState extends State { try { final chan = context.read(); _channel = await chan.getChannel('${widget.scope}:${widget.alias}'); + + if (!mounted || _channel == null) return; + final ud = context.read(); + final ua = context.read(); + if (_channel!.type == 1) { + await ud.listAccount( + _channel!.members + ?.cast() + .map((ele) => ele?.accountId) + .where((ele) => ele != null && ele != ua.user?.id) + .toSet() ?? + {}, + ); + _otherMember = _channel!.members?.cast().firstWhere( + (ele) => ele?.accountId != ua.user?.id, + orElse: () => null, + ); + } } catch (err) { if (!mounted) return; context.showErrorDialog(err); @@ -183,15 +206,18 @@ class _ChatRoomScreenState extends State { @override Widget build(BuildContext context) { final call = context.watch(); + final ud = context.read(); return Scaffold( appBar: AppBar( - title: Text(_channel?.name ?? 'loading'.tr()), + title: Text( + _channel?.type == 1 + ? ud.getAccountFromCache(_otherMember?.accountId)?.nick ?? _channel!.name + : _channel?.name ?? 'loading'.tr(), + ), actions: [ IconButton( - icon: _ongoingCall == null - ? const Icon(Symbols.call) - : const Icon(Symbols.call_end), + icon: _ongoingCall == null ? const Icon(Symbols.call) : const Icon(Symbols.call_end), onPressed: _isCalling ? null : _ongoingCall == null @@ -241,9 +267,9 @@ class _ChatRoomScreenState extends State { ) ], ), - ).height(_ongoingCall != null ? 54 : 0, animate: true).animate( - const Duration(milliseconds: 300), - Curves.fastLinearToSlowEaseIn), + ) + .height(_ongoingCall != null ? 54 : 0, animate: true) + .animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn), if (_messageController.isPending) Expanded( child: const CircularProgressIndicator().center(), @@ -284,8 +310,7 @@ class _ChatRoomScreenState extends State { data: message, isMerged: canMerge, hasMerged: canMergePrevious, - isPending: _messageController.unconfirmedMessages - .contains(message.uuid), + isPending: _messageController.unconfirmedMessages.contains(message.uuid), onReply: (value) { _inputGlobalKey.currentState?.setReply(value); }, @@ -304,6 +329,7 @@ class _ChatRoomScreenState extends State { elevation: 2, child: ChatMessageInput( key: _inputGlobalKey, + otherMember: _otherMember, controller: _messageController, ).padding(bottom: MediaQuery.of(context).padding.bottom), ), diff --git a/lib/types/chat.dart b/lib/types/chat.dart index 2ce9728..ff51476 100644 --- a/lib/types/chat.dart +++ b/lib/types/chat.dart @@ -21,7 +21,7 @@ class SnChannel with _$SnChannel { @HiveField(4) required String alias, @HiveField(5) required String name, @HiveField(6) required String description, - @HiveField(7) required List? members, + @HiveField(7) required List? members, List? messages, @HiveField(8) required int type, @HiveField(9) required int accountId, diff --git a/lib/types/chat.freezed.dart b/lib/types/chat.freezed.dart index f320b1b..ae3b0e9 100644 --- a/lib/types/chat.freezed.dart +++ b/lib/types/chat.freezed.dart @@ -35,7 +35,7 @@ mixin _$SnChannel { @HiveField(6) String get description => throw _privateConstructorUsedError; @HiveField(7) - List? get members => throw _privateConstructorUsedError; + List? get members => throw _privateConstructorUsedError; List? get messages => throw _privateConstructorUsedError; @HiveField(8) int get type => throw _privateConstructorUsedError; @@ -73,7 +73,7 @@ abstract class $SnChannelCopyWith<$Res> { @HiveField(4) String alias, @HiveField(5) String name, @HiveField(6) String description, - @HiveField(7) List? members, + @HiveField(7) List? members, List? messages, @HiveField(8) int type, @HiveField(9) int accountId, @@ -148,7 +148,7 @@ class _$SnChannelCopyWithImpl<$Res, $Val extends SnChannel> members: freezed == members ? _value.members : members // ignore: cast_nullable_to_non_nullable - as List?, + as List?, messages: freezed == messages ? _value.messages : messages // ignore: cast_nullable_to_non_nullable @@ -211,7 +211,7 @@ abstract class _$$SnChannelImplCopyWith<$Res> @HiveField(4) String alias, @HiveField(5) String name, @HiveField(6) String description, - @HiveField(7) List? members, + @HiveField(7) List? members, List? messages, @HiveField(8) int type, @HiveField(9) int accountId, @@ -285,7 +285,7 @@ class __$$SnChannelImplCopyWithImpl<$Res> members: freezed == members ? _value._members : members // ignore: cast_nullable_to_non_nullable - as List?, + as List?, messages: freezed == messages ? _value._messages : messages // ignore: cast_nullable_to_non_nullable @@ -330,7 +330,7 @@ class _$SnChannelImpl extends _SnChannel { @HiveField(4) required this.alias, @HiveField(5) required this.name, @HiveField(6) required this.description, - @HiveField(7) required final List? members, + @HiveField(7) required final List? members, final List? messages, @HiveField(8) required this.type, @HiveField(9) required this.accountId, @@ -366,10 +366,10 @@ class _$SnChannelImpl extends _SnChannel { @override @HiveField(6) final String description; - final List? _members; + final List? _members; @override @HiveField(7) - List? get members { + List? get members { final value = _members; if (value == null) return null; if (_members is EqualUnmodifiableListView) return _members; @@ -484,7 +484,7 @@ abstract class _SnChannel extends SnChannel { @HiveField(4) required final String alias, @HiveField(5) required final String name, @HiveField(6) required final String description, - @HiveField(7) required final List? members, + @HiveField(7) required final List? members, final List? messages, @HiveField(8) required final int type, @HiveField(9) required final int accountId, @@ -520,7 +520,7 @@ abstract class _SnChannel extends SnChannel { String get description; @override @HiveField(7) - List? get members; + List? get members; @override List? get messages; @override diff --git a/lib/types/chat.g.dart b/lib/types/chat.g.dart index d1bd56b..fb37dc7 100644 --- a/lib/types/chat.g.dart +++ b/lib/types/chat.g.dart @@ -24,7 +24,7 @@ class SnChannelImplAdapter extends TypeAdapter<_$SnChannelImpl> { alias: fields[4] as String, name: fields[5] as String, description: fields[6] as String, - members: (fields[7] as List?)?.cast(), + members: (fields[7] as List?)?.cast(), type: fields[8] as int, accountId: fields[9] as int, realm: fields[10] as SnRealm?, @@ -223,7 +223,9 @@ _$SnChannelImpl _$$SnChannelImplFromJson(Map json) => alias: json['alias'] as String, name: json['name'] as String, description: json['description'] as String, - members: json['members'] as List?, + members: (json['members'] as List?) + ?.map((e) => SnChannelMember.fromJson(e as Map)) + .toList(), messages: (json['messages'] as List?) ?.map((e) => SnChatMessage.fromJson(e as Map)) .toList(), @@ -246,7 +248,7 @@ Map _$$SnChannelImplToJson(_$SnChannelImpl instance) => 'alias': instance.alias, 'name': instance.name, 'description': instance.description, - 'members': instance.members, + 'members': instance.members?.map((e) => e.toJson()).toList(), 'messages': instance.messages?.map((e) => e.toJson()).toList(), 'type': instance.type, 'account_id': instance.accountId, diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart index 6fe8e2e..c1a773b 100644 --- a/lib/widgets/chat/chat_message_input.dart +++ b/lib/widgets/chat/chat_message_input.dart @@ -17,10 +17,13 @@ import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/markdown_content.dart'; import 'package:surface/widgets/post/post_media_pending_list.dart'; +import '../../providers/user_directory.dart'; + class ChatMessageInput extends StatefulWidget { final ChatMessageController controller; + final SnChannelMember? otherMember; - const ChatMessageInput({super.key, required this.controller}); + const ChatMessageInput({super.key, required this.controller, this.otherMember}); @override State createState() => ChatMessageInputState(); @@ -170,6 +173,8 @@ class ChatMessageInputState extends State { @override Widget build(BuildContext context) { + final ud = context.read(); + return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -279,7 +284,11 @@ class ChatMessageInputState extends State { controller: _contentController, decoration: InputDecoration( isCollapsed: true, - hintText: 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]), + hintText: widget.otherMember != null + ? 'fieldChatMessageDirect'.tr(args: [ + '@${ud.getAccountFromCache(widget.otherMember?.accountId)?.name}', + ]) + : 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]), border: InputBorder.none, ), onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),