Skip to content

Commit a40d008

Browse files
content: Handle message_embed website previews
Implements support for displaying website previews messages, follows the Web styling, like having different layout for larger viewports (> 576), and any other constraints that are empirically present on Web. Fixes: #1016
1 parent 4a290fd commit a40d008

File tree

4 files changed

+411
-0
lines changed

4 files changed

+411
-0
lines changed

lib/model/content.dart

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,58 @@ class EmbedVideoNode extends BlockContentNode {
504504
}
505505
}
506506

507+
// See:
508+
// https://ogp.me/
509+
// https://oembed.com/
510+
// https://zulip.com/help/image-video-and-website-previews#configure-whether-website-previews-are-shown
511+
class WebsitePreviewNode extends BlockContentNode {
512+
const WebsitePreviewNode({
513+
super.debugHtmlNode,
514+
required this.hrefUrl,
515+
required this.imageSrcUrl,
516+
required this.title,
517+
required this.description,
518+
});
519+
520+
/// The URL from which this preview data was retrieved.
521+
final String hrefUrl;
522+
523+
/// The image URL representing the webpage, content value
524+
/// of `og:image` HTML meta property.
525+
final String imageSrcUrl;
526+
527+
/// Represents the webpage title, derived from either
528+
/// the content of the `og:title` HTML meta property or
529+
/// the <title> HTML element.
530+
final String? title;
531+
532+
/// Description about the webpage, content value of
533+
/// `og:description` HTML meta property.
534+
final String? description;
535+
536+
@override
537+
bool operator ==(Object other) {
538+
return other is WebsitePreviewNode
539+
&& other.hrefUrl == hrefUrl
540+
&& other.imageSrcUrl == imageSrcUrl
541+
&& other.title == title
542+
&& other.description == description;
543+
}
544+
545+
@override
546+
int get hashCode =>
547+
Object.hash('WebsitePreviewNode', hrefUrl, imageSrcUrl, title, description);
548+
549+
@override
550+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
551+
super.debugFillProperties(properties);
552+
properties.add(StringProperty('hrefUrl', hrefUrl));
553+
properties.add(StringProperty('imageSrcUrl', imageSrcUrl));
554+
properties.add(StringProperty('title', title));
555+
properties.add(StringProperty('description', description));
556+
}
557+
}
558+
507559
class TableNode extends BlockContentNode {
508560
const TableNode({super.debugHtmlNode, required this.rows});
509561

@@ -1339,6 +1391,113 @@ class _ZulipContentParser {
13391391
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
13401392
}
13411393

1394+
static final _websitePreviewImageSrcRegexp = RegExp(r'background-image: url\("(.+)"\)');
1395+
1396+
BlockContentNode parseWebsitePreviewNode(dom.Element divElement) {
1397+
assert(divElement.localName == 'div'
1398+
&& divElement.className == 'message_embed');
1399+
1400+
final debugHtmlNode = kDebugMode ? divElement : null;
1401+
final result = () {
1402+
if (divElement.nodes case [
1403+
dom.Element(
1404+
localName: 'a',
1405+
className: 'message_embed_image',
1406+
attributes: {
1407+
'href': final String imageHref,
1408+
'style': final String imageStyleAttr,
1409+
},
1410+
nodes: []),
1411+
dom.Element(
1412+
localName: 'div',
1413+
className: 'data-container',
1414+
nodes: [...]) && final dataContainer,
1415+
]) {
1416+
final match = _websitePreviewImageSrcRegexp.firstMatch(imageStyleAttr);
1417+
if (match == null) return null;
1418+
final imageSrcUrl = match.group(1);
1419+
if (imageSrcUrl == null) return null;
1420+
1421+
String? parseTitle(dom.Element element) {
1422+
assert(element.localName == 'div' &&
1423+
element.className == 'message_embed_title');
1424+
if (element.nodes case [
1425+
dom.Element(localName: 'a', className: '') && final child,
1426+
]) {
1427+
final titleHref = child.attributes['href'];
1428+
// Make sure both image hyperlink and title hyperlink are same.
1429+
if (imageHref != titleHref) return null;
1430+
1431+
if (child.nodes case [dom.Text(text: final title)]) {
1432+
return title;
1433+
}
1434+
}
1435+
return null;
1436+
}
1437+
1438+
String? parseDescription(dom.Element element) {
1439+
assert(element.localName == 'div' &&
1440+
element.className == 'message_embed_description');
1441+
if (element.nodes case [dom.Text(text: final description)]) {
1442+
return description;
1443+
}
1444+
return null;
1445+
}
1446+
1447+
String? title, description;
1448+
switch (dataContainer.nodes) {
1449+
case [
1450+
dom.Element(
1451+
localName: 'div',
1452+
className: 'message_embed_title') && final first,
1453+
dom.Element(
1454+
localName: 'div',
1455+
className: 'message_embed_description') && final second,
1456+
]:
1457+
title = parseTitle(first);
1458+
if (title == null) return null;
1459+
description = parseDescription(second);
1460+
if (description == null) return null;
1461+
1462+
case [dom.Element(localName: 'div') && final single]:
1463+
switch (single.className) {
1464+
case 'message_embed_title':
1465+
title = parseTitle(single);
1466+
if (title == null) return null;
1467+
1468+
case 'message_embed_description':
1469+
description = parseDescription(single);
1470+
if (description == null) return null;
1471+
1472+
default:
1473+
return null;
1474+
}
1475+
1476+
case []:
1477+
// Server generates an empty `<div class="data-container"></div>`
1478+
// if website HTML has neither title (derived from
1479+
// `og:title` or `<title>…</title>`) nor description (derived from
1480+
// `og:description`).
1481+
break;
1482+
1483+
default:
1484+
return null;
1485+
}
1486+
1487+
return WebsitePreviewNode(
1488+
hrefUrl: imageHref,
1489+
imageSrcUrl: imageSrcUrl,
1490+
title: title,
1491+
description: description,
1492+
debugHtmlNode: debugHtmlNode);
1493+
} else {
1494+
return null;
1495+
}
1496+
}();
1497+
1498+
return result ?? UnimplementedBlockContentNode(htmlNode: divElement);
1499+
}
1500+
13421501
BlockContentNode parseTableContent(dom.Element tableElement) {
13431502
assert(tableElement.localName == 'table'
13441503
&& tableElement.className.isEmpty);
@@ -1583,6 +1742,10 @@ class _ZulipContentParser {
15831742
}
15841743
}
15851744

1745+
if (localName == 'div' && className == 'message_embed') {
1746+
return parseWebsitePreviewNode(element);
1747+
}
1748+
15861749
// TODO more types of node
15871750
return UnimplementedBlockContentNode(htmlNode: node);
15881751
}

lib/widgets/content.dart

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart';
44
import 'package:flutter/foundation.dart';
55
import 'package:flutter/gestures.dart';
66
import 'package:flutter/material.dart';
7+
import 'package:flutter/rendering.dart';
78
import 'package:flutter/services.dart';
89
import 'package:html/dom.dart' as dom;
910
import 'package:intl/intl.dart';
@@ -18,6 +19,7 @@ import '../model/internal_link.dart';
1819
import 'code_block.dart';
1920
import 'dialog.dart';
2021
import 'icons.dart';
22+
import 'inset_shadow.dart';
2123
import 'lightbox.dart';
2224
import 'message_list.dart';
2325
import 'poll.dart';
@@ -371,6 +373,7 @@ class BlockContentList extends StatelessWidget {
371373
);
372374
return const SizedBox.shrink();
373375
}(),
376+
WebsitePreviewNode() => WebsitePreview(node: node),
374377
UnimplementedBlockContentNode() =>
375378
Text.rich(_errorUnimplemented(node, context: context)),
376379
};
@@ -846,6 +849,103 @@ class MathBlock extends StatelessWidget {
846849
}
847850
}
848851

852+
class WebsitePreview extends StatelessWidget {
853+
const WebsitePreview({super.key, required this.node});
854+
855+
final WebsitePreviewNode node;
856+
857+
@override
858+
Widget build(BuildContext context) {
859+
final store = PerAccountStoreWidget.of(context);
860+
final resolvedImageSrcUrl = store.tryResolveUrl(node.imageSrcUrl);
861+
final isSmallWidth = MediaQuery.sizeOf(context).width <= 576;
862+
863+
// On Web on larger width viewports, the title and description container's
864+
// width is constrained using `max-width: calc(100% - 115px)`, we do not
865+
// follow the same here for potential benefits listed here:
866+
// https://github.com/zulip/zulip-flutter/pull/1049#discussion_r1915740997
867+
final titleAndDescription = Column(
868+
crossAxisAlignment: CrossAxisAlignment.start,
869+
children: [
870+
if (node.title != null)
871+
GestureDetector(
872+
onTap: () => _launchUrl(context, node.hrefUrl),
873+
child: Text(node.title!,
874+
style: TextStyle(
875+
fontSize: 1.2 * kBaseFontSize,
876+
// Web uses `line-height: normal` for title. MDN docs for it:
877+
// https://developer.mozilla.org/en-US/docs/Web/CSS/line-height#normal
878+
// says actual value depends on user-agent, and default value
879+
// can be roughly 1.2 (unitless). So, use the same here.
880+
height: 1.2,
881+
color: ContentTheme.of(context).colorLink))),
882+
if (node.description != null)
883+
Container(
884+
padding: const EdgeInsets.only(top: 3),
885+
constraints: const BoxConstraints(maxWidth: 500),
886+
child: Text(node.description!)),
887+
]);
888+
889+
final clippedTitleAndDescription = Padding(
890+
padding: const EdgeInsets.symmetric(horizontal: 5),
891+
child: InsetShadowBox(
892+
bottom: 8,
893+
// TODO(#488) use different color for non-message contexts
894+
// TODO(#647) use different color for highlighted messages
895+
// TODO(#681) use different color for DM messages
896+
color: MessageListTheme.of(context).streamMessageBgDefault,
897+
child: ClipRect(
898+
child: ConstrainedBox(
899+
constraints: BoxConstraints(maxHeight: 80),
900+
child: OverflowBox(
901+
maxHeight: double.infinity,
902+
alignment: AlignmentDirectional.topStart,
903+
fit: OverflowBoxFit.deferToChild,
904+
child: Padding(
905+
padding: const EdgeInsets.only(bottom: 8),
906+
child: titleAndDescription))))));
907+
908+
final image = resolvedImageSrcUrl == null ? null
909+
: GestureDetector(
910+
onTap: () => _launchUrl(context, node.hrefUrl),
911+
child: RealmContentNetworkImage(
912+
resolvedImageSrcUrl,
913+
fit: BoxFit.cover));
914+
915+
final result = isSmallWidth
916+
? Column(
917+
crossAxisAlignment: CrossAxisAlignment.start,
918+
spacing: 15,
919+
children: [
920+
if (image != null)
921+
SizedBox(height: 110, width: double.infinity, child: image),
922+
clippedTitleAndDescription,
923+
])
924+
: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
925+
if (image != null)
926+
SizedBox.square(dimension: 80, child: image),
927+
Flexible(child: clippedTitleAndDescription),
928+
]);
929+
930+
return Padding(
931+
// TODO(?) Web has a bottom margin `--markdown-interelement-space-px`
932+
// around the `message_embed` container, which is calculated here:
933+
// https://github.com/zulip/zulip/blob/d28f7d86223bab4f11629637d4237381943f6fc1/web/src/information_density.ts#L80-L102
934+
// But for now we use a static value of 6.72px instead which is the
935+
// default in the web client, see discussion:
936+
// https://github.com/zulip/zulip-flutter/pull/1049#discussion_r1915747908
937+
padding: const EdgeInsets.only(bottom: 6.72),
938+
child: Container(
939+
height: !isSmallWidth ? 90 : null,
940+
decoration: const BoxDecoration(
941+
border: BorderDirectional(start: BorderSide(
942+
// Web has the same color in light and dark mode.
943+
color: Color(0xffededed), width: 3))),
944+
padding: const EdgeInsets.all(5),
945+
child: result));
946+
}
947+
}
948+
849949
//
850950
// Inline layout.
851951
//

0 commit comments

Comments
 (0)