Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ and this project adheres to

### Fixed

- Fix flickering of active collaborator icons between activity states(active,
away, offline) [#3931](https://github.com/OpenFn/lightning/issues/3931)
- Put close button in top right
[PR#4037](https://github.com/OpenFn/lightning/pull/4037)
- Don't grey out in-progress runs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,6 @@ import { getAvatarInitials } from '../utils/avatar';

import { Tooltip } from './Tooltip';

function lessthanmin(val: number, mins: number) {
const now = Date.now();
const threshold = now - mins * 60 * 1000;
return val > threshold;
}

interface ActiveCollaboratorsProps {
className?: string;
}
Expand Down Expand Up @@ -44,7 +38,7 @@ export function ActiveCollaborators({ className }: ActiveCollaboratorsProps) {
return (
<Tooltip key={user.clientId} content={tooltipContent} side="right">
<div
className={`relative inline-flex items-center justify-center rounded-full border-2 ${user.lastSeen && lessthanmin(user.lastSeen, 2) ? 'border-green-500' : 'border-gray-500 '}`}
className={`relative inline-flex items-center justify-center rounded-full border-2 ${user.lastState === 'active' ? 'border-green-500' : 'border-gray-500 '}`}
>
<div
className="w-5 h-5 rounded-full flex items-center justify-center font-normal text-[9px] font-semibold text-white cursor-default"
Expand Down
5 changes: 5 additions & 0 deletions assets/js/collaborative-editor/contexts/StoreProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ export const StoreProvider = ({ children }: StoreProviderProps) => {
// Set up last seen timer
const cleanupTimer = stores.awarenessStore._internal.setupLastSeenTimer();

// set up activityState handler
stores.awarenessStore._internal.initActivityStateChange(state => {
session.awareness?.setLocalStateField('lastState', state);
});

return cleanupTimer;
}
return undefined;
Expand Down
80 changes: 57 additions & 23 deletions assets/js/collaborative-editor/hooks/useAwareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,50 +63,84 @@ export const useAwarenessUsers = (): AwarenessUser[] => {
return useSyncExternalStore(awarenessStore.subscribe, selectUsers);
};

const awayUserCache = new Map<
string,
{ user: AwarenessUser; expiresAt: number }
>();

const AWAY_CACHE_DURATION = 60000; // 60 seconds

/**
* Hook to get only remote users (excluding the local user)
* Useful for cursor rendering where you don't want to show your own cursor
* Deduplicates users by keeping the one with the latest lastSeen timestamp
* and adds connectionCount to show how many connections they have
* Deduplicates users by keeping the one with the highest priority state
* (active > away > idle), then by latest lastSeen timestamp
* Caches away users for 60s to prevent flickering when presence is throttled
* Adds connectionCount to show how many connections they have
*/
export const useRemoteUsers = (): AwarenessUser[] => {
const awarenessStore = useAwarenessStore();

const selectRemoteUsers = awarenessStore.withSelector(state => {
if (!state.localUser) return state.users;

// Filter out local user
const now = Date.now();
const remoteUsers = state.users.filter(
user => user.user.id !== state.localUser?.id
);

// Group users by user ID and deduplicate
const userMap = new Map<string, AwarenessUser>();
const statePriority = { active: 3, away: 2, idle: 1 };
const userMap = new Map<string, AwarenessUser[]>();
const connectionCounts = new Map<string, number>();

remoteUsers.forEach(user => {
const userId = user.user.id;
const count = connectionCounts.get(userId) || 0;
connectionCounts.set(userId, count + 1);

const existingUser = userMap.get(userId);
if (!existingUser) {
userMap.set(userId, user);
} else {
// Keep the user with the latest lastSeen timestamp
const existingLastSeen = existingUser.lastSeen || 0;
const currentLastSeen = user.lastSeen || 0;
if (currentLastSeen > existingLastSeen) {
userMap.set(userId, user);
}
connectionCounts.set(userId, (connectionCounts.get(userId) || 0) + 1);
(userMap.get(userId) || userMap.set(userId, []).get(userId)!).push(user);
});

const selectedUsers = Array.from(userMap.values()).map(users => {
const selected = users.sort((a, b) => {
const stateDiff =
(statePriority[b.lastState || 'idle'] || 0) -
(statePriority[a.lastState || 'idle'] || 0);
return stateDiff || (b.lastSeen || 0) - (a.lastSeen || 0);
})[0];

const userId = selected.user.id;

// clear cache if user becomes active
if (selected.lastState === 'active') {
awayUserCache.delete(userId);
}

// cache away users
if (selected.lastState === 'away') {
awayUserCache.set(userId, {
user: selected,
expiresAt: now + AWAY_CACHE_DURATION,
});
}

return {
...selected,
connectionCount: connectionCounts.get(userId) || 1,
};
});

// Add cached away users that aren't in current results
awayUserCache.forEach((cached, userId) => {
if (now > cached.expiresAt) {
awayUserCache.delete(userId);
} else if (!userMap.has(userId)) {
selectedUsers.push({
...cached.user,
connectionCount: 0,
});
}
});

// Add connection counts to users
return Array.from(userMap.values()).map(user => ({
...user,
connectionCount: connectionCounts.get(user.user.id) || 1,
}));
return selectedUsers;
});

return useSyncExternalStore(awarenessStore.subscribe, selectRemoteUsers);
Expand Down
38 changes: 38 additions & 0 deletions assets/js/collaborative-editor/stores/createAwarenessStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,11 +96,14 @@ import type { Awareness } from 'y-protocols/awareness';
import _logger from '#/utils/logger';

import type {
ActivityState,
AwarenessState,
AwarenessStore,
AwarenessUser,
LocalUserData,
SetStateHandler,
} from '../types/awareness';
import { getVisibilityProps } from '../utils/visibility';

import { createWithSelector } from './common';
import { wrapStoreWithDevTools } from './devtools';
Expand Down Expand Up @@ -234,6 +237,9 @@ export const createAwarenessStore = (): AwarenessStore => {
'selection'
] as AwarenessUser['selection'];
const lastSeen = awarenessState['lastSeen'] as number | undefined;
const lastState = awarenessState['lastState'] as
| ActivityState
| undefined;

// Check if user data actually changed
if (existingUser) {
Expand Down Expand Up @@ -264,6 +270,11 @@ export const createAwarenessStore = (): AwarenessStore => {
hasChanged = true;
}

// compare lastState
if (existingUser.lastState !== lastState) {
hasChanged = true;
}

// Only update if something changed
// If not changed, Immer preserves the existing reference
if (hasChanged) {
Expand All @@ -273,6 +284,7 @@ export const createAwarenessStore = (): AwarenessStore => {
cursor,
selection,
lastSeen,
lastState,
});
}
} else {
Expand All @@ -283,6 +295,7 @@ export const createAwarenessStore = (): AwarenessStore => {
cursor,
selection,
lastSeen,
lastState,
});
}
} catch (error) {
Expand Down Expand Up @@ -312,6 +325,30 @@ export const createAwarenessStore = (): AwarenessStore => {
notify('awarenessChange');
};

const visibilityProps = getVisibilityProps();
const activityStateChangeHandler = (setState: SetStateHandler) => {
const isHidden = document[visibilityProps?.hidden as keyof Document];
if (isHidden) {
setState('away');
} else {
setState('active');
}
};

const initActivityStateChange = (setState: SetStateHandler) => {
if (visibilityProps) {
const handler = activityStateChangeHandler.bind(undefined, setState);
// initial call
handler();
document.addEventListener(visibilityProps.visibilityChange, handler);

// return cleanup function
return () => {
document.removeEventListener(visibilityProps.visibilityChange, handler);
};
}
};

// =============================================================================
// PATTERN 2: Direct Immer → Notify + Awareness Update (Local Commands)
// =============================================================================
Expand Down Expand Up @@ -576,6 +613,7 @@ export const createAwarenessStore = (): AwarenessStore => {
_internal: {
handleAwarenessChange,
setupLastSeenTimer,
initActivityStateChange,
},
};
};
Expand Down
6 changes: 6 additions & 0 deletions assets/js/collaborative-editor/types/awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { z } from 'zod';

import type { WithSelector } from '../stores/common';

export type ActivityState = 'active' | 'away' | 'idle';

/**
* User information stored in awareness
*/
Expand All @@ -24,6 +26,7 @@ export interface AwarenessUser {
head: RelativePosition;
} | null;
lastSeen?: number;
lastState?: ActivityState;
connectionCount?: number;
}

Expand Down Expand Up @@ -104,6 +107,8 @@ export interface AwarenessQueries {
getRawAwareness: () => Awareness | null;
}

export type SetStateHandler = (state: ActivityState) => void;

/**
* Complete awareness store interface following CQS pattern
*/
Expand All @@ -117,5 +122,6 @@ export interface AwarenessStore extends AwarenessCommands, AwarenessQueries {
_internal: {
handleAwarenessChange: () => void;
setupLastSeenTimer: () => () => void;
initActivityStateChange: (setState: SetStateHandler) => void;
};
}
24 changes: 24 additions & 0 deletions assets/js/collaborative-editor/utils/visibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const getVisibilityProps = () => {
if (typeof document.hidden !== 'undefined') {
return { hidden: 'hidden', visibilityChange: 'visibilitychange' };
}

if (
// @ts-expect-error webkitHidden not defined
typeof (document as unknown as Document).webkitHidden !== 'undefined'
) {
return {
hidden: 'webkitHidden',
visibilityChange: 'webkitvisibilitychange',
};
}
// @ts-expect-error mozHidden not defined
if (typeof (document as unknown as Document).mozHidden !== 'undefined') {
return { hidden: 'mozHidden', visibilityChange: 'mozvisibilitychange' };
}
// @ts-expect-error msHidden not defined
if (typeof (document as unknown as Document).msHidden !== 'undefined') {
return { hidden: 'msHidden', visibilityChange: 'msvisibilitychange' };
}
return null;
};
Loading