Message showing

This commit is contained in:
LittleSheep 2024-04-17 23:00:53 +08:00
parent c25ae591b9
commit ba405770ed
12 changed files with 449 additions and 24 deletions

59
lib/models/account.dart Normal file
View File

@ -0,0 +1,59 @@
class Account {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String name;
String nick;
String avatar;
String banner;
String description;
String emailAddress;
int powerLevel;
int externalId;
Account({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.name,
required this.nick,
required this.avatar,
required this.banner,
required this.description,
required this.emailAddress,
required this.powerLevel,
required this.externalId,
});
factory Account.fromJson(Map<String, dynamic> json) => Account(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
name: json["name"],
nick: json["nick"],
avatar: json["avatar"],
banner: json["banner"],
description: json["description"],
emailAddress: json["email_address"],
powerLevel: json["power_level"],
externalId: json["external_id"],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"name": name,
"nick": nick,
"avatar": avatar,
"banner": banner,
"description": description,
"email_address": emailAddress,
"power_level": powerLevel,
"external_id": externalId,
};
}

115
lib/models/message.dart Normal file
View File

@ -0,0 +1,115 @@
import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/post.dart';
class Message {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String content;
dynamic metadata;
int type;
List<Attachment>? attachments;
Channel? channel;
Sender sender;
int? replyId;
Message? replyTo;
int channelId;
int senderId;
Message({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.content,
required this.metadata,
required this.type,
this.attachments,
this.channel,
required this.sender,
required this.replyId,
required this.replyTo,
required this.channelId,
required this.senderId,
});
factory Message.fromJson(Map<String, dynamic> json) => Message(
id: json["id"],
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: List<Attachment>.from(json["attachments"]?.map((x) => Attachment.fromJson(x)) ?? List.empty()),
channel: Channel.fromJson(json["channel"]),
sender: Sender.fromJson(json["sender"]),
replyId: json["reply_id"],
replyTo: json["reply_to"] != null ? Message.fromJson(json["reply_to"]) : null,
channelId: json["channel_id"],
senderId: json["sender_id"],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"content": content,
"metadata": metadata,
"type": type,
"attachments": List<dynamic>.from(attachments?.map((x) => x.toJson()) ?? List.empty()),
"channel": channel?.toJson(),
"sender": sender.toJson(),
"reply_id": replyId,
"reply_to": replyTo?.toJson(),
"channel_id": channelId,
"sender_id": senderId,
};
}
class Sender {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
Account account;
int channelId;
int accountId;
int notify;
Sender({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.account,
required this.channelId,
required this.accountId,
required this.notify,
});
factory Sender.fromJson(Map<String, dynamic> json) => Sender(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
account: Account.fromJson(json["account"]),
channelId: json["channel_id"],
accountId: json["account_id"],
notify: json["notify"],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"account": account.toJson(),
"channel_id": channelId,
"account_id": accountId,
"notify": notify,
};
}

View File

@ -1,6 +1,7 @@
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/screens/account.dart'; import 'package:solian/screens/account.dart';
import 'package:solian/screens/chat/chat.dart';
import 'package:solian/screens/chat/index.dart'; import 'package:solian/screens/chat/index.dart';
import 'package:solian/screens/explore.dart'; import 'package:solian/screens/explore.dart';
import 'package:solian/screens/posts/comment_editor.dart'; import 'package:solian/screens/posts/comment_editor.dart';
@ -19,6 +20,11 @@ final router = GoRouter(
name: 'chat', name: 'chat',
builder: (context, state) => const ChatIndexScreen(), builder: (context, state) => const ChatIndexScreen(),
), ),
GoRoute(
path: '/chat/:channel',
name: 'chat.channel',
builder: (context, state) => ChatScreen(alias: state.pathParameters['channel'] as String),
),
GoRoute( GoRoute(
path: '/account', path: '/account',
name: 'account', name: 'account',

121
lib/screens/chat/chat.dart Normal file
View File

@ -0,0 +1,121 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/chat/message.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:http/http.dart' as http;
class ChatScreen extends StatefulWidget {
final String alias;
const ChatScreen({super.key, required this.alias});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
Channel? _channelMeta;
final PagingController<int, Message> _pagingController = PagingController(firstPageKey: 0);
final http.Client _client = http.Client();
Future<void> fetchMetadata(BuildContext context) async {
var uri = getRequestUri('messaging', '/api/channels/${widget.alias}');
var res = await _client.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
setState(() => _channelMeta = Channel.fromJson(result));
} else {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
}
}
Future<void> fetchMessages(int pageKey, BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final offset = pageKey;
const take = 5;
var uri = getRequestUri(
'messaging',
'/api/channels/${widget.alias}/messages?take=$take&offset=$offset',
);
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
final items = result.data?.map((x) => Message.fromJson(x)).toList() ?? List.empty();
final isLastPage = (result.count - pageKey) < take;
if (isLastPage || result.data == null) {
_pagingController.appendLastPage(items);
} else {
final nextPageKey = pageKey + items.length;
_pagingController.appendPage(items, nextPageKey);
}
} else {
_pagingController.error = utf8.decode(res.bodyBytes);
}
}
bool getMessageMergeable(Message? a, Message? b) {
if (a == null || b == null) return false;
if (a.senderId != b.senderId) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 5;
}
@override
void initState() {
Future.delayed(Duration.zero, () {
fetchMetadata(context);
});
_pagingController.addPageRequestListener((pageKey) => fetchMessages(pageKey, context));
super.initState();
}
@override
Widget build(BuildContext context) {
return IndentWrapper(
noSafeArea: true,
hideDrawer: true,
title: _channelMeta?.name ?? "Loading...",
child: PagedListView<int, Message>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Message>(
itemBuilder: (context, item, index) {
bool isMerged = false, hasMerged = false;
if (index > 0) {
isMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item);
}
if (index + 1 < (_pagingController.itemList?.length ?? 0)) {
hasMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]);
}
return Container(
padding: EdgeInsets.only(
top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0,
left: 12,
right: 12,
),
child: ChatMessage(item: item, underMerged: isMerged),
);
},
),
),
);
}
}

View File

@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart'; import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/indent_wrapper.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
@ -64,7 +65,14 @@ class _ChatIndexScreenState extends State<ChatIndexScreen> {
), ),
title: Text(element.name), title: Text(element.name),
subtitle: Text(element.description), subtitle: Text(element.description),
onTap: () {}, onTap: () {
router.pushNamed(
'chat.channel',
pathParameters: {
'channel': element.alias,
},
);
},
); );
}, },
), ),

View File

@ -44,6 +44,7 @@ class _PostScreenState extends State<PostScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return IndentWrapper( return IndentWrapper(
noSafeArea: true,
hideDrawer: true, hideDrawer: true,
title: AppLocalizations.of(context)!.post, title: AppLocalizations.of(context)!.post,
child: FutureBuilder( child: FutureBuilder(

View File

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:solian/models/message.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ChatMessageContent extends StatelessWidget {
final Message item;
const ChatMessageContent({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Markdown(
selectable: true,
data: item.content,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(0),
onTapLink: (text, href, title) async {
if (href == null) return;
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
);
}
}

View File

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:solian/models/message.dart';
import 'package:solian/widgets/chat/content.dart';
import 'package:solian/widgets/posts/content/attachment.dart';
import 'package:timeago/timeago.dart' as timeago;
class ChatMessage extends StatelessWidget {
final Message item;
final bool underMerged;
const ChatMessage({super.key, required this.item, required this.underMerged});
Widget renderAttachment() {
if (item.attachments != null && item.attachments!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: AttachmentList(items: item.attachments!, provider: 'messaging'),
);
} else {
return Container();
}
}
@override
Widget build(BuildContext context) {
final contentPart = Padding(
padding: const EdgeInsets.only(left: 12, right: 12, top: 2),
child: ChatMessageContent(item: item),
);
final userinfoPart = Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Text(
item.sender.account.nick,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 4),
Text(timeago.format(item.createdAt))
],
),
);
if (underMerged) {
return Row(
children: [
const SizedBox(width: 40),
Expanded(child: contentPart),
],
);
} else {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundImage: NetworkImage(item.sender.account.avatar),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
userinfoPart,
contentPart,
renderAttachment(),
],
),
),
],
);
}
}
}

View File

@ -117,10 +117,11 @@ class _AttachmentItemState extends State<AttachmentItem> {
class AttachmentList extends StatelessWidget { class AttachmentList extends StatelessWidget {
final List<Attachment> items; final List<Attachment> items;
final String provider;
const AttachmentList({super.key, required this.items}); const AttachmentList({super.key, required this.items, required this.provider});
Uri getFileUri(String fileId) => getRequestUri('interactive', '/api/attachments/o/$fileId'); Uri getFileUri(String fileId) => getRequestUri(provider, '/api/attachments/o/$fileId');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -1,7 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/widgets.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/widgets/posts/comment_list.dart'; import 'package:solian/widgets/posts/comment_list.dart';
@ -10,6 +7,7 @@ import 'package:solian/widgets/posts/content/attachment.dart';
import 'package:solian/widgets/posts/content/moment.dart'; import 'package:solian/widgets/posts/content/moment.dart';
import 'package:solian/widgets/posts/item_action.dart'; import 'package:solian/widgets/posts/item_action.dart';
import 'package:solian/widgets/posts/reaction_list.dart'; import 'package:solian/widgets/posts/reaction_list.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:timeago/timeago.dart' as timeago; import 'package:timeago/timeago.dart' as timeago;
class PostItem extends StatefulWidget { class PostItem extends StatefulWidget {
@ -89,7 +87,7 @@ class _PostItemState extends State<PostItem> {
widget.item.attachments!.isNotEmpty) { widget.item.attachments!.isNotEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: AttachmentList(items: widget.item.attachments!), child: AttachmentList(items: widget.item.attachments!, provider: 'interactive'),
); );
} else { } else {
return Container(); return Container();
@ -97,8 +95,6 @@ class _PostItemState extends State<PostItem> {
} }
Widget renderReactions() { Widget renderReactions() {
const density = VisualDensity(horizontal: -4, vertical: -2);
return Container( return Container(
height: 48, height: 48,
padding: const EdgeInsets.only(top: 8, left: 4, right: 4), padding: const EdgeInsets.only(top: 8, left: 4, right: 4),
@ -108,7 +104,7 @@ class _PostItemState extends State<PostItem> {
ActionChip( ActionChip(
avatar: const Icon(Icons.comment), avatar: const Icon(Icons.comment),
label: Text(widget.item.commentCount.toString()), label: Text(widget.item.commentCount.toString()),
tooltip: 'Comment', tooltip: AppLocalizations.of(context)!.comment,
onPressed: () => viewComments(context), onPressed: () => viewComments(context),
), ),
const VerticalDivider(thickness: 0.3, indent: 8, endIndent: 8), const VerticalDivider(thickness: 0.3, indent: 8, endIndent: 8),

View File

@ -85,10 +85,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: cupertino_icons name: cupertino_icons
sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.6" version: "1.0.8"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
@ -183,10 +183,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_markdown name: flutter_markdown
sha256: "31c12de79262b5431c5492e9c89948aa789158435f707d3519a7fdef6af28af7" sha256: "04c4722cc36ec5af38acc38ece70d22d3c2123c61305d555750a091517bbe504"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.22+1" version: "0.6.23"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@ -269,6 +269,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "13.2.4" version: "13.2.4"
hive:
dependency: transitive
description:
name: hive
sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
hive_flutter:
dependency: "direct main"
description:
name: hive_flutter
sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc
url: "https://pub.dev"
source: hosted
version: "1.1.0"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -297,34 +313,34 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: image_picker name: image_picker
sha256: "1f498d086203360cca099d20ffea2963f48c39ce91bdd8a3b6d4a045786b02c8" sha256: fe9ee64ccb8d599a5dfb0e21cc6652232c610bcf667af4e79b9eb175cc30a7a5
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.1.0"
image_picker_android: image_picker_android:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: "844c6da4e4f2829dffdab97816bca09d0e0977e8dcef7450864aba4e07967a58" sha256: "8e75431a62b7feb4fd55cb4a5c6f0ac4564460ec5dc09f9c4a0d50a5ce7c4cb9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.9+6" version: "0.8.10"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
name: image_picker_for_web name: image_picker_for_web
sha256: "6a1704fdd75022272e7e7a897a9068e9c2ff3cd6a66820bf3ded810633eac954" sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.3" version: "3.0.4"
image_picker_ios: image_picker_ios:
dependency: transitive dependency: transitive
description: description:
name: image_picker_ios name: image_picker_ios
sha256: "917a5cadd67d052554cfb258595e54217de53fac5b52939426e26319a02e6297" sha256: f4a6f62be96d6fd268f32a6bf8ef444cd8e3fff64d16923c6e6fe55e0c84a761
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.9+2" version: "0.8.10"
image_picker_linux: image_picker_linux:
dependency: transitive dependency: transitive
description: description:
@ -878,10 +894,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
sha256: "3692a459204a33e04bc94f5fb91158faf4f2c8903281ddd82915adecdb1a901d" sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" version: "2.3.1"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:

View File

@ -56,6 +56,7 @@ dependencies:
uuid: ^4.4.0 uuid: ^4.4.0
media_kit: ^1.1.10+1 media_kit: ^1.1.10+1
media_kit_libs_video: ^1.0.4 media_kit_libs_video: ^1.0.4
hive_flutter: ^1.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: