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 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( nativeTitle: _isReply ? 'Reply' : 'New note', nativeActions: [ IosNavigationAction( label: isSending.value ? 'Sending…' : _isReply ? 'Reply' : 'Post', onPressed: hasText && !isSending.value ? submit : null, ), ], 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), ), ], ), ); } }