Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DEV: lib/user-presence improvements #15046

Merged
merged 2 commits into from Nov 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -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();
Expand All @@ -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) {
Expand Down
15 changes: 0 additions & 15 deletions app/assets/javascripts/discourse/app/lib/page-visible.js

This file was deleted.

168 changes: 119 additions & 49 deletions 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 = [];
eviltrout marked this conversation as resolved.
Show resolved Hide resolved

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've also done callbacks.length = 0 which works too.

}

// 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);
}
Expand Up @@ -54,6 +54,10 @@ import { resetLastEditNotificationClick } from "discourse/models/post-stream";
import { clearAuthMethods } from "discourse/models/login-method";
import { clearTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown";
import { clearTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
import {
clearPresenceCallbacks,
setTestPresence,
} from "discourse/lib/user-presence";

const LEGACY_ENV = !setupApplicationTest;

Expand Down Expand Up @@ -297,6 +301,10 @@ export function acceptance(name, optionsOrCallback) {
clearTopicFooterButtons();
resetLastEditNotificationClick();
clearAuthMethods();
setTestPresence(true);
if (!LEGACY_ENV) {
clearPresenceCallbacks();
}

app._runInitializer("instanceInitializers", (_, initializer) => {
initializer.teardown?.();
Expand Down