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: `
|
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 } })
|
||||||
|
|
|
@ -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!
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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' />
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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