Toast on delete bot directive usage (#620)

* Toast on successful delete bot directive

* refactor duplicate code into a reusable function

* restore empty spacing lines to clean up the diff

* perf optimization, only query for deleteScheduledAt for your own items

* Issue a warning toast if the delete bot was mentioned but the item was not scheduled for deletion

* use bs-secondary color for warning

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
SatsAllDay 2023-11-19 16:09:47 -05:00 committed by GitHub
parent d84c46df81
commit 4596681fbc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 101 additions and 14 deletions

View File

@ -994,6 +994,15 @@ export default {
} }
const parent = await models.item.findUnique({ where: { id: item.parentId } }) const parent = await models.item.findUnique({ where: { id: item.parentId } })
return parent.otsHash return parent.otsHash
},
deleteScheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? ANON_USER_ID
if (meId !== item.userId) {
// Only query for deleteScheduledAt for your own items to keep DB queries minimized
return null
}
const deleteJobs = await models.$queryRawUnsafe(`SELECT startafter FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}'`)
return deleteJobs[0]?.startafter ?? null
} }
} }
} }

View File

@ -67,6 +67,7 @@ export default gql`
createdAt: Date! createdAt: Date!
updatedAt: Date! updatedAt: Date!
deletedAt: Date deletedAt: Date
deleteScheduledAt: Date
title: String title: String
searchTitle: String searchTitle: String
url: String url: String

View File

@ -7,9 +7,10 @@ import InputGroup from 'react-bootstrap/InputGroup'
import { bountySchema } from '../lib/validate' import { bountySchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form' import { SubSelectInitial } from './sub-select-form'
import { useCallback } from 'react' import { useCallback } from 'react'
import { normalizeForwards } from '../lib/form' import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useMe } from './me' import { useMe } from './me'
import { useToast } from './toast'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
export function BountyForm ({ export function BountyForm ({
@ -25,6 +26,7 @@ export function BountyForm ({
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const toaster = useToast()
const schema = bountySchema({ client, me, existingBoost: item?.boost }) const schema = bountySchema({ client, me, existingBoost: item?.boost })
const [upsertBounty] = useMutation( const [upsertBounty] = useMutation(
gql` gql`
@ -51,6 +53,7 @@ export function BountyForm ({
hmac: $hmac hmac: $hmac
) { ) {
id id
deleteScheduledAt
} }
} }
` `
@ -58,7 +61,7 @@ export function BountyForm ({
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ boost, bounty, ...values }) => { async ({ boost, bounty, ...values }) => {
const { error } = await upsertBounty({ const { data, error } = await upsertBounty({
variables: { variables: {
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
id: item?.id, id: item?.id,
@ -78,6 +81,7 @@ export function BountyForm ({
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
toastDeleteScheduled(toaster, data, !!item, values.text)
}, [upsertBounty, router] }, [upsertBounty, router]
) )

View File

@ -2,15 +2,19 @@ import { Form, MarkdownInput } from '../components/form'
import { gql, useMutation } from '@apollo/client' import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css' import styles from './reply.module.css'
import { commentSchema } from '../lib/validate' import { commentSchema } from '../lib/validate'
import { useToast } from './toast'
import { toastDeleteScheduled } from '../lib/form'
import { FeeButtonProvider } from './fee-button' import { FeeButtonProvider } from './fee-button'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) { export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) {
const toaster = useToast()
const [upsertComment] = useMutation( const [upsertComment] = useMutation(
gql` gql`
mutation upsertComment($id: ID! $text: String!) { mutation upsertComment($id: ID! $text: String!) {
upsertComment(id: $id, text: $text) { upsertComment(id: $id, text: $text) {
text text
deleteScheduledAt
} }
}`, { }`, {
update (cache, { data: { upsertComment } }) { update (cache, { data: { upsertComment } }) {
@ -35,10 +39,11 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
}} }}
schema={commentSchema} schema={commentSchema}
onSubmit={async (values, { resetForm }) => { onSubmit={async (values, { resetForm }) => {
const { error } = await upsertComment({ variables: { ...values, id: comment.id } }) const { data, error } = await upsertComment({ variables: { ...values, id: comment.id } })
if (error) { if (error) {
throw new Error({ message: error.toString() }) throw new Error({ message: error.toString() })
} }
toastDeleteScheduled(toaster, data, true, values.text)
if (onSuccess) { if (onSuccess) {
onSuccess() onSuccess()
} }

View File

@ -9,10 +9,11 @@ import Item from './item'
import { discussionSchema } from '../lib/validate' import { discussionSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form' import { SubSelectInitial } from './sub-select-form'
import { useCallback } from 'react' import { useCallback } from 'react'
import { normalizeForwards } from '../lib/form' import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useMe } from './me' import { useMe } from './me'
import useCrossposter from './use-crossposter' import useCrossposter from './use-crossposter'
import { useToast } from './toast'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
export function DiscussionForm ({ export function DiscussionForm ({
@ -27,12 +28,14 @@ export function DiscussionForm ({
// if Web Share Target API was used // if Web Share Target API was used
const shareTitle = router.query.title const shareTitle = router.query.title
const crossposter = useCrossposter() const crossposter = useCrossposter()
const toaster = useToast()
const [upsertDiscussion] = useMutation( const [upsertDiscussion] = useMutation(
gql` gql`
mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { mutation upsertDiscussion($sub: String, $id: ID, $title: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { upsertDiscussion(sub: $sub, id: $id, title: $title, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
id id
deleteScheduledAt
} }
}` }`
) )
@ -75,6 +78,7 @@ export function DiscussionForm ({
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
toastDeleteScheduled(toaster, data, !!item, values.text)
}, [upsertDiscussion, router, item, sub, crossposter] }, [upsertDiscussion, router, item, sub, crossposter]
) )

View File

@ -16,6 +16,8 @@ import { usePrice } from './price'
import Avatar from './avatar' import Avatar from './avatar'
import { jobSchema } from '../lib/validate' import { jobSchema } from '../lib/validate'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useToast } from './toast'
import { toastDeleteScheduled } from '../lib/form'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
function satsMin2Mo (minute) { function satsMin2Mo (minute) {
@ -38,6 +40,7 @@ function PriceHint ({ monthly }) {
export default function JobForm ({ item, sub }) { export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job` const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const router = useRouter() const router = useRouter()
const toaster = useToast()
const [logoId, setLogoId] = useState(item?.uploadId) const [logoId, setLogoId] = useState(item?.uploadId)
const [upsertJob] = useMutation(gql` const [upsertJob] = useMutation(gql`
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String, mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String,
@ -46,6 +49,7 @@ export default function JobForm ({ item, sub }) {
location: $location, remote: $remote, text: $text, location: $location, remote: $remote, text: $text,
url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) { url: $url, maxBid: $maxBid, status: $status, logo: $logo, hash: $hash, hmac: $hmac) {
id id
deleteScheduledAt
} }
}` }`
) )
@ -59,7 +63,7 @@ export default function JobForm ({ item, sub }) {
status = 'STOPPED' status = 'STOPPED'
} }
const { error } = await upsertJob({ const { data, error } = await upsertJob({
variables: { variables: {
id: item?.id, id: item?.id,
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
@ -78,6 +82,7 @@ export default function JobForm ({ item, sub }) {
} else { } else {
await router.push(`/~${sub.name}/recent`) await router.push(`/~${sub.name}/recent`)
} }
toastDeleteScheduled(toaster, data, !!item, values.text)
}, [upsertJob, router, logoId] }, [upsertJob, router, logoId]
) )

View File

@ -10,7 +10,8 @@ import AccordianItem from './accordian-item'
import { linkSchema } from '../lib/validate' import { linkSchema } from '../lib/validate'
import Moon from '../svgs/moon-fill.svg' import Moon from '../svgs/moon-fill.svg'
import { SubSelectInitial } from './sub-select-form' import { SubSelectInitial } from './sub-select-form'
import { normalizeForwards } from '../lib/form' import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import { useToast } from './toast'
import { MAX_TITLE_LENGTH } from '../lib/constants' import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useMe } from './me' import { useMe } from './me'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
@ -19,6 +20,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const toaster = useToast()
const schema = linkSchema({ client, me, existingBoost: item?.boost }) const schema = linkSchema({ client, me, existingBoost: item?.boost })
// if Web Share Target API was used // if Web Share Target API was used
const shareUrl = router.query.url const shareUrl = router.query.url
@ -70,13 +72,14 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) { mutation upsertLink($sub: String, $id: ID, $title: String!, $url: String!, $text: String, $boost: Int, $forward: [ItemForwardInput], $hash: String, $hmac: String) {
upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { upsertLink(sub: $sub, id: $id, title: $title, url: $url, text: $text, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
id id
deleteScheduledAt
} }
}` }`
) )
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ boost, title, ...values }) => { async ({ boost, title, ...values }) => {
const { error } = await upsertLink({ const { data, error } = await upsertLink({
variables: { variables: {
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
id: item?.id, id: item?.id,
@ -95,6 +98,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
toastDeleteScheduled(toaster, data, !!item, values.text)
}, [upsertLink, router] }, [upsertLink, router]
) )

View File

@ -7,14 +7,16 @@ import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '
import { pollSchema } from '../lib/validate' import { pollSchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form' import { SubSelectInitial } from './sub-select-form'
import { useCallback } from 'react' import { useCallback } from 'react'
import { normalizeForwards } from '../lib/form' import { normalizeForwards, toastDeleteScheduled } from '../lib/form'
import { useMe } from './me' import { useMe } from './me'
import { useToast } from './toast'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
export function PollForm ({ item, sub, editThreshold, children }) { export function PollForm ({ item, sub, editThreshold, children }) {
const router = useRouter() const router = useRouter()
const client = useApolloClient() const client = useApolloClient()
const me = useMe() const me = useMe()
const toaster = useToast()
const schema = pollSchema({ client, me, existingBoost: item?.boost }) const schema = pollSchema({ client, me, existingBoost: item?.boost })
const [upsertPoll] = useMutation( const [upsertPoll] = useMutation(
@ -24,6 +26,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
upsertPoll(sub: $sub, id: $id, title: $title, text: $text, upsertPoll(sub: $sub, id: $id, title: $title, text: $text,
options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) { options: $options, boost: $boost, forward: $forward, hash: $hash, hmac: $hmac) {
id id
deleteScheduledAt
} }
}` }`
) )
@ -31,7 +34,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const onSubmit = useCallback( const onSubmit = useCallback(
async ({ boost, title, options, ...values }) => { async ({ boost, title, options, ...values }) => {
const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0) const optionsFiltered = options.slice(initialOptions?.length).filter(word => word.trim().length > 0)
const { error } = await upsertPoll({ const { data, error } = await upsertPoll({
variables: { variables: {
id: item?.id, id: item?.id,
sub: item?.subName || sub?.name, sub: item?.subName || sub?.name,
@ -51,6 +54,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
const prefix = sub?.name ? `/~${sub.name}` : '' const prefix = sub?.name ? `/~${sub.name}` : ''
await router.push(prefix + '/recent') await router.push(prefix + '/recent')
} }
toastDeleteScheduled(toaster, data, !!item, values.text)
}, [upsertPoll, router] }, [upsertPoll, router]
) )

View File

@ -10,6 +10,8 @@ import { commentsViewedAfterComment } from '../lib/new-comments'
import { commentSchema } from '../lib/validate' import { commentSchema } from '../lib/validate'
import { quote } from '../lib/md' import { quote } from '../lib/md'
import { COMMENT_DEPTH_LIMIT } from '../lib/constants' import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useToast } from './toast'
import { toastDeleteScheduled } from '../lib/form'
import { ItemButtonBar } from './post' import { ItemButtonBar } from './post'
export function ReplyOnAnotherPage ({ item }) { export function ReplyOnAnotherPage ({ item }) {
@ -34,6 +36,7 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
const parentId = item.id const parentId = item.id
const replyInput = useRef(null) const replyInput = useRef(null)
const formInnerRef = useRef() const formInnerRef = useRef()
const toaster = useToast()
// Start block to handle iOS Safari's weird selection clearing behavior // Start block to handle iOS Safari's weird selection clearing behavior
const savedRange = useRef() const savedRange = useRef()
@ -139,7 +142,8 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
) )
const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => { const onSubmit = useCallback(async ({ amount, hash, hmac, ...values }, { resetForm }) => {
await upsertComment({ variables: { parentId, hash, hmac, ...values } }) const { data } = await upsertComment({ variables: { parentId, hash, hmac, ...values } })
toastDeleteScheduled(toaster, data, false, values.text)
resetForm({ text: '' }) resetForm({ text: '' })
setReply(replyOpen || false) setReply(replyOpen || false)
}, [upsertComment, setReply, parentId]) }, [upsertComment, setReply, parentId])

View File

@ -25,12 +25,20 @@ export const ToastProvider = ({ children }) => {
}, []) }, [])
const toaster = useMemo(() => ({ const toaster = useMemo(() => ({
success: body => { success: (body, delay = 5000) => {
dispatchToast({ dispatchToast({
body, body,
variant: 'success', variant: 'success',
autohide: true, autohide: true,
delay: 5000 delay
})
},
warning: (body, delay = 5000) => {
dispatchToast({
body,
variant: 'warning',
autohide: true,
delay
}) })
}, },
danger: (body, onCloseCallback) => { danger: (body, onCloseCallback) => {
@ -64,7 +72,7 @@ export const ToastProvider = ({ children }) => {
{toasts.map(toast => ( {toasts.map(toast => (
<Toast <Toast
key={toast.id} bg={toast.variant} show autohide={toast.autohide} key={toast.id} bg={toast.variant} show autohide={toast.autohide}
delay={toast.delay} className={`${styles.toast} ${styles[toast.variant]}`} onClose={() => removeToast(toast.id)} delay={toast.delay} className={`${styles.toast} ${styles[toast.variant]} ${toast.variant === 'warning' ? 'text-dark' : ''}`} onClose={() => removeToast(toast.id)}
> >
<ToastBody> <ToastBody>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
@ -77,7 +85,7 @@ export const ToastProvider = ({ children }) => {
if (toast.onCloseCallback) toast.onCloseCallback() if (toast.onCloseCallback) toast.onCloseCallback()
removeToast(toast.id) removeToast(toast.id)
}} }}
><div className={styles.toastClose}>X</div> ><div className={`${styles.toastClose} ${toast.variant === 'warning' ? 'text-dark' : ''}`}>X</div>
</Button> </Button>
</div> </div>
</ToastBody> </ToastBody>

View File

@ -17,6 +17,10 @@
border-color: var(--bs-danger-border-subtle); border-color: var(--bs-danger-border-subtle);
} }
.warning {
border-color: var(--bs-warning-border-subtle);
}
.toastClose { .toastClose {
color: #fff; color: #fff;
font-family: "lightning"; font-family: "lightning";

View File

@ -6,6 +6,7 @@ export const COMMENT_FIELDS = gql`
parentId parentId
createdAt createdAt
deletedAt deletedAt
deleteScheduledAt
text text
user { user {
id id

View File

@ -55,6 +55,7 @@ export const ITEM_FULL_FIELDS = gql`
fragment ItemFullFields on Item { fragment ItemFullFields on Item {
...ItemFields ...ItemFields
text text
deleteScheduledAt
root { root {
id id
title title

View File

@ -1,3 +1,5 @@
import { hasDeleteMention } from './item'
/** /**
* Normalize an array of forwards by converting the pct from a string to a number * Normalize an array of forwards by converting the pct from a string to a number
* Also extracts nym from nested user object, if necessary * Also extracts nym from nested user object, if necessary
@ -10,3 +12,28 @@ export const normalizeForwards = (forward) => {
} }
return forward.filter(fwd => fwd.nym || fwd.user?.name).map(fwd => ({ nym: fwd.nym ?? fwd.user?.name, pct: Number(fwd.pct) })) return forward.filter(fwd => fwd.nym || fwd.user?.name).map(fwd => ({ nym: fwd.nym ?? fwd.user?.name, pct: Number(fwd.pct) }))
} }
export const toastDeleteScheduled = (toaster, upsertResponseData, isEdit, itemText) => {
const keys = Object.keys(upsertResponseData)
const data = upsertResponseData[keys[0]]
if (!data) return
const deleteScheduledAt = data.deleteScheduledAt ? new Date(data.deleteScheduledAt) : undefined
if (deleteScheduledAt) {
const itemType = {
upsertDiscussion: 'discussion post',
upsertLink: 'link post',
upsertPoll: 'poll',
upsertBounty: 'bounty',
upsertJob: 'job',
upsertComment: 'comment'
}[keys[0]] ?? 'item'
const message = `${itemType === 'comment' ? 'your comment' : isEdit ? `this ${itemType}` : `your new ${itemType}`} will be deleted at ${deleteScheduledAt.toLocaleString()}`
toaster.success(message)
return
}
if (hasDeleteMention(itemText)) {
// There's a delete mention but the deletion wasn't scheduled
toaster.warning('it looks like you tried to use the delete bot but it didn\'t work. make sure you use the correct format: "@delete in n units" e.g. "@delete in 2 hours"', 10000)
}
}

View File

@ -15,6 +15,10 @@ export const isJob = item => typeof item.maxBid !== 'undefined'
// a delete directive preceded by a non word character that isn't a backtick // a delete directive preceded by a non word character that isn't a backtick
const deletePattern = /\B(?<!`)@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi const deletePattern = /\B(?<!`)@delete\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi
const deleteMentionPattern = /\B(?<!`)@delete/i
export const hasDeleteMention = (text) => deleteMentionPattern.test(text ?? '')
export const getDeleteCommand = (text) => { export const getDeleteCommand = (text) => {
if (!text) return false if (!text) return false
const matches = [...text.matchAll(deletePattern)] const matches = [...text.matchAll(deletePattern)]

View File

@ -1,6 +1,7 @@
$primary: #FADA5E; $primary: #FADA5E;
$secondary: #F6911D; $secondary: #F6911D;
$danger: #c03221; $danger: #c03221;
$warning: $secondary;
$info: #007cbe; $info: #007cbe;
$success: #5c8001; $success: #5c8001;
$twitter: #1da1f2; $twitter: #1da1f2;
@ -13,6 +14,7 @@ $theme-colors: (
"primary" : #FADA5E, "primary" : #FADA5E,
"secondary" : #F6911D, "secondary" : #F6911D,
"danger" : #c03221, "danger" : #c03221,
"warning" : $secondary,
"info" : #007cbe, "info" : #007cbe,
"success" : #5c8001, "success" : #5c8001,
"twitter" : #1da1f2, "twitter" : #1da1f2,