lightbox: Display thumbnail with original image sizing

https://github.com/user-attachments/assets/0a007765-0879-4124-9f1b-2cf597518dac

(Initial image opens are older messages missing `data-original-dimensions` attribute)

Fixes #830

Show thumbnail at main image's size in lightbox
With #820 (implementing #799), when you open an image in the lightbox we immediately show the thumbnail (which we'll typically already have loaded, from when you saw the preview in the message list) while we're loading the full image.

One wrinkle is that for some images this means that the thumbnail appears smaller than the full image will, so there's a pop where it expands when the fetch finishes. This is still better than not showing the image at all (at least in cases where the fetch is more than perhaps a few hundred milliseconds), which is why we do it.

But with an extra feature that's now being added in the server, we can do better: the image HTML will encode the original image's dimensions, so we can use those to make sure we show the thumbnail at the same size we'll eventually show the full image. Let's use that.

* `#api design` thread: https://chat.zulip.org/#narrow/stream/378-api-design/topic/thumbnailing.20dimensions/near/1898379
* Server PR: https://github.com/zulip/zulip/pull/31041

flutter test test/model/content_test.dart test/widgets/lightbox_test.dart --reporter expanded 2>&1

diff --git a/lib/model/content.dart b/lib/model/content.dart
index cf2ebd28b5..e9ee7875ef 100644
--- a/lib/model/content.dart
+++ b/lib/model/content.dart
@@ -356,6 +356,8 @@ class ImageNode extends BlockContentNode {
     required this.srcUrl,
     required this.thumbnailUrl,
     required this.loading,
+    required this.originalWidth,
+    required this.originalHeight,
   });

   /// The canonical source URL of the image.
@@ -379,16 +381,25 @@ class ImageNode extends BlockContentNode {
   /// Typically it will be `true` while Server is generating thumbnails.
   final bool loading;

+  /// The width of the canonical image.
+  final double? originalWidth;
+
+  /// The height of the canonical image.
+  final double? originalHeight;
+
   @override
   bool operator ==(Object other) {
     return other is ImageNode
       && other.srcUrl == srcUrl
       && other.thumbnailUrl == thumbnailUrl
-      && other.loading == loading;
+      && other.loading == loading
+      && other.originalWidth == originalWidth
+      && other.originalHeight == originalHeight;
   }

   @override
-  int get hashCode => Object.hash('ImageNode', srcUrl, thumbnailUrl, loading);
+  int get hashCode => Object.hash('ImageNode',
+    srcUrl, thumbnailUrl, loading, originalWidth, originalHeight);

   @override
   void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@@ -396,6 +407,8 @@ class ImageNode extends BlockContentNode {
     properties.add(StringProperty('srcUrl', srcUrl));
     properties.add(StringProperty('thumbnailUrl', thumbnailUrl));
     properties.add(FlagProperty('loading', value: loading, ifTrue: "is loading"));
+    properties.add(DoubleProperty('originalWidth', originalWidth));
+    properties.add(DoubleProperty('originalHeight', originalHeight));
   }
 }

@@ -1037,6 +1050,8 @@ class _ZulipContentParser {
     return CodeBlockNode(spans, debugHtmlNode: debugHtmlNode);
   }

+  static final _imageDimensionsRegExp = RegExp(r'^(\d+)x(\d+)$');
+
   BlockContentNode parseImageNode(dom.Element divElement) {
     assert(_debugParserContext == _ParserContext.block);
     final elements = () {
@@ -1071,6 +1086,8 @@ class _ZulipContentParser {
         srcUrl: href,
         thumbnailUrl: null,
         loading: true,
+        originalWidth: null,
+        originalHeight: null,
         debugHtmlNode: debugHtmlNode);
     }
     final src = imgElement.attributes['src'];
@@ -1093,10 +1110,32 @@ class _ZulipContentParser {
     } else {
       return UnimplementedBlockContentNode(htmlNode: divElement);
     }
+
+    double? originalWidth, originalHeight;
+    final originalDimensions = imgElement.attributes['data-original-dimensions'];
+    if (originalDimensions != null) {
+      // Server encodes this string as "{width}x{height}" (eg. "300x400")
+      final match = _imageDimensionsRegExp.firstMatch(originalDimensions);
+      if (match != null) {
+        final width = int.tryParse(match.group(1)!, radix: 10);
+        final height = int.tryParse(match.group(2)!, radix: 10);
+        if (width != null && height != null) {
+          originalWidth = width.toDouble();
+          originalHeight = height.toDouble();
+        }
+      }
+
+      if (originalWidth == null || originalHeight == null) {
+        return UnimplementedBlockContentNode(htmlNode: divElement);
+      }
+    }
+
     return ImageNode(
       srcUrl: srcUrl,
       thumbnailUrl: thumbnailUrl,
       loading: false,
+      originalWidth: originalWidth,
+      originalHeight: originalHeight,
       debugHtmlNode: debugHtmlNode);
   }

diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 795e1389e2..7b567b74fb 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -572,12 +572,13 @@ class MessageImage extends StatelessWidget {

     return MessageMediaContainer(
       onTap: resolvedSrcUrl == null ? null : () { // TODO(log)
-        Navigator.of(context).push(getLightboxRoute(
+        Navigator.of(context).push(getImageLightboxRoute(
           context: context,
           message: message,
           src: resolvedSrcUrl,
           thumbnailUrl: resolvedThumbnailUrl,
-          mediaType: MediaType.image));
+          originalWidth: node.originalWidth,
+          originalHeight: node.originalHeight));
       },
       child: node.loading
         ? const CupertinoActivityIndicator()
@@ -603,12 +604,10 @@ class MessageInlineVideo extends StatelessWidget {

     return MessageMediaContainer(
       onTap: resolvedSrc == null ? null : () { // TODO(log)
-        Navigator.of(context).push(getLightboxRoute(
+        Navigator.of(context).push(getVideoLightboxRoute(
           context: context,
           message: message,
-          src: resolvedSrc,
-          thumbnailUrl: null,
-          mediaType: MediaType.video));
+          src: resolvedSrc));
       },
       child: Container(
         color: Colors.black, // Web has the same color in light and dark mode.
diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart
index 33ed3f68de..dffe16dfbb 100644
--- a/lib/widgets/lightbox.dart
+++ b/lib/widgets/lightbox.dart
@@ -217,12 +217,16 @@ class _ImageLightboxPage extends StatefulWidget {
     required this.message,
     required this.src,
     required this.thumbnailUrl,
+    required this.originalWidth,
+    required this.originalHeight,
   });

   final Animation<double> routeEntranceAnimation;
   final Message message;
   final Uri src;
   final Uri? thumbnailUrl;
+  final double? originalWidth;
+  final double? originalHeight;

   @override
   State<_ImageLightboxPage> createState() => _ImageLightboxPageState();
@@ -259,8 +263,14 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
     if (frame != null) return child;

     // Display the thumbnail image while original image is downloading.
-    return RealmContentNetworkImage(widget.thumbnailUrl!,
-      filterQuality: FilterQuality.medium);
+    return FittedBox(
+      fit: BoxFit.scaleDown,
+      child: SizedBox(
+        width: widget.originalWidth,
+        height: widget.originalHeight,
+        child: RealmContentNetworkImage(widget.thumbnailUrl!,
+          filterQuality: FilterQuality.medium,
+          fit: BoxFit.contain)));
   }

   Widget _loadingBuilder(BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
@@ -301,11 +311,7 @@ class _ImageLightboxPageState extends State<_ImageLightboxPage> {
               child: RealmContentNetworkImage(widget.src,
                 filterQuality: FilterQuality.medium,
                 frameBuilder: _frameBuilder,
-                loadingBuilder: _loadingBuilder),
-            ),
-          ),
-        ),
-      ));
+                loadingBuilder: _loadingBuilder))))));
   }
 }

@@ -536,18 +542,10 @@ class _VideoLightboxPageState extends State<VideoLightboxPage> with PerAccountSt
   }
 }

-enum MediaType {
-  video,
-  image
-}
-
-Route<void> getLightboxRoute({
-  int? accountId,
-  BuildContext? context,
-  required Message message,
-  required Uri src,
-  required Uri? thumbnailUrl,
-  required MediaType mediaType,
+Route<void> _getLightboxRoute({
+  required int? accountId,
+  required BuildContext? context,
+  required RoutePageBuilder pageBuilder,
 }) {
   return AccountPageRouteBuilder(
     accountId: accountId,
@@ -559,17 +557,7 @@ Route<void> getLightboxRoute({
       Animation<double> secondaryAnimation,
     ) {
       // TODO(#40): Drag down to close?
-      return switch (mediaType) {
-        MediaType.image => _ImageLightboxPage(
-          routeEntranceAnimation: animation,
-          message: message,
-          src: src,
-          thumbnailUrl: thumbnailUrl),
-        MediaType.video => VideoLightboxPage(
-          routeEntranceAnimation: animation,
-          message: message,
-          src: src),
-      };
+      return pageBuilder(context, animation, secondaryAnimation);
     },
     transitionsBuilder: (
       BuildContext context,
@@ -583,3 +571,43 @@ Route<void> getLightboxRoute({
     },
   );
 }
+
+Route<void> getImageLightboxRoute({
+  int? accountId,
+  BuildContext? context,
+  required Message message,
+  required Uri src,
+  required Uri? thumbnailUrl,
+  required double? originalWidth,
+  required double? originalHeight,
+}) {
+  return _getLightboxRoute(
+    accountId: accountId,
+    context: context,
+    pageBuilder: (context, animation, secondaryAnimation) {
+      return _ImageLightboxPage(
+        routeEntranceAnimation: animation,
+        message: message,
+        src: src,
+        thumbnailUrl: thumbnailUrl,
+        originalWidth: originalWidth,
+        originalHeight: originalHeight);
+    });
+}
+
+Route<void> getVideoLightboxRoute({
+  int? accountId,
+  BuildContext? context,
+  required Message message,
+  required Uri src,
+}) {
+  return _getLightboxRoute(
+    accountId: accountId,
+    context: context,
+    pageBuilder: (context, animation, secondaryAnimation) {
+      return VideoLightboxPage(
+        routeEntranceAnimation: animation,
+        message: message,
+        src: src);
+    });
+}

diff --git a/test/model/content_test.dart b/test/model/content_test.dart
index 60a5698c99..0846273795 100644
--- a/test/model/content_test.dart
+++ b/test/model/content_test.dart
@@ -263,7 +263,8 @@ class ContentExample {
         ]),
         ImageNodeList([
           ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3',
-            thumbnailUrl: null, loading: false),
+            thumbnailUrl: null, loading: false,
+            originalWidth: null, originalHeight: null),
         ]),
       ],
       content: [ParagraphNode(links: null, nodes: [TextNode('hello world')])],
@@ -421,6 +422,22 @@ class ContentExample {

   static const imageSingle = ContentExample(
     'single image',
+    // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1900103
+    "[image.jpg](/user_uploads/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg)",
+    '
'
+      ''
+        '
', [
+    ImageNodeList([
+      ImageNode(srcUrl: '/user_uploads/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg',
+        thumbnailUrl: '/user_uploads/thumbnail/2/ce/nvoNL2LaZOciwGZ-FYagddtK/image.jpg/840x560.webp',
+        loading: false,
+        originalWidth: 6000,
+        originalHeight: 4000),
+    ]),
+  ]);
+
+  static const imageSingleNoDimensions = ContentExample(
+    'single image no dimensions',
     // https://chat.zulip.org/#narrow/stream/7-test-here/topic/Thumbnails/near/1893590
     "[image.jpg](/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg)",
     '
'
@@ -429,7 +446,9 @@ class ContentExample {
     ImageNodeList([
       ImageNode(srcUrl: '/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg',
         thumbnailUrl: '/user_uploads/thumbnail/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg/840x560.webp',
-        loading: false),
+        loading: false,
+        originalWidth: null,
+        originalHeight: null),
     ]),
   ]);

@@ -441,7 +460,8 @@ class ContentExample {
         '
', [
     ImageNodeList([
       ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=3',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
     ]),
   ]);

@@ -454,7 +474,8 @@ class ContentExample {
         '
', [
     ImageNodeList([
       ImageNode(srcUrl: '/user_uploads/2/c3/wb9FXk8Ej6qIc28aWKcqUogD/image.jpg',
-        thumbnailUrl: null, loading: true),
+        thumbnailUrl: null, loading: true,
+        originalWidth: null, originalHeight: null),
     ]),
   ]);

@@ -467,7 +488,8 @@ class ContentExample {
       '
', [
     ImageNodeList([
       ImageNode(srcUrl: '/external_content/de28eb3abf4b7786de4545023dc42d434a2ea0c2/68747470733a2f2f75706c6f61642e77696b696d656469612e6f72672f77696b6970656469612f636f6d6d6f6e732f372f37382f566572726567656e64655f626c6f656d5f76616e5f65656e5f48656c656e69756d5f253237456c5f446f7261646f2532372e5f32322d30372d323032332e5f253238642e6a2e622532392e6a7067',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
     ]),
   ]);

@@ -478,7 +500,9 @@ class ContentExample {
       ''
         '
', [
     ImageNodeList([
-      ImageNode(srcUrl: '::not a URL::', thumbnailUrl: null, loading: false),
+      ImageNode(srcUrl: '::not a URL::',
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
     ]),
   ]);

@@ -504,10 +528,14 @@ class ContentExample {
     ImageNodeList([
       ImageNode(srcUrl: '/user_uploads/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg',
         thumbnailUrl: '/user_uploads/thumbnail/2/9b/WkDt2Qsy79iwf3sM9EMp9fYL/image.jpg/840x560.webp',
-        loading: false),
+        loading: false,
+        originalWidth: null,
+        originalHeight: null),
       ImageNode(srcUrl: '/user_uploads/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg',
         thumbnailUrl: '/user_uploads/thumbnail/2/70/pVeI52TwFUEoFE2qT_u9AMCO/image2.jpg/840x560.webp',
-        loading: false),
+        loading: false,
+        originalWidth: null,
+        originalHeight: null),
     ]),
   ]);

@@ -531,9 +559,11 @@ class ContentExample {
     ]),
     ImageNodeList([
       ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/f535ba07f95b99a83aa48e44fd62bbb6c6cf6615/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d33',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
       ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/8f63bc2632a0e41be3f457d86c077e61b4a03e7e/68747470733a2f2f636861742e7a756c69702e6f72672f757365725f617661746172732f322f7265616c6d2f69636f6e2e706e673f76657273696f6e3d34',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
     ]),
   ]);

@@ -558,9 +588,11 @@ class ContentExample {
     ]),
     ImageNodeList([
       ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
       ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
     ]),
     ParagraphNode(links: null, nodes: [
       TextNode('more content'),
@@ -596,9 +628,11 @@ class ContentExample {
     ]),
     ImageNodeList([
       ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/34b2695ca83af76204b0b25a8f2019ee35ec38fa/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e67',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
       ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/d200fb112aaccbff9df767373a201fa59601f362/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d31',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
     ]),
     ParagraphNode(links: null, nodes: [
       TextNode('Test'),
@@ -611,9 +645,11 @@ class ContentExample {
     ]),
     ImageNodeList([
       ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/c4db87e81348dac94eacaa966b46d968b34029cc/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d32',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
       ImageNode(srcUrl: 'https://uploads.zulipusercontent.net/51b70540cf6a5b3c8a0b919c893b8abddd447e88/68747470733a2f2f656e2e77696b6970656469612e6f72672f7374617469632f696d616765732f69636f6e732f77696b6970656469612e706e673f763d33',
-        thumbnailUrl: null, loading: false),
+        thumbnailUrl: null, loading: false,
+        originalWidth: null, originalHeight: null),
     ]),
   ]);

@@ -628,7 +664,8 @@ class ContentExample {
     ListNode(ListStyle.unordered, [[
       ImageNodeList([
         ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
-          thumbnailUrl: null, loading: false),
+          thumbnailUrl: null, loading: false,
+          originalWidth: null, originalHeight: null),
       ]),
     ]]),
   ]);
@@ -654,9 +691,11 @@ class ContentExample {
       ]),
       ImageNodeList([
         ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
-          thumbnailUrl: null, loading: false),
+          thumbnailUrl: null, loading: false,
+          originalWidth: null, originalHeight: null),
         ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png?version=2',
-          thumbnailUrl: null, loading: false),
+          thumbnailUrl: null, loading: false,
+          originalWidth: null, originalHeight: null),
       ]),
     ]]),
   ]);
@@ -680,7 +719,8 @@ class ContentExample {
       ]),
       const ImageNodeList([
         ImageNode(srcUrl: 'https://chat.zulip.org/user_avatars/2/realm/icon.png',
-          thumbnailUrl: null, loading: false),
+          thumbnailUrl: null, loading: false,
+          originalWidth: null, originalHeight: null),
       ]),
       blockUnimplemented('more text'),
     ]]),
@@ -1117,6 +1157,7 @@ void main() {
   testParseExample(ContentExample.mathBlockInQuote);

   testParseExample(ContentExample.imageSingle);
+  testParseExample(ContentExample.imageSingleNoDimensions);
   testParseExample(ContentExample.imageSingleNoThumbnail);
   testParseExample(ContentExample.imageSingleLoadingPlaceholder);
   testParseExample(ContentExample.imageSingleExternal);
diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart
index 23bb15b6b9..419690f788 100644
--- a/test/widgets/lightbox_test.dart
+++ b/test/widgets/lightbox_test.dart
@@ -211,12 +211,13 @@ void main() {
       await tester.pumpWidget(const ZulipApp());
       await tester.pump();
       final navigator = await ZulipApp.navigator;
-      navigator.push(getLightboxRoute(
+      navigator.push(getImageLightboxRoute(
         accountId: eg.selfAccount.id,
         message: message ?? eg.streamMessage(),
         src: src,
         thumbnailUrl: thumbnailUrl,
-        mediaType: MediaType.image,
+        originalHeight: null,
+        originalWidth: null,
       ));
       await tester.pump(); // per-account store
       await tester.pump(const Duration(milliseconds: 301)); // nav transition


content: Make **bold** bolder even in spoiler headers and h1/h2/etc.

This fixes #705 by making boldness context-dependent, much like we did for some font sizes in #544.

That user-facing change is nice but fairly subtle. I'm also hoping this will be helpful by adding content tests for boldness in the first place; we didn't have any before. And I've set a pattern in the `UserMention` tests that should help reduce confusion when we write boldness tests for #647. (Confusion mainly related to the fact that `UserMention` is in a `WidgetSpan`.)

The need for more helpers in lib/widgets/text.dart is another reminder that it'll be great to have https://github.com/flutter/flutter/issues/148026 fixed upstream. But (as with the existing helpers there) I'm hoping this handles things in a way that makes it straightforward to migrate once that's done.

Fixes: #705

| Before | After |
| --- | --- |
| ![75927CB2-41BB-415B-9530-CE418DBB9368](https://github.com/zulip/zulip-flutter/assets/22248748/c21677b2-4b15-4513-8b70-16354ffe48ff) | ![445F7D01-3E38-4A19-B5CD-7746474655D5](https://github.com/zulip/zulip-flutter/assets/22248748/c4c49bcd-e103-47dd-9b94-af7ecc10b5ab) |

content: Bold spans aren't bolder than surrounding text in spoiler headers or h1/h2/etc.
Bold spans should be bolder than surrounding text, even when that surrounding text is bold because it's in a spoiler header or an h1/h2/etc. (or column headings in tables, with #360).

| Current | Better |
| --- | --- |
| ![75927CB2-41BB-415B-9530-CE418DBB9368](https://github.com/zulip/zulip-flutter/assets/22248748/c21677b2-4b15-4513-8b70-16354ffe48ff) | ![445F7D01-3E38-4A19-B5CD-7746474655D5](https://github.com/zulip/zulip-flutter/assets/22248748/c4c49bcd-e103-47dd-9b94-af7ecc10b5ab) |

In fact, in the spoiler header, the bold text is _less_ bold. That's because, following web, we hard-code bold spans to weight 600 but spoiler headers to weight 700.

flutter test test/widgets/content_test.dart test/widgets/text_test.dart --reporter expanded 2>&1

diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 9e7008aca1..2e81a13ce6 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -252,6 +252,11 @@ class Heading extends StatelessWidget {
           fontSize: kBaseFontSize * emHeight,
           height: 1.4,
         )
+          // Could set boldness relative to ambient text style, which itself
+          // might be bolder than normal (e.g. in spoiler headers).
+          // But this didn't seem like a clear improvement and would make inline
+          // **bold** spans less distinct; discussion:
+          //   https://github.com/zulip/zulip-flutter/pull/706#issuecomment-2141326257
           .merge(weightVariableTextStyle(context, wght: 600)),
         node: node));
   }
@@ -723,6 +728,12 @@ class InlineContent extends StatelessWidget {
     required this.nodes,
   }) {
     assert(style.fontSize != null);
+    assert(
+      style.debugLabel!.contains('weightVariableTextStyle')
+      // ([ContentTheme.textStylePlainParagraph] applies [weightVariableTextStyle])
+      || style.debugLabel!.contains('ContentTheme.textStylePlainParagraph')
+      || style.debugLabel!.contains('bolderWghtTextStyle')
+    );
     _builder = _InlineContentBuilder(this);
   }

@@ -733,6 +744,7 @@ class InlineContent extends StatelessWidget {
   ///
   /// Must set [TextStyle.fontSize]. Some descendant spans will consume it,
   /// e.g., to make their content slightly smaller than surrounding text.
+  /// Similarly must set a font weight using [weightVariableTextStyle].
   final TextStyle style;

   final List<InlineContentNode> nodes;
@@ -832,7 +844,7 @@ class _InlineContentBuilder {
   }

   InlineSpan _buildStrong(StrongNode node) => _buildNodes(node.nodes,
-    style: weightVariableTextStyle(_context!, wght: 600));
+    style: bolderWghtTextStyle(widget.style, by: 200));

   InlineSpan _buildDeleted(DeletedNode node) => _buildNodes(node.nodes,
     style: const TextStyle(decoration: TextDecoration.lineThrough));
diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart
index 73093a4294..3f4389e592 100644
--- a/lib/widgets/text.dart
+++ b/lib/widgets/text.dart
@@ -1,4 +1,5 @@
 import 'dart:io';
+import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/material.dart';

@@ -209,7 +210,7 @@ TextStyle weightVariableTextStyle(BuildContext context, {
   double? wght,
   double? wghtIfPlatformRequestsBold,
 }) {
-  double value = wght ?? FontWeight.normal.value.toDouble();
+  double value = wght ?? wghtFromFontWeight(FontWeight.normal);
   if (MediaQuery.boldTextOf(context)) {
     // The framework has a condition on [MediaQueryData.boldText]
     // in the [Text] widget, but that only affects `fontWeight`.
@@ -254,9 +255,51 @@ const kWghtMin = 1.0;
 const kWghtMax = 1000.0;

 /// A [FontVariation] "wght" value that's 300 above a given, clamped to [kWghtMax].
-double bolderWght(double baseWght) {
+///
+/// The input value must be between [kWghtMin] and [kWghtMax].
+///
+/// Pass [by] to use a value other than 300.
+double bolderWght(double baseWght, {double by = 300}) {
   assert(kWghtMin <= baseWght && baseWght <= kWghtMax);
-  return clampDouble(baseWght + 300, kWghtMin, kWghtMax);
+  return clampDouble(baseWght + by, kWghtMin, kWghtMax);
+}
+
+/// A [TextStyle] whose [FontVariation] "wght" and [TextStyle.fontWeight]
+/// have been raised using [bolderWght].
+///
+/// [style] must have already been processed with [weightVariableTextStyle],
+/// and [by] must be positive.
+///
+/// The increase done here will commute with any increase that was done by
+/// [weightVariableTextStyle] to respond to the device bold-text setting,
+/// because both adjustments are done with [bolderWght] with positive `by`.
+///
+/// [by] defaults to 300.
+TextStyle bolderWghtTextStyle(TextStyle style, {double by = 300}) {
+  assert(
+    style.debugLabel!.contains('weightVariableTextStyle')
+    // ([ContentTheme.textStylePlainParagraph] applies [weightVariableTextStyle])
+    || style.debugLabel!.contains('ContentTheme.textStylePlainParagraph')
+    || style.debugLabel!.contains('bolderWghtTextStyle')
+  );
+  assert(by > 0);
+  assert(style.fontVariations!.where((v) => v.axis == 'wght').length == 1);
+
+  final newWght = bolderWght(wghtFromTextStyle(style)!, by: by);
+
+  TextStyle result = style.copyWith(
+    fontVariations: style.fontVariations!.map((v) => v.axis == 'wght'
+      ? FontVariation('wght', newWght)
+      : v).toList(),
+    fontWeight: clampVariableFontWeight(newWght),
+  );
+
+  assert(() {
+    result = result.copyWith(debugLabel: 'bolderWghtTextStyle(by: $by)');
+    return true;
+  }());
+
+  return result;
 }

 /// Find the nearest [FontWeight] constant for a variable-font "wght"-axis value.
@@ -286,6 +329,23 @@ FontWeight clampVariableFontWeight(double wght) {
   }
 }

+/// A "wght" value extracted from a [TextStyle].
+///
+/// Returns the value in [TextStyle.fontVariations] if present.
+/// If that's absent but a [TextStyle.fontWeight] is present,
+/// returns [wghtFromFontWeight] for that value.
+///
+/// The returned value already reflects any response to the system bold-text
+/// setting, so if the [TextStyle] was built using [weightVariableTextStyle],
+/// this value might be larger than the `wght` that was passed to that function.
+double? wghtFromTextStyle(TextStyle style) {
+  double? result = style.fontVariations?.firstWhereOrNull((v) => v.axis == 'wght')?.value;
+  if (result == null && style.fontWeight != null) {
+    result = wghtFromFontWeight(style.fontWeight!);
+  }
+  return result;
+}
+
 /// A good guess at a font's "wght" value to match a given [FontWeight].
 ///
 /// Returns [FontWeight.value] as a double.

diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart
index 8c03d410ed..0a3fbcba91 100644
--- a/test/flutter_checks.dart
+++ b/test/flutter_checks.dart
@@ -77,6 +77,10 @@ extension TextStyleChecks on Subject<TextStyle> {
   // TODO others
 }

+extension FontVariationChecks on Subject<FontVariation> {
+  Subject<String> get axis => has((x) => x.axis, 'axis');
+  Subject<double> get value => has((x) => x.value, 'value');
+}

 extension TextThemeChecks on Subject<TextTheme> {
   Subject<TextStyle?> get displayLarge => has((t) => t.displayLarge, 'displayLarge');
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index 110a85ae1a..26116f8214 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -14,6 +14,7 @@ import 'package:zulip/widgets/icons.dart';
 import 'package:zulip/widgets/message_list.dart';
 import 'package:zulip/widgets/page.dart';
 import 'package:zulip/widgets/store.dart';
+import 'package:zulip/widgets/text.dart';
 import 'package:zulip/widgets/theme.dart';

 import '../example_data.dart' as eg;
@@ -154,6 +155,41 @@ void main() {
     });
   }

+  /// Test the font weight found by [styleFinder] in the rendering of [content].
+  ///
+  /// The weight will be expected to be [expectedWght] when the system
+  /// bold-text setting is not set, and to vary appropriately when it is set.
+  ///
+  /// [styleFinder] must return the [TextStyle] containing the "wght"
+  /// (in [TextStyle.fontVariations]) and the [TextStyle.fontWeight]
+  /// to be checked.
+  Future<void> testFontWeight(String description, {
+    required Widget content,
+    required double expectedWght,
+    required TextStyle Function(WidgetTester tester) styleFinder,
+  }) async {
+    for (final platformRequestsBold in [false, true]) {
+      testWidgets(
+        description + (platformRequestsBold ? ' (platform requests bold)' : ''),
+        (tester) async {
+          tester.platformDispatcher.accessibilityFeaturesTestValue =
+            FakeAccessibilityFeatures(boldText: platformRequestsBold);
+          await prepareContent(tester, content);
+          final style = styleFinder(tester);
+          double effectiveExpectedWght = expectedWght;
+          if (platformRequestsBold) {
+            // bolderWght because that's what [weightVariableTextStyle] uses
+            effectiveExpectedWght = bolderWght(expectedWght);
+          }
+          check(style)
+            ..fontVariations.isNotNull()
+              .any((it) => it..axis.equals('wght')..value.equals(effectiveExpectedWght))
+            ..fontWeight.equals(clampVariableFontWeight(effectiveExpectedWght));
+          tester.platformDispatcher.clearAccessibilityFeaturesTestValue();
+        });
+    }
+  }
+
   group('ThematicBreak', () {
     testWidgets('smoke ThematicBreak', (tester) async {
       await prepareContent(tester, plainContent(ContentExample.thematicBreak.html));
@@ -469,7 +505,69 @@ void main() {
     check((ratioInHeader - ratioInParagraph).abs()).isLessThan(0.001);
   }

-  testContentSmoke(ContentExample.strong);
+  group('strong (bold)', () {
+    testContentSmoke(ContentExample.strong);
+
+    TextStyle findWordBold(WidgetTester tester) {
+      final root = tester.renderObject<RenderParagraph>(find.textContaining('bold')).text;
+      return mergedStyleOfSubstring(root, 'bold')!;
+    }
+
+    testFontWeight('in plain paragraph',
+      expectedWght: 600,
+      // **bold**
+      content: plainContent('
bold
'),
+      styleFinder: findWordBold,
+    );
+
+    for (final level in HeadingLevel.values) {
+      final name = level.name;
+      assert(RegExp(r'^h[1-6]$').hasMatch(name));
+      testFontWeight('in $name',
+        expectedWght: 800,
+        // # **bold**, ## **bold**, ### **bold**, etc.
+        content: plainContent('<$name>bold</$name>'),
+        styleFinder: findWordBold,
+      );
+    }
+
+    testFontWeight('in different kind of span in h1',
+      expectedWght: 800,
+      // # ~~**bold**~~
+      content: plainContent('
bold
'),
+      styleFinder: findWordBold,
+    );
+
+    testFontWeight('in spoiler header',
+      expectedWght: 900,
+      // ```spoiler regular **bold**
+      // content
+      // ```
+      content: plainContent(
+        '

\n'
+          '
regular bold
\n'
+          '

\n'
+          '
content
\n'
+          '

'
+      ),
+      styleFinder: findWordBold,
+    );
+
+    testFontWeight('in different kind of span in spoiler header',
+      expectedWght: 900,
+      // ```spoiler *italic **bold***
+      // content
+      // ```
+      content: plainContent(
+        '

\n'
+          '
italic bold
\n'
+          '

\n'
+          '
content
\n'
+          '

'
+      ),
+      styleFinder: findWordBold,
+    );
+  });

   testContentSmoke(ContentExample.emphasis);

@@ -489,25 +587,63 @@ void main() {
     testContentSmoke(ContentExample.groupMentionPlain);
     testContentSmoke(ContentExample.groupMentionSilent);

+    UserMention? findUserMentionInSpan(InlineSpan rootSpan) {
+      UserMention? result;
+      rootSpan.visitChildren((span) {
+        if (span case (WidgetSpan(child: UserMention() && var widget))) {
+          result = widget;
+          return false;
+        }
+        return true;
+      });
+      return result;
+    }
+
+    TextStyle textStyleFromWidget(WidgetTester tester, UserMention widget, String mentionText) {
+      final fullNameSpan = tester.renderObject<RenderParagraph>(
+        find.descendant(
+          of: find.byWidget(widget), matching: find.text(mentionText))
+      ).text;
+      return mergedStyleOfSubstring(fullNameSpan, mentionText)!;
+    }
+
     testWidgets('maintains font-size ratio with surrounding text', (tester) async {
       await checkFontSizeRatio(tester,
         targetHtml: '@Chris Bobbe',
         targetFontSizeFinder: (rootSpan) {
-          late final double result;
-          rootSpan.visitChildren((span) {
-            if (span case WidgetSpan(child: UserMention() && var widget)) {
-              final fullNameSpan = tester.renderObject<RenderParagraph>(
-                find.descendant(
-                  of: find.byWidget(widget), matching: find.text('@Chris Bobbe'))
-              ).text;
-              result = mergedStyleOfSubstring(fullNameSpan, '@Chris Bobbe')!.fontSize!;
-              return false;
-            }
-            return true;
-          });
-          return result;
+          final widget = findUserMentionInSpan(rootSpan);
+          final style = textStyleFromWidget(tester, widget!, '@Chris Bobbe');
+          return style.fontSize!;
         });
     });
+
+    testFontWeight('silent or non-self mention in plain paragraph',
+      expectedWght: 400,
+      // @_**Greg Price**
+      content: plainContent(
+        '
Greg Price
'),
+      styleFinder: (tester) {
+        return textStyleFromWidget(tester,
+          tester.widget(find.byType(UserMention)), 'Greg Price');
+      });
+
+    // TODO(#647):
+    //  testFontWeight('non-silent self-user mention in plain paragraph',
+    //    expectedWght: 600, // [etc.]
+
+    testFontWeight('silent or non-self mention in bold context',
+      expectedWght: 600,
+      // # @_**Chris Bobbe**
+      content: plainContent(
+        '
Chris Bobbe
'),
+      styleFinder: (tester) {
+        return textStyleFromWidget(tester,
+          tester.widget(find.byType(UserMention)), 'Chris Bobbe');
+      });
+
+    // TODO(#647):
+    //  testFontWeight('non-silent self-user mention in bold context',
+    //    expectedWght: 800, // [etc.]
   });

   Future<void> tapText(WidgetTester tester, Finder textFinder) async {
diff --git a/test/widgets/text_test.dart b/test/widgets/text_test.dart
index 1a956345d6..824bc574eb 100644
--- a/test/widgets/text_test.dart
+++ b/test/widgets/text_test.dart
@@ -1,4 +1,5 @@
 import 'package:checks/checks.dart';
+import 'package:collection/collection.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
 import 'package:zulip/widgets/text.dart';
@@ -151,10 +152,98 @@ void main() {
   });

   test('bolderWght', () {
+    check(() => bolderWght(kWghtMin - 1)).throws<void>();
+    check(() => bolderWght(kWghtMax + 1)).throws<void>();
+
     check(bolderWght(1)).equals(301);
     check(bolderWght(400)).equals(700);
     check(bolderWght(600)).equals(900);
     check(bolderWght(900)).equals(1000);
+
+    check(bolderWght(1,    by: -200)).equals(1);
+    check(bolderWght(201,  by: -200)).equals(1);
+    check(bolderWght(1000, by: -200)).equals(800);
+
+    check(bolderWght(1,   by: 200)).equals(201);
+    check(bolderWght(400, by: 200)).equals(600);
+    check(bolderWght(600, by: 200)).equals(800);
+    check(bolderWght(900, by: 200)).equals(1000);
+  });
+
+  group('bolderWghtTextStyle', () {
+    Future<void> testBolderWghtTextStyle(
+      String description, {
+      required TextStyle Function(BuildContext context) makeStyle,
+      bool platformRequestsBold = false,
+      required double expectedWght,
+      required FontWeight expectedFontWeight,
+    }) async {
+      testWidgets(description, (WidgetTester tester) async {
+        tester.platformDispatcher.accessibilityFeaturesTestValue =
+          FakeAccessibilityFeatures(boldText: platformRequestsBold);
+
+        await tester.pumpWidget(
+          MaterialApp(
+            home: Builder(builder: (context) =>
+              Text('', style: makeStyle(context)))));
+
+        final TextStyle? style = tester.widget<Text>(find.byType(Text)).style;
+
+        check(style).isNotNull().fontWeight.isNotNull().equals(expectedFontWeight);
+
+        final fontVariations = style!.fontVariations;
+        check(fontVariations).isNotNull();
+        final wghtVariation = fontVariations!.singleWhereOrNull((v) => v.axis == 'wght');
+        check(wghtVariation).isNotNull().value.equals(expectedWght);
+
+        tester.platformDispatcher.clearAccessibilityFeaturesTestValue();
+      });
+    }
+
+    testBolderWghtTextStyle('default + default',
+      makeStyle: (context) => bolderWghtTextStyle(weightVariableTextStyle(context)),
+      expectedWght: 700,
+      expectedFontWeight: FontWeight.w700);
+
+    testBolderWghtTextStyle('default + default (platform requests bold)',
+      platformRequestsBold: true,
+      makeStyle: (context) => bolderWghtTextStyle(weightVariableTextStyle(context)),
+      expectedWght: 1000,
+      expectedFontWeight: FontWeight.w900);
+
+    testBolderWghtTextStyle('320 + 200',
+      makeStyle: (context) => bolderWghtTextStyle(
+        weightVariableTextStyle(context, wght: 320),
+        by: 200,
+      ),
+      expectedWght: 520,
+      expectedFontWeight: FontWeight.w500);
+
+    testBolderWghtTextStyle('320 + 200 (platform requests bold)',
+      platformRequestsBold: true,
+      makeStyle: (context) => bolderWghtTextStyle(
+        weightVariableTextStyle(context, wght: 320),
+        by: 200,
+      ),
+      expectedWght: 820,
+      expectedFontWeight: FontWeight.w800);
+
+    testBolderWghtTextStyle('320 + 200 (platform requests bold; custom response to setting)',
+      platformRequestsBold: true,
+      makeStyle: (context) => bolderWghtTextStyle(
+        weightVariableTextStyle(context, wght: 320, wghtIfPlatformRequestsBold: 410),
+        by: 200,
+      ),
+      expectedWght: 610,
+      expectedFontWeight: FontWeight.w600);
+
+    testBolderWghtTextStyle('900 + 200',
+      makeStyle: (context) => bolderWghtTextStyle(
+        weightVariableTextStyle(context, wght: 900),
+        by: 200,
+      ),
+      expectedWght: 1000,
+      expectedFontWeight: FontWeight.w900);
   });

   test('clampVariableFontWeight: FontWeight has the assumed list of values', () {
@@ -209,6 +298,38 @@ void main() {
     check(clampVariableFontWeight(1000)) .equals(FontWeight.w900);
   });

+  test('wghtFromTextStyle', () {
+    doCheck(TextStyle style, double? expected) {
+      check(wghtFromTextStyle(style)).equals(expected);
+    }
+
+    doCheck(const TextStyle(), null);
+    doCheck(const TextStyle(fontVariations: []), null);
+    doCheck(const TextStyle(fontVariations: [FontVariation.slant(45)]), null);
+
+    doCheck(const TextStyle(fontVariations: [FontVariation('wght', 100)]), 100);
+    doCheck(const TextStyle(
+      fontVariations: [FontVariation('wght', 160)],
+      fontWeight: FontWeight.w200,
+    ), 160);
+    doCheck(const TextStyle(
+      fontVariations: [FontVariation('wght', 100), FontVariation('wght', 200)]
+    ), 100);
+    doCheck(const TextStyle(
+      fontVariations: [FontVariation('wght', 100), FontVariation('wght', 100)],
+      fontWeight: FontWeight.w900,
+    ), 100);
+
+    doCheck(const TextStyle(
+      fontVariations: [],
+      fontWeight: FontWeight.w900,
+    ), 900);
+    doCheck(const TextStyle(
+      fontVariations: [FontVariation.slant(45)],
+      fontWeight: FontWeight.w900,
+    ), 900);
+  });
+
   group('proportionalLetterSpacing', () {
     Future<void> testLetterSpacing(
       String description, {


content: Handle strikethrough, the <del> element

This handles the `<del>` element. This means "strikethrough" formatting; it's what you get with Markdown syntax like `~~this~~`, enclosing a span in`~~`.

Handling these means:
- Parsing into `DeletedNode`
- Styling nodes that are of type `DeletedNode` with a line-through

Fixes #357

Final Result:
![Simulator Screenshot - iPhone 15 Pro Max - 2024-03-25 at 06 28 41](https://github.com/zulip/zulip-flutter/assets/63330019/ff1ca7b6-4900-483f-82f2-4193ad477f74)

Handle strikethrough, the <del> element

Handle strikethrough, the <del> element
We should handle the `<del>` element. This means "strikethrough" formatting; it's what you get with Markdown syntax like `~~this~~`, enclosing a span in `~~`.

Handling these means parsing them in `lib/model/content.dart`, and then styling them appropriately in `lib/widgets/content.dart`. For appropriate styling, see the Zulip web app's `web/styles/rendered_markdown.css`.

flutter test test/model/content_test.dart --reporter expanded 2>&1

diff --git a/lib/model/content.dart b/lib/model/content.dart
index 05067c1d58..9156d0f51d 100644
--- a/lib/model/content.dart
+++ b/lib/model/content.dart
@@ -450,6 +450,10 @@ class StrongNode extends InlineContainerNode {
   const StrongNode({super.debugHtmlNode, required super.nodes});
 }

+class DeletedNode extends InlineContainerNode {
+  const DeletedNode({super.debugHtmlNode, required super.nodes});
+}
+
 class EmphasisNode extends InlineContainerNode {
   const EmphasisNode({super.debugHtmlNode, required super.nodes});
 }
@@ -717,6 +721,9 @@ class _ZulipContentParser {
     if (localName == 'strong' && className.isEmpty) {
       return StrongNode(nodes: nodes(), debugHtmlNode: debugHtmlNode);
     }
+    if (localName == 'del' && className.isEmpty) {
+      return DeletedNode(nodes: nodes(), debugHtmlNode: debugHtmlNode);
+    }
     if (localName == 'em' && className.isEmpty) {
       return EmphasisNode(nodes: nodes(), debugHtmlNode: debugHtmlNode);
     }
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index c023d6d7be..36ca7a6f9c 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -622,6 +622,8 @@ class _InlineContentBuilder {
       return const TextSpan(text: "");
     } else if (node is StrongNode) {
       return _buildStrong(node);
+    } else if (node is DeletedNode) {
+      return _buildDeleted(node);
     } else if (node is EmphasisNode) {
       return _buildEmphasis(node);
     } else if (node is LinkNode) {
@@ -653,6 +655,9 @@ class _InlineContentBuilder {
   InlineSpan _buildStrong(StrongNode node) => _buildNodes(node.nodes,
     style: weightVariableTextStyle(_context, wght: 600, wghtIfPlatformRequestsBold: 900));

+  InlineSpan _buildDeleted(DeletedNode node) => _buildNodes(node.nodes,
+    style: const TextStyle(decoration: TextDecoration.lineThrough));
+
   InlineSpan _buildEmphasis(EmphasisNode node) => _buildNodes(node.nodes,
     style: const TextStyle(fontStyle: FontStyle.italic));

diff --git a/test/model/content_test.dart b/test/model/content_test.dart
index a89277daf0..dbf213cebc 100644
--- a/test/model/content_test.dart
+++ b/test/model/content_test.dart
@@ -580,6 +580,11 @@ void main() {
     '
bold
',
     const StrongNode(nodes: [TextNode('bold')]));

+  testParseInline('parse deleted/strike-through',
+    // "~~strike through~~"
+    '
strike through
',
+    const DeletedNode(nodes: [TextNode('strike through')]));
+
   testParseInline('parse emphasis/italic',
     // "*italic*"
     '
italic
',
@@ -590,11 +595,12 @@ void main() {
     '
inline code
',
     const InlineCodeNode(nodes: [TextNode('inline code')]));

-  testParseInline('parse nested strong, em, code',
-    // "***`word`***"
-    '
word
',
-    const StrongNode(nodes: [EmphasisNode(nodes: [InlineCodeNode(nodes: [
-      TextNode('word')])])]));
+  testParseInline('parse nested del, strong, em, code',
+    // "~~***`word`***~~"
+    '
word
',
+    const DeletedNode(
+      nodes: [StrongNode(nodes: [EmphasisNode(nodes: [InlineCodeNode(nodes: [
+        TextNode('word')])])])]));

   group('LinkNode', () {
     testParseInline('parse link',
@@ -618,19 +624,21 @@ void main() {
         nodes: [TextNode('#mobile-team > zulip-flutter')]));
   });

-  testParseInline('parse nested link, strong, em, code',
-    // "[***`word`***](https://example/)"
-    '
word'
-        '
',
+  testParseInline('parse nested link, del, strong, em, code',
+    // "[~~***`word`***~~](https://example/)"
+    '
word'
+        '
',
     const LinkNode(url: 'https://example/',
-      nodes: [StrongNode(nodes: [EmphasisNode(nodes: [InlineCodeNode(nodes: [
-        TextNode('word')])])])]));
+      nodes: [DeletedNode(nodes: [
+        StrongNode(nodes: [EmphasisNode(nodes: [InlineCodeNode(nodes: [
+         TextNode('word')])])])])]));

-  testParseInline('parse nested strong, em, link',
-    // "***[t](/u)***"
-    '
t
',
-    const StrongNode(nodes: [EmphasisNode(nodes: [LinkNode(url: '/u',
-      nodes: [TextNode('t')])])]));
+  testParseInline('parse nested del, strong, em, link',
+    // "~~***[t](/u)***~~"
+    '
t
',
+    const DeletedNode(
+      nodes: [StrongNode(nodes: [EmphasisNode(nodes: [LinkNode(url: '/u',
+        nodes: [TextNode('t')])])])]));

   group('parse @-mentions', () {
     testParseInline('plain user @-mention',
@@ -727,14 +735,15 @@ void main() {
         HeadingNode(level: HeadingLevel.h6, links: null, nodes: [TextNode('six')])]);

     testParse('containing inline markup',
-      // "###### one [***`two`***](https://example/)"
-      '
one two'
-          '
', const [
+      // "###### one [**~~*`two`*~~**](https://example/)"
+      '
one two'
+          '
', const [
         HeadingNode(level: HeadingLevel.h6, links: null, nodes: [
           TextNode('one '),
           LinkNode(url: 'https://example/',
-            nodes: [StrongNode(nodes: [EmphasisNode(nodes: [
-              InlineCodeNode(nodes: [TextNode('two')])])])]),
+            nodes: [StrongNode(
+              nodes: [DeletedNode( nodes: [EmphasisNode(nodes: [
+                InlineCodeNode(nodes: [TextNode('two')])])])])]),
         ])]);

     testParse('amidst paragraphs',


Fix avatar blurriness in profile screen

## Description
Updated the avatar URL used in the profile screen to request a higher-resolution image to reduce blurriness.
Fixes #301

## Outcome
| Before             |  After |
:-------------------------:|:-------------------------:
![image](https://github.com/zulip/zulip-flutter/assets/67259068/b5e84b15-bb8c-421a-bbf4-7a7f97c1057d)  |  ![image](https://github.com/zulip/zulip-flutter/assets/67259068/38ee3f22-0c3f-4b09-827d-d9debf521e82)
![image](https://github.com/zulip/zulip-flutter/assets/67259068/eef42b86-f413-43e3-876e-a7d60c83deb4) |  ![image](https://github.com/zulip/zulip-flutter/assets/67259068/c258e9a0-70aa-4e93-adc1-385a18873561)

Request avatars of the appropriate size

Request avatars of the appropriate size
With #195 fixed with PR #287, we now have a user-profile screen! 🎉

I noticed that on many profiles on CZO, the avatar is more blurry than on the corresponding screen in `zulip-mobile`:

| zulip-flutter | zulip-mobile |
| --- | --- |
| ![image](https://github.com/zulip/zulip-flutter/assets/22248748/66896560-9b27-4f0d-813d-2164e7aaafca) | ![image](https://github.com/zulip/zulip-flutter/assets/22248748/644603ff-c376-45b4-b50e-dc0f0d1839bc) |

This is because we don't yet have special logic for requesting a larger image when we need one. This issue is for adding such logic.

In `zulip-mobile`, we made an `AvatarURL` class with a `get` method that gives you a URL for an image of the specified size in physical pixels (or at least the best possible approximation). `AvatarURL` has [three subclasses](https://github.com/zulip/zulip-mobile/blob/09754f0a5d7408fd490a631339eced3cf77a28e0/src/utils/avatar.js) that each implement `get` very differently.

On CZO in `#api documentation`, I've [summarized](https://chat.zulip.org/#narrow/stream/412-api-documentation/topic/avatar_url.3A.20Request.20specific.20size/near/1636546) zulip-mobile's logic for determining a URL for any given size in physical pixels. I'm hoping the API doc can start specifying that logic, and we can use the new documentation for zulip-flutter's implementation.

To convert a logical-pixel value to a physical-pixel value, we'll want to multiply by the device pixel ratio. As in zulip-mobile:
```js
avatarUrl.get(PixelRatio.getPixelSizeForLayoutSize(size))
```

I think a solution will likely have lots of interactions with #254 and #255, so the best approach might be to consider these three issues together.

Related: #254
Related: #255

An observant user noticed the blurriness and reported it in chat today: https://chat.zulip.org/#narrow/stream/48-mobile/topic/flutter.3A.20blurry.20profile.20picture/near/1735359
@gnprice I'm currently working on this feature. Finished with most of the task using gravatar's. I would like to know which formatter you are using because as I save my code most of the file gets re-formatted causing several changes in the git history. It would be really helpful if you linked the contributing guidelines as well.
@saisumith770 Cool — assigned you the issue, and I'll look at your PR in the next few days.

We don't use an auto-formatter, so you'll want to just tell your editor not to do that. Let's discuss in `#mobile-team` on chat.zulip.org ([thread](https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/turning.20off.20reformat-on-save/near/1745058)).

For contributing guidelines, this repo's README file has information on running tests and making changes. We also follow the same basic style of clean commit history as the Zulip project in general, which is described here:
https://zulip.readthedocs.io/en/latest/contributing/commit-discipline.html

flutter test test/model/avatar_url_test.dart test/widgets/content_test.dart --reporter expanded 2>&1

diff --git a/lib/model/avatar_url.dart b/lib/model/avatar_url.dart
new file mode 100644
index 0000000000..8177a4e8c0
--- /dev/null
+++ b/lib/model/avatar_url.dart
@@ -0,0 +1,50 @@
+/// The size threshold above which is "medium" size for an avatar.
+///
+/// This is DEFAULT_AVATAR_SIZE in zerver/lib/upload.py.
+const defaultUploadSizePx = 100;
+
+abstract class AvatarUrl {
+  factory AvatarUrl.fromUserData({required Uri resolvedUrl}) {
+    // TODO(#255): handle computing gravatars
+    // TODO(#254): handle computing fallback avatar
+    if (resolvedUrl.toString().startsWith(GravatarUrl.origin)) {
+      return GravatarUrl(resolvedUrl: resolvedUrl);
+    } else {
+      return UploadedAvatarUrl(resolvedUrl: resolvedUrl);
+    }
+  }
+
+  Uri get(int sizePhysicalPx);
+}
+
+class GravatarUrl implements AvatarUrl {
+  GravatarUrl({required Uri resolvedUrl}) : standardUrl = resolvedUrl;
+
+  static String origin = 'https://secure.gravatar.com';
+
+  Uri standardUrl;
+
+  @override
+  Uri get(int sizePhysicalPx) {
+    return standardUrl.replace(queryParameters: {
+      ...standardUrl.queryParameters,
+      's': sizePhysicalPx.toString(),
+    });
+  }
+}
+
+class UploadedAvatarUrl implements AvatarUrl {
+  UploadedAvatarUrl({required Uri resolvedUrl}) : standardUrl = resolvedUrl;
+
+  Uri standardUrl;
+
+  @override
+  Uri get(int sizePhysicalPx) {
+    if (sizePhysicalPx > defaultUploadSizePx) {
+      return standardUrl.replace(
+        path: standardUrl.path.replaceFirst(RegExp(r'(?:\.png)?$'), "-medium.png"));
+    }
+
+    return standardUrl;
+  }
+}
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index c5e2682ad2..24794464b4 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -7,6 +7,7 @@ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';

 import '../api/core.dart';
 import '../api/model/model.dart';
+import '../model/avatar_url.dart';
 import '../model/binding.dart';
 import '../model/content.dart';
 import '../model/internal_link.dart';
@@ -1027,7 +1028,7 @@ class Avatar extends StatelessWidget {
     return AvatarShape(
       size: size,
       borderRadius: borderRadius,
-      child: AvatarImage(userId: userId));
+      child: AvatarImage(userId: userId, size: size));
   }
 }

@@ -1040,9 +1041,11 @@ class AvatarImage extends StatelessWidget {
   const AvatarImage({
     super.key,
     required this.userId,
+    required this.size,
   });

   final int userId;
+  final double size;

   @override
   Widget build(BuildContext context) {
@@ -1057,9 +1060,19 @@ class AvatarImage extends StatelessWidget {
       null          => null, // TODO(#255): handle computing gravatars
       var avatarUrl => store.tryResolveUrl(avatarUrl),
     };
-    return (resolvedUrl == null)
-      ? const SizedBox.shrink()
-      : RealmContentNetworkImage(resolvedUrl, filterQuality: FilterQuality.medium, fit: BoxFit.cover);
+
+    if (resolvedUrl == null) {
+      return const SizedBox.shrink();
+    }
+
+    final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: resolvedUrl);
+    final physicalSize = (MediaQuery.devicePixelRatioOf(context) * size).ceil();
+
+    return RealmContentNetworkImage(
+      avatarUrl.get(physicalSize),
+      filterQuality: FilterQuality.medium,
+      fit: BoxFit.cover,
+    );
   }
 }

diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart
index aafdc8c42f..27bf2a7560 100644
--- a/lib/widgets/recent_dm_conversations.dart
+++ b/lib/widgets/recent_dm_conversations.dart
@@ -84,6 +84,8 @@ class RecentDmConversationsItem extends StatelessWidget {
   final DmNarrow narrow;
   final int unreadCount;

+  static const double _avatarSize = 32;
+
   @override
   Widget build(BuildContext context) {
     final store = PerAccountStoreWidget.of(context);
@@ -94,14 +96,14 @@ class RecentDmConversationsItem extends StatelessWidget {
     switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage]
       case []:
         title = selfUser.fullName;
-        avatar = AvatarImage(userId: selfUser.userId);
+        avatar = AvatarImage(userId: selfUser.userId, size: _avatarSize);
       case [var otherUserId]:
         // TODO(#296) actually don't show this row if the user is muted?
         //   (should we offer a "spam folder" style summary screen of recent
         //   1:1 DM conversations from muted users?)
         final otherUser = store.users[otherUserId];
         title = otherUser?.fullName ?? '(unknown user)';
-        avatar = AvatarImage(userId: otherUserId);
+        avatar = AvatarImage(userId: otherUserId, size: _avatarSize);
       default:
         // TODO(i18n): List formatting, like you can do in JavaScript:
         //   new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya'])
@@ -122,7 +124,7 @@ class RecentDmConversationsItem extends StatelessWidget {
         child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48),
           child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
             Padding(padding: const EdgeInsetsDirectional.fromSTEB(12, 8, 0, 8),
-              child: AvatarShape(size: 32, borderRadius: 3, child: avatar)),
+              child: AvatarShape(size: _avatarSize, borderRadius: 3, child: avatar)),
             const SizedBox(width: 8),
             Expanded(child: Padding(
               padding: const EdgeInsets.symmetric(vertical: 4),

diff --git a/test/model/avatar_url_test.dart b/test/model/avatar_url_test.dart
new file mode 100644
index 0000000000..f12d3a60f9
--- /dev/null
+++ b/test/model/avatar_url_test.dart
@@ -0,0 +1,34 @@
+import 'package:checks/checks.dart';
+import 'package:test/scaffolding.dart';
+import 'package:zulip/model/avatar_url.dart';
+
+void main() {
+  const defaultSize = 30;
+  const largeSize = 120;
+
+  group('GravatarUrl', () {
+    test('gravatar url', () {
+      final url = '${GravatarUrl.origin}/avatar/1234';
+      final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: Uri.parse(url));
+
+      check(avatarUrl.get(defaultSize).toString()).equals('$url?s=30');
+    });
+  });
+
+  group('UploadedAvatarUrl', () {
+    test('png image', () {
+      const url = 'https://zulip.example/image.png';
+      final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: Uri.parse(url));
+
+      check(avatarUrl.get(defaultSize).toString()).equals(url);
+    });
+
+    test('png image, larger size', () {
+      const url = 'https://zulip.example/image.png';
+      final avatarUrl = AvatarUrl.fromUserData(resolvedUrl: Uri.parse(url));
+      final expectedUrl = url.replaceAll('.png', '-medium.png');
+
+      check(avatarUrl.get(largeSize).toString()).equals(expectedUrl);
+    });
+  });
+}
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index c485f585a7..5976171eec 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -538,7 +538,7 @@ void main() {
   group('AvatarImage', () {
     late PerAccountStore store;

-    Future<Uri?> actualUrl(WidgetTester tester, String avatarUrl) async {
+    Future<Uri?> actualUrl(WidgetTester tester, String avatarUrl, [double? size]) async {
       addTearDown(testBinding.reset);
       await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
       store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
@@ -548,7 +548,7 @@ void main() {
       prepareBoringImageHttpClient();
       await tester.pumpWidget(GlobalStoreWidget(
         child: PerAccountStoreWidget(accountId: eg.selfAccount.id,
-          child: AvatarImage(userId: user.userId))));
+          child: AvatarImage(userId: user.userId, size: size ?? 30))));
       await tester.pump();
       await tester.pump();
       tester.widget(find.byType(AvatarImage));
@@ -571,6 +571,26 @@ void main() {
       debugNetworkImageHttpClientProvider = null;
     });

+   testWidgets('absolute URL, larger size', (tester) async {
+      tester.view.devicePixelRatio = 2.5;
+      addTearDown(tester.view.resetDevicePixelRatio);
+
+      const avatarUrl = 'https://example/avatar.png';
+      check(await actualUrl(tester, avatarUrl, 50)).isNotNull()
+        .asString.equals(avatarUrl.replaceAll('.png', '-medium.png'));
+      debugNetworkImageHttpClientProvider = null;
+    });
+
+    testWidgets('relative URL, larger size', (tester) async {
+      tester.view.devicePixelRatio = 2.5;
+      addTearDown(tester.view.resetDevicePixelRatio);
+
+      const avatarUrl = '/avatar.png';
+      check(await actualUrl(tester, avatarUrl, 50))
+        .equals(store.tryResolveUrl('/avatar-medium.png')!);
+      debugNetworkImageHttpClientProvider = null;
+    });
+
     testWidgets('smoke with invalid URL', (tester) async {
       const avatarUrl = '::not a URL::';
       check(await actualUrl(tester, avatarUrl)).isNull();