mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-05 11:30:57 -05:00
470 lines
13 KiB
TypeScript
470 lines
13 KiB
TypeScript
import { SparklesIcon } from '@heroicons/react/solid'
|
|
import clsx from 'clsx'
|
|
import { Notification } from 'common/notifications'
|
|
import Link from 'next/link'
|
|
import { ReactNode, useState } from 'react'
|
|
import { useIsMobile } from 'web/hooks/use-is-mobile'
|
|
import { Col } from './layout/col'
|
|
import { Row } from './layout/row'
|
|
import { RelativeTimestampNoTooltip } from './relative-timestamp'
|
|
import { Linkify } from './widgets/linkify'
|
|
import { UserLink } from './widgets/user-link'
|
|
import { Avatar } from './widgets/avatar'
|
|
import { MultiUserReactionModal } from './multi-user-reaction-link'
|
|
import { sortBy } from 'lodash'
|
|
import { ENV_CONFIG } from 'common/envs/constants'
|
|
|
|
export function NotificationItem(props: { notification: Notification }) {
|
|
const { notification } = props
|
|
const { sourceType, reason } = notification
|
|
|
|
const [highlighted, setHighlighted] = useState(!notification.isSeen)
|
|
|
|
const params = {
|
|
notification,
|
|
highlighted,
|
|
setHighlighted,
|
|
}
|
|
|
|
if (sourceType === 'comment_on_profile') {
|
|
return <CommentOnProfileNotification {...params} />
|
|
} else if (sourceType === 'new_match') {
|
|
return <NewMatchNotification {...params} />
|
|
} else if (reason === 'new_profile_like') {
|
|
return <ProfileLikeNotification {...params} />
|
|
} else if (reason === 'new_profile_ship') {
|
|
return <ProfileShipNotification {...params} />
|
|
} else {
|
|
return <BaseNotification {...params}/>
|
|
}
|
|
}
|
|
|
|
export function BaseNotification(props: {
|
|
notification: Notification
|
|
highlighted: boolean
|
|
setHighlighted: (highlighted: boolean) => void
|
|
}) {
|
|
const { notification, highlighted, setHighlighted } = props
|
|
return (
|
|
<NotificationFrame
|
|
notification={notification}
|
|
highlighted={highlighted}
|
|
setHighlighted={setHighlighted}
|
|
icon={
|
|
<AvatarNotificationIcon notification={notification} />
|
|
}
|
|
subtitle={
|
|
<div className="line-clamp-5">
|
|
<Linkify text={notification.sourceText} />
|
|
</div>
|
|
}
|
|
link={notification.sourceSlug}
|
|
>
|
|
<div className="line-clamp-3">
|
|
<span>{notification.title}</span>
|
|
</div>
|
|
</NotificationFrame>
|
|
)
|
|
}
|
|
|
|
export function CommentOnProfileNotification(props: {
|
|
notification: Notification
|
|
highlighted: boolean
|
|
setHighlighted: (highlighted: boolean) => void
|
|
isChildOfGroup?: boolean
|
|
}) {
|
|
const { notification, isChildOfGroup, highlighted, setHighlighted } = props
|
|
const { sourceUserName, sourceUserUsername, sourceText } = notification
|
|
const reasonText = `commented `
|
|
return (
|
|
<NotificationFrame
|
|
notification={notification}
|
|
isChildOfGroup={isChildOfGroup}
|
|
highlighted={highlighted}
|
|
setHighlighted={setHighlighted}
|
|
icon={
|
|
<AvatarNotificationIcon notification={notification} symbol={'💬'} />
|
|
}
|
|
subtitle={
|
|
<div className="line-clamp-2">
|
|
<Linkify text={sourceText} />
|
|
</div>
|
|
}
|
|
link={notification.sourceSlug}
|
|
>
|
|
<div className="line-clamp-3">
|
|
<NotificationUserLink
|
|
name={sourceUserName}
|
|
username={sourceUserUsername}
|
|
/>{' '}
|
|
{reasonText}
|
|
{!isChildOfGroup && <span>on your profile</span>}
|
|
</div>
|
|
</NotificationFrame>
|
|
)
|
|
}
|
|
|
|
export function NewMatchNotification(props: {
|
|
notification: Notification
|
|
highlighted: boolean
|
|
setHighlighted: (highlighted: boolean) => void
|
|
isChildOfGroup?: boolean
|
|
}) {
|
|
const { notification, isChildOfGroup, highlighted, setHighlighted } = props
|
|
const {
|
|
sourceContractTitle,
|
|
sourceText,
|
|
sourceUserName,
|
|
sourceUserUsername,
|
|
} = notification
|
|
return (
|
|
<NotificationFrame
|
|
notification={notification}
|
|
isChildOfGroup={isChildOfGroup}
|
|
highlighted={highlighted}
|
|
setHighlighted={setHighlighted}
|
|
icon={
|
|
<AvatarNotificationIcon notification={notification} symbol={'🌟'} />
|
|
}
|
|
link={getSourceUrl(notification)}
|
|
subtitle={
|
|
<div className="line-clamp-2">
|
|
<Linkify text={sourceText} />
|
|
</div>
|
|
}
|
|
>
|
|
<div className="line-clamp-3">
|
|
<NotificationUserLink
|
|
name={sourceUserName}
|
|
username={sourceUserUsername}
|
|
/>{' '}
|
|
<span>
|
|
proposed a new match:{' '}
|
|
<PrimaryNotificationLink text={sourceContractTitle} />
|
|
</span>
|
|
</div>
|
|
</NotificationFrame>
|
|
)
|
|
}
|
|
|
|
function ProfileLikeNotification(props: {
|
|
notification: Notification
|
|
highlighted: boolean
|
|
setHighlighted: (highlighted: boolean) => void
|
|
isChildOfGroup?: boolean
|
|
}) {
|
|
const { notification, highlighted, setHighlighted, isChildOfGroup } = props
|
|
const [open, setOpen] = useState(false)
|
|
const { sourceUserName, sourceUserUsername } = notification
|
|
const relatedNotifications: Notification[] = notification.data
|
|
?.relatedNotifications ?? [notification]
|
|
const reactorsText =
|
|
relatedNotifications.length > 1
|
|
? `${sourceUserName} & ${relatedNotifications.length - 1} other${
|
|
relatedNotifications.length > 2 ? 's' : ''
|
|
}`
|
|
: sourceUserName
|
|
return (
|
|
<NotificationFrame
|
|
notification={notification}
|
|
isChildOfGroup={isChildOfGroup}
|
|
highlighted={highlighted}
|
|
setHighlighted={setHighlighted}
|
|
icon={
|
|
<MultipleAvatarIcons
|
|
notification={notification}
|
|
symbol={'💖'}
|
|
setOpen={setOpen}
|
|
/>
|
|
}
|
|
link={`https://${ENV_CONFIG.domain}/${sourceUserUsername}`}
|
|
subtitle={<></>}
|
|
>
|
|
{reactorsText && <PrimaryNotificationLink text={reactorsText} />} liked
|
|
you!
|
|
<MultiUserReactionModal
|
|
similarNotifications={relatedNotifications}
|
|
modalLabel={'Who liked it?'}
|
|
open={open}
|
|
setOpen={setOpen}
|
|
/>
|
|
</NotificationFrame>
|
|
)
|
|
}
|
|
|
|
function ProfileShipNotification(props: {
|
|
notification: Notification
|
|
highlighted: boolean
|
|
setHighlighted: (highlighted: boolean) => void
|
|
isChildOfGroup?: boolean
|
|
}) {
|
|
const { notification, highlighted, setHighlighted, isChildOfGroup } = props
|
|
const [open, setOpen] = useState(false)
|
|
const { sourceUserName, sourceUserUsername } = notification
|
|
const relatedNotifications: Notification[] = notification.data
|
|
?.relatedNotifications ?? [notification]
|
|
const reactorsText =
|
|
relatedNotifications.length > 1
|
|
? `${sourceUserName} & ${relatedNotifications.length - 1} other${
|
|
relatedNotifications.length > 2 ? 's' : ''
|
|
}`
|
|
: sourceUserName
|
|
const { creatorId, creatorName, creatorUsername } = notification.data ?? {}
|
|
|
|
return (
|
|
<NotificationFrame
|
|
notification={notification}
|
|
isChildOfGroup={isChildOfGroup}
|
|
highlighted={highlighted}
|
|
setHighlighted={setHighlighted}
|
|
icon={
|
|
<MultipleAvatarIcons
|
|
notification={notification}
|
|
symbol={'💖'}
|
|
setOpen={setOpen}
|
|
/>
|
|
}
|
|
link={`https://${ENV_CONFIG.domain}/${sourceUserUsername}`}
|
|
subtitle={<></>}
|
|
>
|
|
You and {reactorsText && <PrimaryNotificationLink text={reactorsText} />}{' '}
|
|
are being shipped by{' '}
|
|
<NotificationUserLink
|
|
name={creatorName}
|
|
username={creatorUsername}
|
|
userId={creatorId}
|
|
hideBadge
|
|
/>
|
|
!
|
|
<MultiUserReactionModal
|
|
similarNotifications={relatedNotifications}
|
|
modalLabel={'Who liked it?'}
|
|
open={open}
|
|
setOpen={setOpen}
|
|
/>
|
|
</NotificationFrame>
|
|
)
|
|
}
|
|
|
|
const getSourceUrl = (notification: Notification) => {
|
|
const { sourceSlug, sourceId } = notification
|
|
if (sourceSlug) {
|
|
return `${
|
|
sourceSlug.startsWith('/') ? sourceSlug : '/' + sourceSlug
|
|
}#${sourceId}`
|
|
}
|
|
return ''
|
|
}
|
|
|
|
// TODO: fix badges (id based)
|
|
export function NotificationUserLink(props: {
|
|
userId?: string
|
|
name?: string
|
|
username?: string
|
|
className?: string
|
|
hideBadge?: boolean
|
|
}) {
|
|
const { userId, name, username, className, hideBadge } = props
|
|
return (
|
|
<UserLink
|
|
user={{ id: userId || '', name: name || '', username: username || '' }}
|
|
className={clsx(
|
|
className ?? 'hover:text-primary-500 relative flex-shrink-0'
|
|
)}
|
|
hideBadge={hideBadge}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function AvatarNotificationIcon(props: {
|
|
notification: Notification
|
|
symbol?: string | ReactNode
|
|
}) {
|
|
const { notification, symbol } = props
|
|
const { sourceUserName, sourceUserAvatarUrl, sourceUserUsername, sourceSlug } = notification
|
|
const href = !!sourceUserUsername ? `/${sourceUserUsername}` : sourceSlug ?? '/'
|
|
return (
|
|
<div className="relative">
|
|
<Link
|
|
href={href}
|
|
target={href.startsWith('http') ? '_blank' : undefined}
|
|
onClick={(e) => e.stopPropagation}
|
|
>
|
|
<Avatar
|
|
username={sourceUserName}
|
|
avatarUrl={sourceUserAvatarUrl}
|
|
size={'md'}
|
|
noLink={true}
|
|
/>
|
|
<div className="absolute -bottom-2 -right-1 text-lg">{symbol}</div>
|
|
</Link>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function MultipleAvatarIcons(props: {
|
|
notification: Notification
|
|
symbol: string
|
|
setOpen: (open: boolean) => void
|
|
}) {
|
|
const { notification, symbol, setOpen } = props
|
|
const relatedNotifications: Notification[] = sortBy(
|
|
notification.data?.relatedNotifications ?? [notification],
|
|
(n) => n.createdTime
|
|
)
|
|
|
|
const combineAvatars = (notifications: Notification[]) => {
|
|
const totalAvatars = notifications.length
|
|
const maxToShow = Math.min(totalAvatars, 3)
|
|
const avatarsToCombine = notifications.slice(
|
|
totalAvatars - maxToShow,
|
|
totalAvatars
|
|
)
|
|
const max = avatarsToCombine.length
|
|
const startLeft = -0.35 * (max - 1)
|
|
return avatarsToCombine.map((n, index) => (
|
|
<div
|
|
key={index}
|
|
className={'absolute'}
|
|
style={
|
|
index === 0
|
|
? {
|
|
left: `${startLeft}rem`,
|
|
}
|
|
: {
|
|
left: `${startLeft + index * 0.5}rem`,
|
|
}
|
|
}
|
|
>
|
|
<AvatarNotificationIcon
|
|
notification={n}
|
|
symbol={index === max - 1 ? symbol : ''}
|
|
/>
|
|
</div>
|
|
))
|
|
}
|
|
|
|
return (
|
|
<div
|
|
onClick={(event) => {
|
|
if (relatedNotifications.length === 1) return
|
|
event.preventDefault()
|
|
setOpen(true)
|
|
}}
|
|
>
|
|
{relatedNotifications.length > 1 ? (
|
|
<Col
|
|
className={`pointer-events-none relative items-center justify-center`}
|
|
>
|
|
{/* placeholder avatar to set the proper size*/}
|
|
<Avatar size="md" />
|
|
{combineAvatars(relatedNotifications)}
|
|
</Col>
|
|
) : (
|
|
<AvatarNotificationIcon notification={notification} symbol={symbol} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function PrimaryNotificationLink(props: { text: string | undefined }) {
|
|
const { text } = props
|
|
if (!text) {
|
|
return <></>
|
|
}
|
|
return (
|
|
<span className="hover:text-primary-500 font-semibold transition-colors">
|
|
{text}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// the primary skeleton for notifications
|
|
export function NotificationFrame(props: {
|
|
notification: Notification
|
|
highlighted: boolean
|
|
setHighlighted: (highlighted: boolean) => void
|
|
children: React.ReactNode
|
|
icon: ReactNode
|
|
link?: string
|
|
onClick?: () => void
|
|
subtitle?: string | ReactNode
|
|
isChildOfGroup?: boolean
|
|
customBackground?: ReactNode
|
|
}) {
|
|
const {
|
|
notification,
|
|
highlighted,
|
|
setHighlighted,
|
|
children,
|
|
icon,
|
|
subtitle,
|
|
onClick,
|
|
link,
|
|
customBackground,
|
|
} = props
|
|
const isMobile = useIsMobile()
|
|
|
|
const frameObject = (
|
|
<Row className="cursor-pointer text-sm md:text-base">
|
|
<Row className="w-full items-start gap-3">
|
|
<Col className="relative h-full w-10 items-center">{icon}</Col>
|
|
<Col className="font w-full">
|
|
<span>{children}</span>
|
|
<div className="mt-1 line-clamp-3 text-xs md:text-sm">{subtitle}</div>
|
|
</Col>
|
|
|
|
<Row className="mt-1 items-center justify-end gap-1 pr-1 sm:w-36">
|
|
{highlighted && !isMobile && (
|
|
<SparklesIcon className="text-primary-600 h-4 w-4" />
|
|
)}
|
|
<RelativeTimestampNoTooltip
|
|
time={notification.createdTime}
|
|
shortened={isMobile}
|
|
className={clsx(
|
|
'text-xs',
|
|
highlighted ? 'text-primary-600' : 'text-ink-700'
|
|
)}
|
|
/>
|
|
</Row>
|
|
</Row>
|
|
</Row>
|
|
)
|
|
|
|
return (
|
|
<Row className={clsx('hover:bg-primary-100 group p-2 transition-colors')}>
|
|
{customBackground}
|
|
{link && (
|
|
<Col className={'w-full'}>
|
|
<Link
|
|
href={link}
|
|
className={clsx('flex w-full flex-col')}
|
|
onClick={() => {
|
|
if (highlighted) {
|
|
setHighlighted(false)
|
|
}
|
|
}}
|
|
>
|
|
{frameObject}
|
|
</Link>
|
|
</Col>
|
|
)}
|
|
{!link && (
|
|
<Col
|
|
className={'w-full'}
|
|
onClick={() => {
|
|
if (highlighted) {
|
|
setHighlighted(false)
|
|
}
|
|
if (onClick) {
|
|
onClick()
|
|
}
|
|
}}
|
|
>
|
|
{frameObject}
|
|
</Col>
|
|
)}
|
|
</Row>
|
|
)
|
|
}
|