mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #31038 from overleaf/jdt-mj-rm-chat-ide-redesign
Editor Redesign Cleanup: Chat GitOrigin-RevId: 98f969ee84a86761466de182787443b8c9bacefd
This commit is contained in:
committed by
Copybot
parent
551d7b3908
commit
6890f1bb3c
@@ -1179,7 +1179,6 @@
|
||||
"no_image_files_found": "",
|
||||
"no_libraries_selected": "",
|
||||
"no_members": "",
|
||||
"no_messages": "",
|
||||
"no_messages_yet": "",
|
||||
"no_new_commits_in_github": "",
|
||||
"no_one_has_commented_or_left_any_suggestions_yet": "",
|
||||
@@ -1643,7 +1642,6 @@
|
||||
"selection_deleted": "",
|
||||
"send": "",
|
||||
"send_confirmation_code": "",
|
||||
"send_first_message": "",
|
||||
"send_message": "",
|
||||
"send_request": "",
|
||||
"sending": "",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RailIndicator } from '@/features/ide-react/components/rail/rail-indicator'
|
||||
import { useChatContext } from '@/features/chat/context/chat-context'
|
||||
|
||||
export const ChatIndicator = () => {
|
||||
const { unreadMessageCount } = useChatContext()
|
||||
if (unreadMessageCount === 0) {
|
||||
return null
|
||||
}
|
||||
return <RailIndicator count={unreadMessageCount} type="info" />
|
||||
}
|
||||
|
||||
export default ChatIndicator
|
||||
@@ -1,27 +1,23 @@
|
||||
import React, { lazy, Suspense, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import MessageInput from './message-input'
|
||||
import InfiniteScroll from './infinite-scroll'
|
||||
import ChatFallbackError from './chat-fallback-error'
|
||||
import { useLayoutContext } from '../../../shared/context/layout-context'
|
||||
import { useUserContext } from '../../../shared/context/user-context'
|
||||
import withErrorBoundary from '../../../infrastructure/error-boundary'
|
||||
import { FetchError } from '../../../infrastructure/fetch-json'
|
||||
import { useChatContext } from '../context/chat-context'
|
||||
import { FullSizeLoadingSpinner } from '../../../shared/components/loading-spinner'
|
||||
import ChatFallbackError from '@/features/chat/components/chat-fallback-error'
|
||||
import InfiniteScroll from '@/features/chat/components/infinite-scroll'
|
||||
import MessageInput from '@/features/chat/components/message-input'
|
||||
import { useChatContext } from '@/features/chat/context/chat-context'
|
||||
import { FetchError } from '@/infrastructure/fetch-json'
|
||||
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header'
|
||||
|
||||
const MessageList = lazy(() => import('./message-list'))
|
||||
|
||||
const Loading = () => <FullSizeLoadingSpinner delay={500} className="pt-4" />
|
||||
|
||||
const ChatPane = React.memo(function ChatPane() {
|
||||
export const ChatPane = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { chatIsOpen } = useLayoutContext()
|
||||
const user = useUserContext()
|
||||
|
||||
const {
|
||||
status,
|
||||
messages,
|
||||
@@ -36,23 +32,13 @@ const ChatPane = React.memo(function ChatPane() {
|
||||
} = useChatContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (chatIsOpen && !initialMessagesLoaded) {
|
||||
if (!initialMessagesLoaded) {
|
||||
loadInitialMessages()
|
||||
}
|
||||
}, [chatIsOpen, loadInitialMessages, initialMessagesLoaded])
|
||||
}, [loadInitialMessages, initialMessagesLoaded])
|
||||
|
||||
const shouldDisplayPlaceholder = status !== 'pending' && messages.length === 0
|
||||
|
||||
const messageContentCount = messages.length
|
||||
|
||||
// Keep the chat pane in the DOM to avoid resetting the form input and re-rendering MathJax content.
|
||||
const [chatOpenedOnce, setChatOpenedOnce] = useState(chatIsOpen)
|
||||
useEffect(() => {
|
||||
if (chatIsOpen) {
|
||||
setChatOpenedOnce(true)
|
||||
}
|
||||
}, [chatIsOpen])
|
||||
|
||||
if (error) {
|
||||
// let user try recover from fetch errors
|
||||
if (error instanceof FetchError) {
|
||||
@@ -64,51 +50,58 @@ const ChatPane = React.memo(function ChatPane() {
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
if (!chatOpenedOnce) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="chat" aria-label={t('chat')}>
|
||||
<InfiniteScroll
|
||||
atEnd={atEnd}
|
||||
className="messages"
|
||||
fetchData={loadMoreMessages}
|
||||
isLoading={status === 'pending'}
|
||||
itemCount={messageContentCount}
|
||||
>
|
||||
<div>
|
||||
<h2 className="visually-hidden">{t('chat')}</h2>
|
||||
<Suspense fallback={<Loading />}>
|
||||
{status === 'pending' && <Loading />}
|
||||
{shouldDisplayPlaceholder && <Placeholder />}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
resetUnreadMessages={markMessagesAsRead}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
<MessageInput
|
||||
resetUnreadMessages={markMessagesAsRead}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
</aside>
|
||||
<div className="chat-panel">
|
||||
<RailPanelHeader title={t('collaborator_chat')} />
|
||||
<div className="chat-wrapper">
|
||||
<aside className="chat" aria-label={t('chat')}>
|
||||
<InfiniteScroll
|
||||
atEnd={atEnd}
|
||||
className="messages"
|
||||
fetchData={loadMoreMessages}
|
||||
isLoading={status === 'pending'}
|
||||
itemCount={messages.length}
|
||||
>
|
||||
<div className={classNames({ 'h-100': shouldDisplayPlaceholder })}>
|
||||
<h2 className="visually-hidden">{t('chat')}</h2>
|
||||
<Suspense fallback={<Loading />}>
|
||||
{status === 'pending' && <Loading />}
|
||||
{shouldDisplayPlaceholder && <Placeholder />}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
resetUnreadMessages={markMessagesAsRead}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
<MessageInput
|
||||
resetUnreadMessages={markMessagesAsRead}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function Placeholder() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
<div className="no-messages text-center small">{t('no_messages')}</div>
|
||||
<div className="first-message text-center">
|
||||
{t('send_first_message')}
|
||||
<br />
|
||||
<MaterialIcon type="arrow_downward" />
|
||||
<div className="chat-empty-state-placeholder">
|
||||
<div>
|
||||
<span className="chat-empty-state-icon">
|
||||
<MaterialIcon type="forum" />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<div className="chat-empty-state-title">{t('no_messages_yet')}</div>
|
||||
<div className="chat-empty-state-body">
|
||||
{t('start_the_conversation_by_saying_hello_or_sharing_an_update')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default withErrorBoundary(ChatPane, () => <ChatFallbackError />)
|
||||
export default ChatPane
|
||||
|
||||
@@ -1,39 +1,89 @@
|
||||
import type { Message } from '@/features/chat/context/chat-context'
|
||||
import { User } from '../../../../../types/user'
|
||||
import {
|
||||
Message as MessageType,
|
||||
useChatContext,
|
||||
} from '@/features/chat/context/chat-context'
|
||||
import classNames from 'classnames'
|
||||
import MessageDropdown from '@/features/chat/components/message-dropdown'
|
||||
getBackgroundColorForUserId,
|
||||
hslStringToLuminance,
|
||||
} from '@/shared/utils/colors'
|
||||
import MessageContent from '@/features/chat/components/message-content'
|
||||
import classNames from 'classnames'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import MessageDropdown from '@/features/chat/components/message-dropdown'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import firstCharacter from '@/shared/utils/first-character'
|
||||
|
||||
function getAvatarStyle(user?: User) {
|
||||
if (!user?.id) {
|
||||
// Deleted user
|
||||
return {
|
||||
backgroundColor: 'var(--bg-light-disabled)',
|
||||
borderColor: 'var(--bg-light-disabled)',
|
||||
color: 'var(--content-disabled)',
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundColor = getBackgroundColorForUserId(user.id)
|
||||
|
||||
return {
|
||||
borderColor: backgroundColor,
|
||||
backgroundColor,
|
||||
color:
|
||||
hslStringToLuminance(backgroundColor) < 0.5
|
||||
? 'var(--content-primary-dark)'
|
||||
: 'var(--content-primary)',
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageAndDropdown({
|
||||
message,
|
||||
fromSelf,
|
||||
isLast,
|
||||
isFirst,
|
||||
}: {
|
||||
message: MessageType
|
||||
message: Message
|
||||
fromSelf: boolean
|
||||
isLast: boolean
|
||||
isFirst: boolean
|
||||
}) {
|
||||
const { idOfMessageBeingEdited } = useChatContext()
|
||||
const hasChatEditDelete = useFeatureFlag('chat-edit-delete')
|
||||
|
||||
const editing = idOfMessageBeingEdited === message.id
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('message-and-dropdown', {
|
||||
'pending-message': message.pending,
|
||||
})}
|
||||
>
|
||||
{hasChatEditDelete && fromSelf && !message.pending && !editing ? (
|
||||
<MessageDropdown message={message} />
|
||||
) : null}
|
||||
<div className="message-content">
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
messageId={message.id}
|
||||
edited={message.edited}
|
||||
/>
|
||||
<div className="message-row">
|
||||
{!fromSelf && isLast ? (
|
||||
<div className="message-avatar">
|
||||
<div className="avatar" style={getAvatarStyle(message.user)}>
|
||||
{message.user?.id && message.user.email ? (
|
||||
firstCharacter(message.user.first_name || message.user.email)
|
||||
) : (
|
||||
<MaterialIcon
|
||||
type="delete"
|
||||
className="message-avatar-deleted-user-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-avatar-placeholder" />
|
||||
)}
|
||||
<div
|
||||
className={classNames('message-container', {
|
||||
'message-from-self': fromSelf,
|
||||
'first-row-in-message': isFirst,
|
||||
'last-row-in-message': isLast,
|
||||
'pending-message': message.pending,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{hasChatEditDelete && fromSelf ? (
|
||||
<MessageDropdown message={message} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
messageId={message.id}
|
||||
edited={message.edited}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { getHueForUserId } from '@/shared/utils/colors'
|
||||
import { MessageAndDropdown } from './message-and-dropdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Message as MessageType } from '@/features/chat/context/chat-context'
|
||||
import { User } from '../../../../../types/user'
|
||||
import classNames from 'classnames'
|
||||
import { MessageAndDropdown } from '@/features/chat/components/message-and-dropdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface MessageGroupProps {
|
||||
messages: MessageType[]
|
||||
@@ -11,50 +9,40 @@ export interface MessageGroupProps {
|
||||
fromSelf: boolean
|
||||
}
|
||||
|
||||
function hue(user?: User) {
|
||||
return user ? getHueForUserId(user.id) : 0
|
||||
}
|
||||
|
||||
function getMessageStyle(user?: User) {
|
||||
return {
|
||||
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
|
||||
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
|
||||
}
|
||||
}
|
||||
|
||||
function getArrowStyle(user?: User) {
|
||||
return {
|
||||
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
|
||||
}
|
||||
}
|
||||
|
||||
function MessageGroup({ messages, user, fromSelf }: MessageGroupProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('message-wrapper', {
|
||||
'own-message-wrapper': fromSelf,
|
||||
})}
|
||||
>
|
||||
{!fromSelf && (
|
||||
<div className="name" translate="no">
|
||||
<span>
|
||||
{user ? user.first_name || user.email : t('deleted_user')}
|
||||
</span>
|
||||
<div className="chat-message">
|
||||
<div>
|
||||
<div className="message-row">
|
||||
<div className="message-avatar-placeholder" />
|
||||
{!fromSelf && (
|
||||
<div className="message-author">
|
||||
<span>
|
||||
{user?.id && user.email
|
||||
? user.first_name || user.email
|
||||
: t('deleted_user')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="message" style={getMessageStyle(user)}>
|
||||
{!fromSelf && <div className="arrow" style={getArrowStyle(user)} />}
|
||||
|
||||
{messages.map(message => (
|
||||
</div>
|
||||
{messages.map(message => {
|
||||
const nonDeletedMessages = messages.filter(m => !m.deleted)
|
||||
const nonDeletedIndex = nonDeletedMessages.findIndex(
|
||||
m => m.id === message.id
|
||||
)
|
||||
return (
|
||||
<MessageAndDropdown
|
||||
key={message.id}
|
||||
message={message}
|
||||
fromSelf={fromSelf}
|
||||
isLast={nonDeletedIndex === nonDeletedMessages.length - 1}
|
||||
isFirst={nonDeletedIndex === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Message as MessageType } from '@/features/chat/context/chat-contex
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { User } from '../../../../../types/user'
|
||||
import MessageGroup from '@/features/chat/components/message-group'
|
||||
import MessageGroupRedesign from '@/features/ide-redesign/components/chat/message-group'
|
||||
|
||||
const FIVE_MINUTES = 5 * 60 * 1000
|
||||
const TIMESTAMP_GROUP_SIZE = FIVE_MINUTES
|
||||
@@ -19,7 +18,6 @@ function formatTimestamp(date: moment.MomentInput) {
|
||||
interface MessageListProps {
|
||||
messages: MessageType[]
|
||||
resetUnreadMessages(...args: unknown[]): unknown
|
||||
newDesign?: boolean
|
||||
}
|
||||
|
||||
type MessageGroupType = {
|
||||
@@ -63,15 +61,9 @@ function groupMessages(messages: MessageType[]) {
|
||||
return groups
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages,
|
||||
resetUnreadMessages,
|
||||
newDesign,
|
||||
}: MessageListProps) {
|
||||
function MessageList({ messages, resetUnreadMessages }: MessageListProps) {
|
||||
const user = useUserContext()
|
||||
|
||||
const MessageGroupComponent = newDesign ? MessageGroupRedesign : MessageGroup
|
||||
|
||||
function shouldRenderDate(messageIndex: number) {
|
||||
if (messageIndex === 0) {
|
||||
return true
|
||||
@@ -110,7 +102,7 @@ function MessageList({
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
<MessageGroupComponent
|
||||
<MessageGroup
|
||||
messages={group.messages}
|
||||
user={group.user}
|
||||
fromSelf={user ? group.user?.id === user.id : false}
|
||||
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
useRailContext,
|
||||
} from '@/features/ide-react/context/rail-context'
|
||||
import FileTreeOutlinePanel from '@/features/ide-redesign/components/file-tree/file-tree-outline-panel'
|
||||
import {
|
||||
ChatIndicator,
|
||||
ChatPane,
|
||||
} from '@/features/ide-redesign/components/chat/chat'
|
||||
import ChatPane from '@/features/chat/components/chat-pane'
|
||||
import ChatIndicator from '@/features/chat/components/chat-indicator'
|
||||
import getMeta from '@/utils/meta'
|
||||
import classNames from 'classnames'
|
||||
import IntegrationsPanel from '@/features/ide-redesign/components/integrations-panel/integrations-panel'
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import ChatFallbackError from '@/features/chat/components/chat-fallback-error'
|
||||
import InfiniteScroll from '@/features/chat/components/infinite-scroll'
|
||||
import MessageInput from '@/features/chat/components/message-input'
|
||||
import { useChatContext } from '@/features/chat/context/chat-context'
|
||||
import { FetchError } from '@/infrastructure/fetch-json'
|
||||
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useUserContext } from '@/shared/context/user-context'
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import classNames from 'classnames'
|
||||
import { RailIndicator } from '@/features/ide-react/components/rail/rail-indicator'
|
||||
import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header'
|
||||
|
||||
const MessageList = lazy(() => import('../../../chat/components/message-list'))
|
||||
|
||||
export const ChatIndicator = () => {
|
||||
const { unreadMessageCount } = useChatContext()
|
||||
if (unreadMessageCount === 0) {
|
||||
return null
|
||||
}
|
||||
return <RailIndicator count={unreadMessageCount} type="info" />
|
||||
}
|
||||
|
||||
const Loading = () => <FullSizeLoadingSpinner delay={500} className="pt-4" />
|
||||
|
||||
export const ChatPane = () => {
|
||||
const { t } = useTranslation()
|
||||
const user = useUserContext()
|
||||
const {
|
||||
status,
|
||||
messages,
|
||||
initialMessagesLoaded,
|
||||
atEnd,
|
||||
loadInitialMessages,
|
||||
loadMoreMessages,
|
||||
reset,
|
||||
sendMessage,
|
||||
markMessagesAsRead,
|
||||
error,
|
||||
} = useChatContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialMessagesLoaded) {
|
||||
loadInitialMessages()
|
||||
}
|
||||
}, [loadInitialMessages, initialMessagesLoaded])
|
||||
|
||||
const shouldDisplayPlaceholder = status !== 'pending' && messages.length === 0
|
||||
|
||||
if (error) {
|
||||
// let user try recover from fetch errors
|
||||
if (error instanceof FetchError) {
|
||||
return <ChatFallbackError reconnect={reset} />
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<RailPanelHeader title={t('collaborator_chat')} />
|
||||
<div className="chat-wrapper">
|
||||
<aside className="chat" aria-label={t('chat')}>
|
||||
<InfiniteScroll
|
||||
atEnd={atEnd}
|
||||
className="messages"
|
||||
fetchData={loadMoreMessages}
|
||||
isLoading={status === 'pending'}
|
||||
itemCount={messages.length}
|
||||
>
|
||||
<div className={classNames({ 'h-100': shouldDisplayPlaceholder })}>
|
||||
<h2 className="visually-hidden">{t('chat')}</h2>
|
||||
<Suspense fallback={<Loading />}>
|
||||
{status === 'pending' && <Loading />}
|
||||
{shouldDisplayPlaceholder && <Placeholder />}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
resetUnreadMessages={markMessagesAsRead}
|
||||
newDesign
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
<MessageInput
|
||||
resetUnreadMessages={markMessagesAsRead}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Placeholder() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="chat-empty-state-placeholder">
|
||||
<div>
|
||||
<span className="chat-empty-state-icon">
|
||||
<MaterialIcon type="forum" />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="chat-empty-state-title">{t('no_messages_yet')}</div>
|
||||
<div className="chat-empty-state-body">
|
||||
{t('start_the_conversation_by_saying_hello_or_sharing_an_update')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import type { Message } from '@/features/chat/context/chat-context'
|
||||
import { User } from '../../../../../../types/user'
|
||||
import {
|
||||
getBackgroundColorForUserId,
|
||||
hslStringToLuminance,
|
||||
} from '@/shared/utils/colors'
|
||||
import MessageContent from '@/features/chat/components/message-content'
|
||||
import classNames from 'classnames'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import MessageDropdown from '@/features/chat/components/message-dropdown'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
|
||||
function getAvatarStyle(user?: User) {
|
||||
if (!user?.id) {
|
||||
// Deleted user
|
||||
return {
|
||||
backgroundColor: 'var(--bg-light-disabled)',
|
||||
borderColor: 'var(--bg-light-disabled)',
|
||||
color: 'var(--content-disabled)',
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundColor = getBackgroundColorForUserId(user.id)
|
||||
|
||||
return {
|
||||
borderColor: backgroundColor,
|
||||
backgroundColor,
|
||||
color:
|
||||
hslStringToLuminance(backgroundColor) < 0.5
|
||||
? 'var(--content-primary-dark)'
|
||||
: 'var(--content-primary)',
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageAndDropdown({
|
||||
message,
|
||||
fromSelf,
|
||||
isLast,
|
||||
isFirst,
|
||||
}: {
|
||||
message: Message
|
||||
fromSelf: boolean
|
||||
isLast: boolean
|
||||
isFirst: boolean
|
||||
}) {
|
||||
const hasChatEditDelete = useFeatureFlag('chat-edit-delete')
|
||||
|
||||
return (
|
||||
<div className="message-row">
|
||||
<>
|
||||
{!fromSelf && isLast ? (
|
||||
<div className="message-avatar">
|
||||
<div className="avatar" style={getAvatarStyle(message.user)}>
|
||||
{message.user?.id && message.user.email ? (
|
||||
message.user.first_name?.charAt(0) ||
|
||||
message.user.email.charAt(0)
|
||||
) : (
|
||||
<MaterialIcon
|
||||
type="delete"
|
||||
className="message-avatar-deleted-user-icon"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-avatar-placeholder" />
|
||||
)}
|
||||
<div
|
||||
className={classNames('message-container', {
|
||||
'message-from-self': fromSelf,
|
||||
'first-row-in-message': isFirst,
|
||||
'last-row-in-message': isLast,
|
||||
'pending-message': message.pending,
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
{hasChatEditDelete && fromSelf ? (
|
||||
<MessageDropdown message={message} />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<MessageContent
|
||||
content={message.content}
|
||||
messageId={message.id}
|
||||
edited={message.edited}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { MessageGroupProps } from '@/features/chat/components/message-group'
|
||||
import { MessageAndDropdown } from './message-and-dropdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
function MessageGroup({ messages, user, fromSelf }: MessageGroupProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="chat-message-redesign">
|
||||
<div>
|
||||
<div className="message-row">
|
||||
<div className="message-avatar-placeholder" />
|
||||
{!fromSelf && (
|
||||
<div className="message-author">
|
||||
<span>
|
||||
{user?.id && user.email
|
||||
? user.first_name || user.email
|
||||
: t('deleted_user')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{messages.map((message, index) => {
|
||||
const nonDeletedMessages = messages.filter(m => !m.deleted)
|
||||
const nonDeletedIndex = nonDeletedMessages.findIndex(
|
||||
m => m.id === message.id
|
||||
)
|
||||
return (
|
||||
<MessageAndDropdown
|
||||
key={index}
|
||||
message={message}
|
||||
fromSelf={fromSelf}
|
||||
isLast={nonDeletedIndex === nonDeletedMessages.length - 1}
|
||||
isFirst={nonDeletedIndex === 0}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageGroup
|
||||
26
services/web/frontend/js/shared/utils/first-character.ts
Normal file
26
services/web/frontend/js/shared/utils/first-character.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function firstCharacter(str: string): string {
|
||||
if (!str) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (Intl?.Segmenter) {
|
||||
try {
|
||||
const segmenter = new Intl.Segmenter(undefined, {
|
||||
granularity: 'grapheme',
|
||||
})
|
||||
// eslint-disable-next-line no-unreachable-loop
|
||||
for (const { segment } of segmenter.segment(str)) {
|
||||
return segment
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the code point approach below.
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: .charAt(0), [0], and .substring(0, 1) will all split multi-byte
|
||||
// characters (e.g. emojis) into multiple characters, but the spread operator
|
||||
// will keep them mostly intact. This still isn't perfect, so the grapheme
|
||||
// segmenter above is preferred when available.
|
||||
const [first] = [...str]
|
||||
return first ?? ''
|
||||
}
|
||||
@@ -1,17 +1,4 @@
|
||||
:root {
|
||||
--chat-bg: var(--neutral-80);
|
||||
--chat-color: var(--white);
|
||||
--chat-instructions-color: var(--neutral-20);
|
||||
--chat-new-message-bg: var(--neutral-70);
|
||||
--chat-new-message-textarea-color: var(--neutral-90);
|
||||
--chat-new-message-textarea-bg: var(--neutral-20);
|
||||
--chat-new-message-textarea-border: var(--editor-border-color);
|
||||
--chat-new-message-border: var(--editor-border-color);
|
||||
--chat-message-date-color: var(--neutral-40);
|
||||
--chat-message-name-color: var(--white);
|
||||
}
|
||||
|
||||
.ide-redesign-main {
|
||||
--chat-bg: var(--bg-dark-primary);
|
||||
--chat-color: var(--content-primary);
|
||||
--chat-instructions-color: var(--content-primary-dark);
|
||||
@@ -20,34 +7,21 @@
|
||||
--chat-new-message-textarea-bg: var(--bg-dark-primary);
|
||||
--chat-message-date-color: var(--content-secondary-dark);
|
||||
--chat-message-name-color: var(--content-secondary-dark);
|
||||
--chat-new-message-border: var(--border-divider-dark);
|
||||
--chat-date-align: center;
|
||||
}
|
||||
|
||||
@include theme('light') {
|
||||
--chat-bg: var(--white);
|
||||
--chat-color: var(--neutral-70);
|
||||
--chat-color: var(--content-primary);
|
||||
--chat-instructions-color: var(--neutral-70);
|
||||
--chat-new-message-bg: var(--neutral-10);
|
||||
--chat-new-message-textarea-color: var(--neutral-90);
|
||||
--chat-new-message-textarea-bg: var(--white);
|
||||
--chat-new-message-textarea-border: var(--editor-border-color);
|
||||
--chat-new-message-border: var(--editor-border-color);
|
||||
--chat-new-message-border: var(--white);
|
||||
--chat-message-date-color: var(--neutral-70);
|
||||
--chat-message-name-color: var(--neutral-70);
|
||||
|
||||
.ide-redesign-main {
|
||||
--chat-bg: var(--white);
|
||||
--chat-color: var(--content-primary);
|
||||
--chat-instructions-color: var(--neutral-70);
|
||||
--chat-new-message-bg: var(--neutral-10);
|
||||
--chat-new-message-textarea-color: var(--neutral-90);
|
||||
--chat-new-message-textarea-bg: var(--white);
|
||||
--chat-new-message-textarea-border: var(--editor-border-color);
|
||||
--chat-new-message-border: var(--white);
|
||||
--chat-message-date-color: var(--neutral-70);
|
||||
--chat-message-name-color: var(--neutral-70);
|
||||
--chat-date-align: center;
|
||||
}
|
||||
--chat-date-align: center;
|
||||
}
|
||||
|
||||
.chat {
|
||||
@@ -55,19 +29,6 @@
|
||||
|
||||
color: var(--chat-color);
|
||||
|
||||
.no-messages {
|
||||
padding: calc(var(--line-height-03) / 2);
|
||||
color: var(--chat-instructions-color);
|
||||
}
|
||||
|
||||
.first-message {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding: calc(var(--line-height-03) / 2);
|
||||
color: var(--chat-instructions-color);
|
||||
}
|
||||
|
||||
.chat-error {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -94,97 +55,6 @@
|
||||
text-align: var(--chat-date-align, right);
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
.name {
|
||||
font-size: var(--font-size-01);
|
||||
color: var(--chat-message-name-color);
|
||||
margin-bottom: var(--spacing-02);
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.message {
|
||||
border-left: 3px solid transparent;
|
||||
font-size: var(--font-size-02);
|
||||
box-shadow: none;
|
||||
border-radius: var(--border-radius-base);
|
||||
position: relative;
|
||||
|
||||
.message-and-dropdown {
|
||||
clear: both;
|
||||
|
||||
&.pending-message {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.message-dropdown:not(.show) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
padding: var(--spacing-03) var(--spacing-05);
|
||||
overflow-x: auto;
|
||||
color: var(--white);
|
||||
font-weight: bold;
|
||||
|
||||
a {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.message-edited {
|
||||
@include body-xs;
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
transform: rotate(90deg);
|
||||
right: 90%;
|
||||
top: -15px;
|
||||
border: solid;
|
||||
content: ' ';
|
||||
height: 0;
|
||||
width: 0;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border-top-color: transparent !important;
|
||||
border-bottom-color: transparent !important;
|
||||
border-width: 10px;
|
||||
}
|
||||
|
||||
.message-dropdown-menu {
|
||||
min-width: var(--bs-dropdown-min-width);
|
||||
}
|
||||
|
||||
.message-dropdown-menu-btn {
|
||||
@include reset-button;
|
||||
@include action-button;
|
||||
|
||||
color: var(--white);
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
&.own-message-wrapper .message-and-dropdown:hover {
|
||||
background-color: rgb($neutral-90, 0.08);
|
||||
|
||||
.message-dropdown {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: calc(var(--line-height-03) / 4);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.self) {
|
||||
.message {
|
||||
.arrow {
|
||||
@@ -195,17 +65,6 @@
|
||||
|
||||
&.self {
|
||||
margin-top: var(--line-height-03);
|
||||
|
||||
.message-wrapper .message {
|
||||
border-left: none;
|
||||
border-right: 3px solid transparent;
|
||||
|
||||
.arrow {
|
||||
left: 100%;
|
||||
right: auto;
|
||||
border-right-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,7 +82,7 @@
|
||||
overflow: auto;
|
||||
resize: none;
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid var(--chat-new-message-textarea-border);
|
||||
border: 1px solid var(--border-divider-themed);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
color: var(--chat-new-message-textarea-color);
|
||||
@@ -273,7 +132,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-redesign {
|
||||
.chat-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-01);
|
||||
|
||||
@@ -1519,7 +1519,6 @@
|
||||
"no_image_files_found": "No image files found",
|
||||
"no_libraries_selected": "No libraries selected",
|
||||
"no_members": "No members",
|
||||
"no_messages": "No messages",
|
||||
"no_messages_yet": "No messages yet",
|
||||
"no_new_commits_in_github": "No new commits in GitHub since last merge.",
|
||||
"no_one_has_commented_or_left_any_suggestions_yet": "No one has commented or left any suggestions yet.",
|
||||
@@ -2110,7 +2109,6 @@
|
||||
"selection_deleted": "Selection deleted",
|
||||
"send": "Send",
|
||||
"send_confirmation_code": "Send confirmation code",
|
||||
"send_first_message": "Send your first message to your collaborators",
|
||||
"send_message": "Send message",
|
||||
"send_request": "Send request",
|
||||
"send_reset_link": "Send reset link",
|
||||
|
||||
@@ -78,7 +78,9 @@ describe('<ChatPane />', function () {
|
||||
|
||||
// should now reconnect with placeholder message
|
||||
fireEvent.click(reconnectButton)
|
||||
await screen.findByText('Send your first message to your collaborators')
|
||||
await screen.findByText(
|
||||
'Start the conversation by saying hello or sharing an update'
|
||||
)
|
||||
})
|
||||
|
||||
it('a loading spinner is rendered while the messages are loading, then disappears', async function () {
|
||||
@@ -102,7 +104,9 @@ describe('<ChatPane />', function () {
|
||||
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
await screen.findByText('Send your first message to your collaborators')
|
||||
await screen.findByText(
|
||||
'Start the conversation by saying hello or sharing an update'
|
||||
)
|
||||
})
|
||||
|
||||
it('is not rendered when messages are displayed', function () {
|
||||
@@ -111,7 +115,9 @@ describe('<ChatPane />', function () {
|
||||
renderWithEditorContext(<ChatPane />, { user })
|
||||
|
||||
expect(
|
||||
screen.queryByText('Send your first message to your collaborators')
|
||||
screen.queryByText(
|
||||
'Start the conversation by saying hello or sharing an update'
|
||||
)
|
||||
).to.not.exist
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import firstCharacter from '@/shared/utils/first-character'
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
|
||||
const CASES_ALL = [
|
||||
// Regular ASCII characters
|
||||
['Hello', 'H'],
|
||||
// Multi-byte characters (e.g. Chinese)
|
||||
['你好', '你'],
|
||||
// Multi-byte characters (e.g. emojis)
|
||||
['😀 Smile', '😀'],
|
||||
// Empty string
|
||||
['', ''],
|
||||
]
|
||||
|
||||
const CASES_SEGMENTER = [
|
||||
// Regional indicator symbols (e.g. flag emojis)
|
||||
['🇩🇰 Denmark', '🇩🇰'],
|
||||
// ZWJ sequences (e.g. family emoji)
|
||||
['👩👩👧👦 Family', '👩👩👧👦'],
|
||||
// Combining characters (e.g. accented characters)
|
||||
['e\u0301 Accented', 'e\u0301'],
|
||||
['é Accented', 'é'],
|
||||
]
|
||||
|
||||
describe('firstCharacter', function () {
|
||||
it('works for different types of strings', function () {
|
||||
for (const [input, expected] of CASES_ALL) {
|
||||
expect(firstCharacter(input)).to.equal(expected)
|
||||
}
|
||||
for (const [input, expected] of CASES_SEGMENTER) {
|
||||
expect(firstCharacter(input)).to.equal(expected)
|
||||
}
|
||||
})
|
||||
|
||||
describe('when Intl.Segmenter is unavailable', function () {
|
||||
before(function () {
|
||||
this.segmenterStub = sinon.stub(Intl, 'Segmenter').value(undefined)
|
||||
})
|
||||
|
||||
after(function () {
|
||||
this.segmenterStub.restore()
|
||||
})
|
||||
|
||||
it('falls back to the code point approach', function () {
|
||||
for (const [input, expected] of CASES_ALL) {
|
||||
expect(firstCharacter(input)).to.equal(expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user