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 roomId,
required String type, required String type,
required Map<String, dynamic> content, 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 synchronous,
@Default(false) bool disableEncryption, @Default(false) bool disableEncryption,
}) = _SendEventRequest; }) = _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 { 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(BuildContext context) onOpenDrawer; final void Function(BuildContext context) onOpenDrawer;
final String? roomId; final String? roomId;
const RoomAppbar({ const RoomAppbar({
@ -19,6 +20,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
required this.isDesktop, required this.isDesktop,
required this.onOpenDrawer, required this.onOpenDrawer,
this.onOpenMemberList, this.onOpenMemberList,
this.onOpenPinnedMessagesList,
super.key, super.key,
}); });
@ -128,7 +130,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
? .new() ? .new()
: .new([ : .new([
IconButton( IconButton(
onPressed: null, onPressed: () => onOpenPinnedMessagesList?.call(context),
icon: Icon(Icons.push_pin), icon: Icon(Icons.push_pin),
tooltip: "Open pinned messages", 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/models/relation_type.dart";
import "package:nexus/widgets/composer/composer.dart"; import "package:nexus/widgets/composer/composer.dart";
import "package:nexus/widgets/emoji_picker_button.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/renderers/event.dart";
import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/room_appbar.dart";
@ -63,6 +64,7 @@ class RoomChat extends HookConsumerWidget {
isDesktop: isDesktop, isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: null, onOpenMemberList: null,
onOpenPinnedMessagesList: null,
), ),
body: nothing, body: nothing,
); );
@ -390,7 +392,15 @@ class RoomChat extends HookConsumerWidget {
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenMemberList: (thisContext) { onOpenMemberList: (thisContext) {
memberListOpened.value = !memberListOpened.value; memberListOpened.value = !memberListOpened.value;
Scaffold.of(thisContext).openEndDrawer(); if (!showMembersByDefault) {
Scaffold.of(thisContext).openEndDrawer();
}
},
onOpenPinnedMessagesList: (thisContext) {
memberListOpened.value = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
Scaffold.of(thisContext).openEndDrawer();
});
}, },
), ),
body: Row( body: Row(
@ -508,7 +518,14 @@ class RoomChat extends HookConsumerWidget {
MemberList(roomId), MemberList(roomId),
], ],
), ),
endDrawer: showMembersByDefault ? null : MemberList(roomId), endDrawer: showMembersByDefault && memberListOpened.value
? null
: Drawer(
shape: Border(),
child: memberListOpened.value
? MemberList(roomId)
: PinnedMessages(roomId, getEventOptions: getEventOptions),
),
); );
} }
} }