✨ Chat messaging
This commit is contained in:
@ -5,12 +5,19 @@ import 'package:get/get.dart';
|
||||
import 'package:solian/models/message.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:timeago/timeago.dart' show format;
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ChatMessage extends StatelessWidget {
|
||||
final Message item;
|
||||
final bool isCompact;
|
||||
final bool isMerged;
|
||||
|
||||
const ChatMessage({super.key, required this.item, required this.isCompact});
|
||||
const ChatMessage({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.isMerged = false,
|
||||
this.isCompact = false,
|
||||
});
|
||||
|
||||
Future<String?> decodeContent(Map<String, dynamic> content) async {
|
||||
String? text;
|
||||
@ -27,78 +34,117 @@ class ChatMessage extends StatelessWidget {
|
||||
return text;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget buildContent() {
|
||||
final hasAttachment = item.attachments?.isNotEmpty ?? false;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountAvatar(content: item.sender.account.avatar),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.sender.account.nick,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(format(item.createdAt, locale: 'en_short'))
|
||||
],
|
||||
).paddingSymmetric(horizontal: 12),
|
||||
FutureBuilder(
|
||||
future: decodeContent(item.content),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return Opacity(
|
||||
opacity: 0.8,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.more_horiz),
|
||||
Text('messageDecoding'.tr)
|
||||
],
|
||||
),
|
||||
)
|
||||
.animate(onPlay: (c) => c.repeat())
|
||||
.fade(begin: 0, end: 1);
|
||||
} else if (snapshot.hasError) {
|
||||
return Opacity(
|
||||
opacity: 0.9,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.close),
|
||||
Text(
|
||||
'messageDecodeFailed'.trParams(
|
||||
{'message': snapshot.error.toString()}),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Markdown(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: snapshot.data ?? '',
|
||||
padding: const EdgeInsets.all(0),
|
||||
).paddingOnly(
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 2,
|
||||
bottom: hasAttachment ? 4 : 0,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
return FutureBuilder(
|
||||
future: decodeContent(item.content),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return Opacity(
|
||||
opacity: 0.8,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.more_horiz),
|
||||
const SizedBox(width: 4),
|
||||
Text('messageDecoding'.tr)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).animate(onPlay: (c) => c.repeat()).fade(begin: 0, end: 1);
|
||||
} else if (snapshot.hasError) {
|
||||
return Opacity(
|
||||
opacity: 0.9,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.close),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'messageDecodeFailed'
|
||||
.trParams({'message': snapshot.error.toString()}),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Markdown(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: snapshot.data ?? '',
|
||||
padding: const EdgeInsets.all(0),
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href == null) return;
|
||||
await launchUrlString(
|
||||
href,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
).paddingOnly(
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 2,
|
||||
bottom: hasAttachment ? 4 : 0,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget widget;
|
||||
if (isMerged) {
|
||||
widget = buildContent().paddingOnly(left: 40);
|
||||
} else if (isCompact) {
|
||||
widget = Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountAvatar(content: item.sender.account.avatar, radius: 8),
|
||||
Text(
|
||||
item.sender.account.nick,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(format(item.createdAt, locale: 'en_short')),
|
||||
const SizedBox(width: 4),
|
||||
buildContent(),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
widget = Column(
|
||||
key: Key('m${item.uuid}'),
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AccountAvatar(content: item.sender.account.avatar),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
item.sender.account.nick,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(format(item.createdAt, locale: 'en_short'))
|
||||
],
|
||||
).paddingSymmetric(horizontal: 12),
|
||||
buildContent(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (item.isSending) {
|
||||
return Opacity(opacity: 0.65, child: widget);
|
||||
} else {
|
||||
return widget;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
158
lib/widgets/chat/chat_message_input.dart
Normal file
158
lib/widgets/chat/chat_message_input.dart
Normal file
@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/account.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/models/message.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChatMessageInput extends StatefulWidget {
|
||||
final Message? edit;
|
||||
final Message? reply;
|
||||
final Channel channel;
|
||||
final String realm;
|
||||
final Function(Message) onSent;
|
||||
|
||||
const ChatMessageInput({
|
||||
super.key,
|
||||
this.edit,
|
||||
this.reply,
|
||||
required this.channel,
|
||||
required this.realm,
|
||||
required this.onSent,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatMessageInput> createState() => _ChatMessageInputState();
|
||||
}
|
||||
|
||||
class _ChatMessageInputState extends State<ChatMessageInput> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
List<int> _attachments = List.empty(growable: true);
|
||||
|
||||
Map<String, dynamic> encodeMessage(String content) {
|
||||
// TODO Impl E2EE
|
||||
|
||||
return {
|
||||
'value': content,
|
||||
'keypair_id': null,
|
||||
'algorithm': 'plain',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> sendMessage() async {
|
||||
_focusNode.requestFocus();
|
||||
|
||||
final AuthProvider auth = Get.find();
|
||||
final prof = await auth.getProfile();
|
||||
if (!await auth.isAuthorized) return;
|
||||
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
final payload = {
|
||||
'uuid': const Uuid().v4(),
|
||||
'type': 'm.text',
|
||||
'content': encodeMessage(_textController.value.text),
|
||||
'attachments': _attachments,
|
||||
'reply_to': widget.reply?.id,
|
||||
};
|
||||
|
||||
// The mock data
|
||||
final sender = Sender(
|
||||
id: 0,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
account: Account.fromJson(prof.body),
|
||||
channelId: widget.channel.id,
|
||||
accountId: prof.body['id'],
|
||||
notify: 0,
|
||||
);
|
||||
final message = Message(
|
||||
id: 0,
|
||||
uuid: payload['uuid'] as String,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
content: payload['content'] as Map<String, dynamic>,
|
||||
type: payload['type'] as String,
|
||||
sender: sender,
|
||||
replyId: widget.reply?.id,
|
||||
replyTo: widget.reply,
|
||||
channelId: widget.channel.id,
|
||||
senderId: sender.id,
|
||||
);
|
||||
|
||||
widget.onSent(message);
|
||||
resetInput();
|
||||
|
||||
Response resp;
|
||||
if (widget.edit != null) {
|
||||
resp = await client.put(
|
||||
'/api/channels/${widget.realm}/${widget.channel.alias}/messages/${widget.edit!.id}',
|
||||
payload,
|
||||
);
|
||||
} else {
|
||||
resp = await client.post(
|
||||
'/api/channels/${widget.realm}/${widget.channel.alias}/messages',
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
if (resp.statusCode != 200) {
|
||||
context.showErrorDialog(resp.bodyString);
|
||||
}
|
||||
}
|
||||
|
||||
void resetInput() {
|
||||
_textController.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const double height = 56;
|
||||
const borderRadius = BorderRadius.all(Radius.circular(height / 2));
|
||||
|
||||
return Material(
|
||||
borderRadius: borderRadius,
|
||||
elevation: 2,
|
||||
child: ClipRRect(
|
||||
borderRadius: borderRadius,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textController,
|
||||
focusNode: _focusNode,
|
||||
maxLines: null,
|
||||
autocorrect: true,
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: 'messageInputPlaceholder'.trParams(
|
||||
{'channel': '#${widget.channel.alias}'},
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => sendMessage(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: () => sendMessage(),
|
||||
)
|
||||
],
|
||||
).paddingOnly(left: 16, right: 4),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -154,7 +154,7 @@ class _PostDeletionDialogState extends State<PostDeletionDialog> {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) return;
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['interactive'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
@ -9,6 +9,7 @@ import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/attachments/attachment_list.dart';
|
||||
import 'package:solian/widgets/posts/post_quick_action.dart';
|
||||
import 'package:timeago/timeago.dart' show format;
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class PostItem extends StatefulWidget {
|
||||
final Post item;
|
||||
@ -132,6 +133,13 @@ class _PostItemState extends State<PostItem> {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: item.content,
|
||||
padding: const EdgeInsets.all(0),
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href == null) return;
|
||||
await launchUrlString(
|
||||
href,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
).paddingOnly(
|
||||
left: 16,
|
||||
right: 12,
|
||||
|
@ -51,7 +51,7 @@ class _PostQuickActionState extends State<PostQuickAction> {
|
||||
if (_isSubmitting) return;
|
||||
if (!await auth.isAuthorized) return;
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['interactive'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
Reference in New Issue
Block a user