mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
🤖 ## Summary Mobile threads could open above the newest reply because the reply query hydrates across relay pages while the list is still being laid out. Ordinary thread opens now wait for authoritative hydration and late layout before settling on the latest reply. The initial settle is generation-guarded: if another reply arrives while it is pending, the stale target is discarded and the current tail becomes the target. Explicit deep links still own their requested position, existing threads only follow remote replies when the previous tail was visible, and local sends remain visible. ### Related issue No matching issue found. This is separate from the channel unread-navigation behavior in #4239. Originating Buzz thread: `buzz://message?channel=a9081ecd-9be0-400b-8bf9-2e8e0d385b80&id=bfb289fc53754f62f641fbf58bf2d7a9c181a3e6eb09a6ba762aeb6904b6cde4&thread=bfb289fc53754f62f641fbf58bf2d7a9c181a3e6eb09a6ba762aeb6904b6cde4` ### Testing - Added a widget regression covering paginated hydration plus a live reply arriving during the initial settle. - Full mobile Flutter test suite passed; `flutter analyze` passed. - GitHub CI passed, including the Mobile job. - Built, installed, and launched the debug app on an iPad Pro 11-inch (M4), iOS 18.6 simulator. An authenticated manual thread traversal was not performed because the fresh app was not paired to a relay account. --------- Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Signed-off-by: loganj <loganj@squareup.com> Signed-off-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz> Signed-off-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz> Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub1em3jmyn4vu57urqf03txrwreccvejvwdy5c4er8nnrwt7rc4tncscs3ssu <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Brother Darryl <146fb160a3266e6165bfa385f6048c975eda9e21cf65da097a0b5ea7952532a5@buzz.block.builderlab.xyz>
126 lines
3.4 KiB
Dart
126 lines
3.4 KiB
Dart
part of 'thread_detail_page.dart';
|
|
|
|
int _threadTailIndex(int replyCount) => replyCount;
|
|
|
|
double _threadTailTrailingBoundary({
|
|
required bool hasComposerDock,
|
|
required double viewportHeight,
|
|
required double dockHeight,
|
|
}) {
|
|
if (!hasComposerDock) return 1.001;
|
|
if (!viewportHeight.isFinite ||
|
|
viewportHeight <= 0 ||
|
|
!dockHeight.isFinite ||
|
|
dockHeight <= 0) {
|
|
return double.negativeInfinity;
|
|
}
|
|
return 1 - (dockHeight / viewportHeight) + 0.001;
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
class _ThreadTailMetricsObserver with WidgetsBindingObserver {
|
|
final VoidCallback onMetricsChanged;
|
|
|
|
_ThreadTailMetricsObserver({required this.onMetricsChanged});
|
|
|
|
@override
|
|
void didChangeMetrics() => onMetricsChanged();
|
|
}
|
|
|
|
/// 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 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,
|
|
);
|
|
}
|
|
}
|