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 <keyan.kousha+huumn@gmail.com>
This commit is contained in:
ekzyis 2024-01-30 18:04:56 +01:00 committed by GitHub
parent 35c76c077e
commit d1ed72bb85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 192 additions and 18 deletions

View File

@ -487,17 +487,21 @@ export default {
query: ` query: `
SELECT rank_filter.* SELECT rank_filter.*
FROM ( FROM (
${SELECT}, ${SELECT}, position,
rank() OVER ( rank() OVER (
PARTITION BY "pinId" PARTITION BY "pinId"
ORDER BY "Item".created_at DESC ORDER BY "Item".created_at DESC
) )
FROM "Item" FROM "Item"
JOIN "Pin" ON "Item"."pinId" = "Pin".id
${whereClause( ${whereClause(
'"pinId" IS NOT NULL', '"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))} muteClause(me))}
) rank_filter WHERE RANK = 1` ) rank_filter WHERE RANK = 1
ORDER BY position ASC`,
orderBy: 'ORDER BY position ASC'
}, ...subArr) }, ...subArr)
} }
break break
@ -623,6 +627,97 @@ export default {
} else await models.bookmark.create({ data }) } else await models.bookmark.create({ data })
return { id } 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 }) => { subscribeItem: async (parent, { id }, { me, models }) => {
const data = { itemId: Number(id), userId: me.id } const data = { itemId: Number(id), userId: me.id }
const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } }) const old = await models.threadSubscription.findUnique({ where: { userId_itemId: data } })

View File

@ -26,6 +26,7 @@ export default gql`
extend type Mutation { extend type Mutation {
bookmarkItem(id: ID): Item bookmarkItem(id: ID): Item
pinItem(id: ID): Item
subscribeItem(id: ID): Item subscribeItem(id: ID): Item
deleteItem(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! upsertLink(id: ID, sub: String, title: String!, url: String!, text: String, boost: Int, forward: [ItemForwardInput], hash: String, hmac: String): Item!

View File

@ -24,6 +24,7 @@ import { useQuoteReply } from './use-quote-reply'
import { DownZap } from './dont-link-this' import { DownZap } from './dont-link-this'
import Skull from '../svgs/death-skull.svg' import Skull from '../svgs/death-skull.svg'
import { commentSubTreeRootId } from '../lib/item' import { commentSubTreeRootId } from '../lib/item'
import Pin from '../svgs/pushpin-fill.svg'
function Parent ({ item, rootText }) { function Parent ({ item, rootText }) {
const root = useRoot() const root = useRoot()
@ -92,7 +93,7 @@ export function CommentFlat ({ item, rank, siblingComments, ...props }) {
export default function Comment ({ export default function Comment ({
item, children, replyOpen, includeParent, topLevel, item, children, replyOpen, includeParent, topLevel,
rootText, noComments, noReply, truncate, depth rootText, noComments, noReply, truncate, depth, pin
}) { }) {
const [edit, setEdit] = useState() const [edit, setEdit] = useState()
const me = useMe() const me = useMe()
@ -145,7 +146,7 @@ export default function Comment ({
? <Skull className={styles.dontLike} width={24} height={24} /> ? <Skull className={styles.dontLike} width={24} height={24} />
: item.meDontLikeSats > item.meSats : item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} /> ? <DownZap width={24} height={24} className={styles.dontLike} id={item.id} meDontLikeSats={item.meDontLikeSats} />
: <UpVote item={item} className={styles.upvote} />} : pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}> <div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep' {item.user?.meMute && !includeParent && collapse === 'yep'

View File

@ -10,6 +10,13 @@
margin-right: 0rem; margin-right: 0rem;
} }
.pin {
fill: #a5a5a5;
margin-left: .2rem;
margin-right: .3rem;
flex-shrink: 0;
}
.dontLike { .dontLike {
fill: #a5a5a5; fill: #a5a5a5;
margin-right: .35rem; margin-right: .35rem;

View File

@ -1,3 +1,4 @@
import { Fragment } from 'react'
import Comment, { CommentSkeleton } from './comment' import Comment, { CommentSkeleton } from './comment'
import styles from './header.module.css' import styles from './header.module.css'
import Nav from 'react-bootstrap/Nav' 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 }) { export default function Comments ({ parentId, pinned, bio, parentCreatedAt, commentSats, comments, ...props }) {
const router = useRouter() const router = useRouter()
const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
return ( return (
<> <>
{comments?.length > 0 {comments?.length > 0
@ -80,7 +83,12 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
}} }}
/> />
: null} : null}
{comments.map(item => ( {pins.map(item => (
<Fragment key={item.id}>
<Comment depth={1} item={item} {...props} pin />
</Fragment>
))}
{comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} {...props} /> <Comment depth={1} key={item.id} item={item} {...props} />
))} ))}
</> </>

View File

@ -20,7 +20,7 @@ import ActionDropdown from './action-dropdown'
import MuteDropdownItem from './mute' import MuteDropdownItem from './mute'
import { DropdownItemUpVote } from './upvote' import { DropdownItemUpVote } from './upvote'
import { useRoot } from './root' import { useRoot } from './root'
import { MuteSubDropdownItem } from './territory-header' import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
export default function ItemInfo ({ export default function ItemInfo ({
item, full, commentsText = 'comments', item, full, commentsText = 'comments',
@ -47,6 +47,14 @@ export default function ItemInfo ({
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0)) if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0))
}, [item?.meSats, item?.meAnonSats]) }, [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 ( return (
<div className={className || `${styles.other}`}> <div className={className || `${styles.other}`}>
{!item.position && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) && {!item.position && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) &&
@ -155,15 +163,11 @@ export default function ItemInfo ({
nostr note nostr note
</Dropdown.Item> </Dropdown.Item>
)} )}
{item?.mine && !item?.noteId &&
<CrosspostDropdownItem item={item} />}
{me && !item.position && {me && !item.position &&
!item.mine && !item.deletedAt && !item.mine && !item.deletedAt &&
(item.meDontLikeSats > meTotalSats (item.meDontLikeSats > meTotalSats
? <DropdownItemUpVote item={item} /> ? <DropdownItemUpVote item={item} />
: <DontLikeThisDropdownItem id={item.id} />)} : <DontLikeThisDropdownItem id={item.id} />)}
{item.mine && !item.position && !item.deletedAt && !item.bio &&
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />}
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated && {me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
<> <>
<hr className='dropdown-divider' /> <hr className='dropdown-divider' />
@ -174,6 +178,18 @@ export default function ItemInfo ({
<hr className='dropdown-divider' /> <hr className='dropdown-divider' />
<MuteSubDropdownItem item={item} sub={sub} /> <MuteSubDropdownItem item={item} sub={sub} />
</>} </>}
{canPin &&
<>
<hr className='dropdown-divider' />
<PinSubDropdownItem item={item} />
</>}
{item?.mine && !item?.noteId &&
<CrosspostDropdownItem item={item} />}
{item.mine && !item.position && !item.deletedAt && !item.bio &&
<>
<hr className='dropdown-divider' />
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />
</>}
{me && !item.mine && {me && !item.mine &&
<> <>
<hr className='dropdown-divider' /> <hr className='dropdown-divider' />

View File

@ -100,6 +100,11 @@ a.link:visited {
margin-top: -1px; margin-top: -1px;
} }
.other :global(.dropdown-item) {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.item { .item {
display: flex; display: flex;
justify-content: flex-start; justify-content: flex-start;

View File

@ -24,8 +24,19 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
} }
}, [dat]) }, [dat])
const pinMap = useMemo(() => const itemsWithPins = useMemo(() => {
pins?.reduce((a, p) => { a[p.position] = p; return a }, {}), [pins]) 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(() => const Skeleton = useCallback(() =>
<ItemsSkeleton rank={rank} startRank={items?.length} limit={variables.limit} Footer={Foooter} />, [rank, items]) <ItemsSkeleton rank={rank} startRank={items?.length} limit={variables.limit} Footer={Foooter} />, [rank, items])
@ -37,11 +48,8 @@ export default function Items ({ ssrData, variables = {}, query, destructureData
return ( return (
<> <>
<div className={styles.grid}> <div className={styles.grid}>
{items.filter(filter).map((item, i) => ( {itemsWithPins.filter(filter).map((item, i) => (
<Fragment key={item.id}> <ListItem key={item.id} item={item} rank={rank && i + 1} siblingComments={variables.includeComments} />
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
<ListItem item={item} rank={rank && i + 1} siblingComments={variables.includeComments} />
</Fragment>
))} ))}
</div> </div>
<Foooter <Foooter

View File

@ -141,3 +141,32 @@ export function MuteSubDropdownItem ({ item, sub }) {
</Dropdown.Item> </Dropdown.Item>
) )
} }
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 (
<Dropdown.Item
onClick={async () => {
try {
await pinItem({ variables: { id } })
toaster.success(position ? 'pin removed' : 'pin added')
} catch (err) {
toaster.danger(err.message)
}
}}
>
{position ? 'unpin item' : 'pin item'}
</Dropdown.Item>
)
}

View File

@ -3,6 +3,7 @@ import { gql } from '@apollo/client'
export const COMMENT_FIELDS = gql` export const COMMENT_FIELDS = gql`
fragment CommentFields on Item { fragment CommentFields on Item {
id id
position
parentId parentId
createdAt createdAt
deletedAt deletedAt

View File

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