From 9054b6b357f775b2ecbd99212aa121b360716927 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Sun, 22 Mar 2026 16:35:15 -0400 Subject: [PATCH] lazy load memberships --- README.md | 220 +++++++++--------- lib/controllers/author_controller.dart | 44 ++++ lib/controllers/member_controller.dart | 42 ---- lib/controllers/members_controller.dart | 55 +++-- lib/controllers/message_controller.dart | 1 + lib/controllers/rooms_controller.dart | 1 + lib/models/configs/author_config.dart | 14 ++ lib/models/configs/member_config.dart | 13 -- lib/models/membership.dart | 13 +- lib/models/room.dart | 1 - lib/widgets/chat_page/html/html.dart | 4 + .../lazy_loading/message_avatar.dart | 8 +- .../lazy_loading/message_displayname.dart | 10 +- lib/widgets/chat_page/reply_widget.dart | 2 +- 14 files changed, 231 insertions(+), 197 deletions(-) create mode 100644 lib/controllers/author_controller.dart delete mode 100644 lib/controllers/member_controller.dart create mode 100644 lib/models/configs/author_config.dart delete mode 100644 lib/models/configs/member_config.dart diff --git a/README.md b/README.md index 1299c71..4f73220 100644 --- a/README.md +++ b/README.md @@ -15,113 +15,115 @@ A simple and user-friendly Matrix client made with Flutter and the Matrix Dart S ## Progress -- [ ] New logo -- [ ] Make context menus appear as bottom sheets on mobile -- [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2 - - [ ] Allow using remote gomuks over websocket -- [ ] Platform Support - - [x] Linux - - [x] Windows - - [ ] MacOS - - [ ] Android - - [ ] iOS - - [ ] Web (may not be possible) -- [x] Login - - [x] Username / password auth - - [ ] OAuth / OIDC - - [x] Improve initial sync experience -- [x] Rooms / Spaces - - [x] Displaying and choosing - - [x] Reading, showing unread - - [x] Mark as read button on rooms and spaces - - [ ] Searching - - [ ] Creating (Rooms, Spaces, and DMs) - - [x] Joining - - [ ] Parse vias - - [x] Using a text/uri/link - - [x] Plain text - - [x] `matrix:` Uri - - [x] Matrix.to link - - [ ] From space - - [ ] Exploring - - [x] Leaving - - [x] Subspaces -- [x] Messages - - [x] Encryption - - [x] Restoring crypto identity from a recovery passphrase/key - - [x] Sending - - [x] Plain text - - [x] HTML/Markdown - - [x] Replies - - [x] Choose ping on/off - - [ ] Attachments - - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) - - [x] Mentions - - [x] Users - - [x] Rooms - - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) - - [ ] Custom emojis/stickers - - [ ] GIFs using Gomuks' GIF proxies - - [x] Recieving - - [x] Plain text - - [x] HTML - - [x] Replies - - [x] Viewing - - [ ] Jump to original message - - [x] In loaded timeline - - [ ] Out of loaded timeline - - [x] Edits - - [x] Attachments - - [x] Unencrypted - - [ ] Encrypted - - [x] Blurhashing - - [ ] Downloading attachments - - [x] Opening attachments in their own view - - [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1 - - [x] Mentions - - [x] Users - - [x] Rooms - - [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest) - - [x] Matrix URIs - - [x] Matrix.to links - - [ ] Do some fancy fetching to get nice names - - [ ] Make clickable - - [x] Custom emojis/stickers - - [x] History loading - - [x] Backwards - - [ ] Forwards - - [x] Editing - - [x] Deleting -- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl -- [ ] Pins - - [ ] Displaying - - [ ] Creating -- [ ] Threads -- [ ] Profile popouts -- [ ] Copy link to [room, space] -- [ ] Reporting - - [x] Events - - [ ] Rooms -- [ ] Notifications using UnifiedPush -- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) -- [ ] Invites -- [ ] Settings - - [ ] Light/Dark mode - - [ ] SSD or CSD - - [ ] Show media by default - - [ ] Dynamic Theming - - [ ] Devices - - [ ] Viewing devices - - [ ] Verifying devices - - [ ] URL preview: Server / Client / None - - [ ] Account changes - - [ ] Display name - - [ ] Profile picture - - [ ] Timezone - - [ ] Pronouns - - [ ] Password - - [ ] About - - [x] Log Out +- [ ] New logo +- [ ] Make context menus appear as bottom sheets on mobile +- [x] Move from the Dart SDK to the Gomuks SDK with Dart bindings: https://git.federated.nexus/Henry-Hiles/nexus/pulls/2 + - [ ] Allow using remote gomuks over websocket +- [ ] Platform Support + - [x] Linux + - [x] Windows + - [ ] MacOS + - [ ] Android + - [ ] iOS + - [ ] Web (may not be possible) +- [x] Login + - [x] Username / password auth + - [ ] OAuth / OIDC + - [x] Improve initial sync experience +- [x] Rooms / Spaces + - [x] Displaying and choosing + - [x] Reading, showing unread + - [x] Mark as read button on rooms and spaces + - [ ] Searching + - [ ] Creating (Rooms, Spaces, and DMs) + - [x] Joining + - [ ] Parse vias + - [x] Using a text/uri/link + - [x] Plain text + - [x] `matrix:` Uri + - [x] Matrix.to link + - [ ] From space + - [ ] Exploring + - [x] Leaving + - [x] Subspaces +- [x] Messages + - [x] Encryption + - [x] Restoring crypto identity from a recovery passphrase/key + - [x] Sending + - [x] Plain text + - [x] HTML/Markdown + - [x] Replies + - [x] Choose ping on/off + - [ ] Per message profiles + - [ ] Attachments + - [ ] Commands with [MSC4391](https://github.com/matrix-org/matrix-spec-proposals/pull/4391) + - [x] Mentions + - [x] Users + - [x] Rooms + - [ ] Inline emoji picker (Putting this here since it'll be implemented the same way as mentions) + - [ ] Custom emojis/stickers + - [ ] GIFs using Gomuks' GIF proxies + - [x] Recieving + - [x] Plain text + - [x] Per message profiles + - [x] HTML + - [x] Replies + - [x] Viewing + - [ ] Jump to original message + - [x] In loaded timeline + - [ ] Out of loaded timeline + - [x] Edits + - [x] Attachments + - [x] Unencrypted + - [ ] Encrypted + - [x] Blurhashing + - [ ] Downloading attachments + - [x] Opening attachments in their own view + - [ ] Polls: Waiting on https://github.com/SwanFlutter/dynamic_polls/issues/1 + - [x] Mentions + - [x] Users + - [x] Rooms + - [ ] Plain text (not sure if I want to add this or not, I probably won't unless there's interest) + - [x] Matrix URIs + - [x] Matrix.to links + - [ ] Do some fancy fetching to get nice names + - [ ] Make clickable + - [x] Custom emojis/stickers + - [x] History loading + - [x] Backwards + - [ ] Forwards + - [x] Editing + - [x] Deleting +- [ ] Reactions: Waiting on https://github.com/flyerhq/flutter_chat_ui/pull/838 or me doing a custom impl +- [ ] Pins + - [ ] Displaying + - [ ] Creating +- [ ] Threads +- [ ] Profile popouts +- [ ] Copy link to [room, space] +- [ ] Reporting + - [x] Events + - [ ] Rooms +- [ ] Notifications using UnifiedPush +- [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) +- [ ] Invites +- [ ] Settings + - [ ] Light/Dark mode + - [ ] SSD or CSD + - [ ] Show media by default + - [ ] Dynamic Theming + - [ ] Devices + - [ ] Viewing devices + - [ ] Verifying devices + - [ ] URL preview: Server / Client / None + - [ ] Account changes + - [ ] Display name + - [ ] Profile picture + - [ ] Timezone + - [ ] Pronouns + - [ ] Password + - [ ] About + - [x] Log Out ## Build Instructions @@ -136,8 +138,8 @@ cd nexus #### Linux -- With Nix: Either use direnv, or `nix flake develop` -- Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc. +- With Nix: Either use direnv, or `nix flake develop` +- Without Nix: Install Flutter, Go, Olm, Git, Clang, and GLibc. #### Windows / MacOS diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart new file mode 100644 index 0000000..fac80e5 --- /dev/null +++ b/lib/controllers/author_controller.dart @@ -0,0 +1,44 @@ +import "dart:async"; +import "package:collection/collection.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/models/configs/author_config.dart"; +import "package:nexus/models/membership.dart"; + +class AuthorController extends AsyncNotifier { + final AuthorConfig config; + AuthorController(this.config); + + @override + Future build() async { + var member = ref.watch( + MembersController.provider(config.room).select( + (value) => value.firstWhereOrNull( + (membership) => membership.userId == config.message.authorId, + ), + ), + ); + + final pmp = config.message.metadata?["pmp"] == null + ? null + : Membership.fromContent( + IMap(config.message.metadata?["pmp"]), + config.message.authorId, + ); + + return Membership( + avatarUrl: pmp?.avatarUrl ?? member?.avatarUrl, + displayName: + pmp?.displayName ?? + member?.displayName ?? + config.message.authorId.substring(1).split(":").first, + userId: config.message.authorId, + ); + } + + static final provider = AsyncNotifierProvider.family + .autoDispose( + AuthorController.new, + ); +} diff --git a/lib/controllers/member_controller.dart b/lib/controllers/member_controller.dart deleted file mode 100644 index 97e120f..0000000 --- a/lib/controllers/member_controller.dart +++ /dev/null @@ -1,42 +0,0 @@ -import "dart:async"; -import "package:collection/collection.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/members_controller.dart"; -import "package:nexus/models/configs/member_config.dart"; -import "package:nexus/models/membership.dart"; - -class MemberController extends AsyncNotifier { - final MemberConfig config; - MemberController(this.config); - - @override - FutureOr build() { - final member = ref.watch( - MembersController.provider(config.room).select( - (value) => value.firstWhereOrNull( - (membership) => membership.userId == config.userId, - ), - ), - ); - if (config.room.hasFetchedMembers || member != null) { - return member ?? - Membership( - avatarUrl: null, - displayName: config.userId, - userId: config.userId, - ); - } - return Membership( - avatarUrl: null, - displayName: config.userId, - userId: config.userId, - ); - - throw UnimplementedError(); - } - - static final provider = AsyncNotifierProvider.family - .autoDispose( - MemberController.new, - ); -} diff --git a/lib/controllers/members_controller.dart b/lib/controllers/members_controller.dart index acdabd1..90d9b76 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,7 +1,10 @@ import "package:collection/collection.dart"; 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/models/event.dart"; import "package:nexus/models/membership.dart"; +import "package:nexus/models/requests/get_room_state_request.dart"; import "package:nexus/models/room.dart"; class MembersController extends Notifier> { @@ -9,24 +12,42 @@ class MembersController extends Notifier> { MembersController(this.room); @override - IList build() => (room.state["m.room.member"]?.values ?? []) - .map( - (eventRowId) => - room.events.firstWhereOrNull((event) => event.rowId == eventRowId), - ) - .nonNulls - .where((member) => member.content["membership"] == "join") - .map( - (membership) => Membership( - avatarUrl: Uri.tryParse(membership.content["avatar_url"] ?? ""), - userId: membership.stateKey!, - displayName: membership.content["displayname"] ?? membership.stateKey, - ), - ) - .toIList(); + IList build() { + IList membersFromState(IList members) => members.nonNulls + .where((member) => member.content["membership"] == "join") + .map( + (membership) => + Membership.fromContent(membership.content, membership.stateKey!), + ) + .toIList(); - static final provider = NotifierProvider.family - .autoDispose, Room>( + if (room.metadata != null) { + ref + .watch(ClientController.provider.notifier) + .getRoomState( + GetRoomStateRequest( + roomId: room.metadata!.id, + fetchMembers: room.metadata!.hasMemberList == false, + includeMembers: true, + ), + ) + .then((value) => state = membersFromState(value)); + } + + return membersFromState( + (room.state["m.room.members"]?.values ?? []) + .map( + (eventRowId) => room.events.firstWhereOrNull( + (event) => event.rowId == eventRowId, + ), + ) + .nonNulls + .toIList(), + ); + } + + static final provider = + NotifierProvider.family, Room>( MembersController.new, ); } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index bf0a396..d84aabb 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -45,6 +45,7 @@ class MessageController extends AsyncNotifier { "timelineId": event.timelineRowId, "big": event.localContent?.bigEmoji == true, "eventType": type, + "pmp": event.content["com.beeper.per_message_profile"], "editSource": event.localContent?.editSource ?? newContent?["body"] ?? diff --git a/lib/controllers/rooms_controller.dart b/lib/controllers/rooms_controller.dart index 0945644..3c6e287 100644 --- a/lib/controllers/rooms_controller.dart +++ b/lib/controllers/rooms_controller.dart @@ -36,6 +36,7 @@ class RoomsController extends Notifier> { return acc.add( roomId, existing?.copyWith( + hasMore: incoming.hasMore, metadata: incoming.metadata ?? existing.metadata, events: events!, state: incoming.state.entries.fold( diff --git a/lib/models/configs/author_config.dart b/lib/models/configs/author_config.dart new file mode 100644 index 0000000..af63c63 --- /dev/null +++ b/lib/models/configs/author_config.dart @@ -0,0 +1,14 @@ +import "package:flutter_chat_core/flutter_chat_core.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/room.dart"; +part "author_config.freezed.dart"; +part "author_config.g.dart"; + +@freezed +abstract class AuthorConfig with _$AuthorConfig { + const factory AuthorConfig({required Message message, required Room room}) = + _AuthorConfig; + + factory AuthorConfig.fromJson(Map json) => + _$AuthorConfigFromJson(json); +} diff --git a/lib/models/configs/member_config.dart b/lib/models/configs/member_config.dart deleted file mode 100644 index 179f780..0000000 --- a/lib/models/configs/member_config.dart +++ /dev/null @@ -1,13 +0,0 @@ -import "package:freezed_annotation/freezed_annotation.dart"; -import "package:nexus/models/room.dart"; -part "member_config.freezed.dart"; -part "member_config.g.dart"; - -@freezed -abstract class MemberConfig with _$MemberConfig { - const factory MemberConfig({required Room room, required String userId}) = - _MemberConfig; - - factory MemberConfig.fromJson(Map json) => - _$MemberConfigFromJson(json); -} diff --git a/lib/models/membership.dart b/lib/models/membership.dart index 9703bbf..ec18be7 100644 --- a/lib/models/membership.dart +++ b/lib/models/membership.dart @@ -1,15 +1,22 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; part "membership.freezed.dart"; -part "membership.g.dart"; @freezed abstract class Membership with _$Membership { + const Membership._(); const factory Membership({ required Uri? avatarUrl, required String displayName, required String userId, }) = _Membership; - factory Membership.fromJson(Map json) => - _$MembershipFromJson(json); + factory Membership.fromContent( + IMap content, + String userId, + ) => Membership( + avatarUrl: Uri.tryParse(content["avatar_url"] ?? ""), + userId: userId, + displayName: content["displayname"] ?? userId.substring(1).split(":").first, + ); } diff --git a/lib/models/room.dart b/lib/models/room.dart index 4cd371d..3c3eec0 100644 --- a/lib/models/room.dart +++ b/lib/models/room.dart @@ -18,7 +18,6 @@ abstract class Room with _$Room { @Default(IMap.empty()) IMap> receipts, @Default(false) bool dismissNotifications, @Default(true) bool hasMore, - @Default(false) bool hasFetchedMembers, // required IList notifications, }) = _Room; diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart index 1e1ab82..dcc1d49 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -22,6 +22,10 @@ class Html extends ConsumerWidget { html, textStyle: textStyle, customWidgetBuilder: (element) { + if (element.attributes.keys.contains("data-mx-profile-fallback")) { + return SizedBox.shrink(); + } + if (element.attributes.keys.contains("data-mx-spoiler")) { return InlineCustomWidget(child: SpoilerText(text: element.text)); } diff --git a/lib/widgets/chat_page/lazy_loading/message_avatar.dart b/lib/widgets/chat_page/lazy_loading/message_avatar.dart index d68f5fd..71fcf84 100644 --- a/lib/widgets/chat_page/lazy_loading/message_avatar.dart +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -1,9 +1,9 @@ import "package:flutter/widgets.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/member_controller.dart"; +import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/member_config.dart"; +import "package:nexus/models/configs/author_config.dart"; import "package:nexus/models/room.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -16,9 +16,7 @@ class MessageAvatar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => ref .watch( - MemberController.provider( - MemberConfig(room: room, userId: message.authorId), - ), + AuthorController.provider(AuthorConfig(room: room, message: message)), ) .betterWhen( data: (membership) => AvatarOrHash( diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart index 8c02d2e..7053655 100644 --- a/lib/widgets/chat_page/lazy_loading/message_displayname.dart +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -1,9 +1,9 @@ import "package:flutter/widgets.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/controllers/member_controller.dart"; +import "package:nexus/controllers/author_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/configs/member_config.dart"; +import "package:nexus/models/configs/author_config.dart"; import "package:nexus/models/room.dart"; class MessageDisplayname extends ConsumerWidget { @@ -15,13 +15,11 @@ class MessageDisplayname extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => ref .watch( - MemberController.provider( - MemberConfig(room: room, userId: message.authorId), - ), + AuthorController.provider(AuthorConfig(room: room, message: message)), ) .betterWhen( data: (membership) => Text( - membership.displayName, + "${membership.displayName} ${message.metadata?["pmp"] == null ? "" : "(via ${message.authorId})"}", style: style, overflow: TextOverflow.ellipsis, ), diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart index b7284bf..b9fa2e1 100644 --- a/lib/widgets/chat_page/reply_widget.dart +++ b/lib/widgets/chat_page/reply_widget.dart @@ -67,7 +67,7 @@ class ReplyWidget extends ConsumerWidget { mainAxisSize: MainAxisSize.min, spacing: 8, children: [ - MessageAvatar(message, room), + MessageAvatar(replyMessage, room), Flexible( child: MessageDisplayname( replyMessage,