From d1ed72bb859b11999b76a294c8256ef9967c6acf Mon Sep 17 00:00:00 2001 From: ekzyis Date: Tue, 30 Jan 2024 18:04:56 +0100 Subject: [PATCH] Allow territory founders to pin items (#767) * Add pinning of items * Fix empty section in context menu * Pin comments * Fix layout shift during comment pinning * Add comments, rename, formatting * Max 3 pins allowed * Fix argument * Fix missing position update for other items * Improve error message * only show saloon in home * refine pinItem style and transaction usage * pin styling enhancements * simpler handling of excess pins * fix pin positioning like mergePins * give existing pins null subName * prevent empty items on load --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: keyan --- api/resolvers/item.js | 101 +++++++++++++++++- api/typeDefs/item.js | 1 + components/comment.js | 5 +- components/comment.module.css | 7 ++ components/comments.js | 10 +- components/item-info.js | 26 ++++- components/item.module.css | 5 + components/items.js | 22 ++-- components/territory-header.js | 29 +++++ fragments/comments.js | 1 + .../migration.sql | 3 + 11 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 prisma/migrations/20240130165240_existing_pins_null_sub/migration.sql diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 7b48e705..6ecf4e38 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -487,17 +487,21 @@ export default { query: ` SELECT rank_filter.* FROM ( - ${SELECT}, + ${SELECT}, position, rank() OVER ( PARTITION BY "pinId" ORDER BY "Item".created_at DESC ) FROM "Item" + JOIN "Pin" ON "Item"."pinId" = "Pin".id ${whereClause( '"pinId" IS NOT NULL', - sub ? '("subName" = $1 OR "subName" IS NULL)' : '"subName" IS NULL', + '"parentId" IS NULL', + sub ? '"subName" = $1' : '"subName" IS NULL', muteClause(me))} - ) rank_filter WHERE RANK = 1` + ) rank_filter WHERE RANK = 1 + ORDER BY position ASC`, + orderBy: 'ORDER BY position ASC' }, ...subArr) } break @@ -623,6 +627,97 @@ export default { } else await models.bookmark.create({ data }) return { id } }, + pinItem: async (parent, { id }, { me, models }) => { + if (!me) { + throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } }) + } + + const [item] = await models.$queryRawUnsafe( + `${SELECT}, p.position + FROM "Item" LEFT JOIN "Pin" p ON p.id = "Item"."pinId" + WHERE "Item".id = $1`, Number(id)) + + const args = [] + if (item.parentId) { + args.push(item.parentId) + + // OPs can only pin top level replies + if (item.path.split('.').length > 2) { + throw new GraphQLError('can only pin root replies', { extensions: { code: 'FORBIDDEN' } }) + } + + const root = await models.item.findUnique({ + where: { + id: Number(item.parentId) + }, + include: { pin: true } + }) + + if (root.userId !== Number(me.id)) { + throw new GraphQLError('not your post', { extensions: { code: 'FORBIDDEN' } }) + } + } else if (item.subName) { + args.push(item.subName) + + // only territory founder can pin posts + const sub = await models.sub.findUnique({ where: { name: item.subName } }) + if (Number(me.id) !== sub.userId) { + throw new GraphQLError('not your sub', { extensions: { code: 'FORBIDDEN' } }) + } + } else { + throw new GraphQLError('item must have subName or parentId', { extensions: { code: 'BAD_INPUT' } }) + } + + let pinId + if (item.pinId) { + // item is already pinned. remove pin + await models.$transaction([ + models.item.update({ where: { id: item.id }, data: { pinId: null } }), + models.pin.delete({ where: { id: item.pinId } }), + // make sure that pins have no gaps + models.$queryRawUnsafe(` + UPDATE "Pin" + SET position = position - 1 + WHERE position > $2 AND id IN ( + SELECT "pinId" FROM "Item" i + ${whereClause('"pinId" IS NOT NULL', item.subName ? 'i."subName" = $1' : 'i."parentId" = $1')} + )`, ...args, item.position) + ]) + + pinId = null + } else { + // only max 3 pins allowed per territory and post + const [{ count: npins }] = await models.$queryRawUnsafe(` + SELECT COUNT(p.id) FROM "Pin" p + JOIN "Item" i ON i."pinId" = p.id + ${ + whereClause(item.subName ? 'i."subName" = $1' : 'i."parentId" = $1') + }`, ...args) + + if (npins >= 3) { + throw new GraphQLError('max 3 pins allowed', { extensions: { code: 'FORBIDDEN' } }) + } + + const [{ pinId: newPinId }] = await models.$queryRawUnsafe(` + WITH pin AS ( + INSERT INTO "Pin" (position) + SELECT COALESCE(MAX(p.position), 0) + 1 AS position + FROM "Pin" p + JOIN "Item" i ON i."pinId" = p.id + ${whereClause(item.subName ? 'i."subName" = $1' : 'i."parentId" = $1')} + RETURNING id + ) + UPDATE "Item" + SET "pinId" = pin.id + FROM pin + WHERE "Item".id = $2 + RETURNING "pinId"`, ...args, item.id) + + pinId = newPinId + } + + return { id, pinId } + }, subscribeItem: async (parent, { id }, { me, models }) => { const data = { itemId: Number(id), userId: me.id } const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } }) diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 2bff9a71..73e390fa 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -26,6 +26,7 @@ export default gql` extend type Mutation { bookmarkItem(id: ID): Item + pinItem(id: ID): Item subscribeItem(id: ID): Item deleteItem(id: ID): Item upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item! diff --git a/components/comment.js b/components/comment.js index 91a1174c..695dc3c2 100644 --- a/components/comment.js +++ b/components/comment.js @@ -24,6 +24,7 @@ import { useQuoteReply } from './use-quote-reply' import { DownZap } from './dont-link-this' import Skull from '../svgs/death-skull.svg' import { commentSubTreeRootId } from '../lib/item' +import Pin from '../svgs/pushpin-fill.svg' function Parent ({ item, rootText }) { const root = useRoot() @@ -92,7 +93,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) { export default function Comment ({ item, children, replyOpen, includeParent, topLevel, - rootText, noComments, noReply, truncate, depth + rootText, noComments, noReply, truncate, depth, pin }) { const [edit, setEdit] = useState() const me = useMe() @@ -145,7 +146,7 @@ export default function Comment ({ ? : item.meDontLikeSats > item.meSats ? - : } + : pin ? : }
{item.user?.meMute && !includeParent && collapse === 'yep' diff --git a/components/comment.module.css b/components/comment.module.css index e987b872..6ac5b214 100644 --- a/components/comment.module.css +++ b/components/comment.module.css @@ -10,6 +10,13 @@ margin-right: 0rem; } +.pin { + fill: #a5a5a5; + margin-left: .2rem; + margin-right: .3rem; + flex-shrink: 0; +} + .dontLike { fill: #a5a5a5; margin-right: .35rem; diff --git a/components/comments.js b/components/comments.js index e252eb43..e77337a7 100644 --- a/components/comments.js +++ b/components/comments.js @@ -1,3 +1,4 @@ +import { Fragment } from 'react' import Comment, { CommentSkeleton } from './comment' import styles from './header.module.css' import Nav from 'react-bootstrap/Nav' @@ -62,6 +63,8 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, ...props }) { const router = useRouter() + const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position) + return ( <> {comments?.length > 0 @@ -80,7 +83,12 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm }} /> : null} - {comments.map(item => ( + {pins.map(item => ( + + + + ))} + {comments.filter(({ position }) => !position).map(item => ( ))} diff --git a/components/item-info.js b/components/item-info.js index 4033f5b7..62e3e386 100644 --- a/components/item-info.js +++ b/components/item-info.js @@ -20,7 +20,7 @@ import ActionDropdown from './action-dropdown' import MuteDropdownItem from './mute' import { DropdownItemUpVote } from './upvote' import { useRoot } from './root' -import { MuteSubDropdownItem } from './territory-header' +import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header' export default function ItemInfo ({ item, full, commentsText = 'comments', @@ -47,6 +47,14 @@ export default function ItemInfo ({ if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0)) }, [item?.meSats, item?.meAnonSats]) + // territory founders can pin any post in their territory + // and OPs can pin any root reply in their post + const isPost = !item.parentId + const mySub = (me && sub && Number(me.id) === sub.userId) + const myPost = (me && root && Number(me.id) === Number(root.user.id)) + const rootReply = item.path.split('.').length === 2 + const canPin = (isPost && mySub) || (myPost && rootReply) + return (
{!item.position && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) && @@ -155,15 +163,11 @@ export default function ItemInfo ({ nostr note )} - {item?.mine && !item?.noteId && - } {me && !item.position && !item.mine && !item.deletedAt && (item.meDontLikeSats > meTotalSats ? : )} - {item.mine && !item.position && !item.deletedAt && !item.bio && - } {me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated && <>
@@ -174,6 +178,18 @@ export default function ItemInfo ({
} + {canPin && + <> +
+ + } + {item?.mine && !item?.noteId && + } + {item.mine && !item.position && !item.deletedAt && !item.bio && + <> +
+ + } {me && !item.mine && <>
diff --git a/components/item.module.css b/components/item.module.css index f31af6f4..7626a23f 100644 --- a/components/item.module.css +++ b/components/item.module.css @@ -100,6 +100,11 @@ a.link:visited { margin-top: -1px; } +.other :global(.dropdown-item) { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + .item { display: flex; justify-content: flex-start; diff --git a/components/items.js b/components/items.js index a1988a77..9931ebb2 100644 --- a/components/items.js +++ b/components/items.js @@ -24,8 +24,19 @@ export default function Items ({ ssrData, variables = {}, query, destructureData } }, [dat]) - const pinMap = useMemo(() => - pins?.reduce((a, p) => { a[p.position] = p; return a }, {}), [pins]) + const itemsWithPins = useMemo(() => { + if (!pins) return items + + const res = [...items] + pins?.forEach(p => { + if (p.position <= res.length) { + res.splice(p.position - 1, 0, p) + } else { + res.push(p) + } + }) + return res + }, [pins, items]) const Skeleton = useCallback(() => , [rank, items]) @@ -37,11 +48,8 @@ export default function Items ({ ssrData, variables = {}, query, destructureData return ( <>
- {items.filter(filter).map((item, i) => ( - - {pinMap && pinMap[i + 1] && } - - + {itemsWithPins.filter(filter).map((item, i) => ( + ))}
) } + +export function PinSubDropdownItem ({ item: { id, position } }) { + const toaster = useToast() + const [pinItem] = useMutation( + gql` + mutation pinItem($id: ID!) { + pinItem(id: $id) { + position + } + }`, { + // refetch since position of other items might also have changed to fill gaps + refetchQueries: ['SubItems', 'Item'] + } + ) + return ( + { + try { + await pinItem({ variables: { id } }) + toaster.success(position ? 'pin removed' : 'pin added') + } catch (err) { + toaster.danger(err.message) + } + }} + > + {position ? 'unpin item' : 'pin item'} + + ) +} diff --git a/fragments/comments.js b/fragments/comments.js index fc912077..f666d10f 100644 --- a/fragments/comments.js +++ b/fragments/comments.js @@ -3,6 +3,7 @@ import { gql } from '@apollo/client' export const COMMENT_FIELDS = gql` fragment CommentFields on Item { id + position parentId createdAt deletedAt diff --git a/prisma/migrations/20240130165240_existing_pins_null_sub/migration.sql b/prisma/migrations/20240130165240_existing_pins_null_sub/migration.sql new file mode 100644 index 00000000..b1bfa2cb --- /dev/null +++ b/prisma/migrations/20240130165240_existing_pins_null_sub/migration.sql @@ -0,0 +1,3 @@ +-- all existing pins shouldn't have a subName +-- this only impacts old daily discussion threads +update "Item" set "subName" = null where "pinId" is not null; \ No newline at end of file