Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

content: Handle link previews #1049

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 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 @@ -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;
Expand Down Expand Up @@ -1545,6 +1671,10 @@ class _ZulipContentParser {
}
}

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

// TODO more types of node
return UnimplementedBlockContentNode(htmlNode: node);
}
Expand Down
91 changes: 90 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 @@ -364,10 +365,10 @@ class BlockContentList extends StatelessWidget {
);
return const SizedBox.shrink();
}(),
LinkPreviewNode() => MessageLinkPreview(node: node),
UnimplementedBlockContentNode() =>
Text.rich(_errorUnimplemented(node, context: context)),
};

}),
]);
}
Expand Down Expand Up @@ -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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it'll be wrong for DMs, and (in future) for messages where we highlight the background because of @-mentions in the message (#647).

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.
//
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 @@ -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) {
Expand Down Expand Up @@ -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'
Expand Down
Loading