✨ Chat messaging
This commit is contained in:
parent
5b45718ebd
commit
9cb2b9122e
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/providers/account.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/providers/content/attachment.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/providers/content/post.dart';
|
||||
@ -36,6 +37,7 @@ class SolianApp extends StatelessWidget {
|
||||
Get.lazyPut(() => FriendProvider());
|
||||
Get.lazyPut(() => PostProvider());
|
||||
Get.lazyPut(() => AttachmentProvider());
|
||||
Get.lazyPut(() => ChatProvider());
|
||||
Get.lazyPut(() => AccountProvider());
|
||||
Get.lazyPut(() => ChannelProvider());
|
||||
Get.lazyPut(() => RealmProvider());
|
||||
@ -44,6 +46,7 @@ class SolianApp extends StatelessWidget {
|
||||
auth.isAuthorized.then((value) async {
|
||||
if (value) {
|
||||
Get.find<AccountProvider>().connect();
|
||||
Get.find<ChatProvider>().connect();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
@ -1,17 +1,15 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:solian/models/account.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
|
||||
class Message {
|
||||
int id;
|
||||
String uuid;
|
||||
DateTime createdAt;
|
||||
DateTime updatedAt;
|
||||
DateTime? deletedAt;
|
||||
Map<String, dynamic> content;
|
||||
Map<String, dynamic>? metadata;
|
||||
String type;
|
||||
List<String>? attachments;
|
||||
List<int>? attachments;
|
||||
Channel? channel;
|
||||
Sender sender;
|
||||
int? replyId;
|
||||
@ -23,11 +21,11 @@ class Message {
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.uuid,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.deletedAt,
|
||||
required this.content,
|
||||
required this.metadata,
|
||||
required this.type,
|
||||
this.attachments,
|
||||
this.channel,
|
||||
@ -40,13 +38,15 @@ class Message {
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) => Message(
|
||||
id: json['id'],
|
||||
uuid: json['uuid'],
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
updatedAt: DateTime.parse(json['updated_at']),
|
||||
deletedAt: json['deleted_at'],
|
||||
content: json['content'],
|
||||
metadata: json['metadata'],
|
||||
type: json['type'],
|
||||
attachments: json['attachments'],
|
||||
attachments: json["attachments"] != null
|
||||
? List<int>.from(json["attachments"])
|
||||
: null,
|
||||
channel: Channel.fromJson(json['channel']),
|
||||
sender: Sender.fromJson(json['sender']),
|
||||
replyId: json['reply_id'],
|
||||
@ -59,11 +59,11 @@ class Message {
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'uuid': uuid,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
'deleted_at': deletedAt,
|
||||
'content': content,
|
||||
'metadata': metadata,
|
||||
'type': type,
|
||||
'attachments': attachments,
|
||||
'channel': channel?.toJson(),
|
||||
|
@ -144,7 +144,7 @@ class AccountProvider extends GetxController {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) return;
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['passport'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
@ -6,6 +6,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get/get_connect/http/src/request/request.dart';
|
||||
import 'package:solian/providers/account.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:oauth2/oauth2.dart' as oauth2;
|
||||
|
||||
@ -98,6 +99,7 @@ class AuthProvider extends GetConnect {
|
||||
|
||||
Get.find<AccountProvider>().connect();
|
||||
Get.find<AccountProvider>().notifyPrefetch();
|
||||
Get.find<ChatProvider>().connect();
|
||||
|
||||
return credentials!;
|
||||
}
|
||||
@ -105,6 +107,7 @@ class AuthProvider extends GetConnect {
|
||||
void signout() {
|
||||
_cacheUserProfileResponse = null;
|
||||
|
||||
Get.find<ChatProvider>().disconnect();
|
||||
Get.find<AccountProvider>().disconnect();
|
||||
Get.find<AccountProvider>().notifications.clear();
|
||||
Get.find<AccountProvider>().notificationUnread.value = 0;
|
||||
@ -121,7 +124,7 @@ class AuthProvider extends GetConnect {
|
||||
return _cacheUserProfileResponse!;
|
||||
}
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['passport'];
|
||||
client.httpClient.addAuthenticator(requestAuthenticator);
|
||||
|
||||
|
67
lib/providers/chat.dart
Normal file
67
lib/providers/chat.dart
Normal file
@ -0,0 +1,67 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:solian/models/packet.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
|
||||
class ChatProvider extends GetxController {
|
||||
RxBool isConnected = false.obs;
|
||||
RxBool isConnecting = false.obs;
|
||||
|
||||
IOWebSocketChannel? websocket;
|
||||
|
||||
StreamController<NetworkPackage> stream = StreamController.broadcast();
|
||||
|
||||
void connect({noRetry = false}) async {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) throw Exception('unauthorized');
|
||||
|
||||
if (auth.credentials == null) await auth.loadCredentials();
|
||||
|
||||
final uri = Uri.parse(
|
||||
'${ServiceFinder.services['messaging']}/api/ws?tk=${auth.credentials!.accessToken}'
|
||||
.replaceFirst('http', 'ws'),
|
||||
);
|
||||
|
||||
isConnecting.value = true;
|
||||
|
||||
try {
|
||||
websocket = IOWebSocketChannel.connect(uri);
|
||||
await websocket?.ready;
|
||||
} catch (e) {
|
||||
if (!noRetry) {
|
||||
await auth.refreshCredentials();
|
||||
return connect(noRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
listen();
|
||||
|
||||
isConnected.value = true;
|
||||
isConnecting.value = false;
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
websocket?.sink.close(WebSocketStatus.normalClosure);
|
||||
isConnected.value = false;
|
||||
}
|
||||
|
||||
void listen() {
|
||||
websocket?.stream.listen(
|
||||
(event) {
|
||||
final packet = NetworkPackage.fromJson(jsonDecode(event));
|
||||
stream.sink.add(packet);
|
||||
},
|
||||
onDone: () {
|
||||
isConnected.value = false;
|
||||
},
|
||||
onError: (err) {
|
||||
isConnected.value = false;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -37,7 +37,7 @@ class AttachmentProvider extends GetConnect {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) throw Exception('unauthorized');
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['paperclip'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
@ -76,7 +76,7 @@ class AttachmentProvider extends GetConnect {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) throw Exception('unauthorized');
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['paperclip'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
@ -100,7 +100,7 @@ class AttachmentProvider extends GetConnect {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) throw Exception('unauthorized');
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['paperclip'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
@ -7,7 +7,7 @@ class ChannelProvider extends GetxController {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) throw Exception('unauthorized');
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
|
||||
|
||||
final resp = await client.get('/api/channels/$realm/$alias');
|
||||
@ -22,7 +22,7 @@ class ChannelProvider extends GetxController {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) throw Exception('unauthorized');
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
@ -7,7 +7,7 @@ class RealmProvider extends GetxController {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) throw Exception('unauthorized');
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['passport'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
@ -33,7 +33,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
}
|
||||
|
||||
if (markList.isNotEmpty) {
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['passport'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
@ -58,7 +58,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['passport'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
@ -98,7 +98,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
|
||||
context.showErrorDialog(e);
|
||||
}
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['passport'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
@ -122,7 +122,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['passport'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
@ -27,7 +27,7 @@ class _SignUpPopupState extends State<SignUpPopup> {
|
||||
nickname.isEmpty ||
|
||||
password.isEmpty) return;
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['passport'];
|
||||
final resp = await client.post('/api/users', {
|
||||
'name': username,
|
||||
|
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:get/get.dart';
|
||||
@ -5,12 +7,15 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
import 'package:solian/exts.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/models/message.dart';
|
||||
import 'package:solian/models/packet.dart';
|
||||
import 'package:solian/models/pagination.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/providers/content/channel.dart';
|
||||
import 'package:solian/services.dart';
|
||||
import 'package:solian/theme.dart';
|
||||
import 'package:solian/widgets/chat/chat_message.dart';
|
||||
import 'package:solian/widgets/chat/chat_message_input.dart';
|
||||
|
||||
class ChannelChatScreen extends StatefulWidget {
|
||||
final String alias;
|
||||
@ -30,6 +35,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
bool _isBusy = false;
|
||||
|
||||
Channel? _channel;
|
||||
StreamSubscription<NetworkPackage>? _subscription;
|
||||
|
||||
final PagingController<int, Message> _pagingController =
|
||||
PagingController(firstPageKey: 0);
|
||||
@ -53,7 +59,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final AuthProvider auth = Get.find();
|
||||
if (!await auth.isAuthorized) return;
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
@ -76,10 +82,47 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void listenMessages() {
|
||||
final ChatProvider provider = Get.find();
|
||||
_subscription = provider.stream.stream.listen((event) {
|
||||
switch (event.method) {
|
||||
case 'messages.new':
|
||||
final payload = Message.fromJson(event.payload!);
|
||||
if (payload.channelId == _channel?.id) {
|
||||
final idx = _pagingController.itemList
|
||||
?.indexWhere((e) => e.uuid == payload.uuid);
|
||||
if ((idx ?? -1) >= 0) {
|
||||
_pagingController.itemList?[idx!] = payload;
|
||||
} else {
|
||||
_pagingController.itemList?.insert(0, payload);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'messages.update':
|
||||
final payload = Message.fromJson(event.payload!);
|
||||
if (payload.channelId == _channel?.id) {
|
||||
_pagingController.itemList
|
||||
?.map((x) => x.id == payload.id ? payload : x)
|
||||
.toList();
|
||||
}
|
||||
break;
|
||||
case 'messages.burnt':
|
||||
final payload = Message.fromJson(event.payload!);
|
||||
if (payload.channelId == _channel?.id) {
|
||||
_pagingController.itemList = _pagingController.itemList
|
||||
?.where((x) => x.id != payload.id)
|
||||
.toList();
|
||||
}
|
||||
break;
|
||||
}
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
bool checkMessageMergeable(Message? a, Message? b) {
|
||||
if (a?.replyTo != null) return false;
|
||||
if (a == null || b == null) return false;
|
||||
if (a.senderId != b.senderId) return false;
|
||||
if (a.sender.account.id != b.sender.account.id) return false;
|
||||
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
|
||||
}
|
||||
|
||||
@ -107,7 +150,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
child: ChatMessage(
|
||||
item: item,
|
||||
isCompact: isMerged,
|
||||
isMerged: isMerged,
|
||||
),
|
||||
),
|
||||
onLongPress: () {},
|
||||
@ -124,6 +167,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
super.initState();
|
||||
|
||||
getChannel().then((_) {
|
||||
listenMessages();
|
||||
_pagingController.addPageRequestListener(getMessages);
|
||||
});
|
||||
}
|
||||
@ -150,22 +194,47 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
body: Stack(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PagedListView<int, Message>(
|
||||
reverse: true,
|
||||
pagingController: _pagingController,
|
||||
builderDelegate: PagedChildBuilderDelegate<Message>(
|
||||
animateTransitions: true,
|
||||
transitionDuration: 350.ms,
|
||||
itemBuilder: chatHistoryBuilder,
|
||||
noItemsFoundIndicatorBuilder: (_) => Container(),
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PagedListView<int, Message>(
|
||||
reverse: true,
|
||||
clipBehavior: Clip.none,
|
||||
pagingController: _pagingController,
|
||||
builderDelegate: PagedChildBuilderDelegate<Message>(
|
||||
animateTransitions: true,
|
||||
transitionDuration: 350.ms,
|
||||
itemBuilder: chatHistoryBuilder,
|
||||
noItemsFoundIndicatorBuilder: (_) => Container(),
|
||||
),
|
||||
).paddingOnly(bottom: 64),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: ChatMessageInput(
|
||||
realm: widget.realm,
|
||||
channel: _channel!,
|
||||
onSent: (Message item) {
|
||||
setState(() {
|
||||
_pagingController.itemList?.insert(0, item);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
@ -82,7 +82,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
@ -63,7 +63,7 @@ class _PostPublishingScreenState extends State<PostPublishingScreen> {
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final client = GetConnect();
|
||||
final client = GetConnect(maxAuthRetries: 3);
|
||||
client.httpClient.baseUrl = ServiceFinder.services['interactive'];
|
||||
client.httpClient.addAuthenticator(auth.requestAuthenticator);
|
||||
|
||||
|
@ -103,6 +103,7 @@ class SolianMessages extends Translations {
|
||||
'channelTypeDirect': 'DM',
|
||||
'messageDecoding': 'Decoding...',
|
||||
'messageDecodeFailed': 'Unable to decode: @message',
|
||||
'messageInputPlaceholder': 'Message @channel...',
|
||||
},
|
||||
'zh_CN': {
|
||||
'hide': '隐藏',
|
||||
@ -197,6 +198,7 @@ class SolianMessages extends Translations {
|
||||
'channelTypeDirect': '私信聊天',
|
||||
'messageDecoding': '解码信息中…',
|
||||
'messageDecodeFailed': '解码信息失败:@message',
|
||||
'messageInputPlaceholder': '在 @channel 发信息…',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user