From aee7a03e7bd5f38aaeb0d85decdea3e5604a5de7 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 10:50:24 -0700 Subject: [PATCH 1/2] fix: remove container border/bg from image previews in chat --- desktop/src/shared/ui/markdown.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 14cae157..6cc3dcdf 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -192,10 +192,10 @@ function createMarkdownComponents( return ( -
+
{alt}
@@ -262,7 +262,7 @@ function createMarkdownComponents( if (isImageOnlyParagraph(childArray)) { return ( -
+
{imageChildren}
); From b5162b9b3e607edbd91226468d29b68430611215 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Apr 2026 11:03:54 -0700 Subject: [PATCH 2/2] feat(mobile): add swipe-down-to-dismiss for image preview --- .../features/channels/media_viewer_page.dart | 115 +++++++++++++++--- 1 file changed, 96 insertions(+), 19 deletions(-) diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart index a2be88a4..0c7acb57 100644 --- a/mobile/lib/features/channels/media_viewer_page.dart +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -116,22 +116,34 @@ class MediaImageViewerPage extends StatefulWidget { State createState() => _MediaImageViewerPageState(); } -class _MediaImageViewerPageState extends State { +class _MediaImageViewerPageState extends State + with SingleTickerProviderStateMixin { late final TransformationController _transformationController; + late final AnimationController _snapBackController; bool _isTransformed = false; bool _disableHeroOnDismiss = false; + double _dragOffset = 0; + bool _isDragging = false; + + static const _dismissThreshold = 100.0; + static const _backgroundFadeDivisor = 300.0; @override void initState() { super.initState(); _transformationController = TransformationController(); _transformationController.addListener(_handleTransformChanged); + _snapBackController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); } @override void dispose() { _transformationController.removeListener(_handleTransformChanged); _transformationController.dispose(); + _snapBackController.dispose(); super.dispose(); } @@ -143,6 +155,58 @@ class _MediaImageViewerPageState extends State { setState(() { _isTransformed = isTransformed; + // If the user zooms in while dragging, cancel the drag. + if (_isTransformed && _isDragging) { + _isDragging = false; + _dragOffset = 0; + } + }); + } + + void _onInteractionStart(ScaleStartDetails details) { + if (!_isTransformed && details.pointerCount == 1) { + _isDragging = true; + } + } + + void _onInteractionUpdate(ScaleUpdateDetails details) { + if (_isDragging && !_isTransformed) { + setState(() { + _dragOffset += details.focalPointDelta.dy; + }); + } + } + + void _onInteractionEnd(ScaleEndDetails details) { + if (!_isDragging) return; + _isDragging = false; + + if (_dragOffset.abs() > _dismissThreshold) { + _dismiss(); + } else { + _animateSnapBack(); + } + } + + void _animateSnapBack() { + final startOffset = _dragOffset; + final tween = Tween(begin: startOffset, end: 0); + final curved = CurvedAnimation( + parent: _snapBackController, + curve: Curves.easeOut, + ); + + void listener() { + setState(() { + _dragOffset = tween.evaluate(curved); + }); + } + + _snapBackController + ..reset() + ..addListener(listener); + _snapBackController.forward().whenCompleteOrCancel(() { + _snapBackController.removeListener(listener); }); } @@ -180,27 +244,40 @@ class _MediaImageViewerPageState extends State { }, child: Scaffold( key: const ValueKey('message-media-image-viewer'), - backgroundColor: Colors.black, + backgroundColor: Colors.black.withValues( + alpha: (1 - (_dragOffset.abs() / _backgroundFadeDivisor)).clamp( + 0.3, + 1.0, + ), + ), body: Stack( children: [ Positioned.fill( - child: InteractiveViewer( - transformationController: _transformationController, - minScale: 1, - maxScale: 4, - child: Center( - child: HeroMode( - key: const ValueKey('message-media-image-viewer-hero-mode'), - enabled: !_disableHeroOnDismiss, - child: Hero( - tag: widget.heroTag, - child: Image.network( - widget.imageUrl, - fit: BoxFit.contain, - semanticLabel: widget.semanticLabel, - errorBuilder: (_, _, _) => const _MediaLoadFailure( - message: 'Failed to load image', - icon: LucideIcons.imageOff, + child: Transform.translate( + offset: Offset(0, _dragOffset), + child: InteractiveViewer( + transformationController: _transformationController, + onInteractionStart: _onInteractionStart, + onInteractionUpdate: _onInteractionUpdate, + onInteractionEnd: _onInteractionEnd, + minScale: 1, + maxScale: 4, + child: Center( + child: HeroMode( + key: const ValueKey( + 'message-media-image-viewer-hero-mode', + ), + enabled: !_disableHeroOnDismiss, + child: Hero( + tag: widget.heroTag, + child: Image.network( + widget.imageUrl, + fit: BoxFit.contain, + semanticLabel: widget.semanticLabel, + errorBuilder: (_, _, _) => const _MediaLoadFailure( + message: 'Failed to load image', + icon: LucideIcons.imageOff, + ), ), ), ),