From 0c251ca376dfb7c2d699e1cd2c74276c78576600 Mon Sep 17 00:00:00 2001 From: ekzyis <27162016+ekzyis@users.noreply.github.com> Date: Thu, 1 Jun 2023 02:44:06 +0200 Subject: [PATCH] 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 Co-authored-by: keyan --- api/resolvers/item.js | 27 ++- api/resolvers/notifications.js | 228 +++++++++--------- api/resolvers/user.js | 19 +- api/typeDefs/item.js | 2 + components/item-info.js | 2 + components/subscribe.js | 30 +++ fragments/comments.js | 1 + fragments/items.js | 1 + .../migration.sql | 17 ++ .../migration.sql | 38 +++ prisma/schema.prisma | 37 ++- 11 files changed, 274 insertions(+), 128 deletions(-) create mode 100644 components/subscribe.js create mode 100644 prisma/migrations/20230531043651_thread_subscription/migration.sql create mode 100644 prisma/migrations/20230601003219_comments_with_me_subscription/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 91f6872b..382e449a 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -173,12 +173,14 @@ async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args) } else { return await models.$queryRaw(` 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 ( ${query} ) "Item" JOIN users ON "Item"."userId" = users.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 ( SELECT "itemId", sum("ItemAct".msats) FILTER (WHERE act = 'FEE' OR act = 'TIP') AS "meMsats", bool_or(act = 'DONT_LIKE_THIS') AS "meDontLike" @@ -719,6 +721,14 @@ export default { } else await models.bookmark.create({ data }) 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 }) => { const old = await models.item.findUnique({ where: { id: Number(id) } }) if (Number(old.userId) !== Number(me?.id)) { @@ -1067,6 +1077,21 @@ export default { 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 }) => { if (me && Number(item.userId) === Number(me.id)) { return false diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 3ef9d8b3..e7295d87 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -69,127 +69,129 @@ export default { const queries = [] - if (inc === 'replies') { - 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)}` - ) - } 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 + queries.push( + `(SELECT DISTINCT "Item".id::TEXT, "Item".created_at AS "sortTime", NULL::BIGINT as "earnedSats", + 'Reply' AS type FROM "Item" - WHERE "Item"."userId" = $1 - AND "maxBid" IS NOT NULL - AND "statusUpdatedAt" <= $2 AND "statusUpdatedAt" <> created_at + 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)` + ) + + // 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 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 - LIMIT ${LIMIT}+$3)` - ) - } + if (meFull.noteMentions) { + queries.push( + `(SELECT "Item".id::TEXT, "Mention".created_at AS "sortTime", NULL as "earnedSats", + 'Mention' AS type + FROM "Mention" + JOIN "Item" ON "Mention"."itemId" = "Item".id + LEFT JOIN "Item" p ON "Item"."parentId" = p.id + 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.noteMentions) { - queries.push( - `(SELECT "Item".id::TEXT, "Mention".created_at AS "sortTime", NULL as "earnedSats", - 'Mention' AS type - FROM "Mention" - JOIN "Item" ON "Mention"."itemId" = "Item".id - LEFT JOIN "Item" p ON "Item"."parentId" = p.id - 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 + 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 - GROUP BY "userId", created_at` - ) - } + ORDER BY "sortTime" DESC + LIMIT ${LIMIT}+$3)` + ) + } - 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` - ) - } + 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 + 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 diff --git a/api/resolvers/user.js b/api/resolvers/user.js index f384deb5..d77480b5 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -291,8 +291,8 @@ export default { JOIN "Item" p ON "Item".created_at >= p.created_at AND ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} - AND "Item"."userId" <> p."userId" - WHERE p."userId" = $1 + AND "Item"."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 ${await filterClause(me, models)} LIMIT 1`, me.id, lastChecked) @@ -300,6 +300,21 @@ export default { 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 if (user.noteMentions) { const newMentions = await models.$queryRaw(` diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index fc3b5ab4..665df122 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -34,6 +34,7 @@ export default gql` extend type Mutation { bookmarkItem(id: ID): Item + subscribeItem(id: ID): Item deleteItem(id: ID): 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! @@ -102,6 +103,7 @@ export default gql` meSats: Int! meDontLike: Boolean! meBookmark: Boolean! + meSubscription: Boolean! outlawed: Boolean! freebie: Boolean! paidImgLink: Boolean diff --git a/components/item-info.js b/components/item-info.js index 3b168bb2..588afe63 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -13,6 +13,7 @@ import { useMe } from './me' import MoreIcon from '../svgs/more-fill.svg' import DontLikeThisDropdownItem from './dont-link-this' import BookmarkDropdownItem from './bookmark' +import SubscribeDropdownItem from './subscribe' import { CopyLinkDropdownItem } from './share' 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 {me && } + {me && item.user.id !== me.id && } {item.otsHash && diff --git a/components/subscribe.js b/components/subscribe.js new file mode 100644 index 00000000..dde93323 --- /dev/null +++ b/components/subscribe.js @@ -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 ( + subscribeItem({ variables: { id } })} + > + {meSubscription ? 'remove subscription' : 'subscribe'} + + ) +} diff --git a/fragments/comments.js b/fragments/comments.js index a2d97eb0..e6dfef2d 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -19,6 +19,7 @@ export const COMMENT_FIELDS = gql` meSats meDontLike meBookmark + meSubscription outlawed freebie path diff --git a/fragments/items.js b/fragments/items.js index 441a7d73..825bfed0 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -27,6 +27,7 @@ export const ITEM_FIELDS = gql` meSats meDontLike meBookmark + meSubscription outlawed freebie ncomments diff --git a/prisma/migrations/20230531043651_thread_subscription/migration.sql b/prisma/migrations/20230531043651_thread_subscription/migration.sql new file mode 100644 index 00000000..10ddfe5c --- /dev/null +++ b/prisma/migrations/20230531043651_thread_subscription/migration.sql @@ -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"); \ No newline at end of file diff --git a/prisma/migrations/20230601003219_comments_with_me_subscription/migration.sql b/prisma/migrations/20230601003219_comments_with_me_subscription/migration.sql new file mode 100644 index 00000000..9d411107 --- /dev/null +++ b/prisma/migrations/20230601003219_comments_with_me_subscription/migration.sql @@ -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 +$$; \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 07878cd3..3f6972cd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -85,14 +85,15 @@ model User { wildWestMode Boolean @default(false) greeterMode Boolean @default(false) - Earn Earn[] - Upload Upload[] @relation(name: "Uploads") - PollVote PollVote[] - Donation Donation[] - ReferralAct ReferralAct[] - Streak Streak[] - Bookmarks Bookmark[] - Subscriptions Subscription[] + Earn Earn[] + Upload Upload[] @relation(name: "Uploads") + PollVote PollVote[] + Donation Donation[] + ReferralAct ReferralAct[] + Streak Streak[] + Bookmarks Bookmark[] + Subscriptions Subscription[] + ThreadSubscriptions ThreadSubscription[] @@index([createdAt]) @@index([inviteId]) @@ -300,10 +301,11 @@ model Item { // fields for polls pollCost Int? - User User[] - PollOption PollOption[] - PollVote PollVote[] - Bookmark Bookmark[] + User User[] + PollOption PollOption[] + PollVote PollVote[] + Bookmark Bookmark[] + ThreadSubscription ThreadSubscription[] @@index([weightedVotes]) @@index([weightedDownVotes]) @@ -561,3 +563,14 @@ model Bookmark { @@id([userId, itemId]) @@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]) +} \ No newline at end of file