Skip to content

Commit 20b2b2e

Browse files
Collaborator Heads (codesandbox#3704)
* Following * Add breadcrumb for debugging OT * WIP * WIP * WIP on user following * Follow users by range * Tweak the heads, remove the "user joined" notifs * Undo elixir syntax in js * Undo file * Keep right order * Fix types * Add margin to menu button * Add 'View Profile' Link * Fix types * Fix type * Add another nullcheck * Update icon size * Move to new feature flag * Apply suggestions from code review Co-Authored-By: Michaël De Boey <[email protected]> * Fix typing Co-authored-by: Michaël De Boey <[email protected]>
1 parent c5f68e2 commit 20b2b2e

File tree

18 files changed

+653
-72
lines changed

18 files changed

+653
-72
lines changed

packages/app/src/app/components/CodeEditor/elements.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
import styled from 'styled-components';
22

33
export const Icons = styled.div`
4-
position: absolute;
5-
top: 0;
6-
right: 1rem;
74
background-color: rgba(0, 0, 0, 0.3);
85
box-shadow: 0 3px 3px rgba(0, 0, 0, 0.3);
96
border-radius: 2px;
@@ -13,7 +10,6 @@ export const Icons = styled.div`
1310
z-index: 40;
1411
1512
font-size: 0.875rem;
16-
margin-top: 35px;
1713
`;
1814

1915
export const Icon = styled.div`

packages/app/src/app/components/Overlay/Overlay.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,23 @@ export const Overlay: React.FC<IOverlayProps> = ({
4444
height: 0,
4545
});
4646

47-
useLayoutEffect(() => {
47+
const calculateBounds = () => {
4848
const posData = element.current.getBoundingClientRect();
4949
bounds.current = {
5050
top: posData.top,
5151
left: posData.left,
5252
width: posData.width,
5353
height: posData.height,
5454
};
55+
};
56+
57+
useLayoutEffect(() => {
58+
calculateBounds();
59+
60+
window.addEventListener('resize', calculateBounds);
61+
return () => {
62+
window.removeEventListener('resize', calculateBounds);
63+
};
5564
}, []);
5665

5766
const handleClick = () => {

packages/app/src/app/overmind/effects/live/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
LiveMessageEvent,
44
Module,
55
RoomInfo,
6+
UserViewRange,
67
} from '@codesandbox/common/lib/types';
78
import {
89
captureException,
@@ -452,6 +453,18 @@ class Live {
452453
return this.sendImmediately('live:module_state', {});
453454
}
454455

456+
sendUserViewRange(
457+
moduleShortid: string | null,
458+
liveUserId: string,
459+
viewRange: UserViewRange
460+
) {
461+
return this.send('user:view-range', {
462+
liveUserId,
463+
moduleShortid,
464+
viewRange,
465+
});
466+
}
467+
455468
sendUserSelection(
456469
moduleShortid: string | null,
457470
liveUserId: string,

packages/app/src/app/overmind/effects/vscode/index.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Sandbox,
1111
SandboxFs,
1212
Settings,
13+
UserViewRange,
1314
} from '@codesandbox/common/lib/types';
1415
import { notificationState } from '@codesandbox/common/lib/utils/notifications';
1516
import {
@@ -25,6 +26,7 @@ import { debounce } from 'lodash-es';
2526
import * as childProcess from 'node-services/lib/child_process';
2627
import io from 'socket.io-client';
2728

29+
import { indexToLineAndColumn } from 'app/overmind/utils/common';
2830
import { EXTENSIONS_LOCATION, VIM_EXTENSION_ID } from './constants';
2931
import {
3032
initializeCodeSandboxTheme,
@@ -52,7 +54,8 @@ export type VsCodeOptions = {
5254
getCurrentUser: () => CurrentUser | null;
5355
onCodeChange: (data: OnFileChangeData) => void;
5456
onOperationApplied: (data: OnOperationAppliedData) => void;
55-
onSelectionChange: (selection: onSelectionChangeData) => void;
57+
onSelectionChanged: (selection: onSelectionChangeData) => void;
58+
onViewRangeChanged: (viewRange: UserViewRange) => void;
5659
reaction: Reaction;
5760
// These two should be removed
5861
getSignal: any;
@@ -102,6 +105,7 @@ export class VSCodeEffect {
102105
private linter: Linter | null;
103106
private modelsHandler: ModelsHandler;
104107
private modelSelectionListener: { dispose: Function };
108+
private modelViewRangeListener: { dispose: Function };
105109
private readOnly: boolean;
106110
private elements = {
107111
editor: document.createElement('div'),
@@ -114,7 +118,7 @@ export class VSCodeEffect {
114118
getCustomEditor: () => null,
115119
};
116120

117-
onSelectionChangeDebounced: VsCodeOptions['onSelectionChange'] & {
121+
onSelectionChangeDebounced: VsCodeOptions['onSelectionChanged'] & {
118122
cancel(): void;
119123
};
120124

@@ -124,7 +128,7 @@ export class VSCodeEffect {
124128
getState: options.getState,
125129
getSignal: options.getSignal,
126130
};
127-
this.onSelectionChangeDebounced = debounce(options.onSelectionChange, 500);
131+
this.onSelectionChangeDebounced = debounce(options.onSelectionChanged, 500);
128132

129133
this.prepareElements();
130134

@@ -538,6 +542,52 @@ export class VSCodeEffect {
538542
}
539543
};
540544

545+
/**
546+
* Reveal position in editor
547+
* @param scrollType 0 = smooth, 1 = immediate
548+
*/
549+
revealPositionInCenterIfOutsideViewport(pos: number, scrollType: 0 | 1 = 0) {
550+
const activeEditor = this.editorApi.getActiveCodeEditor();
551+
552+
if (activeEditor) {
553+
const model = activeEditor.getModel();
554+
555+
const lineColumnPos = indexToLineAndColumn(
556+
model.getLinesContent() || [],
557+
pos
558+
);
559+
560+
activeEditor.revealPositionInCenterIfOutsideViewport(
561+
lineColumnPos,
562+
scrollType
563+
);
564+
}
565+
}
566+
567+
/**
568+
* Reveal line in editor
569+
* @param scrollType 0 = smooth, 1 = immediate
570+
*/
571+
revealLine(lineNumber: number, scrollType: 0 | 1 = 0) {
572+
const activeEditor = this.editorApi.getActiveCodeEditor();
573+
574+
if (activeEditor) {
575+
activeEditor.revealLine(lineNumber, scrollType);
576+
}
577+
}
578+
579+
/**
580+
* Reveal revealLine in editor
581+
* @param scrollType 0 = smooth, 1 = immediate
582+
*/
583+
revealRange(range: UserViewRange, scrollType: 0 | 1 = 0) {
584+
const activeEditor = this.editorApi.getActiveCodeEditor();
585+
586+
if (activeEditor) {
587+
activeEditor.revealRange(range, scrollType);
588+
}
589+
}
590+
541591
// Communicates the endpoint for the WebsocketLSP
542592
private createContainerForkHandler() {
543593
return () => {
@@ -918,6 +968,10 @@ export class VSCodeEffect {
918968
this.modelSelectionListener.dispose();
919969
}
920970

971+
if (this.modelViewRangeListener) {
972+
this.modelViewRangeListener.dispose();
973+
}
974+
921975
const activeEditor = this.editorApi.getActiveCodeEditor();
922976

923977
if (activeEditor && activeEditor.getModel()) {
@@ -960,6 +1014,26 @@ export class VSCodeEffect {
9601014
this.modelsHandler.isApplyingOperation = false;
9611015
}
9621016

1017+
let lastViewRange = null;
1018+
const isDifferentViewRange = (r1: UserViewRange, r2: UserViewRange) =>
1019+
r1.startLineNumber !== r2.startLineNumber ||
1020+
r1.startColumn !== r2.startColumn ||
1021+
r1.endLineNumber !== r2.endLineNumber ||
1022+
r1.endColumn !== r2.endColumn;
1023+
1024+
this.modelViewRangeListener = activeEditor.onDidScrollChange(e => {
1025+
const [range] = activeEditor.getVisibleRanges();
1026+
1027+
if (
1028+
lastViewRange != null &&
1029+
range &&
1030+
isDifferentViewRange(lastViewRange!, range)
1031+
) {
1032+
lastViewRange = range;
1033+
this.options.onViewRangeChanged(range);
1034+
}
1035+
});
1036+
9631037
this.modelSelectionListener = activeEditor.onDidChangeCursorSelection(
9641038
selectionChange => {
9651039
const lines = activeEditor.getModel().getLinesContent() || [];
@@ -978,7 +1052,7 @@ export class VSCodeEffect {
9781052
/* click inside a selection */ selectionChange.source === 'api'
9791053
) {
9801054
this.onSelectionChangeDebounced.cancel();
981-
this.options.onSelectionChange(data);
1055+
this.options.onSelectionChanged(data);
9821056
} else {
9831057
// This is just on typing, we send a debounced selection update as a
9841058
// safeguard to make sure we are in sync

packages/app/src/app/overmind/namespaces/editor/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ export const moduleSelected: Action<
511511
followingUser.currentModuleShortid !== module.shortid
512512
) {
513513
// Reset following as this is a user change module action
514-
state.live.followingUserId = null;
514+
actions.live.onStopFollow();
515515
}
516516
}
517517

packages/app/src/app/overmind/namespaces/live/actions.ts

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { LiveMessage, LiveMessageEvent } from '@codesandbox/common/lib/types';
1+
import {
2+
LiveMessage,
3+
LiveMessageEvent,
4+
UserViewRange,
5+
} from '@codesandbox/common/lib/types';
26
import { Action, AsyncAction, Operator } from 'app/overmind';
37
import { withLoadApp } from 'app/overmind/factories';
48
import getItems from 'app/overmind/utils/items';
@@ -144,6 +148,7 @@ export const liveMessageReceived: Operator<LiveMessage, any> = pipe(
144148
[LiveMessageEvent.DIRECTORY_DELETED]: liveMessage.onDirectoryDeleted,
145149
[LiveMessageEvent.USER_SELECTION]: liveMessage.onUserSelection,
146150
[LiveMessageEvent.USER_CURRENT_MODULE]: liveMessage.onUserCurrentModule,
151+
[LiveMessageEvent.USER_VIEW_RANGE]: liveMessage.onUserViewRange,
147152
[LiveMessageEvent.LIVE_MODE]: liveMessage.onLiveMode,
148153
[LiveMessageEvent.LIVE_CHAT_ENABLED]: liveMessage.onLiveChatEnabled,
149154
[LiveMessageEvent.LIVE_ADD_EDITOR]: liveMessage.onLiveAddEditor,
@@ -187,6 +192,59 @@ export const sendCurrentSelection: Action = ({ state, effects }) => {
187192
}
188193
};
189194

195+
export const sendCurrentViewRange: Action = ({ state, effects }) => {
196+
if (!state.live.roomInfo) {
197+
return;
198+
}
199+
200+
if (!state.live.isCurrentEditor) {
201+
return;
202+
}
203+
204+
const { liveUserId, currentViewRange } = state.live;
205+
if (liveUserId && currentViewRange) {
206+
effects.live.sendUserViewRange(
207+
state.editor.currentModuleShortid,
208+
liveUserId,
209+
currentViewRange
210+
);
211+
}
212+
};
213+
214+
export const onViewRangeChanged: Action<UserViewRange> = (
215+
{ state, effects },
216+
viewRange
217+
) => {
218+
if (!state.live.roomInfo) {
219+
return;
220+
}
221+
222+
if (state.live.isCurrentEditor) {
223+
const { liveUserId } = state.live;
224+
const moduleShortid = state.editor.currentModuleShortid;
225+
if (!liveUserId) {
226+
return;
227+
}
228+
229+
state.live.currentViewRange = viewRange;
230+
const userIndex = state.live.roomInfo.users.findIndex(
231+
u => u.id === liveUserId
232+
);
233+
234+
if (userIndex !== -1) {
235+
if (state.live.roomInfo.users[userIndex]) {
236+
state.live.roomInfo.users[
237+
userIndex
238+
].currentModuleShortid = moduleShortid;
239+
240+
state.live.roomInfo.users[userIndex].viewRange = viewRange;
241+
242+
effects.live.sendUserViewRange(moduleShortid, liveUserId, viewRange);
243+
}
244+
}
245+
}
246+
};
247+
190248
export const onSelectionChanged: Action<any> = (
191249
{ state, effects },
192250
selection
@@ -310,15 +368,64 @@ export const onFollow: Action<{
310368

311369
effects.analytics.track('Follow Along in Live');
312370
state.live.followingUserId = liveUserId;
371+
actions.live.revealViewRange({ liveUserId });
372+
};
373+
374+
export const onStopFollow: Action = ({ state, effects, actions }) => {
375+
if (!state.live.roomInfo) {
376+
return;
377+
}
378+
379+
state.live.followingUserId = null;
380+
};
381+
382+
export const revealViewRange: Action<{ liveUserId: string }> = (
383+
{ state, effects, actions },
384+
{ liveUserId }
385+
) => {
386+
if (!state.live.roomInfo) {
387+
return;
388+
}
389+
390+
const user = state.live.roomInfo.users.find(u => u.id === liveUserId);
391+
392+
if (user && user.currentModuleShortid && state.editor.currentSandbox) {
393+
const { modules } = state.editor.currentSandbox;
394+
const module = modules.filter(
395+
({ shortid }) => shortid === user.currentModuleShortid
396+
)[0];
397+
398+
actions.editor.moduleSelected({ id: module.id });
399+
400+
if (user.viewRange) {
401+
effects.vscode.revealRange(user.viewRange);
402+
}
403+
}
404+
};
405+
406+
export const revealCursorPosition: Action<{ liveUserId: string }> = (
407+
{ state, effects, actions },
408+
{ liveUserId }
409+
) => {
410+
if (!state.live.roomInfo) {
411+
return;
412+
}
313413

314414
const user = state.live.roomInfo.users.find(u => u.id === liveUserId);
315415

316-
if (user!.currentModuleShortid && state.editor.currentSandbox) {
416+
if (user && user.currentModuleShortid && state.editor.currentSandbox) {
317417
const { modules } = state.editor.currentSandbox;
318418
const module = modules.filter(
319-
({ shortid }) => shortid === user!.currentModuleShortid
419+
({ shortid }) => shortid === user.currentModuleShortid
320420
)[0];
321421

322422
actions.editor.moduleSelected({ id: module.id });
423+
424+
if (user.selection?.primary?.cursorPosition) {
425+
effects.vscode.revealPositionInCenterIfOutsideViewport(
426+
user.selection.primary.cursorPosition,
427+
0
428+
);
429+
}
323430
}
324431
};

0 commit comments

Comments
 (0)