@@ -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+
507559class 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\(("?)(.+?)\1\)' );
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 (2 );
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 }
0 commit comments