Skip to content

Commit 7a914aa

Browse files
content: Handle html parsing of VideoNode
1 parent 711bc69 commit 7a914aa

File tree

3 files changed

+245
-0
lines changed

3 files changed

+245
-0
lines changed

lib/model/content.dart

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,34 @@ class ImageNode extends BlockContentNode {
361361
}
362362
}
363363

364+
class VideoNode extends BlockContentNode {
365+
const VideoNode({
366+
super.debugHtmlNode,
367+
required this.srcUrl,
368+
required this.previewImageUrl,
369+
});
370+
371+
final String srcUrl;
372+
final String? previewImageUrl;
373+
374+
@override
375+
bool operator ==(Object other) {
376+
return other is VideoNode
377+
&& other.srcUrl == srcUrl
378+
&& other.previewImageUrl == previewImageUrl;
379+
}
380+
381+
@override
382+
int get hashCode => Object.hash('VideoNode', srcUrl, previewImageUrl);
383+
384+
@override
385+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
386+
super.debugFillProperties(properties);
387+
properties.add(StringProperty('srcUrl', srcUrl));
388+
properties.add(StringProperty('previewImageUrl', previewImageUrl));
389+
}
390+
}
391+
364392
/// A content node that expects an inline layout context from its parent.
365393
///
366394
/// When rendered into a Flutter widget tree, an inline content node
@@ -948,6 +976,90 @@ class _ZulipContentParser {
948976
return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode);
949977
}
950978

979+
static final _videoClassNameRegexp = () {
980+
const sourceType = r"(message_inline_video|youtube-video|embed-video)?";
981+
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$");
982+
}();
983+
984+
BlockContentNode _parseInlineVideo(dom.Element divElement) {
985+
final videoElement = () {
986+
if (divElement.nodes.length != 1) return null;
987+
final child = divElement.nodes[0];
988+
if (child is! dom.Element) return null;
989+
if (child.localName != 'a') return null;
990+
if (child.className.isNotEmpty) return null;
991+
992+
if (child.nodes.length != 1) return null;
993+
final grandchild = child.nodes[0];
994+
if (grandchild is! dom.Element) return null;
995+
if (grandchild.localName != 'video') return null;
996+
if (grandchild.className.isNotEmpty) return null;
997+
return grandchild;
998+
}();
999+
1000+
final debugHtmlNode = kDebugMode ? divElement : null;
1001+
if (videoElement == null) {
1002+
return UnimplementedBlockContentNode(htmlNode: divElement);
1003+
}
1004+
1005+
final src = videoElement.attributes['src'];
1006+
if (src == null) {
1007+
return UnimplementedBlockContentNode(htmlNode: divElement);
1008+
}
1009+
1010+
return VideoNode(srcUrl: src, previewImageUrl: null, debugHtmlNode: debugHtmlNode);
1011+
}
1012+
1013+
BlockContentNode _parseEmbedVideoWithPreviewImage(dom.Element divElement) {
1014+
final result = () {
1015+
if (divElement.nodes.length != 1) return null;
1016+
final child = divElement.nodes[0];
1017+
if (child is! dom.Element) return null;
1018+
if (child.localName != 'a') return null;
1019+
if (child.className.isNotEmpty) return null;
1020+
1021+
if (child.nodes.length != 1) return null;
1022+
final grandchild = child.nodes[0];
1023+
if (grandchild is! dom.Element) return null;
1024+
if (grandchild.localName != 'img') return null;
1025+
if (grandchild.className.isNotEmpty) return null;
1026+
return (child, grandchild);
1027+
}();
1028+
1029+
final debugHtmlNode = kDebugMode ? divElement : null;
1030+
if (result == null) {
1031+
return UnimplementedBlockContentNode(htmlNode: divElement);
1032+
}
1033+
final (anchorElement, imgElement) = result;
1034+
1035+
final imgSrc = imgElement.attributes['src'];
1036+
if (imgSrc == null) {
1037+
return UnimplementedBlockContentNode(htmlNode: divElement);
1038+
}
1039+
1040+
final href = anchorElement.attributes['href'];
1041+
if (href == null) {
1042+
return UnimplementedBlockContentNode(htmlNode: divElement);
1043+
}
1044+
1045+
return VideoNode(srcUrl: href, previewImageUrl: imgSrc, debugHtmlNode: debugHtmlNode);
1046+
}
1047+
1048+
BlockContentNode parseVideoNode(dom.Element divElement) {
1049+
assert(_debugParserContext == _ParserContext.block);
1050+
assert(divElement.localName == 'div'
1051+
&& _videoClassNameRegexp.hasMatch(divElement.className));
1052+
1053+
final match = _videoClassNameRegexp.firstMatch(divElement.className)!;
1054+
return switch (match.groups([1, 2])) {
1055+
['message_inline_video', null] || [null, 'message_inline_video']
1056+
=> _parseInlineVideo(divElement),
1057+
['youtube-video' || 'embed-video', null] || [null, 'youtube-video' || 'embed-video']
1058+
=> _parseEmbedVideoWithPreviewImage(divElement),
1059+
_ => UnimplementedBlockContentNode(htmlNode: divElement),
1060+
};
1061+
}
1062+
9511063
BlockContentNode parseBlockContent(dom.Node node) {
9521064
assert(_debugParserContext == _ParserContext.block);
9531065
final debugHtmlNode = kDebugMode ? node : null;
@@ -1024,6 +1136,10 @@ class _ZulipContentParser {
10241136
return parseImageNode(element);
10251137
}
10261138

1139+
if (localName == 'div' && _videoClassNameRegexp.hasMatch(className)) {
1140+
return parseVideoNode(element);
1141+
}
1142+
10271143
// TODO more types of node
10281144
return UnimplementedBlockContentNode(htmlNode: node);
10291145
}

lib/widgets/content.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ class BlockContentList extends StatelessWidget {
9090
return MathBlock(node: node);
9191
} else if (node is ImageNodeList) {
9292
return MessageImageList(node: node);
93+
} else if (node is VideoNode) {
94+
return Container();
9395
} else if (node is ImageNode) {
9496
assert(false,
9597
"[ImageNode] not allowed in [BlockContentList]. "

test/model/content_test.dart

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,125 @@ class ContentExample {
521521
blockUnimplemented('more text'),
522522
]]),
523523
]);
524+
525+
static const videoEmbedYoutube = ContentExample(
526+
'video preview for youtube embed with thumbnail',
527+
'https://www.youtube.com/watch?v=aqz-KE-bpKQ',
528+
'<p>'
529+
'<a href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">https://www.youtube.com/watch?v=aqz-KE-bpKQ</a>'
530+
'</p>\n'
531+
'<div class="youtube-video message_inline_image">'
532+
'<a data-id="aqz-KE-bpKQ" href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">'
533+
'<img src="/external_content/ecb96e8f884f481c4bc0179287a44ab9014aa78f/68747470733a2f2f692e7974696d672e636f6d2f76692f61717a2d4b452d62704b512f64656661756c742e6a7067"></a></div>', [
534+
ParagraphNode(links: null, nodes: [
535+
LinkNode(url: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ', nodes: [TextNode('https://www.youtube.com/watch?v=aqz-KE-bpKQ')]),
536+
]),
537+
VideoNode(
538+
srcUrl: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ',
539+
previewImageUrl: '/external_content/ecb96e8f884f481c4bc0179287a44ab9014aa78f/68747470733a2f2f692e7974696d672e636f6d2f76692f61717a2d4b452d62704b512f64656661756c742e6a7067'
540+
),
541+
]);
542+
543+
static const videoEmbedYoutubeClassesFlipped = ContentExample(
544+
'video preview for youtube embed with thumbnail, (hypothetical) class name reorder',
545+
'https://www.youtube.com/watch?v=aqz-KE-bpKQ',
546+
'<p>'
547+
'<a href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">https://www.youtube.com/watch?v=aqz-KE-bpKQ</a>'
548+
'</p>\n'
549+
'<div class="message_inline_image youtube-video">'
550+
'<a data-id="aqz-KE-bpKQ" href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">'
551+
'<img src="/external_content/ecb96e8f884f481c4bc0179287a44ab9014aa78f/68747470733a2f2f692e7974696d672e636f6d2f76692f61717a2d4b452d62704b512f64656661756c742e6a7067"></a></div>', [
552+
ParagraphNode(links: null, nodes: [
553+
LinkNode(url: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ', nodes: [TextNode('https://www.youtube.com/watch?v=aqz-KE-bpKQ')]),
554+
]),
555+
VideoNode(
556+
srcUrl: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ',
557+
previewImageUrl: '/external_content/ecb96e8f884f481c4bc0179287a44ab9014aa78f/68747470733a2f2f692e7974696d672e636f6d2f76692f61717a2d4b452d62704b512f64656661756c742e6a7067'
558+
),
559+
]);
560+
561+
static const videoEmbedVimeoPreviewDisabled = ContentExample(
562+
'video non-preview for attempted vimeo embed with realm link previews disabled',
563+
'https://vimeo.com/1084537',
564+
'<p>'
565+
'<a href="https://vimeo.com/1084537">https://vimeo.com/1084537</a></p>', [
566+
ParagraphNode(links: null, nodes: [
567+
LinkNode(url: 'https://vimeo.com/1084537', nodes: [TextNode('https://vimeo.com/1084537')]),
568+
]),
569+
]);
570+
571+
static const videoEmbedVimeo = ContentExample(
572+
'video preview for vimeo embed with realm link previews enabled',
573+
'https://vimeo.com/1084537',
574+
// Note that the server generates "data-id" attribute with value of a raw HTML,
575+
// web client uses this to show Vimeo's video player in a sandbox iframe.
576+
// The HTML blob is provided by Vimeo and them changing it wouldn't break the
577+
// web client because it relies on this dynamic value.
578+
//
579+
// Discussion:
580+
// https://chat.zulip.org/#narrow/stream/9-issues/topic/Vimeo.20link.20previews.20HTML.20.22data-id.22.20isn't.20a.20.20Vimeo.20video.20ID/near/1767563
581+
'<p>'
582+
'<a href="https://vimeo.com/1084537">Vimeo - Big Buck Bunny</a>'
583+
'</p>\n'
584+
'<div class="embed-video message_inline_image">'
585+
'<a data-id="&lt;iframe src=&quot;https://player.vimeo.com/video/1084537?app_id=122963&quot; width=&quot;640&quot; height=&quot;360&quot; frameborder=&quot;0&quot; allow=&quot;autoplay; fullscreen; picture-in-picture; clipboard-write&quot; title=&quot;Big Buck Bunny&quot;&gt;&lt;/iframe&gt;" href="https://vimeo.com/1084537" title="Big Buck Bunny">'
586+
'<img src="https://uploads.zulipusercontent.net/75aed2df4a1e8657176fcd6159fc40876ace4070/68747470733a2f2f692e76696d656f63646e2e636f6d2f766964656f2f32303936333634392d663032383137343536666334386537633331376566346330376261323539636434623430613336343962643865623530613434313862353965633366356166352d645f363430"></a></div>', [
587+
ParagraphNode(links: null, nodes: [
588+
LinkNode(url: 'https://vimeo.com/1084537', nodes: [TextNode('Vimeo - Big Buck Bunny')]),
589+
]),
590+
VideoNode(
591+
srcUrl: 'https://vimeo.com/1084537',
592+
previewImageUrl: 'https://uploads.zulipusercontent.net/75aed2df4a1e8657176fcd6159fc40876ace4070/68747470733a2f2f692e76696d656f63646e2e636f6d2f766964656f2f32303936333634392d663032383137343536666334386537633331376566346330376261323539636434623430613336343962643865623530613434313862353965633366356166352d645f363430'
593+
),
594+
]);
595+
596+
static const videoEmbedVimeoClassesFlipped = ContentExample(
597+
'video preview for vimeo embed with realm link previews enabled, (hypothetical) class name reorder',
598+
'https://vimeo.com/1084537',
599+
'<p>'
600+
'<a href="https://vimeo.com/1084537">Vimeo - Big Buck Bunny</a>'
601+
'</p>\n'
602+
'<div class="message_inline_image embed-video">'
603+
'<a data-id="&lt;iframe src=&quot;https://player.vimeo.com/video/1084537?app_id=122963&quot; width=&quot;640&quot; height=&quot;360&quot; frameborder=&quot;0&quot; allow=&quot;autoplay; fullscreen; picture-in-picture; clipboard-write&quot; title=&quot;Big Buck Bunny&quot;&gt;&lt;/iframe&gt;" href="https://vimeo.com/1084537" title="Big Buck Bunny">'
604+
'<img src="https://uploads.zulipusercontent.net/75aed2df4a1e8657176fcd6159fc40876ace4070/68747470733a2f2f692e76696d656f63646e2e636f6d2f766964656f2f32303936333634392d663032383137343536666334386537633331376566346330376261323539636434623430613336343962643865623530613434313862353965633366356166352d645f363430"></a></div>', [
605+
ParagraphNode(links: null, nodes: [
606+
LinkNode(url: 'https://vimeo.com/1084537', nodes: [TextNode('Vimeo - Big Buck Bunny')]),
607+
]),
608+
VideoNode(
609+
srcUrl: 'https://vimeo.com/1084537',
610+
previewImageUrl: 'https://uploads.zulipusercontent.net/75aed2df4a1e8657176fcd6159fc40876ace4070/68747470733a2f2f692e76696d656f63646e2e636f6d2f766964656f2f32303936333634392d663032383137343536666334386537633331376566346330376261323539636434623430613336343962643865623530613434313862353965633366356166352d645f363430'
611+
),
612+
]);
613+
614+
static const videoInline = ContentExample(
615+
'video preview for user uploaded video',
616+
'[Big-Buck-Bunny.webm](/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm)',
617+
'<p>'
618+
'<a href="/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm">Big-Buck-Bunny.webm</a>'
619+
'</p>\n'
620+
'<div class="message_inline_image message_inline_video">'
621+
'<a href="/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm" title="Big-Buck-Bunny.webm">'
622+
'<video preload="metadata" src="/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm"></video></a></div>', [
623+
ParagraphNode(links: null, nodes: [
624+
LinkNode(url: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm', nodes: [TextNode('Big-Buck-Bunny.webm')]),
625+
]),
626+
VideoNode(srcUrl: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm', previewImageUrl: null),
627+
]);
628+
629+
static const videoInlineClassesFlipped = ContentExample(
630+
'video preview for user uploaded video, (hypothetical) class name reorder',
631+
'[Big-Buck-Bunny.webm](/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm)',
632+
'<p>'
633+
'<a href="/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm">Big-Buck-Bunny.webm</a>'
634+
'</p>\n'
635+
'<div class="message_inline_video message_inline_image">'
636+
'<a href="/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm" title="Big-Buck-Bunny.webm">'
637+
'<video preload="metadata" src="/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm"></video></a></div>', [
638+
ParagraphNode(links: null, nodes: [
639+
LinkNode(url: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm', nodes: [TextNode('Big-Buck-Bunny.webm')]),
640+
]),
641+
VideoNode(srcUrl: '/user_uploads/2/78/_KoRecCHZTFrVtyTKCkIh5Hq/Big-Buck-Bunny.webm', previewImageUrl: null),
642+
]);
524643
}
525644

526645
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -856,6 +975,14 @@ void main() {
856975
testParseExample(ContentExample.imageClusterInImplicitParagraph);
857976
testParseExample(ContentExample.imageClusterInImplicitParagraphThenContent);
858977

978+
testParseExample(ContentExample.videoEmbedYoutube);
979+
testParseExample(ContentExample.videoEmbedYoutubeClassesFlipped);
980+
testParseExample(ContentExample.videoEmbedVimeoPreviewDisabled);
981+
testParseExample(ContentExample.videoEmbedVimeo);
982+
testParseExample(ContentExample.videoEmbedVimeoClassesFlipped);
983+
testParseExample(ContentExample.videoInline);
984+
testParseExample(ContentExample.videoInlineClassesFlipped);
985+
859986
testParse('parse nested lists, quotes, headings, code blocks',
860987
// "1. > ###### two\n > * three\n\n four"
861988
'<ol>\n<li>\n<blockquote>\n<h6>two</h6>\n<ul>\n<li>three</li>\n'

0 commit comments

Comments
 (0)