fix: User presence is not updated when leaving a document

This commit is contained in:
Tom Moor
2023-05-22 21:05:40 -04:00
parent 4e75b4029a
commit 3317bf2396
5 changed files with 47 additions and 79 deletions

View File

@@ -85,9 +85,7 @@ class WebsocketProvider extends React.Component<Props> {
stars, stars,
memberships, memberships,
policies, policies,
presence,
comments, comments,
views,
subscriptions, subscriptions,
fileOperations, fileOperations,
notifications, notifications,
@@ -105,12 +103,6 @@ class WebsocketProvider extends React.Component<Props> {
}); });
}); });
this.socket.on("disconnect", () => {
// when the socket is disconnected we need to clear all presence state as
// it's no longer reliable.
presence.clear();
});
// on reconnection, reset the transports option, as the Websocket // on reconnection, reset the transports option, as the Websocket
// connection may have failed (caused by proxy, firewall, browser, ...) // connection may have failed (caused by proxy, firewall, browser, ...)
this.socket.io.on("reconnect_attempt", () => { this.socket.io.on("reconnect_attempt", () => {
@@ -468,33 +460,6 @@ class WebsocketProvider extends React.Component<Props> {
this.socket.on("leave", (event: any) => { this.socket.on("leave", (event: any) => {
this.socket?.emit("leave", event); this.socket?.emit("leave", event);
}); });
// received whenever we join a document room, the payload includes
// userIds that are present/viewing and those that are editing.
this.socket.on("document.presence", (event: any) => {
presence.init(event.documentId, event.userIds, event.editingIds);
});
// received whenever a new user joins a document room, aka they
// navigate to / start viewing a document
this.socket.on("user.join", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
views.touch(event.documentId, event.userId);
});
// received whenever a new user leaves a document room, aka they
// navigate away / stop viewing a document
this.socket.on("user.leave", (event: any) => {
presence.leave(event.documentId, event.userId);
views.touch(event.documentId, event.userId);
});
// received when another client in a document room wants to change
// or update it's presence. Currently the only property is whether
// the client is in editing state or not.
this.socket.on("user.presence", (event: any) => {
presence.touch(event.documentId, event.userId, event.isEditing);
});
}; };
render() { render() {

View File

@@ -15,6 +15,7 @@ import useIsMounted from "~/hooks/useIsMounted";
import usePageVisibility from "~/hooks/usePageVisibility"; import usePageVisibility from "~/hooks/usePageVisibility";
import useStores from "~/hooks/useStores"; import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts"; import useToasts from "~/hooks/useToasts";
import { AwarenessChangeEvent } from "~/types";
import Logger from "~/utils/Logger"; import Logger from "~/utils/Logger";
import { supportsPassiveListener } from "~/utils/browser"; import { supportsPassiveListener } from "~/utils/browser";
import { homePath } from "~/utils/routeHelpers"; import { homePath } from "~/utils/routeHelpers";
@@ -30,10 +31,6 @@ export type ConnectionStatus =
| "disconnected" | "disconnected"
| void; | void;
type AwarenessChangeEvent = {
states: { user: { id: string }; cursor: any; scrollY: number | undefined }[];
};
type ConnectionStatusEvent = { status: ConnectionStatus }; type ConnectionStatusEvent = { status: ConnectionStatus };
type MessageEvent = { type MessageEvent = {
@@ -108,11 +105,11 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
history.replace(homePath()); history.replace(homePath());
}); });
provider.on("awarenessChange", ({ states }: AwarenessChangeEvent) => { provider.on("awarenessChange", (event: AwarenessChangeEvent) => {
states.forEach(({ user, cursor, scrollY }) => { presence.updateFromAwarenessChangeEvent(documentId, event);
if (user) {
presence.touch(documentId, user.id, !!cursor);
event.states.forEach(({ user, scrollY }) => {
if (user) {
if (scrollY !== undefined && user.id === ui.observingUserId) { if (scrollY !== undefined && user.id === ui.observingUserId) {
window.scrollTo({ window.scrollTo({
top: scrollY * window.innerHeight, top: scrollY * window.innerHeight,

View File

@@ -1,5 +1,5 @@
import { observable, action } from "mobx"; import { observable, action } from "mobx";
import { USER_PRESENCE_INTERVAL } from "@shared/constants"; import { AwarenessChangeEvent } from "~/types";
type DocumentPresence = Map< type DocumentPresence = Map<
string, string,
@@ -15,19 +15,11 @@ export default class PresenceStore {
timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map(); timeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
// called to setup when we get the initial state from document.presence offlineTimeout = 30000;
// websocket message. overrides any existing state
@action
init(documentId: string, userIds: string[], editingIds: string[]) {
this.data.set(documentId, new Map());
userIds.forEach((userId) =>
this.touch(documentId, userId, editingIds.includes(userId))
);
}
// called when a user leave the room user.leave websocket message. // called when a user leaves the document
@action @action
leave(documentId: string, userId: string) { public leave(documentId: string, userId: string) {
const existing = this.data.get(documentId); const existing = this.data.get(documentId);
if (existing) { if (existing) {
@@ -35,23 +27,27 @@ export default class PresenceStore {
} }
} }
@action public updateFromAwarenessChangeEvent(
update(documentId: string, userId: string, isEditing: boolean) { documentId: string,
const existing = this.data.get(documentId) || new Map(); event: AwarenessChangeEvent
existing.set(userId, { ) {
isEditing, const presence = this.data.get(documentId);
userId, let existingUserIds = (presence ? Array.from(presence.values()) : []).map(
(p) => p.userId
);
event.states.forEach((state) => {
const { user, cursor } = state;
this.update(documentId, user.id, !!cursor);
existingUserIds = existingUserIds.filter((id) => id !== user.id);
});
existingUserIds.forEach((userId) => {
this.leave(documentId, userId);
}); });
this.data.set(documentId, existing);
} }
// called when a user presence message is received user.presence websocket public touch(documentId: string, userId: string, isEditing: boolean) {
// message.
// While in edit mode a message is sent every USER_PRESENCE_INTERVAL, if
// the other clients don't receive within USER_PRESENCE_INTERVAL*2 then a
// timeout is triggered causing the users presence to default back to not
// editing state as a safety measure.
touch(documentId: string, userId: string, isEditing: boolean) {
const id = `${documentId}-${userId}`; const id = `${documentId}-${userId}`;
let timeout = this.timeouts.get(id); let timeout = this.timeouts.get(id);
@@ -62,20 +58,28 @@ export default class PresenceStore {
this.update(documentId, userId, isEditing); this.update(documentId, userId, isEditing);
if (isEditing) { timeout = setTimeout(() => {
timeout = setTimeout(() => { this.leave(documentId, userId);
this.update(documentId, userId, false); }, this.offlineTimeout);
}, USER_PRESENCE_INTERVAL * 2); this.timeouts.set(id, timeout);
this.timeouts.set(id, timeout);
}
} }
get(documentId: string): DocumentPresence | null | undefined { @action
private update(documentId: string, userId: string, isEditing: boolean) {
const existing = this.data.get(documentId) || new Map();
existing.set(userId, {
isEditing,
userId,
});
this.data.set(documentId, existing);
}
public get(documentId: string): DocumentPresence | null | undefined {
return this.data.get(documentId); return this.data.get(documentId);
} }
@action @action
clear() { public clear() {
this.data.clear(); this.data.clear();
} }
} }

View File

@@ -202,3 +202,7 @@ export type WebsocketEvent =
| WebsocketCollectionUpdateIndexEvent | WebsocketCollectionUpdateIndexEvent
| WebsocketEntityDeletedEvent | WebsocketEntityDeletedEvent
| WebsocketEntitiesEvent; | WebsocketEntitiesEvent;
export type AwarenessChangeEvent = {
states: { user: { id: string }; cursor: any; scrollY: number | undefined }[];
};

View File

@@ -5,8 +5,6 @@ import {
UserPreferences, UserPreferences,
} from "./types"; } from "./types";
export const USER_PRESENCE_INTERVAL = 5000;
export const MAX_AVATAR_DISPLAY = 6; export const MAX_AVATAR_DISPLAY = 6;
export const TeamPreferenceDefaults: TeamPreferences = { export const TeamPreferenceDefaults: TeamPreferences = {