improve comment performance

This commit is contained in:
keyan 2023-05-06 16:51:17 -05:00
parent 1a5d8880dd
commit 347a6a54d2
15 changed files with 139 additions and 116 deletions

View File

@ -6,7 +6,7 @@ import { getMetadata, metadataRuleSets } from 'page-metadata-parser'
import domino from 'domino'
import {
BOOST_MIN, ITEM_SPAM_INTERVAL,
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST
MAX_TITLE_LENGTH, ITEM_FILTER_THRESHOLD, DONT_LIKE_THIS_COST, COMMENT_DEPTH_LIMIT
} from '../../lib/constants'
import { msatsToSats } from '../../lib/format'
import { parse } from 'tldts'
@ -27,20 +27,9 @@ async function comments (me, models, id, sort, root) {
break
}
const flat = await models.$queryRaw(`
WITH RECURSIVE base AS (
${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path
FROM "Item"
WHERE "parentId" = $1
${await filterClause(me, models)}
UNION ALL
${SELECT}, p.sort_path || row_number() OVER (${orderBy}, "Item".path)
FROM base p
JOIN "Item" ON "Item"."parentId" = p.id
WHERE true
${await filterClause(me, models)})
SELECT * FROM base ORDER BY sort_path`, Number(id))
return nestComments(flat, id, root)[0]
const filter = await filterClause(me, models)
const [{ item_comments: comments }] = await models.$queryRaw('SELECT item_comments($1, $2, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, orderBy, filter)
return comments
}
export async function getItem (parent, { id }, { me, models }) {
@ -1120,32 +1109,6 @@ const createItem = async (parent, { sub, title, url, text, boost, forward, bount
return item
}
function nestComments (flat, parentId, root) {
const result = []
let added = 0
for (let i = 0; i < flat.length;) {
flat[i].root = root
if (!flat[i].comments) flat[i].comments = []
if (Number(flat[i].parentId) === Number(parentId)) {
result.push(flat[i])
added++
i++
} else if (result.length > 0) {
const item = result[result.length - 1]
const [nested, newAdded] = nestComments(flat.slice(i), item.id, root)
if (newAdded === 0) {
break
}
item.comments.push(...nested)
i += newAdded
added += newAdded
} else {
break
}
}
return [result, added]
}
// we have to do our own query because ltree is unsupported
export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title,

View File

@ -19,8 +19,11 @@ import { abbrNum } from '../lib/format'
import Share from './share'
import ItemInfo from './item-info'
import { Badge } from 'react-bootstrap'
import { RootProvider, useRoot } from './root'
function Parent ({ item, rootText }) {
const root = useRoot()
const ParentFrag = () => (
<>
<span> \ </span>
@ -30,20 +33,16 @@ function Parent ({ item, rootText }) {
</>
)
if (!item.root) {
return <ParentFrag />
}
return (
<>
{Number(item.root.id) !== Number(item.parentId) && <ParentFrag />}
{Number(root.id) !== Number(item.parentId) && <ParentFrag />}
<span> \ </span>
<Link href={`/items/${item.root.id}`} passHref>
<a className='text-reset'>{rootText || 'on:'} {item.root.title}</a>
<Link href={`/items/${root.id}`} passHref>
<a className='text-reset'>{rootText || 'on:'} {root?.title}</a>
</Link>
{item.root.subName &&
<Link href={`/~${item.root.subName}`}>
<a>{' '}<Badge className={itemStyles.newComment} variant={null}>{item.root.subName}</Badge></a>
{root.subName &&
<Link href={`/~${root.subName}`}>
<a>{' '}<Badge className={itemStyles.newComment} variant={null}>{root.subName}</Badge></a>
</Link>}
</>
)
@ -76,7 +75,9 @@ export function CommentFlat ({ item, ...props }) {
}
}}
>
<Comment item={item} {...props} />
<RootProvider root={item.root}>
<Comment item={item} {...props} />
</RootProvider>
</div>
)
}
@ -89,6 +90,7 @@ export default function Comment ({
const [collapse, setCollapse] = useState(false)
const ref = useRef(null)
const router = useRouter()
const root = useRoot()
useEffect(() => {
if (Number(router.query.commentId) === Number(item.id)) {
@ -103,9 +105,8 @@ export default function Comment ({
}, [item])
const bottomedOut = depth === COMMENT_DEPTH_LIMIT
const op = item.root?.user.name === item.user.name
const bountyPaid = item.root?.bountyPaidTo?.includes(Number(item.id))
const op = root.user.name === item.user.name
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
return (
<div
@ -126,7 +127,7 @@ export default function Comment ({
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${abbrNum(item.root.bounty)} sats paid`}>
<ActionTooltip notForm overlayText={`${abbrNum(root.bounty)} sats paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
@ -177,7 +178,7 @@ export default function Comment ({
<div className={`${styles.children}`}>
{!noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen}>
{item.root?.bounty && !bountyPaid && <PayBounty item={item} />}
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={`${styles.comments} ml-sm-1 ml-md-3`}>

View File

@ -1,8 +1,8 @@
import React from 'react'
import { Component } from 'react'
import LayoutStatic from './layout-static'
import styles from '../styles/404.module.css'
class ErrorBoundary extends React.Component {
class ErrorBoundary extends Component {
constructor (props) {
super(props)

View File

@ -20,6 +20,7 @@ import Check from '../svgs/check-double-line.svg'
import Share from './share'
import Toc from './table-of-contents'
import Link from 'next/link'
import { RootProvider } from './root'
function BioItem ({ item, handleClick }) {
const me = useMe()
@ -157,7 +158,7 @@ export default function ItemFull ({ item, bio, ...props }) {
}, [item.lastCommentAt])
return (
<>
<RootProvider root={item.root || item}>
{item.parentId
? <Comment topLevel item={item} replyOpen includeParent noComments {...props} />
: (
@ -171,6 +172,6 @@ export default function ItemFull ({ item, bio, ...props }) {
<div className={styles.comments}>
<Comments parentId={item.id} pinned={item.position} commentSats={item.commentSats} comments={item.comments} />
</div>}
</>
</RootProvider>
)
}

View File

@ -1,7 +1,5 @@
import { useRouter } from 'next/router'
import React from 'react'
import { ignoreClick } from '../lib/clicks'
import Comment from './comment'
import { Fragment } from 'react'
import { CommentFlat } from './comment'
import Item from './item'
import ItemJob from './item-job'
import { ItemsSkeleton } from './items'
@ -9,33 +7,22 @@ import styles from './items.module.css'
import MoreFooter from './more-footer'
export default function MixedItems ({ rank, items, cursor, fetchMore }) {
const router = useRouter()
return (
<>
<div className={styles.grid}>
{items.map((item, i) => (
<React.Fragment key={item.id}>
<Fragment key={item.id}>
{item.parentId
? (
<><div />
<div
className='pb-1 mb-1 clickToContext' onClick={e => {
if (ignoreClick(e)) {
return
}
router.push({
pathname: '/items/[id]',
query: { id: item.root.id, commentId: item.id }
}, `/items/${item.root.id}`)
}}
>
<Comment item={item} noReply includeParent clickToContext />
<div className='pb-1 mb-1'>
<CommentFlat item={item} noReply includeParent clickToContext />
</div>
</>)
: (item.isJob
? <ItemJob item={item} rank={rank && i + 1} />
: <Item item={item} rank={rank && i + 1} />)}
</React.Fragment>
</Fragment>
))}
</div>
<MoreFooter

View File

@ -4,8 +4,8 @@ import ItemJob from './item-job'
import styles from './items.module.css'
import { ITEMS } from '../fragments/items'
import MoreFooter from './more-footer'
import React from 'react'
import Comment from './comment'
import { Fragment } from 'react'
import { CommentFlat } from './comment'
export default function Items ({ variables = {}, query, destructureData, rank, items, pins, cursor }) {
const { data, fetchMore } = useQuery(query || ITEMS, { variables })
@ -28,19 +28,19 @@ export default function Items ({ variables = {}, query, destructureData, rank, i
<>
<div className={styles.grid}>
{items.map((item, i) => (
<React.Fragment key={item.id}>
<Fragment key={item.id}>
{pinMap && pinMap[i + 1] && <Item item={pinMap[i + 1]} />}
{item.parentId
? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></>
? <><div /><div className='pb-3'><CommentFlat item={item} noReply includeParent /></div></>
: (item.isJob
? <ItemJob item={item} rank={rank && i + 1} />
: (item.title
? <Item item={item} rank={rank && i + 1} />
: (
<div className='pb-2'>
<Comment item={item} noReply includeParent clickToContext />
<CommentFlat item={item} noReply includeParent clickToContext />
</div>)))}
</React.Fragment>
</Fragment>
))}
</div>
<MoreFooter

View File

@ -14,6 +14,7 @@ import HandCoin from '../svgs/hand-coin-fill.svg'
import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
import CowboyHatIcon from '../svgs/cowboy.svg'
import BaldIcon from '../svgs/bald.svg'
import { RootProvider } from './root'
// TODO: oh man, this is a mess ... each notification type should just be a component ...
function Notification ({ n }) {
@ -132,7 +133,9 @@ function Notification ({ n }) {
? <Item item={n.item} />
: (
<div className='pb-2'>
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
<RootProvider root={n.item.root}>
<Comment item={n.item} noReply includeParent rootText={n.__typename === 'Reply' ? 'replying on:' : undefined} clickToContext />
</RootProvider>
</div>)}
</div>
</>)}

View File

@ -1,4 +1,3 @@
import React from 'react'
import { useQuery } from '@apollo/client'
import AccordianItem from './accordian-item'
import Item, { ItemSkeleton } from './item'

View File

@ -8,10 +8,12 @@ import { useMe } from './me'
import { abbrNum } from '../lib/format'
import { useShowModal } from './modal'
import FundError from './fund-error'
import { useRoot } from './root'
export default function PayBounty ({ children, item }) {
const me = useMe()
const showModal = useShowModal()
const root = useRoot()
const [act] = useMutation(
gql`
@ -48,7 +50,7 @@ export default function PayBounty ({ children, item }) {
// update root bounty status
cache.modify({
id: `Item:${item.root.id}`,
id: `Item:${root.id}`,
fields: {
bountyPaidTo (existingPaidTo = []) {
return [...(existingPaidTo || []), Number(item.id)]
@ -62,11 +64,11 @@ export default function PayBounty ({ children, item }) {
const handlePayBounty = async () => {
try {
await act({
variables: { id: item.id, sats: item.root.bounty },
variables: { id: item.id, sats: root.bounty },
optimisticResponse: {
act: {
id: `Item:${item.id}`,
sats: item.root.bounty
sats: root.bounty
}
}
})
@ -81,14 +83,14 @@ export default function PayBounty ({ children, item }) {
}
}
if (!me || item.mine || item.root.user.name !== me.name) {
if (!me || item.mine || root.user.name !== me.name) {
return null
}
return (
<ActionTooltip
notForm
overlayText={`${item.root.bounty} sats`}
overlayText={`${root.bounty} sats`}
>
<ModalButton
clicker={
@ -102,7 +104,7 @@ export default function PayBounty ({ children, item }) {
</div>
<div className='text-center'>
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty()}>
pay <small>{abbrNum(item.root.bounty)} sats</small>
pay <small>{abbrNum(root.bounty)} sats</small>
</Button>
</div>
</ModalButton>

15
components/root.js Normal file
View File

@ -0,0 +1,15 @@
import { useContext, createContext } from 'react'
export const RootContext = createContext()
export function RootProvider ({ root, children }) {
return (
<RootContext.Provider value={root}>
{children}
</RootContext.Provider>
)
}
export function useRoot () {
return useContext(RootContext)
}

View File

@ -3,8 +3,8 @@ import { ItemSkeleton } from './item'
import styles from './items.module.css'
import { ITEM_SEARCH } from '../fragments/items'
import MoreFooter from './more-footer'
import React from 'react'
import Comment from './comment'
import { Fragment } from 'react'
import { CommentFlat } from './comment'
import ItemFull from './item-full'
export default function SearchItems ({ variables, items, pins, cursor }) {
@ -22,11 +22,11 @@ export default function SearchItems ({ variables, items, pins, cursor }) {
<>
<div className={styles.grid}>
{items.map((item, i) => (
<React.Fragment key={item.id}>
<Fragment key={item.id}>
{item.parentId
? <><div /><div className='pb-3'><Comment item={item} noReply includeParent /></div></>
? <><div /><CommentFlat item={item} noReply includeParent /></>
: <><div /><div className={item.text ? 'pb-3' : ''}><ItemFull item={item} noReply /></div></>}
</React.Fragment>
</Fragment>
))}
</div>
<MoreFooter

View File

@ -26,19 +26,6 @@ export const COMMENT_FIELDS = gql`
mine
otsHash
ncomments
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
id
}
}
}
`
@ -50,6 +37,19 @@ export const MORE_FLAT_COMMENTS = gql`
cursor
comments {
...CommentFields
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
id
}
}
}
}
}
@ -63,6 +63,19 @@ export const TOP_COMMENTS = gql`
cursor
comments {
...CommentFields
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
id
}
}
}
}
}

View File

@ -72,6 +72,19 @@ export const SUB_FLAT_COMMENTS = gql`
cursor
comments {
...CommentFields
root {
id
title
bounty
bountyPaidTo
subName
user {
name
streak
hideCowboyHat
id
}
}
}
}
}

View File

@ -12,8 +12,8 @@ import ThumbDown from '../svgs/thumb-down-fill.svg'
import { Checkbox, Form } from '../components/form'
import { useRouter } from 'next/router'
import Item from '../components/item'
import Comment from '../components/comment'
import React from 'react'
import { CommentFlat } from '../components/comment'
import { Fragment } from 'react'
import ItemJob from '../components/item-job'
export const getServerSideProps = getGetServerSideProps(WALLET_HISTORY)
@ -134,7 +134,7 @@ function Detail ({ fact }) {
return <div className={styles.itemWrapper}><Item item={fact.item} /></div>
}
return <div className={styles.commentWrapper}><Comment item={fact.item} includeParent noReply truncate /></div>
return <div className={styles.commentWrapper}><CommentFlat item={fact.item} includeParent noReply truncate /></div>
}
export default function Satistics ({ data: { walletHistory: { facts, cursor } } }) {
@ -231,7 +231,7 @@ export default function Satistics ({ data: { walletHistory: { facts, cursor } }
<tbody>
{facts.map((f, i) => {
const uri = href(f)
const Wrapper = uri ? Link : ({ href, ...props }) => <React.Fragment {...props} />
const Wrapper = uri ? Link : ({ href, ...props }) => <Fragment {...props} />
return (
<Wrapper href={uri} key={f.id}>
<tr className={styles.row}>

View File

@ -0,0 +1,26 @@
CREATE OR REPLACE FUNCTION item_comments(_item_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 AS "createdAt", "Item".updated_at AS "updatedAt", '
|| ' item_comments("Item".id, $2 - 1, $3, $4) AS comments '
|| ' FROM "Item" p '
|| ' JOIN "Item" ON "Item"."parentId" = p.id '
|| ' WHERE p.id = $1 '
|| _where || ' '
|| _order_by
|| ' ) sub'
INTO result USING _item_id, _level, _where, _order_by;
RETURN result;
END
$$;