360 lines
13 KiB
JavaScript
360 lines
13 KiB
JavaScript
import Link from 'next/link'
|
|
import { useRouter } from 'next/router'
|
|
import { useEffect, useState } from 'react'
|
|
import Badge from 'react-bootstrap/Badge'
|
|
import Dropdown from 'react-bootstrap/Dropdown'
|
|
import Countdown from './countdown'
|
|
import { abbrNum, numWithUnits } from '@/lib/format'
|
|
import { newComments, commentsViewedAt } from '@/lib/new-comments'
|
|
import { datePivot, timeSince } from '@/lib/time'
|
|
import { DeleteDropdownItem } from './delete'
|
|
import styles from './item.module.css'
|
|
import { useMe } from './me'
|
|
import DontLikeThisDropdownItem, { OutlawDropdownItem } from './dont-link-this'
|
|
import BookmarkDropdownItem from './bookmark'
|
|
import SubscribeDropdownItem from './subscribe'
|
|
import { CopyLinkDropdownItem, CrosspostDropdownItem } from './share'
|
|
import Badges from './badge'
|
|
import { USER_ID } from '@/lib/constants'
|
|
import ActionDropdown from './action-dropdown'
|
|
import MuteDropdownItem from './mute'
|
|
import { DropdownItemUpVote } from './upvote'
|
|
import { useRoot } from './root'
|
|
import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
|
|
import UserPopover from './user-popover'
|
|
import { useQrPayment } from './payment'
|
|
import { useRetryCreateItem } from './use-item-submit'
|
|
import { useToast } from './toast'
|
|
import { useShowModal } from './modal'
|
|
import classNames from 'classnames'
|
|
|
|
export default function ItemInfo ({
|
|
item, full, commentsText = 'comments',
|
|
commentTextSingular = 'comment', className, embellishUser, extraInfo, edit, toggleEdit, editText,
|
|
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true,
|
|
setDisableRetry, disableRetry
|
|
}) {
|
|
const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { minutes: 10 })
|
|
const { me } = useMe()
|
|
const router = useRouter()
|
|
const [hasNewComments, setHasNewComments] = useState(false)
|
|
const root = useRoot()
|
|
const sub = item?.sub || root?.sub
|
|
|
|
useEffect(() => {
|
|
if (!full) {
|
|
setHasNewComments(newComments(item))
|
|
}
|
|
}, [item])
|
|
|
|
// allow anon edits if they have the correct hmac for the item invoice
|
|
// (the server will verify the hmac)
|
|
const [anonEdit, setAnonEdit] = useState(false)
|
|
useEffect(() => {
|
|
const invParams = window.localStorage.getItem(`item:${item.id}:hash:hmac`)
|
|
setAnonEdit(!!invParams && !me && Number(item.user.id) === USER_ID.anon)
|
|
}, [])
|
|
|
|
// deleted items can never be edited and every item has a 10 minute edit window
|
|
// except bios, they can always be edited but they should never show the countdown
|
|
const noEdit = !!item.deletedAt || (Date.now() >= editThreshold) || item.bio
|
|
const canEdit = !noEdit && ((me && item.mine) || anonEdit)
|
|
|
|
// 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)
|
|
const meSats = (me ? item.meSats : item.meAnonSats) || 0
|
|
|
|
return (
|
|
<div className={className || `${styles.other}`}>
|
|
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === USER_ID.ad) &&
|
|
<>
|
|
<span title={`from ${numWithUnits(item.upvotes, {
|
|
abbreviate: false,
|
|
unitSingular: 'stacker',
|
|
unitPlural: 'stackers'
|
|
})} ${item.mine
|
|
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
|
|
: `(${numWithUnits(meSats, { abbreviate: false })}${item.meDontLikeSats
|
|
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
|
|
: ''} from me)`} `}
|
|
>
|
|
{numWithUnits(item.sats)}
|
|
</span>
|
|
<span> \ </span>
|
|
</>}
|
|
{item.boost > 0 &&
|
|
<>
|
|
<span>{abbrNum(item.boost)} boost</span>
|
|
<span> \ </span>
|
|
</>}
|
|
<Link
|
|
href={`/items/${item.id}`} onClick={(e) => {
|
|
const viewedAt = commentsViewedAt(item)
|
|
if (viewedAt) {
|
|
e.preventDefault()
|
|
router.push(
|
|
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
|
|
`/items/${item.id}`)
|
|
}
|
|
}} title={numWithUnits(item.commentSats)} className='text-reset position-relative'
|
|
>
|
|
{numWithUnits(item.ncomments, {
|
|
abbreviate: false,
|
|
unitPlural: commentsText,
|
|
unitSingular: commentTextSingular
|
|
})}
|
|
{hasNewComments &&
|
|
<span className={styles.notification}>
|
|
<span className='invisible'>{' '}</span>
|
|
</span>}
|
|
</Link>
|
|
<span> \ </span>
|
|
<span>
|
|
{showUser &&
|
|
<Link href={`/${item.user.name}`}>
|
|
<UserPopover name={item.user.name}>@{item.user.name}</UserPopover>
|
|
<Badges badgeClassName='fill-grey' spacingClassName='ms-xs' height={12} width={12} user={item.user} />
|
|
{embellishUser}
|
|
</Link>}
|
|
<span> </span>
|
|
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
|
|
{timeSince(new Date(item.createdAt))}
|
|
</Link>
|
|
{item.prior &&
|
|
<>
|
|
<span> \ </span>
|
|
<Link href={`/items/${item.prior}`} className='text-reset'>
|
|
yesterday
|
|
</Link>
|
|
</>}
|
|
</span>
|
|
{item.subName &&
|
|
<Link href={`/~${item.subName}`}>
|
|
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
|
|
</Link>}
|
|
{sub?.nsfw &&
|
|
<Badge className={styles.newComment} bg={null}>nsfw</Badge>}
|
|
{(item.outlawed && !item.mine &&
|
|
<Link href='/recent/outlawed'>
|
|
{' '}<Badge className={styles.newComment} bg={null}>outlawed</Badge>
|
|
</Link>) ||
|
|
(item.freebie && !item.position &&
|
|
<Link href='/recent/freebies'>
|
|
{' '}<Badge className={styles.newComment} bg={null}>freebie</Badge>
|
|
</Link>
|
|
)}
|
|
{(item.apiKey &&
|
|
<>{' '}<Badge className={styles.newComment} bg={null}>bot</Badge></>
|
|
)}
|
|
{extraBadges}
|
|
{
|
|
showActionDropdown &&
|
|
<>
|
|
<EditInfo
|
|
item={item} edit={edit} canEdit={canEdit}
|
|
setCanEdit={setAnonEdit} toggleEdit={toggleEdit} editText={editText} editThreshold={editThreshold}
|
|
/>
|
|
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
|
|
<ActionDropdown>
|
|
<CopyLinkDropdownItem item={item} />
|
|
<InfoDropdownItem item={item} />
|
|
{(item.parentId || item.text) && onQuoteReply &&
|
|
<Dropdown.Item onClick={onQuoteReply}>quote reply</Dropdown.Item>}
|
|
{me && <BookmarkDropdownItem item={item} />}
|
|
{me && <SubscribeDropdownItem item={item} />}
|
|
{item.otsHash &&
|
|
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
|
|
opentimestamp
|
|
</Link>}
|
|
{item?.noteId && (
|
|
<Dropdown.Item onClick={() => window.open(`https://njump.me/${item.noteId}`, '_blank', 'noopener,noreferrer,nofollow')}>
|
|
nostr note
|
|
</Dropdown.Item>
|
|
)}
|
|
{item && item.mine && !item.noteId && !item.isJob && !item.parentId &&
|
|
<CrosspostDropdownItem item={item} />}
|
|
{me && !item.position &&
|
|
!item.mine && !item.deletedAt &&
|
|
(item.meDontLikeSats > meSats
|
|
? <DropdownItemUpVote item={item} />
|
|
: <DontLikeThisDropdownItem item={item} />)}
|
|
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
|
|
<>
|
|
<hr className='dropdown-divider' />
|
|
<OutlawDropdownItem item={item} />
|
|
</>}
|
|
{item.mine && item.invoice?.id &&
|
|
<>
|
|
<hr className='dropdown-divider' />
|
|
<Link href={`/invoices/${item.invoice?.id}`} className='text-reset dropdown-item'>
|
|
view invoice
|
|
</Link>
|
|
</>}
|
|
{me && !nested && !item.mine && sub && Number(me.id) !== Number(sub.userId) &&
|
|
<>
|
|
<hr className='dropdown-divider' />
|
|
<MuteSubDropdownItem item={item} sub={sub} />
|
|
</>}
|
|
{canPin &&
|
|
<>
|
|
<hr className='dropdown-divider' />
|
|
<PinSubDropdownItem 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' />
|
|
<MuteDropdownItem user={item.user} />
|
|
</>}
|
|
</ActionDropdown>
|
|
</>
|
|
}
|
|
{extraInfo}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InfoDropdownItem ({ item }) {
|
|
const { me } = useMe()
|
|
const showModal = useShowModal()
|
|
|
|
const onClick = () => {
|
|
showModal((onClose) => {
|
|
return (
|
|
<div className={styles.details}>
|
|
<div>id</div>
|
|
<div>{item.id}</div>
|
|
<div>created at</div>
|
|
<div>{item.createdAt}</div>
|
|
<div>cost</div>
|
|
<div>{item.cost}</div>
|
|
<div>sats</div>
|
|
<div>{item.sats}</div>
|
|
{me && (
|
|
<>
|
|
<div>sats from me</div>
|
|
<div>{item.meSats}</div>
|
|
</>
|
|
)}
|
|
<div>zappers</div>
|
|
<div>{item.upvotes}</div>
|
|
</div>
|
|
)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Dropdown.Item onClick={onClick}>
|
|
details
|
|
</Dropdown.Item>
|
|
)
|
|
}
|
|
|
|
function PaymentInfo ({ item, disableRetry, setDisableRetry }) {
|
|
const { me } = useMe()
|
|
const toaster = useToast()
|
|
const retryCreateItem = useRetryCreateItem({ id: item.id })
|
|
const waitForQrPayment = useQrPayment()
|
|
const [disableInfoRetry, setDisableInfoRetry] = useState(disableRetry)
|
|
if (item.deletedAt) return null
|
|
|
|
const disableDualRetry = disableRetry || disableInfoRetry
|
|
function setDisableDualRetry (value) {
|
|
setDisableInfoRetry(value)
|
|
setDisableRetry?.(value)
|
|
}
|
|
|
|
let Component
|
|
let onClick
|
|
if (me && item.invoice?.actionState && item.invoice?.actionState !== 'PAID') {
|
|
if (item.invoice?.actionState === 'FAILED') {
|
|
Component = () => <span className={classNames('text-warning', disableDualRetry && 'pulse')}>retry payment</span>
|
|
onClick = async () => {
|
|
if (disableDualRetry) return
|
|
setDisableDualRetry(true)
|
|
try {
|
|
const { error } = await retryCreateItem({ variables: { invoiceId: parseInt(item.invoice?.id) } })
|
|
if (error) throw error
|
|
} catch (error) {
|
|
toaster.danger(error.message)
|
|
} finally {
|
|
setDisableDualRetry(false)
|
|
}
|
|
}
|
|
} else {
|
|
Component = () => (
|
|
<span
|
|
className='text-info'
|
|
>pending
|
|
</span>
|
|
)
|
|
onClick = () => waitForQrPayment({ id: item.invoice?.id }, null, { cancelOnClose: false }).catch(console.error)
|
|
}
|
|
} else {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<span> \ </span>
|
|
<span
|
|
className='text-reset pointer fw-bold'
|
|
onClick={onClick}
|
|
>
|
|
<Component />
|
|
</span>
|
|
</>
|
|
)
|
|
}
|
|
|
|
function EditInfo ({ item, edit, canEdit, setCanEdit, toggleEdit, editText, editThreshold }) {
|
|
const router = useRouter()
|
|
|
|
if (canEdit) {
|
|
return (
|
|
<>
|
|
<span> \ </span>
|
|
<span
|
|
className='text-reset pointer fw-bold'
|
|
onClick={() => toggleEdit ? toggleEdit() : router.push(`/items/${item.id}/edit`)}
|
|
>
|
|
<span>{editText || 'edit'} </span>
|
|
{(!item.invoice?.actionState || item.invoice?.actionState === 'PAID')
|
|
? <Countdown
|
|
date={editThreshold}
|
|
onComplete={() => { setCanEdit(false) }}
|
|
/>
|
|
: <span>10:00</span>}
|
|
</span>
|
|
</>
|
|
)
|
|
}
|
|
|
|
if (edit && !canEdit) {
|
|
// if we're still editing after timer ran out
|
|
return (
|
|
<>
|
|
<span> \ </span>
|
|
<span
|
|
className='text-reset pointer fw-bold'
|
|
onClick={() => toggleEdit ? toggleEdit() : router.push(`/items/${item.id}`)}
|
|
>
|
|
<span>cancel </span>
|
|
<span>00:00</span>
|
|
</span>
|
|
</>
|
|
)
|
|
}
|
|
|
|
return null
|
|
}
|