mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
🤖 ## Summary In a channel conversation, two identities with the same display name looked identical. In the example below, the channel shows "Bad Janet" twice, for two different identities. A reader cannot tell which one was added and then removed. This PR uses the contextual names from the previous PR everywhere a conversation names someone: - Message authors, threads, forum posts and replies - Reactions, the typing indicator, and system rows (joined, added, removed) - Inline mentions and the agent activity sheet - The profile sheet title, which now matches the row that opened it Names are compared against the channel's members, so a label only gets longer when a real collision exists in that channel. The mention picker labels each choice against every candidate, so two "Honey" choices look different. When you pick one, it still inserts the identity's own name ("@Honey"), so the message text does not change. Part 2 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists, Search, Pulse). ### Related issue Related to #2910. ### Testing - A widget test checks that the profile sheet names an author the same way the channel does. - A unit test checks that two same-name mention choices get different labels but insert the same wire name. - `flutter analyze` is clean, and the full `flutter test` suite passes. - Checked on an Android emulator against the live relay, in an archived channel with two identities named "Bad Janet". | Before | After | | --- | --- | | <img width="300" alt="Before: the channel shows two different identities both as Bad Janet" src="https://github.com/user-attachments/assets/c7f6c345-12d8-4e11-9434-bbd2c63722db" /> | <img width="300" alt="After: the second identity shows as Bad Janet · sqxl" src="https://github.com/user-attachments/assets/51c6b9fc-8900-4200-8ce0-ad1887f41dba" /> | --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
201 lines
5.9 KiB
Dart
201 lines
5.9 KiB
Dart
part of 'thread_detail_page.dart';
|
|
|
|
const _threadTailScrollTolerance = 0.5;
|
|
|
|
// Keep the direct-position correction finite in case the viewport cannot
|
|
// expose its tail (for example, continuously changing media dimensions).
|
|
const _latestTailCorrectionLimit = 8;
|
|
|
|
Widget _trackActiveThreadScrollPosition(
|
|
Widget child,
|
|
ObjectRef<ScrollPosition?> activePosition,
|
|
) => Builder(
|
|
builder: (context) {
|
|
activePosition.value = Scrollable.of(context).position;
|
|
return child;
|
|
},
|
|
);
|
|
|
|
bool _jumpActiveThreadScrollToTail(
|
|
ObjectRef<ScrollPosition?> activePosition,
|
|
bool Function()? testOverride,
|
|
) {
|
|
if (testOverride != null) return testOverride();
|
|
final position = activePosition.value;
|
|
if (position == null || !position.hasContentDimensions) return false;
|
|
// Moving the one active viewport avoids a second list and its visible bounce.
|
|
position.jumpTo(position.maxScrollExtent);
|
|
return true;
|
|
}
|
|
|
|
Future<bool> _animateActiveThreadScrollToTail(
|
|
BuildContext context,
|
|
ObjectRef<ScrollPosition?> activePosition,
|
|
) async {
|
|
final position = activePosition.value;
|
|
if (position == null || !position.hasContentDimensions) return false;
|
|
if (MediaQuery.disableAnimationsOf(context)) {
|
|
position.jumpTo(position.maxScrollExtent);
|
|
return true;
|
|
}
|
|
await position.animateTo(
|
|
position.maxScrollExtent,
|
|
duration: jumpToLatestScrollDuration,
|
|
curve: jumpToLatestScrollCurve,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/// Returns whether the thread is at its effective scroll end.
|
|
///
|
|
/// Item positions can lag or briefly oscillate during lazy layout, so an exact
|
|
/// end-of-scroll measurement remains authoritative even while the tail item
|
|
/// reports outside the visible boundary.
|
|
@visibleForTesting
|
|
bool threadTailIsAtEffectiveEnd({
|
|
required bool tailIsLaidOut,
|
|
required bool tailIsVisible,
|
|
required double? extentAfter,
|
|
}) =>
|
|
tailIsVisible ||
|
|
(tailIsLaidOut &&
|
|
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 String channelId;
|
|
final List<TypingEntry> entries;
|
|
final bool animated;
|
|
|
|
const _ThreadTypingIndicator({
|
|
required this.channelId,
|
|
required this.entries,
|
|
this.animated = true,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final child = entries.isEmpty
|
|
? const SizedBox.shrink()
|
|
: ChannelTypingIndicator(channelId: channelId, entries: entries);
|
|
if (!animated || MediaQuery.disableAnimationsOf(context)) return child;
|
|
return AnimatedSize(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
alignment: Alignment.bottomCenter,
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Display evidence while the authoritative thread query has no value (still
|
|
/// loading, or failed on first load): the route snapshot plus live, cached
|
|
/// and optimistic replies. The page keeps the query's loading/error status,
|
|
/// so this list is never presented as complete, and nothing is re-queried.
|
|
List<TimelineMessage> _provisionalThreadMessages(
|
|
List<TimelineMessage> routeSnapshot,
|
|
List<TimelineMessage> observed,
|
|
List<NostrEvent> liveChannelEvents,
|
|
) {
|
|
final byId = <String, TimelineMessage>{
|
|
for (final message in routeSnapshot)
|
|
if (!_isDeletedBy(liveChannelEvents, message.id)) message.id: message,
|
|
for (final message in observed) message.id: message,
|
|
};
|
|
return byId.values.toList()..sort((a, b) {
|
|
final order = a.createdAt.compareTo(b.createdAt);
|
|
return order != 0 ? order : a.id.compareTo(b.id);
|
|
});
|
|
}
|