mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
🤖 ## Summary This PR finishes the change on the remaining mobile screens. After it, every place that names a person or agent uses a contextual name: - The channel list, DM labels, and the new-DM picker - The channel header and details, and the members and add-members sheets - Huddle avatars, spotlight, and overlay - The Activity inbox - Search results for people and messages - Pulse notes, agent cards, and reply previews Screens that belong to a channel compare names against its members. Search, Pulse, and the new-DM picker have no channel, so they compare the identities shown together. In the example below, Search showed four results all named "murderbot". Now the human keeps the plain name, and baxen's three agents are labeled by owner and key suffix. Part 3 of 3: #7894 (resolver) → #7895 (conversations) → #7896 (lists, Search, Pulse). ### Related issue Related to #2910. The desktop search change in #6483 addresses the same problem in a different app. ### Testing - Widget tests cover the Activity inbox, Search, and Pulse notes with colliding names. - `flutter analyze` is clean, and the full `flutter test` suite passes. - Checked on an Android emulator against the live relay by searching for "murderbot". The after image also shows "classy-murderbot", a new account that was created between the two captures. | Before | After | | --- | --- | | <img width="300" alt="Before: Search shows four people all named murderbot" src="https://github.com/user-attachments/assets/21bbcc52-fea8-4b31-ab8c-ff3ec9b32a39" /> | <img width="300" alt="After: three results show as baxen's murderbot with a key suffix, and the human shows as murderbot" src="https://github.com/user-attachments/assets/d59584d9-4a35-46da-b39a-83addfad3cd2" /> | --------- Signed-off-by: Logan Johnson <loganj@squareup.com> Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
289 lines
9.1 KiB
Dart
289 lines
9.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:buzz/shared/identity_names/identity_names.dart';
|
|
import 'package:buzz/shared/identity_names/identity_names_provider.dart';
|
|
import 'package:buzz/shared/profile/user_cache_provider.dart';
|
|
import 'package:buzz/shared/profile/user_profile.dart';
|
|
import 'package:buzz/features/pulse/compose_note_page.dart';
|
|
import 'package:buzz/features/pulse/pulse_models.dart';
|
|
import 'package:buzz/shared/theme/theme.dart';
|
|
import 'package:buzz/shared/widgets/avatar_image.dart';
|
|
|
|
class _FakeUserCacheNotifier extends UserCacheNotifier {
|
|
final Map<String, UserProfile> _users;
|
|
_FakeUserCacheNotifier(this._users);
|
|
|
|
@override
|
|
Map<String, UserProfile> build() => _users;
|
|
|
|
@override
|
|
UserProfile? get(String pubkey) => _users[pubkey.toLowerCase()];
|
|
|
|
@override
|
|
Future<bool> preload(List<String> pubkeys) async => true;
|
|
}
|
|
|
|
class _MutableUserCache extends UserCacheNotifier {
|
|
_MutableUserCache(this._initial);
|
|
|
|
final Map<String, UserProfile> _initial;
|
|
|
|
@override
|
|
Map<String, UserProfile> build() => _initial;
|
|
|
|
@override
|
|
UserProfile? get(String pubkey) => state[pubkey.toLowerCase()];
|
|
|
|
@override
|
|
Future<bool> preload(List<String> pubkeys) async => true;
|
|
|
|
void replace(UserProfile profile) =>
|
|
state = {...state, profile.pubkey: profile};
|
|
}
|
|
|
|
void main() {
|
|
final replyNote = UserNote(
|
|
id: 'note1',
|
|
pubkey: 'alice_pk',
|
|
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120,
|
|
content: 'The original note being replied to',
|
|
tags: const [],
|
|
);
|
|
|
|
Widget buildTestable(
|
|
Widget home, {
|
|
TextScaler textScaler = TextScaler.noScaling,
|
|
String displayName = 'Alice',
|
|
Map<String, UserProfile>? users,
|
|
}) {
|
|
return ProviderScope(
|
|
overrides: [
|
|
userCacheProvider.overrideWith(
|
|
() => _FakeUserCacheNotifier(
|
|
users ??
|
|
{
|
|
'alice_pk': UserProfile(
|
|
pubkey: 'alice_pk',
|
|
displayName: displayName,
|
|
),
|
|
},
|
|
),
|
|
),
|
|
],
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Builder(
|
|
builder: (context) => MediaQuery(
|
|
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
|
|
child: home,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
testWidgets('reply mode shows a rich preview of the replied-to note', (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(buildTestable(ComposeNotePage(replyTo: replyNote)));
|
|
await tester.pump();
|
|
|
|
expect(find.text('Replying to Alice'), findsOneWidget);
|
|
expect(find.text('Alice'), findsOneWidget); // author name in the row
|
|
expect(find.textContaining('original note being replied to'), findsWidgets);
|
|
expect(find.text('Reply'), findsOneWidget); // action button label
|
|
// Named parent: the preview avatar initial comes from the authored name.
|
|
expect(_replyPreviewAvatarInitial(tester, 'Replying to Alice'), 'A');
|
|
});
|
|
|
|
testWidgets('a supplied context follows profile changes while open', (
|
|
tester,
|
|
) async {
|
|
final author = 'a' * 64;
|
|
final cache = _MutableUserCache({
|
|
author: UserProfile(pubkey: author, displayName: 'Scout'),
|
|
});
|
|
final note = UserNote(
|
|
id: 'note-live',
|
|
pubkey: author,
|
|
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120,
|
|
content: 'Original',
|
|
tags: const [],
|
|
);
|
|
await tester.pumpWidget(
|
|
ProviderScope(
|
|
overrides: [userCacheProvider.overrideWith(() => cache)],
|
|
child: MaterialApp(
|
|
theme: AppTheme.light(),
|
|
home: Consumer(
|
|
builder: (context, ref, _) {
|
|
// The opener's context, as NoteCard passes it to the route.
|
|
final IdentityNames opened = ref
|
|
.read(identityNameSourcesProvider)
|
|
.scope([author]);
|
|
return ComposeNotePage(replyTo: note, names: opened);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
expect(find.text('Replying to Scout'), findsOneWidget);
|
|
|
|
cache.replace(UserProfile(pubkey: author, displayName: 'Renamed'));
|
|
await tester.pump();
|
|
expect(find.text('Replying to Renamed'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('reply preview constrains its timestamp at large text sizes', (
|
|
tester,
|
|
) async {
|
|
final oldReplyNote = UserNote(
|
|
id: 'old-note',
|
|
pubkey: 'alice_pk',
|
|
createdAt: DateTime.utc(2025, 9, 30, 12).millisecondsSinceEpoch ~/ 1000,
|
|
content: 'An older note',
|
|
tags: const [],
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
buildTestable(
|
|
ComposeNotePage(replyTo: oldReplyNote),
|
|
textScaler: const TextScaler.linear(2),
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
final timestamp = tester.widget<Text>(find.text('Sep 30'));
|
|
expect(timestamp.maxLines, 1);
|
|
expect(timestamp.overflow, TextOverflow.ellipsis);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('gives the reply author unused timestamp width', (tester) async {
|
|
tester.view.devicePixelRatio = 1;
|
|
tester.view.physicalSize = const Size(320, 600);
|
|
addTearDown(() {
|
|
tester.view.resetPhysicalSize();
|
|
tester.view.resetDevicePixelRatio();
|
|
});
|
|
const displayName = 'A moderately long Pulse reply author';
|
|
|
|
await tester.pumpWidget(
|
|
buildTestable(
|
|
ComposeNotePage(replyTo: replyNote),
|
|
displayName: displayName,
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(tester.getSize(find.text(displayName)).width, greaterThan(150));
|
|
expect(find.text('2m'), findsOneWidget);
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('new-note mode shows no reply preview', (tester) async {
|
|
await tester.pumpWidget(buildTestable(const ComposeNotePage()));
|
|
await tester.pump();
|
|
|
|
expect(find.textContaining('Replying to'), findsNothing);
|
|
expect(find.text('Post'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('reply preview stays compact for a short note', (tester) async {
|
|
await tester.pumpWidget(buildTestable(ComposeNotePage(replyTo: replyNote)));
|
|
await tester.pump();
|
|
|
|
// The preview content sits just above the divider; for a one-line note
|
|
// the gap between the content text and the divider must be small (this
|
|
// guards the earlier "preview row way too tall" regression).
|
|
final contentBottom = tester
|
|
.getRect(find.text('The original note being replied to'))
|
|
.bottom;
|
|
final divider = find.byType(Divider);
|
|
expect(divider, findsOneWidget);
|
|
final dividerTop = tester.getRect(divider).top;
|
|
expect(
|
|
dividerTop - contentBottom,
|
|
lessThan(24),
|
|
reason: 'reply preview should hug its content, not reserve tall space',
|
|
);
|
|
});
|
|
|
|
testWidgets('reply preview with an image note clips to a bounded height', (
|
|
tester,
|
|
) async {
|
|
final imageNote = UserNote(
|
|
id: 'note2',
|
|
pubkey: 'alice_pk',
|
|
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60,
|
|
content: '#buzz\n',
|
|
tags: const [
|
|
[
|
|
'imeta',
|
|
'url https://example.com/big.png',
|
|
'm image/png',
|
|
'dim 800x1600',
|
|
],
|
|
],
|
|
);
|
|
await tester.pumpWidget(buildTestable(ComposeNotePage(replyTo: imageNote)));
|
|
await tester.pump();
|
|
|
|
// Renders the rich content (not the raw "" markdown).
|
|
expect(find.textContaining('![image]'), findsNothing);
|
|
// The whole reply context stays bounded: divider sits within a screen of
|
|
// the "Replying to" label (guards a tall image blowing up the page).
|
|
final labelTop = tester.getRect(find.text('Replying to Alice')).top;
|
|
final dividerTop = tester.getRect(find.byType(Divider)).top;
|
|
expect(dividerTop - labelTop, lessThan(220));
|
|
});
|
|
|
|
testWidgets('reply preview keys unnamed parent authors to their hex key', (
|
|
tester,
|
|
) async {
|
|
const a11ce =
|
|
'a11ce00000000000000000000000000000000000000000000000000000000000';
|
|
|
|
final unnamedParentNote = UserNote(
|
|
id: 'note-unnamed-parent',
|
|
pubkey: a11ce,
|
|
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120,
|
|
content: 'The original note being replied to',
|
|
tags: const [],
|
|
);
|
|
|
|
await tester.pumpWidget(
|
|
buildTestable(
|
|
ComposeNotePage(replyTo: unnamedParentNote),
|
|
users: const {},
|
|
),
|
|
);
|
|
await tester.pump();
|
|
|
|
expect(find.text('Replying to npub15yw\u2026ccpw'), findsOneWidget);
|
|
expect(
|
|
_replyPreviewAvatarInitial(tester, 'Replying to npub15yw\u2026ccpw'),
|
|
'A',
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Avatar fallback initial in the reply-context preview, located by the
|
|
/// `Replying to` label it renders beside — the parent author's avatar, not
|
|
/// the composer's.
|
|
String _replyPreviewAvatarInitial(WidgetTester tester, String replyingLabel) {
|
|
final preview = find
|
|
.ancestor(of: find.text(replyingLabel), matching: find.byType(Column))
|
|
.first;
|
|
final avatar = find.descendant(
|
|
of: preview,
|
|
matching: find.byType(AvatarImage),
|
|
);
|
|
final initial = tester.widget<Text>(
|
|
find.descendant(of: avatar, matching: find.byType(Text)),
|
|
);
|
|
return initial.data!;
|
|
}
|