Skip to content

Commit 4ebdaa0

Browse files
rajveermalviyagnprice
authored andcommitted
content: Handle html parsing of EmbedVideoNode
1 parent 8248db5 commit 4ebdaa0

File tree

3 files changed

+199
-0
lines changed

3 files changed

+199
-0
lines changed

lib/model/content.dart

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,50 @@ class ImageNode extends BlockContentNode {
374374
}
375375
}
376376

377+
class EmbedVideoNode extends BlockContentNode {
378+
const EmbedVideoNode({
379+
super.debugHtmlNode,
380+
required this.hrefUrl,
381+
required this.previewImageSrcUrl,
382+
});
383+
384+
/// A URL string for the video, typically on an external service.
385+
///
386+
/// For example, this URL may be on youtube.com or vimeo.com.
387+
///
388+
/// Unlike with [previewImageSrcUrl] or [InlineVideoNode.srcUrl],
389+
/// no requests should be made to this URL unless the user explicitly chooses
390+
/// to interact with the video, in order to protect the user's privacy.
391+
final String hrefUrl;
392+
393+
/// A URL string for a thumbnail image for the video, on the Zulip server.
394+
///
395+
/// This may be a relative URL string. It also may not work without adding
396+
/// authentication credentials to the request.
397+
///
398+
/// Like [InlineVideoNode.srcUrl] and unlike [hrefUrl], this is suitable
399+
/// from a privacy perspective for eagerly fetching data when the user
400+
/// passively scrolls the video into view.
401+
final String previewImageSrcUrl;
402+
403+
@override
404+
bool operator ==(Object other) {
405+
return other is EmbedVideoNode
406+
&& other.hrefUrl == hrefUrl
407+
&& other.previewImageSrcUrl == previewImageSrcUrl;
408+
}
409+
410+
@override
411+
int get hashCode => Object.hash('EmbedVideoNode', hrefUrl, previewImageSrcUrl);
412+
413+
@override
414+
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
415+
super.debugFillProperties(properties);
416+
properties.add(StringProperty('hrefUrl', hrefUrl));
417+
properties.add(StringProperty('previewImageSrcUrl', previewImageSrcUrl));
418+
}
419+
}
420+
377421
/// A content node that expects an inline layout context from its parent.
378422
///
379423
/// When rendered into a Flutter widget tree, an inline content node
@@ -968,6 +1012,50 @@ class _ZulipContentParser {
9681012
return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode);
9691013
}
9701014

1015+
static final _videoClassNameRegexp = () {
1016+
const sourceType = r"(youtube-video|embed-video)";
1017+
return RegExp("^message_inline_image $sourceType|$sourceType message_inline_image\$");
1018+
}();
1019+
1020+
BlockContentNode parseEmbedVideoNode(dom.Element divElement) {
1021+
assert(_debugParserContext == _ParserContext.block);
1022+
assert(divElement.localName == 'div'
1023+
&& _videoClassNameRegexp.hasMatch(divElement.className));
1024+
1025+
final pair = () {
1026+
if (divElement.nodes.length != 1) return null;
1027+
final child = divElement.nodes[0];
1028+
if (child is! dom.Element) return null;
1029+
if (child.localName != 'a') return null;
1030+
if (child.className.isNotEmpty) return null;
1031+
1032+
if (child.nodes.length != 1) return null;
1033+
final grandchild = child.nodes[0];
1034+
if (grandchild is! dom.Element) return null;
1035+
if (grandchild.localName != 'img') return null;
1036+
if (grandchild.className.isNotEmpty) return null;
1037+
return (child, grandchild);
1038+
}();
1039+
1040+
final debugHtmlNode = kDebugMode ? divElement : null;
1041+
if (pair == null) {
1042+
return UnimplementedBlockContentNode(htmlNode: divElement);
1043+
}
1044+
final (anchorElement, imgElement) = pair;
1045+
1046+
final imgSrc = imgElement.attributes['src'];
1047+
if (imgSrc == null) {
1048+
return UnimplementedBlockContentNode(htmlNode: divElement);
1049+
}
1050+
1051+
final href = anchorElement.attributes['href'];
1052+
if (href == null) {
1053+
return UnimplementedBlockContentNode(htmlNode: divElement);
1054+
}
1055+
1056+
return EmbedVideoNode(hrefUrl: href, previewImageSrcUrl: imgSrc, debugHtmlNode: debugHtmlNode);
1057+
}
1058+
9711059
BlockContentNode parseBlockContent(dom.Node node) {
9721060
assert(_debugParserContext == _ParserContext.block);
9731061
final debugHtmlNode = kDebugMode ? node : null;
@@ -1048,6 +1136,16 @@ class _ZulipContentParser {
10481136
return parseImageNode(element);
10491137
}
10501138

1139+
if (localName == 'div') {
1140+
final match = _videoClassNameRegexp.firstMatch(className);
1141+
if (match != null) {
1142+
final videoClass = match.group(1) ?? match.group(2)!;
1143+
if (videoClass case 'youtube-video' || 'embed-video') {
1144+
return parseEmbedVideoNode(element);
1145+
}
1146+
}
1147+
}
1148+
10511149
// TODO more types of node
10521150
return UnimplementedBlockContentNode(htmlNode: node);
10531151
}

lib/widgets/content.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,14 @@ class BlockContentList extends StatelessWidget {
9898
"It should be wrapped in [ImageNodeList]."
9999
);
100100
return MessageImage(node: node);
101+
} else if (node is EmbedVideoNode) {
102+
return Text.rich(
103+
TextSpan(children: [
104+
const TextSpan(text: "(unimplemented:", style: errorStyle),
105+
TextSpan(text: node.debugHtmlText, style: errorCodeStyle),
106+
const TextSpan(text: ")", style: errorStyle),
107+
]),
108+
style: errorStyle);
101109
} else if (node is UnimplementedBlockContentNode) {
102110
return Text.rich(_errorUnimplemented(node));
103111
} else {

test/model/content_test.dart

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,93 @@ class ContentExample {
613613
ThematicBreakNode(),
614614
ParagraphNode(links: null, nodes: [TextNode('b')]),
615615
]);
616+
617+
static const videoEmbedYoutube = ContentExample(
618+
'video preview for youtube embed with thumbnail',
619+
'https://www.youtube.com/watch?v=aqz-KE-bpKQ',
620+
'<p>'
621+
'<a href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">https://www.youtube.com/watch?v=aqz-KE-bpKQ</a>'
622+
'</p>\n'
623+
'<div class="youtube-video message_inline_image">'
624+
'<a data-id="aqz-KE-bpKQ" href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">'
625+
'<img src="/external_content/ecb96e8f884f481c4bc0179287a44ab9014aa78f/68747470733a2f2f692e7974696d672e636f6d2f76692f61717a2d4b452d62704b512f64656661756c742e6a7067"></a></div>', [
626+
ParagraphNode(links: null, nodes: [
627+
LinkNode(url: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ', nodes: [TextNode('https://www.youtube.com/watch?v=aqz-KE-bpKQ')]),
628+
]),
629+
EmbedVideoNode(
630+
hrefUrl: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ',
631+
previewImageSrcUrl: '/external_content/ecb96e8f884f481c4bc0179287a44ab9014aa78f/68747470733a2f2f692e7974696d672e636f6d2f76692f61717a2d4b452d62704b512f64656661756c742e6a7067'
632+
),
633+
]);
634+
635+
static const videoEmbedYoutubeClassesFlipped = ContentExample(
636+
'video preview for youtube embed with thumbnail, (hypothetical) class name reorder',
637+
null, // "https://www.youtube.com/watch?v=aqz-KE-bpKQ" (hypothetical server variation)
638+
'<p>'
639+
'<a href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">https://www.youtube.com/watch?v=aqz-KE-bpKQ</a>'
640+
'</p>\n'
641+
'<div class="message_inline_image youtube-video">'
642+
'<a data-id="aqz-KE-bpKQ" href="https://www.youtube.com/watch?v=aqz-KE-bpKQ">'
643+
'<img src="/external_content/ecb96e8f884f481c4bc0179287a44ab9014aa78f/68747470733a2f2f692e7974696d672e636f6d2f76692f61717a2d4b452d62704b512f64656661756c742e6a7067"></a></div>', [
644+
ParagraphNode(links: null, nodes: [
645+
LinkNode(url: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ', nodes: [TextNode('https://www.youtube.com/watch?v=aqz-KE-bpKQ')]),
646+
]),
647+
EmbedVideoNode(
648+
hrefUrl: 'https://www.youtube.com/watch?v=aqz-KE-bpKQ',
649+
previewImageSrcUrl: '/external_content/ecb96e8f884f481c4bc0179287a44ab9014aa78f/68747470733a2f2f692e7974696d672e636f6d2f76692f61717a2d4b452d62704b512f64656661756c742e6a7067'
650+
),
651+
]);
652+
653+
static const videoEmbedVimeoPreviewDisabled = ContentExample(
654+
'video non-preview for attempted vimeo embed with realm link previews disabled',
655+
'https://vimeo.com/1084537',
656+
'<p>'
657+
'<a href="https://vimeo.com/1084537">https://vimeo.com/1084537</a></p>', [
658+
ParagraphNode(links: null, nodes: [
659+
LinkNode(url: 'https://vimeo.com/1084537', nodes: [TextNode('https://vimeo.com/1084537')]),
660+
]),
661+
]);
662+
663+
static const videoEmbedVimeo = ContentExample(
664+
'video preview for vimeo embed with realm link previews enabled',
665+
'https://vimeo.com/1084537',
666+
// The server really does generate an attribute called "data-id" whose value
667+
// is a blob of HTML. The web client uses this to show Vimeo's video player
668+
// inside a sandbox iframe. The HTML comes from Vimeo and may change form;
669+
// that's OK the way web uses it, but we shouldn't try to parse it. See:
670+
// 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
671+
'<p>'
672+
'<a href="https://vimeo.com/1084537">Vimeo - Big Buck Bunny</a>'
673+
'</p>\n'
674+
'<div class="embed-video message_inline_image">'
675+
'<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">'
676+
'<img src="https://uploads.zulipusercontent.net/75aed2df4a1e8657176fcd6159fc40876ace4070/68747470733a2f2f692e76696d656f63646e2e636f6d2f766964656f2f32303936333634392d663032383137343536666334386537633331376566346330376261323539636434623430613336343962643865623530613434313862353965633366356166352d645f363430"></a></div>', [
677+
ParagraphNode(links: null, nodes: [
678+
LinkNode(url: 'https://vimeo.com/1084537', nodes: [TextNode('Vimeo - Big Buck Bunny')]),
679+
]),
680+
EmbedVideoNode(
681+
hrefUrl: 'https://vimeo.com/1084537',
682+
previewImageSrcUrl: 'https://uploads.zulipusercontent.net/75aed2df4a1e8657176fcd6159fc40876ace4070/68747470733a2f2f692e76696d656f63646e2e636f6d2f766964656f2f32303936333634392d663032383137343536666334386537633331376566346330376261323539636434623430613336343962643865623530613434313862353965633366356166352d645f363430'
683+
),
684+
]);
685+
686+
static const videoEmbedVimeoClassesFlipped = ContentExample(
687+
'video preview for vimeo embed with realm link previews enabled, (hypothetical) class name reorder',
688+
'https://vimeo.com/1084537',
689+
'<p>'
690+
'<a href="https://vimeo.com/1084537">Vimeo - Big Buck Bunny</a>'
691+
'</p>\n'
692+
'<div class="message_inline_image embed-video">'
693+
'<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">'
694+
'<img src="https://uploads.zulipusercontent.net/75aed2df4a1e8657176fcd6159fc40876ace4070/68747470733a2f2f692e76696d656f63646e2e636f6d2f766964656f2f32303936333634392d663032383137343536666334386537633331376566346330376261323539636434623430613336343962643865623530613434313862353965633366356166352d645f363430"></a></div>', [
695+
ParagraphNode(links: null, nodes: [
696+
LinkNode(url: 'https://vimeo.com/1084537', nodes: [TextNode('Vimeo - Big Buck Bunny')]),
697+
]),
698+
EmbedVideoNode(
699+
hrefUrl: 'https://vimeo.com/1084537',
700+
previewImageSrcUrl: 'https://uploads.zulipusercontent.net/75aed2df4a1e8657176fcd6159fc40876ace4070/68747470733a2f2f692e76696d656f63646e2e636f6d2f766964656f2f32303936333634392d663032383137343536666334386537633331376566346330376261323539636434623430613336343962643865623530613434313862353965633366356166352d645f363430'
701+
),
702+
]);
616703
}
617704

618705
UnimplementedBlockContentNode blockUnimplemented(String html) {
@@ -925,6 +1012,12 @@ void main() {
9251012
testParseExample(ContentExample.imageClusterInImplicitParagraph);
9261013
testParseExample(ContentExample.imageClusterInImplicitParagraphThenContent);
9271014

1015+
testParseExample(ContentExample.videoEmbedYoutube);
1016+
testParseExample(ContentExample.videoEmbedYoutubeClassesFlipped);
1017+
testParseExample(ContentExample.videoEmbedVimeoPreviewDisabled);
1018+
testParseExample(ContentExample.videoEmbedVimeo);
1019+
testParseExample(ContentExample.videoEmbedVimeoClassesFlipped);
1020+
9281021
testParse('parse nested lists, quotes, headings, code blocks',
9291022
// "1. > ###### two\n > * three\n\n four"
9301023
'<ol>\n<li>\n<blockquote>\n<h6>two</h6>\n<ul>\n<li>three</li>\n'

0 commit comments

Comments
 (0)