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`
This commit is contained in:
David Taylor 2021-11-25 12:07:07 +00:00 committed by GitHub
parent 6e2d4a14ac
commit fd93d6f955
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 140 additions and 73 deletions

View File

@ -46,7 +46,7 @@ export default {
messageBus.alwaysLongPoll = !isProduction(); messageBus.alwaysLongPoll = !isProduction();
messageBus.shouldLongPollCallback = () => 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 // we do not want to start anything till document is complete
messageBus.stop(); messageBus.stop();
@ -56,7 +56,11 @@ export default {
// When 20 minutes pass we stop long polling due to "shouldLongPollCallback". // When 20 minutes pass we stop long polling due to "shouldLongPollCallback".
onPresenceChange({ onPresenceChange({
unseenTime: LONG_POLL_AFTER_UNSEEN_TIME, 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) { if (siteSettings.login_required && !user) {

View File

@ -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;
}
}

View File

@ -1,68 +1,138 @@
// for android we test webkit import { isTesting } from "discourse-common/config/environment";
const hiddenProperty =
document.hidden !== undefined
? "hidden"
: document.webkitHidden !== undefined
? "webkitHidden"
: undefined;
const MAX_UNSEEN_TIME = 60000;
let seenUserTime = Date.now();
export default function (maxUnseenTime) {
maxUnseenTime = maxUnseenTime === undefined ? MAX_UNSEEN_TIME : maxUnseenTime;
const now = Date.now();
if (seenUserTime + maxUnseenTime < now) {
return false;
}
if (hiddenProperty !== undefined) {
return !document[hiddenProperty];
} else {
return document && document.hasFocus;
}
}
const callbacks = []; const callbacks = [];
const MIN_DELTA = 60000; const DEFAULT_USER_UNSEEN_MS = 60000;
const DEFAULT_BROWSER_HIDDEN_MS = 0;
let browserHiddenAt = null;
let lastUserActivity = Date.now();
let userSeenJustNow = false;
let callbackWaitingForPresence = false;
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 (browserHiddenAt) {
const timeSinceBrowserHidden = Date.now() - browserHiddenAt;
if (timeSinceBrowserHidden >= browserHiddenTime) {
return false;
}
}
const timeSinceUserActivity = Date.now() - lastUserActivity;
if (timeSinceUserActivity >= userUnseenTime) {
return false;
}
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,
});
}
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;
}
if (userSeenJustNow) {
lastUserActivity = Date.now();
userSeenJustNow = false;
}
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() { export function seenUser() {
let lastSeenTime = seenUserTime; userSeenJustNow = true;
seenUserTime = Date.now(); if (callbackWaitingForPresence) {
let delta = seenUserTime - lastSeenTime; processChanges();
if (lastSeenTime && delta > MIN_DELTA) {
callbacks.forEach((info) => {
if (delta > info.unseenTime) {
info.callback();
}
});
} }
} }
// register a callback for cases where presence changed export function visibilityChanged() {
export function onPresenceChange({ unseenTime, callback }) { if (document.hidden) {
if (unseenTime < MIN_DELTA) { processChanges();
throw "unseenTime is too short"; } else {
seenUser();
} }
callbacks.push({ unseenTime, callback });
} }
// We could piggieback on the Scroll mixin, but it is not applied export function setTestPresence(value) {
// consistently to all pages if (!isTesting()) {
// throw "Only available in test mode";
// We try to keep this as cheap as possible by performing absolute minimal }
// amount of work when the event handler is fired testPresence = value;
// }
// 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); export function clearPresenceCallbacks() {
$(document).bind("click.discourse-track-presence", seenUser); callbacks.splice(0, callbacks.length);
$(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);
}

View File

@ -54,6 +54,10 @@ import { resetLastEditNotificationClick } from "discourse/models/post-stream";
import { clearAuthMethods } from "discourse/models/login-method"; import { clearAuthMethods } from "discourse/models/login-method";
import { clearTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown"; import { clearTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown";
import { clearTopicFooterButtons } from "discourse/lib/register-topic-footer-button"; import { clearTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
import {
clearPresenceCallbacks,
setTestPresence,
} from "discourse/lib/user-presence";
const LEGACY_ENV = !setupApplicationTest; const LEGACY_ENV = !setupApplicationTest;
@ -297,6 +301,10 @@ export function acceptance(name, optionsOrCallback) {
clearTopicFooterButtons(); clearTopicFooterButtons();
resetLastEditNotificationClick(); resetLastEditNotificationClick();
clearAuthMethods(); clearAuthMethods();
setTestPresence(true);
if (!LEGACY_ENV) {
clearPresenceCallbacks();
}
app._runInitializer("instanceInitializers", (_, initializer) => { app._runInitializer("instanceInitializers", (_, initializer) => {
initializer.teardown?.(); initializer.teardown?.();