فهرست منبع

Merge pull request #15557 from overleaf/ii-ide-page-prototype-review-panel-track-changes

Review panel track changes for React IDE page

GitOrigin-RevId: d061596581ff10bd897b286dcd5c280ce79a6384
ilkin-overleaf 2 سال پیش
والد
کامیت
7db5d761ea
18فایلهای تغییر یافته به همراه493 افزوده شده و 107 حذف شده
  1. 0 30
      services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
  2. 1 1
      services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
  3. 424 27
      services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts
  4. 1 21
      services/web/frontend/js/features/ide-react/context/review-panel/review-panel-context.tsx
  5. 30 0
      services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts
  6. 0 9
      services/web/frontend/js/features/ide-react/scope-adapters/review-panel-context-adapter.ts
  7. 2 2
      services/web/frontend/js/features/source-editor/components/review-panel/toolbar/toggle-menu.tsx
  8. 8 5
      services/web/frontend/js/features/source-editor/context/review-panel/types/review-panel-state.ts
  9. 3 0
      services/web/frontend/js/shared/context/project-context.jsx
  10. 2 2
      services/web/frontend/stories/decorators/scope.tsx
  11. 2 1
      services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx
  12. 3 2
      services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx
  13. 3 2
      services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx
  14. 5 0
      services/web/types/public-access-level.ts
  15. 1 1
      services/web/types/review-panel/comment-thread.ts
  16. 2 1
      services/web/types/review-panel/entry.ts
  17. 1 2
      services/web/types/review-panel/review-panel.ts
  18. 5 1
      services/web/types/user.ts

+ 0 - 30
services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx

@@ -1,4 +1,3 @@
-import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
 import {
   createContext,
   FC,
@@ -79,35 +78,6 @@ export type EditorScopeValue = {
   error_state: boolean
 }
 
-export function populateEditorScope(
-  store: ReactScopeValueStore,
-  projectId: string
-) {
-  // This value is not used in the React code. It's just here to prevent errors
-  // from EditorProvider
-  store.set('state.loading', false)
-
-  store.set('project.name', null)
-
-  store.set('editor', {
-    showSymbolPalette: false,
-    toggleSymbolPalette: () => {},
-    sharejs_doc: null,
-    open_doc_id: null,
-    open_doc_name: null,
-    opening: true,
-    trackChanges: false,
-    wantTrackChanges: false,
-    // No Ace here
-    newSourceEditor: true,
-    error_state: false,
-  })
-  store.persisted('editor.showVisual', false, `editor.mode.${projectId}`, {
-    toPersisted: showVisual => (showVisual ? 'rich-text' : 'source'),
-    fromPersisted: mode => mode === 'rich-text',
-  })
-}
-
 const EditorManagerContext = createContext<EditorManager | undefined>(undefined)
 
 export const EditorManagerProvider: FC = ({ children }) => {

+ 1 - 1
services/web/frontend/js/features/ide-react/context/ide-react-context.tsx

@@ -18,7 +18,7 @@ import {
 import { JoinProjectPayload } from '@/features/ide-react/connection/join-project-payload'
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
 import { getMockIde } from '@/shared/context/mock/mock-ide'
-import { populateEditorScope } from '@/features/ide-react/context/editor-manager-context'
+import { populateEditorScope } from '@/features/ide-react/scope-adapters/editor-manager-context-adapter'
 import { postJSON } from '@/infrastructure/fetch-json'
 import { EventLog } from '@/features/ide-react/editor/event-log'
 import { populateSettingsScope } from '@/features/ide-react/scope-adapters/settings-adapter'

+ 424 - 27
services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts

@@ -1,21 +1,76 @@
-import { useState, useMemo, useCallback } from 'react'
+import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
 import useScopeValue from '../../../../../shared/hooks/use-scope-value'
+import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
 import { sendMB } from '../../../../../infrastructure/event-tracking'
 import { dispatchReviewPanelLayout as handleLayoutChange } from '@/features/source-editor/extensions/changes/change-manager'
 import { useProjectContext } from '@/shared/context/project-context'
 import { useLayoutContext } from '@/shared/context/layout-context'
+import { useUserContext } from '@/shared/context/user-context'
+import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
+import { useConnectionContext } from '@/features/ide-react/context/connection-context'
+import { debugConsole } from '@/utils/debugging'
+import { postJSON } from '@/infrastructure/fetch-json'
 import { ReviewPanelStateReactIde } from '../types/review-panel-state'
+import ColorManager from '@/ide/colors/ColorManager'
 import * as ReviewPanel from '../types/review-panel-state'
 import {
   SubView,
   ThreadId,
 } from '../../../../../../../types/review-panel/review-panel'
+import { UserId } from '../../../../../../../types/user'
+import { PublicAccessLevel } from '../../../../../../../types/public-access-level'
+import { DeepReadonly } from '../../../../../../../types/utils'
+
+function formatUser(user: any): any {
+  let isSelf, name
+  const id =
+    (user != null ? user._id : undefined) ||
+    (user != null ? user.id : undefined)
+
+  if (id == null) {
+    return {
+      email: null,
+      name: 'Anonymous',
+      isSelf: false,
+      hue: ColorManager.ANONYMOUS_HUE,
+      avatar_text: 'A',
+    }
+  }
+  if (id === window.user_id) {
+    name = 'You'
+    isSelf = true
+  } else {
+    name = [user.first_name, user.last_name]
+      .filter(n => n != null && n !== '')
+      .join(' ')
+    if (name === '') {
+      name =
+        (user.email != null ? user.email.split('@')[0] : undefined) || 'Unknown'
+    }
+    isSelf = false
+  }
+  return {
+    id,
+    email: user.email,
+    name,
+    isSelf,
+    hue: ColorManager.getHueForUserId(id),
+    avatar_text: [user.first_name, user.last_name]
+      .filter(n => n != null)
+      .map(n => n[0])
+      .join(''),
+  }
+}
 
 function useReviewPanelState(): ReviewPanelStateReactIde {
   const { reviewPanelOpen, setReviewPanelOpen } = useLayoutContext()
+  const { projectId } = useIdeReactContext()
+  const project = useProjectContext()
+  const user = useUserContext()
+  const { socket } = useConnectionContext()
   const {
-    features: { trackChangesVisible },
-  } = useProjectContext()
+    features: { trackChangesVisible, trackChanges },
+  } = project
 
   const [subView, setSubView] = useScopeValue<ReviewPanel.Value<'subView'>>(
     'reviewPanel.subView'
@@ -47,7 +102,7 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
     ReviewPanel.Value<'resolvedComments'>
   >('reviewPanel.resolvedComments', true)
 
-  const [wantTrackChanges] = useScopeValue<
+  const [wantTrackChanges, setWantTrackChanges] = useScopeValue<
     ReviewPanel.Value<'wantTrackChanges'>
   >('editor.wantTrackChanges')
   const [openDocId] =
@@ -58,28 +113,337 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
     'reviewPanel.rendererData.lineHeight'
   )
 
-  const [toggleTrackChangesForEveryone] = useScopeValue<
+  const [formattedProjectMembers, setFormattedProjectMembers] = useState<
+    ReviewPanel.Value<'formattedProjectMembers'>
+  >({})
+  const [trackChangesState, setTrackChangesState] = useState<
+    ReviewPanel.Value<'trackChangesState'>
+  >({})
+  const [trackChangesOnForEveryone, setTrackChangesOnForEveryone] =
+    useState<ReviewPanel.Value<'trackChangesOnForEveryone'>>(false)
+  const [trackChangesOnForGuests, setTrackChangesOnForGuests] =
+    useState<ReviewPanel.Value<'trackChangesOnForGuests'>>(false)
+  const [trackChangesForGuestsAvailable, setTrackChangesForGuestsAvailable] =
+    useState<ReviewPanel.Value<'trackChangesForGuestsAvailable'>>(false)
+
+  const currentUserType = useCallback((): 'member' | 'guest' | 'anonymous' => {
+    if (!user) {
+      return 'anonymous'
+    }
+    if (project.owner === user.id) {
+      return 'member'
+    }
+    for (const member of project.members as any[]) {
+      if (member._id === user.id) {
+        return 'member'
+      }
+    }
+    return 'guest'
+  }, [project.members, project.owner, user])
+
+  const applyClientTrackChangesStateToServer = useCallback(
+    (
+      trackChangesOnForEveryone: boolean,
+      trackChangesOnForGuests: boolean,
+      trackChangesState: ReviewPanel.Value<'trackChangesState'>
+    ) => {
+      const data: {
+        on?: boolean
+        on_for?: Record<UserId, boolean>
+        on_for_guests?: boolean
+      } = {}
+      if (trackChangesOnForEveryone) {
+        data.on = true
+      } else {
+        data.on_for = {}
+        const entries = Object.entries(trackChangesState) as Array<
+          [
+            UserId,
+            NonNullable<
+              typeof trackChangesState[keyof typeof trackChangesState]
+            >
+          ]
+        >
+        for (const [userId, { value }] of entries) {
+          data.on_for[userId] = value
+        }
+        if (trackChangesOnForGuests) {
+          data.on_for_guests = true
+        }
+      }
+      postJSON(`/project/${projectId}/track_changes`, {
+        body: data,
+      }).catch(debugConsole.error)
+    },
+    [projectId]
+  )
+
+  const setGuestsTCState = useCallback(
+    (newValue: boolean) => {
+      setTrackChangesOnForGuests(newValue)
+      if (currentUserType() === 'guest' || currentUserType() === 'anonymous') {
+        setWantTrackChanges(newValue)
+      }
+    },
+    [currentUserType, setWantTrackChanges]
+  )
+
+  const setUserTCState = useCallback(
+    (
+      trackChangesState: DeepReadonly<ReviewPanel.Value<'trackChangesState'>>,
+      userId: UserId,
+      newValue: boolean,
+      isLocal = false
+    ) => {
+      const newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = {
+        ...trackChangesState,
+      }
+      const state =
+        newTrackChangesState[userId] ??
+        ({} as NonNullable<typeof newTrackChangesState[UserId]>)
+      newTrackChangesState[userId] = state
+
+      if (state.syncState == null || state.syncState === 'synced') {
+        state.value = newValue
+        state.syncState = 'synced'
+      } else if (state.syncState === 'pending' && state.value === newValue) {
+        state.syncState = 'synced'
+      } else if (isLocal) {
+        state.value = newValue
+        state.syncState = 'pending'
+      }
+
+      setTrackChangesState(newTrackChangesState)
+
+      if (userId === user.id) {
+        setWantTrackChanges(newValue)
+      }
+
+      return newTrackChangesState
+    },
+    [setWantTrackChanges, user.id]
+  )
+
+  const setEveryoneTCState = useCallback(
+    (newValue: boolean, isLocal = false) => {
+      setTrackChangesOnForEveryone(newValue)
+      let newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = {
+        ...trackChangesState,
+      }
+      for (const member of project.members as any[]) {
+        newTrackChangesState = setUserTCState(
+          newTrackChangesState,
+          member._id,
+          newValue,
+          isLocal
+        )
+      }
+      setGuestsTCState(newValue)
+
+      newTrackChangesState = setUserTCState(
+        newTrackChangesState,
+        project.owner._id,
+        newValue,
+        isLocal
+      )
+
+      return { trackChangesState: newTrackChangesState }
+    },
+    [
+      project.members,
+      project.owner._id,
+      setGuestsTCState,
+      setUserTCState,
+      trackChangesState,
+    ]
+  )
+
+  const toggleTrackChangesForEveryone = useCallback<
     ReviewPanel.UpdaterFn<'toggleTrackChangesForEveryone'>
-  >('toggleTrackChangesForEveryone')
-  const [toggleTrackChangesForUser] = useScopeValue<
-    ReviewPanel.UpdaterFn<'toggleTrackChangesForUser'>
-  >('toggleTrackChangesForUser')
-  const [toggleTrackChangesForGuests] = useScopeValue<
+  >(
+    (onForEveryone: boolean) => {
+      const { trackChangesState } = setEveryoneTCState(onForEveryone, true)
+      setGuestsTCState(onForEveryone)
+      applyClientTrackChangesStateToServer(
+        onForEveryone,
+        onForEveryone,
+        trackChangesState
+      )
+    },
+    [applyClientTrackChangesStateToServer, setEveryoneTCState, setGuestsTCState]
+  )
+
+  const toggleTrackChangesForGuests = useCallback<
     ReviewPanel.UpdaterFn<'toggleTrackChangesForGuests'>
-  >('toggleTrackChangesForGuests')
+  >(
+    (onForGuests: boolean) => {
+      setGuestsTCState(onForGuests)
+      applyClientTrackChangesStateToServer(
+        trackChangesOnForEveryone,
+        onForGuests,
+        trackChangesState
+      )
+    },
+    [
+      applyClientTrackChangesStateToServer,
+      setGuestsTCState,
+      trackChangesOnForEveryone,
+      trackChangesState,
+    ]
+  )
+
+  const toggleTrackChangesForUser = useCallback<
+    ReviewPanel.UpdaterFn<'toggleTrackChangesForUser'>
+  >(
+    (onForUser: boolean, userId: UserId) => {
+      const newTrackChangesState = setUserTCState(
+        trackChangesState,
+        userId,
+        onForUser,
+        true
+      )
+      applyClientTrackChangesStateToServer(
+        trackChangesOnForEveryone,
+        trackChangesOnForGuests,
+        newTrackChangesState
+      )
+    },
+    [
+      applyClientTrackChangesStateToServer,
+      setUserTCState,
+      trackChangesOnForEveryone,
+      trackChangesOnForGuests,
+      trackChangesState,
+    ]
+  )
+
+  const applyTrackChangesStateToClient = useCallback(
+    (state: boolean | Record<UserId, boolean>) => {
+      if (typeof state === 'boolean') {
+        setEveryoneTCState(state)
+        setGuestsTCState(state)
+      } else {
+        setTrackChangesOnForEveryone(false)
+        // TODO
+        // @ts-ignore
+        setGuestsTCState(state.__guests__ === true)
+
+        let newTrackChangesState: ReviewPanel.Value<'trackChangesState'> = {
+          ...trackChangesState,
+        }
+        for (const member of project.members as any[]) {
+          newTrackChangesState = setUserTCState(
+            newTrackChangesState,
+            member._id,
+            state[member._id] ?? false
+          )
+        }
+        newTrackChangesState = setUserTCState(
+          newTrackChangesState,
+          project.owner._id,
+          state[project.owner._id] ?? false
+        )
+        return newTrackChangesState
+      }
+    },
+    [
+      project.members,
+      project.owner._id,
+      setEveryoneTCState,
+      setGuestsTCState,
+      setUserTCState,
+      trackChangesState,
+    ]
+  )
+
+  const setGuestFeatureBasedOnProjectAccessLevel = (
+    projectPublicAccessLevel: PublicAccessLevel
+  ) => {
+    setTrackChangesForGuestsAvailable(projectPublicAccessLevel === 'tokenBased')
+  }
+
+  useEffect(() => {
+    setGuestFeatureBasedOnProjectAccessLevel(project.publicAccessLevel)
+  }, [project.publicAccessLevel])
+
+  useEffect(() => {
+    if (
+      trackChangesForGuestsAvailable ||
+      !trackChangesOnForGuests ||
+      trackChangesOnForEveryone
+    ) {
+      return
+    }
+
+    // Overrides guest setting
+    toggleTrackChangesForGuests(false)
+  }, [
+    toggleTrackChangesForGuests,
+    trackChangesForGuestsAvailable,
+    trackChangesOnForEveryone,
+    trackChangesOnForGuests,
+  ])
+
+  const projectJoinedEffectExecuted = useRef(false)
+  useEffect(() => {
+    if (!projectJoinedEffectExecuted.current) {
+      requestAnimationFrame(() => {
+        if (trackChanges) {
+          applyTrackChangesStateToClient(project.trackChangesState)
+        } else {
+          applyTrackChangesStateToClient(false)
+        }
+        setGuestFeatureBasedOnProjectAccessLevel(project.publicAccessLevel)
+      })
+      projectJoinedEffectExecuted.current = true
+    }
+  }, [
+    applyTrackChangesStateToClient,
+    trackChanges,
+    project.publicAccessLevel,
+    project.trackChangesState,
+  ])
+
+  useEffect(() => {
+    setFormattedProjectMembers(prevState => {
+      const tempFormattedProjectMembers: typeof prevState = {}
+      if (project.owner) {
+        tempFormattedProjectMembers[project.owner._id] = formatUser(
+          project.owner
+        )
+      }
+      if (project.members) {
+        for (const member of project.members) {
+          if (member.privileges === 'readAndWrite') {
+            if (!trackChangesState[member._id]) {
+              // An added member will have track changes enabled if track changes is on for everyone
+              setUserTCState(
+                trackChangesState,
+                member._id,
+                trackChangesOnForEveryone,
+                true
+              )
+            }
+            tempFormattedProjectMembers[member._id] = formatUser(member)
+          }
+        }
+      }
+      return tempFormattedProjectMembers
+    })
+  }, [
+    project.members,
+    project.owner,
+    setUserTCState,
+    trackChangesOnForEveryone,
+    trackChangesState,
+  ])
+
+  useSocketListener(
+    socket,
+    'toggle-track-changes',
+    applyTrackChangesStateToClient
+  )
 
-  const [trackChangesState] = useScopeValue<
-    ReviewPanel.Value<'trackChangesState'>
-  >('reviewPanel.trackChangesState')
-  const [trackChangesOnForEveryone] = useScopeValue<
-    ReviewPanel.Value<'trackChangesOnForEveryone'>
-  >('reviewPanel.trackChangesOnForEveryone')
-  const [trackChangesOnForGuests] = useScopeValue<
-    ReviewPanel.Value<'trackChangesOnForGuests'>
-  >('reviewPanel.trackChangesOnForGuests')
-  const [trackChangesForGuestsAvailable] = useScopeValue<
-    ReviewPanel.Value<'trackChangesForGuestsAvailable'>
-  >('reviewPanel.trackChangesForGuestsAvailable')
   const [resolveComment] =
     useScopeValue<ReviewPanel.UpdaterFn<'resolveComment'>>('resolveComment')
   const [submitNewComment] =
@@ -95,10 +459,6 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
       (entry: { thread_id: ThreadId; replyContent: string }) => void
     >('submitReply')
 
-  const [formattedProjectMembers] = useScopeValue<
-    ReviewPanel.Value<'formattedProjectMembers'>
-  >('reviewPanel.formattedProjectMembers')
-
   const toggleReviewPanel = useCallback(() => {
     if (!trackChangesVisible) {
       return
@@ -150,6 +510,43 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
   const [toolbarHeight, setToolbarHeight] = useState(0)
   const [layoutSuspended, setLayoutSuspended] = useState(false)
 
+  // listen for events from the CodeMirror 6 track changes extension
+  useEffect(() => {
+    const toggleTrackChangesFromKbdShortcut = () => {
+      if (trackChangesVisible && trackChanges) {
+        const userId: UserId = user.id
+        const state = trackChangesState[userId]
+        if (state) {
+          toggleTrackChangesForUser(!state.value, userId)
+        }
+      }
+    }
+
+    const handleEditorEvents = (e: Event) => {
+      const event = e as CustomEvent
+      const { type } = event.detail
+
+      switch (type) {
+        case 'toggle-track-changes': {
+          toggleTrackChangesFromKbdShortcut()
+          break
+        }
+      }
+    }
+
+    window.addEventListener('editor:event', handleEditorEvents)
+
+    return () => {
+      window.removeEventListener('editor:event', handleEditorEvents)
+    }
+  }, [
+    toggleTrackChangesForUser,
+    trackChanges,
+    trackChangesState,
+    trackChangesVisible,
+    user.id,
+  ])
+
   const values = useMemo<ReviewPanelStateReactIde['values']>(
     () => ({
       collapsed,

+ 1 - 21
services/web/frontend/js/features/ide-react/context/review-panel/review-panel-context.tsx

@@ -1,4 +1,4 @@
-import { useContext, createContext } from 'react'
+import { createContext } from 'react'
 import useReviewPanelState from '@/features/ide-react/context/review-panel/hooks/use-review-panel-state'
 import { ReviewPanelStateReactIde } from '@/features/ide-react/context/review-panel/types/review-panel-state'
 
@@ -21,23 +21,3 @@ export const ReviewPanelReactIdeProvider: React.FC = ({ children }) => {
     </ReviewPanelReactIdeValueContext.Provider>
   )
 }
-
-export function useReviewPanelReactIdeValueContext() {
-  const context = useContext(ReviewPanelReactIdeValueContext)
-  if (!context) {
-    throw new Error(
-      'ReviewPanelReactIdeValueContext is only available inside ReviewPanelReactIdeProvider'
-    )
-  }
-  return context
-}
-
-export function useReviewPanelReactIdeUpdaterFnsContext() {
-  const context = useContext(ReviewPanelReactIdeUpdaterFnsContext)
-  if (!context) {
-    throw new Error(
-      'ReviewPanelReactIdeUpdaterFnsContext is only available inside ReviewPanelReactIdeProvider'
-    )
-  }
-  return context
-}

+ 30 - 0
services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts

@@ -0,0 +1,30 @@
+import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store'
+
+export function populateEditorScope(
+  store: ReactScopeValueStore,
+  projectId: string
+) {
+  // This value is not used in the React code. It's just here to prevent errors
+  // from EditorProvider
+  store.set('state.loading', false)
+
+  store.set('project.name', null)
+
+  store.set('editor', {
+    showSymbolPalette: false,
+    toggleSymbolPalette: () => {},
+    sharejs_doc: null,
+    open_doc_id: null,
+    open_doc_name: null,
+    opening: true,
+    trackChanges: false,
+    wantTrackChanges: false,
+    // No Ace here
+    newSourceEditor: true,
+    error_state: false,
+  })
+  store.persisted('editor.showVisual', false, `editor.mode.${projectId}`, {
+    toPersisted: showVisual => (showVisual ? 'rich-text' : 'source'),
+    fromPersisted: mode => mode === 'rich-text',
+  })
+}

+ 0 - 9
services/web/frontend/js/features/ide-react/scope-adapters/review-panel-context-adapter.ts

@@ -17,20 +17,11 @@ export default function populateReviewPanelScope(store: ReactScopeValueStore) {
   store.set('users', {})
   store.set('reviewPanel.resolvedComments', {})
   store.set('reviewPanel.rendererData.lineHeight', 0)
-  store.set('reviewPanel.trackChangesState', {})
-  store.set('reviewPanel.trackChangesOnForEveryone', false)
-  store.set('reviewPanel.trackChangesOnForGuests', false)
-  store.set('reviewPanel.trackChangesForGuestsAvailable', false)
-  store.set('reviewPanel.formattedProjectMembers', {})
-  store.set('toggleTrackChangesForEveryone', () => {})
-  store.set('toggleTrackChangesForUser', () => {})
-  store.set('toggleTrackChangesForGuests', () => {})
   store.set('resolveComment', () => {})
   store.set('submitNewComment', async () => {})
   store.set('deleteComment', () => {})
   store.set('gotoEntry', () => {})
   store.set('saveEdit', () => {})
-  store.set('toggleReviewPanel', () => {})
   store.set('unresolveComment', () => {})
   store.set('deleteThread', () => {})
   store.set('refreshResolvedCommentsDropdown', async () => {})

+ 2 - 2
services/web/frontend/js/features/source-editor/components/review-panel/toolbar/toggle-menu.tsx

@@ -141,11 +141,11 @@ function ToggleMenu() {
               description={t('track_changes_for_x', { name: member.name })}
               handleToggle={() =>
                 toggleTrackChangesForUser(
-                  !trackChangesState[member.id].value,
+                  !trackChangesState[member.id]?.value,
                   member.id
                 )
               }
-              value={trackChangesState[member.id].value}
+              value={Boolean(trackChangesState[member.id]?.value)}
               disabled={
                 trackChangesOnForEveryone ||
                 !project.features.trackChanges ||

+ 8 - 5
services/web/frontend/js/features/source-editor/context/review-panel/types/review-panel-state.ts

@@ -9,6 +9,7 @@ import {
 } from '../../../../../../../types/review-panel/review-panel'
 import { DocId } from '../../../../../../../types/project-settings'
 import { dispatchReviewPanelLayout } from '../../../extensions/changes/change-manager'
+import { UserId } from '../../../../../../../types/user'
 
 /* eslint-disable no-use-before-define */
 export interface ReviewPanelState {
@@ -31,14 +32,16 @@ export interface ReviewPanelState {
     loading: boolean
     openDocId: DocId | null
     lineHeight: number
-    trackChangesState: Record<string, { value: boolean; syncState: string }>
+    trackChangesState:
+      | Record<UserId, { value: boolean; syncState: 'synced' | 'pending' }>
+      | Record<UserId, undefined>
     trackChangesOnForEveryone: boolean
     trackChangesOnForGuests: boolean
     trackChangesForGuestsAvailable: boolean
     formattedProjectMembers: Record<
       string,
       {
-        id: string
+        id: UserId
         name: string
       }
     >
@@ -55,9 +58,9 @@ export interface ReviewPanelState {
     submitReply: (threadId: ThreadId, replyContent: string) => void
     acceptChanges: (entryIds: unknown) => void
     rejectChanges: (entryIds: unknown) => void
-    toggleTrackChangesForEveryone: (isOn: boolean) => unknown
-    toggleTrackChangesForUser: (isOn: boolean, memberId: string) => unknown
-    toggleTrackChangesForGuests: (isOn: boolean) => unknown
+    toggleTrackChangesForEveryone: (onForEveryone: boolean) => void
+    toggleTrackChangesForUser: (onForUser: boolean, userId: UserId) => void
+    toggleTrackChangesForGuests: (onForGuests: boolean) => void
     toggleReviewPanel: () => void
     bulkAcceptActions: () => void
     bulkRejectActions: () => void

+ 3 - 0
services/web/frontend/js/shared/context/project-context.jsx

@@ -89,6 +89,7 @@ export function ProjectProvider({ children }) {
     publicAccesLevel: publicAccessLevel,
     owner,
     showNewCompileTimeoutUI,
+    trackChangesState,
   } = project || projectFallback
 
   const tags = useMemo(
@@ -123,6 +124,7 @@ export function ProjectProvider({ children }) {
       showNewCompileTimeoutUI:
         newCompileTimeoutOverride || showNewCompileTimeoutUI,
       tags,
+      trackChangesState,
     }
   }, [
     _id,
@@ -136,6 +138,7 @@ export function ProjectProvider({ children }) {
     showNewCompileTimeoutUI,
     newCompileTimeoutOverride,
     tags,
+    trackChangesState,
   ])
 
   return (

+ 2 - 2
services/web/frontend/stories/decorators/scope.tsx

@@ -1,6 +1,6 @@
 import { useEffect, useMemo } from 'react'
 import { get } from 'lodash'
-import { User } from '../../../types/user'
+import { User, UserId } from '../../../types/user'
 import { Project } from '../../../types/project'
 import {
   mockBuildFile,
@@ -26,7 +26,7 @@ const scopeWatchers: [string, (value: any) => void][] = []
 
 const initialize = () => {
   const user: User = {
-    id: 'story-user',
+    id: 'story-user' as UserId,
     email: 'story-user@example.com',
     allowedFreeTrial: true,
     features: { dropbox: true, symbolPalette: true },

+ 2 - 1
services/web/test/frontend/features/source-editor/components/codemirror-editor-autocomplete.spec.tsx

@@ -6,6 +6,7 @@ import { mockScope } from '../helpers/mock-scope'
 import { EditorProviders } from '../../../helpers/editor-providers'
 import CodeMirrorEditor from '../../../../../frontend/js/features/source-editor/components/codemirror-editor'
 import { activeEditorLine } from '../helpers/active-editor-line'
+import { UserId } from '../../../../../types/user'
 
 const Container: FC = ({ children }) => (
   <div style={{ width: 785, height: 785 }}>{children}</div>
@@ -860,7 +861,7 @@ describe('autocomplete', { scrollBehavior: false }, function () {
 
     window.metaAttributesCache.set('ol-showSymbolPalette', true)
     const user = {
-      id: '123abd',
+      id: '123abd' as UserId,
       email: 'testuser@example.com',
     }
     cy.mount(

+ 3 - 2
services/web/test/frontend/features/subscription/components/dashboard/group-subscription-memberships.test.tsx

@@ -16,6 +16,7 @@ import {
   groupActiveSubscriptionWithPendingLicenseChange,
 } from '../../fixtures/subscriptions'
 import * as useLocationModule from '../../../../../../frontend/js/shared/hooks/use-location'
+import { UserId } from '../../../../../../types/user'
 
 const userId = 'fff999fff999'
 const memberGroupSubscriptions: MemberGroupSubscription[] = [
@@ -24,7 +25,7 @@ const memberGroupSubscriptions: MemberGroupSubscription[] = [
     userIsGroupManager: false,
     planLevelName: 'Professional',
     admin_id: {
-      id: 'abc123abc123',
+      id: 'abc123abc123' as UserId,
       email: 'you@example.com',
     },
   },
@@ -33,7 +34,7 @@ const memberGroupSubscriptions: MemberGroupSubscription[] = [
     userIsGroupManager: true,
     planLevelName: 'Collaborator',
     admin_id: {
-      id: 'bcd456bcd456',
+      id: 'bcd456bcd456' as UserId,
       email: 'someone@example.com',
     },
   },

+ 3 - 2
services/web/test/frontend/features/subscription/components/dashboard/managed-group-subscriptions.test.tsx

@@ -10,6 +10,7 @@ import {
   cleanUpContext,
   renderWithSubscriptionDashContext,
 } from '../../helpers/render-with-subscription-dash-context'
+import { UserId } from '../../../../../../types/user'
 
 function getManagedGroupSubscription(groupSSO: boolean, managedUsers: boolean) {
   const subscriptionOne = {
@@ -17,7 +18,7 @@ function getManagedGroupSubscription(groupSSO: boolean, managedUsers: boolean) {
     userIsGroupMember: true,
     planLevelName: 'Professional',
     admin_id: {
-      id: 'abc123abc123',
+      id: 'abc123abc123' as UserId,
       email: 'you@example.com',
     },
     features: {
@@ -31,7 +32,7 @@ function getManagedGroupSubscription(groupSSO: boolean, managedUsers: boolean) {
     userIsGroupMember: false,
     planLevelName: 'Collaborator',
     admin_id: {
-      id: 'bcd456bcd456',
+      id: 'bcd456bcd456' as UserId,
       email: 'someone@example.com',
     },
     features: {

+ 5 - 0
services/web/types/public-access-level.ts

@@ -0,0 +1,5 @@
+export type PublicAccessLevel =
+  | 'readOnly'
+  | 'readAndWrite'
+  | 'private'
+  | 'tokenBased'

+ 1 - 1
services/web/types/review-panel/comment-thread.ts

@@ -1,8 +1,8 @@
 import {
   ReviewPanelCommentThreadMessage,
   ReviewPanelUser,
-  UserId,
 } from './review-panel'
+import { UserId } from '../user'
 import { DateString } from '../helpers/date'
 
 interface ReviewPanelCommentThreadBase {

+ 2 - 1
services/web/types/review-panel/entry.ts

@@ -1,4 +1,5 @@
-import { ThreadId, UserId } from './review-panel'
+import { ThreadId } from './review-panel'
+import { UserId } from '../user'
 
 export interface ReviewPanelEntryScreenPos {
   y: number

+ 1 - 2
services/web/types/review-panel/review-panel.ts

@@ -1,5 +1,6 @@
 import { Brand } from '../helpers/brand'
 import { DocId } from '../project-settings'
+import { UserId } from '../user'
 import {
   ReviewPanelAddCommentEntry,
   ReviewPanelBulkActionsEntry,
@@ -29,8 +30,6 @@ export type ReviewPanelDocEntries = Record<
 
 export type ReviewPanelEntries = Record<DocId, ReviewPanelDocEntries>
 
-export type UserId = Brand<string, 'UserId'>
-
 export interface ReviewPanelUser {
   avatar_text: string
   email: string

+ 5 - 1
services/web/types/user.ts

@@ -1,10 +1,14 @@
+import { Brand } from './helpers/brand'
+
 export type RefProviders = {
   mendeley?: boolean
   zotero?: boolean
 }
 
+export type UserId = Brand<string, 'UserId'>
+
 export type User = {
-  id: string
+  id: UserId
   email: string
   allowedFreeTrial?: boolean
   signUpDate?: string // date string