1
0
Fork 0
forked from Nexus/nexus

Compare commits

..

11 commits

Author SHA1 Message Date
e1d7a30a06
Support one level of subspaces 2026-06-06 15:11:40 -04:00
0c61623a94
fix fallback for null displayName in mention chip 2026-06-06 13:15:41 -04:00
0a9b71230b
set sidebar width to infinity 2026-06-06 13:04:44 -04:00
15c057707f
slightly lower size of sidebar 2026-06-06 13:04:12 -04:00
9cc18e16b8
only show appbar on member list if its a drawer 2026-06-06 13:02:51 -04:00
240984a832
Don't connect M3EToggleButtonGroup
I think it looks nicer this way.
2026-06-06 12:25:09 -04:00
633e83b915
slight cleanups 2026-06-06 12:21:42 -04:00
c5122fc34f
add some more padding to member list 2026-06-06 10:55:12 -04:00
457de3c77c
more performant member list 2026-06-06 10:51:56 -04:00
c9aa0173d8
fix navigation rail hash 2026-06-06 10:18:40 -04:00
22aa26fd05
fix keyboard navigation of forms
Including submit with keyboard, and not defocusing on submit of a form
2026-06-06 10:15:29 -04:00
10 changed files with 246 additions and 216 deletions

View file

@ -8,12 +8,13 @@ import "package:nexus/models/content/create.dart";
import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/power_levels.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
class MembersGroupedController extends AsyncNotifier<IMap<int?, ISet<Event>>> { class MembersGroupedController
extends AsyncNotifier<IList<MapEntry<int?, ISet<Event>>>> {
final MembersByStatusConfig config; final MembersByStatusConfig config;
MembersGroupedController(this.config); MembersGroupedController(this.config);
@override @override
Future<IMap<int?, ISet<Event>>> build() async { Future<IList<MapEntry<int?, ISet<Event>>>> build() async {
final room = ref.watch( final room = ref.watch(
RoomsController.provider.select((value) => value[config.roomId]), RoomsController.provider.select((value) => value[config.roomId]),
); );
@ -42,7 +43,8 @@ class MembersGroupedController extends AsyncNotifier<IMap<int?, ISet<Event>>> {
MembersByStatusController.provider(config).future, MembersByStatusController.provider(config).future,
); );
return members.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) { return members
.fold<IMap<int?, ISet<Event>>>(.new(), (result, event) {
final groupKey = creators?.contains(event.stateKey!) == true final groupKey = creators?.contains(event.stateKey!) == true
? null ? null
: content.users[event.stateKey!] ?? content.usersDefault; : content.users[event.stateKey!] ?? content.usersDefault;
@ -52,13 +54,17 @@ class MembersGroupedController extends AsyncNotifier<IMap<int?, ISet<Event>>> {
(value) => value.add(event), (value) => value.add(event),
ifAbsent: () => .new({event}), ifAbsent: () => .new({event}),
); );
}); })
.toEntryIList(
compare: (a, b) =>
(b?.key ?? double.infinity).compareTo(a?.key ?? double.infinity),
);
} }
static final provider = static final provider =
AsyncNotifierProvider.family< AsyncNotifierProvider.family<
MembersGroupedController, MembersGroupedController,
IMap<int?, ISet<Event>>, IList<MapEntry<int?, ISet<Event>>>,
MembersByStatusConfig MembersByStatusConfig
>(MembersGroupedController.new); >(MembersGroupedController.new);
} }

View file

@ -6,7 +6,9 @@ import "package:nexus/controllers/account_data_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/controllers/top_level_spaces_controller.dart"; import "package:nexus/controllers/top_level_spaces_controller.dart";
import "package:nexus/controllers/space_edges_controller.dart"; import "package:nexus/controllers/space_edges_controller.dart";
import "package:nexus/models/room.dart";
import "package:nexus/models/space.dart"; import "package:nexus/models/space.dart";
import "package:nexus/models/subspace.dart";
class SpacesController extends Notifier<IList<Space>> { class SpacesController extends Notifier<IList<Space>> {
@override @override
@ -14,91 +16,97 @@ class SpacesController extends Notifier<IList<Space>> {
final rooms = ref.watch(RoomsController.provider); final rooms = ref.watch(RoomsController.provider);
final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider); final topLevelSpaceIds = ref.watch(TopLevelSpacesController.provider);
final spaceEdges = ref.watch(SpaceEdgesController.provider); final spaceEdges = ref.watch(SpaceEdgesController.provider);
final accountData = ref.watch(AccountDataController.provider);
final childRoomsBySpaceId = IMap.fromEntries( final childrenById = {
topLevelSpaceIds.map((spaceId) { for (final entry in spaceEdges.entries)
ISet<String> walk(String currentId) { entry.key: entry.value.map((e) => e.childId).toList(),
final children = spaceEdges[currentId] ?? .new(); };
return children.fold<ISet<String>>(.new(), (acc, edge) { Set<String> collectDescendants(String startId) {
final childId = edge.childId; final visited = <String>{};
final isSpace = spaceEdges.containsKey(childId); final stack = [startId];
return acc while (stack.isNotEmpty) {
.addAll(!isSpace ? ISet([childId]) : const .empty()) final current = stack.removeLast();
.addAll(isSpace ? walk(childId) : const .empty()); final children = childrenById[current] ?? const [];
});
for (final child in children) {
if (visited.add(child)) {
stack.add(child);
}
}
} }
return MapEntry( return visited;
spaceId, }
walk(spaceId).map((id) => rooms[id]).nonNulls.toIList(),
);
}),
);
final allNestedRoomIds = childRoomsBySpaceId.values Space buildSpace(String spaceId) {
.expand((l) => l) final space = rooms[spaceId];
.map( final directChildrenIds = childrenById[spaceId] ?? const [];
(room) => rooms.entries
.firstWhere( final directRooms = <Room>[];
(entry) => entry.value.metadata?.id == room.metadata?.id, final subSpaces = <Subspace>[];
)
.key, for (final childId in directChildrenIds) {
) final room = rooms[childId];
.toISet(); if (room == null) continue;
if (childrenById.containsKey(childId)) {
final descendants = collectDescendants(childId);
subSpaces.add(
.new(
room: room,
children: .new(descendants.map((id) => rooms[id]).nonNulls),
),
);
} else {
directRooms.add(room);
}
}
return .new(
id: spaceId,
room: space,
title: space?.metadata?.name ?? "Unnamed Space",
children: .new(directRooms),
subSpaces: .new(subSpaces),
);
}
final spaces = topLevelSpaceIds.map(buildSpace).toIList();
final usedRoomIds = {
for (final space in spaces) ...[
...space.children.map((r) => r.metadata?.id),
...space.subSpaces.expand((s) => s.children.map((r) => r.metadata?.id)),
],
}.nonNulls.toISet();
final directMessages = IMap(
accountData["m.direct"]?.content ?? {},
).values.expand((e) => e).toISet();
final otherRooms = rooms.entries final otherRooms = rooms.entries
.where( .where(
(e) => (e) =>
!allNestedRoomIds.contains(e.key) && !usedRoomIds.contains(e.key) &&
!topLevelSpaceIds.contains(e.key) && !topLevelSpaceIds.contains(e.key) &&
!spaceEdges.containsKey(e.key), !childrenById.containsKey(e.key),
) )
.map((e) => e.value); .map((e) => e.value)
.toIList();
final accountData = ref.watch(AccountDataController.provider);
final directMessages = IMap(
accountData["m.direct"]?.content ?? {},
).values.expand((element) => element);
final homeRooms = otherRooms final homeRooms = otherRooms
.where( .where((r) => !directMessages.contains(r.metadata?.id))
(room) =>
directMessages.any(
(directMessage) => directMessage == room.metadata?.id,
) ==
false,
)
.toIList(); .toIList();
final dmRooms = otherRooms final dmRooms = otherRooms
.where( .where((r) => directMessages.contains(r.metadata?.id))
(room) => directMessages.any(
(directMessage) => directMessage == room.metadata?.id,
),
)
.toIList(); .toIList();
final topLevelSpacesList = topLevelSpaceIds final allSpaces = <Space>[
.map((id) {
final room = rooms[id];
if (room == null) return null;
final children = childRoomsBySpaceId[id] ?? .new();
return Space(
subSpaces: const IList.empty(), // TODO
id: id,
title: room.metadata?.name ?? "Unnamed Room",
room: room,
children: children,
);
})
.nonNulls
.toIList();
return <Space>[
.new( .new(
id: "home", id: "home",
title: "Home", title: "Home",
@ -113,8 +121,10 @@ class SpacesController extends Notifier<IList<Space>> {
children: dmRooms, children: dmRooms,
subSpaces: .new(), subSpaces: .new(),
), ),
...topLevelSpacesList, ...spaces,
] ];
return allSpaces
.map( .map(
(space) => space.copyWith( (space) => space.copyWith(
children: .new( children: .new(

View file

@ -80,6 +80,9 @@ class LoginPage extends HookConsumerWidget {
validator: requiredValidator, validator: requiredValidator,
controller: password, controller: password,
obscureText: true, obscureText: true,
onFieldSubmitted: (_) => tryLogin(),
// Don't defocus on submit
onEditingComplete: () {},
), ),
], ],
), ),

View file

@ -15,6 +15,22 @@ class VerifyPage extends HookConsumerWidget {
final inputError = useState<String?>(null); final inputError = useState<String?>(null);
final formKey = useRef(GlobalKey<FormState>()); final formKey = useRef(GlobalKey<FormState>());
Future<void> verify() async {
isLoading.value = true;
try {
if (formKey.value.currentState?.validate() != true) {
return;
}
inputError.value = await ref
.watch(ClientController.provider.notifier)
.verify(passphraseController.text);
} finally {
isLoading.value = false;
}
}
return Scaffold( return Scaffold(
appBar: Appbar(), appBar: Appbar(),
body: AlertDialog( body: AlertDialog(
@ -40,29 +56,16 @@ class VerifyPage extends HookConsumerWidget {
label: Text("Recovery Key or Passphrase"), label: Text("Recovery Key or Passphrase"),
errorText: inputError.value, errorText: inputError.value,
), ),
onFieldSubmitted: (_) => verify(),
// Don't defocus on submit
onEditingComplete: () {},
), ),
], ],
), ),
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: isLoading.value onPressed: isLoading.value ? null : verify,
? null
: () async {
isLoading.value = true;
try {
if (formKey.value.currentState?.validate() != true) {
return;
}
inputError.value = await ref
.watch(ClientController.provider.notifier)
.verify(passphraseController.text);
} finally {
isLoading.value = false;
}
},
child: Text("Verify"), child: Text("Verify"),
), ),
], ],

View file

@ -36,7 +36,9 @@ class MentionChip extends ConsumerWidget {
child: IgnorePointer( child: IgnorePointer(
child: Chip( child: Chip(
label: Text( label: Text(
(membership == null ? null : "@${membership.displayName}") ?? (membership?.displayName == null
? null
: "@${membership!.displayName}") ??
mention, mention,
style: .new( style: .new(
fontWeight: .bold, fontWeight: .bold,

View file

@ -3,7 +3,8 @@ import "package:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_buttons/m3e_buttons.dart"; import "package:m3e_buttons/m3e_buttons.dart";
import "package:material_segmented_list/material_segmented_list.dart"; import "package:m3e_card_list/m3e_card_list.dart";
import "package:nexus/controllers/members_by_status_controller.dart";
import "package:nexus/controllers/members_grouped_controller.dart"; import "package:nexus/controllers/members_grouped_controller.dart";
import "package:nexus/helpers/extensions/get_localpart.dart"; import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/string_to_color.dart"; import "package:nexus/helpers/extensions/string_to_color.dart";
@ -20,31 +21,26 @@ class MemberList extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final status = useState(MembershipStatus.join); final statusIndex = useState(0);
final membersData = ref.watch(
MembersGroupedController.provider(
.new(roomId: roomId, status: status.value),
),
);
final options = <String, MembershipStatus>{ final options = <String, MembershipStatus>{
"Joined": .join, "Joined": .join,
"Invited": .invite, "Invited": .invite,
"Banned": .ban, "Banned": .ban,
}; };
final status = options.values.toIList()[statusIndex.value];
return Drawer( return Drawer(
shape: Border(), shape: Border(),
child: Column( child: Column(
children: [ children: [
if (Scaffold.of(context).hasEndDrawer)
AppBar( AppBar(
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
leading: Icon(Icons.people), leading: Icon(Icons.people),
title: Text("Members"), title: Text("Members"),
actionsPadding: .only(right: 4), actionsPadding: .only(right: 4),
actions: [ actions: [
if (Scaffold.of(context).hasEndDrawer)
IconButton( IconButton(
onPressed: Scaffold.of(context).closeEndDrawer, onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close), icon: Icon(Icons.close),
@ -52,18 +48,18 @@ class MemberList extends HookConsumerWidget {
), ),
], ],
), ),
M3EToggleButtonGroup( Padding(
type: .connected, padding: .symmetric(vertical: 8),
selectedIndex: options.values.toIList().indexOf(status.value), child: M3EToggleButtonGroup(
selectedIndex: statusIndex.value,
onSelectedIndexChanged: (index) => onSelectedIndexChanged: (index) =>
status.value = options.values.elementAt(index ?? 0), statusIndex.value = index ?? statusIndex.value,
// overflow: M3EButtonGroupOverflow.menu,
actions: options actions: options
.mapTo( .mapTo(
(name, value) => M3EToggleButtonGroupAction( (name, value) => M3EToggleButtonGroupAction(
checkedLabel: Text( checkedLabel: Text(
"$name${switch (membersData) { "$name${switch (ref.watch(MembersByStatusController.provider(.new(roomId: roomId, status: value)))) {
AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.values.expand((element) => element).length})", AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.length})",
_ => "", _ => "",
}}", }}",
), ),
@ -72,8 +68,13 @@ class MemberList extends HookConsumerWidget {
) )
.toList(), .toList(),
), ),
),
switch (membersData) { switch (ref.watch(
MembersGroupedController.provider(
.new(roomId: roomId, status: status),
),
)) {
AsyncError(:final error, :final stackTrace) => ErrorDialog( AsyncError(:final error, :final stackTrace) => ErrorDialog(
error, error,
stackTrace, stackTrace,
@ -84,71 +85,76 @@ class MemberList extends HookConsumerWidget {
child: Padding( child: Padding(
padding: .symmetric(vertical: 18), padding: .symmetric(vertical: 18),
child: Text( child: Text(
"No ${options.keys.toIList()[options.values.toIList().indexOf(status.value)]} Members", "No ${options.keys.toIList()[statusIndex.value]} Members",
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
), ),
), ),
) )
: Expanded( : Expanded(
child: ListView( child: CustomScrollView(
padding: .all(12), slivers: [
children: [
for (final MapEntry(key: powerLevel, value: members) for (final MapEntry(key: powerLevel, value: members)
in value.toEntryIList( in value) ...[
compare: (a, b) => (b?.key ?? double.infinity) SliverToBoxAdapter(
.compareTo(a?.key ?? double.infinity), child: Padding(
)) ...[ padding: .symmetric(horizontal: 16),
Padding(
padding: .symmetric(horizontal: 4),
child: DividerText( child: DividerText(
powerLevel == null powerLevel == null
? "Creators" ? "Creators"
: "Power Level $powerLevel", : "Power Level $powerLevel",
), ),
), ),
SegmentedListSection( ),
children: members SliverM3ECardList(
.map( padding: .all(4),
(member) => switch (member.content) { color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
margin: .symmetric(horizontal: 12, vertical: 4),
itemCount: members.length,
itemBuilder: (context, index) =>
switch (members[index].content) {
MembershipContent( MembershipContent(
:final avatarUrl, :final avatarUrl,
:final displayName, :final displayName,
) => ) =>
SegmentedListTile( ListTile(
onTap: () {},
// context.showUserPopover(
// member.content as MembershipContent,
// member.stateKey!,
// roomId: roomId,
// globalPosition: details.globalPosition,
// ),
title: Text( title: Text(
displayName ?? displayName ??
member.stateKey!.localpart, members[index]
.stateKey!
.localpart,
overflow: .ellipsis, overflow: .ellipsis,
style: .new( style: .new(
color: member.stateKey!.colorHash, color: members[index]
.stateKey!
.colorHash,
fontWeight: .bold, fontWeight: .bold,
), ),
), ),
subtitle: Text( subtitle: Text(
member.stateKey!, members[index].stateKey!,
overflow: .ellipsis, overflow: .ellipsis,
), ),
leading: AvatarOrHash( leading: AvatarOrHash(
avatarUrl, avatarUrl,
displayName ?? displayName ??
member.sender.localpart, members[index].sender.localpart,
), ),
), ),
_ => throw Exception( _ => throw Exception(
"Member content was not MembershipContent", "Member content was not MembershipContent",
), ),
}, },
) onTap: (index) {
.toList(), // context.showUserPopover(
// member.content as MembershipContent,
// member.stateKey!,
// roomId: roomId,
// globalPosition: details.globalPosition,
// ),
},
), ),
SizedBox(height: 4),
], ],
], ],
), ),

View file

@ -67,7 +67,7 @@ class Sidebar extends HookConsumerWidget {
.toList(); .toList();
return Drawer( return Drawer(
width: 340, width: 330,
shape: Border(), shape: Border(),
child: Row( child: Row(
children: [ children: [
@ -206,19 +206,20 @@ class Sidebar extends HookConsumerWidget {
], ],
), ),
child: NavigationRailM3E( child: NavigationRailM3E(
expandedWidth: 360, expandedWidth: double.infinity,
scrollable: true, scrollable: true,
background: Colors.transparent, background: Colors.transparent,
type: .alwaysExpand, type: .alwaysExpand,
selectedIndex: selectedRoomIndex ?? 0, selectedIndex: selectedRoomIndex ?? 0,
sections: [ sections: [
.new( .new(
header: selectedSpace.room == null ? null : Text("Rooms"),
destinations: roomsToDestinations(selectedSpace.children), destinations: roomsToDestinations(selectedSpace.children),
), ),
for (final subSpace in selectedSpace.subSpaces) for (final subSpace in selectedSpace.subSpaces)
.new( .new(
header: Text( header: Text(
subSpace.room.metadata?.name ?? "Unnamed Room", subSpace.room.metadata?.name ?? "Unnamed Space",
), ),
destinations: roomsToDestinations(subSpace.children), destinations: roomsToDestinations(subSpace.children),
), ),

View file

@ -32,7 +32,7 @@ flutter.buildFlutterApplication {
window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM="; window_size = "sha256-XelNtp7tpZ91QCEcvewVphNUtgQX7xrp5QP0oFo6DgM=";
emoji_text_field = "sha256-3TOys09EP2GRo6pUBGPXaqBlE39O2Cmwt42Hs1cTDKo="; emoji_text_field = "sha256-3TOys09EP2GRo6pUBGPXaqBlE39O2Cmwt42Hs1cTDKo=";
linkify = "sha256-mxV/XHLxF9cn7sUPr2SUNjVmDr5lbxkuGCbNdyiZi2c="; linkify = "sha256-mxV/XHLxF9cn7sUPr2SUNjVmDr5lbxkuGCbNdyiZi2c=";
navigation_rail_m3e = "sha256-Zkqo8RkNSWiELB+Bk6iLsgft9THuJiMtysEwXNS/ZgY="; navigation_rail_m3e = "sha256-+2awDTQnK58gGRY1nuHckG/jjxarsYSRu9ovR4i4TEc=";
}; };
postInstall = '' postInstall = ''

View file

@ -783,6 +783,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.3" version: "0.0.3"
m3e_card_list:
dependency: "direct main"
description:
name: m3e_card_list
sha256: d4aba0123cccda40ac80789befa8d355e1dc16aa7dcee910157690b0546d78d6
url: "https://pub.dev"
source: hosted
version: "0.1.0"
m3e_design: m3e_design:
dependency: transitive dependency: transitive
description: description:
@ -807,14 +815,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.13.0"
material_segmented_list:
dependency: "direct main"
description:
name: material_segmented_list
sha256: "384bfd41a78e745397ceff1dd39700961e6a5419ad911d1797bcc13ea3824241"
url: "https://pub.dev"
source: hosted
version: "1.0.5"
measure_size: measure_size:
dependency: "direct main" dependency: "direct main"
description: description:
@ -993,7 +993,7 @@ packages:
source: hosted source: hosted
version: "2.1.5" version: "2.1.5"
path_provider_android: path_provider_android:
dependency: "direct main" dependency: transitive
description: description:
name: path_provider_android name: path_provider_android
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"

View file

@ -27,7 +27,6 @@ dependencies:
intl: 0.20.2 intl: 0.20.2
fast_immutable_collections: 11.2.0 fast_immutable_collections: 11.2.0
path_provider: 2.1.5 path_provider: 2.1.5
path_provider_android: 2.2.23
url_launcher: 6.3.2 url_launcher: 6.3.2
freezed_annotation: 3.1.0 freezed_annotation: 3.1.0
image_picker: 1.2.2 image_picker: 1.2.2
@ -62,12 +61,12 @@ dependencies:
media_kit_video: 2.0.1 media_kit_video: 2.0.1
media_kit_libs_video: 1.0.7 media_kit_libs_video: 1.0.7
measure_size: ^5.0.2 measure_size: ^5.0.2
material_segmented_list: ^1.0.5
m3e_buttons: ^0.0.3 m3e_buttons: ^0.0.3
navigation_rail_m3e: navigation_rail_m3e:
git: git:
url: https://github.com/Henry-Hiles/material_3_expressive url: https://github.com/Henry-Hiles/material_3_expressive
path: packages/navigation_rail_m3e path: packages/navigation_rail_m3e
m3e_card_list: ^0.1.0
dev_dependencies: dev_dependencies:
build_runner: 2.15.0 build_runner: 2.15.0