Ephemeral item support (#570)

backend impl and some of the UI for ephemeral item support

more to come, this is just a WIP so far

Consolidate client-side ephemeral fee logic in FeeButton components for easier reuse

* update the update_item function to handle the case where an item was not
ephemeral, but now is, so we charge the user accordingly

* introduce `hasDeleteCommand` for a better logical abstraction for some use cases in the code

* introduce `EPHEMERAL_FEE_SATS` which is derived from `EPHEMERAL_FEE_MSATS`, so we don't
have to the same calculation over and over

Remove fees for ephemeral items

* remove unused markdownField prop in FeeButton

* remove empty migration

minor code cleanup

Centralize delete item by author code to reduce duplication
This commit is contained in:
SatsAllDay 2023-10-22 12:02:58 -04:00 committed by GitHub
parent e713387920
commit 1d394bebe1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 73 additions and 16 deletions

View File

@ -16,7 +16,7 @@ import { parse } from 'tldts'
import uu from 'url-unshort'
import { advSchema, amountSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '../../lib/validate'
import { sendUserNotification } from '../webPush'
import { defaultCommentSort, isJob } from '../../lib/item'
import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item'
import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications'
import { datePivot } from '../../lib/time'
@ -654,21 +654,7 @@ export default {
throw new GraphQLError('item does not belong to you', { extensions: { code: 'FORBIDDEN' } })
}
const data = { deletedAt: new Date() }
if (old.text) {
data.text = '*deleted by author*'
}
if (old.title) {
data.title = 'deleted by author'
}
if (old.url) {
data.url = null
}
if (old.pollCost) {
data.pollCost = null
}
return await models.item.update({ where: { id: Number(id) }, data })
return await deleteItemByAuthor({ models, id, item: old })
},
upsertLink: async (parent, { id, hash, hmac, ...item }, { me, models, lnd }) => {
await ssValidate(linkSchema, item, { models, me })
@ -1109,6 +1095,12 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
await createMentions(item, models)
if (hasDeleteCommand(old.text)) {
// delete any deletion jobs that were created from a prior version of the item
await clearDeletionJobs(item, models)
}
await enqueueDeletionJob(item, models)
item.comments = []
return item
}
@ -1138,12 +1130,27 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
await createMentions(item, models)
await enqueueDeletionJob(item, models)
notifyUserSubscribers({ models, item })
item.comments = []
return item
}
const clearDeletionJobs = async (item, models) => {
await models.$queryRawUnsafe(`DELETE FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}';`)
}
const enqueueDeletionJob = async (item, models) => {
const deleteCommand = getDeleteCommand(item.text)
if (deleteCommand) {
await models.$queryRawUnsafe(`
INSERT INTO pgboss.job (name, data, startafter)
VALUES ('deleteItem', jsonb_build_object('id', ${item.id}), now() + interval '${deleteCommand.number} ${deleteCommand.unit}s');`)
}
}
const getForwardUsers = async (models, forward) => {
const fwdUsers = []
if (forward) {

View File

@ -11,3 +11,38 @@ export const defaultCommentSort = (pinned, bio, createdAt) => {
}
export const isJob = item => typeof item.maxBid !== 'undefined'
const deletePattern = /\B@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi
export const getDeleteCommand = (text = '') => {
const matches = [...text.matchAll(deletePattern)]
const commands = matches?.map(match => ({ number: match[1], unit: match[2] }))
return commands.length ? commands[commands.length - 1] : undefined
}
export const hasDeleteCommand = (text) => !!getDeleteCommand(text)
export const deleteItemByAuthor = async ({ models, id, item }) => {
if (!item) {
item = await models.item.findUnique({ where: { id: Number(id) } })
}
if (!item) {
console.log('attempted to delete an item that does not exist', id)
return
}
const updateData = { deletedAt: new Date() }
if (item.text) {
updateData.text = '*deleted by author*'
}
if (item.title) {
updateData.title = 'deleted by author'
}
if (item.url) {
updateData.url = null
}
if (item.pollCost) {
updateData.pollCost = null
}
return await models.item.update({ where: { id: Number(id) }, data: updateData })
}

13
worker/ephemeralItems.js Normal file
View File

@ -0,0 +1,13 @@
import { deleteItemByAuthor } from '../lib/item.js'
export function deleteItem ({ models }) {
return async function ({ data: eventData }) {
console.log('deleteItem', eventData)
const { id } = eventData
try {
await deleteItemByAuthor({ models, id })
} catch (err) {
console.error('failed to delete item', err)
}
}
}

View File

@ -15,6 +15,7 @@ import fetch from 'cross-fetch'
import { authenticatedLndGrpc } from 'ln-service'
import { views, rankViews } from './views.js'
import { imgproxy } from './imgproxy.js'
import { deleteItem } from './ephemeralItems.js'
const { loadEnvConfig } = nextEnv
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
@ -68,6 +69,7 @@ async function work () {
await boss.work('views', views(args))
await boss.work('rankViews', rankViews(args))
await boss.work('imgproxy', imgproxy(args))
await boss.work('deleteItem', deleteItem(args))
console.log('working jobs')
}