Files
buzz/mobile/lib/features/channels/thread_detail_page.dart
klopez4212 58cc4b7e9b Fix mobile jump-to-latest flicker (#6807)
## Summary

- Keep jump-to-latest hidden through composer and keyboard layout
changes while the user is still following the tail.
- Stabilize thread tail detection during lazy scroll updates.
- Unmount inactive iOS Liquid Glass controls so the channel arrow cannot
bleed into an opened thread.

## Testing

- just mobile-check
- flutter test (1,864 passed)
- Signed iOS Release build and install on iPhone Air

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-28 17:12:36 +01:00

991 lines
38 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/frosted_app_bar.dart';
import '../../shared/widgets/frosted_scaffold.dart';
import '../../shared/widgets/ios_glass_navigation_button.dart';
import '../../shared/widgets/keyboard_dismiss_on_drag.dart';
import '../../shared/widgets/message_author_meta.dart';
import '../../shared/profile/user_cache_provider.dart';
import '../../shared/profile/user_profile.dart';
import 'android_ime_lift.dart';
import 'channel_link_navigation.dart';
import 'channel_messages_provider.dart';
import 'channel_typing_provider.dart';
import 'channel_typing_indicator.dart';
import 'thread_replies_provider.dart';
import 'channels_provider.dart';
import 'compose_bar.dart';
import 'composer_dock_size_reporter.dart';
import 'date_formatters.dart';
import 'day_divider.dart';
import 'ime_metrics_settle_observer.dart';
import 'initial_thread_tail_settle.dart';
import 'laid_out_viewport.dart';
import 'jump_to_latest_button.dart';
import 'jump_to_latest_switcher.dart';
import 'local_message_send_animation_provider.dart';
import 'local_message_send_transition.dart';
import '../profile/user_profile_sheet.dart';
import 'message_actions.dart';
import 'message_action_backdrop_state.dart';
import 'message_long_press_region.dart';
import 'message_content.dart';
import 'reaction_row.dart';
import '../../shared/read_state/read_state_format.dart';
import '../../shared/read_state/read_state_provider.dart';
import 'send_message_provider.dart';
import 'small_avatar.dart';
import 'sticky_date_header.dart';
import 'timeline_message.dart';
part 'thread_detail_page/nested_thread_summary_row.dart';
part 'thread_detail_page/message_list.dart';
part 'thread_detail_page/sticky_date.dart';
part 'thread_detail_helpers.dart';
part 'thread_detail_page/tail_alignment.dart';
part 'thread_detail_page/thread_message.dart';
part 'thread_detail_page/avatar.dart';
const _landingHighlightDuration = Duration(seconds: 3);
const _landingHighlightDelay = Duration(milliseconds: 50);
const _landingHighlightTransitionDuration = Duration(milliseconds: 300);
const _landingHighlightOpacity = 0.12;
/// Full-screen thread detail page.
///
/// Shows the thread head message, direct replies, typing indicators scoped to
/// the thread, and a compose bar for replying.
class ThreadDetailPage extends HookConsumerWidget {
final TimelineMessage threadHead;
final List<TimelineMessage> allMessages;
final String channelId;
final String? currentPubkey;
final bool isMember;
final bool isArchived;
final String? initialMessageId;
/// Overrides the tail jump only in deterministic lazy-layout tests.
@visibleForTesting
final bool Function()? jumpThreadTailForTesting;
const ThreadDetailPage({
super.key,
required this.threadHead,
required this.allMessages,
required this.channelId,
required this.currentPubkey,
required this.isMember,
required this.isArchived,
this.initialMessageId,
this.jumpThreadTailForTesting,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appView = View.of(context);
final composerDockHeight = useState(0.0);
final composerFocusNode = useFocusNode();
final restoreComposerFocus = useRef<VoidCallback?>(null);
final settledImeBottomInset = useState(
usesFixedAndroidImeViewport
? appView.viewInsets.bottom / appView.devicePixelRatio
: 0.0,
);
useEffect(() {
final session = ref.read(relaySessionProvider.notifier);
return session.registerVisibleChannel(channelId);
}, [channelId]);
final sendMessage = ref.read(sendMessageProvider);
final localSendAnimations = ref.watch(
localMessageSendAnimationProvider(channelId),
);
// Relay thread queries are keyed by the outermost root, even when this
// page displays a nested branch. Query that root, then select this head's
// direct children from the returned subtree below.
final queryRootId = threadHead.rootId ?? threadHead.id;
final repliesArgs = ThreadRepliesArgs(
channelId: channelId,
rootId: queryRootId,
);
final relayReplyState = ref.watch(threadRepliesProvider(repliesArgs));
final repliesState = ref.watch(threadRepliesWithLocalProvider(repliesArgs));
final relayRepliesAvailable = relayReplyState.value != null;
// The thread query is one-shot and asks only for content kinds, so a
// reaction, edit, or deletion that lands while the thread is open never
// reaches it — a new pill (and its burst) only showed up after leaving and
// re-entering, which refetched. The channel socket already receives those
// events, so union the two sources and format once.
final liveChannelEvents =
ref.watch(channelMessagesProvider(channelId)).value ??
const <NostrEvent>[];
final replyMessages = repliesState.whenData((events) {
return formatTimeline(
mergeThreadEvents(events, liveChannelEvents),
currentPubkey: currentPubkey,
);
});
final fetchedReplies = replyMessages.value;
final hasFetchedReplies = fetchedReplies != null;
// A terminal query error cannot produce a more authoritative list. Keep
// loading states provisional, but let the hydrated route snapshot drive
// the one-shot target jump when the relay query has definitively failed.
final canUseMessagesForInitialTarget =
relayReplyState.value != null ||
(relayReplyState.hasError && !relayReplyState.retrying);
final liveDeletionHidesHead = _isDeletedBy(
liveChannelEvents,
threadHead.id,
);
final allMsgs = fetchedReplies == null
? allMessages
: [
// Only fall back to the pushed-route snapshot when neither source
// carries the head, and no live deletion has suppressed it. That
// keeps a temporarily unavailable head visible without restoring
// a head that was deleted while this page was open.
if (!liveDeletionHidesHead &&
!fetchedReplies.any((message) => message.id == threadHead.id))
threadHead,
...fetchedReplies,
];
final routeAnimation = ModalRoute.of(context)?.animation;
final reducedLandingHighlightMotion = MediaQuery.disableAnimationsOf(
context,
);
final highlightedMessageId = useState<String?>(null);
final initialTargetReadyForHighlight = useState(false);
useEffect(
() {
final messageId = initialMessageId;
if (messageId == null || !initialTargetReadyForHighlight.value) {
return null;
}
var disposed = false;
Timer? revealTimer;
Timer? dismissTimer;
void revealHighlight() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (disposed) return;
revealTimer = Timer(_landingHighlightDelay, () {
if (disposed) return;
highlightedMessageId.value = messageId;
dismissTimer = Timer(
_landingHighlightDuration +
(reducedLandingHighlightMotion
? Duration.zero
: _landingHighlightTransitionDuration),
() {
if (!disposed) highlightedMessageId.value = null;
},
);
});
});
}
void handleRouteStatus(AnimationStatus status) {
if (status != AnimationStatus.completed) return;
routeAnimation?.removeStatusListener(handleRouteStatus);
revealHighlight();
}
if (routeAnimation == null ||
routeAnimation.status == AnimationStatus.completed) {
revealHighlight();
} else {
routeAnimation.addStatusListener(handleRouteStatus);
}
return () {
disposed = true;
routeAnimation?.removeStatusListener(handleRouteStatus);
revealTimer?.cancel();
dismissTimer?.cancel();
};
},
[
initialMessageId,
initialTargetReadyForHighlight.value,
reducedLandingHighlightMotion,
routeAnimation,
],
);
// Index all messages by parentId so we can find direct children of any
// message and compute thread summaries for nested threads.
final childrenByParent = <String, List<TimelineMessage>>{};
for (final msg in allMsgs) {
final pid = msg.parentId;
if (pid == null) continue;
childrenByParent.putIfAbsent(pid, () => []).add(msg);
}
final replies = childrenByParent[threadHead.id] ?? const [];
final liveHead =
allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead;
final itemScrollController = useMemoized(ItemScrollController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final listViewport = useMemoized(LaidOutViewport.new);
useEffect(() => listViewport.dispose, [listViewport]);
final stickyDateHeaderState = useValueNotifier(
StickyDateHeaderState.hidden,
);
final stickyDayTimestamp = useValueNotifier<int?>(null);
final didJumpToInitialMessage = useRef(false);
final initialHighlightTargetIndex = useState<int?>(null);
final initialViewportReady = useState(false);
final followsThreadTail = useRef(false);
final userOptedOutOfTailFollow = useRef(false);
final userDragDetachedTailFollow = useRef(false);
final tailIntent = useMemoized(_ThreadTailIntent.new);
final initialTailSettle = useMemoized(InitialThreadTailSettle.new);
final isAtThreadTail = useState(true);
final isNavigatingToThreadTail = useState(false);
// Ordinary thread entry owns a tail-follow intent before lazy item
// positions settle. Keep Latest suppressed through those initial layout
// frames; a deep-link entry must expose it for its older target instead.
final hidesLatestForInitialTailSettle = useState(initialMessageId == null);
final hidesLatestForComposerTailCorrection = useState(false);
final tailCorrectionInProgress = useRef(false);
final tailCorrectionGeneration = useRef(0);
final activeThreadScrollPosition = useRef<ScrollPosition?>(null);
final composerHasFocus = useListenable(composerFocusNode).hasFocus;
final viewportHeight = useListenable(listViewport.height).value;
final previousViewportHeight = useRef(viewportHeight);
final settledImeLift = usesFixedAndroidImeViewport
? (settledImeBottomInset.value -
MediaQuery.viewPaddingOf(context).bottom)
.clamp(0.0, double.infinity)
.toDouble()
: 0.0;
final timelineBottomInset =
composerDockHeight.value +
(followsThreadTail.value || !initialTailSettle.isComplete
? settledImeLift
: 0);
final navigationBottomInset = composerDockHeight.value + settledImeLift;
// Keep the route snapshot usable while the relay query is pending. Once
// authoritative replies arrive, suppress only the frame(s) used to place
// the hydrated target, then reveal the settled viewport.
final threadViewportVisible =
!relayRepliesAvailable || initialViewportReady.value;
// Item 0 is the thread head; reply `i` lives at `i + 1`.
const headIndex = 0;
int indexForReply(int chronologicalIndex) => chronologicalIndex + 1;
final tailAnchorIndex = replies.length + 1;
final stickyDateIndex = _ThreadStickyDateIndex(
head: liveHead,
replies: replies,
);
void updateStickyDateHeader(Iterable<ItemPosition> positions) {
final update = stickyDateIndex.resolve(
positions: positions,
viewportHeight: viewportHeight,
stickyTop: frostedAppBarHeight(context) + Grid.twelve,
stickyHeaderHeight: StickyDateHeader.heightOf(context),
);
stickyDateHeaderState.value = update.state;
stickyDayTimestamp.value = update.activeDayTimestamp;
}
double threadTailAlignment() => _threadTailAlignmentForViewport(
// This reporter already reflects Scaffold resize, typing rows, and every
// other layout consumer above or below the list.
fullHeight: viewportHeight > 0
? viewportHeight
: MediaQuery.sizeOf(context).height,
imeBottomInset: 0,
usesFixedImeViewport: true,
bottomInset:
Grid.xs +
composerDockHeight.value +
(followsThreadTail.value ? settledImeLift : 0),
);
bool threadTailIsVisible() {
if (isMember &&
!isArchived &&
(!viewportHeight.isFinite ||
viewportHeight <= 0 ||
composerDockHeight.value <= 0)) {
return false;
}
if (!viewportHeight.isFinite || viewportHeight <= 0) return false;
final trailingBoundary =
1 - ((Grid.xs + timelineBottomInset) / viewportHeight) + 0.001;
final lastMessageIndex = _threadTailIndex(replies.length);
var tailItemIsLaidOut = false;
var tailItemIsVisible = false;
for (final item in itemPositionsListener.itemPositions.value) {
if (item.index != lastMessageIndex) continue;
tailItemIsLaidOut = true;
tailItemIsVisible = item.itemTrailingEdge <= trailingBoundary;
break;
}
final position = activeThreadScrollPosition.value;
return threadTailIsAtEffectiveEnd(
tailIsLaidOut: tailItemIsLaidOut,
tailIsVisible: tailItemIsVisible,
extentAfter: position != null && position.hasContentDimensions
? position.extentAfter
: null,
);
}
Widget trackActiveScrollPosition(Widget child) =>
_trackActiveThreadScrollPosition(child, activeThreadScrollPosition);
bool jumpActiveScrollPositionToTail() => _jumpActiveThreadScrollToTail(
activeThreadScrollPosition,
jumpThreadTailForTesting,
);
Future<bool> animateActiveScrollPositionToTail() =>
_animateActiveThreadScrollToTail(context, activeThreadScrollPosition);
void finishThreadTailCorrection({
required bool revealViewport,
required int generation,
int corrections = 0,
}) {
if (!context.mounted) return;
if (generation != tailCorrectionGeneration.value) return;
// A finger drag interrupts the correction. Do not take control back
// from the user with another jump after they detach from the tail.
if (!tailCorrectionInProgress.value ||
tailIntent.isDragging ||
userOptedOutOfTailFollow.value) {
tailCorrectionInProgress.value = false;
hidesLatestForComposerTailCorrection.value = false;
isNavigatingToThreadTail.value = false;
isAtThreadTail.value = threadTailIsVisible();
return;
}
final reachedTail = threadTailIsVisible();
// Lazy children can revise maxScrollExtent for several frames. Keep
// moving the same active position until the measured tail is visible;
if (!reachedTail && corrections < _latestTailCorrectionLimit) {
jumpActiveScrollPositionToTail();
WidgetsBinding.instance.addPostFrameCallback(
(_) => finishThreadTailCorrection(
revealViewport: revealViewport,
generation: generation,
corrections: corrections + 1,
),
);
WidgetsBinding.instance.scheduleFrame();
return;
}
tailCorrectionInProgress.value = false;
hidesLatestForComposerTailCorrection.value = false;
isNavigatingToThreadTail.value = false;
if (revealViewport) initialViewportReady.value = true;
isAtThreadTail.value = reachedTail;
}
void correctThreadTailInstantly() {
if (!jumpActiveScrollPositionToTail()) return;
tailCorrectionInProgress.value = true;
final generation = ++tailCorrectionGeneration.value;
isAtThreadTail.value = threadTailIsVisible();
WidgetsBinding.instance.addPostFrameCallback(
(_) => finishThreadTailCorrection(
revealViewport: false,
generation: generation,
),
);
WidgetsBinding.instance.scheduleFrame();
}
void followThreadTailFromComposer() {
if (userDragDetachedTailFollow.value) return;
hidesLatestForComposerTailCorrection.value = true;
initialTailSettle.abandon();
initialViewportReady.value = true;
tailIntent.endDrag();
tailIntent.detach();
userOptedOutOfTailFollow.value = false;
followsThreadTail.value = true;
final reachedTail = threadTailIsVisible();
isAtThreadTail.value = reachedTail;
if (reachedTail) {
hidesLatestForComposerTailCorrection.value = false;
} else {
correctThreadTailInstantly();
}
}
useEffect(() {
if (!composerHasFocus) {
hidesLatestForComposerTailCorrection.value = false;
}
return null;
}, [composerHasFocus]);
useEffect(
() {
void onPositionsChanged() {
updateStickyDateHeader(itemPositionsListener.itemPositions.value);
final tailIsVisible = threadTailIsVisible();
if (!userOptedOutOfTailFollow.value && tailIsVisible) {
followsThreadTail.value = true;
}
if (tailCorrectionInProgress.value) return;
if (isAtThreadTail.value != tailIsVisible) {
isAtThreadTail.value = tailIsVisible;
}
}
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
return () => itemPositionsListener.itemPositions.removeListener(
onPositionsChanged,
);
},
[
itemPositionsListener,
replies.length,
liveHead.createdAt,
viewportHeight,
],
);
useEffect(() {
stickyDateHeaderState.value = StickyDateHeaderState.hidden;
stickyDayTimestamp.value = null;
return null;
}, [threadHead.id]);
void scrollToThreadLatest() {
if (!itemScrollController.isAttached) return;
initialTailSettle.abandon();
tailIntent.endDrag();
tailIntent.detach();
// An explicit Latest tap supersedes a still-pending Inbox/Activity
// deep-link. Without consuming that one-shot intent, the authoritative
// thread query can finish after this navigation and jump back to the
// originally linked older message.
didJumpToInitialMessage.value = true;
initialHighlightTargetIndex.value = null;
initialTargetReadyForHighlight.value = false;
userOptedOutOfTailFollow.value = false;
userDragDetachedTailFollow.value = false;
followsThreadTail.value = true;
isNavigatingToThreadTail.value = true;
tailCorrectionInProgress.value = true;
final generation = ++tailCorrectionGeneration.value;
Future<void> navigateToTail() async {
if (!await animateActiveScrollPositionToTail()) {
if (generation == tailCorrectionGeneration.value) {
tailCorrectionInProgress.value = false;
isNavigatingToThreadTail.value = false;
}
return;
}
finishThreadTailCorrection(
revealViewport: true,
generation: generation,
);
}
unawaited(navigateToTail());
}
useEffect(
() {
final messageId = initialMessageId;
// Wait for either the authoritative thread query or a terminal query
// error before consuming the one-shot jump. During loading, the fallback
// main-timeline list can contain only the linked reply; after an error,
// that hydrated snapshot is the best available target list.
if (messageId == null || !canUseMessagesForInitialTarget) return null;
final chronologicalIndex = replies.indexWhere(
(reply) => reply.id == messageId,
);
final targetIndex = messageId == threadHead.id
? headIndex
: chronologicalIndex < 0
? null
: indexForReply(chronologicalIndex);
if (targetIndex == null) {
initialTailSettle.abandon();
initialViewportReady.value = true;
return null;
}
if (didJumpToInitialMessage.value) return null;
didJumpToInitialMessage.value = true;
initialTailSettle.abandon();
userOptedOutOfTailFollow.value = true;
userDragDetachedTailFollow.value = false;
tailIntent.schedule(
allowed: true,
revalidate: () =>
context.mounted &&
itemScrollController.isAttached &&
!tailIntent.isDragging,
action: () {
// The provisional route snapshot can make the linked reply look like
// the tail. This authoritative deep-link jump intentionally leaves
// the user at an older item, so it must opt out of follow-tail first.
tailIntent.detach();
followsThreadTail.value = false;
isAtThreadTail.value = false;
itemScrollController.jumpTo(index: targetIndex, alignment: 0.35);
initialHighlightTargetIndex.value = targetIndex;
},
);
return null;
},
[
initialMessageId,
canUseMessagesForInitialTarget,
fetchedReplies,
replies.length,
],
);
useEffect(
() {
final targetIndex = initialHighlightTargetIndex.value;
if (targetIndex == null || initialTargetReadyForHighlight.value) {
return null;
}
var completionScheduled = false;
void markReadyAfterTargetLayout() {
if (completionScheduled ||
!itemPositionsListener.itemPositions.value.any(
(position) => position.index == targetIndex,
)) {
return;
}
completionScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
initialTargetReadyForHighlight.value = true;
initialViewportReady.value = true;
});
}
itemPositionsListener.itemPositions.addListener(
markReadyAfterTargetLayout,
);
markReadyAfterTargetLayout();
return () => itemPositionsListener.itemPositions.removeListener(
markReadyAfterTargetLayout,
);
},
[initialHighlightTargetIndex.value, initialTargetReadyForHighlight.value],
);
// A top-anchored list doesn't stick to the newest item the way the old
// reversed one did, so follow the tail explicitly: when a reply arrives
// while the last item is on screen, scroll it into view. If the user has
// scrolled up to read, leave them where they are.
final previousReplyCount = useRef(replies.length);
final topOverlayFraction = viewportHeight > 0
? frostedAppBarHeight(context) / viewportHeight
: 0.0;
final settleGeometry = (
composerDockHeight.value,
settledImeLift,
viewportHeight,
);
useEffect(() {
if (!hasFetchedReplies || viewportHeight <= 0) return null;
if (initialMessageId != null) {
initialTailSettle.abandon();
previousReplyCount.value = replies.length;
return null;
}
if (isMember && !isArchived && composerDockHeight.value <= 0) {
return null;
}
if (!initialTailSettle.isComplete) {
previousReplyCount.value = replies.length;
followsThreadTail.value = true;
initialTailSettle.schedule(
context: context,
controller: itemScrollController,
positionsListener: itemPositionsListener,
targetIndex: replies.isEmpty
? null
: indexForReply(replies.length - 1),
hiddenTopFraction: topOverlayFraction,
hiddenBottomFraction:
(composerDockHeight.value + settledImeLift) / viewportHeight,
onSettled: () {
if (context.mounted) initialViewportReady.value = true;
},
);
return null;
}
final previous = previousReplyCount.value;
previousReplyCount.value = replies.length;
if (replies.length <= previous) return null;
final positions = itemPositionsListener.itemPositions.value;
// Positions still describe the list as it was *before* these replies, so
// compare against the old tail. Measuring against the new one only reads
// as "at the tail" when exactly one reply arrived.
final previousTailAnchorIndex = previous + 1;
final wasAtTail =
positions.isEmpty ||
positions.any(
(position) => position.index >= previousTailAnchorIndex,
);
final localPubkey = currentPubkey?.toLowerCase();
final hasNewLocalReply =
localPubkey != null &&
replies
.skip(previous)
.any((reply) => reply.pubkey.toLowerCase() == localPubkey);
// A reply the current user just sent must be visible even if they were
// reading at the head of a long thread. Remote arrivals still respect
// the user's scroll position.
if (tailIntent.isDragging) return null;
if (!hasNewLocalReply && (userOptedOutOfTailFollow.value || !wasAtTail)) {
return null;
}
if (hasNewLocalReply) {
userOptedOutOfTailFollow.value = false;
userDragDetachedTailFollow.value = false;
}
followsThreadTail.value = true;
tailIntent.scheduleNextFrame(
allowed: true,
revalidate: () =>
context.mounted &&
itemScrollController.isAttached &&
!tailIntent.isDragging &&
!userOptedOutOfTailFollow.value,
// A reply arrival changes list geometry. Keep this correction instant;
// only an explicit tap on Latest should animate navigation.
action: correctThreadTailInstantly,
);
return null;
}, [hasFetchedReplies, replies.length, settleGeometry]);
final readState = ref.watch(readStateProvider);
final visibleReplyReadKey = replies
.map((reply) => '${reply.id}:${reply.createdAt}')
.join(',');
useEffect(() {
if (!readState.isReady || replies.isEmpty) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
for (final reply in replies) {
ref
.read(readStateProvider.notifier)
.markContextRead(msgContextKey(reply.id), reply.createdAt);
}
});
return null;
}, [threadHead.id, readState.isReady, visibleReplyReadKey]);
// Thread-scoped typing indicators (exclude self).
final allTyping = ref.watch(channelTypingProvider(channelId));
final threadTyping = allTyping
.where((e) => e.threadHeadId == threadHead.id)
.where(
(e) =>
currentPubkey == null ||
e.pubkey.toLowerCase() != currentPubkey?.toLowerCase(),
)
.toList();
// The root of the entire thread chain. If the current thread head is
// itself a root message its rootId is null, so fall back to its own id.
final effectiveRootId = threadHead.rootId ?? threadHead.id;
// Composer size changes and keyboard metrics changes are independent:
// the dock grows first, then the Scaffold's viewport shrinks once the
// keyboard appears. Re-align after that latter layout pass too, but only
// while the user was already following the thread tail.
void realignThreadTailAfterMetricsChange() {
listViewport.reportAfterLayout();
final shouldFollowTail =
!tailIntent.isDragging &&
!userOptedOutOfTailFollow.value &&
(followsThreadTail.value || threadTailIsVisible());
if (!shouldFollowTail || !initialTailSettle.isComplete) return;
followsThreadTail.value = true;
tailIntent.scheduleNextFrame(
allowed: true,
revalidate: () =>
context.mounted &&
itemScrollController.isAttached &&
!tailIntent.isDragging &&
!userOptedOutOfTailFollow.value &&
followsThreadTail.value,
action: () {
final targetAlignment = threadTailAlignment();
final positions = itemPositionsListener.itemPositions.value;
final anchorPosition = positions
.where((position) => position.index == tailAnchorIndex)
.firstOrNull;
final headIsVisible = positions.any(
(position) =>
position.index == headIndex && position.itemTrailingEdge > 0,
);
if (anchorPosition != null &&
((anchorPosition.itemLeadingEdge - targetAlignment).abs() <
0.005 ||
(headIsVisible &&
anchorPosition.itemLeadingEdge < targetAlignment))) {
return;
}
// This runs once after Android's frame-by-frame IME metrics settle.
// Keep the resulting layout correction instant.
correctThreadTailInstantly();
},
);
}
useEffect(() {
final previousHeight = previousViewportHeight.value;
previousViewportHeight.value = viewportHeight;
if ((viewportHeight - previousHeight).abs() >= 0.5 &&
initialTailSettle.isComplete) {
realignThreadTailAfterMetricsChange();
}
return null;
}, [viewportHeight]);
void updateComposerDockHeight(double height) {
listViewport.reportAfterLayout();
final previousHeight = composerDockHeight.value;
final heightDelta = height - previousHeight;
if (heightDelta.abs() < 0.5) return;
final shouldFollowTail =
!tailIntent.isDragging &&
!userOptedOutOfTailFollow.value &&
(followsThreadTail.value || threadTailIsVisible());
if (shouldFollowTail) followsThreadTail.value = true;
composerDockHeight.value = height;
if (shouldFollowTail) realignThreadTailAfterMetricsChange();
}
useEffect(() {
final observer = ImeMetricsSettleObserver(
onMetricsSettled: () {
if (!usesFixedAndroidImeViewport) {
realignThreadTailAfterMetricsChange();
return;
}
final nextInset =
appView.viewInsets.bottom / appView.devicePixelRatio;
if ((settledImeBottomInset.value - nextInset).abs() >= 0.5) {
settledImeBottomInset.value = nextInset;
}
},
);
WidgetsBinding.instance.addObserver(observer);
return () {
WidgetsBinding.instance.removeObserver(observer);
observer.dispose();
};
}, [appView, itemScrollController]);
useEffect(() {
if (usesFixedAndroidImeViewport) {
realignThreadTailAfterMetricsChange();
}
return null;
}, [settledImeBottomInset.value]);
// Channel names for message content rendering.
final channelsAsync = ref.watch(channelsProvider);
final channel = channelsAsync.value
?.where((candidate) => candidate.id == channelId)
.firstOrNull;
final channelNamesMap = <String, String>{};
channelsAsync.whenData((channels) {
for (final ch in channels) {
channelNamesMap[ch.name.toLowerCase()] = ch.id;
}
});
final usesNativeIosGlassBackButton =
Navigator.canPop(context) &&
Theme.of(context).platform == TargetPlatform.iOS;
return FrostedScaffold(
resizeToAvoidBottomInset: !usesFixedAndroidImeViewport,
appBar: FrostedAppBar(
leading: usesNativeIosGlassBackButton
? IosGlassNavigationButton(
key: const ValueKey('thread-ios-glass-back'),
icon: IosGlassNavigationIcon.back,
semanticLabel: 'Back',
onPressed: () => Navigator.of(context).maybePop(),
width: iosGlassChannelHeaderLeadingWidth,
buttonCenterX: iosGlassChannelHeaderButtonCenterX,
nativeViewSuppressed: messageActionBackdropActive,
)
: null,
iconColor: context.colors.primary,
title: Padding(
padding: EdgeInsets.only(
left: usesNativeIosGlassBackButton
? iosGlassChannelHeaderTitleSpacing
: 0,
),
child: const Text('Thread', key: ValueKey('thread-app-bar-title')),
),
titleStyle: channelTitleTextStyle,
),
body: Stack(
fit: StackFit.expand,
children: [
Column(
children: [
Expanded(
child: _ThreadMessageList(
viewport: listViewport,
onUserScrollStart: () {
hidesLatestForInitialTailSettle.value = false;
hidesLatestForComposerTailCorrection.value = false;
initialTailSettle.abandon();
initialViewportReady.value = true;
tailCorrectionInProgress.value = false;
isNavigatingToThreadTail.value = false;
tailIntent.beginDrag();
userOptedOutOfTailFollow.value = true;
userDragDetachedTailFollow.value = true;
followsThreadTail.value = false;
},
onUserScrollEnd: () {
tailIntent.endDrag();
tailIntent.schedule(
allowed: userOptedOutOfTailFollow.value,
revalidate: () =>
context.mounted &&
itemScrollController.isAttached &&
!tailIntent.isDragging &&
userOptedOutOfTailFollow.value,
action: () {
_resumeThreadTailFollow(
isVisible: threadTailIsVisible,
userOptedOut: userOptedOutOfTailFollow,
followsTail: followsThreadTail,
);
if (!userOptedOutOfTailFollow.value) {
userDragDetachedTailFollow.value = false;
}
},
);
},
visible: threadViewportVisible,
itemScrollController: itemScrollController,
itemPositionsListener: itemPositionsListener,
bottomInset: timelineBottomInset,
replies: replies,
localSendAnimations: localSendAnimations,
trackActiveScrollPosition: trackActiveScrollPosition,
headIsDeleted: liveDeletionHidesHead,
head: liveHead,
stickyDayTimestamp: stickyDayTimestamp,
channelNames: channelNamesMap,
channelId: channelId,
currentPubkey: currentPubkey,
highlightedMessageId: highlightedMessageId.value,
allMessages: allMsgs,
isMember: isMember,
isArchived: isArchived,
composerFocusNode: composerFocusNode,
restoreComposerFocus: () =>
restoreComposerFocus.value?.call(),
childrenByParent: childrenByParent,
),
),
if (!isMember || isArchived)
_ThreadTypingIndicator(entries: threadTyping, animated: false),
],
),
if (threadViewportVisible)
Positioned(
left: 0,
right: 0,
top: frostedAppBarHeight(context) + Grid.twelve,
child: StickyDateHeader(
key: const ValueKey('thread-sticky-date-header'),
state: stickyDateHeaderState,
),
),
if (isMember && !isArchived)
AndroidImeLift(
child: Align(
alignment: Alignment.bottomCenter,
child: ComposerDockSizeReporter(
key: const ValueKey('thread-composer-dock'),
onHeightChanged: updateComposerDockHeight,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ThreadTypingIndicator(entries: threadTyping),
ComposeBar(
channelId: channelId,
focusNode: composerFocusNode,
onFocusRestorerChanged: (restoreFocus) =>
restoreComposerFocus.value = restoreFocus,
hintText: 'Reply in thread\u2026',
threadHeadId: threadHead.id,
rootId: effectiveRootId,
onFocusRequested: followThreadTailFromComposer,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) => sendMessage.call(
channelId: channelId,
content: content,
mentionPubkeys: mentionPubkeys,
channel: channel,
parentEventId: threadHead.id,
rootEventId: effectiveRootId,
mediaTags: mediaTags,
),
),
],
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: navigationBottomInset + Grid.xs,
child: Center(
child: JumpToLatestSwitcher(
id: 'thread',
visible:
threadViewportVisible &&
hasFetchedReplies &&
!isNavigatingToThreadTail.value &&
!hidesLatestForInitialTailSettle.value &&
!hidesLatestForComposerTailCorrection.value &&
!(composerHasFocus && !userDragDetachedTailFollow.value) &&
!isAtThreadTail.value,
onPressed: scrollToThreadLatest,
),
),
),
],
),
);
}
}