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(
shape: Border(),
child: Column(
children: [
if (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",
),
],
),
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,
child: Scaffold(
backgroundColor: Colors.transparent,
body: Column(
children: [
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),
),
)
: 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",
.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,
),
),
)
: 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(
padding: .all(4),
color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
margin: .symmetric(horizontal: 12, vertical: 4),
itemCount: members.length,
itemBuilder: (context, index) =>
switch (members[index].content) {
MembershipContent(
:final avatarUrl,
:final displayName,
) =>
ListTile(
title: Text(
displayName ??
members[index]
SliverM3ECardList(
padding: .all(4),
color: Theme.of(
context,
).colorScheme.surfaceContainerHigh,
margin: .symmetric(horizontal: 12, vertical: 4),
itemCount: members.length,
itemBuilder: (context, index) =>
switch (members[index].content) {
MembershipContent(
:final avatarUrl,
:final displayName,
) =>
ListTile(
title: Text(
displayName ??
members[index]
.stateKey!
.localpart,
overflow: .ellipsis,
style: .new(
color: members[index]
.stateKey!
.localpart,
overflow: .ellipsis,
style: .new(
color: members[index]
.stateKey!
.colorHash,
fontWeight: .bold,
.colorHash,
fontWeight: .bold,
),
),
subtitle: Text(
members[index].stateKey!,
overflow: .ellipsis,
),
leading: AvatarOrHash(
avatarUrl,
height: 36,
displayName ??
members[index]
.stateKey!
.localpart,
),
),
subtitle: Text(
members[index].stateKey!,
overflow: .ellipsis,
),
leading: AvatarOrHash(
avatarUrl,
height: 36,
displayName ??
members[index]
.stateKey!
.localpart,
_ => throw Exception(
"Member content was not MembershipContent",
),
},
onTap: (index) {
final member = members[index];
if (member.content
case MembershipContent content) {
showModalBottomSheet(
constraints: BoxConstraints.loose(
Size(
500,
(context.size?.height ?? 1000) - 80,
),
),
_ => throw Exception(
"Member content was not MembershipContent",
),
},
onTap: (index) {
final member = members[index];
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,
),
),
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
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);
return Column(
children: [
if (Scaffold.of(context).hasEndDrawer)
AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.pin),
title: Text("Pinned Messages"),
actionsPadding: .only(right: 4),
actions: [
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
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,
),
],
),
return Drawer(
width: 600,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.push_pin),
title: Text("Pinned Messages"),
actionsPadding: .only(right: 4),
actions: [
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close),
tooltip: "Close pinned messages",
),
AsyncData(:final value) => ListView.builder(
padding: .symmetric(vertical: 18),
itemCount: value.length,
itemBuilder: (context, index) {
final event = value[index];
],
),
body: switch (pinsProvider) {
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(
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(
margin: .only(bottom: 8),
child: InkWell(
borderRadius: .circular(12),
onTap: () {
Navigator.pop(context);
// TODO: // Close drawer, then scroll to the message
},
child: Padding(
padding: .all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.push_pin,
size: 16,
color: theme.colorScheme.primary,
return Card(
margin: .only(bottom: 8),
child: InkWell(
borderRadius: .circular(12),
onTap: () {
Navigator.pop(context);
// TODO: // Close drawer, then scroll to the message
},
child: Padding(
padding: .all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.push_pin,
size: 16,
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 {
final bool isDesktop;
final void Function(BuildContext context)? onOpenMemberList;
final void Function(BuildContext context)? onOpenPinnedMessagesList;
final void Function(BuildContext context) onOpenDrawer;
final void Function()? onOpenPinnedMessagesList;
final void Function() onOpenDrawer;
final String? roomId;
const RoomAppbar({
required this.roomId,
@ -103,7 +103,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
height: 24,
fallback: Icon(Icons.numbers),
)
: DrawerButton(onPressed: () => onOpenDrawer(context)),
: DrawerButton(onPressed: onOpenDrawer),
scrolledUnderElevation: 0,
title: room == null
? null
@ -130,7 +130,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
? .new()
: .new([
IconButton(
onPressed: () => onOpenPinnedMessagesList?.call(context),
onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin),
tooltip: "Open pinned messages",
),

View file

@ -62,9 +62,7 @@ class RoomChat extends HookConsumerWidget {
appBar: RoomAppbar(
roomId: this.roomId,
isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: null,
onOpenPinnedMessagesList: null,
onOpenDrawer: () => Scaffold.of(context).openDrawer(),
),
body: nothing,
);
@ -386,149 +384,148 @@ class RoomChat extends HookConsumerWidget {
}
return Scaffold(
appBar: RoomAppbar(
roomId: roomId,
isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: (thisContext) {
memberListOpened.value = !memberListOpened.value;
if (!showMembersByDefault) {
Scaffold.of(thisContext).openEndDrawer();
}
},
onOpenPinnedMessagesList: (thisContext) {
memberListOpened.value = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
Scaffold.of(thisContext).openEndDrawer();
});
},
),
body: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncData(:final value?) ||
AsyncLoading(:final value?) => CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: .symmetric(vertical: 36),
child: Center(
child: ElevatedButton(
onPressed: controllerData is AsyncData
? loadOlder
: null,
child: Text("Load More"),
endDrawer: PinnedEventsDrawer(roomId, getEventOptions: getEventOptions),
body: Builder(
builder: (middleContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId),
appBar: RoomAppbar(
roomId: roomId,
isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: (thisContext) {
memberListOpened.value = !memberListOpened.value;
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: () {
Scaffold.of(middleContext).openEndDrawer();
},
),
body: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncData(:final value?) ||
AsyncLoading(:final value?) => CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: .symmetric(vertical: 36),
child: Center(
child: ElevatedButton(
onPressed: controllerData is AsyncData
? loadOlder
: null,
child: Text("Load More"),
),
),
),
),
),
),
SuperSliverList.builder(
listController: listController.value,
itemCount: value.length,
itemBuilder: (_, index) {
final event = value[index];
final previousEvent = value.getOrNull(index - 1);
return HighlightWrapper(
EventRenderer(
event,
onTapReply: () async {
final replyId = event.replyTo;
listController.value.animateToItem(
index: value.indexWhere(
(element) => element.eventId == replyId,
),
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
highlightedEvent.value = replyId;
await Future.delayed(.new(seconds: 1), () {
if (highlightedEvent.value == replyId) {
highlightedEvent.value = null;
}
});
},
getEventOptions: getEventOptions,
isGrouped:
previousEvent?.content
is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType !=
"m.replace" &&
"${event.sender}${event.pmp?.id}" ==
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
),
isHighlighted:
highlightedEvent.value == event.eventId,
);
},
),
SuperSliverList.builder(
listController: listController.value,
itemCount: value.length,
itemBuilder: (_, index) {
final event = value[index];
final previousEvent = value.getOrNull(
index - 1,
);
return HighlightWrapper(
EventRenderer(
event,
onTapReply: () async {
final replyId = event.replyTo;
listController.value.animateToItem(
index: value.indexWhere(
(element) =>
element.eventId == replyId,
),
scrollController: scrollController,
alignment: 0.5,
duration: (_) =>
.new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
highlightedEvent.value = replyId;
await Future.delayed(
.new(seconds: 1),
() {
if (highlightedEvent.value ==
replyId) {
highlightedEvent.value = null;
}
},
);
},
getEventOptions: getEventOptions,
isGrouped:
previousEvent?.content
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(
padding: .only(bottom: composerSize.value),
SliverPadding(
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)
MemberList(roomId),
],
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),
],
),
),
),
endDrawer: showMembersByDefault && memberListOpened.value
? null
: Drawer(
shape: Border(),
child: memberListOpened.value
? MemberList(roomId)
: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
),
),
);
}
}