Skip to content

Commit 533fa33

Browse files
content: Handle message_embed website previews
Fixes: #1016
1 parent cf19d68 commit 533fa33

File tree

4 files changed

+337
-1
lines changed

4 files changed

+337
-1
lines changed

lib/model/content.dart

+130
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
@@ -1448,6 +1499,81 @@ class _ZulipContentParser {
14481499
return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement);
14491500
}
14501501

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

1674+
if (localName == 'div' && className == 'message_embed') {
1675+
return parseLinkPreviewNode(element);
1676+
}
1677+
15481678
// TODO more types of node
15491679
return UnimplementedBlockContentNode(htmlNode: node);
15501680
}

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';
@@ -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!,
871+
style: const TextStyle(height: 1.4))),
872+
]);
873+
874+
final clippedTitleAndDescription = Container(
875+
constraints: const BoxConstraints(maxHeight: 80),
876+
padding: const EdgeInsets.symmetric(horizontal: 5),
877+
child: InsetShadowBox(
878+
bottom: 8,
879+
// TODO(#647) use different color for highlighted messages
880+
// TODO(#681) use different color for DM messages
881+
color: messageListTheme.streamMessageBgDefault,
882+
child: UnconstrainedBox(
883+
alignment: Alignment.topLeft,
884+
constrainedAxis: Axis.horizontal,
885+
clipBehavior: Clip.antiAlias,
886+
child: Padding(
887+
padding: const EdgeInsets.only(bottom: 8.0),
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

+65
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)