From 7b723e2d248d9ef08884120f569aef5e1cb93ce6 Mon Sep 17 00:00:00 2001 From: John Wesley Date: Sun, 21 Jan 2024 15:49:00 -0500 Subject: [PATCH] Implement direct message sending/viewing --- lib/src/api/messages.dart | 53 +++++++ lib/src/models/message.dart | 45 ++++++ lib/src/screens/explore/user_screen.dart | 60 +++++++- lib/src/screens/profile/message_item.dart | 64 +++++++++ .../profile/message_thread_screen.dart | 130 ++++++++++++++++++ lib/src/screens/profile/messages_screen.dart | 107 ++++++++++++++ lib/src/screens/profile/profile_screen.dart | 10 +- 7 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 lib/src/api/messages.dart create mode 100644 lib/src/models/message.dart create mode 100644 lib/src/screens/profile/message_item.dart create mode 100644 lib/src/screens/profile/message_thread_screen.dart create mode 100644 lib/src/screens/profile/messages_screen.dart diff --git a/lib/src/api/messages.dart b/lib/src/api/messages.dart new file mode 100644 index 0000000..cb14116 --- /dev/null +++ b/lib/src/api/messages.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:interstellar/src/models/message.dart'; +import 'package:interstellar/src/utils/utils.dart'; + +Future fetchMessages( + http.Client client, + String instanceHost, { + int? page, +}) async { + final response = await client + .get(Uri.https(instanceHost, '/api/messages', {'p': page?.toString()})); + + httpErrorHandler(response, message: 'Failed to load messages'); + + return MessageListModel.fromJson( + jsonDecode(response.body) as Map); +} + +Future postMessage( + http.Client client, + String instanceHost, + int userId, + String body, +) async { + final response = await client.post( + Uri.https(instanceHost, '/api/users/$userId/message'), + body: jsonEncode({'body': body}), + ); + + httpErrorHandler(response, message: 'Failed to send message'); + + return MessageThreadModel.fromJson( + jsonDecode(response.body) as Map); +} + +Future postMessageThreadReply( + http.Client client, + String instanceHost, + int threadId, + String body, +) async { + final response = await client.post( + Uri.https(instanceHost, '/api/messages/thread/$threadId/reply'), + body: jsonEncode({'body': body}), + ); + + httpErrorHandler(response, message: 'Failed to send message'); + + return MessageThreadModel.fromJson( + jsonDecode(response.body) as Map); +} diff --git a/lib/src/models/message.dart b/lib/src/models/message.dart new file mode 100644 index 0000000..6725734 --- /dev/null +++ b/lib/src/models/message.dart @@ -0,0 +1,45 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:interstellar/src/models/shared.dart'; +import 'package:interstellar/src/models/user.dart'; + +part 'message.freezed.dart'; +part 'message.g.dart'; + +@freezed +class MessageListModel with _$MessageListModel { + const factory MessageListModel({ + required List items, + required PaginationModel pagination, + }) = _MessageListModel; + + factory MessageListModel.fromJson(Map json) => + _$MessageListModelFromJson(json); +} + +@freezed +class MessageThreadModel with _$MessageThreadModel { + const factory MessageThreadModel({ + required List participants, + required int messageCount, + required List messages, + required int threadId, + }) = _MessageThreadModel; + + factory MessageThreadModel.fromJson(Map json) => + _$MessageThreadModelFromJson(json); +} + +@freezed +class MessageItemModel with _$MessageItemModel { + const factory MessageItemModel({ + required UserModel sender, + required String body, + required String status, + required int threadId, + required DateTime createdAt, + required int messageId, + }) = _MessageItemModel; + + factory MessageItemModel.fromJson(Map json) => + _$MessageItemModelFromJson(json); +} diff --git a/lib/src/screens/explore/user_screen.dart b/lib/src/screens/explore/user_screen.dart index 6e0b112..f8831ad 100644 --- a/lib/src/screens/explore/user_screen.dart +++ b/lib/src/screens/explore/user_screen.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; import 'package:interstellar/src/api/content_sources.dart'; +import 'package:interstellar/src/api/messages.dart'; import 'package:interstellar/src/api/users.dart' as api_users; import 'package:interstellar/src/models/user.dart'; import 'package:interstellar/src/screens/entries/entries_list.dart'; import 'package:interstellar/src/screens/feed_screen.dart'; import 'package:interstellar/src/screens/posts/posts_list.dart'; +import 'package:interstellar/src/screens/profile/message_thread_screen.dart'; import 'package:interstellar/src/screens/settings/settings_controller.dart'; import 'package:interstellar/src/utils/utils.dart'; import 'package:interstellar/src/widgets/avatar.dart'; import 'package:interstellar/src/widgets/markdown.dart'; +import 'package:interstellar/src/widgets/text_editor.dart'; import 'package:provider/provider.dart'; class UserScreen extends StatefulWidget { @@ -25,6 +28,7 @@ class UserScreen extends StatefulWidget { class _UserScreenState extends State { DetailedUserModel? _data; FeedMode _feedMode = FeedMode.entries; + TextEditingController? _messageController; @override void initState() { @@ -89,9 +93,63 @@ class _UserScreenState extends State { Text(' ${intFormat(_data!.followersCount)}'), ], ), - ) + ), + if (!_data!.username.contains('@')) + IconButton( + onPressed: () { + setState(() { + _messageController = TextEditingController(); + }); + }, + icon: const Icon(Icons.mail), + tooltip: 'Send message', + ) ], ), + if (_messageController != null) + Column(children: [ + TextEditor( + _messageController!, + isMarkdown: true, + label: 'Message', + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () async { + setState(() { + _messageController = null; + }); + }, + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () async { + final newThread = await postMessage( + context.read().httpClient, + context.read().instanceHost, + _data!.userId, + _messageController!.text, + ); + + setState(() { + _messageController = null; + + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + MessageThreadScreen(initData: newThread), + ), + ); + }); + }, + child: const Text('Send'), + ) + ], + ) + ]), if (_data!.about != null) Padding( padding: const EdgeInsets.only(top: 12), diff --git a/lib/src/screens/profile/message_item.dart b/lib/src/screens/profile/message_item.dart new file mode 100644 index 0000000..18e52d8 --- /dev/null +++ b/lib/src/screens/profile/message_item.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:interstellar/src/models/message.dart'; +import 'package:interstellar/src/screens/explore/user_screen.dart'; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:interstellar/src/widgets/display_name.dart'; +import 'package:interstellar/src/widgets/markdown.dart'; +import 'package:provider/provider.dart'; + +class MessageItem extends StatelessWidget { + const MessageItem(this.item, this.onUpdate, {this.onClick, super.key}); + + final MessageThreadModel item; + final void Function(MessageThreadModel) onUpdate; + final void Function()? onClick; + + @override + Widget build(BuildContext context) { + final messageUser = item.participants + .where((user) => + user.username != + context + .watch() + .selectedAccount + .split("@") + .first) + .first; + + return Card( + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: InkWell( + onTap: onClick, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 10), + child: DisplayName( + messageUser.username, + icon: messageUser.avatar?.storageUrl, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => UserScreen(messageUser.userId), + ), + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Markdown(item.messages.first.body), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/screens/profile/message_thread_screen.dart b/lib/src/screens/profile/message_thread_screen.dart new file mode 100644 index 0000000..283d0e6 --- /dev/null +++ b/lib/src/screens/profile/message_thread_screen.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:interstellar/src/api/messages.dart'; +import 'package:interstellar/src/models/message.dart'; +import 'package:interstellar/src/screens/explore/user_screen.dart'; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:interstellar/src/widgets/display_name.dart'; +import 'package:interstellar/src/widgets/markdown.dart'; +import 'package:interstellar/src/widgets/text_editor.dart'; +import 'package:provider/provider.dart'; + +class MessageThreadScreen extends StatefulWidget { + const MessageThreadScreen({ + this.initData, + this.onUpdate, + super.key, + }); + + final MessageThreadModel? initData; + final void Function(MessageThreadModel)? onUpdate; + + @override + State createState() => _MessageThreadScreenState(); +} + +class _MessageThreadScreenState extends State { + MessageThreadModel? _data; + final TextEditingController _controller = TextEditingController(); + + @override + void initState() { + _data = widget.initData; + + super.initState(); + } + + @override + Widget build(BuildContext context) { + if (_data == null) { + return const Center(child: CircularProgressIndicator()); + } + MessageThreadModel data = _data!; + + final messageUser = data.participants + .where((user) => + user.username != + context + .watch() + .selectedAccount + .split("@") + .first) + .first; + + return Scaffold( + appBar: AppBar(title: Text('Messages with ${messageUser.username}')), + body: ListView(children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Column(children: [ + TextEditor( + _controller, + isMarkdown: true, + label: 'Reply', + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton( + onPressed: () async { + final newThread = await postMessageThreadReply( + context.read().httpClient, + context.read().instanceHost, + data.threadId, + _controller.text, + ); + + _controller.text = ''; + + setState(() { + _data = newThread; + }); + + if (widget.onUpdate != null) { + widget.onUpdate!(newThread); + } + }, + child: const Text('Send')) + ], + ) + ]), + ), + ...data.messages.map( + (message) => Card( + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 10), + child: DisplayName( + message.sender.username, + icon: message.sender.avatar?.storageUrl, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + UserScreen(message.sender.userId), + ), + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 8), + child: Markdown(message.body), + ), + ], + ), + ), + ), + ) + ]), + ); + } +} diff --git a/lib/src/screens/profile/messages_screen.dart b/lib/src/screens/profile/messages_screen.dart new file mode 100644 index 0000000..dec7222 --- /dev/null +++ b/lib/src/screens/profile/messages_screen.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:interstellar/src/api/messages.dart'; +import 'package:interstellar/src/models/message.dart'; +import 'package:interstellar/src/screens/profile/message_item.dart'; +import 'package:interstellar/src/screens/profile/message_thread_screen.dart'; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:provider/provider.dart'; + +class MessagesScreen extends StatefulWidget { + const MessagesScreen({super.key}); + + @override + State createState() => _MessagesScreenState(); +} + +class _MessagesScreenState extends State { + final PagingController _pagingController = + PagingController(firstPageKey: 1); + + @override + void initState() { + super.initState(); + + _pagingController.addPageRequestListener((pageKey) { + _fetchPage(pageKey); + }); + } + + Future _fetchPage(int pageKey) async { + try { + final newPage = await fetchMessages( + context.read().httpClient, + context.read().instanceHost, + page: pageKey, + ); + + // Check BuildContext + if (!mounted) return; + + final isLastPage = + newPage.pagination.currentPage == newPage.pagination.maxPage; + // Prevent duplicates + final currentItemIds = + _pagingController.itemList?.map((e) => e.threadId) ?? []; + final newItems = newPage.items + .where((e) => !currentItemIds.contains(e.threadId)) + .toList(); + + if (isLastPage) { + _pagingController.appendLastPage(newItems); + } else { + final nextPageKey = pageKey + 1; + _pagingController.appendPage(newItems, nextPageKey); + } + } catch (error) { + _pagingController.error = error; + } + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: () => Future.sync( + () => _pagingController.refresh(), + ), + child: CustomScrollView( + slivers: [ + const SliverToBoxAdapter(child: SizedBox(height: 12)), + PagedSliverList( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => + MessageItem(item, (newValue) { + var newList = _pagingController.itemList; + newList![index] = newValue; + setState(() { + _pagingController.itemList = newList; + }); + }, onClick: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => MessageThreadScreen( + initData: item, + onUpdate: (newValue) { + var newList = _pagingController.itemList; + newList![index] = newValue; + setState(() { + _pagingController.itemList = newList; + }); + }), + ), + ); + }), + ), + ) + ], + ), + ); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } +} diff --git a/lib/src/screens/profile/profile_screen.dart b/lib/src/screens/profile/profile_screen.dart index 7d7587f..1beff16 100644 --- a/lib/src/screens/profile/profile_screen.dart +++ b/lib/src/screens/profile/profile_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:interstellar/src/screens/profile/messages_screen.dart'; import 'package:interstellar/src/screens/profile/notification_screen.dart'; import 'package:interstellar/src/screens/profile/self_feed.dart'; import 'package:interstellar/src/screens/settings/settings_controller.dart'; @@ -19,7 +20,7 @@ class _ProfileScreenState extends State { return whenLoggedIn( context, DefaultTabController( - length: 2, + length: 3, child: Scaffold( appBar: AppBar( title: @@ -29,14 +30,19 @@ class _ProfileScreenState extends State { text: 'Notifications', icon: NotificationBadge(child: Icon(Icons.notifications)), ), + Tab( + text: 'Messages', + icon: Icon(Icons.message), + ), Tab( text: 'Overview', - icon: Icon(Icons.article), + icon: Icon(Icons.person), ), ]), ), body: const TabBarView(children: [ NotificationsScreen(), + MessagesScreen(), SelfFeed(), ]), ),