Support for pinned messages #49

Merged
Henry-Hiles merged 24 commits from istalri/nexus:support_pinned_messages into main 2026-07-14 19:29:47 -04:00
6 changed files with 94 additions and 13 deletions
Showing only changes of commit 135be17555 - Show all commits

Added a context menu entry for pinning/unpinning

; We can now also send the state event via the setState call.
istalri 2026-07-12 00:18:03 +02:00

View file

@ -94,9 +94,9 @@ A simple and user-friendly Matrix client made with Flutter and a Gomuks backend.
- [x] Editing - [x] Editing
- [x] Deleting - [x] Deleting
- [x] Reactions - [x] Reactions
- [ ] Pins - [x] Pins
- [ ] Displaying - [x] Displaying
- [ ] Creating - [x] Creating
- [ ] Threads - [ ] Threads
- [x] Profile popouts - [x] Profile popouts
- [x] Working actions - [x] Working actions

View file

@ -28,6 +28,7 @@ import "package:nexus/models/requests/report_request.dart";
import "package:nexus/models/requests/send_event_request.dart"; import "package:nexus/models/requests/send_event_request.dart";
import "package:nexus/models/requests/send_message_request.dart"; import "package:nexus/models/requests/send_message_request.dart";
import "package:nexus/models/requests/set_membership_request.dart"; import "package:nexus/models/requests/set_membership_request.dart";
import "package:nexus/models/requests/set_state_request.dart";
import "package:nexus/models/room.dart"; import "package:nexus/models/room.dart";
import "package:nexus/models/sync_data.dart"; import "package:nexus/models/sync_data.dart";
import "package:nexus/src/third_party/gomuks.g.dart"; import "package:nexus/src/third_party/gomuks.g.dart";
@ -172,6 +173,9 @@ class ClientController extends AsyncNotifier<int> {
Future<Event> sendEvent(SendEventRequest request) async => Future<Event> sendEvent(SendEventRequest request) async =>
Event.fromJson(await _sendCommand("send_event", request.toJson())); Event.fromJson(await _sendCommand("send_event", request.toJson()));
Future<Event> setState(SetStateRequest request) async =>
Event.fromJson(await _sendCommand("set_state", request.toJson()));
Future<String?> verify(String recoveryKey) async { Future<String?> verify(String recoveryKey) async {
try { try {
await _sendCommand("verify", {"recovery_key": recoveryKey}); await _sendCommand("verify", {"recovery_key": recoveryKey});

View file

@ -41,22 +41,49 @@ class PinnedEventsController extends AsyncNotifier<IList<Event>> {
return .new(); return .new();
} }
Future<void> togglePin(Event event) async { Future<void> addPin(Event event) async {
final client = ref.read(ClientController.provider.notifier); final client = ref.read(ClientController.provider.notifier);
final current = await future; final current = await future;
final updatedPinnedIds = current.any((e) => e.eventId == event.eventId) if (current.any((ev) => ev.eventId == event.eventId)) {
? current return;
.where((e) => e.eventId != event.eventId) }
.map((e) => e.eventId)
.toList()
: [...current.map((e) => e.eventId), event.eventId];
await client.sendEvent( final updatedPinnedIds = [
...current.map((ev) => ev.eventId),
event.eventId,
];
await client.setState(
.new( .new(
roomId: roomId, roomId: roomId,
type: EventType.pinnedEvents.type, type: EventType.pinnedEvents.type,
stateKey: "", stateKey:
"", //The empty string stands for m.room.pinned_events according to matrix spec.
content: {"pinned": updatedPinnedIds},
),
);
}
istalri marked this conversation as resolved Outdated

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.
Future<void> removePin(Event event) async {
final client = ref.read(ClientController.provider.notifier);
final current = await future;
if (!current.any((ev) => ev.eventId == event.eventId)) {
return;
}
final updatedPinnedIds = [
...current
.where((ev) => ev.eventId != event.eventId)
.map((ev) => ev.eventId),
];
await client.setState(
.new(
roomId: roomId,
type: EventType.pinnedEvents.type,
stateKey: "", //The empty string stands for m.room.pinned_events according to matrix spec.
content: {"pinned": updatedPinnedIds}, content: {"pinned": updatedPinnedIds},
), ),
); );

View file

@ -8,7 +8,6 @@ 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,
@Default(false) bool synchronous, @Default(false) bool synchronous,
@Default(false) bool disableEncryption, @Default(false) bool disableEncryption,
}) = _SendEventRequest; }) = _SendEventRequest;

View file

@ -0,0 +1,17 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "set_state_request.freezed.dart";
part "set_state_request.g.dart";
@freezed
abstract class SetStateRequest with _$SetStateRequest {
const factory SetStateRequest({
required String roomId,
required String type,
required String stateKey,
required Map<String, dynamic> content,
@Default(0) int delayMs,
}) = _SetStateRequest;
factory SetStateRequest.fromJson(Map<String, Object?> json) =>
_$SetStateRequestFromJson(json);
}

View file

@ -7,6 +7,7 @@ import "package:measure_size/measure_size.dart";
import "package:nexus/controllers/account_data_controller.dart"; import "package:nexus/controllers/account_data_controller.dart";
import "package:nexus/controllers/client_controller.dart"; import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/client_state_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/pinned_events_controller.dart";
import "package:nexus/controllers/power_level_controller.dart"; import "package:nexus/controllers/power_level_controller.dart";
import "package:nexus/controllers/rooms_controller.dart"; import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart";
@ -249,6 +250,39 @@ class RoomChat extends HookConsumerWidget {
}, },
child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")), child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
), ),
if (ref.watch(
PowerLevelController.provider(
PowerLevelConfig(eventType: .pinnedEvents, roomId: roomId),
),
))
() {
final isPinned = ref
.watch(PinnedEventsController.provider(roomId))
.maybeWhen(
data: (pinnedEvents) =>
pinnedEvents.any((ev) => ev.eventId == event.eventId),
orElse: () => false,
);
return PopupMenuItem(
onTap: () async {
final notifier = ref.read(
PinnedEventsController.provider(roomId).notifier,
);
if (isPinned) {
await notifier.removePin(event);
} else {
await notifier.addPin(event);
}
},
child: ListTile(
leading: Icon(
isPinned ? Icons.push_pin_outlined : Icons.push_pin,
),
title: Text(isPinned ? "Unpin Event" : "Pin Event"),
),
);
}(),
PopupMenuItem( PopupMenuItem(
onTap: () async { onTap: () async {
final room = ref.watch( final room = ref.watch(