mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-25 01:51:37 -04:00
462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
import {SparklesIcon} from '@heroicons/react/24/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: React.MouseEvent<HTMLAnchorElement>) => 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>
|
|
)
|
|
}
|