diff --git a/lib/model/content.dart b/lib/model/content.dart index 8a5204973e..022e333097 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -504,6 +504,58 @@ class EmbedVideoNode extends BlockContentNode { } } +// See: +// https://ogp.me/ +// https://oembed.com/ +// https://zulip.com/help/image-video-and-website-previews#configure-whether-website-previews-are-shown +class WebsitePreviewNode extends BlockContentNode { + const WebsitePreviewNode({ + 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 WebsitePreviewNode + && other.hrefUrl == hrefUrl + && other.imageSrcUrl == imageSrcUrl + && other.title == title + && other.description == description; + } + + @override + int get hashCode => + Object.hash('WebsitePreviewNode', 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)); + } +} + class TableNode extends BlockContentNode { const TableNode({super.debugHtmlNode, required this.rows}); @@ -1339,6 +1391,113 @@ class _ZulipContentParser { return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode); } + static final _websitePreviewImageSrcRegexp = RegExp(r'background-image: url\(("?)(.+?)\1\)'); + + BlockContentNode parseWebsitePreviewNode(dom.Element divElement) { + assert(divElement.localName == 'div' + && divElement.className == 'message_embed'); + + final debugHtmlNode = kDebugMode ? divElement : null; + final result = () { + if (divElement.nodes case [ + dom.Element( + localName: 'a', + className: 'message_embed_image', + attributes: { + 'href': final String imageHref, + 'style': final String imageStyleAttr, + }, + nodes: []), + dom.Element( + localName: 'div', + className: 'data-container', + nodes: [...]) && final dataContainer, + ]) { + final match = _websitePreviewImageSrcRegexp.firstMatch(imageStyleAttr); + if (match == null) return null; + final imageSrcUrl = match.group(2); + if (imageSrcUrl == null) return null; + + String? parseTitle(dom.Element element) { + assert(element.localName == 'div' && + element.className == 'message_embed_title'); + if (element.nodes case [ + dom.Element(localName: 'a', className: '') && final child, + ]) { + final titleHref = child.attributes['href']; + // Make sure both image hyperlink and title hyperlink are same. + if (imageHref != titleHref) return null; + + if (child.nodes case [dom.Text(text: final title)]) { + return title; + } + } + return null; + } + + String? parseDescription(dom.Element element) { + assert(element.localName == 'div' && + element.className == 'message_embed_description'); + if (element.nodes case [dom.Text(text: final description)]) { + return description; + } + return null; + } + + String? title, description; + switch (dataContainer.nodes) { + case [ + dom.Element( + localName: 'div', + className: 'message_embed_title') && final first, + dom.Element( + localName: 'div', + className: 'message_embed_description') && final second, + ]: + title = parseTitle(first); + if (title == null) return null; + description = parseDescription(second); + if (description == null) return null; + + case [dom.Element(localName: 'div') && final single]: + switch (single.className) { + case 'message_embed_title': + title = parseTitle(single); + if (title == null) return null; + + case 'message_embed_description': + description = parseDescription(single); + if (description == null) return null; + + default: + return null; + } + + case []: + // Server generates an empty `<div class="data-container"></div>` + // if website HTML has neither title (derived from + // `og:title` or `<title>…`) nor description (derived from + // `og:description`). + break; + + default: + return null; + } + + return WebsitePreviewNode( + hrefUrl: imageHref, + imageSrcUrl: imageSrcUrl, + title: title, + description: description, + debugHtmlNode: debugHtmlNode); + } else { + return null; + } + }(); + + return result ?? UnimplementedBlockContentNode(htmlNode: divElement); + } + BlockContentNode parseTableContent(dom.Element tableElement) { assert(tableElement.localName == 'table' && tableElement.className.isEmpty); @@ -1583,6 +1742,10 @@ class _ZulipContentParser { } } + if (localName == 'div' && className == 'message_embed') { + return parseWebsitePreviewNode(element); + } + // TODO more types of node return UnimplementedBlockContentNode(htmlNode: node); } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 728ee880f6..5306d74a35 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:html/dom.dart' as dom; import 'package:intl/intl.dart'; @@ -18,6 +19,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'; @@ -42,6 +44,7 @@ class ContentTheme extends ThemeExtension { colorDirectMentionBackground: const HSLColor.fromAHSL(0.2, 240, 0.7, 0.7).toColor(), colorGlobalTimeBackground: const HSLColor.fromAHSL(1, 0, 0, 0.93).toColor(), colorGlobalTimeBorder: const HSLColor.fromAHSL(1, 0, 0, 0.8).toColor(), + colorLink: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor(), colorMathBlockBorder: const HSLColor.fromAHSL(0.15, 240, 0.8, 0.5).toColor(), colorMessageMediaContainerBackground: const Color.fromRGBO(0, 0, 0, 0.03), colorPollNames: const HSLColor.fromAHSL(1, 0, 0, .45).toColor(), @@ -75,6 +78,7 @@ class ContentTheme extends ThemeExtension { colorDirectMentionBackground: const HSLColor.fromAHSL(0.25, 240, 0.52, 0.6).toColor(), colorGlobalTimeBackground: const HSLColor.fromAHSL(0.2, 0, 0, 0).toColor(), colorGlobalTimeBorder: const HSLColor.fromAHSL(0.4, 0, 0, 0).toColor(), + colorLink: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor(), // the same as light in Web colorMathBlockBorder: const HSLColor.fromAHSL(1, 240, 0.4, 0.4).toColor(), colorMessageMediaContainerBackground: const HSLColor.fromAHSL(0.03, 0, 0, 1).toColor(), colorPollNames: const HSLColor.fromAHSL(1, 236, .15, .7).toColor(), @@ -107,6 +111,7 @@ class ContentTheme extends ThemeExtension { required this.colorDirectMentionBackground, required this.colorGlobalTimeBackground, required this.colorGlobalTimeBorder, + required this.colorLink, required this.colorMathBlockBorder, required this.colorMessageMediaContainerBackground, required this.colorPollNames, @@ -139,6 +144,7 @@ class ContentTheme extends ThemeExtension { final Color colorDirectMentionBackground; final Color colorGlobalTimeBackground; final Color colorGlobalTimeBorder; + final Color colorLink; final Color colorMathBlockBorder; // TODO(#46) this won't be needed final Color colorMessageMediaContainerBackground; final Color colorPollNames; @@ -199,6 +205,7 @@ class ContentTheme extends ThemeExtension { Color? colorDirectMentionBackground, Color? colorGlobalTimeBackground, Color? colorGlobalTimeBorder, + Color? colorLink, Color? colorMathBlockBorder, Color? colorMessageMediaContainerBackground, Color? colorPollNames, @@ -221,6 +228,7 @@ class ContentTheme extends ThemeExtension { colorDirectMentionBackground: colorDirectMentionBackground ?? this.colorDirectMentionBackground, colorGlobalTimeBackground: colorGlobalTimeBackground ?? this.colorGlobalTimeBackground, colorGlobalTimeBorder: colorGlobalTimeBorder ?? this.colorGlobalTimeBorder, + colorLink: colorLink ?? this.colorLink, colorMathBlockBorder: colorMathBlockBorder ?? this.colorMathBlockBorder, colorMessageMediaContainerBackground: colorMessageMediaContainerBackground ?? this.colorMessageMediaContainerBackground, colorPollNames: colorPollNames ?? this.colorPollNames, @@ -250,6 +258,7 @@ class ContentTheme extends ThemeExtension { colorDirectMentionBackground: Color.lerp(colorDirectMentionBackground, other.colorDirectMentionBackground, t)!, colorGlobalTimeBackground: Color.lerp(colorGlobalTimeBackground, other.colorGlobalTimeBackground, t)!, colorGlobalTimeBorder: Color.lerp(colorGlobalTimeBorder, other.colorGlobalTimeBorder, t)!, + colorLink: Color.lerp(colorLink, other.colorLink, t)!, colorMathBlockBorder: Color.lerp(colorMathBlockBorder, other.colorMathBlockBorder, t)!, colorMessageMediaContainerBackground: Color.lerp(colorMessageMediaContainerBackground, other.colorMessageMediaContainerBackground, t)!, colorPollNames: Color.lerp(colorPollNames, other.colorPollNames, t)!, @@ -364,6 +373,7 @@ class BlockContentList extends StatelessWidget { ); return const SizedBox.shrink(); }(), + WebsitePreviewNode() => WebsitePreview(node: node), UnimplementedBlockContentNode() => Text.rich(_errorUnimplemented(node, context: context)), }; @@ -839,6 +849,103 @@ class MathBlock extends StatelessWidget { } } +class WebsitePreview extends StatelessWidget { + const WebsitePreview({super.key, required this.node}); + + final WebsitePreviewNode node; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final resolvedImageSrcUrl = store.tryResolveUrl(node.imageSrcUrl); + final isSmallWidth = MediaQuery.sizeOf(context).width <= 576; + + // On Web on larger width viewports, the title and description container's + // width is constrained using `max-width: calc(100% - 115px)`, we do not + // follow the same here for potential benefits listed here: + // https://github.com/zulip/zulip-flutter/pull/1049#discussion_r1915740997 + final titleAndDescription = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (node.title != null) + GestureDetector( + onTap: () => _launchUrl(context, node.hrefUrl), + child: Text(node.title!, + style: TextStyle( + fontSize: 1.2 * kBaseFontSize, + // Web uses `line-height: normal` for title. MDN docs for it: + // https://developer.mozilla.org/en-US/docs/Web/CSS/line-height#normal + // says actual value depends on user-agent, and default value + // can be roughly 1.2 (unitless). So, use the same here. + height: 1.2, + color: ContentTheme.of(context).colorLink))), + if (node.description != null) + Container( + padding: const EdgeInsets.only(top: 3), + constraints: const BoxConstraints(maxWidth: 500), + child: Text(node.description!)), + ]); + + final clippedTitleAndDescription = Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: InsetShadowBox( + bottom: 8, + // TODO(#488) use different color for non-message contexts + // TODO(#647) use different color for highlighted messages + // TODO(#681) use different color for DM messages + color: MessageListTheme.of(context).streamMessageBgDefault, + child: ClipRect( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: 80), + child: OverflowBox( + maxHeight: double.infinity, + alignment: AlignmentDirectional.topStart, + fit: OverflowBoxFit.deferToChild, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: titleAndDescription)))))); + + final image = resolvedImageSrcUrl == null ? null + : GestureDetector( + onTap: () => _launchUrl(context, node.hrefUrl), + child: RealmContentNetworkImage( + resolvedImageSrcUrl, + fit: BoxFit.cover)); + + final result = isSmallWidth + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 15, + children: [ + if (image != null) + SizedBox(height: 110, width: double.infinity, child: image), + clippedTitleAndDescription, + ]) + : Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (image != null) + SizedBox.square(dimension: 80, child: image), + Flexible(child: clippedTitleAndDescription), + ]); + + return Padding( + // TODO(?) Web has a bottom margin `--markdown-interelement-space-px` + // around the `message_embed` container, which is calculated here: + // https://github.com/zulip/zulip/blob/d28f7d86223bab4f11629637d4237381943f6fc1/web/src/information_density.ts#L80-L102 + // But for now we use a static value of 6.72px instead which is the + // default in the web client, see discussion: + // https://github.com/zulip/zulip-flutter/pull/1049#discussion_r1915747908 + padding: const EdgeInsets.only(bottom: 6.72), + child: Container( + height: !isSmallWidth ? 90 : null, + decoration: const BoxDecoration( + border: BorderDirectional(start: 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. // @@ -1029,8 +1136,7 @@ class _InlineContentBuilder { assert(recognizer != null); _pushRecognizer(recognizer); final result = _buildNodes(node.nodes, - // Web has the same color in light and dark mode. - style: TextStyle(color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor())); + style: TextStyle(color: ContentTheme.of(_context!).colorLink)); _popRecognizer(); return result; diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 117c121660..24d60fb2af 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -1029,6 +1029,110 @@ class ContentExample { InlineVideoNode(srcUrl: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm'), ]); + static const websitePreviewSmoke = ContentExample( + 'website preview smoke', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html', + '

https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html

\n' + '
' + '' + '
' + '' + '
Zulip is an organized team chat app for distributed teams of all sizes.
', [ + 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'), + ]), + WebsitePreviewNode( + 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 websitePreviewWithoutTitle = ContentExample( + 'website preview without title', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html', + '

https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html

\n' + '
' + '' + '
' + '
Zulip is an organized team chat app for distributed teams of all sizes.
', [ + 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'), + ]), + WebsitePreviewNode( + 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 websitePreviewWithoutDescription = ContentExample( + 'website preview without description', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html', + '

https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html

\n' + '', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html')], + url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html'), + ]), + WebsitePreviewNode( + hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html', + imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67', + title: 'Zulip — organized team chat', + description: null), + ]); + + static const websitePreviewWithoutTitleOrDescription = ContentExample( + 'website preview without title and description', + 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html', + '

https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html

\n' + '
' + '' + '
', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html')], + url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html'), + ]), + WebsitePreviewNode( + hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html', + imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67', + title: null, + description: null), + ]); + + static const legacyWebsitePreviewSmoke = ContentExample( + 'legacy website preview smoke', + // https://chat.zulip.org/#narrow/channel/7-test-here/topic/URL.20previews/near/192777 + 'www.youtube.com', + '

www.youtube.com

\n' + '
' + '' + '
' + '' + '
Enjoy the videos and music you love, upload original content, and share it all with friends, family, and the world on YouTube.
', [ + ParagraphNode(links: [], nodes: [ + LinkNode( + nodes: [TextNode('www.youtube.com')], + url: 'http://www.youtube.com'), + ]), + WebsitePreviewNode( + hrefUrl: 'http://www.youtube.com', + imageSrcUrl: 'https://youtube.com/yts/img/yt_1200-vfl4C3T0K.png', + title: 'YouTube', + description: 'Enjoy the videos and music you love, upload ' + 'original content, and share it all with friends, family, and ' + 'the world on YouTube.'), + ]); + static const tableWithSingleRow = ContentExample( 'table with single row', // https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971202 @@ -1570,6 +1674,12 @@ void main() { testParseExample(ContentExample.videoInline); testParseExample(ContentExample.videoInlineClassesFlipped); + testParseExample(ContentExample.websitePreviewSmoke); + testParseExample(ContentExample.websitePreviewWithoutTitle); + testParseExample(ContentExample.websitePreviewWithoutDescription); + testParseExample(ContentExample.websitePreviewWithoutTitleOrDescription); + testParseExample(ContentExample.legacyWebsitePreviewSmoke); + testParseExample(ContentExample.tableWithSingleRow); testParseExample(ContentExample.tableWithMultipleRows); testParseExample(ContentExample.tableWithBoldAndItalicHeaders); diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 571034093d..0b81365ea8 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -1005,6 +1005,69 @@ void main() { }); }); + group('WebsitePreview', () { + Future prepare(WidgetTester tester, String html) async { + await prepareContent(tester, plainContent(html), + wrapWithPerAccountStoreWidget: true); + } + + testWidgets('smoke', (tester) async { + final url = Uri.parse(ContentExample.websitePreviewSmoke.markdown!); + await prepare(tester, ContentExample.websitePreviewSmoke.html); + + await tester.tap(find.textContaining( + 'Zulip is an organized team chat app for ' + 'distributed teams of all sizes.')); + + await tester.tap(find.text('Zulip — organized team chat')); + 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.websitePreviewWithoutTitle.markdown!); + await prepare(tester, ContentExample.websitePreviewWithoutTitle.html); + + await tester.tap(find.textContaining( + 'Zulip is an organized team chat app for ' + 'distributed teams of all sizes.')); + + 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.websitePreviewWithoutDescription.markdown!); + await prepare(tester, ContentExample.websitePreviewWithoutDescription.html); + + await tester.tap(find.text('Zulip — organized team chat')); + 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 or description', (tester) async { + final url = Uri.parse(ContentExample.websitePreviewWithoutTitleOrDescription.markdown!); + await prepare(tester, ContentExample.websitePreviewWithoutTitleOrDescription.html); + + 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);