Direct messages

This commit is contained in:
LittleSheep 2024-12-08 13:45:51 +08:00
parent 4805e68fcd
commit 669107a99f
8 changed files with 219 additions and 39 deletions

View File

@ -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 {}"
}

View File

@ -242,6 +242,7 @@
"realmMemberAddDescription": "给当前领域添加新成员。",
"realmMemberAdded": "领域成员已添加。",
"fieldChatMessage": "在 {} 中发消息",
"fieldChatMessageDirect": "给 {} 发消息",
"eventResourceTag": "消息 {}",
"messageDelete": "删除消息 {}",
"messageDeleteDescription": "你确定要删除这个消息吗?该操作不可撤销。同时您将留下一条删除消息的记录。",
@ -402,5 +403,8 @@
"accountDeletion": "删除帐户",
"accountDeletionDescription": "你确定要删除这个帐户吗?该操作不可撤销,其隶属于该帐户的所有资源(帖子、聊天频道、发布者、制品等)都将被永久删除。三思而后行!",
"accountDeletionActionDescription": "删除你的 Solarpass 帐户。",
"accountDeletionSubmitted": "帐户删除申请已发出,你可以检查你的收件箱并根据邮件内的指示完成删除操作。"
"accountDeletionSubmitted": "帐户删除申请已发出,你可以检查你的收件箱并根据邮件内的指示完成删除操作。",
"channelNewChannel": "新建频道",
"channelNewDirectMessage": "发起私信",
"channelDirectMessageDescription": "与 {} 的私聊"
}

View File

@ -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<ChatScreen> {
final _fabKey = GlobalKey<ExpandableFabState>();
bool _isBusy = true;
List<SnChannel>? _channels;
@ -30,17 +39,29 @@ class _ChatScreenState extends State<ChatScreen> {
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<UserDirectoryProvider>();
for (final channel in channels) {
if (channel.type == 1) {
await ud.listAccount(
channel.members
?.cast<SnChannelMember?>()
.map((ele) => ele?.accountId)
.where((ele) => ele != null)
.toSet() ??
{},
);
}
}
if (mounted) setState(() => _channels = channels);
})
..onError((err) {
@ -54,6 +75,32 @@ class _ChatScreenState extends State<ChatScreen> {
});
}
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<SnNetworkProvider>();
final ua = context.read<UserProvider>();
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<ChatScreen> {
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
return Scaffold(
appBar: AppBar(
leading: AutoAppBarLeading(),
title: Text('screenChat').tr(),
),
floatingActionButton: FloatingActionButton(
child: const Icon(Symbols.chat_add_on),
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<ChatScreen> {
itemBuilder: (context, idx) {
final channel = _channels![idx];
final lastMessage = _lastMessages?[channel.id];
if (channel.type == 1) {
final otherMember = channel.members?.cast<SnChannelMember?>().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

View File

@ -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<ChatRoomScreen> {
bool _isCalling = false;
SnChannel? _channel;
SnChannelMember? _otherMember;
SnChatCall? _ongoingCall;
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
@ -50,6 +55,24 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
try {
final chan = context.read<ChatChannelProvider>();
_channel = await chan.getChannel('${widget.scope}:${widget.alias}');
if (!mounted || _channel == null) return;
final ud = context.read<UserDirectoryProvider>();
final ua = context.read<UserProvider>();
if (_channel!.type == 1) {
await ud.listAccount(
_channel!.members
?.cast<SnChannelMember?>()
.map((ele) => ele?.accountId)
.where((ele) => ele != null && ele != ua.user?.id)
.toSet() ??
{},
);
_otherMember = _channel!.members?.cast<SnChannelMember?>().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<ChatRoomScreen> {
@override
Widget build(BuildContext context) {
final call = context.watch<ChatCallProvider>();
final ud = context.read<UserDirectoryProvider>();
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<ChatRoomScreen> {
)
],
),
).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<ChatRoomScreen> {
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<ChatRoomScreen> {
elevation: 2,
child: ChatMessageInput(
key: _inputGlobalKey,
otherMember: _otherMember,
controller: _messageController,
).padding(bottom: MediaQuery.of(context).padding.bottom),
),

View File

@ -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<dynamic>? members,
@HiveField(7) required List<SnChannelMember>? members,
List<SnChatMessage>? messages,
@HiveField(8) required int type,
@HiveField(9) required int accountId,

View File

@ -35,7 +35,7 @@ mixin _$SnChannel {
@HiveField(6)
String get description => throw _privateConstructorUsedError;
@HiveField(7)
List<dynamic>? get members => throw _privateConstructorUsedError;
List<SnChannelMember>? get members => throw _privateConstructorUsedError;
List<SnChatMessage>? 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<dynamic>? members,
@HiveField(7) List<SnChannelMember>? members,
List<SnChatMessage>? 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<dynamic>?,
as List<SnChannelMember>?,
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<dynamic>? members,
@HiveField(7) List<SnChannelMember>? members,
List<SnChatMessage>? 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<dynamic>?,
as List<SnChannelMember>?,
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<dynamic>? members,
@HiveField(7) required final List<SnChannelMember>? members,
final List<SnChatMessage>? 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<dynamic>? _members;
final List<SnChannelMember>? _members;
@override
@HiveField(7)
List<dynamic>? get members {
List<SnChannelMember>? 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<dynamic>? members,
@HiveField(7) required final List<SnChannelMember>? members,
final List<SnChatMessage>? 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<dynamic>? get members;
List<SnChannelMember>? get members;
@override
List<SnChatMessage>? get messages;
@override

View File

@ -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<dynamic>(),
members: (fields[7] as List?)?.cast<SnChannelMember>(),
type: fields[8] as int,
accountId: fields[9] as int,
realm: fields[10] as SnRealm?,
@ -223,7 +223,9 @@ _$SnChannelImpl _$$SnChannelImplFromJson(Map<String, dynamic> json) =>
alias: json['alias'] as String,
name: json['name'] as String,
description: json['description'] as String,
members: json['members'] as List<dynamic>?,
members: (json['members'] as List<dynamic>?)
?.map((e) => SnChannelMember.fromJson(e as Map<String, dynamic>))
.toList(),
messages: (json['messages'] as List<dynamic>?)
?.map((e) => SnChatMessage.fromJson(e as Map<String, dynamic>))
.toList(),
@ -246,7 +248,7 @@ Map<String, dynamic> _$$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,

View File

@ -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<ChatMessageInput> createState() => ChatMessageInputState();
@ -170,6 +173,8 @@ class ChatMessageInputState extends State<ChatMessageInput> {
@override
Widget build(BuildContext context) {
final ud = context.read<UserDirectoryProvider>();
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -279,7 +284,11 @@ class ChatMessageInputState extends State<ChatMessageInput> {
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(),