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] 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";
@ -159,7 +160,10 @@ class ClientController extends AsyncNotifier<int> {
calloc.free(bufferPointer); calloc.free(bufferPointer);
final json = response.buf.toJson(); final json = response.buf.toJson();
if (json is String) throw json; if (response.command.cast<Utf8>().toDartString() == "error") {
throw json;
}
return json; return json;
} }
@ -169,8 +173,27 @@ class ClientController extends AsyncNotifier<int> {
Future<Event> sendMessage(SendMessageRequest request) async => Future<Event> sendMessage(SendMessageRequest request) async =>
Event.fromJson(await _sendCommand("send_message", request.toJson())); Event.fromJson(await _sendCommand("send_message", request.toJson()));
Future<Event> sendEvent(SendEventRequest request) async => Future<Event> sendEvent(SendEventRequest request) async {
Event.fromJson(await _sendCommand("send_event", request.toJson())); 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 { Future<String?> verify(String recoveryKey) async {
try { 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( .new(
roomId: roomId, roomId: roomId,
type: EventType.reaction.type, type: EventType.reaction.type,
content: { content: ReactionContent(key: reaction),
"m.relates_to": {
"event_id": event.eventId,
"rel_type": "m.annotation",
"key": reaction,
},
},
synchronous: true, synchronous: true,
disableEncryption: true, disableEncryption: true,
relatesTo: event.eventId,
relationType: "m.annotation",
), ),
); );
} }

View file

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

View file

@ -7,8 +7,9 @@ part "pinned_events.g.dart";
@freezed @freezed
abstract class PinnedEventsContent extends Content with _$PinnedEventsContent { abstract class PinnedEventsContent extends Content with _$PinnedEventsContent {
PinnedEventsContent._(); PinnedEventsContent._();
factory PinnedEventsContent({@Default(IList.empty()) IList<String> pinned}) = factory PinnedEventsContent({
_PinnedEventsContent; @Default(IList.empty()) @JsonKey(name: "pinned") IList<String> pinnedEvents,
}) = _PinnedEventsContent;
factory PinnedEventsContent.fromJson(Map<String, Object?> json) => factory PinnedEventsContent.fromJson(Map<String, Object?> json) =>
_$PinnedEventsContentFromJson(json); _$PinnedEventsContentFromJson(json);

View file

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

View file

@ -1,4 +1,5 @@
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/content/content.dart";
part "send_event_request.freezed.dart"; part "send_event_request.freezed.dart";
part "send_event_request.g.dart"; part "send_event_request.g.dart";
@ -7,7 +8,9 @@ abstract class SendEventRequest with _$SendEventRequest {
const factory SendEventRequest({ const factory SendEventRequest({
required String roomId, required String roomId,
required String type, required String type,
required Map<String, dynamic> content, required Content content,
String? relatesTo,
String? relationType,
@Default(false) bool synchronous, @Default(false) bool synchronous,
@Default(false) bool disableEncryption, @Default(false) bool disableEncryption,
}) = _SendEventRequest; }) = _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( return Drawer(
shape: Border(), shape: Border(),
child: Column( child: Scaffold(
backgroundColor: Colors.transparent,
body: Column(
children: [ 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(
padding: .symmetric(vertical: 8), padding: .symmetric(vertical: 8),
child: M3EToggleButtonGroup( child: M3EToggleButtonGroup(
@ -155,8 +143,8 @@ class MemberList extends HookConsumerWidget {
if (member.content if (member.content
case MembershipContent content) { case MembershipContent content) {
showModalBottomSheet( showModalBottomSheet(
constraints: BoxConstraints.loose( constraints: .loose(
Size( .new(
500, 500,
(context.size?.height ?? 1000) - 80, (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, spacing: 8,
mainAxisSize: .min, mainAxisSize: .min,
children: [ children: [
Text("Unknown message type:", style: errorStyle), Text(
"Unknown message type:",
style: errorStyle,
),
Text(body), Text(body),
], ],
), ),

View file

@ -12,13 +12,15 @@ 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) onOpenDrawer; final void Function()? onOpenPinnedMessagesList;
final void Function() onOpenDrawer;
final String? roomId; final String? roomId;
const RoomAppbar({ const RoomAppbar({
required this.roomId, required this.roomId,
required this.isDesktop, required this.isDesktop,
required this.onOpenDrawer, required this.onOpenDrawer,
this.onOpenMemberList, this.onOpenMemberList,
this.onOpenPinnedMessagesList,
super.key, super.key,
}); });
@ -101,7 +103,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
height: 24, height: 24,
fallback: Icon(Icons.numbers), fallback: Icon(Icons.numbers),
) )
: DrawerButton(onPressed: () => onOpenDrawer(context)), : DrawerButton(onPressed: onOpenDrawer),
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
title: room == null title: room == null
? null ? null
@ -128,7 +130,7 @@ class RoomAppbar extends ConsumerWidget implements PreferredSizeWidget {
? .new() ? .new()
: .new([ : .new([
IconButton( IconButton(
onPressed: null, onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin), icon: Icon(Icons.push_pin),
tooltip: "Open pinned messages", 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/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_ids_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";
import "package:nexus/controllers/via_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/content/message.dart";
import "package:nexus/models/event.dart"; 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_events_drawer.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";
@ -61,8 +62,7 @@ class RoomChat extends HookConsumerWidget {
appBar: RoomAppbar( appBar: RoomAppbar(
roomId: this.roomId, roomId: this.roomId,
isDesktop: isDesktop, isDesktop: isDesktop,
onOpenDrawer: (_) => Scaffold.of(context).openDrawer(), onOpenDrawer: () => Scaffold.of(context).openDrawer(),
onOpenMemberList: null,
), ),
body: nothing, body: nothing,
); );
@ -81,6 +81,27 @@ class RoomChat extends HookConsumerWidget {
final topEventBeforeLoad = useState<String?>(null); 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 { Future<void> loadOlder() async {
if (controllerData case AsyncData(:final value?)) { if (controllerData case AsyncData(:final value?)) {
topEventBeforeLoad.value = value.firstOrNull?.eventId; topEventBeforeLoad.value = value.firstOrNull?.eventId;
@ -177,6 +198,7 @@ class RoomChat extends HookConsumerWidget {
IList<PopupMenuEntry> getEventOptions(Event event) { IList<PopupMenuEntry> getEventOptions(Event event) {
final danger = theme.colorScheme.error; final danger = theme.colorScheme.error;
final isSentByMe = event.sender == userId; final isSentByMe = event.sender == userId;
return [ return [
if (ref.watch( if (ref.watch(
PowerLevelController.provider( PowerLevelController.provider(
@ -229,7 +251,7 @@ class RoomChat extends HookConsumerWidget {
), ),
if (ref.watch( if (ref.watch(
PowerLevelController.provider( PowerLevelController.provider(
PowerLevelConfig(eventType: .message, roomId: roomId), .new(eventType: .message, roomId: roomId),
), ),
)) ))
PopupMenuItem( PopupMenuItem(
@ -249,6 +271,35 @@ 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(
.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( PopupMenuItem(
onTap: () async { onTap: () async {
final room = ref.watch( final room = ref.watch(
@ -384,14 +435,25 @@ class RoomChat extends HookConsumerWidget {
} }
return Scaffold( return Scaffold(
endDrawer: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToId: jumpToId,
),
body: Builder(
builder: (middleContext) => Scaffold(
endDrawer: showMembersByDefault ? null : MemberList(roomId),
appBar: RoomAppbar( appBar: RoomAppbar(
roomId: roomId, roomId: roomId,
isDesktop: isDesktop, isDesktop: isDesktop,
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(); Scaffold.of(thisContext).openEndDrawer();
}, },
onOpenPinnedMessagesList: () {
Scaffold.of(middleContext).openEndDrawer();
},
), ),
body: Row( body: Row(
children: [ children: [
@ -425,28 +487,14 @@ class RoomChat extends HookConsumerWidget {
itemCount: value.length, itemCount: value.length,
itemBuilder: (_, index) { itemBuilder: (_, index) {
final event = value[index]; final event = value[index];
final previousEvent = value.getOrNull(index - 1); final previousEvent = value.getOrNull(
index - 1,
);
return HighlightWrapper( return HighlightWrapper(
EventRenderer( EventRenderer(
event, event,
onTapReply: () async { onTapReply: () =>
final replyId = event.replyTo; jumpToId(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;
}
});
},
getEventOptions: getEventOptions, getEventOptions: getEventOptions,
isGrouped: isGrouped:
previousEvent?.content previousEvent?.content
@ -484,7 +532,8 @@ class RoomChat extends HookConsumerWidget {
child: Composer( child: Composer(
roomId, roomId,
node: composerNode, node: composerNode,
onSend: (text, {required shouldMention, required tags}) => onSend:
(text, {required shouldMention, required tags}) =>
notifier notifier
.send( .send(
text, text,
@ -508,7 +557,8 @@ class RoomChat extends HookConsumerWidget {
MemberList(roomId), MemberList(roomId),
], ],
), ),
endDrawer: showMembersByDefault ? null : MemberList(roomId), ),
),
); );
} }
} }