Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
content: Handle message_embed website previews
Browse files Browse the repository at this point in the history
Fixes: #1016
rajveermalviya committed Dec 12, 2024
1 parent 28b3536 commit aeabbe6
Showing 4 changed files with 337 additions and 1 deletion.
130 changes: 130 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
@@ -581,6 +581,57 @@ class TableCellNode extends BlockInlineContainerNode {
}
}

// Ref:
// https://ogp.me/
// https://oembed.com/
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
@@ -1448,6 +1499,81 @@ class _ZulipContentParser {
return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement);
}

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.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.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.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.nodes.length != 1) return null;

final titleHref = child.attributes['href'];
// Make sure both image hyperlink and title hyperlink are same.
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 LinkPreviewNode(
hrefUrl: imageHref,
imageSrcUrl: imageSrcUrl,
title: title,
description: description);
}();

return result ?? UnimplementedBlockContentNode(htmlNode: divElement);
}

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

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

// TODO more types of node
return UnimplementedBlockContentNode(htmlNode: node);
}
91 changes: 90 additions & 1 deletion lib/widgets/content.dart
Original file line number Diff line number Diff line change
@@ -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';
@@ -364,10 +365,10 @@ class BlockContentList extends StatelessWidget {
);
return const SizedBox.shrink();
}(),
LinkPreviewNode() => MessageLinkPreview(node: node),
UnimplementedBlockContentNode() =>
Text.rich(_errorUnimplemented(node, context: context)),
};

}),
]);
}
@@ -839,6 +840,94 @@ 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 titleAndDescription = Column(
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,
height: 1.0,
// Web has the same color in light and dark mode.
color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()))),
if (node.description != null)
Container(
padding: const EdgeInsets.only(top: 3),
constraints: const BoxConstraints(maxWidth: 500),
child: Text(node.description!,
style: const TextStyle(height: 1.4))),
]);

final clippedTitleAndDescription = Container(
constraints: const BoxConstraints(maxHeight: 80),
padding: const EdgeInsets.symmetric(horizontal: 5),
child: InsetShadowBox(
bottom: 8,
// TODO(#647) use different color for highlighted messages
// TODO(#681) use different color for DM messages
color: messageListTheme.streamMessageBgDefault,
child: UnconstrainedBox(
alignment: Alignment.topLeft,
constrainedAxis: Axis.horizontal,
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: isSmallWidth
? titleAndDescription
: LayoutBuilder(
builder: (context, constraints) => ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth - 115),
child: titleAndDescription))))));

final result = isSmallWidth
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 5,
children: [
GestureDetector(
onTap: () => _launchUrl(context, node.hrefUrl),
child: RealmContentNetworkImage(
Uri.parse(node.imageSrcUrl),
fit: BoxFit.cover,
width: double.infinity,
height: 100)),
clippedTitleAndDescription,
])
: Row(crossAxisAlignment: CrossAxisAlignment.start, 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: clippedTitleAndDescription),
]);

return Container(
decoration: const BoxDecoration(
border: Border(left: BorderSide(
// Web has the same color in light and dark mode.
color: Color(0xffededed), width: 3))),
padding: const EdgeInsets.all(5),
child: result);
}
}

//
// Inline layout.
//
65 changes: 65 additions & 0 deletions test/model/content_test.dart
Original file line number Diff line number Diff line change
@@ -1134,6 +1134,67 @@ class ContentExample {
], isHeader: false),
]),
]);

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) {
@@ -1479,6 +1540,10 @@ void main() {
testParseExample(ContentExample.tableWithDifferentTextAlignmentInColumns);
testParseExample(ContentExample.tableWithLinkCenterAligned);

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'
52 changes: 52 additions & 0 deletions test/widgets/content_test.dart
Original file line number Diff line number Diff line change
@@ -1002,6 +1002,58 @@ void main() {
});
});

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

testWidgets('smoke', (tester) async {
final url = Uri.parse(ContentExample.linkPreviewSmoke.markdown!);
await prepare(tester, ContentExample.linkPreviewSmoke.html);
tester.widget(find.byType(MessageLinkPreview));

await tester.tap(find.text(ContentExample.linkPreviewSmoke.markdown!));
check(testBinding.takeLaunchUrlCalls())
.single.equals((url: url, mode: LaunchMode.platformDefault));

await tester.tap(find.byType(RealmContentNetworkImage));
check(testBinding.takeLaunchUrlCalls())
.single.equals((url: url, mode: LaunchMode.platformDefault));
debugNetworkImageHttpClientProvider = null;
});

testWidgets('smoke: without title', (tester) async {
final url = Uri.parse(ContentExample.linkPreviewWithoutTitle.markdown!);
await prepare(tester, ContentExample.linkPreviewWithoutTitle.html);
tester.widget(find.byType(MessageLinkPreview));

await tester.tap(find.text(ContentExample.linkPreviewWithoutTitle.markdown!));
check(testBinding.takeLaunchUrlCalls())
.single.equals((url: url, mode: LaunchMode.platformDefault));

await tester.tap(find.byType(RealmContentNetworkImage));
check(testBinding.takeLaunchUrlCalls())
.single.equals((url: url, mode: LaunchMode.platformDefault));
debugNetworkImageHttpClientProvider = null;
});

testWidgets('smoke: without description', (tester) async {
final url = Uri.parse(ContentExample.linkPreviewWithoutDescription.markdown!);
await prepare(tester, ContentExample.linkPreviewWithoutDescription.html);
tester.widget(find.byType(MessageLinkPreview));

await tester.tap(find.text(ContentExample.linkPreviewWithoutDescription.markdown!));
check(testBinding.takeLaunchUrlCalls())
.single.equals((url: url, mode: LaunchMode.platformDefault));

await tester.tap(find.byType(RealmContentNetworkImage));
check(testBinding.takeLaunchUrlCalls())
.single.equals((url: url, mode: LaunchMode.platformDefault));
debugNetworkImageHttpClientProvider = null;
});
});

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

0 comments on commit aeabbe6

Please sign in to comment.