✨ Direct messages
This commit is contained in:
@ -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
|
||||
|
@ -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),
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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(),
|
||||
|
Reference in New Issue
Block a user