support pinned posts + recurring pins

This commit is contained in:
keyan 2022-01-07 10:32:31 -06:00
parent e950b0df7f
commit c3e6627cea
12 changed files with 189 additions and 16 deletions

View File

@ -75,7 +75,7 @@ export default {
Query: {
moreItems: async (parent, { sort, cursor, name, within }, { me, models }) => {
const decodedCursor = decodeCursor(cursor)
let items; let user
let items; let user; let pins
switch (sort) {
case 'user':
@ -92,6 +92,7 @@ export default {
${SELECT}
FROM "Item"
WHERE "userId" = $1 AND "parentId" IS NULL AND created_at <= $2
AND "pinId" IS NULL
ORDER BY created_at DESC
OFFSET $3
LIMIT ${LIMIT}`, user.id, decodedCursor.time, decodedCursor.offset)
@ -110,6 +111,7 @@ export default {
FROM "Item"
${timedLeftJoinSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1 AND created_at > $3
AND "pinId" IS NULL
${timedOrderBySats(1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset, new Date(new Date() - 7))
@ -121,10 +123,25 @@ export default {
FROM "Item"
${timedLeftJoinSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${timedOrderBySats(1)}
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
}
if (decodedCursor.offset === 0) {
// get pins for the page and return those separately
pins = await models.$queryRaw(`SELECT rank_filter.*
FROM (
${SELECT},
rank() OVER (
PARTITION BY "pinId"
ORDER BY created_at DESC
)
FROM "Item"
WHERE "pinId" IS NOT NULL
) rank_filter WHERE RANK = 1`)
}
break
case 'top':
items = await models.$queryRaw(`
@ -132,6 +149,7 @@ export default {
FROM "Item"
${timedLeftJoinSats(1)}
WHERE "parentId" IS NULL AND created_at <= $1
AND "pinId" IS NULL
${topClause(within)}
ORDER BY x.sats DESC NULLS LAST, created_at DESC
OFFSET $2
@ -141,7 +159,7 @@ export default {
items = await models.$queryRaw(`
${SELECT}
FROM "Item"
WHERE "parentId" IS NULL AND created_at <= $1
WHERE "parentId" IS NULL AND created_at <= $1 AND "pinId" IS NULL
ORDER BY created_at DESC
OFFSET $2
LIMIT ${LIMIT}`, decodedCursor.time, decodedCursor.offset)
@ -149,7 +167,8 @@ export default {
}
return {
cursor: items.length === LIMIT ? nextCursorEncoded(decodedCursor) : null,
items
items,
pins
}
},
moreFlatComments: async (parent, { cursor, name, sort, within }, { me, models }) => {
@ -367,6 +386,18 @@ export default {
},
Item: {
position: async (item, args, { models }) => {
if (!item.pinId) {
return null
}
const pin = await models.pin.findUnique({ where: { id: item.pinId } })
if (!pin) {
return null
}
return pin.position
},
user: async (item, args, { models }) =>
await models.user.findUnique({ where: { id: item.userId } }),
ncomments: async (item, args, { models }) => {
@ -591,7 +622,7 @@ function nestComments (flat, parentId) {
// we have to do our own query because ltree is unsupported
export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,
"Item".text, "Item".url, "Item"."userId", "Item"."parentId", ltree2text("Item"."path") AS "path"`
"Item".text, "Item".url, "Item"."userId", "Item"."parentId", "Item"."pinId", ltree2text("Item"."path") AS "path"`
const LEFT_JOIN_SATS_SELECT = 'SELECT i.id, SUM(CASE WHEN "ItemAct".act = \'VOTE\' THEN "ItemAct".sats ELSE 0 END) as sats, SUM(CASE WHEN "ItemAct".act = \'BOOST\' THEN "ItemAct".sats ELSE 0 END) as boost'

View File

@ -34,6 +34,7 @@ export default gql`
type Items {
cursor: String
items: [Item!]!
pins: [Item!]
}
type Comments {
@ -62,5 +63,6 @@ export default gql`
ncomments: Int!
comments: [Item!]!
path: String
position: Int
}
`

View File

@ -5,6 +5,7 @@ import UpVote from './upvote'
import { useEffect, useRef, useState } from 'react'
import Countdown from './countdown'
import { NOFOLLOW_LIMIT } from '../lib/constants'
import Pin from '../svgs/pin.svg'
export default function Item ({ item, rank, children }) {
const mine = item.mine
@ -29,7 +30,7 @@ export default function Item ({ item, rank, children }) {
</div>)
: <div />}
<div className={styles.item}>
<UpVote item={item} className={styles.upvote} />
{item.position ? <Pin width={24} height={24} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
<div className={styles.hunk}>
<div className={`${styles.main} flex-wrap ${wrap ? 'd-inline' : ''}`}>
<Link href={`/items/${item.id}`} passHref>
@ -47,8 +48,11 @@ export default function Item ({ item, rank, children }) {
</>}
</div>
<div className={`${styles.other}`}>
<span title={`${item.sats} upvotes \\ ${item.tips} tipped${item.meSats > 0 ? ` (${item.meSats} from me)` : ''}`}>{item.sats + item.tips} sats</span>
<span> \ </span>
{!item.position &&
<>
<span title={`${item.sats} upvotes \\ ${item.tips} tipped${item.meSats > 0 ? ` (${item.meSats} from me)` : ''}`}>{item.sats + item.tips} sats</span>
<span> \ </span>
</>}
{item.boost > 0 &&
<>
<span>{item.boost} boost</span>

View File

@ -16,6 +16,12 @@
flex: 1 0 128px;
}
.pin {
fill: var(--theme-body);
stroke: var(--theme-color);
margin-right: .2rem;
}
.linkSmall {
width: 128px;
display: inline-block;

View File

@ -3,8 +3,9 @@ import Item, { ItemSkeleton } from './item'
import styles from './items.module.css'
import { MORE_ITEMS } from '../fragments/items'
import MoreFooter from './more-footer'
import React from 'react'
export default function Items ({ variables, rank, items, cursor }) {
export default function Items ({ variables, rank, items, pins, cursor }) {
const { data, fetchMore } = useQuery(MORE_ITEMS, { variables })
if (!data && !items) {
@ -12,14 +13,19 @@ export default function Items ({ variables, rank, items, cursor }) {
}
if (data) {
({ moreItems: { items, cursor } } = data)
({ moreItems: { items, pins, cursor } } = data)
}
const pinMap = pins?.reduce((a, p) => { a[p.position] = p; return a }, {})
return (
<>
<div className={styles.grid}>
{items.map((item, i) => (
<Item item={item} rank={rank && i + 1} key={item.id} />
<React.Fragment key={item.id}>
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} key={pinMap[i + 1].id} />}
<Item item={item} rank={rank && i + 1} key={item.id} />
</React.Fragment>
))}
</div>
<MoreFooter

View File

@ -38,6 +38,10 @@ export const MORE_ITEMS = gql`
cursor
items {
...ItemFields
},
pins {
...ItemFields
position
}
}
}`
@ -68,6 +72,7 @@ export const ITEM_FULL = gql`
query Item($id: ID!) {
item(id: $id) {
...ItemFields
position
text
comments {
...CommentsRecursive

View File

@ -47,7 +47,8 @@ export default function getApolloClient () {
return {
cursor: incoming.cursor,
items: [...(existing?.items || []), ...incoming.items]
items: [...(existing?.items || []), ...incoming.items],
pins: existing?.pins
}
}
},

View File

@ -6,11 +6,11 @@ import { MORE_ITEMS } from '../fragments/items'
const variables = { sort: 'hot' }
export const getServerSideProps = getGetServerSideProps(MORE_ITEMS, variables)
export default function Index ({ data: { moreItems: { items, cursor } } }) {
export default function Index ({ data: { moreItems: { items, pins, cursor } } }) {
return (
<Layout>
<Items
items={items} cursor={cursor}
items={items} pins={pins} cursor={cursor}
variables={variables} rank
/>
</Layout>

View File

@ -0,0 +1,59 @@
-- AlterTable
ALTER TABLE "Item" ADD COLUMN "pinId" INTEGER,
ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
-- CreateTable
CREATE TABLE "Pin" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cron" TEXT,
"timezone" TEXT,
"position" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Item" ADD FOREIGN KEY ("pinId") REFERENCES "Pin"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- pin upserts add new pgboss.schedule
CREATE OR REPLACE FUNCTION pin_upsert_trigger_func() RETURNS TRIGGER AS $$
BEGIN
-- only schedule if pin has new.cron set
IF new.cron IS NOT NULL THEN
-- pgboss updates when inserts have the same name
INSERT INTO pgboss.schedule (name, cron, timezone)
VALUES ('repin-' || new.id, new.cron, new.timezone)
ON CONFLICT (name) DO UPDATE SET
cron = EXCLUDED.cron,
timezone = EXCLUDED.timezone,
data = EXCLUDED.data,
options = EXCLUDED.options,
updated_on = now();
-- if old.cron is set but new.cron isn't ... we need to delete the job
ELSIF old.cron IS NOT NULL AND new.cron IS NULL THEN
DELETE FROM pgboss.schedule where name = 'repin-' || new.id;
END IF;
RETURN new;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS pin_upsert_trigger ON "Pin";
CREATE TRIGGER pin_upsert_trigger
AFTER INSERT OR UPDATE ON "Pin"
FOR EACH ROW EXECUTE PROCEDURE pin_upsert_trigger_func();
-- pin delete removes from pgboss.schedule
CREATE OR REPLACE FUNCTION pin_delete_trigger_func() RETURNS TRIGGER AS $$
BEGIN
DELETE FROM pgboss.schedule where name = 'repin-' || old.id;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS pin_delete_trigger ON "Pin";
CREATE TRIGGER pin_delete_trigger
AFTER DELETE ON "Pin"
FOR EACH ROW EXECUTE PROCEDURE pin_delete_trigger_func();

View File

@ -83,7 +83,7 @@ model Message {
model Item {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
title String?
text String?
url String?
@ -95,13 +95,27 @@ model Item {
actions ItemAct[]
mentions Mention[]
path Unsupported("LTREE")?
pin Pin? @relation(fields: [pinId], references: [id])
pinId Int?
User User[] @relation("Item")
@@index([createdAt])
@@index([userId])
@@index([parentId])
}
// the active pin is the latest one when it's a recurring cron
model Pin {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at")
cron String?
timezone String?
position Int
Item Item[]
}
enum ItemActType {
VOTE
BOOST

5
svgs/pin.svg Normal file
View File

@ -0,0 +1,5 @@
<svg width="302" height="308" viewBox="0 0 302 308" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M129.115 160.364L142.992 173.981L50.3695 268.367L30.7354 274.38L36.4819 254.761L129.115 160.364Z" stroke-width="9"/>
<path d="M207.857 104.935C228.879 129.791 243.94 155.699 249.202 178.77C254.417 201.631 249.984 221.211 232.696 234.98C205.344 256.765 177.645 263.158 151.824 258.363C125.865 253.542 101.218 237.297 80.4255 212.713C59.7384 188.254 48.7432 160.128 48.9871 133.081C49.23 106.127 60.6273 79.8929 85.406 58.9359C102.378 44.5815 122.065 43.5273 143.128 52.3031C164.436 61.1806 186.812 80.0517 207.857 104.935Z" stroke-width="9"/>
<path d="M221.575 93.935C242.294 118.433 253.781 146.518 255.776 171.976C257.773 197.443 250.288 219.901 233.584 234.029C216.88 248.157 193.491 251.811 168.708 245.616C143.934 239.423 118.145 223.434 97.4255 198.936C76.706 174.439 65.2192 146.354 63.2235 120.895C61.2272 95.4283 68.7123 72.9704 85.4164 58.8426C102.12 44.7148 125.509 41.0605 150.292 47.2557C175.066 53.4488 200.855 69.4373 221.575 93.935Z" stroke-width="9"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -21,9 +21,49 @@ async function work () {
await boss.start()
await boss.work('checkInvoice', checkInvoice)
await boss.work('checkWithdrawal', checkWithdrawal)
await boss.work('repin-*', repin)
console.log('working jobs')
}
async function repin ({ name }) {
console.log(name)
// get the id
const id = name.slice('repin-'.length)
if (id.length === 0 || isNaN(id)) {
console.log('repin id not found in', name)
return
}
// get the latest item with this id
const pinId = Number(id)
const current = await models.item.findFirst(
{
where: {
pinId
},
orderBy: {
createdAt: 'desc'
}
}
)
if (!current) {
console.log('could not find existing item for', name)
return
}
// create a new item with matching 1) title, text, and url and 2) setting pinId
await models.item.create({
data: {
title: current.title,
text: current.text,
url: current.url,
userId: current.userId,
pinId
}
})
}
async function checkInvoice ({ data: { hash } }) {
const inv = await getInvoice({ id: hash, lnd })
console.log(inv)
@ -44,7 +84,7 @@ async function checkInvoice ({ data: { hash } }) {
}))
} else if (new Date(inv.expires_at) > new Date()) {
// not expired, recheck in 5 seconds
boss.send('checkInvoice', { hash }, walletOptions)
await boss.send('checkInvoice', { hash }, walletOptions)
}
}
@ -83,7 +123,7 @@ async function checkWithdrawal ({ data: { id, hash } }) {
SELECT reverse_withdrawl(${id}, ${status})`)
} else {
// we need to requeue to check again in 5 seconds
boss.send('checkWithdrawal', { id, hash }, walletOptions)
await boss.send('checkWithdrawal', { id, hash }, walletOptions)
}
}