diff --git a/lib/model/content.dart b/lib/model/content.dart index 9156d0f51d..08ff476574 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -178,6 +178,19 @@ class LineBreakNode extends BlockContentNode { int get hashCode => 'LineBreakNode'.hashCode; } +/// A `hr` element +class ThematicBreakNode extends BlockContentNode { + const ThematicBreakNode({super.debugHtmlNode}); + + @override + bool operator ==(Object other) { + return other is ThematicBreakNode; + } + + @override + int get hashCode => 'ThematicBreakNode'.hashCode; +} + /// A `p` element, or a place where the DOM tree logically wanted one. /// /// We synthesize these in the absence of an actual `p` element in cases where @@ -969,6 +982,10 @@ class _ZulipContentParser { return LineBreakNode(debugHtmlNode: debugHtmlNode); } + if (localName == 'hr' && className.isEmpty) { + return ThematicBreakNode(debugHtmlNode: debugHtmlNode); + } + if (localName == 'p' && className.isEmpty) { // Oddly, the way a math block gets encoded in Zulip HTML is inside a
. if (element.nodes case [dom.Element(localName: 'span') && var child, ...]) { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 36ca7a6f9c..d4a7528378 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -74,6 +74,8 @@ class BlockContentList extends StatelessWidget { // This goes in a Column. So to get the effect of a newline, // just use an empty Text. return const Text(''); + } else if (node is ThematicBreakNode) { + return const ThematicBreak(); } else if (node is ParagraphNode) { return Paragraph(node: node); } else if (node is HeadingNode) { @@ -107,6 +109,22 @@ class BlockContentList extends StatelessWidget { } } +class ThematicBreak extends StatelessWidget { + const ThematicBreak({super.key}); + + static const htmlHeight = 2.0; + static const htmlMarginY = 20.0; + + @override + Widget build(BuildContext context) { + return Divider( + color: const HSLColor.fromAHSL(1, 0, 0, .87).toColor(), + thickness: htmlHeight, + height: 2 * htmlMarginY + htmlHeight, + ); + } +} + class Paragraph extends StatelessWidget { const Paragraph({super.key, required this.node}); diff --git a/test/model/content_test.dart b/test/model/content_test.dart index dbf213cebc..eaddd70704 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -521,6 +521,17 @@ class ContentExample { blockUnimplemented('more text'), ]]), ]); + + static const thematicBreak = ContentExample( + 'parse thematic break (
a
\nb
', + [ + ParagraphNode(links: null, nodes: [TextNode('a')]), + ThematicBreakNode(), + ParagraphNode(links: null, nodes: [TextNode('b')]), + ]); } UnimplementedBlockContentNode blockUnimplemented(String html) { @@ -721,6 +732,8 @@ void main() { LineBreakNode(), ]); + testParseExample(ContentExample.thematicBreak); + testParse('parse two plain-text paragraphs', // "hello\n\nworld" 'hello
\nworld
', const [ diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 5298ee09c3..3809e2b33d 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -62,6 +62,13 @@ void main() { }); } + group('ThematicBreak', () { + testWidgets('smoke ThematicBreak', (tester) async { + await prepareContentBare(tester, ContentExample.thematicBreak.html); + tester.widget(find.byType(ThematicBreak)); + }); + }); + group('Heading', () { testWidgets('plain h6', (tester) async { await prepareContentBare(tester,