Add better error handling, send messages early and update when delivered

This commit is contained in:
Henry Hiles 2026-03-30 12:47:49 -04:00
commit 55ecbc3590
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
9 changed files with 111 additions and 74 deletions

View file

@ -9,6 +9,7 @@ import "package:flutter/foundation.dart";
import "package:nexus/controllers/account_data_controller.dart"; import "package:nexus/controllers/account_data_controller.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/init_complete_controller.dart"; import "package:nexus/controllers/init_complete_controller.dart";
import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/controllers/space_edges_controller.dart"; import "package:nexus/controllers/space_edges_controller.dart";
import "package:nexus/controllers/sync_status_controller.dart"; import "package:nexus/controllers/sync_status_controller.dart";
@ -74,6 +75,13 @@ class ClientController extends AsyncNotifier<int> {
case "init_complete": case "init_complete":
ref.watch(InitCompleteController.provider.notifier).complete(); ref.watch(InitCompleteController.provider.notifier).complete();
break; break;
case "send_complete":
final event = Event.fromJson(decodedMuksEvent["event"]);
ref
.watch(NewEventsController.provider(event.roomId).notifier)
.add(IList([event]));
break;
case "sync_complete": case "sync_complete":
final syncData = SyncData.fromJson(decodedMuksEvent); final syncData = SyncData.fromJson(decodedMuksEvent);
final roomProvider = RoomsController.provider; final roomProvider = RoomsController.provider;
@ -150,8 +158,8 @@ class ClientController extends AsyncNotifier<int> {
Future<void> redactEvent(RedactEventRequest report) => Future<void> redactEvent(RedactEventRequest report) =>
_sendCommand("redact_event", report.toJson()); _sendCommand("redact_event", report.toJson());
Future<void> sendMessage(SendMessageRequest request) => Future<Event> sendMessage(SendMessageRequest request) async =>
_sendCommand("send_message", request.toJson()); Event.fromJson(await _sendCommand("send_message", request.toJson()));
Future<String?> verify(String recoveryKey) async { Future<String?> verify(String recoveryKey) async {
try { try {

View file

@ -46,6 +46,7 @@ class MessageController extends AsyncNotifier<Message?> {
"big": event.localContent?.bigEmoji == true, "big": event.localContent?.bigEmoji == true,
"eventType": type, "eventType": type,
"pmp": event.content["com.beeper.per_message_profile"], "pmp": event.content["com.beeper.per_message_profile"],
"error": event.sendError,
"editSource": "editSource":
event.localContent?.editSource ?? event.localContent?.editSource ??
newContent?["body"] ?? newContent?["body"] ??

View file

@ -28,7 +28,6 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
var room = ref.read(RoomsController.provider)[roomId]; var room = ref.read(RoomsController.provider)[roomId];
if (room == null) return InMemoryChatController(); if (room == null) return InMemoryChatController();
final state = await client.getRoomState( final state = await client.getRoomState(
GetRoomStateRequest(roomId: roomId), GetRoomStateRequest(roomId: roomId),
); );
@ -78,7 +77,6 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
ref.onDispose( ref.onDispose(
ref.listen(NewEventsController.provider(roomId), (_, next) async { ref.listen(NewEventsController.provider(roomId), (_, next) async {
final controller = await future;
for (final event in next) { for (final event in next) {
if (event.type == "m.room.redaction") { if (event.type == "m.room.redaction") {
final controller = await future; final controller = await future;
@ -116,12 +114,8 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
), ),
); );
} }
if (message != null && if (message != null && ref.mounted) {
!controller.messages.any( await insertMessage(message);
(oldMessage) => oldMessage.id == message.id,
) &&
ref.mounted) {
await controller.insertMessage(message);
} }
} }
} }
@ -152,19 +146,11 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
: controller.updateMessage(oldMessage, message); : controller.updateMessage(oldMessage, message);
} }
Future<void> deleteMessage(Message message, {String? reason}) async { Future<void> deleteMessage(Message message, {String? reason}) => ref
final controller = await future; .watch(ClientController.provider.notifier)
await controller.removeMessage(message); .redactEvent(
await ref RedactEventRequest(eventId: message.id, roomId: roomId, reason: reason),
.watch(ClientController.provider.notifier) );
.redactEvent(
RedactEventRequest(
eventId: message.id,
roomId: roomId,
reason: reason,
),
);
}
Future<void> loadOlder([InMemoryChatController? chatController]) async { Future<void> loadOlder([InMemoryChatController? chatController]) async {
final response = await ref final response = await ref
@ -242,7 +228,8 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
} }
final client = ref.watch(ClientController.provider.notifier); final client = ref.watch(ClientController.provider.notifier);
client.sendMessage( final room = ref.read(RoomsController.provider)[roomId];
final event = await client.sendMessage(
SendMessageRequest( SendMessageRequest(
roomId: roomId, roomId: roomId,
mentions: Mentions( mentions: Mentions(
@ -260,6 +247,15 @@ class RoomChatController extends AsyncNotifier<InMemoryChatController> {
: Relation(eventId: relation.id, relationType: relationType), : Relation(eventId: relation.id, relationType: relationType),
), ),
); );
final message = room == null
? null
: await ref.watch(
MessageController.provider(
MessageConfig(room: room, event: event),
).future,
);
if (message != null) insertMessage(message);
} }
Future<chat.User> resolveUser(String id) async { Future<chat.User> resolveUser(String id) async {

View file

@ -6,7 +6,6 @@ part "message_config.g.dart";
@freezed @freezed
abstract class MessageConfig with _$MessageConfig { abstract class MessageConfig with _$MessageConfig {
const MessageConfig._();
const factory MessageConfig({ const factory MessageConfig({
@Default(false) bool alwaysReturn, @Default(false) bool alwaysReturn,
@Default(false) bool includeEdits, @Default(false) bool includeEdits,
@ -14,15 +13,6 @@ abstract class MessageConfig with _$MessageConfig {
required Event event, required Event event,
}) = _MessageConfig; }) = _MessageConfig;
@override
bool operator ==(Object other) =>
other.runtimeType == runtimeType &&
other is MessageConfig &&
other.event.eventId == event.eventId;
@override
int get hashCode => Object.hash(runtimeType, event.eventId);
factory MessageConfig.fromJson(Map<String, Object?> json) => factory MessageConfig.fromJson(Map<String, Object?> json) =>
_$MessageConfigFromJson(json); _$MessageConfigFromJson(json);
} }

View file

@ -21,7 +21,7 @@ import "package:nexus/widgets/chat_page/room_appbar.dart";
import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart"; import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart";
import "package:nexus/widgets/chat_page/reply_widget.dart"; import "package:nexus/widgets/chat_page/reply_widget.dart";
import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/widgets/form_text_input.dart";
// import "package:dynamic_polls/dynamic_polls.dart"; import "package:nexus/main.dart";
class RoomChat extends HookConsumerWidget { class RoomChat extends HookConsumerWidget {
final bool isDesktop; final bool isDesktop;
@ -108,11 +108,13 @@ class RoomChat extends HookConsumerWidget {
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
notifier.deleteMessage(
message,
reason: deleteReasonController.text,
);
Navigator.of(context).pop(); Navigator.of(context).pop();
await notifier
.deleteMessage(
message,
reason: deleteReasonController.text,
)
.onError(showError);
}, },
child: Text("Delete"), child: Text("Delete"),
), ),

View file

@ -2,6 +2,7 @@ import "package:flutter/material.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart";
import "package:timeago/timeago.dart";
class MessageWrapper extends StatelessWidget { class MessageWrapper extends StatelessWidget {
final Message message; final Message message;
@ -10,41 +11,69 @@ class MessageWrapper extends StatelessWidget {
const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); const MessageWrapper(this.message, this.child, this.groupStatus, {super.key});
@override @override
Widget build(BuildContext context) => ClipRRect( Widget build(BuildContext context) {
borderRadius: BorderRadius.all(Radius.circular(12)), final theme = Theme.of(context);
child: AnimatedContainer( final error = message.metadata?["error"];
padding: message.metadata?["flashing"] == true return ClipRRect(
? EdgeInsets.all(8) borderRadius: BorderRadius.all(Radius.circular(12)),
: EdgeInsets.all(0), child: AnimatedContainer(
color: message.metadata?["flashing"] == true padding: message.metadata?["flashing"] == true
? Theme.of(context).colorScheme.onSurface.withAlpha(50) ? EdgeInsets.all(8)
: Colors.transparent, : EdgeInsets.all(0),
duration: Duration(milliseconds: 250), color: message.metadata?["flashing"] == true
child: Row( ? Theme.of(context).colorScheme.onSurface.withAlpha(50)
spacing: 8, : Colors.transparent,
crossAxisAlignment: CrossAxisAlignment.start, duration: Duration(milliseconds: 250),
children: [ child: Row(
groupStatus?.isFirst != false spacing: 8,
? MessageAvatar(message, height: 40) crossAxisAlignment: CrossAxisAlignment.start,
: SizedBox(width: 40), children: [
Expanded( groupStatus?.isFirst != false
child: Column( ? MessageAvatar(message, height: 40)
crossAxisAlignment: CrossAxisAlignment.start, : SizedBox(width: 40),
spacing: 4, Expanded(
children: [ child: Column(
if (groupStatus?.isFirst != false) crossAxisAlignment: CrossAxisAlignment.start,
MessageDisplayname( spacing: 4,
message, children: [
style: Theme.of(context).textTheme.titleMedium?.copyWith( if (groupStatus?.isFirst != false)
fontWeight: FontWeight.bold, Row(
children: [
Flexible(
child: MessageDisplayname(
message,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (message.deliveredAt != null &&
groupStatus?.isFirst == true)
Tooltip(
message: message.deliveredAt!.toString(),
child: Text(
format(message.deliveredAt!),
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
),
],
), ),
), child,
child, if (error != null && error != "not sent")
], Text(
error,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
),
), ),
), ],
], ),
), ),
), );
); }
} }

View file

@ -40,7 +40,9 @@ class TextMessageWrapper extends StatelessWidget {
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSentByMe color: isSentByMe
? colorScheme.primaryContainer ? (message.id.startsWith("~")
? colorScheme.onPrimary
: colorScheme.primaryContainer)
: colorScheme.surfaceContainer, : colorScheme.surfaceContainer,
), ),
child: Column( child: Column(

View file

@ -1357,6 +1357,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.0+1" version: "0.1.0+1"
timeago:
dependency: "direct main"
description:
name: timeago
sha256: b05159406a97e1cbb2b9ee4faa9fb096fe0e2dfcd8b08fcd2a00553450d3422e
url: "https://pub.dev"
source: hosted
version: "3.7.1"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View file

@ -61,6 +61,7 @@ dependencies:
hooks: ^1.0.0 hooks: ^1.0.0
code_assets: ^1.0.0 code_assets: ^1.0.0
ffigen: ^20.1.1 ffigen: ^20.1.1
timeago: ^3.7.1
dev_dependencies: dev_dependencies:
build_runner: ^2.4.11 build_runner: ^2.4.11