mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
478 lines
21 KiB
TypeScript
478 lines
21 KiB
TypeScript
import {CheckIcon} from '@heroicons/react/24/outline'
|
|
import {useEditorState} from '@tiptap/react'
|
|
import clsx from 'clsx'
|
|
import {MAX_COMMENT_LENGTH} from 'common/comment'
|
|
import {Profile} from 'common/profiles/profile'
|
|
import {User} from 'common/user'
|
|
import {findKey} from 'lodash'
|
|
import {useRouter} from 'next/router'
|
|
import React, {useEffect, useRef, useState} from 'react'
|
|
import {BiEnvelope} from 'react-icons/bi'
|
|
import {Button, buttonClass} from 'web/components/buttons/button'
|
|
import {CommentInputTextArea} from 'web/components/comments/comment-input'
|
|
import {linkifyTrailingUrl} from 'web/components/editor/autolink'
|
|
import {Col} from 'web/components/layout/col'
|
|
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
|
|
import {Row} from 'web/components/layout/row'
|
|
import {EmailVerificationPrompt} from 'web/components/messaging/email-verification-prompt'
|
|
import {usePrivateMessageMembershipsContext} from 'web/components/messaging/private-message-memberships-context'
|
|
import {
|
|
AccountOnHoldNotice,
|
|
isAutoBanUnderReviewError,
|
|
} from 'web/components/moderation/account-on-hold'
|
|
import {useTextEditor} from 'web/components/widgets/editor'
|
|
import {Tooltip} from 'web/components/widgets/tooltip'
|
|
import {useFirebaseUser} from 'web/hooks/use-firebase-user'
|
|
import {usePrivateUser, useUser} from 'web/hooks/use-user'
|
|
import {api} from 'web/lib/api'
|
|
import {skipEmailVerification} from 'web/lib/dev-flags'
|
|
import {firebaseLogin} from 'web/lib/firebase/users'
|
|
import {useT} from 'web/lib/locale'
|
|
|
|
export const SendMessageButton = (props: {
|
|
toUser: User
|
|
profile: Profile
|
|
includeLabel?: boolean
|
|
circleButton?: boolean
|
|
text?: string
|
|
tooltipText?: string
|
|
className?: string
|
|
disabled?: boolean
|
|
accentIfMessaged?: boolean
|
|
onPointerDown?: () => void
|
|
size?: string
|
|
}) => {
|
|
const {
|
|
toUser,
|
|
profile,
|
|
includeLabel,
|
|
circleButton,
|
|
text,
|
|
tooltipText,
|
|
disabled,
|
|
onPointerDown,
|
|
className,
|
|
size = 'h-6 w-6',
|
|
accentIfMessaged,
|
|
} = props
|
|
const firebaseUser = useFirebaseUser()
|
|
const router = useRouter()
|
|
const privateUser = usePrivateUser()
|
|
const currentUser = useUser()
|
|
const {memberIdsByChannelId} = usePrivateMessageMembershipsContext()
|
|
const t = useT()
|
|
|
|
const [openComposeModal, setOpenComposeModal] = useState(false)
|
|
const [error, setError] = useState('')
|
|
// Set instead of `error` when the daily new-conversation limit auto-banned them: that case gets the
|
|
// full explanation panel rather than a one-line red error, since the outcome deserves context.
|
|
const [onHold, setOnHold] = useState(false)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
const previousDirectMessageChannel = findKey(
|
|
memberIdsByChannelId,
|
|
(dm) => dm.includes(toUser.id) && dm.length === 1,
|
|
)
|
|
|
|
const previousChannelId =
|
|
previousDirectMessageChannel !== undefined ? previousDirectMessageChannel : undefined
|
|
|
|
const putAccent = previousChannelId !== undefined && accentIfMessaged
|
|
|
|
const messageButtonClicked = async () => {
|
|
if (disabled) return
|
|
if (!currentUser) return firebaseLogin()
|
|
if (previousChannelId) router.push(`/messages/${previousChannelId}`)
|
|
else setOpenComposeModal(true)
|
|
}
|
|
const editor = useTextEditor({
|
|
key: `compose-new-message-${toUser.id}`,
|
|
size: 'sm',
|
|
max: MAX_COMMENT_LENGTH,
|
|
placeholder: t(
|
|
'send_message.placeholder',
|
|
`What genuinely resonated with you in {name}'s profile? What would you like to explore together?`,
|
|
{name: toUser.name},
|
|
),
|
|
// The editor sets an inline `min-height` from nRowsMin (editor.tsx), and inline style beats the
|
|
// class below — so nRowsMin, not `min-h-[150px]`, is what actually decides the height. Five rows
|
|
// gives a message with a 200-character minimum somewhere to live.
|
|
className: 'min-h-[150px]',
|
|
nRowsMin: 5,
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (openComposeModal && editor) {
|
|
// Focus the editor after a short delay to ensure the modal is fully rendered
|
|
setTimeout(() => {
|
|
editor.commands.focus()
|
|
}, 100)
|
|
}
|
|
}, [openComposeModal, editor])
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
const editorWrapRef = useRef<HTMLDivElement>(null)
|
|
|
|
// The panel scrolls, and the editor grows with the message — so once the content outgrows the
|
|
// panel, every new line would push the send button (and the line being typed) below the fold.
|
|
// Pin the scroll to the bottom whenever the editor gets taller, so what's being written stays in
|
|
// view and the header scrolls off the top instead. Only growth triggers it: on shrink, or when
|
|
// the user scrolls up deliberately, we leave the position alone.
|
|
useEffect(() => {
|
|
const wrap = editorWrapRef.current
|
|
const scroller = scrollRef.current
|
|
if (!wrap || !scroller) return
|
|
|
|
let lastHeight = wrap.getBoundingClientRect().height
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
const height = entry.contentRect.height
|
|
if (height > lastHeight) scroller.scrollTop = scroller.scrollHeight
|
|
lastHeight = height
|
|
})
|
|
observer.observe(wrap)
|
|
return () => observer.disconnect()
|
|
}, [openComposeModal, firebaseUser?.emailVerified])
|
|
|
|
const sendMessage = async () => {
|
|
if (!editor) return
|
|
setSubmitting(true)
|
|
const res = await api('create-private-user-message-channel', {
|
|
userIds: [toUser.id],
|
|
}).catch((e) => {
|
|
if (isAutoBanUnderReviewError(e)) setOnHold(true)
|
|
else setError(e.message)
|
|
setSubmitting(false)
|
|
return
|
|
})
|
|
if (!res) return
|
|
|
|
linkifyTrailingUrl(editor)
|
|
const msgRes = await api('create-private-user-message', {
|
|
channelId: res.channelId,
|
|
content: editor.getJSON(),
|
|
}).catch((e: any) => {
|
|
setError(e.message)
|
|
setSubmitting(false)
|
|
return
|
|
})
|
|
if (!msgRes) return
|
|
|
|
router.push(`/messages/${res.channelId}`)
|
|
}
|
|
|
|
const [insertedChips, setInsertedChips] = useState<string[]>([])
|
|
|
|
const toggleChip = (label: string) => {
|
|
if (!editor) return
|
|
|
|
const alreadyInserted = insertedChips.includes(label)
|
|
|
|
if (alreadyInserted) {
|
|
// remove the token from the editor text
|
|
const current = editor.getText()
|
|
const cleaned = current.replace(new RegExp(`\\s?${label}`, 'gi'), '').trim()
|
|
editor.commands.setContent(cleaned)
|
|
setInsertedChips((prev) => prev.filter((c) => c !== label))
|
|
} else {
|
|
// append at cursor (or end)
|
|
editor.chain().focus().insertContent(` ${label}`).run()
|
|
setInsertedChips((prev) => [...prev, label])
|
|
}
|
|
}
|
|
|
|
const MIN_CHARS = 200
|
|
|
|
// Subscribe to just the trimmed length; the editor no longer re-renders us per keystroke (see
|
|
// editor.tsx), and this drives the live progress bar / unlock threshold below.
|
|
const charCount =
|
|
useEditorState({
|
|
editor,
|
|
selector: ({editor}) => editor?.getText().trim().length ?? 0,
|
|
}) ?? 0
|
|
const pct = Math.min((charCount / MIN_CHARS) * 100, 100)
|
|
const isReady = charCount >= MIN_CHARS
|
|
const firstName = toUser.name.split(' ')[0]
|
|
// Amber while short, green once it unlocks — the design's signal that the threshold is met. The
|
|
// green is the brand sage rather than the design's raw oklch, so it stays on-palette.
|
|
const barColor = isReady ? '#6B8F71' : '#C17F3E'
|
|
|
|
if (privateUser?.blockedByUserIds.includes(toUser.id)) return null
|
|
|
|
return (
|
|
<>
|
|
{/* `w-fit`: Tooltip's reference element is a plain span, so in a flex column it stretched to the
|
|
full width and armed the tooltip across all the empty space beside the button. */}
|
|
<Tooltip
|
|
className="w-fit"
|
|
text={
|
|
tooltipText ||
|
|
(putAccent ? t('send_message.', 'Follow Up') : t('send_message.button_label', 'Message'))
|
|
}
|
|
noTap
|
|
>
|
|
{text ? (
|
|
<Button
|
|
// Solid fill, tight radius, an envelope leading the label: the one control in Connect
|
|
// that should read as a button at a glance. The `cta` glow is dropped — at this size it
|
|
// haloed the button against the page instead of sitting on it.
|
|
className={clsx(
|
|
'h-fit gap-2 !rounded-[10px] !px-5 !py-3 !text-[15px] font-semibold !shadow-none',
|
|
disabled && 'opacity-50 cursor-not-allowed',
|
|
)}
|
|
// `cta`, not `primary`: `primary` is the *tinted secondary* pill (see button.tsx), which
|
|
// in the dark ramp is brown-on-brown and read as disabled on the one control that is the
|
|
// whole point of the Connect section.
|
|
color={'cta'}
|
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault()
|
|
onPointerDown?.()
|
|
messageButtonClicked()
|
|
}}
|
|
onPointerDown={onPointerDown}
|
|
disabled={disabled}
|
|
>
|
|
<BiEnvelope className="h-[18px] w-[18px] flex-none" />
|
|
{text}
|
|
</Button>
|
|
) : circleButton ? (
|
|
<button
|
|
className={clsx(
|
|
'h-7 w-7 rounded-full transition-colors',
|
|
disabled ? 'bg-gray-400 cursor-not-allowed' : 'bg-primary-900 hover:bg-primary-600',
|
|
)}
|
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault()
|
|
onPointerDown?.()
|
|
messageButtonClicked()
|
|
}}
|
|
onPointerDown={onPointerDown}
|
|
disabled={disabled}
|
|
>
|
|
<BiEnvelope
|
|
className={clsx('m-auto h-5 w-5 text-white drop-shadow', includeLabel && 'mr-2')}
|
|
/>
|
|
</button>
|
|
) : (
|
|
<button
|
|
onPointerDown={onPointerDown}
|
|
disabled={disabled}
|
|
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
e.preventDefault()
|
|
onPointerDown?.()
|
|
messageButtonClicked()
|
|
}}
|
|
className={clsx(
|
|
'relative border border-canvas-200',
|
|
buttonClass('xs', 'none'),
|
|
disabled && 'opacity-50 cursor-not-allowed',
|
|
className,
|
|
putAccent
|
|
? 'bg-green-200 border-green-500 !text-green-500'
|
|
: 'bg-canvas-50 border-canvas-300 text-ink-500 hover:border-primary-400 hover:bg-primary-50',
|
|
)}
|
|
>
|
|
{putAccent && (
|
|
<span className="absolute top-[3px] right-[3px] w-[7px] h-[7px] rounded-full bg-green-500 border-[1.5px] border-canvas-50" />
|
|
)}
|
|
<BiEnvelope className={clsx(size, includeLabel && 'mr-2')} />{' '}
|
|
{includeLabel && <>{t('send_message.button_label', 'Message')}</>}
|
|
</button>
|
|
)}
|
|
</Tooltip>
|
|
|
|
{/* `!min-h-0` / `!h-auto` override the shared modal's fixed 60vh floor: this composer's content is
|
|
short, and the forced height left roughly a third of the panel as empty canvas below the
|
|
progress bar, which is what made it read as unfinished. */}
|
|
{/* size="lg" (max-w-2xl) rather than the default md: this is the one modal in the app people
|
|
actually compose long-form text in, and 200 characters minimum in a 448px column is a
|
|
cramped place to do it. Mobile is unaffected — the panel is full-width there regardless. */}
|
|
<Modal open={openComposeModal} setOpen={setOpenComposeModal} size="lg" className="!min-h-0">
|
|
{/* `[&>*]:shrink-0` is load-bearing. This is a flex column, so by default a long message let
|
|
flex compress the editor instead of overflowing the panel — and TextEditor's shell is
|
|
`overflow-hidden`, so the compressed part was clipped with no way to scroll to it. That is
|
|
why the top of a long message and the image/emoji toolbar became unreachable. Keeping
|
|
children at their natural height makes the panel itself overflow, which `overflow-y-auto`
|
|
then scrolls. */}
|
|
<Col className={clsx(MODAL_CLASS, '!h-auto max-h-[85vh] overflow-hidden pb-5 !gap-0')}>
|
|
{/* Split into a scrolling region and a pinned footer.
|
|
Everything used to live in one scrolling column, so the progress bar and the send button
|
|
— the two things telling you whether you may send yet — drifted off the bottom as soon as
|
|
the message got long. They are now outside the scroller entirely and cannot move.
|
|
`min-h-0` on the scroller is load-bearing: a flex child's default `min-height: auto`
|
|
refuses to shrink below its content, so without it the region would grow past the panel
|
|
and push the footer out again — the same class of bug as the `[&>*]:shrink-0` note. */}
|
|
<Col
|
|
ref={scrollRef}
|
|
className={'w-full flex-1 min-h-0 overflow-y-auto gap-4 [&>*]:shrink-0'}
|
|
>
|
|
{/* Header. Follows the Compose Modal design: portrait, an uppercase "writing to" eyebrow,
|
|
then the serif title and a hairline. The earlier full-bleed gradient band competed with
|
|
the content it was introducing. */}
|
|
<Col className={'w-full'}>
|
|
<Row className={'items-center gap-3.5 mb-6'}>
|
|
{profile.pinned_url && (
|
|
<img
|
|
src={profile.pinned_url}
|
|
alt=""
|
|
className={'h-13 w-13 rounded-full object-cover border border-canvas-200'}
|
|
style={{width: 52, height: 52}}
|
|
/>
|
|
)}
|
|
<Col className={'gap-0.5'}>
|
|
<span
|
|
className={
|
|
'text-primary-600 text-[11px] font-semibold uppercase tracking-[0.09em]'
|
|
}
|
|
>
|
|
{t('send_message.writing_to', 'Writing to')}
|
|
</span>
|
|
<span className={'text-ink-900 text-[17px] font-semibold leading-tight'}>
|
|
{toUser.name}
|
|
</span>
|
|
</Col>
|
|
</Row>
|
|
|
|
<h2
|
|
className={
|
|
'font-heading text-[32px] font-medium leading-[1.15] text-ink-1000 !mt-0 mb-3'
|
|
}
|
|
>
|
|
{t('send_message.title', 'Start a meaningful conversation')}
|
|
</h2>
|
|
<p className={'text-ink-500 text-[15px] leading-[1.55] max-w-[92%]'}>
|
|
{t(
|
|
'send_message.guidance',
|
|
'Compass is about depth. Take a moment to write something genuine.',
|
|
)}
|
|
</p>
|
|
<div className={'h-px bg-canvas-200 mt-6'} />
|
|
</Col>
|
|
|
|
{/* skipEmailVerification is dev-only and cannot be enabled in a production build; the API
|
|
still rejects unverified senders. See web/lib/dev-flags.ts. */}
|
|
{onHold ? (
|
|
<AccountOnHoldNotice reason={'auto_rate_limit'} className="max-w-xl mt-2" />
|
|
) : firebaseUser?.emailVerified || skipEmailVerification ? (
|
|
<>
|
|
{/* Topics box: a 40% tint rather than solid canvas-100. At full strength the fill sat a
|
|
whole step away from the modal's canvas-50 surface and read as a separate panel. The
|
|
border is what defines the box; the fill only needs to hint at it. Works in both
|
|
themes, since canvas-100 sits on the far side of canvas-50 either way. */}
|
|
{!!profile.keywords?.length && (
|
|
<div
|
|
className={
|
|
'w-full bg-canvas-100/80 border border-canvas-200 rounded-2xl p-[18px]'
|
|
}
|
|
>
|
|
<p className={'text-primary-600 mb-3 text-[12.5px] font-semibold'}>
|
|
{t('send_message.keywords_hint', `Insert some of {name}'s topics`, {
|
|
name: firstName,
|
|
})}
|
|
</p>
|
|
<div className={'flex flex-wrap gap-[9px]'}>
|
|
{profile.keywords.map((k) => (
|
|
<button
|
|
key={k}
|
|
type={'button'}
|
|
onClick={() => toggleChip(k)}
|
|
// Same chip language as the filter rail, so selecting a topic here reads the
|
|
// same as narrowing a search there.
|
|
className={clsx(
|
|
'inline-flex items-center gap-1.5 text-[13.5px] font-medium px-[15px] py-2 rounded-full border transition-colors duration-150 whitespace-nowrap',
|
|
insertedChips.includes(k)
|
|
? 'bg-cta border-cta text-white'
|
|
: 'bg-canvas-0 border-canvas-300 text-ink-700 hover:bg-primary-50 hover:border-primary-400',
|
|
)}
|
|
>
|
|
{insertedChips.includes(k) && (
|
|
<CheckIcon className={'h-3 w-3'} strokeWidth={3} />
|
|
)}
|
|
{k}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* No wrapper here. TextEditor already draws its own bordered shell
|
|
(`border-ink-300 ... rounded-lg` in editor.tsx), so adding one produced two nested
|
|
outlines. Restyle that single shell instead — `!` because clsx just concatenates and
|
|
the base classes would otherwise win by stylesheet order. */}
|
|
{/* Plain wrapper purely so the effect above has something whose height it can observe;
|
|
it stretches like any flex child, so it doesn't change the layout. */}
|
|
<div ref={editorWrapRef} className={'w-full'}>
|
|
<CommentInputTextArea
|
|
editor={editor}
|
|
user={currentUser}
|
|
submit={sendMessage}
|
|
isSubmitting={!editor || submitting}
|
|
isDisabled={charCount < MIN_CHARS}
|
|
hideSubmitButton
|
|
submitOnEnter={false}
|
|
// max-h-none: let the editor grow and let the *modal* do the scrolling instead of
|
|
// trapping the message in a ~25vh inner scroller. That inner box made it awkward to
|
|
// scroll back to the top of a long message, and pushed the image/emoji toolbar —
|
|
// which sits below it — out of reach on shorter windows.
|
|
maxHeight={'max-h-none'}
|
|
className={
|
|
'!rounded-2xl !border-[1.5px] !border-canvas-200 !bg-canvas-0/50 !shadow-none focus-within:!border-primary-400 focus-within:!ring-0'
|
|
}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<EmailVerificationPrompt t={t} className="max-w-xl" />
|
|
)}
|
|
{error && (
|
|
<span
|
|
className={
|
|
'text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 text-sm mt-2'
|
|
}
|
|
>
|
|
{error}
|
|
</span>
|
|
)}
|
|
</Col>
|
|
|
|
{/* Pinned footer — outside the scroller, so it is visible no matter how long the message is.
|
|
Hidden once the account is on hold: there is nothing left to send until we've reviewed it. */}
|
|
{(firebaseUser?.emailVerified || skipEmailVerification) && !onHold && (
|
|
<Col className={'w-full shrink-0 gap-3.5 pt-4'}>
|
|
{/* Progress toward the minimum length, with the count beside it. */}
|
|
<div className={'w-full flex items-center gap-3.5'}>
|
|
<div className={'h-[5px] flex-1 rounded-full bg-canvas-200 overflow-hidden'}>
|
|
<div
|
|
className={'h-full rounded-full transition-all duration-200 ease-out'}
|
|
style={{background: barColor, width: `${pct}%`}}
|
|
/>
|
|
</div>
|
|
<span className={'tabular-nums text-ink-500 text-[12.5px] whitespace-nowrap'}>
|
|
{charCount} / {MIN_CHARS}
|
|
</span>
|
|
</div>
|
|
|
|
<button
|
|
type={'button'}
|
|
data-testid="send-message-submit"
|
|
onClick={sendMessage}
|
|
disabled={!isReady || submitting}
|
|
className={clsx(
|
|
'w-full rounded-[14px] px-5 py-[15px] text-[15px] font-semibold transition-colors duration-150',
|
|
isReady
|
|
? 'bg-primary-600 text-white hover:bg-primary-700 cursor-pointer'
|
|
: 'bg-canvas-200 text-ink-400 cursor-not-allowed',
|
|
)}
|
|
>
|
|
{isReady
|
|
? t('send_message.send', 'Send message')
|
|
: t('send_message.unlock', 'Write {count} more characters to unlock', {
|
|
count: MIN_CHARS - charCount,
|
|
})}
|
|
</button>
|
|
</Col>
|
|
)}
|
|
</Col>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|