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 domino from 'domino'
import { import {
BOOST_MIN, ITEM_SPAM_INTERVAL, 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' } from '../../lib/constants'
import { msatsToSats } from '../../lib/format' import { msatsToSats } from '../../lib/format'
import { parse } from 'tldts' import { parse } from 'tldts'
@ -27,20 +27,9 @@ async function comments (me, models, id, sort, root) {
break break
} }
const flat = await models.$queryRaw(` const filter = await filterClause(me, models)
WITH RECURSIVE base AS ( const [{ item_comments: comments }] = await models.$queryRaw('SELECT item_comments($1, $2, $3, $4)', Number(id), COMMENT_DEPTH_LIMIT, orderBy, filter)
${SELECT}, ARRAY[row_number() OVER (${orderBy}, "Item".path)] AS sort_path return comments
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]
} }
export async function getItem (parent, { id }, { me, models }) { 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 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 // we have to do our own query because ltree is unsupported
export const SELECT = export const SELECT =
`SELECT "Item".id, "Item".created_at as "createdAt", "Item".updated_at as "updatedAt", "Item".title, `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 Share from './share'
import ItemInfo from './item-info' import ItemInfo from './item-info'
import { Badge } from 'react-bootstrap' import { Badge } from 'react-bootstrap'
import { RootProvider, useRoot } from './root'
function Parent ({ item, rootText }) { function Parent ({ item, rootText }) {
const root = useRoot()
const ParentFrag = () => ( const ParentFrag = () => (
<> <>
<span> \ </span> <span> \ </span>
@ -30,20 +33,16 @@ function Parent ({ item, rootText }) {
</> </>
) )
if (!item.root) {
return <ParentFrag />
}
return ( return (
<> <>
{Number(item.root.id) !== Number(item.parentId) && <ParentFrag />} {Number(root.id) !== Number(item.parentId) && <ParentFrag />}
<span> \ </span> <span> \ </span>
<Link href={`/items/${item.root.id}`} passHref> <Link href={`/items/${root.id}`} passHref>
<a className='text-reset'>{rootText || 'on:'} {item.root.title}</a> <a className='text-reset'>{rootText || 'on:'} {root?.title}</a>
</Link> </Link>
{item.root.subName && {root.subName &&
<Link href={`/~${item.root.subName}`}> <Link href={`/~${root.subName}`}>
<a>{' '}<Badge className={itemStyles.newComment} variant={null}>{item.root.subName}</Badge></a> <a>{' '}<Badge className={itemStyles.newComment} variant={null}>{root.subName}</Badge></a>
</Link>} </Link>}
</> </>
) )
@ -76,7 +75,9 @@ export function CommentFlat ({ item, ...props }) {
} }
}} }}
> >
<RootProvider root={item.root}>
<Comment item={item} {...props} /> <Comment item={item} {...props} />
</RootProvider>
</div> </div>
) )
} }
@ -89,6 +90,7 @@ export default function Comment ({
const [collapse, setCollapse] = useState(false) const [collapse, setCollapse] = useState(false)
const ref = useRef(null) const ref = useRef(null)
const router = useRouter() const router = useRouter()
const root = useRoot()
useEffect(() => { useEffect(() => {
if (Number(router.query.commentId) === Number(item.id)) { if (Number(router.query.commentId) === Number(item.id)) {
@ -103,9 +105,8 @@ export default function Comment ({
}, [item]) }, [item])
const bottomedOut = depth === COMMENT_DEPTH_LIMIT const bottomedOut = depth === COMMENT_DEPTH_LIMIT
const op = root.user.name === item.user.name
const op = item.root?.user.name === item.user.name const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
const bountyPaid = item.root?.bountyPaidTo?.includes(Number(item.id))
return ( return (
<div <div
@ -126,7 +127,7 @@ export default function Comment ({
<> <>
{includeParent && <Parent item={item} rootText={rootText} />} {includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid && {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} /> <BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>} </ActionTooltip>}
</> </>
@ -177,7 +178,7 @@ export default function Comment ({
<div className={`${styles.children}`}> <div className={`${styles.children}`}>
{!noReply && {!noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen}> <Reply depth={depth + 1} item={item} replyOpen={replyOpen}>
{item.root?.bounty && !bountyPaid && <PayBounty item={item} />} {root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>} </Reply>}
{children} {children}
<div className={`${styles.comments} ml-sm-1 ml-md-3`}> <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 LayoutStatic from './layout-static'
import styles from '../styles/404.module.css' import styles from '../styles/404.module.css'
class ErrorBoundary extends React.Component { class ErrorBoundary extends Component {
constructor (props) { constructor (props) {
super(props) super(props)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,10 +8,12 @@ import { useMe } from './me'
import { abbrNum } from '../lib/format' import { abbrNum } from '../lib/format'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import FundError from './fund-error' import FundError from './fund-error'
import { useRoot } from './root'
export default function PayBounty ({ children, item }) { export default function PayBounty ({ children, item }) {
const me = useMe() const me = useMe()
const showModal = useShowModal() const showModal = useShowModal()
const root = useRoot()
const [act] = useMutation( const [act] = useMutation(
gql` gql`
@ -48,7 +50,7 @@ export default function PayBounty ({ children, item }) {
// update root bounty status // update root bounty status
cache.modify({ cache.modify({
id: `Item:${item.root.id}`, id: `Item:${root.id}`,
fields: { fields: {
bountyPaidTo (existingPaidTo = []) { bountyPaidTo (existingPaidTo = []) {
return [...(existingPaidTo || []), Number(item.id)] return [...(existingPaidTo || []), Number(item.id)]
@ -62,11 +64,11 @@ export default function PayBounty ({ children, item }) {
const handlePayBounty = async () => { const handlePayBounty = async () => {
try { try {
await act({ await act({
variables: { id: item.id, sats: item.root.bounty }, variables: { id: item.id, sats: root.bounty },
optimisticResponse: { optimisticResponse: {
act: { act: {
id: `Item:${item.id}`, 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 null
} }
return ( return (
<ActionTooltip <ActionTooltip
notForm notForm
overlayText={`${item.root.bounty} sats`} overlayText={`${root.bounty} sats`}
> >
<ModalButton <ModalButton
clicker={ clicker={
@ -102,7 +104,7 @@ export default function PayBounty ({ children, item }) {
</div> </div>
<div className='text-center'> <div className='text-center'>
<Button className='mt-4' variant='primary' onClick={() => handlePayBounty()}> <Button className='mt-4' variant='primary' onClick={() => handlePayBounty()}>
pay <small>{abbrNum(item.root.bounty)} sats</small> pay <small>{abbrNum(root.bounty)} sats</small>
</Button> </Button>
</div> </div>
</ModalButton> </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 styles from './items.module.css'
import { ITEM_SEARCH } from '../fragments/items' import { ITEM_SEARCH } from '../fragments/items'
import MoreFooter from './more-footer' import MoreFooter from './more-footer'
import React from 'react' import { Fragment } from 'react'
import Comment from './comment' import { CommentFlat } from './comment'
import ItemFull from './item-full' import ItemFull from './item-full'
export default function SearchItems ({ variables, items, pins, cursor }) { export default function SearchItems ({ variables, items, pins, cursor }) {
@ -22,11 +22,11 @@ export default function SearchItems ({ variables, items, pins, cursor }) {
<> <>
<div className={styles.grid}> <div className={styles.grid}>
{items.map((item, i) => ( {items.map((item, i) => (
<React.Fragment key={item.id}> <Fragment key={item.id}>
{item.parentId {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></>} : <><div /><div className={item.text ? 'pb-3' : ''}><ItemFull item={item} noReply /></div></>}
</React.Fragment> </Fragment>
))} ))}
</div> </div>
<MoreFooter <MoreFooter

View File

@ -26,6 +26,17 @@ export const COMMENT_FIELDS = gql`
mine mine
otsHash otsHash
ncomments ncomments
}
`
export const MORE_FLAT_COMMENTS = gql`
${COMMENT_FIELDS}
query MoreFlatComments($sub: String, $sort: String!, $cursor: String, $name: String, $within: String) {
moreFlatComments(sub: $sub, sort: $sort, cursor: $cursor, name: $name, within: $within) {
cursor
comments {
...CommentFields
root { root {
id id
title title
@ -40,17 +51,6 @@ export const COMMENT_FIELDS = gql`
} }
} }
} }
`
export const MORE_FLAT_COMMENTS = gql`
${COMMENT_FIELDS}
query MoreFlatComments($sub: String, $sort: String!, $cursor: String, $name: String, $within: String) {
moreFlatComments(sub: $sub, sort: $sort, cursor: $cursor, name: $name, within: $within) {
cursor
comments {
...CommentFields
}
} }
} }
` `
@ -63,6 +63,19 @@ export const TOP_COMMENTS = gql`
cursor cursor
comments { comments {
...CommentFields ...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 cursor
comments { comments {
...CommentFields ...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 { Checkbox, Form } from '../components/form'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import Item from '../components/item' import Item from '../components/item'
import Comment from '../components/comment' import { CommentFlat } from '../components/comment'
import React from 'react' import { Fragment } from 'react'
import ItemJob from '../components/item-job' import ItemJob from '../components/item-job'
export const getServerSideProps = getGetServerSideProps(WALLET_HISTORY) 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.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 } } }) { export default function Satistics ({ data: { walletHistory: { facts, cursor } } }) {
@ -231,7 +231,7 @@ export default function Satistics ({ data: { walletHistory: { facts, cursor } }
<tbody> <tbody>
{facts.map((f, i) => { {facts.map((f, i) => {
const uri = href(f) const uri = href(f)
const Wrapper = uri ? Link : ({ href, ...props }) => <React.Fragment {...props} /> const Wrapper = uri ? Link : ({ href, ...props }) => <Fragment {...props} />
return ( return (
<Wrapper href={uri} key={f.id}> <Wrapper href={uri} key={f.id}>
<tr className={styles.row}> <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
$$;