refactor chatview

This commit is contained in:
Henry Hiles 2026-09-24 14:24:41 -04:00
commit 6ad4657d76
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
5 changed files with 622 additions and 593 deletions

View file

@ -0,0 +1,256 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/pinned_ids.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/recent_emoji.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/via.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/emoji_picker.dart";
import "package:nexus/main.dart";
IList<PopupMenuEntry> buildEventOptions({
required BuildContext context,
required WidgetRef ref,
required String roomId,
required String userId,
required Event event,
required void Function(Event, RelationType) onRelation,
}) {
final theme = Theme.of(context);
final danger = theme.colorScheme.error;
final notifier = ref.read(RoomChatController.provider(roomId).notifier);
final client = ref.read(ClientController.provider.notifier);
final isPinned = ref
.watch(PinnedIdsController.provider(roomId))
.contains(event.eventId);
Future<void> sendReaction(String emoji) async {
await notifier.sendReaction(emoji, event).onError(showError);
await ref
.read(RecentEmojiController.provider.notifier)
.add(emoji)
.onError(showError);
}
void showReasonDialog({
required String title,
required String description,
required String action,
required Future<void> Function(String reason) onConfirm,
}) {
showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (context) {
final reasonController = useTextEditingController();
return AlertDialog(
title: Text(title),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(description),
const SizedBox(height: 12),
TextField(
controller: reasonController,
textCapitalization: .sentences,
decoration: .new(labelText: "Reason (optional)"),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: const Text("Cancel"),
),
TextButton(
onPressed: () {
final reason = reasonController.text;
Navigator.of(context).pop();
onConfirm(reason).onError(showError);
},
child: Text(action),
),
],
);
},
),
);
}
return .new([
if (ref.watch(
PowerLevelController.provider(.new(eventType: .reaction, roomId: roomId)),
))
PopupMenuItem(
enabled: false,
child: IconTheme(
data: theme.iconTheme,
child: Row(
children: [
for (final emoji in {
...ref
.watch(RecentEmojiController.provider)
.map((entry) => entry.emoji),
"👍",
"🤣",
"😭",
"🤔",
}.take(4))
IconButton(
icon: Text(emoji),
onPressed: () {
Navigator.of(context).pop();
sendReaction(emoji);
},
),
IconButton(
icon: const Icon(Icons.emoji_emotions),
onPressed: () {
Navigator.of(context).pop();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => EmojiPicker(
allowFreeText: true,
onSelection: (emoji) {
Navigator.of(context).pop();
sendReaction(emoji);
},
),
);
},
),
],
),
),
),
if (ref.watch(
PowerLevelController.provider(.new(eventType: .message, roomId: roomId)),
))
PopupMenuItem(
onTap: () => onRelation(event, .reply),
child: const ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
),
if (event.content is MessageContent && event.sender == userId)
PopupMenuItem(
onTap: () => onRelation(event, .edit),
child: const ListTile(leading: Icon(Icons.edit), title: Text("Edit")),
),
if (ref.watch(
PowerLevelController.provider(
.state(eventType: .pinnedEvents, roomId: roomId),
),
))
PopupMenuItem(
onTap: () async {
try {
final pins = ref.read(
PinnedIdsController.provider(roomId).notifier,
);
if (isPinned) {
await pins.removePin(event.eventId);
} else {
await pins.addPin(event.eventId);
}
} catch (error, stackTrace) {
showError(error, stackTrace);
}
},
child: ListTile(
leading: const Icon(Icons.push_pin),
title: Text(isPinned ? "Unpin Event" : "Pin Event"),
),
),
PopupMenuItem(
onTap: () async {
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room == null) return;
final vias = ref.read(ViaController.provider(room));
await Clipboard.setData(
ClipboardData(
text:
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias",
),
);
},
child: const ListTile(
leading: Icon(Icons.link),
title: Text("Copy Link"),
),
),
if (event.content case MessageContent(:final body?))
PopupMenuItem(
onTap: () => Clipboard.setData(ClipboardData(text: body)),
child: const ListTile(
leading: Icon(Icons.copy),
title: Text("Copy Text"),
),
),
if (ref.watch(
PowerLevelController.provider(
.redaction(targetUser: event.sender, roomId: roomId),
),
))
PopupMenuItem(
onTap: () => showReasonDialog(
title: "Delete Message",
description:
"Are you sure you want to delete this message? "
"This cannot be reversed.",
action: "Delete",
onConfirm: (reason) => notifier.deleteMessage(event, reason: reason),
),
child: ListTile(
leading: Icon(Icons.delete, color: danger),
title: Text("Delete", style: .new(color: danger)),
),
),
PopupMenuItem(
onTap: () => showReasonDialog(
title: "Report",
description:
"Report this event to your server administrators, "
"who can take action like banning this server or room.",
action: "Report",
onConfirm: (reason) => client.reportEvent(
.new(
roomId: roomId,
eventId: event.eventId,
reason: reason.isEmpty ? null : reason,
),
),
),
child: ListTile(
leading: Icon(Icons.report, color: danger),
title: Text("Report", style: .new(color: danger)),
),
),
]);
}

View file

@ -4,7 +4,7 @@ import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/key.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/sidebar.dart"; import "package:nexus/widgets/sidebar.dart";
import "package:nexus/widgets/room_chat.dart"; import "package:nexus/widgets/room_chat/room_chat.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
class const ChatPage({super.key}) extends ConsumerWidget { class const ChatPage({super.key}) extends ConsumerWidget {

View file

@ -1,592 +0,0 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:measure_size/measure_size.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/member_list_opened.dart";
import "package:nexus/controllers/pinned_ids.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/recent_emoji.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/via.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.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";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/loading.dart";
import "package:super_sliver_list/super_sliver_list.dart";
final class const RoomChat({
required final String? roomId,
required final bool isDesktop,
required final bool showMembersByDefault,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final relatedEvent = useState<Event?>(null);
final relationType = useState(RelationType.reply);
final highlightedEvent = useState<String?>(null);
final composerSize = useState<double>(64);
final userId = ref.watch(ClientStateController.provider)?.userId;
final memberListOpened = ref
.watch(MemberListOpenedController.provider)
.requireValue;
final theme = Theme.of(context);
final nothing = Center(
child: Text(
"Nothing to see here...",
style: theme.textTheme.headlineMedium,
),
);
if (userId == null || this.roomId == null) {
return Scaffold(
appBar: RoomAppbar(
roomId: this.roomId,
isDesktop: isDesktop,
onOpenDrawer: () => Scaffold.of(context).openDrawer(),
),
body: nothing,
);
}
final roomId = this.roomId!;
final controllerProvider = RoomChatController.provider(roomId);
final notifier = ref.watch(controllerProvider.notifier);
final client = ref.watch(ClientController.provider.notifier);
final listController = useRef(ListController());
final scrollController = useScrollController();
final controllerData = ref.watch(controllerProvider);
final topEventBeforeLoad = useState<String?>(null);
final hasMore = useState<bool>(true);
Future<void> jumpToId(String eventId) async {
final index =
controllerData.value?.indexWhere(
(element) => element.eventId == eventId,
) ??
-1;
if (index == -1) 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;
hasMore.value = await notifier.loadOlder();
}
}
useEffect(() {
ref
.read(controllerProvider.future)
.then(
(_) => WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent - .000001,
);
}
}),
);
return null;
}, [scrollController.hasClients]);
useEffect(() {
if (controllerData case AsyncData(:final value?)
when scrollController.hasClients) {
if (topEventBeforeLoad.value != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
final index = value.indexWhere(
(event) => event.eventId == topEventBeforeLoad.value,
);
if (index != -1) {
listController.value.jumpToItem(
index: index,
scrollController: scrollController,
alignment: 0,
);
}
}
topEventBeforeLoad.value = null;
});
} else if (scrollController.position.atEdge &&
scrollController.position.pixels != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent,
);
}
});
}
}
return null;
}, [controllerData]);
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || !scrollController.position.atEdge) {
return;
}
final room = ref.watch(
RoomsController.provider.select((value) => value[roomId]),
);
if (room == null) return;
if (scrollController.position.pixels == 0) {
if (room.hasMore) {
await loadOlder();
}
} else {
await client.markRead(room);
}
}
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [roomId, controllerData]);
final composerNode = useFocusNode(
onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey == .escape) {
relatedEvent.value = null;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
IList<PopupMenuEntry> getEventOptions(Event event) {
final danger = theme.colorScheme.error;
final isSentByMe = event.sender == userId;
final recentEmoji = ref.watch(RecentEmojiController.provider);
return [
if (ref.watch(
PowerLevelController.provider(
.new(eventType: .reaction, roomId: roomId),
),
))
PopupMenuItem(
enabled: false,
child: IconTheme(
data: theme.iconTheme,
child: Row(
children: [
...{
...recentEmoji.map((entry) => entry.emoji),
"👍",
"🤣",
"😭",
"🤔",
}
.toIList()
.sublist(0, 4)
.map(
(emoji) => IconButton(
onPressed: () async {
Navigator.of(context).pop();
await notifier
.sendReaction(emoji, event)
.onError(showError);
ref
.watch(RecentEmojiController.provider.notifier)
.add(emoji)
.onError(showError);
},
icon: Text(emoji),
),
),
IconButton(
icon: Icon(Icons.emoji_emotions),
onPressed: () {
// TODO: More adaptive, e.g. dialog on desktop
Navigator.of(context).pop();
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) => EmojiPicker(
onSelection: (value) {
Navigator.of(context).pop();
notifier
.sendReaction(value, event)
.onError(showError);
},
allowFreeText: true,
),
);
},
),
],
),
),
),
if (ref.watch(
PowerLevelController.provider(
.new(eventType: .message, roomId: roomId),
),
))
PopupMenuItem(
onTap: () {
relatedEvent.value = event;
relationType.value = .reply;
composerNode.requestFocus();
},
child: ListTile(leading: Icon(Icons.reply), title: Text("Reply")),
),
if (event.content is MessageContent && isSentByMe)
PopupMenuItem(
onTap: () {
relatedEvent.value = event;
relationType.value = .edit;
composerNode.requestFocus();
},
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(
RoomsController.provider.select((value) => value[roomId]),
);
if (room == null) return;
final vias = ref.watch(ViaController.provider(room));
await Clipboard.setData(
ClipboardData(
text:
"matrix:roomid/${room.metadata?.id.substring(1)}/e/${event.eventId}$vias",
),
);
},
child: ListTile(leading: Icon(Icons.link), title: Text("Copy Link")),
),
if (event.content case MessageContent(:final body?))
PopupMenuItem(
onTap: () async {
await Clipboard.setData(ClipboardData(text: body));
},
child: ListTile(
leading: Icon(Icons.copy),
title: Text("Copy Text"),
),
),
if (ref.watch(
PowerLevelController.provider(
.redaction(targetUser: event.sender, roomId: roomId),
),
))
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (_) {
final deleteReasonController = useTextEditingController();
return AlertDialog(
title: Text("Delete Message"),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(
"Are you sure you want to delete this message? This can not be reversed.",
),
SizedBox(height: 12),
TextField(
controller: deleteReasonController,
textCapitalization: .sentences,
decoration: .new(
labelText: "Reason for deletion (optional)",
),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await notifier
.deleteMessage(
event,
reason: deleteReasonController.text,
)
.onError(showError);
},
child: Text("Delete"),
),
],
);
},
),
),
child: ListTile(
leading: Icon(Icons.delete, color: danger),
title: Text("Delete", style: .new(color: danger)),
),
),
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (context) => HookBuilder(
builder: (_) {
final reasonController = useTextEditingController();
return AlertDialog(
title: Text("Report"),
content: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [
Text(
"Report this event to your server administrators, who can take action like banning this server or room.",
),
SizedBox(height: 12),
TextField(
controller: reasonController,
textCapitalization: .sentences,
decoration: .new(
labelText: "Reason for report (optional)",
),
),
],
),
actions: [
TextButton(
onPressed: Navigator.of(context).pop,
child: Text("Cancel"),
),
TextButton(
onPressed: () {
client.reportEvent(
.new(
roomId: roomId,
eventId: event.eventId,
reason: reasonController.text.isEmpty
? null
: reasonController.text,
),
);
Navigator.of(context).pop();
},
child: Text("Report"),
),
],
);
},
),
),
child: ListTile(
leading: Icon(Icons.report, color: danger),
title: Text("Report", style: .new(color: danger)),
),
),
].toIList();
}
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,
onOpenMemberList: (thisContext) {
ref
.watch(MemberListOpenedController.provider.notifier)
.set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: () {
Scaffold.of(middleContext).openEndDrawer();
},
),
body: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncData(:final value?) ||
AsyncLoading(:final value?) => CustomScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
controller: scrollController,
slivers: [
if (hasMore.value)
SliverToBoxAdapter(
child: Padding(
padding: .symmetric(vertical: 36),
child: Center(
child: ElevatedButton(
onPressed: controllerData is AsyncData
? loadOlder
: null,
child: Text("Load More"),
),
),
),
),
SuperSliverList.builder(
listController: listController.value,
itemCount: value.length,
itemBuilder: (_, index) {
final event = value[index];
final previousEvent = value.getOrNull(
index - 1,
);
return HighlightWrapper(
EventRenderer(
event,
onTapReply: () =>
jumpToId(event.replyTo!),
getEventOptions: getEventOptions,
isGrouped:
previousEvent?.content
is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType !=
"m.replace" &&
"${event.sender}${event.pmp?.id}" ==
"${previousEvent?.sender}${previousEvent?.pmp?.id}",
),
isHighlighted:
highlightedEvent.value == event.eventId,
);
},
),
SliverPadding(
padding: .only(bottom: composerSize.value),
),
],
),
AsyncData() => nothing,
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) => composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend:
(text, {required shouldMention, required tags}) =>
notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relation: relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () => relatedEvent.value = null,
),
),
),
],
),
),
if (memberListOpened == true && showMembersByDefault)
MemberList(roomId),
],
),
),
),
);
}
}

View file

@ -0,0 +1,76 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/models/content/message.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/highlight_wrapper.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const ChatTimeline({
required final AsyncValue<IList<Event>?> controllerData,
required final ScrollController scrollController,
required final ListController listController,
required final bool hasMore,
required final Future<void> Function() loadOlder,
required final Future<void> Function(String) jumpToId,
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final String? highlightedEvent,
required final double composerHeight,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => switch (controllerData) {
AsyncData(:final value?) || AsyncLoading(:final value?) => CustomScrollView(
keyboardDismissBehavior: .onDrag,
controller: scrollController,
slivers: [
if (hasMore)
SliverToBoxAdapter(
child: Padding(
padding: .symmetric(vertical: 36),
child: Center(
child: ElevatedButton(
onPressed: controllerData is AsyncData ? loadOlder : null,
child: Text("Load More"),
),
),
),
),
SuperSliverList.builder(
listController: listController,
itemCount: value.length,
itemBuilder: (_, index) {
final event = value[index];
final previousEvent = value.getOrNull(index - 1);
return HighlightWrapper(
EventRenderer(
event,
onTapReply: () => jumpToId(event.replyTo!),
getEventOptions: getEventOptions,
isGrouped:
previousEvent?.content is MessageContent &&
previousEvent?.redactedBy == null &&
previousEvent?.relationType != "m.replace" &&
event.sender == previousEvent?.sender &&
event.pmp?.id == previousEvent?.pmp?.id,
),
isHighlighted: highlightedEvent == event.eventId,
);
},
),
SliverPadding(padding: .only(bottom: composerHeight)),
],
),
AsyncData() => Center(child: Text("Nothing to see here...")),
AsyncLoading() => Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
};
}

View file

@ -0,0 +1,289 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:measure_size/measure_size.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/member_list_opened.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.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/pinned_events_drawer.dart";
import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/main.dart";
import "package:super_sliver_list/super_sliver_list.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart";
import "package:nexus/helpers/build_event_options.dart";
final class const RoomChat({
required final String? roomId,
required final bool isDesktop,
required final bool showMembersByDefault,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final relatedEvent = useState<Event?>(null);
final relationType = useState(RelationType.reply);
final highlightedEvent = useState<String?>(null);
final composerSize = useState<double>(64);
final userId = ref.watch(ClientStateController.provider)?.userId;
final memberListOpened = ref
.watch(MemberListOpenedController.provider)
.requireValue;
final theme = Theme.of(context);
final nothing = Center(
child: Text(
"Nothing to see here...",
style: theme.textTheme.headlineMedium,
),
);
if (userId == null || this.roomId == null) {
return Scaffold(
appBar: RoomAppbar(
roomId: this.roomId,
isDesktop: isDesktop,
onOpenDrawer: () => Scaffold.of(context).openDrawer(),
),
body: nothing,
);
}
final roomId = this.roomId!;
final controllerProvider = RoomChatController.provider(roomId);
final notifier = ref.watch(controllerProvider.notifier);
final client = ref.watch(ClientController.provider.notifier);
final listController = useRef(ListController());
final scrollController = useScrollController();
final controllerData = ref.watch(controllerProvider);
final topEventBeforeLoad = useState<String?>(null);
final hasMore = useState<bool>(true);
final loadingOlder = useRef(false);
Future<void> jumpToId(String eventId) async {
final index =
controllerData.value?.indexWhere(
(element) => element.eventId == eventId,
) ??
-1;
if (index == -1) 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 (loadingOlder.value || !hasMore.value) return;
if (controllerData case AsyncData(:final value?)) {
loadingOlder.value = true;
topEventBeforeLoad.value = value.firstOrNull?.eventId;
try {
hasMore.value = await notifier.loadOlder();
} finally {
loadingOlder.value = false;
}
}
}
useEffect(() {
ref
.read(controllerProvider.future)
.then(
(_) => WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent - .000001,
);
}
}),
);
return null;
}, [scrollController.hasClients]);
useEffect(() {
if (controllerData case AsyncData(:final value?)
when scrollController.hasClients) {
if (topEventBeforeLoad.value != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
final index = value.indexWhere(
(event) => event.eventId == topEventBeforeLoad.value,
);
if (index != -1) {
listController.value.jumpToItem(
index: index,
scrollController: scrollController,
alignment: 0,
);
}
}
topEventBeforeLoad.value = null;
});
} else if (scrollController.position.atEdge &&
scrollController.position.pixels != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.hasClients) {
scrollController.jumpTo(
scrollController.position.maxScrollExtent,
);
}
});
}
}
return null;
}, [controllerData]);
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || !scrollController.position.atEdge) {
return;
}
final room = ref.read(
RoomsController.provider.select((value) => value[roomId]),
);
if (room == null) return;
if (scrollController.position.pixels == 0) {
if (room.hasMore) {
await loadOlder();
}
} else {
await client.markRead(room);
}
}
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [roomId, controllerData]);
final composerNode = useFocusNode(
onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey == .escape) {
relatedEvent.value = null;
return .handled;
}
return .ignored;
},
);
IList<PopupMenuEntry> getEventOptions(Event event) => buildEventOptions(
context: context,
ref: ref,
roomId: roomId,
userId: userId,
event: event,
onRelation: (event, type) {
relatedEvent.value = event;
relationType.value = type;
composerNode.requestFocus();
},
);
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,
onOpenMemberList: (thisContext) {
ref
.read(MemberListOpenedController.provider.notifier)
.set(!memberListOpened);
Scaffold.of(thisContext).openEndDrawer();
},
onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
),
body: Row(
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: ChatTimeline(
controllerData: controllerData,
scrollController: scrollController,
listController: listController.value,
hasMore: hasMore.value,
loadOlder: loadOlder,
jumpToId: jumpToId,
getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value,
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) => composerSize.value = size.height,
child: Composer(
roomId,
node: composerNode,
onSend:
(text, {required shouldMention, required tags}) =>
notifier
.send(
text,
tags: tags,
relationType: relationType.value,
shouldMention: shouldMention,
relation: relatedEvent.value,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () => relatedEvent.value = null,
),
),
),
],
),
),
if (memberListOpened == true && showMembersByDefault)
MemberList(roomId),
],
),
),
),
);
}
}