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
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:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user