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
This commit is contained in:
Tim Down 2023-05-02 15:00:45 +01:00 committed by Copybot
parent e89b4b4214
commit 70bae34bd8
17 changed files with 448 additions and 64 deletions

View file

@ -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": "",

View file

@ -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>
)

View file

@ -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>

View file

@ -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>
)

View file

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

View file

@ -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>
)
}

View file

@ -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>
)
}

View file

@ -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,

View file

@ -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']>

View file

@ -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({

View file

@ -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 {

View file

@ -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 {

View file

@ -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

View file

@ -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%
);

View file

@ -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%
);

View file

@ -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)
})
})
})

View file

@ -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,