diff --git a/README.md b/README.md index da4a6cb..a551475 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend. - [x] Notifications using UnifiedPush ([#35](https://git.federated.nexus/Nexus/nexus/issues/35)) - [x] Notifications page - [ ] Group calls using [MSC4195](https://github.com/matrix-org/matrix-spec-proposals/pull/4195) -- [ ] Invites +- [x] Invites - [x] Settings ## Try it out diff --git a/lib/controllers/client.dart b/lib/controllers/client.dart index 7d9604b..28da24e 100644 --- a/lib/controllers/client.dart +++ b/lib/controllers/client.dart @@ -191,10 +191,8 @@ class ClientController extends AsyncNotifier { Future getRoomSummary(JoinRoomRequest request) async => .fromJson(await _sendCommand("get_room_summary", request.toJson())); - Future leaveRoom(Room room) async { - if (room.metadata == null) return; - await _sendCommand("leave_room", {"room_id": room.metadata!.id}); - } + Future leaveRoom(String roomId) => + _sendCommand("leave_room", {"room_id": roomId}); Future> getRoomState(GetRoomStateRequest request) async { Future getState(GetRoomStateRequest request) async => diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index 07e02f6..ef536c7 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -1,11 +1,13 @@ import "dart:async"; import "dart:ffi"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/foundation.dart"; import "package:nexus/controllers/account_data.dart"; import "package:nexus/controllers/client.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/init_complete.dart"; +import "package:nexus/controllers/invites.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/space_edges.dart"; import "package:nexus/controllers/sync_status.dart"; @@ -61,10 +63,12 @@ class GomuksListenerController extends AsyncNotifier { final syncData = SyncData.fromJson(decodedMuksEvent); final roomProvider = RoomsController.provider; final accountDataProvider = AccountDataController.provider; + final invitesProvider = InvitesController.provider; if (syncData.clearState) { ref.invalidate(roomProvider); ref.invalidate(accountDataProvider); + ref.invalidate(invitesProvider); } ref @@ -73,6 +77,13 @@ class GomuksListenerController extends AsyncNotifier { ref .read(accountDataProvider.notifier) .update(syncData.accountData); + ref + .read(invitesProvider.notifier) + .update( + syncData.invitedRooms, + joinedRooms: syncData.rooms.keys.toISet(), + leftRooms: syncData.leftRooms, + ); if (syncData.topLevelSpaces case final topLevelSpaces?) { ref diff --git a/lib/controllers/invites.dart b/lib/controllers/invites.dart new file mode 100644 index 0000000..55899b1 --- /dev/null +++ b/lib/controllers/invites.dart @@ -0,0 +1,117 @@ +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.dart"; +import "package:nexus/controllers/client_state.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/models/content/avatar.dart"; +import "package:nexus/models/content/canonical_alias.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/encryption.dart"; +import "package:nexus/models/content/history_visibility.dart"; +import "package:nexus/models/content/join_rules.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/content/name.dart"; +import "package:nexus/models/content/topic.dart"; +import "package:nexus/models/invited_room.dart"; +import "package:nexus/models/room_summary.dart"; + +/// Pending invites, newest first. +class InvitesController extends Notifier> { + @override + IList build() => .new(); + + /// Adds new invites, and removes invites for rooms that have since been + /// joined or left. + void update( + IList invitedRooms, { + ISet joinedRooms = const .empty(), + ISet leftRooms = const .empty(), + }) { + final userId = ref.read(ClientStateController.provider)?.userId; + + RoomSummary toSummary(InvitedRoom invite) { + final summary = invite.inviteState.fold( + RoomSummary(roomId: invite.roomId, membership: .invite), + (summary, event) => switch (event.content) { + NameContent(:final name) when name.isNotEmpty => summary.copyWith( + name: name, + ), + AvatarContent(:final url) => summary.copyWith(avatarUrl: url), + TopicContent(:final topic) when topic.isNotEmpty => summary.copyWith( + topic: topic, + ), + CanonicalAliasContent(:final alias) => summary.copyWith( + canonicalAlias: alias, + ), + JoinRulesContent(:final joinRule) => summary.copyWith( + joinRule: joinRule, + ), + CreateContent(:final type, :final roomVersion) => summary.copyWith( + roomType: type, + roomVersion: roomVersion, + ), + EncryptionContent(:final algorithm) => summary.copyWith( + encryption: algorithm, + ), + HistoryVisibilityContent(:final historyVisibility) => + summary.copyWith( + worldReadable: historyVisibility == .worldReadable, + ), + MembershipContent(:final isDirect) when event.stateKey == userId => + summary.copyWith(invitedBy: event.sender, isDirect: isDirect), + _ => summary, + }, + ); + + // The inviter is only known once the user's own invite has been found + final inviterProfile = switch (invite.inviteState + .firstWhereOrNull( + (event) => + event.type == EventType.membership.type && + event.stateKey == summary.invitedBy, + ) + ?.content) { + MembershipContent content => content, + _ => null, + }; + + return summary.copyWith( + inviterProfile: inviterProfile, + // Direct messages usually have no name, so use the inviter's + name: + summary.name ?? + (summary.isDirect + ? inviterProfile?.displayName ?? summary.invitedBy?.localpart + : null), + ); + } + + final newInvites = invitedRooms.map(toSummary).toIList(); + final newRoomIds = newInvites.map((invite) => invite.roomId).toISet(); + final removed = joinedRooms.addAll(leftRooms); + + state = newInvites + .addAll(state.where((invite) => !newRoomIds.contains(invite.roomId))) + .removeWhere((invite) => removed.contains(invite.roomId)); + } + + void remove(String roomId) => + state = state.removeWhere((invite) => invite.roomId == roomId); + + Future decline(String roomId) async { + await ref.read(ClientController.provider.notifier).leaveRoom(roomId); + remove(roomId); + } + + /// Declines every invite, removing each as it succeeds. If any fail, the + /// first error is thrown once all have finished. + Future declineAll() => + Future.wait(state.map((invite) => decline(invite.roomId))); + + static final provider = + NotifierProvider>( + InvitesController.new, + ); +} diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index e3a0f14..a732bcd 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -4,6 +4,7 @@ import "dart:io"; import "package:flutter/services.dart"; import "package:material_ui/material_ui.dart"; import "package:nexus/controllers/portal.dart"; +import "package:nexus/controllers/sidebar_page.dart"; import "package:nexus/main.dart"; import "package:flutter_local_notifications/flutter_local_notifications.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; @@ -35,6 +36,7 @@ class NotificationController if (int.tryParse(details.payload!) case final rowId?) { navigatorKey.currentState?.popUntil((route) => route.isFirst); + ref.read(SidebarPageController.provider.notifier).set(.notifications); ref .read(NotificationRequestController.provider.notifier) .request(rowId); @@ -53,6 +55,7 @@ class NotificationController if (int.tryParse(call.arguments) case int rowId) { navigatorKey.currentState?.popUntil((route) => route.isFirst); + ref.read(SidebarPageController.provider.notifier).set(.notifications); ref .read(NotificationRequestController.provider.notifier) .request(rowId); diff --git a/lib/controllers/sidebar_page.dart b/lib/controllers/sidebar_page.dart new file mode 100644 index 0000000..5598691 --- /dev/null +++ b/lib/controllers/sidebar_page.dart @@ -0,0 +1,13 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/models/sidebar_page.dart"; + +class SidebarPageController extends Notifier { + @override + SidebarPage build() => .rooms; + + void set(SidebarPage page) => state = page; + + static final provider = NotifierProvider( + SidebarPageController.new, + ); +} diff --git a/lib/helpers/extensions/join_room.dart b/lib/helpers/extensions/join_room.dart new file mode 100644 index 0000000..90cdd34 --- /dev/null +++ b/lib/helpers/extensions/join_room.dart @@ -0,0 +1,71 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/client.dart"; +import "package:nexus/controllers/sidebar_page.dart"; +import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/models/requests/join_room.dart"; + +extension JoinRoom on BuildContext { + /// Joins a room, showing progress, success (with a button to open the room) + /// and errors in snackbars. + /// + /// Returns the joined room's ID, or null if joining failed. + Future joinRoom( + JoinRoomRequest request, { + required String name, + }) async { + final container = ProviderScope.containerOf(this); + final messenger = ScaffoldMessenger.of(this); + final colorScheme = Theme.of(this).colorScheme; + + final joining = messenger.showSnackBar( + .new(content: Text("Joining $name..."), duration: const .new(days: 1)), + ); + + try { + final roomId = await container + .read(ClientController.provider.notifier) + .joinRoom(request); + + joining.close(); + messenger.showSnackBar( + .new( + content: Text("Joined $name."), + action: .new( + label: "Open", + onPressed: () async { + if (await container.focusRoom(roomId)) { + container + .read(SidebarPageController.provider.notifier) + .set(.rooms); + } else { + messenger.showSnackBar( + .new( + content: Text( + "$name hasn't finished syncing yet, try again in a moment.", + ), + ), + ); + } + }, + ), + ), + ); + + return roomId; + } catch (error) { + joining.close(); + messenger.showSnackBar( + .new( + backgroundColor: colorScheme.errorContainer, + content: Text( + error.toString(), + style: .new(color: colorScheme.onErrorContainer), + ), + ), + ); + + return null; + } + } +} diff --git a/lib/models/content/membership.dart b/lib/models/content/membership.dart index 46be974..b1a5508 100644 --- a/lib/models/content/membership.dart +++ b/lib/models/content/membership.dart @@ -20,6 +20,7 @@ sealed class MembershipContent extends Content with _$MembershipContent { @JsonKey(name: "membership") required MembershipStatus status, Uri? avatarUrl, String? reason, + @Default(false) bool isDirect, }) = _MembershipContent; factory MembershipContent.fromJson(Map json) => diff --git a/lib/models/invited_room.dart b/lib/models/invited_room.dart new file mode 100644 index 0000000..6dba1d9 --- /dev/null +++ b/lib/models/invited_room.dart @@ -0,0 +1,16 @@ +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/matrix_event.dart"; + +part "invited_room.freezed.dart"; +part "invited_room.g.dart"; + +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const InvitedRoom({ + required final String roomId, + final IList inviteState = const .empty(), +}) with _$InvitedRoom { + factory InvitedRoom.fromJson(Map json) => + _$InvitedRoomFromJson(json); +} diff --git a/lib/models/matrix_event.dart b/lib/models/matrix_event.dart new file mode 100644 index 0000000..2eddbff --- /dev/null +++ b/lib/models/matrix_event.dart @@ -0,0 +1,28 @@ +import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/content/content.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; + +part "matrix_event.freezed.dart"; +part "matrix_event.g.dart"; + +/// A plain Matrix event, as sent by the server (e.g. stripped invite state). +/// +/// Unlike [Event], this isn't stored in the gomuks database, so it has no row +/// IDs or local content. +@Freezed(toJson: false, fromJson: false) +@JsonSerializable(createToJson: false) +class const MatrixEvent({ + required final String type, + final String? stateKey, + final String? sender, + final String? eventId, + final String? roomId, + @JsonKey(name: "origin_server_ts") + @EpochDateTimeConverter() + final DateTime? timestamp, + required final Content content, +}) with _$MatrixEvent { + factory MatrixEvent.fromJson(Map json) => + _$MatrixEventFromJson(json) + .copyWith(content: .fromEventJson(json["content"], json["type"])); +} diff --git a/lib/models/nav_page.dart b/lib/models/nav_page.dart index 2443c53..a731de2 100644 --- a/lib/models/nav_page.dart +++ b/lib/models/nav_page.dart @@ -4,5 +4,6 @@ import "package:flutter_riverpod/flutter_riverpod.dart"; abstract class const NavPage({super.key}) extends Widget { String get title; IconData get icon; - Future? badgeBuilder(WidgetRef ref) => null; + + int? badgeCount(WidgetRef ref) => null; } diff --git a/lib/models/requests/join_room.dart b/lib/models/requests/join_room.dart index 350b762..3cdbc88 100644 --- a/lib/models/requests/join_room.dart +++ b/lib/models/requests/join_room.dart @@ -9,6 +9,9 @@ part "join_room.g.dart"; class const JoinRoomRequest({ required final String roomIdOrAlias, final IList via = const IList.empty(), + + /// Set when accepting an invite, so gomuks can mark DMs as such. + final bool fromInvite = false, }) with _$JoinRoomRequest { Map toJson() => _$JoinRoomRequestToJson(this); diff --git a/lib/models/room_summary.dart b/lib/models/room_summary.dart index d0a6bee..35c86bd 100644 --- a/lib/models/room_summary.dart +++ b/lib/models/room_summary.dart @@ -1,12 +1,14 @@ import "package:freezed_annotation/freezed_annotation.dart"; import "package:nexus/models/content/create.dart"; +import "package:nexus/models/content/membership.dart"; import "package:nexus/models/join_rule.dart"; +import "package:nexus/models/membership_status.dart"; part "room_summary.freezed.dart"; part "room_summary.g.dart"; @Freezed(toJson: false, fromJson: false) -@JsonSerializable() +@JsonSerializable(createToJson: false) class const RoomSummary({ required final String roomId, @JsonKey(name: "num_joined_members") final int? joinedMembers, @@ -17,9 +19,24 @@ class const RoomSummary({ final String? topic, final String? roomVersion, @JsonKey(unknownEnumValue: RoomType.room) final RoomType? roomType, -}) with _$RoomSummary { - Map toJson() => _$RoomSummaryToJson(this); + /// The encryption algorithm, if the room is encrypted. + final String? encryption, + final bool? worldReadable, + + /// The current user's membership in the room, if any. + @JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue) + final MembershipStatus? membership, + + /// Who invited the current user, for invites. + @JsonKey(includeFromJson: false) final String? invitedBy, + + /// The inviter's membership, for their display name and avatar. + @JsonKey(includeFromJson: false) final MembershipContent? inviterProfile, + + /// Whether this is an invite to a direct message. + @JsonKey(includeFromJson: false) final bool isDirect = false, +}) with _$RoomSummary { factory RoomSummary.fromJson(Map json) => _$RoomSummaryFromJson(json); } diff --git a/lib/models/sidebar_page.dart b/lib/models/sidebar_page.dart new file mode 100644 index 0000000..18ee417 --- /dev/null +++ b/lib/models/sidebar_page.dart @@ -0,0 +1 @@ +enum SidebarPage { rooms, notifications, invites } diff --git a/lib/models/sync_data.dart b/lib/models/sync_data.dart index a9c10bf..a26043b 100644 --- a/lib/models/sync_data.dart +++ b/lib/models/sync_data.dart @@ -1,5 +1,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/invited_room.dart"; import "package:nexus/models/room.dart"; import "package:nexus/models/space_edge.dart"; @@ -7,17 +8,16 @@ part "sync_data.freezed.dart"; part "sync_data.g.dart"; @Freezed(toJson: false, fromJson: false) -@JsonSerializable() +@JsonSerializable(createToJson: false) class const SyncData({ final bool clearState = false, final IMap> accountData = const IMap.empty(), final IMap rooms = const IMap.empty(), final ISet leftRooms = const ISet.empty(), + final IList invitedRooms = const IList.empty(), final IMap>? spaceEdges, final IList? topLevelSpaces, }) with _$SyncData { - Map toJson() => _$SyncDataToJson(this); - factory SyncData.fromJson(Map json) => _$SyncDataFromJson(json); } diff --git a/lib/widgets/invites_list.dart b/lib/widgets/invites_list.dart index 35dd26f..ad07ce0 100644 --- a/lib/widgets/invites_list.dart +++ b/lib/widgets/invites_list.dart @@ -1,9 +1,17 @@ -import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/invites.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/main.dart"; +import "package:nexus/models/content/create.dart"; import "package:nexus/models/nav_page.dart"; +import "package:nexus/models/room_summary.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/widgets/room_summary_dialog.dart"; class const InvitesList({super.key}) - extends StatelessWidget + extends HookConsumerWidget implements NavPage { @override String get title => "Invites"; @@ -11,12 +19,117 @@ class const InvitesList({super.key}) @override IconData get icon => Icons.inbox; - // TODO: Show notification badge here @override - Future? badgeBuilder(WidgetRef ref) => null; + int? badgeCount(WidgetRef ref) => switch (ref.watch( + InvitesController.provider.select((invites) => invites.length), + )) { + 0 => null, + final count => count, + }; @override + Widget build(BuildContext context, WidgetRef ref) { + final invites = ref.watch(InvitesController.provider); + final decliningAll = useState(false); + + Future declineAll() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Decline All Invites"), + content: Text( + "Are you sure you want to decline all ${invites.length} invites?", + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("Cancel"), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text("Decline All"), + ), + ], + ), + ); + if (confirmed != true) return; + + decliningAll.value = true; + await ref + .read(InvitesController.provider.notifier) + .declineAll() + .onError(showError); + if (context.mounted) decliningAll.value = false; + } + + return Scaffold( + backgroundColor: Colors.transparent, + floatingActionButton: invites.isEmpty + ? null + : FloatingActionButton.extended( + onPressed: decliningAll.value ? null : declineAll, + icon: decliningAll.value + ? const SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ) + : const Icon(Icons.clear_all), + label: const Text("Decline all"), + ), + body: invites.isEmpty + ? Center( + child: Text( + "No invites", + style: Theme.of(context).textTheme.headlineMedium, + ), + ) + : ListView( + // Leave room for the FAB under the last invite + padding: const .only(left: 8, top: 8, right: 8, bottom: 88), + children: invites.map(InviteCard.new).toList(), + ), + ); + } +} + +class const InviteCard(final RoomSummary invite, {super.key}) + extends StatelessWidget { + @override Widget build(BuildContext context) { - return const Placeholder(); + final name = invite.name ?? invite.canonicalAlias ?? invite.roomId; + final inviterName = + invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart; + + return Card( + clipBehavior: .antiAlias, + child: ListTile( + onTap: () => showDialog( + context: context, + builder: (_) => RoomSummaryDialog(invite), + ), + leading: AvatarOrHash( + invite.avatarUrl, + name, + dimension: 40, + fallback: invite.isDirect + ? null + : Icon( + invite.roomType == RoomType.space + ? Icons.workspaces + : Icons.numbers, + ), + ), + title: Text(name, maxLines: 1, overflow: .ellipsis), + subtitle: Text( + [ + if (inviterName != null) + "${invite.isDirect ? "Direct message from" : "Invited by"} $inviterName", + ?invite.topic, + ].join("\n"), + maxLines: 2, + overflow: .ellipsis, + ), + ), + ); } } diff --git a/lib/widgets/notifications_list.dart b/lib/widgets/notifications_list.dart index ee1cb14..11ea451 100644 --- a/lib/widgets/notifications_list.dart +++ b/lib/widgets/notifications_list.dart @@ -24,9 +24,8 @@ class const NotificationsList({super.key}) @override IconData get icon => Icons.notifications; - // TODO: Show notification badge here @override - Future? badgeBuilder(WidgetRef ref) => null; + Null badgeCount(_) => null; @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart index 27aa8c1..7cf5e36 100644 --- a/lib/widgets/room_list.dart +++ b/lib/widgets/room_list.dart @@ -28,7 +28,7 @@ class const RoomList({super.key}) extends ConsumerWidget implements NavPage { // TODO: Show unread badge here and on sidebar hamburger @override - Future? badgeBuilder(WidgetRef ref) => null; + int? badgeCount(WidgetRef ref) => null; @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index a348540..35a2f2e 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -127,7 +127,7 @@ final class const RoomMenuButton( ), ); await client - .leaveRoom(room!) + .leaveRoom(room!.metadata!.id) .onError(showError) .whenComplete(snackbar.close); }, diff --git a/lib/widgets/room_summary_dialog.dart b/lib/widgets/room_summary_dialog.dart index 496e72f..9ccde20 100644 --- a/lib/widgets/room_summary_dialog.dart +++ b/lib/widgets/room_summary_dialog.dart @@ -1,8 +1,10 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:material_ui/material_ui.dart"; -import "package:nexus/controllers/client.dart"; -import "package:nexus/helpers/extensions/focus_room.dart"; +import "package:nexus/controllers/invites.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/join_room.dart"; +import "package:nexus/main.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/linkified_text.dart"; @@ -14,109 +16,129 @@ class const RoomSummaryDialog( super.key, }) extends StatelessWidget { @override - Widget build(BuildContext context) => AlertDialog( - constraints: .loose(.fromWidth(400)), - scrollable: true, - contentPadding: EdgeInsets.all(24).copyWith(top: 8), - title: Row( - spacing: 12, - mainAxisSize: .min, - children: [ - if (summary.avatarUrl != null) - ExpandableImage( - summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!), - child: AvatarOrHash( - summary.avatarUrl, - "", - dimension: 64, - fallback: Icon(Icons.numbers), - ), - ), - Expanded( - child: Text( - summary.name ?? summary.canonicalAlias ?? "Unnamed Room", - overflow: .ellipsis, - maxLines: 3, - ), - ), - ], - ), - content: Column( - children: [ - ListTile( - title: Text( - "${summary.joinedMembers ?? "Unknown number of"} members", - ), - leading: Icon(Icons.people), - ), - ListTile( - title: SelectableText(summary.canonicalAlias ?? summary.roomId), - leading: Icon(Icons.numbers), - ), + Widget build(BuildContext context) { + final name = summary.name ?? summary.canonicalAlias ?? summary.roomId; + final isInvite = summary.membership == .invite; + final colorScheme = Theme.of(context).colorScheme; - if (summary.topic != null) - ListTile( - titleAlignment: ListTileTitleAlignment.titleHeight, - title: LinkifiedText( - summary.topic!, - style: Theme.of(context).textTheme.bodyLarge, - ), - leading: Icon(Icons.info), - ), - ], - ), - - actions: [ - TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), - if (via != null) - TextButton( - onPressed: () async { - // Capture everything before the dialog closes. - final container = ProviderScope.containerOf(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); - final colors = Theme.of(context).colorScheme; - Navigator.of(context).pop(); - - final roomIdOrAlias = summary.canonicalAlias ?? summary.roomId; - - final snackbar = scaffoldMessenger.showSnackBar( - .new( - content: Text("Joining room $roomIdOrAlias."), - duration: Duration(days: 999), + return AlertDialog( + constraints: .loose(.fromWidth(400)), + scrollable: true, + contentPadding: EdgeInsets.all(24).copyWith(top: 8), + title: Row( + spacing: 12, + mainAxisSize: .min, + children: [ + if (summary.avatarUrl != null) + ExpandableImage( + summary.avatarUrl == null ? null : .new(mxc: summary.avatarUrl!), + child: AvatarOrHash( + summary.avatarUrl, + "", + dimension: 64, + fallback: Icon(Icons.numbers), ), - ); + ), + Expanded( + child: Text( + summary.name ?? summary.canonicalAlias ?? "Unnamed Room", + overflow: .ellipsis, + maxLines: 3, + ), + ), + ], + ), + content: Column( + children: [ + if (summary.invitedBy case final invitedBy?) + ListTile( + leading: AvatarOrHash( + summary.inviterProfile?.avatarUrl, + summary.inviterProfile?.displayName ?? invitedBy.localpart, + ), + title: Text( + "${summary.isDirect ? "Direct message from" : "Invited by"} ${summary.inviterProfile?.displayName ?? invitedBy.localpart}", + ), + subtitle: SelectableText(invitedBy), + ), + // Invite state never includes the member count + if (summary.joinedMembers != null || !isInvite) + ListTile( + title: Text( + "${summary.joinedMembers ?? "Unknown number of"} members", + ), + leading: Icon(Icons.people), + ), + ListTile( + title: SelectableText(summary.canonicalAlias ?? summary.roomId), + leading: Icon(Icons.numbers), + ), + if (summary.topic != null) + ListTile( + titleAlignment: ListTileTitleAlignment.titleHeight, + title: LinkifiedText( + summary.topic!, + style: Theme.of(context).textTheme.bodyLarge, + ), + leading: Icon(Icons.info), + ), + if (summary.encryption != null) + ListTile(title: Text("Encrypted"), leading: Icon(Icons.lock)), + if (summary.worldReadable == true) + ListTile( + title: Text("Anyone can read the history"), + leading: Icon(Icons.public), + ), + ], + ), + actions: [ + TextButton(onPressed: Navigator.of(context).pop, child: Text("Cancel")), + if (isInvite) ...[ + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); + Navigator.of(context).pop(); - try { - final id = await container - .read(ClientController.provider.notifier) - .joinRoom(.new(roomIdOrAlias: summary.roomId, via: via!)); + container + .read(InvitesController.provider.notifier) + .decline(summary.roomId) + .onError(showError); + }, + style: TextButton.styleFrom(foregroundColor: colorScheme.error), + child: Text("Decline"), + ), + TextButton( + onPressed: () { + final container = ProviderScope.containerOf(context); - snackbar.close(); - - scaffoldMessenger.showSnackBar( - .new( - content: Text("Room $roomIdOrAlias successfully joined."), - action: .new( - label: "Open", - onPressed: () => container.focusRoom(id), - ), - ), + context + .joinRoom( + .new(roomIdOrAlias: summary.roomId, fromInvite: true), + name: name, + ) + .then((roomId) { + if (roomId != null) { + container + .read(InvitesController.provider.notifier) + .remove(summary.roomId); + } + }); + Navigator.of(context).pop(); + }, + child: Text("Accept"), + ), + ] else if (via != null) + TextButton( + onPressed: () { + context.joinRoom( + .new(roomIdOrAlias: summary.roomId, via: via!), + name: name, ); - } catch (error) { - snackbar.close(); - scaffoldMessenger.showSnackBar( - .new( - backgroundColor: colors.errorContainer, - content: Text( - error.toString(), - style: .new(color: colors.onErrorContainer), - ), - ), - ); - } - }, - child: Text("Join"), - ), - ], - ); + Navigator.of(context).pop(); + }, + child: Text("Join"), + ), + ], + ); + } } diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index c748490..4c80802 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -1,47 +1,47 @@ -import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:material_ui/material_ui.dart"; -import "package:nexus/controllers/notification_request.dart"; +import "package:nexus/controllers/sidebar_page.dart"; import "package:nexus/models/nav_page.dart"; +import "package:nexus/models/sidebar_page.dart"; import "package:nexus/widgets/invites_list.dart"; import "package:nexus/widgets/notifications_list.dart"; import "package:nexus/widgets/room_list.dart"; -class const Sidebar({super.key}) extends HookConsumerWidget { +class const Sidebar({super.key}) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - const pages = [RoomList(), NotificationsList(), InvitesList()]; - final notificationsIndex = pages.indexWhere( - (page) => page is NotificationsList, + final selected = ref.watch(SidebarPageController.provider); + final pages = SidebarPage.values.map( + (page) => switch (page) { + .rooms => const RoomList(), + .notifications => const NotificationsList(), + .invites => const InvitesList(), + }, ); - final selectedIndex = useState( - ref.read(NotificationRequestController.provider) == null - ? 0 - : notificationsIndex, - ); - - ref.listen(NotificationRequestController.provider, (_, next) { - if (next != null) selectedIndex.value = notificationsIndex; - }); - return Drawer( width: 330, shape: Border(), child: Scaffold( backgroundColor: Colors.transparent, - body: pages[selectedIndex.value], + body: pages.elementAt(selected.index), bottomNavigationBar: NavigationBar( - selectedIndex: selectedIndex.value, - destinations: pages - .map( - (element) => NavigationDestination( - icon: Icon(element.icon), - label: element.title, - ), - ) - .toList(), - onDestinationSelected: (value) => selectedIndex.value = value, + selectedIndex: selected.index, + destinations: pages.map((page) { + final badgeCount = page.badgeCount(ref); + + return NavigationDestination( + icon: Badge.count( + count: badgeCount ?? 0, + isLabelVisible: badgeCount != null, + child: Icon(page.icon), + ), + label: page.title, + ); + }).toList(), + onDestinationSelected: (index) => ref + .read(SidebarPageController.provider.notifier) + .set(SidebarPage.values[index]), ), ), );