Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Perpetual Edit #1915

Draft
wants to merge 13 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion api/paidAction/itemUpdate.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { PAID_ACTION_PAYMENT_METHODS, USER_ID } from '@/lib/constants'
import { PAID_ACTION_PAYMENT_METHODS, USER_ID, ADMIN_ITEMS } from '@/lib/constants'
import { uploadFees } from '../resolvers/upload'
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { notifyItemMention, notifyMention } from '@/lib/webPush'
Expand Down Expand Up @@ -65,6 +65,30 @@ export async function perform (args, context) {
data: { paid: true }
})

// history tracking
// has to be older than 10 minutes, not a bio, not a job, not deleted, not a special item.
const adminItem = ADMIN_ITEMS.includes(old.id)
if (old.createdAt < new Date(Date.now() - 10 * 60 * 1000) && !old.bio && old.subName !== 'jobs' && !data.deletedAt && !adminItem) {
await tx.oldItem.create({
data: {
createdAt: old.createdAt,
updatedAt: old.updatedAt,
title: old.title,
text: old.text,
url: old.url,
userId: old.userId,
subName: old.subName,
imgproxyUrls: old.imgproxyUrls,
cloneBornAt: old.cloneBornAt,
cloneDiedAt: new Date(),
originalItemId: parseInt(id)
}
})

data.cloneBornAt = new Date() // we can use this to determine if the item has been edited
data.cloneDiedAt = null
}

// we put boost in the where clause because we don't want to update the boost
// if it has changed concurrently
await tx.item.update({
Expand Down
28 changes: 21 additions & 7 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
USER_ID, POLL_COST, ADMIN_ITEMS, GLOBAL_SEED,
NOFOLLOW_LIMIT, UNKNOWN_LINK_REL, SN_ADMIN_IDS,
BOOST_MULT,
ITEM_EDIT_SECONDS,
COMMENTS_LIMIT,
COMMENTS_OF_COMMENT_LIMIT,
FULL_COMMENTS_THRESHOLD
Expand Down Expand Up @@ -633,6 +632,9 @@ export default {
}
},
item: getItem,
oldItem: async (parent, { id }, { models }) => {
return await models.oldItem.findUnique({ where: { id: Number(id) } })
},
pageTitleAndUnshorted: async (parent, { url }, { models }) => {
const res = {}
try {
Expand Down Expand Up @@ -1206,6 +1208,20 @@ export default {
}
return await models.user.findUnique({ where: { id: item.userId } })
},
oldVersions: async (item, args, { models }) => {
return await models.oldItem.findMany({
select: {
id: true,
createdAt: true,
updatedAt: true,
cloneBornAt: true,
cloneDiedAt: true,
originalItemId: true
},
where: { originalItemId: item.id },
orderBy: { cloneDiedAt: 'desc' } // ordering by cloneDiedAt allows us to see the most recent edits first
})
},
forwards: async (item, args, { models }) => {
return await models.itemForward.findMany({
where: {
Expand Down Expand Up @@ -1487,13 +1503,12 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..

const user = await models.user.findUnique({ where: { id: meId } })

// edits are only allowed for own items within 10 minutes
// but forever if an admin is editing an "admin item", it's their bio or a job
// edit always allowed for own items
// or if it's an admin item, their bio or a job TODO: adjust every edit
const myBio = user.bioId === old.id
const timer = Date.now() < datePivot(new Date(old.invoicePaidAt ?? old.createdAt), { seconds: ITEM_EDIT_SECONDS })
const canEdit = (timer && ownerEdit) || adminEdit || myBio || isJob(old)
const canEdit = ownerEdit || adminEdit || myBio || isJob(old)
if (!canEdit) {
throw new GqlInputError('item can no longer be edited')
throw new GqlInputError('item cannot be edited')
}

if (item.url && !isJob(item)) {
Expand All @@ -1515,7 +1530,6 @@ export const updateItem = async (parent, { sub: subName, forward, hash, hmac, ..

// never change author of item
item.userId = old.userId

const resultItem = await performPaidAction('ITEM_UPDATE', item, { models, me, lnd })

resultItem.comments = []
Expand Down
20 changes: 20 additions & 0 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default gql`
auctionPosition(sub: String, id: ID, boost: Int): Int!
boostPosition(sub: String, id: ID, boost: Int): BoostPositions!
itemRepetition(parentId: ID): Int!
oldItem(id: ID!): OldItem
}

type BoostPositions {
Expand Down Expand Up @@ -90,6 +91,22 @@ export default gql`
ad: Item
}

type OldItem {
id: ID!
createdAt: Date
updatedAt: Date
title: String
text: String
url: String
userId: Int
subName: String
imgproxyUrls: JSONObject
cloneBornAt: Date
cloneDiedAt: Date
deletedAt: Date
originalItemId: Int
}

type Comments {
cursor: String
comments: [Item!]!
Expand Down Expand Up @@ -165,6 +182,9 @@ export default gql`
parentOtsHash: String
forwards: [ItemForward]
imgproxyUrls: JSONObject
cloneBornAt: Date
cloneDiedAt: Date
oldVersions: [OldItem!]
rel: String
apiKey: Boolean
invoice: Invoice
Expand Down
4 changes: 2 additions & 2 deletions components/comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@ export default function Comment ({
</>
}
edit={edit}
toggleEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
toggleShadowEdit={e => { setEdit(!edit) }}
shadowEditText={edit ? 'cancel' : 'edit'}
/>}

{!includeParent && (collapse === 'yep'
Expand Down
6 changes: 3 additions & 3 deletions components/discussion-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { UPSERT_DISCUSSION } from '@/fragments/paidAction'
import useItemSubmit from './use-item-submit'

export function DiscussionForm ({
item, sub, editThreshold, titleLabel = 'title',
item, sub, shadowEditThreshold, titleLabel = 'title',
textLabel = 'text',
handleSubmit, children
}) {
Expand Down Expand Up @@ -75,8 +75,8 @@ export function DiscussionForm ({
label={<>{textLabel} <small className='text-muted ms-2'>optional</small></>}
name='text'
minRows={6}
hint={editThreshold
? <div className='text-muted fw-bold font-monospace'><Countdown date={editThreshold} /></div>
hint={shadowEditThreshold && shadowEditThreshold > Date.now() // when shadow edit countdown expires don't show it
? <div className='text-muted fw-bold font-monospace'><Countdown date={shadowEditThreshold} /></div>
: null}
/>
<AdvPostForm storageKeyPrefix={storageKeyPrefix} item={item} sub={sub} />
Expand Down
81 changes: 81 additions & 0 deletions components/item-history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { timeSince } from '@/lib/time'
import styles from './item.module.css'
import Text from './text'
import { Dropdown } from 'react-bootstrap'
import { useShowModal } from './modal'
import { EDIT } from '@/fragments/items'
import { useQuery } from '@apollo/client'
import PageLoading from './page-loading'

// OldItem: takes a versionId and shows the old item
export function OldItem ({ versionId }) {
const { data } = useQuery(EDIT, { variables: { id: versionId } })
if (!data) return <PageLoading />

const actionType = data?.oldItem?.cloneBornAt ? 'edited' : 'created'
const timestamp = data?.oldItem?.cloneBornAt
? data?.oldItem?.cloneDiedAt
: data?.oldItem?.createdAt

return (
<>
<div className={styles.other}>
{actionType} {timeSince(new Date(timestamp))} ago
</div>
<div>
<h5>{data?.oldItem?.title}</h5>
<Text
itemId={data?.oldItem?.originalItemId}
topLevel
imgproxyUrls={data?.oldItem?.imgproxyUrls}
>
{data?.oldItem?.text}
</Text>
</div>
</>
)
}

export const HistoryDropdownItem = ({ version }) => {
const showModal = useShowModal()

const actionType = !version.cloneBornAt ? 'created' : 'edited'
const timestamp = version.cloneBornAt
? version.cloneDiedAt
: version.createdAt

return (
<Dropdown.Item
key={version.id}
title={version.cloneBornAt || version.createdAt}
onClick={() => showModal((onClose) => <OldItem versionId={version.id} />)}
>
{actionType} {timeSince(new Date(timestamp))} ago
</Dropdown.Item>
)
}

// History dropdown: takes an item and maps over the oldVersions
export default function HistoryDropdown ({ item }) {
const mostRecentTimestamp = item.cloneBornAt || item.oldVersions[0].createdAt

return (
<Dropdown className='pointer' as='span'>
<Dropdown.Toggle as='span' onPointerDown={e => e.preventDefault()}>
edited
</Dropdown.Toggle>
<Dropdown.Menu style={{ maxHeight: '15rem', overflowY: 'auto' }}>
<Dropdown.Header className='text-muted'>
edited {item.oldVersions.length} times
</Dropdown.Header>
<hr className='dropdown-divider' />
<Dropdown.Item title={mostRecentTimestamp}>
edited {timeSince(new Date(mostRecentTimestamp))} ago (most recent)
</Dropdown.Item>
{item.oldVersions.map((version) => (
<HistoryDropdownItem key={version.id} version={version} />
))}
</Dropdown.Menu>
</Dropdown>
)
}
45 changes: 29 additions & 16 deletions components/item-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import { useToast } from './toast'
import { useShowModal } from './modal'
import classNames from 'classnames'
import SubPopover from './sub-popover'
import useCanEdit from './use-can-edit'
import useCanShadowEdit from './use-can-edit'
import HistoryDropdown from './item-history'

function itemTitle (item) {
let title = ''
Expand Down Expand Up @@ -65,7 +66,7 @@ function itemTitle (item) {

export default function ItemInfo ({
item, full, commentsText = 'comments',
commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleEdit, editText,
commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleShadowEdit, shadowEditText,
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true,
setDisableRetry, disableRetry
}) {
Expand All @@ -74,7 +75,7 @@ export default function ItemInfo ({
const [hasNewComments, setHasNewComments] = useState(false)
const root = useRoot()
const sub = item?.sub || root?.sub
const [canEdit, setCanEdit, editThreshold] = useCanEdit(item)
const [canShadowEdit, setCanShadowEdit, shadowEditThreshold] = useCanShadowEdit(item)

useEffect(() => {
if (!full) {
Expand Down Expand Up @@ -145,6 +146,11 @@ export default function ItemInfo ({
yesterday
</Link>
</>}
{item.oldVersions?.length > 0 && !item.deletedAt && // bios, jobs and admin items are not tracked
<>
<span> </span>
<HistoryDropdown item={item} />
</>}
</span>
{item.subName &&
<SubPopover sub={item.subName}>
Expand All @@ -171,8 +177,9 @@ export default function ItemInfo ({
showActionDropdown &&
<>
<EditInfo
item={item} edit={edit} canEdit={canEdit}
setCanEdit={setCanEdit} toggleEdit={toggleEdit} editText={editText} editThreshold={editThreshold}
item={item} edit={edit} canShadowEdit={canShadowEdit}
setCanShadowEdit={setCanShadowEdit} toggleShadowEdit={toggleShadowEdit}
shadowEditText={shadowEditText} shadowEditThreshold={shadowEditThreshold}
/>
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
<ActionDropdown>
Expand Down Expand Up @@ -219,6 +226,13 @@ export default function ItemInfo ({
<hr className='dropdown-divider' />
<PinSubDropdownItem item={item} />
</>}
{item.mine && !item.deletedAt && sub && // has to have a sub for edit page
<>
<hr className='dropdown-divider' />
<Dropdown.Item onClick={() => !item.parentId ? router.push(`/items/${item.id}/edit`) : toggleShadowEdit(true)} className='text-reset dropdown-item'>
edit
</Dropdown.Item>
</>}
{item.mine && !item.position && !item.deletedAt && !item.bio &&
<>
<hr className='dropdown-divider' />
Expand Down Expand Up @@ -339,40 +353,39 @@ function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
)
}

function EditInfo ({ item, edit, canEdit, setCanEdit, toggleEdit, editText, editThreshold }) {
function EditInfo ({ item, edit, canShadowEdit, setCanShadowEdit, toggleShadowEdit, shadowEditText, shadowEditThreshold }) {
const router = useRouter()

if (canEdit) {
if (canShadowEdit) {
return (
<>
<span> \ </span>
<span
className='text-reset pointer fw-bold font-monospace'
onClick={() => toggleEdit ? toggleEdit() : router.push(`/items/${item.id}/edit`)}
onClick={() => toggleShadowEdit ? toggleShadowEdit() : router.push(`/items/${item.id}/edit`)}
>
<span>{editText || 'edit'} </span>
<span>{shadowEditText || 'edit'} </span>
{(!item.invoice?.actionState || item.invoice?.actionState === 'PAID')
? <Countdown
date={editThreshold}
onComplete={() => { setCanEdit(false) }}
date={shadowEditThreshold}
onComplete={() => { setCanShadowEdit(false) }}
/>
: <span>10:00</span>}
</span>
</>
)
}

if (edit && !canEdit) {
// if we're still editing after timer ran out
if (edit && !canShadowEdit) {
// we're not in shadow editing mode anymore
return (
<>
<span> \ </span>
<span
className='text-reset pointer fw-bold font-monospace'
onClick={() => toggleEdit ? toggleEdit() : router.push(`/items/${item.id}`)}
onClick={() => toggleShadowEdit ? toggleShadowEdit() : router.push(`/items/${item.id}`)}
>
<span>cancel </span>
<span>00:00</span>
<span>cancel</span>
</span>
</>
)
Expand Down
4 changes: 2 additions & 2 deletions components/job-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default function JobForm ({ item, sub }) {

export function JobButtonBar ({
itemId, disable, className, children, handleStop, onCancel, hasCancel = true,
createText = 'post', editText = 'save', stopText = 'remove'
createText = 'post', shadowEditText = 'save', stopText = 'remove'
}) {
return (
<div className={`mt-3 ${className}`}>
Expand All @@ -145,7 +145,7 @@ export function JobButtonBar ({
<div className='d-flex align-items-center ms-auto'>
{hasCancel && <CancelButton onClick={onCancel} />}
<FeeButton
text={itemId ? editText : createText}
text={itemId ? shadowEditText : createText}
variant='secondary'
disabled={disable}
/>
Expand Down
Loading