feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
@@ -0,0 +1,10 @@
part of '../compose_bar.dart';
Set<String> _agentMentionLabels({
required Iterable<MentionCandidate> candidates,
}) {
return <String>{
for (final candidate in candidates)
if (candidate.isAgent) candidate.label,
};
}
@@ -0,0 +1,738 @@
part of '../compose_bar.dart';
enum _AttachmentSurface { closed, menu, camera, photos }
const _attachmentMenuWidth = 216.0;
const _attachmentMenuPadding = Grid.xs;
const _attachmentMenuItemHeight = 52.0;
const _attachmentMenuItemSpacing = Grid.xxs;
const _attachmentMenuIconSize = 24.0;
const _attachmentMenuIconSlotWidth = 28.0;
const _attachmentExpandedHeight = 372.0;
@immutable
class _AttachmentMenuLayout {
final double itemHeight;
final double contentHeight;
final double height;
const _AttachmentMenuLayout({
required this.itemHeight,
required this.contentHeight,
required this.height,
});
factory _AttachmentMenuLayout.from(BuildContext context) {
final textPainter = TextPainter(
text: TextSpan(
text: context.l10n.cameraMenu,
style: context.textTheme.titleMedium,
),
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
maxLines: 1,
)..layout();
final itemHeight = math.max(
_attachmentMenuItemHeight,
textPainter.height + (Grid.xxs * 2),
);
textPainter.dispose();
final contentHeight =
(_attachmentMenuPadding * 2) +
(itemHeight * 4) +
(_attachmentMenuItemSpacing * 3);
return _AttachmentMenuLayout(
itemHeight: itemHeight,
contentHeight: contentHeight,
height: math.min(contentHeight, _attachmentExpandedHeight),
);
}
bool get isScrollable => contentHeight > height;
}
class _AttachmentSurfacePanel extends HookWidget {
final _AttachmentSurface surface;
final Widget suggestionPanel;
final VoidCallback onBack;
final VoidCallback onCamera;
final VoidCallback onPhotos;
final VoidCallback onVideo;
final VoidCallback onFiles;
final Future<void> Function(XFile image) onCapture;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
const _AttachmentSurfacePanel({
super.key,
required this.surface,
required this.suggestionPanel,
required this.onBack,
required this.onCamera,
required this.onPhotos,
required this.onVideo,
required this.onFiles,
required this.onCapture,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.onChooseAllPhotos,
});
@override
Widget build(BuildContext context) {
if (surface == _AttachmentSurface.closed) return suggestionPanel;
final menuLayout = _AttachmentMenuLayout.from(context);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final isExpanded =
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos;
final morphController = useAnimationController(
initialValue: isExpanded ? 1 : 0,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 320),
reverseDuration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 250),
);
final rawProgress = useAnimation(morphController);
final renderedExpandedSurface = useState<_AttachmentSurface?>(
isExpanded ? surface : null,
);
final latestSurface = useRef(surface);
latestSurface.value = surface;
useEffect(() {
void disposeCollapsedContent(AnimationStatus status) {
if (status == AnimationStatus.dismissed &&
latestSurface.value == _AttachmentSurface.menu) {
renderedExpandedSurface.value = null;
}
}
morphController.addStatusListener(disposeCollapsedContent);
return () =>
morphController.removeStatusListener(disposeCollapsedContent);
}, [morphController]);
useEffect(() {
if (isExpanded) {
renderedExpandedSurface.value = surface;
morphController.forward();
} else {
morphController.reverse();
}
return null;
}, [isExpanded, morphController, surface]);
final visibleExpandedSurface =
renderedExpandedSurface.value ?? (isExpanded ? surface : null);
final cameraInitializationReady =
reducedMotion ||
(surface == _AttachmentSurface.camera && rawProgress >= 1);
final expandedContent = switch (visibleExpandedSurface) {
_AttachmentSurface.camera => KeyedSubtree(
key: const ValueKey('camera-preview'),
child: KeyedSubtree(
key: ValueKey(
cameraInitializationReady
? 'camera-initialization-ready'
: 'camera-initialization-deferred',
),
child: _InlineCameraPreview(
initializeCamera: cameraInitializationReady,
onClose: onBack,
onCapture: onCapture,
),
),
),
_AttachmentSurface.photos => KeyedSubtree(
key: const ValueKey('photo-gallery'),
child: _PhotoGalleryPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
onChooseAllPhotos: onChooseAllPhotos,
),
),
_AttachmentSurface.closed ||
_AttachmentSurface.menu ||
null => const SizedBox.shrink(),
};
double interval(double value, double begin, double end) {
return ((value - begin) / (end - begin)).clamp(0.0, 1.0);
}
final menuOpacity = 1 - interval(rawProgress, 0.12, 0.38);
final expandedOpacity = interval(rawProgress, 0.28, 0.65);
final sizeProgress = morphController.status == AnimationStatus.reverse
? const Cubic(0.22, 1, 0.36, 1).transform(rawProgress)
: Curves.easeInOutCubic.transform(rawProgress);
return LayoutBuilder(
builder: (context, constraints) {
final expandedWidth = constraints.maxWidth;
const expandedHeight = _attachmentExpandedHeight;
final width =
_attachmentMenuWidth +
((expandedWidth - _attachmentMenuWidth) * sizeProgress);
final height =
menuLayout.height +
((expandedHeight - menuLayout.height) * sizeProgress);
final baseColor = appPopoverColor(context);
final expandedColor =
visibleExpandedSurface == _AttachmentSurface.camera
? Colors.black
: baseColor;
return Align(
alignment: Alignment.topLeft,
heightFactor: 1,
child: SizedBox(
width: width,
height: height,
child: Material(
key: const ValueKey('attachment-surface-popover'),
type: MaterialType.card,
color: Color.lerp(baseColor, expandedColor, sizeProgress),
surfaceTintColor: Colors.transparent,
elevation: appPopoverElevation,
shadowColor: appPopoverShadowColor(context),
shape: appPopoverShape(context),
clipBehavior: Clip.antiAlias,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
left: 0,
top: 0,
width: _attachmentMenuWidth,
height: menuLayout.height,
child: IgnorePointer(
ignoring: surface != _AttachmentSurface.menu,
child: Opacity(
opacity: menuOpacity,
child: _AttachmentMenu(
layout: menuLayout,
onCamera: onCamera,
onPhotos: onPhotos,
onVideo: onVideo,
onFiles: onFiles,
),
),
),
),
Positioned(
left: 0,
top: 0,
width: expandedWidth,
height: expandedHeight,
child: IgnorePointer(
ignoring: !isExpanded,
child: Opacity(
opacity: expandedOpacity,
child: expandedContent,
),
),
),
],
),
),
),
);
},
);
}
}
@immutable
class _ComposeDraftPayload {
final String content;
final List<List<String>> mediaTags;
const _ComposeDraftPayload({required this.content, required this.mediaTags});
factory _ComposeDraftPayload.fromDraft({
required String text,
required List<BlobDescriptor> attachments,
required List<CustomEmoji> customEmoji,
}) {
var content = text;
final mediaTags = <List<String>>[];
for (final attachment in attachments) {
mediaTags.add(attachment.toImetaTag());
content += '\n${attachment.toMarkdownImage()}';
}
mediaTags.addAll(buildCustomEmojiTags(content, customEmoji));
return _ComposeDraftPayload(content: content, mediaTags: mediaTags);
}
}
enum _PendingAttachmentKind { image, video, file }
@immutable
class _PendingAttachment {
static var _nextId = 0;
final int id;
final XFile file;
final _PendingAttachmentKind kind;
final bool deleteAfterUse;
_PendingAttachment({
required this.file,
required this.kind,
this.deleteAfterUse = false,
}) : id = _nextId++;
}
Future<void> _deleteXFile(XFile file) async {
final path = file.path;
if (path.isEmpty) return;
try {
await File(path).delete();
} on FileSystemException {
// Temporary files may already have been removed by the operating system.
}
}
Future<void> _deleteOwnedAttachments(
Iterable<_PendingAttachment> attachments,
) async {
for (final attachment in attachments) {
if (attachment.deleteAfterUse) await _deleteXFile(attachment.file);
}
}
void _useOwnedAttachmentCleanup(
ValueNotifier<List<_PendingAttachment>> attachments,
) {
useEffect(
() =>
() => unawaited(_deleteOwnedAttachments(attachments.value)),
[attachments],
);
}
void _removePendingAttachment(
ValueNotifier<List<_PendingAttachment>> attachments,
ObjectRef<int> draftRevision,
int id,
) {
draftRevision.value += 1;
final removed = attachments.value.where((attachment) => attachment.id == id);
attachments.value = _withoutAttachment(attachments.value, id);
unawaited(_deleteOwnedAttachments(removed));
}
Future<void> _retainAndQueueImages(
BuildContext context,
List<XFile> images,
void Function(List<XFile>, {bool deleteAfterUse}) queueImages,
) async {
final retained = await retainTemporaryImages(images);
if (!context.mounted) {
for (final image in retained) {
await _deleteXFile(image);
}
return;
}
queueImages(retained, deleteAfterUse: true);
}
Future<BlobDescriptor> _uploadPendingAttachment(
MediaUploadService service,
_PendingAttachment attachment, {
ValueChanged<double>? onProgress,
UploadCancellationToken? cancellationToken,
}) => switch (attachment.kind) {
_PendingAttachmentKind.image => service.uploadImage(
attachment.file,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
_PendingAttachmentKind.video => service.uploadVideo(
attachment.file,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
_PendingAttachmentKind.file => service.uploadFile(
attachment.file,
onProgress: onProgress,
cancellationToken: cancellationToken,
),
};
class _AttachmentTrigger extends StatelessWidget {
final _AttachmentSurface surface;
final bool formattingOpen;
final ValueChanged<BuildContext> onTap;
const _AttachmentTrigger({
required this.surface,
required this.formattingOpen,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 240);
return SizedBox.square(
dimension: 36,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
shape: BoxShape.circle,
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: IconButton(
tooltip: switch (surface) {
_AttachmentSurface.closed =>
formattingOpen
? context.l10n.closeFormatting
: context.l10n.addAttachment,
_AttachmentSurface.menu => context.l10n.closeAttachments,
_AttachmentSurface.camera ||
_AttachmentSurface.photos => context.l10n.backAttachmentOptions,
},
onPressed: () => _runComposerAction(() => onTap(context)),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: AnimatedRotation(
duration: duration,
curve: Curves.easeOutBack,
turns: surface == _AttachmentSurface.menu || formattingOpen
? 0.125
: 0,
child: AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1).animate(animation),
child: child,
),
),
child: Icon(
switch (surface) {
_AttachmentSurface.camera => LucideIcons.camera,
_AttachmentSurface.photos => LucideIcons.images,
_AttachmentSurface.closed ||
_AttachmentSurface.menu => LucideIcons.plus,
},
key: ValueKey('attachment-trigger-${surface.name}'),
size: 20,
color: context.colors.onSurfaceVariant,
),
),
),
),
),
);
}
}
class _AttachmentMenu extends StatelessWidget {
final _AttachmentMenuLayout layout;
final VoidCallback onCamera;
final VoidCallback onPhotos;
final VoidCallback onVideo;
final VoidCallback onFiles;
const _AttachmentMenu({
required this.layout,
required this.onCamera,
required this.onPhotos,
required this.onVideo,
required this.onFiles,
});
@override
Widget build(BuildContext context) {
return SizedBox(
key: const ValueKey('attachment-menu'),
width: _attachmentMenuWidth,
height: layout.height,
child: ListView.separated(
key: const ValueKey('attachment-menu-scroll'),
padding: const EdgeInsets.all(_attachmentMenuPadding),
physics: layout.isScrollable
? null
: const NeverScrollableScrollPhysics(),
itemCount: 4,
separatorBuilder: (_, _) =>
const SizedBox(height: _attachmentMenuItemSpacing),
itemBuilder: (context, index) {
final (icon, id, label, onTap) = switch (index) {
0 => (
LucideIcons.camera,
'camera',
context.l10n.cameraMenu,
onCamera,
),
1 => (
LucideIcons.images,
'photos',
context.l10n.photosMenu,
onPhotos,
),
2 => (
LucideIcons.video,
'video',
context.l10n.videoMenu,
onVideo,
),
_ => (
LucideIcons.file,
'files',
context.l10n.filesMenu,
onFiles,
),
};
return _AttachmentMenuItem(
height: layout.itemHeight,
icon: icon,
id: id,
label: label,
onTap: onTap,
);
},
),
);
}
}
class _AttachmentMenuItem extends StatelessWidget {
final double height;
final IconData icon;
final String id;
final String label;
final VoidCallback onTap;
const _AttachmentMenuItem({
required this.height,
required this.icon,
required this.id,
required this.label,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return SizedBox(
key: ValueKey('attachment-menu-item-$id'),
height: height,
child: Tooltip(
message: label,
child: InkWell(
onTap: () => _runComposerAction(onTap),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Row(
children: [
SizedBox(
key: ValueKey('attachment-menu-icon-$id'),
width: _attachmentMenuIconSlotWidth,
child: Center(
child: Icon(
icon,
size: _attachmentMenuIconSize,
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
label,
key: ValueKey(
'attachment-menu-label-$id',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w400,
),
),
),
],
),
),
),
),
);
}
}
List<_PendingAttachment> _withoutAttachment(
List<_PendingAttachment> attachments,
int id,
) {
return [
for (final attachment in attachments)
if (attachment.id != id) attachment,
];
}
class _AttachmentStrip extends StatelessWidget {
final List<_PendingAttachment> attachments;
final ValueChanged<int> onRemove;
const _AttachmentStrip({required this.attachments, required this.onRemove});
@override
Widget build(BuildContext context) {
final thumbWidth = 72.0;
final thumbHeight = 72.0;
return SizedBox(
height: thumbHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: attachments.length,
separatorBuilder: (_, _) => const SizedBox(width: Grid.half),
itemBuilder: (context, index) {
final attachment = attachments[index];
return Container(
key: ValueKey('compose-attachment:${attachment.id}'),
width: thumbWidth,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(Radii.md),
child: attachment.kind == _PendingAttachmentKind.video
? ColoredBox(
color: Colors.black,
child: Center(
child: Icon(
LucideIcons.video,
color: Colors.white,
size: 24,
),
),
)
: attachment.kind == _PendingAttachmentKind.image &&
attachment.file.path.isNotEmpty
? Image.file(
File(attachment.file.path),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(
LucideIcons.image,
color: context.colors.onSurfaceVariant,
),
),
)
: attachment.kind == _PendingAttachmentKind.image
? _MemoryAttachmentImage(file: attachment.file)
: ColoredBox(
color: context.colors.surface,
child: Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LucideIcons.file,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.quarter),
Text(
attachment.file.name.isEmpty
? context.l10n.fileAttachment
: attachment.file.name,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
),
),
Positioned(
top: Grid.quarter,
right: Grid.quarter,
child: SizedBox(
width: 24,
height: 24,
child: IconButton(
onPressed: () =>
_runComposerAction(() => onRemove(attachment.id)),
tooltip: context.l10n.removeAttachment,
visualDensity: VisualDensity.compact,
style: IconButton.styleFrom(
backgroundColor: context.colors.surface.withValues(
alpha: 0.92,
),
minimumSize: const Size(24, 24),
maximumSize: const Size(24, 24),
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Icon(
LucideIcons.x,
size: 14,
color: context.colors.onSurface,
),
),
),
),
],
),
);
},
),
);
}
}
class _MemoryAttachmentImage extends HookConsumerWidget {
final XFile file;
const _MemoryAttachmentImage({required this.file});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bytes = useFuture(useMemoized(() => file.readAsBytes(), [file]));
final data = bytes.data;
if (data == null || data.isEmpty) {
return ColoredBox(
color: context.colors.surface,
child: Icon(LucideIcons.image, color: context.colors.onSurfaceVariant),
);
}
return Image.memory(
data,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => ColoredBox(
color: context.colors.surface,
child: Icon(LucideIcons.image, color: context.colors.onSurfaceVariant),
),
);
}
}
@@ -0,0 +1,322 @@
part of '../compose_bar.dart';
class _InlineCameraPreview extends HookConsumerWidget {
final bool initializeCamera;
final Future<void> Function(XFile image) onCapture;
final VoidCallback onClose;
const _InlineCameraPreview({
required this.initializeCamera,
required this.onCapture,
required this.onClose,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useState<camera.CameraController?>(null);
final controllerRef = useRef<camera.CameraController?>(null);
final isInitializing = useState(true);
final isCapturing = useState(false);
final error = useState<String?>(null);
useEffect(() {
if (!initializeCamera) return null;
var disposed = false;
var generation = 0;
Future<void> disposeCurrent() async {
generation += 1;
final current = controllerRef.value;
controllerRef.value = null;
if (!disposed) controller.value = null;
await current?.dispose();
}
Future<void> initialize() async {
final currentGeneration = ++generation;
if (!disposed) {
isInitializing.value = true;
error.value = null;
}
camera.CameraController? next;
try {
final available = await camera.availableCameras();
if (disposed || currentGeneration != generation) return;
if (available.isEmpty) {
throw camera.CameraException(
'no-cameras',
context.l10n.cameraNoDevices,
);
}
final description = available.firstWhere(
(candidate) =>
candidate.lensDirection == camera.CameraLensDirection.back,
orElse: () => available.first,
);
next = camera.CameraController(
description,
camera.ResolutionPreset.high,
enableAudio: false,
);
await next.initialize();
if (disposed || currentGeneration != generation) {
await next.dispose();
return;
}
controllerRef.value = next;
controller.value = next;
isInitializing.value = false;
} catch (cameraError) {
await next?.dispose();
if (!disposed && currentGeneration == generation) {
error.value = _cameraErrorMessage(context, cameraError);
isInitializing.value = false;
}
}
}
final lifecycleListener = AppLifecycleListener(
onInactive: () => unawaited(disposeCurrent()),
onResume: () => unawaited(initialize()),
);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!disposed) unawaited(initialize());
});
return () {
disposed = true;
lifecycleListener.dispose();
generation += 1;
final current = controllerRef.value;
controllerRef.value = null;
unawaited(current?.dispose() ?? Future<void>.value());
};
}, [initializeCamera]);
Future<void> capture() async {
final activeController = controller.value;
if (activeController == null ||
isCapturing.value ||
activeController.value.isTakingPicture) {
return;
}
isCapturing.value = true;
error.value = null;
try {
final image = await activeController.takePicture();
if (context.mounted) {
await processCapturedImage(image, onCapture);
} else {
await processCapturedImage(image, (_) async {});
}
} catch (captureError) {
if (context.mounted) {
error.value = _cameraErrorMessage(context, captureError);
}
} finally {
if (context.mounted) isCapturing.value = false;
}
}
final activeController = controller.value;
final usesAndroidCameraLayout =
defaultTargetPlatform == TargetPlatform.android;
return ColoredBox(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
if (activeController case final initialized?)
_CameraFeed(controller: initialized)
else
_CameraPlaceholder(
isInitializing: isInitializing.value,
message: error.value,
),
if (activeController != null)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(Grid.twelve),
child: _CameraCaptureButton(
isPressed: isCapturing.value,
onTap: capture,
),
),
),
Positioned(
top: usesAndroidCameraLayout ? null : Grid.xxs,
left: usesAndroidCameraLayout ? Grid.twelve : null,
right: usesAndroidCameraLayout ? null : Grid.xxs,
bottom: usesAndroidCameraLayout
? Grid.twelve + ((_cameraCaptureSize - _cameraBackSize) / 2)
: null,
child: _CameraCloseButton(
onTap: onClose,
emphasized: usesAndroidCameraLayout,
),
),
],
),
);
}
}
class _CameraFeed extends StatelessWidget {
final camera.CameraController controller;
const _CameraFeed({required this.controller});
@override
Widget build(BuildContext context) {
final previewSize = controller.value.previewSize;
if (previewSize == null) return const ColoredBox(color: Colors.black);
return ClipRect(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: previewSize.height,
height: previewSize.width,
child: camera.CameraPreview(controller),
),
),
);
}
}
class _CameraPlaceholder extends StatelessWidget {
final bool isInitializing;
final String? message;
const _CameraPlaceholder({
required this.isInitializing,
required this.message,
});
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.black,
child: Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: isInitializing
? BuzzLoadingIndicator(
size: 44,
color: Colors.white,
semanticLabel: context.l10n.startingCamera,
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.cameraOff,
color: Colors.white,
size: 28,
),
const SizedBox(height: Grid.xxs),
Text(
message ?? context.l10n.cameraUnavailable,
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: Colors.white,
),
),
],
),
),
),
);
}
}
class _CameraCaptureButton extends StatelessWidget {
final bool isPressed;
final VoidCallback onTap;
const _CameraCaptureButton({required this.isPressed, required this.onTap});
@override
Widget build(BuildContext context) {
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 100);
return Semantics(
button: true,
label: context.l10n.takePhoto,
child: GestureDetector(
onTap: isPressed ? null : () => _runComposerAction(onTap),
child: AnimatedScale(
scale: isPressed ? 0.92 : 1,
duration: duration,
curve: Curves.easeOutCubic,
child: Container(
width: _cameraCaptureSize,
height: _cameraCaptureSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.24),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
),
padding: const EdgeInsets.all(Grid.half),
child: const DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
);
}
}
class _CameraCloseButton extends StatelessWidget {
final VoidCallback onTap;
final bool emphasized;
const _CameraCloseButton({required this.onTap, required this.emphasized});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: emphasized ? _cameraBackSize : 36,
child: IconButton(
onPressed: () => _runComposerAction(onTap),
tooltip: context.l10n.backAttachmentOptions,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(
alpha: emphasized ? 0.68 : 0.56,
),
foregroundColor: Colors.white,
),
icon: Icon(LucideIcons.arrowLeft, size: emphasized ? 24 : 18),
),
);
}
}
const _cameraCaptureSize = 64.0;
const _cameraBackSize = 44.0;
String _cameraErrorMessage(BuildContext context, Object error) {
if (error is camera.CameraException) {
return switch (error.code) {
'CameraAccessDenied' ||
'CameraAccessDeniedWithoutPrompt' ||
'CameraAccessRestricted' => context.l10n.cameraAccessDisabled,
'no-cameras' => context.l10n.cameraUnavailableDevice,
_ => context.l10n.cameraStartRetry,
};
}
return context.l10n.cameraStartRetry;
}
@@ -0,0 +1,967 @@
part of '../compose_bar.dart';
/// Rich compose bar with @mention autocomplete and a markdown formatting
/// toolbar. Used in both channel and thread views — the caller provides an
/// [onSend] callback that handles actual message submission.
typedef ComposeBarOnSend =
Future<void> Function(
String content,
List<String> mentionPubkeys, {
List<List<String>> mediaTags,
});
class ComposeBar extends HookConsumerWidget {
final String channelId;
final String channelName;
final String? hintText;
final ComposeBarOnSend onSend;
/// Optional thread IDs for thread-scoped typing indicators.
final String? threadHeadId;
final String? rootId;
const ComposeBar({
super.key,
required this.channelId,
this.channelName = '',
this.hintText,
this.threadHeadId,
this.rootId,
required this.onSend,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useMemoized(_MarkdownEditingController.new);
useListenable(controller);
useEffect(() => controller.dispose, [controller]);
// Restore and persist unsent text as a local draft so the Activity
// inbox Drafts filter reflects real composer state.
//
// The effect is additionally keyed on the active relay + pubkey identity:
// provider-level namespacing alone cannot protect a composer that stays
// mounted through an in-place community/account switch — the controller
// would retain the old identity's text and the next edit would persist it
// into the new identity's store. On identity change we replace the
// controller content with the new identity's own saved draft (or clear).
final draftKey = composeDraftKey(channelId, threadHeadId: threadHeadId);
final draftRevision = useRef(0);
final draftIdentity =
'${ref.watch(relayConfigProvider).baseUrl}'
':${ref.watch(myPubkeyProvider) ?? 'anon'}';
final focusNode = useFocusNode();
useEffect(
() =>
() => _dismissComposerKeyboard(focusNode),
[focusNode],
);
final isComposerExpanded = useState(false);
final isEmojiPickerOpen = useState(false);
final attachmentSurface = useState(_AttachmentSurface.closed);
final iosAttachmentPopover = useMemoized(
_IOSAttachmentPopoverController.new,
);
useEffect(
() =>
() => unawaited(iosAttachmentPopover.dispose()),
[iosAttachmentPopover],
);
final isSending = useState(false);
final showFormatting = useState(false);
final attachments = useState<List<_PendingAttachment>>([]);
_useOwnedAttachmentCleanup(attachments);
final uploadError = useState<String?>(null);
final uploadingCount = useState(0);
final uploadProgress = useState(0.0);
final uploadGeneration = useRef(0);
final activeUploadCancellation = useRef<UploadCancellationToken?>(null);
_useComposeDraftLifecycle(
ref: ref,
controller: controller,
draftKey: draftKey,
channelId: channelId,
threadHeadId: threadHeadId,
draftIdentity: draftIdentity,
draftRevision: draftRevision,
attachments: attachments,
uploadGeneration: uploadGeneration,
activeUploadCancellation: activeUploadCancellation,
uploadingCount: uploadingCount,
isSending: isSending,
attachmentSurface: attachmentSurface,
uploadError: uploadError,
iosAttachmentPopover: iosAttachmentPopover,
);
final clipboardHasImage = useState(false);
final hasAttachments = attachments.value.isNotEmpty;
final customEmoji = ref.watch(customEmojiListProvider);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final composerExpansionController = useAnimationController(
initialValue: 0,
upperBound: 1.05,
);
final composerExpansionValue = useAnimation(composerExpansionController);
final composerExpansionProgress = composerExpansionValue
.clamp(0.0, 1.0)
.toDouble();
void collapseComposer() {
if (!isComposerExpanded.value) return;
showFormatting.value = false;
isComposerExpanded.value = false;
}
useEffect(() {
void collapseWhenUnfocused() {
if (!focusNode.hasFocus && !isEmojiPickerOpen.value) {
collapseComposer();
}
}
focusNode.addListener(collapseWhenUnfocused);
return () => focusNode.removeListener(collapseWhenUnfocused);
}, [focusNode]);
final appView = View.of(context);
useEffect(() {
final observer = _ComposerKeyboardMetricsObserver(
view: appView,
onKeyboardHidden: () {
collapseComposer();
focusNode.unfocus();
},
);
WidgetsBinding.instance.addObserver(observer);
return () => WidgetsBinding.instance.removeObserver(observer);
}, [appView, focusNode]);
final resolvedHint = hintText ??
(channelName.isNotEmpty
? context.l10n.messageChannelHint(channelName)
: context.l10n.messageHint);
useEffect(() {
final target = isComposerExpanded.value ? 1.0 : 0.0;
if (reducedMotion) {
composerExpansionController.value = target;
} else if ((composerExpansionController.value - target).abs() > 0.001) {
composerExpansionController.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 220),
bounce: 0.08,
),
composerExpansionController.value,
target,
0,
snapToEnd: true,
),
);
}
return null;
}, [isComposerExpanded.value, reducedMotion]);
useEffect(() {
if (defaultTargetPlatform != TargetPlatform.iOS) return null;
var disposed = false;
Future<void> refreshClipboardAvailability() async {
final hasImage = await ref
.read(mediaUploadServiceProvider)
.clipboardHasImage();
if (!disposed && context.mounted) {
clipboardHasImage.value = hasImage;
}
}
void refreshWhenFocused() {
if (focusNode.hasFocus) refreshClipboardAvailability();
}
final lifecycleListener = AppLifecycleListener(
onResume: refreshClipboardAvailability,
);
focusNode.addListener(refreshWhenFocused);
refreshClipboardAvailability();
return () {
disposed = true;
focusNode.removeListener(refreshWhenFocused);
lifecycleListener.dispose();
};
}, [focusNode]);
// Mention state --------------------------------------------------------
final mentionQuery = useState<String?>(null);
final mentionStartIdx = useState(-1);
// Map of displayName → selected mention candidate built as the user selects
// mentions. Used to pass resolved pubkeys directly to onSend and to attach
// selected non-member agents before the message is published.
final mentionMap = useRef(<String, MentionCandidate>{});
// Channel autocomplete state ----------------------------------------------
final channelQuery = useState<String?>(null);
final channelStartIdx = useState(-1);
final channelsAsync = ref.watch(channelsProvider);
final membersAsync = ref.watch(channelMembersProvider(channelId));
final currentPubkey = ref.watch(currentPubkeyProvider);
final userCache = ref.watch(userCacheProvider);
final isDmChannel =
channelsAsync.asData?.value.any((c) => c.id == channelId && c.isDm) ??
false;
// Preload profiles for channel members, mentionable agents, and their
// owners so @mention suggestions show names ("managed by …" included).
final relayAgents = ref.watch(agentDirectoryProvider).asData?.value;
final agentOwners = ref.watch(agentOwnersProvider).asData?.value;
final agentMentionLabels = _agentMentionLabels(
candidates: mentionMap.value.values,
);
final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join(
'\u0000',
);
useEffect(() {
controller.setAgentMentionNames(agentMentionLabels);
return null;
}, [controller, agentMentionLabelsKey]);
useEffect(
() {
final memberList = membersAsync.asData?.value ?? <ChannelMember>[];
final pubkeys = [
...memberList.map((m) => m.pubkey),
...?relayAgents?.map((a) => a.pubkey),
...?agentOwners?.values,
];
if (pubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(pubkeys);
}
return null;
},
[
membersAsync.asData?.value.length,
relayAgents?.length,
agentOwners?.length,
],
);
// Typing indicator broadcast — throttled to one event per 3 seconds.
final lastTypingSentMs = useRef(0);
final isModifyingText = useRef(false);
// Detect @mention query and broadcast typing on text / selection change.
useEffect(() {
void listener() {
if (isModifyingText.value) return;
final text = controller.text;
final sel = controller.selection;
// Broadcast typing indicator (throttled).
if (text.isNotEmpty) {
final now = DateTime.now().millisecondsSinceEpoch;
if (now - lastTypingSentMs.value > _typingThrottleMs) {
lastTypingSentMs.value = now;
_sendTypingIndicator(
ref,
channelId: channelId,
threadHeadId: threadHeadId,
rootId: rootId,
);
}
}
if (!sel.isValid || !sel.isCollapsed) {
mentionQuery.value = null;
channelQuery.value = null;
return;
}
final cursor = sel.baseOffset;
if (cursor < 1) {
mentionQuery.value = null;
channelQuery.value = null;
return;
}
// Walk backward from cursor looking for trigger characters.
// stopAtSpace: false — @mentions support multi-word display names.
final atPos = findTrigger(text, cursor, '@', stopAtSpace: false);
if (atPos != null) {
mentionQuery.value = text.substring(atPos + 1, cursor).toLowerCase();
mentionStartIdx.value = atPos;
channelQuery.value = null;
} else {
mentionQuery.value = null;
}
// Channel autocomplete detection — only when no @mention is active.
if (mentionQuery.value == null) {
final hashPos = findTrigger(text, cursor, '#');
if (hashPos != null) {
channelQuery.value = text
.substring(hashPos + 1, cursor)
.toLowerCase();
channelStartIdx.value = hashPos;
} else {
channelQuery.value = null;
}
} else {
channelQuery.value = null;
}
}
controller.addListener(listener);
return () => controller.removeListener(listener);
}, [controller]);
// Ranked mention candidates (desktop-parity ordering + eligibility).
final suggestions = mentionQuery.value == null
? const <MentionCandidate>[]
: ref
.watch(
mentionCandidatesProvider((
channelId: channelId,
query: mentionQuery.value!,
)),
)
.take(_mentionSuggestionLimit)
.toList();
// Resolve owner names for the visible "managed by …" subtitles.
useEffect(() {
final ownerPubkeys = [for (final s in suggestions) ?s.ownerPubkey];
if (ownerPubkeys.isNotEmpty) {
ref.read(userCacheProvider.notifier).preload(ownerPubkeys);
}
return null;
}, [suggestions.length, mentionQuery.value]);
// Filter channels against the query.
final channels = channelsAsync.asData?.value ?? <Channel>[];
final channelSuggestions = filterChannels(channels, channelQuery.value);
// Insert a selected mention into the text field.
void insertMention(MentionCandidate candidate) {
final name = candidate.label;
// Track the resolved candidate so we can pass its pubkey and prepare
// selected non-member agents at send time.
mentionMap.value[name] = candidate;
final start = mentionStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '@$name ',
);
mentionQuery.value = null;
}
// Insert a selected channel into the text field.
void insertChannel(Channel channel) {
final start = channelStartIdx.value.clamp(0, controller.text.length);
spliceAndMoveCursor(
controller,
focusNode,
start: start,
replacement: '#${channel.name} ',
);
channelQuery.value = null;
}
// Insert `@` at the cursor to manually trigger mention mode.
void triggerMention() => _insertTriggerAtCursor(controller, focusNode, '@');
// Insert `#` at the cursor to manually trigger channel mode.
void triggerChannel() => _insertTriggerAtCursor(controller, focusNode, '#');
// Insert a selected emoji at the cursor without replacing the draft.
void insertEmoji(String emoji) {
final text = controller.text;
final selection = controller.selection;
final cursor = selection.isValid
? selection.baseOffset.clamp(0, text.length)
: text.length;
controller.value = TextEditingValue(
text: text.replaceRange(cursor, cursor, emoji),
selection: TextSelection.collapsed(offset: cursor + emoji.length),
);
focusNode.requestFocus();
}
void clearComposer() {
draftRevision.value += 1;
controller.clear();
attachments.value = [];
mentionMap.value.clear();
mentionQuery.value = null;
channelQuery.value = null;
attachmentSurface.value = _AttachmentSurface.closed;
showFormatting.value = false;
uploadError.value = null;
focusNode.requestFocus();
}
void removeAttachment(int id) {
_removePendingAttachment(attachments, draftRevision, id);
}
// Send the message.
Future<void> send() async {
final text = controller.text.trim();
if ((text.isEmpty && !hasAttachments) ||
isSending.value ||
uploadingCount.value > 0) {
return;
}
// Resolved before any await: see
// `_reportSendCancelledByCommunitySwitch`.
final messenger = ScaffoldMessenger.maybeOf(context);
final communityChangedMessage =
context.l10n.messageNotSentCommunityChanged;
// Extract pubkeys for mentions present in the final text.
final selectedMentions = <MentionCandidate>[
for (final entry in mentionMap.value.entries)
if (hasMention(text, entry.key)) entry.value,
];
final outgoing = _OutgoingMentions(selectedMentions);
final scan = await _scanNonMemberMentions(
ref,
channelId: channelId,
selectedMentions: selectedMentions,
currentPubkey: currentPubkey,
);
// Mentioning humans outside the channel prompts "Invite" / "Do
// nothing" (send without inviting) — mirrors desktop's
// NonMemberMentionDialog. Agents keep the existing silent auto-add.
if (scan.humans.isNotEmpty) {
if (!context.mounted) return;
final choice = await _promptNonMemberMention(
context,
names: [for (final candidate in scan.humans) candidate.label],
canInvite: scan.canAddMembers,
);
if (choice == null) return; // Dismissed — keep the draft, send nothing.
outgoing.resolveHumanChoice(choice, scan.humans);
}
final queuedAttachments = List<_PendingAttachment>.of(attachments.value);
final channelActions = ref.read(channelActionsProvider);
// An add that was refused doesn't block the message: it is reported and
// the un-added mentions are demoted to reference tags so the send lands.
Future<void> addMentionedNonMembers() => outgoing.addNonMembers(
channelActions,
scan: scan,
messenger: messenger,
privateChannelError: context.l10n.privateChannelAddDenied,
);
isSending.value = true;
try {
if (queuedAttachments.isEmpty) {
try {
await addMentionedNonMembers();
final payload = _ComposeDraftPayload.fromDraft(
text: text,
attachments: const [],
customEmoji: customEmoji,
);
await onSend(
payload.content,
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
if (context.mounted) clearComposer();
} on StateError {
_reportSendCancelledByCommunitySwitch(
messenger,
communityChangedMessage,
);
} catch (error) {
// send() runs unawaited, so a relay rejection or publish timeout
// would otherwise vanish with the composer looking idle. The draft
// is kept (clearComposer never ran) so the user can retry.
messenger?.showSnackBar(
SnackBar(content: Text(_composeSendErrorMessage(error))),
);
}
return;
}
final draftText = controller.value;
final draftAttachments = List<_PendingAttachment>.of(attachments.value);
final draftMentions = Map<String, MentionCandidate>.of(
mentionMap.value,
);
clearComposer();
final clearedDraftRevision = draftRevision.value;
uploadingCount.value += 1;
uploadProgress.value = 0;
isSending.value = false;
final queueGeneration = uploadGeneration.value;
final cancellation = UploadCancellationToken();
final uploadService = ref.read(mediaUploadServiceProvider);
activeUploadCancellation.value = cancellation;
final delivery = onSend;
unawaited(() async {
var retainedForRetry = false;
try {
final uploaded = <BlobDescriptor>[];
for (var index = 0; index < queuedAttachments.length; index++) {
final attachment = queuedAttachments[index];
final descriptor = await _uploadPendingAttachment(
uploadService,
attachment,
onProgress: (progress) {
if (context.mounted) {
uploadProgress.value =
(index + progress) / queuedAttachments.length;
}
},
cancellationToken: cancellation,
);
if (queueGeneration != uploadGeneration.value) return;
uploaded.add(descriptor);
if (context.mounted) {
uploadProgress.value = (index + 1) / queuedAttachments.length;
}
}
final payload = _ComposeDraftPayload.fromDraft(
text: text,
attachments: uploaded,
customEmoji: customEmoji,
);
if (queueGeneration != uploadGeneration.value) return;
await addMentionedNonMembers();
if (queueGeneration != uploadGeneration.value) return;
await delivery(
payload.content,
outgoing.pubkeys,
mediaTags: [...payload.mediaTags, ...outgoing.referenceTags],
);
} catch (error) {
if (cancellation.isCancelled) return;
if (context.mounted) uploadError.value = _formatUploadError(error);
if (context.mounted &&
queueGeneration == uploadGeneration.value &&
draftRevision.value == clearedDraftRevision) {
controller.value = draftText;
attachments.value = draftAttachments;
retainedForRetry = true;
mentionMap.value
..clear()
..addAll(draftMentions);
focusNode.requestFocus();
}
} finally {
if (!retainedForRetry) {
await _deleteOwnedAttachments(queuedAttachments);
}
if (activeUploadCancellation.value == cancellation) {
activeUploadCancellation.value = null;
}
if (context.mounted && queueGeneration == uploadGeneration.value) {
uploadingCount.value = math.max(0, uploadingCount.value - 1);
}
}
}());
} finally {
if (context.mounted && isSending.value) isSending.value = false;
}
}
void queueAttachment(
XFile file,
_PendingAttachmentKind kind, {
bool deleteAfterUse = false,
}) {
draftRevision.value += 1;
uploadError.value = null;
attachments.value = [
...attachments.value,
_PendingAttachment(
file: file,
kind: kind,
deleteAfterUse: deleteAfterUse,
),
];
}
Future<void> pickThenQueue({
required Future<XFile?> Function() pick,
required _PendingAttachmentKind kind,
}) async {
uploadError.value = null;
try {
final picked = await pick();
if (picked == null || !context.mounted) return;
queueAttachment(picked, kind);
} catch (error) {
if (context.mounted) {
uploadError.value = _formatUploadError(error);
}
}
}
void queueImages(List<XFile> images, {bool deleteAfterUse = false}) {
if (images.isEmpty) return;
draftRevision.value += 1;
uploadError.value = null;
attachments.value = [
...attachments.value,
for (final image in images)
_PendingAttachment(
file: image,
kind: _PendingAttachmentKind.image,
deleteAfterUse: deleteAfterUse,
),
];
}
Future<void> retainAndQueueImages(List<XFile> images) =>
_retainAndQueueImages(context, images, queueImages);
Widget buildContextMenu(
BuildContext context,
EditableTextState editableTextState,
) {
void pasteImage() {
ContextMenuController.removeAny();
unawaited(() async {
try {
final image = await ref
.read(mediaUploadServiceProvider)
.readClipboardImage(filename: context.l10n.pastedImage);
if (image != null && context.mounted) {
queueAttachment(image, _PendingAttachmentKind.image);
} else if (context.mounted) {
uploadError.value = context.l10n.unableReadPastedImage;
}
} catch (error) {
if (context.mounted) uploadError.value = _formatUploadError(error);
}
}());
}
if (defaultTargetPlatform == TargetPlatform.iOS &&
SystemContextMenu.isSupportedByField(editableTextState)) {
return SystemContextMenu.editableText(
editableTextState: editableTextState,
items: [
if (clipboardHasImage.value)
IOSSystemContextMenuItemCustom(
title: context.l10n.pasteImage,
onPressed: pasteImage,
),
...SystemContextMenu.getDefaultItems(editableTextState),
],
);
}
final buttonItems = [...editableTextState.contextMenuButtonItems];
if (defaultTargetPlatform == TargetPlatform.iOS &&
clipboardHasImage.value) {
buttonItems.insert(
0,
ContextMenuButtonItem(
label: context.l10n.pasteImage,
onPressed: pasteImage,
),
);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
}
void uploadPastedImage(KeyboardInsertedContent content) {
final bytes = content.data;
if (bytes == null || bytes.isEmpty) {
uploadError.value = context.l10n.unableReadPastedImage;
return;
}
queueAttachment(
XFile.fromData(bytes, name: context.l10n.pastedImage),
_PendingAttachmentKind.image,
);
}
// Wrap (or insert) markdown formatting around the current selection.
void applyFormat(String prefix, [String? suffix]) {
suffix ??= prefix;
final text = controller.text;
final sel = controller.selection;
if (!sel.isValid) return;
isModifyingText.value = true;
try {
if (sel.isCollapsed) {
final offset = sel.baseOffset;
final updated =
'${text.substring(0, offset)}$prefix$suffix${text.substring(offset)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: offset + prefix.length,
);
} else {
final selected = text.substring(sel.start, sel.end);
final updated =
'${text.substring(0, sel.start)}$prefix$selected$suffix${text.substring(sel.end)}';
controller.text = updated;
controller.selection = TextSelection.collapsed(
offset: sel.start + prefix.length + selected.length + suffix.length,
);
}
} finally {
isModifyingText.value = false;
}
focusNode.requestFocus();
}
// ----- Widget tree ----------------------------------------------------
void chooseAttachment(
Future<void> Function() choose, {
String? errorMessage,
}) {
attachmentSurface.value = _AttachmentSurface.closed;
unawaited(() async {
try {
await choose();
} catch (error) {
if (context.mounted) {
uploadError.value = errorMessage ?? _formatUploadError(error);
}
}
}());
}
void toggleAttachments() {
attachmentSurface.value = switch (attachmentSurface.value) {
_AttachmentSurface.closed => _AttachmentSurface.menu,
_AttachmentSurface.menu => _AttachmentSurface.closed,
_AttachmentSurface.camera ||
_AttachmentSurface.photos => _AttachmentSurface.menu,
};
}
void handleAttachmentTap(BuildContext triggerContext) {
if (defaultTargetPlatform != TargetPlatform.iOS ||
attachmentSurface.value != _AttachmentSurface.closed) {
toggleAttachments();
return;
}
unawaited(
iosAttachmentPopover
.present(
sourceContext: triggerContext,
onCapture: (image) => retainAndQueueImages([image]),
onChoosePhotos: retainAndQueueImages,
onAllPhotos: () => chooseAttachment(() async {
final photos = await ref
.read(mediaUploadServiceProvider)
.pickGalleryImages();
queueImages(photos);
}, errorMessage: context.l10n.unableOpenPhotoLibrary),
onVideo: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickGalleryVideo,
kind: _PendingAttachmentKind.video,
);
}),
onFiles: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickAttachmentFile,
kind: _PendingAttachmentKind.file,
);
}),
)
.then((didPresent) {
if (!didPresent && context.mounted) {
focusNode.unfocus();
toggleAttachments();
}
}),
);
}
void openCamera() {
focusNode.unfocus();
attachmentSurface.value = _AttachmentSurface.camera;
}
final motionDuration = reducedMotion
? Duration.zero
: Duration(
milliseconds:
attachmentSurface.value == _AttachmentSurface.camera ||
attachmentSurface.value == _AttachmentSurface.photos
? 320
: 250,
);
final suggestionOverlayController = useMemoized(
OverlayPortalController.new,
);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) suggestionOverlayController.show();
});
return null;
}, [suggestionOverlayController]);
void expandComposer() {
if (isComposerExpanded.value) return;
attachmentSurface.value = _AttachmentSurface.closed;
isComposerExpanded.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) focusNode.requestFocus();
});
}
final suggestionPanel = channelSuggestions.isNotEmpty
? KeyedSubtree(
key: const ValueKey('channel-suggestions'),
child: _ChannelSuggestions(
suggestions: channelSuggestions,
onSelect: insertChannel,
),
)
: suggestions.isNotEmpty
? KeyedSubtree(
key: const ValueKey('mention-suggestions'),
child: _MentionSuggestions(
suggestions: suggestions,
userCache: userCache,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
onSelect: insertMention,
),
)
: const SizedBox.shrink(key: ValueKey('no-suggestions'));
Widget buildOverlayPanel(_AttachmentSurface surface) {
return _AttachmentSurfacePanel(
key: ValueKey(
surface == _AttachmentSurface.closed
? 'composer-suggestions'
: 'attachment-surface',
),
surface: surface,
suggestionPanel: suggestionPanel,
onBack: () => attachmentSurface.value = _AttachmentSurface.menu,
onCamera: openCamera,
onPhotos: () {
focusNode.unfocus();
attachmentSurface.value = _AttachmentSurface.photos;
},
onVideo: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickGalleryVideo,
kind: _PendingAttachmentKind.video,
);
}),
onFiles: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenQueue(
pick: service.pickAttachmentFile,
kind: _PendingAttachmentKind.file,
);
}),
onCapture: (image) async {
attachmentSurface.value = _AttachmentSurface.closed;
await retainAndQueueImages([image]);
},
onPickAllPhotos: ref.read(mediaUploadServiceProvider).pickGalleryImages,
onChoosePhotos: (photos) async {
attachmentSurface.value = _AttachmentSurface.closed;
await retainAndQueueImages(photos);
},
onChooseAllPhotos: (photos) async {
attachmentSurface.value = _AttachmentSurface.closed;
queueImages(photos);
},
);
}
// Suggestions and attachments live in the overlay so showing them cannot
// reflow the composer. Both stay anchored just above the capsule.
final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress;
final hasPendingUploads = uploadingCount.value > 0;
return _ComposerDockFrame(
widthFactor: composerWidthFactor,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_UploadProgressMotion(
visible: hasPendingUploads,
progress: uploadProgress.value,
reducedMotion: reducedMotion,
onCancel: () {
activeUploadCancellation.value?.cancel();
uploadGeneration.value += 1;
uploadingCount.value = 0;
uploadProgress.value = 0;
},
),
_ComposerOverlayPortal(
controller: suggestionOverlayController,
attachmentSurface: attachmentSurface,
reducedMotion: reducedMotion,
buildOverlayPanel: buildOverlayPanel,
onDismissAttachmentSurface: () {
attachmentSurface.value = _AttachmentSurface.closed;
},
child: _ComposeBarLayout(
attachments: attachments.value,
onRemoveAttachment: removeAttachment,
uploadError: uploadError.value,
isExpanded: isComposerExpanded.value,
controller: controller,
focusNode: focusNode,
contextMenuBuilder: buildContextMenu,
onContentInserted: uploadPastedImage,
onSend: () => unawaited(send()),
resolvedHint: resolvedHint,
attachmentSurface: attachmentSurface.value,
onAttachmentTap: handleAttachmentTap,
onExpand: expandComposer,
expansionValue: composerExpansionValue,
expansionProgress: composerExpansionProgress,
formattingOpen: showFormatting.value,
onCloseFormatting: () => showFormatting.value = false,
motionDuration: motionDuration,
onFormat: applyFormat,
onMention: () {
attachmentSurface.value = _AttachmentSurface.closed;
triggerMention();
},
onChannel: () {
attachmentSurface.value = _AttachmentSurface.closed;
triggerChannel();
},
onEmoji: () {
attachmentSurface.value = _AttachmentSurface.closed;
isEmojiPickerOpen.value = true;
_showComposerEmojiPicker(context, insertEmoji, () {
if (!context.mounted) return;
isEmojiPickerOpen.value = false;
focusNode.requestFocus();
});
},
onOpenFormatting: () {
attachmentSurface.value = _AttachmentSurface.closed;
showFormatting.value = true;
},
hasPendingUploads: hasPendingUploads,
canSend: controller.text.trim().isNotEmpty || hasAttachments,
isSending: isSending.value,
),
),
],
),
);
}
}
@@ -0,0 +1,144 @@
part of '../compose_bar.dart';
class _ComposerDockFrame extends StatelessWidget {
final double widthFactor;
final Widget child;
const _ComposerDockFrame({required this.widthFactor, required this.child});
@override
Widget build(BuildContext context) {
final backdropHeight = mobileTabFooterBackdropHeight(context);
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
key: const ValueKey('composer-footer-gradient'),
left: 0,
right: 0,
bottom: 0,
height: backdropHeight,
child: IgnorePointer(
child: MobileTabFooterBackdrop(height: backdropHeight),
),
),
Padding(
padding: EdgeInsets.only(
left: Grid.twelve,
right: Grid.twelve,
bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
),
child: Align(
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
key: const ValueKey('composer-width-transition'),
widthFactor: widthFactor,
child: child,
),
),
),
],
);
}
}
class _ComposerOverlayPortal extends StatelessWidget {
final OverlayPortalController controller;
final ValueListenable<_AttachmentSurface> attachmentSurface;
final bool reducedMotion;
final Widget Function(_AttachmentSurface surface) buildOverlayPanel;
final VoidCallback onDismissAttachmentSurface;
final Widget child;
const _ComposerOverlayPortal({
required this.controller,
required this.attachmentSurface,
required this.reducedMotion,
required this.buildOverlayPanel,
required this.onDismissAttachmentSurface,
required this.child,
});
@override
Widget build(BuildContext context) {
return OverlayPortal.overlayChildLayoutBuilder(
controller: controller,
overlayChildBuilder: (context, layoutInfo) {
final composerOrigin = MatrixUtils.transformPoint(
layoutInfo.childPaintTransform,
Offset.zero,
);
return ValueListenableBuilder<_AttachmentSurface>(
valueListenable: attachmentSurface,
builder: (context, surface, _) {
final surfaceDuration = reducedMotion
? Duration.zero
: Duration(
milliseconds:
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos
? 320
: 250,
);
final expandedSurfaceCoversComposer =
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos;
final surfaceLeft = expandedSurfaceCoversComposer
? Grid.twelve
: composerOrigin.dx;
final surfaceWidth = expandedSurfaceCoversComposer
? layoutInfo.overlaySize.width - (Grid.twelve * 2)
: layoutInfo.childSize.width;
final overlayAnchorY =
composerOrigin.dy +
(expandedSurfaceCoversComposer
? layoutInfo.childSize.height + Grid.twelve
: 0);
return Stack(
children: [
if (surface != _AttachmentSurface.closed)
Positioned(
left: 0,
top: 0,
right: 0,
height: composerOrigin.dy,
child: ExcludeSemantics(
child: GestureDetector(
key: const ValueKey('attachment-dismiss-barrier'),
behavior: HitTestBehavior.opaque,
onTap: onDismissAttachmentSurface,
),
),
),
AnimatedPositioned(
duration: surfaceDuration,
curve:
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos
? const Cubic(0.34, 1.25, 0.64, 1)
: const Cubic(0.22, 1, 0.36, 1),
left: surfaceLeft,
bottom: layoutInfo.overlaySize.height - overlayAnchorY,
width: surfaceWidth,
child: ClipRect(
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: surface == _AttachmentSurface.closed
? _SuggestionPanelMotion(
duration: surfaceDuration,
alignment: Alignment.bottomLeft,
child: buildOverlayPanel(surface),
)
: buildOverlayPanel(surface),
),
),
),
],
);
},
);
},
child: child,
);
}
}
@@ -0,0 +1,60 @@
part of '../compose_bar.dart';
void _useComposeDraftLifecycle({
required WidgetRef ref,
required _MarkdownEditingController controller,
required String draftKey,
required String channelId,
required String? threadHeadId,
required String draftIdentity,
required ObjectRef<int> draftRevision,
required ValueNotifier<List<_PendingAttachment>> attachments,
required ObjectRef<int> uploadGeneration,
required ObjectRef<UploadCancellationToken?> activeUploadCancellation,
required ValueNotifier<int> uploadingCount,
required ValueNotifier<bool> isSending,
required ValueNotifier<_AttachmentSurface> attachmentSurface,
required ValueNotifier<String?> uploadError,
required _IOSAttachmentPopoverController iosAttachmentPopover,
}) {
final lastDraftIdentity = useRef<String?>(null);
useEffect(() {
final identityChanged =
lastDraftIdentity.value != null &&
lastDraftIdentity.value != draftIdentity;
lastDraftIdentity.value = draftIdentity;
final saved = ref.read(composeDraftsProvider.notifier).textFor(draftKey);
if (identityChanged) {
draftRevision.value += 1;
uploadGeneration.value += 1;
activeUploadCancellation.value?.cancel();
activeUploadCancellation.value = null;
uploadingCount.value = 0;
isSending.value = false;
attachmentSurface.value = _AttachmentSurface.closed;
uploadError.value = null;
unawaited(iosAttachmentPopover.dispose());
final staleAttachments = attachments.value;
attachments.value = const [];
unawaited(_deleteOwnedAttachments(staleAttachments));
controller.text = saved ?? '';
} else if (saved != null && controller.text.isEmpty) {
controller.text = saved;
}
void persistDraft() {
draftRevision.value += 1;
ref
.read(composeDraftsProvider.notifier)
.save(
key: draftKey,
channelId: channelId,
threadHeadId: threadHeadId,
text: controller.text,
);
}
controller.addListener(persistDraft);
return () => controller.removeListener(persistDraft);
}, [controller, draftKey, draftIdentity]);
}
@@ -0,0 +1,90 @@
part of '../compose_bar.dart';
class _FormattingToolbar extends StatelessWidget {
final void Function(String prefix, [String? suffix]) onFormat;
const _FormattingToolbar({required this.onFormat});
@override
Widget build(BuildContext context) {
return Row(
key: const ValueKey('formatting-actions'),
mainAxisSize: MainAxisSize.min,
children: [
_FormatButton(
icon: LucideIcons.bold,
tooltip: context.l10n.bold,
onTap: () => onFormat('**'),
),
_FormatButton(
icon: LucideIcons.italic,
tooltip: context.l10n.italic,
onTap: () => onFormat('_'),
),
_FormatButton(
icon: LucideIcons.strikethrough,
tooltip: context.l10n.strikethrough,
onTap: () => onFormat('~~'),
),
_FormatButton(
icon: LucideIcons.code,
tooltip: context.l10n.code,
onTap: () => onFormat('`'),
),
_FormatButton(
icon: LucideIcons.squareCode,
tooltip: context.l10n.codeBlock,
onTap: () => onFormat('```\n', '\n```'),
),
],
);
}
}
class _FormatButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final VoidCallback onTap;
const _FormatButton({
required this.icon,
required this.tooltip,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkWell(
borderRadius: BorderRadius.circular(Radii.sm),
onTap: () => _runComposerAction(onTap),
child: Padding(
padding: const EdgeInsets.all(Grid.xxs),
child: Icon(icon, size: 18, color: context.colors.primary),
),
),
);
}
}
class _ComposeAction extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _ComposeAction({required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: () => _runComposerAction(onTap),
icon: Icon(icon, size: 20, color: context.colors.onSurfaceVariant),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
);
}
}
@@ -0,0 +1,455 @@
part of '../compose_bar.dart';
const _typingThrottleMs = 3000;
class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver {
final FlutterView view;
final VoidCallback onKeyboardHidden;
bool _wasVisible;
_ComposerKeyboardMetricsObserver({
required this.view,
required this.onKeyboardHidden,
}) : _wasVisible = view.viewInsets.bottom > 0;
@override
void didChangeMetrics() {
final isVisible = view.viewInsets.bottom > 0;
if (_wasVisible && !isVisible) onKeyboardHidden();
_wasVisible = isVisible;
}
}
void _runComposerAction(VoidCallback action) {
unawaited(HapticFeedback.selectionClick());
action();
}
void _showComposerEmojiPicker(
BuildContext context,
ValueChanged<String> onSelect,
VoidCallback onDismiss,
) {
showEmojiPicker(
context: context,
onSelect: (emoji) => _runComposerAction(() => onSelect(emoji)),
onDismiss: onDismiss,
);
}
void _dismissComposerKeyboard(FocusNode focusNode) {
focusNode.unfocus();
unawaited(SystemChannels.textInput.invokeMethod<void>('TextInput.hide'));
}
const _pastedImageMimeTypes = <String>[
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
];
/// Cap on ranked mention suggestions shown — matches desktop's
/// `MENTION_SUGGESTION_LIMIT`.
const _mentionSuggestionLimit = 50;
/// Walk backward from [cursor] looking for [trigger] (e.g. `@` or `#`) at a
/// word boundary. Returns the index of the trigger character, or `null` if none
/// is found.
///
/// When [stopAtSpace] is `true` the walk stops at both spaces and newlines —
/// appropriate for `#channel` names which are kebab-case slugs without spaces.
/// When `false`, only newlines stop the walk, allowing multi-word queries like
/// `@Alice Smith` to match members with multi-word display names.
@visibleForTesting
int? findTrigger(
String text,
int cursor,
String trigger, {
bool stopAtSpace = true,
}) {
for (var i = cursor - 1; i >= 0; i--) {
final ch = text[i];
if (ch == '\n') break;
if (stopAtSpace && ch == ' ') break;
if (ch == trigger) {
if (i == 0 || text[i - 1] == ' ' || text[i - 1] == '\n') {
return i;
}
break;
}
}
return null;
}
/// Replace the range `[start, cursor)` with [replacement] and move the cursor
/// to the end of the replacement. Used by both mention and channel insertion.
@visibleForTesting
void spliceAndMoveCursor(
TextEditingController controller,
FocusNode focusNode, {
required int start,
required String replacement,
}) {
final text = controller.text;
final cursor =
(controller.selection.isValid
? controller.selection.baseOffset
: text.length)
.clamp(start, text.length);
final before = text.substring(0, start);
final after = text.substring(cursor);
controller.text = '$before$replacement$after';
controller.selection = TextSelection.collapsed(
offset: start + replacement.length,
);
focusNode.requestFocus();
}
/// Insert [trigger] (e.g. `@` or `#`) at the cursor position, prefixed with
/// a space if needed for word separation. Used by `triggerMention` and
/// `triggerChannel`.
void _insertTriggerAtCursor(
TextEditingController controller,
FocusNode focusNode,
String trigger,
) {
final text = controller.text;
final cursor = controller.selection.isValid
? controller.selection.baseOffset
: text.length;
final needsSpace =
cursor > 0 && text[cursor - 1] != ' ' && text[cursor - 1] != '\n';
final insert = needsSpace ? ' $trigger' : trigger;
final before = text.substring(0, cursor);
final after = text.substring(cursor);
controller.text = '$before$insert$after';
controller.selection = TextSelection.collapsed(
offset: cursor + insert.length,
);
focusNode.requestFocus();
}
bool hasMention(String text, String name) {
final pattern = RegExp(
'(?:^|\\s|[*_]{1,3}|\\|\\|)@${RegExp.escape(name)}(?=\\|\\||[\\s,;.!?:)\\]}*_]|\$)',
caseSensitive: false,
);
return pattern.hasMatch(text);
}
/// Outcome of the non-member mention prompt. `null` (dialog dismissed)
/// cancels the send and keeps the draft.
enum _NonMemberMentionChoice { invite, sendWithoutInviting }
/// User-facing text for a failed add or send.
String _composeSendErrorMessage(Object error) {
if (error is AddMembersException) return error.message;
return error.toString().replaceFirst('Exception: ', '');
}
/// Ask whether to invite mentioned humans who aren't channel members, or
/// send without inviting them. Mirrors desktop's `NonMemberMentionDialog`.
/// [canInvite] false (a private channel the sender doesn't own/administer)
/// drops the Invite action — the relay rejects that add, so offering it would
/// only produce an error.
Future<_NonMemberMentionChoice?> _promptNonMemberMention(
BuildContext context, {
required List<String> names,
required bool canInvite,
}) {
return showBuzzDialog<_NonMemberMentionChoice>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.l10n.mentionOutsideTitle),
content: Text(
canInvite
? context.l10n.mentionOutsidePeopleContent(names.join(', '))
: '${context.l10n.mentionOutsidePeopleContent(names.join(', '))} '
'${context.l10n.privateChannelNoInvite}',
),
actions: [
TextButton(
onPressed: () => Navigator.of(
dialogContext,
).pop(_NonMemberMentionChoice.sendWithoutInviting),
child: Text(
canInvite ? context.l10n.doNothing : context.l10n.sendAnyway,
),
),
if (canInvite)
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(_NonMemberMentionChoice.invite),
child: Text(context.l10n.invite),
),
],
),
);
}
/// Send a typing indicator over the WebSocket (fire-and-forget).
///
/// Desktop sends these as `["EVENT", signedEvent]` over the WebSocket — not
/// via HTTP. Ephemeral events like typing indicators are broadcast-only and
/// the relay doesn't persist them, so the HTTP `/api/events` endpoint may
/// silently discard them.
void _sendTypingIndicator(
WidgetRef ref, {
required String channelId,
String? threadHeadId,
String? rootId,
}) {
try {
final config = ref.read(relayConfigProvider);
final nsec = config.nsec;
if (nsec == null || nsec.isEmpty) return;
final privkeyHex = nostr.Nip19.decode(payload: nsec).data;
if (privkeyHex.isEmpty) return;
final tags = <List<String>>[
['h', channelId],
if (threadHeadId != null && rootId != null && rootId != threadHeadId) ...[
['e', rootId, '', 'root'],
['e', threadHeadId, '', 'reply'],
] else if (threadHeadId != null)
['e', threadHeadId, '', 'reply'],
];
final event = nostr.Event.from(
kind: EventKind.typingIndicator,
content: '',
tags: tags,
secretKey: privkeyHex,
verify: false,
);
// Send directly over WebSocket — fire-and-forget, matching desktop.
final session = ref.read(relaySessionProvider.notifier);
session.sendRaw(['EVENT', event.toMap()]);
} catch (_) {
// Fire-and-forget — typing indicator failure is non-fatal.
}
}
/// Reports a send that was cancelled because the active community changed.
///
/// The send path is fire-and-forget, so a `StateError` escaping it would be
/// silent. The composer's own error line cannot carry this message either: the
/// identity change that causes the failure also resets that state on the next
/// frame, so [messenger] must be resolved before the send's first `await`.
void _reportSendCancelledByCommunitySwitch(
ScaffoldMessengerState? messenger,
String message,
) {
messenger?.showSnackBar(
SnackBar(content: Text(message)),
);
}
/// What an add attempt left undone: who is still a non-member, and why.
@immutable
class _NonMemberAddOutcome {
final List<String> notAdded;
final List<String> errors;
const _NonMemberAddOutcome({required this.notAdded, required this.errors});
static const empty = _NonMemberAddOutcome(notAdded: [], errors: []);
}
/// Adds mentioned non-members to the channel before a send.
///
/// Agents are added silently with the `bot` role; humans are only passed here
/// after they have been explicitly invited from the mention prompt.
///
/// A rejection is reported, never thrown: the send is fire-and-forget, so an
/// escaping error would drop the message with nothing shown. [StateError] still
/// propagates — a community switch must cancel the whole send.
Future<_NonMemberAddOutcome> _addMentionedNonMembers(
ChannelActions channelActions, {
required String channelId,
required List<String> agentPubkeys,
required List<String> humanPubkeys,
required bool canAddMembers,
required String privateChannelError,
}) async {
final pending = [
if (agentPubkeys.isNotEmpty) (agentPubkeys, 'bot'),
if (humanPubkeys.isNotEmpty) (humanPubkeys, 'member'),
];
if (pending.isEmpty) return _NonMemberAddOutcome.empty;
// A plain member of a private channel cannot add anyone: skip the doomed
// kind:9000 rather than trading it for a relay rejection.
if (!canAddMembers) {
return _NonMemberAddOutcome(
notAdded: [for (final (pubkeys, _) in pending) ...pubkeys],
errors: [privateChannelError],
);
}
final notAdded = <String>[];
final errors = <String>[];
for (final (pubkeys, role) in pending) {
try {
await channelActions.addMembers(
channelId: channelId,
pubkeys: pubkeys,
role: role,
);
} on StateError {
rethrow;
} catch (error) {
notAdded.addAll(
error is AddMembersException ? error.failures.keys : pubkeys,
);
errors.add(_composeSendErrorMessage(error));
}
}
return _NonMemberAddOutcome(notAdded: notAdded, errors: errors);
}
/// Mentioned identities that aren't in the channel yet, plus whether the sender
/// is allowed to add them at all.
@immutable
class _NonMemberMentionScan {
final String channelId;
final List<String> agentPubkeys;
final List<MentionCandidate> humans;
final bool canAddMembers;
const _NonMemberMentionScan({
required this.channelId,
required this.agentPubkeys,
required this.humans,
required this.canAddMembers,
});
}
/// Resolves which mentioned identities are non-members, and whether this
/// identity may add them (see [Channel.canAddMembers]). DMs are skipped: their
/// participant set is fixed at creation.
Future<_NonMemberMentionScan> _scanNonMemberMentions(
WidgetRef ref, {
required String channelId,
required List<MentionCandidate> selectedMentions,
required String? currentPubkey,
}) async {
final none = _NonMemberMentionScan(
channelId: channelId,
agentPubkeys: const [],
humans: const [],
canAddMembers: true,
);
if (selectedMentions.isEmpty) return none;
final channel = (await ref.read(
channelsProvider.future,
)).firstWhere((candidate) => candidate.id == channelId);
if (channel.isDm) return none;
final members = await ref.read(channelMembersProvider(channelId).future);
final memberPubkeys = {
for (final member in members) member.pubkey.toLowerCase(),
};
String? selfRole;
if (currentPubkey != null) {
final self = currentPubkey.toLowerCase();
for (final member in members) {
if (member.pubkey.toLowerCase() == self) {
selfRole = member.role;
break;
}
}
}
final agentPubkeys = <String>[];
final humans = <MentionCandidate>[];
final seen = <String>{};
for (final candidate in selectedMentions) {
final pubkey = candidate.pubkey.toLowerCase();
if (memberPubkeys.contains(pubkey) || !seen.add(pubkey)) continue;
if (candidate.isAgent) {
agentPubkeys.add(pubkey);
} else {
humans.add(candidate);
}
}
return _NonMemberMentionScan(
channelId: channelId,
agentPubkeys: agentPubkeys,
humans: humans,
canAddMembers: channel.canAddMembers(selfRole),
);
}
/// The p-tags and `mention` reference tags an outgoing message should carry.
///
/// Anyone who ends up *not* added is demoted from a p-tag to a reference tag so
/// their name still renders without notifying a non-member — mirrors desktop's
/// `mergeOutgoingTagsWithReferenceMentions`.
class _OutgoingMentions {
List<String> pubkeys;
final List<List<String>> referenceTags = [];
List<String> _invitedHumanPubkeys = const [];
_OutgoingMentions(List<MentionCandidate> selectedMentions)
: pubkeys = LinkedHashSet<String>.from(
selectedMentions.map((candidate) => candidate.pubkey.toLowerCase()),
).toList();
void demote(Iterable<String> demoted) {
final excluded = {for (final pubkey in demoted) pubkey.toLowerCase()};
if (excluded.isEmpty) return;
pubkeys = [
for (final pubkey in pubkeys)
if (!excluded.contains(pubkey)) pubkey,
];
referenceTags.addAll([
for (final pubkey in excluded) ['mention', pubkey],
]);
}
/// Applies the mention prompt's outcome: invite them, or send without.
void resolveHumanChoice(
_NonMemberMentionChoice choice,
List<MentionCandidate> humans,
) {
final humanPubkeys = [
for (final candidate in humans) candidate.pubkey.toLowerCase(),
];
switch (choice) {
case _NonMemberMentionChoice.invite:
_invitedHumanPubkeys = humanPubkeys;
case _NonMemberMentionChoice.sendWithoutInviting:
demote(humanPubkeys);
}
}
/// Adds the scanned non-members, demoting and reporting whatever didn't land.
Future<void> addNonMembers(
ChannelActions channelActions, {
required _NonMemberMentionScan scan,
required ScaffoldMessengerState? messenger,
required String privateChannelError,
}) async {
final outcome = await _addMentionedNonMembers(
channelActions,
channelId: scan.channelId,
agentPubkeys: scan.agentPubkeys,
humanPubkeys: _invitedHumanPubkeys,
canAddMembers: scan.canAddMembers,
privateChannelError: privateChannelError,
);
demote(outcome.notAdded);
if (outcome.errors.isNotEmpty) {
messenger?.showSnackBar(
SnackBar(content: Text(outcome.errors.join(' '))),
);
}
}
}
@@ -0,0 +1,199 @@
part of '../compose_bar.dart';
const _nativeAttachmentPopoverChannel = MethodChannel(
'buzz/native_attachment_popover',
);
final _iosAttachmentPopoverCoordinator = _IOSAttachmentPopoverCoordinator(
_nativeAttachmentPopoverChannel,
);
class _IOSAttachmentPopoverCallbacks {
final Future<void> Function(XFile image) onCapture;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final VoidCallback onAllPhotos;
final VoidCallback onVideo;
final VoidCallback onFiles;
const _IOSAttachmentPopoverCallbacks({
required this.onCapture,
required this.onChoosePhotos,
required this.onAllPhotos,
required this.onVideo,
required this.onFiles,
});
}
class _IOSAttachmentPopoverCoordinator {
static final Object _cancelledSupportCheck = Object();
final MethodChannel _channel;
Object? _activeOwner;
_IOSAttachmentPopoverCallbacks? _callbacks;
Completer<void>? _pendingSupportCancellation;
bool _didPresent = false;
bool _handlerInstalled = false;
_IOSAttachmentPopoverCoordinator(this._channel);
Future<bool> present({
required Object owner,
required BuildContext sourceContext,
required Future<void> Function(XFile image) onCapture,
required Future<void> Function(List<XFile> photos) onChoosePhotos,
required VoidCallback onAllPhotos,
required VoidCallback onVideo,
required VoidCallback onFiles,
}) async {
if (defaultTargetPlatform != TargetPlatform.iOS) return false;
if (_activeOwner case final activeOwner?) {
if (identical(activeOwner, owner)) return true;
if (!_didPresent) _clearOwner(activeOwner);
return _didPresent;
}
final supportCancellation = Completer<void>();
_activeOwner = owner;
_pendingSupportCancellation = supportCancellation;
_callbacks = _IOSAttachmentPopoverCallbacks(
onCapture: onCapture,
onChoosePhotos: onChoosePhotos,
onAllPhotos: onAllPhotos,
onVideo: onVideo,
onFiles: onFiles,
);
_ensureHandler();
try {
final supportResult = await Future.any<Object?>([
_channel.invokeMethod<bool>('isSupported'),
supportCancellation.future.then<Object?>((_) => _cancelledSupportCheck),
]);
if (identical(supportResult, _cancelledSupportCheck)) return true;
if (identical(_pendingSupportCancellation, supportCancellation)) {
_pendingSupportCancellation = null;
}
if (!identical(_activeOwner, owner)) return false;
final supported = supportResult == true;
if (!supported || !sourceContext.mounted) {
_clearOwner(owner);
return false;
}
final renderObject = sourceContext.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) {
_clearOwner(owner);
return false;
}
final origin = renderObject.localToGlobal(Offset.zero);
_didPresent = true;
final didPresent =
await _channel.invokeMethod<bool>('present', {
'x': origin.dx,
'y': origin.dy,
'width': renderObject.size.width,
'height': renderObject.size.height,
}) ??
false;
if (!identical(_activeOwner, owner)) return false;
if (!didPresent) _clearOwner(owner);
return didPresent;
} on PlatformException {
_clearOwner(owner);
return false;
}
}
Future<void> disposeOwner(Object owner) async {
if (!identical(_activeOwner, owner)) return;
_callbacks = null;
if (!_didPresent) {
_clearOwner(owner);
return;
}
try {
await _channel.invokeMethod<void>('dismiss');
} on PlatformException {
// The native bridge is unavailable, so there is nothing left to dismiss.
} on MissingPluginException {
// The native bridge is unavailable, so there is nothing left to dismiss.
} finally {
_clearOwner(owner);
}
}
void _ensureHandler() {
if (_handlerInstalled) return;
_handlerInstalled = true;
_channel.setMethodCallHandler(_handleMethodCall);
}
Future<void> _handleMethodCall(MethodCall call) async {
final callbacks = _callbacks;
switch (call.method) {
case 'cameraCaptured':
if (call.arguments case final String path) {
final activeCallbacks = callbacks;
if (activeCallbacks != null) {
await processCapturedImage(XFile(path), activeCallbacks.onCapture);
}
}
case 'photosSelected':
final paths = (call.arguments as List<Object?>? ?? const [])
.whereType<String>()
.toList();
if (callbacks != null && paths.isNotEmpty) {
await processTemporaryImages([
for (final path in paths) XFile(path),
], callbacks.onChoosePhotos);
}
case 'pickAllPhotos':
callbacks?.onAllPhotos();
case 'pickVideo':
callbacks?.onVideo();
case 'pickFiles':
callbacks?.onFiles();
case 'dismissed':
_activeOwner = null;
_callbacks = null;
_didPresent = false;
}
}
void _clearOwner(Object owner) {
if (!identical(_activeOwner, owner)) return;
final supportCancellation = _pendingSupportCancellation;
_pendingSupportCancellation = null;
if (supportCancellation != null && !supportCancellation.isCompleted) {
supportCancellation.complete();
}
_activeOwner = null;
_callbacks = null;
_didPresent = false;
}
}
class _IOSAttachmentPopoverController {
Future<bool> present({
required BuildContext sourceContext,
required Future<void> Function(XFile image) onCapture,
required Future<void> Function(List<XFile> photos) onChoosePhotos,
required VoidCallback onAllPhotos,
required VoidCallback onVideo,
required VoidCallback onFiles,
}) => _iosAttachmentPopoverCoordinator.present(
owner: this,
sourceContext: sourceContext,
onCapture: onCapture,
onChoosePhotos: onChoosePhotos,
onAllPhotos: onAllPhotos,
onVideo: onVideo,
onFiles: onFiles,
);
Future<void> dispose() => _iosAttachmentPopoverCoordinator.disposeOwner(this);
}
@@ -0,0 +1,239 @@
part of '../compose_bar.dart';
const _inlinePhotoPickerViewType = 'buzz/inline_photo_picker';
const _inlinePhotoPickerSupportChannel = MethodChannel(
'buzz/inline_photo_picker',
);
class _IOSInlinePhotoPicker extends HookWidget {
final VoidCallback onBack;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
final Widget fallback;
const _IOSInlinePhotoPicker({
required this.onBack,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.onChooseAllPhotos,
required this.fallback,
});
@override
Widget build(BuildContext context) {
final supportFuture = useMemoized(
() async =>
await _inlinePhotoPickerSupportChannel.invokeMethod<bool>(
'isSupported',
) ??
false,
);
final support = useFuture(supportFuture);
final pickerChannel = useState<MethodChannel?>(null);
final selectedCount = useState(0);
final selectedPaths = useState<List<String>>(const []);
final isPreparingSelection = useState(false);
final isProcessing = useState(false);
useEffect(() {
final channel = pickerChannel.value;
if (channel == null) return null;
channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'selectionCountChanged':
selectedCount.value = call.arguments as int? ?? 0;
selectedPaths.value = const [];
isPreparingSelection.value = selectedCount.value > 0;
case 'selectionDidChange':
final paths = (call.arguments as List<Object?>? ?? const [])
.whereType<String>()
.toList();
selectedPaths.value = paths;
isPreparingSelection.value = false;
case 'didFail':
isPreparingSelection.value = false;
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(
call.arguments as String? ??
context.l10n.preparingPhotos,
),
),
);
}
});
return () => channel.setMethodCallHandler(null);
}, [pickerChannel.value]);
final canSelect =
selectedCount.value > 0 &&
selectedPaths.value.length == selectedCount.value &&
!isPreparingSelection.value &&
!isProcessing.value;
Future<void> submitSelection() async {
if (!canSelect) return;
isProcessing.value = true;
try {
final paths = List<String>.from(selectedPaths.value);
final claimed =
await pickerChannel.value?.invokeMethod<bool>(
'claimSelection',
paths,
) ??
false;
if (!claimed) return;
final photos = [for (final path in paths) XFile(path)];
await processTemporaryImages(photos, onChoosePhotos);
} finally {
if (context.mounted) isProcessing.value = false;
}
}
Future<void> openAllPhotos() async {
if (isPreparingSelection.value || isProcessing.value) return;
isProcessing.value = true;
try {
final photos = await onPickAllPhotos();
if (photos.isNotEmpty) {
await onChooseAllPhotos(photos);
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(content: Text(context.l10n.unablePhotoLibrary)),
);
}
} finally {
if (context.mounted) isProcessing.value = false;
}
}
if (support.connectionState != ConnectionState.done) {
return const _NativePhotoPickerLoading();
}
if (support.hasError || support.data != true) return fallback;
return SizedBox(
key: const ValueKey('ios-inline-photo-picker'),
height: _attachmentExpandedHeight,
width: double.infinity,
child: Stack(
fit: StackFit.expand,
children: [
UiKitView(
viewType: _inlinePhotoPickerViewType,
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (viewId) {
pickerChannel.value = MethodChannel(
'buzz/inline_photo_picker/$viewId',
);
},
),
PositionedDirectional(
start: Grid.twelve,
bottom: Grid.twelve,
child: SafeArea(
top: false,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.62),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.28),
),
),
child: IconButton(
key: const ValueKey('ios-inline-photo-picker-back'),
onPressed: isProcessing.value
? null
: () => _runComposerAction(onBack),
tooltip: context.l10n.backAttachmentOptions,
icon: const Icon(
LucideIcons.chevronLeft,
color: Colors.white,
),
),
),
),
),
PositionedDirectional(
end: Grid.twelve,
bottom: Grid.twelve,
child: SafeArea(
top: false,
child: FilledButton(
key: const ValueKey('ios-inline-photo-picker-select'),
onPressed: canSelect
? () =>
_runComposerAction(() => unawaited(submitSelection()))
: selectedCount.value == 0 &&
!isPreparingSelection.value &&
!isProcessing.value
? () => _runComposerAction(() => unawaited(openAllPhotos()))
: null,
style: FilledButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.76),
disabledBackgroundColor: Colors.black.withValues(alpha: 0.32),
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white70,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
shape: const StadiumBorder(),
side: BorderSide(color: Colors.white.withValues(alpha: 0.28)),
),
child: isPreparingSelection.value
? SizedBox.square(
dimension: 20,
child: BuzzLoadingIndicator(
size: 20,
color: Colors.white,
semanticLabel: context.l10n.preparingPhotos,
),
)
: Text(
selectedCount.value == 0
? context.l10n.allPhotos
: context.l10n.addPhoto(selectedCount.value),
),
),
),
),
if (isProcessing.value)
ColoredBox(
color: Color.fromRGBO(0, 0, 0, 0.28),
child: Center(
child: BuzzLoadingIndicator(
size: 44,
color: Colors.white,
semanticLabel: context.l10n.preparingPhotos,
),
),
),
],
),
);
}
}
class _NativePhotoPickerLoading extends StatelessWidget {
const _NativePhotoPickerLoading();
@override
Widget build(BuildContext context) {
return SizedBox(
key: const ValueKey('ios-inline-photo-picker-loading'),
height: _attachmentExpandedHeight,
width: double.infinity,
child: Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: context.l10n.openingPhotos,
),
),
);
}
}
@@ -0,0 +1,323 @@
part of '../compose_bar.dart';
class _ComposeBarLayout extends StatelessWidget {
final List<_PendingAttachment> attachments;
final ValueChanged<int> onRemoveAttachment;
final String? uploadError;
final bool isExpanded;
final TextEditingController 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 double expansionValue;
final double expansionProgress;
final bool formattingOpen;
final VoidCallback onCloseFormatting;
final Duration motionDuration;
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;
const _ComposeBarLayout({
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.expansionValue,
required this.expansionProgress,
required this.formattingOpen,
required this.onCloseFormatting,
required this.motionDuration,
required this.onFormat,
required this.onMention,
required this.onChannel,
required this.onEmoji,
required this.onOpenFormatting,
required this.canSend,
required this.hasPendingUploads,
required this.isSending,
});
@override
Widget build(BuildContext context) {
return _DragDownToDismissKeyboard(child: _buildBar(context));
}
Widget _buildBar(BuildContext context) {
final trimmedDraft = controller.text.trim();
final collapsedText = trimmedDraft.isEmpty
? resolvedHint
: trimmedDraft.replaceAll(RegExp(r'\s+'), ' ');
final composerRadius =
Radii.dialog + Grid.quarter * (1 - expansionProgress);
return Container(
key: const ValueKey('composer-surface'),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(composerRadius),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
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)
TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.send,
contextMenuBuilder: contextMenuBuilder,
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: _pastedImageMimeTypes,
onContentInserted: onContentInserted,
),
onSubmitted: (_) => onSend(),
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,
),
)
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,
),
],
),
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: expansionValue,
child: IgnorePointer(
ignoring: !isExpanded,
child: Opacity(
opacity: expansionProgress,
child: Transform.translate(
offset: Offset(0, Grid.xxs * (1 - expansionProgress)),
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: LucideIcons.atSign,
onTap: onMention,
),
_ComposeAction(
icon: LucideIcons.hash,
onTap: onChannel,
),
_ComposeAction(
icon: LucideIcons.smilePlus,
onTap: onEmoji,
),
_ComposeAction(
icon: LucideIcons.aLargeSmall,
onTap: onOpenFormatting,
),
const Spacer(),
_SendButton(
isDisabled:
!canSend || hasPendingUploads,
isSending: isSending,
onTap: onSend,
),
],
),
),
),
],
),
],
),
),
),
),
),
),
],
),
);
}
}
/// 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,
);
}
}
@@ -0,0 +1,398 @@
part of '../compose_bar.dart';
enum _MarkdownStyle { codeBlock, inlineCode, bold, italic, strikethrough }
class _MarkdownRule {
final RegExp pattern;
final _MarkdownStyle style;
final bool parsesNestedFormatting;
_MarkdownRule(
String pattern,
this.style, {
this.parsesNestedFormatting = true,
}) : pattern = RegExp(pattern);
}
class _MarkdownEditingController extends TextEditingController {
final Set<String> _agentMentionNames = <String>{};
static final _rules = [
_MarkdownRule(
r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```',
_MarkdownStyle.codeBlock,
parsesNestedFormatting: false,
),
_MarkdownRule(
r'`([^`\n]*?)`',
_MarkdownStyle.inlineCode,
parsesNestedFormatting: false,
),
_MarkdownRule(r'\*\*([^\n]*?)\*\*', _MarkdownStyle.bold),
_MarkdownRule(r'~~([^\n]*?)~~', _MarkdownStyle.strikethrough),
_MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic),
];
/// Updates the known agent labels which should render as agent mention
/// chips. The editor still stores the literal `@Name` text, matching the
/// markdown sent to the relay.
void setAgentMentionNames(Iterable<String> names) {
final next = {
for (final name in names)
if (name.trim().isNotEmpty) name.trim().toLowerCase(),
};
if (setEquals(_agentMentionNames, next)) return;
_agentMentionNames
..clear()
..addAll(next);
notifyListeners();
}
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
final baseStyle = style ?? const TextStyle();
final composingRange =
withComposing && value.composing.isValid && !value.composing.isCollapsed
? value.composing
: TextRange.empty;
return TextSpan(
style: baseStyle,
children: _buildMarkdownSpans(
context,
text,
baseStyle,
sourceOffset: 0,
composingRange: composingRange,
),
);
}
List<InlineSpan> _buildMarkdownSpans(
BuildContext context,
String source,
TextStyle inheritedStyle, {
required int sourceOffset,
required TextRange composingRange,
}) {
final spans = <InlineSpan>[];
var offset = 0;
while (offset < source.length) {
final tail = source.substring(offset);
_MarkdownRule? nextRule;
RegExpMatch? nextMatch;
for (final rule in _rules) {
final match = rule.pattern.firstMatch(tail);
if (match == null) continue;
if (nextMatch == null || match.start < nextMatch.start) {
nextRule = rule;
nextMatch = match;
}
}
if (nextRule == null || nextMatch == null) {
spans.addAll(
_buildTextSpans(
context,
source.substring(offset),
inheritedStyle,
sourceOffset + offset,
composingRange,
),
);
break;
}
if (nextMatch.start > 0) {
spans.addAll(
_buildTextSpans(
context,
tail.substring(0, nextMatch.start),
inheritedStyle,
sourceOffset + offset,
composingRange,
),
);
}
final fullMatch = nextMatch.group(0)!;
final content = nextMatch.group(1)!;
final (contentStart, contentEnd) = _contentBounds(
fullMatch,
nextRule.style,
);
final contentStyle = _styleFor(context, inheritedStyle, nextRule.style);
final matchOffset = sourceOffset + offset + nextMatch.start;
spans.add(
TextSpan(
text: fullMatch.substring(0, contentStart),
style: _hiddenSyntaxStyle(inheritedStyle),
),
);
if (nextRule.parsesNestedFormatting) {
spans.addAll(
_buildMarkdownSpans(
context,
content,
contentStyle,
sourceOffset: matchOffset + contentStart,
composingRange: composingRange,
),
);
} else {
spans.addAll(
_buildTextSpans(
context,
content,
contentStyle,
matchOffset + contentStart,
composingRange,
renderAgentMentions: false,
),
);
}
spans.add(
TextSpan(
text: fullMatch.substring(contentEnd),
style: _hiddenSyntaxStyle(inheritedStyle),
),
);
offset += nextMatch.end;
}
return spans;
}
List<InlineSpan> _buildTextSpans(
BuildContext context,
String source,
TextStyle style,
int sourceOffset,
TextRange composingRange, {
bool renderAgentMentions = true,
}) {
List<InlineSpan> buildTextSegment(String text, TextStyle segmentStyle) =>
renderAgentMentions
? _buildAgentMentionSpans(context, text, segmentStyle)
: [TextSpan(text: text, style: segmentStyle)];
if (source.isEmpty) return const [];
final localStart = (composingRange.start - sourceOffset)
.clamp(0, source.length)
.toInt();
final localEnd = (composingRange.end - sourceOffset)
.clamp(0, source.length)
.toInt();
if (!composingRange.isValid ||
composingRange.isCollapsed ||
localStart >= localEnd) {
return buildTextSegment(source, style);
}
final composingDecorations = [
if (style.decoration != null && style.decoration != TextDecoration.none)
style.decoration!,
TextDecoration.underline,
];
final composingStyle = style.copyWith(
decoration: TextDecoration.combine(composingDecorations),
);
return [
if (localStart > 0)
...buildTextSegment(source.substring(0, localStart), style),
TextSpan(
text: source.substring(localStart, localEnd),
style: composingStyle,
),
if (localEnd < source.length)
...buildTextSegment(source.substring(localEnd), style),
];
}
List<InlineSpan> _buildAgentMentionSpans(
BuildContext context,
String source,
TextStyle style,
) {
if (_agentMentionNames.isEmpty) {
return [TextSpan(text: source, style: style)];
}
final escapedNames = _agentMentionNames.toList()
..sort((a, b) => b.length.compareTo(a.length));
final expression = RegExp(
r'(^|\s)@(' +
escapedNames.map(RegExp.escape).join('|') +
r')(?=\s|[,.!?:;)\]}*_]|$)',
caseSensitive: false,
multiLine: true,
);
final spans = <InlineSpan>[];
var offset = 0;
for (final match in expression.allMatches(source)) {
final prefix = match.group(1)!;
if (match.start > offset) {
spans.add(
TextSpan(text: source.substring(offset, match.start), style: style),
);
}
if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style));
final label = match.group(2)!;
spans.add(
WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: _ComposerAgentMentionChip(label: label, textStyle: style),
),
);
// The visual chip replaces the `@` placeholder. Keep the label as
// invisible source text so the text span still has one character per
// source character, preserving native cursor and deletion behavior.
spans.add(
TextSpan(
text: label,
semanticsLabel: '',
style: _hiddenMentionTextStyle(style),
),
);
offset = match.end;
}
if (offset < source.length) {
spans.add(TextSpan(text: source.substring(offset), style: style));
}
return spans.isEmpty ? [TextSpan(text: source, style: style)] : spans;
}
TextStyle _hiddenMentionTextStyle(TextStyle inheritedStyle) =>
inheritedStyle.copyWith(
color: Colors.transparent,
fontSize: 0.01,
height: 0.01,
letterSpacing: 0,
decoration: TextDecoration.none,
);
(int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) {
final delimiterLength = switch (markdownStyle) {
_MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2,
_MarkdownStyle.italic || _MarkdownStyle.inlineCode => 1,
_MarkdownStyle.codeBlock => 3,
};
if (markdownStyle != _MarkdownStyle.codeBlock) {
return (delimiterLength, fullMatch.length - delimiterLength);
}
final openingLength = fullMatch.startsWith('```\r\n')
? 5
: fullMatch.startsWith('```\n')
? 4
: delimiterLength;
final closingLength = fullMatch.endsWith('\r\n```')
? 5
: fullMatch.endsWith('\n```')
? 4
: delimiterLength;
return (openingLength, fullMatch.length - closingLength);
}
TextStyle _styleFor(
BuildContext context,
TextStyle inheritedStyle,
_MarkdownStyle markdownStyle,
) {
return switch (markdownStyle) {
_MarkdownStyle.bold => inheritedStyle.merge(
const TextStyle(fontWeight: FontWeight.w700),
),
_MarkdownStyle.italic => inheritedStyle.merge(
const TextStyle(fontStyle: FontStyle.italic),
),
_MarkdownStyle.strikethrough => inheritedStyle.merge(
const TextStyle(decoration: TextDecoration.lineThrough),
),
_MarkdownStyle.inlineCode ||
_MarkdownStyle.codeBlock => inheritedStyle.merge(
TextStyle(
fontFamily: 'GeistMono',
color: context.colors.onSurface,
backgroundColor: context.colors.surface,
),
),
};
}
TextStyle _hiddenSyntaxStyle(TextStyle inheritedStyle) {
return inheritedStyle.copyWith(
color: Colors.transparent,
fontSize: 0.01,
height: 0.01,
letterSpacing: 0,
decoration: TextDecoration.none,
);
}
}
class _ComposerAgentMentionChip extends StatelessWidget {
final String label;
final TextStyle textStyle;
const _ComposerAgentMentionChip({
required this.label,
required this.textStyle,
});
@override
Widget build(BuildContext context) {
final style = textStyle.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
height: 1,
);
final fontSize = style.fontSize ?? 16;
return Semantics(
label: context.l10n.agentMentionLabel(label),
excludeSemantics: true,
child: Container(
key: const ValueKey('composer-agent-mention-chip'),
padding: const EdgeInsets.fromLTRB(
Grid.half,
Grid.quarter + 1,
Grid.half,
Grid.quarter,
),
decoration: BoxDecoration(
// The composer surface is already tinted, so the body chip's
// low-opacity fill disappears here. Keep the same chip geometry
// while giving this editable token enough contrast to read as one.
color: context.colors.primary.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: context.colors.primary.withValues(alpha: 0.12),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
LucideIcons.bot,
size: fontSize * 0.95,
color: context.colors.primary,
),
const SizedBox(width: Grid.quarter),
Text(label, style: style),
],
),
),
);
}
}
@@ -0,0 +1,398 @@
part of '../compose_bar.dart';
class _PhotoGalleryPicker extends StatelessWidget {
final VoidCallback onBack;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
const _PhotoGalleryPicker({
required this.onBack,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.onChooseAllPhotos,
});
@override
Widget build(BuildContext context) {
final fallback = _RecentPhotoGalleryPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
onChooseAllPhotos: onChooseAllPhotos,
);
if (defaultTargetPlatform != TargetPlatform.iOS) return fallback;
return _IOSInlinePhotoPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
onChooseAllPhotos: onChooseAllPhotos,
fallback: fallback,
);
}
}
class _RecentPhotoGalleryPicker extends HookConsumerWidget {
final VoidCallback onBack;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Future<void> Function(List<XFile> photos) onChooseAllPhotos;
const _RecentPhotoGalleryPicker({
required this.onBack,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.onChooseAllPhotos,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recentPhotos = useMemoized(
ref.read(photoLibraryProvider).loadRecentPhotos,
);
final recentSnapshot = useFuture(recentPhotos);
final selection = useState<List<RecentPhoto>>([]);
final isResolving = useState(false);
final actionError = useState<String?>(null);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
void togglePhoto(RecentPhoto photo) {
if (isResolving.value) return;
final current = selection.value;
final existingIndex = current.indexWhere((item) => item.id == photo.id);
selection.value = existingIndex < 0
? [...current, photo]
: [
...current.take(existingIndex),
...current.skip(existingIndex + 1),
];
}
Future<void> choosePhotos() async {
if (isResolving.value) return;
isResolving.value = true;
actionError.value = null;
try {
final photos = selection.value.isEmpty
? await onPickAllPhotos()
: await ref
.read(photoLibraryProvider)
.resolveSelectedPhotos(selection.value);
if (photos.isNotEmpty && context.mounted) {
await (selection.value.isEmpty ? onChooseAllPhotos : onChoosePhotos)(
photos,
);
}
} catch (_) {
if (context.mounted) {
actionError.value = selection.value.isEmpty
? context.l10n.unablePhotoLibrary
: context.l10n.preparingPhotos;
}
} finally {
if (context.mounted) isResolving.value = false;
}
}
final selectedCount = selection.value.length;
final actionLabel = selectedCount == 0
? context.l10n.allPhotos
: context.l10n.addPhoto(selectedCount);
Widget buildGalleryBody() {
if (recentSnapshot.connectionState != ConnectionState.done) {
return Center(
child: BuzzLoadingIndicator(
size: 44,
semanticLabel: context.l10n.loadingRecentPhotos,
),
);
}
if (recentSnapshot.hasError) {
return _PhotoGalleryMessage(
icon: LucideIcons.images,
title: context.l10n.recentPhotosUnavailableTitle,
message: context.l10n.recentPhotosUnavailableMessage,
);
}
final photos = recentSnapshot.data ?? const <RecentPhoto>[];
if (photos.isEmpty) {
return _PhotoGalleryMessage(
icon: LucideIcons.images,
title: context.l10n.noRecentPhotosTitle,
message: context.l10n.noRecentPhotosMessage,
);
}
return GridView.builder(
key: const ValueKey('recent-photo-grid'),
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: Grid.quarter,
mainAxisSpacing: Grid.quarter,
),
itemCount: photos.length,
itemBuilder: (context, index) {
final photo = photos[index];
final selectionIndex = selection.value.indexWhere(
(item) => item.id == photo.id,
);
return _RecentPhotoTile(
photo: photo,
selectionIndex: selectionIndex,
reducedMotion: reducedMotion,
onTap: () => _runComposerAction(() => togglePhoto(photo)),
);
},
);
}
return Padding(
key: const ValueKey('photo-gallery-picker'),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 40,
child: Row(
children: [
IconButton(
key: const ValueKey('photo-gallery-back'),
onPressed: isResolving.value
? null
: () => _runComposerAction(onBack),
tooltip: context.l10n.backAttachmentOptions,
visualDensity: VisualDensity.compact,
icon: const Icon(LucideIcons.arrowLeft, size: 20),
),
const SizedBox(width: Grid.quarter),
Expanded(
child: Text(
context.l10n.recentPhotos,
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
if (selectedCount > 0)
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: Text(
context.l10n.selectedCount(selectedCount),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: Grid.half),
Expanded(
child: Column(
children: [
Expanded(child: buildGalleryBody()),
if (actionError.value case final error?) ...[
const SizedBox(height: Grid.half),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 72),
child: SingleChildScrollView(
child: Text(
error,
key: const ValueKey('photo-gallery-error'),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
textAlign: TextAlign.center,
),
),
),
],
],
),
),
const SizedBox(height: Grid.xxs),
SizedBox(
width: double.infinity,
child: selectedCount == 0
? OutlinedButton.icon(
key: const ValueKey('photo-gallery-action'),
onPressed: isResolving.value
? null
: () => _runComposerAction(
() => unawaited(choosePhotos()),
),
icon: isResolving.value
? BuzzLoadingIndicator(
size: 22,
color: context.colors.primary,
semanticLabel: context.l10n.openingAllPhotos,
)
: const Icon(LucideIcons.images, size: 18),
label: Text(actionLabel),
)
: FilledButton.icon(
key: const ValueKey('photo-gallery-action'),
onPressed: isResolving.value
? null
: () => _runComposerAction(
() => unawaited(choosePhotos()),
),
icon: isResolving.value
? BuzzLoadingIndicator(
size: 22,
color: Colors.white,
semanticLabel: context.l10n.preparingPhotos,
)
: const Icon(LucideIcons.plus, size: 18),
label: Text(actionLabel),
),
),
],
),
);
}
}
class _RecentPhotoTile extends StatelessWidget {
final RecentPhoto photo;
final int selectionIndex;
final bool reducedMotion;
final VoidCallback onTap;
const _RecentPhotoTile({
required this.photo,
required this.selectionIndex,
required this.reducedMotion,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final isSelected = selectionIndex >= 0;
return Semantics(
button: true,
selected: isSelected,
label: isSelected
? context.l10n.photoSelected(selectionIndex + 1)
: context.l10n.selectPhoto,
child: GestureDetector(
key: ValueKey('recent-photo-${photo.id}'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm),
child: Image.memory(
photo.thumbnailBytes,
fit: BoxFit.cover,
gaplessPlayback: true,
),
),
AnimatedContainer(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: isSelected
? context.colors.primary
: Colors.transparent,
width: isSelected ? 3 : 0,
),
color: isSelected
? Colors.black.withValues(alpha: 0.08)
: Colors.transparent,
),
),
PositionedDirectional(
top: Grid.half,
end: Grid.half,
child: AnimatedScale(
scale: isSelected ? 1 : 0.8,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedOpacity(
opacity: isSelected ? 1 : 0,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 100),
child: Container(
key: ValueKey('photo-selection-index-${photo.id}'),
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.primary,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
child: Text(
'${selectionIndex + 1}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimary,
fontWeight: FontWeight.w700,
),
),
),
),
),
),
],
),
),
);
}
}
class _PhotoGalleryMessage extends StatelessWidget {
final IconData icon;
final String title;
final String message;
const _PhotoGalleryMessage({
required this.icon,
required this.title,
required this.message,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 32, color: context.colors.onSurfaceVariant),
const SizedBox(height: Grid.xxs),
Text(
title,
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: Grid.quarter),
Text(
message,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
@@ -0,0 +1,49 @@
part of '../compose_bar.dart';
class _SendButton extends StatelessWidget {
final bool isSending;
final bool isDisabled;
final VoidCallback onTap;
const _SendButton({
required this.isSending,
required this.onTap,
this.isDisabled = false,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
onPressed: (isSending || isDisabled)
? null
: () => _runComposerAction(onTap),
style: IconButton.styleFrom(
backgroundColor: context.colors.primary,
disabledBackgroundColor: context.colors.primary.withValues(
alpha: 0.5,
),
shape: const CircleBorder(),
),
padding: EdgeInsets.zero,
icon: isSending
? BuzzLoadingIndicator(
size: 18,
color: context.colors.onPrimary,
semanticLabel: context.l10n.sendingMessage,
)
: Icon(
LucideIcons.arrowUp,
size: 18,
color: context.colors.onPrimary,
),
),
);
}
}
String _formatUploadError(Object error) {
return error.toString().replaceFirst('Exception: ', '');
}
@@ -0,0 +1,308 @@
part of '../compose_bar.dart';
class _SuggestionPanelMotion extends HookWidget {
final Duration duration;
final Alignment alignment;
final Widget child;
const _SuggestionPanelMotion({
required this.duration,
required this.alignment,
required this.child,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final springController = useAnimationController(
initialValue: 1,
upperBound: 1.08,
);
final springValue = useAnimation(springController);
final previousChildKey = useRef<Key?>(child.key);
useEffect(() {
if (previousChildKey.value == child.key) return null;
previousChildKey.value = child.key;
if (reducedMotion) {
springController.value = 1;
} else {
springController
..stop()
..value = 0.9
..animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 320),
bounce: 0.18,
),
0.9,
1,
0,
snapToEnd: true,
),
);
}
return null;
}, [child.key, reducedMotion]);
return Transform.scale(
scale: springValue,
alignment: alignment,
child: AnimatedSize(
duration: duration,
curve: Curves.easeInOutCubic,
alignment: alignment,
child: AnimatedSwitcher(
duration: duration,
reverseDuration: duration,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: alignment,
clipBehavior: Clip.none,
children: [...previousChildren, ?currentChild],
),
transitionBuilder: (child, animation) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeInOutCubic,
);
return AnimatedBuilder(
animation: curvedAnimation,
child: child,
builder: (context, child) => IgnorePointer(
ignoring: animation.status == AnimationStatus.reverse,
child: Opacity(
opacity: animation.value.clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, Grid.xs * (1 - animation.value)),
child: Transform.scale(
scale: 0.92 + (0.08 * curvedAnimation.value),
alignment: alignment,
child: child,
),
),
),
),
);
},
child: child,
),
),
);
}
}
class _MentionSuggestions extends StatelessWidget {
final List<MentionCandidate> suggestions;
final Map<String, UserProfile> userCache;
final String? currentPubkey;
final bool isDmChannel;
final void Function(MentionCandidate) onSelect;
const _MentionSuggestions({
required this.suggestions,
required this.userCache,
required this.currentPubkey,
required this.isDmChannel,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('mention-suggestions-popover'),
type: MaterialType.card,
color: appPopoverColor(context),
surfaceTintColor: Colors.transparent,
elevation: appPopoverElevation,
shadowColor: appPopoverShadowColor(context),
shape: appPopoverShape(context),
clipBehavior: Clip.hardEdge,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final candidate = suggestions[index];
final name = candidate.label;
final avatarUrl =
candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl;
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: AvatarImage(
imageUrl: avatarUrl,
radius: 18,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
name[0].toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
title: Text(name, style: context.textTheme.titleSmall),
subtitle: _MentionSuggestionInfo.build(
context,
candidate: candidate,
currentPubkey: currentPubkey,
isDmChannel: isDmChannel,
userCache: userCache,
),
onTap: () => _runComposerAction(() => onSelect(candidate)),
);
},
),
),
);
}
}
/// The secondary info line under a mention suggestion — mirrors desktop's
/// `MentionAutocomplete` subtitle: bot icon + "agent" (or an "admin" badge
/// for human admins), then "managed by …" / "not in channel".
abstract final class _MentionSuggestionInfo {
static Widget? build(
BuildContext context, {
required MentionCandidate candidate,
required String? currentPubkey,
required bool isDmChannel,
required Map<String, UserProfile> userCache,
}) {
final ownerLabel = candidate.isAgent
? formatOwnerLabel(
candidate.ownerPubkey,
currentPubkey,
userCache,
currentUserLabel: context.l10n.you,
)
: null;
final notInChannel = !isDmChannel && !candidate.isMember;
final isAdmin = !candidate.isAgent && candidate.role == 'admin';
final String? detail;
if (ownerLabel != null && notInChannel) {
detail = context.l10n.managedByNotInChannel(ownerLabel);
} else if (ownerLabel != null) {
detail = context.l10n.managedBy(ownerLabel);
} else if (notInChannel) {
detail = context.l10n.notInChannel;
} else {
detail = null;
}
if (!candidate.isAgent && !isAdmin && detail == null) return null;
final style = context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
);
return Row(
children: [
if (candidate.isAgent) ...[
Icon(
LucideIcons.bot,
size: 12,
color: context.colors.onSurfaceVariant,
),
const SizedBox(width: Grid.half),
Text(context.l10n.agent, style: style),
] else if (isAdmin)
Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: 1,
),
decoration: BoxDecoration(
color: context.colors.secondaryContainer,
borderRadius: BorderRadius.circular(Radii.sm),
),
child: Text(
context.l10n.adminRole,
style: style?.copyWith(
color: context.colors.onSecondaryContainer,
),
),
),
if (detail != null) ...[
if (candidate.isAgent || isAdmin) const SizedBox(width: Grid.xxs),
Flexible(
child: Text(detail, style: style, overflow: TextOverflow.ellipsis),
),
],
],
);
}
}
@visibleForTesting
List<Channel> filterChannels(List<Channel> channels, String? query) {
if (query == null) return const [];
final q = query.toLowerCase();
return channels
.where((c) => c.channelType != 'dm')
.where((c) {
if (q.isEmpty) return true;
return c.name.toLowerCase().contains(q);
})
.take(8)
.toList();
}
class _ChannelSuggestions extends StatelessWidget {
final List<Channel> suggestions;
final void Function(Channel) onSelect;
const _ChannelSuggestions({
required this.suggestions,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('channel-suggestions-popover'),
type: MaterialType.card,
color: appPopoverColor(context),
surfaceTintColor: Colors.transparent,
elevation: appPopoverElevation,
shadowColor: appPopoverShadowColor(context),
shape: appPopoverShape(context),
clipBehavior: Clip.hardEdge,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 240),
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox.shrink(),
itemBuilder: (context, index) {
final channel = suggestions[index];
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
horizontalTitleGap: 0,
leading: SizedBox.square(
dimension: 36,
child: Icon(
LucideIcons.hash,
size: 20,
color: context.colors.onSurfaceVariant,
),
),
title: Text(channel.name, style: context.textTheme.bodyLarge),
onTap: () => _runComposerAction(() => onSelect(channel)),
);
},
),
),
);
}
}
@@ -0,0 +1,197 @@
part of '../compose_bar.dart';
class _UploadProgressMotion extends StatelessWidget {
final bool visible;
final double progress;
final bool reducedMotion;
final VoidCallback onCancel;
const _UploadProgressMotion({
required this.visible,
required this.progress,
required this.reducedMotion,
required this.onCancel,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
key: const ValueKey('compose-upload-progress-motion'),
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 220),
reverseDuration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
transitionBuilder: (child, animation) {
final motion = CurvedAnimation(
parent: animation,
curve: Curves.easeInCubic,
reverseCurve: Curves.easeOutCubic,
);
return ClipRect(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1.15),
end: Offset.zero,
).animate(motion),
child: child,
),
);
},
child: visible
? Padding(
key: const ValueKey('compose-upload-progress-visible'),
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: _UploadProgressPill(
progress: progress,
reducedMotion: reducedMotion,
onCancel: onCancel,
),
)
: const SizedBox.shrink(
key: ValueKey('compose-upload-progress-hidden'),
),
);
}
}
/// A lightweight post-send upload status that stays above the composer, so the
/// composer is immediately available for the next message.
class _UploadProgressPill extends HookConsumerWidget {
final double progress;
final bool reducedMotion;
final VoidCallback onCancel;
const _UploadProgressPill({
required this.progress,
required this.reducedMotion,
required this.onCancel,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cancelHovered = useState(false);
final clampedProgress = progress.clamp(0.0, 1.0).toDouble();
final percentage = (clampedProgress * 100).round();
return Semantics(
liveRegion: true,
label: context.l10n.uploadingPercent(percentage),
child: SizedBox(
key: const ValueKey('compose-upload-progress'),
width: 300,
height: 36,
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.full),
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.full),
),
child: Stack(
fit: StackFit.expand,
children: [
Align(
alignment: Alignment.centerLeft,
child: AnimatedFractionallySizedBox(
key: const ValueKey('compose-upload-progress-fill'),
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 150),
curve: Curves.easeOutCubic,
widthFactor: clampedProgress,
heightFactor: 1,
child: ColoredBox(
color: context.colors.primary.withValues(
alpha: context.theme.brightness == Brightness.dark
? 0.15
: 0.12,
),
),
),
),
Padding(
padding: const EdgeInsets.only(
left: Grid.twelve,
right: Grid.half,
),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.uploading,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(
width: 36,
child: FittedBox(
alignment: Alignment.centerRight,
fit: BoxFit.scaleDown,
child: Text(
'$percentage%',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: Grid.xxs),
Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
child: MouseRegion(
onEnter: (_) => cancelHovered.value = true,
onExit: (_) => cancelHovered.value = false,
child: Material(
color: cancelHovered.value
? context.colors.onSurface.withValues(
alpha: 0.08,
)
: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.full),
clipBehavior: Clip.antiAlias,
child: InkWell(
key: const ValueKey('compose-upload-cancel'),
onTap: onCancel,
borderRadius: BorderRadius.circular(Radii.full),
child: Padding(
key: const ValueKey(
'compose-upload-cancel-padding',
),
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.quarter,
),
child: Text(
context.l10n.cancelUpload,
style: context.textTheme.labelMedium
?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
),
],
),
),
],
),
),
),
),
);
}
}