diff --git a/app/assets/javascripts/discourse/app/initializers/sticky-avatars.js b/app/assets/javascripts/discourse/app/initializers/sticky-avatars.js new file mode 100644 index 00000000000..401a831272f --- /dev/null +++ b/app/assets/javascripts/discourse/app/initializers/sticky-avatars.js @@ -0,0 +1,10 @@ +import StickyAvatars from "discourse/lib/sticky-avatars"; + +export default { + name: "sticky-avatars", + after: "inject-objects", + + initialize(container) { + StickyAvatars.init(container); + }, +}; diff --git a/app/assets/javascripts/discourse/app/lib/sticky-avatars.js b/app/assets/javascripts/discourse/app/lib/sticky-avatars.js new file mode 100644 index 00000000000..31f03da871d --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/sticky-avatars.js @@ -0,0 +1,110 @@ +import { addWidgetCleanCallback } from "discourse/components/mount-widget"; +import Site from "discourse/models/site"; +import { bind } from "discourse-common/utils/decorators"; +import { schedule } from "@ember/runloop"; + +export default class StickyAvatars { + stickyClass = "sticky-avatar"; + topicPostSelector = "#topic .post-stream .topic-post"; + intersectionObserver = null; + direction = "⬇️"; + prevOffset = -1; + + static init(container) { + new this(container).init(); + } + + constructor(container) { + this.container = container; + } + + init() { + if (Site.currentProp("mobileView") || !("IntersectionObserver" in window)) { + return; + } + + const appEvents = this.container.lookup("service:app-events"); + appEvents.on("topic:current-post-scrolled", this._handlePostNodes); + appEvents.on("topic:scrolled", this._handleScroll); + appEvents.on("page:topic-loaded", this._initIntersectionObserver); + + addWidgetCleanCallback("post-stream", this._clearIntersectionObserver); + } + + @bind + _handleScroll(offset) { + if (offset <= 0) { + this.direction = "⬇️"; + document + .querySelectorAll(`${this.topicPostSelector}.${this.stickyClass}`) + .forEach((node) => node.classList.remove(this.stickyClass)); + } else if (offset > this.prevOffset) { + this.direction = "⬇️"; + } else { + this.direction = "⬆️"; + } + this.prevOffset = offset; + } + + @bind + _handlePostNodes() { + this._clearIntersectionObserver(); + this._initIntersectionObserver(); + + schedule("afterRender", () => { + document.querySelectorAll(this.topicPostSelector).forEach((postNode) => { + this.intersectionObserver.observe(postNode); + + const topicAvatarNode = postNode.querySelector(".topic-avatar"); + if (!topicAvatarNode || !postNode.querySelector("#post_1")) { + return; + } + + const topicMapNode = postNode.querySelector(".topic-map"); + if (!topicMapNode) { + return; + } + topicAvatarNode.style.marginBottom = `${topicMapNode.clientHeight}px`; + }); + }); + } + + @bind + _initIntersectionObserver() { + schedule("afterRender", () => { + const headerOffset = + parseInt( + getComputedStyle(document.body).getPropertyValue("--header-offset"), + 10 + ) || 0; + const headerHeight = Math.max(headerOffset, 0); + + this.intersectionObserver = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (!entry.isIntersecting || entry.intersectionRatio === 1) { + entry.target.classList.remove(this.stickyClass); + return; + } + + const postContentHeight = entry.target.querySelector(".contents") + ?.clientHeight; + if ( + this.direction === "⬆️" || + postContentHeight > window.innerHeight - headerHeight + ) { + entry.target.classList.add(this.stickyClass); + } + }); + }, + { threshold: [0.0, 1.0], rootMargin: `-${headerHeight}px 0px 0px 0px` } + ); + }); + } + + @bind + _clearIntersectionObserver() { + this.intersectionObserver?.disconnect(); + this.intersectionObserver = null; + } +} diff --git a/app/assets/javascripts/discourse/tests/acceptance/sticky-avatars-test.js b/app/assets/javascripts/discourse/tests/acceptance/sticky-avatars-test.js new file mode 100644 index 00000000000..9fed8842c34 --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/sticky-avatars-test.js @@ -0,0 +1,29 @@ +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; +import { test } from "qunit"; +import { find, scrollTo, visit, waitUntil } from "@ember/test-helpers"; +import { setupApplicationTest as EMBER_CLI_ENV } from "ember-qunit"; + +acceptance("Sticky Avatars", function (needs) { + if (!EMBER_CLI_ENV) { + return; // helpers not available in legacy env + } + + const container = document.getElementById("ember-testing-container"); + + needs.hooks.beforeEach(function () { + container.scrollTop = 0; + }); + + test("Adds sticky avatars when scrolling up", async function (assert) { + await visit("/t/internationalization-localization/280"); + + await scrollTo(container, 0, 800); + await scrollTo(container, 0, 700); + + await waitUntil(() => find(".sticky-avatar")); + assert.ok( + find("#post_5").parentElement.classList.contains("sticky-avatar"), + "Sticky avatar is applied" + ); + }); +}); diff --git a/app/assets/stylesheets/desktop/topic-post.scss b/app/assets/stylesheets/desktop/topic-post.scss index ab3737de0f8..0ac61f4dede 100644 --- a/app/assets/stylesheets/desktop/topic-post.scss +++ b/app/assets/stylesheets/desktop/topic-post.scss @@ -769,6 +769,14 @@ span.highlighted { transition: visibility 1s, opacity ease-out 1s; } +.topic-post.sticky-avatar { + .topic-avatar { + position: sticky; + top: calc(var(--header-offset) - 0.25em); + margin-bottom: 25px; + } +} + /* Tablet (portrait) ----------- */ @media all and (max-width: 790px) {