parent
47645ea327
commit
7f7bdbf525
20 changed files with 1477 additions and 577 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,10 @@ class const RelationPreview(
|
|||
Expanded(
|
||||
child: Padding(
|
||||
padding: .symmetric(vertical: 8),
|
||||
child: EventPreview(relatedEvent!),
|
||||
child: EventPreview(
|
||||
relatedEvent!.rowId,
|
||||
roomId: relatedEvent!.roomId,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
|
|||
RoomChatController.provider((
|
||||
roomId: event.roomId,
|
||||
contextualEvent: null,
|
||||
threadRoot: null,
|
||||
)).notifier,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
169
lib/widgets/room_chat/chat_body.dart
Normal file
169
lib/widgets/room_chat/chat_body.dart
Normal 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();
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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!);
|
||||
|
|
|
|||
122
lib/widgets/thread_drawer.dart
Normal file
122
lib/widgets/thread_drawer.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
88
lib/widgets/thread_summary.dart
Normal file
88
lib/widgets/thread_summary.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
146
lib/widgets/threads_drawer.dart
Normal file
146
lib/widgets/threads_drawer.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue