Просмотр исходного кода

Merge pull request #12549 from overleaf/ii-history-react-list-of-all-versions

[web] All versions of history entries

GitOrigin-RevId: 7365ac4913c115b3b2872a3713d893463719c15e
ilkin-overleaf 3 лет назад
Родитель
Сommit
8e0aa685ce
24 измененных файлов с 735 добавлено и 57 удалено
  1. 13 0
      services/web/frontend/extracted-translations.json
  2. 22 0
      services/web/frontend/js/features/history/components/change-list/all-history-list.tsx
  3. 10 7
      services/web/frontend/js/features/history/components/change-list/change-list.tsx
  4. 39 0
      services/web/frontend/js/features/history/components/change-list/changes.tsx
  5. 49 0
      services/web/frontend/js/features/history/components/change-list/history-version.tsx
  6. 96 0
      services/web/frontend/js/features/history/components/change-list/label-badges.tsx
  7. 5 0
      services/web/frontend/js/features/history/components/change-list/labels-list.tsx
  8. 0 5
      services/web/frontend/js/features/history/components/change-list/main.tsx
  9. 54 0
      services/web/frontend/js/features/history/components/change-list/metadata-users-list.tsx
  10. 19 0
      services/web/frontend/js/features/history/components/change-list/origin.tsx
  11. 135 23
      services/web/frontend/js/features/history/context/history-context.tsx
  12. 9 5
      services/web/frontend/js/features/history/context/types/history-context-value.ts
  13. 9 2
      services/web/frontend/js/features/history/services/api.ts
  14. 12 1
      services/web/frontend/js/features/history/services/types/shared.ts
  15. 27 4
      services/web/frontend/js/features/history/services/types/update.ts
  16. 2 2
      services/web/frontend/js/features/history/utils/highlights-from-diff-response.ts
  17. 40 0
      services/web/frontend/js/features/history/utils/history-details.ts
  18. 60 0
      services/web/frontend/js/features/history/utils/label.ts
  19. 2 2
      services/web/frontend/js/features/utils/format-date.js
  20. 1 1
      services/web/frontend/stories/history/toggle-switch.stories.tsx
  21. 121 5
      services/web/frontend/stylesheets/app/editor/history-react.less
  22. 8 0
      services/web/frontend/stylesheets/core/mixins.less
  23. 1 0
      services/web/frontend/stylesheets/core/variables.less
  24. 1 0
      services/web/locales/en.json

+ 13 - 0
services/web/frontend/extracted-translations.json

@@ -38,6 +38,7 @@
   "all_projects": "",
   "all_projects": "",
   "also": "",
   "also": "",
   "an_error_occurred_when_verifying_the_coupon_code": "",
   "an_error_occurred_when_verifying_the_coupon_code": "",
+  "anonymous": "",
   "anyone_with_link_can_edit": "",
   "anyone_with_link_can_edit": "",
   "anyone_with_link_can_view": "",
   "anyone_with_link_can_view": "",
   "approaching_compile_timeout_limit_upgrade_for_more_compile_time": "",
   "approaching_compile_timeout_limit_upgrade_for_more_compile_time": "",
@@ -246,6 +247,10 @@
   "faster_compiles_feedback_seems_same": "",
   "faster_compiles_feedback_seems_same": "",
   "faster_compiles_feedback_seems_slower": "",
   "faster_compiles_feedback_seems_slower": "",
   "faster_compiles_feedback_thanks": "",
   "faster_compiles_feedback_thanks": "",
+  "file_action_created": "",
+  "file_action_deleted": "",
+  "file_action_edited": "",
+  "file_action_renamed": "",
   "file_already_exists": "",
   "file_already_exists": "",
   "file_already_exists_in_this_location": "",
   "file_already_exists_in_this_location": "",
   "file_name": "",
   "file_name": "",
@@ -347,6 +352,12 @@
   "hide_document_preamble": "",
   "hide_document_preamble": "",
   "hide_outline": "",
   "hide_outline": "",
   "history": "",
   "history": "",
+  "history_entry_origin_dropbox": "",
+  "history_entry_origin_git": "",
+  "history_entry_origin_github": "",
+  "history_entry_origin_upload": "",
+  "history_label_created_by": "",
+  "history_label_project_current_state": "",
   "history_view_a11y_description": "",
   "history_view_a11y_description": "",
   "history_view_all": "",
   "history_view_all": "",
   "history_view_labels": "",
   "history_view_labels": "",
@@ -551,6 +562,7 @@
   "other_output_files": "",
   "other_output_files": "",
   "overall_theme": "",
   "overall_theme": "",
   "overleaf": "",
   "overleaf": "",
+  "overleaf_history_system": "",
   "overleaf_labs": "",
   "overleaf_labs": "",
   "overwrite": "",
   "overwrite": "",
   "owned_by_x": "",
   "owned_by_x": "",
@@ -947,6 +959,7 @@
   "x_price_per_year": "",
   "x_price_per_year": "",
   "year": "",
   "year": "",
   "yes_move_me_to_personal_plan": "",
   "yes_move_me_to_personal_plan": "",
+  "you": "",
   "you_already_have_a_subscription": "",
   "you_already_have_a_subscription": "",
   "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_a_manager_and_member_of_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_a_manager_of_commons_at_institution_x": "",
   "you_are_a_manager_of_commons_at_institution_x": "",

+ 22 - 0
services/web/frontend/js/features/history/components/change-list/all-history-list.tsx

@@ -0,0 +1,22 @@
+import { Fragment } from 'react'
+import { useHistoryContext } from '../../context/history-context'
+import HistoryVersion from './history-version'
+
+function AllHistoryList() {
+  const { updates } = useHistoryContext()
+
+  return (
+    <>
+      {updates.map((update, index) => (
+        <Fragment key={`${update.fromV}_${update.toV}`}>
+          {update.meta.first_in_day && index > 0 && (
+            <hr className="history-version-divider" />
+          )}
+          <HistoryVersion update={update} />
+        </Fragment>
+      ))}
+    </>
+  )
+}
+
+export default AllHistoryList

+ 10 - 7
services/web/frontend/js/features/history/components/change-list/change-list.tsx

@@ -1,10 +1,11 @@
 import usePersistedState from '../../../../shared/hooks/use-persisted-state'
 import usePersistedState from '../../../../shared/hooks/use-persisted-state'
 import ToggleSwitch from './toggle-switch'
 import ToggleSwitch from './toggle-switch'
-import Main from './main'
+import AllHistoryList from './all-history-list'
+import LabelsList from './labels-list'
 import { useHistoryContext } from '../../context/history-context'
 import { useHistoryContext } from '../../context/history-context'
 
 
 function ChangeList() {
 function ChangeList() {
-  const { projectId, isError } = useHistoryContext()
+  const { projectId, error } = useHistoryContext()
   const [labelsOnly, setLabelsOnly] = usePersistedState(
   const [labelsOnly, setLabelsOnly] = usePersistedState(
     `history.userPrefs.showOnlyLabels.${projectId}`,
     `history.userPrefs.showOnlyLabels.${projectId}`,
     false
     false
@@ -12,14 +13,16 @@ function ChangeList() {
 
 
   return (
   return (
     <aside className="change-list">
     <aside className="change-list">
-      <div className="history-header toggle-switch-container">
-        {!isError && (
+      <div className="history-header history-toggle-switch-container">
+        {!error && (
           <ToggleSwitch labelsOnly={labelsOnly} setLabelsOnly={setLabelsOnly} />
           <ToggleSwitch labelsOnly={labelsOnly} setLabelsOnly={setLabelsOnly} />
         )}
         )}
       </div>
       </div>
-      <div className="version-list-container">
-        <Main />
-      </div>
+      {!error && (
+        <div className="version-list-container">
+          {labelsOnly ? <LabelsList /> : <AllHistoryList />}
+        </div>
+      )}
     </aside>
     </aside>
   )
   )
 }
 }

+ 39 - 0
services/web/frontend/js/features/history/components/change-list/changes.tsx

@@ -0,0 +1,39 @@
+import { useTranslation } from 'react-i18next'
+import { getProjectOpDoc } from '../../utils/history-details'
+import { LoadedUpdate } from '../../services/types/update'
+
+type ChangesProps = {
+  pathNames: LoadedUpdate['pathnames']
+  projectOps: LoadedUpdate['project_ops']
+}
+
+function Changes({ pathNames, projectOps }: ChangesProps) {
+  const { t } = useTranslation()
+
+  return (
+    <ol className="history-version-changes">
+      {pathNames.map(pathName => (
+        <li key={pathName}>
+          <div className="history-version-change-action">
+            {t('file_action_edited')}
+          </div>
+          <div className="history-version-change-doc">{pathName}</div>
+        </li>
+      ))}
+      {projectOps.map((op, index) => (
+        <li key={index}>
+          <div className="history-version-change-action">
+            {op.rename && t('file_action_renamed')}
+            {op.add && t('file_action_created')}
+            {op.remove && t('file_action_deleted')}
+          </div>
+          <div className="history-version-change-doc">
+            {getProjectOpDoc(op)}
+          </div>
+        </li>
+      ))}
+    </ol>
+  )
+}
+
+export default Changes

+ 49 - 0
services/web/frontend/js/features/history/components/change-list/history-version.tsx

@@ -0,0 +1,49 @@
+import LabelBadges from './label-badges'
+import Changes from './changes'
+import MetadataUsersList from './metadata-users-list'
+import Origin from './origin'
+import { useUserContext } from '../../../../shared/context/user-context'
+import { relativeDate, formatTime } from '../../../utils/format-date'
+import { LoadedUpdate } from '../../services/types/update'
+
+type HistoryEntryProps = {
+  update: LoadedUpdate
+}
+
+function HistoryVersion({ update }: HistoryEntryProps) {
+  const { id: currentUserId } = useUserContext()
+
+  return (
+    <div>
+      {update.meta.first_in_day && (
+        <time className="history-version-day">
+          {relativeDate(update.meta.end_ts)}
+        </time>
+      )}
+      <div className="history-version-details">
+        <div>
+          <time className="history-version-metadata-time">
+            {formatTime(update.meta.end_ts, 'Do MMMM, h:mm a')}
+          </time>
+          <LabelBadges
+            labels={update.labels}
+            showTooltip
+            currentUserId={currentUserId}
+          />
+          <Changes
+            pathNames={update.pathnames}
+            projectOps={update.project_ops}
+          />
+          <MetadataUsersList
+            users={update.meta.users}
+            origin={update.meta.origin}
+            currentUserId={currentUserId}
+          />
+          <Origin origin={update.meta.origin} />
+        </div>
+      </div>
+    </div>
+  )
+}
+
+export default HistoryVersion

+ 96 - 0
services/web/frontend/js/features/history/components/change-list/label-badges.tsx

@@ -0,0 +1,96 @@
+import { Fragment } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../../../../shared/components/icon'
+import Tooltip from '../../../../shared/components/tooltip'
+import { useHistoryContext } from '../../context/history-context'
+import { isPseudoLabel } from '../../utils/label'
+import { formatDate } from '../../../../utils/dates'
+import { orderBy } from 'lodash'
+import { Label, PseudoCurrentStateLabel } from '../../services/types/update'
+
+type LabelBadgesProps = {
+  showTooltip: boolean
+  currentUserId: string
+  labels: Array<Label | PseudoCurrentStateLabel>
+}
+
+function LabelBadges({ labels, currentUserId, showTooltip }: LabelBadgesProps) {
+  const { t } = useTranslation()
+  const { labels: allLabels } = useHistoryContext()
+  const orderedLabels = orderBy(labels, ['created_at'], ['desc'])
+
+  const handleDelete = (e: React.MouseEvent, label: Label) => {
+    e.stopPropagation()
+  }
+
+  return (
+    <>
+      {orderedLabels.map(label => {
+        const isPseudoCurrentStateLabel = isPseudoLabel(label)
+        const isOwnedByCurrentUser = !isPseudoCurrentStateLabel
+          ? label.user_id === currentUserId
+          : null
+        const currentLabelData = allLabels?.find(({ id }) => id === label.id)
+        const labelOwnerName =
+          currentLabelData && !isPseudoLabel(currentLabelData)
+            ? currentLabelData.user_display_name
+            : t('anonymous')
+
+        const badgeContent = (
+          <span className="history-version-badge">
+            <Icon type="tag" fw />
+            <span className="history-version-badge-comment">
+              {isPseudoCurrentStateLabel
+                ? t('history_label_project_current_state')
+                : label.comment}
+            </span>
+            {isOwnedByCurrentUser && !isPseudoCurrentStateLabel && (
+              <button
+                type="button"
+                className="history-version-label-delete-btn"
+                onClick={e => handleDelete(e, label)}
+                aria-label={t('delete')}
+              >
+                <span aria-hidden="true">&times;</span>
+              </button>
+            )}
+          </span>
+        )
+
+        return (
+          <Fragment key={label.id}>
+            {showTooltip && !isPseudoCurrentStateLabel ? (
+              <Tooltip
+                key={label.id}
+                description={
+                  <div className="history-version-label-tooltip">
+                    <div className="history-version-label-tooltip-row">
+                      <b>
+                        <Icon type="tag" fw />
+                        {label.comment}
+                      </b>
+                    </div>
+                    <div className="history-version-label-tooltip-row">
+                      {t('history_label_created_by')} {labelOwnerName}
+                    </div>
+                    <div className="history-version-label-tooltip-row">
+                      <time>{formatDate(label.created_at)}</time>
+                    </div>
+                  </div>
+                }
+                id={label.id}
+                overlayProps={{ placement: 'left' }}
+              >
+                {badgeContent}
+              </Tooltip>
+            ) : (
+              badgeContent
+            )}
+          </Fragment>
+        )
+      })}
+    </>
+  )
+}
+
+export default LabelBadges

+ 5 - 0
services/web/frontend/js/features/history/components/change-list/labels-list.tsx

@@ -0,0 +1,5 @@
+function LabelsList() {
+  return <div>Labels only</div>
+}
+
+export default LabelsList

+ 0 - 5
services/web/frontend/js/features/history/components/change-list/main.tsx

@@ -1,5 +0,0 @@
-function Main() {
-  return <div>Change List</div>
-}
-
-export default Main

+ 54 - 0
services/web/frontend/js/features/history/components/change-list/metadata-users-list.tsx

@@ -0,0 +1,54 @@
+import { useTranslation } from 'react-i18next'
+import { getUserColor, formatUserName } from '../../utils/history-details'
+import { LoadedUpdate } from '../../services/types/update'
+
+type MetadataUsersListProps = {
+  currentUserId: string
+} & Pick<LoadedUpdate['meta'], 'users' | 'origin'>
+
+function MetadataUsersList({
+  users,
+  origin,
+  currentUserId,
+}: MetadataUsersListProps) {
+  const { t } = useTranslation()
+
+  return (
+    <ol className="history-version-metadata-users">
+      {users.map((user, index) => {
+        let userName: string
+        if (!user) {
+          userName = t('anonymous')
+        } else if (user?.id === currentUserId) {
+          userName = t('you')
+        } else {
+          userName = formatUserName(user)
+        }
+
+        return (
+          <li key={index}>
+            <span
+              className="history-version-user-badge-color"
+              style={{ backgroundColor: getUserColor(user) }}
+            />
+            {userName}
+          </li>
+        )
+      })}
+      {!users.length && (
+        <li>
+          <span
+            className="history-version-user-badge-color"
+            style={{ backgroundColor: getUserColor() }}
+          />
+          {origin?.kind === 'history-resync' ||
+          origin?.kind === 'history-migration'
+            ? t('overleaf_history_system')
+            : t('anonymous')}
+        </li>
+      )}
+    </ol>
+  )
+}
+
+export default MetadataUsersList

+ 19 - 0
services/web/frontend/js/features/history/components/change-list/origin.tsx

@@ -0,0 +1,19 @@
+import { useTranslation } from 'react-i18next'
+import { LoadedUpdate } from '../../services/types/update'
+
+function Origin({ origin }: Pick<LoadedUpdate['meta'], 'origin'>) {
+  const { t } = useTranslation()
+
+  let result: string | null = null
+  if (origin?.kind === 'dropbox') result = t('history_entry_origin_dropbox')
+  if (origin?.kind === 'upload') result = t('history_entry_origin_upload')
+  if (origin?.kind === 'git-bridge') result = t('history_entry_origin_git')
+  if (origin?.kind === 'github') result = t('history_entry_origin_github')
+
+  if (result) {
+    return <span className="history-version-origin">({result})</span>
+  }
+  return null
+}
+
+export default Origin

+ 135 - 23
services/web/frontend/js/features/history/context/history-context.tsx

@@ -1,30 +1,52 @@
-import { createContext, useContext, useEffect, useState, useMemo } from 'react'
-import { useIdeContext } from '../../../shared/context/ide-context'
+import {
+  createContext,
+  useContext,
+  useCallback,
+  useEffect,
+  useState,
+  useMemo,
+  useRef,
+} from 'react'
 import { useLayoutContext } from '../../../shared/context/layout-context'
 import { useLayoutContext } from '../../../shared/context/layout-context'
-import useAsync from '../../../shared/hooks/use-async'
+import { useUserContext } from '../../../shared/context/user-context'
+import { useProjectContext } from '../../../shared/context/project-context'
 import { HistoryContextValue } from './types/history-context-value'
 import { HistoryContextValue } from './types/history-context-value'
-import { Update, UpdateSelection } from '../services/types/update'
-import { FileSelection } from '../services/types/file'
-import { diffFiles, fetchUpdates } from '../services/api'
+import { diffFiles, fetchLabels, fetchUpdates } from '../services/api'
 import { renamePathnameKey, isFileRenamed } from '../utils/file-tree'
 import { renamePathnameKey, isFileRenamed } from '../utils/file-tree'
+import { loadLabels } from '../utils/label'
+import ColorManager from '../../../ide/colors/ColorManager'
+import moment from 'moment'
+import * as eventTracking from '../../../infrastructure/event-tracking'
+import { cloneDeep } from 'lodash'
+import { LoadedUpdate, Update, UpdateSelection } from '../services/types/update'
+import { FileSelection } from '../services/types/file'
+import { Nullable } from '../../../../../types/utils'
 
 
 function useHistory() {
 function useHistory() {
   const { view } = useLayoutContext()
   const { view } = useLayoutContext()
-  const ide = useIdeContext()
-  const projectId = ide.project_id
+  const user = useUserContext()
+  const project = useProjectContext()
+  const userId = user.id
+  const projectId = project._id
+  const projectOwnerId = project.owner?._id
   const [updateSelection, setUpdateSelection] =
   const [updateSelection, setUpdateSelection] =
     useState<UpdateSelection | null>(null)
     useState<UpdateSelection | null>(null)
   const [fileSelection, setFileSelection] = useState<FileSelection | null>(null)
   const [fileSelection, setFileSelection] = useState<FileSelection | null>(null)
-  /* eslint-disable no-unused-vars */
-  const [viewMode, setViewMode] = useState<HistoryContextValue['viewMode']>('')
+  const [updates, setUpdates] = useState<LoadedUpdate[]>([])
+  const [loadingFileTree, setLoadingFileTree] =
+    useState<HistoryContextValue['loadingFileTree']>(true)
   const [nextBeforeTimestamp, setNextBeforeTimestamp] =
   const [nextBeforeTimestamp, setNextBeforeTimestamp] =
-    useState<HistoryContextValue['nextBeforeTimestamp']>(null)
+    useState<HistoryContextValue['nextBeforeTimestamp']>()
   const [atEnd, setAtEnd] = useState<HistoryContextValue['atEnd']>(false)
   const [atEnd, setAtEnd] = useState<HistoryContextValue['atEnd']>(false)
-  const [userHasFullFeature, setUserHasFullFeature] =
-    useState<HistoryContextValue['userHasFullFeature']>(undefined)
   const [freeHistoryLimitHit, setFreeHistoryLimitHit] =
   const [freeHistoryLimitHit, setFreeHistoryLimitHit] =
     useState<HistoryContextValue['freeHistoryLimitHit']>(false)
     useState<HistoryContextValue['freeHistoryLimitHit']>(false)
   const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
   const [labels, setLabels] = useState<HistoryContextValue['labels']>(null)
+  const [isLoading, setIsLoading] = useState(false)
+  const [error, setError] = useState(null)
+  /* eslint-disable no-unused-vars */
+  const [viewMode, setViewMode] = useState<HistoryContextValue['viewMode']>('')
+  const [userHasFullFeature, setUserHasFullFeature] =
+    useState<HistoryContextValue['userHasFullFeature']>(undefined)
   const [selection, setSelection] = useState<HistoryContextValue['selection']>({
   const [selection, setSelection] = useState<HistoryContextValue['selection']>({
     docs: {},
     docs: {},
     pathname: null,
     pathname: null,
@@ -42,18 +64,110 @@ function useHistory() {
   })
   })
   /* eslint-enable no-unused-vars */
   /* eslint-enable no-unused-vars */
 
 
-  const { isLoading, isError, error, data, runAsync } = useAsync<{
-    updates: Update[]
-  }>()
-  const updates = useMemo(() => data?.updates ?? [], [data?.updates])
-  const loadingFileTree = true
+  const fetchNextBatchOfUpdates = useCallback(() => {
+    const loadUpdates = (updatesData: Update[]) => {
+      const dateTimeNow = new Date()
+      const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
+      let previousUpdate = updates[updates.length - 1]
+      let cutOffIndex: Nullable<number> = null
+
+      let loadedUpdates: LoadedUpdate[] = cloneDeep(updatesData)
+      for (const [index, update] of loadedUpdates.entries()) {
+        for (const user of update.meta.users) {
+          if (user) {
+            user.hue = ColorManager.getHueForUserId(user.id)
+          }
+        }
+        if (
+          !previousUpdate ||
+          !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, 'day')
+        ) {
+          update.meta.first_in_day = true
+        }
+
+        previousUpdate = update
+
+        if (userHasFullFeature && update.meta.end_ts < timestamp24hoursAgo) {
+          cutOffIndex = index || 1 // Make sure that we show at least one entry (to allow labelling).
+          setFreeHistoryLimitHit(true)
+          if (projectOwnerId === userId) {
+            eventTracking.send(
+              'subscription-funnel',
+              'editor-click-feature',
+              'history'
+            )
+            eventTracking.sendMB('paywall-prompt', {
+              'paywall-type': 'history',
+            })
+          }
+          break
+        }
+      }
+
+      if (!userHasFullFeature && cutOffIndex != null) {
+        loadedUpdates = loadedUpdates.slice(0, cutOffIndex)
+      }
+
+      setUpdates(updates.concat(loadedUpdates))
+
+      // TODO first load
+    }
+
+    if (atEnd) return
+
+    const updatesPromise = fetchUpdates(projectId, nextBeforeTimestamp)
+    const labelsPromise = labels == null ? fetchLabels(projectId) : null
+
+    setIsLoading(true)
+    Promise.all([updatesPromise, labelsPromise])
+      .then(([{ updates: updatesData, nextBeforeTimestamp }, labels]) => {
+        const lastUpdateToV = updatesData.length ? updatesData[0].toV : null
+
+        if (labels) {
+          setLabels(loadLabels(labels, lastUpdateToV))
+        }
+
+        loadUpdates(updatesData)
+        setNextBeforeTimestamp(nextBeforeTimestamp)
+        if (
+          nextBeforeTimestamp == null ||
+          freeHistoryLimitHit ||
+          !updates.length
+        ) {
+          setAtEnd(true)
+        }
+        if (!updates.length) {
+          setLoadingFileTree(false)
+        }
+      })
+      .catch(error => {
+        setError(error)
+        setAtEnd(true)
+        setLoadingFileTree(false)
+      })
+      .finally(() => {
+        setIsLoading(false)
+      })
+  }, [
+    atEnd,
+    freeHistoryLimitHit,
+    labels,
+    nextBeforeTimestamp,
+    projectId,
+    projectOwnerId,
+    userId,
+    userHasFullFeature,
+    updates,
+  ])
 
 
   // Initial load when the History tab is active
   // Initial load when the History tab is active
+  const initialFetch = useRef(false)
   useEffect(() => {
   useEffect(() => {
-    if (view === 'history') {
-      runAsync(fetchUpdates(projectId)).catch(console.error)
+    if (view === 'history' && !initialFetch.current) {
+      fetchNextBatchOfUpdates()
+      initialFetch.current = true
     }
     }
-  }, [view, projectId, runAsync])
+  }, [view, fetchNextBatchOfUpdates])
 
 
   // Load files when the update selection changes
   // Load files when the update selection changes
   useEffect(() => {
   useEffect(() => {
@@ -87,7 +201,6 @@ function useHistory() {
     () => ({
     () => ({
       atEnd,
       atEnd,
       error,
       error,
-      isError,
       isLoading,
       isLoading,
       freeHistoryLimitHit,
       freeHistoryLimitHit,
       labels,
       labels,
@@ -106,7 +219,6 @@ function useHistory() {
     [
     [
       atEnd,
       atEnd,
       error,
       error,
-      isError,
       isLoading,
       isLoading,
       freeHistoryLimitHit,
       freeHistoryLimitHit,
       labels,
       labels,

+ 9 - 5
services/web/frontend/js/features/history/context/types/history-context-value.ts

@@ -1,20 +1,24 @@
 import { Nullable } from '../../../../../../types/utils'
 import { Nullable } from '../../../../../../types/utils'
-import { Update, UpdateSelection } from '../../services/types/update'
+import {
+  Label,
+  LoadedUpdate,
+  PseudoCurrentStateLabel,
+  UpdateSelection,
+} from '../../services/types/update'
 import { Selection } from '../../../../../../types/history/selection'
 import { Selection } from '../../../../../../types/history/selection'
 import { FileSelection } from '../../services/types/file'
 import { FileSelection } from '../../services/types/file'
 
 
 export type HistoryContextValue = {
 export type HistoryContextValue = {
-  updates: Update[]
+  updates: LoadedUpdate[]
   viewMode: string
   viewMode: string
-  nextBeforeTimestamp: Nullable<number>
+  nextBeforeTimestamp: number | undefined
   atEnd: boolean
   atEnd: boolean
   userHasFullFeature: boolean | undefined
   userHasFullFeature: boolean | undefined
   freeHistoryLimitHit: boolean
   freeHistoryLimitHit: boolean
   selection: Selection
   selection: Selection
-  isError: boolean
   isLoading: boolean
   isLoading: boolean
   error: Nullable<unknown>
   error: Nullable<unknown>
-  labels: Nullable<unknown>
+  labels: Nullable<Array<Label | PseudoCurrentStateLabel>>
   loadingFileTree: boolean
   loadingFileTree: boolean
   projectId: string
   projectId: string
   fileSelection: FileSelection | null
   fileSelection: FileSelection | null

+ 9 - 2
services/web/frontend/js/features/history/services/api.ts

@@ -1,6 +1,6 @@
 import { getJSON } from '../../../infrastructure/fetch-json'
 import { getJSON } from '../../../infrastructure/fetch-json'
 import { FileDiff } from './types/file'
 import { FileDiff } from './types/file'
-import { Update } from './types/update'
+import { Label, Update } from './types/update'
 import { DocDiffResponse } from './types/doc'
 import { DocDiffResponse } from './types/doc'
 
 
 const BATCH_SIZE = 10
 const BATCH_SIZE = 10
@@ -16,7 +16,14 @@ export function fetchUpdates(projectId: string, before?: number) {
 
 
   const queryParamsSerialized = new URLSearchParams(queryParams).toString()
   const queryParamsSerialized = new URLSearchParams(queryParams).toString()
   const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}`
   const updatesURL = `/project/${projectId}/updates?${queryParamsSerialized}`
-  return getJSON<{ updates: Update[] }>(updatesURL)
+  return getJSON<{ updates: Update[]; nextBeforeTimestamp?: number }>(
+    updatesURL
+  )
+}
+
+export function fetchLabels(projectId: string) {
+  const labelsURL = `/project/${projectId}/labels`
+  return getJSON<Label[]>(labelsURL)
 }
 }
 
 
 export function diffFiles(projectId: string, fromV: number, toV: number) {
 export function diffFiles(projectId: string, fromV: number, toV: number) {

+ 12 - 1
services/web/frontend/js/features/history/services/types/shared.ts

@@ -1,3 +1,5 @@
+import { Nullable } from '../../../../../../types/utils'
+
 export interface User {
 export interface User {
   first_name: string
   first_name: string
   last_name: string
   last_name: string
@@ -6,7 +8,16 @@ export interface User {
 }
 }
 
 
 export interface Meta {
 export interface Meta {
-  users: User[]
+  users: Nullable<User>[]
   start_ts: number
   start_ts: number
   end_ts: number
   end_ts: number
+  origin?: {
+    kind:
+      | 'dropbox'
+      | 'upload'
+      | 'git-bridge'
+      | 'github'
+      | 'history-resync'
+      | 'history-migration'
+  }
 }
 }

+ 27 - 4
services/web/frontend/js/features/history/services/types/update.ts

@@ -1,4 +1,5 @@
-import { Meta } from './shared'
+import { Meta, User } from './shared'
+import { Nullable } from '../../../../../../types/utils'
 
 
 interface UpdateLabel {
 interface UpdateLabel {
   id: string
   id: string
@@ -8,11 +9,18 @@ interface UpdateLabel {
   created_at: string
   created_at: string
 }
 }
 
 
-interface Label extends UpdateLabel {
+export interface Label extends UpdateLabel {
   user_display_name: string
   user_display_name: string
 }
 }
 
 
-interface ProjectOp {
+export interface PseudoCurrentStateLabel {
+  id: '1'
+  isPseudoCurrentStateLabel: true
+  version: Nullable<number>
+  created_at: string
+}
+
+export interface ProjectOp {
   add?: { pathname: string }
   add?: { pathname: string }
   rename?: { pathname: string; newPathname: string }
   rename?: { pathname: string; newPathname: string }
   remove?: { pathname: string }
   remove?: { pathname: string }
@@ -23,11 +31,26 @@ export interface Update {
   fromV: number
   fromV: number
   toV: number
   toV: number
   meta: Meta
   meta: Meta
-  labels?: Label[]
+  labels: Label[]
   pathnames: string[]
   pathnames: string[]
   project_ops: ProjectOp[]
   project_ops: ProjectOp[]
 }
 }
 
 
+interface LoadedUpdateMetaUser extends User {
+  hue?: number
+}
+
+export type LoadedUpdateMetaUsers = Nullable<LoadedUpdateMetaUser>[]
+
+interface LoadedUpdateMeta extends Meta {
+  first_in_day?: true
+  users: LoadedUpdateMetaUsers
+}
+
+export interface LoadedUpdate extends Update {
+  meta: LoadedUpdateMeta
+}
+
 export interface UpdateSelection {
 export interface UpdateSelection {
   update: Update
   update: Update
   comparing: boolean
   comparing: boolean

+ 2 - 2
services/web/frontend/js/features/history/utils/highlights-from-diff-response.ts

@@ -33,7 +33,7 @@ export function highlightsFromDiffResponse(chunks: DocDiffChunk[]) {
           // There doesn't seem to be a convenient way to make this translatable
           // There doesn't seem to be a convenient way to make this translatable
           label: `Added by ${name} on ${date}`,
           label: `Added by ${name} on ${date}`,
           range,
           range,
-          hue: ColorManager.getHueForUserId(user.id),
+          hue: ColorManager.getHueForUserId(user?.id),
         })
         })
       } else if (isDeletion) {
       } else if (isDeletion) {
         highlights.push({
         highlights.push({
@@ -41,7 +41,7 @@ export function highlightsFromDiffResponse(chunks: DocDiffChunk[]) {
           // There doesn't seem to be a convenient way to make this translatable
           // There doesn't seem to be a convenient way to make this translatable
           label: `Deleted by ${name} on ${date}`,
           label: `Deleted by ${name} on ${date}`,
           range,
           range,
-          hue: ColorManager.getHueForUserId(user.id),
+          hue: ColorManager.getHueForUserId(user?.id),
         })
         })
       }
       }
     }
     }

+ 40 - 0
services/web/frontend/js/features/history/utils/history-details.ts

@@ -0,0 +1,40 @@
+import ColorManager from '../../../ide/colors/ColorManager'
+import { Nullable } from '../../../../../types/utils'
+import { User } from '../services/types/shared'
+import { ProjectOp } from '../services/types/update'
+
+export const getUserColor = (
+  user?: Nullable<{ id: string; _id?: never } | { id?: never; _id: string }>
+) => {
+  const curUserId = user?.id || user?._id
+  const hue = ColorManager.getHueForUserId(curUserId) || 100
+
+  return `hsl(${hue}, 70%, 50%)`
+}
+
+export const formatUserName = (user: User) => {
+  let name = [user.first_name, user.last_name]
+    .filter(n => n != null)
+    .join(' ')
+    .trim()
+  if (name === '') {
+    name = user.email.split('@')[0]
+  }
+  if (name == null || name === '') {
+    return '?'
+  }
+  return name
+}
+
+export const getProjectOpDoc = (projectOp: ProjectOp) => {
+  if (projectOp.rename) {
+    return `${projectOp.rename.pathname} → ${projectOp.rename.newPathname}`
+  }
+  if (projectOp.add) {
+    return `${projectOp.add.pathname}`
+  }
+  if (projectOp.remove) {
+    return `${projectOp.remove.pathname}`
+  }
+  return ''
+}

+ 60 - 0
services/web/frontend/js/features/history/utils/label.ts

@@ -0,0 +1,60 @@
+import { orderBy } from 'lodash'
+import { Label, PseudoCurrentStateLabel } from '../services/types/update'
+import { Nullable } from '../../../../../types/utils'
+
+export const isPseudoLabel = (
+  label: Label | PseudoCurrentStateLabel
+): label is PseudoCurrentStateLabel => {
+  return (label as PseudoCurrentStateLabel).isPseudoCurrentStateLabel === true
+}
+
+const sortLabelsByVersionAndDate = (
+  labels: Array<Label | PseudoCurrentStateLabel>
+) => {
+  return orderBy(
+    labels,
+    ['isPseudoCurrentStateLabel', 'version', 'created_at'],
+    ['asc', 'desc', 'desc']
+  )
+}
+
+const deletePseudoCurrentStateLabelIfExistent = (
+  labels: Array<Label | PseudoCurrentStateLabel>
+) => {
+  if (labels.length && isPseudoLabel(labels[0])) {
+    const [, ...rest] = labels
+    return rest
+  }
+  return labels
+}
+
+const addPseudoCurrentStateLabelIfNeeded = (
+  labels: Array<Label | PseudoCurrentStateLabel>,
+  mostRecentVersion: Nullable<number>
+) => {
+  if (!labels.length || labels[0].version !== mostRecentVersion) {
+    const pseudoCurrentStateLabel: PseudoCurrentStateLabel = {
+      id: '1',
+      isPseudoCurrentStateLabel: true,
+      version: mostRecentVersion,
+      created_at: new Date().toISOString(),
+    }
+    return [pseudoCurrentStateLabel, ...labels]
+  }
+  return labels
+}
+
+export const loadLabels = (
+  labels: Label[],
+  lastUpdateToV: Nullable<number>
+) => {
+  const sortedLabels = sortLabelsByVersionAndDate(labels)
+  const labelsWithoutPseudoLabel =
+    deletePseudoCurrentStateLabelIfExistent(sortedLabels)
+  const labelsWithPseudoLabelIfNeeded = addPseudoCurrentStateLabelIfNeeded(
+    labelsWithoutPseudoLabel,
+    lastUpdateToV
+  )
+
+  return labelsWithPseudoLabelIfNeeded
+}

+ 2 - 2
services/web/frontend/js/features/utils/format-date.js

@@ -11,8 +11,8 @@ moment.updateLocale('en', {
   },
   },
 })
 })
 
 
-export function formatTime(date) {
-  return moment(date).format('h:mm a')
+export function formatTime(date, format = 'h:mm a') {
+  return moment(date).format(format)
 }
 }
 
 
 export function relativeDate(date) {
 export function relativeDate(date) {

+ 1 - 1
services/web/frontend/stories/history/toggle-switch.stories.tsx

@@ -31,7 +31,7 @@ export default {
     (Story: React.ComponentType) => (
     (Story: React.ComponentType) => (
       <div className="history-react">
       <div className="history-react">
         <div className="change-list">
         <div className="change-list">
-          <div className="history-header toggle-switch-container">
+          <div className="history-header history-toggle-switch-container">
             <Story />
             <Story />
           </div>
           </div>
         </div>
         </div>

+ 121 - 5
services/web/frontend/stylesheets/app/editor/history-react.less

@@ -15,7 +15,7 @@ history-root {
     height: @history-toolbar-height;
     height: @history-toolbar-height;
     background-color: @history-react-header-bg;
     background-color: @history-react-header-bg;
     color: @history-react-header-color;
     color: @history-react-header-color;
-    font-size: 14px;
+    font-size: @font-size-small;
     display: flex;
     display: flex;
     flex-direction: column;
     flex-direction: column;
     justify-content: center;
     justify-content: center;
@@ -50,12 +50,9 @@ history-root {
 
 
   .change-list {
   .change-list {
     width: @versions-list-width;
     width: @versions-list-width;
+    font-size: @font-size-small;
     border-left: 1px solid @history-react-separator-color;
     border-left: 1px solid @history-react-separator-color;
     box-sizing: content-box;
     box-sizing: content-box;
-
-    .toggle-switch-container {
-      padding: 0 16px;
-    }
   }
   }
 
 
   .toggle-switch-label {
   .toggle-switch-label {
@@ -66,6 +63,112 @@ history-root {
     }
     }
   }
   }
 
 
+  .history-toggle-switch-container,
+  .history-version-day,
+  .history-version-details {
+    padding: 0 16px;
+  }
+
+  .history-version-day {
+    position: sticky;
+    top: 0;
+    display: block;
+    padding-top: 12px;
+    padding-bottom: 4px;
+    line-height: 20px;
+    background-color: @white;
+  }
+
+  .history-version-details {
+    padding-top: 6px;
+    padding-bottom: 6px;
+
+    &:hover {
+      cursor: pointer;
+      background-color: @neutral-10;
+    }
+  }
+
+  .history-version-metadata-time {
+    display: block;
+    margin-bottom: 4px;
+    font-weight: bold;
+    color: @neutral-90;
+  }
+
+  .history-version-metadata-users,
+  .history-version-changes {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+  }
+
+  .history-version-metadata-users {
+    display: inline;
+
+    > li {
+      display: inline-flex;
+      align-items: center;
+      margin-right: 8px;
+    }
+  }
+
+  .history-version-changes {
+    > li {
+      margin-bottom: 4px;
+    }
+  }
+
+  .history-version-user-badge-color {
+    @size: 8px;
+    display: inline-block;
+    width: @size;
+    height: @size;
+    margin-right: 4px;
+    border-radius: 2px;
+  }
+
+  .history-version-day,
+  .history-version-change-action,
+  .history-version-metadata-users,
+  .history-version-origin {
+    color: @neutral-70;
+  }
+
+  .history-version-change-doc {
+    color: @neutral-90;
+  }
+
+  .history-version-divider {
+    margin: 6px 8px;
+    border-color: @neutral-20;
+  }
+
+  .history-version-badge {
+    display: inline-flex;
+    align-items: center;
+    overflow: hidden;
+    height: 24px;
+    margin-bottom: 3px;
+    margin-right: 10px;
+    padding: 4px;
+    white-space: nowrap;
+    color: @ol-blue-gray-6;
+    background-color: @neutral-20;
+    border-radius: 4px;
+
+    &-comment {
+      margin-left: 2px;
+    }
+  }
+
+  .history-version-label-delete-btn {
+    .reset-button;
+    marigin-left: 8px;
+    padding: 4px;
+    font-size: 24px;
+  }
+
   .history-loading-panel {
   .history-loading-panel {
     padding-top: 10rem;
     padding-top: 10rem;
     font-family: @font-family-serif;
     font-family: @font-family-serif;
@@ -85,3 +188,16 @@ history-root {
     margin-top: 2px;
     margin-top: 2px;
   }
   }
 }
 }
+
+.history-version-label-tooltip {
+  padding: 6px;
+  text-align: initial;
+
+  &-row {
+    margin-bottom: 6.25px;
+
+    &:last-child {
+      margin-bottom: initial;
+    }
+  }
+}

+ 8 - 0
services/web/frontend/stylesheets/core/mixins.less

@@ -1317,3 +1317,11 @@
     outline: 0;
     outline: 0;
   }
   }
 }
 }
+
+.reset-button {
+  padding: 0;
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+  -webkit-appearance: none;
+}

+ 1 - 0
services/web/frontend/stylesheets/core/variables.less

@@ -13,6 +13,7 @@
 @neutral-90: #1b222c;
 @neutral-90: #1b222c;
 @neutral-40: #afb5c0;
 @neutral-40: #afb5c0;
 @neutral-10: #f4f5f6;
 @neutral-10: #f4f5f6;
+@neutral-70: #495365;
 @neutral-80: #2f3a4c;
 @neutral-80: #2f3a4c;
 
 
 // Styleguide colors
 // Styleguide colors

+ 1 - 0
services/web/locales/en.json

@@ -1681,6 +1681,7 @@
   "year": "year",
   "year": "year",
   "yes_move_me_to_personal_plan": "Yes, move me to the Personal plan",
   "yes_move_me_to_personal_plan": "Yes, move me to the Personal plan",
   "yes_that_is_correct": "Yes, that’s correct",
   "yes_that_is_correct": "Yes, that’s correct",
+  "you": "You",
   "you_already_have_a_subscription": "You already have a subscription",
   "you_already_have_a_subscription": "You already have a subscription",
   "you_and_collaborators_get_access_to": "You and your project collaborators get access to",
   "you_and_collaborators_get_access_to": "You and your project collaborators get access to",
   "you_and_collaborators_get_access_to_info": "These features are available to you and your collaborators (other Overleaf users that you invite to your projects).",
   "you_and_collaborators_get_access_to_info": "These features are available to you and your collaborators (other Overleaf users that you invite to your projects).",