mirror of
https://github.com/block/buzz.git
synced 2026-11-08 10:21:46 +00:00
In the mobile composer, Buzz links (channel, message, repo, PR, issue) and selected agent `@mentions` render as chips, with the source text kept as invisible characters behind the chip. Backspace right after a chip removed one of those hidden characters, so the chip turned into a broken raw URL or partial name with no visible change for most of the presses. This PR gives chips one consistent editing model: - **A link is a chip only once something follows it.** Its pattern runs to whitespace or the end of the text, so in practice that is any time the caret isn't at its end. Pasting exactly one link adds a space after it (unless whitespace already follows), so it becomes a chip straight away. - **A link shows its raw URL while the caret is at its end.** Backspacing the space after a chip, or moving the caret back past it, reveals the URL for editing, one character at a time. Moving the caret away turns it back into a chip. A caret at the link's start leaves it a chip. - **Agent `@mention` chips stay atomic.** Backspace right after one removes the whole mention. - **Chips are atomic for selection and forward delete.** A caret placed inside a chip moves to its nearer edge, arrow keys step over it, and a selection that covers part of a chip grows to cover all of it. Forward delete just before a chip removes the whole chip. Typing, pasting, deleting or cutting over a selection replaces exactly the (snapped) selection. The controller records each chip's source range while it builds the spans, so only what is actually drawn as a chip behaves as a unit. Text in code spans and IME compositions are never rewritten. Undo restores the previous text: edits at the end of a revealed link are plain text edits, so undo no longer looks like a backspace into a chip. Human `@mentions` aren't chips today and are unchanged. After this change, in the simulator: a link becomes a chip once a space follows it, backspace reveals the raw URL, the next backspace removes one character, and the arrow keys reveal the link and turn it back into a chip. https://github.com/user-attachments/assets/1d051dd7-92fa-47c9-b569-a7ef5e00ca6f <img src="https://github.com/user-attachments/assets/9980aa01-bcf2-4a1b-9c25-4349ce999e26" width="300" alt="Composer with a channel link chip after a space"> <img src="https://github.com/user-attachments/assets/5dfbaa44-a0f4-4917-b7ca-eeeb63e47dae" width="300" alt="After one backspace the raw URL shows"> <img src="https://github.com/user-attachments/assets/1a675d56-7310-4b13-9dd9-caa26ff99283" width="300" alt="The next backspace removes one character"> ### Validation - Added tests: the composer permalink-chip test now covers the boundary space and reveal (soft and hardware backspace, arrow keys), caret and selection snapping, forward delete, paste adding a space, IME composition, and undo after typing at the end of a link or after completing a link. The agent-chip test covers whole-mention backspace, selection snapping over a mention and a mention in a code span. Breaking each rule (reveal, snapping, paste space, whole-chip delete, widening only inside one chip) fails a test. - Focused existing validation: the full mobile suite (`just mobile-check mobile-test`) passes. - Automated app run: iPhone 17 Pro simulator, iOS 27, debug build of this branch, in a private test channel. Typed a Buzz channel link and a space (chip), backspaced twice (raw URL, then one character removed), retyped the character and space (chip again), then moved the caret left past the space (raw URL) and right again (chip). Nothing was sent. The run covered the composer only; the change touches nothing outside it. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
468 lines
16 KiB
Dart
468 lines
16 KiB
Dart
part of '../compose_bar.dart';
|
|
|
|
class _ComposeBarLayout extends HookWidget {
|
|
final GlobalKey surfaceKey;
|
|
final Widget? voiceNoteRecorder;
|
|
final List<_PendingAttachment> attachments;
|
|
final ValueChanged<int> onRemoveAttachment;
|
|
final String? uploadError;
|
|
final bool isExpanded;
|
|
final _MarkdownEditingController controller;
|
|
final FocusNode focusNode;
|
|
final EditableTextContextMenuBuilder contextMenuBuilder;
|
|
final ValueChanged<KeyboardInsertedContent> onContentInserted;
|
|
final VoidCallback onSend;
|
|
final String resolvedHint;
|
|
final _AttachmentSurface attachmentSurface;
|
|
final ValueChanged<BuildContext> onAttachmentTap;
|
|
final VoidCallback onExpand;
|
|
final Animation<double> expansionAnimation;
|
|
final bool formattingOpen;
|
|
final VoidCallback onCloseFormatting;
|
|
final Duration motionDuration;
|
|
final Duration resizeDuration;
|
|
final void Function(String prefix, [String? suffix]) onFormat;
|
|
final VoidCallback onMention;
|
|
final VoidCallback onChannel;
|
|
final VoidCallback onEmoji;
|
|
final VoidCallback onOpenFormatting;
|
|
final bool canSend;
|
|
final bool hasPendingUploads;
|
|
final bool isSending;
|
|
final bool readOnly;
|
|
|
|
const _ComposeBarLayout({
|
|
required this.surfaceKey,
|
|
required this.voiceNoteRecorder,
|
|
required this.attachments,
|
|
required this.onRemoveAttachment,
|
|
required this.uploadError,
|
|
required this.isExpanded,
|
|
required this.controller,
|
|
required this.focusNode,
|
|
required this.contextMenuBuilder,
|
|
required this.onContentInserted,
|
|
required this.onSend,
|
|
required this.resolvedHint,
|
|
required this.attachmentSurface,
|
|
required this.onAttachmentTap,
|
|
required this.onExpand,
|
|
required this.expansionAnimation,
|
|
required this.formattingOpen,
|
|
required this.onCloseFormatting,
|
|
required this.motionDuration,
|
|
required this.resizeDuration,
|
|
required this.onFormat,
|
|
required this.onMention,
|
|
required this.onChannel,
|
|
required this.onEmoji,
|
|
required this.onOpenFormatting,
|
|
required this.canSend,
|
|
required this.hasPendingUploads,
|
|
required this.isSending,
|
|
this.readOnly = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final recordingTransition = useAnimationController(
|
|
duration: motionDuration,
|
|
reverseDuration: MediaQuery.disableAnimationsOf(context)
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 140),
|
|
initialValue: voiceNoteRecorder == null ? 0 : 1,
|
|
);
|
|
useEffect(() {
|
|
if (voiceNoteRecorder == null) {
|
|
recordingTransition.reverse();
|
|
} else {
|
|
recordingTransition.forward();
|
|
}
|
|
return null;
|
|
}, [voiceNoteRecorder != null, motionDuration]);
|
|
return _DragDownToDismissKeyboard(
|
|
child: _buildBar(context, recordingTransition),
|
|
);
|
|
}
|
|
|
|
Widget _buildBar(
|
|
BuildContext context,
|
|
Animation<double> recordingTransition,
|
|
) {
|
|
final trimmedDraft = controller.text.trim();
|
|
final collapsedText = trimmedDraft.isEmpty
|
|
? resolvedHint
|
|
: trimmedDraft.replaceAll(RegExp(r'\s+'), ' ');
|
|
final hasVoiceNoteAttachment = attachments.any(
|
|
(attachment) => attachment.kind == _PendingAttachmentKind.voiceNote,
|
|
);
|
|
final composerContent = Column(
|
|
key: ValueKey(
|
|
hasVoiceNoteAttachment
|
|
? 'composer-voice-note-preview-content'
|
|
: 'composer-standard-content',
|
|
),
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (attachments.isNotEmpty) ...[
|
|
_AttachmentStrip(
|
|
attachments: attachments,
|
|
onRemove: onRemoveAttachment,
|
|
),
|
|
const SizedBox(height: Grid.xxs),
|
|
],
|
|
if (uploadError case final error?) ...[
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
error,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.error,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: Grid.xxs),
|
|
],
|
|
// Keep the default state out of the focus system entirely so
|
|
// restored native focus cannot expand a newly opened channel.
|
|
if (isExpanded)
|
|
resizeDuration == Duration.zero
|
|
? KeyedSubtree(
|
|
key: const ValueKey('composer-text-height-motion'),
|
|
child: _buildTextField(context),
|
|
)
|
|
: AnimatedSize(
|
|
key: const ValueKey('composer-text-height-motion'),
|
|
alignment: Alignment.topCenter,
|
|
duration: resizeDuration,
|
|
curve: Curves.easeOutCubic,
|
|
child: _buildTextField(context),
|
|
)
|
|
else
|
|
Row(
|
|
children: [
|
|
_AttachmentTrigger(
|
|
surface: attachmentSurface,
|
|
formattingOpen: false,
|
|
onTap: onAttachmentTap,
|
|
),
|
|
const SizedBox(width: Grid.xxs),
|
|
Expanded(
|
|
child: Semantics(
|
|
button: true,
|
|
label: resolvedHint,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: () => _runComposerAction(onExpand),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: Grid.half),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
collapsedText,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: context.textTheme.bodyLarge?.copyWith(
|
|
color: trimmedDraft.isEmpty
|
|
? context.colors.onSurfaceVariant
|
|
: context.colors.onSurface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: Grid.xxs),
|
|
_SendButton(
|
|
isDisabled: !canSend || hasPendingUploads,
|
|
isSending: isSending,
|
|
onTap: onSend,
|
|
),
|
|
],
|
|
),
|
|
_ExpandedComposerActionsMotion(
|
|
animation: expansionAnimation,
|
|
isExpanded: isExpanded,
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: Grid.xxs),
|
|
Row(
|
|
children: [
|
|
_AttachmentTrigger(
|
|
surface: attachmentSurface,
|
|
formattingOpen: formattingOpen,
|
|
onTap: (triggerContext) {
|
|
if (formattingOpen) {
|
|
onCloseFormatting();
|
|
} else {
|
|
onAttachmentTap(triggerContext);
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(width: Grid.half),
|
|
Expanded(
|
|
child: AnimatedSwitcher(
|
|
duration: motionDuration,
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeInCubic,
|
|
layoutBuilder: (currentChild, previousChildren) => Stack(
|
|
alignment: Alignment.centerLeft,
|
|
children: [...previousChildren, ?currentChild],
|
|
),
|
|
child: formattingOpen
|
|
? _FormattingToolbar(onFormat: onFormat)
|
|
: Row(
|
|
key: const ValueKey('standard-actions'),
|
|
children: [
|
|
_ComposeAction(
|
|
icon: BuzzIcons.atSign,
|
|
onTap: onMention,
|
|
),
|
|
_ComposeAction(
|
|
icon: BuzzIcons.hash,
|
|
onTap: onChannel,
|
|
),
|
|
_ComposeAction(
|
|
icon: BuzzIcons.smilePlus,
|
|
onTap: onEmoji,
|
|
),
|
|
_ComposeAction(
|
|
icon: BuzzIcons.aLargeSmall,
|
|
onTap: onOpenFormatting,
|
|
),
|
|
const Spacer(),
|
|
_SendButton(
|
|
isDisabled: !canSend || hasPendingUploads,
|
|
isSending: isSending,
|
|
onTap: onSend,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
final contentMotionDuration = MediaQuery.disableAnimationsOf(context)
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 140);
|
|
final content = ClipRect(
|
|
child: AnimatedSwitcher(
|
|
key: const ValueKey('composer-content-morph'),
|
|
duration: contentMotionDuration,
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeOutCubic,
|
|
layoutBuilder: (currentChild, previousChildren) => Stack(
|
|
alignment: Alignment.bottomCenter,
|
|
children: [...previousChildren, ?currentChild],
|
|
),
|
|
transitionBuilder: (child, animation) => FadeTransition(
|
|
opacity: animation,
|
|
child: SizeTransition(
|
|
sizeFactor: animation,
|
|
alignment: Alignment.bottomCenter,
|
|
child: child,
|
|
),
|
|
),
|
|
child: voiceNoteRecorder == null
|
|
? composerContent
|
|
: KeyedSubtree(
|
|
key: const ValueKey('composer-voice-note-content'),
|
|
child: voiceNoteRecorder!,
|
|
),
|
|
),
|
|
);
|
|
return AnimatedBuilder(
|
|
animation: Listenable.merge([expansionAnimation, recordingTransition]),
|
|
child: content,
|
|
builder: (context, child) {
|
|
final progress = expansionAnimation.value.clamp(0.0, 1.0).toDouble();
|
|
final composerRadius = Radii.dialog + Grid.quarter * (1 - progress);
|
|
final radius = BorderRadius.lerp(
|
|
BorderRadius.circular(composerRadius),
|
|
BorderRadius.circular(Radii.full),
|
|
Curves.easeInOutCubic.transform(recordingTransition.value),
|
|
)!;
|
|
final usesIosConcentricSurface =
|
|
defaultTargetPlatform == TargetPlatform.iOS;
|
|
final voiceNoteInsetProgress = hasVoiceNoteAttachment
|
|
? 1.0
|
|
: recordingTransition.value;
|
|
final composer = KeyedSubtree(
|
|
key: surfaceKey,
|
|
child: Container(
|
|
key: const ValueKey('composer-surface'),
|
|
decoration: BoxDecoration(
|
|
color: usesIosConcentricSurface
|
|
? Colors.transparent
|
|
: context.colors.surfaceContainerHighest,
|
|
borderRadius: radius,
|
|
border: Border.all(
|
|
color: Colors.black.withValues(alpha: 0.04),
|
|
width: 1,
|
|
),
|
|
),
|
|
padding: EdgeInsets.all(
|
|
Grid.xxs + Grid.half * voiceNoteInsetProgress,
|
|
),
|
|
child: child,
|
|
),
|
|
);
|
|
if (!usesIosConcentricSurface) return composer;
|
|
return ConcentricSheetSurface(
|
|
key: const ValueKey('composer-ios-concentric-surface'),
|
|
enabled: true,
|
|
usesGlass: true,
|
|
color: context.colors.surfaceContainerHighest,
|
|
padding: EdgeInsets.zero,
|
|
providesSheetSurface: false,
|
|
minimumRadius: radius.topLeft.x,
|
|
contentClipRadius: radius.topLeft.x,
|
|
child: composer,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildTextField(BuildContext context) {
|
|
return TextField(
|
|
controller: controller,
|
|
focusNode: focusNode,
|
|
readOnly: readOnly,
|
|
inputFormatters: [_ComposerChipEditingFormatter(controller)],
|
|
keyboardType: TextInputType.multiline,
|
|
textInputAction: TextInputAction.newline,
|
|
contextMenuBuilder: contextMenuBuilder,
|
|
// Flutter's Cupertino magnifier rebuilds its overlay on every
|
|
// selection-handle update. Keep the iOS handles and native edit menu,
|
|
// but let the handles track the finger directly here.
|
|
magnifierConfiguration: defaultTargetPlatform == TargetPlatform.iOS
|
|
? TextMagnifierConfiguration.disabled
|
|
: null,
|
|
contentInsertionConfiguration: ContentInsertionConfiguration(
|
|
allowedMimeTypes: _pastedImageMimeTypes,
|
|
onContentInserted: onContentInserted,
|
|
),
|
|
minLines: 1,
|
|
maxLines: 5,
|
|
style: context.textTheme.bodyLarge,
|
|
decoration: InputDecoration(
|
|
hintText: resolvedHint,
|
|
hintStyle: context.textTheme.bodyLarge?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
border: InputBorder.none,
|
|
enabledBorder: InputBorder.none,
|
|
focusedBorder: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: Grid.half,
|
|
vertical: Grid.half,
|
|
),
|
|
isDense: true,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ExpandedComposerActionsMotion extends StatelessWidget {
|
|
final Animation<double> animation;
|
|
final bool isExpanded;
|
|
final Widget child;
|
|
|
|
const _ExpandedComposerActionsMotion({
|
|
required this.animation,
|
|
required this.isExpanded,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: animation,
|
|
child: child,
|
|
builder: (context, child) {
|
|
final value = animation.value;
|
|
final progress = value.clamp(0.0, 1.0).toDouble();
|
|
return ClipRect(
|
|
child: Align(
|
|
alignment: Alignment.topCenter,
|
|
heightFactor: value,
|
|
child: IgnorePointer(
|
|
ignoring: !isExpanded,
|
|
child: Opacity(
|
|
opacity: progress,
|
|
child: Transform.translate(
|
|
offset: Offset(0, Grid.xxs * (1 - progress)),
|
|
child: child,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Drag the compose bar downward to put the keyboard away.
|
|
///
|
|
/// Continues the gesture the message list starts: once your finger reaches the
|
|
/// composer, keep pulling down and the keyboard goes with it. Uses a raw
|
|
/// [Listener] rather than a `GestureDetector` on purpose — a gesture recognizer
|
|
/// here would enter the arena against the `TextField` and could steal taps,
|
|
/// caret placement, and selection drags. A [Listener] only observes.
|
|
class _DragDownToDismissKeyboard extends HookWidget {
|
|
final Widget child;
|
|
|
|
const _DragDownToDismissKeyboard({required this.child});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// A ref, not state: pointer travel must not rebuild the composer, which
|
|
// would churn the TextField mid-gesture.
|
|
final downwardTravel = useRef(0.0);
|
|
final startedInEditable = useRef(false);
|
|
|
|
return Listener(
|
|
onPointerDown: (event) {
|
|
downwardTravel.value = 0;
|
|
final hitTest = HitTestResult();
|
|
RendererBinding.instance.hitTestInView(
|
|
hitTest,
|
|
event.position,
|
|
event.viewId,
|
|
);
|
|
startedInEditable.value = hitTest.path.any(
|
|
(entry) => entry.target is RenderEditable,
|
|
);
|
|
},
|
|
onPointerCancel: (_) {
|
|
downwardTravel.value = 0;
|
|
startedInEditable.value = false;
|
|
},
|
|
onPointerUp: (_) {
|
|
downwardTravel.value = 0;
|
|
startedInEditable.value = false;
|
|
},
|
|
onPointerMove: (event) {
|
|
if (startedInEditable.value) return;
|
|
final dy = event.delta.dy;
|
|
if (dy <= 0) {
|
|
downwardTravel.value = 0;
|
|
return;
|
|
}
|
|
downwardTravel.value += dy;
|
|
if (downwardTravel.value < keyboardDismissDragThreshold) return;
|
|
downwardTravel.value = 0;
|
|
dismissKeyboard(context);
|
|
},
|
|
child: child,
|
|
);
|
|
}
|
|
}
|