This commit is contained in:
keyan 2023-09-28 15:02:25 -05:00
parent b6cb895871
commit 4bd489a36a
14 changed files with 440 additions and 228 deletions

View File

@ -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)
}

View File

@ -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`
)

View File

@ -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

View File

@ -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
}
`

View File

@ -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 ({
: <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>
<ItemInfo
item={item}
pendingSats={pendingSats}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>
{item.user?.meMute && !includeParent && collapse === 'yep'
? (
<span
className={`${itemStyles.other} ${styles.other} pointer`} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
>reply from someone you muted
</span>)
: <ItemInfo
item={item}
pendingSats={pendingSats}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
{!includeParent && (collapse === 'yep'
? <Eye
className={styles.collapser} height={10} width={10} onClick={() => {

View File

@ -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 ({
<ActionDropdown>
<CopyLinkDropdownItem item={item} />
{me && <BookmarkDropdownItem item={item} />}
{me && item.user.id !== me.id && <SubscribeDropdownItem item={item} />}
{me && !item.mine && <SubscribeDropdownItem item={item} />}
{item.otsHash &&
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
ots timestamp
opentimestamp
</Link>}
{me && !item.meSats && !item.position &&
!item.mine && !item.deletedAt && <DontLikeThisDropdownItem id={item.id} />}
{item.mine && !item.position && !item.deletedAt &&
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />}
{me && !item.mine &&
<>
<hr className='dropdown-divider' />
<MuteDropdownItem user={item.user} />
</>}
</ActionDropdown>
{extraInfo}
</div>

40
components/mute.js Normal file
View File

@ -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 (
<Dropdown.Item
onClick={async () => {
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}`}
</Dropdown.Item>
)
}

View File

@ -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 }) {
<div className={styles.username}>@{user.name}<Hat className='' user={user} badge /></div>
{isMe &&
<Button className='py-0' style={{ lineHeight: '1.25' }} variant='link' onClick={() => setEditting(true)}>edit nym</Button>}
{!isMe &&
{!isMe && me &&
<div className='ms-2'>
<ActionDropdown>
{me && <SubscribeUserDropdownItem user={user} target='posts' />}
{me && <SubscribeUserDropdownItem user={user} target='comments' />}
<SubscribeUserDropdownItem user={user} target='posts' />
<SubscribeUserDropdownItem user={user} target='comments' />
<MuteDropdownItem user={user} />
</ActionDropdown>
</div>}
</div>

View File

@ -12,6 +12,7 @@ export const COMMENT_FIELDS = gql`
streak
hideCowboyHat
id
meMute
}
sats
meAnonSats @client

View File

@ -14,6 +14,7 @@ export const ITEM_FIELDS = gql`
streak
hideCowboyHat
id
meMute
}
otsHash
position

View File

@ -158,6 +158,7 @@ export const USER_FIELDS = gql`
isContributor
meSubscriptionPosts
meSubscriptionComments
meMute
}`
export const TOP_USERS = gql`

View File

@ -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;

View File

@ -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
$$;

View File

@ -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")