WIP: Support for pinned messages #49

Draft
istalri wants to merge 9 commits from istalri/nexus:support_pinned_messages into main
7 changed files with 433 additions and 254 deletions

View file

@ -0,0 +1,69 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/event_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/content/pinned_events.dart";
import "package:nexus/models/event.dart";
class PinnedEventsController extends AsyncNotifier<IList<Event>> {
final String roomId;
PinnedEventsController(this.roomId);
@override
Future<IList<Event>> build() async {
final room = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room == null) return .new();
final pinnedRowId = room.state[EventType.pinnedEvents.type]?[""];
final pinnedStateEvent = pinnedRowId == null
? null
: room.events[pinnedRowId];
if (pinnedStateEvent == null) return .new();
if (pinnedStateEvent.content case PinnedEventsContent content) {
return (await Future.wait(
content.pinnedEvents.map(
(eventId) => ref.watch(
EventController.provider(
.new(eventId: eventId, roomId: roomId),
).future,
),
),
)).nonNulls.toIList();
}
return .new();
}
Future<void> togglePin(Event event) async {
final client = ref.read(ClientController.provider.notifier);
final current = await future;
final updatedPinnedIds = current.any((e) => e.eventId == event.eventId)
? current
.where((e) => e.eventId != event.eventId)
.map((e) => e.eventId)
.toList()
: [...current.map((e) => e.eventId), event.eventId];
await client.sendEvent(
.new(
roomId: roomId,
type: EventType.pinnedEvents.type,
stateKey: "",
content: {"pinned": updatedPinnedIds},
),
);
}
static final provider = AsyncNotifierProvider.family
.autoDispose<PinnedEventsController, IList<Event>, String>(
PinnedEventsController.new,

I think I'd prefer a removePin and addPin, separate methods. And of course this needs to be called from the context menu. The context menu should have an option of "Pin" if the event is not pinned (not present in pinned events controller), and you have permission to pin, or "Unpin" if the event IS pinned, and you have permission to pin.

I think I'd prefer a `removePin` and `addPin`, separate methods. And of course this needs to be called from the context menu. The context menu should have an option of "Pin" if the event is not pinned (not present in pinned events controller), and you have permission to pin, or "Unpin" if the event IS pinned, and you have permission to pin.

Yeah, makes sense, seperation of concern is a useful standard. This would make it cleaner.

Yeah, makes sense, seperation of concern is a useful standard. This would make it cleaner.
);
}

View file

@ -7,8 +7,9 @@ part "pinned_events.g.dart";
@freezed
abstract class PinnedEventsContent extends Content with _$PinnedEventsContent {
PinnedEventsContent._();
factory PinnedEventsContent({@Default(IList.empty()) IList<String> pinned}) =
_PinnedEventsContent;
factory PinnedEventsContent({
@Default(IList.empty()) @JsonKey(name: "pinned") IList<String> pinnedEvents,
}) = _PinnedEventsContent;
factory PinnedEventsContent.fromJson(Map<String, Object?> json) =>
_$PinnedEventsContentFromJson(json);

View file

@ -8,6 +8,7 @@ abstract class SendEventRequest with _$SendEventRequest {
required String roomId,
required String type,
required Map<String, dynamic> content,
@Default(null) String? stateKey,

Not certain where you got this from... It's not valid: https://spec.mau.fi/gomuks/rpc.html#cmd-send-event

Instead, set_state must be used. This will need its own function in ClientController.

Not certain where you got this from... It's not valid: https://spec.mau.fi/gomuks/rpc.html#cmd-send-event Instead, [`set_state`](https://spec.mau.fi/gomuks/rpc.html#cmd-set-state) must be used. This will need its own function in `ClientController`.

Oh then I understood something wrong. I though it was needed for this:

Matrix Spec:

{
"content": {
"pinned": [
"$someevent:example.org"
]
},
"event_id": "$143273582443PhrSn:example.org",
"origin_server_ts": 1432735824653,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"state_key": "",
"type": "m.room.pinned_events",
"unsigned": {
"age": 1234,
"membership": "join"
}
}

Oh then I understood something wrong. I though it was needed for this: Matrix Spec: > { > "content": { > "pinned": [ > "$someevent:example.org" > ] > }, > "event_id": "$143273582443PhrSn:example.org", > "origin_server_ts": 1432735824653, > "room_id": "!jEsUZKDJdhlrceRyVU:example.org", > "sender": "@example:example.org", > "state_key": "", > "type": "m.room.pinned_events", > "unsigned": { > "age": 1234, > "membership": "join" > } > }

Ah, well eventually it gets to Matrix, but we actually use gomuks as an SDK essentially, so we use that to e.g. send events. You can reference their docs for a schema of all commands: https://spec.mau.fi/gomuks/rpc.html

Ah, well eventually it gets to Matrix, but we actually use gomuks as an SDK essentially, so we use that to e.g. send events. You can reference their docs for a schema of all commands: https://spec.mau.fi/gomuks/rpc.html

Okay, I see. I will look into it.

Edit: Just read the gomuks spec, seems the set_state is straight forward. At first I was not sure why there needs to be multiple endpoints for events but I then it clicked and yeah it makes sense to separate state events from other events. Although I think it's slightly misleading to have send_event and set_state and not send_state_event or the other way around have also set_reaction etc but maybe that's just me.

Okay, I see. I will look into it. Edit: Just read the gomuks spec, seems the `set_state` is straight forward. At first I was not sure why there needs to be multiple endpoints for events but I then it clicked and yeah it makes sense to separate state events from other events. Although I think it's slightly misleading to have `send_event` and `set_state` and not `send_state_event` or the other way around have also `set_reaction` etc but maybe that's just me.
@Default(false) bool synchronous,
@Default(false) bool disableEncryption,
}) = _SendEventRequest;

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

@ -0,0 +1,85 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/pinned_events_controller.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/renderers/event.dart";
class PinnedEventsDrawer extends HookConsumerWidget {
final String roomId;
final IList<PopupMenuEntry> Function(Event event)? getEventOptions;
const PinnedEventsDrawer(this.roomId, {this.getEventOptions, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pinsProvider = ref.watch(PinnedEventsController.provider(roomId));
final theme = Theme.of(context);
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",
),
],
),
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,
),
],
),
),
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`.
AsyncData(:final value) ||
AsyncLoading(:final value?) => ListView.builder(
padding: .symmetric(horizontal: 8),
itemCount: value.length,
itemBuilder: (context, index) {
final event = value[index];
return InkWell(
borderRadius: .circular(12),
onTap: () {
Navigator.pop(context);
// TODO: // Close drawer, then scroll to the message
},
child: EventRenderer(
event,
maxLines: 2,
isGrouped: false,
getEventOptions: getEventOptions,
),
);
},
),
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
),
);
}
}

View file

@ -12,13 +12,15 @@ 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) onOpenDrawer;
final void Function()? onOpenPinnedMessagesList;
final void Function() onOpenDrawer;
final String? roomId;
const RoomAppbar({
required this.roomId,
required this.isDesktop,
required this.onOpenDrawer,
this.onOpenMemberList,
this.onOpenPinnedMessagesList,
super.key,
});
@ -101,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
@ -128,7 +130,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
? .new()
: .new([
IconButton(
onPressed: null,
onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin),
tooltip: "Open pinned messages",
),

View file

@ -17,6 +17,7 @@ import "package:nexus/models/event.dart";
import "package:nexus/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.dart";
import "package:nexus/widgets/emoji_picker_button.dart";
import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart";
@ -61,8 +62,7 @@ class RoomChat extends HookConsumerWidget {
appBar: RoomAppbar(
roomId: this.roomId,
isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: null,
onOpenDrawer: () => Scaffold.of(context).openDrawer(),
),
body: nothing,
);
@ -384,131 +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;
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 ? null : MemberList(roomId),
);
}
}