diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 8fff0491..8a02cf92 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -136,77 +136,6 @@ export async function joinSatRankView (me, models) { return 'JOIN zap_rank_tender_view ON "Item".id = zap_rank_tender_view.id' } -export async function filterClause (me, models, type) { - // if you are explicitly asking for marginal content, don't filter them - if (['outlawed', 'borderland', 'freebies'].includes(type)) { - if (me && ['outlawed', 'borderland'].includes(type)) { - // unless the item is mine - return ` AND "Item"."userId" <> ${me.id} ` - } - - return '' - } - - // by default don't include freebies unless they have upvotes - let clause = ' AND (NOT "Item".freebie OR "Item"."weightedVotes" - "Item"."weightedDownVotes" > 0' - if (me) { - const user = await models.user.findUnique({ where: { id: me.id } }) - // wild west mode has everything - if (user.wildWestMode) { - return '' - } - // greeter mode includes freebies if feebies haven't been flagged - if (user.greeterMode) { - clause = ' AND (NOT "Item".freebie OR ("Item"."weightedVotes" - "Item"."weightedDownVotes" >= 0 AND "Item".freebie)' - } - - // always include if it's mine - clause += ` OR "Item"."userId" = ${me.id})` - } else { - // close default freebie clause - clause += ')' - } - - // if the item is above the threshold or is mine - clause += ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` - if (me) { - clause += ` OR "Item"."userId" = ${me.id}` - } - clause += ')' - - return clause -} - -function typeClause (type) { - switch (type) { - case 'links': - return ' AND "Item".url IS NOT NULL AND "Item"."parentId" IS NULL' - case 'discussions': - return ' AND "Item".url IS NULL AND "Item".bio = false AND "Item"."pollCost" IS NULL AND "Item"."parentId" IS NULL' - case 'polls': - return ' AND "Item"."pollCost" IS NOT NULL AND "Item"."parentId" IS NULL' - case 'bios': - return ' AND "Item".bio = true AND "Item"."parentId" IS NULL' - case 'bounties': - return ' AND "Item".bounty IS NOT NULL AND "Item"."parentId" IS NULL' - case 'comments': - return ' AND "Item"."parentId" IS NOT NULL' - case 'freebies': - return ' AND "Item".freebie' - case 'outlawed': - return ` AND "Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}` - case 'borderland': - return ' AND "Item"."weightedVotes" - "Item"."weightedDownVotes" < 0 ' - case 'all': - case 'bookmarks': - return '' - case 'jobs': - return ' AND "Item"."subName" = \'jobs\'' - default: - return ' AND "Item"."parentId" IS NULL' - } -} - // this grabs all the stuff we need to display the item list and only // hits the db once ... orderBy needs to be duplicated on the outer query because // joining does not preserve the order of the inner query @@ -221,13 +150,15 @@ async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args) ${orderBy}`, ...args) } else { return await models.$queryRawUnsafe(` - SELECT "Item".*, to_json(users.*) as user, COALESCE("ItemAct"."meMsats", 0) as "meMsats", + SELECT "Item".*, to_jsonb(users.*) || jsonb_build_object('meMute', "Mute"."mutedId" IS NOT NULL) as user, + COALESCE("ItemAct"."meMsats", 0) as "meMsats", COALESCE("ItemAct"."meDontLike", false) as "meDontLike", b."itemId" IS NOT NULL AS "meBookmark", "ThreadSubscription"."itemId" IS NOT NULL AS "meSubscription", "ItemForward"."itemId" IS NOT NULL AS "meForward" FROM ( ${query} ) "Item" JOIN users ON "Item"."userId" = users.id + LEFT JOIN "Mute" ON "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId" LEFT JOIN "Bookmark" b ON b."itemId" = "Item".id AND b."userId" = ${me.id} LEFT JOIN "ThreadSubscription" ON "ThreadSubscription"."itemId" = "Item".id AND "ThreadSubscription"."userId" = ${me.id} LEFT JOIN "ItemForward" ON "ItemForward"."itemId" = "Item".id AND "ItemForward"."userId" = ${me.id} @@ -243,24 +174,26 @@ async function itemQueryWithMeta ({ me, models, query, orderBy = '' }, ...args) } } -const subClause = (sub, num, table, solo) => { - return sub ? ` ${solo ? 'WHERE' : 'AND'} ${table ? `"${table}".` : ''}"subName" = $${num} ` : '' -} - const relationClause = (type) => { + let clause = '' switch (type) { case 'comments': - return ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id ' + clause += ' FROM "Item" JOIN "Item" root ON "Item"."rootId" = root.id ' + break case 'bookmarks': - return ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" ' + clause += ' FROM "Item" JOIN "Bookmark" ON "Bookmark"."itemId" = "Item"."id" ' + break case 'outlawed': case 'borderland': case 'freebies': case 'all': - return ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id ' + clause += ' FROM "Item" LEFT JOIN "Item" root ON "Item"."rootId" = root.id ' + break default: - return ' FROM "Item" ' + clause += ' FROM "Item" ' } + + return clause } const selectClause = (type) => type === 'bookmarks' @@ -269,8 +202,91 @@ const selectClause = (type) => type === 'bookmarks' const subClauseTable = (type) => COMMENT_TYPE_QUERY.includes(type) ? 'root' : 'Item' +export const whereClause = (...clauses) => { + const clause = clauses.flat(Infinity).filter(c => c).join(' AND ') + return clause ? ` WHERE ${clause} ` : '' +} + const activeOrMine = (me) => { - return me ? ` AND ("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id}) ` : ' AND "Item".status <> \'STOPPED\' ' + return me ? `("Item".status <> 'STOPPED' OR "Item"."userId" = ${me.id})` : '"Item".status <> \'STOPPED\'' +} + +export const muteClause = me => + me ? `NOT EXISTS (SELECT 1 FROM "Mute" WHERE "Mute"."muterId" = ${me.id} AND "Mute"."mutedId" = "Item"."userId")` : '' + +const subClause = (sub, num, table) => { + return sub ? `${table ? `"${table}".` : ''}"subName" = $${num}` : '' +} + +export async function filterClause (me, models, type) { + // if you are explicitly asking for marginal content, don't filter them + if (['outlawed', 'borderland', 'freebies'].includes(type)) { + if (me && ['outlawed', 'borderland'].includes(type)) { + // unless the item is mine + return `"Item"."userId" <> ${me.id}` + } + + return '' + } + + // handle freebies + // by default don't include freebies unless they have upvotes + let freebieClauses = ['NOT "Item".freebie', '"Item"."weightedVotes" - "Item"."weightedDownVotes" > 0'] + if (me) { + const user = await models.user.findUnique({ where: { id: me.id } }) + // wild west mode has everything + if (user.wildWestMode) { + return '' + } + // greeter mode includes freebies if feebies haven't been flagged + if (user.greeterMode) { + freebieClauses = ['NOT "Item".freebie', '"Item"."weightedVotes" - "Item"."weightedDownVotes" >= 0'] + } + + // always include if it's mine + freebieClauses.push(`"Item"."userId" = ${me.id}`) + } + const freebieClause = '(' + freebieClauses.join(' OR ') + ')' + + // handle outlawed + // if the item is above the threshold or is mine + const outlawClauses = [`"Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`] + if (me) { + outlawClauses.push(`"Item"."userId" = ${me.id}`) + } + const outlawClause = '(' + outlawClauses.join(' OR ') + ')' + + return [freebieClause, outlawClause] +} + +function typeClause (type) { + switch (type) { + case 'links': + return ['"Item".url IS NOT NULL', '"Item"."parentId" IS NULL'] + case 'discussions': + return ['"Item".url IS NULL', '"Item".bio = false', '"Item"."pollCost" IS NULL', '"Item"."parentId" IS NULL'] + case 'polls': + return ['"Item"."pollCost" IS NOT NULL', '"Item"."parentId" IS NULL'] + case 'bios': + return ['"Item".bio = true', '"Item"."parentId" IS NULL'] + case 'bounties': + return ['"Item".bounty IS NOT NULL', '"Item"."parentId" IS NULL'] + case 'comments': + return '"Item"."parentId" IS NOT NULL' + case 'freebies': + return '"Item".freebie' + case 'outlawed': + return `"Item"."weightedVotes" - "Item"."weightedDownVotes" <= -${ITEM_FILTER_THRESHOLD}` + case 'borderland': + return '"Item"."weightedVotes" - "Item"."weightedDownVotes" < 0' + case 'all': + case 'bookmarks': + return '' + case 'jobs': + return '"Item"."subName" = \'jobs\'' + default: + return '"Item"."parentId" IS NULL' + } } export default { @@ -324,12 +340,14 @@ export default { query: ` ${selectClause(type)} ${relationClause(type)} - WHERE "${table}"."userId" = $2 AND "${table}".created_at <= $1 - ${subClause(sub, 5, subClauseTable(type))} - ${activeOrMine(me)} - ${await filterClause(me, models, type)} - ${typeClause(type)} - ${whenClause(when || 'forever', type)} + ${whereClause( + `"${table}"."userId" = $2`, + `"${table}".created_at <= $1`, + subClause(sub, 5, subClauseTable(type)), + activeOrMine(me), + await filterClause(me, models, type), + typeClause(type), + whenClause(when || 'forever', type))} ${await orderByClause(by, me, models, type)} OFFSET $3 LIMIT $4`, @@ -343,11 +361,14 @@ export default { query: ` ${SELECT} ${relationClause(type)} - WHERE "Item".created_at <= $1 - ${subClause(sub, 4, subClauseTable(type))} - ${activeOrMine(me)} - ${await filterClause(me, models, type)} - ${typeClause(type)} + ${whereClause( + '"Item".created_at <= $1', + subClause(sub, 4, subClauseTable(type)), + activeOrMine(me), + await filterClause(me, models, type), + typeClause(type), + muteClause(me) + )} ORDER BY "Item".created_at DESC OFFSET $2 LIMIT $3`, @@ -361,12 +382,15 @@ export default { query: ` ${selectClause(type)} ${relationClause(type)} - WHERE "Item".created_at <= $1 - AND "Item"."pinId" IS NULL AND "Item"."deletedAt" IS NULL - ${subClause(sub, 4, subClauseTable(type))} - ${typeClause(type)} - ${whenClause(when, type)} - ${await filterClause(me, models, type)} + ${whereClause( + '"Item".created_at <= $1', + '"Item"."pinId" IS NULL', + '"Item"."deletedAt" IS NULL', + subClause(sub, 4, subClauseTable(type)), + typeClause(type), + whenClause(when, type), + await filterClause(me, models, type), + muteClause(me))} ${await orderByClause(by || 'zaprank', me, models, type)} OFFSET $2 LIMIT $3`, @@ -392,10 +416,13 @@ export default { THEN rank() OVER (ORDER BY "maxBid" DESC, created_at ASC) ELSE rank() OVER (ORDER BY created_at DESC) END AS rank FROM "Item" - WHERE "parentId" IS NULL AND created_at <= $1 - AND "pinId" IS NULL - ${subClause(sub, 4)} - AND status IN ('ACTIVE', 'NOSATS') + ${whereClause( + '"parentId" IS NULL', + 'created_at <= $1', + '"pinId" IS NULL', + subClause(sub, 4), + "status IN ('ACTIVE', 'NOSATS')" + )} ORDER BY group_rank, rank OFFSET $2 LIMIT $3`, @@ -410,7 +437,9 @@ export default { ${SELECT}, rank FROM "Item" ${await joinSatRankView(me, models)} - ${subClause(sub, 3, 'Item', true)} + ${whereClause( + subClause(sub, 3, 'Item', true), + muteClause(me))} ORDER BY rank ASC OFFSET $1 LIMIT $2`, @@ -428,11 +457,12 @@ export default { ${SELECT}, rank() OVER ( PARTITION BY "pinId" - ORDER BY created_at DESC + ORDER BY "Item".created_at DESC ) FROM "Item" - WHERE "pinId" IS NOT NULL - ${subClause(sub, 1)} + ${whereClause( + '"pinId" IS NOT NULL', + subClause(sub, 1))} ) rank_filter WHERE RANK = 1` }, ...subArr) } diff --git a/api/resolvers/notifications.js b/api/resolvers/notifications.js index 241eded2..3cd7aae1 100644 --- a/api/resolvers/notifications.js +++ b/api/resolvers/notifications.js @@ -1,6 +1,6 @@ import { GraphQLError } from 'graphql' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' -import { getItem, filterClause } from './item' +import { getItem, filterClause, whereClause, muteClause } from './item' import { getInvoice } from './wallet' import { pushSubscriptionSchema, ssValidate } from '../../lib/validate' import { replyToSubscription } from '../webPush' @@ -79,8 +79,13 @@ export default { '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)} + ${whereClause( + 'p."userId" = $1', + '"Item"."userId" <> $1', + '"Item".created_at <= $2', + await filterClause(me, models), + muteClause(me) + )} ORDER BY "sortTime" DESC LIMIT ${LIMIT}+$3` ) @@ -92,32 +97,36 @@ export default { 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 - -- Only show items that have been created since subscribing to the thread - AND "Item".created_at >= "ThreadSubscription".created_at - -- don't notify on posts - AND "Item"."parentId" IS NOT NULL - ${await filterClause(me, models)} + ${whereClause( + '"ThreadSubscription"."userId" = $1', + '"Item"."userId" <> $1', + '"Item".created_at <= $2', + '"Item".created_at >= "ThreadSubscription".created_at', + '"Item"."parentId" IS NOT NULL', + await filterClause(me, models), + muteClause(me) + )} ORDER BY "sortTime" DESC LIMIT ${LIMIT}+$3` ) // User subscriptions + // Only include posts or comments created after the corresponding subscription was enabled, not _all_ from history itemDrivenQueries.push( `SELECT DISTINCT "Item".id::TEXT, "Item".created_at AS "sortTime", NULL::BIGINT as "earnedSats", 'FollowActivity' AS type FROM "Item" JOIN "UserSubscription" ON "Item"."userId" = "UserSubscription"."followeeId" - WHERE "UserSubscription"."followerId" = $1 - AND "Item".created_at <= $2 - AND ( - -- Only include posts or comments created after the corresponding subscription was enabled, not _all_ from history + ${whereClause( + '"UserSubscription"."followerId" = $1', + '"Item".created_at <= $2', + `( ("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt") OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt") - ) - ${await filterClause(me, models)} + )`, + await filterClause(me, models), + muteClause(me) + )} ORDER BY "sortTime" DESC LIMIT ${LIMIT}+$3` ) @@ -129,10 +138,13 @@ export default { 'Mention' AS type FROM "Mention" JOIN "Item" ON "Mention"."itemId" = "Item".id - WHERE "Mention"."userId" = $1 - AND "Mention".created_at <= $2 - AND "Item"."userId" <> $1 - ${await filterClause(me, models)} + ${whereClause( + '"Mention"."userId" = $1', + '"Mention".created_at <= $2', + '"Item"."userId" <> $1', + await filterClause(me, models), + muteClause(me) + )} ORDER BY "sortTime" DESC LIMIT ${LIMIT}+$3` ) diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 92155c73..2cb13ada 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -4,7 +4,7 @@ import { GraphQLError } from 'graphql' import { decodeCursor, LIMIT, nextCursorEncoded } from '../../lib/cursor' import { msatsToSats } from '../../lib/format' import { bioSchema, emailSchema, settingsSchema, ssValidate, userSchema } from '../../lib/validate' -import { getItem, updateItem, filterClause, createItem } from './item' +import { getItem, updateItem, filterClause, createItem, whereClause, muteClause } from './item' import { datePivot } from '../../lib/time' const contributors = new Set() @@ -286,87 +286,97 @@ export default { // check if any votes have been cast for them since checkedNotesAt if (user.noteItemSats) { - const votes = await models.$queryRawUnsafe(` - SELECT 1 - FROM "Item" - JOIN "ItemAct" ON - "ItemAct"."itemId" = "Item".id - AND "ItemAct"."userId" <> "Item"."userId" - WHERE "ItemAct".created_at > $2 - AND "Item"."userId" = $1 - AND "ItemAct".act = 'TIP' - LIMIT 1`, me.id, lastChecked) - if (votes.length > 0) { + const [newSats] = await models.$queryRawUnsafe(` + SELECT EXISTS( + SELECT * + FROM "Item" + JOIN "ItemAct" ON + "ItemAct"."itemId" = "Item".id + AND "ItemAct"."userId" <> "Item"."userId" + WHERE "ItemAct".created_at > $2 + AND "Item"."userId" = $1 + AND "ItemAct".act = 'TIP')`, me.id, lastChecked) + if (newSats.exists) { return true } } // check if they have any replies since checkedNotesAt - const newReplies = await models.$queryRawUnsafe(` - SELECT 1 + const [newReply] = await models.$queryRawUnsafe(` + SELECT EXISTS( + SELECT * FROM "Item" JOIN "Item" p ON - "Item".created_at >= p.created_at - AND ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} - AND "Item"."userId" <> $1 - WHERE p."userId" = $1 - AND "Item".created_at > $2::timestamp(3) without time zone - ${await filterClause(me, models)} - LIMIT 1`, me.id, lastChecked) - if (newReplies.length > 0) { + ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} + ${whereClause( + 'p."userId" = $1', + '"Item"."userId" <> $1', + '"Item".created_at > $2::timestamp(3) without time zone', + await filterClause(me, models), + muteClause(me) + )})`, me.id, lastChecked) + if (newReply.exists) { return true } // break out thread subscription to decrease the search space of the already expensive reply query - const newtsubs = await models.$queryRawUnsafe(` - 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) { + const [newThreadSubReply] = await models.$queryRawUnsafe(` + SELECT EXISTS( + SELECT * + FROM "ThreadSubscription" + JOIN "Item" p ON "ThreadSubscription"."itemId" = p.id + JOIN "Item" ON ${user.noteAllDescendants ? '"Item".path <@ p.path' : '"Item"."parentId" = p.id'} + ${whereClause( + '"ThreadSubscription"."userId" = $1', + '"Item".created_at > $2::timestamp(3) without time zone', + await filterClause(me, models), + muteClause(me) + )})`, me.id, lastChecked) + if (newThreadSubReply.exists) { return true } - const newUserSubs = await models.$queryRawUnsafe(` - SELECT 1 - FROM "UserSubscription" - JOIN "Item" ON "UserSubscription"."followeeId" = "Item"."userId" - WHERE - "UserSubscription"."followerId" = $1 - AND "Item".created_at > $2::timestamp(3) without time zone - AND ( - ("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt") - OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt") - ) - ${await filterClause(me, models)} - LIMIT 1`, me.id, lastChecked) - if (newUserSubs.length > 0) { + const [newUserSubs] = await models.$queryRawUnsafe(` + SELECT EXISTS( + SELECT * + FROM "UserSubscription" + JOIN "Item" ON "UserSubscription"."followeeId" = "Item"."userId" + ${whereClause( + '"UserSubscription"."followerId" = $1', + '"Item".created_at > $2::timestamp(3) without time zone', + `( + ("Item"."parentId" IS NULL AND "UserSubscription"."postsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."postsSubscribedAt") + OR ("Item"."parentId" IS NOT NULL AND "UserSubscription"."commentsSubscribedAt" IS NOT NULL AND "Item".created_at >= "UserSubscription"."commentsSubscribedAt") + )`, + await filterClause(me, models), + muteClause(me))})`, me.id, lastChecked) + if (newUserSubs.exists) { return true } // check if they have any mentions since checkedNotesAt if (user.noteMentions) { - const newMentions = await models.$queryRawUnsafe(` - SELECT "Item".id, "Item".created_at + const [newMentions] = await models.$queryRawUnsafe(` + SELECT EXISTS( + SELECT * FROM "Mention" JOIN "Item" ON "Mention"."itemId" = "Item".id - WHERE "Mention"."userId" = $1 - AND "Mention".created_at > $2 - AND "Item"."userId" <> $1 - LIMIT 1`, me.id, lastChecked) - if (newMentions.length > 0) { + ${whereClause( + '"Mention"."userId" = $1', + '"Mention".created_at > $2', + '"Item"."userId" <> $1', + await filterClause(me, models), + muteClause(me) + )})`, me.id, lastChecked) + if (newMentions.exists) { return true } } if (user.noteForwardedSats) { - const votes = await models.$queryRawUnsafe(` - SELECT 1 + const [newFwdSats] = await models.$queryRawUnsafe(` + SELECT EXISTS( + SELECT * FROM "Item" JOIN "ItemAct" ON "ItemAct"."itemId" = "Item".id @@ -376,9 +386,8 @@ export default { AND "ItemForward"."userId" = $1 WHERE "ItemAct".created_at > $2 AND "Item"."userId" <> $1 - AND "ItemAct".act = 'TIP' - LIMIT 1`, me.id, lastChecked) - if (votes.length > 0) { + AND "ItemAct".act = 'TIP')`, me.id, lastChecked) + if (newFwdSats.exists) { return true } } @@ -432,13 +441,13 @@ export default { // check if new invites have been redeemed if (user.noteInvites) { - const newInvitees = await models.$queryRawUnsafe(` - SELECT "Invite".id - FROM users JOIN "Invite" on users."inviteId" = "Invite".id - WHERE "Invite"."userId" = $1 - AND users.created_at > $2 - LIMIT 1`, me.id, lastChecked) - if (newInvitees.length > 0) { + const [newInvites] = await models.$queryRawUnsafe(` + SELECT EXISTS( + SELECT * + FROM users JOIN "Invite" on users."inviteId" = "Invite".id + WHERE "Invite"."userId" = $1 + AND users.created_at > $2)`, me.id, lastChecked) + if (newInvites.exists) { return true } @@ -626,6 +635,17 @@ export default { } return { id } }, + toggleMute: async (parent, { id }, { me, models }) => { + const lookupData = { muterId: Number(me.id), mutedId: Number(id) } + const where = { muterId_mutedId: lookupData } + const existing = await models.mute.findUnique({ where }) + if (existing) { + await models.mute.delete({ where }) + } else { + await models.mute.create({ data: { ...lookupData } }) + } + return { id } + }, hideWelcomeBanner: async (parent, data, { me, models }) => { if (!me) { throw new GraphQLError('you must be logged in', { extensions: { code: 'UNAUTHENTICATED' } }) @@ -670,6 +690,21 @@ export default { } }) }, + meMute: async (user, args, { me, models }) => { + if (!me) return false + if (typeof user.meMute !== 'undefined') return user.meMute + + const mute = await models.mute.findUnique({ + where: { + muterId_mutedId: { + muterId: Number(me.id), + mutedId: Number(user.id) + } + } + }) + + return !!mute + }, nposts: async (user, { when }, { models }) => { if (typeof user.nposts !== 'undefined') { return user.nposts diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 82674c36..26574d87 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -34,6 +34,7 @@ export default gql` hideWelcomeBanner: Boolean subscribeUserPosts(id: ID): User subscribeUserComments(id: ID): User + toggleMute(id: ID): User } type AuthMethods { @@ -97,5 +98,6 @@ export default gql` hideIsContributor: Boolean! meSubscriptionPosts: Boolean! meSubscriptionComments: Boolean! + meMute: Boolean } ` diff --git a/components/comment.js b/components/comment.js index 607e4700..d92eab6c 100644 --- a/components/comment.js +++ b/components/comment.js @@ -99,9 +99,9 @@ export default function Comment ({ }) { const [edit, setEdit] = useState() const me = useMe() + const isHiddenFreebie = !me?.wildWestMode && !me?.greeterMode && !item.mine && item.freebie && item.wvotes <= 0 const [collapse, setCollapse] = useState( - !me?.wildWestMode && !me?.greeterMode && - !item.mine && item.freebie && item.wvotes <= 0 + isHiddenFreebie || item?.user?.meMute ? 'yep' : 'nope') const ref = useRef(null) @@ -149,25 +149,35 @@ export default function Comment ({ : }
- {op}} - extraInfo={ - <> - {includeParent && } - {bountyPaid && - - - } - - } - onEdit={e => { setEdit(!edit) }} - editText={edit ? 'cancel' : 'edit'} - /> + {item.user?.meMute && !includeParent && collapse === 'yep' + ? ( + { + setCollapse('nope') + window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope') + }} + >reply from someone you muted + ) + : {op}} + extraInfo={ + <> + {includeParent && } + {bountyPaid && + + + } + + } + onEdit={e => { setEdit(!edit) }} + editText={edit ? 'cancel' : 'edit'} + />} + {!includeParent && (collapse === 'yep' ? { diff --git a/components/item-info.js b/components/item-info.js index b65f18f8..e37191b4 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -16,6 +16,7 @@ import { CopyLinkDropdownItem } from './share' import Hat from './hat' import { AD_USER_ID } from '../lib/constants' import ActionDropdown from './action-dropdown' +import MuteDropdownItem from './mute' export default function ItemInfo ({ item, pendingSats, full, commentsText = 'comments', @@ -131,15 +132,20 @@ export default function ItemInfo ({ {me && } - {me && item.user.id !== me.id && } + {me && !item.mine && } {item.otsHash && - ots timestamp + opentimestamp } {me && !item.meSats && !item.position && !item.mine && !item.deletedAt && } {item.mine && !item.position && !item.deletedAt && } + {me && !item.mine && + <> +
+ + }
{extraInfo}
diff --git a/components/mute.js b/components/mute.js new file mode 100644 index 00000000..a9f4338a --- /dev/null +++ b/components/mute.js @@ -0,0 +1,40 @@ +import { useMutation } from '@apollo/client' +import { gql } from 'graphql-tag' +import Dropdown from 'react-bootstrap/Dropdown' +import { useToast } from './toast' + +export default function MuteDropdownItem ({ user: { name, id, meMute } }) { + const toaster = useToast() + const [toggleMute] = useMutation( + gql` + mutation toggleMute($id: ID!) { + toggleMute(id: $id) { + meMute + } + }`, { + update (cache, { data: { toggleMute } }) { + cache.modify({ + id: `User:${id}`, + fields: { + meMute: () => toggleMute.meMute + } + }) + } + } + ) + return ( + { + try { + await toggleMute({ variables: { id } }) + toaster.success(`${meMute ? 'un' : ''}muted ${name}`) + } catch (err) { + console.error(err) + toaster.danger(`failed to ${meMute ? 'un' : ''}mute ${name}`) + } + }} + > + {`${meMute ? 'un' : ''}mute ${name}`} + + ) +} diff --git a/components/user-header.js b/components/user-header.js index 70dcd117..e39fbcea 100644 --- a/components/user-header.js +++ b/components/user-header.js @@ -21,6 +21,7 @@ import Hat from './hat' import SubscribeUserDropdownItem from './subscribeUser' import ActionDropdown from './action-dropdown' import CodeIcon from '../svgs/terminal-box-fill.svg' +import MuteDropdownItem from './mute' export default function UserHeader ({ user }) { const router = useRouter() @@ -156,11 +157,12 @@ function NymView ({ user, isMe, setEditting }) {
@{user.name}
{isMe && } - {!isMe && + {!isMe && me &&
- {me && } - {me && } + + +
}
diff --git a/fragments/comments.js b/fragments/comments.js index c57cb5fb..ca7aa945 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -12,6 +12,7 @@ export const COMMENT_FIELDS = gql` streak hideCowboyHat id + meMute } sats meAnonSats @client diff --git a/fragments/items.js b/fragments/items.js index 383cbdce..5345ca10 100644 --- a/fragments/items.js +++ b/fragments/items.js @@ -14,6 +14,7 @@ export const ITEM_FIELDS = gql` streak hideCowboyHat id + meMute } otsHash position diff --git a/fragments/users.js b/fragments/users.js index 1860ab16..bbf90079 100644 --- a/fragments/users.js +++ b/fragments/users.js @@ -158,6 +158,7 @@ export const USER_FIELDS = gql` isContributor meSubscriptionPosts meSubscriptionComments + meMute }` export const TOP_USERS = gql` diff --git a/prisma/migrations/20230927194433_mute/migration.sql b/prisma/migrations/20230927194433_mute/migration.sql new file mode 100644 index 00000000..c3087279 --- /dev/null +++ b/prisma/migrations/20230927194433_mute/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "Mute" ( + "muterId" INTEGER NOT NULL, + "mutedId" INTEGER NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Mute_pkey" PRIMARY KEY ("muterId","mutedId") +); + +-- CreateIndex +CREATE INDEX "Mute_mutedId_muterId_idx" ON "Mute"("mutedId", "muterId"); + +-- AddForeignKey +ALTER TABLE "Mute" ADD CONSTRAINT "Mute_muterId_fkey" FOREIGN KEY ("muterId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Mute" ADD CONSTRAINT "Mute_mutedId_fkey" FOREIGN KEY ("mutedId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20230927235403_comments_with_mutes/migration.sql b/prisma/migrations/20230927235403_comments_with_mutes/migration.sql new file mode 100644 index 00000000..bccb12af --- /dev/null +++ b/prisma/migrations/20230927235403_comments_with_mutes/migration.sql @@ -0,0 +1,40 @@ +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.*) || jsonb_build_object(''meMute'', "Mute"."mutedId" IS NOT NULL) 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 "Mute" ON "Mute"."muterId" = $5 AND "Mute"."mutedId" = "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 f4e523cc..3f9f9d0e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -92,12 +92,26 @@ model User { hideWelcomeBanner Boolean @default(false) diagnostics Boolean @default(false) hideIsContributor Boolean @default(false) + muters Mute[] @relation("muter") + muteds Mute[] @relation("muted") @@index([createdAt], map: "users.created_at_index") @@index([inviteId], map: "users.inviteId_index") @@map("users") } +model Mute { + muterId Int + mutedId Int + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + muter User @relation("muter", fields: [muterId], references: [id], onDelete: Cascade) + muted User @relation("muted", fields: [mutedId], references: [id], onDelete: Cascade) + + @@id([muterId, mutedId]) + @@index([mutedId, muterId]) +} + model Streak { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @map("created_at") @@ -536,14 +550,14 @@ model ThreadSubscription { } model UserSubscription { - followerId Int - followeeId Int - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") @updatedAt - postsSubscribedAt DateTime? - commentsSubscribedAt DateTime? - follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade) - followee User @relation("followee", fields: [followeeId], references: [id], onDelete: Cascade) + followerId Int + followeeId Int + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") + postsSubscribedAt DateTime? + commentsSubscribedAt DateTime? + follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade) + followee User @relation("followee", fields: [followeeId], references: [id], onDelete: Cascade) @@id([followerId, followeeId]) @@index([createdAt], map: "UserSubscription.created_at_index") @@ -626,4 +640,4 @@ enum LogLevel { INFO WARN ERROR -} \ No newline at end of file +}