From a28bced44ded0fa6bdcab573798387922903a215 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 27 Jan 2026 19:09:43 +0000 Subject: [PATCH] shows room but not really --- lib/controllers/client_controller.dart | 19 + lib/controllers/header_controller.dart | 24 + lib/controllers/room_chat_controller.dart | 184 ++-- lib/controllers/spaces_controller.dart | 50 +- lib/helpers/extensions/get_headers.dart | 7 + lib/helpers/extensions/link_to_mention.dart | 40 + lib/main.dart | 2 + lib/models/client_state.dart | 1 + lib/models/event.dart | 2 +- lib/models/relation_type.dart | 1 + lib/models/report.dart | 14 + lib/models/room_metadata.dart | 4 +- lib/pages/chat_page.dart | 14 +- lib/widgets/avatar_or_hash.dart | 3 + lib/widgets/chat_page/chat_box.dart | 36 +- lib/widgets/chat_page/html/html.dart | 106 +- lib/widgets/chat_page/html/mention_chip.dart | 4 +- lib/widgets/chat_page/relation_preview.dart | 30 +- lib/widgets/chat_page/room_appbar.dart | 35 +- lib/widgets/chat_page/room_chat.dart | 984 +++++++++---------- lib/widgets/chat_page/room_menu.dart | 101 +- lib/widgets/chat_page/sidebar.dart | 31 +- lib/widgets/chat_page/top_widget.dart | 14 +- 23 files changed, 893 insertions(+), 813 deletions(-) create mode 100644 lib/controllers/header_controller.dart create mode 100644 lib/helpers/extensions/get_headers.dart create mode 100644 lib/helpers/extensions/link_to_mention.dart create mode 100644 lib/models/relation_type.dart create mode 100644 lib/models/report.dart diff --git a/lib/controllers/client_controller.dart b/lib/controllers/client_controller.dart index bc7a904..f0ad4ea 100644 --- a/lib/controllers/client_controller.dart +++ b/lib/controllers/client_controller.dart @@ -11,6 +11,8 @@ import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/helpers/extensions/gomuks_buffer.dart"; import "package:nexus/models/client_state.dart"; import "package:nexus/models/login.dart"; +import "package:nexus/models/report.dart"; +import "package:nexus/models/room.dart"; import "package:nexus/models/sync_data.dart"; import "package:nexus/models/sync_status.dart"; import "package:nexus/src/third_party/gomuks.g.dart"; @@ -118,6 +120,23 @@ class ClientController extends AsyncNotifier { } } + Future leaveRoom(Room room) async { + if (room.metadata == null) return; + await sendCommand("leave_room", {"room_id": room.metadata!.id}); + } + + Future reportEvent(Report report) => + sendCommand("report_event", report.toJson()); + + Future markRead(Room room) async { + if (room.events.isEmpty || room.metadata == null) return; + await sendCommand("mark_read", { + "room_id": room.metadata?.id, + "receipt_type": "m.read", + "event_id": room.events.last.eventId, + }); + } + Future login(Login login) async { try { await sendCommand("login", login.toJson()); diff --git a/lib/controllers/header_controller.dart b/lib/controllers/header_controller.dart new file mode 100644 index 0000000..dcb73ea --- /dev/null +++ b/lib/controllers/header_controller.dart @@ -0,0 +1,24 @@ +import "package:ffi/ffi.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/src/third_party/gomuks.g.dart"; + +class HeaderController extends AsyncNotifier> { + @override + Future> build() async { + final handle = await ref.watch(ClientController.provider.future); + final info = GomuksGetAccountInfo(handle); + final headers = { + "authorization": + "Bearer ${info.access_token.cast().toDartString()}", + }; + + GomuksFreeAccountInfo(info); + return headers; + } + + static final provider = + AsyncNotifierProvider>( + HeaderController.new, + ); +} diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 9175e36..ffc8ffc 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -2,13 +2,9 @@ import "package:collection/collection.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_chat_core/flutter_chat_core.dart" as chat; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/controllers/avatar_controller.dart"; -import "package:nexus/controllers/events_controller.dart"; -import "package:nexus/helpers/extensions/event_to_message.dart"; -import "package:nexus/helpers/extensions/list_to_messages.dart"; import "package:fluttertagger/fluttertagger.dart" as tagger; import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/room.dart"; class RoomChatController extends AsyncNotifier { final Room room; @@ -16,52 +12,54 @@ class RoomChatController extends AsyncNotifier { @override Future build() async { - final timeline = await ref.watch(EventsController.provider(room).future); + // final timeline = await ref.watch(EventsController.provider(room).future); - ref.onDispose( - room.client.onTimelineEvent.stream.listen((event) async { - if (event.roomId != room.id) return; + return InMemoryChatController(); - if (event.type == EventTypes.Redaction) { - final controller = await future; - final message = controller.messages.firstWhereOrNull( - (message) => message.id == event.redacts, - ); - if (message == null) return; + // ref.onDispose( + // room.client.onTimelineEvent.stream.listen((event) async { + // if (event.roomId != room.metadata.id) return; - await controller.removeMessage(message); - } else { - 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, - ); + // if (event.type == "m.room.redaction") { + // final controller = await future; + // final message = controller.messages.firstWhereOrNull( + // (message) => message.id == event.redacts, + // ); + // if (message == null) return; - return InMemoryChatController( - messages: await timeline.events.toMessages(room, timeline), - ); + // await controller.removeMessage(message); + // } else { + // 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 insertMessage(Message message) async { @@ -79,37 +77,29 @@ class RoomChatController extends AsyncNotifier { } Future deleteMessage(Message message, {String? reason}) async { - final controller = await future; - await controller.removeMessage(message); - await room.redactEvent(message.id, reason: reason); + // final controller = await future; + // await controller.removeMessage(message); + // await room.redactEvent(message.id, reason: reason); } Future loadOlder() async { - final currentEvents = await future; - await ref.watch(EventsController.provider(room).notifier).prev(); - final timeline = await ref.watch(EventsController.provider(room).future); + // final currentEvents = await future; + // await ref.watch(EventsController.provider(room).notifier).prev(); + // final timeline = await ref.watch(EventsController.provider(room).future); - final controller = await future; - await controller.insertAllMessages( - await timeline.events - .where( - (event) => !currentEvents.messages.any( - (existingEvent) => existingEvent.id == event.eventId, - ), - ) - .toList() - .toMessages(room, timeline), - index: 0, - ); - ref.notifyListeners(); - } - - Future markRead() async { - if (!room.hasNewMessages) return; - final controller = await future; - final id = controller.messages.last.id; - - await room.setReadMarker(id, mRead: id); + // final controller = await future; + // await controller.insertAllMessages( + // await timeline.events + // .where( + // (event) => !currentEvents.messages.any( + // (existingEvent) => existingEvent.id == event.eventId, + // ), + // ) + // .toList() + // .toMessages(room, timeline), + // index: 0, + // ); + // ref.notifyListeners(); } Future updateMessage(Message message, Message newMessage) async => @@ -121,37 +111,37 @@ class RoomChatController extends AsyncNotifier { required RelationType relationType, Message? relation, }) async { - var taggedMessage = message; + // var taggedMessage = message; - for (final tag in tags) { - final escaped = RegExp.escape(tag.id); - final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); + // for (final tag in tags) { + // final escaped = RegExp.escape(tag.id); + // final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); - taggedMessage = taggedMessage.replaceAllMapped( - pattern, - (match) => match.group(1)!, - ); - } + // taggedMessage = taggedMessage.replaceAllMapped( + // pattern, + // (match) => match.group(1)!, + // ); + // } - await room.sendTextEvent( - taggedMessage, - editEventId: relationType == RelationType.edit ? relation?.id : null, - inReplyTo: (relationType == RelationType.reply && relation != null) - ? await room.getEventById(relation.id) - : null, - ); + // await room.sendTextEvent( + // taggedMessage, + // editEventId: relationType == RelationType.edit ? relation?.id : null, + // inReplyTo: (relationType == RelationType.reply && relation != null) + // ? await room.getEventById(relation.id) + // : null, + // ); } Future resolveUser(String id) async { - final user = await room.client.getUserProfile(id); + // final user = await room.client.getUserProfile(id); return chat.User( id: id, - name: user.displayname, - imageSource: user.avatarUrl == null - ? null - : (await ref.watch( - AvatarController.provider(user.avatarUrl!.toString()).future, - )).toString(), + // name: user.displayname, + // imageSource: user.avatarUrl == null + // ? null + // : (await ref.watch( + // AvatarController.provider(user.avatarUrl!.toString()).future, + // )).toString(), ); } diff --git a/lib/controllers/spaces_controller.dart b/lib/controllers/spaces_controller.dart index a62c55c..f0d7a6b 100644 --- a/lib/controllers/spaces_controller.dart +++ b/lib/controllers/spaces_controller.dart @@ -15,35 +15,34 @@ class SpacesController extends Notifier> { final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); final spaceEdges = ref.watch(SpaceEdgesController.provider); - ISet collectChildIds(String spaceId) { - ISet result = ISet(); - void walk(String currentId) { - final children = spaceEdges[currentId] ?? IList(); - for (final edge in children) { - final childId = edge.childId; - if (!result.contains(childId)) { - result = result.add(childId); - walk(childId); - } - } - } - - walk(spaceId); - return result; - } - - final spaceIdToChildren = IMap.fromEntries( + final childRoomsBySpaceId = IMap.fromEntries( topLevelSpaceIds.map((spaceId) { - final children = collectChildIds( + ISet walk(String currentId) { + final children = spaceEdges[currentId] ?? IList(); + + return children.fold>(const ISet.empty(), (acc, edge) { + final childId = edge.childId; + final isSpace = spaceEdges.containsKey(childId); + + return acc + .addAll(!isSpace ? ISet([childId]) : const ISet.empty()) + .addAll(isSpace ? walk(childId) : const ISet.empty()); + }); + } + + return MapEntry( spaceId, - ).map((id) => rooms[id]).nonNulls.toIList(); - return MapEntry(spaceId, children); + walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(), + ); }), ); - final allNestedRoomIds = spaceIdToChildren.values + final allNestedRoomIds = childRoomsBySpaceId.values .expand((l) => l) - .map((r) => rooms.entries.firstWhere((e) => e.value == r).key) + .map( + (room) => + rooms.entries.firstWhere((entry) => entry.value == room).key, + ) .toISet(); final dmRooms = rooms.values @@ -55,7 +54,8 @@ class SpacesController extends Notifier> { (e) => e.value.metadata?.dmUserId == null && !allNestedRoomIds.contains(e.key) && - !topLevelSpaceIds.contains(e.key), + !topLevelSpaceIds.contains(e.key) && + !spaceEdges.containsKey(e.key), ) .map((e) => e.value) .toIList(); @@ -65,7 +65,7 @@ class SpacesController extends Notifier> { final room = rooms[id]; if (room == null) return null; - final children = spaceIdToChildren[id] ?? IList(); + final children = childRoomsBySpaceId[id] ?? IList(); return Space( id: id, title: room.metadata?.name ?? "Unnamed Room", diff --git a/lib/helpers/extensions/get_headers.dart b/lib/helpers/extensions/get_headers.dart new file mode 100644 index 0000000..e1bb5f3 --- /dev/null +++ b/lib/helpers/extensions/get_headers.dart @@ -0,0 +1,7 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/header_controller.dart"; + +extension GetHeaders on WidgetRef { + Map get headers => + watch(HeaderController.provider).requireValue; +} diff --git a/lib/helpers/extensions/link_to_mention.dart b/lib/helpers/extensions/link_to_mention.dart new file mode 100644 index 0000000..33d2bd2 --- /dev/null +++ b/lib/helpers/extensions/link_to_mention.dart @@ -0,0 +1,40 @@ +extension LinkToMention on String { + /// Extracts a Matrix identifier from this string. + /// + /// Supports: + /// - https://matrix.to/#/... + /// - matrix:roomid/... + /// - matrix:r/... + /// - matrix:u/... + /// + /// Returns the decoded identifier (e.g. "#room:matrix.org") + /// or null if this is not a Matrix link. + String? get mention { + final trimmed = trim(); + + final matrixTo = RegExp( + r"^https?://matrix\.to/#/([^/?#]+)", + caseSensitive: false, + ); + + final matrixToMatch = matrixTo.firstMatch(trimmed); + if (matrixToMatch != null) { + return Uri.decodeComponent(matrixToMatch.group(1)!); + } + + if (trimmed.toLowerCase().startsWith("matrix:")) { + try { + final uri = Uri.parse(trimmed); + + if (uri.pathSegments.isNotEmpty) { + final identifier = uri.pathSegments.last; + if (identifier.isNotEmpty) { + return Uri.decodeComponent(identifier); + } + } + } catch (_) {} + } + + return null; + } +} diff --git a/lib/main.dart b/lib/main.dart index a84f3f7..8100343 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import "package:flutter/foundation.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/header_controller.dart"; import "package:nexus/controllers/multi_provider_controller.dart"; import "package:nexus/controllers/shared_prefs_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; @@ -108,6 +109,7 @@ class App extends StatelessWidget { IListConst([ SharedPrefsController.provider, ClientController.provider, + HeaderController.provider, ]), ), ) diff --git a/lib/models/client_state.dart b/lib/models/client_state.dart index 2c1bf05..a8781d0 100644 --- a/lib/models/client_state.dart +++ b/lib/models/client_state.dart @@ -8,6 +8,7 @@ abstract class ClientState with _$ClientState { required bool isInitialized, required bool isLoggedIn, required bool isVerified, + required String userId, }) = _ClientState; factory ClientState.fromJson(Map json) => diff --git a/lib/models/event.dart b/lib/models/event.dart index 397c325..e80dff9 100644 --- a/lib/models/event.dart +++ b/lib/models/event.dart @@ -23,7 +23,7 @@ abstract class Event with _$Event { String? transactionId, String? redactedBy, String? relatesTo, - String? relatesType, + @JsonKey(name: "relates_type") String? relationType, String? decryptionError, String? sendError, @Default(IMap.empty()) IMap reactions, diff --git a/lib/models/relation_type.dart b/lib/models/relation_type.dart new file mode 100644 index 0000000..80c5223 --- /dev/null +++ b/lib/models/relation_type.dart @@ -0,0 +1 @@ +enum RelationType { edit, reply } diff --git a/lib/models/report.dart b/lib/models/report.dart new file mode 100644 index 0000000..8b35e7a --- /dev/null +++ b/lib/models/report.dart @@ -0,0 +1,14 @@ +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 json) => _$ReportFromJson(json); +} diff --git a/lib/models/room_metadata.dart b/lib/models/room_metadata.dart index 7636494..7c16cae 100644 --- a/lib/models/room_metadata.dart +++ b/lib/models/room_metadata.dart @@ -20,7 +20,9 @@ abstract class RoomMetadata with _$RoomMetadata { required bool hasMemberList, @JsonKey(name: "preview_event_rowid") required int previewEventRowID, @EpochDateTimeConverter() required DateTime sortingTimestamp, - @Default(false) bool markedUnread, + required int unreadHighlights, + required int unreadNotifications, + required int unreadMessages, }) = _RoomMetadata; factory RoomMetadata.fromJson(Map json) => diff --git a/lib/pages/chat_page.dart b/lib/pages/chat_page.dart index 0899605..e1f1074 100644 --- a/lib/pages/chat_page.dart +++ b/lib/pages/chat_page.dart @@ -1,6 +1,6 @@ import "package:flutter/material.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 { const ChatPage({super.key}); @@ -16,12 +16,12 @@ class ChatPage extends StatelessWidget { builder: (context) => Row( children: [ if (isDesktop) Sidebar(), - // Expanded( - // child: RoomChat( - // isDesktop: isDesktop, - // showMembersByDefault: showMembersByDefault, - // ), - // ), + Expanded( + child: RoomChat( + isDesktop: isDesktop, + showMembersByDefault: showMembersByDefault, + ), + ), ], ), ), diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index a077810..809d5d2 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -6,12 +6,14 @@ class AvatarOrHash extends StatelessWidget { final String title; final Widget? fallback; final bool hasBadge; + final int badgeNumber; final double height; final Map headers; const AvatarOrHash( this.avatar, this.title, { this.fallback, + this.badgeNumber = 0, this.hasBadge = false, this.height = 24, required this.headers, @@ -30,6 +32,7 @@ class AvatarOrHash extends StatelessWidget { child: Center( child: Badge( isLabelVisible: hasBadge, + label: badgeNumber != 0 ? Text(badgeNumber.toString()) : null, smallSize: 12, backgroundColor: Theme.of(context).colorScheme.primary, child: ClipRRect( diff --git a/lib/widgets/chat_page/chat_box.dart b/lib/widgets/chat_page/chat_box.dart index 8fd5acb..10b02fe 100644 --- a/lib/widgets/chat_page/chat_box.dart +++ b/lib/widgets/chat_page/chat_box.dart @@ -5,10 +5,9 @@ import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:fluttertagger/fluttertagger.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:matrix/matrix.dart"; import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/chat_page/mention_overlay.dart"; +import "package:nexus/models/room.dart"; import "package:nexus/widgets/chat_page/relation_preview.dart"; class ChatBox extends HookConsumerWidget { @@ -94,7 +93,6 @@ class ChatBox extends HookConsumerWidget { relatedMessage: relatedMessage, relationType: relationType, onDismiss: onDismiss, - room: room, ), Container( color: theme.colorScheme.surfaceContainerHighest, @@ -105,20 +103,21 @@ class ChatBox extends HookConsumerWidget { PopupMenuButton( itemBuilder: (context) => [], icon: Icon(Icons.add), - enabled: room.canSendDefaultMessages, + // enabled: room.canSendDefaultMessages, TODO: Permissions check ), Expanded( child: FlutterTagger( triggerStrategy: TriggerStrategy.eager, - overlay: MentionOverlay( - room, - query: query.value, - triggerCharacter: triggerCharacter.value, - addTag: ({required id, required name}) { - controller.value.addTag(id: id, name: name); - node.requestFocus(); - }, - ), + overlay: SizedBox.shrink(), + // MentionOverlay( TODO: Fix + // room, + // query: query.value, + // triggerCharacter: triggerCharacter.value, + // addTag: ({required id, required name}) { + // controller.value.addTag(id: id, name: name); + // node.requestFocus(); + // }, + // ), controller: controller.value, onSearch: (newQuery, newTriggerCharacter) { triggerCharacter.value = newTriggerCharacter; @@ -126,13 +125,13 @@ class ChatBox extends HookConsumerWidget { }, triggerCharacterAndStyles: {"@": style, "#": style}, builder: (context, key) => TextFormField( - enabled: room.canSendDefaultMessages, + // enabled: room.canSendDefaultMessages, maxLines: 12, minLines: 1, decoration: InputDecoration( - hintText: room.canSendDefaultMessages - ? "Your message here..." - : "You don't have permission to send messages in this room...", + // hintText: room.canSendDefaultMessages + // ? "Your message here..." + // : "You don't have permission to send messages in this room...", border: InputBorder.none, ), controller: controller.value, @@ -143,7 +142,8 @@ class ChatBox extends HookConsumerWidget { ), ), IconButton( - onPressed: room.canSendDefaultMessages ? send : null, + onPressed: send, + // onPressed: room.canSendDefaultMessages ? send : null, icon: Icon(Icons.send), ), ], diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart index 04a5a1b..769da8f 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -2,21 +2,16 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/controllers/thumbnail_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/extensions/link_to_mention.dart"; import "package:nexus/helpers/launch_helper.dart"; -import "package:nexus/models/image_data.dart"; import "package:nexus/widgets/chat_page/html/mention_chip.dart"; import "package:nexus/widgets/chat_page/html/spoiler_text.dart"; import "package:nexus/widgets/chat_page/html/code_block.dart"; import "package:nexus/widgets/chat_page/html/quoted.dart"; -import "package:nexus/widgets/error_dialog.dart"; class Html extends ConsumerWidget { final String html; - final Client client; - const Html(this.html, {required this.client, super.key}); + const Html(this.html, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( @@ -38,61 +33,60 @@ class Html extends ConsumerWidget { ) : null, - "blockquote" => Quoted(Html(element.innerHtml, client: client)), + "blockquote" => Quoted(Html(element.innerHtml)), "a" => - element.attributes["href"]?.parseIdentifierIntoParts() == null + element.attributes["href"]?.mention == null ? null : InlineCustomWidget(child: MentionChip(element.text)), - "img" => - element.attributes["src"] == null - ? null - : Consumer( - builder: (_, ref, _) => ref - .watch( - ThumbnailController.provider( - ImageData( - uri: element.attributes["src"]!, - height: height, - width: width, - ), - ), - ) - .when( - data: (uri) { - if (uri == null) return SizedBox.shrink(); - - return InlineCustomWidget( - child: Image.network( - uri, - headers: client.headers, - errorBuilder: (_, error, _) => Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), - height: height.toDouble(), - width: width?.toDouble(), - loadingBuilder: (_, child, loadingProgress) => - loadingProgress == null - ? child - : CircularProgressIndicator(), - ), - ); - }, - error: ErrorDialog.new, - loading: () => InlineCustomWidget( - child: SizedBox( - width: width?.toDouble(), - height: height.toDouble(), - child: CircularProgressIndicator(), - ), - ), - ), - ), + // "img" => TODO: Img support + // element.attributes["src"] == null + // ? null + // : Consumer( + // builder: (_, ref, _) => ref + // .watch( + // ThumbnailController.provider( + // ImageData( + // uri: element.attributes["src"]!, + // height: height, + // width: width, + // ), + // ), + // ) + // .when( + // data: (uri) { + // if (uri == null) return SizedBox.shrink(); + // return InlineCustomWidget( + // child: Image.network( + // uri, + // headers: client.headers, + // errorBuilder: (_, error, _) => Text( + // "Image Failed to Load", + // style: TextStyle( + // color: Theme.of(context).colorScheme.error, + // ), + // ), + // height: height.toDouble(), + // width: width?.toDouble(), + // loadingBuilder: (_, child, loadingProgress) => + // loadingProgress == null + // ? child + // : CircularProgressIndicator(), + // ), + // ); + // }, + // error: ErrorDialog.new, + // loading: () => InlineCustomWidget( + // child: SizedBox( + // width: width?.toDouble(), + // height: height.toDouble(), + // child: CircularProgressIndicator(), + // ), + // ), + // ), + // ), ("del" || "h1" || "h2" || diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart index e2c5003..67d2877 100644 --- a/lib/widgets/chat_page/html/mention_chip.dart +++ b/lib/widgets/chat_page/html/mention_chip.dart @@ -1,5 +1,5 @@ import "package:flutter/material.dart"; -import "package:matrix/matrix.dart"; +import "package:nexus/helpers/extensions/link_to_mention.dart"; class MentionChip extends StatelessWidget { final String label; @@ -8,7 +8,7 @@ class MentionChip extends StatelessWidget { @override Widget build(BuildContext context) => ActionChip( label: Text( - label.parseIdentifierIntoParts()?.primaryIdentifier ?? label, + label.mention ?? label, style: TextStyle( fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onPrimary, diff --git a/lib/widgets/chat_page/relation_preview.dart b/lib/widgets/chat_page/relation_preview.dart index 07bac4e..9918b35 100644 --- a/lib/widgets/chat_page/relation_preview.dart +++ b/lib/widgets/chat_page/relation_preview.dart @@ -1,22 +1,16 @@ import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:matrix/matrix.dart"; -import "package:nexus/controllers/avatar_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; class RelationPreview extends ConsumerWidget { final Message? relatedMessage; final RelationType relationType; final VoidCallback onDismiss; - final Room room; const RelationPreview({ required this.relatedMessage, required this.relationType, required this.onDismiss, - required this.room, super.key, }); @@ -37,18 +31,18 @@ class RelationPreview extends ConsumerWidget { "Editing message:", style: TextStyle(fontWeight: FontWeight.bold), ), - AvatarOrHash( - ref - .watch( - AvatarController.provider( - relatedMessage!.metadata!["avatarUrl"], - ), - ) - .whenOrNull(data: (data) => data), - relatedMessage!.metadata!["displayName"].toString(), - headers: room.client.headers, - height: 16, - ), + // AvatarOrHash( + // ref + // .watch( + // AvatarController.provider( + // relatedMessage!.metadata!["avatarUrl"], + // ), + // ) + // .whenOrNull(data: (data) => data), + // relatedMessage!.metadata!["displayName"].toString(), + // headers: room.client.headers, + // height: 16, + // ), Text( relatedMessage!.metadata?["displayName"] ?? relatedMessage!.authorId, diff --git a/lib/widgets/chat_page/room_appbar.dart b/lib/widgets/chat_page/room_appbar.dart index b36a3ad..10090b6 100644 --- a/lib/widgets/chat_page/room_appbar.dart +++ b/lib/widgets/chat_page/room_appbar.dart @@ -1,13 +1,13 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/models/full_room.dart"; +import "package:nexus/models/room.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/chat_page/room_menu.dart"; class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { final bool isDesktop; - final FullRoom room; + final Room room; final void Function(BuildContext context) onOpenMemberList; final void Function(BuildContext context) onOpenDrawer; const RoomAppbar( @@ -24,22 +24,27 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) => Appbar( leading: isDesktop - ? AvatarOrHash( - room.avatar, - room.title, - height: 24, - fallback: Icon(Icons.numbers), - headers: room.roomData.client.headers, - ) + ? null + // AvatarOrHash( TODO: Images + // room.avatar, + // room.title, + // height: 24, + // fallback: Icon(Icons.numbers), + // headers: room.roomData.client.headers, + // ) : DrawerButton(onPressed: () => onOpenDrawer(context)), scrolledUnderElevation: 0, title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(room.title, overflow: TextOverflow.ellipsis, maxLines: 1), - if (room.roomData.topic.isNotEmpty) + Text( + room.metadata?.name ?? "Unnamed Room", + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + if (room.metadata?.topic?.isNotEmpty == true) Text( - room.roomData.topic, + room.metadata!.topic!, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelMedium?.copyWith( @@ -54,7 +59,7 @@ class RoomAppbar extends StatelessWidget implements PreferredSizeWidget { onPressed: () => onOpenMemberList(context), icon: Icon(Icons.people), ), - RoomMenu(room.roomData), - ], + RoomMenu(room), + ].toIList(), ); } diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 0d57663..18baf2a 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -9,6 +9,8 @@ import "package:flyer_chat_image_message/flyer_chat_image_message.dart"; import "package:flyer_chat_system_message/flyer_chat_system_message.dart"; import "package:flyer_chat_text_message/flyer_chat_text_message.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/controllers/selected_room_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; @@ -16,9 +18,9 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/models/relation_type.dart"; +import "package:nexus/models/report.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/member_list.dart"; import "package:nexus/widgets/chat_page/room_appbar.dart"; import "package:nexus/widgets/chat_page/top_widget.dart"; import "package:nexus/widgets/form_text_input.dart"; @@ -37,112 +39,68 @@ class RoomChat extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final client = ref.watch(ClientController.provider.notifier); final replyToMessage = useState(null); final memberListOpened = useState(showMembersByDefault); final relationType = useState(RelationType.reply); final theme = Theme.of(context); final danger = theme.colorScheme.error; + final room = ref.watch(SelectedRoomController.provider); + final userId = ref.watch(ClientStateController.provider)?.userId; - return ref - .watch(SelectedRoomController.provider) - .betterWhen( - data: (room) { - if (room == null) { - return Center( - child: Text( - "Nothing to see here...", - style: theme.textTheme.headlineMedium, - ), - ); - } - final controllerProvider = RoomChatController.provider( - room.roomData, - ); - final notifier = ref.watch(controllerProvider.notifier); + if (room == null || userId == null) { + return Center( + child: Text( + "Nothing to see here...", + style: theme.textTheme.headlineMedium, + ), + ); + } + final controllerProvider = RoomChatController.provider(room); + final notifier = ref.watch(controllerProvider.notifier); - List getMessageOptions(Message message) => [ - PopupMenuItem( - onTap: () { - replyToMessage.value = message; - relationType.value = RelationType.reply; - }, - child: ListTile( - leading: Icon(Icons.reply), - title: Text("Reply"), - ), - ), - // Should check if is state event (has state_key), if so, don't show edit option - if (message is TextMessage && - message.authorId == room.roomData.client.userID) - PopupMenuItem( - onTap: () { - replyToMessage.value = message; - relationType.value = RelationType.edit; - }, - child: ListTile( - leading: Icon(Icons.edit), - title: Text("Edit"), - ), - ), - if (message.authorId == room.roomData.client.userID || - room.roomData.canRedact) - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (_) { - final deleteReasonController = - useTextEditingController(); - return AlertDialog( - title: Text("Delete Message"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Are you sure you want to delete this message? This can not be reversed.", - ), - SizedBox(height: 12), - FormTextInput( - required: false, - capitalize: true, - controller: deleteReasonController, - title: "Reason for deletion (optional)", - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () async { - notifier.deleteMessage( - message, - reason: deleteReasonController.text, - ); - Navigator.of(context).pop(); - }, - child: Text("Delete"), - ), - ], - ); - }, - ), - ), - child: ListTile( - leading: Icon(Icons.delete), - title: Text("Delete"), - ), - ), - PopupMenuItem( - onTap: () => showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text("Report"), - content: Text( - "Report this message to your server administrators, who can take action like banning that user or blocking that server from federating.", + List getMessageOptions(Message message) { + final isSentByMe = message.authorId == userId; + return [ + PopupMenuItem( + onTap: () { + replyToMessage.value = message; + relationType.value = RelationType.reply; + }, + child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")), + ), + if (message is TextMessage && isSentByMe) + PopupMenuItem( + onTap: () { + replyToMessage.value = message; + relationType.value = RelationType.edit; + }, + child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), + ), + if (isSentByMe) // TODO: Or if user has permission to redact others' messages + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (_) { + final deleteReasonController = useTextEditingController(); + return AlertDialog( + title: Text("Delete Message"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Are you sure you want to delete this message? This can not be reversed.", + ), + SizedBox(height: 12), + FormTextInput( + required: false, + capitalize: true, + controller: deleteReasonController, + title: "Reason for deletion (optional)", + ), + ], ), actions: [ TextButton( @@ -150,418 +108,436 @@ class RoomChat extends HookConsumerWidget { child: Text("Cancel"), ), TextButton( - onPressed: () { - room.roomData.client.reportEvent( - room.roomData.id, - message.id, + onPressed: () async { + notifier.deleteMessage( + message, + reason: deleteReasonController.text, ); Navigator.of(context).pop(); }, - child: Text("Report"), + child: Text("Delete"), + ), + ], + ); + }, + ), + ), + child: ListTile(leading: Icon(Icons.delete), title: Text("Delete")), + ), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (_) { + final reasonController = useTextEditingController(); + return AlertDialog( + title: Text("Report"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "Report this event to your server administrators, who can take action like banning this server or room.", + ), + + SizedBox(height: 12), + FormTextInput( + required: false, + capitalize: true, + controller: reasonController, + title: "Reason for report (optional)", ), ], ), - ), - child: ListTile( - leading: Icon(Icons.report, color: danger), - title: Text("Report", style: TextStyle(color: danger)), - ), - ), - ]; - - return Scaffold( - appBar: RoomAppbar( - room, - isDesktop: isDesktop, - onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), - onOpenMemberList: (thisContext) { - memberListOpened.value = !memberListOpened.value; - Scaffold.of(thisContext).openEndDrawer(); - }, - ), - body: Row( - children: [ - Expanded( - child: Column( - children: [ - Expanded( - child: ref - .watch(controllerProvider) - .betterWhen( - data: (controller) => Chat( - currentUserId: room.roomData.client.userID!, - theme: ChatTheme.fromThemeData(theme) - .copyWith( - colors: ChatColors.fromThemeData(theme) - .copyWith( - primary: theme - .colorScheme - .primaryContainer, - onPrimary: theme - .colorScheme - .onPrimaryContainer, - ), - ), - onMessageSecondaryTap: - ( - context, - message, { - required index, - TapUpDetails? details, - }) => details?.globalPosition == null - ? null - : context.showContextMenu( - globalPosition: - details!.globalPosition, - children: getMessageOptions(message), - ), - onMessageLongPress: - ( - context, - message, { - required details, - required index, - }) => context.showContextMenu( - globalPosition: details.globalPosition, - children: getMessageOptions(message), - ), - onMessageTap: - ( - context, - message, { - required details, - required index, - }) { - if (message is ImageMessage) { - showDialog( - context: context, - builder: (_) => Dialog( - backgroundColor: - Colors.transparent, - insetPadding: EdgeInsets.all(64), - child: InteractiveViewer( - child: Image( - image: CachedNetworkImage( - message.source, - ref.watch( - CrossCacheController - .provider, - ), - headers: room - .roomData - .client - .headers, - ), - ), - ), - ), - ); - } - }, - builders: Builders( - loadMoreBuilder: (_) => Loading(), - chatAnimatedListBuilder: (_, itemBuilder) => - ChatAnimatedList( - itemBuilder: itemBuilder, - onEndReached: notifier.loadOlder, - onStartReached: notifier.markRead, - bottomPadding: 72, - ), - composerBuilder: (_) => ChatBox( - relationType: relationType.value, - relatedMessage: replyToMessage.value, - onDismiss: () => - replyToMessage.value = null, - room: room.roomData, - ), - - // customMessageBuilder: - // ( - // context, - // message, - // index, { - // required bool isSentByMe, - // MessageGroupStatus? groupStatus, - // }) { - // final poll = - // message.metadata?["poll"] - // as PollStartContent; - // final responses = - // (message.metadata?["responses"] - // as Map< - // String, - // Set - // >) - // .values - // .expand((set) => set) - // .fold({}, ( - // acc, - // value, - // ) { - // acc[value] = - // (acc[value] ?? 0) + 1; - // return acc; - // }); - - // return Column( - // crossAxisAlignment: - // CrossAxisAlignment.start, - // spacing: 4, - // children: [ - // TopWidget( - // message, - // headers: room - // .roomData - // .client - // .headers, - // groupStatus: groupStatus, - // ), - - // // TODO: Make this actually work - // DynamicPolls( - // startDate: DateTime.now(), - // endDate: DateTime.now(), - // private: - // poll.kind == - // PollKind.undisclosed, - // allowReselection: true, - // backgroundDecoration: - // BoxDecoration( - // borderRadius: - // BorderRadius.all( - // Radius.circular(16), - // ), - // border: Border.all( - // color: theme - // .colorScheme - // .primaryContainer, - // width: 4, - // ), - // ), - // allStyle: Styles( - // titleStyle: TitleStyle( - // style: theme - // .textTheme - // .headlineSmall, - // ), - // optionStyle: OptionStyle( - // fillColor: theme - // .colorScheme - // .primaryContainer, - // selectedBorderColor: theme - // .colorScheme - // .primary, - // borderColor: theme - // .colorScheme - // .primary, - // unselectedBorderColor: - // Colors.transparent, - // textSelectColor: theme - // .colorScheme - // .primary, - // ), - // ), - // onOptionSelected: - // (int index) {}, - // title: poll.question.mText, - // options: poll.answers - // .map( - // (option) => option.mText, - // ) - // .toList(), - // ), - // ], - // ); - // }, - textMessageBuilder: - ( - context, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatTextMessage( - customWidget: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Html( - (message.metadata?["formatted"] - as String) - .replaceAllMapped( - RegExp( - "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", - caseSensitive: false, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != - null) { - return m.group(1)!; - } - - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ) - .replaceAll("\n", "
"), - client: room.roomData.client, - ), - if (message.editedAt != null) - Text( - "(edited)", - style: theme - .textTheme - .labelSmall, - ), - ], - ), - topWidget: TopWidget( - message, - headers: - room.roomData.client.headers, - groupStatus: groupStatus, - ), - message: message, - showTime: true, - index: index, - ), - linkPreviewBuilder: - (_, message, isSentByMe) => LinkPreview( - text: message.text, - backgroundColor: isSentByMe - ? theme.colorScheme.inversePrimary - : theme - .colorScheme - .surfaceContainerLow, - insidePadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - linkPreviewData: - message.linkPreviewData, - onLinkPreviewDataFetched: - (linkPreviewData) => - notifier.updateMessage( - message, - message.copyWith( - linkPreviewData: - linkPreviewData, - ), - ), - ), - imageMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatImageMessage( - topWidget: TopWidget( - message, - headers: - room.roomData.client.headers, - groupStatus: groupStatus, - alwaysShow: true, - ), - customImageProvider: - CachedNetworkImage( - message.source, - ref.watch( - CrossCacheController.provider, - ), - headers: room - .roomData - .client - .headers, - ), - errorBuilder: - (context, error, stackTrace) => - Center( - child: Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.error, - ), - ), - ), - message: message, - index: index, - ), - fileMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => InkWell( - onTap: () => showDialog( - context: context, - builder: (_) => Dialog( - child: Text( - "TODO: Download Attachments", // TODO - ), - ), - ), - child: FlyerChatFileMessage( - topWidget: TopWidget( - message, - headers: - room.roomData.client.headers, - groupStatus: groupStatus, - ), - message: message, - index: index, - ), - ), - systemMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => FlyerChatSystemMessage( - message: message, - index: index, - ), - unsupportedMessageBuilder: - ( - _, - message, - index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) => Text( - "${message.authorId} sent ${message.metadata?["eventType"]}", - style: theme.textTheme.labelSmall - ?.copyWith(color: Colors.grey), - ), - ), - resolveUser: notifier.resolveUser, - chatController: controller, - ), - ), - ), - ], + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), ), - ), + TextButton( + onPressed: () { + if (room.metadata == null) return; + client.reportEvent( + Report( + roomId: room.metadata!.id, + eventId: message.id, + reason: reasonController.text.isEmpty + ? null + : reasonController.text, + ), + ); + Navigator.of(context).pop(); + }, + child: Text("Report"), + ), + ], + ); + }, + ), + ), + child: ListTile( + leading: Icon(Icons.report, color: danger), + title: Text("Report", style: TextStyle(color: danger)), + ), + ), + ]; + } - if (memberListOpened.value == true && showMembersByDefault) - MemberList(room.roomData), - ], - ), + return Scaffold( + appBar: RoomAppbar( + room, + isDesktop: isDesktop, + onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), + onOpenMemberList: (thisContext) { + memberListOpened.value = !memberListOpened.value; + Scaffold.of(thisContext).openEndDrawer(); + }, + ), + body: Row( + children: [ + Expanded( + child: Column( + children: [ + Expanded( + child: ref + .watch(controllerProvider) + .betterWhen( + data: (controller) => Chat( + currentUserId: userId, + theme: ChatTheme.fromThemeData(theme).copyWith( + colors: ChatColors.fromThemeData(theme).copyWith( + primary: theme.colorScheme.primaryContainer, + onPrimary: theme.colorScheme.onPrimaryContainer, + ), + ), + onMessageSecondaryTap: + ( + context, + message, { + required index, + TapUpDetails? details, + }) => details?.globalPosition == null + ? null + : context.showContextMenu( + globalPosition: details!.globalPosition, + children: getMessageOptions(message), + ), + onMessageLongPress: + ( + context, + message, { + required details, + required index, + }) => context.showContextMenu( + globalPosition: details.globalPosition, + children: getMessageOptions(message), + ), + onMessageTap: + ( + context, + message, { + required details, + required index, + }) { + if (message is ImageMessage) { + showDialog( + context: context, + builder: (_) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.all(64), + child: InteractiveViewer( + child: Image( + image: CachedNetworkImage( + message.source, + ref.watch( + CrossCacheController.provider, + ), + headers: ref.headers, + ), + ), + ), + ), + ); + } + }, + builders: Builders( + loadMoreBuilder: (_) => Loading(), + chatAnimatedListBuilder: (_, itemBuilder) => + ChatAnimatedList( + itemBuilder: itemBuilder, + onEndReached: notifier.loadOlder, + onStartReached: () => client.markRead(room), + bottomPadding: 72, + ), + composerBuilder: (_) => ChatBox( + relationType: relationType.value, + relatedMessage: replyToMessage.value, + onDismiss: () => replyToMessage.value = null, + room: room, + ), - endDrawer: showMembersByDefault - ? null - : MemberList(room.roomData), - ); - }, - ); + // TODO: Polls + // customMessageBuilder: + // ( + // context, + // message, + // index, { + // required bool isSentByMe, + // MessageGroupStatus? groupStatus, + // }) { + // final poll = + // message.metadata?["poll"] + // as PollStartContent; + // final responses = + // (message.metadata?["responses"] + // as Map< + // String, + // Set + // >) + // .values + // .expand((set) => set) + // .fold({}, ( + // acc, + // value, + // ) { + // acc[value] = + // (acc[value] ?? 0) + 1; + // return acc; + // }); + + // return Column( + // crossAxisAlignment: + // CrossAxisAlignment.start, + // spacing: 4, + // children: [ + // TopWidget( + // message, + // headers: room + // .roomData + // .client + // .headers, + // groupStatus: groupStatus, + // ), + + // DynamicPolls( + // startDate: DateTime.now(), + // endDate: DateTime.now(), + // private: + // poll.kind == + // PollKind.undisclosed, + // allowReselection: true, + // backgroundDecoration: + // BoxDecoration( + // borderRadius: + // BorderRadius.all( + // Radius.circular(16), + // ), + // border: Border.all( + // color: theme + // .colorScheme + // .primaryContainer, + // width: 4, + // ), + // ), + // allStyle: Styles( + // titleStyle: TitleStyle( + // style: theme + // .textTheme + // .headlineSmall, + // ), + // optionStyle: OptionStyle( + // fillColor: theme + // .colorScheme + // .primaryContainer, + // selectedBorderColor: theme + // .colorScheme + // .primary, + // borderColor: theme + // .colorScheme + // .primary, + // unselectedBorderColor: + // Colors.transparent, + // textSelectColor: theme + // .colorScheme + // .primary, + // ), + // ), + // onOptionSelected: + // (int index) {}, + // title: poll.question.mText, + // options: poll.answers + // .map( + // (option) => option.mText, + // ) + // .toList(), + // ), + // ], + // ); + // }, + textMessageBuilder: + ( + context, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatTextMessage( + customWidget: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Html( + (message.metadata?["formatted"] + as String) + .replaceAllMapped( + RegExp( + "(]*>.*?<\\/a>)|(\\bhttps?:\\/\\/[^\\s<]+)", + caseSensitive: false, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ) + .replaceAll("\n", "
"), + ), + if (message.editedAt != null) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + ], + ), + topWidget: TopWidget( + message, + groupStatus: groupStatus, + ), + message: message, + showTime: true, + index: index, + ), + linkPreviewBuilder: (_, message, isSentByMe) => + LinkPreview( + text: message.text, + backgroundColor: isSentByMe + ? theme.colorScheme.inversePrimary + : theme.colorScheme.surfaceContainerLow, + insidePadding: EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + linkPreviewData: message.linkPreviewData, + onLinkPreviewDataFetched: (linkPreviewData) => + notifier.updateMessage( + message, + message.copyWith( + linkPreviewData: linkPreviewData, + ), + ), + ), + imageMessageBuilder: + ( + _, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatImageMessage( + topWidget: TopWidget( + message, + groupStatus: groupStatus, + alwaysShow: true, + ), + customImageProvider: CachedNetworkImage( + message.source, + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + errorBuilder: (context, error, stackTrace) => + Center( + child: Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + ), + ), + ), + message: message, + index: index, + ), + fileMessageBuilder: + ( + _, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => InkWell( + onTap: () => showDialog( + context: context, + builder: (_) => Dialog( + child: Text( + "TODO: Download Attachments", // TODO + ), + ), + ), + child: FlyerChatFileMessage( + topWidget: TopWidget( + message, + groupStatus: groupStatus, + ), + message: message, + index: index, + ), + ), + systemMessageBuilder: + ( + _, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => FlyerChatSystemMessage( + message: message, + index: index, + ), + unsupportedMessageBuilder: + ( + _, + message, + index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) => Text( + "${message.authorId} sent ${message.metadata?["eventType"]}", + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.grey, + ), + ), + ), + resolveUser: notifier.resolveUser, + chatController: controller, + ), + ), + ), + ], + ), + ), + + // if (memberListOpened.value == true && showMembersByDefault) TODO: Member list + // MemberList(room), + ], + ), + + // endDrawer: showMembersByDefault ? null : MemberList(room), + ); } } diff --git a/lib/widgets/chat_page/room_menu.dart b/lib/widgets/chat_page/room_menu.dart index 2342a20..f14b8ca 100644 --- a/lib/widgets/chat_page/room_menu.dart +++ b/lib/widgets/chat_page/room_menu.dart @@ -2,27 +2,20 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; import "package:nexus/models/room.dart"; import "package:nexus/widgets/form_text_input.dart"; -class RoomMenu extends StatelessWidget { +class RoomMenu extends ConsumerWidget { final Room room; final IList children; const RoomMenu(this.room, {this.children = const IList.empty(), super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final danger = Theme.of(context).colorScheme.error; - - void markRead(String roomId) async { - // TODO: Set parent read - for (final child in children) { - // await child.setReadMarker( TODO: Set children read - // child.roomData.lastEvent?.eventId, - // mRead: child.roomData.lastEvent?.eventId, - // ); - } - } + final client = ref.watch(ClientController.provider.notifier); return PopupMenuButton( itemBuilder: (_) => [ @@ -33,45 +26,51 @@ class RoomMenu extends StatelessWidget { // }, // child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")), // ), - // PopupMenuItem( - // onTap: () => markRead(room.id), - // child: ListTile( - // leading: Icon(Icons.check), - // title: Text("Mark as Read"), - // ), - // ), - // PopupMenuItem( - // onTap: () => showDialog( - // context: context, - // builder: (context) => AlertDialog( - // title: Text("Leave Room"), - // content: Text( - // "Are you sure you want to leave \"${room.getLocalizedDisplayname()}\"?", - // ), - // actions: [ - // TextButton( - // onPressed: Navigator.of(context).pop, - // child: Text("Cancel"), - // ), - // TextButton( - // onPressed: () async { - // Navigator.of(context).pop(); - // final snackbar = ScaffoldMessenger.of( - // context, - // ).showSnackBar(SnackBar(content: Text("Leaving room..."))); - // await room.leave(); - // snackbar.close(); - // }, - // child: Text("Leave"), - // ), - // ], - // ), - // ), - // child: ListTile( - // leading: Icon(Icons.logout, color: danger), - // title: Text("Leave", style: TextStyle(color: danger)), - // ), - // ), + PopupMenuItem( + onTap: () async { + await client.markRead(room); + await Future.wait(children.map((child) => client.markRead(child))); + }, + child: ListTile( + leading: Icon(Icons.check), + title: Text("Mark as Read"), + ), + ), + PopupMenuItem( + onTap: () => showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text("Leave Room"), + content: Text( + "Are you sure you want to leave \"${room.metadata?.name ?? "Unnamed Room"}\"?", + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () async { + Navigator.of(context).pop(); + final snackbar = ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Leaving room..."), + duration: Duration(days: 1), + ), + ); + await client.leaveRoom(room); + snackbar.close(); + }, + child: Text("Leave"), + ), + ], + ), + ), + child: ListTile( + leading: Icon(Icons.logout, color: danger), + title: Text("Leave", style: TextStyle(color: danger)), + ), + ), // PopupMenuItem( // onTap: () => showDialog( // context: context, diff --git a/lib/widgets/chat_page/sidebar.dart b/lib/widgets/chat_page/sidebar.dart index 3a59b41..a8157cf 100644 --- a/lib/widgets/chat_page/sidebar.dart +++ b/lib/widgets/chat_page/sidebar.dart @@ -1,3 +1,4 @@ +import "package:collection/collection.dart"; import "package:flutter/material.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; @@ -39,7 +40,7 @@ class Sidebar extends HookConsumerWidget { final indexOfSelectedRoom = selectedSpace.children.indexWhere( (room) => room.metadata?.id == selectedRoomId, ); - final selectedRoomIndex = indexOfSelected == -1 + final selectedRoomIndex = indexOfSelectedRoom == -1 ? selectedSpace.children.isEmpty ? null : 0 @@ -65,11 +66,17 @@ class Sidebar extends HookConsumerWidget { fallback: space.icon == null ? null : Icon(space.icon), space.title, headers: {}, // TODO - hasBadge: false, - // space.children.firstWhereOrNull( TODO - // (room) => room.roomData.hasNewMessages, - // ) != - // null, + hasBadge: + space.children.firstWhereOrNull( + (room) => room.metadata?.unreadMessages != 0, + ) != + null, + badgeNumber: space.children.fold( + 0, + (previousValue, room) => + previousValue + + (room.metadata?.unreadNotifications ?? 0), + ), ), label: Text(space.title), padding: EdgeInsets.only(top: 4), @@ -184,13 +191,16 @@ class Sidebar extends HookConsumerWidget { // space.client.headers, TODO ), title: Text( - selectedSpace.room?.metadata?.avatar.toString() ?? - selectedSpace.title, + selectedSpace.title, overflow: TextOverflow.ellipsis, ), backgroundColor: Colors.transparent, actions: [ - if (selectedSpace.room != null) RoomMenu(selectedSpace.room!), + if (selectedSpace.room != null) + RoomMenu( + selectedSpace.room!, + children: selectedSpace.children, + ), ], ), body: NavigationRail( @@ -203,8 +213,9 @@ class Sidebar extends HookConsumerWidget { (room) => NavigationRailDestination( label: Text(room.metadata?.name ?? "Unnamed Room"), icon: AvatarOrHash( - // hasBadge: room.roomData.hasNewMessages, TODO null, + hasBadge: room.metadata?.unreadMessages != 0, + badgeNumber: room.metadata?.unreadNotifications ?? 0, // room.avatar, TODO room.metadata?.name ?? "Unnamed Room", fallback: selectedSpaceId == "dms" diff --git a/lib/widgets/chat_page/top_widget.dart b/lib/widgets/chat_page/top_widget.dart index 7831cb9..0cef0fa 100644 --- a/lib/widgets/chat_page/top_widget.dart +++ b/lib/widgets/chat_page/top_widget.dart @@ -8,11 +8,9 @@ import "package:nexus/widgets/chat_page/html/quoted.dart"; class TopWidget extends ConsumerWidget { final Message message; final bool alwaysShow; - final Map headers; final MessageGroupStatus? groupStatus; const TopWidget( this.message, { - required this.headers, required this.groupStatus, this.alwaysShow = false, super.key, @@ -62,11 +60,11 @@ class TopWidget extends ConsumerWidget { mainAxisSize: MainAxisSize.min, spacing: 8, children: [ - Avatar( - userId: replyMessage.authorId, - headers: headers, - size: 16, - ), + // Avatar( TODO: images + // userId: replyMessage.authorId, + // headers: headers, + // size: 16, + // ), Flexible( child: Text( replyMessage.metadata?["displayName"] ?? @@ -104,7 +102,7 @@ class TopWidget extends ConsumerWidget { mainAxisSize: MainAxisSize.min, spacing: 8, children: [ - Avatar(userId: message.authorId, headers: headers), + // Avatar(userId: message.authorId, headers: headers), TODO: images Flexible( child: Text( message.metadata?["displayName"] ?? message.authorId,