-
-
Notifications
You must be signed in to change notification settings - Fork 131
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
base: master
Are you sure you want to change the base?
Changes from all commits
9ffc455
b97ca2e
7207cbf
c9a1283
51f9ab4
318a8c2
f5649dd
2aaa949
6d95cba
9dc9311
a23ad43
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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') { | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -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 | ||
|
@@ -411,6 +419,7 @@ export default { | |
${whereClause( | ||
`"${table}"."userId" = $3`, | ||
activeOrMine(me), | ||
scheduledOrMine(me), | ||
nsfwClause(showNsfw), | ||
typeClause(type), | ||
by === 'boost' && '"Item".boost > 0', | ||
|
@@ -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': | ||
|
@@ -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))} | ||
|
@@ -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 | ||
|
@@ -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) | ||
|
@@ -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`, | ||
|
@@ -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))} | ||
|
@@ -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) | ||
|
@@ -733,6 +749,36 @@ export default { | |
homeMaxBoost: homeAgg._max.boost || 0, | ||
subMaxBoost: subAgg?._max.boost || 0 | ||
} | ||
}, | ||
scheduledItems: async (parent, { cursor, limit = LIMIT }, { me, models }) => { | ||
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 | ||
} | ||
} | ||
}, | ||
|
||
|
@@ -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)) | ||
|
||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you forgot |
||
} | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 && | ||
<> | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,6 +19,7 @@ export const ITEM_FIELDS = gql` | |
parentId | ||
createdAt | ||
invoicePaidAt | ||
scheduledAt | ||
deletedAt | ||
title | ||
url | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you want to add this to |
||
export const getDeleteCommand = (text) => { | ||
if (!text) return false | ||
const matches = [...text.matchAll(deletePattern)] | ||
|
@@ -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) } }) | ||
|
There was a problem hiding this comment.
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