diff --git a/addons/website/__manifest__.py b/addons/website/__manifest__.py
index c5cf214e4e2..49fe6145843 100644
--- a/addons/website/__manifest__.py
+++ b/addons/website/__manifest__.py
@@ -38,6 +38,7 @@
'views/snippets/s_text_cover.xml',
'views/snippets/s_text_image.xml',
'views/snippets/s_image_text.xml',
+ 'views/snippets/s_instagram_page.xml',
'views/snippets/s_banner.xml',
'views/snippets/s_text_block.xml',
'views/snippets/s_features.xml',
@@ -219,6 +220,7 @@
'website/static/src/snippets/s_facebook_page/options.js',
'website/static/src/snippets/s_image_gallery/options.js',
'website/static/src/snippets/s_image_gallery/000.xml',
+ 'website/static/src/snippets/s_instagram_page/options.js',
'website/static/src/snippets/s_countdown/options.js',
'website/static/src/snippets/s_countdown/options.xml',
'website/static/src/snippets/s_masonry_block/options.js',
diff --git a/addons/website/static/src/js/editor/snippets.options.js b/addons/website/static/src/js/editor/snippets.options.js
index 3a398417b45..ee66601e1fc 100644
--- a/addons/website/static/src/js/editor/snippets.options.js
+++ b/addons/website/static/src/js/editor/snippets.options.js
@@ -924,7 +924,13 @@ options.Class.include({
_select: async function (previewMode, widget) {
await this._super(...arguments);
- if (this.options.isWebsite && !widget.$el.closest('[data-no-widget-refresh="true"]').length) {
+ // Some blocks flicker when we start their public widgets, so we skip
+ // the refresh for them to avoid the flickering.
+ const targetNoRefreshSelector = ".s_instagram_page";
+ // TODO: we should review the way public widgets are restarted when
+ // converting to OWL and a new API.
+ if (this.options.isWebsite && !widget.$el.closest('[data-no-widget-refresh="true"]').length
+ && !this.$target[0].matches(targetNoRefreshSelector)) {
// TODO the flag should be retrieved through widget params somehow
await this._refreshPublicWidgets();
}
diff --git a/addons/website/static/src/scss/website.edit_mode.scss b/addons/website/static/src/scss/website.edit_mode.scss
index ae614e90a5f..fe72f699d41 100644
--- a/addons/website/static/src/scss/website.edit_mode.scss
+++ b/addons/website/static/src/scss/website.edit_mode.scss
@@ -20,6 +20,11 @@ $-editor-messages-margin-x: 2%;
display: block;
}
}
+%o-disable-iframes {
+ iframe {
+ pointer-events: none;
+ }
+}
.o_we_snippet_area_animation {
animation-delay: 999ms; // Disable it but allow to inherit the animation
@@ -203,14 +208,17 @@ table.editorbar-panel {
// Facebook Page
.editor_enable .o_facebook_page:not(.o_facebook_preview) {
- iframe {
- pointer-events: none;
- }
+ @extend %o-disable-iframes;
.o_facebook_alert .o_add_facebook_page {
cursor: pointer;
}
}
+// Prevent Instagram block to open Instagram in edit mode.
+.editor_enable .s_instagram_page {
+ @extend %o-disable-iframes;
+}
+
// s_countdown preview classes
body.editor_enable {
.s_countdown {
diff --git a/addons/website/static/src/snippets/s_instagram_page/000.js b/addons/website/static/src/snippets/s_instagram_page/000.js
new file mode 100644
index 00000000000..d3355b03190
--- /dev/null
+++ b/addons/website/static/src/snippets/s_instagram_page/000.js
@@ -0,0 +1,110 @@
+/** @odoo-module **/
+
+import publicWidget from "@web/legacy/js/public/public_widget";
+
+// Note that Instagram can automatically detect the language of the user and
+// translate the embed.
+
+const InstagramPage = publicWidget.Widget.extend({
+ selector: ".s_instagram_page",
+ disabledInEditableMode: false,
+
+ /**
+ * @override
+ */
+ start() {
+ const iframeEl = document.createElement("iframe");
+ this.el.querySelector(".o_instagram_container").appendChild(iframeEl);
+ iframeEl.setAttribute("scrolling", "no");
+ iframeEl.classList.add("w-100");
+ // We can already estimate the height of the iframe.
+ iframeEl.height = this._estimateIframeHeight();
+ // We have to setup the message listener before setting the src, because
+ // the iframe can send a message before this JS is fully loaded.
+ this.__onMessage = this._onMessage.bind(this);
+ window.addEventListener("message", this.__onMessage);
+ // We set the src now, we are ready to receive the message.
+ iframeEl.src = `https://www.instagram.com/${this.el.dataset.instagramPage}/embed`;
+
+ return this._super(...arguments);
+ },
+ /**
+ * @override
+ */
+ destroy() {
+ const iframeEl = this.el.querySelector(".o_instagram_container iframe");
+ if (iframeEl) {
+ iframeEl.remove();
+ window.removeEventListener("message", this.__onMessage);
+ }
+ this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Gives an estimation of the height of the Instagram iframe.
+ *
+ * @private
+ * @returns {number}
+ */
+ _estimateIframeHeight() {
+ // In the meantime Instagram doesn't send us a message with the height,
+ // we use a formula to estimate the height of the iframe (the formula
+ // has been found with a linear regression).
+ const iframeEl = this.el.querySelector(".o_instagram_container iframe");
+ const iframeWidth = parseInt(getComputedStyle(iframeEl).width);
+ // The profile picture is smaller when width < 432px.
+ return 0.659 * iframeWidth + (iframeWidth < 432 ? 156 : 203);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * Called when a message is sent. Instagram sends us a message with the
+ * height of the iframe.
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onMessage(ev) {
+ const iframeEl = this.el.querySelector(".o_instagram_container iframe");
+ if (!iframeEl) {
+ // TODO: fix this case. We should never end up here. It happens when
+ // - Drop Instagram snippet
+ // - Undo
+ // - Drop a new Instagram snippet
+ // => The listener of the first one is still active because the
+ // public widget has not been destroyed.
+ window.removeEventListener("message", this.__onMessage);
+ return;
+ }
+ if (ev.origin !== "https://www.instagram.com" || iframeEl.contentWindow !== ev.source) {
+ // It's not a message from Instagram or it's a message from another
+ // Instagram iframe.
+ return;
+ }
+ const evDataJSON = JSON.parse(ev.data);
+ if (evDataJSON.type !== "MEASURE") {
+ // It's not a measure message.
+ return;
+ }
+ const height = parseInt(evDataJSON.details.height);
+ // Here we get the exact height of the iframe.
+ // Instagram can return a height of 0 before the real height.
+ if (height) {
+ // Prevent history step in edit mode.
+ this.options.wysiwyg?.odooEditor.observerUnactive();
+ iframeEl.height = height;
+ this.options.wysiwyg?.odooEditor.observerActive();
+ }
+ },
+});
+
+publicWidget.registry.InstagramPage = InstagramPage;
+
+export default InstagramPage;
diff --git a/addons/website/static/src/snippets/s_instagram_page/000.scss b/addons/website/static/src/snippets/s_instagram_page/000.scss
new file mode 100644
index 00000000000..f0e9c2f1161
--- /dev/null
+++ b/addons/website/static/src/snippets/s_instagram_page/000.scss
@@ -0,0 +1,5 @@
+.s_instagram_page {
+ div.container-fluid {
+ padding: 0;
+ }
+}
diff --git a/addons/website/static/src/snippets/s_instagram_page/options.js b/addons/website/static/src/snippets/s_instagram_page/options.js
new file mode 100644
index 00000000000..63c852c0159
--- /dev/null
+++ b/addons/website/static/src/snippets/s_instagram_page/options.js
@@ -0,0 +1,103 @@
+/** @odoo-module **/
+
+import {_t} from "@web/core/l10n/translation";
+import options from "@web_editor/js/editor/snippets.options";
+import SocialMediaOption from "@website/snippets/s_social_media/options";
+
+options.registry.InstagramPage = options.Class.extend({
+ /**
+ * @override
+ */
+ init() {
+ this._super(...arguments);
+ this.orm = this.bindService("orm");
+ this.instagramUrlStr = "instagram.com/";
+ },
+ /**
+ * @override
+ */
+ async onBuilt() {
+ // First we check if the user has changed his instagram during the
+ // current edition (via the social media options).
+ const dbSocialValuesCache = SocialMediaOption.getDbSocialValuesCache();
+ let socialInstagram = dbSocialValuesCache && dbSocialValuesCache["social_instagram"];
+ // If not, we check the value in the DB.
+ if (!socialInstagram) {
+ let websiteId;
+ this.trigger_up("context_get", {
+ callback: function (ctx) {
+ websiteId = ctx["website_id"];
+ },
+ });
+ const values = await this.orm.read("website", [websiteId], ["social_instagram"]);
+ socialInstagram = values[0]["social_instagram"];
+ }
+ if (socialInstagram) {
+ const pageName = this._getInstagramPageNameFromUrl(socialInstagram);
+ if (pageName) {
+ this.$target[0].dataset.instagramPage = pageName;
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Options
+ //--------------------------------------------------------------------------
+
+ /**
+ * Registers the instagram page name.
+ *
+ * @see this.selectClass for parameters
+ */
+ async setInstagramPage(previewMode, widgetValue, params) {
+ if (widgetValue.includes(this.instagramUrlStr)) {
+ widgetValue = this._getInstagramPageNameFromUrl(widgetValue);
+ }
+ if (!widgetValue) {
+ this.displayNotification({
+ message: _t("The Instagram page name is not valid"),
+ type: "warning",
+ });
+ }
+ this.$target[0].dataset.instagramPage = widgetValue || "";
+ // As the public widget restart is disabled for instagram, we have to
+ // manually restart the widget.
+ await this.trigger_up("widgets_start_request", {
+ $target: this.$target,
+ editableMode: true,
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _computeWidgetState(widgetName, params) {
+ if (widgetName === "setInstagramPage") {
+ return this.$target[0].dataset.instagramPage;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * Returns the instagram page name from the given url.
+ *
+ * @private
+ * @param {string} url
+ * @returns {string|undefined}
+ */
+ _getInstagramPageNameFromUrl(url) {
+ const pageName = url.split(this.instagramUrlStr)[1];
+ if (!pageName || pageName.includes("?") || pageName.includes("#") ||
+ (pageName.includes("/") && pageName.split("/")[1].length > 0)) {
+ return;
+ }
+ return pageName.split("/")[0];
+ },
+});
+
+export default {
+ InstagramPage: options.registry.InstagramPage,
+};
diff --git a/addons/website/static/src/snippets/s_social_media/options.js b/addons/website/static/src/snippets/s_social_media/options.js
index 63e4b07f6b7..d0602ec69cf 100644
--- a/addons/website/static/src/snippets/s_social_media/options.js
+++ b/addons/website/static/src/snippets/s_social_media/options.js
@@ -11,6 +11,9 @@ const clearDbSocialValuesCache = () => {
dbSocialValuesProm = undefined;
dbSocialValues = undefined;
};
+const getDbSocialValuesCache = () => {
+ return dbSocialValues;
+};
options.registry.SocialMedia = options.Class.extend({
init() {
@@ -365,4 +368,5 @@ options.registry.SocialMedia = options.Class.extend({
export default {
SocialMedia: options.registry.SocialMedia,
clearDbSocialValuesCache,
+ getDbSocialValuesCache,
};
diff --git a/addons/website/views/snippets/s_instagram_page.xml b/addons/website/views/snippets/s_instagram_page.xml
new file mode 100644
index 00000000000..03246bb3801
--- /dev/null
+++ b/addons/website/views/snippets/s_instagram_page.xml
@@ -0,0 +1,40 @@
+
+