mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
298 lines
8.6 KiB
TypeScript
298 lines
8.6 KiB
TypeScript
|
import {
|
||
|
createContext,
|
||
|
useContext,
|
||
|
useEffect,
|
||
|
FC,
|
||
|
useCallback,
|
||
|
useMemo,
|
||
|
useState,
|
||
|
} from 'react'
|
||
|
import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
|
||
|
import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
|
||
|
import { useConnectionContext } from '@/features/ide-react/context/connection-context'
|
||
|
import useScopeValue from '@/shared/hooks/use-scope-value'
|
||
|
import ColorManager from '@/ide/colors/ColorManager'
|
||
|
import { CursorPosition } from '@/features/ide-react/types/cursor-position'
|
||
|
import { omit } from 'lodash'
|
||
|
import { Doc } from '../../../../../types/doc'
|
||
|
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||
|
import { findDocEntityById } from '@/features/ide-react/util/find-doc-entity-by-id'
|
||
|
import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
|
||
|
|
||
|
type OnlineUser = {
|
||
|
id: string
|
||
|
user_id: string
|
||
|
email: string
|
||
|
name: string
|
||
|
initial?: string
|
||
|
doc_id?: string
|
||
|
doc?: Doc | null
|
||
|
row?: number
|
||
|
column?: number
|
||
|
}
|
||
|
|
||
|
type ConnectedUser = {
|
||
|
client_id: string
|
||
|
user_id: string
|
||
|
email: string
|
||
|
first_name: string
|
||
|
last_name: string
|
||
|
cursorData?: {
|
||
|
doc_id: string
|
||
|
row: number
|
||
|
column: number
|
||
|
}
|
||
|
}
|
||
|
|
||
|
type CursorHighlight = {
|
||
|
label: string
|
||
|
cursor: {
|
||
|
row: number
|
||
|
column: number
|
||
|
}
|
||
|
hue: number
|
||
|
}
|
||
|
|
||
|
type OnlineUsersContextValue = {
|
||
|
onlineUsers: Record<string, OnlineUser>
|
||
|
onlineUserCursorHighlights: Record<string, CursorHighlight[]>
|
||
|
onlineUsersArray: OnlineUser[]
|
||
|
onlineUsersCount: number
|
||
|
}
|
||
|
|
||
|
export function populateOnlineUsersScope(store: ReactScopeValueStore) {
|
||
|
store.set('onlineUsers', {})
|
||
|
store.set('onlineUserCursorHighlights', {})
|
||
|
store.set('onlineUsersArray', [])
|
||
|
store.set('onlineUsersCount', 0)
|
||
|
}
|
||
|
|
||
|
const OnlineUsersContext = createContext<OnlineUsersContextValue | undefined>(
|
||
|
undefined
|
||
|
)
|
||
|
|
||
|
export const OnlineUsersProvider: FC = ({ children }) => {
|
||
|
const { eventEmitter } = useIdeReactContext()
|
||
|
const { socket } = useConnectionContext()
|
||
|
const [openDocId] = useScopeValue<string | null>('editor.open_doc_id')
|
||
|
const { fileTreeData } = useFileTreeData()
|
||
|
|
||
|
const [onlineUsers, setOnlineUsers] =
|
||
|
useScopeValue<OnlineUsersContextValue['onlineUsers']>('onlineUsers')
|
||
|
const [onlineUserCursorHighlights, setOnlineUserCursorHighlights] =
|
||
|
useScopeValue<OnlineUsersContextValue['onlineUserCursorHighlights']>(
|
||
|
'onlineUserCursorHighlights'
|
||
|
)
|
||
|
const [onlineUsersArray, setOnlineUsersArray] =
|
||
|
useScopeValue<OnlineUsersContextValue['onlineUsersArray']>(
|
||
|
'onlineUsersArray'
|
||
|
)
|
||
|
const [onlineUsersCount, setOnlineUsersCount] =
|
||
|
useScopeValue<OnlineUsersContextValue['onlineUsersCount']>(
|
||
|
'onlineUsersCount'
|
||
|
)
|
||
|
|
||
|
const [currentPosition, setCurrentPosition] = useState<CursorPosition | null>(
|
||
|
null
|
||
|
)
|
||
|
const [cursorUpdateInterval, setCursorUpdateInterval] = useState(500)
|
||
|
|
||
|
const calculateValues = useCallback(
|
||
|
(onlineUsers: OnlineUsersContextValue['onlineUsers']) => {
|
||
|
const decoratedOnlineUsers: OnlineUsersContextValue['onlineUsers'] = {}
|
||
|
const onlineUsersArray: OnlineUser[] = []
|
||
|
const onlineUserCursorHighlights: OnlineUsersContextValue['onlineUserCursorHighlights'] =
|
||
|
{}
|
||
|
|
||
|
for (const [clientId, user] of Object.entries(onlineUsers)) {
|
||
|
const decoratedUser = { ...user }
|
||
|
const docId = user.doc_id
|
||
|
if (docId) {
|
||
|
decoratedUser.doc = findDocEntityById(fileTreeData, docId)
|
||
|
}
|
||
|
|
||
|
// If the user's name is empty use their email as display name
|
||
|
// Otherwise they're probably an anonymous user
|
||
|
if (user.name === null || user.name.trim().length === 0) {
|
||
|
decoratedUser.name = user.email ? user.email.trim() : 'Anonymous'
|
||
|
}
|
||
|
|
||
|
decoratedUser.initial = user.name?.[0]
|
||
|
if (!decoratedUser.initial || decoratedUser.initial === ' ') {
|
||
|
decoratedUser.initial = '?'
|
||
|
}
|
||
|
|
||
|
onlineUsersArray.push(decoratedUser)
|
||
|
decoratedOnlineUsers[clientId] = decoratedUser
|
||
|
|
||
|
if (docId == null || user.row == null || user.column == null) {
|
||
|
continue
|
||
|
}
|
||
|
if (!onlineUserCursorHighlights[docId]) {
|
||
|
onlineUserCursorHighlights[docId] = []
|
||
|
}
|
||
|
onlineUserCursorHighlights[docId].push({
|
||
|
label: user.name,
|
||
|
cursor: {
|
||
|
row: user.row,
|
||
|
column: user.column,
|
||
|
},
|
||
|
hue: ColorManager.getHueForUserId(user.user_id),
|
||
|
})
|
||
|
}
|
||
|
|
||
|
const cursorUpdateInterval =
|
||
|
onlineUsersArray.length > 0 ? 500 : 60 * 1000 * 5
|
||
|
|
||
|
return {
|
||
|
onlineUsers: decoratedOnlineUsers,
|
||
|
onlineUsersArray,
|
||
|
onlineUserCursorHighlights,
|
||
|
cursorUpdateInterval,
|
||
|
}
|
||
|
},
|
||
|
[fileTreeData]
|
||
|
)
|
||
|
|
||
|
const setAllValues = useCallback(
|
||
|
(newOnlineUsers: OnlineUsersContextValue['onlineUsers']) => {
|
||
|
const values = calculateValues(newOnlineUsers)
|
||
|
setOnlineUsers(values.onlineUsers)
|
||
|
setOnlineUsersArray(values.onlineUsersArray)
|
||
|
setOnlineUsersCount(values.onlineUsersArray.length)
|
||
|
setOnlineUserCursorHighlights(values.onlineUserCursorHighlights)
|
||
|
setCursorUpdateInterval(values.cursorUpdateInterval)
|
||
|
},
|
||
|
[
|
||
|
calculateValues,
|
||
|
setOnlineUserCursorHighlights,
|
||
|
setOnlineUsers,
|
||
|
setOnlineUsersArray,
|
||
|
setOnlineUsersCount,
|
||
|
]
|
||
|
)
|
||
|
|
||
|
useEffect(() => {
|
||
|
const handleProjectJoined = () => {
|
||
|
socket.emit(
|
||
|
'clientTracking.getConnectedUsers',
|
||
|
// eslint-disable-next-line n/handle-callback-err
|
||
|
(error: Error, connectedUsers: ConnectedUser[]) => {
|
||
|
// TODO: MIGRATION: Handle error (although the original code doesn't)
|
||
|
const newOnlineUsers: OnlineUsersContextValue['onlineUsers'] = {}
|
||
|
for (const user of connectedUsers) {
|
||
|
if (user.client_id === socket.publicId) {
|
||
|
// Don't store myself
|
||
|
continue
|
||
|
}
|
||
|
// Store data in the same format returned by clientTracking.clientUpdated
|
||
|
newOnlineUsers[user.client_id] = {
|
||
|
id: user.client_id,
|
||
|
user_id: user.user_id,
|
||
|
email: user.email,
|
||
|
name: `${user.first_name} ${user.last_name}`,
|
||
|
doc_id: user.cursorData?.doc_id,
|
||
|
row: user.cursorData?.row,
|
||
|
column: user.cursorData?.column,
|
||
|
}
|
||
|
}
|
||
|
setAllValues(newOnlineUsers)
|
||
|
}
|
||
|
)
|
||
|
}
|
||
|
eventEmitter.on('project:joined', handleProjectJoined)
|
||
|
|
||
|
return () => {
|
||
|
eventEmitter.off('project:joined', handleProjectJoined)
|
||
|
}
|
||
|
}, [eventEmitter, setAllValues, setOnlineUsers, socket])
|
||
|
|
||
|
// Track the position of the main cursor
|
||
|
useEffect(() => {
|
||
|
const handleCursorUpdate = (position: CursorPosition | null) => {
|
||
|
if (position) {
|
||
|
setCurrentPosition(position)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
eventEmitter.on('cursor:editor:update', handleCursorUpdate)
|
||
|
|
||
|
return () => {
|
||
|
eventEmitter.off('cursor:editor:update', handleCursorUpdate)
|
||
|
}
|
||
|
}, [cursorUpdateInterval, eventEmitter])
|
||
|
|
||
|
// Send the latest position to other clients when currentPosition changes
|
||
|
useEffect(() => {
|
||
|
const timer = window.setTimeout(() => {
|
||
|
socket.emit('clientTracking.updatePosition', {
|
||
|
row: currentPosition?.row,
|
||
|
column: currentPosition?.column,
|
||
|
doc_id: openDocId,
|
||
|
})
|
||
|
}, cursorUpdateInterval)
|
||
|
|
||
|
return () => {
|
||
|
window.clearTimeout(timer)
|
||
|
}
|
||
|
}, [currentPosition, cursorUpdateInterval, openDocId, socket])
|
||
|
|
||
|
const handleClientUpdated = useCallback(
|
||
|
(client: OnlineUser) => {
|
||
|
// Check it's not me!
|
||
|
if (client.id !== socket.publicId) {
|
||
|
setAllValues({ ...onlineUsers, [client.id]: client })
|
||
|
}
|
||
|
},
|
||
|
[onlineUsers, setAllValues, socket.publicId]
|
||
|
)
|
||
|
|
||
|
useSocketListener(socket, 'clientTracking.clientUpdated', handleClientUpdated)
|
||
|
|
||
|
const handleClientDisconnected = useCallback(
|
||
|
(clientId: string) => {
|
||
|
setAllValues(omit(onlineUsers, clientId))
|
||
|
},
|
||
|
[onlineUsers, setAllValues]
|
||
|
)
|
||
|
|
||
|
useSocketListener(
|
||
|
socket,
|
||
|
'clientTracking.clientDisconnected',
|
||
|
handleClientDisconnected
|
||
|
)
|
||
|
|
||
|
const value = useMemo<OnlineUsersContextValue>(
|
||
|
() => ({
|
||
|
onlineUsers,
|
||
|
onlineUsersArray,
|
||
|
onlineUserCursorHighlights,
|
||
|
onlineUsersCount,
|
||
|
}),
|
||
|
[
|
||
|
onlineUsers,
|
||
|
onlineUsersArray,
|
||
|
onlineUserCursorHighlights,
|
||
|
onlineUsersCount,
|
||
|
]
|
||
|
)
|
||
|
|
||
|
return (
|
||
|
<OnlineUsersContext.Provider value={value}>
|
||
|
{children}
|
||
|
</OnlineUsersContext.Provider>
|
||
|
)
|
||
|
}
|
||
|
|
||
|
export function useOnlineUsersContext(): OnlineUsersContextValue {
|
||
|
const context = useContext(OnlineUsersContext)
|
||
|
|
||
|
if (!context) {
|
||
|
throw new Error(
|
||
|
'useOnlineUsersContext is only available inside OnlineUsersProvider'
|
||
|
)
|
||
|
}
|
||
|
|
||
|
return context
|
||
|
}
|