mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
[web] remove mentions from chat message editing (#35761)
GitOrigin-RevId: 6cd27490485196b82a9c4d9ffa19ba5d8e10922d
This commit is contained in:
@@ -583,7 +583,6 @@
|
||||
"edit_dictionary_empty": "",
|
||||
"edit_dictionary_remove": "",
|
||||
"edit_figure": "",
|
||||
"edit_message": "",
|
||||
"edit_reference": "",
|
||||
"edit_sso_configuration": "",
|
||||
"edit_tag": "",
|
||||
|
||||
@@ -6,10 +6,7 @@ import { debugConsole } from '@/utils/debugging'
|
||||
import { Message, useChatContext } from '@/features/chat/context/chat-context'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mentionsFeatureEnabled } from '@/shared/utils/mentions'
|
||||
import { MentionsInput } from '@/shared/components/mentions-input'
|
||||
import { parseMentions } from '@/shared/utils/parse-mentions'
|
||||
import { MentionBadge } from '@/shared/components/mention-badge'
|
||||
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
|
||||
|
||||
const MessageContent: FC<{
|
||||
content: Message['content']
|
||||
@@ -50,51 +47,64 @@ const MessageContent: FC<{
|
||||
}
|
||||
}, [content, mounted])
|
||||
|
||||
const completeEdit = useCallback(
|
||||
(value: string) => {
|
||||
editMessage(messageId, value)
|
||||
const completeEdit = useCallback(() => {
|
||||
editMessage(messageId, editedContent)
|
||||
}, [editMessage, editedContent, messageId])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
completeEdit()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
cancelMessageEdit()
|
||||
setEditedContent(content)
|
||||
}
|
||||
},
|
||||
[editMessage, messageId]
|
||||
[cancelMessageEdit, completeEdit, content]
|
||||
)
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
cancelMessageEdit()
|
||||
setEditedContent(content)
|
||||
}, [cancelMessageEdit, content])
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEditedContent(e.target.value)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const mentionsEnabled = mentionsFeatureEnabled()
|
||||
const handleAutoFocus = useCallback(
|
||||
(textarea: HTMLTextAreaElement) => textarea.select(),
|
||||
[]
|
||||
)
|
||||
|
||||
return editing ? (
|
||||
<>
|
||||
<MentionsInput
|
||||
className="chat-message-edit-input"
|
||||
label={t('edit_message')}
|
||||
initialValue={content}
|
||||
onChange={setEditedContent}
|
||||
onSubmit={completeEdit}
|
||||
onCancel={handleCancel}
|
||||
<AutoExpandingTextArea
|
||||
value={editedContent}
|
||||
style={{ width: '100%' }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
|
||||
selectOnFocus
|
||||
onAutoFocus={handleAutoFocus}
|
||||
/>
|
||||
<br />
|
||||
<OLButton size="sm" variant="secondary" onClick={handleCancel}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => completeEdit(editedContent)}
|
||||
onClick={() => {
|
||||
cancelMessageEdit()
|
||||
setEditedContent(content)
|
||||
}}
|
||||
>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton size="sm" variant="secondary" onClick={() => completeEdit()}>
|
||||
{t('save')}
|
||||
</OLButton>
|
||||
</>
|
||||
) : (
|
||||
<p ref={root} translate="no">
|
||||
{mentionsEnabled ? (
|
||||
renderMentionedContent(content)
|
||||
) : (
|
||||
<Linkify>{content}</Linkify>
|
||||
)}
|
||||
<Linkify>{content}</Linkify>
|
||||
{edited ? (
|
||||
<>
|
||||
{' '}
|
||||
@@ -105,14 +115,4 @@ const MessageContent: FC<{
|
||||
)
|
||||
}
|
||||
|
||||
function renderMentionedContent(content: string) {
|
||||
return parseMentions(content).map((segment, index) =>
|
||||
segment.type === 'mention' ? (
|
||||
<MentionBadge key={index} userId={segment.userId} />
|
||||
) : (
|
||||
<Linkify key={index}>{segment.value}</Linkify>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default MessageContent
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
drawSelection,
|
||||
placeholder as placeholderExt,
|
||||
} from '@codemirror/view'
|
||||
import { Compartment, EditorSelection } from '@codemirror/state'
|
||||
import { Compartment } from '@codemirror/state'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { autocompletion, completionStatus } from '@codemirror/autocomplete'
|
||||
import classNames from 'classnames'
|
||||
@@ -27,7 +27,7 @@ export type MentionsInputHandle = {
|
||||
// Returns true if the editor was focused, false if the view isn't ready yet.
|
||||
focus: () => boolean
|
||||
// Empties the editor. Used by inputs that stay mounted between submissions
|
||||
// (the chat composer and the review-panel reply box).
|
||||
// (the review-panel reply box).
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
@@ -35,16 +35,11 @@ type MentionsInputProps = {
|
||||
onSubmit: (value: string) => void
|
||||
onChange?: (value: string) => void
|
||||
onBlur?: (value: string) => void
|
||||
// Called on Escape (when the autocomplete popup is not open). Used by the
|
||||
// edit flows to cancel without saving.
|
||||
onCancel?: () => void
|
||||
onFocus?: () => void
|
||||
placeholder?: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
initialValue?: string
|
||||
autoFocus?: boolean
|
||||
selectOnFocus?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -73,8 +68,8 @@ const mentionsInputTheme = EditorView.theme({
|
||||
})
|
||||
|
||||
// A small CodeMirror 6 input that supports `@mention` autocompletion. Shared by
|
||||
// the chat composer/edit field and the review panel's add-comment, reply, and
|
||||
// edit fields so all of them get the same mention behaviour.
|
||||
// the review panel's add-comment, reply, and edit fields so all of them get the
|
||||
// same mention behaviour.
|
||||
export const MentionsInput = forwardRef<
|
||||
MentionsInputHandle,
|
||||
MentionsInputProps
|
||||
@@ -83,14 +78,11 @@ export const MentionsInput = forwardRef<
|
||||
onChange,
|
||||
onSubmit,
|
||||
onBlur,
|
||||
onCancel,
|
||||
onFocus,
|
||||
placeholder = '',
|
||||
label,
|
||||
disabled = false,
|
||||
initialValue = '',
|
||||
autoFocus = false,
|
||||
selectOnFocus = false,
|
||||
className,
|
||||
},
|
||||
ref
|
||||
@@ -108,14 +100,10 @@ export const MentionsInput = forwardRef<
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onSubmitRef = useRef(onSubmit)
|
||||
const onBlurRef = useRef(onBlur)
|
||||
const onCancelRef = useRef(onCancel)
|
||||
const onFocusRef = useRef(onFocus)
|
||||
const disabledRef = useRef(disabled)
|
||||
onChangeRef.current = onChange
|
||||
onSubmitRef.current = onSubmit
|
||||
onBlurRef.current = onBlur
|
||||
onCancelRef.current = onCancel
|
||||
onFocusRef.current = onFocus
|
||||
disabledRef.current = disabled
|
||||
|
||||
useImperativeHandle(
|
||||
@@ -182,10 +170,6 @@ export const MentionsInput = forwardRef<
|
||||
}
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
onFocusRef.current?.()
|
||||
return false
|
||||
},
|
||||
blur: (_event, view) => {
|
||||
if (completionStatus(view.state) === 'active') {
|
||||
return false
|
||||
@@ -205,20 +189,6 @@ export const MentionsInput = forwardRef<
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
run: view => {
|
||||
// Let the autocomplete popup handle Escape when it's open.
|
||||
if (completionStatus(view.state) === 'active') {
|
||||
return false
|
||||
}
|
||||
if (onCancelRef.current) {
|
||||
onCancelRef.current()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
@@ -228,11 +198,6 @@ export const MentionsInput = forwardRef<
|
||||
viewRef.current = view
|
||||
if (autoFocus) {
|
||||
view.focus()
|
||||
if (selectOnFocus) {
|
||||
view.dispatch({
|
||||
selection: EditorSelection.range(0, view.state.doc.length),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
|
||||
@@ -90,35 +90,6 @@
|
||||
padding: calc(var(--line-height-03) / 4);
|
||||
background-color: var(--chat-new-message-textarea-bg);
|
||||
}
|
||||
|
||||
.chat-message-input {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid var(--border-divider-themed);
|
||||
background-color: var(--chat-new-message-textarea-bg);
|
||||
|
||||
&.cm-focused {
|
||||
// suppress CM6's default focus outline; the border conveys focus
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
color: var(--chat-new-message-textarea-color);
|
||||
font-size: var(--font-size-02);
|
||||
line-height: var(--line-height-02);
|
||||
padding: calc(var(--line-height-03) / 4);
|
||||
}
|
||||
|
||||
.cm-cursor,
|
||||
.cm-cursor-primary {
|
||||
border-left-color: var(--chat-new-message-textarea-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,36 +221,6 @@
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.mention-badge {
|
||||
color: var(--content-info);
|
||||
}
|
||||
|
||||
.chat-message-edit-input {
|
||||
width: 100%;
|
||||
|
||||
.cm-editor {
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid var(--border-divider-themed);
|
||||
background-color: var(--chat-new-message-textarea-bg);
|
||||
|
||||
&.cm-focused {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
color: var(--chat-new-message-textarea-color);
|
||||
font-size: var(--font-size-02);
|
||||
line-height: var(--line-height-02);
|
||||
padding: calc(var(--line-height-03) / 4);
|
||||
}
|
||||
|
||||
.cm-cursor,
|
||||
.cm-cursor-primary {
|
||||
border-left-color: var(--chat-new-message-textarea-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.message-container.message-from-self {
|
||||
|
||||
@@ -753,7 +753,6 @@
|
||||
"edit_dictionary_empty": "Your custom dictionary is empty.",
|
||||
"edit_dictionary_remove": "Remove from dictionary",
|
||||
"edit_figure": "Edit figure",
|
||||
"edit_message": "Edit message",
|
||||
"edit_reference": "Edit reference",
|
||||
"edit_sso_configuration": "Edit SSO Configuration",
|
||||
"edit_tag": "Edit tag",
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import ChatPane from '@/features/chat/components/chat-pane'
|
||||
import {
|
||||
EditorProviders,
|
||||
makeProjectProvider,
|
||||
USER_ID,
|
||||
USER_EMAIL,
|
||||
} from '../../../helpers/editor-providers'
|
||||
import { mockProject } from '../../source-editor/helpers/mock-project'
|
||||
|
||||
describe('<MessageContent /> editing', function () {
|
||||
const message = {
|
||||
id: 'msg_1',
|
||||
content: 'hello world',
|
||||
user: {
|
||||
id: USER_ID,
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
email: USER_EMAIL,
|
||||
},
|
||||
timestamp: new Date('2025-01-01T00:00:00.000Z').getTime(),
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
|
||||
window.metaAttributesCache.set('ol-splitTestVariants', {
|
||||
'chat-edit-delete': 'enabled',
|
||||
})
|
||||
|
||||
cy.intercept('GET', '/project/*/messages*', [message]).as('getMessages')
|
||||
cy.intercept('POST', '/project/*/messages/msg_1/edit', {}).as('editMessage')
|
||||
|
||||
cy.mount(
|
||||
<EditorProviders
|
||||
providers={{ ProjectProvider: makeProjectProvider(mockProject()) }}
|
||||
>
|
||||
<ChatPane />
|
||||
</EditorProviders>
|
||||
)
|
||||
|
||||
cy.wait('@getMessages')
|
||||
cy.findByText('hello world')
|
||||
})
|
||||
|
||||
function openEditor() {
|
||||
cy.get('.message-dropdown-menu-btn').click({ force: true })
|
||||
cy.findByText('Edit').click({ force: true })
|
||||
return cy.findByRole('textbox', { name: 'Edit message' })
|
||||
}
|
||||
|
||||
it('saves an edited message on Enter', function () {
|
||||
openEditor().type('{selectAll}edited message{enter}', { force: true })
|
||||
|
||||
cy.wait('@editMessage')
|
||||
.its('request.body.content')
|
||||
.should('equal', 'edited message')
|
||||
})
|
||||
|
||||
it('saves an edited message with the Save button', function () {
|
||||
openEditor().type('{selectAll}edited message', { force: true })
|
||||
cy.findByRole('button', { name: 'Save' }).click({ force: true })
|
||||
|
||||
cy.wait('@editMessage')
|
||||
.its('request.body.content')
|
||||
.should('equal', 'edited message')
|
||||
})
|
||||
|
||||
it('cancels editing on Escape without saving', function () {
|
||||
openEditor().type('{selectAll}discarded{esc}', { force: true })
|
||||
|
||||
// Editor closes and the original message is shown again.
|
||||
cy.findByRole('textbox', { name: 'Edit message' }).should('not.exist')
|
||||
cy.findByText('hello world').should('exist')
|
||||
cy.get('@editMessage.all').should('have.length', 0)
|
||||
})
|
||||
|
||||
it('cancels editing with the Cancel button', function () {
|
||||
openEditor().type('{selectAll}discarded', { force: true })
|
||||
cy.findByRole('button', { name: 'Cancel' }).click({ force: true })
|
||||
|
||||
cy.findByRole('textbox', { name: 'Edit message' }).should('not.exist')
|
||||
cy.findByText('hello world').should('exist')
|
||||
cy.get('@editMessage.all').should('have.length', 0)
|
||||
})
|
||||
})
|
||||
@@ -12,8 +12,6 @@ import { UserProvider } from '@/shared/context/user-context'
|
||||
import { ModalsContextProvider } from '@/features/ide-react/context/modals-context'
|
||||
import { SplitTestProvider } from '@/shared/context/split-test-context'
|
||||
|
||||
import { EditorProviders } from '../../../helpers/editor-providers'
|
||||
|
||||
describe('<MessageGroup />', function () {
|
||||
function ChatProviders({
|
||||
children,
|
||||
@@ -29,17 +27,15 @@ describe('<MessageGroup />', function () {
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorProviders>
|
||||
<UserProvider>
|
||||
<ModalsContextProvider>
|
||||
<SplitTestProvider>
|
||||
<ChatContext.Provider value={mockContextValue as any}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
</SplitTestProvider>
|
||||
</ModalsContextProvider>
|
||||
</UserProvider>
|
||||
</EditorProviders>
|
||||
<UserProvider>
|
||||
<ModalsContextProvider>
|
||||
<SplitTestProvider>
|
||||
<ChatContext.Provider value={mockContextValue as any}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
</SplitTestProvider>
|
||||
</ModalsContextProvider>
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -156,7 +152,7 @@ describe('<MessageGroup />', function () {
|
||||
expect(screen.queryByText('(edited)')).to.not.exist
|
||||
})
|
||||
|
||||
it('renders message being edited with a CodeMirror input and action buttons', function () {
|
||||
it('renders message being edited with textarea and action buttons', function () {
|
||||
const messageBeingEdited: MessageType = {
|
||||
content: 'original message content',
|
||||
user: currentUser,
|
||||
@@ -170,8 +166,8 @@ describe('<MessageGroup />', function () {
|
||||
</ChatProviders>
|
||||
)
|
||||
|
||||
const editor = screen.getByRole('textbox', { name: 'Edit message' })
|
||||
expect(editor.textContent).to.contain('original message content')
|
||||
const textarea = screen.getByDisplayValue('original message content')
|
||||
expect(textarea.tagName.toLowerCase()).to.equal('textarea')
|
||||
|
||||
screen.getByRole('button', { name: 'Cancel' })
|
||||
screen.getByRole('button', { name: 'Save' })
|
||||
|
||||
Reference in New Issue
Block a user