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
5 changed files with 1477 additions and 577 deletions

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(