Przeglądaj źródła

Add paywall to React history view (#12849)

* Implement history view paywall

* Add tests and some CSS fallbacks

* Make additional faded version above paywall non-clickable

* Change isFaded to faded for consistency

* Remove unused import

* Add missing attribute

* SHow all labels in free tier

* Address review comments

* Change Boolean conversion

Co-authored-by: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com>

* Make adding or deleting a label show up in version list again

* Refactor to use visibleUpdateCount rather than maintaining two separate update arrays

* Removed unused import

* Use data-testid instead on class

* Round gradient values

* Correct test selector

---------

Co-authored-by: ilkin-overleaf <100852799+ilkin-overleaf@users.noreply.github.com>
GitOrigin-RevId: a2b021f3f4d3b9eb1358edb2ee4aa7db1bc7240e
Tim Down 3 lat temu
rodzic
commit
70bae34bd8

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

@@ -50,6 +50,7 @@
   "are_you_affiliated_with_an_institution": "",
   "are_you_still_at": "",
   "ascending": "",
+  "ask_proj_owner_to_upgrade_for_full_history": "",
   "ask_proj_owner_to_upgrade_for_git_bridge": "",
   "ask_proj_owner_to_upgrade_for_longer_compiles": "",
   "ask_proj_owner_to_upgrade_for_references_search": "",
@@ -161,6 +162,7 @@
   "created_at": "",
   "creating": "",
   "current_password": "",
+  "currently_seeing_only_24_hrs_history": "",
   "currently_subscribed_to_plan": "",
   "customize_your_group_subscription": "",
   "date_and_owner": "",

+ 25 - 5
services/web/frontend/js/features/history/components/change-list/all-history-list.tsx

@@ -2,15 +2,27 @@ import { Fragment, useEffect, useRef, useState } from 'react'
 import { useHistoryContext } from '../../context/history-context'
 import HistoryVersion from './history-version'
 import LoadingSpinner from '../../../../shared/components/loading-spinner'
+import { OwnerPaywallPrompt } from './owner-paywall-prompt'
+import { NonOwnerPaywallPrompt } from './non-owner-paywall-prompt'
 
 function AllHistoryList() {
-  const { updatesInfo, loadingState, fetchNextBatchOfUpdates } =
-    useHistoryContext()
-  const { updates, atEnd } = updatesInfo
+  const {
+    updatesInfo,
+    loadingState,
+    fetchNextBatchOfUpdates,
+    currentUserIsOwner,
+  } = useHistoryContext()
+  const { visibleUpdateCount, updates, atEnd } = updatesInfo
   const scrollerRef = useRef<HTMLDivElement>(null)
   const bottomRef = useRef<HTMLDivElement>(null)
   const intersectionObserverRef = useRef<IntersectionObserver | null>(null)
   const [bottomVisible, setBottomVisible] = useState(false)
+  const showPaywall =
+    loadingState === 'ready' && updatesInfo.freeHistoryLimitHit
+  const showOwnerPaywall = showPaywall && currentUserIsOwner
+  const showNonOwnerPaywall = showPaywall && !currentUserIsOwner
+  const visibleUpdates =
+    visibleUpdateCount === null ? updates : updates.slice(0, visibleUpdateCount)
 
   // Create an intersection observer that watches for any part of an element
   // positioned at the bottom of the list to be visible
@@ -63,15 +75,23 @@ function AllHistoryList() {
     <div ref={scrollerRef} className="history-all-versions-scroller">
       <div className="history-all-versions-container">
         <div ref={bottomRef} className="history-versions-bottom" />
-        {updates.map((update, index) => (
+        {visibleUpdates.map((update, index) => (
           <Fragment key={`${update.fromV}_${update.toV}`}>
             {update.meta.first_in_day && index > 0 && (
               <hr className="history-version-divider" />
             )}
-            <HistoryVersion update={update} />
+            <HistoryVersion
+              update={update}
+              faded={
+                updatesInfo.freeHistoryLimitHit &&
+                index === visibleUpdates.length - 1
+              }
+            />
           </Fragment>
         ))}
       </div>
+      {showOwnerPaywall ? <OwnerPaywallPrompt /> : null}
+      {showNonOwnerPaywall ? <NonOwnerPaywallPrompt /> : null}
       {loadingState === 'ready' ? null : <LoadingSpinner />}
     </div>
   )

+ 4 - 1
services/web/frontend/js/features/history/components/change-list/history-version-details.tsx

@@ -5,11 +5,13 @@ import { UpdateRange } from '../../services/types/update'
 type HistoryVersionDetailsProps = {
   children: React.ReactNode
   selected: boolean
+  selectable: boolean
 } & UpdateRange
 
 function HistoryVersionDetails({
   children,
   selected,
+  selectable,
   fromV,
   toV,
   fromVTimestamp,
@@ -34,9 +36,10 @@ function HistoryVersionDetails({
     <div
       className={classnames('history-version-details', {
         'history-version-selected': selected,
+        'history-version-selectable': selectable,
       })}
       data-testid="history-version-details"
-      onClick={handleSelect}
+      onClick={selectable ? handleSelect : undefined}
     >
       {children}
     </div>

+ 21 - 11
services/web/frontend/js/features/history/components/change-list/history-version.tsx

@@ -10,12 +10,14 @@ import { isVersionSelected } from '../../utils/history-details'
 import { relativeDate, formatTime } from '../../../utils/format-date'
 import { orderBy } from 'lodash'
 import { LoadedUpdate } from '../../services/types/update'
+import classNames from 'classnames'
 
 type HistoryEntryProps = {
   update: LoadedUpdate
+  faded: boolean
 }
 
-function HistoryVersion({ update }: HistoryEntryProps) {
+function HistoryVersion({ update, faded }: HistoryEntryProps) {
   const { id: currentUserId } = useUserContext()
   const { projectId, selection } = useHistoryContext()
 
@@ -23,7 +25,12 @@ function HistoryVersion({ update }: HistoryEntryProps) {
   const selected = isVersionSelected(selection, update.fromV, update.toV)
 
   return (
-    <div>
+    <div
+      data-testid="history-version"
+      className={classNames({
+        'history-version-faded': faded,
+      })}
+    >
       {update.meta.first_in_day && (
         <time className="history-version-day">
           {relativeDate(update.meta.end_ts)}
@@ -35,6 +42,7 @@ function HistoryVersion({ update }: HistoryEntryProps) {
         fromVTimestamp={update.meta.end_ts}
         toVTimestamp={update.meta.end_ts}
         selected={selected}
+        selectable={!faded}
       >
         <div className="history-version-main-details">
           <time className="history-version-metadata-time">
@@ -59,15 +67,17 @@ function HistoryVersion({ update }: HistoryEntryProps) {
           />
           <Origin origin={update.meta.origin} />
         </div>
-        <HistoryVersionDropdown
-          id={`${update.fromV}_${update.toV}`}
-          projectId={projectId}
-          isComparing={selection.comparing}
-          isSelected={selected}
-          fromV={update.fromV}
-          toV={update.toV}
-          updateMetaEndTimestamp={update.meta.end_ts}
-        />
+        {faded ? null : (
+          <HistoryVersionDropdown
+            id={`${update.fromV}_${update.toV}`}
+            projectId={projectId}
+            isComparing={selection.comparing}
+            isSelected={selected}
+            fromV={update.fromV}
+            toV={update.toV}
+            updateMetaEndTimestamp={update.meta.end_ts}
+          />
+        )}
       </HistoryVersionDetails>
     </div>
   )

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

@@ -44,6 +44,7 @@ function LabelsList() {
             fromVTimestamp={fromVTimestamp}
             toVTimestamp={toVTimestamp}
             selected={selected}
+            selectable
           >
             <div className="history-version-main-details">
               {labels.map(label => (

+ 15 - 0
services/web/frontend/js/features/history/components/change-list/non-owner-paywall-prompt.tsx

@@ -0,0 +1,15 @@
+import { useTranslation } from 'react-i18next'
+
+export function NonOwnerPaywallPrompt() {
+  const { t } = useTranslation()
+
+  return (
+    <div className="history-paywall-prompt">
+      <h2 className="history-paywall-heading">{t('premium_feature')}</h2>
+      <p>{t('currently_seeing_only_24_hrs_history')}</p>
+      <p>
+        <strong>{t('ask_proj_owner_to_upgrade_for_full_history')}</strong>
+      </p>
+    </div>
+  )
+}

+ 60 - 0
services/web/frontend/js/features/history/components/change-list/owner-paywall-prompt.tsx

@@ -0,0 +1,60 @@
+import { useTranslation } from 'react-i18next'
+import Icon from '../../../../shared/components/icon'
+import { useCallback, useEffect, useState } from 'react'
+import * as eventTracking from '../../../../infrastructure/event-tracking'
+import StartFreeTrialButton from '../../../../shared/components/start-free-trial-button'
+import { paywallPrompt } from '../../../../main/account-upgrade'
+
+function FeatureItem({ text }: { text: string }) {
+  return (
+    <li>
+      <Icon type="check" /> {text}
+    </li>
+  )
+}
+
+export function OwnerPaywallPrompt() {
+  const { t } = useTranslation()
+  const [clickedFreeTrialButton, setClickedFreeTrialButton] = useState(false)
+
+  useEffect(() => {
+    eventTracking.send('subscription-funnel', 'editor-click-feature', 'history')
+    paywallPrompt('history')
+  }, [])
+
+  const handleFreeTrialClick = useCallback(() => {
+    setClickedFreeTrialButton(true)
+  }, [])
+
+  return (
+    <div className="history-paywall-prompt">
+      <h2 className="history-paywall-heading">{t('premium_feature')}</h2>
+      <p>{t('currently_seeing_only_24_hrs_history')}</p>
+      <p>
+        <strong>
+          {t('upgrade_to_get_feature', { feature: 'full Project History' })}
+        </strong>
+      </p>
+      <ul className="history-feature-list">
+        <FeatureItem text={t('unlimited_projects')} />
+        <FeatureItem
+          text={t('collabs_per_proj', { collabcount: 'Multiple' })}
+        />
+        <FeatureItem text={t('full_doc_history')} />
+        <FeatureItem text={t('sync_to_dropbox')} />
+        <FeatureItem text={t('sync_to_github')} />
+        <FeatureItem text={t('compile_larger_projects')} />
+      </ul>
+      <p>
+        <StartFreeTrialButton
+          source="history"
+          buttonProps={{ bsStyle: 'default', className: 'btn-premium' }}
+          handleClick={handleFreeTrialClick}
+        />
+      </p>
+      {clickedFreeTrialButton ? (
+        <p className="small">{t('refresh_page_after_starting_free_trial')}</p>
+      ) : null}
+    </div>
+  )
+}

+ 26 - 35
services/web/frontend/js/features/history/context/history-context.tsx

@@ -18,14 +18,12 @@ import { loadLabels } from '../utils/label'
 import { autoSelectFile } from '../utils/auto-select-file'
 import ColorManager from '../../../ide/colors/ColorManager'
 import moment from 'moment'
-import * as eventTracking from '../../../infrastructure/event-tracking'
 import { cloneDeep } from 'lodash'
 import {
   FetchUpdatesResponse,
   LoadedUpdate,
   Update,
 } from '../services/types/update'
-import { Nullable } from '../../../../../types/utils'
 import { Selection } from '../services/types/selection'
 
 // Allow testing of infinite scrolling by providing query string parameters to
@@ -71,6 +69,10 @@ function useHistory() {
   const userId = user.id
   const projectId = project._id
   const projectOwnerId = project.owner?._id
+  const userHasFullFeature = Boolean(
+    project.features?.versioning || user.isAdmin
+  )
+  const currentUserIsOwner = projectOwnerId === userId
 
   const [selection, setSelection] = useState<Selection>(selectionInitialState)
 
@@ -78,6 +80,7 @@ function useHistory() {
     HistoryContextValue['updatesInfo']
   >({
     updates: [],
+    visibleUpdateCount: null,
     atEnd: false,
     freeHistoryLimitHit: false,
     nextBeforeTimestamp: undefined,
@@ -86,19 +89,15 @@ function useHistory() {
   const [loadingState, setLoadingState] =
     useState<HistoryContextValue['loadingState']>('loadingInitial')
   const [error, setError] = useState(null)
-  // eslint-disable-next-line no-unused-vars
-  const [userHasFullFeature, setUserHasFullFeature] =
-    useState<HistoryContextValue['userHasFullFeature']>(undefined)
 
   const fetchNextBatchOfUpdates = useCallback(() => {
     const loadUpdates = (updatesData: Update[]) => {
       const dateTimeNow = new Date()
       const timestamp24hoursAgo = dateTimeNow.setDate(dateTimeNow.getDate() - 1)
-      let { updates, freeHistoryLimitHit } = updatesInfo
+      let { updates, freeHistoryLimitHit, visibleUpdateCount } = updatesInfo
       let previousUpdate = updates[updates.length - 1]
-      let cutOffIndex: Nullable<number> = null
 
-      let loadedUpdates: LoadedUpdate[] = cloneDeep(updatesData)
+      const loadedUpdates: LoadedUpdate[] = cloneDeep(updatesData)
       for (const [index, update] of loadedUpdates.entries()) {
         for (const user of update.meta.users) {
           if (user) {
@@ -114,28 +113,24 @@ function useHistory() {
 
         previousUpdate = update
 
-        if (userHasFullFeature && update.meta.end_ts < timestamp24hoursAgo) {
-          cutOffIndex = index || 1 // Make sure that we show at least one entry (to allow labelling).
+        if (
+          !userHasFullFeature &&
+          visibleUpdateCount === null &&
+          update.meta.end_ts < timestamp24hoursAgo
+        ) {
+          // Make sure that we show at least one entry (to allow labelling), and
+          // load one extra update that is displayed but faded out above the
+          // paywall
+          visibleUpdateCount = index + 1
           freeHistoryLimitHit = 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)
+      return {
+        updates: updates.concat(loadedUpdates),
+        visibleUpdateCount,
+        freeHistoryLimitHit,
       }
-
-      return { updates: updates.concat(loadedUpdates), freeHistoryLimitHit }
     }
 
     if (updatesInfo.atEnd || loadingState === 'loadingUpdates') return
@@ -156,13 +151,15 @@ function useHistory() {
           setLabels(loadLabels(labels, lastUpdateToV))
         }
 
-        const { updates, freeHistoryLimitHit } = loadUpdates(updatesData)
+        const { updates, visibleUpdateCount, freeHistoryLimitHit } =
+          loadUpdates(updatesData)
 
         const atEnd =
           nextBeforeTimestamp == null || freeHistoryLimitHit || !updates.length
 
         setUpdatesInfo({
           updates,
+          visibleUpdateCount,
           freeHistoryLimitHit,
           atEnd,
           nextBeforeTimestamp,
@@ -175,15 +172,7 @@ function useHistory() {
       .finally(() => {
         setLoadingState('ready')
       })
-  }, [
-    loadingState,
-    labels,
-    projectId,
-    projectOwnerId,
-    userId,
-    userHasFullFeature,
-    updatesInfo,
-  ])
+  }, [loadingState, labels, projectId, userHasFullFeature, updatesInfo])
 
   const resetSelection = useCallback(() => {
     setSelection(selectionInitialState)
@@ -257,6 +246,7 @@ function useHistory() {
       labels,
       setLabels,
       userHasFullFeature,
+      currentUserIsOwner,
       projectId,
       selection,
       setSelection,
@@ -272,6 +262,7 @@ function useHistory() {
       labels,
       setLabels,
       userHasFullFeature,
+      currentUserIsOwner,
       projectId,
       selection,
       setSelection,

+ 3 - 1
services/web/frontend/js/features/history/context/types/history-context-value.ts

@@ -12,6 +12,7 @@ type LoadingState =
 export type HistoryContextValue = {
   updatesInfo: {
     updates: LoadedUpdate[]
+    visibleUpdateCount: Nullable<number>
     atEnd: boolean
     nextBeforeTimestamp: number | undefined
     freeHistoryLimitHit: boolean
@@ -19,7 +20,8 @@ export type HistoryContextValue = {
   setUpdatesInfo: React.Dispatch<
     React.SetStateAction<HistoryContextValue['updatesInfo']>
   >
-  userHasFullFeature: boolean | undefined
+  userHasFullFeature: boolean
+  currentUserIsOwner: boolean
   loadingState: LoadingState
   setLoadingState: React.Dispatch<
     React.SetStateAction<HistoryContextValue['loadingState']>

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

@@ -25,6 +25,7 @@ export const projectShape = {
     references: PropTypes.bool,
     mendeley: PropTypes.bool,
     zotero: PropTypes.bool,
+    versioning: PropTypes.bool,
   }),
   publicAccessLevel: PropTypes.string,
   tokens: PropTypes.shape({

+ 33 - 2
services/web/frontend/stylesheets/app/editor/history-react.less

@@ -1,5 +1,6 @@
 @versions-list-width: 320px;
 @history-toolbar-height: 40px;
+@history-change-list-padding: 16px;
 
 history-root {
   height: 100%;
@@ -78,7 +79,7 @@ history-root {
   .history-toggle-switch-container,
   .history-version-day,
   .history-version-details {
-    padding: 0 16px;
+    padding: 0 @history-change-list-padding;
   }
 
   .history-version-day {
@@ -98,7 +99,7 @@ history-root {
     padding-top: 8px;
     padding-bottom: 8px;
 
-    &:hover {
+    &.history-version-selectable:hover {
       cursor: pointer;
       background-color: @neutral-10;
     }
@@ -267,6 +268,36 @@ history-root {
       margin-left: 5px;
     }
   }
+
+  .history-paywall-prompt {
+    padding: @history-change-list-padding;
+
+    .history-feature-list {
+      list-style: none;
+      padding-left: 8px;
+
+      li {
+        margin-bottom: 16px;
+      }
+    }
+
+    button {
+      width: 100%;
+    }
+  }
+
+  .history-version-faded .history-version-details {
+    max-height: 6em;
+    .mask-image(linear-gradient(black 35%, transparent));
+    overflow: hidden;
+  }
+
+  .history-paywall-heading {
+    .premium-text;
+    font-family: inherit;
+    font-size: 20px;
+    font-weight: 700;
+  }
 }
 
 .history-version-label-tooltip {

+ 6 - 0
services/web/frontend/stylesheets/components/buttons.less

@@ -176,6 +176,12 @@
   .btn-secondary;
 }
 
+// Buttons relating to premium features have a special background
+.btn-premium {
+  .premium-background;
+  color: @white;
+}
+
 .reset-btns {
   // reset all buttons back to their original colors
   .btn-danger {

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

@@ -448,6 +448,25 @@
   filter: e(%('progid:DXImageTransform.Microsoft.gradient(enabled = false)'));
 }
 
+.mask-image(@gradient) {
+  // It has to be this way round, otherwise Less removes the prefixed version, for some reason
+  mask-image: @gradient;
+  -webkit-mask-image: @gradient;
+}
+
+.premium-background {
+  background-image: @premium-gradient;
+}
+
+.premium-text {
+  background-clip: text;
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+  color: @white; // Fallback
+  background-color: @blue-70; // Fallback
+  .premium-background;
+}
+
 // Retina images
 //
 // Short retina mixin for setting background-image and -size

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

@@ -38,6 +38,8 @@
 @blue: #405ebf;
 @blue-10: #f1f4f9;
 @blue-30: #97b6e5;
+@blue-40: #6597e0;
+@blue-70: #214475;
 @blueDark: #040d2d;
 @green: #46a546;
 @green-10: #ebf6ea;
@@ -1174,3 +1176,11 @@
 @history-react-header-color: #fff;
 @history-react-separator-color: @neutral-80;
 @history-main-bg: #fff;
+
+// Gradients
+@premium-gradient: linear-gradient(
+  246deg,
+  @blue-70 0%,
+  #254c84 29%,
+  @blue-40 97%
+);

+ 8 - 0
services/web/frontend/stylesheets/variables/all.less

@@ -959,3 +959,11 @@
 @history-react-header-color: #fff;
 @history-react-separator-color: @neutral-80;
 @history-main-bg: #fff;
+
+// Gradients
+@premium-gradient: linear-gradient(
+  246deg,
+  @blue-70 0%,
+  #254c84 29%,
+  @blue-40 97%
+);

+ 209 - 5
services/web/test/frontend/features/history/components/change-list.spec.tsx

@@ -1,17 +1,22 @@
 import { useState } from 'react'
 import ToggleSwitch from '../../../../../frontend/js/features/history/components/change-list/toggle-switch'
 import ChangeList from '../../../../../frontend/js/features/history/components/change-list/change-list'
-import { EditorProviders } from '../../../helpers/editor-providers'
+import {
+  EditorProviders,
+  USER_EMAIL,
+  USER_ID,
+} from '../../../helpers/editor-providers'
 import { HistoryProvider } from '../../../../../frontend/js/features/history/context/history-context'
 import { updates } from '../fixtures/updates'
 import { labels } from '../fixtures/labels'
 
 const mountWithEditorProviders = (
   component: React.ReactNode,
-  scope: Record<string, unknown> = {}
+  scope: Record<string, unknown> = {},
+  props: Record<string, unknown> = {}
 ) => {
   cy.mount(
-    <EditorProviders scope={scope}>
+    <EditorProviders scope={scope} {...props}>
       <HistoryProvider>
         <div style={{ display: 'flex', justifyContent: 'center' }}>
           <div className="history-react">{component}</div>
@@ -79,7 +84,13 @@ describe('change list', function () {
     })
 
     it('renders tags', function () {
-      mountWithEditorProviders(<ChangeList />, scope)
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: true,
+        },
+      })
       waitForData()
 
       cy.findByLabelText(/all history/i).click({ force: true })
@@ -144,7 +155,13 @@ describe('change list', function () {
     })
 
     it('deletes tag', function () {
-      mountWithEditorProviders(<ChangeList />, scope)
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: true,
+        },
+      })
       waitForData()
 
       cy.findByLabelText(/all history/i).click({ force: true })
@@ -187,6 +204,8 @@ describe('change list', function () {
           cy.contains(/sorry, something went wrong/i)
         })
       })
+      cy.findByText(labelToDelete).should('have.length', 1)
+
       cy.intercept('DELETE', '/project/*/labels/*', {
         statusCode: 204,
       }).as('delete')
@@ -197,4 +216,189 @@ describe('change list', function () {
       cy.findByText(labelToDelete).should('not.exist')
     })
   })
+
+  describe('paywall', function () {
+    const now = Date.now()
+    const oneMinuteAgo = now - 60 * 1000
+    const justOverADayAgo = now - 25 * 60 * 60 * 1000
+    const twoDaysAgo = now - 48 * 60 * 60 * 1000
+
+    const updates = {
+      updates: [
+        {
+          fromV: 3,
+          toV: 4,
+          meta: {
+            users: [
+              {
+                first_name: 'john.doe',
+                last_name: '',
+                email: 'john.doe@test.com',
+                id: '1',
+              },
+            ],
+            start_ts: oneMinuteAgo,
+            end_ts: oneMinuteAgo,
+          },
+          labels: [],
+          pathnames: [],
+          project_ops: [{ add: { pathname: 'name.tex' }, atV: 3 }],
+        },
+        {
+          fromV: 1,
+          toV: 3,
+          meta: {
+            users: [
+              {
+                first_name: 'bobby.lapointe',
+                last_name: '',
+                email: 'bobby.lapointe@test.com',
+                id: '2',
+              },
+            ],
+            start_ts: justOverADayAgo,
+            end_ts: justOverADayAgo - 10 * 1000,
+          },
+          labels: [],
+          pathnames: ['main.tex'],
+          project_ops: [],
+        },
+        {
+          fromV: 0,
+          toV: 1,
+          meta: {
+            users: [
+              {
+                first_name: 'john.doe',
+                last_name: '',
+                email: 'john.doe@test.com',
+                id: '1',
+              },
+            ],
+            start_ts: twoDaysAgo,
+            end_ts: twoDaysAgo,
+          },
+          labels: [
+            {
+              id: 'label1',
+              comment: 'tag-1',
+              version: 0,
+              user_id: USER_ID,
+              created_at: justOverADayAgo,
+            },
+          ],
+          pathnames: [],
+          project_ops: [{ add: { pathname: 'main.tex' }, atV: 0 }],
+        },
+      ],
+    }
+
+    const labels = [
+      {
+        id: 'label1',
+        comment: 'tag-1',
+        version: 0,
+        user_id: USER_ID,
+        created_at: justOverADayAgo,
+        user_display_name: 'john.doe',
+      },
+    ]
+
+    const waitForData = () => {
+      cy.wait('@updates')
+      cy.wait('@labels')
+      cy.wait('@diff')
+    }
+
+    beforeEach(function () {
+      cy.intercept('GET', '/project/*/updates*', {
+        body: updates,
+      }).as('updates')
+      cy.intercept('GET', '/project/*/labels', {
+        body: labels,
+      }).as('labels')
+      cy.intercept('GET', '/project/*/filetree/diff*', {
+        body: { diff: [{ pathname: 'main.tex' }, { pathname: 'name.tex' }] },
+      }).as('diff')
+    })
+
+    it('shows non-owner paywall', function () {
+      const scope = {
+        ui: {
+          view: 'history',
+          pdfLayout: 'sideBySide',
+          chatOpen: true,
+        },
+      }
+
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: false,
+        },
+      })
+
+      waitForData()
+
+      cy.get('.history-paywall-prompt').should('have.length', 1)
+      cy.findAllByTestId('history-version').should('have.length', 2)
+      cy.get('.history-paywall-prompt button').should('not.exist')
+    })
+
+    it('shows owner paywall', function () {
+      const scope = {
+        ui: {
+          view: 'history',
+          pdfLayout: 'sideBySide',
+          chatOpen: true,
+        },
+      }
+
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: false,
+        },
+        projectOwner: {
+          _id: USER_ID,
+          email: USER_EMAIL,
+        },
+      })
+
+      waitForData()
+
+      cy.get('.history-paywall-prompt').should('have.length', 1)
+      cy.findAllByTestId('history-version').should('have.length', 2)
+      cy.get('.history-paywall-prompt button').should('have.length', 1)
+    })
+
+    it('shows all labels in free tier', function () {
+      const scope = {
+        ui: {
+          view: 'history',
+          pdfLayout: 'sideBySide',
+          chatOpen: true,
+        },
+      }
+
+      mountWithEditorProviders(<ChangeList />, scope, {
+        user: {
+          id: USER_ID,
+          email: USER_EMAIL,
+          isAdmin: false,
+        },
+        projectOwner: {
+          _id: USER_ID,
+          email: USER_EMAIL,
+        },
+      })
+
+      waitForData()
+
+      cy.findByLabelText(/labels/i).click({ force: true })
+      cy.get('.history-version-label').should('have.length', 1)
+    })
+  })
 })

+ 5 - 4
services/web/test/frontend/helpers/editor-providers.js

@@ -24,6 +24,10 @@ export const USER_EMAIL = 'testuser@example.com'
 export function EditorProviders({
   user = { id: USER_ID, email: USER_EMAIL },
   projectId = PROJECT_ID,
+  projectOwner = {
+    _id: '124abd',
+    email: 'owner@example.com',
+  },
   rootDocId = '_root_doc_id',
   socket = {
     on: sinon.stub(),
@@ -77,10 +81,7 @@ export function EditorProviders({
     project: {
       _id: window.project_id,
       name: PROJECT_NAME,
-      owner: {
-        _id: '124abd',
-        email: 'owner@example.com',
-      },
+      owner: projectOwner,
       features,
       rootDoc_id: rootDocId,
       rootFolder,