Item popover (#1162)
* WIP Item Popover * Hide user on ItemSumarry to avoid infinite popovers * Introduce HoverablePopover * Delete itempopover & userpopover css * Fix excess bottom padding on the ItemPopover * Fix ItemSummary: Use text for itens that doesn't have a title * Handling #itemid/something links + Tweaks for rendering comment summary * refine hoverable popover --------- 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
691818e779
commit
471888563e
|
@ -0,0 +1,49 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import { Popover } from 'react-bootstrap'
|
||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
||||
import styles from './hoverable-popover.module.css'
|
||||
|
||||
export default function HoverablePopover ({ id, trigger, body, onShow }) {
|
||||
const [showOverlay, setShowOverlay] = useState(false)
|
||||
|
||||
const timeoutId = useRef(null)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
clearTimeout(timeoutId.current)
|
||||
onShow && onShow()
|
||||
timeoutId.current = setTimeout(() => {
|
||||
setShowOverlay(true)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(timeoutId.current)
|
||||
timeoutId.current = setTimeout(() => setShowOverlay(false), 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
show={showOverlay}
|
||||
placement='bottom'
|
||||
onHide={handleMouseLeave}
|
||||
overlay={
|
||||
<Popover
|
||||
onPointerEnter={handleMouseEnter}
|
||||
onPointerLeave={handleMouseLeave}
|
||||
className={styles.HoverablePopover}
|
||||
>
|
||||
<Popover.Body className={styles.HoverablePopover}>
|
||||
{body}
|
||||
</Popover.Body>
|
||||
</Popover>
|
||||
}
|
||||
>
|
||||
<span
|
||||
onPointerEnter={handleMouseEnter}
|
||||
onPointerLeave={handleMouseLeave}
|
||||
>
|
||||
{trigger}
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
)
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
.userPopover {
|
||||
.hoverablePopover {
|
||||
border: 1px solid var(--theme-toolbarActive)
|
||||
}
|
||||
|
||||
.userPopBody {
|
||||
.hoverablePopBody {
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
|
@ -26,7 +26,7 @@ import UserPopover from './user-popover'
|
|||
export default function ItemInfo ({
|
||||
item, full, commentsText = 'comments',
|
||||
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText,
|
||||
onQuoteReply, extraBadges, nested, pinnable
|
||||
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true
|
||||
}) {
|
||||
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
|
||||
const me = useMe()
|
||||
|
@ -61,10 +61,10 @@ export default function ItemInfo ({
|
|||
{!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) &&
|
||||
<>
|
||||
<span title={`from ${numWithUnits(item.upvotes, {
|
||||
abbreviate: false,
|
||||
unitSingular: 'stacker',
|
||||
unitPlural: 'stackers'
|
||||
})} ${item.mine
|
||||
abbreviate: false,
|
||||
unitSingular: 'stacker',
|
||||
unitPlural: 'stackers'
|
||||
})} ${item.mine
|
||||
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
|
||||
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats
|
||||
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
|
||||
|
@ -102,12 +102,13 @@ export default function ItemInfo ({
|
|||
</Link>
|
||||
<span> \ </span>
|
||||
<span>
|
||||
<UserPopover name={item.user.name}>
|
||||
<Link href={`/${item.user.name}`}>
|
||||
@{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} />
|
||||
{embellishUser}
|
||||
</Link>
|
||||
</UserPopover>
|
||||
{showUser &&
|
||||
<UserPopover name={item.user.name}>
|
||||
<Link href={`/${item.user.name}`}>
|
||||
@{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} />
|
||||
{embellishUser}
|
||||
</Link>
|
||||
</UserPopover>}
|
||||
<span> </span>
|
||||
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
|
||||
{timeSince(new Date(item.createdAt))}
|
||||
|
@ -152,54 +153,57 @@ export default function ItemInfo ({
|
|||
/>
|
||||
</span>
|
||||
</>}
|
||||
<ActionDropdown>
|
||||
<CopyLinkDropdownItem 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 > meTotalSats
|
||||
? <DropdownItemUpVote item={item} />
|
||||
: <DontLikeThisDropdownItem id={item.id} />)}
|
||||
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
|
||||
<>
|
||||
<hr className='dropdown-divider' />
|
||||
<OutlawDropdownItem item={item} />
|
||||
</>}
|
||||
{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>
|
||||
{
|
||||
showActionDropdown &&
|
||||
<ActionDropdown>
|
||||
<CopyLinkDropdownItem 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 > meTotalSats
|
||||
? <DropdownItemUpVote item={item} />
|
||||
: <DontLikeThisDropdownItem id={item.id} />)}
|
||||
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
|
||||
<>
|
||||
<hr className='dropdown-divider' />
|
||||
<OutlawDropdownItem item={item} />
|
||||
</>}
|
||||
{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>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { ITEM } from '@/fragments/items'
|
||||
import errorStyles from '@/styles/error.module.css'
|
||||
import { useLazyQuery } from '@apollo/client'
|
||||
import classNames from 'classnames'
|
||||
import HoverablePopover from './hoverable-popover'
|
||||
import { ItemSkeleton, ItemSummary } from './item'
|
||||
|
||||
export default function ItemPopover ({ id, children }) {
|
||||
const [getItem, { loading, data }] = useLazyQuery(
|
||||
ITEM,
|
||||
{
|
||||
variables: { id },
|
||||
fetchPolicy: 'cache-first'
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<HoverablePopover
|
||||
onShow={getItem}
|
||||
trigger={children}
|
||||
body={!data || loading
|
||||
? <ItemSkeleton showUpvote={false} />
|
||||
: !data.item
|
||||
? <h1 className={classNames(errorStyles.status, errorStyles.describe)}>ITEM NOT FOUND</h1>
|
||||
: <ItemSummary item={data.item} />}
|
||||
/>
|
||||
)
|
||||
}
|
|
@ -18,6 +18,26 @@ import { Badge } from 'react-bootstrap'
|
|||
import AdIcon from '@/svgs/advertisement-fill.svg'
|
||||
import { DownZap } from './dont-link-this'
|
||||
import { timeLeft } from '@/lib/time'
|
||||
import classNames from 'classnames'
|
||||
import removeMd from 'remove-markdown'
|
||||
|
||||
function onItemClick (e, router, item) {
|
||||
const viewedAt = commentsViewedAt(item)
|
||||
if (viewedAt) {
|
||||
e.preventDefault()
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
window.open(
|
||||
`/items/${item.id}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
} else {
|
||||
router.push(
|
||||
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
|
||||
`/items/${item.id}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function SearchTitle ({ title }) {
|
||||
return reactStringReplace(title, /\*\*\*([^*]+)\*\*\*/g, (match, i) => {
|
||||
|
@ -51,23 +71,9 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
|||
<div className={`${styles.main} flex-wrap`}>
|
||||
<Link
|
||||
href={`/items/${item.id}`}
|
||||
onClick={(e) => {
|
||||
const viewedAt = commentsViewedAt(item)
|
||||
if (viewedAt) {
|
||||
e.preventDefault()
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
window.open(
|
||||
`/items/${item.id}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
} else {
|
||||
router.push(
|
||||
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
|
||||
`/items/${item.id}`)
|
||||
}
|
||||
}
|
||||
}} ref={titleRef} className={`${styles.title} text-reset me-2`}
|
||||
onClick={(e) => onItemClick(e, router, item)}
|
||||
ref={titleRef}
|
||||
className={`${styles.title} text-reset me-2`}
|
||||
>
|
||||
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
|
||||
{item.pollCost && <PollIndicator item={item} />}
|
||||
|
@ -108,7 +114,48 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
|
|||
)
|
||||
}
|
||||
|
||||
export function ItemSkeleton ({ rank, children }) {
|
||||
export function ItemSummary ({ item }) {
|
||||
const router = useRouter()
|
||||
const link = (
|
||||
<Link
|
||||
href={`/items/${item.id}`}
|
||||
onClick={(e) => onItemClick(e, router, item)}
|
||||
className={`${item.title && styles.title} ${styles.summaryText} text-reset me-2`}
|
||||
>
|
||||
{item.title ?? removeMd(item.text)}
|
||||
</Link>
|
||||
)
|
||||
const info = (
|
||||
<ItemInfo
|
||||
item={item}
|
||||
showUser={false}
|
||||
showActionDropdown={false}
|
||||
extraBadges={item.title && Number(item?.user?.id) === AD_USER_ID && <Badge className={styles.newComment} bg={null}>AD</Badge>}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.item, 'mb-0 pb-0')}>
|
||||
<div className={styles.hunk}>
|
||||
{item.title
|
||||
? (
|
||||
<>
|
||||
{link}
|
||||
{info}
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{info}
|
||||
{link}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ItemSkeleton ({ rank, children, showUpvote = true }) {
|
||||
return (
|
||||
<>
|
||||
{rank
|
||||
|
@ -118,7 +165,7 @@ export function ItemSkeleton ({ rank, children }) {
|
|||
</div>)
|
||||
: <div />}
|
||||
<div className={`${styles.item} ${styles.skeleton}`}>
|
||||
<UpVote className={styles.upvote} />
|
||||
{showUpvote && <UpVote className={styles.upvote} />}
|
||||
<div className={styles.hunk}>
|
||||
<div className={`${styles.main} flex-wrap flex-md-nowrap`}>
|
||||
<span className={`${styles.title} clouds text-reset flex-md-fill flex-md-shrink-0 me-2`} />
|
||||
|
@ -149,11 +196,10 @@ function PollIndicator ({ item }) {
|
|||
return (
|
||||
<span className={styles.icon} title={isActive ? 'active' : 'results in'}>
|
||||
<PollIcon
|
||||
className={`${
|
||||
isActive
|
||||
? 'fill-success'
|
||||
className={`${isActive
|
||||
? 'fill-success'
|
||||
: 'fill-grey'
|
||||
} ms-1`} height={14} width={14}
|
||||
} ms-1`} height={14} width={14}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
|
|
|
@ -6,6 +6,14 @@
|
|||
margin-bottom: .15rem;
|
||||
}
|
||||
|
||||
.summaryText {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.notification {
|
||||
position: absolute;
|
||||
padding: 3px;
|
||||
|
|
|
@ -22,6 +22,7 @@ import Link from 'next/link'
|
|||
import { UNKNOWN_LINK_REL } from '@/lib/constants'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import UserPopover from './user-popover'
|
||||
import ItemPopover from './item-popover'
|
||||
|
||||
export function SearchText ({ text }) {
|
||||
return (
|
||||
|
@ -227,7 +228,11 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
|
|||
try {
|
||||
const linkText = parseInternalLinks(href)
|
||||
if (linkText) {
|
||||
return <Link href={href}>{linkText}</Link>
|
||||
return (
|
||||
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
|
||||
<Link href={href}>{linkText}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore errors like invalid URLs
|
||||
|
|
|
@ -1,28 +1,28 @@
|
|||
import { USER } from '@/fragments/users'
|
||||
import errorStyles from '@/styles/error.module.css'
|
||||
import { useLazyQuery } from '@apollo/client'
|
||||
import Link from 'next/link'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Popover } from 'react-bootstrap'
|
||||
import OverlayTrigger from 'react-bootstrap/OverlayTrigger'
|
||||
import { UserBase, UserSkeleton } from './user-list'
|
||||
import styles from './user-popover.module.css'
|
||||
import classNames from 'classnames'
|
||||
import Link from 'next/link'
|
||||
import HoverablePopover from './hoverable-popover'
|
||||
import ItemPopover from './item-popover'
|
||||
import { UserBase, UserSkeleton } from './user-list'
|
||||
|
||||
function StackingSince ({ since }) {
|
||||
return (
|
||||
<small className='text-muted d-flex-inline'>
|
||||
stacking since:{' '}
|
||||
{since
|
||||
? <Link href={`/items/${since}`}>#{since}</Link>
|
||||
? (
|
||||
<ItemPopover id={since}>
|
||||
<Link href={`/items/${since}`}>#{since}</Link>
|
||||
</ItemPopover>
|
||||
)
|
||||
: <span>never</span>}
|
||||
</small>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UserPopover ({ name, children }) {
|
||||
const [showOverlay, setShowOverlay] = useState(false)
|
||||
|
||||
const [getUser, { loading, data }] = useLazyQuery(
|
||||
USER,
|
||||
{
|
||||
|
@ -31,52 +31,19 @@ export default function UserPopover ({ name, children }) {
|
|||
}
|
||||
)
|
||||
|
||||
const timeoutId = useRef(null)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
clearTimeout(timeoutId.current)
|
||||
getUser()
|
||||
timeoutId.current = setTimeout(() => {
|
||||
setShowOverlay(true)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
clearTimeout(timeoutId.current)
|
||||
timeoutId.current = setTimeout(() => setShowOverlay(false), 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayTrigger
|
||||
show={showOverlay}
|
||||
placement='bottom'
|
||||
onHide={handleMouseLeave}
|
||||
overlay={
|
||||
<Popover
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={styles.userPopover}
|
||||
>
|
||||
<Popover.Body className={styles.userPopBody}>
|
||||
{!data || loading
|
||||
? <UserSkeleton />
|
||||
: !data.user
|
||||
? <h1 className={classNames(errorStyles.status, errorStyles.describe)}>USER NOT FOUND</h1>
|
||||
: (
|
||||
<UserBase user={data.user} className='mb-0 pb-0'>
|
||||
<StackingSince since={data.user.since} />
|
||||
</UserBase>
|
||||
)}
|
||||
</Popover.Body>
|
||||
</Popover>
|
||||
}
|
||||
>
|
||||
<span
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</OverlayTrigger>
|
||||
<HoverablePopover
|
||||
onShow={getUser}
|
||||
trigger={children}
|
||||
body={!data || loading
|
||||
? <UserSkeleton />
|
||||
: !data.user
|
||||
? <h1 className={classNames(errorStyles.status, errorStyles.describe)}>USER NOT FOUND</h1>
|
||||
: (
|
||||
<UserBase user={data.user} className='mb-0 pb-0'>
|
||||
<StackingSince since={data.user.since} />
|
||||
</UserBase>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue