From bbe36ff86f7acec9d74c6b572de2b7492c89ef94 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Fri, 26 Dec 2025 17:00:59 -0500 Subject: [PATCH] add mention support --- README.md | 6 +- lib/controllers/room_chat_controller.dart | 7 +- lib/controllers/rooms_controller.dart | 23 ++++ lib/helpers/extensions/event_to_message.dart | 2 +- lib/widgets/chat_page/chat_box.dart | 91 ++----------- lib/widgets/chat_page/html/html.dart | 20 +-- lib/widgets/chat_page/html/mention_chip.dart | 26 ++++ lib/widgets/chat_page/mention_overlay.dart | 130 +++++++++++++++++++ 8 files changed, 198 insertions(+), 107 deletions(-) create mode 100644 lib/controllers/rooms_controller.dart create mode 100644 lib/widgets/chat_page/html/mention_chip.dart create mode 100644 lib/widgets/chat_page/mention_overlay.dart diff --git a/README.md b/README.md index 01243b2..36982c2 100644 --- a/README.md +++ b/README.md @@ -43,9 +43,9 @@ A simple and user-friendly Matrix client made with Flutter and the Matrix Dart S - [x] HTML/Markdown - [x] Replies - [ ] Attachments - - [ ] Mentions - - [ ] Users - - [ ] Rooms + - [x] Mentions + - [x] Users + - [x] Rooms - [ ] Custom emojis/stickers - [ ] GIFs, maybe through Tenor or something - [ ] Encrypted messages diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 4c7be45..5ce3584 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -102,11 +102,12 @@ class RoomChatController extends AsyncNotifier { var taggedMessage = message; for (final tag in tags) { - final escaped = RegExp.escape(tag.id.substring(1)); - final pattern = RegExp(r"@@(" + escaped + r")#[^#]*#"); + final escaped = RegExp.escape(tag.id); + final pattern = RegExp(r"@+(" + escaped + r")(#[^#]*#)?"); + taggedMessage = taggedMessage.replaceAllMapped( pattern, - (m) => "@${m.group(1)}", + (match) => match.group(1)!, ); } diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart new file mode 100644 index 0000000..864d656 --- /dev/null +++ b/lib/controllers/rooms_controller.dart @@ -0,0 +1,23 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/helpers/extensions/get_full_room.dart"; +import "package:nexus/models/full_room.dart"; + +class RoomsController extends AsyncNotifier> { + @override + Future> build() async { + final client = await ref.watch(ClientController.provider.future); + + ref.onDispose( + client.onSync.stream.listen((_) => ref.invalidateSelf()).cancel, + ); + + return IList(await Future.wait(client.rooms.map((room) => room.fullRoom))); + } + + static final provider = + AsyncNotifierProvider>( + RoomsController.new, + ); +} diff --git a/lib/helpers/extensions/event_to_message.dart b/lib/helpers/extensions/event_to_message.dart index ed7d326..22e4988 100644 --- a/lib/helpers/extensions/event_to_message.dart +++ b/lib/helpers/extensions/event_to_message.dart @@ -25,7 +25,7 @@ extension EventToMessage on Event { newContent?["formatted_body"] ?? newContent?["body"] ?? event.content["formatted_body"] ?? - event.body, + event.content["body"], "reply": await replyEvent?.toMessage(mustBeText: true), "eventType": event.type, "avatarUrl": sender.avatarUrl.toString(), diff --git a/lib/widgets/chat_page/chat_box.dart b/lib/widgets/chat_page/chat_box.dart index bb7c7cf..a702690 100644 --- a/lib/widgets/chat_page/chat_box.dart +++ b/lib/widgets/chat_page/chat_box.dart @@ -6,14 +6,9 @@ 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/avatar_controller.dart"; -import "package:nexus/controllers/members_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/chat_page/mention_overlay.dart"; import "package:nexus/widgets/chat_page/reply_preview.dart"; -import "package:nexus/widgets/loading.dart"; class ChatBox extends HookConsumerWidget { final Message? replyToMessage; @@ -95,82 +90,14 @@ class ChatBox extends HookConsumerWidget { Expanded( child: FlutterTagger( triggerStrategy: TriggerStrategy.eager, - overlay: Padding( - padding: EdgeInsets.all(8), - child: ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(12)), - child: Container( - color: theme.colorScheme.surfaceContainerHigh, - padding: EdgeInsets.all(8), - child: switch (triggerCharacter.value) { - "@" => - ref - .watch(MembersController.provider(room)) - .betterWhen( - data: (members) => ListView( - children: - (query.value.isEmpty - ? members - : members.where( - (member) => - member.senderId - .contains( - query.value, - ) || - (member.content["displayname"] - as String?) - ?.contains( - query - .value, - ) == - true, - )) - .map( - (member) => ListTile( - leading: AvatarOrHash( - ref - .watch( - AvatarController.provider( - member - .content["avatar_url"] - .toString(), - ), - ) - .whenOrNull( - data: (data) => - data, - ), - member - .content["displayname"] - .toString(), - headers: - room.client.headers, - ), - title: Text( - member.content["displayname"] - as String? ?? - member.senderId, - ), - onTap: () => controller - .value - .addTag( - id: member.senderId, - name: member - .senderId - .substring(1) - .split(":") - .first, - ), - ), - ) - .toList(), - ), - ), - "#" => Text("Todo"), - _ => Loading(), - }, - ), - ), + overlay: MentionOverlay( + 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) { diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart index 0046ac3..735696e 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -7,6 +7,7 @@ import "package:nexus/controllers/thumbnail_controller.dart"; import "package:nexus/helpers/extensions/get_headers.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/quoted.dart"; @@ -41,24 +42,7 @@ class Html extends ConsumerWidget { "a" => Uri.tryParse(element.attributes["href"] ?? "")?.host == "matrix.to" - ? InlineCustomWidget( - child: ActionChip( - label: Text( - element.text - .replaceFirst("https://matrix.to/#/", "") - .replaceFirst("http://matrix.to/#/", ""), - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimary, - ), - ), - backgroundColor: Theme.of(context).colorScheme.primary, - onPressed: () { - // TODO: Open room or join room dialog, or user popover - showAboutDialog(context: context); - }, - ), - ) + ? MentionChip(element.text) : null, "img" => diff --git a/lib/widgets/chat_page/html/mention_chip.dart b/lib/widgets/chat_page/html/mention_chip.dart new file mode 100644 index 0000000..1c53d49 --- /dev/null +++ b/lib/widgets/chat_page/html/mention_chip.dart @@ -0,0 +1,26 @@ +import "package:flutter/material.dart"; +import "package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart"; +import "package:matrix/matrix.dart"; + +class MentionChip extends StatelessWidget { + final String label; + const MentionChip(this.label, {super.key}); + + @override + Widget build(BuildContext context) => InlineCustomWidget( + child: ActionChip( + label: Text( + label.parseIdentifierIntoParts()?.primaryIdentifier ?? label, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimary, + ), + ), + backgroundColor: Theme.of(context).colorScheme.primary, + onPressed: () { + // TODO: Open room or join room dialog, or user popover + showAboutDialog(context: context); + }, + ), + ); +} diff --git a/lib/widgets/chat_page/mention_overlay.dart b/lib/widgets/chat_page/mention_overlay.dart new file mode 100644 index 0000000..6558a9d --- /dev/null +++ b/lib/widgets/chat_page/mention_overlay.dart @@ -0,0 +1,130 @@ +import "package:flutter/material.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:matrix/matrix.dart"; +import "package:nexus/controllers/avatar_controller.dart"; +import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/controllers/rooms_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/loading.dart"; + +class MentionOverlay extends ConsumerWidget { + final String? triggerCharacter; + final String query; + final Room room; + final void Function({required String id, required String name}) addTag; + const MentionOverlay( + this.room, { + required this.query, + required this.addTag, + required this.triggerCharacter, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) => Padding( + padding: EdgeInsets.all(8), + child: ClipRRect( + borderRadius: BorderRadius.all(Radius.circular(12)), + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + padding: EdgeInsets.all(8), + child: switch (triggerCharacter) { + "@" => + ref + .watch(MembersController.provider(room)) + .betterWhen( + data: (members) => ListView( + children: + (query.isEmpty + ? members + : members.where( + (member) => + member.senderId.toLowerCase().contains( + query.toLowerCase(), + ) || + (member.content["displayname"] + as String?) + ?.toLowerCase() + .contains( + query.toLowerCase(), + ) == + true, + )) + .map( + (member) => ListTile( + leading: AvatarOrHash( + ref + .watch( + AvatarController.provider( + member.content["avatar_url"] + .toString(), + ), + ) + .whenOrNull(data: (data) => data), + member.content["displayname"].toString(), + headers: room.client.headers, + ), + title: Text( + member.content["displayname"] as String? ?? + member.senderId, + ), + onTap: () => addTag( + id: member.senderId, + name: member.senderId + .substring(1) + .split(":") + .first, + ), + ), + ) + .toList(), + ), + ), + "#" => + ref + .watch(RoomsController.provider) + .betterWhen( + data: (rooms) => ListView( + children: + (query.isEmpty + ? rooms + : rooms.where( + (room) => room.title.toLowerCase().contains( + query.toLowerCase(), + ), + )) + .map( + (room) => ListTile( + leading: AvatarOrHash( + room.avatar, + room.title, + fallback: Icon(Icons.numbers), + headers: room.roomData.client.headers, + ), + title: Text(room.title), + subtitle: room.roomData.topic.isEmpty + ? null + : Text(room.roomData.topic), + onTap: () => addTag( + id: "[#${room.roomData.getLocalizedDisplayname()}](https://matrix.to/#/${room.roomData.id})", + name: + (room.roomData.canonicalAlias.isEmpty + ? room.roomData.id + : room.roomData.canonicalAlias) + .substring(1) + .split(":") + .first, + ), + ), + ) + .toList(), + ), + ), + _ => Loading(), + }, + ), + ), + ); +}