Repository: zulip/zulip-flutter (language: dart)

Issue:
content: Bold text in strikethrough should have strikethrough line, but doesn't

content: Inline code links should be rendered with the link color
We should fix inline code's color when it is a part of a link.

| web (expected) | flutter |
| - | - |
| ![Screenshot from 2024-07-11 21-33-29](https://github.com/user-attachments/assets/256fd1d0-c627-428d-9706-59dd562da0bd) |  ![1000013523](https://github.com/user-attachments/assets/fecc3c09-ddb5-4dd5-bfdc-1333990a99d7) |

Since a recent change (https://github.com/zulip/zulip-flutter/commit/299c76be7), inline code links no longer have the ambient color and is always black. We want to fix this to match the behavior of the web app.

For reference, the web app [defines](https://github.com/zulip/zulip/blob/4d34a902c9c2c7df87ded0ce5a05df7ccf052c97/web/styles/app_variables.css#L494-L495) a separate CSS variable `--color-markdown-code-link`

```css
    --color-markdown-link: hsl(200deg 100% 40%);
    --color-markdown-code-link: var(--color-markdown-link);
```

that [applies to](https://github.com/zulip/zulip/blob/4d34a902c9c2c7df87ded0ce5a05df7ccf052c97/web/styles/rendered_markdown.css#L679C1-L685) inline code that appears inside a link:


```
    & a {
        color: var(--color-markdown-link);
        text-decoration: none;

        & code {
            color: var(--color-markdown-code-link);
        }
```
content: Inline code in a bold span should be bold (but isn't)
### References
* Related symptom: #1813 
* Related symptom: #806 
content: Unicode emoji in strikethrough should have strikethrough line, but doesn't



Discussion hints:

Pull request: content: Fix bugs where `TextSpan` styles clobber each other with `InlineContent.style` attributes
Fixes #806.
Fixes #1812.
Fixes #1817.
Fixes #1818.

There are other issues with inline-span styles:
- #1813
- #1819
- (possibly more)

but I think those all involve `WidgetSpan`s in `InlineContent`'s span tree and can be addressed separately.

| Before | After |
| --- | --- |
| <img width="559" height="1062" alt="image" src="https://github.com/user-attachments/assets/9ee6e311-a0d2-4bb2-bda3-c279f30f5c6a" /> | <img width="559" height="1062" alt="image" src="https://github.com/user-attachments/assets/cc57ebba-cf9d-4b20-b4fe-9419d7c731eb" /> |
| <img width="559" height="1062" alt="image" src="https://github.com/user-attachments/assets/ccd69f1e-042f-49b6-a340-02bb9467c71f" /> | <img width="559" height="1062" alt="image" src="https://github.com/user-attachments/assets/850a587e-3671-478a-a82f-782418bd1f09" /> |

Test command: flutter test test/model/content_test.dart test/widgets/content_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 5851222a1c..3626d55ae4 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -1127,8 +1127,7 @@ class _InlineContentBuilder {
 
       case UnicodeEmojiNode():
         return TextSpan(text: node.emojiUnicode, recognizer: _recognizer,
-          style: widget.style
-            .merge(ContentTheme.of(_context!).textStyleEmoji));
+          style: ContentTheme.of(_context!).textStyleEmoji);
 
       case ImageEmojiNode():
         return WidgetSpan(alignment: PlaceholderAlignment.middle,
@@ -1138,9 +1137,8 @@ class _InlineContentBuilder {
         final nodes = node.nodes;
         return nodes == null
           ? TextSpan(
-              style: widget.style
-                .merge(ContentTheme.of(_context!).textStyleInlineMath)
-                .apply(fontSizeFactor: kInlineCodeFontSizeFactor),
+              style: ContentTheme.of(_context!).textStyleInlineMath
+                .copyWith(fontSize: widget.style.fontSize! * kInlineCodeFontSizeFactor),
               children: [TextSpan(text: node.texSource)])
           : WidgetSpan(
               alignment: PlaceholderAlignment.baseline,
@@ -1181,11 +1179,9 @@ class _InlineContentBuilder {
     // TODO `code`: find equivalent of web's `unicode-bidi: embed; direction: ltr`
 
     return _buildNodes(
-      style: widget.style
-        .merge(ContentTheme.of(_context!).textStyleInlineCode)
-        .apply(fontSizeFactor: kInlineCodeFontSizeFactor),
-      node.nodes,
-    );
+      style: ContentTheme.of(_context!).textStyleInlineCode
+        .copyWith(fontSize: widget.style.fontSize! * kInlineCodeFontSizeFactor),
+      node.nodes);
 
     // Another fun solution -- we can in fact have a border!  Like so:
     //   TextStyle(
diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart
index 13bd141772..f2f0edd968 100644
--- a/lib/widgets/text.dart
+++ b/lib/widgets/text.dart
@@ -288,8 +288,11 @@ double bolderWght(double baseWght, {double by = 300}) {
   return clampDouble(baseWght + by, kWghtMin, kWghtMax);
 }
 
-/// A [TextStyle] whose [FontVariation] "wght" and [TextStyle.fontWeight]
-/// have been raised using [bolderWght].
+/// A [TextStyle] with [FontVariation] "wght" and [TextStyle.fontWeight]
+/// that have been raised from the input using [bolderWght].
+///
+/// Non-weight attributes in [style] are ignored
+/// and will not appear in the result.
 ///
 /// [style] must have already been processed with [weightVariableTextStyle],
 /// and [by] must be positive.
@@ -311,12 +314,9 @@ TextStyle bolderWghtTextStyle(TextStyle style, {double by = 300}) {
 
   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),
-  );
+  TextStyle result = TextStyle(
+    fontVariations: [FontVariation('wght', newWght)],
+    fontWeight: clampVariableFontWeight(newWght));
 
   assert(() {
     result = result.copyWith(debugLabel: 'bolderWghtTextStyle(by: $by)');


Test patch:
diff --git a/test/model/content_test.dart b/test/model/content_test.dart
index 9cabfbb3b8..84e3baed42 100644
--- a/test/model/content_test.dart
+++ b/test/model/content_test.dart
@@ -85,6 +85,13 @@ class ContentExample {
     '<p><strong>bold</strong></p>',
     const StrongNode(nodes: [TextNode('bold')]));
 
+  static final deleted = ContentExample.inline(
+    'deleted/strike-through',
+    '~~strike through~~',
+    expectedText: 'strike through',
+    '<p><del>strike through</del></p>',
+    const DeletedNode(nodes: [TextNode('strike through')]));
+
   static final emphasis = ContentExample.inline(
     'emphasis/italic',
     '*italic*',
@@ -1535,10 +1542,7 @@ void main() async {
 
   testParseExample(ContentExample.strong);
 
-  testParseInline('parse deleted/strike-through',
-    // "~~strike through~~"
-    '<p><del>strike through</del></p>',
-    const DeletedNode(nodes: [TextNode('strike through')]));
+  testParseExample(ContentExample.deleted);
 
   testParseExample(ContentExample.emphasis);
 
diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart
index aef5ef3cc0..28af496c59 100644
--- a/test/widgets/content_test.dart
+++ b/test/widgets/content_test.dart
@@ -186,11 +186,11 @@ void main() {
   /// [styleFinder] must return the [TextStyle] containing the "wght"
   /// (in [TextStyle.fontVariations]) and the [TextStyle.fontWeight]
   /// to be checked.
-  Future<void> testFontWeight(String description, {
+  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)' : ''),
@@ -707,8 +707,18 @@ void main() {
           '<tbody>\n<tr>\n<td>text</td>\n</tr>\n</tbody>\n'
           '</table>'),
       styleFinder: findWordBold);
+
+    testWidgets('has strike-through line in strike-through', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1817
+      await prepareContent(tester,
+        plainContent('<p><del><strong>bold</strong></del></p>'));
+      final style = mergedStyleOf(tester, 'bold');
+      check(style!.decoration).equals(TextDecoration.lineThrough);
+    });
   });
 
+  testContentSmoke(ContentExample.deleted);
+
   testContentSmoke(ContentExample.emphasis);
 
   group('inline code', () {
@@ -719,6 +729,22 @@ void main() {
         targetHtml: '<code>code</code>',
         targetFontSizeFinder: mkTargetFontSizeFinderFromPattern('code'));
     });
+
+    testFontWeight('is bold in bold span',
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/1812
+      expectedWght: 600,
+      // **`bold`**
+      content: plainContent('<p><strong><code>bold</code></strong></p>'),
+      styleFinder: (tester) => mergedStyleOf(tester, 'bold')!,
+    );
+
+    testWidgets('is link-colored in link span', (tester) async {
+      // Regression test for: https://github.com/zulip/zulip-flutter/issues/806
+      await prepareContent(tester,
+        plainContent('<p><a href="https://example/"><code>code</code></a></p>'));
+      final style = mergedStyleOf(tester, 'code');
+      check(style!.color).equals(const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor());
+    });
   });
 
   group('UserMention', () {
@@ -980,6 +1006,14 @@ void main() {
           _ => throw StateError('unexpected platform in test'),
         });
     }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS}));
+
+    testWidgets('has strike-through line in strike-through', (tester) async {
+      // Regression test for https://github.com/zulip/zulip-flutter/issues/1818
+      await prepareContent(tester,
+        plainContent('<p><del>foo<span aria-label="thumbs up" class="emoji emoji-1f44d" role="img" title="thumbs up">:thumbs_up:</span>bar</del></p>'));
+      final style = mergedStyleOf(tester, '\u{1f44d}');
+      check(style!.decoration).equals(TextDecoration.lineThrough);
+    });
   });
 
   group('inline math', () {
@@ -1010,27 +1044,35 @@ void main() {
         });
     });
 
-    testWidgets('maintains font-size ratio with surrounding text, when falling back to TeX source', (tester) async {
-      const unsupportedHtml = '<span class="katex">'
-        '<span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>λ</mi></mrow>'
-          '<annotation encoding="application/x-tex"> \\lambda </annotation></semantics></math></span>'
-        '<span class="katex-html" aria-hidden="true">'
-          '<span class="base unknown">' // Server doesn't generate this 'unknown' class.
-          '<span class="strut" style="height:0.6944em;"></span>'
-          '<span class="mord mathnormal">λ</span></span></span></span>';
-      await checkFontSizeRatio(tester,
-        targetHtml: unsupportedHtml,
-        targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(r'\lambda'));
-    });
+    group('fallback to displaying KaTeX source if unsupported KaTeX HTML', () {
+      testContentSmoke(ContentExample.mathInlineUnknown);
 
-    testWidgets('displays KaTeX content', (tester) async {
-      await prepareContent(tester, plainContent(ContentExample.mathInline.html));
-      tester.widget(find.text('λ', findRichText: true));
-    });
+      assert(ContentExample.mathInlineUnknown.html.startsWith('<p>'));
+      assert(ContentExample.mathInlineUnknown.html.endsWith('</p>'));
+      final unsupportedKatexHtml = ContentExample.mathInlineUnknown.html
+        .substring(3, ContentExample.mathInlineUnknown.html.length - 4);
+      final expectedText = ContentExample.mathInlineUnknown.expectedText!;
 
-    testWidgets('fallback to displaying KaTeX source if unsupported KaTeX HTML', (tester) async {
-      await prepareContent(tester, plainContent(ContentExample.mathInlineUnknown.html));
-      tester.widget(find.text(r'\lambda'));
+      testWidgets('maintains font-size ratio with surrounding text, when falling back to TeX source', (tester) async {
+        await checkFontSizeRatio(tester,
+          targetHtml: unsupportedKatexHtml,
+          targetFontSizeFinder: mkTargetFontSizeFinderFromPattern(expectedText));
+      });
+
+      testFontWeight('is bold in bold span',
+        // Regression test for: https://github.com/zulip/zulip-flutter/issues/1812
+        expectedWght: 600,
+        content: plainContent('<p><strong>$unsupportedKatexHtml</strong></p>'),
+        styleFinder: (tester) => mergedStyleOf(tester, expectedText)!,
+      );
+
+      testWidgets('is link-colored in link span', (tester) async {
+        // Regression test for: https://github.com/zulip/zulip-flutter/issues/806
+        await prepareContent(tester,
+          plainContent('<p><a href="https://example/">$unsupportedKatexHtml</a></p>'));
+        final style = mergedStyleOf(tester, expectedText);
+        check(style!.color).equals(const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor());
+      });
     });
   });