Skip to content

Commit 49cf612

Browse files
content: Handle message_embed website previews
The LinkPreview widget 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 9056c3c commit 49cf612

File tree

4 files changed

+357
-1
lines changed

4 files changed

+357
-1
lines changed

lib/model/content.dart

+150
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,57 @@ class TableCellNode extends BlockInlineContainerNode {
581581
}
582582
}
583583

584+
// Ref:
585+
// https://ogp.me/
586+
// https://oembed.com/
587+
class LinkPreviewNode extends BlockContentNode {
588+
const LinkPreviewNode({
589+
super.debugHtmlNode,
590+
required this.hrefUrl,
591+
required this.imageSrcUrl,
592+
required this.title,
593+
required this.description,
594+
});
595+
596+
/// The URL from which this preview data was retrieved.
597+
final String hrefUrl;
598+
599+
/// The image URL representing the webpage, content value
600+
/// of `og:image` HTML meta property.
601+
final String imageSrcUrl;
602+
603+
/// Represents the webpage title, derived from either
604+
/// the content of the `og:title` HTML meta property or
605+
/// the <title> HTML element.
606+
final String? title;
607+
608+
/// Description about the webpage, content value of
609+
/// `og:description` HTML meta property.
610+
final String? description;
611+
612+
@override
613+
bool operator ==(Object other) {
614+
return other is LinkPreviewNode
615+
&& other.hrefUrl == hrefUrl
616+
&& other.imageSrcUrl == imageSrcUrl
617+
&& other.title == title
618+
&& other.description == description;
619+
}
620+
621+
@override
622+
int get hashCode =>
623+
Object.hash('LinkPreviewNode', hrefUrl, imageSrcUrl, title, description);
624+
625+
@override
626+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
627+
super.debugFillProperties(properties);
628+
properties.add(StringProperty('hrefUrl', hrefUrl));
629+
properties.add(StringProperty('imageSrcUrl', imageSrcUrl));
630+
properties.add(StringProperty('title', title));
631+
properties.add(StringProperty('description', description));
632+
}
633+
}
634+
584635
/// A content node that expects an inline layout context from its parent.
585636
///
586637
/// When rendered into a Flutter widget tree, an inline content node
@@ -1453,6 +1504,101 @@ class _ZulipContentParser {
14531504
return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement);
14541505
}
14551506

1507+
static final _linkPreviewImageSrcRegexp = RegExp(r'background-image: url\("(.+)"\)');
1508+
1509+
BlockContentNode parseLinkPreviewNode(dom.Element divElement) {
1510+
assert(divElement.localName == 'div'
1511+
&& divElement.className == 'message_embed');
1512+
1513+
final result = () {
1514+
if (divElement.nodes case [
1515+
dom.Element(
1516+
localName: 'a',
1517+
className: 'message_embed_image',
1518+
attributes: {
1519+
'href': final String imageHref,
1520+
'style': final String imageStyleAttr,
1521+
},
1522+
nodes: []),
1523+
dom.Element(
1524+
localName: 'div',
1525+
className: 'data-container',
1526+
nodes: [...]) && final dataContainer,
1527+
]) {
1528+
final match = _linkPreviewImageSrcRegexp.firstMatch(imageStyleAttr);
1529+
if (match == null) return null;
1530+
final imageSrcUrl = match.group(1);
1531+
if (imageSrcUrl == null) return null;
1532+
1533+
String? parseTitle(dom.Element element) {
1534+
assert(element.localName == 'div' &&
1535+
element.className == 'message_embed_title');
1536+
if (element.nodes case [
1537+
dom.Element(localName: 'a', className: '') && final child,
1538+
]) {
1539+
final titleHref = child.attributes['href'];
1540+
// Make sure both image hyperlink and title hyperlink are same.
1541+
if (imageHref != titleHref) return null;
1542+
1543+
if (child.nodes case [dom.Text(text: final title)]) {
1544+
return title;
1545+
}
1546+
}
1547+
return null;
1548+
}
1549+
1550+
String? parseDescription(dom.Element element) {
1551+
assert(element.localName == 'div' &&
1552+
element.className == 'message_embed_description');
1553+
if (element.nodes case [dom.Text(text: final description)]) {
1554+
return description;
1555+
}
1556+
return null;
1557+
}
1558+
1559+
String? title, description;
1560+
switch (dataContainer.nodes) {
1561+
case [
1562+
dom.Element(
1563+
localName: 'div',
1564+
className: 'message_embed_title') && final first,
1565+
dom.Element(
1566+
localName: 'div',
1567+
className: 'message_embed_description') && final second,
1568+
]:
1569+
title = parseTitle(first);
1570+
if (title == null) return null;
1571+
description = parseDescription(second);
1572+
if (description == null) return null;
1573+
1574+
case [dom.Element(localName: 'div') && final single]:
1575+
switch (single.className) {
1576+
case 'message_embed_title':
1577+
title = parseTitle(single);
1578+
if (title == null) return null;
1579+
case 'message_embed_description':
1580+
description = parseDescription(single);
1581+
if (description == null) return null;
1582+
}
1583+
1584+
default:
1585+
return null;
1586+
}
1587+
1588+
return LinkPreviewNode(
1589+
hrefUrl: imageHref,
1590+
imageSrcUrl: imageSrcUrl,
1591+
title: title,
1592+
description: description,
1593+
);
1594+
} else {
1595+
return null;
1596+
}
1597+
}();
1598+
1599+
return result ?? UnimplementedBlockContentNode(htmlNode: divElement);
1600+
}
1601+
14561602
BlockContentNode parseBlockContent(dom.Node node) {
14571603
final debugHtmlNode = kDebugMode ? node : null;
14581604
if (node is! dom.Element) {
@@ -1547,6 +1693,10 @@ class _ZulipContentParser {
15471693
}
15481694
}
15491695

1696+
if (localName == 'div' && className == 'message_embed') {
1697+
return parseLinkPreviewNode(element);
1698+
}
1699+
15501700
// TODO more types of node
15511701
return UnimplementedBlockContentNode(htmlNode: node);
15521702
}

lib/widgets/content.dart

+90-1
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,10 +372,10 @@ class BlockContentList extends StatelessWidget {
371372
);
372373
return const SizedBox.shrink();
373374
}(),
375+
LinkPreviewNode() => LinkPreview(node: node),
374376
UnimplementedBlockContentNode() =>
375377
Text.rich(_errorUnimplemented(node, context: context)),
376378
};
377-
378379
}),
379380
]);
380381
}
@@ -846,6 +847,94 @@ class MathBlock extends StatelessWidget {
846847
}
847848
}
848849

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

test/model/content_test.dart

+65
Original file line numberDiff line numberDiff line change
@@ -1155,6 +1155,67 @@ class ContentExample {
11551155
], isHeader: false),
11561156
]),
11571157
]);
1158+
1159+
static const linkPreviewSmoke = ContentExample(
1160+
'link preview smoke',
1161+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
1162+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html</a></p>\n'
1163+
'<div class="message_embed">'
1164+
'<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>'
1165+
'<div class="data-container">'
1166+
'<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>'
1167+
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
1168+
ParagraphNode(links: [], nodes: [
1169+
LinkNode(
1170+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html')],
1171+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html'),
1172+
]),
1173+
LinkPreviewNode(
1174+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
1175+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1176+
title: 'Zulip — organized team chat',
1177+
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
1178+
]);
1179+
1180+
static const linkPreviewWithoutTitle = ContentExample(
1181+
'link preview without title',
1182+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
1183+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html</a></p>\n'
1184+
'<div class="message_embed">'
1185+
'<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>'
1186+
'<div class="data-container">'
1187+
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
1188+
ParagraphNode(links: [], nodes: [
1189+
LinkNode(
1190+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html')],
1191+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html'),
1192+
]),
1193+
LinkPreviewNode(
1194+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
1195+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1196+
title: null,
1197+
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
1198+
]);
1199+
1200+
static const linkPreviewWithoutDescription = ContentExample(
1201+
'link preview without description',
1202+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
1203+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html</a></p>\n'
1204+
'<div class="message_embed">'
1205+
'<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>'
1206+
'<div class="data-container">'
1207+
'<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>', [
1208+
ParagraphNode(links: [], nodes: [
1209+
LinkNode(
1210+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html')],
1211+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html'),
1212+
]),
1213+
LinkPreviewNode(
1214+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
1215+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1216+
title: 'Zulip — organized team chat',
1217+
description: null),
1218+
]);
11581219
}
11591220

11601221
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -1504,6 +1565,10 @@ void main() {
15041565
testParseExample(ContentExample.tableWithDifferentTextAlignmentInColumns);
15051566
testParseExample(ContentExample.tableWithLinkCenterAligned);
15061567

1568+
testParseExample(ContentExample.linkPreviewSmoke);
1569+
testParseExample(ContentExample.linkPreviewWithoutTitle);
1570+
testParseExample(ContentExample.linkPreviewWithoutDescription);
1571+
15071572
testParse('parse nested lists, quotes, headings, code blocks',
15081573
// "1. > ###### two\n > * three\n\n four"
15091574
'<ol>\n<li>\n<blockquote>\n<h6>two</h6>\n<ul>\n<li>three</li>\n'

0 commit comments

Comments
 (0)