Files
buzz/mobile/lib/features/channels/thread_detail_helpers.dart
klopez4212 ffb12d3b05 Fix mobile thread tail and iOS channel header (#6399)
## Summary

- Hide the thread Latest control once the lazy tail is fully reached.
- Polish the mobile channel header and use native iOS liquid glass for
Back.
- Keep the two-line header aligned and safe at larger text sizes.

## Validation

- `just mobile-check`
- `flutter test` (1,553 tests)
- Signed iPhone build installed and launched

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Carl <3c4caeafb646d23867f1c4832e68211d77e2561946171625f75c3ce1a3f2670f@buzz.block.builderlab.xyz>
2026-08-21 15:27:30 +01:00

127 lines
3.5 KiB
Dart

part of 'thread_detail_page.dart';
/// Returns whether a bounded tail correction reached the effective end.
///
/// Item positions can lag the active scroll position by a frame, so an exact
/// end-of-scroll measurement is sufficient even while the tail item still
/// reports outside the visible boundary.
@visibleForTesting
bool threadTailCorrectionReachedEnd({
required bool tailIsVisible,
required double? extentAfter,
}) =>
tailIsVisible ||
(extentAfter != null && extentAfter <= _threadTailScrollTolerance);
int _threadTailIndex(int replyCount) => replyCount;
void _resumeThreadTailFollow({
required bool Function() isVisible,
required ObjectRef<bool> userOptedOut,
required ObjectRef<bool> followsTail,
}) {
if (!isVisible()) return;
userOptedOut.value = false;
followsTail.value = true;
}
bool _isDeletedBy(Iterable<NostrEvent> events, String messageId) {
for (final event in events) {
if (event.kind != EventKind.deletion &&
event.kind != EventKind.nip29DeleteEvent) {
continue;
}
if (event.tags.any(
(tag) => tag.length >= 2 && tag[0] == 'e' && tag[1] == messageId,
)) {
return true;
}
}
return false;
}
/// Build a lightweight summary for a nested thread (reply that has its own
/// replies). Same logic as the top-level [ThreadSummary] but kept local to
/// avoid coupling.
ThreadSummary _buildNestedSummary(
String messageId,
List<TimelineMessage> children,
) {
final seen = <String>{};
final participants = <String>[];
for (var i = children.length - 1; i >= 0 && participants.length < 3; i--) {
final pk = children[i].pubkey.toLowerCase();
if (seen.add(pk)) participants.add(pk);
}
return ThreadSummary(
threadHeadId: messageId,
replyCount: children.length,
participantPubkeys: participants.reversed.toList(),
lastReplyAt: children.last.createdAt,
);
}
/// Serializes deferred tail work behind the latest user scroll intent.
class _ThreadTailIntent {
var _generation = 0;
var isDragging = false;
void detach() => _generation++;
void beginDrag() {
isDragging = true;
detach();
}
void endDrag() => isDragging = false;
void scheduleNextFrame({
required bool allowed,
required bool Function() revalidate,
required VoidCallback action,
}) {
if (!allowed) return;
final generation = ++_generation;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (generation == _generation && revalidate()) action();
});
}
void schedule({
required bool allowed,
required bool Function() revalidate,
required VoidCallback action,
}) {
if (!allowed) return;
final generation = ++_generation;
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (generation == _generation && revalidate()) action();
});
WidgetsBinding.instance.scheduleFrame();
});
}
}
/// Thread-scoped typing status with optional size animation.
class _ThreadTypingIndicator extends StatelessWidget {
final List<TypingEntry> entries;
final bool animated;
const _ThreadTypingIndicator({required this.entries, this.animated = true});
@override
Widget build(BuildContext context) {
final child = entries.isEmpty
? const SizedBox.shrink()
: ChannelTypingIndicator(entries: entries);
if (!animated || MediaQuery.disableAnimationsOf(context)) return child;
return AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment: Alignment.bottomCenter,
child: child,
);
}
}