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:
parent
35c76c077e
commit
d1ed72bb85
|
@ -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 } })
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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 ({
|
|||
? <Skull className={styles.dontLike} width={24} height={24} />
|
||||
: item.meDontLikeSats > item.meSats
|
||||
? <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='d-flex align-items-center'>
|
||||
{item.user?.meMute && !includeParent && collapse === 'yep'
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 => (
|
||||
<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} />
|
||||
))}
|
||||
</>
|
||||
|
|
|
@ -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 (
|
||||
<div className={className || `${styles.other}`}>
|
||||
{!item.position && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) &&
|
||||
|
@ -155,15 +163,11 @@ export default function ItemInfo ({
|
|||
nostr note
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{item?.mine && !item?.noteId &&
|
||||
<CrosspostDropdownItem item={item} />}
|
||||
{me && !item.position &&
|
||||
!item.mine && !item.deletedAt &&
|
||||
(item.meDontLikeSats > meTotalSats
|
||||
? <DropdownItemUpVote item={item} />
|
||||
: <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 &&
|
||||
<>
|
||||
<hr className='dropdown-divider' />
|
||||
|
@ -174,6 +178,18 @@ export default function ItemInfo ({
|
|||
<hr className='dropdown-divider' />
|
||||
<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 &&
|
||||
<>
|
||||
<hr className='dropdown-divider' />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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(() =>
|
||||
<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 (
|
||||
<>
|
||||
<div className={styles.grid}>
|
||||
{items.filter(filter).map((item, i) => (
|
||||
<Fragment key={item.id}>
|
||||
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
|
||||
<ListItem item={item} rank={rank && i + 1} siblingComments={variables.includeComments} />
|
||||
</Fragment>
|
||||
{itemsWithPins.filter(filter).map((item, i) => (
|
||||
<ListItem key={item.id} item={item} rank={rank && i + 1} siblingComments={variables.includeComments} />
|
||||
))}
|
||||
</div>
|
||||
<Foooter
|
||||
|
|
|
@ -141,3 +141,32 @@ export function MuteSubDropdownItem ({ item, sub }) {
|
|||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import { gql } from '@apollo/client'
|
|||
export const COMMENT_FIELDS = gql`
|
||||
fragment CommentFields on Item {
|
||||
id
|
||||
position
|
||||
parentId
|
||||
createdAt
|
||||
deletedAt
|
||||
|
|
|
@ -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;
|
Loading…
Reference in New Issue