add thread support

Fixes #129
This commit is contained in:
Henry Hiles 2026-11-05 19:14:46 -05:00
commit 7f7bdbf525
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
20 changed files with 1477 additions and 577 deletions

View file

@ -33,12 +33,12 @@ class const BotCommandForm(
(index, parameter) => BotOptionPill(
parameter,
key: ValueKey(parameter.key),
roomId: draft.roomId,
composer: draft.composer,
onSubmit: onSubmit,
onRemove: parameter.optional
? () => ref
.read(
BotCommandDraftController.provider(draft.roomId)
BotCommandDraftController.provider(draft.composer)
.notifier,
)
.removeOption(parameter.key)

View file

@ -3,6 +3,7 @@ import "package:flutter/scheduler.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/composer_params.dart";
import "package:nexus/controllers/bot_argument_suggestions.dart";
import "package:nexus/controllers/bot_command_draft.dart";
import "package:nexus/models/bot_argument_suggestion.dart";
@ -10,7 +11,7 @@ import "package:nexus/models/bot_command.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
class const BotCommandPanel(
final String roomId, {
final ComposerParams composer, {
required final IList<BotCommand> commands,
required final TextEditingController controller,
required final void Function(BotCommand command) onPick,
@ -19,9 +20,9 @@ class const BotCommandPanel(
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final draftProvider = BotCommandDraftController.provider(roomId);
final draftProvider = BotCommandDraftController.provider(composer);
final suggestionsProvider = BotArgumentSuggestionsController.provider(
roomId,
composer,
);
final text = useState(controller.text);

View file

@ -2,12 +2,13 @@ import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/composer_params.dart";
import "package:nexus/controllers/bot_command_draft.dart";
import "package:nexus/models/content/bot_command_description.dart";
class const BotOptionPill(
final BotParameter parameter, {
required final String roomId,
required final ComposerParams composer,
/// Called on backspace while empty, to remove the parameter or command.
final VoidCallback? onRemove,
@ -17,7 +18,7 @@ class const BotOptionPill(
@override
Widget build(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
final provider = BotCommandDraftController.provider(roomId);
final provider = BotCommandDraftController.provider(composer);
final (:text, :isFocused, :error, :isLast) = ref.watch(
provider.select(
(draft) => (

View file

@ -10,6 +10,7 @@ import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/show_error_snack_bar.dart";
import "package:nexus/models/bot_command.dart";
import "package:nexus/models/bot_command_draft.dart";
import "package:nexus/models/composer_params.dart";
import "package:nexus/widgets/composer/bot_command_form.dart";
import "package:nexus/widgets/composer/bot_command_panel.dart";
import "package:nexus/widgets/emoji_picker.dart";
@ -75,6 +76,7 @@ class const Composer(
})
onSendLocation,
required final Future<void> Function(BotCommandDraft draft) onSendBotCommand,
final String? threadRoot,
final FocusNode? node,
super.key,
}) extends HookConsumerWidget {
@ -131,7 +133,9 @@ class const Composer(
final poll = useState<PollStart?>(null);
final location = useState<LocationMessageContent?>(null);
final botCommandProvider = BotCommandDraftController.provider(roomId);
final ComposerParams composer = (roomId: roomId, threadRoot: threadRoot);
final botCommandProvider = BotCommandDraftController.provider(composer);
final attachmentProvider = AttachmentController.provider(composer);
final botCommand = ref.watch(botCommandProvider);
useEffect(() {
sticker.value = null;
@ -150,8 +154,6 @@ class const Composer(
final isEditing = relationType == .edit && relatedEvent != null;
useEffect(() {
final attachmentProvider = AttachmentController.provider(roomId);
if (relationType == .edit && relatedEvent != null) {
textBefore.value ??= controller.text;
attachmentBefore.value ??= (ref.read(attachmentProvider),);
@ -247,13 +249,11 @@ class const Composer(
};
}, [roomId]);
final attachment = ref.watch(AttachmentController.provider(roomId));
final attachment = ref.watch(attachmentProvider);
Future<void> attach(Future<XFile?> pick) async {
if (await pick case final file?) {
await ref
.read(AttachmentController.provider(roomId).notifier)
.add(file);
await ref.read(attachmentProvider.notifier).add(file);
}
}
@ -266,9 +266,7 @@ class const Composer(
final file = await File(join(directory.path, filename))
.writeAsBytes(bytes);
await ref
.read(AttachmentController.provider(roomId).notifier)
.add(.new(file.path));
await ref.read(attachmentProvider.notifier).add(.new(file.path));
}
/// Attaches the first file on the clipboard, or the image on it if there
@ -283,7 +281,7 @@ class const Composer(
final files = await Pasteboard.files().onError((_, _) => const []);
if (files.firstOrNull case final path?) {
ref
.read(AttachmentController.provider(roomId).notifier)
.read(attachmentProvider.notifier)
.add(XFile(path))
.onError(showError);
return true;
@ -338,7 +336,9 @@ class const Composer(
if (hasReplacement) {
if (botCommand != null) {
if (ref
.read(BotArgumentSuggestionsController.provider(roomId).notifier)
.read(
BotArgumentSuggestionsController.provider(composer).notifier,
)
.pickTop()) {
return;
}
@ -432,8 +432,7 @@ class const Composer(
: Icon(Icons.file_copy),
title: Text(attachment.$1),
trailing: IconButton(
onPressed: () =>
ref.invalidate(AttachmentController.provider(roomId)),
onPressed: () => ref.invalidate(attachmentProvider),
icon: Icon(Icons.close),
),
),
@ -442,7 +441,7 @@ class const Composer(
duration: Durations.short4,
alignment: .bottomCenter,
child: BotCommandPanel(
roomId,
composer,
commands: botCommands,
controller: controller,
onPick: pickBotCommand,

View file

@ -29,7 +29,10 @@ class const RelationPreview(
Expanded(
child: Padding(
padding: .symmetric(vertical: 8),
child: EventPreview(relatedEvent!),
child: EventPreview(
relatedEvent!.rowId,
roomId: relatedEvent!.roomId,
),
),
),

View file

@ -1,14 +1,23 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/extensions/is_shown_as_message.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";
class const EventPreview(final Event event, {super.key})
extends StatelessWidget {
class const EventPreview(
final int rowId, {
required final String roomId,
super.key,
}) extends ConsumerWidget {
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final event = ref.watch(
RoomsController.provider.select((rooms) => rooms[roomId]?.events[rowId]),
);
if (event == null) return SizedBox.shrink();
final showSender = event.hasMessageContent;
return IgnorePointer(
@ -27,8 +36,8 @@ class const EventPreview(final Event event, {super.key})
children: [
if (showSender) MessageDisplayname(event, shouldWrap: false),
EventRenderer(
event.rowId,
roomId: event.roomId,
rowId,
roomId: roomId,
textOnly: true,
maxLines: 1,
),

View file

@ -73,6 +73,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
RoomChatController.provider((
roomId: event.roomId,
contextualEvent: null,
threadRoot: null,
)).notifier,
);

View file

@ -44,6 +44,7 @@ import "package:nexus/widgets/renderers/power_levels_diff.dart";
import "package:nexus/widgets/renderers/pinned_events_diff.dart";
import "package:nexus/widgets/renderers/server_acl_diff.dart";
import "package:nexus/widgets/swipe_to_reply.dart";
import "package:nexus/widgets/thread_summary.dart";
import "package:nexus/widgets/timestamp.dart";
import "package:nexus/widgets/user_span.dart";
@ -54,6 +55,7 @@ class const EventRenderer(
final bool textOnly = false,
final int? maxLines,
final JumpToEvent? jumpToEvent,
final void Function(String threadRoot)? onOpenThread,
final IList<PopupMenuEntry> Function(Event event)? getEventOptions,
/// If set, events can be swiped, calling this
@ -106,18 +108,18 @@ class const EventRenderer(
(settings.hideMembershipEvents, settings.hideServerAclEvents),
);
// Knocks and their outcomes stay visible, as moderators need to act on them
if (hideMembershipEvents &&
event.content is MembershipContent &&
![event.content, event.previousContent].any(
(content) => content is MembershipContent && content.status == .knock,
)) {
return SizedBox.shrink();
}
final isHidden =
// Knocks and their outcomes stay visible, as moderators need to act
// on them
(hideMembershipEvents &&
event.content is MembershipContent &&
![event.content, event.previousContent].any(
(content) =>
content is MembershipContent && content.status == .knock,
)) ||
(hideServerAclEvents && event.content is ServerACLContent);
if (hideServerAclEvents && event.content is ServerACLContent) {
return SizedBox.shrink();
}
if (isHidden && !textOnly) return SizedBox.shrink();
final isGrouped =
(previousEvent?.isShownAsMessage ?? false) &&
@ -170,15 +172,18 @@ class const EventRenderer(
onTapReply: jumpToEvent == null
? null
: () async {
final replyEvent = await ref.read(
final replyRowId = await ref.read(
EventController.provider(
.new(roomId: roomId, eventId: event.replyTo!),
).future,
);
if (replyEvent != null) {
await jumpToEvent!(replyEvent.rowId);
if (replyRowId != null) {
await jumpToEvent!(replyRowId);
}
},
onTapThread: onOpenThread == null
? null
: () => onOpenThread!(event.eventId),
isGrouped: isGrouped,
maxLines: maxLines,
textOnly: textOnly,
@ -236,11 +241,11 @@ class const EventRenderer(
),
)
.value) {
final poll?
final pollRowId?
when ref
.watch(
PollVotesController.provider(
.new(roomId: roomId, eventRowId: poll.rowId),
.new(roomId: roomId, eventRowId: pollRowId),
),
)
.value
@ -430,6 +435,15 @@ class const EventRenderer(
globalPosition: details.globalPosition,
);
final threadSummary = switch (onOpenThread) {
final onOpenThread? when !textOnly && child is! MessageRenderer =>
ThreadSummary(event, onTap: () => onOpenThread(event.eventId)),
_ => null,
};
final belowIndent = child is MessageRenderer
? 4.0
: 8 + GenericEventRenderer.textIndent(context);
return Column(
crossAxisAlignment: .start,
children: [
@ -492,6 +506,7 @@ class const EventRenderer(
...[
if (child is! MessageRenderer) ReactionRow(event),
?threadSummary,
// Outside the message itself, so it doesn't move when swiped
if (readReceiptsChat case final chat? when event.isShownAsMessage)
@ -523,6 +538,7 @@ class const EventRenderer(
RoomChatController.provider((
roomId: roomId,
contextualEvent: null,
threadRoot: null,
)).notifier,
)
.hideClientSticky(event.rowId),
@ -530,13 +546,20 @@ class const EventRenderer(
],
),
),
].map((child) => Padding(padding: .only(left: 4), child: child)),
].map(
(child) => Padding(
padding: .only(left: belowIndent),
child: child,
),
),
]
else if (textOnly)
Text(
event.redactedBy == null ? "Unknown event type" : "Redacted event",
style: errorStyle,
),
)
else if (threadSummary != null)
Padding(padding: .only(left: 4), child: threadSummary),
],
);
}

View file

@ -13,6 +13,14 @@ class const GenericEventRenderer(
final List<Widget> details = const [],
super.key,
}) extends StatelessWidget {
static const _iconPadding = 4.0;
static const _spacing = 8.0;
/// How far the text is from the start, past the icon, so what's shown under
/// the event can line up with it.
static double textIndent(BuildContext context) =>
(IconTheme.of(context).size ?? 24) + _iconPadding * 2 + _spacing;
@override
Widget build(BuildContext context) {
// Only previewing the event, so it should stay short
@ -22,12 +30,15 @@ class const GenericEventRenderer(
return Padding(
padding: .symmetric(vertical: 4),
child: Row(
spacing: 8,
spacing: _spacing,
mainAxisSize: .min,
// At the top, by the text, rather than the middle of any details
crossAxisAlignment: .start,
children: [
Padding(padding: .symmetric(horizontal: 4), child: Icon(icon)),
Padding(
padding: .symmetric(horizontal: _iconPadding),
child: Icon(icon),
),
Flexible(
child: Column(
mainAxisSize: .min,

View file

@ -27,10 +27,15 @@ import "package:nexus/widgets/event_preview.dart";
import "package:nexus/widgets/players/video.dart";
import "package:nexus/widgets/players/audio.dart";
import "package:nexus/widgets/renderers/poll.dart";
import "package:nexus/widgets/thread_summary.dart";
class const MessageRenderer(
final Event event, {
final VoidCallback? onTapReply,
/// If set, thread roots show a summary of their thread, calling this when
/// tapped.
final VoidCallback? onTapThread,
final bool textOnly = false,
final bool isGrouped = false,
final int? maxLines,
@ -255,9 +260,10 @@ class const MessageRenderer(
),
)) {
AsyncData(:final value?) ||
AsyncLoading(
:final value?,
) => EventPreview(value),
AsyncLoading(:final value?) => EventPreview(
value,
roomId: event.roomId,
),
AsyncError _ => Text(
"An error occurred while fetching the reply",
style: errorStyle,
@ -270,6 +276,8 @@ class const MessageRenderer(
),
rendered,
if (!textOnly) ReactionRow(event),
if (onTapThread case final onTapThread? when !textOnly)
ThreadSummary(event, onTap: onTapThread),
],
),
),

View file

@ -2,6 +2,7 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/event.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/helpers/extensions/success_scheme.dart";
import "package:nexus/models/content/message.dart";
@ -54,17 +55,21 @@ class const PinnedEventsDiff(
),
)) {
// Keeps showing the event while it reloads
AsyncValue(value: final event?) => InkWell(
AsyncValue(value: final rowId?) => InkWell(
borderRadius: .circular(4),
hoverColor: color.withValues(alpha: 0.12),
onTap: jumpToEvent == null
? null
: () => jumpToEvent!(event.rowId),
onTap: jumpToEvent == null ? null : () => jumpToEvent!(rowId),
child: Row(
mainAxisSize: .min,
children: [
// Other events already say who sent them
if (event.content is MessageContent) ...[
if (ref.watch(
RoomsController.provider.select(
(rooms) => rooms[roomId]?.events[rowId],
),
)
case final event?
when event.content is MessageContent) ...[
MessageDisplayname(
event,
clickable: false,
@ -81,8 +86,8 @@ class const PinnedEventsDiff(
Flexible(
child: IgnorePointer(
child: EventRenderer(
event.rowId,
roomId: event.roomId,
rowId,
roomId: roomId,
textOnly: true,
maxLines: 1,
),

View file

@ -66,6 +66,7 @@ class const PollRenderer(final Event event, final PollStart poll, {super.key})
final notifier = RoomChatController.provider((
roomId: event.roomId,
contextualEvent: null,
threadRoot: null,
)).notifier;
Future<void> run(

View file

@ -17,6 +17,7 @@ final class const RoomAppbar({
final void Function(BuildContext context)? onOpenMemberList,
final void Function()? onOpenPinnedMessagesList,
final void Function()? onOpenSearch,
final void Function()? onOpenThreads,
super.key,
}) extends ConsumerWidget implements PreferredSizeWidget {
@override
@ -62,11 +63,6 @@ final class const RoomAppbar({
room == null
? []
: [
IconButton(
onPressed: onOpenSearch?.call,
icon: Icon(Icons.search),
tooltip: "Search this room",
),
IconButton(
onPressed: onOpenPinnedMessagesList?.call,
icon: Icon(Icons.push_pin),
@ -77,7 +73,11 @@ final class const RoomAppbar({
tooltip: "Open member list",
icon: Icon(Icons.people),
),
RoomMenuButton(room),
RoomMenuButton(
room,
onOpenSearch: onOpenSearch,
onOpenThreads: onOpenThreads,
),
],
),
);

View file

@ -0,0 +1,169 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:measure_size/measure_size.dart";
import "package:nexus/controllers/power_level.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/tombstone.dart";
import "package:nexus/helpers/hooks/chat_composer.dart";
import "package:nexus/helpers/hooks/chat_scroll.dart";
import "package:nexus/main.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/composer/composer.dart";
import "package:nexus/widgets/composer/typing_indicator.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart";
import "package:nexus/widgets/tombstone_banner.dart";
final class const ChatBody({
required final ChatParams chat,
required final ChatScroll scroll,
required final JumpToEvent jumpToEvent,
required final IList<PopupMenuEntry> Function(Event event) getEventOptions,
required final ChatComposer composer,
final void Function(String threadRoot)? onOpenThread,
super.key,
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final roomId = chat.roomId;
final repliedEventRowId = composer.repliedEventRowId;
final composerSize = useState<double>(64);
final typingIndicatorSize = useState<double>(0);
final controllerProvider = RoomChatController.provider(chat);
final notifier = ref.watch(controllerProvider.notifier);
final controllerData = ref.watch(controllerProvider);
final swipeToReply = ref.watch(
SettingsController.provider.select(
(settings) => settings.value?.swipeToReply ?? false,
),
);
final tombstone = ref.watch(TombstoneController.provider(roomId));
final canReply =
tombstone == null &&
ref.watch(
PowerLevelController.provider(
.new(eventType: .message, roomId: roomId),
),
);
return Stack(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncLoading(value: _?) || AsyncData(value: _) => ChatTimeline(
scroll: scroll,
jumpToEvent: jumpToEvent,
chat: chat,
getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply
? (rowId) => composer.relate(rowId, .reply)
: null,
onOpenThread: onOpenThread,
composerHeight: composerSize.value,
typingIndicatorHeight: typingIndicatorSize.value,
),
AsyncLoading _ => Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
),
),
AnimatedPositioned(
duration: Durations.short4,
bottom: composerSize.value,
left: 12,
// Leaves room for the jump to bottom button when it's shown
right: scroll.atBottom ? 12 : 72,
child: MeasureSize(
onChange: (size) => typingIndicatorSize.value = size.height,
child: TypingIndicator(roomId),
),
),
Positioned(
right: 16,
bottom: composerSize.value,
child: IgnorePointer(
ignoring: scroll.atBottom,
child: AnimatedOpacity(
opacity: scroll.atBottom ? 0 : 1,
duration: Durations.short4,
child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom,
child: const Icon(Icons.keyboard_arrow_down),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) => composerSize.value = size.height,
child: switch (tombstone) {
final tombstone? => TombstoneBanner(tombstone),
null => Composer(
roomId,
threadRoot: chat.threadRoot,
node: composer.node,
onSend: (text, {required shouldMention, required tags}) =>
notifier
.send(
text,
tags: tags,
relationType: composer.relationType.value,
shouldMention: shouldMention,
relatedEventRowId: composer.relatedEvent.value,
)
.onError(showError),
onSendSticker: (shortcode, image, {required shouldMention}) =>
notifier
.sendSticker(
shortcode,
image,
shouldMention: shouldMention,
repliedEventRowId: repliedEventRowId,
)
.onError(showError),
onSendPoll: (poll, {required shouldMention}) => notifier
.sendPoll(
poll,
shouldMention: shouldMention,
repliedEventRowId: repliedEventRowId,
)
.onError(showError),
onSendLocation: (location, {required shouldMention}) => notifier
.sendLocation(
location,
shouldMention: shouldMention,
repliedEventRowId: repliedEventRowId,
)
.onError(showError),
onSendBotCommand: (draft) => notifier
.sendBotCommand(draft, repliedEventRowId: repliedEventRowId)
.onError(showError),
relationType: composer.relationType.value,
relatedEvent: composer.relatedEvent.value,
onDismiss: () {
composer.relatedEvent.value = null;
composer.node.requestFocus();
},
),
},
),
),
],
);
}
}

View file

@ -12,12 +12,12 @@ class const ChatTimeline({
required final ChatScroll scroll,
required final JumpToEvent jumpToEvent,
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
required final int? highlightedEvent,
required final double composerHeight,
/// The typing indicator's height, or zero while it's hidden.
required final double typingIndicatorHeight,
final void Function(int rowId)? onSwipe,
final void Function(String threadRoot)? onOpenThread,
super.key,
}) extends StatelessWidget {
Widget eventRow(
@ -30,58 +30,62 @@ class const ChatTimeline({
previousEventRowId: previousEventRowId,
roomId: chat.roomId,
jumpToEvent: jumpToEvent,
onOpenThread: onOpenThread,
getEventOptions: getEventOptions,
onSwipe: onSwipe == null ? null : () => onSwipe!(eventRowId),
readReceiptsChat: chat,
),
key: key,
isHighlighted: highlightedEvent == eventRowId,
isHighlighted: scroll.highlightedEvent == eventRowId,
);
@override
Widget build(BuildContext context) => CustomScrollView(
reverse: true,
center: scroll.centerKey,
keyboardDismissBehavior: .onDrag,
controller: scroll.scrollController,
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
// Makes room for the typing indicator, with a small gap above it,
// so it doesn't cover messages
AnimatedContainer(
duration: Durations.medium2,
curve: Curves.easeInOutCubicEmphasized,
height: typingIndicatorHeight == 0
? 0
: typingIndicatorHeight + 4,
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) => TweenAnimationBuilder(
// Makes room for the typing indicator, with a small gap above it, so it
// doesn't cover messages
tween: Tween(
end: typingIndicatorHeight == 0 ? 0.0 : typingIndicatorHeight + 4,
),
duration: Durations.medium2,
curve: Curves.easeInOutCubicEmphasized,
builder: (context, typingIndicatorGap, _) {
final bottomPadding = composerHeight + typingIndicatorGap;
return CustomScrollView(
reverse: true,
center: scroll.centerKey,
// Offset 0 is the newest event above the padding, so short
// timelines can't be scrolled past either end
anchor: (bottomPadding / constraints.maxHeight).clamp(0, 1),
keyboardDismissBehavior: .onDrag,
controller: scroll.scrollController,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: bottomPadding)),
SuperSliverList.builder(
itemCount: scroll.liveRows.length,
itemBuilder: (_, index) => eventRow(
scroll.liveRows[index],
index > 0
? scroll.liveRows.getOrNull(index - 1)
: scroll.historyRows.firstOrNull,
key: scroll.keyFor(scroll.liveRows[index]),
),
),
SuperSliverList.builder(
key: scroll.centerKey,
itemCount: scroll.historyRows.length,
itemBuilder: (_, index) => eventRow(
scroll.historyRows[index],
scroll.historyRows.getOrNull(index + 1),
key: scroll.keyFor(scroll.historyRows[index]),
),
),
SizedBox(height: composerHeight),
],
),
),
SuperSliverList.builder(
itemCount: scroll.liveRows.length,
itemBuilder: (_, index) => eventRow(
scroll.liveRows[index],
index > 0
? scroll.liveRows.getOrNull(index - 1)
: scroll.historyRows.firstOrNull,
key: scroll.keyFor(scroll.liveRows[index]),
),
),
SuperSliverList.builder(
key: scroll.centerKey,
itemCount: scroll.historyRows.length,
itemBuilder: (_, index) => eventRow(
scroll.historyRows[index],
scroll.historyRows.getOrNull(index + 1),
key: scroll.keyFor(scroll.historyRows[index]),
),
),
],
);
},
),
);
}

View file

@ -1,32 +1,23 @@
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/power_level.dart";
import "package:nexus/controllers/settings.dart";
import "package:nexus/controllers/tombstone.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/room_chat.dart";
import "package:nexus/helpers/hooks/chat_composer.dart";
import "package:nexus/helpers/hooks/chat_scroll.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/composer/typing_indicator.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.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/widgets/search_drawer.dart";
import "package:nexus/widgets/sync_status_banner.dart";
import "package:nexus/widgets/tombstone_banner.dart";
import "package:nexus/main.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart";
import "package:nexus/widgets/thread_drawer.dart";
import "package:nexus/widgets/threads_drawer.dart";
import "package:nexus/widgets/room_chat/chat_body.dart";
import "package:nexus/helpers/extensions/build_event_options.dart";
import "package:nexus/controllers/jump_request.dart";
@ -38,21 +29,23 @@ final class const RoomChat({
}) extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final relatedEvent = useState<int?>(null);
final relationType = useState(RelationType.reply);
final repliedEventRowId = relationType.value == .reply
? relatedEvent.value
: null;
final composer = ChatComposer.use();
final pendingJump = ref.read(JumpRequestController.provider);
final contextualEvent = useState<int?>(
pendingJump != null && pendingJump.roomId == this.roomId
? pendingJump.rowId
: null,
);
final highlightedEvent = useState<int?>(null);
final contextualEvent = useState<int?>(switch (pendingJump) {
// Threaded events open their thread instead, leaving the timeline
(:final roomId, :final rowId)
when roomId == this.roomId &&
ref
.read(RoomsController.provider)[roomId]
?.events[rowId]
?.threadRoot ==
null =>
rowId,
_ => null,
});
final composerSize = useState<double>(64);
final typingIndicatorSize = useState<double>(0);
final thread = useState<({String root, int? event})?>(null);
final threadScaffold = useMemoized(GlobalKey<ScaffoldState>.new);
final userId = ref.watch(ClientStateController.provider)?.userId;
final memberListOpened = ref
@ -93,6 +86,7 @@ final class const RoomChat({
final ChatParams chat = (
roomId: roomId,
contextualEvent: contextualEvent.value,
threadRoot: null,
);
final controllerProvider = RoomChatController.provider(chat);
final notifier = ref.watch(controllerProvider.notifier);
@ -116,49 +110,24 @@ final class const RoomChat({
},
);
final lastComposerHeight = useRef(composerSize.value);
void openThread(String root, {int? event}) {
thread.value = (root: root, event: event);
useEffect(() {
final delta = composerSize.value - lastComposerHeight.value;
lastComposerHeight.value = composerSize.value;
if (delta != 0) {
final wasAtBottom = scroll.atBottom;
WidgetsBinding.instance.addPostFrameCallback((_) {
final controller = scroll.scrollController;
if (!controller.hasClients) return;
final position = controller.position;
controller.jumpTo(
wasAtBottom ? position.minScrollExtent : position.pixels - delta,
);
});
}
return null;
}, [composerSize.value]);
final composerNode = useFocusNode(
onKeyEvent: (_, event) {
if (event is KeyDownEvent && event.logicalKey == .escape) {
relatedEvent.value = null;
return .handled;
}
return .ignored;
},
);
// The drawer is only there once rebuilt with the thread
WidgetsBinding.instance.addPostFrameCallback(
(_) => threadScaffold.currentState?.openEndDrawer(),
);
}
// Threaded events aren't in the timeline, only their thread
Future<void> jumpToEvent(int rowId) async {
highlightedEvent.value = rowId;
final threadRoot = ref
.read(RoomsController.provider)[roomId]
?.events[rowId]
?.threadRoot;
await scroll.jumpToEvent(rowId);
await Future.delayed(Durations.extralong1, () {
if (highlightedEvent.value == rowId) {
highlightedEvent.value = null;
}
});
if (threadRoot == null) return scroll.jumpToEvent(rowId);
openThread(threadRoot, event: rowId);
}
useEffect(() {
@ -182,226 +151,93 @@ final class const RoomChat({
return null;
}, [jumpRowId]);
void onRelation(int rowId, RelationType type) {
relatedEvent.value = rowId;
relationType.value = type;
composerNode.requestFocus();
}
IList<PopupMenuEntry> getEventOptions(Event event) =>
event.buildEventOptions(
context: context,
ref: ref,
roomId: roomId,
userId: userId,
onRelation: onRelation,
);
final swipeToReply = ref.watch(
SettingsController.provider.select(
(settings) => settings.value?.swipeToReply ?? false,
),
);
final tombstone = ref.watch(TombstoneController.provider(roomId));
final canReply =
tombstone == null &&
ref.watch(
PowerLevelController.provider(
.new(eventType: .message, roomId: roomId),
),
onRelation: composer.relate,
onStartThread: openThread,
);
return Scaffold(
key: threadScaffold,
endDrawerEnableOpenDragGesture: false,
endDrawer: SearchDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
body: Builder(
builder: (searchContext) => Scaffold(
endDrawerEnableOpenDragGesture: false,
endDrawer: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
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,
onOpenSearch: Scaffold.of(searchContext).openEndDrawer,
),
body: Column(
children: [
const SyncStatusBanner(),
Expanded(
child: Row(
endDrawer: switch (thread.value) {
(:final root, :final event) => ThreadDrawer(
roomId,
threadRoot: root,
initialEvent: event,
jumpToRoomEvent: jumpToEvent,
userId: userId,
key: ValueKey((roomId, root, event)),
),
null => null,
},
body: Scaffold(
endDrawerEnableOpenDragGesture: false,
endDrawer: SearchDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
body: Builder(
builder: (searchContext) => Scaffold(
endDrawerEnableOpenDragGesture: false,
endDrawer: ThreadsDrawer(roomId, onOpenThread: openThread),
body: Builder(
builder: (threadsContext) => Scaffold(
endDrawerEnableOpenDragGesture: false,
endDrawer: PinnedEventsDrawer(
roomId,
getEventOptions: getEventOptions,
jumpToEvent: jumpToEvent,
),
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,
onOpenSearch: Scaffold.of(searchContext).openEndDrawer,
onOpenThreads: Scaffold.of(threadsContext).openEndDrawer,
),
body: Column(
children: [
const SyncStatusBanner(),
Expanded(
child: Stack(
child: Row(
children: [
Positioned.fill(
child: Padding(
padding: .symmetric(horizontal: 4),
child: switch (controllerData) {
AsyncLoading(value: _?) ||
AsyncData(value: _) => ChatTimeline(
scroll: scroll,
chat: chat,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
onSwipe: swipeToReply && canReply
? (rowId) => onRelation(rowId, .reply)
: null,
highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value,
typingIndicatorHeight:
typingIndicatorSize.value,
),
AsyncLoading _ => Loading(),
AsyncError(
:final error,
:final stackTrace,
) =>
ErrorDialog(error, stackTrace),
},
),
),
AnimatedPositioned(
duration: Durations.short4,
bottom: composerSize.value,
left: 12,
// Leaves room for the jump to bottom button when it's shown
right: scroll.atBottom ? 12 : 72,
child: MeasureSize(
onChange: (size) =>
typingIndicatorSize.value = size.height,
child: TypingIndicator(roomId),
),
),
Positioned(
right: 16,
bottom: composerSize.value,
child: IgnorePointer(
ignoring: scroll.atBottom,
child: AnimatedOpacity(
opacity: scroll.atBottom ? 0 : 1,
duration: Durations.short4,
child: FloatingActionButton.small(
onPressed: scroll.jumpToBottom,
child: const Icon(
Icons.keyboard_arrow_down,
),
),
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: MeasureSize(
onChange: (size) =>
composerSize.value = size.height,
child: switch (tombstone) {
final tombstone? => TombstoneBanner(
tombstone,
),
null => Composer(
roomId,
node: composerNode,
onSend:
(
text, {
required shouldMention,
required tags,
}) => notifier
.send(
text,
tags: tags,
relationType:
relationType.value,
shouldMention: shouldMention,
relatedEventRowId:
relatedEvent.value,
)
.onError(showError),
onSendSticker:
(
shortcode,
image, {
required shouldMention,
}) => notifier
.sendSticker(
shortcode,
image,
shouldMention: shouldMention,
repliedEventRowId:
repliedEventRowId,
)
.onError(showError),
onSendPoll:
(poll, {required shouldMention}) =>
notifier
.sendPoll(
poll,
shouldMention:
shouldMention,
repliedEventRowId:
repliedEventRowId,
)
.onError(showError),
onSendLocation:
(
location, {
required shouldMention,
}) => notifier
.sendLocation(
location,
shouldMention: shouldMention,
repliedEventRowId:
repliedEventRowId,
)
.onError(showError),
onSendBotCommand: (draft) => notifier
.sendBotCommand(
draft,
repliedEventRowId:
repliedEventRowId,
)
.onError(showError),
relationType: relationType.value,
relatedEvent: relatedEvent.value,
onDismiss: () {
relatedEvent.value = null;
composerNode.requestFocus();
},
),
},
Expanded(
child: ChatBody(
chat: chat,
scroll: scroll,
jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
composer: composer,
onOpenThread: openThread,
),
),
if (memberListOpened && showMembersByDefault)
MemberList(roomId),
],
),
),
if (memberListOpened && showMembersByDefault)
MemberList(roomId),
],
),
),
],
),
),
),
),

View file

@ -16,6 +16,8 @@ import "package:nexus/main.dart";
final class const RoomMenuButton(
final Room? room, {
final IList<Room> children = const .empty(),
final VoidCallback? onOpenSearch,
final VoidCallback? onOpenThreads,
super.key,
}) extends ConsumerWidget {
@override
@ -27,6 +29,19 @@ final class const RoomMenuButton(
icon: const Icon(Icons.more_vert),
tooltip: "Room options",
itemBuilder: (_) => [
if (onOpenSearch case final onOpenSearch?)
PopupMenuItem(
onTap: onOpenSearch,
child: ListTile(leading: Icon(Icons.search), title: Text("Search")),
),
if (onOpenThreads case final onOpenThreads?)
PopupMenuItem(
onTap: onOpenThreads,
child: ListTile(
leading: Icon(Icons.forum),
title: Text("View Threads"),
),
),
PopupMenuItem(
onTap: () async {
if (room != null) await client.markRead(room!);

View file

@ -0,0 +1,122 @@
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();
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,146 @@
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/error_dialog.dart";
import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/event_preview.dart";
import "package:nexus/widgets/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,
),
),
Spacer(),
if (info.latest case final latest?) ...[
Text(
"Last active",
style: theme.textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
Timestamp(latest.timestamp),
],
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),
],
),
),
},
),
);
}
}