WIP: Support for pinned messages #49

Draft
istalri wants to merge 9 commits from istalri/nexus:support_pinned_messages into main
4 changed files with 355 additions and 355 deletions
Showing only changes of commit 1f776c2a77 - Show all commits

use a double scaffold approach for pins drawer

Henry Hiles 2026-07-06 20:36:35 -04:00
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs

View file

@ -33,152 +33,156 @@ class MemberList extends HookConsumerWidget {
return Drawer( return Drawer(
shape: Border(), shape: Border(),
child: Column( child: Scaffold(
children: [ backgroundColor: Colors.transparent,
if (Scaffold.of(context).hasEndDrawer) body: Column(
AppBar( children: [
scrolledUnderElevation: 0, Padding(
leading: Icon(Icons.people), padding: .symmetric(vertical: 8),
title: Text("Members"), child: M3EToggleButtonGroup(
actionsPadding: .only(right: 4), selectedIndex: statusIndex.value,
actions: [ onSelectedIndexChanged: (index) =>
IconButton( statusIndex.value = index ?? statusIndex.value,
onPressed: Scaffold.of(context).closeEndDrawer, actions: options
icon: Icon(Icons.close), .mapTo(
tooltip: "Close member list", (name, value) => M3EToggleButtonGroupAction(
), checkedLabel: Text(
], "$name${switch (ref.watch(MembersByStatusController.provider(.new(roomId: roomId, status: value)))) {
), AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.length})",
Padding( _ => "",
padding: .symmetric(vertical: 8), }}",
child: M3EToggleButtonGroup(
selectedIndex: statusIndex.value,
onSelectedIndexChanged: (index) =>
statusIndex.value = index ?? statusIndex.value,
actions: options
.mapTo(
(name, value) => M3EToggleButtonGroupAction(
checkedLabel: Text(
"$name${switch (ref.watch(MembersByStatusController.provider(.new(roomId: roomId, status: value)))) {
AsyncData(:final value) || AsyncLoading(:final value?) => " (${value.length})",
_ => "",
}}",
),
label: Text(name),
),
)
.toList(),
),
),
switch (ref.watch(
MembersGroupedController.provider(
.new(roomId: roomId, status: status),
),
)) {
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
AsyncData(:final value) || AsyncLoading(:final value?) =>
value.isEmpty
? Center(
child: Padding(
padding: .symmetric(vertical: 18),
child: Text(
"No ${options.keys.toIList()[statusIndex.value]} Members",
style: Theme.of(context).textTheme.headlineSmall,
), ),
label: Text(name),
), ),
) )
: Expanded( .toList(),
child: CustomScrollView( ),
slivers: [ ),
for (final MapEntry(key: powerLevel, value: members)
in value) ...[ switch (ref.watch(
SliverToBoxAdapter( MembersGroupedController.provider(
child: Padding( .new(roomId: roomId, status: status),
padding: .symmetric(horizontal: 16), ),
child: DividerText( )) {
powerLevel == null AsyncError(:final error, :final stackTrace) => ErrorDialog(
? "Creators" error,
: "Power Level $powerLevel", stackTrace,
),
AsyncData(:final value) || AsyncLoading(:final value?) =>
value.isEmpty
? Center(
child: Padding(
padding: .symmetric(vertical: 18),
child: Text(
"No ${options.keys.toIList()[statusIndex.value]} Members",
style: Theme.of(context).textTheme.headlineSmall,
),
),
)
: Expanded(
child: CustomScrollView(
slivers: [
for (final MapEntry(key: powerLevel, value: members)
in value) ...[
SliverToBoxAdapter(
child: Padding(
padding: .symmetric(horizontal: 16),
child: DividerText(
powerLevel == null
? "Creators"
: "Power Level $powerLevel",
),
), ),
), ),
), SliverM3ECardList(
SliverM3ECardList( padding: .all(4),
padding: .all(4), color: Theme.of(
color: Theme.of( context,
context, ).colorScheme.surfaceContainerHigh,
).colorScheme.surfaceContainerHigh, margin: .symmetric(horizontal: 12, vertical: 4),
margin: .symmetric(horizontal: 12, vertical: 4), itemCount: members.length,
itemCount: members.length, itemBuilder: (context, index) =>
itemBuilder: (context, index) => switch (members[index].content) {
switch (members[index].content) { MembershipContent(
MembershipContent( :final avatarUrl,
:final avatarUrl, :final displayName,
:final displayName, ) =>
) => ListTile(
ListTile( title: Text(
title: Text( displayName ??
displayName ?? members[index]
members[index] .stateKey!
.localpart,
overflow: .ellipsis,
style: .new(
color: members[index]
.stateKey! .stateKey!
.localpart, .colorHash,
overflow: .ellipsis, fontWeight: .bold,
style: .new( ),
color: members[index] ),
.stateKey! subtitle: Text(
.colorHash, members[index].stateKey!,
fontWeight: .bold, overflow: .ellipsis,
),
leading: AvatarOrHash(
avatarUrl,
height: 36,
displayName ??
members[index]
.stateKey!
.localpart,
), ),
), ),
subtitle: Text( _ => throw Exception(
members[index].stateKey!, "Member content was not MembershipContent",
overflow: .ellipsis, ),
), },
leading: AvatarOrHash( onTap: (index) {
avatarUrl, final member = members[index];
height: 36, if (member.content
displayName ?? case MembershipContent content) {
members[index] showModalBottomSheet(
.stateKey! constraints: BoxConstraints.loose(
.localpart, Size(
500,
(context.size?.height ?? 1000) - 80,
), ),
), ),
_ => throw Exception( isScrollControlled: true,
"Member content was not MembershipContent", context: context,
), builder: (context) => UserBottomSheet(
}, content,
onTap: (index) { member.stateKey!,
final member = members[index]; roomId: roomId,
if (member.content
case MembershipContent content) {
showModalBottomSheet(
constraints: BoxConstraints.loose(
Size(
500,
(context.size?.height ?? 1000) - 80,
), ),
), );
isScrollControlled: true, }
context: context, },
builder: (context) => UserBottomSheet( ),
content, ],
member.stateKey!,
roomId: roomId,
),
);
}
},
),
], ],
], ),
), ),
), AsyncLoading _ => Loading(),
AsyncLoading _ => Loading(), },
}, ],
], ),
appBar: Scaffold.of(context).hasEndDrawer
? AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.people),
title: Text("Members"),
actionsPadding: .only(right: 4),
actions: [
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close),
tooltip: "Close member list",
),
],
)
: null,
), ),
); );
} }

View file

@ -13,97 +13,96 @@ class PinnedEventsDrawer extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final pinsAsync = ref.watch(PinnedEventsController.provider(roomId)); final pinsProvider = ref.watch(PinnedEventsController.provider(roomId));
final theme = Theme.of(context); final theme = Theme.of(context);
return Column( return Drawer(
children: [ width: 600,
if (Scaffold.of(context).hasEndDrawer) child: Scaffold(
AppBar( backgroundColor: Colors.transparent,
scrolledUnderElevation: 0, appBar: AppBar(
leading: Icon(Icons.pin), scrolledUnderElevation: 0,
title: Text("Pinned Messages"), leading: Icon(Icons.push_pin),
actionsPadding: .only(right: 4), title: Text("Pinned Messages"),
actions: [ actionsPadding: .only(right: 4),
IconButton( actions: [
onPressed: Scaffold.of(context).closeEndDrawer, IconButton(
icon: Icon(Icons.close), onPressed: Scaffold.of(context).closeEndDrawer,
tooltip: "Close pinned messages", icon: Icon(Icons.close),
), tooltip: "Close pinned messages",
],
),
Expanded(
child: switch (pinsAsync) {
AsyncData(:final value) when value.isEmpty => Center(
child: Column(
children: [
Icon(
Icons.push_pin_outlined,
size: 48,
color: theme.colorScheme.surfaceContainerHigh,
),
SizedBox(height: 12),
Text(
"No pinned messages.",
style: theme.textTheme.headlineSmall,
),
],
),
), ),
AsyncData(:final value) => ListView.builder( ],
padding: .symmetric(vertical: 18), ),
itemCount: value.length, body: switch (pinsProvider) {
itemBuilder: (context, index) { AsyncData(:final value) when value.isEmpty => Center(
final event = value[index]; child: Column(
children: [
Icon(
Icons.push_pin_outlined,
size: 48,
color: theme.colorScheme.surfaceContainerHigh,
),
SizedBox(height: 12),
Text(
"No pinned messages.",
style: theme.textTheme.headlineSmall,
),
],
),
),
AsyncData(:final value) => ListView.builder(
Henry-Hiles marked this conversation as resolved Outdated

I would say instead of this, a SuperSliverView should be used, like we do in the main chat view, and instead of an extra card, just wrap EventRenderer with an InkWell or something. And then, you don't need to set textOnly on EventRenderer.

I would say instead of this, a `SuperSliverView` should be used, like we do in the main chat view, and instead of an extra card, just wrap `EventRenderer` with an `InkWell` or something. And then, you don't need to set `textOnly` on `EventRenderer`.

Okay, I take it back about SuperSliverView, the performance of ListView seems acceptable here.

Okay, I take it back about `SuperSliverView`, the performance of `ListView` seems acceptable here.

I've done my other suggestion with not using textOnly.

I've done my other suggestion with not using `textOnly`.
padding: .symmetric(vertical: 18),
itemCount: value.length,
itemBuilder: (context, index) {
final event = value[index];
return Card( return Card(
margin: .only(bottom: 8), margin: .only(bottom: 8),
child: InkWell( child: InkWell(
borderRadius: .circular(12), borderRadius: .circular(12),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
// TODO: // Close drawer, then scroll to the message // TODO: // Close drawer, then scroll to the message
}, },
child: Padding( child: Padding(
padding: .all(12), padding: .all(12),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Icon(
Icons.push_pin, Icons.push_pin,
size: 16, size: 16,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
),
SizedBox(width: 8),
Expanded(
child: EventRenderer(
event,
textOnly: true,
maxLines: 2,
isGrouped: false,
getEventOptions: getEventOptions,
), ),
SizedBox(width: 8), ),
Expanded( ],
child: EventRenderer(
event,
textOnly: true,
maxLines: 2,
isGrouped: false,
getEventOptions: getEventOptions,
),
),
],
),
), ),
), ),
); ),
}, );
},
),
AsyncLoading() => Center(child: Loading()),
AsyncError(:final error) => Center(
child: Column(
children: [
Icon(Icons.error_outline, color: theme.colorScheme.error),
SizedBox(height: 8),
Text("$error"),
],
), ),
AsyncLoading() => Center(child: Loading()), ),
AsyncError(:final error) => Center( },
child: Column( ),
children: [
Icon(Icons.error_outline, color: theme.colorScheme.error),
SizedBox(height: 8),
Text("$error"),
],
),
),
},
),
],
); );
} }
} }

View file

@ -12,8 +12,8 @@ import "package:nexus/widgets/room_menu.dart";
class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget { class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
final bool isDesktop; final bool isDesktop;
final void Function(BuildContext context)? onOpenMemberList; final void Function(BuildContext context)? onOpenMemberList;
final void Function(BuildContext context)? onOpenPinnedMessagesList; final void Function()? onOpenPinnedMessagesList;
final void Function(BuildContext context) onOpenDrawer; final void Function() onOpenDrawer;
final String? roomId; final String? roomId;
const RoomAppbar({ const RoomAppbar({
required this.roomId, required this.roomId,
@ -103,7 +103,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
height: 24, height: 24,
fallback: Icon(Icons.numbers), fallback: Icon(Icons.numbers),
) )
: DrawerButton(onPressed: () => onOpenDrawer(context)), : DrawerButton(onPressed: onOpenDrawer),
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
title: room == null title: room == null
? null ? null
@ -130,7 +130,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
? .new() ? .new()
: .new([ : .new([
IconButton( IconButton(
onPressed: () => onOpenPinnedMessagesList?.call(context), onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin), icon: Icon(Icons.push_pin),
tooltip: "Open pinned messages", tooltip: "Open pinned messages",
), ),

View file

@ -62,9 +62,7 @@ class RoomChat extends HookConsumerWidget {
appBar: RoomAppbar( appBar: RoomAppbar(
roomId: this.roomId, roomId: this.roomId,
isDesktop: isDesktop, isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenDrawer: () => Scaffold.of(context).openDrawer(),
onOpenMemberList: null,
onOpenPinnedMessagesList: null,
), ),
body: nothing, body: nothing,
); );
@ -386,149 +384,148 @@ class RoomChat extends HookConsumerWidget {
} }
return Scaffold( return Scaffold(
appBar: RoomAppbar( endDrawer: PinnedEventsDrawer(roomId, getEventOptions: getEventOptions),
roomId: roomId, body: Builder(
isDesktop: isDesktop, builder: (middleContext) => Scaffold(
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), endDrawer: showMembersByDefault ? null : MemberList(roomId),
onOpenMemberList: (thisContext) { appBar: RoomAppbar(
memberListOpened.value = !memberListOpened.value; roomId: roomId,
if (!showMembersByDefault) { isDesktop: isDesktop,
Scaffold.of(thisContext).openEndDrawer(); onOpenDrawer: Scaffold.of(context).openDrawer,
} onOpenMemberList: (thisContext) {
}, memberListOpened.value = !memberListOpened.value;
onOpenPinnedMessagesList: (thisContext) { Scaffold.of(thisContext).openEndDrawer();
memberListOpened.value = false; },
WidgetsBinding.instance.addPostFrameCallback((_) { onOpenPinnedMessagesList: () {
Scaffold.of(thisContext).openEndDrawer(); Scaffold.of(middleContext).openEndDrawer();
}); },
}, ),
), body: Row(
body: Row( children: [
children: [ Expanded(
Expanded( child: Stack(
child: Stack( children: [
children: [ Positioned.fill(
Positioned.fill( child: Padding(
child: Padding( padding: .symmetric(horizontal: 4),
padding: .symmetric(horizontal: 4), child: switch (controllerData) {
child: switch (controllerData) { AsyncData(:final value?) ||
AsyncData(:final value?) || AsyncLoading(:final value?) => CustomScrollView(
AsyncLoading(:final value?) => CustomScrollView( controller: scrollController,
controller: scrollController, slivers: [
slivers: [ SliverToBoxAdapter(
SliverToBoxAdapter( child: Padding(
child: Padding( padding: .symmetric(vertical: 36),
padding: .symmetric(vertical: 36), child: Center(
child: Center( child: ElevatedButton(
child: ElevatedButton( onPressed: controllerData is AsyncData
onPressed: controllerData is AsyncData ? loadOlder
? loadOlder : null,
: null, child: Text("Load More"),
child: Text("Load More"), ),
),
), ),
), ),
),
),
SuperSliverList.builder( SuperSliverList.builder(
listController: listController.value, listController: listController.value,
itemCount: value.length, itemCount: value.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final event = value[index]; final event = value[index];
final previousEvent = value.getOrNull(index - 1); final previousEvent = value.getOrNull(
return HighlightWrapper( index - 1,
EventRenderer( );
event, return HighlightWrapper(
onTapReply: () async { EventRenderer(
final replyId = event.replyTo; event,
listController.value.animateToItem( onTapReply: () async {
index: value.indexWhere( final replyId = event.replyTo;
(element) => element.eventId == replyId, listController.value.animateToItem(
), index: value.indexWhere(
scrollController: scrollController, (element) =>
alignment: 0.5, element.eventId == replyId,
duration: (_) => .new(milliseconds: 700), ),
curve: (_) => Curves.easeInOut, scrollController: scrollController,
); alignment: 0.5,
highlightedEvent.value = replyId; duration: (_) =>
await Future.delayed(.new(seconds: 1), () { .new(milliseconds: 700),
if (highlightedEvent.value == replyId) { curve: (_) => Curves.easeInOut,
highlightedEvent.value = null; );
} highlightedEvent.value = replyId;
}); await Future.delayed(
}, .new(seconds: 1),
getEventOptions: getEventOptions, () {
isGrouped: if (highlightedEvent.value ==
previousEvent?.content replyId) {
is MessageContent && highlightedEvent.value = null;
previousEvent?.redactedBy == null && }
previousEvent?.relationType != },
"m.replace" && );
"${event.sender}${event.pmp?.id}" == },
"${previousEvent?.sender}${previousEvent?.pmp?.id}", getEventOptions: getEventOptions,
), isGrouped:
isHighlighted: previousEvent?.content
highlightedEvent.value == event.eventId, is MessageContent &&
); previousEvent?.redactedBy == null &&
}, previousEvent?.relationType !=
), "m.replace" &&
"${event.sender}${event.pmp?.id}" ==
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
),
isHighlighted:
highlightedEvent.value == event.eventId,
);
},
),
SliverPadding( SliverPadding(
padding: .only(bottom: composerSize.value), padding: .only(bottom: composerSize.value),
),
],
), ),
], AsyncData() => nothing,
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
), ),
AsyncData() => nothing,
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) => composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend: (text, {required shouldMention, required tags}) =>
notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relation: relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () => relatedEvent.value = null,
), ),
), Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) => composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend:
(text, {required shouldMention, required tags}) =>
notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relation: relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () => relatedEvent.value = null,
),
),
),
],
), ),
], ),
),
),
if (memberListOpened.value == true && showMembersByDefault) if (memberListOpened.value == true && showMembersByDefault)
Henry-Hiles marked this conversation as resolved Outdated

This isn't a good way to do this, the double scaffold is better. I will push a fix.

This isn't a good way to do this, the double scaffold is better. I will push a fix.

Done.

Done.
MemberList(roomId), MemberList(roomId),
], ],
),
),
), ),
endDrawer: showMembersByDefault && memberListOpened.value
? null
: Drawer(
shape: Border(),
child: memberListOpened.value
? MemberList(roomId)
: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
),
),
); );
} }
} }