Full functional message chat

This commit is contained in:
LittleSheep 2024-05-30 23:14:29 +08:00
parent 2716690c41
commit 9a2e0756b8
12 changed files with 352 additions and 72 deletions

View File

@ -15,6 +15,7 @@ import 'package:solian/router.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/chat/chat_message.dart'; import 'package:solian/widgets/chat/chat_message.dart';
import 'package:solian/widgets/chat/chat_message_action.dart';
import 'package:solian/widgets/chat/chat_message_input.dart'; import 'package:solian/widgets/chat/chat_message_input.dart';
class ChannelChatScreen extends StatefulWidget { class ChannelChatScreen extends StatefulWidget {
@ -117,17 +118,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
case 'messages.update': case 'messages.update':
final payload = Message.fromJson(event.payload!); final payload = Message.fromJson(event.payload!);
if (payload.channelId == _channel?.id) { if (payload.channelId == _channel?.id) {
_pagingController.itemList final idx = _pagingController.itemList
?.map((x) => x.id == payload.id ? payload : x) ?.indexWhere((x) => x.uuid == payload.uuid);
.toList(); if (idx != null) {
_pagingController.itemList?[idx] = payload;
}
} }
break; break;
case 'messages.burnt': case 'messages.burnt':
final payload = Message.fromJson(event.payload!); final payload = Message.fromJson(event.payload!);
if (payload.channelId == _channel?.id) { if (payload.channelId == _channel?.id) {
_pagingController.itemList = _pagingController.itemList final idx = _pagingController.itemList
?.where((x) => x.id != payload.id) ?.indexWhere((x) => x.uuid != payload.uuid);
.toList(); if (idx != null) {
_pagingController.itemList?.removeAt(idx - 1);
}
} }
break; break;
} }
@ -142,6 +147,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return a.createdAt.difference(b.createdAt).inMinutes <= 3; return a.createdAt.difference(b.createdAt).inMinutes <= 3;
} }
Message? _messageToReplying;
Message? _messageToEditing;
Widget chatHistoryBuilder(context, item, index) { Widget chatHistoryBuilder(context, item, index) {
bool isMerged = false, hasMerged = false; bool isMerged = false, hasMerged = false;
if (index > 0) { if (index > 0) {
@ -158,16 +166,31 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
} }
return InkWell( return InkWell(
child: Container( child: Container(
padding: EdgeInsets.only(
top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0,
),
child: ChatMessage( child: ChatMessage(
item: item, item: item,
isMerged: isMerged, isMerged: isMerged,
).paddingOnly(
top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0,
), ),
), ),
onLongPress: () {}, onLongPress: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ChatMessageAction(
channel: _channel!,
realm: _channel!.realm,
item: item,
onEdit: () {
setState(() => _messageToEditing = item);
},
onReply: () {
setState(() => _messageToReplying = item);
},
),
);
},
); );
} }
@ -254,6 +277,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
left: 16, left: 16,
right: 16, right: 16,
child: ChatMessageInput( child: ChatMessageInput(
edit: _messageToEditing,
reply: _messageToReplying,
realm: widget.realm, realm: widget.realm,
placeholder: placeholder, placeholder: placeholder,
channel: _channel!, channel: _channel!,
@ -262,6 +287,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_pagingController.itemList?.insert(0, item); _pagingController.itemList?.insert(0, item);
}); });
}, },
onReset: () {
setState(() {
_messageToReplying = null;
_messageToEditing = null;
});
},
), ),
), ),
], ],

View File

@ -48,7 +48,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
void promptLeaveChannel() async { void promptLeaveChannel() async {
final did = await showDialog( final did = await showDialog(
context: context, context: context,
builder: (context) => ChannelDeletion( builder: (context) => ChannelDeletionDialog(
channel: widget.channel, channel: widget.channel,
realm: widget.realm, realm: widget.realm,
isOwned: _isOwned, isOwned: _isOwned,

View File

@ -46,7 +46,7 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
void promptLeaveChannel() async { void promptLeaveChannel() async {
final did = await showDialog( final did = await showDialog(
context: context, context: context,
builder: (context) => RealmDeletion( builder: (context) => RealmDeletionDialog(
realm: widget.realm, realm: widget.realm,
isOwned: _isOwned, isOwned: _isOwned,
), ),

View File

@ -149,6 +149,10 @@ class SolianMessages extends Translations {
'messageDecoding': 'Decoding...', 'messageDecoding': 'Decoding...',
'messageDecodeFailed': 'Unable to decode: @message', 'messageDecodeFailed': 'Unable to decode: @message',
'messageInputPlaceholder': 'Message @channel', 'messageInputPlaceholder': 'Message @channel',
'messageActionList': 'Actions of Message',
'messageDeletionConfirm': 'Confirm message deletion',
'messageDeletionConfirmCaption':
'Are your sure to delete message @id? This action cannot be undone!',
}, },
'zh_CN': { 'zh_CN': {
'hide': '隐藏', 'hide': '隐藏',
@ -287,6 +291,9 @@ class SolianMessages extends Translations {
'messageDecoding': '解码信息中…', 'messageDecoding': '解码信息中…',
'messageDecodeFailed': '解码信息失败:@message', 'messageDecodeFailed': '解码信息失败:@message',
'messageInputPlaceholder': '在 @channel 发信息', 'messageInputPlaceholder': '在 @channel 发信息',
'messageActionList': '消息的操作',
'messageDeletionConfirm': '确认删除消息',
'messageDeletionConfirmCaption': '你确定要删除消息 @id 吗?该操作不可撤销。',
} }
}; };
} }

View File

@ -5,12 +5,12 @@ import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
class ChannelDeletion extends StatefulWidget { class ChannelDeletionDialog extends StatefulWidget {
final Channel channel; final Channel channel;
final String realm; final String realm;
final bool isOwned; final bool isOwned;
const ChannelDeletion({ const ChannelDeletionDialog({
super.key, super.key,
required this.channel, required this.channel,
required this.realm, required this.realm,
@ -18,10 +18,10 @@ class ChannelDeletion extends StatefulWidget {
}); });
@override @override
State<ChannelDeletion> createState() => _ChannelDeletionState(); State<ChannelDeletionDialog> createState() => _ChannelDeletionDialogState();
} }
class _ChannelDeletionState extends State<ChannelDeletion> { class _ChannelDeletionDialogState extends State<ChannelDeletionDialog> {
bool _isBusy = false; bool _isBusy = false;
Future<void> deleteChannel() async { Future<void> deleteChannel() async {
@ -30,7 +30,7 @@ class _ChannelDeletionState extends State<ChannelDeletion> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging']; client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator); client.httpClient.addAuthenticator(auth.requestAuthenticator);
@ -51,7 +51,7 @@ class _ChannelDeletionState extends State<ChannelDeletion> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging']; client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator); client.httpClient.addAuthenticator(auth.requestAuthenticator);

View File

@ -39,7 +39,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
void getMembers() async { void getMembers() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging']; client.httpClient.baseUrl = ServiceFinder.services['messaging'];
final resp = await client final resp = await client
@ -76,7 +76,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging']; client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator); client.httpClient.addAuthenticator(auth.requestAuthenticator);
@ -99,7 +99,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging']; client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator); client.httpClient.addAuthenticator(auth.requestAuthenticator);

View File

@ -10,6 +10,7 @@ import 'package:url_launcher/url_launcher_string.dart';
class ChatMessage extends StatelessWidget { class ChatMessage extends StatelessWidget {
final Message item; final Message item;
final bool isContentPreviewing;
final bool isCompact; final bool isCompact;
final bool isMerged; final bool isMerged;
final bool isHasMerged; final bool isHasMerged;
@ -17,6 +18,7 @@ class ChatMessage extends StatelessWidget {
const ChatMessage({ const ChatMessage({
super.key, super.key,
required this.item, required this.item,
this.isContentPreviewing = false,
this.isMerged = false, this.isMerged = false,
this.isHasMerged = false, this.isHasMerged = false,
this.isCompact = false, this.isCompact = false,
@ -94,7 +96,9 @@ class ChatMessage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget widget; Widget widget;
if (isMerged) { if (isContentPreviewing) {
widget = buildContent();
} else if (isMerged) {
widget = buildContent().paddingOnly(left: 52); widget = buildContent().paddingOnly(left: 52);
} else if (isCompact) { } else if (isCompact) {
widget = Row( widget = Row(
@ -139,6 +143,7 @@ class ChatMessage extends StatelessWidget {
), ),
], ],
).paddingSymmetric(horizontal: 12), ).paddingSymmetric(horizontal: 12),
if (item.attachments?.isNotEmpty ?? false)
AttachmentList( AttachmentList(
parentId: item.uuid, parentId: item.uuid,
attachmentsId: item.attachments ?? List.empty(), attachmentsId: item.attachments ?? List.empty(),

View File

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/chat/chat_message_deletion.dart';
class ChatMessageAction extends StatefulWidget {
final Channel channel;
final Realm? realm;
final Message item;
final Function? onEdit;
final Function? onReply;
const ChatMessageAction({
super.key,
required this.channel,
required this.realm,
required this.item,
this.onEdit,
this.onReply,
});
@override
State<ChatMessageAction> createState() => _ChatMessageActionState();
}
class _ChatMessageActionState extends State<ChatMessageAction> {
bool _isBusy = false;
bool _canModifyContent = false;
void checkAbleToModifyContent() async {
final AuthProvider provider = Get.find();
if (!await provider.isAuthorized) return;
setState(() => _isBusy = true);
final prof = await provider.getProfile();
setState(() {
_canModifyContent =
prof.body?['id'] == widget.item.sender.account.externalId;
_isBusy = false;
});
}
@override
void initState() {
super.initState();
checkAbleToModifyContent();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'messageActionList'.tr,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
'#${widget.item.id.toString().padLeft(8, '0')}',
style: Theme.of(context).textTheme.bodySmall,
),
],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
Expanded(
child: ListView(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const FaIcon(FontAwesomeIcons.reply, size: 20),
title: Text('reply'.tr),
onTap: () async {
if (widget.onReply != null) widget.onReply!();
Navigator.pop(context);
},
),
if (_canModifyContent)
const Divider(thickness: 0.3, height: 0.3)
.paddingSymmetric(vertical: 16),
if (_canModifyContent)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.edit),
title: Text('edit'.tr),
onTap: () async {
if (widget.onEdit != null) widget.onEdit!();
Navigator.pop(context);
},
),
if (_canModifyContent)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.delete),
title: Text('delete'.tr),
onTap: () async {
final value = await showDialog(
context: context,
builder: (context) => ChatMessageDeletionDialog(
channel: widget.channel,
realm: widget.realm,
item: widget.item,
),
);
if (value != null) {
Navigator.pop(context, true);
}
},
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
class ChatMessageDeletionDialog extends StatefulWidget {
final Channel channel;
final Realm? realm;
final Message item;
const ChatMessageDeletionDialog({
super.key,
required this.channel,
required this.realm,
required this.item,
});
@override
State<ChatMessageDeletionDialog> createState() =>
_ChatMessageDeletionDialogState();
}
class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> {
bool _isBusy = false;
void performAction() async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
setState(() => _isBusy = true);
final scope = (widget.realm?.alias.isNotEmpty ?? false)
? widget.realm?.alias
: 'global';
final resp = await client.delete(
'/api/channels/$scope/${widget.channel.alias}/messages/${widget.item.id}',
);
if (resp.statusCode == 200) {
Navigator.pop(context, resp.body);
} else {
context.showErrorDialog(resp.bodyString);
setState(() => _isBusy = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('messageDeletionConfirm'.tr),
content: Text('messageDeletionConfirmCaption'.trParams({
'id': '#${widget.item.id}',
})),
actions: <Widget>[
TextButton(
onPressed: _isBusy ? null : () => Navigator.pop(context, false),
child: Text('cancel'.tr),
),
TextButton(
onPressed: _isBusy ? null : performAction,
child: Text('confirm'.tr),
),
],
);
}
}

View File

@ -7,6 +7,7 @@ import 'package:solian/models/message.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/attachments/attachment_publish.dart'; import 'package:solian/widgets/attachments/attachment_publish.dart';
import 'package:solian/widgets/chat/chat_message.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
class ChatMessageInput extends StatefulWidget { class ChatMessageInput extends StatefulWidget {
@ -16,6 +17,7 @@ class ChatMessageInput extends StatefulWidget {
final Channel channel; final Channel channel;
final String realm; final String realm;
final Function(Message) onSent; final Function(Message) onSent;
final Function()? onReset;
const ChatMessageInput({ const ChatMessageInput({
super.key, super.key,
@ -25,6 +27,7 @@ class ChatMessageInput extends StatefulWidget {
required this.channel, required this.channel,
required this.realm, required this.realm,
required this.onSent, required this.onSent,
this.onReset,
}); });
@override @override
@ -99,7 +102,7 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
senderId: sender.id, senderId: sender.id,
); );
widget.onSent(message); if (widget.edit == null) widget.onSent(message);
resetInput(); resetInput();
Response resp; Response resp;
@ -121,21 +124,53 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
} }
void resetInput() { void resetInput() {
if (widget.onReset != null) widget.onReset!();
_textController.clear(); _textController.clear();
} }
void syncWidget() {
if (widget.edit != null) {
_textController.text = widget.edit!.content['value'];
}
}
@override
void didUpdateWidget(covariant ChatMessageInput oldWidget) {
syncWidget();
super.didUpdateWidget(oldWidget);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const double height = 56; const borderRadius = BorderRadius.all(Radius.circular(20));
const borderRadius = BorderRadius.all(Radius.circular(height / 2));
final notifyBannerActions = [
TextButton(
onPressed: resetInput,
child: Text('cancel'.tr),
)
];
return Material( return Material(
borderRadius: borderRadius, borderRadius: borderRadius,
elevation: 2, elevation: 2,
child: ClipRRect( child: ClipRRect(
borderRadius: borderRadius, borderRadius: borderRadius,
child: SizedBox( child: Column(
height: height, mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.edit != null)
MaterialBanner(
leading: const Icon(Icons.edit),
dividerColor: Colors.transparent,
content: ChatMessage(
item: widget.edit!,
isContentPreviewing: true,
),
actions: notifyBannerActions,
),
SizedBox(
height: 56,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
@ -170,6 +205,8 @@ class _ChatMessageInputState extends State<ChatMessageInput> {
], ],
).paddingOnly(left: 16, right: 4), ).paddingOnly(left: 16, right: 4),
), ),
],
),
), ),
); );
} }

View File

@ -5,21 +5,21 @@ import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
class RealmDeletion extends StatefulWidget { class RealmDeletionDialog extends StatefulWidget {
final Realm realm; final Realm realm;
final bool isOwned; final bool isOwned;
const RealmDeletion({ const RealmDeletionDialog({
super.key, super.key,
required this.realm, required this.realm,
required this.isOwned, required this.isOwned,
}); });
@override @override
State<RealmDeletion> createState() => _RealmDeletionState(); State<RealmDeletionDialog> createState() => _RealmDeletionDialogState();
} }
class _RealmDeletionState extends State<RealmDeletion> { class _RealmDeletionDialogState extends State<RealmDeletionDialog> {
bool _isBusy = false; bool _isBusy = false;
Future<void> deleteChannel() async { Future<void> deleteChannel() async {
@ -28,7 +28,7 @@ class _RealmDeletionState extends State<RealmDeletion> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['passport']; client.httpClient.baseUrl = ServiceFinder.services['passport'];
client.httpClient.addAuthenticator(auth.requestAuthenticator); client.httpClient.addAuthenticator(auth.requestAuthenticator);
@ -48,7 +48,7 @@ class _RealmDeletionState extends State<RealmDeletion> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['passport']; client.httpClient.baseUrl = ServiceFinder.services['passport'];
client.httpClient.addAuthenticator(auth.requestAuthenticator); client.httpClient.addAuthenticator(auth.requestAuthenticator);

View File

@ -37,7 +37,7 @@ class _RealmMemberListPopupState extends State<RealmMemberListPopup> {
void getMembers() async { void getMembers() async {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['passport']; client.httpClient.baseUrl = ServiceFinder.services['passport'];
final resp = await client.get('/api/realms/${widget.realm.alias}/members'); final resp = await client.get('/api/realms/${widget.realm.alias}/members');
@ -73,7 +73,7 @@ class _RealmMemberListPopupState extends State<RealmMemberListPopup> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['passport']; client.httpClient.baseUrl = ServiceFinder.services['passport'];
client.httpClient.addAuthenticator(auth.requestAuthenticator); client.httpClient.addAuthenticator(auth.requestAuthenticator);
@ -96,7 +96,7 @@ class _RealmMemberListPopupState extends State<RealmMemberListPopup> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
final client = GetConnect(); final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['passport']; client.httpClient.baseUrl = ServiceFinder.services['passport'];
client.httpClient.addAuthenticator(auth.requestAuthenticator); client.httpClient.addAuthenticator(auth.requestAuthenticator);