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',
+ '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'),
+ ]),
+ 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',
+ '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'),
+ ]),
+ 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',
+ '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'),
+ ]),
+ 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"
'\n- \n
\ntwo
\n\n- three
\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 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);