diff --git a/.gitignore b/.gitignore index ad4ff4d9294..0e726f326b2 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,7 @@ !/plugins/discourse-local-dates !/plugins/discourse-narrative-bot !/plugins/discourse-presence -!/plugins/lazy-yt/ +!/plugins/discourse-lazy-videos/ !/plugins/chat/ !/plugins/poll/ !/plugins/styleguide diff --git a/app/assets/stylesheets/common/printer-friendly.scss b/app/assets/stylesheets/common/printer-friendly.scss index b8cef469afa..58649c225e3 100644 --- a/app/assets/stylesheets/common/printer-friendly.scss +++ b/app/assets/stylesheets/common/printer-friendly.scss @@ -16,7 +16,7 @@ #topic-progress, .quote-controls, .topic-timer-info, - div.lazyYT, + div.lazy-video-container, .post-info.edits, .post-action, .saving-text, diff --git a/app/services/search_indexer.rb b/app/services/search_indexer.rb index 530ac26c6d0..f0af956e85b 100644 --- a/app/services/search_indexer.rb +++ b/app/services/search_indexer.rb @@ -396,7 +396,7 @@ class SearchIndexer end MENTION_CLASSES ||= %w[mention mention-group] - ATTRIBUTES ||= %w[alt title href data-youtube-title] + ATTRIBUTES ||= %w[alt title href data-video-title] def start_element(_name, attributes = []) attributes = Hash[*attributes.flatten] diff --git a/lib/plugin/metadata.rb b/lib/plugin/metadata.rb index ab59144079c..96d836ac51e 100644 --- a/lib/plugin/metadata.rb +++ b/lib/plugin/metadata.rb @@ -45,6 +45,7 @@ class Plugin::Metadata "discourse-graphviz", "discourse-group-tracker", "discourse-invite-tokens", + "discourse-lazy-videos", "discourse-local-dates", "discourse-login-with-amazon", "discourse-logster-rate-limit-checker", @@ -93,7 +94,6 @@ class Plugin::Metadata "discourse-zendesk-plugin", "docker_manager", "chat", - "lazy-yt", "poll", "styleguide", ], diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index 66d9c96dfae..5d142f9e31a 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -433,13 +433,19 @@ module PrettyText # extract Youtube links doc - .css("div[data-youtube-id]") + .css("div[data-video-id]") .each do |div| - if div["data-youtube-id"].present? - links << DetectedLink.new( - "https://www.youtube.com/watch?v=#{div["data-youtube-id"]}", - false, - ) + if div["data-video-id"].present? && div["data-provider-name"].present? + base_url = + case div["data-provider-name"] + when "youtube" + "https://www.youtube.com/watch?v=" + when "vimeo" + "https://vimeo.com/" + when "tiktok" + "https://m.tiktok.com/v/" + end + links << DetectedLink.new(base_url + div["data-video-id"], false) end end diff --git a/lib/tasks/plugin.rake b/lib/tasks/plugin.rake index 6f4d67fe12e..9c8d4e601ee 100644 --- a/lib/tasks/plugin.rake +++ b/lib/tasks/plugin.rake @@ -4,7 +4,7 @@ directory "plugins" desc "install all official plugins (use GIT_WRITE=1 to pull with write access)" task "plugin:install_all_official" do - skip = Set.new(%w[customer-flair lazy-yt poll]) + skip = Set.new(%w[customer-flair poll]) map = { "Canned Replies" => "https://github.com/discourse/discourse-canned-replies" } diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs index 588df4baca7..0ac4035be2f 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.hbs @@ -13,7 +13,13 @@ {{#each this.cookedBodies as |cooked|}} {{#if cooked.needsCollapser}} - {{cooked.body}} + {{#if cooked.videoAttributes}} +
+ +
+ {{else}} + {{cooked.body}} + {{/if}}
{{else}} {{cooked.body}} diff --git a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js index d62df9e639e..3a6100e6887 100644 --- a/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js +++ b/plugins/chat/assets/javascripts/discourse/components/chat-message-collapser.js @@ -1,10 +1,13 @@ import Component from "@glimmer/component"; import { htmlSafe } from "@ember/template"; +import { inject as service } from "@ember/service"; import { escapeExpression } from "discourse/lib/utilities"; import domFromString from "discourse-common/lib/dom-from-string"; import I18n from "I18n"; export default class ChatMessageCollapser extends Component { + @service siteSettings; + get hasUploads() { return hasUploads(this.args.uploads); } @@ -28,8 +31,8 @@ export default class ChatMessageCollapser extends Component { domFromString(this.args.cooked) ); - if (hasYoutube(elements)) { - return this.youtubeCooked(elements); + if (hasLazyVideo(elements)) { + return this.lazyVideoCooked(elements); } if (hasImageOnebox(elements)) { @@ -47,20 +50,26 @@ export default class ChatMessageCollapser extends Component { return []; } - youtubeCooked(elements) { + lazyVideoCooked(elements) { return elements.reduce((acc, e) => { - if (youtubePredicate(e)) { - const id = e.dataset.youtubeId; - const link = `https://www.youtube.com/watch?v=${escapeExpression(id)}`; - const title = escapeExpression(e.dataset.youtubeTitle); - const header = htmlSafe( - `${title}` - ); - const body = document.createElement("div"); - body.className = "chat-message-collapser-youtube"; - body.appendChild(e); + if (this.siteSettings.lazy_videos_enabled && lazyVideoPredicate(e)) { + const getVideoAttributes = requirejs( + "discourse/plugins/discourse-lazy-videos/lib/lazy-video-attributes" + ).default; - acc.push({ header, body, needsCollapser: true }); + const videoAttributes = getVideoAttributes(e); + + if (this.siteSettings[`lazy_${videoAttributes.providerName}_enabled`]) { + const link = escapeExpression(videoAttributes.url); + const title = videoAttributes.title; + const header = htmlSafe( + `${title}` + ); + + acc.push({ header, body: e, videoAttributes, needsCollapser: true }); + } else { + acc.push({ body: e, needsCollapser: false }); + } } else { acc.push({ body: e, needsCollapser: false }); } @@ -125,16 +134,12 @@ export default class ChatMessageCollapser extends Component { } } -function youtubePredicate(e) { - return ( - e.classList.length && - e.classList.contains("onebox") && - e.classList.contains("lazyYT-container") - ); +function lazyVideoPredicate(e) { + return e.classList.contains("lazy-video-container"); } -function hasYoutube(elements) { - return elements.some((e) => youtubePredicate(e)); +function hasLazyVideo(elements) { + return elements.some((e) => lazyVideoPredicate(e)); } function animatedImagePredicate(e) { @@ -198,7 +203,7 @@ export function isCollapsible(cooked, uploads) { const elements = Array.prototype.slice.call(domFromString(cooked)); return ( - hasYoutube(elements) || + hasLazyVideo(elements) || hasImageOnebox(elements) || hasUploads(uploads) || hasImage(elements) || diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js index 8439021ba06..877fb9c319b 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-plugin-decorators.js @@ -34,20 +34,6 @@ export default { } ); } - if (siteSettings.lazy_yt_enabled) { - api.decorateChatMessage( - (element) => { - element - .querySelectorAll(".lazyYT:not(.lazyYT-video-loaded)") - .forEach((iframe) => { - $(iframe).lazyYT(); - }); - }, - { - id: "lazy-yt", - } - ); - } }, initialize(container) { diff --git a/plugins/chat/assets/stylesheets/common/chat-message-images.scss b/plugins/chat/assets/stylesheets/common/chat-message-images.scss index 300629fe7f2..75958050b40 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message-images.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message-images.scss @@ -21,9 +21,24 @@ $max_image_height: 150px; .chat-message-collapser .chat-message-collapser-header + div - .chat-message-collapser-youtube { + .chat-message-collapser-lazy-video { object-fit: contain; height: $max_image_height; width: calc(#{$max_image_height} / 9 * 16); } + + // Prevent overflow of old lazy-yt images + // TODO: remove in December 2023 + .lazyYT.lazyYT-container { + border: none; + a { + display: flex; + } + .ytp-thumbnail-image { + object-fit: contain; + height: $max_image_height; + width: calc(#{$max_image_height} / 9 * 16); + pointer-events: none; + } + } } diff --git a/plugins/chat/assets/stylesheets/common/chat-message.scss b/plugins/chat/assets/stylesheets/common/chat-message.scss index 2ee5de37395..9a7d7d1a39a 100644 --- a/plugins/chat/assets/stylesheets/common/chat-message.scss +++ b/plugins/chat/assets/stylesheets/common/chat-message.scss @@ -88,16 +88,6 @@ text-decoration: none; } - img.ytp-thumbnail-image, - img.youtube-thumbnail { - height: 100%; - max-height: unset; - - &:hover { - border-radius: 0; - } - } - // Automatic aspect-ratio mapping https://developer.mozilla.org/en-US/docs/Web/Media/images/aspect_ratio_mapping p img:not(.emoji) { max-width: 100%; diff --git a/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js b/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js index 4674ac50722..c59e0ece4dd 100644 --- a/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js +++ b/plugins/chat/test/javascripts/acceptance/chat-live-pane-collapse-test.js @@ -30,7 +30,7 @@ acceptance("Discourse Chat - Chat live pane collapse", function (needs) { id: 1, message: "https://www.youtube.com/watch?v=aOWkVdU4NH0", cooked: - '
', + '
', excerpt: '[Picnic with my cat (shaved ice & lemonade…', created_at: "2021-07-20T08:14:16.950Z", @@ -106,8 +106,9 @@ acceptance("Discourse Chat - Chat live pane collapse", function (needs) { ); }); - skip("can collapse and expand youtube chat", async function (assert) { - const youtubeContainer = ".chat-message-container[data-id='1'] .lazyYT"; + skip("can collapse and expand videos in chat", async function (assert) { + const videoContainer = + ".chat-message-container[data-id='1'] .lazy-video-container"; const expandImage = ".chat-message-container[data-id='1'] .chat-message-collapser-closed"; const collapseImage = @@ -115,19 +116,19 @@ acceptance("Discourse Chat - Chat live pane collapse", function (needs) { await visit("/chat/c/cat/1"); - assert.ok(visible(youtubeContainer)); + assert.ok(visible(videoContainer)); assert.ok(visible(collapseImage), "the open arrow is shown"); assert.notOk(exists(expandImage), "the close arrow is hidden"); await click(collapseImage); - assert.notOk(visible(youtubeContainer)); + assert.notOk(visible(videoContainer)); assert.ok(visible(expandImage), "the close arrow is shown"); assert.notOk(exists(collapseImage), "the open arrow is hidden"); await click(expandImage); - assert.ok(visible(youtubeContainer)); + assert.ok(visible(videoContainer)); assert.ok(visible(collapseImage), "the open arrow is shown again"); assert.notOk(exists(expandImage), "the close arrow is hidden again"); }); diff --git a/plugins/chat/test/javascripts/components/chat-message-collapser-test.js b/plugins/chat/test/javascripts/components/chat-message-collapser-test.js index 56be03b199c..0025a99fdbd 100644 --- a/plugins/chat/test/javascripts/components/chat-message-collapser-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-collapser-test.js @@ -10,9 +10,9 @@ import { module, test } from "qunit"; const youtubeCooked = "

written text

" + - '
Vid 1
' + + '
Vid 1
' + "

more written text

" + - '
Vid 2
' + + '
Vid 2
' + "

and even more

"; const animatedImageCooked = @@ -71,7 +71,13 @@ module( setupRenderingTest(hooks); test("escapes youtube header", async function (assert) { - this.set("cooked", youtubeCooked.replace("ytId1", evilString)); + this.set( + "cooked", + youtubeCooked.replace( + "https://www.youtube.com/watch?v=ytId1", + `https://www.youtube.com/watch?v=${evilString}` + ) + ); await render(hbs``); assert.true( @@ -124,7 +130,7 @@ module( await render(hbs``); - const youtubeDivs = queryAll(".onebox"); + const youtubeDivs = queryAll(".youtube-onebox"); assert.strictEqual( youtubeDivs.length, @@ -138,11 +144,11 @@ module( ); assert.false( - visible(".onebox[data-youtube-id='ytId1']"), + visible(".youtube-onebox[data-video-id='ytId1']"), "first youtube preview hidden" ); assert.true( - visible(".onebox[data-youtube-id='ytId2']"), + visible(".youtube-onebox[data-video-id='ytId2']"), "second youtube preview still visible" ); @@ -160,11 +166,11 @@ module( ); assert.true( - visible(".onebox[data-youtube-id='ytId1']"), + visible(".youtube-onebox[data-video-id='ytId1']"), "first youtube preview still visible" ); assert.false( - visible(".onebox[data-youtube-id='ytId2']"), + visible(".youtube-onebox[data-video-id='ytId2']"), "second youtube preview hidden" ); diff --git a/plugins/chat/test/javascripts/components/chat-message-text-test.js b/plugins/chat/test/javascripts/components/chat-message-text-test.js index f0ba6fcd14f..b5f05f80bc3 100644 --- a/plugins/chat/test/javascripts/components/chat-message-text-test.js +++ b/plugins/chat/test/javascripts/components/chat-message-text-test.js @@ -22,7 +22,7 @@ module("Discourse Chat | Component | chat-message-text", function (hooks) { test("shows collapsed", async function (assert) { this.set( "cooked", - '
' + '
' ); await render( @@ -51,7 +51,10 @@ module("Discourse Chat | Component | chat-message-text", function (hooks) { }); test("shows edits - collapsible message", async function (assert) { - this.set("cooked", '
'); + this.set( + "cooked", + '
' + ); await render( hbs`` diff --git a/plugins/discourse-lazy-videos/README.md b/plugins/discourse-lazy-videos/README.md new file mode 100644 index 00000000000..47d4df67524 --- /dev/null +++ b/plugins/discourse-lazy-videos/README.md @@ -0,0 +1,12 @@ +## Discourse Lazy Videos + +Adds lazy loading support for embedded videos + +### Supported providers + + - YouTube + - Vimeo + +### Experimental + + - TikTok \ No newline at end of file diff --git a/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-iframe.hbs b/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-iframe.hbs new file mode 100644 index 00000000000..d662c4787a5 --- /dev/null +++ b/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-iframe.hbs @@ -0,0 +1,11 @@ +{{#if @providerName}} + +{{/if}} \ No newline at end of file diff --git a/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-iframe.js b/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-iframe.js new file mode 100644 index 00000000000..7c81303ea8f --- /dev/null +++ b/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-iframe.js @@ -0,0 +1,14 @@ +import Component from "@glimmer/component"; + +export default class LazyVideo extends Component { + get iframeSrc() { + switch (this.args.providerName) { + case "youtube": + return `https://www.youtube.com/embed/${this.args.videoId}?autoplay=1`; + case "vimeo": + return `https://player.vimeo.com/video/${this.args.videoId}?autoplay=1`; + case "tiktok": + return `https://www.tiktok.com/embed/v2/${this.args.videoId}`; + } + } +} diff --git a/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-video.hbs b/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-video.hbs new file mode 100644 index 00000000000..b3dba578c7e --- /dev/null +++ b/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-video.hbs @@ -0,0 +1,51 @@ +
+ {{#if this.isLoaded}} + + {{else}} +
+ +
+
+ + {{/if}} +
\ No newline at end of file diff --git a/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-video.js b/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-video.js new file mode 100644 index 00000000000..6257605aadf --- /dev/null +++ b/plugins/discourse-lazy-videos/assets/javascripts/discourse/components/lazy-video.js @@ -0,0 +1,23 @@ +import Component from "@glimmer/component"; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; + +export default class LazyVideo extends Component { + @tracked isLoaded = false; + + @action + loadEmbed() { + if (!this.isLoaded) { + this.isLoaded = true; + this.args.onLoadedVideo?.(); + } + } + + @action + onKeyPress(event) { + if (event.key === "Enter") { + event.preventDefault(); + this.loadEmbed(); + } + } +} diff --git a/plugins/discourse-lazy-videos/assets/javascripts/initializers/lazy-videos.js b/plugins/discourse-lazy-videos/assets/javascripts/initializers/lazy-videos.js new file mode 100644 index 00000000000..ffee62ef815 --- /dev/null +++ b/plugins/discourse-lazy-videos/assets/javascripts/initializers/lazy-videos.js @@ -0,0 +1,46 @@ +import { withPluginApi } from "discourse/lib/plugin-api"; +import getVideoAttributes from "../lib/lazy-video-attributes"; +import { hbs } from "ember-cli-htmlbars"; + +function initLazyEmbed(api) { + api.decorateCookedElement( + (cooked, helper) => { + if (cooked.classList.contains("d-editor-preview")) { + return; + } + + const lazyContainers = cooked.querySelectorAll(".lazy-video-container"); + + lazyContainers.forEach((container) => { + const siteSettings = api.container.lookup("site-settings:main"); + const videoAttributes = getVideoAttributes(container); + + if (siteSettings[`lazy_${videoAttributes.providerName}_enabled`]) { + const onLoadedVideo = () => { + const postId = cooked.closest("article")?.dataset?.postId; + if (postId) { + api.preventCloak(parseInt(postId, 10)); + } + }; + + const lazyVideo = helper.renderGlimmer( + "p.lazy-video-wrapper", + hbs``, + { param: videoAttributes, onLoadedVideo } + ); + + container.replaceWith(lazyVideo); + } + }); + }, + { onlyStream: true, id: "discourse-lazy-videos" } + ); +} + +export default { + name: "discourse-lazy-videos", + + initialize() { + withPluginApi("1.6.0", initLazyEmbed); + }, +}; diff --git a/plugins/discourse-lazy-videos/assets/javascripts/lib/lazy-video-attributes.js b/plugins/discourse-lazy-videos/assets/javascripts/lib/lazy-video-attributes.js new file mode 100644 index 00000000000..f4dd6626264 --- /dev/null +++ b/plugins/discourse-lazy-videos/assets/javascripts/lib/lazy-video-attributes.js @@ -0,0 +1,13 @@ +export default function getVideoAttributes(cooked) { + if (!cooked.classList.contains("lazy-video-container")) { + return {}; + } + + const url = cooked.querySelector("a")?.getAttribute("href"); + const thumbnail = cooked.querySelector("img")?.getAttribute("src"); + const title = cooked.dataset.videoTitle; + const providerName = cooked.dataset.providerName; + const id = cooked.dataset.videoId; + + return { url, thumbnail, title, providerName, id }; +} diff --git a/plugins/discourse-lazy-videos/assets/stylesheets/lazy-videos.scss b/plugins/discourse-lazy-videos/assets/stylesheets/lazy-videos.scss new file mode 100644 index 00000000000..1877a9ddaec --- /dev/null +++ b/plugins/discourse-lazy-videos/assets/stylesheets/lazy-videos.scss @@ -0,0 +1,137 @@ +.lazy-video-container { + z-index: z("base"); + position: relative; + display: block; + height: 0; + padding: 0 0 56.25% 0; + background-color: #000; + margin-bottom: 12px; + + .video-thumbnail { + cursor: pointer; + overflow: hidden; + height: 0; + padding: 0 0 56.25% 0; + + img { + object-fit: cover; + width: 100%; + pointer-events: none; + } + + &:hover, + &:focus { + .icon { + transform: translate(-50%, -50%) scale(1.1); + } + } + + &:focus { + outline: 5px auto Highlight; + outline: 5px auto -webkit-focus-ring-color; + } + + &:active { + outline: 0px; + } + } + + .title-container { + position: absolute; + display: flex; + align-items: center; + top: 0; + width: 100%; + height: 60px; + overflow: hidden; + background: linear-gradient(rgba(0, 0, 0, 0.6), rgba(255, 0, 0, 0)); + + .title-wrapper { + overflow: hidden; + padding-inline: 20px; + padding-block: 10px; + + .title-link { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #fff; + text-decoration: none; + font-size: 18px; + font-family: Arial, sans-serif; + + &:hover { + text-decoration: underline; + } + } + } + } + + iframe { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; + } + + .icon { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + transition: 150ms; + + // Default play button + background: svg-uri( + "" + ); + width: 60px; + height: 60px; + + &.youtube-icon { + width: 68px; + height: 48px; + background: svg-uri( + "" + ); + } + + &.vimeo-icon { + width: 77px; + height: 44px; + background: svg-uri( + "" + ); + } + + &.tiktok-icon { + width: 58px; + height: 64px; + background: svg-uri( + " " + ); + } + } +} + +// TikTok iframe isn't fluid +.lazy-video-container.tiktok-onebox { + width: 332px; + height: 745px; + padding: 0; + + .video-thumbnail.tiktok img { + height: 745px; + } + + iframe { + min-width: 332px; + height: 742px; + background-color: #fff; + border-top: 3px solid #fff; + border-radius: 9px; + } +} diff --git a/plugins/discourse-lazy-videos/config/locales/server.en.yml b/plugins/discourse-lazy-videos/config/locales/server.en.yml new file mode 100644 index 00000000000..cff25e5b6e4 --- /dev/null +++ b/plugins/discourse-lazy-videos/config/locales/server.en.yml @@ -0,0 +1,3 @@ +en: + site_settings: + lazy_videos_enabled: "Enable the Lazy Videos plugin" \ No newline at end of file diff --git a/plugins/discourse-lazy-videos/config/settings.yml b/plugins/discourse-lazy-videos/config/settings.yml new file mode 100644 index 00000000000..b89902684ca --- /dev/null +++ b/plugins/discourse-lazy-videos/config/settings.yml @@ -0,0 +1,17 @@ +plugins: + lazy_videos_enabled: + default: true + client: true + hidden: true + lazy_youtube_enabled: + default: true + client: true + hidden: false + lazy_vimeo_enabled: + default: true + client: true + hidden: false + lazy_tiktok_enabled: + default: false + client: true + hidden: false \ No newline at end of file diff --git a/plugins/discourse-lazy-videos/db/post_migrate/20230317194217_rebake_lazy_yt_posts.rb b/plugins/discourse-lazy-videos/db/post_migrate/20230317194217_rebake_lazy_yt_posts.rb new file mode 100644 index 00000000000..ab99a586b3f --- /dev/null +++ b/plugins/discourse-lazy-videos/db/post_migrate/20230317194217_rebake_lazy_yt_posts.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class RebakeLazyYtPosts < ActiveRecord::Migration[7.0] + def up + execute <<~SQL + UPDATE posts SET baked_version = 0 + WHERE cooked LIKE '%lazyYT-container%' + SQL + end + + def down + # do nothing + end +end diff --git a/plugins/discourse-lazy-videos/lib/lazy-videos/lazy_tiktok.rb b/plugins/discourse-lazy-videos/lib/lazy-videos/lazy_tiktok.rb new file mode 100644 index 00000000000..d667cce6948 --- /dev/null +++ b/plugins/discourse-lazy-videos/lib/lazy-videos/lazy_tiktok.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "onebox" + +class Onebox::Engine::TiktokOnebox + include Onebox::Engine + alias_method :default_onebox_to_html, :to_html + + def to_html + if SiteSetting.lazy_videos_enabled && SiteSetting.lazy_tiktok_enabled && + oembed_data.embed_product_id + thumbnail_url = oembed_data.thumbnail_url + escaped_title = ERB::Util.html_escape(oembed_data.title) + + <<~HTML +
+ + + +
+ HTML + else + default_onebox_to_html + end + end +end diff --git a/plugins/discourse-lazy-videos/lib/lazy-videos/lazy_vimeo.rb b/plugins/discourse-lazy-videos/lib/lazy-videos/lazy_vimeo.rb new file mode 100644 index 00000000000..5e983dd18ee --- /dev/null +++ b/plugins/discourse-lazy-videos/lib/lazy-videos/lazy_vimeo.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "onebox" + +class Onebox::Engine::VimeoOnebox + include Onebox::Engine + alias_method :default_onebox_to_html, :to_html + + def to_html + if SiteSetting.lazy_videos_enabled && SiteSetting.lazy_vimeo_enabled + video_id = oembed_data[:video_id] + thumbnail_url = "https://vumbnail.com/#{oembed_data[:video_id]}.jpg" + escaped_title = ERB::Util.html_escape(og_data.title) + + <<~HTML +
+ + + +
+ HTML + else + default_onebox_to_html + end + end +end diff --git a/plugins/discourse-lazy-videos/lib/lazy-videos/lazy_youtube.rb b/plugins/discourse-lazy-videos/lib/lazy-videos/lazy_youtube.rb new file mode 100644 index 00000000000..adf3e64e788 --- /dev/null +++ b/plugins/discourse-lazy-videos/lib/lazy-videos/lazy_youtube.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "onebox" + +class Onebox::Engine::YoutubeOnebox + include Onebox::Engine + alias_method :default_onebox_to_html, :to_html + + def to_html + if SiteSetting.lazy_videos_enabled && SiteSetting.lazy_youtube_enabled && video_id && + !params["list"] + result = parse_embed_response + result ||= get_opengraph.data + + thumbnail_url = "https://img.youtube.com/vi/#{video_id}/maxresdefault.jpg" + + begin + Onebox::Helpers.fetch_response(thumbnail_url) + rescue StandardError + thumbnail_url = result[:image] + end + + escaped_title = ERB::Util.html_escape(video_title) + + <<~HTML +
+ + + +
+ HTML + else + default_onebox_to_html + end + end +end diff --git a/plugins/discourse-lazy-videos/plugin.rb b/plugins/discourse-lazy-videos/plugin.rb new file mode 100644 index 00000000000..8875cad76af --- /dev/null +++ b/plugins/discourse-lazy-videos/plugin.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# name: discourse-lazy-videos +# about: Lazy loading for embedded videos +# version: 0.1 +# authors: Jan Cernik +# url: https://github.com/discourse/discourse-lazy-videos + +hide_plugin if self.respond_to?(:hide_plugin) +enabled_site_setting :lazy_videos_enabled + +register_asset "stylesheets/lazy-videos.scss" + +require_relative "lib/lazy-videos/lazy_youtube" +require_relative "lib/lazy-videos/lazy_vimeo" +require_relative "lib/lazy-videos/lazy_tiktok" + +after_initialize do + on(:reduce_cooked) do |fragment| + fragment + .css(".lazy-video-container") + .each do |video| + title = video["data-video-title"] + href = video.at_css("a")["href"] + video.replace("

#{title}

") + end + end +end diff --git a/plugins/discourse-lazy-videos/spec/components/pretty_text_spec.rb b/plugins/discourse-lazy-videos/spec/components/pretty_text_spec.rb new file mode 100644 index 00000000000..dd1c506770a --- /dev/null +++ b/plugins/discourse-lazy-videos/spec/components/pretty_text_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" +require "pretty_text" + +RSpec.describe PrettyText do + let(:post) { Fabricate(:post) } + + it "replaces lazy videos in emails" do + cooked_html = <<~HTML +
+ + + +
+ +
+ + + +
+ + HTML + + email_formated = <<~HTML +

15 Sorting Algorithms in 6 Minutes

+

Dear Rich

+ HTML + + expect(PrettyText.format_for_email(cooked_html, post)).to match_html(email_formated) + end +end diff --git a/plugins/discourse-lazy-videos/test/javascripts/components/lazy-video-test.js b/plugins/discourse-lazy-videos/test/javascripts/components/lazy-video-test.js new file mode 100644 index 00000000000..a1d2f54b4e3 --- /dev/null +++ b/plugins/discourse-lazy-videos/test/javascripts/components/lazy-video-test.js @@ -0,0 +1,49 @@ +import { setupRenderingTest } from "discourse/tests/helpers/component-test"; +import hbs from "htmlbars-inline-precompile"; +import { module, test } from "qunit"; +import { click, render } from "@ember/test-helpers"; + +module("Discourse Lazy Videos | Component | lazy-video", function (hooks) { + setupRenderingTest(hooks); + + this.attributes = { + url: "https://www.youtube.com/watch?v=kPRA0W1kECg", + thumbnail: "thumbnail.jpeg", + title: "15 Sorting Algorithms in 6 Minutes", + providerName: "youtube", + id: "kPRA0W1kECg", + }; + + test("displays the correct video title", async function (assert) { + await render(hbs``); + + assert.dom(".title-link").hasText(this.attributes.title); + }); + + test("displays the correct provider icon", async function (assert) { + await render(hbs``); + + assert.dom(".icon.youtube-icon").exists(); + }); + + test("loads the iframe when clicked", async function (assert) { + await render(hbs``); + assert.dom(".lazy-video-container.video-loaded").doesNotExist(); + + await click(".video-thumbnail.youtube"); + assert.dom(".lazy-video-container.video-loaded iframe").exists(); + }); + + test("accepts an optional onLoadedVideo callback function", async function (assert) { + this.set("foo", 1); + this.set("onLoadedVideo", () => this.set("foo", 2)); + + await render( + hbs`` + ); + assert.strictEqual(this.foo, 1); + + await click(".video-thumbnail.youtube"); + assert.strictEqual(this.foo, 2); + }); +}); diff --git a/plugins/lazy-yt/README.md b/plugins/lazy-yt/README.md deleted file mode 100644 index 5f0e7d7e0d1..00000000000 --- a/plugins/lazy-yt/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# lazy-yt - -Lazy load YouTube videos plugin for [Discourse](http://discourse.org), highly inspired by the [lazyYT](https://github.com/tylerpearson/lazyYT) jQuery plugin. diff --git a/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js b/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js deleted file mode 100644 index cbc4df3c684..00000000000 --- a/plugins/lazy-yt/assets/javascripts/initializers/lazyYT.js +++ /dev/null @@ -1,33 +0,0 @@ -import { withPluginApi } from "discourse/lib/plugin-api"; -import initLazyYt from "../lib/lazyYT"; - -export default { - name: "apply-lazyYT", - initialize() { - withPluginApi("0.1", (api) => { - initLazyYt($); - api.decorateCooked( - ($elem) => { - const iframes = $(".lazyYT", $elem); - if (iframes.length === 0) { - return; - } - - $(".lazyYT", $elem).lazyYT({ - onPlay(e, $el) { - // don't cloak posts that have playing videos in them - const postId = parseInt( - $el.closest("article").data("post-id"), - 10 - ); - if (postId) { - api.preventCloak(postId); - } - }, - }); - }, - { id: "discourse-lazyyt" } - ); - }); - }, -}; diff --git a/plugins/lazy-yt/assets/javascripts/lib/lazyYT.js b/plugins/lazy-yt/assets/javascripts/lib/lazyYT.js deleted file mode 100644 index 467515e4ee2..00000000000 --- a/plugins/lazy-yt/assets/javascripts/lib/lazyYT.js +++ /dev/null @@ -1,179 +0,0 @@ -/*! - * lazyYT (lazy load YouTube videos) - * v1.0.1 - 2014-12-30 - * (CC) This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. - * http://creativecommons.org/licenses/by-sa/4.0/ - * Contributors: https://github.com/tylerpearson/lazyYT/graphs/contributors || https://github.com/daugilas/lazyYT/graphs/contributors - * - * Usage:
loading...
- * - * Note: Discourse has forked this from the original, beware when updating the file. - * - */ - -import escape from "discourse-common/lib/escape"; - -export default function initLazyYt($) { - "use strict"; - - function setUp($el, settings) { - let width = $el.data("width"), - height = $el.data("height"), - ratio = $el.data("ratio") ? $el.data("ratio") : settings.default_ratio, - id = $el.data("youtube-id"), - title = $el.data("youtube-title"), - padding_bottom, - innerHtml = [], - $thumb, - thumb_img, - youtube_parameters = $el.data("parameters") || ""; - - ratio = ratio.split(":"); - - // width and height might override default_ratio value - if (typeof width === "number" && typeof height === "number") { - $el.width(width); - padding_bottom = height + "px"; - } else if (typeof width === "number") { - $el.width(width); - padding_bottom = (width * ratio[1]) / ratio[0] + "px"; - } else { - width = $el.width(); - - // no width means that container is fluid and will be the size of its parent - if (width === 0) { - width = $el.parent().width(); - } - - padding_bottom = (ratio[1] / ratio[0]) * 100 + "%"; - } - - // - // This HTML will be placed inside 'lazyYT' container - - innerHtml.push('
'); - - // Play button from YouTube (exactly as it is in YouTube) - innerHtml.push('
"); - innerHtml.push(""); - innerHtml.push( - '' - ); - innerHtml.push( - '' - ); - innerHtml.push(""); - innerHtml.push("
"); // end of .ytp-large-play-button - - innerHtml.push("
"); // end of .ytp-thumbnail - - // Video title (info bar) - innerHtml.push('
'); - innerHtml.push('"); // .html5-title-text-wrapper - innerHtml.push("
"); // end of Video title .html5-info-bar - - let prefetchedThumbnail = $el[0].querySelector(".ytp-thumbnail-image"); - - $el - .css({ - "padding-bottom": padding_bottom, - }) - .html(innerHtml.join("")); - - if (width > 640) { - thumb_img = "maxresdefault.jpg"; - } else if (width > 480) { - thumb_img = "sddefault.jpg"; - } else if (width > 320) { - thumb_img = "hqdefault.jpg"; - } else if (width > 120) { - thumb_img = "mqdefault.jpg"; - } else if (width === 0) { - // sometimes it fails on fluid layout - thumb_img = "hqdefault.jpg"; - } else { - thumb_img = "default.jpg"; - } - - if (prefetchedThumbnail) { - $el.find(".ytp-thumbnail").append(prefetchedThumbnail); - } else { - // Fallback for old posts which were baked before the lazy-yt onebox prefetched a thumbnail - $el - .find(".ytp-thumbnail") - .append( - $( - [ - '', - ].join("") - ) - ); - } - - $thumb = $el - .find(".ytp-thumbnail") - .addClass("lazyYT-image-loaded") - .on("keypress click", function (e) { - // Only support Enter for keypress - if (e.type === "keypress" && e.keyCode !== 13) { - return; - } - e.preventDefault(); - - if ( - !$el.hasClass("lazyYT-video-loaded") && - $thumb.hasClass("lazyYT-image-loaded") - ) { - $el - .html( - '' - ) - .addClass("lazyYT-video-loaded"); - } - - if (settings.onPlay) { - settings.onPlay(e, $el); - } - }); - } - - $.fn.lazyYT = function (newSettings) { - let defaultSettings = { - default_ratio: "16:9", - callback: null, // TODO: execute callback if given - container_class: "lazyYT-container", - }; - let settings = Object.assign(defaultSettings, newSettings); - - return this.each(function () { - let $el = $(this).addClass(settings.container_class); - setUp($el, settings); - }); - }; -} diff --git a/plugins/lazy-yt/assets/stylesheets/lazyYT.css b/plugins/lazy-yt/assets/stylesheets/lazyYT.css deleted file mode 100644 index 848b57a11bd..00000000000 --- a/plugins/lazy-yt/assets/stylesheets/lazyYT.css +++ /dev/null @@ -1,127 +0,0 @@ -/*! -* lazyYT (lazy load YouTube videos) -* v1.0.1 - 2014-12-30 -* (CC) This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License. -* http://creativecommons.org/licenses/by-sa/4.0/ -* Contributors: https://github.com/tylerpearson/lazyYT/graphs/contributors || https://github.com/daugilas/lazyYT/graphs/contributors -*/ - -.lazyYT-container { - position: relative; - z-index: z("base"); - display: block; - height: 0; - padding: 0 0 56.25% 0; - overflow: hidden; - background-color: #000000; - margin-bottom: 12px; -} - -.lazyYT-container iframe { - position: absolute; - top: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - border: 0; -} - -/* -* Video Title (YouTube style) -*/ - -.lazyYT-container .html5-info-bar { - position: absolute; - top: 0; - z-index: 935; - width: 100%; - height: 30px; - overflow: hidden; - font-family: Arial, sans-serif; - font-size: 12px; - color: #fff; - background-color: rgba(0, 0, 0, 0.8); - -webkit-transition: opacity 0.25s cubic-bezier(0, 0, 0.2, 1); - -moz-transition: opacity 0.25s cubic-bezier(0, 0, 0.2, 1); - transition: opacity 0.25s cubic-bezier(0, 0, 0.2, 1); -} - -.lazyYT-container .html5-title { - padding-right: 6px; - padding-left: 12px; -} - -.lazyYT-container .html5-title-text-wrapper { - overflow: hidden; - -o-text-overflow: ellipsis; - text-overflow: ellipsis; - word-wrap: normal; - white-space: nowrap; -} - -.lazyYT-container .html5-title-text { - width: 100%; - font-size: 13px; - line-height: 30px; - color: #ccc; - text-decoration: none; -} - -.lazyYT-container .html5-title-text:hover { - color: #fff; - text-decoration: underline; -} - -/* -* Thumbnail -*/ - -.ytp-thumbnail { - padding-bottom: inherit; - cursor: pointer; - background-position: 50% 50%; - background-repeat: no-repeat; - -webkit-background-size: cover; - -moz-background-size: cover; - -o-background-size: cover; - background-size: cover; -} - -/* -* Play button (YouTube style) -*/ - -.ytp-large-play-button { - position: absolute; - top: 50% !important; - left: 50% !important; - width: 86px !important; - height: 60px !important; - padding: 0 !important; - margin: -29px 0 0 -42px !important; - font-size: normal !important; - font-weight: normal !important; - line-height: 1 !important; - opacity: 0.9; - z-index: 935; -} - -.ytp-large-play-button-svg { - opacity: 0.9; - fill: #1f1f1f; -} - -.lazyYT-image-loaded:hover .ytp-large-play-button-svg, -.lazyYT-image-loaded:focus .ytp-large-play-button-svg, -.ytp-large-play-button:focus .ytp-large-play-button-svg { - opacity: 1; - fill: #cc181e; -} - -.ytp-thumbnail-image { - position: absolute; - width: 100%; - height: 100%; - object-fit: cover; -} diff --git a/plugins/lazy-yt/assets/stylesheets/lazyYT_mobile.scss b/plugins/lazy-yt/assets/stylesheets/lazyYT_mobile.scss deleted file mode 100644 index 149a8d9b84f..00000000000 --- a/plugins/lazy-yt/assets/stylesheets/lazyYT_mobile.scss +++ /dev/null @@ -1,3 +0,0 @@ -.lazyYT { - max-width: 100%; -} diff --git a/plugins/lazy-yt/config/locales/server.en.yml b/plugins/lazy-yt/config/locales/server.en.yml deleted file mode 100644 index 1ac0bee45f9..00000000000 --- a/plugins/lazy-yt/config/locales/server.en.yml +++ /dev/null @@ -1,3 +0,0 @@ -en: - site_settings: - lazy_yt_enabled: "Enable the LazyYT plugin" diff --git a/plugins/lazy-yt/config/settings.yml b/plugins/lazy-yt/config/settings.yml deleted file mode 100644 index cca346d5df1..00000000000 --- a/plugins/lazy-yt/config/settings.yml +++ /dev/null @@ -1,5 +0,0 @@ -plugins: - lazy_yt_enabled: - default: true - client: false - hidden: true diff --git a/plugins/lazy-yt/plugin.rb b/plugins/lazy-yt/plugin.rb deleted file mode 100644 index 5fb481c041a..00000000000 --- a/plugins/lazy-yt/plugin.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -# name: lazy-yt -# about: Uses the lazyYT plugin to lazy load Youtube videos -# version: 1.0.1 -# authors: Arpit Jalan -# url: https://github.com/discourse/discourse/tree/main/plugins/lazy-yt - -hide_plugin if self.respond_to?(:hide_plugin) -enabled_site_setting :lazy_yt_enabled - -require "onebox" - -# stylesheet -register_asset "stylesheets/lazyYT.css" -register_asset "stylesheets/lazyYT_mobile.scss", :mobile - -# freedom patch YouTube Onebox -class Onebox::Engine::YoutubeOnebox - include Onebox::Engine - alias_method :yt_onebox_to_html, :to_html - - def to_html - if SiteSetting.lazy_yt_enabled && video_id && !params["list"] - size_restricted = [params["width"], params["height"]].any? - video_width = (params["width"] && params["width"].to_i <= 695) ? params["width"] : 690 # embed width - video_height = (params["height"] && params["height"].to_i <= 500) ? params["height"] : 388 # embed height - size_tags = ["width=\"#{video_width}\"", "height=\"#{video_height}\""] - - result = parse_embed_response - result ||= get_opengraph.data - - thumbnail_url = result[:image] || "https://img.youtube.com/vi/#{video_id}/hqdefault.jpg" - - # Put in the LazyYT div instead of the iframe - escaped_title = ERB::Util.html_escape(video_title) - - <<~HTML -
- - - -
- HTML - else - yt_onebox_to_html - end - end -end - -after_initialize do - on(:reduce_cooked) do |fragment| - fragment - .css(".lazyYT") - .each do |yt| - begin - youtube_id = yt["data-youtube-id"] - parameters = yt["data-parameters"] - uri = URI("https://www.youtube.com/embed/#{youtube_id}?autoplay=1&#{parameters}") - yt.replace %{

https://#{uri.host}#{uri.path}

} - rescue URI::InvalidURIError - # remove any invalid/weird URIs - yt.remove - end - end - end -end diff --git a/spec/lib/pretty_text_spec.rb b/spec/lib/pretty_text_spec.rb index ac11ef85e21..cf53fc17f72 100644 --- a/spec/lib/pretty_text_spec.rb +++ b/spec/lib/pretty_text_spec.rb @@ -1019,12 +1019,30 @@ RSpec.describe PrettyText do expect(extract_urls(html)).to eq(["https://example.com"]) end - it "should lazyYT videos" do - expect( - extract_urls( - "
", - ), - ).to eq(["https://www.youtube.com/watch?v=yXEuEUQIP3Q"]) + context "when lazy-videos" do + it "should extract youtube url" do + expect( + extract_urls( + "
", + ), + ).to eq(["https://www.youtube.com/watch?v=yXEuEUQIP3Q"]) + end + + it "should extract vimeo url" do + expect( + extract_urls( + "
", + ), + ).to eq(["https://vimeo.com/786646692"]) + end + + it "should extract tiktok url" do + expect( + extract_urls( + "
", + ), + ).to eq(["https://m.tiktok.com/v/6718335390845095173"]) + end end it "should extract links to posts" do diff --git a/spec/services/search_indexer_spec.rb b/spec/services/search_indexer_spec.rb index b8c1cc8b9eb..f784fcd83cb 100644 --- a/spec/services/search_indexer_spec.rb +++ b/spec/services/search_indexer_spec.rb @@ -28,7 +28,7 @@ RSpec.describe SearchIndexer do it "extract youtube title" do html = - "
" + "
" scrubbed = SearchIndexer::HtmlScrubber.scrub(html) expect(scrubbed).to eq( "Metallica Mixer Explains Missing Bass on 'And Justice for All' [Exclusive]",