Skip to content

Commit 8fba3fe

Browse files
content: Handle message_embed website previews
Fixes: #1016
1 parent 94cf40e commit 8fba3fe

File tree

4 files changed

+313
-0
lines changed

4 files changed

+313
-0
lines changed

lib/model/content.dart

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,55 @@ class EmbedVideoNode extends BlockContentNode {
504504
}
505505
}
506506

507+
// Ref: https://ogp.me/
508+
class LinkPreviewNode extends BlockContentNode {
509+
const LinkPreviewNode({
510+
super.debugHtmlNode,
511+
required this.hrefUrl,
512+
required this.imageSrcUrl,
513+
required this.title,
514+
required this.description,
515+
});
516+
517+
/// The URL from which this preview data was retrieved.
518+
final String hrefUrl;
519+
520+
/// The image URL representing the webpage, content value
521+
/// of `og:image` HTML meta property.
522+
final String imageSrcUrl;
523+
524+
/// Represents the webpage title, derived from either
525+
/// the content of the `og:title` HTML meta property or
526+
/// the <title> HTML element.
527+
final String? title;
528+
529+
/// Description about the webpage, content value of
530+
/// `og:description` HTML meta property.
531+
final String? description;
532+
533+
@override
534+
bool operator ==(Object other) {
535+
return other is LinkPreviewNode
536+
&& other.hrefUrl == hrefUrl
537+
&& other.imageSrcUrl == imageSrcUrl
538+
&& other.title == title
539+
&& other.description == description;
540+
}
541+
542+
@override
543+
int get hashCode =>
544+
Object.hash('LinkPreviewNode', hrefUrl, imageSrcUrl, title, description);
545+
546+
@override
547+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
548+
super.debugFillProperties(properties);
549+
properties.add(StringProperty('hrefUrl', hrefUrl));
550+
properties.add(StringProperty('imageSrcUrl', imageSrcUrl));
551+
properties.add(StringProperty('title', title));
552+
properties.add(StringProperty('description', description));
553+
}
554+
}
555+
507556
/// A content node that expects an inline layout context from its parent.
508557
///
509558
/// When rendered into a Flutter widget tree, an inline content node
@@ -1222,6 +1271,90 @@ class _ZulipContentParser {
12221271
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
12231272
}
12241273

1274+
static final _linkPreviewImageSrcRegexp = RegExp(r'background-image: url\("(.+)"\)');
1275+
1276+
BlockContentNode parseLinkPreviewNode(dom.Element divElement) {
1277+
assert(_debugParserContext == _ParserContext.block);
1278+
assert(divElement.localName == 'div'
1279+
&& divElement.className == 'message_embed');
1280+
1281+
final result = () {
1282+
if (divElement.nodes.length != 2) return null;
1283+
1284+
final first = divElement.nodes.first;
1285+
if (first is! dom.Element) return null;
1286+
if (first.localName != 'a') return null;
1287+
if (first.className != 'message_embed_image') return null;
1288+
if (first.attributes.length != 3) return null; // 'class', 'href', 'style'
1289+
if (first.nodes.isNotEmpty) return null;
1290+
1291+
final imageHref = first.attributes['href'];
1292+
if (imageHref == null) return null;
1293+
1294+
final styleAttr = first.attributes['style'];
1295+
if (styleAttr == null) return null;
1296+
final match = _linkPreviewImageSrcRegexp.firstMatch(styleAttr);
1297+
if (match == null) return null;
1298+
final imageSrcUrl = match.group(1);
1299+
if (imageSrcUrl == null) return null;
1300+
1301+
final second = divElement.nodes.last;
1302+
if (second is! dom.Element) return null;
1303+
if (second.localName != 'div') return null;
1304+
if (second.className != 'data-container') return null;
1305+
if (second.attributes.length != 1) return null; // 'class'
1306+
if (second.nodes.isEmpty) return null;
1307+
if (second.nodes.length > 2) return null;
1308+
1309+
String? title, description;
1310+
for (final node in second.nodes) {
1311+
if (node is! dom.Element) return null;
1312+
if (node.localName != 'div') return null;
1313+
1314+
switch (node.className) {
1315+
case 'message_embed_title':
1316+
if (node.attributes.length != 1) return null; // 'class'
1317+
if (node.nodes.length != 1) return null;
1318+
final child = node.nodes.single;
1319+
if (child is! dom.Element) return null;
1320+
if (child.localName != 'a') return null;
1321+
if (child.className.isNotEmpty) return null;
1322+
if (child.attributes.length != 2) return null; // 'href', 'title'
1323+
if (child.nodes.length != 1) return null;
1324+
1325+
final titleHref = child.attributes['href'];
1326+
if (imageHref != titleHref) return null;
1327+
final grandchild = child.nodes.single;
1328+
if (grandchild is! dom.Text) return null;
1329+
title = grandchild.text;
1330+
1331+
case 'message_embed_description':
1332+
if (node.nodes.length != 1) return null;
1333+
final child = node.nodes.single;
1334+
if (child is! dom.Text) return null;
1335+
description = child.text;
1336+
1337+
default:
1338+
return null;
1339+
}
1340+
}
1341+
1342+
return (imageHref, imageSrcUrl, title, description);
1343+
}();
1344+
1345+
if (result == null) {
1346+
return UnimplementedBlockContentNode(htmlNode: divElement);
1347+
}
1348+
final (hrefUrl, imageSrcUrl, title, description) = result;
1349+
1350+
return LinkPreviewNode(
1351+
hrefUrl: hrefUrl,
1352+
imageSrcUrl: imageSrcUrl,
1353+
title: title,
1354+
description: description,
1355+
);
1356+
}
1357+
12251358
BlockContentNode parseBlockContent(dom.Node node) {
12261359
assert(_debugParserContext == _ParserContext.block);
12271360
final debugHtmlNode = kDebugMode ? node : null;
@@ -1315,6 +1448,10 @@ class _ZulipContentParser {
13151448
}
13161449
}
13171450

1451+
if (localName == 'div' && className == 'message_embed') {
1452+
return parseLinkPreviewNode(element);
1453+
}
1454+
13181455
// TODO more types of node
13191456
return UnimplementedBlockContentNode(htmlNode: node);
13201457
}

lib/widgets/content.dart

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import '../model/internal_link.dart';
1818
import 'code_block.dart';
1919
import 'dialog.dart';
2020
import 'icons.dart';
21+
import 'inset_shadow.dart';
2122
import 'lightbox.dart';
2223
import 'message_list.dart';
2324
import 'poll.dart';
@@ -324,6 +325,7 @@ class BlockContentList extends StatelessWidget {
324325
}(),
325326
InlineVideoNode() => MessageInlineVideo(node: node),
326327
EmbedVideoNode() => MessageEmbedVideo(node: node),
328+
LinkPreviewNode() => MessageLinkPreview(node: node),
327329
UnimplementedBlockContentNode() =>
328330
Text.rich(_errorUnimplemented(node, context: context)),
329331
};
@@ -799,6 +801,90 @@ class MathBlock extends StatelessWidget {
799801
}
800802
}
801803

804+
class MessageLinkPreview extends StatelessWidget {
805+
const MessageLinkPreview({super.key, required this.node});
806+
807+
final LinkPreviewNode node;
808+
809+
@override
810+
Widget build(BuildContext context) {
811+
final messageListTheme = MessageListTheme.of(context);
812+
final isSmallWidth = MediaQuery.sizeOf(context).width <= 576;
813+
814+
final dataContainer = Container(
815+
constraints: const BoxConstraints(maxHeight: 80),
816+
padding: const EdgeInsets.symmetric(horizontal: 5),
817+
child: InsetShadowBox(
818+
bottom: 8,
819+
color: messageListTheme.streamMessageBgDefault,
820+
child: UnconstrainedBox(
821+
alignment: Alignment.topCenter,
822+
constrainedAxis: Axis.horizontal,
823+
clipBehavior: Clip.antiAlias,
824+
child: Column(
825+
mainAxisAlignment: MainAxisAlignment.start,
826+
crossAxisAlignment: CrossAxisAlignment.start,
827+
mainAxisSize: MainAxisSize.min,
828+
children: [
829+
if (node.title != null)
830+
GestureDetector(
831+
onTap: () => _launchUrl(context, node.hrefUrl),
832+
child: Text(node.title!,
833+
style: TextStyle(
834+
fontSize: 1.2 * kBaseFontSize,
835+
color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()))),
836+
if (node.description != null) ...[
837+
const SizedBox(height: 3),
838+
ConstrainedBox(
839+
constraints: const BoxConstraints(maxWidth: 500),
840+
child: Text(node.description!)),
841+
],
842+
const SizedBox(height: 8),
843+
]))));
844+
845+
if (isSmallWidth) {
846+
return Container(
847+
decoration: const BoxDecoration(border:
848+
Border(left: BorderSide(color: Color(0xFFEDEDED), width: 3))),
849+
padding: const EdgeInsets.all(5),
850+
child: Column(
851+
mainAxisAlignment: MainAxisAlignment.start,
852+
crossAxisAlignment: CrossAxisAlignment.start,
853+
mainAxisSize: MainAxisSize.min,
854+
children: [
855+
GestureDetector(
856+
onTap: () => _launchUrl(context, node.hrefUrl),
857+
child: RealmContentNetworkImage(
858+
Uri.parse(node.imageSrcUrl),
859+
fit: BoxFit.cover,
860+
width: double.infinity,
861+
height: 100)),
862+
const SizedBox(height: 5),
863+
dataContainer,
864+
]));
865+
}
866+
867+
return Container(
868+
decoration: const BoxDecoration(border:
869+
Border(left: BorderSide(color: Color(0xFFEDEDED), width: 3))),
870+
padding: const EdgeInsets.all(5),
871+
child: Row(
872+
mainAxisAlignment: MainAxisAlignment.start,
873+
crossAxisAlignment: CrossAxisAlignment.start,
874+
mainAxisSize: MainAxisSize.max,
875+
children: [
876+
GestureDetector(
877+
onTap: () => _launchUrl(context, node.hrefUrl),
878+
child: RealmContentNetworkImage(Uri.parse(node.imageSrcUrl),
879+
fit: BoxFit.cover,
880+
width: 80,
881+
height: 80,
882+
alignment: Alignment.center)),
883+
Flexible(child: dataContainer),
884+
]));
885+
}
886+
}
887+
802888
//
803889
// Inline layout.
804890
//

test/model/content_test.dart

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,67 @@ class ContentExample {
892892
]),
893893
InlineVideoNode(srcUrl: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm'),
894894
]);
895+
896+
static const linkPreviewSmoke = ContentExample(
897+
'link preview smoke',
898+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
899+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html</a></p>\n'
900+
'<div class="message_embed">'
901+
'<a class="message_embed_image" href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html" style="background-image: url(&quot;https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67&quot;)"></a>'
902+
'<div class="data-container">'
903+
'<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>'
904+
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
905+
ParagraphNode(links: [], nodes: [
906+
LinkNode(
907+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html')],
908+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html'),
909+
]),
910+
LinkPreviewNode(
911+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
912+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
913+
title: 'Zulip — organized team chat',
914+
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
915+
]);
916+
917+
static const linkPreviewWithoutTitle = ContentExample(
918+
'link preview without title',
919+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
920+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html</a></p>\n'
921+
'<div class="message_embed">'
922+
'<a class="message_embed_image" href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html" style="background-image: url(&quot;https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67&quot;)"></a>'
923+
'<div class="data-container">'
924+
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
925+
ParagraphNode(links: [], nodes: [
926+
LinkNode(
927+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html')],
928+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html'),
929+
]),
930+
LinkPreviewNode(
931+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
932+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
933+
title: null,
934+
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
935+
]);
936+
937+
static const linkPreviewWithoutDescription = ContentExample(
938+
'link preview without description',
939+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
940+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html</a></p>\n'
941+
'<div class="message_embed">'
942+
'<a class="message_embed_image" href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html" style="background-image: url(&quot;https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67&quot;)"></a>'
943+
'<div class="data-container">'
944+
'<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>', [
945+
ParagraphNode(links: [], nodes: [
946+
LinkNode(
947+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html')],
948+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html'),
949+
]),
950+
LinkPreviewNode(
951+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
952+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
953+
title: 'Zulip — organized team chat',
954+
description: null),
955+
]);
895956
}
896957

897958
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -1221,6 +1282,10 @@ void main() {
12211282
testParseExample(ContentExample.videoInline);
12221283
testParseExample(ContentExample.videoInlineClassesFlipped);
12231284

1285+
testParseExample(ContentExample.linkPreviewSmoke);
1286+
testParseExample(ContentExample.linkPreviewWithoutTitle);
1287+
testParseExample(ContentExample.linkPreviewWithoutDescription);
1288+
12241289
testParse('parse nested lists, quotes, headings, code blocks',
12251290
// "1. > ###### two\n > * three\n\n four"
12261291
'<ol>\n<li>\n<blockquote>\n<h6>two</h6>\n<ul>\n<li>three</li>\n'

test/widgets/content_test.dart

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,31 @@ void main() {
955955
});
956956
});
957957

958+
group('MessageLinkPreview', () {
959+
Future<void> prepare(WidgetTester tester, String html) async {
960+
await prepareContent(tester, plainContent(html),
961+
wrapWithPerAccountStoreWidget: true);
962+
}
963+
964+
testWidgets('smoke', (tester) async {
965+
await prepare(tester, ContentExample.linkPreviewSmoke.html);
966+
tester.widget(find.byType(MessageLinkPreview));
967+
debugNetworkImageHttpClientProvider = null;
968+
});
969+
970+
testWidgets('smoke: without title', (tester) async {
971+
await prepare(tester, ContentExample.linkPreviewWithoutTitle.html);
972+
tester.widget(find.byType(MessageLinkPreview));
973+
debugNetworkImageHttpClientProvider = null;
974+
});
975+
976+
testWidgets('smoke: without description', (tester) async {
977+
await prepare(tester, ContentExample.linkPreviewWithoutDescription.html);
978+
tester.widget(find.byType(MessageLinkPreview));
979+
debugNetworkImageHttpClientProvider = null;
980+
});
981+
});
982+
958983
group('RealmContentNetworkImage', () {
959984
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);
960985

0 commit comments

Comments
 (0)