Direct messages

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

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),
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<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(),