reorganize and rename widgets

This commit is contained in:
Henry Hiles 2026-11-09 17:25:02 -05:00
commit d5f29dc344
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
3 changed files with 568 additions and 547 deletions

View file

@ -0,0 +1,125 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/client.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/build_event_options.dart";
import "package:nexus/helpers/hooks/chat_composer.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/widgets/room_chat/chat_body.dart";
/// The thread from [threadRoot], with a composer to send to it.
final class const ThreadDrawer(
final String roomId, {
required final String threadRoot,
/// An event in the thread to scroll to once it loads.
final int? initialEvent,
required final String userId,
/// Jumps to events outside this thread, like ones replied to from it.
required final JumpToEvent jumpToRoomEvent,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final composer = ChatComposer.use(ref, (
roomId: roomId,
threadRoot: threadRoot,
));
final contextualEvent = useState(initialEvent);
final ChatParams chat = (
roomId: roomId,
contextualEvent: contextualEvent.value,
threadRoot: threadRoot,
);
final controllerProvider = RoomChatController.provider(chat);
final controllerData = ref.watch(controllerProvider);
final scroll = ChatScroll.use(
controllerData: controllerData,
paginate: ref.watch(controllerProvider.notifier).paginate,
contextualEvent: contextualEvent,
markRead: () async {
final room = ref.read(
RoomsController.provider.select((rooms) => rooms[roomId]),
);
if (room != null) {
await ref
.read(ClientController.provider.notifier)
.markRead(room, threadRoot: threadRoot);
}
},
);
final initialEventLoaded =
controllerData.value?.timeline.contains(initialEvent) ?? false;
// Only once, so returning to the newest events doesn't jump back to it
final jumpedToInitialEvent = useRef(false);
useEffect(() {
if (initialEvent case final rowId?
when initialEventLoaded && !jumpedToInitialEvent.value) {
jumpedToInitialEvent.value = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => scroll.jumpToEvent(rowId),
);
}
return null;
}, [initialEventLoaded]);
// This chat only has the thread, so other events are left to the room.
// Ones in the thread that aren't loaded are loaded around, like the room.
Future<void> jumpToEvent(int rowId) async {
final isInThread =
ref
.read(RoomsController.provider)[roomId]
?.events[rowId]
?.threadRoot ==
threadRoot;
if (isInThread ||
(controllerData.value?.timeline.contains(rowId) ?? false)) {
return scroll.jumpToEvent(rowId);
}
Scaffold.of(context).closeEndDrawer();
await jumpToRoomEvent(rowId);
}
return Drawer(
width: 400,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.forum),
title: Text("Thread"),
actionsPadding: .only(right: 4),
actions: [
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close),
tooltip: "Close thread",
),
],
),
body: ChatBody(
chat: chat,
scroll: scroll,
jumpToEvent: jumpToEvent,
getEventOptions: (event) => event.buildEventOptions(
context: context,
ref: ref,
roomId: roomId,
userId: userId,
onRelation: composer.relate,
),
composer: composer,
),
),
);
}
}

View file

@ -0,0 +1,88 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/thread_events.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/renderers/event.dart";
/// How many replies are in the thread from [root], and the latest one, or nothing if it has none.
class const ThreadSummary(
final Event root, {
required final VoidCallback onTap,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final thread = ref.watch(
RoomsController.provider.select(
(rooms) => rooms[root.roomId]?.thread(root),
),
);
if (thread case (:final count, :final latest) when count > 0) {
return Card(
margin: .only(top: 8),
color: colorScheme.surfaceContainerHigh,
child: InkWell(
onTap: onTap,
child: Padding(
padding: .symmetric(vertical: 12, horizontal: 16),
child: Column(
crossAxisAlignment: .start,
spacing: 8,
children: [
Row(
mainAxisSize: .min,
spacing: 6,
children: [
Icon(Icons.forum, size: 16, color: colorScheme.primary),
Text(
"$count ${count == 1 ? "reply" : "replies"}",
style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.primary,
),
),
Icon(
Icons.chevron_right,
size: 16,
color: colorScheme.primary,
),
],
),
if (latest != null)
IgnorePointer(
child: Row(
mainAxisSize: .min,
spacing: 8,
children: [
MessageAvatar(latest, height: 24),
Flexible(
child: MessageDisplayname(latest, shouldWrap: false),
),
Flexible(
flex: 2,
child: EventRenderer(
latest.rowId,
roomId: latest.roomId,
textOnly: true,
maxLines: 1,
),
),
],
),
),
],
),
),
),
);
}
return SizedBox.shrink();
}
}

View file

@ -0,0 +1,157 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/thread_list.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/thread_events.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/common/error_dialog.dart";
import "package:nexus/widgets/common/loading.dart";
import "package:nexus/widgets/renderers/event_preview.dart";
import "package:nexus/widgets/common/timestamp.dart";
final class const ThreadsDrawer(
final String roomId, {
required final void Function(String threadRoot) onOpenThread,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final threads = ref.watch(
RoomsController.provider.select(
(rooms) => rooms[roomId]?.threads ?? const .empty(),
),
);
final serverProvider = ThreadListController.provider(roomId);
final server = ref.watch(serverProvider);
final theme = Theme.of(context);
void open(String threadRoot) {
Navigator.of(context).pop();
onOpenThread(threadRoot);
}
return Drawer(
width: 400,
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
scrolledUnderElevation: 0,
leading: Icon(Icons.forum),
title: Text("Threads"),
actionsPadding: .only(right: 4),
actions: [
IconButton(
onPressed: Scaffold.of(context).closeEndDrawer,
icon: Icon(Icons.close),
tooltip: "Close threads",
),
],
),
body: switch (server) {
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
AsyncValue(hasValue: true, value: final nextBatch)
when threads.isNotEmpty =>
ListView.builder(
padding: .all(8),
itemCount: threads.length + (nextBatch == null ? 0 : 1),
itemBuilder: (context, index) {
if (index == threads.length) {
return Center(
child: TextButton(
onPressed: server.isLoading
? null
: () => ref
.read(serverProvider.notifier)
.loadMore()
.onError(showError),
child: Text("Load more"),
),
);
}
final (:root, :info) = threads[index];
return Card(
margin: .symmetric(vertical: 4),
color: theme.colorScheme.surfaceContainerHigh,
clipBehavior: .antiAlias,
child: InkWell(
onTap: () => open(root.eventId),
child: Padding(
padding: .symmetric(vertical: 8, horizontal: 12),
child: Column(
crossAxisAlignment: .start,
spacing: 4,
children: [
EventPreview(root.rowId, roomId: roomId),
Row(
spacing: 6,
children: [
Icon(
Icons.forum,
size: 16,
color: theme.colorScheme.primary,
),
Text(
"${info.count} ${info.count == 1 ? "reply" : "replies"}",
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
),
),
if (info.latest case final latest?)
Expanded(
child: Row(
mainAxisAlignment: .end,
spacing: 4,
children: [
Flexible(
child: Text(
"Last active:",
maxLines: 1,
overflow: .ellipsis,
style: theme.textTheme.labelSmall
?.copyWith(color: Colors.grey),
),
),
Timestamp(latest.timestamp),
],
),
)
else
Spacer(),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.primary,
),
],
),
],
),
),
),
);
},
),
AsyncLoading() => Loading(),
AsyncData() => Center(
child: Column(
mainAxisSize: .min,
children: [
Icon(
Icons.forum_outlined,
size: 48,
color: theme.colorScheme.onSurface,
),
SizedBox(height: 12),
Text("No threads", style: theme.textTheme.headlineSmall),
],
),
),
},
),
);
}
}