Merge pull request #31038 from overleaf/jdt-mj-rm-chat-ide-redesign

Editor Redesign Cleanup: Chat

GitOrigin-RevId: 98f969ee84a86761466de182787443b8c9bacefd
This commit is contained in:
Mathias Jakobsen
2026-02-13 09:02:58 +00:00
committed by Copybot
parent 551d7b3908
commit 6890f1bb3c
15 changed files with 265 additions and 544 deletions

View File

@@ -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": "",

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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>
)
}

View File

@@ -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}

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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

View 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 ?? ''
}

View File

@@ -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);

View File

@@ -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",

View File

@@ -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
})
})

View File

@@ -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)
}
})
})
})