Skip to content

Commit

Permalink
content: Handle message_embed website previews
Browse files Browse the repository at this point in the history
Fixes: #1016
  • Loading branch information
rajveermalviya committed Nov 18, 2024
1 parent 94cf40e commit 8fba3fe
Show file tree
Hide file tree
Showing 4 changed files with 313 additions and 0 deletions.
137 changes: 137 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,55 @@ class EmbedVideoNode extends BlockContentNode {
}
}

// Ref: https://ogp.me/
class LinkPreviewNode extends BlockContentNode {
const LinkPreviewNode({
super.debugHtmlNode,
required this.hrefUrl,
required this.imageSrcUrl,
required this.title,
required this.description,
});

/// The URL from which this preview data was retrieved.
final String hrefUrl;

/// The image URL representing the webpage, content value
/// of `og:image` HTML meta property.
final String imageSrcUrl;

/// Represents the webpage title, derived from either
/// the content of the `og:title` HTML meta property or
/// the <title> HTML element.
final String? title;

/// Description about the webpage, content value of
/// `og:description` HTML meta property.
final String? description;

@override
bool operator ==(Object other) {
return other is LinkPreviewNode
&& other.hrefUrl == hrefUrl
&& other.imageSrcUrl == imageSrcUrl
&& other.title == title
&& other.description == description;
}

@override
int get hashCode =>
Object.hash('LinkPreviewNode', hrefUrl, imageSrcUrl, title, description);

@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('hrefUrl', hrefUrl));
properties.add(StringProperty('imageSrcUrl', imageSrcUrl));
properties.add(StringProperty('title', title));
properties.add(StringProperty('description', description));
}
}

/// A content node that expects an inline layout context from its parent.
///
/// When rendered into a Flutter widget tree, an inline content node
Expand Down Expand Up @@ -1222,6 +1271,90 @@ class _ZulipContentParser {
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
}

static final _linkPreviewImageSrcRegexp = RegExp(r'background-image: url\("(.+)"\)');

BlockContentNode parseLinkPreviewNode(dom.Element divElement) {
assert(_debugParserContext == _ParserContext.block);
assert(divElement.localName == 'div'
&& divElement.className == 'message_embed');

final result = () {
if (divElement.nodes.length != 2) return null;

final first = divElement.nodes.first;
if (first is! dom.Element) return null;
if (first.localName != 'a') return null;
if (first.className != 'message_embed_image') return null;
if (first.attributes.length != 3) return null; // 'class', 'href', 'style'
if (first.nodes.isNotEmpty) return null;

final imageHref = first.attributes['href'];
if (imageHref == null) return null;

final styleAttr = first.attributes['style'];
if (styleAttr == null) return null;
final match = _linkPreviewImageSrcRegexp.firstMatch(styleAttr);
if (match == null) return null;
final imageSrcUrl = match.group(1);
if (imageSrcUrl == null) return null;

final second = divElement.nodes.last;
if (second is! dom.Element) return null;
if (second.localName != 'div') return null;
if (second.className != 'data-container') return null;
if (second.attributes.length != 1) return null; // 'class'
if (second.nodes.isEmpty) return null;
if (second.nodes.length > 2) return null;

String? title, description;
for (final node in second.nodes) {
if (node is! dom.Element) return null;
if (node.localName != 'div') return null;

switch (node.className) {
case 'message_embed_title':
if (node.attributes.length != 1) return null; // 'class'
if (node.nodes.length != 1) return null;
final child = node.nodes.single;
if (child is! dom.Element) return null;
if (child.localName != 'a') return null;
if (child.className.isNotEmpty) return null;
if (child.attributes.length != 2) return null; // 'href', 'title'
if (child.nodes.length != 1) return null;

final titleHref = child.attributes['href'];
if (imageHref != titleHref) return null;
final grandchild = child.nodes.single;
if (grandchild is! dom.Text) return null;
title = grandchild.text;

case 'message_embed_description':
if (node.nodes.length != 1) return null;
final child = node.nodes.single;
if (child is! dom.Text) return null;
description = child.text;

default:
return null;
}
}

return (imageHref, imageSrcUrl, title, description);
}();

if (result == null) {
return UnimplementedBlockContentNode(htmlNode: divElement);
}
final (hrefUrl, imageSrcUrl, title, description) = result;

return LinkPreviewNode(
hrefUrl: hrefUrl,
imageSrcUrl: imageSrcUrl,
title: title,
description: description,
);
}

BlockContentNode parseBlockContent(dom.Node node) {
assert(_debugParserContext == _ParserContext.block);
final debugHtmlNode = kDebugMode ? node : null;
Expand Down Expand Up @@ -1315,6 +1448,10 @@ class _ZulipContentParser {
}
}

if (localName == 'div' && className == 'message_embed') {
return parseLinkPreviewNode(element);
}

// TODO more types of node
return UnimplementedBlockContentNode(htmlNode: node);
}
Expand Down
86 changes: 86 additions & 0 deletions lib/widgets/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import '../model/internal_link.dart';
import 'code_block.dart';
import 'dialog.dart';
import 'icons.dart';
import 'inset_shadow.dart';
import 'lightbox.dart';
import 'message_list.dart';
import 'poll.dart';
Expand Down Expand Up @@ -324,6 +325,7 @@ class BlockContentList extends StatelessWidget {
}(),
InlineVideoNode() => MessageInlineVideo(node: node),
EmbedVideoNode() => MessageEmbedVideo(node: node),
LinkPreviewNode() => MessageLinkPreview(node: node),
UnimplementedBlockContentNode() =>
Text.rich(_errorUnimplemented(node, context: context)),
};
Expand Down Expand Up @@ -799,6 +801,90 @@ class MathBlock extends StatelessWidget {
}
}

class MessageLinkPreview extends StatelessWidget {
const MessageLinkPreview({super.key, required this.node});

final LinkPreviewNode node;

@override
Widget build(BuildContext context) {
final messageListTheme = MessageListTheme.of(context);
final isSmallWidth = MediaQuery.sizeOf(context).width <= 576;

final dataContainer = Container(
constraints: const BoxConstraints(maxHeight: 80),
padding: const EdgeInsets.symmetric(horizontal: 5),
child: InsetShadowBox(
bottom: 8,
color: messageListTheme.streamMessageBgDefault,
child: UnconstrainedBox(
alignment: Alignment.topCenter,
constrainedAxis: Axis.horizontal,
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (node.title != null)
GestureDetector(
onTap: () => _launchUrl(context, node.hrefUrl),
child: Text(node.title!,
style: TextStyle(
fontSize: 1.2 * kBaseFontSize,
color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()))),
if (node.description != null) ...[
const SizedBox(height: 3),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Text(node.description!)),
],
const SizedBox(height: 8),
]))));

if (isSmallWidth) {
return Container(
decoration: const BoxDecoration(border:
Border(left: BorderSide(color: Color(0xFFEDEDED), width: 3))),
padding: const EdgeInsets.all(5),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => _launchUrl(context, node.hrefUrl),
child: RealmContentNetworkImage(
Uri.parse(node.imageSrcUrl),
fit: BoxFit.cover,
width: double.infinity,
height: 100)),
const SizedBox(height: 5),
dataContainer,
]));
}

return Container(
decoration: const BoxDecoration(border:
Border(left: BorderSide(color: Color(0xFFEDEDED), width: 3))),
padding: const EdgeInsets.all(5),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
GestureDetector(
onTap: () => _launchUrl(context, node.hrefUrl),
child: RealmContentNetworkImage(Uri.parse(node.imageSrcUrl),
fit: BoxFit.cover,
width: 80,
height: 80,
alignment: Alignment.center)),
Flexible(child: dataContainer),
]));
}
}

//
// Inline layout.
//
Expand Down
65 changes: 65 additions & 0 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,67 @@ class ContentExample {
]),
InlineVideoNode(srcUrl: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm'),
]);

static const linkPreviewSmoke = ContentExample(
'link preview smoke',
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html</a></p>\n'
'<div class="message_embed">'
'<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>'
'<div class="data-container">'
'<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>'
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
ParagraphNode(links: [], nodes: [
LinkNode(
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html')],
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html'),
]),
LinkPreviewNode(
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title+description.html',
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
title: 'Zulip — organized team chat',
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
]);

static const linkPreviewWithoutTitle = ContentExample(
'link preview without title',
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html</a></p>\n'
'<div class="message_embed">'
'<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>'
'<div class="data-container">'
'<div class="message_embed_description">Zulip is an organized team chat app for distributed teams of all sizes.</div></div></div>', [
ParagraphNode(links: [], nodes: [
LinkNode(
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html')],
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html'),
]),
LinkPreviewNode(
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+description+notitle.html',
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
title: null,
description: 'Zulip is an organized team chat app for distributed teams of all sizes.'),
]);

static const linkPreviewWithoutDescription = ContentExample(
'link preview without description',
'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
'<p><a href="https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html">https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html</a></p>\n'
'<div class="message_embed">'
'<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>'
'<div class="data-container">'
'<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>', [
ParagraphNode(links: [], nodes: [
LinkNode(
nodes: [TextNode('https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html')],
url: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html'),
]),
LinkPreviewNode(
hrefUrl: 'https://pub-14f7b5e1308d42b69c4a46608442a50c.r2.dev/image+title.html',
imageSrcUrl: 'https://uploads.zulipusercontent.net/98fe2fe57d1ac641d4d84b6de2c520ff48fcf498/68747470733a2f2f7374617469632e7a756c6970636861742e636f6d2f7374617469632f696d616765732f6c6f676f2f7a756c69702d69636f6e2d313238783132382e706e67',
title: 'Zulip — organized team chat',
description: null),
]);
}

UnimplementedBlockContentNode blockUnimplemented(String html) {
Expand Down Expand Up @@ -1221,6 +1282,10 @@ void main() {
testParseExample(ContentExample.videoInline);
testParseExample(ContentExample.videoInlineClassesFlipped);

testParseExample(ContentExample.linkPreviewSmoke);
testParseExample(ContentExample.linkPreviewWithoutTitle);
testParseExample(ContentExample.linkPreviewWithoutDescription);

testParse('parse nested lists, quotes, headings, code blocks',
// "1. > ###### two\n > * three\n\n four"
'<ol>\n<li>\n<blockquote>\n<h6>two</h6>\n<ul>\n<li>three</li>\n'
Expand Down
25 changes: 25 additions & 0 deletions test/widgets/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,31 @@ void main() {
});
});

group('MessageLinkPreview', () {
Future<void> prepare(WidgetTester tester, String html) async {
await prepareContent(tester, plainContent(html),
wrapWithPerAccountStoreWidget: true);
}

testWidgets('smoke', (tester) async {
await prepare(tester, ContentExample.linkPreviewSmoke.html);
tester.widget(find.byType(MessageLinkPreview));
debugNetworkImageHttpClientProvider = null;
});

testWidgets('smoke: without title', (tester) async {
await prepare(tester, ContentExample.linkPreviewWithoutTitle.html);
tester.widget(find.byType(MessageLinkPreview));
debugNetworkImageHttpClientProvider = null;
});

testWidgets('smoke: without description', (tester) async {
await prepare(tester, ContentExample.linkPreviewWithoutDescription.html);
tester.widget(find.byType(MessageLinkPreview));
debugNetworkImageHttpClientProvider = null;
});
});

group('RealmContentNetworkImage', () {
final authHeaders = authHeader(email: eg.selfAccount.email, apiKey: eg.selfAccount.apiKey);

Expand Down

0 comments on commit 8fba3fe

Please sign in to comment.