parent
dbf30c3706
commit
d37ec2878b
21 changed files with 561 additions and 147 deletions
|
|
@ -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<int>? 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<void> declineAll() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,9 +24,8 @@ class const NotificationsList({super.key})
|
|||
@override
|
||||
IconData get icon => Icons.notifications;
|
||||
|
||||
// TODO: Show notification badge here
|
||||
@override
|
||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
||||
Null badgeCount(_) => null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class const RoomList({super.key}) extends ConsumerWidget implements NavPage {
|
|||
|
||||
// TODO: Show unread badge here and on sidebar hamburger
|
||||
@override
|
||||
Future<int>? badgeBuilder(WidgetRef ref) => null;
|
||||
int? badgeCount(WidgetRef ref) => null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ final class const RoomMenuButton(
|
|||
),
|
||||
);
|
||||
await client
|
||||
.leaveRoom(room!)
|
||||
.leaveRoom(room!.metadata!.id)
|
||||
.onError(showError)
|
||||
.whenComplete(snackbar.close);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <NavPage>[RoomList(), NotificationsList(), InvitesList()];
|
||||
final notificationsIndex = pages.indexWhere(
|
||||
(page) => page is NotificationsList,
|
||||
final selected = ref.watch(SidebarPageController.provider);
|
||||
final pages = SidebarPage.values.map<NavPage>(
|
||||
(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]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue