Files
Compass/web/components/chat/chat-message.tsx
Okechi Jones-Williams 549eba0bbe [User functionality] Messages UI page and messaging helpers added for end-to-end scenarios (#53)
* Added Database checks to the onboarding flow

* Added compatibility page setup
Added more compatibility questions

* Finished up the onboarding flow suite
Added compatibility question tests and verifications
Updated tests to cover Keywords and Headline changes recently made
Updated tests to cover all of the big5 personality traits

* .

* Fix: Merge conflict

* .

* Fix: Added fix for None discriptive error issue #36
Updated signUp.spec.ts to use new fixture
Updated Account information variable names
Deleted "deleteUserFixture.ts" as it was incorporated into the "base.ts" file

* Linting and Prettier

* Minor cleaning

* Added Google account to the Onboarding flow

* Added account cleanup for google accounts

* Started work on Sign-in tests
Updated seedDatabase.ts to throw an error if the user already exists, to also add display names and usernames so they seedUser func acts like a normal basic user
Some organising of the google auth code

* Linting and Prettier

* Added checks to the deleteUser func to check if the accout exists
Added account deletion checks

* Linting and Prettier

* Formatting update, fixed homePage locator for signin

* .

* .

* .

* Coderabbitai fix's

* Fix

* Improve test utilities and stabilize onboarding flow tests

* Changes requested

* Changed POM/Fixture structure to use an app class to instantiate the page objects

* Apply suggestion from @MartinBraquet

* Delete .vscode/settings.json

* Apply suggestion from @MartinBraquet

* Apply suggestion from @MartinBraquet

* Apply suggestion from @MartinBraquet

* Linting and Prettier

* Updated People page

* Fix app.ts

* Updated peoplePage.ts: continued adding functions to use filters
Updated filters.tsx: added data testid

* Coderabbitai fix's

* .

* Updated People page
Added data test attributes to search.tsx and profile-grid.tsx

* Lint and Prettier

* .

* Continued work on filter tests
Added testid attributes to people page so the info from the results can be accessed

* Added more filter tests

* Added tests for hiding profiles

* Added a context manager to test with multiple accounts interacting with each other
Added an option to verify the users email when creating an account

* Added Tests for sending/recieving messages
Added tests for staring/favoriting profiles
Added testIds where necessary
Added messagesPage.ts
Updated peoplePage.ts

* Linting and Prettier

* Coderabbit suggestions

* CodeRabbit suggestion #2

* Fix #1

* Minor fixes

* TC fix

---------

Co-authored-by: MartinBraquet <martin.braquet@gmail.com>
2026-05-29 01:00:57 +02:00

317 lines
11 KiB
TypeScript

import clsx from 'clsx'
import {DisplayUser} from 'common/api/user-types'
import {ChatMessage} from 'common/chat-message'
import {compassUserId} from 'common/profiles/constants'
import {first, last} from 'lodash'
import {Dispatch, memo, SetStateAction, useRef, useState} from 'react'
import {MessageActions} from 'web/components/chat/message-actions'
import {MessageReactions} from 'web/components/chat/message-reactions'
import {Col} from 'web/components/layout/col'
import {Modal, MODAL_CLASS} from 'web/components/layout/modal'
import {Row} from 'web/components/layout/row'
import {MultipleOrSingleAvatars} from 'web/components/multiple-or-single-avatars'
import {RelativeTimestamp} from 'web/components/relative-timestamp'
import {Avatar} from 'web/components/widgets/avatar'
import {Content} from 'web/components/widgets/editor'
import {UserAvatarAndBadge} from 'web/components/widgets/user-link'
export function ChatMessageItem(props: {
chats: ChatMessage[]
currentUser: DisplayUser | undefined | null
otherUser?: DisplayUser | null
onReplyClick?: (chat: ChatMessage) => void
beforeSameUser: boolean
firstOfUser: boolean
hideAvatar: boolean
onRequestEdit?: (chat: ChatMessage) => void
setMessages?: Dispatch<SetStateAction<ChatMessage[] | undefined>>
}) {
const {
chats,
onReplyClick,
currentUser,
otherUser,
beforeSameUser,
firstOfUser,
hideAvatar,
onRequestEdit,
setMessages,
} = props
const chat = first(chats)
const [emojiOpenForId, setEmojiOpenForId] = useState<number | null>(null)
const [emojiKey, setEmojiKey] = useState(0)
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasMovedRef = useRef(false)
const lastPositionRef = useRef<{x: number; y: number} | null>(null)
if (!chat) return null
// console.log('chat', chat)
const isMe = currentUser?.id === chat.userId
const {username, avatarUrl, id} =
!isMe && otherUser
? otherUser
: isMe && currentUser
? currentUser
: {username: '', avatarUrl: undefined, id: ''}
const startLongPress = (messageId: number) => {
hasMovedRef.current = false
lastPositionRef.current = null
if (longPressTimerRef.current) clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = setTimeout(() => {
if (!hasMovedRef.current) {
setEmojiOpenForId(messageId)
setEmojiKey((k) => k + 1)
}
}, 1000)
}
const cancelLongPress = () => {
if (longPressTimerRef.current) {
clearTimeout(longPressTimerRef.current)
longPressTimerRef.current = null
}
}
const handleMouseMove = (e: React.MouseEvent) => {
const currentPos = {x: e.clientX, y: e.clientY}
if (lastPositionRef.current) {
const dx = Math.abs(currentPos.x - lastPositionRef.current.x)
const dy = Math.abs(currentPos.y - lastPositionRef.current.y)
if (dx > 5 || dy > 5) {
hasMovedRef.current = true
}
}
lastPositionRef.current = currentPos
}
return (
<Row
className={clsx(
'@container items-end justify-start gap-1',
isMe && 'flex-row-reverse',
firstOfUser ? 'mt-2' : 'mt-1',
)}
>
{!isMe && !hideAvatar && (
<MessageAvatar
beforeSameUser={beforeSameUser}
username={username}
userAvatarUrl={avatarUrl}
userId={id}
/>
)}
<Col className="sm:max-w-[calc(100vw-6rem)] md:max-w-[70%]">
<Col className="gap-1">
{chats.map((chat) => (
<div
className={clsx('group flex items-end gap-1', isMe && 'flex-row-reverse')}
key={chat.id}
>
<div className="group relative">
<Row>
<div
className={clsx(
'rounded-3xl px-3 py-2',
chat.visibility !== 'system_status' && '',
chat.visibility === 'system_status'
? 'bg-canvas-50 italic'
: isMe
? 'bg-primary-200 items-end self-end rounded-r-none group-first:rounded-tr-3xl'
: 'bg-canvas-50 items-start self-start rounded-l-none group-first:rounded-tl-3xl',
)}
onMouseDown={() => startLongPress(chat.id)}
onMouseUp={cancelLongPress}
onMouseLeave={cancelLongPress}
onMouseMove={handleMouseMove}
onTouchStart={() => startLongPress(chat.id)}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
data-testid="conversation-message"
>
<Content
size={'sm'}
content={chat.content}
key={chat.id}
className={'!text-ink-1000'}
/>
</div>
</Row>
{/* Hidden host for emoji picker, opened via long-press */}
<div className={clsx('absolute -mt-2', isMe ? 'right-40' : 'left-40')}>
<MessageActions
message={{
id: chat.id,
userId: chat.userId,
content: chat.content,
isEdited: chat.isEdited,
reactions: chat.reactions,
}}
setMessages={setMessages}
hideTrigger
openEmojiPickerKey={emojiOpenForId === chat.id ? emojiKey : undefined}
/>
</div>
<MessageReactions
message={{
id: chat.id,
reactions: chat.reactions as Record<string, string[]> | undefined,
}}
className={clsx('ml-2', isMe ? 'justify-end' : 'justify-start')}
setMessages={setMessages}
/>
</div>
<Col className="mb-2 mr-1 text-xs">
{chat.visibility !== 'system_status' && (
<Row className={'items-center gap-3'}>
{/*{!isMe &&*/}
{/* <Link*/}
{/* href={'/' + username}*/}
{/* className="text-ink-500 dark:text-ink-600 pl-3 text-sm"*/}
{/* >*/}
{/* {name}*/}
{/* </Link>*/}
{/*}*/}
{onReplyClick && (
<div className="flex items-center gap-1">
{/*<button*/}
{/* className="text-ink-400 hover:text-ink-600"*/}
{/* onClick={() => onReplyClick?.(chat)}*/}
{/*>*/}
{/* <ReplyIcon className="h-4 w-4"/>*/}
{/*</button>*/}
<MessageActions
message={{
id: chat.id,
userId: chat.userId,
content: chat.content,
isEdited: chat.isEdited,
reactions: chat.reactions,
}}
setMessages={setMessages}
onRequestEdit={() => onRequestEdit?.(chat)}
className="text-xs group-last:block"
/>
</div>
)}
</Row>
)}
<RelativeTimestamp
time={chat.createdTime}
shortened
className="hidden text-xs group-last:block"
/>
</Col>
</div>
))}
</Col>
</Col>
<div className={clsx(isMe ? 'pr-1' : '', 'pb-2')}></div>
</Row>
)
}
export const SystemChatMessageItem = memo(function SystemChatMessageItem(props: {
chats: ChatMessage[]
otherUsers: DisplayUser[] | undefined
}) {
const {chats, otherUsers} = props
const chat = last(chats)
const [showUsers, setShowUsers] = useState(false)
if (!chat) return null
const totalUsers = otherUsers?.length || 1
const hideAvatar =
(chat.visibility === 'system_status' && chat.userId === compassUserId && chats.length === 1) ||
totalUsers < 2
return (
<Row className={clsx('flex-row-reverse items-center gap-1')}>
<Row className="grow" />
<Col className={clsx('grow-y justify-end pb-2')}>
<RelativeTimestamp time={chat.createdTime} shortened className="text-xs" />
</Col>
<Col className="max-w-[calc(100vw-6rem)] md:max-w-[80%]">
<Col className={clsx(' bg-canvas-50 px-1 py-2 text-sm italic')}>
{totalUsers > 1 ? (
<span>
{totalUsers} user{totalUsers > 1 ? 's' : ''} joined the chat!
</span>
) : (
<>
<Content content={chat.content} size={'sm'} />
{chat.visibility !== 'system_status' && (
<div className="invisible absolute right-0 top-0 -mt-2 flex translate-x-2 items-center opacity-0 transition-all group-hover:visible group-hover:translate-x-0 group-hover:opacity-100">
<MessageActions
message={{
id: chat.id,
userId: chat.userId,
content: chat.content,
isEdited: chat.isEdited,
reactions: chat.reactions,
}}
/>
</div>
)}
</>
)}
</Col>
</Col>
{!hideAvatar && (
<MultipleOrSingleAvatars
size={'xs'}
spacing={0.3}
startLeft={0.6}
avatars={otherUsers || []}
onClick={() => setShowUsers(true)}
/>
)}
{showUsers && (
<MultiUserModal
showUsers={showUsers}
setShowUsers={setShowUsers}
otherUsers={otherUsers ?? []}
/>
)}
</Row>
)
})
export const MultiUserModal = (props: {
showUsers: boolean
setShowUsers: (show: boolean) => void
otherUsers: DisplayUser[]
}) => {
const {showUsers, setShowUsers, otherUsers} = props
return (
<Modal open={showUsers} setOpen={setShowUsers}>
<Col className={clsx(MODAL_CLASS)}>
{otherUsers?.map((user) => (
<Row key={user.id} className={'w-full items-center justify-start gap-2'}>
<UserAvatarAndBadge user={user} />
</Row>
))}
</Col>
</Modal>
)
}
function MessageAvatar(props: {
beforeSameUser: boolean
userAvatarUrl?: string
username?: string
userId: string
}) {
const {beforeSameUser, userAvatarUrl, username} = props
return (
<Col
className={clsx(
beforeSameUser ? 'pointer-events-none invisible' : '',
'grow-y justify-end pb-2 pr-1',
)}
>
<Avatar avatarUrl={userAvatarUrl} username={username} size="xs" />
</Col>
)
}