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 11 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
24 changes: 23 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,28 @@ 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: {
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
}

Copy link
Member Author

@Soxasora Soxasora Feb 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we really just want to track Item content editing, I switched to a Prisma-based version of the trigger that I think it's more appropriate. Before, every update to Item could fire the procedure and confront the conditions.

Also on this, not having Item inheritance made me think that having just enough columns can be okay, but also having the whole item is okay to me, maybe future-proof

// 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
17 changes: 10 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 @@ -1206,6 +1205,12 @@ export default {
}
return await models.user.findUnique({ where: { id: item.userId } })
},
oldVersions: async (item, args, { models }) => {
return await models.oldItem.findMany({
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 +1492,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 +1519,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
19 changes: 19 additions & 0 deletions api/typeDefs/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,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 +181,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
51 changes: 51 additions & 0 deletions components/item-history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { timeSince } from '@/lib/time'
import styles from './item.module.css'
import Text from './text'
import { Dropdown } from 'react-bootstrap'
import { useShowModal } from './modal'

// OldItem: takes a version and shows the old item
export function OldItem ({ version }) {
return (
<>
<div className={styles.other}>
{!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago
</div>
<div>
<h5>{version.title}</h5>
<Text itemId={version.originalItemId} topLevel imgproxyUrls={version.imgproxyUrls}>{version.text}</Text>
</div>
</>
)
}

// History dropdown: takes an item and by mapping over the oldVersions, it will show the history of the item
export default function HistoryDropdown ({ item }) {
const showModal = useShowModal()

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={item.oldVersions[0].cloneDiedAt}>
edited {timeSince(new Date(item.oldVersions[0].cloneDiedAt))} ago (most recent)
</Dropdown.Item>
{item.oldVersions.map((version) => (
<Dropdown.Item
key={version.id}
title={version.cloneBornAt || version.createdAt}
onClick={() => showModal((onClose) => <OldItem version={version} />)}
>
{!version.cloneBornAt ? 'created' : 'edited'} {timeSince(new Date(version.cloneBornAt || version.createdAt))} ago
</Dropdown.Item>
))}
</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
16 changes: 8 additions & 8 deletions components/use-can-edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,24 @@ import { datePivot } from '@/lib/time'
import { useMe } from '@/components/me'
import { ITEM_EDIT_SECONDS, USER_ID } from '@/lib/constants'

export default function useCanEdit (item) {
export default function useCanShadowEdit (item) {
const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { seconds: ITEM_EDIT_SECONDS })
const { me } = useMe()

// deleted items can never be edited and every item has a 10 minute edit window
// deleted items can never be edited and every item has a 10 minute shadow edit window
// except bios, they can always be edited but they should never show the countdown
const noEdit = !!item.deletedAt || (Date.now() >= editThreshold) || item.bio
const noEdit = !!item.deletedAt || item.bio
const authorEdit = me && item.mine
const [canEdit, setCanEdit] = useState(!noEdit && authorEdit)
const [canShadowEdit, setCanShadowEdit] = useState(!noEdit && authorEdit)

useEffect(() => {
// allow anon edits if they have the correct hmac for the item invoice
// allow anon shadow edits if they have the correct hmac for the item invoice
// (the server will verify the hmac)
const invParams = window.localStorage.getItem(`item:${item.id}:hash:hmac`)
const anonEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon
// anonEdit should not override canEdit, but only allow edits if they aren't already allowed
setCanEdit(canEdit => canEdit || anonEdit)
// anonEdit should not override canShadowEdit, but only allow edits if they aren't already allowed
setCanShadowEdit(canShadowEdit => canShadowEdit || anonEdit)
}, [])

return [canEdit, setCanEdit, editThreshold]
return [canShadowEdit, setCanShadowEdit, editThreshold]
}
17 changes: 17 additions & 0 deletions fragments/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ export const COMMENT_FIELDS = gql`
ncomments
nDirectComments
imgproxyUrls
cloneBornAt
cloneDiedAt
oldVersions {
id
createdAt
updatedAt
title
text
url
userId
subName
imgproxyUrls
cloneBornAt
cloneDiedAt
deletedAt
originalItemId
}
rel
apiKey
invoice {
Expand Down
Loading