Add thread subscriptions (#293)

* Add thread subscriptions

* remove dead code: reply only notifications

* break out thread subscription queries to reduce search space

* one db dip for item lists/threads re:meSubscription

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
This commit is contained in:
ekzyis 2023-06-01 02:44:06 +02:00 committed by GitHub
parent e97509eea7
commit 0c251ca376
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 274 additions and 128 deletions

View File

@ -173,12 +173,14 @@ async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args)
} else { } else {
return await models.$queryRaw(` return await models.$queryRaw(`
SELECT "Item".*, to_json(users.*) as user, COALESCE("ItemAct"."meMsats", 0) as "meMsats", SELECT "Item".*, to_json(users.*) as user, COALESCE("ItemAct"."meMsats", 0) as "meMsats",
COALESCE("ItemAct"."meDontLike", false) as "meDontLike", "Bookmark"."itemId" IS NOT NULL AS "meBookmark" COALESCE("ItemAct"."meDontLike", false) as "meDontLike", "Bookmark"."itemId" IS NOT NULL AS "meBookmark",
"ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription"
FROM ( FROM (
${query} ${query}
) "Item" ) "Item"
JOIN users ON "Item"."userId" = users.id JOIN users ON "Item"."userId" = users.id
LEFT JOIN "Bookmark" ON "Bookmark"."itemId" = "Item".id AND "Bookmark"."userId" = ${me.id} LEFT JOIN "Bookmark" ON "Bookmark"."itemId" = "Item".id AND "Bookmark"."userId" = ${me.id}
LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."itemId" = "Item".id AND "ThreadSubscription"."userId" = ${me.id}
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats", SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats",
bool_or(act = 'DONT_LIKE_THIS') AS "meDontLike" bool_or(act = 'DONT_LIKE_THIS') AS "meDontLike"
@ -719,6 +721,14 @@ export default {
} else await models.bookmark.create({ data }) } else await models.bookmark.create({ data })
return { id } return { id }
}, },
subscribeItem: async (parent, { id }, { me, models }) => {
const data = { itemId: Number(id), userId: me.id }
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })
if (old) {
await models.threadSubscription.delete({ where: { userId_itemId: data } })
} else await models.threadSubscription.create({ data })
return { id }
},
deleteItem: async (parent, { id }, { me, models }) => { deleteItem: async (parent, { id }, { me, models }) => {
const old = await models.item.findUnique({ where: { id: Number(id) } }) const old = await models.item.findUnique({ where: { id: Number(id) } })
if (Number(old.userId) !== Number(me?.id)) { if (Number(old.userId) !== Number(me?.id)) {
@ -1067,6 +1077,21 @@ export default {
return !!bookmark return !!bookmark
}, },
meSubscription: async (item, args, { me, models }) => {
if (!me) return false
if (typeof item.meSubscription === 'boolean') return item.meSubscription
const subscription = await models.threadSubscription.findUnique({
where: {
userId_itemId: {
itemId: Number(item.id),
userId: me.id
}
}
})
return !!subscription
},
outlawed: async (item, args, { me, models }) => { outlawed: async (item, args, { me, models }) => {
if (me && Number(item.userId) === Number(me.id)) { if (me && Number(item.userId) === Number(me.id)) {
return false return false

View File

@ -69,127 +69,129 @@ export default {
const queries = [] const queries = []
if (inc === 'replies') { queries.push(
queries.push( `(SELECT DISTINCT "Item".id::TEXT, "Item".created_at AS "sortTime", NULL::BIGINT as "earnedSats",
`SELECT DISTINCT "Item".id::TEXT, "Item".created_at AS "sortTime", NULL::BIGINT as "earnedSats", 'Reply' AS type
'Reply' AS type
FROM "Item"
JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE p."userId" = $1
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
${await filterClause(me, models)}`
)
} else {
queries.push(
`(SELECT DISTINCT "Item".id::TEXT, "Item".created_at AS "sortTime", NULL::BIGINT as "earnedSats",
'Reply' AS type
FROM "Item"
JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE p."userId" = $1
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
${await filterClause(me, models)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
queries.push(
`(SELECT "Item".id::text, "Item"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats",
'JobChanged' AS type
FROM "Item" FROM "Item"
WHERE "Item"."userId" = $1 JOIN "Item" p ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
AND "maxBid" IS NOT NULL WHERE p."userId" = $1 AND "Item"."userId" <> $1 AND "Item".created_at <= $2
AND "statusUpdatedAt" <= $2 AND "statusUpdatedAt" <> created_at ${await filterClause(me, models)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
// break out thread subscription to decrease the search space of the already expensive reply query
queries.push(
`(SELECT DISTINCT "Item".id::TEXT, "Item".created_at AS "sortTime", NULL::BIGINT as "earnedSats",
'Reply' AS type
FROM "ThreadSubscription"
JOIN "Item" p ON "ThreadSubscription"."itemId" = p.id
JOIN "Item" ON ${meFull.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE
"ThreadSubscription"."userId" = $1
AND "Item"."userId" <> $1 AND "Item".created_at <= $2
${await filterClause(me, models)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
queries.push(
`(SELECT "Item".id::text, "Item"."statusUpdatedAt" AS "sortTime", NULL as "earnedSats",
'JobChanged' AS type
FROM "Item"
WHERE "Item"."userId" = $1
AND "maxBid" IS NOT NULL
AND "statusUpdatedAt" <= $2 AND "statusUpdatedAt" <> created_at
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
if (meFull.noteItemSats) {
queries.push(
`(SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime",
MAX("Item".msats/1000) as "earnedSats", 'Votification' AS type
FROM "Item"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id
WHERE "ItemAct"."userId" <> $1
AND "ItemAct".created_at <= $2
AND "ItemAct".act IN ('TIP', 'FEE')
AND "Item"."userId" = $1
GROUP BY "Item".id
ORDER BY "sortTime" DESC ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)` LIMIT ${LIMIT}+$3)`
) )
}
if (meFull.noteItemSats) { if (meFull.noteMentions) {
queries.push( queries.push(
`(SELECT "Item".id::TEXT, MAX("ItemAct".created_at) AS "sortTime", `(SELECT "Item".id::TEXT, "Mention".created_at AS "sortTime", NULL as "earnedSats",
MAX("Item".msats/1000) as "earnedSats", 'Votification' AS type 'Mention' AS type
FROM "Item" FROM "Mention"
JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id JOIN "Item" ON "Mention"."itemId" = "Item".id
WHERE "ItemAct"."userId" <> $1 LEFT JOIN "Item" p ON "Item"."parentId" = p.id
AND "ItemAct".created_at <= $2 WHERE "Mention"."userId" = $1
AND "ItemAct".act IN ('TIP', 'FEE') AND "Mention".created_at <= $2
AND "Item"."userId" = $1 AND "Item"."userId" <> $1
GROUP BY "Item".id AND (p."userId" IS NULL OR p."userId" <> $1)
ORDER BY "sortTime" DESC ${await filterClause(me, models)}
LIMIT ${LIMIT}+$3)` ORDER BY "sortTime" DESC
) LIMIT ${LIMIT}+$3)`
} )
}
if (meFull.noteMentions) { if (meFull.noteDeposits) {
queries.push( queries.push(
`(SELECT "Item".id::TEXT, "Mention".created_at AS "sortTime", NULL as "earnedSats", `(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", FLOOR("msatsReceived" / 1000) as "earnedSats",
'Mention' AS type 'InvoicePaid' AS type
FROM "Mention" FROM "Invoice"
JOIN "Item" ON "Mention"."itemId" = "Item".id WHERE "Invoice"."userId" = $1
LEFT JOIN "Item" p ON "Item"."parentId" = p.id AND "confirmedAt" IS NOT NULL
WHERE "Mention"."userId" = $1
AND "Mention".created_at <= $2
AND "Item"."userId" <> $1
AND (p."userId" IS NULL OR p."userId" <> $1)
${await filterClause(me, models)}
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
}
if (meFull.noteDeposits) {
queries.push(
`(SELECT "Invoice".id::text, "Invoice"."confirmedAt" AS "sortTime", FLOOR("msatsReceived" / 1000) as "earnedSats",
'InvoicePaid' AS type
FROM "Invoice"
WHERE "Invoice"."userId" = $1
AND "confirmedAt" IS NOT NULL
AND created_at <= $2
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
}
if (meFull.noteInvites) {
queries.push(
`(SELECT "Invite".id, MAX(users.created_at) AS "sortTime", NULL as "earnedSats",
'Invitification' AS type
FROM users JOIN "Invite" on users."inviteId" = "Invite".id
WHERE "Invite"."userId" = $1
AND users.created_at <= $2
GROUP BY "Invite".id
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
queries.push(
`(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats",
'Referral' AS type
FROM users
WHERE "users"."referrerId" = $1
AND "inviteId" IS NULL
AND users.created_at <= $2
LIMIT ${LIMIT}+$3)`
)
}
if (meFull.noteEarning) {
queries.push(
`SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
'Earn' AS type
FROM "Earn"
WHERE "userId" = $1
AND created_at <= $2 AND created_at <= $2
GROUP BY "userId", created_at` ORDER BY "sortTime" DESC
) LIMIT ${LIMIT}+$3)`
} )
}
if (meFull.noteCowboyHat) { if (meFull.noteInvites) {
queries.push( queries.push(
`SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type `(SELECT "Invite".id, MAX(users.created_at) AS "sortTime", NULL as "earnedSats",
FROM "Streak" 'Invitification' AS type
WHERE "userId" = $1 FROM users JOIN "Invite" on users."inviteId" = "Invite".id
AND updated_at <= $2` WHERE "Invite"."userId" = $1
) AND users.created_at <= $2
} GROUP BY "Invite".id
ORDER BY "sortTime" DESC
LIMIT ${LIMIT}+$3)`
)
queries.push(
`(SELECT users.id::text, users.created_at AS "sortTime", NULL as "earnedSats",
'Referral' AS type
FROM users
WHERE "users"."referrerId" = $1
AND "inviteId" IS NULL
AND users.created_at <= $2
LIMIT ${LIMIT}+$3)`
)
}
if (meFull.noteEarning) {
queries.push(
`SELECT min(id)::text, created_at AS "sortTime", FLOOR(sum(msats) / 1000) as "earnedSats",
'Earn' AS type
FROM "Earn"
WHERE "userId" = $1
AND created_at <= $2
GROUP BY "userId", created_at`
)
}
if (meFull.noteCowboyHat) {
queries.push(
`SELECT id::text, updated_at AS "sortTime", 0 as "earnedSats", 'Streak' AS type
FROM "Streak"
WHERE "userId" = $1
AND updated_at <= $2`
)
} }
// we do all this crazy subquery stuff to make 'reward' islands // we do all this crazy subquery stuff to make 'reward' islands

View File

@ -291,8 +291,8 @@ export default {
JOIN "Item" p ON JOIN "Item" p ON
"Item".created_at >= p.created_at "Item".created_at >= p.created_at
AND ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} AND ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
AND "Item"."userId" <> p."userId" AND "Item"."userId" <> $1
WHERE p."userId" = $1 WHERE (p."userId" = $1 OR p.id = ANY(SELECT "itemId" FROM "ThreadSubscription" WHERE "userId" = $1))
AND "Item".created_at > $2::timestamp(3) without time zone AND "Item".created_at > $2::timestamp(3) without time zone
${await filterClause(me, models)} ${await filterClause(me, models)}
LIMIT 1`, me.id, lastChecked) LIMIT 1`, me.id, lastChecked)
@ -300,6 +300,21 @@ export default {
return true return true
} }
// break out thread subscription to decrease the search space of the already expensive reply query
const newtsubs = await models.$queryRaw(`
SELECT 1
FROM "ThreadSubscription"
JOIN "Item" p ON "ThreadSubscription"."itemId" = p.id
JOIN "Item" ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'}
WHERE
"ThreadSubscription"."userId" = $1
AND "Item".created_at > $2::timestamp(3) without time zone
${await filterClause(me, models)}
LIMIT 1`, me.id, lastChecked)
if (newtsubs.length > 0) {
return true
}
// check if they have any mentions since checkedNotesAt // check if they have any mentions since checkedNotesAt
if (user.noteMentions) { if (user.noteMentions) {
const newMentions = await models.$queryRaw(` const newMentions = await models.$queryRaw(`

View File

@ -34,6 +34,7 @@ export default gql`
extend type Mutation { extend type Mutation {
bookmarkItem(id: ID): Item bookmarkItem(id: ID): Item
subscribeItem(id: ID): Item
deleteItem(id: ID): Item deleteItem(id: ID): Item
upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String): Item! upsertLink(id: ID, sub: String, title: String!, url: String!, boost: Int, forward: String): Item!
upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String): Item! upsertDiscussion(id: ID, sub: String, title: String!, text: String, boost: Int, forward: String): Item!
@ -102,6 +103,7 @@ export default gql`
meSats: Int! meSats: Int!
meDontLike: Boolean! meDontLike: Boolean!
meBookmark: Boolean! meBookmark: Boolean!
meSubscription: Boolean!
outlawed: Boolean! outlawed: Boolean!
freebie: Boolean! freebie: Boolean!
paidImgLink: Boolean paidImgLink: Boolean

View File

@ -13,6 +13,7 @@ import { useMe } from './me'
import MoreIcon from '../svgs/more-fill.svg' import MoreIcon from '../svgs/more-fill.svg'
import DontLikeThisDropdownItem from './dont-link-this' import DontLikeThisDropdownItem from './dont-link-this'
import BookmarkDropdownItem from './bookmark' import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem } from './share' import { CopyLinkDropdownItem } from './share'
export default function ItemInfo ({ item, full, commentsText, className, embellishUser, extraInfo, onEdit, editText }) { export default function ItemInfo ({ item, full, commentsText, className, embellishUser, extraInfo, onEdit, editText }) {
@ -98,6 +99,7 @@ export default function ItemInfo ({ item, full, commentsText, className, embelli
<ItemDropdown> <ItemDropdown>
<CopyLinkDropdownItem item={item} /> <CopyLinkDropdownItem item={item} />
{me && <BookmarkDropdownItem item={item} />} {me && <BookmarkDropdownItem item={item} />}
{me && item.user.id !== me.id && <SubscribeDropdownItem item={item} />}
{item.otsHash && {item.otsHash &&
<Dropdown.Item> <Dropdown.Item>
<Link passHref href={`/items/${item.id}/ots`}> <Link passHref href={`/items/${item.id}/ots`}>

30
components/subscribe.js Normal file
View File

@ -0,0 +1,30 @@
import { useMutation } from '@apollo/client'
import { gql } from 'apollo-server-micro'
import { Dropdown } from 'react-bootstrap'
export default function SubscribeDropdownItem ({ item: { id, meSubscription } }) {
const [subscribeItem] = useMutation(
gql`
mutation subscribeItem($id: ID!) {
subscribeItem(id: $id) {
meSubscription
}
}`, {
update (cache, { data: { subscribeItem } }) {
cache.modify({
id: `Item:${id}`,
fields: {
meSubscription: () => subscribeItem.meSubscription
}
})
}
}
)
return (
<Dropdown.Item
onClick={() => subscribeItem({ variables: { id } })}
>
{meSubscription ? 'remove subscription' : 'subscribe'}
</Dropdown.Item>
)
}

View File

@ -19,6 +19,7 @@ export const COMMENT_FIELDS = gql`
meSats meSats
meDontLike meDontLike
meBookmark meBookmark
meSubscription
outlawed outlawed
freebie freebie
path path

View File

@ -27,6 +27,7 @@ export const ITEM_FIELDS = gql`
meSats meSats
meDontLike meDontLike
meBookmark meBookmark
meSubscription
outlawed outlawed
freebie freebie
ncomments ncomments

View File

@ -0,0 +1,17 @@
-- CreateTable
CREATE TABLE "ThreadSubscription" (
"userId" INTEGER NOT NULL,
"itemId" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId","itemId")
);
-- AddForeignKey
ALTER TABLE "ThreadSubscription" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ThreadSubscription" ADD FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateIndex
CREATE INDEX "ThreadSubscription.created_at_index" ON "ThreadSubscription"("created_at");

View File

@ -0,0 +1,38 @@
CREATE OR REPLACE FUNCTION item_comments_with_me(_item_id int, _me_id int, _level int, _where text, _order_by text)
RETURNS jsonb
LANGUAGE plpgsql STABLE PARALLEL SAFE AS
$$
DECLARE
result jsonb;
BEGIN
IF _level < 1 THEN
RETURN '[]'::jsonb;
END IF;
EXECUTE ''
|| 'SELECT COALESCE(jsonb_agg(sub), ''[]''::jsonb) AS comments '
|| 'FROM ( '
|| ' SELECT "Item".*, "Item".created_at at time zone ''UTC'' AS "createdAt", "Item".updated_at at time zone ''UTC'' AS "updatedAt", '
|| ' item_comments_with_me("Item".id, $5, $2 - 1, $3, $4) AS comments, to_jsonb(users.*) as user, '
|| ' COALESCE("ItemAct"."meMsats", 0) AS "meMsats", COALESCE("ItemAct"."meDontLike", false) AS "meDontLike", '
|| ' "Bookmark"."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription" '
|| ' FROM "Item" p '
|| ' JOIN "Item" ON "Item"."parentId" = p.id '
|| ' JOIN users ON users.id = "Item"."userId" '
|| ' LEFT JOIN "Bookmark" ON "Bookmark"."itemId" = "Item".id AND "Bookmark"."userId" = $5 '
|| ' LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."itemId" = "Item".id AND "ThreadSubscription"."userId" = $5 '
|| ' LEFT JOIN LATERAL ( '
|| ' SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = ''FEE'' OR act = ''TIP'') AS "meMsats", '
|| ' bool_or(act = ''DONT_LIKE_THIS'') AS "meDontLike" '
|| ' FROM "ItemAct" '
|| ' WHERE "ItemAct"."userId" = $5 '
|| ' AND "ItemAct"."itemId" = "Item".id '
|| ' GROUP BY "ItemAct"."itemId" '
|| ' ) "ItemAct" ON true '
|| ' WHERE p.id = $1 ' || _where || ' '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _level, _where, _order_by, _me_id;
RETURN result;
END
$$;

View File

@ -85,14 +85,15 @@ model User {
wildWestMode Boolean @default(false) wildWestMode Boolean @default(false)
greeterMode Boolean @default(false) greeterMode Boolean @default(false)
Earn Earn[] Earn Earn[]
Upload Upload[] @relation(name: "Uploads") Upload Upload[] @relation(name: "Uploads")
PollVote PollVote[] PollVote PollVote[]
Donation Donation[] Donation Donation[]
ReferralAct ReferralAct[] ReferralAct ReferralAct[]
Streak Streak[] Streak Streak[]
Bookmarks Bookmark[] Bookmarks Bookmark[]
Subscriptions Subscription[] Subscriptions Subscription[]
ThreadSubscriptions ThreadSubscription[]
@@index([createdAt]) @@index([createdAt])
@@index([inviteId]) @@index([inviteId])
@ -300,10 +301,11 @@ model Item {
// fields for polls // fields for polls
pollCost Int? pollCost Int?
User User[] User User[]
PollOption PollOption[] PollOption PollOption[]
PollVote PollVote[] PollVote PollVote[]
Bookmark Bookmark[] Bookmark Bookmark[]
ThreadSubscription ThreadSubscription[]
@@index([weightedVotes]) @@index([weightedVotes])
@@index([weightedDownVotes]) @@index([weightedDownVotes])
@ -561,3 +563,14 @@ model Bookmark {
@@id([userId, itemId]) @@id([userId, itemId])
@@index([createdAt]) @@index([createdAt])
} }
model ThreadSubscription {
user User @relation(fields: [userId], references: [id])
userId Int
item Item @relation(fields: [itemId], references: [id])
itemId Int
createdAt DateTime @default(now()) @map(name: "created_at")
@@id([userId, itemId])
@@index([createdAt])
}