Merge pull request #23669 from overleaf/mj-ide-chat-look

[web] Update chat in editor redesign

GitOrigin-RevId: 79c79eb9c774fbaa1a5a1e15386b629cc03239b3
This commit is contained in:
David 2025-02-26 10:26:44 +00:00 committed by Copybot
parent a6d71eb555
commit fc19e1d34a
11 changed files with 296 additions and 67 deletions

View file

@ -259,6 +259,7 @@
"code_editor_tooltip_message": "",
"code_editor_tooltip_title": "",
"collaborate_online_and_offline": "",
"collaborator_chat": "",
"collabs_per_proj": "",
"collabs_per_proj_single": "",
"collapse": "",
@ -1044,6 +1045,7 @@
"no_libraries_selected": "",
"no_members": "",
"no_messages": "",
"no_messages_yet": "",
"no_new_commits_in_github": "",
"no_one_has_commented_or_left_any_suggestions_yet": "",
"no_other_projects_found": "",
@ -1585,6 +1587,7 @@
"start_by_fixing_the_first_error_in_your_doc": "",
"start_free_trial": "",
"start_free_trial_without_exclamation": "",
"start_the_conversation_by_saying_hello_or_sharing_an_update": "",
"start_typing_find_your_company": "",
"start_typing_find_your_organization": "",
"start_typing_find_your_university": "",

View file

@ -1,6 +1,7 @@
import moment from 'moment'
import Message from './message'
import type { Message as MessageType } from '@/features/chat/context/chat-context'
import MessageRedesign from '@/features/ide-redesign/components/chat/message'
import { useUserContext } from '@/shared/context/user-context'
const FIVE_MINUTES = 5 * 60 * 1000
@ -16,11 +17,16 @@ function formatTimestamp(date: moment.MomentInput) {
interface MessageListProps {
messages: MessageType[]
resetUnreadMessages(...args: unknown[]): unknown
newDesign?: boolean
}
function MessageList({ messages, resetUnreadMessages }: MessageListProps) {
function MessageList({
messages,
resetUnreadMessages,
newDesign,
}: MessageListProps) {
const user = useUserContext()
const MessageComponent = newDesign ? MessageRedesign : Message
function shouldRenderDate(messageIndex: number) {
if (messageIndex === 0) {
return true
@ -58,7 +64,7 @@ function MessageList({ messages, resetUnreadMessages }: MessageListProps) {
</time>
</div>
)}
<Message
<MessageComponent
message={message}
fromSelf={message.user ? message.user.id === user.id : false}
/>

View file

@ -3,7 +3,7 @@ import MessageContent from './message-content'
import type { Message as MessageType } from '@/features/chat/context/chat-context'
import { User } from '../../../../../types/user'
interface MessageProps {
export interface MessageProps {
message: MessageType
fromSelf: boolean
}

View file

@ -9,8 +9,10 @@ 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 '../rail'
const MessageList = lazy(() => import('../../chat/components/message-list'))
const MessageList = lazy(() => import('../../../chat/components/message-list'))
export const ChatIndicator = () => {
const { unreadMessageCount } = useChatContext()
@ -24,7 +26,6 @@ const Loading = () => <FullSizeLoadingSpinner delay={500} className="pt-4" />
export const ChatPane = () => {
const { t } = useTranslation()
const user = useUserContext()
const {
status,
@ -65,44 +66,55 @@ export const ChatPane = () => {
}
return (
<aside className="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">
<InfiniteScroll
atEnd={atEnd}
className="messages"
fetchData={loadMoreMessages}
isLoading={status === 'pending'}
itemCount={messageContentCount}
>
<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="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>
)
}

View file

@ -0,0 +1,60 @@
import { MessageProps } from '@/features/chat/components/message'
import { User } from '../../../../../../types/user'
import { getHueForUserId } from '@/shared/utils/colors'
import MessageContent from '@/features/chat/components/message-content'
import classNames from 'classnames'
function hue(user?: User) {
return user ? getHueForUserId(user.id) : 0
}
function getAvatarStyle(user?: User) {
return {
borderColor: `hsl(${hue(user)}, 85%, 40%)`,
backgroundColor: `hsl(${hue(user)}, 85%, 40%`,
}
}
function Message({ message, fromSelf }: MessageProps) {
return (
<div className="chat-message-redesign">
<div className="message-row">
<div className="message-avatar-placeholder" />
{!fromSelf && (
<div className="message-author">
<span>{message.user.first_name || message.user.email}</span>
</div>
)}
</div>
{message.contents.map((content, index) => (
<div key={index} className="message-row">
<>
{!fromSelf && index === message.contents.length - 1 ? (
<div className="message-avatar">
<div className="avatar" style={getAvatarStyle(message.user)}>
{message.user.first_name?.charAt(0) ||
message.user.email.charAt(0)}
</div>
</div>
) : (
<div className="message-avatar-placeholder" />
)}
<div
className={classNames('message-container', {
'message-from-self': fromSelf,
'first-row-in-message': index === 0,
'last-row-in-message': index === message.contents.length - 1,
})}
>
<div className="message-content">
<MessageContent content={content} />
</div>
</div>
</>
</div>
))}
</div>
)
}
export default Message

View file

@ -1,24 +1,18 @@
import { ElementType } from 'react'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import MaterialIcon from '@/shared/components/material-icon'
import OlButton from '@/features/ui/components/ol/ol-button'
import { useRailContext } from '../../contexts/rail-context'
import { RailPanelHeader } from '../rail'
import { useTranslation } from 'react-i18next'
const integrationPanelComponents = importOverleafModules(
'integrationPanelComponents'
) as { import: { default: ElementType }; path: string }[]
export default function IntegrationsPanel() {
const { handlePaneCollapse } = useRailContext()
const { t } = useTranslation()
return (
<div className="integrations-panel">
<header className="integrations-panel-header">
<h4 className="integrations-panel-title">Integrations</h4>
<OlButton onClick={handlePaneCollapse} variant="ghost" size="sm">
<MaterialIcon type="close" />
</OlButton>
</header>
<RailPanelHeader title={t('integrations')} />
{integrationPanelComponents.map(
({ import: { default: Component }, path }) => (
<Component key={path} />

View file

@ -1,4 +1,4 @@
import { ReactElement, useCallback, useMemo } from 'react'
import { FC, ReactElement, useCallback, useMemo } from 'react'
import { Nav, NavLink, Tab, TabContainer } from 'react-bootstrap-5'
import MaterialIcon, {
AvailableUnfilledIcon,
@ -8,13 +8,14 @@ import { useLayoutContext } from '@/shared/context/layout-context'
import { ErrorIndicator, ErrorPane } from './errors'
import { RailTabKey, useRailContext } from '../contexts/rail-context'
import FileTreeOutlinePanel from './file-tree-outline-panel'
import { ChatIndicator, ChatPane } from './chat'
import { ChatIndicator, ChatPane } from './chat/chat'
import getMeta from '@/utils/meta'
import { HorizontalResizeHandle } from '@/features/ide-react/components/resize/horizontal-resize-handle'
import { HorizontalToggler } from '@/features/ide-react/components/resize/horizontal-toggler'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import IntegrationsPanel from './integrations-panel/integrations-panel'
import OLButton from '@/features/ui/components/ol/ol-button'
type RailElement = {
icon: AvailableUnfilledIcon
@ -234,3 +235,15 @@ const RailActionElement = ({ action }: { action: RailAction }) => {
)
}
}
export const RailPanelHeader: FC<{ title: string }> = ({ title }) => {
const { handlePaneCollapse } = useRailContext()
return (
<header className="rail-panel-header">
<h4 className="rail-panel-title">{title}</h4>
<OLButton onClick={handlePaneCollapse} variant="ghost" size="sm">
<MaterialIcon type="close" />
</OLButton>
</header>
)
}

View file

@ -3,19 +3,6 @@
height: 100%;
}
.integrations-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-03) var(--spacing-04);
}
.integrations-panel-title {
font-size: var(--font-size-02);
color: var(--content-primary);
margin-bottom: 0;
}
.integrations-panel-card-button {
all: unset;
background-color: var(--white);

View file

@ -5,6 +5,8 @@
--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);
}
@ -16,6 +18,8 @@
--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-message-date-color: var(--neutral-70);
--chat-message-name-color: var(--neutral-70);
}
@ -24,11 +28,14 @@
--chat-bg: var(--white);
--chat-color: var(--neutral-70);
--chat-instructions-color: var(--neutral-70);
--chat-new-message-bg: var(--neutral-10);
--chat-new-message-bg: var(--white);
--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 {
@ -72,7 +79,7 @@
font-size: var(--font-size-01);
color: var(--chat-message-date-color);
margin-bottom: calc(var(--line-height-03) / 2);
text-align: right;
text-align: var(--chat-date-align, right);
}
.message-wrapper {
@ -158,13 +165,13 @@
height: $new-message-height;
background-color: var(--chat-new-message-bg);
padding: calc(var(--line-height-03) / 4);
border-top: 1px solid var(--editor-border-color);
border-top: 1px solid var(--chat-new-message-border);
textarea {
overflow: auto;
resize: none;
border-radius: var(--border-radius-base);
border: 1px solid var(--editor-border-color);
border: 1px solid var(--chat-new-message-textarea-border);
height: 100%;
width: 100%;
color: var(--chat-new-message-textarea-color);
@ -174,3 +181,133 @@
}
}
}
.chat-empty-state-placeholder {
display: flex;
justify-content: center;
flex-direction: column;
text-align: center;
height: 100%;
gap: var(--spacing-06);
padding: var(--spacing-02);
.chat-empty-state-icon {
padding: var(--spacing-08);
font-size: var(--font-size-08);
height: 80px;
width: 80px;
border-radius: 50%;
display: inline-block;
line-height: 32px;
background-color: var(--bg-light-secondary);
.material-symbols {
font-size: 32px;
}
}
.chat-empty-state-title {
font-size: var(--font-size-02);
line-height: var(--line-height-02);
font-weight: bold;
}
.chat-empty-state-body {
font-size: var(--font-size-02);
line-height: var(--line-height-02);
color: var(--content-secondary);
}
}
.chat-message-redesign {
display: flex;
flex-direction: column;
gap: var(--spacing-01);
.message-row {
display: flex;
align-items: flex-end;
gap: var(--spacing-03);
}
.message-avatar,
.message-avatar-placeholder {
flex: 0 0 24px;
}
.message-avatar .avatar {
width: 24px;
height: 24px;
line-height: 24px;
border-radius: 50%;
text-align: center;
color: var(--white);
text-transform: uppercase;
}
.message-author,
.message-container {
flex: 1 1 auto;
max-width: calc(100% - 24px - var(--spacing-03));
}
.message-container {
display: flex;
justify-content: flex-start;
}
.message-author {
font-size: var(--font-size-01);
line-height: var(--line-height-01);
}
.message-content {
background-color: var(--bg-light-secondary);
border-radius: var(--border-radius-large);
padding: var(--spacing-03) var(--spacing-04);
width: fit-content;
max-width: 100%;
overflow-x: auto;
p {
margin: 0;
}
}
.message-container.message-from-self {
justify-content: flex-end;
.message-content {
background-color: var(--bg-accent-03);
}
&:not(.first-row-in-message) .message-content {
border-top-right-radius: var(--border-radius-base);
}
&:not(.last-row-in-message) .message-content {
border-bottom-right-radius: var(--border-radius-base);
}
}
.message-container:not(.message-from-self) {
&:not(.first-row-in-message) .message-content {
border-top-left-radius: var(--border-radius-base);
}
&:not(.last-row-in-message) .message-content {
border-bottom-left-radius: var(--border-radius-base);
}
}
}
.chat-panel {
display: flex;
flex-direction: column;
height: 100%;
.chat-wrapper {
flex: 1 1 auto;
position: relative;
}
}

View file

@ -6,6 +6,20 @@
--ide-rail-link-active-indicator-background: var(--neutral-90);
}
.rail-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-03) var(--spacing-04);
background-color: var(--ide-rail-background);
}
.rail-panel-title {
font-size: var(--font-size-02);
color: var(--content-primary);
margin-bottom: 0;
}
.ide-rail-tab-button {
border: 0;
background: none;

View file

@ -337,6 +337,7 @@
"collaborate_online_and_offline": "Collaborate online and offline, using your own workflow",
"collaboration": "Collaboration",
"collaborator": "Collaborator",
"collaborator_chat": "Collaborator chat",
"collabratec_account_not_registered": "IEEE Collabratec™ account not registered. Please connect to Overleaf from IEEE Collabratec™ or log in with a different account.",
"collabs_per_proj": "__collabcount__ collaborators per project",
"collabs_per_proj_single": "__collabcount__ collaborator per project",
@ -1381,6 +1382,7 @@
"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.",
"no_other_projects_found": "No other projects found, please create another project first",
@ -2067,6 +2069,7 @@
"start_by_fixing_the_first_error_in_your_doc": "Start by fixing the first error in your doc to avoid problems later on.",
"start_free_trial": "Start Free Trial!",
"start_free_trial_without_exclamation": "Start Free Trial",
"start_the_conversation_by_saying_hello_or_sharing_an_update": "Start the conversation by saying hello or sharing an update",
"start_typing_find_your_company": " Start typing to find your company",
"start_typing_find_your_organization": "Start typing to find your organization",
"start_typing_find_your_university": "Start typing to find your university",