@@ -504,6 +504,58 @@ class EmbedVideoNode extends BlockContentNode {
504
504
}
505
505
}
506
506
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
+
507
559
class TableNode extends BlockContentNode {
508
560
const TableNode ({super .debugHtmlNode, required this .rows});
509
561
@@ -1339,6 +1391,112 @@ class _ZulipContentParser {
1339
1391
return EmbedVideoNode (hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
1340
1392
}
1341
1393
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 result = () {
1401
+ if (divElement.nodes case [
1402
+ dom.Element (
1403
+ localName: 'a' ,
1404
+ className: 'message_embed_image' ,
1405
+ attributes: {
1406
+ 'href' : final String imageHref,
1407
+ 'style' : final String imageStyleAttr,
1408
+ },
1409
+ nodes: []),
1410
+ dom.Element (
1411
+ localName: 'div' ,
1412
+ className: 'data-container' ,
1413
+ nodes: [...]) && final dataContainer,
1414
+ ]) {
1415
+ final match = _websitePreviewImageSrcRegexp.firstMatch (imageStyleAttr);
1416
+ if (match == null ) return null ;
1417
+ final imageSrcUrl = match.group (1 );
1418
+ if (imageSrcUrl == null ) return null ;
1419
+
1420
+ String ? parseTitle (dom.Element element) {
1421
+ assert (element.localName == 'div' &&
1422
+ element.className == 'message_embed_title' );
1423
+ if (element.nodes case [
1424
+ dom.Element (localName: 'a' , className: '' ) && final child,
1425
+ ]) {
1426
+ final titleHref = child.attributes['href' ];
1427
+ // Make sure both image hyperlink and title hyperlink are same.
1428
+ if (imageHref != titleHref) return null ;
1429
+
1430
+ if (child.nodes case [dom.Text (text: final title)]) {
1431
+ return title;
1432
+ }
1433
+ }
1434
+ return null ;
1435
+ }
1436
+
1437
+ String ? parseDescription (dom.Element element) {
1438
+ assert (element.localName == 'div' &&
1439
+ element.className == 'message_embed_description' );
1440
+ if (element.nodes case [dom.Text (text: final description)]) {
1441
+ return description;
1442
+ }
1443
+ return null ;
1444
+ }
1445
+
1446
+ String ? title, description;
1447
+ switch (dataContainer.nodes) {
1448
+ case [
1449
+ dom.Element (
1450
+ localName: 'div' ,
1451
+ className: 'message_embed_title' ) && final first,
1452
+ dom.Element (
1453
+ localName: 'div' ,
1454
+ className: 'message_embed_description' ) && final second,
1455
+ ]:
1456
+ title = parseTitle (first);
1457
+ if (title == null ) return null ;
1458
+ description = parseDescription (second);
1459
+ if (description == null ) return null ;
1460
+
1461
+ case [dom.Element (localName: 'div' ) && final single]:
1462
+ switch (single.className) {
1463
+ case 'message_embed_title' :
1464
+ title = parseTitle (single);
1465
+ if (title == null ) return null ;
1466
+
1467
+ case 'message_embed_description' :
1468
+ description = parseDescription (single);
1469
+ if (description == null ) return null ;
1470
+
1471
+ default :
1472
+ return null ;
1473
+ }
1474
+
1475
+ case []:
1476
+ // Server generates an empty `<div class="data-container"></div>`
1477
+ // if website HTML has neither title (derived from
1478
+ // `og:title` or `<title>…</title>`) nor description (derived from
1479
+ // `og:description`).
1480
+ break ;
1481
+
1482
+ default :
1483
+ return null ;
1484
+ }
1485
+
1486
+ return WebsitePreviewNode (
1487
+ hrefUrl: imageHref,
1488
+ imageSrcUrl: imageSrcUrl,
1489
+ title: title,
1490
+ description: description,
1491
+ );
1492
+ } else {
1493
+ return null ;
1494
+ }
1495
+ }();
1496
+
1497
+ return result ?? UnimplementedBlockContentNode (htmlNode: divElement);
1498
+ }
1499
+
1342
1500
BlockContentNode parseTableContent (dom.Element tableElement) {
1343
1501
assert (tableElement.localName == 'table'
1344
1502
&& tableElement.className.isEmpty);
@@ -1583,6 +1741,10 @@ class _ZulipContentParser {
1583
1741
}
1584
1742
}
1585
1743
1744
+ if (localName == 'div' && className == 'message_embed' ) {
1745
+ return parseWebsitePreviewNode (element);
1746
+ }
1747
+
1586
1748
// TODO more types of node
1587
1749
return UnimplementedBlockContentNode (htmlNode: node);
1588
1750
}
0 commit comments