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/assets/reply-preview.png b/assets/reply-preview.png deleted file mode 100644 index 3c4cc3e..0000000 Binary files a/assets/reply-preview.png and /dev/null differ diff --git a/assets/reply.webp b/assets/reply.webp deleted file mode 100644 index e8f139e..0000000 Binary files a/assets/reply.webp and /dev/null differ diff --git a/lib/controllers/author_controller.dart b/lib/controllers/author_controller.dart new file mode 100644 index 0000000..c7e4e05 --- /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 = await ref.watch( + MembersController.provider(config.room).selectAsync( + (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/members_controller.dart b/lib/controllers/members_controller.dart index 268a30d..80e73a0 100644 --- a/lib/controllers/members_controller.dart +++ b/lib/controllers/members_controller.dart @@ -1,25 +1,39 @@ -import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:nexus/models/event.dart"; +import "package:nexus/controllers/client_controller.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> { +class MembersController extends AsyncNotifier> { final Room room; 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") - .toIList(); + Future> build() async { + if (room.metadata == null) return const IList.empty(); - static final provider = NotifierProvider.family - .autoDispose, Room>( + final state = await ref + .watch(ClientController.provider.notifier) + .getRoomState( + GetRoomStateRequest( + roomId: room.metadata!.id, + fetchMembers: room.metadata!.hasMemberList == false, + includeMembers: true, + ), + ); + + return state.nonNulls + .where((member) => member.content["membership"] == "join") + .map( + (membership) => + Membership.fromContent(membership.content, membership.stateKey!), + ) + .toIList(); + } + + static final provider = + AsyncNotifierProvider.family, Room>( MembersController.new, ); } diff --git a/lib/controllers/message_controller.dart b/lib/controllers/message_controller.dart index f3ef13b..d84aabb 100644 --- a/lib/controllers/message_controller.dart +++ b/lib/controllers/message_controller.dart @@ -2,9 +2,8 @@ import "package:collection/collection.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/members_controller.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/message_config.dart"; +import "package:nexus/models/configs/message_config.dart"; class MessageController extends AsyncNotifier { final MessageConfig config; @@ -27,12 +26,6 @@ class MessageController extends AsyncNotifier { if (!ref.mounted) return null; - final members = ref.read(MembersController.provider(config.room)); - final author = members.firstWhereOrNull( - (member) => member.stateKey == event.authorId, - ); - if (!ref.mounted) return null; - final content = (event.decrypted ?? event.content); final type = (config.event.decryptedType ?? config.event.type); final newContent = content["m.new_content"] as Map?; @@ -52,14 +45,11 @@ class MessageController extends AsyncNotifier { "timelineId": event.timelineRowId, "big": event.localContent?.bigEmoji == true, "eventType": type, - "avatarUrl": author?.content["avatar_url"], + "pmp": event.content["com.beeper.per_message_profile"], "editSource": event.localContent?.editSource ?? newContent?["body"] ?? content["body"], - "displayName": author?.content["displayname"]?.isNotEmpty == true - ? author?.content["displayname"] - : event.authorId.substring(1).split(":")[0], "txnId": config.event.transactionId, }; diff --git a/lib/controllers/messages_controller.dart b/lib/controllers/messages_controller.dart index 83bd815..28885fb 100644 --- a/lib/controllers/messages_controller.dart +++ b/lib/controllers/messages_controller.dart @@ -2,8 +2,8 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/message_controller.dart"; -import "package:nexus/models/message_config.dart"; -import "package:nexus/models/messages_config.dart"; +import "package:nexus/models/configs/message_config.dart"; +import "package:nexus/models/configs/messages_config.dart"; class MessagesController extends AsyncNotifier> { final MessagesConfig config; diff --git a/lib/controllers/room_chat_controller.dart b/lib/controllers/room_chat_controller.dart index 4a4dba2..d737154 100644 --- a/lib/controllers/room_chat_controller.dart +++ b/lib/controllers/room_chat_controller.dart @@ -1,5 +1,4 @@ import "dart:async"; - import "package:collection/collection.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; @@ -11,8 +10,8 @@ import "package:nexus/controllers/message_controller.dart"; import "package:nexus/controllers/messages_controller.dart"; import "package:nexus/controllers/new_events_controller.dart"; import "package:nexus/controllers/rooms_controller.dart"; -import "package:nexus/models/message_config.dart"; -import "package:nexus/models/messages_config.dart"; +import "package:nexus/models/configs/messages_config.dart"; +import "package:nexus/models/configs/message_config.dart"; import "package:nexus/models/requests/get_room_state_request.dart"; import "package:nexus/models/requests/paginate_request.dart"; import "package:nexus/models/requests/redact_event_request.dart"; @@ -31,11 +30,7 @@ class RoomChatController extends AsyncNotifier { if (room == null) return InMemoryChatController(); final state = await client.getRoomState( - GetRoomStateRequest( - roomId: roomId, - fetchMembers: room.metadata?.hasMemberList == false, - includeMembers: true, - ), + GetRoomStateRequest(roomId: roomId), ); ref 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/message_config.dart b/lib/models/configs/message_config.dart similarity index 100% rename from lib/models/message_config.dart rename to lib/models/configs/message_config.dart diff --git a/lib/models/messages_config.dart b/lib/models/configs/messages_config.dart similarity index 100% rename from lib/models/messages_config.dart rename to lib/models/configs/messages_config.dart diff --git a/lib/models/membership.dart b/lib/models/membership.dart new file mode 100644 index 0000000..ec18be7 --- /dev/null +++ b/lib/models/membership.dart @@ -0,0 +1,22 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +part "membership.freezed.dart"; + +@freezed +abstract class Membership with _$Membership { + const Membership._(); + const factory Membership({ + required Uri? avatarUrl, + required String displayName, + required String userId, + }) = _Membership; + + 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/requests/get_room_state_request.dart b/lib/models/requests/get_room_state_request.dart index a154d5f..de66b72 100644 --- a/lib/models/requests/get_room_state_request.dart +++ b/lib/models/requests/get_room_state_request.dart @@ -6,7 +6,7 @@ part "get_room_state_request.g.dart"; abstract class GetRoomStateRequest with _$GetRoomStateRequest { const factory GetRoomStateRequest({ required String roomId, - required bool fetchMembers, + @Default(false) bool fetchMembers, @Default(false) bool includeMembers, }) = _GetRoomStateRequest; diff --git a/lib/widgets/chat_page/chat_box.dart b/lib/widgets/chat_page/composer/chat_box.dart similarity index 96% rename from lib/widgets/chat_page/chat_box.dart rename to lib/widgets/chat_page/composer/chat_box.dart index b9e7dbb..7f07de2 100644 --- a/lib/widgets/chat_page/chat_box.dart +++ b/lib/widgets/chat_page/composer/chat_box.dart @@ -8,8 +8,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/models/room.dart"; -import "package:nexus/widgets/chat_page/mention_overlay.dart"; -import "package:nexus/widgets/chat_page/relation_preview.dart"; +import "package:nexus/widgets/chat_page/composer/mention_overlay.dart"; +import "package:nexus/widgets/chat_page/composer/relation_preview.dart"; class ChatBox extends HookConsumerWidget { final Message? relatedMessage; @@ -86,10 +86,11 @@ class ChatBox extends HookConsumerWidget { child: Column( children: [ RelationPreview( + relatedMessage, + room: room, shouldMention: shouldMention.value, toggleShouldMention: () => shouldMention.value = !shouldMention.value, - relatedMessage: relatedMessage, relationType: relationType, onDismiss: onDismiss, ), diff --git a/lib/widgets/chat_page/mention_overlay.dart b/lib/widgets/chat_page/composer/mention_overlay.dart similarity index 56% rename from lib/widgets/chat_page/mention_overlay.dart rename to lib/widgets/chat_page/composer/mention_overlay.dart index 9858574..d95253d 100644 --- a/lib/widgets/chat_page/mention_overlay.dart +++ b/lib/widgets/chat_page/composer/mention_overlay.dart @@ -2,6 +2,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.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/models/room.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/loading.dart"; @@ -31,55 +32,47 @@ class MentionOverlay extends ConsumerWidget { color: Theme.of(context).colorScheme.surfaceContainerHigh, padding: EdgeInsets.all(8), child: switch (triggerCharacter) { - "@" => Consumer( - builder: (_, ref, _) { - final members = ref.watch(MembersController.provider(room)); - return ListView( - children: - (query.isEmpty - ? members - : members.where( - (member) => - member.stateKey?.toLowerCase().contains( - query.toLowerCase(), - ) == - true || - (member.content["displayname"] as String?) - ?.toLowerCase() - .contains(query.toLowerCase()) == - true, - )) - .map( - (member) => ListTile( - leading: AvatarOrHash( - Uri.tryParse( - member.content["avatar_url"] ?? "", - ), - member.content["displayname"] ?? "", - ), - title: Text( - member.content["displayname"] as String? ?? - member.stateKey ?? - "Unknown User", - ), - subtitle: member.stateKey != null - ? Text(member.stateKey!) - : null, - onTap: () => addTag( - id: "[@${member.content["displayname"]}](https://matrix.to/#/${member.stateKey})", - name: - member.stateKey - ?.substring(1) + "@" => + ref + .watch(MembersController.provider(room)) + .betterWhen( + data: (members) => ListView( + children: + (query.isEmpty + ? members + : members.where( + (member) => + member.userId.toLowerCase().contains( + query.toLowerCase(), + ) == + true || + member.displayName + .toLowerCase() + .contains( + query.toLowerCase(), + ) == + true, + )) + .map( + (member) => ListTile( + leading: AvatarOrHash( + member.avatarUrl, + member.displayName, + ), + title: Text(member.displayName), + subtitle: Text(member.userId), + onTap: () => addTag( + id: "[@${member.displayName}](https://matrix.to/#/${member.userId})", + name: member.userId + .substring(1) .split(":") - .first ?? - "Unknown User", - ), - ), - ) - .toList(), - ); - }, - ), + .first, + ), + ), + ) + .toList(), + ), + ), "#" => ListView( children: (query.isEmpty diff --git a/lib/widgets/chat_page/relation_preview.dart b/lib/widgets/chat_page/composer/relation_preview.dart similarity index 83% rename from lib/widgets/chat_page/relation_preview.dart rename to lib/widgets/chat_page/composer/relation_preview.dart index 7aa3ae8..7fded20 100644 --- a/lib/widgets/chat_page/relation_preview.dart +++ b/lib/widgets/chat_page/composer/relation_preview.dart @@ -2,7 +2,9 @@ import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/relation_type.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; class RelationPreview extends ConsumerWidget { final Message? relatedMessage; @@ -10,8 +12,11 @@ class RelationPreview extends ConsumerWidget { final VoidCallback onDismiss; final bool shouldMention; final VoidCallback toggleShouldMention; - const RelationPreview({ - required this.relatedMessage, + final Room room; + + const RelationPreview( + this.relatedMessage, { + required this.room, required this.relationType, required this.onDismiss, required this.shouldMention, @@ -36,14 +41,10 @@ class RelationPreview extends ConsumerWidget { "Editing message:", style: TextStyle(fontWeight: FontWeight.bold), ), - AvatarOrHash( - Uri.tryParse(relatedMessage?.metadata?["avatarUrl"] ?? ""), - relatedMessage?.metadata?["displayName"]?.toString() ?? "", - height: 16, - ), - Text( - relatedMessage!.metadata?["displayName"] ?? - relatedMessage!.authorId, + MessageAvatar(relatedMessage!, room), + MessageDisplayname( + relatedMessage!, + room, style: theme.textTheme.labelMedium?.copyWith( fontWeight: FontWeight.bold, ), 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 new file mode 100644 index 0000000..71fcf84 --- /dev/null +++ b/lib/widgets/chat_page/lazy_loading/message_avatar.dart @@ -0,0 +1,30 @@ +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/author_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/configs/author_config.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; + +class MessageAvatar extends ConsumerWidget { + final Message message; + final Room room; + final double height; + const MessageAvatar(this.message, this.room, {this.height = 16, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch( + AuthorController.provider(AuthorConfig(room: room, message: message)), + ) + .betterWhen( + data: (membership) => AvatarOrHash( + membership.avatarUrl, + membership.displayName, + height: height, + ), + loading: () => + AvatarOrHash(null, message.authorId.substring(1), height: height), + ); +} diff --git a/lib/widgets/chat_page/lazy_loading/message_displayname.dart b/lib/widgets/chat_page/lazy_loading/message_displayname.dart new file mode 100644 index 0000000..7c10df3 --- /dev/null +++ b/lib/widgets/chat_page/lazy_loading/message_displayname.dart @@ -0,0 +1,28 @@ +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/author_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/models/configs/author_config.dart"; +import "package:nexus/models/room.dart"; + +class MessageDisplayname extends ConsumerWidget { + final Message message; + final Room room; + final TextStyle? style; + const MessageDisplayname(this.message, this.room, {this.style, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) => ref + .watch( + AuthorController.provider(AuthorConfig(room: room, message: message)), + ) + .betterWhen( + data: (membership) => Text( + "${membership.displayName} ${message.metadata?["pmp"] == null ? "" : "(via ${message.authorId})"}", + style: style, + overflow: TextOverflow.ellipsis, + ), + loading: () => Text(""), + ); +} diff --git a/lib/widgets/chat_page/member_list.dart b/lib/widgets/chat_page/member_list.dart index 24d22e4..8cdbbb9 100644 --- a/lib/widgets/chat_page/member_list.dart +++ b/lib/widgets/chat_page/member_list.dart @@ -1,6 +1,7 @@ import "package:flutter/material.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/members_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/models/room.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; @@ -10,15 +11,17 @@ class MemberList extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final members = ref.watch(MembersController.provider(room)); + final membersProvider = ref.watch(MembersController.provider(room)); return Drawer( shape: Border(), - child: ListView( + child: Column( children: [ AppBar( scrolledUnderElevation: 0, leading: Icon(Icons.people), - title: Text("Members (${members.length})"), + title: Text( + "Members ${membersProvider.when(data: (members) => "${members.length}", error: (_, _) => "", loading: () => "")}", + ), actionsPadding: EdgeInsets.only(right: 4), actions: [ if (Scaffold.of(context).hasEndDrawer) @@ -29,24 +32,32 @@ class MemberList extends ConsumerWidget { ), ], ), - ...members.map( - (member) => ListTile( - onTap: () => showDialog( - context: context, - builder: (context) => - Dialog(child: Text("TODO: Open member popover")), - ), - leading: AvatarOrHash( - Uri.tryParse(member.content["avatar_url"] ?? ""), - member.content["displayname"].toString(), - ), - title: Text( - member.content["displayname"].toString(), - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - member.stateKey ?? "Unknown User", - overflow: TextOverflow.ellipsis, + membersProvider.betterWhen( + data: (members) => Expanded( + child: ListView( + children: members + .map( + (member) => ListTile( + onTap: () => showDialog( + context: context, + builder: (context) => + Dialog(child: Text("TODO: Open member popover")), + ), + leading: AvatarOrHash( + member.avatarUrl, + member.displayName, + ), + title: Text( + member.displayName, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + member.userId, + overflow: TextOverflow.ellipsis, + ), + ), + ) + .toList(), ), ), ), diff --git a/lib/widgets/chat_page/reply_widget.dart b/lib/widgets/chat_page/reply_widget.dart index cd30acc..b9fa2e1 100644 --- a/lib/widgets/chat_page/reply_widget.dart +++ b/lib/widgets/chat_page/reply_widget.dart @@ -1,15 +1,15 @@ -import "dart:math"; import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/event_controller.dart"; import "package:nexus/controllers/message_controller.dart"; import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/models/message_config.dart"; +import "package:nexus/models/configs/message_config.dart"; import "package:nexus/models/requests/get_event_request.dart"; import "package:nexus/models/room.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/chat_page/html/quoted.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; typedef OnTapReply = void Function(Message message)?; @@ -61,73 +61,28 @@ class ReplyWidget extends ConsumerWidget { return SizedBox.shrink(); } - final smallerText = - message is TextMessage && - replyMessage.metadata?["body"] != null - ? replyMessage.metadata!["body"].substring( - 0, - min( - max( - max( - (message as TextMessage) - .text - .length - - (replyMessage - .metadata?["displayName"] - as String) - .length - - 5, - message - .metadata?["displayName"] - .length, - ), - 5, - ), - replyMessage.metadata!["body"].length, - ), - ) - : null; - final replyText = - (smallerText == null || - smallerText.length == - replyMessage - .metadata!["body"] - .length) - ? replyMessage.metadata!["body"] - : "$smallerText..."; - return InkWell( onTap: () => onTapReply?.call(replyMessage), child: Row( mainAxisSize: MainAxisSize.min, spacing: 8, children: [ - AvatarOrHash( - Uri.tryParse( - replyMessage.metadata?["avatarUrl"] ?? - "", - ), - replyMessage.metadata?["displayName"] ?? - "", - height: 16, - ), + MessageAvatar(replyMessage, room), Flexible( - child: Text( - replyMessage - .metadata?["displayName"] ?? - replyMessage.authorId, + child: MessageDisplayname( + replyMessage, + room, style: Theme.of(context) .textTheme .labelMedium ?.copyWith( fontWeight: FontWeight.bold, ), - overflow: TextOverflow.ellipsis, ), ), Flexible( child: Text( - replyText, + replyMessage.metadata!["body"], overflow: TextOverflow.ellipsis, style: Theme.of( context, diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 839109f..6b3839a 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -13,15 +13,14 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/models/relation_type.dart"; import "package:nexus/models/requests/report_request.dart"; -import "package:nexus/widgets/chat_page/chat_box.dart"; +import "package:nexus/widgets/chat_page/composer/chat_box.dart"; import "package:nexus/widgets/chat_page/image_message.dart"; import "package:nexus/widgets/chat_page/member_list.dart"; -import "package:nexus/widgets/chat_page/message_wrapper.dart"; +import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; import "package:nexus/widgets/chat_page/room_appbar.dart"; -import "package:nexus/widgets/chat_page/text_message_wrapper.dart"; +import "package:nexus/widgets/chat_page/wrappers/text_message_wrapper.dart"; import "package:nexus/widgets/chat_page/reply_widget.dart"; import "package:nexus/widgets/form_text_input.dart"; -import "package:nexus/widgets/loading.dart"; // import "package:dynamic_polls/dynamic_polls.dart"; class RoomChat extends HookConsumerWidget { @@ -233,7 +232,7 @@ class RoomChat extends HookConsumerWidget { children: getMessageOptions(message), ), builders: Builders( - loadMoreBuilder: (_) => Loading(), + loadMoreBuilder: (_) => SizedBox.shrink(), chatAnimatedListBuilder: (_, itemBuilder) => ChatAnimatedList( @@ -320,6 +319,7 @@ class RoomChat extends HookConsumerWidget { ), ), groupStatus, + room, ), systemMessageBuilder: diff --git a/lib/widgets/chat_page/message_wrapper.dart b/lib/widgets/chat_page/wrappers/message_wrapper.dart similarity index 73% rename from lib/widgets/chat_page/message_wrapper.dart rename to lib/widgets/chat_page/wrappers/message_wrapper.dart index da53be0..1be6c2b 100644 --- a/lib/widgets/chat_page/message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/message_wrapper.dart @@ -1,12 +1,21 @@ import "package:flutter/material.dart"; import "package:flutter_chat_core/flutter_chat_core.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/models/room.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_avatar.dart"; +import "package:nexus/widgets/chat_page/lazy_loading/message_displayname.dart"; class MessageWrapper extends StatelessWidget { final Message message; final Widget child; + final Room room; final MessageGroupStatus? groupStatus; - const MessageWrapper(this.message, this.child, this.groupStatus, {super.key}); + const MessageWrapper( + this.message, + this.child, + this.groupStatus, + this.room, { + super.key, + }); @override Widget build(BuildContext context) => ClipRRect( @@ -24,11 +33,7 @@ class MessageWrapper extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ groupStatus?.isFirst != false - ? AvatarOrHash( - Uri.parse(message.metadata?["avatarUrl"] ?? ""), - height: 40, - message.metadata?["displayName"] ?? "", - ) + ? MessageAvatar(message, room, height: 40) : SizedBox(width: 40), Expanded( child: Column( @@ -36,9 +41,9 @@ class MessageWrapper extends StatelessWidget { spacing: 4, children: [ if (groupStatus?.isFirst != false) - Text( - message.metadata?["displayName"] ?? message.authorId, - overflow: TextOverflow.ellipsis, + MessageDisplayname( + message, + room, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), diff --git a/lib/widgets/chat_page/text_message_wrapper.dart b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart similarity index 97% rename from lib/widgets/chat_page/text_message_wrapper.dart rename to lib/widgets/chat_page/wrappers/text_message_wrapper.dart index 9734a34..41bc01e 100644 --- a/lib/widgets/chat_page/text_message_wrapper.dart +++ b/lib/widgets/chat_page/wrappers/text_message_wrapper.dart @@ -3,7 +3,7 @@ import "package:flutter_chat_core/flutter_chat_core.dart"; import "package:flutter_link_previewer/flutter_link_previewer.dart"; import "package:nexus/models/room.dart"; import "package:nexus/widgets/chat_page/html/html.dart"; -import "package:nexus/widgets/chat_page/message_wrapper.dart"; +import "package:nexus/widgets/chat_page/wrappers/message_wrapper.dart"; import "package:nexus/widgets/chat_page/reply_widget.dart"; class TextMessageWrapper extends StatelessWidget { @@ -109,6 +109,7 @@ class TextMessageWrapper extends StatelessWidget { ), ), groupStatus, + room, ); } } diff --git a/pubspec.lock b/pubspec.lock index da5de89..af73796 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: analyzer_buffer - sha256: aba2f75e63b3135fd1efaa8b6abefe1aa6e41b6bd9806221620fa48f98156033 + sha256: "5fcd06b0715ebeee99f03e3f437b3412249969d8d12b191ea8a1d76e42a4e4a1" url: "https://pub.dev" source: hosted - version: "0.1.11" + version: "0.3.1" analyzer_plugin: dependency: transitive description: @@ -521,10 +521,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "38ec6c303e2c83ee84512f5fc2a82ae311531021938e63d7137eccc107bf3c02" + sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.3.1" flutter_svg: dependency: "direct main" description: @@ -643,10 +643,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: b880efcd17757af0aa242e5dceac2fb781a014c22a32435a5daa8f17e9d5d8a9 + sha256: "08527ec06aaef75e4b78694e045ef0cd8346594eaf9cc18b0f866398b07b93b1" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.3.1" html: dependency: transitive description: @@ -1051,26 +1051,26 @@ packages: dependency: transitive description: name: riverpod - sha256: "16ff608d21e8ea64364f2b7c049c94a02ab81668f78845862b6e88b71dd4935a" + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.1" riverpod_analyzer_utils: dependency: transitive description: name: riverpod_analyzer_utils - sha256: "947b05d04c52a546a2ac6b19ef2a54b08520ff6bdf9f23d67957a4c8df1c3bc0" + sha256: e55bc08c084a424e1bbdc303fe8ea75daafe4269b68fd0e0f6f1678413715b66 url: "https://pub.dev" source: hosted - version: "1.0.0-dev.8" + version: "1.0.0-dev.9" riverpod_lint: dependency: "direct dev" description: name: riverpod_lint - sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43" + sha256: "64e8debf5b719a37d48b9785dd595d34133fdcd84b8fd07157a621c54ab2156f" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.1.3" rxdart: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3c0198d..ec69ae9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,8 +21,8 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter - flutter_riverpod: ^3.0.3 - hooks_riverpod: ^3.0.3 + flutter_riverpod: ^3.3.1 + hooks_riverpod: ^3.3.1 intl: ^0.20.1 fast_immutable_collections: ^11.0.0 path_provider: ^2.1.3 @@ -69,7 +69,7 @@ dev_dependencies: custom_lint: ^0.8.0 flutter_lints: ^6.0.0 freezed: ^3.2.3 - riverpod_lint: ^3.0.3 + riverpod_lint: ^3.1.3 flutter_launcher_icons: ^0.14.1 json_serializable: ^6.11.1