working message rendering

This commit is contained in:
Henry Hiles 2026-01-28 14:17:18 +00:00
commit 7c76bb6e66
No known key found for this signature in database
20 changed files with 305 additions and 197 deletions

View file

@ -1,6 +1,7 @@
import "dart:developer"; import "dart:developer";
import "dart:ffi"; import "dart:ffi";
import "dart:isolate"; import "dart:isolate";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:ffi/ffi.dart"; import "package:ffi/ffi.dart";
import "package:flutter/foundation.dart"; import "package:flutter/foundation.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
@ -10,8 +11,12 @@ import "package:nexus/controllers/sync_status_controller.dart";
import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/controllers/top_level_spaces_controller.dart";
import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart";
import "package:nexus/models/client_state.dart"; import "package:nexus/models/client_state.dart";
import "package:nexus/models/login.dart"; import "package:nexus/models/event.dart";
import "package:nexus/models/report.dart"; import "package:nexus/models/get_event_request.dart";
import "package:nexus/models/get_related_events_request.dart";
import "package:nexus/models/login_request.dart";
import "package:nexus/models/profile.dart";
import "package:nexus/models/report_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/sync_data.dart"; import "package:nexus/models/sync_data.dart";
import "package:nexus/models/sync_status.dart"; import "package:nexus/models/sync_status.dart";
@ -34,7 +39,7 @@ class ClientController extends AsyncNotifier<int> {
try { try {
final muksEventType = command.cast<Utf8>().toDartString(); final muksEventType = command.cast<Utf8>().toDartString();
debugPrint("Handling $muksEventType..."); debugPrint("Handling $muksEventType...");
final Map<String, dynamic> decodedMuksEvent = data.toJson(); final decodedMuksEvent = data.toJson();
switch (muksEventType) { switch (muksEventType) {
case "client_state": case "client_state":
@ -92,10 +97,7 @@ class ClientController extends AsyncNotifier<int> {
throw Exception("GomuksStart returned error code $errorCode"); throw Exception("GomuksStart returned error code $errorCode");
} }
Future<Map<String, dynamic>> sendCommand( Future<dynamic> sendCommand(String command, Map<String, dynamic> data) async {
String command,
Map<String, dynamic> data,
) async {
final bufferPointer = data.toGomuksBufferPtr(); final bufferPointer = data.toGomuksBufferPtr();
final handle = await future; final handle = await future;
final response = await Isolate.run( final response = await Isolate.run(
@ -125,7 +127,27 @@ class ClientController extends AsyncNotifier<int> {
await sendCommand("leave_room", {"room_id": room.metadata!.id}); await sendCommand("leave_room", {"room_id": room.metadata!.id});
} }
Future<void> reportEvent(Report report) => Future<IList<Event>?> getRelatedEvents(
GetRelatedEventsRequest request,
) async {
final response =
(await sendCommand("get_related_events", request.toJson())) as List?;
return response?.map((event) => Event.fromJson(event)).toIList();
}
Future<Event?> getEvent(GetEventRequest request) async {
final json = await sendCommand("get_event", request.toJson());
return json == null ? null : Event.fromJson(json);
}
Future<Profile?> getProfile(String userId) async {
final json = await sendCommand("get_profile", {"user_id": userId});
return json == null ? null : Profile.fromJson(json);
}
Future<void> reportEvent(ReportRequest report) =>
sendCommand("report_event", report.toJson()); sendCommand("report_event", report.toJson());
Future<void> markRead(Room room) async { Future<void> markRead(Room room) async {
@ -137,7 +159,7 @@ class ClientController extends AsyncNotifier<int> {
}); });
} }
Future<bool> login(Login login) async { Future<bool> login(LoginRequest login) async {
try { try {
await sendCommand("login", login.toJson()); await sendCommand("login", login.toJson());
return true; return true;
@ -151,7 +173,7 @@ class ClientController extends AsyncNotifier<int> {
final response = await sendCommand("discover_homeserver", { final response = await sendCommand("discover_homeserver", {
"user_id": "@fakeuser:${homeserver.host}", "user_id": "@fakeuser:${homeserver.host}",
}); });
return (response["m.homeserver"] as Map<String, dynamic>)["base_url"]; return response["m.homeserver"]?["base_url"];
} catch (error) { } catch (error) {
return null; return null;
} }

View file

@ -1,18 +0,0 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:matrix/matrix.dart";
class EventsController extends AsyncNotifier<Timeline> {
EventsController(this.room);
final Room room;
@override
Future<Timeline> build({String? from}) => room.getTimeline();
Future<void> prev() async {
final timeline = await future;
await timeline.requestHistory();
}
static final provider = AsyncNotifierProvider.autoDispose
.family<EventsController, Timeline, Room>(EventsController.new);
}

View file

@ -0,0 +1,18 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/models/event.dart";
class NewEventsController extends Notifier<IList<Event>> {
final String roomId;
NewEventsController(this.roomId);
@override
IList<Event> build() => const IList.empty();
void add(IList<Event> newEvents) => state = newEvents;
static final provider = NotifierProvider.autoDispose
.family<NewEventsController, IList<Event>, String>(
NewEventsController.new,
);
}

View file

@ -1,65 +1,72 @@
import "package:collection/collection.dart"; import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:flutter_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_chat_core/flutter_chat_core.dart" as chat;
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:fluttertagger/fluttertagger.dart" as tagger; import "package:fluttertagger/fluttertagger.dart" as tagger;
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/controllers/selected_room_controller.dart";
import "package:nexus/helpers/extensions/event_to_message.dart";
import "package:nexus/helpers/extensions/list_to_messages.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/room.dart";
class RoomChatController extends AsyncNotifier<ChatController> { class RoomChatController extends AsyncNotifier<ChatController> {
final Room room; final String roomId;
RoomChatController(this.room); RoomChatController(this.roomId);
@override @override
Future<ChatController> build() async { Future<ChatController> build() async {
// final timeline = await ref.watch(EventsController.provider(room).future); final client = ref.watch(ClientController.provider.notifier);
final events =
ref.read(SelectedRoomController.provider)?.events ??
const IList.empty();
return InMemoryChatController(); ref.onDispose(
ref.listen(NewEventsController.provider(roomId), (_, next) async {
for (final event in next) {
if (event.type == "m.room.redaction") {
final controller = await future;
final message = controller.messages.firstWhereOrNull(
(message) => message.id == event.content["redacts"],
);
if (message == null) return;
// ref.onDispose( await controller.removeMessage(message);
// room.client.onTimelineEvent.stream.listen((event) async { } else {
// if (event.roomId != room.metadata.id) return; final message = await event.toMessage(client, includeEdits: true);
if (event.relationType == "m.replace") {
final controller = await future;
final oldMessage = controller.messages.firstWhereOrNull(
(element) => element.id == event.relatesTo,
);
if (oldMessage == null || message == null) return;
// if (event.type == "m.room.redaction") { return await updateMessage(
// final controller = await future; oldMessage,
// final message = controller.messages.firstWhereOrNull( message.copyWith(
// (message) => message.id == event.redacts, id: oldMessage.id,
// ); replyToMessageId: oldMessage.replyToMessageId,
// if (message == null) return; metadata: {
...(oldMessage.metadata ?? {}),
...(message.metadata ?? {})
.toIMap()
.where((key, value) => value != null)
.unlock,
},
),
);
}
if (message != null) {
return await insertMessage(message);
}
}
}
}).close,
);
// await controller.removeMessage(message); final messages = await events.toMessages(client);
// } else { return InMemoryChatController(messages: messages);
// final message = await event.toMessage(includeEdits: true, timeline);
// if (event.relationshipType == RelationshipTypes.edit) {
// final controller = await future;
// final oldMessage = controller.messages.firstWhereOrNull(
// (element) => element.id == event.relationshipEventId,
// );
// if (oldMessage == null || message == null) return;
// return await updateMessage(
// oldMessage,
// message.copyWith(
// id: oldMessage.id,
// replyToMessageId: oldMessage.replyToMessageId,
// metadata: {
// ...(oldMessage.metadata ?? {}),
// ...((message.metadata ?? {}).filterMap(
// (key, value) => value == null ? null : MapEntry(key, value),
// )),
// },
// ),
// );
// }
// if (message != null) {
// return await insertMessage(message);
// }
// }
// }).cancel,
// );
// return InMemoryChatController(
// messages: await timeline.events.toMessages(room, timeline),
// );
} }
Future<void> insertMessage(Message message) async { Future<void> insertMessage(Message message) async {
@ -145,8 +152,8 @@ class RoomChatController extends AsyncNotifier<ChatController> {
); );
} }
static final provider = AsyncNotifierProvider.family static final provider =
.autoDispose<RoomChatController, ChatController, Room>( AsyncNotifierProvider.family<RoomChatController, ChatController, String>(
RoomChatController.new, RoomChatController.new,
); );
} }

View file

@ -1,5 +1,6 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/new_events_controller.dart";
import "package:nexus/models/read_receipt.dart"; import "package:nexus/models/read_receipt.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
@ -13,11 +14,18 @@ class RoomsController extends Notifier<IMap<String, Room>> {
final incoming = entry.value; final incoming = entry.value;
final existing = acc[roomId]; final existing = acc[roomId];
ref
.watch(NewEventsController.provider(roomId).notifier)
.add(incoming.events);
return acc.add( return acc.add(
roomId, roomId,
existing?.copyWith( existing?.copyWith(
metadata: incoming.metadata ?? existing.metadata, metadata: incoming.metadata ?? existing.metadata,
events: existing.events.addAll(incoming.events), events: existing.events.updateById(
incoming.events,
(item) => item.eventId,
),
state: incoming.state.entries.fold( state: incoming.state.entries.fold(
existing.state, existing.state,
(stateAcc, event) => stateAcc.add( (stateAcc, event) => stateAcc.add(

View file

@ -1,23 +1,36 @@
import "package:collection/collection.dart"; import "dart:developer";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:matrix/matrix.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/get_event_request.dart";
import "package:nexus/models/get_related_events_request.dart";
extension EventToMessage on Event { extension EventToMessage on Event {
Future<Message?> toMessage( Future<Message?> toMessage(
Timeline timeline, { ClientController client, {
bool mustBeText = false, bool mustBeText = false,
bool includeEdits = false, bool includeEdits = false,
}) async { }) async {
final replyId = inReplyToEventId(); if (relationType == "m.replace" && !includeEdits) return null;
final newEvent = (unsigned?["m.relations"] as Map?)?["m.replace"];
final event = newEvent == null ? this : Event.fromJson(newEvent, room);
final newEvents = await client.getRelatedEvents(
GetRelatedEventsRequest(
roomId: roomId,
eventId: eventId,
relationType: "m.replace",
),
);
final event = newEvents?.lastOrNull ?? this;
final replyId = this.content["m.relates_to"]?["m.in_reply_to"]?["event_id"];
final replyEvent = replyId == null final replyEvent = replyId == null
? null ? null
: await room.getEventById(replyId); : await client.getEvent(
GetEventRequest(roomId: roomId, eventId: replyId),
);
final sender = final author = await client.getProfile(event.authorId);
await event.fetchSenderUser() ?? event.senderFromMemoryOrFallback;
final newContent = event.content["m.new_content"] as Map?; final newContent = event.content["m.new_content"] as Map?;
final metadata = { final metadata = {
"formatted": "formatted":
@ -26,109 +39,108 @@ extension EventToMessage on Event {
event.content["formatted_body"] ?? event.content["formatted_body"] ??
event.content["body"] ?? event.content["body"] ??
"", "",
"reply": await replyEvent?.toMessage(mustBeText: true, timeline), "reply": await replyEvent?.toMessage(client, mustBeText: true),
"body": newContent?["body"] ?? event.content["body"], "body": newContent?["body"] ?? event.content["body"],
"eventType": event.type, "eventType": event.type,
"avatarUrl": sender.avatarUrl.toString(), "avatarUrl": author?.avatarUrl,
"displayName": sender.displayName ?? sender.id, "displayName": author?.displayName ?? authorId,
"txnId": transactionId, "txnId": transactionId,
}; };
final editedAt = event.relationshipType == RelationshipTypes.edit final editedAt = event.relationType == "m.replace" ? event.timestamp : null;
? event.originServerTs
: null;
if ((redacted && !mustBeText) || if ((event.redactedBy != null && !mustBeText) ||
(!includeEdits && (relationshipType == RelationshipTypes.edit))) { (!includeEdits && (relationType == "m.replace"))) {
return null; return null;
} }
// TODO: Use server-generated preview if enabled when https://github.com/famedly/matrix-dart-sdk/issues/2195 is fixed. // TODO: Use server-generated preview if enabled
// final match = Uri.tryParse( // final match = Uri.tryParse(
// RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "", // RegExp(regexLink, caseSensitive: false).firstMatch(body)?.group(0) ?? "",
// ); // );
// final preview = match == null
// ? null
// : await room.client.getUrlPreview(match);
final asText = final asText =
Message.text( Message.text(
metadata: metadata, metadata: metadata,
id: eventId, id: eventId,
authorId: senderId, authorId: authorId,
text: redacted ? "This message has been deleted..." : event.body, text: redactedBy == null
? event.content["body"] ?? ""
: "This message has been deleted...",
replyToMessageId: replyId, replyToMessageId: replyId,
deliveredAt: originServerTs, deliveredAt: timestamp,
editedAt: editedAt, editedAt: editedAt,
) )
as TextMessage; as TextMessage;
final content = (decrypted ?? this.content);
if (mustBeText) return asText; if (mustBeText) return asText;
return switch (type) { return switch (type) {
EventTypes.Encrypted => asText.copyWith( "m.room.encrypted" => asText.copyWith(
text: "Unable to decrypt message.", text: "Unable to decrypt message.",
metadata: {...metadata, "formatted": "Unable to decrypt message."}, metadata: {...metadata, "formatted": "Unable to decrypt message."},
), ),
PollEventContent.startType => Message.custom( // "org.matrix.msc3381.poll.start" => Message.custom(
metadata: { // metadata: {
...metadata, // ...metadata,
"poll": event.parsedPollEventContent.pollStartContent, // "poll": event.parsedPollEventContent.pollStartContent,
"responses": event.getPollResponses(timeline), // "responses": event.getPollResponses(timeline),
}, // },
id: eventId, // id: eventId,
deliveredAt: originServerTs, // deliveredAt: originServerTs,
authorId: senderId, // authorId: senderId,
), // ),
(EventTypes.Sticker || EventTypes.Message) => switch (messageType) { ("m.sticker" || "m.room.message") => switch (content["msgtype"]) {
(MessageTypes.Sticker || MessageTypes.Image) => Message.image( ("m.sticker" || "m.image") => Message.image(
metadata: metadata, metadata: metadata,
id: eventId, id: eventId,
authorId: senderId, authorId: authorId,
text: event.text, text: event.localContent?.sanitizedHtml,
source: (await getAttachmentUri()).toString(), source: "(await getAttachmentUri()).toString()", // TODO
replyToMessageId: replyId, replyToMessageId: replyId,
deliveredAt: originServerTs, deliveredAt: timestamp,
blurhash: (event.content["info"] as Map?)?["xyz.amorgan.blurhash"], blurhash: (event.content["info"] as Map?)?["xyz.amorgan.blurhash"],
), ),
MessageTypes.Audio => Message.audio( "m.audio" => Message.audio(
metadata: metadata, metadata: metadata,
id: eventId, id: eventId,
authorId: senderId, authorId: authorId,
text: event.text, text: event.content["body"],
replyToMessageId: replyId, replyToMessageId: replyId,
source: (await event.getAttachmentUri()).toString(), source: "(await event.getAttachmentUri()).toString()", // TODO
deliveredAt: originServerTs, deliveredAt: timestamp,
// TODO: See if we can figure out duration // TODO: See if we can figure out duration
duration: Duration(hours: 1), duration: Duration(hours: 1),
), ),
MessageTypes.File => Message.file( "m.file" => Message.file(
name: event.content["filename"].toString(), name: event.content["filename"].toString(),
metadata: metadata, metadata: metadata,
id: eventId, id: eventId,
authorId: senderId, authorId: authorId,
source: (await event.getAttachmentUri()).toString(), source: "(await event.getAttachmentUri()).toString()", // TODO
replyToMessageId: replyId, replyToMessageId: replyId,
deliveredAt: originServerTs, deliveredAt: timestamp,
), ),
_ => asText, _ => asText,
}, },
EventTypes.RoomMember => Message.system( "m.room.member" => Message.system(
metadata: metadata, metadata: metadata,
id: eventId, id: eventId,
authorId: senderId, authorId: authorId,
deliveredAt: timestamp,
text: text:
"${event.asUser.displayName ?? event.asUser.id} ${switch (Membership.values.firstWhereOrNull((membership) => membership.name == event.content["membership"])) { "${content["displayname"] ?? event.stateKey} ${switch (event.content["membership"]) {
Membership.invite => "was invited to", "invite" => "was invited to",
Membership.join => "joined", "join" => "joined",
Membership.leave => "left", "leave" => "left",
Membership.knock => "asked to join", "knock" => "asked to join",
Membership.ban => "was banned from", "ban" => "was banned from",
_ => "did something relating to", _ => "did something relating to",
}} the room.", }} the room.",
), ),
EventTypes.Redaction => null, "m.room.redaction" => null,
_ => _ =>
// Turn this on for debugging purposes // Turn this on for debugging purposes
false false
@ -136,7 +148,7 @@ extension EventToMessage on Event {
? Message.unsupported( ? Message.unsupported(
metadata: metadata, metadata: metadata,
id: eventId, id: eventId,
authorId: senderId, authorId: authorId,
replyToMessageId: replyId, replyToMessageId: replyId,
) )
: null, : null,

View file

@ -4,7 +4,7 @@ import "dart:typed_data";
import "package:ffi/ffi.dart"; import "package:ffi/ffi.dart";
import "package:nexus/src/third_party/gomuks.g.dart"; import "package:nexus/src/third_party/gomuks.g.dart";
extension GomuksOwnedBufferToJson on GomuksOwnedBuffer { extension GomuksOwnedBufferToX on GomuksOwnedBuffer {
Uint8List toBytes() { Uint8List toBytes() {
try { try {
if (base == nullptr || length <= 0) return Uint8List(0); if (base == nullptr || length <= 0) return Uint8List(0);
@ -14,14 +14,7 @@ extension GomuksOwnedBufferToJson on GomuksOwnedBuffer {
} }
} }
Map<String, dynamic> toJson() { dynamic toJson() => jsonDecode(utf8.decode(toBytes()));
final bytes = toBytes();
if (bytes.isEmpty) return {};
final json = jsonDecode(utf8.decode(bytes));
if (json is Map<String, dynamic>?) return json ?? {};
throw json;
}
} }
extension JsonToGomuksBuffer on Map<String, dynamic> { extension JsonToGomuksBuffer on Map<String, dynamic> {

View file

@ -1,10 +1,12 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart";
import "package:matrix/matrix.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/extensions/event_to_message.dart"; import "package:nexus/helpers/extensions/event_to_message.dart";
import "package:nexus/models/event.dart";
extension ListToMessages on List<MatrixEvent> { extension ListToMessages on IList<Event> {
Future<List<Message>> toMessages(Room room, Timeline timeline) async => Future<List<Message>> toMessages(ClientController client) async =>
(await Future.wait( (await Future.wait(
map((event) => Event.fromMatrixEvent(event, room).toMessage(timeline)), map((event) => event.toMessage(client)),
)).nonNulls.toList().reversed.toList(); )).nonNulls.toList();
} }

View file

@ -23,7 +23,7 @@ abstract class Event with _$Event {
String? transactionId, String? transactionId,
String? redactedBy, String? redactedBy,
String? relatesTo, String? relatesTo,
@JsonKey(name: "relates_type") String? relationType, String? relationType,
String? decryptionError, String? decryptionError,
String? sendError, String? sendError,
@Default(IMap.empty()) IMap<String, int> reactions, @Default(IMap.empty()) IMap<String, int> reactions,

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "get_event_request.freezed.dart";
part "get_event_request.g.dart";
@freezed
abstract class GetEventRequest with _$GetEventRequest {
const factory GetEventRequest({
required String roomId,
required String eventId,
@Default(false) bool unredact,
}) = _GetEventRequest;
factory GetEventRequest.fromJson(Map<String, Object?> json) =>
_$GetEventRequestFromJson(json);
}

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "get_related_events_request.freezed.dart";
part "get_related_events_request.g.dart";
@freezed
abstract class GetRelatedEventsRequest with _$GetRelatedEventsRequest {
const factory GetRelatedEventsRequest({
required String roomId,
required String eventId,
required String relationType,
}) = _GetRelatedEventsRequest;
factory GetRelatedEventsRequest.fromJson(Map<String, Object?> json) =>
_$GetRelatedEventsRequestFromJson(json);
}

View file

@ -1,14 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "login.freezed.dart";
part "login.g.dart";
@freezed
abstract class Login with _$Login {
const factory Login({
required String username,
required String password,
required String homeserverUrl,
}) = _Login;
factory Login.fromJson(Map<String, Object?> json) => _$LoginFromJson(json);
}

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "login_request.freezed.dart";
part "login_request.g.dart";
@freezed
abstract class LoginRequest with _$LoginRequest {
const factory LoginRequest({
required String username,
required String password,
required String homeserverUrl,
}) = _LoginRequest;
factory LoginRequest.fromJson(Map<String, Object?> json) =>
_$LoginRequestFromJson(json);
}

29
lib/models/profile.dart Normal file
View file

@ -0,0 +1,29 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:freezed_annotation/freezed_annotation.dart";
part "profile.freezed.dart";
part "profile.g.dart";
@freezed
abstract class Profile with _$Profile {
const factory Profile({
String? avatarUrl,
@JsonKey(name: "displayname") String? displayName,
@JsonKey(name: "us.cloke.msc4175.tz") String? timezone,
@Default(IList.empty())
@JsonKey(name: "io.fsky.nyx.pronouns")
IList<Pronoun> pronouns,
}) = _Profile;
factory Profile.fromJson(Map<String, Object?> json) =>
_$ProfileFromJson(json);
}
@freezed
abstract class Pronoun with _$Pronoun {
const factory Pronoun({required String language, required String summary}) =
_Pronoun;
factory Pronoun.fromJson(Map<String, Object?> json) =>
_$PronounFromJson(json);
}

View file

@ -1,14 +0,0 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "report.freezed.dart";
part "report.g.dart";
@freezed
abstract class Report with _$Report {
const factory Report({
required String roomId,
required String eventId,
String? reason,
}) = _Report;
factory Report.fromJson(Map<String, Object?> json) => _$ReportFromJson(json);
}

View file

@ -0,0 +1,15 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "report_request.freezed.dart";
part "report_request.g.dart";
@freezed
abstract class ReportRequest with _$ReportRequest {
const factory ReportRequest({
required String roomId,
required String eventId,
String? reason,
}) = _ReportRequest;
factory ReportRequest.fromJson(Map<String, Object?> json) =>
_$ReportRequestFromJson(json);
}

View file

@ -1,12 +1,13 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/chat_page/sidebar.dart"; import "package:nexus/widgets/chat_page/sidebar.dart";
import "package:nexus/widgets/chat_page/room_chat.dart"; import "package:nexus/widgets/chat_page/room_chat.dart";
class ChatPage extends StatelessWidget { class ChatPage extends ConsumerWidget {
const ChatPage({super.key}); const ChatPage({super.key});
@override @override
Widget build(BuildContext context) => LayoutBuilder( Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 650; final isDesktop = constraints.maxWidth > 650;
final showMembersByDefault = constraints.maxWidth > 1000; final showMembersByDefault = constraints.maxWidth > 1000;

View file

@ -5,7 +5,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
import "package:nexus/models/homeserver.dart"; import "package:nexus/models/homeserver.dart";
import "package:nexus/models/login.dart"; import "package:nexus/models/login_request.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -174,7 +174,7 @@ class LoginPage extends HookConsumerWidget {
onPressed: () async { onPressed: () async {
isLoading.value = true; isLoading.value = true;
final succeeded = await client.login( final succeeded = await client.login(
Login( LoginRequest(
username: username.text, username: username.text,
password: password.text, password: password.text,
homeserverUrl: homeserver.value!, homeserverUrl: homeserver.value!,

View file

@ -44,9 +44,9 @@ class ChatBox extends HookConsumerWidget {
} }
void send() { void send() {
if (controller.value.text.trim().isEmpty) return; if (controller.value.text.trim().isEmpty || room.metadata == null) return;
ref ref
.watch(RoomChatController.provider(room).notifier) .watch(RoomChatController.provider(room.metadata!.id).notifier)
.send( .send(
controller.value.formattedText, controller.value.formattedText,
relation: relatedMessage, relation: relatedMessage,

View file

@ -18,7 +18,7 @@ import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/relation_type.dart"; import "package:nexus/models/relation_type.dart";
import "package:nexus/models/report.dart"; import "package:nexus/models/report_request.dart";
import "package:nexus/widgets/chat_page/chat_box.dart"; import "package:nexus/widgets/chat_page/chat_box.dart";
import "package:nexus/widgets/chat_page/html/html.dart"; import "package:nexus/widgets/chat_page/html/html.dart";
import "package:nexus/widgets/chat_page/room_appbar.dart"; import "package:nexus/widgets/chat_page/room_appbar.dart";
@ -43,12 +43,13 @@ class RoomChat extends HookConsumerWidget {
final replyToMessage = useState<Message?>(null); final replyToMessage = useState<Message?>(null);
final memberListOpened = useState<bool>(showMembersByDefault); final memberListOpened = useState<bool>(showMembersByDefault);
final relationType = useState(RelationType.reply); final relationType = useState(RelationType.reply);
final theme = Theme.of(context);
final danger = theme.colorScheme.error;
final room = ref.watch(SelectedRoomController.provider); final room = ref.watch(SelectedRoomController.provider);
final userId = ref.watch(ClientStateController.provider)?.userId; final userId = ref.watch(ClientStateController.provider)?.userId;
if (room == null || userId == null) { final theme = Theme.of(context);
final danger = theme.colorScheme.error;
if (room == null || userId == null || room.metadata?.id == null) {
return Center( return Center(
child: Text( child: Text(
"Nothing to see here...", "Nothing to see here...",
@ -56,7 +57,8 @@ class RoomChat extends HookConsumerWidget {
), ),
); );
} }
final controllerProvider = RoomChatController.provider(room);
final controllerProvider = RoomChatController.provider(room.metadata!.id);
final notifier = ref.watch(controllerProvider.notifier); final notifier = ref.watch(controllerProvider.notifier);
List<PopupMenuEntry> getMessageOptions(Message message) { List<PopupMenuEntry> getMessageOptions(Message message) {
@ -158,7 +160,7 @@ class RoomChat extends HookConsumerWidget {
onPressed: () { onPressed: () {
if (room.metadata == null) return; if (room.metadata == null) return;
client.reportEvent( client.reportEvent(
Report( ReportRequest(
roomId: room.metadata!.id, roomId: room.metadata!.id,
eventId: message.id, eventId: message.id,
reason: reasonController.text.isEmpty reason: reasonController.text.isEmpty