mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +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>
286 lines
11 KiB
Dart
286 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||
|
||
import '../../shared/identity_names/identity_names.dart';
|
||
import '../../shared/identity_names/identity_names_provider.dart';
|
||
import '../../shared/mentions/mention_tags.dart';
|
||
import '../../shared/theme/theme.dart';
|
||
import '../../shared/widgets/avatar_image.dart';
|
||
import '../../shared/widgets/buzz_loading_indicator.dart';
|
||
import '../../shared/widgets/frosted_app_bar.dart';
|
||
import '../../shared/widgets/frosted_scaffold.dart';
|
||
import '../channels/message_content.dart';
|
||
import '../profile/profile_provider.dart';
|
||
import '../../shared/profile/user_cache_provider.dart';
|
||
import 'note_card.dart';
|
||
import 'pulse_actions.dart';
|
||
import 'pulse_models.dart';
|
||
|
||
/// Full-screen page for composing a new note or replying to one.
|
||
///
|
||
/// When [replyTo] is null this composes a new top-level note; when set the
|
||
/// page shows the note being replied to as context and the action button
|
||
/// reads "Reply". The text field auto-focuses on open so the keyboard is
|
||
/// ready immediately.
|
||
class ComposeNotePage extends HookConsumerWidget {
|
||
final UserNote? replyTo;
|
||
|
||
/// The comparison context of the surface that opened the reply, so the
|
||
/// reply context names people as they were shown there. Its labels are
|
||
/// resolved against live naming facts while this page is open.
|
||
final IdentityNames? names;
|
||
|
||
const ComposeNotePage({super.key, this.replyTo, this.names});
|
||
|
||
bool get _isReply => replyTo != null;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final controller = useTextEditingController();
|
||
final focusNode = useFocusNode();
|
||
final isSending = useState(false);
|
||
final hasText = useListenableSelector(
|
||
controller,
|
||
() => controller.text.trim().isNotEmpty,
|
||
);
|
||
final profile = ref.watch(profileProvider).asData?.value;
|
||
|
||
// Auto-focus the field once the page has mounted.
|
||
useEffect(() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
focusNode.requestFocus();
|
||
});
|
||
return null;
|
||
}, const []);
|
||
|
||
Future<void> submit() async {
|
||
if (!hasText || isSending.value) return;
|
||
isSending.value = true;
|
||
try {
|
||
await publishNote(ref, content: controller.text, replyTo: replyTo);
|
||
if (context.mounted) Navigator.of(context).pop(true);
|
||
} catch (_) {
|
||
// Keep the page open so the draft isn't lost; re-enable the button.
|
||
isSending.value = false;
|
||
rethrow;
|
||
}
|
||
}
|
||
|
||
return FrostedScaffold(
|
||
resizeToAvoidBottomInset: true,
|
||
appBar: FrostedAppBar(
|
||
leading: TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Cancel'),
|
||
),
|
||
actions: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: Grid.xxs),
|
||
child: FilledButton(
|
||
onPressed: hasText && !isSending.value ? submit : null,
|
||
style: FilledButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||
minimumSize: const Size(0, 36),
|
||
shape: const StadiumBorder(),
|
||
),
|
||
child: isSending.value
|
||
? SizedBox(
|
||
width: 16,
|
||
height: 16,
|
||
child: BuzzLoadingIndicator(
|
||
size: 16,
|
||
semanticLabel: _isReply
|
||
? 'Sending reply'
|
||
: 'Publishing post',
|
||
),
|
||
)
|
||
: Text(_isReply ? 'Reply' : 'Post'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
body: SafeArea(
|
||
top: false,
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
SizedBox(height: frostedAppBarHeight(context)),
|
||
if (_isReply) _ReplyContext(note: replyTo!, names: names),
|
||
Expanded(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.all(Grid.xs),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
AvatarImage(
|
||
imageUrl: profile?.avatarUrl,
|
||
radius: 18,
|
||
backgroundColor: context.colors.primaryContainer,
|
||
fallback: Text(
|
||
profile?.initial ?? '?',
|
||
style: context.textTheme.labelMedium?.copyWith(
|
||
color: context.colors.onPrimaryContainer,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: Grid.xxs),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: controller,
|
||
focusNode: focusNode,
|
||
minLines: 3,
|
||
maxLines: null,
|
||
autofocus: true,
|
||
keyboardType: TextInputType.multiline,
|
||
textInputAction: TextInputAction.newline,
|
||
style: context.textTheme.bodyLarge,
|
||
decoration: InputDecoration(
|
||
hintText: _isReply
|
||
? 'Post your reply'
|
||
: 'What’s on your mind?',
|
||
border: InputBorder.none,
|
||
enabledBorder: InputBorder.none,
|
||
focusedBorder: InputBorder.none,
|
||
isDense: true,
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
vertical: Grid.half,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A read-only preview of the note being replied to, laid out like a list
|
||
/// row (avatar + name + time + content) but non-interactive. Capped in
|
||
/// height so a long note doesn't push the editor off-screen.
|
||
class _ReplyContext extends ConsumerWidget {
|
||
final UserNote note;
|
||
final IdentityNames? names;
|
||
|
||
const _ReplyContext({required this.note, this.names});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final pubkey = note.pubkey.toLowerCase();
|
||
final profile =
|
||
ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ??
|
||
ref.read(userCacheProvider.notifier).get(pubkey);
|
||
final mentionPubkeys = mentionedPubkeysFromTags(note.tags);
|
||
final labels =
|
||
names?.withSources(ref.watch(identityNameSourcesProvider)) ??
|
||
watchIdentityNames(ref, {pubkey, ...mentionPubkeys});
|
||
final displayName = labels.labelFor(pubkey);
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(Grid.gutter, Grid.xs, Grid.gutter, 0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Replying to $displayName',
|
||
style: context.textTheme.labelMedium?.copyWith(
|
||
color: context.colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
const SizedBox(height: Grid.xs),
|
||
// Mirror the NoteCard list-row layout, read-only. Shrink-wraps to
|
||
// the content; long notes are capped to ~3 lines via the parent
|
||
// page scroll + ellipsis on the content.
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
AvatarImage(
|
||
imageUrl: profile?.avatarUrl,
|
||
radius: 18,
|
||
backgroundColor: context.colors.primaryContainer,
|
||
fallback: Text(
|
||
// Name-derived when the profile is cached; keyed to the
|
||
// hex public key when it isn't, so the compact-npub
|
||
// fallback label doesn't render `N` for every unnamed
|
||
// author.
|
||
profile?.initial ??
|
||
(pubkey.isNotEmpty ? pubkey[0].toUpperCase() : '?'),
|
||
style: context.textTheme.labelMedium?.copyWith(
|
||
color: context.colors.onPrimaryContainer,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: Grid.xs),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
displayName,
|
||
maxLines: 1,
|
||
style: messageUsernameTextStyle,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: Grid.half),
|
||
ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: Grid.xxl),
|
||
child: Text(
|
||
formatPulseRelativeTime(note.createdAt),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: messageTimestampTextStyle.copyWith(
|
||
color: context.colors.onSurfaceVariant,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: Grid.half),
|
||
// Rich content (images, links, mentions) like the list
|
||
// row, but clipped to a compact max height so a tall
|
||
// image or long note can't blow up the page.
|
||
ConstrainedBox(
|
||
constraints: const BoxConstraints(maxHeight: 132),
|
||
child: ClipRect(
|
||
child: Align(
|
||
alignment: Alignment.topLeft,
|
||
heightFactor: 1,
|
||
child: MessageContent(
|
||
content: note.content,
|
||
mentionLabels: {
|
||
for (final key in mentionPubkeys)
|
||
key: labels.labelFor(key),
|
||
},
|
||
tags: note.tags,
|
||
baseStyle: messageBodyTextStyle.copyWith(
|
||
color: context.colors.onSurface,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: Grid.xs),
|
||
Divider(
|
||
height: 1,
|
||
color: context.colors.outlineVariant.withValues(alpha: 0.5),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|