Skip to content

Commit 193280a

Browse files
content: Handle message_embed website previews
Implements support for displaying website previews messages, follows the Web styling, like having different layout for larger viewports (> 576), and any other constraints that are empirically present on Web. Fixes: #1016
1 parent 1e9aa81 commit 193280a

File tree

4 files changed

+403
-0
lines changed

4 files changed

+403
-0
lines changed

lib/model/content.dart

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

507+
// See:
508+
// https://ogp.me/
509+
// https://oembed.com/
510+
// https://zulip.com/help/image-video-and-website-previews#configure-whether-website-previews-are-shown
511+
class WebsitePreviewNode extends BlockContentNode {
512+
const WebsitePreviewNode({
513+
super.debugHtmlNode,
514+
required this.hrefUrl,
515+
required this.imageSrcUrl,
516+
required this.title,
517+
required this.description,
518+
});
519+
520+
/// The URL from which this preview data was retrieved.
521+
final String hrefUrl;
522+
523+
/// The image URL representing the webpage, content value
524+
/// of `og:image` HTML meta property.
525+
final String imageSrcUrl;
526+
527+
/// Represents the webpage title, derived from either
528+
/// the content of the `og:title` HTML meta property or
529+
/// the <title> HTML element.
530+
final String? title;
531+
532+
/// Description about the webpage, content value of
533+
/// `og:description` HTML meta property.
534+
final String? description;
535+
536+
@override
537+
bool operator ==(Object other) {
538+
return other is WebsitePreviewNode
539+
&& other.hrefUrl == hrefUrl
540+
&& other.imageSrcUrl == imageSrcUrl
541+
&& other.title == title
542+
&& other.description == description;
543+
}
544+
545+
@override
546+
int get hashCode =>
547+
Object.hash('WebsitePreviewNode', hrefUrl, imageSrcUrl, title, description);
548+
549+
@override
550+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
551+
super.debugFillProperties(properties);
552+
properties.add(StringProperty('hrefUrl', hrefUrl));
553+
properties.add(StringProperty('imageSrcUrl', imageSrcUrl));
554+
properties.add(StringProperty('title', title));
555+
properties.add(StringProperty('description', description));
556+
}
557+
}
558+
507559
class TableNode extends BlockContentNode {
508560
const TableNode({super.debugHtmlNode, required this.rows});
509561

@@ -1339,6 +1391,112 @@ class _ZulipContentParser {
13391391
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
13401392
}
13411393

1394+
static final _websitePreviewImageSrcRegexp = RegExp(r'background-image: url\("(.+)"\)');
1395+
1396+
BlockContentNode parseWebsitePreviewNode(dom.Element divElement) {
1397+
assert(divElement.localName == 'div'
1398+
&& divElement.className == 'message_embed');
1399+
1400+
final result = () {
1401+
if (divElement.nodes case [
1402+
dom.Element(
1403+
localName: 'a',
1404+
className: 'message_embed_image',
1405+
attributes: {
1406+
'href': final String imageHref,
1407+
'style': final String imageStyleAttr,
1408+
},
1409+
nodes: []),
1410+
dom.Element(
1411+
localName: 'div',
1412+
className: 'data-container',
1413+
nodes: [...]) && final dataContainer,
1414+
]) {
1415+
final match = _websitePreviewImageSrcRegexp.firstMatch(imageStyleAttr);
1416+
if (match == null) return null;
1417+
final imageSrcUrl = match.group(1);
1418+
if (imageSrcUrl == null) return null;
1419+
1420+
String? parseTitle(dom.Element element) {
1421+
assert(element.localName == 'div' &&
1422+
element.className == 'message_embed_title');
1423+
if (element.nodes case [
1424+
dom.Element(localName: 'a', className: '') && final child,
1425+
]) {
1426+
final titleHref = child.attributes['href'];
1427+
// Make sure both image hyperlink and title hyperlink are same.
1428+
if (imageHref != titleHref) return null;
1429+
1430+
if (child.nodes case [dom.Text(text: final title)]) {
1431+
return title;
1432+
}
1433+
}
1434+
return null;
1435+
}
1436+
1437+
String? parseDescription(dom.Element element) {
1438+
assert(element.localName == 'div' &&
1439+
element.className == 'message_embed_description');
1440+
if (element.nodes case [dom.Text(text: final description)]) {
1441+
return description;
1442+
}
1443+
return null;
1444+
}
1445+
1446+
String? title, description;
1447+
switch (dataContainer.nodes) {
1448+
case []:
1449+
// Server generates an empty `<div class="data-container"></div>`
1450+
// if website HTML doesn't have both title (derived from
1451+
// `og:title` or `<title>…</title>`) and description (derived from
1452+
// `og:description`).
1453+
break;
1454+
1455+
case [
1456+
dom.Element(
1457+
localName: 'div',
1458+
className: 'message_embed_title') && final first,
1459+
dom.Element(
1460+
localName: 'div',
1461+
className: 'message_embed_description') && final second,
1462+
]:
1463+
title = parseTitle(first);
1464+
if (title == null) return null;
1465+
description = parseDescription(second);
1466+
if (description == null) return null;
1467+
1468+
case [dom.Element(localName: 'div') && final single]:
1469+
switch (single.className) {
1470+
case 'message_embed_title':
1471+
title = parseTitle(single);
1472+
if (title == null) return null;
1473+
1474+
case 'message_embed_description':
1475+
description = parseDescription(single);
1476+
if (description == null) return null;
1477+
1478+
default:
1479+
return null;
1480+
}
1481+
1482+
default:
1483+
return null;
1484+
}
1485+
1486+
return WebsitePreviewNode(
1487+
hrefUrl: imageHref,
1488+
imageSrcUrl: imageSrcUrl,
1489+
title: title,
1490+
description: description,
1491+
);
1492+
} else {
1493+
return null;
1494+
}
1495+
}();
1496+
1497+
return result ?? UnimplementedBlockContentNode(htmlNode: divElement);
1498+
}
1499+
13421500
BlockContentNode parseTableContent(dom.Element tableElement) {
13431501
assert(tableElement.localName == 'table'
13441502
&& tableElement.className.isEmpty);
@@ -1583,6 +1741,10 @@ class _ZulipContentParser {
15831741
}
15841742
}
15851743

1744+
if (localName == 'div' && className == 'message_embed') {
1745+
return parseWebsitePreviewNode(element);
1746+
}
1747+
15861748
// TODO more types of node
15871749
return UnimplementedBlockContentNode(htmlNode: node);
15881750
}

lib/widgets/content.dart

Lines changed: 97 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';
@@ -371,6 +372,7 @@ class BlockContentList extends StatelessWidget {
371372
);
372373
return const SizedBox.shrink();
373374
}(),
375+
WebsitePreviewNode() => WebsitePreview(node: node),
374376
UnimplementedBlockContentNode() =>
375377
Text.rich(_errorUnimplemented(node, context: context)),
376378
};
@@ -846,6 +848,101 @@ class MathBlock extends StatelessWidget {
846848
}
847849
}
848850

851+
class WebsitePreview extends StatelessWidget {
852+
const WebsitePreview({super.key, required this.node});
853+
854+
final WebsitePreviewNode node;
855+
856+
@override
857+
Widget build(BuildContext context) {
858+
final store = PerAccountStoreWidget.of(context);
859+
final resolvedImageSrcUrl = store.tryResolveUrl(node.imageSrcUrl);
860+
final isSmallWidth = MediaQuery.sizeOf(context).width <= 576;
861+
862+
// On Web on larger width viewports, the title and description container's
863+
// width is constrained using `max-width: calc(100% - 115px)`, we do not
864+
// follow the same here for potential benefits listed here:
865+
// https://github.com/zulip/zulip-flutter/pull/1049#discussion_r1915740997
866+
final titleAndDescription = Column(
867+
crossAxisAlignment: CrossAxisAlignment.start,
868+
mainAxisSize: MainAxisSize.min,
869+
children: [
870+
if (node.title != null)
871+
GestureDetector(
872+
onTap: () => _launchUrl(context, node.hrefUrl),
873+
child: Text(node.title!,
874+
style: TextStyle(
875+
fontSize: 1.2 * kBaseFontSize,
876+
height: kTextHeightNone,
877+
color: ContentTheme.of(context).colorLink))),
878+
if (node.description != null)
879+
Container(
880+
padding: const EdgeInsets.only(top: 3),
881+
constraints: const BoxConstraints(maxWidth: 500),
882+
child: Text(node.description!)),
883+
]);
884+
885+
final clippedTitleAndDescription = Container(
886+
constraints: const BoxConstraints(maxHeight: 80),
887+
padding: const EdgeInsets.symmetric(horizontal: 5),
888+
child: InsetShadowBox(
889+
bottom: 8,
890+
// TODO(#488) use different color for non-message contexts
891+
// TODO(#647) use different color for highlighted messages
892+
// TODO(#681) use different color for DM messages
893+
color: MessageListTheme.of(context).streamMessageBgDefault,
894+
child: UnconstrainedBox(
895+
alignment: AlignmentDirectional.topStart,
896+
constrainedAxis: Axis.horizontal,
897+
clipBehavior: Clip.hardEdge,
898+
child: Padding(
899+
padding: const EdgeInsets.only(bottom: 8.0),
900+
child: titleAndDescription))));
901+
902+
final result = isSmallWidth
903+
? Column(
904+
crossAxisAlignment: CrossAxisAlignment.start,
905+
spacing: 15,
906+
children: [
907+
if (resolvedImageSrcUrl != null)
908+
GestureDetector(
909+
onTap: () => _launchUrl(context, node.hrefUrl),
910+
child: RealmContentNetworkImage(
911+
resolvedImageSrcUrl,
912+
fit: BoxFit.cover,
913+
width: double.infinity,
914+
height: 100)),
915+
clippedTitleAndDescription,
916+
])
917+
: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
918+
if (resolvedImageSrcUrl != null)
919+
GestureDetector(
920+
onTap: () => _launchUrl(context, node.hrefUrl),
921+
child: RealmContentNetworkImage(
922+
resolvedImageSrcUrl,
923+
fit: BoxFit.cover,
924+
width: 80,
925+
height: 80)),
926+
Flexible(child: clippedTitleAndDescription),
927+
]);
928+
929+
return Padding(
930+
// TODO(?) Web has a bottom margin `--markdown-interelement-space-px`
931+
// around the `message_embed` container, which is calculated here:
932+
// https://github.com/zulip/zulip/blob/d28f7d86223bab4f11629637d4237381943f6fc1/web/src/information_density.ts#L80-L102
933+
// But for now we use a static value instead, see discussion:
934+
// https://github.com/zulip/zulip-flutter/pull/1049#discussion_r1915747908
935+
padding: const EdgeInsets.only(bottom: 5),
936+
child: Container(
937+
decoration: const BoxDecoration(
938+
border: BorderDirectional(start: BorderSide(
939+
// Web has the same color in light and dark mode.
940+
color: Color(0xffededed), width: 3))),
941+
padding: const EdgeInsets.all(5),
942+
child: result));
943+
}
944+
}
945+
849946
//
850947
// Inline layout.
851948
//

test/model/content_test.dart

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,86 @@ class ContentExample {
10291029
InlineVideoNode(srcUrl: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm'),
10301030
]);
10311031

1032+
static const websitePreviewSmoke = ContentExample(
1033+
'link preview smoke',
1034+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
1035+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html</a></p>\n'
1036+
'<div class="message_embed">'
1037+
'<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>'
1038+
'<div class="data-container">'
1039+
'<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>'
1040+
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
1041+
ParagraphNode(links: [], nodes: [
1042+
LinkNode(
1043+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html')],
1044+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html'),
1045+
]),
1046+
WebsitePreviewNode(
1047+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
1048+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1049+
title: 'Zulip — organized team chat',
1050+
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
1051+
]);
1052+
1053+
static const websitePreviewWithoutTitle = ContentExample(
1054+
'link preview without title',
1055+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
1056+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html</a></p>\n'
1057+
'<div class="message_embed">'
1058+
'<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>'
1059+
'<div class="data-container">'
1060+
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
1061+
ParagraphNode(links: [], nodes: [
1062+
LinkNode(
1063+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html')],
1064+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html'),
1065+
]),
1066+
WebsitePreviewNode(
1067+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
1068+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1069+
title: null,
1070+
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
1071+
]);
1072+
1073+
static const websitePreviewWithoutDescription = ContentExample(
1074+
'link preview without description',
1075+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
1076+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html</a></p>\n'
1077+
'<div class="message_embed">'
1078+
'<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>'
1079+
'<div class="data-container">'
1080+
'<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>', [
1081+
ParagraphNode(links: [], nodes: [
1082+
LinkNode(
1083+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html')],
1084+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html'),
1085+
]),
1086+
WebsitePreviewNode(
1087+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
1088+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1089+
title: 'Zulip — organized team chat',
1090+
description: null),
1091+
]);
1092+
1093+
static const websitePreviewWithoutTitleAndDescription = ContentExample(
1094+
'link preview without title and description',
1095+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html',
1096+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html</a></p>\n'
1097+
'<div class="message_embed">'
1098+
'<a class="message_embed_image" href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html" style="background-image: url(&quot;https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67&quot;)"></a>'
1099+
'<div class="data-container"></div></div>', [
1100+
ParagraphNode(links: [], nodes: [
1101+
LinkNode(
1102+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html')],
1103+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html'),
1104+
]),
1105+
WebsitePreviewNode(
1106+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+nodescription+notitle.html',
1107+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1108+
title: null,
1109+
description: null),
1110+
]);
1111+
10321112
static const tableWithSingleRow = ContentExample(
10331113
'table with single row',
10341114
// https://chat.zulip.org/#narrow/channel/7-test-here/topic/Rajesh/near/1971202
@@ -1570,6 +1650,11 @@ void main() {
15701650
testParseExample(ContentExample.videoInline);
15711651
testParseExample(ContentExample.videoInlineClassesFlipped);
15721652

1653+
testParseExample(ContentExample.websitePreviewSmoke);
1654+
testParseExample(ContentExample.websitePreviewWithoutTitle);
1655+
testParseExample(ContentExample.websitePreviewWithoutDescription);
1656+
testParseExample(ContentExample.websitePreviewWithoutTitleAndDescription);
1657+
15731658
testParseExample(ContentExample.tableWithSingleRow);
15741659
testParseExample(ContentExample.tableWithMultipleRows);
15751660
testParseExample(ContentExample.tableWithBoldAndItalicHeaders);

0 commit comments

Comments
 (0)