FEATURE: Move sticky avatars into core

FEATURE: Move sticky avatars into core

This patch takes the small component we had for sticky avatars and adds it into our core code base.

A small refactor has been made to have a StickyAvatars dedicated class.

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 0000000..401a831
--- /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 0000000..31f03da
--- /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 0000000..9fed884
--- /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 ab3737d..0ac61f4 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) {

GitHub sha: c2be7c65e87abb76f11298248347d94654925ee4

This commit appears in #14739 which was approved by pmusaraj and jjaffeux. It was merged by Flink.

This commit has been mentioned on Discourse Meta. There might be relevant details there:

This commit has been mentioned on Discourse Meta. There might be relevant details there: