diff --git a/lib/model/content.dart b/lib/model/content.dart index 8cb3f9a4dc..d0fe98dfbc 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -581,6 +581,57 @@ class TableCellNode extends BlockInlineContainerNode { } } +// Ref: +// https://ogp.me/ +// https://oembed.com/ +class LinkPreviewNode extends BlockContentNode { + const LinkPreviewNode({ + super.debugHtmlNode, + required this.hrefUrl, + required this.imageSrcUrl, + required this.title, + required this.description, + }); + + /// The URL from which this preview data was retrieved. + final String hrefUrl; + + /// The image URL representing the webpage, content value + /// of `og:image` HTML meta property. + final String imageSrcUrl; + + /// Represents the webpage title, derived from either + /// the content of the `og:title` HTML meta property or + /// the HTML element. + final String? title; + + /// Description about the webpage, content value of + /// `og:description` HTML meta property. + final String? description; + + @override + bool operator ==(Object other) { + return other is LinkPreviewNode + && other.hrefUrl == hrefUrl + && other.imageSrcUrl == imageSrcUrl + && other.title == title + && other.description == description; + } + + @override + int get hashCode => + Object.hash('LinkPreviewNode', hrefUrl, imageSrcUrl, title, description); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('hrefUrl', hrefUrl)); + properties.add(StringProperty('imageSrcUrl', imageSrcUrl)); + properties.add(StringProperty('title', title)); + properties.add(StringProperty('description', description)); + } +} + /// A content node that expects an inline layout context from its parent. /// /// When rendered into a Flutter widget tree, an inline content node @@ -1448,6 +1499,81 @@ class _ZulipContentParser { return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement); } + static final _linkPreviewImageSrcRegexp = RegExp(r'background-image: url\("(.+)"\)'); + + BlockContentNode parseLinkPreviewNode(dom.Element divElement) { + assert(_debugParserContext == _ParserContext.block); + assert(divElement.localName == 'div' + && divElement.className == 'message_embed'); + + final result = () { + if (divElement.nodes.length != 2) return null; + + final first = divElement.nodes.first; + if (first is! dom.Element) return null; + if (first.localName != 'a') return null; + if (first.className != 'message_embed_image') return null; + if (first.nodes.isNotEmpty) return null; + + final imageHref = first.attributes['href']; + if (imageHref == null) return null; + + final styleAttr = first.attributes['style']; + if (styleAttr == null) return null; + final match = _linkPreviewImageSrcRegexp.firstMatch(styleAttr); + if (match == null) return null; + final imageSrcUrl = match.group(1); + if (imageSrcUrl == null) return null; + + final second = divElement.nodes.last; + if (second is! dom.Element) return null; + if (second.localName != 'div') return null; + if (second.className != 'data-container') return null; + if (second.nodes.isEmpty) return null; + if (second.nodes.length > 2) return null; + + String? title, description; + for (final node in second.nodes) { + if (node is! dom.Element) return null; + if (node.localName != 'div') return null; + + switch (node.className) { + case 'message_embed_title': + if (node.nodes.length != 1) return null; + final child = node.nodes.single; + if (child is! dom.Element) return null; + if (child.localName != 'a') return null; + if (child.className.isNotEmpty) return null; + if (child.nodes.length != 1) return null; + + final titleHref = child.attributes['href']; + // Make sure both image hyperlink and title hyperlink are same. + if (imageHref != titleHref) return null; + final grandchild = child.nodes.single; + if (grandchild is! dom.Text) return null; + title = grandchild.text; + + case 'message_embed_description': + if (node.nodes.length != 1) return null; + final child = node.nodes.single; + if (child is! dom.Text) return null; + description = child.text; + + default: + return null; + } + } + + return LinkPreviewNode( + hrefUrl: imageHref, + imageSrcUrl: imageSrcUrl, + title: title, + description: description); + }(); + + return result ?? UnimplementedBlockContentNode(htmlNode: divElement); + } + BlockContentNode parseBlockContent(dom.Node node) { assert(_debugParserContext == _ParserContext.block); final debugHtmlNode = kDebugMode ? node : null; @@ -1545,6 +1671,10 @@ class _ZulipContentParser { } } + if (localName == 'div' && className == 'message_embed') { + return parseLinkPreviewNode(element); + } + // TODO more types of node return UnimplementedBlockContentNode(htmlNode: node); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index f3b369e876..e965c90c1a 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -18,6 +18,7 @@ import '../model/internal_link.dart'; import 'code_block.dart'; import 'dialog.dart'; import 'icons.dart'; +import 'inset_shadow.dart'; import 'lightbox.dart'; import 'message_list.dart'; import 'poll.dart'; @@ -364,10 +365,10 @@ class BlockContentList extends StatelessWidget { ); return const SizedBox.shrink(); }(), + LinkPreviewNode() => MessageLinkPreview(node: node), UnimplementedBlockContentNode() => Text.rich(_errorUnimplemented(node, context: context)), }; - }), ]); } @@ -839,6 +840,94 @@ class MathBlock extends StatelessWidget { } } +class MessageLinkPreview extends StatelessWidget { + const MessageLinkPreview({super.key, required this.node}); + + final LinkPreviewNode node; + + @override + Widget build(BuildContext context) { + final messageListTheme = MessageListTheme.of(context); + final isSmallWidth = MediaQuery.sizeOf(context).width <= 576; + + final titleAndDescription = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (node.title != null) + GestureDetector( + onTap: () => _launchUrl(context, node.hrefUrl), + child: Text(node.title!, + style: TextStyle( + fontSize: 1.2 * kBaseFontSize, + height: 1.0, + // Web has the same color in light and dark mode. + color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()))), + if (node.description != null) + Container( + padding: const EdgeInsets.only(top: 3), + constraints: const BoxConstraints(maxWidth: 500), + child: Text(node.description!, + style: const TextStyle(height: 1.4))), + ]); + + final clippedTitleAndDescription = Container( + constraints: const BoxConstraints(maxHeight: 80), + padding: const EdgeInsets.symmetric(horizontal: 5), + child: InsetShadowBox( + bottom: 8, + // TODO(#647) use different color for highlighted messages + // TODO(#681) use different color for DM messages + color: messageListTheme.streamMessageBgDefault, + child: UnconstrainedBox( + alignment: Alignment.topLeft, + constrainedAxis: Axis.horizontal, + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: isSmallWidth + ? titleAndDescription + : LayoutBuilder( + builder: (context, constraints) => ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth - 115), + child: titleAndDescription)))))); + + final result = isSmallWidth + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5, + children: [ + GestureDetector( + onTap: () => _launchUrl(context, node.hrefUrl), + child: RealmContentNetworkImage( + Uri.parse(node.imageSrcUrl), + fit: BoxFit.cover, + width: double.infinity, + height: 100)), + clippedTitleAndDescription, + ]) + : Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + GestureDetector( + onTap: () => _launchUrl(context, node.hrefUrl), + child: RealmContentNetworkImage(Uri.parse(node.imageSrcUrl), + fit: BoxFit.cover, + width: 80, + height: 80, + alignment: Alignment.center)), + Flexible(child: clippedTitleAndDescription), + ]); + + return Container( + decoration: const BoxDecoration( + border: Border(left: BorderSide( + // Web has the same color in light and dark mode. + color: Color(0xffededed), width: 3))), + padding: const EdgeInsets.all(5), + child: result); + } +} + // // Inline layout. // diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 8a895b6ab1..a8fdceaf7e 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1134,6 +1134,67 @@ class ContentExample { ], isHeader: false), ]), ]); + + static const linkPreviewSmoke = ContentExample( + 'link preview smoke', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html', + '<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html</a></p>\n' + '<div class="message_embed">' + '<a class="message_embed_image" href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html" style="background-image: url("https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67")"></a>' + '<div class="data-container">' + '<div class="message_embed_title"><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html" title="Zulip — organized team chat">Zulip — organized team chat</a></div>' + '<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html')], + url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html'), + ]), + LinkPreviewNode( + hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html', + imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67', + title: 'Zulip — organized team chat', + description: 'Zulip is an organized team chat app for distributed teams of all sizes.'), + ]); + + static const linkPreviewWithoutTitle = ContentExample( + 'link preview without title', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html', + '<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html</a></p>\n' + '<div class="message_embed">' + '<a class="message_embed_image" href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html" style="background-image: url("https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67")"></a>' + '<div class="data-container">' + '<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html')], + url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html'), + ]), + LinkPreviewNode( + hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html', + imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67', + title: null, + description: 'Zulip is an organized team chat app for distributed teams of all sizes.'), + ]); + + static const linkPreviewWithoutDescription = ContentExample( + 'link preview without description', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html', + '<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html</a></p>\n' + '<div class="message_embed">' + '<a class="message_embed_image" href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html" style="background-image: url("https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67")"></a>' + '<div class="data-container">' + '<div class="message_embed_title"><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html" title="Zulip — organized team chat">Zulip — organized team chat</a></div></div></div>', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html')], + url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html'), + ]), + LinkPreviewNode( + hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html', + imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67', + title: 'Zulip — organized team chat', + description: null), + ]); } UnimplementedBlockContentNode blockUnimplemented(String html) { @@ -1479,6 +1540,10 @@ void main() { testParseExample(ContentExample.tableWithDifferentTextAlignmentInColumns); testParseExample(ContentExample.tableWithLinkCenterAligned); + testParseExample(ContentExample.linkPreviewSmoke); + testParseExample(ContentExample.linkPreviewWithoutTitle); + testParseExample(ContentExample.linkPreviewWithoutDescription); + testParse('parse nested lists, quotes, headings, code blocks', // "1. > ###### two\n > * three\n\n four" '<ol>\n<li>\n<blockquote>\n<h6>two</h6>\n<ul>\n<li>three</li>\n' diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index bee5325714..fbbd55c401 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1002,6 +1002,58 @@ void main() { }); }); + group('MessageLinkPreview', () { + Future<void> prepare(WidgetTester tester, String html) async { + await prepareContent(tester, plainContent(html), + wrapWithPerAccountStoreWidget: true); + } + + testWidgets('smoke', (tester) async { + final url = Uri.parse(ContentExample.linkPreviewSmoke.markdown!); + await prepare(tester, ContentExample.linkPreviewSmoke.html); + tester.widget(find.byType(MessageLinkPreview)); + + await tester.tap(find.text(ContentExample.linkPreviewSmoke.markdown!)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.platformDefault)); + + await tester.tap(find.byType(RealmContentNetworkImage)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.platformDefault)); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke: without title', (tester) async { + final url = Uri.parse(ContentExample.linkPreviewWithoutTitle.markdown!); + await prepare(tester, ContentExample.linkPreviewWithoutTitle.html); + tester.widget(find.byType(MessageLinkPreview)); + + await tester.tap(find.text(ContentExample.linkPreviewWithoutTitle.markdown!)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.platformDefault)); + + await tester.tap(find.byType(RealmContentNetworkImage)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.platformDefault)); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('smoke: without description', (tester) async { + final url = Uri.parse(ContentExample.linkPreviewWithoutDescription.markdown!); + await prepare(tester, ContentExample.linkPreviewWithoutDescription.html); + tester.widget(find.byType(MessageLinkPreview)); + + await tester.tap(find.text(ContentExample.linkPreviewWithoutDescription.markdown!)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.platformDefault)); + + await tester.tap(find.byType(RealmContentNetworkImage)); + check(testBinding.takeLaunchUrlCalls()) + .single.equals((url: url, mode: LaunchMode.platformDefault)); + debugNetworkImageHttpClientProvider = null; + }); + }); + group('RealmContentNetworkImage', () { final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);