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 ``
+ // if website HTML has neither title (derived from
+ // `og:title` or `…`) 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);