Files
Compass/web/components/notification-items.tsx
2026-02-24 18:00:12 +01:00

462 lines
14 KiB
TypeScript

import {SparklesIcon} from '@heroicons/react/solid'
import clsx from 'clsx'
import {ENV_CONFIG} from 'common/envs/constants'
import {Notification} from 'common/notifications'
import {sortBy} from 'lodash'
import Link from 'next/link'
import {ReactNode, useState} from 'react'
import {useIsMobile} from 'web/hooks/use-is-mobile'
import {useT} from 'web/lib/locale'
import {Col} from './layout/col'
import {Row} from './layout/row'
import {MultiUserReactionModal} from './multi-user-reaction-link'
import {RelativeTimestampNoTooltip} from './relative-timestamp'
import {Avatar} from './widgets/avatar'
import {Linkify} from './widgets/linkify'
import {UserLink} from './widgets/user-link'
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 if (reason === 'connection_interest_match') {
return <ConnectionInterestMatchNotification {...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 t = useT()
const reasonText = t('notifications.comment.commented', `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>{t('notifications.comment.on_your_profile', '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
const t = useT()
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>
{t('notifications.match.proposed_new_match', '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 t = useT()
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} />}{' '}
{t('notifications.profile.liked_you', 'liked you!')}
<MultiUserReactionModal
similarNotifications={relatedNotifications}
modalLabel={t('notifications.who_liked_it', '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 t = useT()
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} />}{' '}
{t('notifications.profile.are_being_shipped_by', 'are being shipped by')}{' '}
<NotificationUserLink
name={creatorName}
username={creatorUsername}
userId={creatorId}
hideBadge
/>
!
<MultiUserReactionModal
similarNotifications={relatedNotifications}
modalLabel={t('notifications.who_liked_it', 'Who liked it?')}
open={open}
setOpen={setOpen}
/>
</NotificationFrame>
)
}
export function ConnectionInterestMatchNotification(props: {
notification: Notification
highlighted: boolean
setHighlighted: (highlighted: boolean) => void
isChildOfGroup?: boolean
}) {
const {notification, highlighted, setHighlighted, isChildOfGroup} = props
const {sourceUserName, sourceUserUsername, sourceText} = notification
const t = useT()
const connectionType = notification.data?.connectionType || sourceText
const type = t(`profile.relationship.${connectionType}`, connectionType).toLowerCase()
return (
<NotificationFrame
notification={notification}
isChildOfGroup={isChildOfGroup}
highlighted={highlighted}
setHighlighted={setHighlighted}
icon={<AvatarNotificationIcon notification={notification} />}
link={`/${sourceUserUsername}`}
subtitle={<></>}
>
<NotificationUserLink name={sourceUserName} username={sourceUserUsername} />{' '}
{t('notifications.connection.interested_in_you', 'is interested in a {type} with you', {
type,
})}
!
</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>
)
}