Implement direct message sending/viewing
This commit is contained in:
parent
c856953f31
commit
7b723e2d24
|
@ -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?>);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
]),
|
||||
),
|
||||
|
|
Loading…
Reference in New Issue