Skip to content

feat: Add support for scheduled posts #2222

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

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
5 changes: 5 additions & 0 deletions api/paidAction/itemCreate.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { notifyItemMention, notifyItemParents, notifyMention, notifyTerritorySub
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
import { msatsToSats, satsToMsats } from '@/lib/format'
import { GqlInputError } from '@/lib/error'
import { getScheduleAt } from '@/lib/item'

export const anonable = true

Expand Down Expand Up @@ -96,6 +97,9 @@ export async function perform (args, context) {
const mentions = await getMentions(args, context)
const itemMentions = await getItemMentions(args, context)

// Check if this is a scheduled post
const scheduleAt = getScheduleAt(data.text)

// start with median vote
if (me) {
const [row] = await tx.$queryRaw`SELECT
Expand All @@ -112,6 +116,7 @@ export async function perform (args, context) {
...data,
...invoiceData,
boost,
scheduledAt: scheduleAt,
threadSubscriptions: {
createMany: {
data: [
Expand Down
71 changes: 66 additions & 5 deletions api/resolvers/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function commentsOrderByClause (me, models, sort) {
if (sort === 'recent') {
return `ORDER BY ${sharedSorts},
("Item".cost > 0 OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0) DESC,
COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
COALESCE("Item"."scheduledAt", "Item"."invoicePaidAt", "Item".created_at) DESC, "Item".id DESC`
}

if (sort === 'hot') {
Expand Down Expand Up @@ -108,7 +108,8 @@ export async function getItem (parent, { id }, { me, models }) {
FROM "Item"
${whereClause(
'"Item".id = $1',
activeOrMine(me)
activeOrMine(me),
scheduledOrMine(me)
)}`
}, Number(id))
return item
Expand All @@ -130,6 +131,7 @@ export async function getAd (parent, { sub, subArr = [], showNsfw = false }, { m
'"Item".bio = false',
'"Item".boost > 0',
activeOrMine(),
scheduledOrMine(me),
subClause(sub, 1, 'Item', me, showNsfw),
muteClause(me))}
ORDER BY boost desc, "Item".created_at ASC
Expand Down Expand Up @@ -256,6 +258,12 @@ export const activeOrMine = (me) => {
export const muteClause = me =>
me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : ''

export const scheduledOrMine = (me) => {
return me
? `("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= now() OR "Item"."userId" = ${me.id})`
: '("Item"."scheduledAt" IS NULL OR "Item"."scheduledAt" <= now())'
}

const HIDE_NSFW_CLAUSE = '("Sub"."nsfw" = FALSE OR "Sub"."nsfw" IS NULL)'

export const nsfwClause = showNsfw => showNsfw ? '' : HIDE_NSFW_CLAUSE
Expand Down Expand Up @@ -411,6 +419,7 @@ export default {
${whereClause(
`"${table}"."userId" = $3`,
activeOrMine(me),
scheduledOrMine(me),
nsfwClause(showNsfw),
typeClause(type),
by === 'boost' && '"Item".boost > 0',
Expand All @@ -433,14 +442,15 @@ export default {
'"Item"."deletedAt" IS NULL',
subClause(sub, 4, subClauseTable(type), me, showNsfw),
activeOrMine(me),
scheduledOrMine(me),
await filterClause(me, models, type),
typeClause(type),
muteClause(me)
)}
ORDER BY COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC
ORDER BY COALESCE("Item"."scheduledAt", "Item"."invoicePaidAt", "Item".created_at) DESC
OFFSET $2
LIMIT $3`,
orderBy: 'ORDER BY COALESCE("Item"."invoicePaidAt", "Item".created_at) DESC'
orderBy: 'ORDER BY COALESCE("Item"."scheduledAt", "Item"."invoicePaidAt", "Item".created_at) DESC'
}, decodedCursor.time, decodedCursor.offset, limit, ...subArr)
break
case 'top':
Expand All @@ -457,6 +467,7 @@ export default {
typeClause(type),
whenClause(when, 'Item'),
activeOrMine(me),
scheduledOrMine(me),
await filterClause(me, models, type),
by === 'boost' && '"Item".boost > 0',
muteClause(me))}
Expand Down Expand Up @@ -484,6 +495,7 @@ export default {
typeClause(type),
await filterClause(me, models, type),
activeOrMine(me),
scheduledOrMine(me),
muteClause(me))}
${orderByClause('random', me, models, type)}
OFFSET $1
Expand Down Expand Up @@ -513,6 +525,7 @@ export default {
'"parentId" IS NULL',
'"Item"."deletedAt" IS NULL',
activeOrMine(me),
scheduledOrMine(me),
'created_at <= $1',
'"pinId" IS NULL',
subClause(sub, 4)
Expand Down Expand Up @@ -543,6 +556,7 @@ export default {
'"pinId" IS NOT NULL',
'"parentId" IS NULL',
sub ? '"subName" = $1' : '"subName" IS NULL',
scheduledOrMine(me),
muteClause(me))}
) rank_filter WHERE RANK = 1
ORDER BY position ASC`,
Expand All @@ -569,6 +583,7 @@ export default {
'"Item".bio = false',
ad ? `"Item".id <> ${ad.id}` : '',
activeOrMine(me),
scheduledOrMine(me),
await filterClause(me, models, type),
subClause(sub, 3, 'Item', me, showNsfw),
muteClause(me))}
Expand Down Expand Up @@ -653,6 +668,7 @@ export default {
${SELECT}
FROM "Item"
WHERE url ~* $1 AND ("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID')
AND (${scheduledOrMine(me)})
ORDER BY created_at DESC
LIMIT 3`
}, similar)
Expand Down Expand Up @@ -733,6 +749,36 @@ export default {
homeMaxBoost: homeAgg._max.boost || 0,
subMaxBoost: subAgg?._max.boost || 0
}
},
scheduledItems: async (parent, { cursor, limit = LIMIT }, { me, models }) => {
Copy link
Member

Choose a reason for hiding this comment

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

I don't think you used this anywhere. It was a good direction though, it would've been nice to access a list of my scheduled posts.

note: I didn't look much into this query

if (!me) {
throw new GqlAuthenticationError()
}

const decodedCursor = decodeCursor(cursor)

const items = await itemQueryWithMeta({
me,
models,
query: `
${SELECT}, trim(both ' ' from
coalesce(ltree2text(subpath("path", 0, -1)), '')) AS "ancestorTitles"
FROM "Item"
WHERE "userId" = $1 AND "scheduledAt" IS NOT NULL AND "deletedAt" IS NULL
AND ("invoiceActionState" IS NULL OR "invoiceActionState" = 'PAID')
AND created_at <= $2::timestamp
ORDER BY "scheduledAt" ASC
OFFSET $3
LIMIT $4`,
orderBy: ''
}, me.id, decodedCursor.time, decodedCursor.offset, limit)

return {
cursor: items.length === limit ? nextCursorEncoded(decodedCursor, limit) : null,
items,
pins: [],
ad: null
}
}
},

Expand Down Expand Up @@ -1357,7 +1403,8 @@ export default {
FROM "Item"
${whereClause(
'"Item".id = $1',
`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'${me ? ` OR "Item"."userId" = ${me.id}` : ''})`
`("Item"."invoiceActionState" IS NULL OR "Item"."invoiceActionState" = 'PAID'${me ? ` OR "Item"."userId" = ${me.id}` : ''})`,
scheduledOrMine(me)
)}`
}, Number(item.rootId))

Expand Down Expand Up @@ -1416,6 +1463,20 @@ export default {
AND data->>'userId' = ${meId}::TEXT
AND state = 'created'`
return reminderJobs[0]?.startafter ?? null
},
scheduledAt: async (item, args, { me, models }) => {
const meId = me?.id ?? USER_ID.anon
if (meId !== item.userId) {
// Only show scheduledAt for published scheduled posts (scheduledAt <= now)
// For unpublished scheduled posts, only show to owner
if (!item.scheduledAt || item.scheduledAt > new Date()) {
return null
}
}
return item.scheduledAt
},
isScheduled: async (item, args, { me, models }) => {
return !!item.scheduledAt
Comment on lines +1478 to +1479
Copy link
Member

Choose a reason for hiding this comment

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

I think you forgot isScheduled around the code, as you can now just do !!item.scheduledAt when you need it.

}
}
}
Expand Down
8 changes: 5 additions & 3 deletions api/resolvers/search.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { decodeCursor, LIMIT, nextCursorEncoded } from '@/lib/cursor'
import { whenToFrom } from '@/lib/time'
import { getItem, itemQueryWithMeta, SELECT } from './item'
import { getItem, itemQueryWithMeta, SELECT, scheduledOrMine } from './item'
import { parse } from 'tldts'

function queryParts (q) {
Expand Down Expand Up @@ -163,7 +163,8 @@ export default {
WITH r(id, rank) AS (VALUES ${values})
${SELECT}, rank
FROM "Item"
JOIN r ON "Item".id = r.id`,
JOIN r ON "Item".id = r.id
WHERE ${scheduledOrMine(me)}`,
orderBy: 'ORDER BY rank ASC'
})

Expand Down Expand Up @@ -501,7 +502,8 @@ export default {
WITH r(id, rank) AS (VALUES ${values})
${SELECT}, rank
FROM "Item"
JOIN r ON "Item".id = r.id`,
JOIN r ON "Item".id = r.id
WHERE ${scheduledOrMine(me)}`,
orderBy: 'ORDER BY rank ASC, msats DESC'
})).map((item, i) => {
const e = sitems.body.hits.hits[i]
Expand Down
3 changes: 3 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!
scheduledItems(cursor: String, limit: Limit): Items
}

type BoostPositions {
Expand Down Expand Up @@ -112,6 +113,8 @@ export default gql`
deletedAt: Date
deleteScheduledAt: Date
reminderScheduledAt: Date
scheduledAt: Date
isScheduled: Boolean!
title: String
searchTitle: String
url: String
Expand Down
4 changes: 2 additions & 2 deletions components/item-info.js
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,8 @@ export default function ItemInfo ({
{embellishUser}
</Link>}
<span> </span>
<Link href={`/items/${item.id}`} title={item.invoicePaidAt || item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.invoicePaidAt || item.createdAt))}
<Link href={`/items/${item.id}`} title={item.scheduledAt || item.invoicePaidAt || item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.scheduledAt || item.invoicePaidAt || item.createdAt))}
Comment on lines +141 to +142
Copy link
Member

Choose a reason for hiding this comment

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

A nitpick: I think it would be better UX if the countdown says "in 5m" rather than "5m". Or something else that signals that it's a scheduled, not-yet-live post.

</Link>
{item.prior &&
<>
Expand Down
4 changes: 2 additions & 2 deletions components/item-job.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,8 @@ export default function ItemJob ({ item, toc, rank, children, disableRetry, setD
@{item.user.name}<Badges badgeClassName='fill-grey' height={12} width={12} user={item.user} />
</Link>
<span> </span>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.createdAt))}
<Link href={`/items/${item.id}`} title={item.scheduledAt || item.invoicePaidAt || item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.scheduledAt || item.invoicePaidAt || item.createdAt))}
</Link>
</span>
{item.subName &&
Expand Down
1 change: 1 addition & 0 deletions fragments/items.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const ITEM_FIELDS = gql`
parentId
createdAt
invoicePaidAt
scheduledAt
deletedAt
title
url
Expand Down
14 changes: 14 additions & 0 deletions fragments/paidAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const ITEM_PAID_ACTION_FIELDS = gql`
id
deleteScheduledAt
reminderScheduledAt
scheduledAt
isScheduled
...CommentFields
comments {
comments {
Expand All @@ -39,6 +41,8 @@ const ITEM_PAID_ACTION_FIELDS_NO_CHILD_COMMENTS = gql`
id
deleteScheduledAt
reminderScheduledAt
scheduledAt
isScheduled
...CommentFields
}
}
Expand Down Expand Up @@ -151,6 +155,8 @@ export const UPSERT_DISCUSSION = gql`
id
deleteScheduledAt
reminderScheduledAt
scheduledAt
isScheduled
}
...PaidActionFields
}
Expand All @@ -168,6 +174,8 @@ export const UPSERT_JOB = gql`
id
deleteScheduledAt
reminderScheduledAt
scheduledAt
isScheduled
}
...PaidActionFields
}
Expand All @@ -183,6 +191,8 @@ export const UPSERT_LINK = gql`
id
deleteScheduledAt
reminderScheduledAt
scheduledAt
isScheduled
}
...PaidActionFields
}
Expand All @@ -200,6 +210,8 @@ export const UPSERT_POLL = gql`
id
deleteScheduledAt
reminderScheduledAt
scheduledAt
isScheduled
}
...PaidActionFields
}
Expand All @@ -215,6 +227,8 @@ export const UPSERT_BOUNTY = gql`
id
deleteScheduledAt
reminderScheduledAt
scheduledAt
isScheduled
}
...PaidActionFields
}
Expand Down
6 changes: 6 additions & 0 deletions lib/apollo.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,12 @@ function getClient (uri) {

return reactiveVar()
}
},
// Ensure scheduled post state changes are properly reflected in cache
scheduledAt: {
merge (existing, incoming) {
return incoming
}
}
}
}
Expand Down
24 changes: 24 additions & 0 deletions lib/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@ const reminderPattern = /\B@remindme\s+in\s+(\d+)\s+(second|minute|hour|day|week

const reminderMentionPattern = /\B@remindme/i

const schedulePattern = /\B@schedule\s+in\s+(\d+)\s+(second|minute|hour|day|week|month|year)s?/gi

const scheduleMentionPattern = /\B@schedule/i

export const hasDeleteMention = (text) => deleteMentionPattern.test(text ?? '')

export const hasScheduleMention = (text) => scheduleMentionPattern.test(text ?? '')

Comment on lines +30 to +31
Copy link
Member

Choose a reason for hiding this comment

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

Did you want to add this to lib/form.js to toast a successful scheduling?

export const getDeleteCommand = (text) => {
if (!text) return false
const matches = [...text.matchAll(deletePattern)]
Expand Down Expand Up @@ -61,6 +67,24 @@ export const getReminderCommand = (text) => {

export const hasReminderCommand = (text) => !!getReminderCommand(text)

export const getScheduleCommand = (text) => {
if (!text) return false
const matches = [...text.matchAll(schedulePattern)]
const commands = matches?.map(match => ({ number: parseInt(match[1]), unit: match[2] }))
return commands.length ? commands[commands.length - 1] : undefined
}

export const getScheduleAt = (text) => {
const command = getScheduleCommand(text)
if (command) {
const { number, unit } = command
return datePivot(new Date(), { [`${unit}s`]: number })
}
return null
}

export const hasScheduleCommand = (text) => !!getScheduleCommand(text)

export const deleteItemByAuthor = async ({ models, id, item }) => {
if (!item) {
item = await models.item.findUnique({ where: { id: Number(id) } })
Expand Down
Loading