Skip to content

Commit 43b4845

Browse files
committed
fix: inactive collaborators should still ping their last seen
1 parent 3964acb commit 43b4845

File tree

2 files changed

+133
-55
lines changed

2 files changed

+133
-55
lines changed

assets/js/collaborative-editor/components/ActiveCollaborators.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { cn } from "../../utils/cn";
2-
import { useRemoteUsers } from "../hooks/useAwareness";
3-
import { getAvatarInitials } from "../utils/avatar";
1+
import { cn } from '../../utils/cn';
2+
import { useRemoteUsers } from '../hooks/useAwareness';
3+
import { getAvatarInitials } from '../utils/avatar';
44

5-
import { Tooltip } from "./Tooltip";
5+
import { Tooltip } from './Tooltip';
66

77
function lessthanmin(val: number, mins: number) {
88
const now = Date.now();
@@ -22,12 +22,12 @@ export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) {
2222
}
2323

2424
return (
25-
<div className={cn("flex items-center gap-1.5", className)}>
25+
<div className={cn('flex items-center gap-1.5', className)}>
2626
{remoteUsers.map(user => {
2727
const nameParts = user.user.name.split(/\s+/);
28-
const firstName = nameParts[0] || "";
28+
const firstName = nameParts[0] || '';
2929
const lastName =
30-
nameParts.length > 1 ? nameParts[nameParts.length - 1] : "";
30+
nameParts.length > 1 ? nameParts[nameParts.length - 1] : '';
3131

3232
const userForInitials = {
3333
first_name: firstName,
@@ -44,14 +44,14 @@ export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) {
4444
return (
4545
<Tooltip key={user.clientId} content={tooltipContent} side="right">
4646
<div
47-
className={`relative inline-flex items-center justify-center rounded-full border-2 ${user.lastSeen && lessthanmin(user.lastSeen, 2) ? "border-green-500" : "border-gray-500 "}`}
47+
className={`relative inline-flex items-center justify-center rounded-full border-2 ${user.lastSeen && lessthanmin(user.lastSeen, 2) ? 'border-green-500' : 'border-gray-500 '}`}
4848
>
4949
<div
5050
className="w-5 h-5 rounded-full flex items-center justify-center font-normal text-[9px] font-semibold text-white cursor-default"
5151
style={{
5252
backgroundColor: user.user.color,
5353
textShadow:
54-
"1px 0 0 rgba(0, 0, 0, 0.5), 0 -1px 0 rgba(0, 0, 0, 0.5), 0 1px 0 rgba(0, 0, 0, 0.5), -1px 0 0 rgba(0, 0, 0, 0.5)",
54+
'1px 0 0 rgba(0, 0, 0, 0.5), 0 -1px 0 rgba(0, 0, 0, 0.5), 0 1px 0 rgba(0, 0, 0, 0.5), -1px 0 0 rgba(0, 0, 0, 0.5)',
5555
}}
5656
>
5757
{initials}

assets/js/collaborative-editor/stores/createAwarenessStore.ts

Lines changed: 124 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -90,22 +90,22 @@
9090
* rawAwareness (too large/circular)
9191
*/
9292

93-
import { produce } from "immer";
94-
import type { Awareness } from "y-protocols/awareness";
93+
import { produce } from 'immer';
94+
import type { Awareness } from 'y-protocols/awareness';
9595

96-
import _logger from "#/utils/logger";
96+
import _logger from '#/utils/logger';
9797

9898
import type {
9999
AwarenessState,
100100
AwarenessStore,
101101
AwarenessUser,
102102
LocalUserData,
103-
} from "../types/awareness";
103+
} from '../types/awareness';
104104

105-
import { createWithSelector } from "./common";
106-
import { wrapStoreWithDevTools } from "./devtools";
105+
import { createWithSelector } from './common';
106+
import { wrapStoreWithDevTools } from './devtools';
107107

108-
const logger = _logger.ns("AwarenessStore").seal();
108+
const logger = _logger.ns('AwarenessStore').seal();
109109

110110
/**
111111
* Creates an awareness store instance with useSyncExternalStore + Immer pattern
@@ -131,12 +131,12 @@ export const createAwarenessStore = (): AwarenessStore => {
131131

132132
// Redux DevTools integration
133133
const devtools = wrapStoreWithDevTools({
134-
name: "AwarenessStore",
135-
excludeKeys: ["rawAwareness"], // Exclude Y.js Awareness object
134+
name: 'AwarenessStore',
135+
excludeKeys: ['rawAwareness'], // Exclude Y.js Awareness object
136136
maxAge: 200, // Higher limit since awareness changes are frequent
137137
});
138138

139-
const notify = (actionName: string = "stateChange") => {
139+
const notify = (actionName: string = 'stateChange') => {
140140
devtools.notifyWithAction(actionName, () => state);
141141
listeners.forEach(listener => {
142142
listener();
@@ -169,22 +169,22 @@ export const createAwarenessStore = (): AwarenessStore => {
169169

170170
awareness.getStates().forEach((awarenessState, clientId) => {
171171
// Validate user data structure
172-
if (awarenessState["user"]) {
172+
if (awarenessState['user']) {
173173
try {
174174
// Note: We're not using Zod validation here as it's runtime performance critical
175175
// and we trust the awareness protocol more than external API data
176176
const user: AwarenessUser = {
177177
clientId,
178-
user: awarenessState["user"] as AwarenessUser["user"],
179-
cursor: awarenessState["cursor"] as AwarenessUser["cursor"],
178+
user: awarenessState['user'] as AwarenessUser['user'],
179+
cursor: awarenessState['cursor'] as AwarenessUser['cursor'],
180180
selection: awarenessState[
181-
"selection"
182-
] as AwarenessUser["selection"],
183-
lastSeen: awarenessState["lastSeen"] as number | undefined,
181+
'selection'
182+
] as AwarenessUser['selection'],
183+
lastSeen: awarenessState['lastSeen'] as number | undefined,
184184
};
185185
users.push(user);
186186
} catch (error) {
187-
logger.warn("Invalid user data for client", clientId, error);
187+
logger.warn('Invalid user data for client', clientId, error);
188188
}
189189
}
190190
});
@@ -200,7 +200,7 @@ export const createAwarenessStore = (): AwarenessStore => {
200200
*/
201201
const handleAwarenessChange = () => {
202202
if (!awarenessInstance) {
203-
logger.warn("handleAwarenessChange called without awareness instance");
203+
logger.warn('handleAwarenessChange called without awareness instance');
204204
return;
205205
}
206206

@@ -210,7 +210,7 @@ export const createAwarenessStore = (): AwarenessStore => {
210210
draft.users = users;
211211
draft.lastUpdated = Date.now();
212212
});
213-
notify("awarenessChange");
213+
notify('awarenessChange');
214214
};
215215

216216
// =============================================================================
@@ -224,16 +224,16 @@ export const createAwarenessStore = (): AwarenessStore => {
224224
awareness: Awareness,
225225
userData: LocalUserData
226226
) => {
227-
logger.debug("Initializing awareness", { userData });
227+
logger.debug('Initializing awareness', { userData });
228228

229229
awarenessInstance = awareness;
230230

231231
// Set up awareness with user data
232-
awareness.setLocalStateField("user", userData);
233-
awareness.setLocalStateField("lastSeen", Date.now());
232+
awareness.setLocalStateField('user', userData);
233+
awareness.setLocalStateField('lastSeen', Date.now());
234234

235235
// Set up awareness observer for Pattern 1 updates
236-
awareness.on("change", handleAwarenessChange);
236+
awareness.on('change', handleAwarenessChange);
237237

238238
// Update local state
239239
state = produce(state, draft => {
@@ -248,17 +248,17 @@ export const createAwarenessStore = (): AwarenessStore => {
248248
handleAwarenessChange();
249249

250250
devtools.connect();
251-
notify("initializeAwareness");
251+
notify('initializeAwareness');
252252
};
253253

254254
/**
255255
* Clean up awareness instance
256256
*/
257257
const destroyAwareness = () => {
258-
logger.debug("Destroying awareness");
258+
logger.debug('Destroying awareness');
259259

260260
if (awarenessInstance) {
261-
awarenessInstance.off("change", handleAwarenessChange);
261+
awarenessInstance.off('change', handleAwarenessChange);
262262
awarenessInstance = null;
263263
}
264264

@@ -277,28 +277,28 @@ export const createAwarenessStore = (): AwarenessStore => {
277277
draft.isConnected = false;
278278
draft.lastUpdated = Date.now();
279279
});
280-
notify("destroyAwareness");
280+
notify('destroyAwareness');
281281
};
282282

283283
/**
284284
* Update local user data in awareness
285285
*/
286286
const updateLocalUserData = (userData: Partial<LocalUserData>) => {
287287
if (!awarenessInstance || !state.localUser) {
288-
logger.warn("Cannot update user data - awareness not initialized");
288+
logger.warn('Cannot update user data - awareness not initialized');
289289
return;
290290
}
291291

292292
const updatedUserData = { ...state.localUser, ...userData };
293293

294294
// Update awareness first
295-
awarenessInstance.setLocalStateField("user", updatedUserData);
295+
awarenessInstance.setLocalStateField('user', updatedUserData);
296296

297297
// Update local state for immediate UI response
298298
state = produce(state, draft => {
299299
draft.localUser = updatedUserData;
300300
});
301-
notify("updateLocalUserData");
301+
notify('updateLocalUserData');
302302

303303
// Note: awareness observer will also fire and update the users array
304304
};
@@ -308,12 +308,12 @@ export const createAwarenessStore = (): AwarenessStore => {
308308
*/
309309
const updateLocalCursor = (cursor: { x: number; y: number } | null) => {
310310
if (!awarenessInstance) {
311-
logger.warn("Cannot update cursor - awareness not initialized");
311+
logger.warn('Cannot update cursor - awareness not initialized');
312312
return;
313313
}
314314

315315
// Update awareness
316-
awarenessInstance.setLocalStateField("cursor", cursor);
316+
awarenessInstance.setLocalStateField('cursor', cursor);
317317

318318
// Immediate local state update for responsiveness
319319
state = produce(state, draft => {
@@ -330,22 +330,22 @@ export const createAwarenessStore = (): AwarenessStore => {
330330
}
331331
}
332332
});
333-
notify("updateLocalCursor");
333+
notify('updateLocalCursor');
334334
};
335335

336336
/**
337337
* Update local text selection
338338
*/
339339
const updateLocalSelection = (
340-
selection: AwarenessUser["selection"] | null
340+
selection: AwarenessUser['selection'] | null
341341
) => {
342342
if (!awarenessInstance) {
343-
logger.warn("Cannot update selection - awareness not initialized");
343+
logger.warn('Cannot update selection - awareness not initialized');
344344
return;
345345
}
346346

347347
// Update awareness
348-
awarenessInstance.setLocalStateField("selection", selection);
348+
awarenessInstance.setLocalStateField('selection', selection);
349349

350350
// Immediate local state update for responsiveness
351351
state = produce(state, draft => {
@@ -362,19 +362,20 @@ export const createAwarenessStore = (): AwarenessStore => {
362362
}
363363
}
364364
});
365-
notify("updateLocalSelection");
365+
notify('updateLocalSelection');
366366
};
367367

368368
/**
369369
* Update last seen timestamp
370+
* @param forceTimestamp - Optional timestamp to use instead of Date.now()
370371
*/
371-
const updateLastSeen = () => {
372+
const updateLastSeen = (forceTimestamp?: number) => {
372373
if (!awarenessInstance) {
373374
return;
374375
}
375376

376-
const timestamp = Date.now();
377-
awarenessInstance.setLocalStateField("lastSeen", timestamp);
377+
const timestamp = forceTimestamp ?? Date.now();
378+
awarenessInstance.setLocalStateField('lastSeen', timestamp);
378379

379380
// Note: We don't update local state here as awareness observer will handle it
380381
};
@@ -383,19 +384,96 @@ export const createAwarenessStore = (): AwarenessStore => {
383384
* Set up automatic last seen updates
384385
*/
385386
const setupLastSeenTimer = () => {
386-
if (lastSeenTimer) {
387-
clearInterval(lastSeenTimer);
387+
let frozenTimestamp: number | null = null;
388+
389+
const startTimer = () => {
390+
if (lastSeenTimer) {
391+
clearInterval(lastSeenTimer);
392+
}
393+
lastSeenTimer = setInterval(() => {
394+
// If page is hidden, use frozen timestamp, otherwise use current time
395+
if (frozenTimestamp) frozenTimestamp++; // This is to make sure that state is updated and data gets transmitted
396+
updateLastSeen(frozenTimestamp ?? undefined);
397+
}, 10000); // Update every 10 seconds
398+
};
399+
400+
const getVisibilityProps = () => {
401+
if (typeof document.hidden !== 'undefined') {
402+
return { hidden: 'hidden', visibilityChange: 'visibilitychange' };
403+
}
404+
405+
if (
406+
// @ts-expect-error webkitHidden not defined
407+
typeof (document as unknown as Document).webkitHidden !== 'undefined'
408+
) {
409+
return {
410+
hidden: 'webkitHidden',
411+
visibilityChange: 'webkitvisibilitychange',
412+
};
413+
}
414+
// @ts-expect-error mozHidden not defined
415+
if (typeof (document as unknown as Document).mozHidden !== 'undefined') {
416+
return { hidden: 'mozHidden', visibilityChange: 'mozvisibilitychange' };
417+
}
418+
// @ts-expect-error msHidden not defined
419+
if (typeof (document as unknown as Document).msHidden !== 'undefined') {
420+
return { hidden: 'msHidden', visibilityChange: 'msvisibilitychange' };
421+
}
422+
return null;
423+
};
424+
425+
const visibilityProps = getVisibilityProps();
426+
427+
const handleVisibilityChange = () => {
428+
if (!visibilityProps) return;
429+
430+
const isHidden = (document as unknown as Document)[
431+
visibilityProps.hidden as keyof Document
432+
];
433+
434+
if (isHidden) {
435+
// Page is hidden, freeze the current timestamp
436+
frozenTimestamp = Date.now();
437+
} else {
438+
// Page is visible, unfreeze and update immediately
439+
frozenTimestamp = null;
440+
updateLastSeen();
441+
}
442+
};
443+
444+
// Set up visibility change listener if supported
445+
if (visibilityProps) {
446+
document.addEventListener(
447+
visibilityProps.visibilityChange,
448+
handleVisibilityChange
449+
);
450+
451+
// Check initial visibility state
452+
const isHidden = (document as unknown as Document)[
453+
visibilityProps.hidden as keyof Document
454+
];
455+
if (isHidden) {
456+
// Start with frozen timestamp if already hidden
457+
frozenTimestamp = Date.now();
458+
}
388459
}
389460

390-
lastSeenTimer = setInterval(() => {
391-
updateLastSeen();
392-
}, 10000); // Update every 10 seconds
461+
// Always start the timer (whether visible or hidden)
462+
startTimer();
393463

464+
// cleanup
394465
return () => {
395466
if (lastSeenTimer) {
396467
clearInterval(lastSeenTimer);
397468
lastSeenTimer = null;
398469
}
470+
471+
if (visibilityProps) {
472+
document.removeEventListener(
473+
visibilityProps.visibilityChange,
474+
handleVisibilityChange
475+
);
476+
}
399477
};
400478
};
401479

@@ -410,7 +488,7 @@ export const createAwarenessStore = (): AwarenessStore => {
410488
state = produce(state, draft => {
411489
draft.isConnected = isConnected;
412490
});
413-
notify("setConnected");
491+
notify('setConnected');
414492
};
415493

416494
// =============================================================================

0 commit comments

Comments
 (0)