DEV: `lib/user-presence` improvements (#15046)

DEV: lib/user-presence improvements (#15046)

  • Remove JQuery
  • Remove legacy document.webkitHidden support. None of our currently supported browsers need this
  • Use passive event listeners. These allows the browser to process the events first, before passing control to us
  • Add a new unseenTime parameter. This allows consumers to request a delay before being notified about the browser going into the background
  • Add a method for removing a callback
  • Fire the callback when presence changes in either direction. Previously it would only fire when the user becomes present after a period of inactivity.
  • Ensure callbacks are only called once for each state change. Previously they would be called every 60s, regardless of the value
  • Listen to the visibilitychanged and focus events, treating them as equivalent to user action. This will make messagebus re-activate more quickly when switching back to a stale tab
  • Add test helpers
  • Delete the unused discourse/lib/page-visible module.
  • Call message-bus’s onVisibilityChange API directly, rather than dispatching a fake event on the document
diff --git a/app/assets/javascripts/discourse/app/initializers/message-bus.js b/app/assets/javascripts/discourse/app/initializers/message-bus.js
index b7964f6..fff5732 100644
--- a/app/assets/javascripts/discourse/app/initializers/message-bus.js
+++ b/app/assets/javascripts/discourse/app/initializers/message-bus.js
@@ -46,7 +46,7 @@ export default {
 
     messageBus.alwaysLongPoll = !isProduction();
     messageBus.shouldLongPollCallback = () =>
-      userPresent(LONG_POLL_AFTER_UNSEEN_TIME);
+      userPresent({ userUnseenTime: LONG_POLL_AFTER_UNSEEN_TIME });
 
     // we do not want to start anything till document is complete
     messageBus.stop();
@@ -56,7 +56,11 @@ export default {
     // When 20 minutes pass we stop long polling due to "shouldLongPollCallback".
     onPresenceChange({
       unseenTime: LONG_POLL_AFTER_UNSEEN_TIME,
-      callback: () => document.dispatchEvent(new Event("visibilitychange")),
+      callback: (present) => {
+        if (present && messageBus.onVisibilityChange) {
+          messageBus.onVisibilityChange();
+        }
+      },
     });
 
     if (siteSettings.login_required && !user) {
diff --git a/app/assets/javascripts/discourse/app/lib/page-visible.js b/app/assets/javascripts/discourse/app/lib/page-visible.js
deleted file mode 100644
index 0771a2f..0000000
--- a/app/assets/javascripts/discourse/app/lib/page-visible.js
+++ /dev/null
@@ -1,15 +0,0 @@
-// for android we test webkit
-let hiddenProperty =
-  document.hidden !== undefined
-    ? "hidden"
-    : document.webkitHidden !== undefined
-    ? "webkitHidden"
-    : undefined;
-
-export default function () {
-  if (hiddenProperty !== undefined) {
-    return !document[hiddenProperty];
-  } else {
-    return document && document.hasFocus;
-  }
-}
diff --git a/app/assets/javascripts/discourse/app/lib/user-presence.js b/app/assets/javascripts/discourse/app/lib/user-presence.js
index 92f1711..7fe1af6 100644
--- a/app/assets/javascripts/discourse/app/lib/user-presence.js
+++ b/app/assets/javascripts/discourse/app/lib/user-presence.js
@@ -1,68 +1,138 @@
-// for android we test webkit
-const hiddenProperty =
-  document.hidden !== undefined
-    ? "hidden"
-    : document.webkitHidden !== undefined
-    ? "webkitHidden"
-    : undefined;
+import { isTesting } from "discourse-common/config/environment";
 
-const MAX_UNSEEN_TIME = 60000;
+const callbacks = [];
+
+const DEFAULT_USER_UNSEEN_MS = 60000;
+const DEFAULT_BROWSER_HIDDEN_MS = 0;
+
+let browserHiddenAt = null;
+let lastUserActivity = Date.now();
+let userSeenJustNow = false;
 
-let seenUserTime = Date.now();
+let callbackWaitingForPresence = false;
 
-export default function (maxUnseenTime) {
-  maxUnseenTime = maxUnseenTime === undefined ? MAX_UNSEEN_TIME : maxUnseenTime;
-  const now = Date.now();
+let testPresence = true;
+
+// Check whether the document is currently visible, and the user is actively using the site
+// Will return false if the browser went into the background more than `browserHiddenTime` milliseconds ago
+// Will also return false if there has been no user activty for more than `userUnseenTime` milliseconds
+// Otherwise, will return true
+export default function userPresent({
+  browserHiddenTime = DEFAULT_BROWSER_HIDDEN_MS,
+  userUnseenTime = DEFAULT_USER_UNSEEN_MS,
+} = {}) {
+  if (isTesting()) {
+    return testPresence;
+  }
 
-  if (seenUserTime + maxUnseenTime < now) {
+  if (browserHiddenAt) {
+    const timeSinceBrowserHidden = Date.now() - browserHiddenAt;
+    if (timeSinceBrowserHidden >= browserHiddenTime) {
+      return false;
+    }
+  }
+
+  const timeSinceUserActivity = Date.now() - lastUserActivity;
+  if (timeSinceUserActivity >= userUnseenTime) {
     return false;
   }
 
-  if (hiddenProperty !== undefined) {
-    return !document[hiddenProperty];
-  } else {
-    return document && document.hasFocus;
+  return true;
+}
+
+// Register a callback to be triggered when the value of `userPresent()` changes.
+// userUnseenTime and browserHiddenTime work the same as for `userPresent()`
+// 'not present' callbacks may lag by up to 10s, depending on the reason
+// 'now present' callbacks should be almost instantaneous
+export function onPresenceChange({
+  userUnseenTime = DEFAULT_USER_UNSEEN_MS,
+  browserHiddenTime = DEFAULT_BROWSER_HIDDEN_MS,
+  callback,
+} = {}) {
+  if (userUnseenTime < DEFAULT_USER_UNSEEN_MS) {
+    throw `userUnseenTime must be at least ${DEFAULT_USER_UNSEEN_MS}`;
   }
+  callbacks.push({
+    userUnseenTime,
+    browserHiddenTime,
+    lastState: true,
+    callback,
+  });
 }
 
-const callbacks = [];
+export function removeOnPresenceChange(callback) {
+  const i = callbacks.findIndex((c) => c.callback === callback);
+  callbacks.splice(i, 1);
+}
+
+function processChanges() {
+  const browserHidden = document.hidden;
+  if (!!browserHiddenAt !== browserHidden) {
+    browserHiddenAt = browserHidden ? Date.now() : null;
+  }
 
-const MIN_DELTA = 60000;
+  if (userSeenJustNow) {
+    lastUserActivity = Date.now();
+    userSeenJustNow = false;
+  }
 
-export function seenUser() {
-  let lastSeenTime = seenUserTime;
-  seenUserTime = Date.now();
-  let delta = seenUserTime - lastSeenTime;
-
-  if (lastSeenTime && delta > MIN_DELTA) {
-    callbacks.forEach((info) => {
-      if (delta > info.unseenTime) {
-        info.callback();
-      }
+  callbackWaitingForPresence = false;
+  for (const callback of callbacks) {
+    const currentState = userPresent({
+      userUnseenTime: callback.userUnseenTime,
+      browserHiddenTime: callback.browserHiddenTime,
     });
+
+    if (callback.lastState !== currentState) {
+      try {
+        callback.callback(currentState);
+      } finally {
+        callback.lastState = currentState;
+      }
+    }
+
+    if (!currentState) {
+      callbackWaitingForPresence = true;
+    }
+  }
+}
+
+export function seenUser() {
+  userSeenJustNow = true;
+  if (callbackWaitingForPresence) {
+    processChanges();
+  }
+}
+
+export function visibilityChanged() {
+  if (document.hidden) {
+    processChanges();
+  } else {
+    seenUser();
   }
 }
 
-// register a callback for cases where presence changed
-export function onPresenceChange({ unseenTime, callback }) {
-  if (unseenTime < MIN_DELTA) {
-    throw "unseenTime is too short";
+export function setTestPresence(value) {
+  if (!isTesting()) {
+    throw "Only available in test mode";
   }
-  callbacks.push({ unseenTime, callback });
+  testPresence = value;
+}
+
+export function clearPresenceCallbacks() {
+  callbacks.splice(0, callbacks.length);
 }
 
-// We could piggieback on the Scroll mixin, but it is not applied
-// consistently to all pages
-//
-// We try to keep this as cheap as possible by performing absolute minimal
-// amount of work when the event handler is fired
-//
-// An alternative would be to use a timer that looks at the scroll position
-// however this will not work as message bus can issue page updates and scroll
-// page around when user is not present
-//
-// We avoid tracking mouse move which would be very expensive
-
-$(document).bind("touchmove.discourse-track-presence", seenUser);
-$(document).bind("click.discourse-track-presence", seenUser);
-$(window).bind("scroll.discourse-track-presence", seenUser);
+if (!isTesting()) {
+  // Some of these events occur very frequently. Therefore seenUser() is as fast as possible.
+  document.addEventListener("touchmove", seenUser, { passive: true });
+  document.addEventListener("click", seenUser, { passive: true });
+  window.addEventListener("scroll", seenUser, { passive: true });
+  window.addEventListener("focus", seenUser, { passive: true });
+
+  document.addEventListener("visibilitychange", visibilityChanged, {
+    passive: true,
+  });
+
+  setInterval(processChanges, 10000);
+}
diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js
index 84e9aa5..956deeb 100644

[... diff too long, it was truncated ...]

GitHub sha: fd93d6f955302907aa5e6ee96ff24e038464f04a

This commit appears in #15046 which was approved by eviltrout. It was merged by davidtaylorhq.

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