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
15 changed files with 570 additions and 279 deletions

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";
@ -159,7 +160,10 @@ class ClientController extends AsyncNotifier<int> {
calloc.free(bufferPointer);
final json = response.buf.toJson();
if (json is String) throw json;
if (response.command.cast<Utf8>().toDartString() == "error") {
throw json;
}
return json;
}
@ -169,8 +173,27 @@ class ClientController extends AsyncNotifier<int> {
Future<Event> sendMessage(SendMessageRequest request) async =>
Event.fromJson(await _sendCommand("send_message", request.toJson()));
Future<Event> sendEvent(SendEventRequest request) async =>
Event.fromJson(await _sendCommand("send_event", request.toJson()));
Future<Event> sendEvent(SendEventRequest request) async {
final json = request.toJson();
final content = request.content.toJson();
return Event.fromJson(
await _sendCommand("send_event", {
...json,
"content": {
...content,
"m.relates_to": {
...((content["m.relates_to"] as Map<String, dynamic>?) ?? {}),
"event_id": request.relatesTo,
"rel_type": request.relationType,
},
},
}),
);
}
Future<String?> setState(SetStateRequest request) async =>
await _sendCommand("set_state", request.toJson());
Future<String?> verify(String recoveryKey) async {
try {

View file

@ -0,0 +1,30 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/event_controller.dart";
import "package:nexus/controllers/pinned_ids_controller.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 pinIds = ref.watch(PinnedIdsController.provider(roomId));
return (await Future.wait(
pinIds.map(
(eventId) => ref.watch(
EventController.provider(
.new(eventId: eventId, roomId: roomId),
).future,
),
),
)).nonNulls.toIList();
}
static final provider = AsyncNotifierProvider.family
.autoDispose<PinnedEventsController, IList<Event>, String>(
PinnedEventsController.new,
);
}

View file

@ -0,0 +1,53 @@
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";
class PinnedIdsController extends Notifier<IList<String>> {
final String roomId;
PinnedIdsController(this.roomId);
@override
IList<String> build() {
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?.content case PinnedEventsContent content) {
return content.pinnedEvents;
}
return .new();
}
Future<void> addPin(String eventId) async =>
setPinned(.new(pinnedEvents: .new(state.add(eventId))));
Future<void> removePin(String eventId) async =>
setPinned(.new(pinnedEvents: .new(state.remove(eventId))));
Future<void> setPinned(PinnedEventsContent content) => ref
.read(ClientController.provider.notifier)
.setState(
.new(
roomId: roomId,
type: EventType.pinnedEvents.type,
stateKey: "",
content: content,
),
);
static final provider = NotifierProvider.family
.autoDispose<PinnedIdsController, IList<String>, String>(
PinnedIdsController.new,
);
}

View file

@ -205,15 +205,11 @@ class RoomChatController extends AsyncNotifier<IList<Event>?> {
.new(
roomId: roomId,
type: EventType.reaction.type,
content: {
"m.relates_to": {
"event_id": event.eventId,
"rel_type": "m.annotation",
"key": reaction,
},
},
content: ReactionContent(key: reaction),
synchronous: true,
disableEncryption: true,
relatesTo: event.eventId,
relationType: "m.annotation",
),
);
}

View file

@ -10,7 +10,7 @@ part "message.g.dart";
@Freezed(unionKey: "msgtype", fallbackUnion: "default")
abstract class MessageContent extends Content with _$MessageContent {
MessageContent._();
factory MessageContent({required String body}) = UnknownMessageContent;
factory MessageContent({String? body}) = UnknownMessageContent;
@FreezedUnionValue("m.text")
factory MessageContent.text({

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

@ -3,7 +3,7 @@ import "package:nexus/models/content/content.dart";
part "reaction.freezed.dart";
part "reaction.g.dart";
@freezed
@Freezed(toJson: false)
abstract class ReactionContent extends Content with _$ReactionContent {
ReactionContent._();
static String? keyJsonFromJson(Map<dynamic, dynamic> json, String key) =>
@ -13,6 +13,11 @@ abstract class ReactionContent extends Content with _$ReactionContent {
@JsonKey(readValue: ReactionContent.keyJsonFromJson) String? key,
}) = _ReactionContent;
@override
Map<String, dynamic> toJson() => {
"m.relates_to": {"key": key},
};
factory ReactionContent.fromJson(Map<String, Object?> json) =>
_$ReactionContentFromJson(json);
}

View file

@ -1,4 +1,5 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
part "send_event_request.freezed.dart";
part "send_event_request.g.dart";
@ -7,7 +8,9 @@ abstract class SendEventRequest with _$SendEventRequest {
const factory SendEventRequest({
required String roomId,
required String type,
required Map<String, dynamic> content,
required Content content,
String? relatesTo,
String? relationType,
@Default(false) bool synchronous,
@Default(false) bool disableEncryption,
}) = _SendEventRequest;

View file

@ -0,0 +1,23 @@
import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
import "package:nexus/models/ms_duration.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 Content content,
@JsonKey(name: "delay_ms", includeIfNull: false)
@MSDuration()
@Default(null)
Duration? delay,
}) = _SetStateRequest;
factory SetStateRequest.fromJson(Map<String, Object?> json) =>
_$SetStateRequestFromJson(json);
}

View file

@ -33,22 +33,10 @@ class MemberList extends HookConsumerWidget {
return Drawer(
shape: Border(),
child: Column(
child: Scaffold(
backgroundColor: Colors.transparent,
body: 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(
@ -155,8 +143,8 @@ class MemberList extends HookConsumerWidget {
if (member.content
case MembershipContent content) {
showModalBottomSheet(
constraints: BoxConstraints.loose(
Size(
constraints: .loose(
.new(
500,
(context.size?.height ?? 1000) - 80,
),
@ -180,6 +168,22 @@ class MemberList extends HookConsumerWidget {
},
],
),
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,92 @@
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;
final Future<void> Function(String eventId) jumpToId;
const PinnedEventsDrawer(
this.roomId, {
required this.getEventOptions,
required this.jumpToId,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pinsProvider = ref.watch(PinnedEventsController.provider(roomId));
final theme = Theme.of(context);
return Drawer(
width: 400,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.push_pin),
title: Text("Pinned Events"),
actionsPadding: .only(right: 4),
actions: [
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close),
tooltip: "Close pinned events",
),
],
),
body: switch (pinsProvider) {
AsyncData(:final value) when value.isEmpty => Center(
child: Column(
mainAxisSize: .min,
children: [
Icon(
Icons.push_pin_outlined,
size: 48,
color: theme.colorScheme.onSurface,
),
SizedBox(height: 12),
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`.
Text("No pinned events", style: theme.textTheme.headlineSmall),
],
),
),
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);
jumpToId(event.eventId);
},
child: Padding(
padding: .symmetric(vertical: 4),
child: EventRenderer(
event,
maxLines: 2,
isGrouped: false,
getEventOptions: getEventOptions,
),
),
);
},
),
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
),
);
}
}

View file

@ -276,11 +276,20 @@ class MessageRenderer extends ConsumerWidget {
],
],
),
MessageContent(:final body) => Row(
MessageContent(:final body) =>
body == null
? Text(
"This message is redacted",
style: errorStyle,
)
: Row(
spacing: 8,
mainAxisSize: .min,
children: [
Text("Unknown message type:", style: errorStyle),
Text(
"Unknown message type:",
style: errorStyle,
),
Text(body),
],
),

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

@ -7,16 +7,17 @@ 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_ids_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";
import "package:nexus/controllers/via_controller.dart";
import "package:nexus/models/configs/power_level_config.dart";
import "package:nexus/models/content/message.dart";
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,
);
@ -81,6 +81,27 @@ class RoomChat extends HookConsumerWidget {
final topEventBeforeLoad = useState<String?>(null);
Future<void> jumpToId(String eventId) async {
final index = controllerData.value?.indexWhere(
(element) => element.eventId == eventId,
);
if (index == null) return;
listController.value.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut,
);
highlightedEvent.value = eventId;
await Future.delayed(.new(seconds: 1), () {
if (highlightedEvent.value == eventId) {
highlightedEvent.value = null;
}
});
}
Future<void> loadOlder() async {
if (controllerData case AsyncData(:final value?)) {
topEventBeforeLoad.value = value.firstOrNull?.eventId;
@ -177,6 +198,7 @@ class RoomChat extends HookConsumerWidget {
IList<PopupMenuEntry> getEventOptions(Event event) {
final danger = theme.colorScheme.error;
final isSentByMe = event.sender == userId;
return [
if (ref.watch(
PowerLevelController.provider(
@ -229,7 +251,7 @@ class RoomChat extends HookConsumerWidget {
),
if (ref.watch(
PowerLevelController.provider(
PowerLevelConfig(eventType: .message, roomId: roomId),
.new(eventType: .message, roomId: roomId),
),
))
PopupMenuItem(
@ -249,6 +271,35 @@ class RoomChat extends HookConsumerWidget {
},
child: ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
),
if (ref.watch(
PowerLevelController.provider(
.state(eventType: .pinnedEvents, roomId: roomId),
),
))
switch (ref
.watch(PinnedIdsController.provider(roomId))
.contains(event.eventId)) {
bool isPinned => PopupMenuItem(
onTap: () async {
try {
final notifier = ref.read(
PinnedIdsController.provider(roomId).notifier,
);
if (isPinned) {
await notifier.removePin(event.eventId);
} else {
await notifier.addPin(event.eventId);
}
} catch (error, stackTrace) {
showError(error, stackTrace);
}
},
child: ListTile(
leading: Icon(Icons.push_pin),
title: Text(isPinned == true ? "Unpin Event" : "Pin Event"),
),
),
},
PopupMenuItem(
onTap: () async {
final room = ref.watch(
@ -384,14 +435,25 @@ class RoomChat extends HookConsumerWidget {
}
return Scaffold(
endDrawer: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToId: jumpToId,
),
body: Builder(
builder: (middleContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId),
appBar: RoomAppbar(
roomId: roomId,
isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(),
onOpenDrawer: Scaffold.of(context).openDrawer,
onOpenMemberList: (thisContext) {
memberListOpened.value = !memberListOpened.value;
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: () {
Scaffold.of(middleContext).openEndDrawer();
},
),
body: Row(
children: [
@ -425,28 +487,14 @@ class RoomChat extends HookConsumerWidget {
itemCount: value.length,
itemBuilder: (_, index) {
final event = value[index];
final previousEvent = value.getOrNull(index - 1);
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;
}
});
},
onTapReply: () =>
jumpToId(event.replyTo!),
getEventOptions: getEventOptions,
isGrouped:
previousEvent?.content
@ -484,7 +532,8 @@ class RoomChat extends HookConsumerWidget {
child: Composer(
roomId,
node: composerNode,
onSend: (text, {required shouldMention, required tags}) =>
onSend:
(text, {required shouldMention, required tags}) =>
notifier
.send(
text,
@ -508,7 +557,8 @@ class RoomChat extends HookConsumerWidget {
MemberList(roomId),
],
),
endDrawer: showMembersByDefault ? null : MemberList(roomId),
),
),
);
}
}