WIP: Support for pinned messages #49

Draft
istalri wants to merge 9 commits from istalri/nexus:support_pinned_messages into main
5 changed files with 224 additions and 3 deletions
Showing only changes of commit a2d2aeca7a - Show all commits

First Draft: Support for pinned messages.

istalri 2026-07-07 00:54:40 +02:00

View file

@ -0,0 +1,92 @@
import "package:collection/collection.dart";
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/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 RoomPinsController extends AsyncNotifier<IList<Event>> {
final String roomId;
RoomPinsController(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();
final pinnedContent = switch (pinnedStateEvent.content) {
PinnedEventsContent content => content,
_ => null,
};
if (pinnedContent == null || pinnedContent.pinned.isEmpty) {
return .new();
}
final localEvents = <Event>[];
final missingIds = <String>[];
for (final eventId in pinnedContent.pinned) {
final localEvent = room.events.values.firstWhereOrNull(
(e) => e.eventId == eventId,
);
if (localEvent != null) {
localEvents.add(localEvent);
} else {
missingIds.add(eventId);
}
}
if (missingIds.isNotEmpty) {
final client = ref.read(ClientController.provider.notifier);
final fetched = await Future.wait(
missingIds.map(
(id) => client.getEvent(.new(roomId: roomId, eventId: id)),
),
);
localEvents.addAll(fetched.whereType<Event>());
}
return localEvents.toIList();
}
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<RoomPinsController, IList<Event>, String>(
RoomPinsController.new,
);
}

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

@ -0,0 +1,109 @@
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/room_pins_controller.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/renderers/event.dart";
class PinnedMessages extends HookConsumerWidget {
final String roomId;
final IList<PopupMenuEntry> Function(Event event)? getEventOptions;
const PinnedMessages(this.roomId, {this.getEventOptions, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pinsAsync = ref.watch(RoomPinsController.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,
),
],
),
),
AsyncData(:final value) => ListView.builder(
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,
),
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"),
],
),
),
},
),
],
);
}
}

View file

@ -12,6 +12,7 @@ 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 String? roomId;
const RoomAppbar({
@ -19,6 +20,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
required this.isDesktop,
required this.onOpenDrawer,
this.onOpenMemberList,
this.onOpenPinnedMessagesList,
super.key,
});
@ -128,7 +130,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
? .new()
: .new([
IconButton(
onPressed: null,
onPressed: () => onOpenPinnedMessagesList?.call(context),
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_messages.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart";
@ -63,6 +64,7 @@ class RoomChat extends HookConsumerWidget {
isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: null,
onOpenPinnedMessagesList: null,
),
body: nothing,
);
@ -390,7 +392,15 @@ class RoomChat extends HookConsumerWidget {
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(
@ -508,7 +518,14 @@ class RoomChat extends HookConsumerWidget {
MemberList(roomId),
],
),
endDrawer: showMembersByDefault ? null : MemberList(roomId),
endDrawer: showMembersByDefault && memberListOpened.value
? null
: Drawer(
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.
shape: Border(),
child: memberListOpened.value
? MemberList(roomId)
: PinnedMessages(roomId, getEventOptions: getEventOptions),
),
);
}
}