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] Deleting
- [x] Reactions
- [ ] Pins
- [ ] Displaying
- [ ] Creating
- [x] Pins
- [x] Displaying
- [x] Creating
- [ ] Threads
- [x] Profile popouts
- [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_message_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/sync_data.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 =>
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 {
try {
await _sendCommand("verify", {"recovery_key": recoveryKey});

View file

@ -41,22 +41,49 @@ class PinnedEventsController extends AsyncNotifier<IList<Event>> {
return .new();
}
Future<void> togglePin(Event event) async {
Future<void> addPin(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];
if (current.any((ev) => ev.eventId == event.eventId)) {
return;
}
await client.sendEvent(
final updatedPinnedIds = [
...current.map((ev) => ev.eventId),
event.eventId,
];
await client.setState(
.new(
roomId: roomId,
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},
),
);

View file

@ -8,7 +8,6 @@ abstract class SendEventRequest with _$SendEventRequest {
required String roomId,
required String type,
required Map<String, dynamic> content,
@Default(null) String? stateKey,
@Default(false) bool synchronous,
@Default(false) bool disableEncryption,
}) = _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/client_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/rooms_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")),
),
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(
onTap: () async {
final room = ref.watch(