Skip to content
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