mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-31 10:19:38 -04:00
890 lines
31 KiB
TypeScript
890 lines
31 KiB
TypeScript
import {JSONContent} from '@tiptap/core'
|
|
import clsx from 'clsx'
|
|
import {INVERTED_DIET_CHOICES, INVERTED_LANGUAGE_CHOICES} from 'common/choices'
|
|
import {FilterFields} from 'common/filters'
|
|
import {formatFilters, locationType} from 'common/filters-format'
|
|
import {Gender} from 'common/gender'
|
|
import {CompatibilityScore} from 'common/profiles/compatibility-score'
|
|
import {Profile} from 'common/profiles/profile'
|
|
import {CardSize, DisplayOptions} from 'common/profiles-rendering'
|
|
import {parseJsonContentToText} from 'common/util/parse'
|
|
import {clamp} from 'lodash'
|
|
import {
|
|
Brain,
|
|
Briefcase,
|
|
Cigarette,
|
|
HandHeart,
|
|
Languages,
|
|
Salad,
|
|
SearchX,
|
|
Sparkles,
|
|
Wine,
|
|
} from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import React, {useMemo, useRef, useState} from 'react'
|
|
import {PiMagnifyingGlassBold} from 'react-icons/pi'
|
|
import {LocationFilterProps} from 'web/components/filters/location-filter'
|
|
import GenderIcon from 'web/components/gender-icon'
|
|
import {IconWithInfo} from 'web/components/icons'
|
|
import {Row} from 'web/components/layout/row'
|
|
import {SendMessageButton} from 'web/components/messaging/send-message-button'
|
|
import {AccountOnHoldNotice} from 'web/components/moderation/account-on-hold'
|
|
import {ProfileAvatar} from 'web/components/profile/profile-avatar'
|
|
import {ProfileLocation} from 'web/components/profile/profile-location'
|
|
import {getSeekingText} from 'web/components/profile-about'
|
|
import {GetNotifiedButton} from 'web/components/searches/get-notified-button'
|
|
import {CompatibilityRing} from 'web/components/widgets/compatible-badge'
|
|
import HideProfileButton from 'web/components/widgets/hide-profile-button'
|
|
import {StarButton} from 'web/components/widgets/star-button'
|
|
import {LoadMoreUntilNotVisible} from 'web/components/widgets/visibility-observer'
|
|
import {BookmarkedSearchesType} from 'web/hooks/use-bookmarked-searches'
|
|
import {useChoicesContext} from 'web/hooks/use-choices'
|
|
import {ColumnCountOptions, useColumnCount} from 'web/hooks/use-column-count'
|
|
import {useLongPressReveal} from 'web/hooks/use-long-press-reveal'
|
|
import {useMeasurementSystem} from 'web/hooks/use-measurement-system'
|
|
import {isDark, useTheme} from 'web/hooks/use-theme'
|
|
import {useUser} from 'web/hooks/use-user'
|
|
import {useT} from 'web/lib/locale'
|
|
import {capitalizePure} from 'web/lib/util/time'
|
|
|
|
import {Col} from './layout/col'
|
|
|
|
/**
|
|
* Per-card-size tuning. Cards are text-first: the photo is a round avatar rather than a panel, so
|
|
* the headline and bio get the width and the visual weight.
|
|
*
|
|
* `headlineCharsPerLine` is a rough average for the rendered column width at that size — it only feeds
|
|
* the line budget below, so being a few characters off just shifts a clamp by one line.
|
|
*/
|
|
type CardSizeConfig = {
|
|
/** Feeds the masonry column count — see `useColumnCount`. */
|
|
columns: ColumnCountOptions
|
|
avatarPx: number
|
|
ringPx: number
|
|
padding: string
|
|
gap: string
|
|
nameClass: string
|
|
metaClass: string
|
|
headlineClass: string
|
|
bioClass: string
|
|
keywordClass: string
|
|
/** Caps the reading measure on wide cards — full-width lines get unreadable past ~90 chars. */
|
|
textWidthClass: string
|
|
/**
|
|
* Wide cards would otherwise be medium's content with a void beside it. Instead they get a rail
|
|
* of the detail fields the compact sizes leave to the profile page — that's the reason to pick
|
|
* this size: depth per person rather than people per screen.
|
|
*/
|
|
detailRail: boolean
|
|
/** Rough characters per rendered line for the headline, used to size the line budget. */
|
|
headlineCharsPerLine: number
|
|
/** Headline + bio lines a card aims for, so cards with a long headline show less bio. */
|
|
totalTextLines: number
|
|
maxHeadlineLines: number
|
|
maxBioLines: number
|
|
maxKeywords: number
|
|
}
|
|
|
|
const MIN_BIO_LINES = 2
|
|
|
|
const CARD_SIZE_CONFIG: Record<CardSize, CardSizeConfig> = {
|
|
small: {
|
|
columns: {minCardWidth: 300, maxColumns: 4},
|
|
avatarPx: 44,
|
|
ringPx: 30,
|
|
padding: 'px-3.5 py-3',
|
|
gap: 'gap-2',
|
|
nameClass: 'text-base',
|
|
metaClass: 'text-xs',
|
|
headlineClass: 'text-sm',
|
|
bioClass: 'text-[13px]',
|
|
keywordClass: 'text-[11px] px-2 py-0.5',
|
|
textWidthClass: '',
|
|
detailRail: false,
|
|
headlineCharsPerLine: 52,
|
|
totalTextLines: 6,
|
|
maxHeadlineLines: 3,
|
|
maxBioLines: 5,
|
|
maxKeywords: 4,
|
|
},
|
|
medium: {
|
|
columns: {minCardWidth: 380, maxColumns: 3},
|
|
avatarPx: 52,
|
|
ringPx: 34,
|
|
padding: 'px-4 py-3.5',
|
|
gap: 'gap-2.5',
|
|
nameClass: 'text-lg',
|
|
metaClass: 'text-sm',
|
|
headlineClass: 'text-base',
|
|
bioClass: 'text-sm',
|
|
keywordClass: 'text-xs px-2.5 py-0.5',
|
|
textWidthClass: '',
|
|
detailRail: false,
|
|
headlineCharsPerLine: 66,
|
|
totalTextLines: 6,
|
|
maxHeadlineLines: 3,
|
|
maxBioLines: 5,
|
|
maxKeywords: 5,
|
|
},
|
|
large: {
|
|
columns: {maxColumns: 1, minCardWidth: 0},
|
|
avatarPx: 80,
|
|
ringPx: 42,
|
|
padding: 'px-5 py-4',
|
|
gap: 'gap-3',
|
|
nameClass: 'text-xl',
|
|
metaClass: 'text-sm',
|
|
headlineClass: 'text-lg',
|
|
bioClass: 'text-base',
|
|
keywordClass: 'text-sm px-3 py-1',
|
|
textWidthClass: 'max-w-3xl',
|
|
detailRail: true,
|
|
headlineCharsPerLine: 78,
|
|
totalTextLines: 6,
|
|
maxHeadlineLines: 3,
|
|
maxBioLines: 5,
|
|
maxKeywords: 8,
|
|
},
|
|
}
|
|
|
|
// Tailwind needs the full class name in the source, so the clamp can't be interpolated.
|
|
const LINE_CLAMP_CLASS: Record<number, string> = {
|
|
1: 'line-clamp-1',
|
|
2: 'line-clamp-2',
|
|
3: 'line-clamp-3',
|
|
4: 'line-clamp-4',
|
|
5: 'line-clamp-5',
|
|
6: 'line-clamp-6',
|
|
}
|
|
|
|
export function ProfileGridSkeleton(props: {
|
|
count?: number
|
|
className?: string
|
|
cardSize?: CardSize
|
|
}) {
|
|
const {count = 20, className, cardSize} = props
|
|
const config = CARD_SIZE_CONFIG[cardSize ?? 'medium']
|
|
|
|
// Same measured-width masonry as the real grid, so the layout doesn't reflow when profiles land.
|
|
const gridRef = useRef<HTMLDivElement>(null)
|
|
const columnCount = useColumnCount(gridRef, config.columns)
|
|
|
|
// Varied heights so the skeleton previews the masonry rather than a uniform grid.
|
|
const bioLines = [3, 2, 4, 2, 3, 3]
|
|
|
|
return (
|
|
<div ref={gridRef} className={clsx('flex items-start gap-4 py-4', className)}>
|
|
{Array.from({length: columnCount}).map((_, columnIndex) => (
|
|
<Col key={columnIndex} className="min-w-0 flex-1 gap-4">
|
|
{Array.from({length: count})
|
|
.map((_, i) => i)
|
|
.filter((i) => i % columnCount === columnIndex)
|
|
.map((i) => (
|
|
<div
|
|
key={i}
|
|
className={clsx(
|
|
'rounded-xl border border-canvas-300 bg-canvas-50 animate-pulse space-y-3',
|
|
config.padding,
|
|
)}
|
|
>
|
|
<div className="flex flex-row items-center gap-3">
|
|
<div
|
|
className="shrink-0 rounded-full bg-canvas-200"
|
|
style={{height: config.avatarPx, width: config.avatarPx}}
|
|
/>
|
|
<div className="flex-1 space-y-2">
|
|
{/* name */}
|
|
<div className="h-4 bg-canvas-200 rounded w-2/5" />
|
|
{/* location */}
|
|
<div className="h-3 bg-canvas-200 rounded w-1/3" />
|
|
</div>
|
|
{/* compatibility ring */}
|
|
<div
|
|
className="shrink-0 rounded-full bg-canvas-200"
|
|
style={{height: config.ringPx, width: config.ringPx}}
|
|
/>
|
|
</div>
|
|
{/* headline */}
|
|
<div className="h-4 bg-canvas-200 rounded w-4/5" />
|
|
{/* bio lines */}
|
|
<div className="space-y-1.5">
|
|
{Array.from({length: bioLines[i % bioLines.length]}).map((_, j) => (
|
|
<div
|
|
key={j}
|
|
className="h-3 bg-canvas-200 rounded"
|
|
style={{width: `${95 - j * 12}%`}}
|
|
/>
|
|
))}
|
|
</div>
|
|
{/* keyword pills */}
|
|
<div className="flex gap-2 pt-1">
|
|
<div className="h-6 bg-canvas-200 rounded-full w-16" />
|
|
<div className="h-6 bg-canvas-200 rounded-full w-20" />
|
|
<div className="h-6 bg-canvas-200 rounded-full w-14" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</Col>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const ProfileGrid = (props: {
|
|
profiles: Profile[]
|
|
loadMore: () => Promise<boolean>
|
|
isLoadingMore: boolean
|
|
isReloading: boolean
|
|
compatibilityScores: Record<string, CompatibilityScore> | undefined
|
|
starredUserIds: string[] | undefined
|
|
refreshStars: () => Promise<void>
|
|
onHide?: (userId: string) => void
|
|
hiddenUserIds?: string[]
|
|
onUndoHidden?: (userId: string) => void
|
|
displayOptions?: Partial<DisplayOptions>
|
|
filters: Partial<FilterFields>
|
|
locationFilterProps: LocationFilterProps
|
|
bookmarkedSearches: BookmarkedSearchesType[]
|
|
refreshBookmarkedSearches: () => void
|
|
}) => {
|
|
const {
|
|
profiles,
|
|
loadMore,
|
|
isLoadingMore,
|
|
isReloading,
|
|
compatibilityScores,
|
|
starredUserIds,
|
|
refreshStars,
|
|
onHide,
|
|
hiddenUserIds,
|
|
onUndoHidden,
|
|
displayOptions,
|
|
filters,
|
|
locationFilterProps,
|
|
bookmarkedSearches,
|
|
refreshBookmarkedSearches,
|
|
} = props
|
|
|
|
const {cardSize} = displayOptions ?? {}
|
|
const config = CARD_SIZE_CONFIG[cardSize ?? 'medium']
|
|
|
|
const user = useUser()
|
|
const t = useT()
|
|
const choicesIdsToLabels = useChoicesContext()
|
|
const {measurementSystem} = useMeasurementSystem()
|
|
|
|
// Same summary the Saved Searches modal shows, so the empty state spells out what was searched.
|
|
const searchSummary = useMemo(
|
|
() =>
|
|
formatFilters(
|
|
filters,
|
|
locationFilterProps?.location ? (locationFilterProps as unknown as locationType) : null,
|
|
choicesIdsToLabels,
|
|
measurementSystem,
|
|
t,
|
|
) ?? [],
|
|
[filters, locationFilterProps, choicesIdsToLabels, measurementSystem, t],
|
|
)
|
|
|
|
const other_profiles = profiles.filter((profile) => profile.user_id !== user?.id)
|
|
|
|
// Masonry: cards are only as tall as their content, so a sparse profile stays short instead of
|
|
// padding itself out with empty space. Round-robin (rather than CSS `columns`, which fills the
|
|
// first column top to bottom) keeps the sort order reading left to right across each row.
|
|
const gridRef = useRef<HTMLDivElement>(null)
|
|
const columnCount = useColumnCount(gridRef, config.columns)
|
|
const columns = useMemo(() => {
|
|
const cols: Profile[][] = Array.from({length: columnCount}, () => [])
|
|
other_profiles.forEach((profile, i) => cols[i % columnCount].push(profile))
|
|
return cols
|
|
}, [other_profiles, columnCount])
|
|
|
|
return (
|
|
<div className="relative" data-testid="people-profile-grid">
|
|
<div
|
|
ref={gridRef}
|
|
className={clsx('flex items-start gap-4 py-4', isReloading && 'animate-pulse opacity-80')}
|
|
>
|
|
{columns.map((column, columnIndex) => (
|
|
<Col key={columnIndex} className="min-w-0 flex-1 gap-4">
|
|
{column.map((profile) => (
|
|
<ProfilePreview
|
|
key={profile.id}
|
|
profile={profile}
|
|
compatibilityScore={compatibilityScores?.[profile.user_id]}
|
|
hasStar={starredUserIds?.includes(profile.user_id) ?? false}
|
|
refreshStars={refreshStars}
|
|
onHide={onHide}
|
|
isHidden={hiddenUserIds?.includes(profile.user_id) ?? false}
|
|
onUndoHidden={onUndoHidden}
|
|
displayOptions={displayOptions}
|
|
/>
|
|
))}
|
|
</Col>
|
|
))}
|
|
</div>
|
|
|
|
<LoadMoreUntilNotVisible loadMore={loadMore} />
|
|
|
|
{isLoadingMore && <ProfileGridSkeleton count={columnCount} cardSize={cardSize} />}
|
|
|
|
{user?.isBannedFromPosting ? (
|
|
<Col className="items-center py-8">
|
|
<AccountOnHoldNotice reason={user.banReason} className="max-w-xl" />
|
|
</Col>
|
|
) : (
|
|
!isLoadingMore &&
|
|
!isReloading &&
|
|
other_profiles.length === 0 && (
|
|
<Col className="items-center gap-3 py-16 px-4 text-center">
|
|
<div className="rounded-full bg-canvas-200 p-4">
|
|
<SearchX className="h-7 w-7 text-ink-400" />
|
|
</div>
|
|
<p className="text-lg font-medium text-ink-900">
|
|
{t('profile_grid.no_profiles', 'No profiles found')}
|
|
</p>
|
|
{searchSummary.length > 0 && (
|
|
<Col className="mt-1 max-w-md gap-2 items-center">
|
|
<div className="text-xs uppercase tracking-wide text-ink-400">
|
|
{t('profile_grid.searched_for', 'Your search')}
|
|
</div>
|
|
<Row className="flex-wrap justify-center gap-1.5">
|
|
{searchSummary.map((entry) => (
|
|
<span
|
|
key={entry}
|
|
className="rounded-full border border-canvas-200 bg-canvas-0 px-2.5 py-1 text-xs text-ink-700"
|
|
>
|
|
{entry}
|
|
</span>
|
|
))}
|
|
</Row>
|
|
</Col>
|
|
)}
|
|
<GetNotifiedButton
|
|
filters={filters}
|
|
locationFilterProps={locationFilterProps}
|
|
bookmarkedSearches={bookmarkedSearches}
|
|
refreshBookmarkedSearches={refreshBookmarkedSearches}
|
|
color="cta"
|
|
size="lg"
|
|
className="mt-1"
|
|
/>
|
|
<p className="max-w-sm text-ink-500">
|
|
{t(
|
|
'profile_grid.notification_cta',
|
|
"We'll notify you as soon as someone new matches your search.",
|
|
)}
|
|
</p>
|
|
</Col>
|
|
)
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* The detail fields the compact card sizes leave to the profile page. Deliberately not gated on
|
|
* the `show*` display toggles: those toggles only cover the compact card, and picking the wide
|
|
* size *is* the request to see this.
|
|
*/
|
|
function ProfileDetailRail(props: {profile: Profile; className?: string}) {
|
|
const {profile, className} = props
|
|
const choicesIdsToLabels = useChoicesContext()
|
|
const t = useT()
|
|
|
|
const seekingText = profile.pref_relation_styles?.length ? getSeekingText(profile, t, true) : null
|
|
|
|
const rows = [
|
|
seekingText && {
|
|
key: 'seeking',
|
|
testid: 'people-profile-seeking',
|
|
icon: <PiMagnifyingGlassBold className="h-4 w-4" />,
|
|
text: seekingText,
|
|
},
|
|
profile.occupation_title && {
|
|
key: 'occupation',
|
|
icon: <Briefcase className="h-4 w-4" />,
|
|
text: profile.occupation_title,
|
|
},
|
|
!!profile.interests?.length && {
|
|
key: 'interests',
|
|
icon: <Sparkles className="h-4 w-4" />,
|
|
text: profile.interests
|
|
.slice(0, 5)
|
|
.map((id) => choicesIdsToLabels['interests'][id])
|
|
.join(' • '),
|
|
},
|
|
!!profile.causes?.length && {
|
|
key: 'causes',
|
|
icon: <HandHeart className="h-4 w-4" />,
|
|
text: profile.causes
|
|
.slice(0, 5)
|
|
.map((id) => choicesIdsToLabels['causes'][id])
|
|
.join(' • '),
|
|
},
|
|
!!profile.diet?.length && {
|
|
key: 'diet',
|
|
icon: <Salad className="h-4 w-4" />,
|
|
text: profile.diet.map((e) => t(`profile.diet.${e}`, INVERTED_DIET_CHOICES[e])).join(' • '),
|
|
},
|
|
profile.is_smoker && {
|
|
key: 'smoking',
|
|
icon: <Cigarette className="h-4 w-4" />,
|
|
text: t('profile.optional.smoking', 'Smokes'),
|
|
},
|
|
profile.drinks_per_month !== null &&
|
|
profile.drinks_per_month !== undefined && {
|
|
key: 'drinks',
|
|
icon: <Wine className="h-4 w-4" />,
|
|
text: `${profile.drinks_per_month} ${t('filter.drinks.per_month', 'per month')}`,
|
|
},
|
|
profile.mbti && {
|
|
key: 'mbti',
|
|
icon: <Brain className="h-4 w-4" />,
|
|
text: profile.mbti.toUpperCase(),
|
|
},
|
|
!!profile.languages?.length && {
|
|
key: 'languages',
|
|
icon: <Languages className="h-4 w-4" />,
|
|
text: profile.languages
|
|
.map((v) => t(`profile.language.${v}`, INVERTED_LANGUAGE_CHOICES[v]))
|
|
.join(' • '),
|
|
},
|
|
].filter(Boolean) as Array<{key: string; testid?: string; icon: React.ReactNode; text: string}>
|
|
|
|
// A profile with none of these would otherwise leave a bordered empty column.
|
|
if (!rows.length) return null
|
|
|
|
return (
|
|
<Col
|
|
className={clsx('gap-1.5 border-l border-canvas-300 pl-5 text-ink-500', className)}
|
|
data-testid="profile-detail-rail"
|
|
>
|
|
{rows.map((row) => (
|
|
<IconWithInfo key={row.key} testid={row.testid} icon={row.icon} text={row.text} />
|
|
))}
|
|
</Col>
|
|
)
|
|
}
|
|
|
|
function ProfilePreview(props: {
|
|
profile: Profile
|
|
compatibilityScore: CompatibilityScore | undefined
|
|
hasStar: boolean
|
|
refreshStars: () => Promise<void>
|
|
onHide?: (userId: string) => void
|
|
isHidden?: boolean
|
|
onUndoHidden?: (userId: string) => void
|
|
displayOptions?: Partial<DisplayOptions>
|
|
}) {
|
|
const {
|
|
profile,
|
|
compatibilityScore,
|
|
onHide,
|
|
isHidden,
|
|
onUndoHidden,
|
|
displayOptions,
|
|
hasStar,
|
|
refreshStars,
|
|
} = props
|
|
|
|
const {
|
|
showPhotos,
|
|
showAge,
|
|
showGender,
|
|
showHeadline,
|
|
showKeywords,
|
|
showCity,
|
|
showBio,
|
|
cardSize,
|
|
// Fields no longer shown on the card — they live on the profile page. Toggles are still in
|
|
// `DisplayOptions` (and commented out in `field-toggles.tsx`) in case we bring them back.
|
|
// showLanguages,
|
|
// showOccupation,
|
|
// showSeeking,
|
|
// showInterests,
|
|
// showCauses,
|
|
// showDiet,
|
|
// showSmoking,
|
|
// showDrinks,
|
|
// showMBTI,
|
|
} = displayOptions ?? {}
|
|
const {user} = profile
|
|
// const choicesIdsToLabels = useChoicesContext()
|
|
const t = useT()
|
|
|
|
const config = CARD_SIZE_CONFIG[cardSize ?? 'medium']
|
|
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
const [showRing, setShowRing] = useState(false)
|
|
const ringTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
const pointerStartRef = useRef<{x: number; y: number} | null>(null)
|
|
|
|
// Touch has no hover, so the actions can't hide behind one — a press and hold reveals them.
|
|
const {
|
|
containerRef: cardRef,
|
|
revealed: showActions,
|
|
longPressedRef,
|
|
start: startLongPress,
|
|
move: moveLongPress,
|
|
cancel: clearLongPress,
|
|
handlers: longPressHandlers,
|
|
} = useLongPressReveal<HTMLDivElement>()
|
|
|
|
const {theme} = useTheme()
|
|
const isDarkTheme = isDark(theme)
|
|
|
|
const headline = showHeadline !== false ? profile.headline?.trim() : undefined
|
|
const bioText = useMemo(() => {
|
|
if (showBio === false) return ''
|
|
// The `card` projection sends a pre-truncated snippet instead of the rich-text document.
|
|
if (profile.bio_snippet !== undefined) return profile.bio_snippet
|
|
return parseJsonContentToText(profile.bio as JSONContent)
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
}, [profile.bio, profile.bio_snippet, showBio])
|
|
|
|
// Keep the total amount of text roughly constant per card: the longer the headline, the fewer
|
|
// bio lines we show. A card with no headline gives the whole budget to the bio, and vice versa.
|
|
const estimatedHeadlineLines = headline
|
|
? Math.ceil(headline.length / config.headlineCharsPerLine)
|
|
: 0
|
|
const headlineLines = clamp(
|
|
estimatedHeadlineLines,
|
|
headline ? 1 : 0,
|
|
bioText ? config.maxHeadlineLines : config.maxHeadlineLines + 1,
|
|
)
|
|
const bioLines = bioText
|
|
? clamp(config.totalTextLines - headlineLines, MIN_BIO_LINES, config.maxBioLines)
|
|
: 0
|
|
|
|
const handlePointerDown = (e: React.PointerEvent) => {
|
|
// Only a press on the card itself arms the press feedback. Two things to keep out:
|
|
// the card's own controls (message, star, hide), and anything in a modal one of them opens —
|
|
// Headless UI renders those in a portal, so React still bubbles their events up to this card
|
|
// even though the DOM node lives outside it.
|
|
const card = e.currentTarget as HTMLElement
|
|
const target = e.target as HTMLElement | null
|
|
if (!target || !card.contains(target) || target.closest('button, [role="button"]')) {
|
|
pointerStartRef.current = null
|
|
return
|
|
}
|
|
pointerStartRef.current = {x: e.clientX, y: e.clientY}
|
|
|
|
startLongPress(e)
|
|
}
|
|
|
|
// A press that turns into a scroll is not a hold.
|
|
const handlePointerMove = (e: React.PointerEvent) => {
|
|
moveLongPress(e)
|
|
}
|
|
|
|
const handlePointerUp = (e: React.PointerEvent) => {
|
|
clearLongPress()
|
|
|
|
// The hold already did something — don't also navigate or run the press feedback.
|
|
if (longPressedRef.current) {
|
|
pointerStartRef.current = null
|
|
return
|
|
}
|
|
|
|
if (pointerStartRef.current) {
|
|
const dx = Math.abs(e.clientX - pointerStartRef.current.x)
|
|
const dy = Math.abs(e.clientY - pointerStartRef.current.y)
|
|
|
|
// Check if opening in new tab
|
|
const isNewTab = e.button === 1 || e.ctrlKey || e.metaKey || e.shiftKey
|
|
|
|
// If moved more than 10px, treat as drag/scroll - cancel loading
|
|
// Also cancel if opening in new tab
|
|
if (dx > 10 || dy > 10 || isNewTab) {
|
|
setIsLoading(false)
|
|
setShowRing(false)
|
|
if (ringTimeoutRef.current) {
|
|
clearTimeout(ringTimeoutRef.current)
|
|
ringTimeoutRef.current = null
|
|
}
|
|
} else {
|
|
setIsLoading(true)
|
|
ringTimeoutRef.current = setTimeout(() => {
|
|
setShowRing(true)
|
|
}, 500)
|
|
}
|
|
}
|
|
pointerStartRef.current = null
|
|
}
|
|
|
|
// Browsers still fire a click after the hold, which would navigate away from the actions we just
|
|
// revealed; the hold would likewise pop the browser's own link menu on top of them.
|
|
const {onClick: handleClick, onContextMenu: handleContextMenu} = longPressHandlers
|
|
|
|
// If this profile was just hidden, render a compact placeholder with Undo action.
|
|
if (isHidden) {
|
|
return (
|
|
<div className="block rounded-xl border border-canvas-300 bg-canvas-50 dark:bg-gray-800/50 p-3 text-sm">
|
|
<Row className="items-center justify-between gap-2">
|
|
<span className="text-ink-700 dark:text-ink-300">
|
|
{t(
|
|
'profile_grid.profile_hidden_short',
|
|
"You won't see {name} in your search results anymore.",
|
|
{name: user?.name},
|
|
)}
|
|
</span>
|
|
<button
|
|
className="text-primary-500 hover:text-primary-700 underline"
|
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
onUndoHidden?.(profile.user_id)
|
|
}}
|
|
>
|
|
{t('profile_grid.undo', 'Undo')}
|
|
</button>
|
|
</Row>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// const seekingText = profile.pref_relation_styles?.length
|
|
// ? cardSize === 'large'
|
|
// ? getSeekingText(profile, t, true)
|
|
// : getSeekingConnectionText(profile, t, true)
|
|
// : null
|
|
|
|
// if (!profile.work?.length && !profile.occupation_title && !profile.interests?.length && (profile.bio_length || 0) < 100) {
|
|
// return null
|
|
// }
|
|
|
|
const keywords = showKeywords !== false ? (profile.keywords ?? []) : []
|
|
const visibleKeywords = keywords.slice(0, config.maxKeywords)
|
|
const hiddenKeywordCount = keywords.length - visibleKeywords.length
|
|
|
|
const textBlock = (
|
|
<>
|
|
{headline && (
|
|
<p
|
|
className={clsx(
|
|
'main-font my-0 italic text-ink-900',
|
|
config.headlineClass,
|
|
config.textWidthClass,
|
|
LINE_CLAMP_CLASS[headlineLines],
|
|
)}
|
|
data-testid="people-profile-headline"
|
|
>
|
|
“{headline}”
|
|
</p>
|
|
)}
|
|
|
|
{!!bioLines && (
|
|
<p
|
|
className={clsx(
|
|
'my-0',
|
|
// Without a headline the bio *is* the card's voice, so give it more contrast.
|
|
headline ? 'text-ink-500' : 'text-ink-600',
|
|
config.bioClass,
|
|
config.textWidthClass,
|
|
LINE_CLAMP_CLASS[bioLines],
|
|
)}
|
|
data-testid="people-profile-bio"
|
|
>
|
|
{bioText}
|
|
</p>
|
|
)}
|
|
|
|
{!!visibleKeywords.length && (
|
|
<Row className="flex-wrap gap-1.5 pt-0.5" data-testid="profile-keywords">
|
|
{visibleKeywords.map(capitalizePure).map((tag, i) => (
|
|
<span
|
|
key={i}
|
|
className={clsx(
|
|
'rounded-full border border-canvas-300 bg-canvas-100 text-ink-700 transition-colors group-hover:border-primary-200',
|
|
config.keywordClass,
|
|
)}
|
|
>
|
|
{tag.trim()}
|
|
</span>
|
|
))}
|
|
{hiddenKeywordCount > 0 && (
|
|
<span className={clsx('rounded-full text-ink-400', config.keywordClass)}>
|
|
+{hiddenKeywordCount}
|
|
</span>
|
|
)}
|
|
</Row>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
return (
|
|
<div
|
|
ref={cardRef}
|
|
className={clsx(
|
|
'relative overflow-hidden rounded-xl bg-canvas-50',
|
|
isLoading && 'scale-[0.94] transition-transform duration-[80ms] ease-out',
|
|
!isLoading && 'transition-transform duration-[120ms] ease-in',
|
|
)}
|
|
data-testid="people-profile-results"
|
|
>
|
|
<Link
|
|
href={`/${user.username}`}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerCancel={clearLongPress}
|
|
onClick={handleClick}
|
|
onContextMenu={handleContextMenu}
|
|
className={clsx(
|
|
'relative overflow-hidden rounded-xl bg-canvas-50 person-card',
|
|
// Stops the hold from turning into a text selection / iOS link callout.
|
|
'select-none [-webkit-touch-callout:none]',
|
|
!showRing && 'border border-canvas-300',
|
|
'transition-all duration-[120ms] ease-in',
|
|
'before:absolute before:left-0 before:top-0 before:bottom-0 before:w-[4px]',
|
|
'before:bg-[#C17F3E] before:rounded-l-lg',
|
|
'before:opacity-0 before:transition-opacity before:duration-[120ms]',
|
|
'hover:before:opacity-100',
|
|
'hover:border-primary-300 hover:shadow-lg',
|
|
'relative z-10 cursor-pointer group block rounded-xl overflow-hidden bg-transparent h-full',
|
|
'text-ink-600',
|
|
)}
|
|
>
|
|
{/* Phase 1: Dim overlay */}
|
|
{isLoading && (
|
|
<div className="absolute inset-0 bg-canvas-100/[0.32] rounded-xl z-20 pointer-events-none" />
|
|
)}
|
|
<Col
|
|
className={clsx(
|
|
'relative w-full rounded-xl transition-opacity duration-75',
|
|
config.padding,
|
|
config.gap,
|
|
isLoading && 'opacity-50',
|
|
)}
|
|
>
|
|
<Row className="items-start gap-3">
|
|
<ProfileAvatar
|
|
profile={profile}
|
|
sizePx={config.avatarPx}
|
|
hidePhoto={showPhotos === false}
|
|
/>
|
|
{/* Stretch to the avatar's height and center: without a location, the name would
|
|
otherwise sit at the top with a void beneath it. */}
|
|
<Col className="min-w-0 flex-1 justify-center gap-0.5 self-stretch">
|
|
<Row className="min-w-0 items-baseline gap-2">
|
|
<h3
|
|
className={clsx(
|
|
'main-font my-0 truncate font-medium text-gray-900 dark:text-white',
|
|
config.nameClass,
|
|
)}
|
|
data-testid="people-profile-name"
|
|
>
|
|
{user.name}
|
|
</h3>
|
|
<Row
|
|
className={clsx('shrink-0 items-center gap-1 text-ink-500', config.metaClass)}
|
|
data-testid="people-profile-age-gender"
|
|
>
|
|
{showAge !== false && !!profile.age && <span>{profile.age}</span>}
|
|
{showGender !== false && profile.gender && (
|
|
<GenderIcon gender={profile.gender as Gender} className="h-3.5 w-3.5" />
|
|
)}
|
|
</Row>
|
|
</Row>
|
|
{showCity !== false && (
|
|
<ProfileLocation
|
|
profile={profile}
|
|
hideIcon
|
|
className={clsx('text-ink-500', config.metaClass)}
|
|
/>
|
|
)}
|
|
</Col>
|
|
{/* The score owns the top-right corner; the actions slot in to its left (with a wider
|
|
gap than sits between the actions themselves). With no score the actions take the
|
|
corner themselves. */}
|
|
<Row className="shrink-0 items-center gap-2.5">
|
|
{/* Hidden until asked for: hover on desktop, a press and hold on touch. They stay
|
|
clickable while invisible on desktop only — hovering is what uncovers them there,
|
|
whereas on touch an invisible button is just a trap in the corner of the card. */}
|
|
<Row
|
|
className={clsx(
|
|
'items-center gap-1 transition-opacity',
|
|
showActions
|
|
? 'opacity-100'
|
|
: [
|
|
'opacity-0 pointer-events-none lg:pointer-events-auto',
|
|
'lg:group-hover:opacity-100 lg:group-focus-within:opacity-100',
|
|
],
|
|
)}
|
|
>
|
|
{onHide && (
|
|
<HideProfileButton
|
|
hiddenUserId={profile.user_id}
|
|
onHidden={onHide}
|
|
stopPropagation
|
|
eyeOff
|
|
/>
|
|
)}
|
|
{hasStar !== undefined && (
|
|
<StarButton
|
|
targetProfile={profile}
|
|
isStarred={hasStar}
|
|
refresh={refreshStars}
|
|
size={'h-4 w-4'}
|
|
className="h-7 w-7 !rounded-lg !p-1 hover:border-primary-400 hover:bg-primary-50"
|
|
/>
|
|
)}
|
|
{user && (
|
|
<div data-testid="message-profile-button">
|
|
<SendMessageButton
|
|
toUser={user}
|
|
profile={profile}
|
|
size={'h-4 w-4'}
|
|
className={clsx('!p-1 w-7 h-7')}
|
|
accentIfMessaged
|
|
/>
|
|
</div>
|
|
)}
|
|
</Row>
|
|
{compatibilityScore && (
|
|
<CompatibilityRing compatibility={compatibilityScore} sizePx={config.ringPx} />
|
|
)}
|
|
</Row>
|
|
</Row>
|
|
|
|
{/* 2:1 split rather than a fixed-width rail: the text column is capped at its reading
|
|
measure, so a rail pinned to the card's right edge would leave all the slack as a
|
|
gutter down the middle. */}
|
|
{config.detailRail ? (
|
|
<Row className="gap-6">
|
|
<Col className={clsx('min-w-0 flex-[2]', config.gap)}>{textBlock}</Col>
|
|
{/* Below lg the card is too narrow to split — the rail would squeeze both columns. */}
|
|
<ProfileDetailRail profile={profile} className="hidden min-w-0 flex-1 lg:flex" />
|
|
</Row>
|
|
) : (
|
|
textBlock
|
|
)}
|
|
</Col>
|
|
</Link>
|
|
|
|
{/* Phase 2: Animated ring - appears after 200ms */}
|
|
{isLoading && showRing && (
|
|
<div
|
|
className="absolute inset-0 z-20 pointer-events-none rounded-xl overflow-hidden"
|
|
style={{
|
|
padding: '4px',
|
|
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
|
|
WebkitMaskComposite: 'xor',
|
|
maskComposite: 'exclude',
|
|
}}
|
|
>
|
|
<div
|
|
className="absolute -inset-[200%] animate-spin"
|
|
style={{
|
|
background: `conic-gradient(from 0deg, ${isDarkTheme ? '#1f1c17' : '#f3f0ea'}, ${isDarkTheme ? '#1f1c17' : '#f3f0ea'}, #C17F3E)`,
|
|
animationDuration: '1s',
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|