Enhance message composer: add minRows support, character count validation, and dynamic placeholder customization

This commit is contained in:
MartinBraquet
2026-04-05 21:09:09 +02:00
parent 6aa9085739
commit 7c591ca73e
10 changed files with 315 additions and 194 deletions

View File

@@ -494,6 +494,12 @@
"messages.you_prefix": "Sie: ",
"messaging.email_verification_required": "Sie müssen Ihre E-Mail überprüfen, um Personen zu schreiben.",
"messaging.send_thoughtful_message": "Sende ihnen eine durchdachte Nachricht",
"send_message.placeholder": "Was hat dich an {name}s Profil wirklich angesprochen? Was möchtest du gemeinsam erkunden?",
"send_message.title": "Beginne ein bedeutungsvolles Gespräch",
"send_message.guidance": "Compass geht um Tiefe. Nimm dir einen Moment, um etwas Echtes zu schreiben.",
"send_message.keywords_hint": "Füge einige von {name}s Themen in deine Nachricht ein",
"send_message.more_chars": "{count} Zeichen mehr",
"send_message.ready": "Bereit zu senden",
"more_options_user.ban_user": "Benutzer sperren",
"more_options_user.banned": "Gesperrt",
"more_options_user.banning": "Sperre...",
@@ -1130,7 +1136,6 @@
"security.seo.title": "Sicherheit",
"security.title": "Sicherheit",
"send_message.button_label": "Nachricht",
"send_message.title": "Kontakt",
"settings.action.cancel": "Abbrechen",
"settings.action.save": "Speichern",
"settings.connection_preferences.description": "Kontrollieren Sie, wie andere sich mit Ihnen verbinden können.",

View File

@@ -494,6 +494,12 @@
"messages.you_prefix": "Vous : ",
"messaging.email_verification_required": "Vous devez vérifier votre e-mail pour pouvoir envoyer des messages.",
"messaging.send_thoughtful_message": "Envoyez-leur un message réfléchi",
"send_message.placeholder": "Qu'est-ce qui vous a vraiment touché dans le profil de {name} ? Qu'aimeriez-vous explorer ensemble ?",
"send_message.title": "Commencez une conversation qui a du sens",
"send_message.guidance": "Prenez un moment pour écrire quelque chose d'authentique.",
"send_message.keywords_hint": "Insérez quelques-uns des sujets de {name} dans votre message",
"send_message.more_chars": "{count} caractères de plus",
"send_message.ready": "Prêt à envoyer",
"more_options_user.ban_user": "Bannir l'utilisateur",
"more_options_user.banned": "Banni",
"more_options_user.banning": "Bannissement...",
@@ -1129,7 +1135,6 @@
"security.seo.title": "Sécurité",
"security.title": "Sécurité",
"send_message.button_label": "Contacter",
"send_message.title": "Contactez",
"settings.action.cancel": "Annuler",
"settings.action.save": "Enregistrer",
"settings.connection_preferences.description": "Contrôlez comment les autres peuvent se connecter avec vous.",

View File

@@ -131,6 +131,7 @@ export function CommentInputTextArea(props: {
cancelEditing?: () => void
isSubmitting: boolean
submitOnEnter?: boolean
isDisabled?: boolean
isEditing?: boolean
commentTypes?: CommentType[]
}) {
@@ -144,6 +145,7 @@ export function CommentInputTextArea(props: {
replyTo,
commentTypes = ['comment'],
cancelEditing,
isDisabled,
} = props
const t = useT()
@@ -200,7 +202,7 @@ export function CommentInputTextArea(props: {
{user && !isSubmitting && submit && commentTypes.includes('repost') && (
<Tooltip text={'Post question & comment to your followers'} className={'mt-2'}>
<button
disabled={!editor || editor.isEmpty}
disabled={isDisabled || !editor || editor.isEmpty}
className="text-ink-500 hover:text-ink-700 active:bg-ink-300 disabled:text-ink-300 px-2 transition-colors"
onClick={() => submit('repost')}
>
@@ -220,10 +222,10 @@ export function CommentInputTextArea(props: {
</button>
</Row>
)}
{!isSubmitting && submit && commentTypes.includes('comment') && (
{!isSubmitting && !isDisabled && submit && commentTypes.includes('comment') && (
<button
className="text-ink-500 hover:text-ink-700 active:bg-ink-300 disabled:text-ink-300 px-4 transition-colors"
disabled={!editor || editor.isEmpty}
disabled={isDisabled || !editor || editor.isEmpty}
onClick={() => submit('comment')}
>
<PaperAirplaneIcon className="m-0 h-[25px] w-[22px] p-0" />

View File

@@ -1,5 +1,6 @@
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'
@@ -9,10 +10,8 @@ import {Button} from 'web/components/buttons/button'
import {CommentInputTextArea} from 'web/components/comments/comment-input'
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 {useTextEditor} from 'web/components/widgets/editor'
import {Title} from 'web/components/widgets/title'
import {Tooltip} from 'web/components/widgets/tooltip'
import {useFirebaseUser} from 'web/hooks/use-firebase-user'
import {useSortedPrivateMessageMemberships} from 'web/hooks/use-private-messages'
@@ -24,13 +23,15 @@ import {useT} from 'web/lib/locale'
export const SendMessageButton = (props: {
toUser: User
currentUser: User | undefined | null
profile: Profile
includeLabel?: boolean
circleButton?: boolean
text?: string
tooltipText?: string
disabled?: boolean
}) => {
const {toUser, currentUser, includeLabel, circleButton, text, tooltipText, disabled} = props
const {toUser, currentUser, profile, includeLabel, circleButton, text, tooltipText, disabled} =
props
const firebaseUser = useFirebaseUser()
const router = useRouter()
const privateUser = usePrivateUser()
@@ -60,7 +61,13 @@ export const SendMessageButton = (props: {
key: `compose-new-message-${toUser.id}`,
size: 'sm',
max: MAX_COMMENT_LENGTH,
// placeholder: t('send_message.placeholder', '...'),
placeholder: t(
'send_message.placeholder',
`What genuinely resonated with you in {name}'s profile? What would you like to explore together?`,
{name: toUser.name},
),
className: 'min-h-[150px]',
nRowsMin: 3,
})
useEffect(() => {
@@ -97,6 +104,36 @@ export const SendMessageButton = (props: {
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
const charCount = editor?.getText().trim().length ?? 0
const pct = Math.min((charCount / MIN_CHARS) * 100, 100)
// Smooth color transition from red (0%) to green (100%)
const r = pct < 50 ? 255 : Math.round(((100 - pct) / 50) * 255)
const g = pct < 50 ? Math.round((pct / 50) * 255) : 255
const b = Math.round(0)
const barColor = `rgb(${r}, ${g}, ${b})`
if (privateUser?.blockedByUserIds.includes(toUser.id)) return null
return (
@@ -143,19 +180,78 @@ export const SendMessageButton = (props: {
<Modal open={openComposeModal} setOpen={setOpenComposeModal}>
<Col className={MODAL_CLASS}>
<Row className={'w-full'}>
<Title className={'!mb-2'}>
{t('send_message.title', 'Message')} {toUser.name}
</Title>
</Row>
<Col className={'w-full'}>
<p className={'!mb-2 text-xl font-bold'}>
{t('send_message.title', 'Start a meaningful conversation')}
</p>
<p className={'guidance'}>
{t(
'send_message.guidance',
'Compass is about depth. Take a moment to write something genuine.',
)}
</p>
</Col>
{firebaseUser?.emailVerified ? (
<CommentInputTextArea
editor={editor}
user={currentUser}
submit={sendMessage}
isSubmitting={!editor || submitting}
submitOnEnter={false}
/>
<>
{!!profile.keywords?.length && (
<div className={'w-full border border-canvas-100 rounded-xl p-2'}>
<p className={'text-ink-1000/55 mb-2 text-xs'}>
{t(
'send_message.keywords_hint',
`Insert some of {name} topics in your message`,
{name: toUser.name},
)}
</p>
<div className={'flex flex-wrap gap-2'}>
{profile.keywords.map((k) => (
<button
key={k}
type={'button'}
onClick={() => toggleChip(k)}
className={clsx(
'text-xs px-3 py-1 rounded-full transition-colors',
insertedChips.includes(k)
? 'bg-primary-200 border border-primary-300'
: 'bg-canvas-100 hover:bg-canvas-200',
)}
>
{k}
</button>
))}
</div>
</div>
)}
<CommentInputTextArea
editor={editor}
user={currentUser}
submit={sendMessage}
isSubmitting={!editor || submitting}
isDisabled={charCount < MIN_CHARS}
submitOnEnter={false}
/>
{/* quality meter */}
<div className={'mt-2 w-full flex items-center gap-3'}>
<div
className={
'h-1 flex-1 rounded-full bg-canvas-100 border border-canvas-300 overflow-hidden'
}
>
<div
className={'h-full rounded-full transition-all duration-300'}
style={{backgroundColor: barColor, width: `${pct}%`}}
/>
</div>
<span className={'tabular-nums guidance shrink-0'}>
{charCount < MIN_CHARS
? t('send_message.more_chars', '{count} more characters', {
count: MIN_CHARS - charCount,
})
: t('send_message.ready', 'Ready to send')}
</span>
</div>
</>
) : (
<EmailVerificationPrompt t={t} className="max-w-xl" />
)}

View File

@@ -45,7 +45,6 @@ import {Input} from 'web/components/widgets/input'
import {RadioToggleGroup} from 'web/components/widgets/radio-toggle-group'
import {Select} from 'web/components/widgets/select'
import {Slider} from 'web/components/widgets/slider'
import {Title} from 'web/components/widgets/title'
import {ChoiceMap, ChoiceSetter, useChoicesContext} from 'web/hooks/use-choices'
import {api} from 'web/lib/api'
import {useLocale, useT} from 'web/lib/locale'
@@ -277,9 +276,13 @@ export const OptionalProfileUserForm = (props: {
return (
<>
<Title>{t('profile.optional.subtitle', 'Optional information')}</Title>
<Col className={'gap-8'}>
<p className={'guidance'}>
{t(
'profile.optional.subtitle',
'Although all the fields below are optional, they will help people better understand you and connect with you.',
)}
</p>
<Category title={t('profile.llm.extract.title', 'Auto-fill')} className={'mt-0'} />
<LLMExtractSection
parsingEditor={parsingEditor}

View File

@@ -93,6 +93,7 @@ export function ConnectActions(props: {profile: Profile; user: User}) {
<SendMessageButton
toUser={user}
currentUser={currentUser}
profile={profile}
text={t('messaging.send_thoughtful_message', 'Send them a thoughtful message')}
/>
) : (

View File

@@ -221,6 +221,7 @@ export default function ProfileHeader(props: {
<SendMessageButton
toUser={user}
currentUser={currentUser}
profile={profile}
tooltipText={tooltipText}
disabled={!profile.allow_direct_messaging}
/>

View File

@@ -84,9 +84,11 @@ export function useTextEditor(props: {
extensions?: Extensions
className?: string
onChange?: () => void
nRowsMin?: number
}) {
const {placeholder, className, max, defaultValue, size = 'md', key, onChange} = props
const {placeholder, className, max, defaultValue, size = 'md', key, onChange, nRowsMin} = props
const simple = size === 'sm'
const minRows = nRowsMin ?? (simple ? 2 : 3)
const [content, setContent] = usePersistentLocalState<JSONContent | undefined>(
undefined,
@@ -116,7 +118,7 @@ export function useTextEditor(props: {
'dark:[&_.ProseMirror-gapcursor]:after:border-white', // gap cursor
className,
),
style: `min-height: ${1 + 1.625 * (simple ? 2 : 3)}em`, // 1em padding + 1.625 lines per row
style: `min-height: ${1 + 1.625 * minRows}em`, // 1em padding + 1.625 lines per row
},
})
@@ -149,7 +151,7 @@ export function useTextEditor(props: {
Placeholder.configure({
placeholder,
emptyEditorClass:
'before:content-[attr(data-placeholder)] before:text-ink-500 before:float-left before:h-0 cursor-text',
'before:content-[attr(data-placeholder)] before:text-ink-1000/55 before:text-sm before:float-left before:h-0 cursor-text',
}),
CharacterCount.configure({limit: max}),
...(props.extensions ?? []),

View File

@@ -1,167 +1,167 @@
import {UserIcon} from '@heroicons/react/24/solid'
import {LikeData, ShipData} from 'common/api/profile-types'
import {Profile} from 'common/profiles/profile'
import {keyBy, orderBy} from 'lodash'
import Image from 'next/image'
import Link from 'next/link'
import {Col} from 'web/components/layout/col'
import {SendMessageButton} from 'web/components/messaging/send-message-button'
import {Avatar, EmptyAvatar} from 'web/components/widgets/avatar'
import {Carousel} from 'web/components/widgets/carousel'
import {UserLink} from 'web/components/widgets/user-link'
import {useProfileByUserId} from 'web/hooks/use-profile'
import {useUser} from 'web/hooks/use-user'
import {useUserById} from 'web/hooks/use-user-supabase'
import {Subtitle} from './profile-subtitle'
import {ShipsList} from './ships-display'
export const LikesDisplay = (props: {
likesGiven: LikeData[]
likesReceived: LikeData[]
ships: ShipData[]
refreshShips: () => Promise<void>
profileProfile: Profile
}) => {
const {likesGiven, likesReceived, ships, refreshShips, profileProfile} = props
const likesGivenByUserId = keyBy(likesGiven, (l) => l.user_id)
const likesReceivedByUserId = keyBy(likesReceived, (l) => l.user_id)
const mutualLikeUserIds = Object.keys(likesGivenByUserId).filter(
(userId) => likesReceivedByUserId[userId],
)
const mutualLikes = mutualLikeUserIds.map((user_id) => {
const likeGiven = likesGivenByUserId[user_id]
const likeReceived = likesReceivedByUserId[user_id]
const created_time = Math.max(likeGiven.created_time, likeReceived.created_time)
return {user_id, created_time}
})
const sortedMutualLikes = orderBy(mutualLikes, 'created_time', 'desc')
const onlyLikesGiven = likesGiven.filter((l) => !likesReceivedByUserId[l.user_id])
const onlyLikesReceived = likesReceived.filter((l) => !likesGivenByUserId[l.user_id])
if (
sortedMutualLikes.length === 0 &&
onlyLikesReceived.length === 0 &&
onlyLikesGiven.length === 0 &&
ships.length === 0
) {
return null
}
return (
<Col className="gap-4">
{sortedMutualLikes.length > 0 && (
<Col className="gap-2">
<Subtitle>Mutual likes</Subtitle>
<Carousel>
{sortedMutualLikes.map((like) => {
return (
<MatchTile
key={like.user_id}
matchUserId={like.user_id}
profileProfile={profileProfile}
/>
)
})}
</Carousel>
</Col>
)}
{onlyLikesReceived.length > 0 && (
<LikesList label="Likes received" likes={onlyLikesReceived} />
)}
{onlyLikesGiven.length > 0 && <LikesList label="Likes given" likes={onlyLikesGiven} />}
{ships.length > 0 && (
<ShipsList
label="Shipped with"
ships={ships}
profileProfile={profileProfile}
refreshShips={refreshShips}
/>
)}
</Col>
)
}
const LikesList = (props: {label: string; likes: LikeData[]}) => {
const {label, likes} = props
const maxShown = 50
const truncatedLikes = likes.slice(0, maxShown)
return (
<Col className="gap-1">
<Subtitle>{label}</Subtitle>
{truncatedLikes.length > 0 ? (
<Carousel className="w-full" labelsParentClassName="gap-0">
{truncatedLikes.map((like) => (
<UserAvatar className="-ml-1 first:ml-0" key={like.user_id} userId={like.user_id} />
))}
</Carousel>
) : (
<div className="text-ink-500">None</div>
)}
</Col>
)
}
const UserAvatar = (props: {userId: string; className?: string}) => {
const {userId, className} = props
const profile = useProfileByUserId(userId)
const user = useUserById(userId)
// console.debug('UserAvatar', user?.username, profile?.pinned_url)
if (!profile) return <EmptyAvatar className={className} size={10} />
return <Avatar className={className} avatarUrl={profile.pinned_url} username={user?.username} />
}
export const MatchTile = (props: {profileProfile: Profile; matchUserId: string}) => {
const {matchUserId, profileProfile} = props
const profile = useProfileByUserId(matchUserId)
const user = useUserById(matchUserId)
const currentUser = useUser()
const isYourMatch = currentUser?.id === profileProfile.user_id
if (!profile || !user) return <Col className="mb-2 h-[184px] w-[200px] shrink-0"></Col>
const {pinned_url} = profile
return (
<Col className="mb-2 w-[200px] shrink-0 overflow-hidden rounded">
<Col className="bg-canvas-0 w-full px-4 py-2">
<UserLink
className={
'hover:text-primary-500 text-ink-1000 truncate font-semibold transition-colors'
}
user={user}
hideBadge
/>
</Col>
<Col className="relative h-36 w-full overflow-hidden">
{pinned_url ? (
<Link href={`/${user.username}`}>
<Image
src={pinned_url}
// You must set these so we don't pay an extra $1k/month to vercel
width={200}
height={144}
alt={`${user.username}`}
className="h-36 w-full object-cover"
/>
</Link>
) : (
<Col className="bg-ink-300 h-full w-full items-center justify-center">
<UserIcon className="h-20 w-20" />
</Col>
)}
{isYourMatch && (
<Col className="absolute right-3 top-2 gap-2">
<SendMessageButton toUser={user as any} currentUser={currentUser} circleButton />
</Col>
)}
</Col>
</Col>
)
}
// import {UserIcon} from '@heroicons/react/24/solid'
// import {LikeData, ShipData} from 'common/api/profile-types'
// import {Profile} from 'common/profiles/profile'
// import {keyBy, orderBy} from 'lodash'
// import Image from 'next/image'
// import Link from 'next/link'
// import {Col} from 'web/components/layout/col'
// import {SendMessageButton} from 'web/components/messaging/send-message-button'
// import {Avatar, EmptyAvatar} from 'web/components/widgets/avatar'
// import {Carousel} from 'web/components/widgets/carousel'
// import {UserLink} from 'web/components/widgets/user-link'
// import {useProfileByUserId} from 'web/hooks/use-profile'
// import {useUser} from 'web/hooks/use-user'
// import {useUserById} from 'web/hooks/use-user-supabase'
//
// import {Subtitle} from './profile-subtitle'
// import {ShipsList} from './ships-display'
//
// export const LikesDisplay = (props: {
// likesGiven: LikeData[]
// likesReceived: LikeData[]
// ships: ShipData[]
// refreshShips: () => Promise<void>
// profileProfile: Profile
// }) => {
// const {likesGiven, likesReceived, ships, refreshShips, profileProfile} = props
//
// const likesGivenByUserId = keyBy(likesGiven, (l) => l.user_id)
// const likesReceivedByUserId = keyBy(likesReceived, (l) => l.user_id)
// const mutualLikeUserIds = Object.keys(likesGivenByUserId).filter(
// (userId) => likesReceivedByUserId[userId],
// )
//
// const mutualLikes = mutualLikeUserIds.map((user_id) => {
// const likeGiven = likesGivenByUserId[user_id]
// const likeReceived = likesReceivedByUserId[user_id]
// const created_time = Math.max(likeGiven.created_time, likeReceived.created_time)
// return {user_id, created_time}
// })
// const sortedMutualLikes = orderBy(mutualLikes, 'created_time', 'desc')
// const onlyLikesGiven = likesGiven.filter((l) => !likesReceivedByUserId[l.user_id])
// const onlyLikesReceived = likesReceived.filter((l) => !likesGivenByUserId[l.user_id])
//
// if (
// sortedMutualLikes.length === 0 &&
// onlyLikesReceived.length === 0 &&
// onlyLikesGiven.length === 0 &&
// ships.length === 0
// ) {
// return null
// }
//
// return (
// <Col className="gap-4">
// {sortedMutualLikes.length > 0 && (
// <Col className="gap-2">
// <Subtitle>Mutual likes</Subtitle>
// <Carousel>
// {sortedMutualLikes.map((like) => {
// return (
// <MatchTile
// key={like.user_id}
// matchUserId={like.user_id}
// profileProfile={profileProfile}
// />
// )
// })}
// </Carousel>
// </Col>
// )}
//
// {onlyLikesReceived.length > 0 && (
// <LikesList label="Likes received" likes={onlyLikesReceived} />
// )}
// {onlyLikesGiven.length > 0 && <LikesList label="Likes given" likes={onlyLikesGiven} />}
// {ships.length > 0 && (
// <ShipsList
// label="Shipped with"
// ships={ships}
// profileProfile={profileProfile}
// refreshShips={refreshShips}
// />
// )}
// </Col>
// )
// }
//
// const LikesList = (props: {label: string; likes: LikeData[]}) => {
// const {label, likes} = props
//
// const maxShown = 50
// const truncatedLikes = likes.slice(0, maxShown)
//
// return (
// <Col className="gap-1">
// <Subtitle>{label}</Subtitle>
// {truncatedLikes.length > 0 ? (
// <Carousel className="w-full" labelsParentClassName="gap-0">
// {truncatedLikes.map((like) => (
// <UserAvatar className="-ml-1 first:ml-0" key={like.user_id} userId={like.user_id} />
// ))}
// </Carousel>
// ) : (
// <div className="text-ink-500">None</div>
// )}
// </Col>
// )
// }
//
// const UserAvatar = (props: {userId: string; className?: string}) => {
// const {userId, className} = props
// const profile = useProfileByUserId(userId)
// const user = useUserById(userId)
//
// // console.debug('UserAvatar', user?.username, profile?.pinned_url)
//
// if (!profile) return <EmptyAvatar className={className} size={10} />
// return <Avatar className={className} avatarUrl={profile.pinned_url} username={user?.username} />
// }
//
// export const MatchTile = (props: {profileProfile: Profile; matchUserId: string}) => {
// const {matchUserId, profileProfile} = props
// const profile = useProfileByUserId(matchUserId)
// const user = useUserById(matchUserId)
// const currentUser = useUser()
// const isYourMatch = currentUser?.id === profileProfile.user_id
//
// if (!profile || !user) return <Col className="mb-2 h-[184px] w-[200px] shrink-0"></Col>
// const {pinned_url} = profile
//
// return (
// <Col className="mb-2 w-[200px] shrink-0 overflow-hidden rounded">
// <Col className="bg-canvas-0 w-full px-4 py-2">
// <UserLink
// className={
// 'hover:text-primary-500 text-ink-1000 truncate font-semibold transition-colors'
// }
// user={user}
// hideBadge
// />
// </Col>
// <Col className="relative h-36 w-full overflow-hidden">
// {pinned_url ? (
// <Link href={`/${user.username}`}>
// <Image
// src={pinned_url}
// // You must set these so we don't pay an extra $1k/month to vercel
// width={200}
// height={144}
// alt={`${user.username}`}
// className="h-36 w-full object-cover"
// />
// </Link>
// ) : (
// <Col className="bg-ink-300 h-full w-full items-center justify-center">
// <UserIcon className="h-20 w-20" />
// </Col>
// )}
// {isYourMatch && (
// <Col className="absolute right-3 top-2 gap-2">
// <SendMessageButton toUser={user as any} currentUser={currentUser} circleButton />
// </Col>
// )}
// </Col>
// </Col>
// )
// }

6
web/lib/util/strings.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Returns the possessive form of a name.
* Adds 's unless the name already ends with s, in which case just adds '.
*/
export const possessive = (name: string) =>
name.endsWith('s') ? `${name}'` : `${name}'s`