Преглед изворни кода

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

Editor Redesign Cleanup: Chat

GitOrigin-RevId: 98f969ee84a86761466de182787443b8c9bacefd
Mathias Jakobsen пре 6 месеци
родитељ
комит
6890f1bb3c

+ 0 - 2
services/web/frontend/extracted-translations.json

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

+ 12 - 0
services/web/frontend/js/features/chat/components/chat-indicator.tsx

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

+ 58 - 65
services/web/frontend/js/features/chat/components/chat-pane.tsx

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

+ 72 - 22
services/web/frontend/js/features/chat/components/message-and-dropdown.tsx

@@ -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'
+  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 MessageContent from '@/features/chat/components/message-content'
 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>
   )

+ 26 - 38
services/web/frontend/js/features/chat/components/message-group.tsx

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

+ 2 - 10
services/web/frontend/js/features/chat/components/message-list.tsx

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

+ 2 - 4
services/web/frontend/js/features/ide-react/components/rail/rail.tsx

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

+ 0 - 115
services/web/frontend/js/features/ide-redesign/components/chat/chat.tsx

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

+ 0 - 92
services/web/frontend/js/features/ide-redesign/components/chat/message-and-dropdown.tsx

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

+ 0 - 43
services/web/frontend/js/features/ide-redesign/components/chat/message-group.tsx

@@ -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 - 0
services/web/frontend/js/shared/utils/first-character.ts

@@ -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 ?? ''
+}

+ 6 - 147
services/web/frontend/stylesheets/pages/editor/chat.scss

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

+ 0 - 2
services/web/locales/en.json

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

+ 9 - 3
services/web/test/frontend/features/chat/components/chat-pane.test.tsx → services/web/test/frontend/features/chat/components/chat.test.tsx

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

+ 51 - 0
services/web/test/frontend/shared/utils/first-character.test.ts

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