mirror of
https://github.com/block/buzz.git
synced 2026-11-05 08:51:10 +00:00
## 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>
127 lines
3.5 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|