Skip to content

Commit

Permalink
content: Handle message_embed website previews
Browse files Browse the repository at this point in the history
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
  • Loading branch information
rajveermalviya committed Jan 21, 2025
1 parent e1df51b commit 019edde
Show file tree
Hide file tree
Showing 4 changed files with 356 additions and 1 deletion.
150 changes: 150 additions & 0 deletions lib/model/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1453,6 +1504,101 @@ class _ZulipContentParser {
return tableNode ?? UnimplementedBlockContentNode(htmlNode: tableElement);
}

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

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

final result = () {
if (divElement.nodes case [
dom.Element(
localName: 'a',
className: 'message_embed_image',
attributes: {
'href': final String imageHref,
'style': final String imageStyleAttr,
},
nodes: []),
dom.Element(
localName: 'div',
className: 'data-container',
nodes: [...]) && final dataContainer,
]) {
final match = _linkPreviewImageSrcRegexp.firstMatch(imageStyleAttr);
if (match == null) return null;
final imageSrcUrl = match.group(1);
if (imageSrcUrl == null) return null;

String? parseTitle(dom.Element element) {
assert(element.localName == 'div' &&
element.className == 'message_embed_title');
if (element.nodes case [
dom.Element(localName: 'a', className: '') && final child,
]) {
final titleHref = child.attributes['href'];
// Make sure both image hyperlink and title hyperlink are same.
if (imageHref != titleHref) return null;

if (child.nodes case [dom.Text(text: final title)]) {
return title;
}
}
return null;
}

String? parseDescription(dom.Element element) {
assert(element.localName == 'div' &&
element.className == 'message_embed_description');
if (element.nodes case [dom.Text(text: final description)]) {
return description;
}
return null;
}

String? title, description;
switch (dataContainer.nodes) {
case [
dom.Element(
localName: 'div',
className: 'message_embed_title') && final first,
dom.Element(
localName: 'div',
className: 'message_embed_description') && final second,
]:
title = parseTitle(first);
if (title == null) return null;
description = parseDescription(second);
if (description == null) return null;

case [dom.Element(localName: 'div') && final single]:
switch (single.className) {
case 'message_embed_title':
title = parseTitle(single);
if (title == null) return null;
case 'message_embed_description':
description = parseDescription(single);
if (description == null) return null;
}

default:
return null;
}

return LinkPreviewNode(
hrefUrl: imageHref,
imageSrcUrl: imageSrcUrl,
title: title,
description: description,
);
} else {
return null;
}
}();

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

BlockContentNode parseBlockContent(dom.Node node) {
final debugHtmlNode = kDebugMode ? node : null;
if (node is! dom.Element) {
Expand Down Expand Up @@ -1547,6 +1693,10 @@ class _ZulipContentParser {
}
}

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

// TODO more types of node
return UnimplementedBlockContentNode(htmlNode: node);
}
Expand Down
90 changes: 89 additions & 1 deletion 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 @@ -371,10 +372,10 @@ class BlockContentList extends StatelessWidget {
);
return const SizedBox.shrink();
}(),
LinkPreviewNode() => LinkPreview(node: node),
UnimplementedBlockContentNode() =>
Text.rich(_errorUnimplemented(node, context: context)),
};

}),
]);
}
Expand Down Expand Up @@ -846,6 +847,93 @@ class MathBlock extends StatelessWidget {
}
}

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

final LinkPreviewNode node;

@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
final resolvedImageSrcUrl = store.tryResolveUrl(node.imageSrcUrl);
final isSmallWidth = MediaQuery.sizeOf(context).width <= 576;

// On Web on larger width viewports, the title and description container's
// width is constrained using `max-width: calc(100% - 115px)`, we do not
// follow the same here for potential benefits listed here:
// https://github.com/zulip/zulip-flutter/pull/1049#discussion_r1915740997
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,
color: ContentTheme.of(context).colorLink))),
if (node.description != null)
Container(
padding: const EdgeInsets.only(top: 3),
constraints: const BoxConstraints(maxWidth: 500),
child: Text(node.description!)),
]);

final clippedTitleAndDescription = Container(
constraints: const BoxConstraints(maxHeight: 80),
padding: const EdgeInsets.symmetric(horizontal: 5),
child: InsetShadowBox(
bottom: 8,
// TODO(#488) use different color for non-message contexts
// TODO(#647) use different color for highlighted messages
// TODO(#681) use different color for DM messages
color: MessageListTheme.of(context).streamMessageBgDefault,
child: UnconstrainedBox(
alignment: AlignmentDirectional.topStart,
constrainedAxis: Axis.horizontal,
clipBehavior: Clip.hardEdge,
child: Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: titleAndDescription))));

final result = isSmallWidth
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 15,
children: [
if (resolvedImageSrcUrl != null)
GestureDetector(
onTap: () => _launchUrl(context, node.hrefUrl),
child: RealmContentNetworkImage(
resolvedImageSrcUrl,
fit: BoxFit.cover,
width: double.infinity,
height: 100)),
clippedTitleAndDescription,
])
: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (resolvedImageSrcUrl != null)
GestureDetector(
onTap: () => _launchUrl(context, node.hrefUrl),
child: RealmContentNetworkImage(
resolvedImageSrcUrl,
fit: BoxFit.cover,
width: 80,
height: 80)),
Flexible(child: clippedTitleAndDescription),
]);

return Container(
decoration: const BoxDecoration(
border: BorderDirectional(start: 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.
//
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 @@ -1155,6 +1155,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) {
Expand Down Expand Up @@ -1504,6 +1565,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'
Expand Down
Loading

0 comments on commit 019edde

Please sign in to comment.