Skip to content

Commit 5b7b364

Browse files
content: Handle message_embed website previews
Fixes: zulip#1016
1 parent cf19d68 commit 5b7b364

File tree

4 files changed

+335
-1
lines changed

4 files changed

+335
-1
lines changed

lib/model/content.dart

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,55 @@ class TableCellNode extends BlockInlineContainerNode {
581581
}
582582
}
583583

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

1500+
static final _linkPreviewImageSrcRegexp = RegExp(r'background-image: url\("(.+)"\)');
1501+
1502+
BlockContentNode parseLinkPreviewNode(dom.Element divElement) {
1503+
assert(_debugParserContext == _ParserContext.block);
1504+
assert(divElement.localName == 'div'
1505+
&& divElement.className == 'message_embed');
1506+
1507+
final result = () {
1508+
if (divElement.nodes.length != 2) return null;
1509+
1510+
final first = divElement.nodes.first;
1511+
if (first is! dom.Element) return null;
1512+
if (first.localName != 'a') return null;
1513+
if (first.className != 'message_embed_image') return null;
1514+
if (first.nodes.isNotEmpty) return null;
1515+
1516+
final imageHref = first.attributes['href'];
1517+
if (imageHref == null) return null;
1518+
1519+
final styleAttr = first.attributes['style'];
1520+
if (styleAttr == null) return null;
1521+
final match = _linkPreviewImageSrcRegexp.firstMatch(styleAttr);
1522+
if (match == null) return null;
1523+
final imageSrcUrl = match.group(1);
1524+
if (imageSrcUrl == null) return null;
1525+
1526+
final second = divElement.nodes.last;
1527+
if (second is! dom.Element) return null;
1528+
if (second.localName != 'div') return null;
1529+
if (second.className != 'data-container') return null;
1530+
if (second.nodes.isEmpty) return null;
1531+
if (second.nodes.length > 2) return null;
1532+
1533+
String? title, description;
1534+
for (final node in second.nodes) {
1535+
if (node is! dom.Element) return null;
1536+
if (node.localName != 'div') return null;
1537+
1538+
switch (node.className) {
1539+
case 'message_embed_title':
1540+
if (node.nodes.length != 1) return null;
1541+
final child = node.nodes.single;
1542+
if (child is! dom.Element) return null;
1543+
if (child.localName != 'a') return null;
1544+
if (child.className.isNotEmpty) return null;
1545+
if (child.nodes.length != 1) return null;
1546+
1547+
final titleHref = child.attributes['href'];
1548+
// Make sure both image hyperlink and title hyperlink are same.
1549+
if (imageHref != titleHref) return null;
1550+
final grandchild = child.nodes.single;
1551+
if (grandchild is! dom.Text) return null;
1552+
title = grandchild.text;
1553+
1554+
case 'message_embed_description':
1555+
if (node.nodes.length != 1) return null;
1556+
final child = node.nodes.single;
1557+
if (child is! dom.Text) return null;
1558+
description = child.text;
1559+
1560+
default:
1561+
return null;
1562+
}
1563+
}
1564+
1565+
return LinkPreviewNode(
1566+
hrefUrl: imageHref,
1567+
imageSrcUrl: imageSrcUrl,
1568+
title: title,
1569+
description: description);
1570+
}();
1571+
1572+
return result ?? UnimplementedBlockContentNode(htmlNode: divElement);
1573+
}
1574+
14511575
BlockContentNode parseBlockContent(dom.Node node) {
14521576
assert(_debugParserContext == _ParserContext.block);
14531577
final debugHtmlNode = kDebugMode ? node : null;
@@ -1545,6 +1669,10 @@ class _ZulipContentParser {
15451669
}
15461670
}
15471671

1672+
if (localName == 'div' && className == 'message_embed') {
1673+
return parseLinkPreviewNode(element);
1674+
}
1675+
15481676
// TODO more types of node
15491677
return UnimplementedBlockContentNode(htmlNode: node);
15501678
}

lib/widgets/content.dart

Lines changed: 90 additions & 1 deletion
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';
@@ -364,10 +365,10 @@ class BlockContentList extends StatelessWidget {
364365
);
365366
return const SizedBox.shrink();
366367
}(),
368+
LinkPreviewNode() => MessageLinkPreview(node: node),
367369
UnimplementedBlockContentNode() =>
368370
Text.rich(_errorUnimplemented(node, context: context)),
369371
};
370-
371372
}),
372373
]);
373374
}
@@ -839,6 +840,94 @@ class MathBlock extends StatelessWidget {
839840
}
840841
}
841842

843+
class MessageLinkPreview extends StatelessWidget {
844+
const MessageLinkPreview({super.key, required this.node});
845+
846+
final LinkPreviewNode node;
847+
848+
@override
849+
Widget build(BuildContext context) {
850+
final messageListTheme = MessageListTheme.of(context);
851+
final isSmallWidth = MediaQuery.sizeOf(context).width <= 576;
852+
853+
final titleAndDescription = Column(
854+
crossAxisAlignment: CrossAxisAlignment.start,
855+
mainAxisSize: MainAxisSize.min,
856+
children: [
857+
if (node.title != null)
858+
GestureDetector(
859+
onTap: () => _launchUrl(context, node.hrefUrl),
860+
child: Text(node.title!,
861+
style: TextStyle(
862+
fontSize: 1.2 * kBaseFontSize,
863+
height: 1.0,
864+
// Web has the same color in light and dark mode.
865+
color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()))),
866+
if (node.description != null)
867+
Container(
868+
padding: const EdgeInsets.only(top: 3),
869+
constraints: const BoxConstraints(maxWidth: 500),
870+
child: Text(node.description!, style: const TextStyle(height: 1.4))),
871+
]);
872+
873+
final clippedTitleAndDescription = Container(
874+
constraints: const BoxConstraints(maxHeight: 80),
875+
padding: const EdgeInsets.symmetric(horizontal: 5),
876+
child: InsetShadowBox(
877+
bottom: 8,
878+
// TODO(#647) use different color for highlighted messages
879+
// TODO(#681) use different color for DM messages
880+
color: messageListTheme.streamMessageBgDefault,
881+
child: Padding(
882+
padding: const EdgeInsets.only(bottom: 8),
883+
// Let the content overflow vertically.
884+
child: UnconstrainedBox(
885+
alignment: Alignment.topLeft,
886+
constrainedAxis: Axis.horizontal,
887+
clipBehavior: Clip.antiAlias,
888+
child: isSmallWidth
889+
? titleAndDescription
890+
: LayoutBuilder(
891+
builder: (context, constraints) => ConstrainedBox(
892+
constraints: BoxConstraints(
893+
maxWidth: constraints.maxWidth - 115),
894+
child: titleAndDescription))))));
895+
896+
final result = isSmallWidth
897+
? Column(
898+
crossAxisAlignment: CrossAxisAlignment.start,
899+
spacing: 5,
900+
children: [
901+
GestureDetector(
902+
onTap: () => _launchUrl(context, node.hrefUrl),
903+
child: RealmContentNetworkImage(
904+
Uri.parse(node.imageSrcUrl),
905+
fit: BoxFit.cover,
906+
width: double.infinity,
907+
height: 100)),
908+
clippedTitleAndDescription,
909+
])
910+
: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
911+
GestureDetector(
912+
onTap: () => _launchUrl(context, node.hrefUrl),
913+
child: RealmContentNetworkImage(Uri.parse(node.imageSrcUrl),
914+
fit: BoxFit.cover,
915+
width: 80,
916+
height: 80,
917+
alignment: Alignment.center)),
918+
Flexible(child: clippedTitleAndDescription),
919+
]);
920+
921+
return Container(
922+
decoration: const BoxDecoration(
923+
border: Border(left: BorderSide(
924+
// Web has the same color in light and dark mode.
925+
color: Color(0xffededed), width: 3))),
926+
padding: const EdgeInsets.all(5),
927+
child: result);
928+
}
929+
}
930+
842931
//
843932
// Inline layout.
844933
//

test/model/content_test.dart

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,6 +1134,67 @@ class ContentExample {
11341134
], isHeader: false),
11351135
]),
11361136
]);
1137+
1138+
static const linkPreviewSmoke = ContentExample(
1139+
'link preview smoke',
1140+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
1141+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html</a></p>\n'
1142+
'<div class="message_embed">'
1143+
'<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>'
1144+
'<div class="data-container">'
1145+
'<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>'
1146+
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
1147+
ParagraphNode(links: [], nodes: [
1148+
LinkNode(
1149+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html')],
1150+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html'),
1151+
]),
1152+
LinkPreviewNode(
1153+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
1154+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1155+
title: 'Zulip — organized team chat',
1156+
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
1157+
]);
1158+
1159+
static const linkPreviewWithoutTitle = ContentExample(
1160+
'link preview without title',
1161+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
1162+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html</a></p>\n'
1163+
'<div class="message_embed">'
1164+
'<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>'
1165+
'<div class="data-container">'
1166+
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
1167+
ParagraphNode(links: [], nodes: [
1168+
LinkNode(
1169+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html')],
1170+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html'),
1171+
]),
1172+
LinkPreviewNode(
1173+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
1174+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1175+
title: null,
1176+
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
1177+
]);
1178+
1179+
static const linkPreviewWithoutDescription = ContentExample(
1180+
'link preview without description',
1181+
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
1182+
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html</a></p>\n'
1183+
'<div class="message_embed">'
1184+
'<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>'
1185+
'<div class="data-container">'
1186+
'<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>', [
1187+
ParagraphNode(links: [], nodes: [
1188+
LinkNode(
1189+
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html')],
1190+
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html'),
1191+
]),
1192+
LinkPreviewNode(
1193+
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
1194+
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
1195+
title: 'Zulip — organized team chat',
1196+
description: null),
1197+
]);
11371198
}
11381199

11391200
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -1479,6 +1540,10 @@ void main() {
14791540
testParseExample(ContentExample.tableWithDifferentTextAlignmentInColumns);
14801541
testParseExample(ContentExample.tableWithLinkCenterAligned);
14811542

1543+
testParseExample(ContentExample.linkPreviewSmoke);
1544+
testParseExample(ContentExample.linkPreviewWithoutTitle);
1545+
testParseExample(ContentExample.linkPreviewWithoutDescription);
1546+
14821547
testParse('parse nested lists, quotes, headings, code blocks',
14831548
// "1. > ###### two\n > * three\n\n four"
14841549
'<ol>\n<li>\n<blockquote>\n<h6>two</h6>\n<ul>\n<li>three</li>\n'

0 commit comments

Comments
 (0)