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:
Felipe Bueno 2024-05-15 14:05:50 -03:00 committed by GitHub
parent 691818e779
commit 471888563e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 247 additions and 140 deletions

View File

@ -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>
)
}

View File

@ -1,8 +1,8 @@
.userPopover { .hoverablePopover {
border: 1px solid var(--theme-toolbarActive) border: 1px solid var(--theme-toolbarActive)
} }
.userPopBody { .hoverablePopBody {
font-weight: 500; font-weight: 500;
font-size: 0.9rem; font-size: 0.9rem;
} }

View File

@ -26,7 +26,7 @@ import UserPopover from './user-popover'
export default function ItemInfo ({ export default function ItemInfo ({
item, full, commentsText = 'comments', item, full, commentsText = 'comments',
commentTextSingular = 'comment', className, embellishUser, extraInfo, onEdit, editText, 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 editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
const me = useMe() 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) && {!(item.position && (pinnable || !item.subName)) && !(!item.parentId && Number(item.user?.id) === AD_USER_ID) &&
<> <>
<span title={`from ${numWithUnits(item.upvotes, { <span title={`from ${numWithUnits(item.upvotes, {
abbreviate: false, abbreviate: false,
unitSingular: 'stacker', unitSingular: 'stacker',
unitPlural: 'stackers' unitPlural: 'stackers'
})} ${item.mine })} ${item.mine
? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post` ? `\\ ${numWithUnits(item.meSats, { abbreviate: false })} to post`
: `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats : `(${numWithUnits(meTotalSats, { abbreviate: false })}${item.meDontLikeSats
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}` ? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
@ -102,12 +102,13 @@ export default function ItemInfo ({
</Link> </Link>
<span> \ </span> <span> \ </span>
<span> <span>
<UserPopover name={item.user.name}> {showUser &&
<Link href={`/${item.user.name}`}> <UserPopover name={item.user.name}>
@{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} /> <Link href={`/${item.user.name}`}>
{embellishUser} @{item.user.name}<span> </span><Hat className='fill-grey' user={item.user} height={12} width={12} />
</Link> {embellishUser}
</UserPopover> </Link>
</UserPopover>}
<span> </span> <span> </span>
<Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning> <Link href={`/items/${item.id}`} title={item.createdAt} className='text-reset' suppressHydrationWarning>
{timeSince(new Date(item.createdAt))} {timeSince(new Date(item.createdAt))}
@ -152,54 +153,57 @@ export default function ItemInfo ({
/> />
</span> </span>
</>} </>}
<ActionDropdown> {
<CopyLinkDropdownItem item={item} /> showActionDropdown &&
{(item.parentId || item.text) && onQuoteReply && <ActionDropdown>
<Dropdown.Item onClick={onQuoteReply}>quote reply</Dropdown.Item>} <CopyLinkDropdownItem item={item} />
{me && <BookmarkDropdownItem item={item} />} {(item.parentId || item.text) && onQuoteReply &&
{me && <SubscribeDropdownItem item={item} />} <Dropdown.Item onClick={onQuoteReply}>quote reply</Dropdown.Item>}
{item.otsHash && {me && <BookmarkDropdownItem item={item} />}
<Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'> {me && <SubscribeDropdownItem item={item} />}
opentimestamp {item.otsHash &&
</Link>} <Link href={`/items/${item.id}/ots`} className='text-reset dropdown-item'>
{item?.noteId && ( opentimestamp
<Dropdown.Item onClick={() => window.open(`https://njump.me/${item.noteId}`, '_blank', 'noopener,noreferrer,nofollow')}> </Link>}
nostr note {item?.noteId && (
</Dropdown.Item> <Dropdown.Item onClick={() => window.open(`https://njump.me/${item.noteId}`, '_blank', 'noopener,noreferrer,nofollow')}>
)} nostr note
{item && item.mine && !item.noteId && !item.isJob && !item.parentId && </Dropdown.Item>
<CrosspostDropdownItem item={item} />} )}
{me && !item.position && {item && item.mine && !item.noteId && !item.isJob && !item.parentId &&
!item.mine && !item.deletedAt && <CrosspostDropdownItem item={item} />}
(item.meDontLikeSats > meTotalSats {me && !item.position &&
? <DropdownItemUpVote item={item} /> !item.mine && !item.deletedAt &&
: <DontLikeThisDropdownItem id={item.id} />)} (item.meDontLikeSats > meTotalSats
{me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated && ? <DropdownItemUpVote item={item} />
<> : <DontLikeThisDropdownItem id={item.id} />)}
<hr className='dropdown-divider' /> {me && sub && !item.mine && !item.outlawed && Number(me.id) === Number(sub.userId) && sub.moderated &&
<OutlawDropdownItem item={item} /> <>
</>} <hr className='dropdown-divider' />
{me && !nested && !item.mine && sub && Number(me.id) !== Number(sub.userId) && <OutlawDropdownItem item={item} />
<> </>}
<hr className='dropdown-divider' /> {me && !nested && !item.mine && sub && Number(me.id) !== Number(sub.userId) &&
<MuteSubDropdownItem item={item} sub={sub} /> <>
</>} <hr className='dropdown-divider' />
{canPin && <MuteSubDropdownItem item={item} sub={sub} />
<> </>}
<hr className='dropdown-divider' /> {canPin &&
<PinSubDropdownItem item={item} /> <>
</>} <hr className='dropdown-divider' />
{item.mine && !item.position && !item.deletedAt && !item.bio && <PinSubDropdownItem item={item} />
<> </>}
<hr className='dropdown-divider' /> {item.mine && !item.position && !item.deletedAt && !item.bio &&
<DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} /> <>
</>} <hr className='dropdown-divider' />
{me && !item.mine && <DeleteDropdownItem itemId={item.id} type={item.title ? 'post' : 'comment'} />
<> </>}
<hr className='dropdown-divider' /> {me && !item.mine &&
<MuteDropdownItem user={item.user} /> <>
</>} <hr className='dropdown-divider' />
</ActionDropdown> <MuteDropdownItem user={item.user} />
</>}
</ActionDropdown>
}
{extraInfo} {extraInfo}
</div> </div>
) )

View File

@ -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} />}
/>
)
}

View File

@ -18,6 +18,26 @@ import { Badge } from 'react-bootstrap'
import AdIcon from '@/svgs/advertisement-fill.svg' import AdIcon from '@/svgs/advertisement-fill.svg'
import { DownZap } from './dont-link-this' import { DownZap } from './dont-link-this'
import { timeLeft } from '@/lib/time' 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 }) { export function SearchTitle ({ title }) {
return reactStringReplace(title, /\*\*\*([^*]+)\*\*\*/g, (match, i) => { 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`}> <div className={`${styles.main} flex-wrap`}>
<Link <Link
href={`/items/${item.id}`} href={`/items/${item.id}`}
onClick={(e) => { onClick={(e) => onItemClick(e, router, item)}
const viewedAt = commentsViewedAt(item) ref={titleRef}
if (viewedAt) { className={`${styles.title} text-reset me-2`}
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`}
> >
{item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title} {item.searchTitle ? <SearchTitle title={item.searchTitle} /> : item.title}
{item.pollCost && <PollIndicator item={item} />} {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 ( return (
<> <>
{rank {rank
@ -118,7 +165,7 @@ export function ItemSkeleton ({ rank, children }) {
</div>) </div>)
: <div />} : <div />}
<div className={`${styles.item} ${styles.skeleton}`}> <div className={`${styles.item} ${styles.skeleton}`}>
<UpVote className={styles.upvote} /> {showUpvote && <UpVote className={styles.upvote} />}
<div className={styles.hunk}> <div className={styles.hunk}>
<div className={`${styles.main} flex-wrap flex-md-nowrap`}> <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`} /> <span className={`${styles.title} clouds text-reset flex-md-fill flex-md-shrink-0 me-2`} />
@ -149,11 +196,10 @@ function PollIndicator ({ item }) {
return ( return (
<span className={styles.icon} title={isActive ? 'active' : 'results in'}> <span className={styles.icon} title={isActive ? 'active' : 'results in'}>
<PollIcon <PollIcon
className={`${ className={`${isActive
isActive ? 'fill-success'
? 'fill-success'
: 'fill-grey' : 'fill-grey'
} ms-1`} height={14} width={14} } ms-1`} height={14} width={14}
/> />
</span> </span>
) )

View File

@ -6,6 +6,14 @@
margin-bottom: .15rem; margin-bottom: .15rem;
} }
.summaryText {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.notification { .notification {
position: absolute; position: absolute;
padding: 3px; padding: 3px;

View File

@ -22,6 +22,7 @@ import Link from 'next/link'
import { UNKNOWN_LINK_REL } from '@/lib/constants' import { UNKNOWN_LINK_REL } from '@/lib/constants'
import isEqual from 'lodash/isEqual' import isEqual from 'lodash/isEqual'
import UserPopover from './user-popover' import UserPopover from './user-popover'
import ItemPopover from './item-popover'
export function SearchText ({ text }) { export function SearchText ({ text }) {
return ( return (
@ -227,7 +228,11 @@ export default memo(function Text ({ rel, imgproxyUrls, children, tab, itemId, o
try { try {
const linkText = parseInternalLinks(href) const linkText = parseInternalLinks(href)
if (linkText) { if (linkText) {
return <Link href={href}>{linkText}</Link> return (
<ItemPopover id={linkText.replace('#', '').split('/')[0]}>
<Link href={href}>{linkText}</Link>
</ItemPopover>
)
} }
} catch { } catch {
// ignore errors like invalid URLs // ignore errors like invalid URLs

View File

@ -1,28 +1,28 @@
import { USER } from '@/fragments/users' import { USER } from '@/fragments/users'
import errorStyles from '@/styles/error.module.css' import errorStyles from '@/styles/error.module.css'
import { useLazyQuery } from '@apollo/client' 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 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 }) { function StackingSince ({ since }) {
return ( return (
<small className='text-muted d-flex-inline'> <small className='text-muted d-flex-inline'>
stacking since:{' '} stacking since:{' '}
{since {since
? <Link href={`/items/${since}`}>#{since}</Link> ? (
<ItemPopover id={since}>
<Link href={`/items/${since}`}>#{since}</Link>
</ItemPopover>
)
: <span>never</span>} : <span>never</span>}
</small> </small>
) )
} }
export default function UserPopover ({ name, children }) { export default function UserPopover ({ name, children }) {
const [showOverlay, setShowOverlay] = useState(false)
const [getUser, { loading, data }] = useLazyQuery( const [getUser, { loading, data }] = useLazyQuery(
USER, 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 ( return (
<OverlayTrigger <HoverablePopover
show={showOverlay} onShow={getUser}
placement='bottom' trigger={children}
onHide={handleMouseLeave} body={!data || loading
overlay={ ? <UserSkeleton />
<Popover : !data.user
onMouseEnter={handleMouseEnter} ? <h1 className={classNames(errorStyles.status, errorStyles.describe)}>USER NOT FOUND</h1>
onMouseLeave={handleMouseLeave} : (
className={styles.userPopover} <UserBase user={data.user} className='mb-0 pb-0'>
> <StackingSince since={data.user.since} />
<Popover.Body className={styles.userPopBody}> </UserBase>
{!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>
) )
} }