Implement direct message sending/viewing

This commit is contained in:
John Wesley 2024-01-21 15:49:00 -05:00
parent c856953f31
commit 7b723e2d24
7 changed files with 466 additions and 3 deletions

53
lib/src/api/messages.dart Normal file
View File

@ -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<MessageListModel> 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<String, dynamic>);
}
Future<MessageThreadModel> 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<String, Object?>);
}
Future<MessageThreadModel> 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<String, Object?>);
}

View File

@ -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<MessageThreadModel> items,
required PaginationModel pagination,
}) = _MessageListModel;
factory MessageListModel.fromJson(Map<String, Object?> json) =>
_$MessageListModelFromJson(json);
}
@freezed
class MessageThreadModel with _$MessageThreadModel {
const factory MessageThreadModel({
required List<DetailedUserModel> participants,
required int messageCount,
required List<MessageItemModel> messages,
required int threadId,
}) = _MessageThreadModel;
factory MessageThreadModel.fromJson(Map<String, Object?> 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<String, Object?> json) =>
_$MessageItemModelFromJson(json);
}

View File

@ -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<UserScreen> {
DetailedUserModel? _data;
FeedMode _feedMode = FeedMode.entries;
TextEditingController? _messageController;
@override
void initState() {
@ -89,9 +93,63 @@ class _UserScreenState extends State<UserScreen> {
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<SettingsController>().httpClient,
context.read<SettingsController>().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),

View File

@ -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<SettingsController>()
.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),
),
],
),
),
),
);
}
}

View File

@ -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<MessageThreadScreen> createState() => _MessageThreadScreenState();
}
class _MessageThreadScreenState extends State<MessageThreadScreen> {
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<SettingsController>()
.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<SettingsController>().httpClient,
context.read<SettingsController>().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),
),
],
),
),
),
)
]),
);
}
}

View File

@ -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<MessagesScreen> createState() => _MessagesScreenState();
}
class _MessagesScreenState extends State<MessagesScreen> {
final PagingController<int, MessageThreadModel> _pagingController =
PagingController(firstPageKey: 1);
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
}
Future<void> _fetchPage(int pageKey) async {
try {
final newPage = await fetchMessages(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().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<int, MessageThreadModel>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<MessageThreadModel>(
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();
}
}

View File

@ -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<ProfileScreen> {
return whenLoggedIn(
context,
DefaultTabController(
length: 2,
length: 3,
child: Scaffold(
appBar: AppBar(
title:
@ -29,14 +30,19 @@ class _ProfileScreenState extends State<ProfileScreen> {
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(),
]),
),