Chat and Review-panel mentions (#34564)

* feat: changing chat to use cm6 editor instead to allow for mentions display and autocomplete

* feat: changing review panel comment, edit, and reply to also use cm6 editor to enable mentions

* feat: updating tests, and moving from jsdom to cypress tests as jsdom cant handle cm6

* feat: centralizing mentions component and applying to both chat and review-panel

* feat: centralizing mentions input, and using changesUsers in chat mentions

* fixing dark mode styles of chat

* feat: updating mentions dropdown style and adding custom icon generation for mentions cm6 autocomplete tooltip

* feat: making mentions look like links, and fixing border radius on tooltip

* fix: naming and using correct color util

* feat: fixing hover on highlight

* update scroll bar dance in review panel

when a system had persistent scroll bars, we would run into issues calculating the height of the review panel

Co-authored-by: Kristina <7614497+khjrtbrg@users.noreply.github.com>

---------

Co-authored-by: Kristina <7614497+khjrtbrg@users.noreply.github.com>
GitOrigin-RevId: 96441ad55f9614427506edfecf3d87bae82d1607
This commit is contained in:
Jimmy Domagala-Tang
2026-09-17 11:29:26 +00:00
committed by Copybot
co-authored by Kristina
parent 9ff7e66b99
commit 7e550a9475
33 changed files with 1056 additions and 342 deletions
@@ -570,6 +570,7 @@
"each_user_will_have_access_to": "",
"easily_manage_your_project_files_everywhere": "",
"edit": "",
"edit_comment": "",
"edit_comment_error_message": "",
"edit_comment_error_title": "",
"edit_content_directly": "",
@@ -577,6 +578,7 @@
"edit_dictionary_empty": "",
"edit_dictionary_remove": "",
"edit_figure": "",
"edit_message": "",
"edit_reference": "",
"edit_sso_configuration": "",
"edit_tag": "",
@@ -6,7 +6,10 @@ 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 AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
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'
const MessageContent: FC<{
content: Message['content']
@@ -47,64 +50,51 @@ const MessageContent: FC<{
}
}, [content, mounted])
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)
}
const completeEdit = useCallback(
(value: string) => {
editMessage(messageId, value)
},
[cancelMessageEdit, completeEdit, content]
[editMessage, messageId]
)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditedContent(e.target.value)
},
[]
)
const handleCancel = useCallback(() => {
cancelMessageEdit()
setEditedContent(content)
}, [cancelMessageEdit, content])
const handleAutoFocus = useCallback(
(textarea: HTMLTextAreaElement) => textarea.select(),
[]
)
const mentionsEnabled = mentionsFeatureEnabled()
return editing ? (
<>
<AutoExpandingTextArea
value={editedContent}
style={{ width: '100%' }}
onKeyDown={handleKeyDown}
onChange={handleChange}
<MentionsInput
className="chat-message-edit-input"
label={t('edit_message')}
initialValue={content}
onChange={setEditedContent}
onSubmit={completeEdit}
onCancel={handleCancel}
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
onAutoFocus={handleAutoFocus}
selectOnFocus
/>
<br />
<OLButton size="sm" variant="secondary" onClick={handleCancel}>
{t('cancel')}
</OLButton>
<OLButton
size="sm"
variant="secondary"
onClick={() => {
cancelMessageEdit()
setEditedContent(content)
}}
onClick={() => completeEdit(editedContent)}
>
{t('cancel')}
</OLButton>
<OLButton size="sm" variant="secondary" onClick={() => completeEdit()}>
{t('save')}
</OLButton>
</>
) : (
<p ref={root} translate="no">
<Linkify>{content}</Linkify>
{mentionsEnabled ? (
renderMentionedContent(content)
) : (
<Linkify>{content}</Linkify>
)}
{edited ? (
<>
{' '}
@@ -115,4 +105,14 @@ 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
@@ -1,4 +1,9 @@
import { useTranslation } from 'react-i18next'
import { useCallback, useRef } from 'react'
import {
MentionsInput,
MentionsInputHandle,
} from '@/shared/components/mentions-input'
type MessageInputProps = {
resetUnreadMessages: () => void
@@ -7,33 +12,25 @@ type MessageInputProps = {
function MessageInput({ resetUnreadMessages, sendMessage }: MessageInputProps) {
const { t } = useTranslation()
const inputRef = useRef<MentionsInputHandle | null>(null)
function handleKeyDown(event: React.KeyboardEvent) {
const selectingCharacter = event.nativeEvent.isComposing
if (event.key === 'Enter' && !selectingCharacter) {
event.preventDefault()
const target = event.target as HTMLInputElement
sendMessage(target.value)
// wrap the form reset in setTimeout so input sources have time to finish
// https://github.com/overleaf/internal/pull/9206
window.setTimeout(() => {
target.blur()
target.closest('form')?.reset()
target.focus()
}, 0)
}
}
const handleSubmit = useCallback(
(message: string) => {
sendMessage(message)
inputRef.current?.clear()
},
[sendMessage]
)
return (
<form className="new-message">
<label htmlFor="chat-input" className="visually-hidden">
{`${t('your_message_to_collaborators')}…`}
</label>
<textarea
id="chat-input"
<MentionsInput
ref={inputRef}
className="chat-message-input"
label={`${t('your_message_to_collaborators')}…`}
placeholder={`${t('your_message_to_collaborators')}…`}
onKeyDown={handleKeyDown}
onClick={resetUnreadMessages}
onSubmit={handleSubmit}
onFocus={resetUnreadMessages}
/>
</form>
)
@@ -1,5 +1,6 @@
import React, { ElementType, FC, PropsWithChildren } from 'react'
import { ChatProvider } from '@/features/chat/context/chat-context'
import { ChangesUsersProvider } from '@/shared/context/changes-users-context'
import { ConnectionProvider } from './connection-context'
import { DetachCompileProvider } from '@/shared/context/detach-compile-context'
import { DetachProvider } from '@/shared/context/detach-context'
@@ -47,6 +48,7 @@ export const ReactContextRoot: FC<
> = ({ children, providers = {} }) => {
const Providers = {
ChatProvider,
ChangesUsersProvider,
ConnectionProvider,
DetachCompileProvider,
DetachProvider,
@@ -130,9 +132,11 @@ export const ReactContextRoot: FC<
<Providers.OutlineProvider>
<Providers.CommandRegistryProvider>
<Providers.EditorSelectionProvider>
{
childrenWrappedWithDynamicProviders
}
<Providers.ChangesUsersProvider>
{
childrenWrappedWithDynamicProviders
}
</Providers.ChangesUsersProvider>
</Providers.EditorSelectionProvider>
</Providers.CommandRegistryProvider>
</Providers.OutlineProvider>
@@ -7,13 +7,15 @@ import { EditorSelection } from '@codemirror/state'
import { useTranslation } from 'react-i18next'
import { useThreadsActionsContext } from '../context/threads-context'
import { removeNewCommentRangeEffect } from '@/features/source-editor/extensions/add-comment'
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
import { ReviewPanelEntry } from './review-panel-entry'
import { ThreadId } from '../../../../../types/review-panel/review-panel'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { debugConsole } from '@/utils/debugging'
import OLButton from '@/shared/components/ol/ol-button'
import { isFormSubmitKeypressEvent } from '@/features/review-panel/utils/form-events'
import {
MentionsInput,
MentionsInputHandle,
} from '@/shared/components/mentions-input'
export const ReviewPanelAddComment = memo<{
docId: string
@@ -36,55 +38,32 @@ export const ReviewPanelAddComment = memo<{
})
}, [view, threadId])
const submitForm = useCallback(async () => {
if (content.trim().length === 0) {
return
}
setSubmitting(true)
const text = view.state.sliceDoc(from, to)
try {
await addComment(from, text, content)
handleClose()
view.dispatch({
selection: EditorSelection.cursor(view.state.selection.main.anchor),
})
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('add_comment_error_title'),
t('add_comment_error_message')
)
}
setSubmitting(false)
}, [
content,
view,
from,
to,
addComment,
handleClose,
showGenericMessageModal,
t,
])
const handleKeyPress = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isFormSubmitKeypressEvent(event)) {
event.preventDefault()
submitForm()
const submitForm = useCallback(
async (commentContent: string) => {
if (commentContent.trim().length === 0) {
return
}
},
[submitForm]
)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
setSubmitting(true)
const text = view.state.sliceDoc(from, to)
try {
await addComment(from, text, commentContent)
handleClose()
view.dispatch({
selection: EditorSelection.cursor(view.state.selection.main.anchor),
})
} catch (err) {
debugConsole.error(err)
showGenericMessageModal(
t('add_comment_error_title'),
t('add_comment_error_message')
)
}
setSubmitting(false)
},
[]
[view, from, to, addComment, handleClose, showGenericMessageModal, t]
)
const handleBlur = useCallback(() => {
@@ -98,15 +77,16 @@ export const ReviewPanelAddComment = memo<{
const handleSubmit = useCallback<FormEventHandler>(
event => {
event.preventDefault()
submitForm()
submitForm(content)
},
[submitForm]
[submitForm, content]
)
// We only ever want to focus the element once
const hasBeenFocused = useRef(false)
const commentInputRef = useRef<MentionsInputHandle | null>(null)
// Auto-focus the textarea once the element has been correctly positioned.
// Auto-focus the input once the element has been correctly positioned.
// We cannot use the autofocus attribute as we need to wait until the parent element
// has been positioned (with the "top" attribute) to avoid scrolling to the initial
// position of the element
@@ -118,9 +98,7 @@ export const ReviewPanelAddComment = memo<{
for (const mutation of mutationList) {
const target = mutation.target as HTMLElement
if (target.style.top) {
const textArea = target.getElementsByTagName('textarea')[0]
if (textArea) {
textArea.focus()
if (commentInputRef.current?.focus()) {
hasBeenFocused.current = true
}
}
@@ -171,13 +149,12 @@ export const ReviewPanelAddComment = memo<{
onSubmit={handleSubmit}
ref={handleElement}
>
<AutoExpandingTextArea
name="message"
className="review-panel-add-comment-textarea"
onChange={handleChange}
onKeyPress={handleKeyPress}
<MentionsInput
ref={commentInputRef}
className="review-panel-add-comment-editor"
onChange={setContent}
onSubmit={submitForm}
placeholder={t('add_your_comment_here')}
value={content}
disabled={submitting}
/>
<div className="review-panel-add-comment-buttons">
@@ -1,5 +1,5 @@
import { memo, useMemo } from 'react'
import { useChangesUsersContext } from '../context/changes-users-context'
import { useChangesUsersContext } from '@/shared/context/changes-users-context'
import { Change } from '../../../../../types/change'
import ReviewPanelEntryUser from './review-panel-entry-user'
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
import { useChangesUsersContext } from '../context/changes-users-context'
import { useChangesUsersContext } from '@/shared/context/changes-users-context'
import { ReviewPanelChangeUser } from './review-panel-change-user'
import { ReviewPanelEntry } from './review-panel-entry'
import { useModalsContext } from '@/features/ide-react/context/modals-context'
@@ -1,9 +1,12 @@
import { memo, useCallback, useState } from 'react'
import { memo, useCallback, useRef, useState } from 'react'
import { Change, CommentOperation } from '../../../../../types/change'
import { ReviewPanelMessage } from './review-panel-message'
import { useTranslation } from 'react-i18next'
import { useThreadsContext } from '../context/threads-context'
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
import {
MentionsInput,
MentionsInputHandle,
} from '@/shared/components/mentions-input'
import ReviewPanelResolvedMessage from './review-panel-resolved-message'
import { ReviewPanelResolvedCommentThread } from '../../../../../types/review-panel/comment-thread'
import {
@@ -11,7 +14,6 @@ import {
ThreadId,
} from '../../../../../types/review-panel/review-panel'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { isFormSubmitKeypressEvent } from '@/features/review-panel/utils/form-events'
export const ReviewPanelCommentContent = memo<{
comment: Change<CommentOperation>
@@ -40,44 +42,32 @@ export const ReviewPanelCommentContent = memo<{
const permissions = usePermissionsContext()
const [submitting, setSubmitting] = useState(false)
const [content, setContent] = useState('')
const commentInputRef = useRef<MentionsInputHandle | null>(null)
const hasActiveContent = content.trim().length > 0
const handleSubmit = useCallback(() => {
if (!onReply || submitting) {
return
}
if (!hasActiveContent) {
return
}
setSubmitting(true)
return onReply(content)
.then(() => {
setContent('')
})
.finally(() => {
setSubmitting(false)
})
}, [onReply, submitting, content, hasActiveContent])
const handleKeyPress = useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (isFormSubmitKeypressEvent(event)) {
event.preventDefault()
handleSubmit()
const handleSubmit = useCallback(
(value: string) => {
if (!onReply || submitting) {
return
}
},
[handleSubmit]
)
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value)
if (value.trim().length === 0) {
return
}
setSubmitting(true)
// Clearing the editor fires its change handler, which resets `content`.
return onReply(value)
.then(() => {
commentInputRef.current?.clear()
})
.finally(() => {
setSubmitting(false)
})
},
[]
[onReply, submitting]
)
const thread = threads?.[comment.op.t]
@@ -127,13 +117,13 @@ export const ReviewPanelCommentContent = memo<{
)}
{permissions.comment && !isResolved && (
<AutoExpandingTextArea
name="content"
className="review-panel-comment-input"
onChange={handleChange}
onKeyPress={handleKeyPress}
<MentionsInput
ref={commentInputRef}
className="review-panel-add-comment-editor review-panel-comment-input"
onChange={setContent}
onSubmit={handleSubmit}
placeholder={t('reply')}
value={content}
disabled={submitting}
/>
)}
</div>
@@ -1,7 +1,7 @@
import { memo } from 'react'
import { buildName } from '../utils/build-name'
import { buildName } from '@/shared/utils/build-name'
import { ReviewPanelUser } from '../../../../../types/review-panel/review-panel'
import { ChangesUser } from '../context/changes-users-context'
import { ChangesUser } from '@/shared/context/changes-users-context'
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
import { useTranslation } from 'react-i18next'
@@ -60,13 +60,22 @@ export const ReviewPanelEntry: FC<
(event: React.FocusEvent | React.MouseEvent) => {
setFocused(true)
if (event.target instanceof HTMLTextAreaElement) {
// The add-comment input is a CodeMirror editor (contenteditable div)
// rather than a textarea, so also treat focus landing inside it as a
// text-input focus.
const target = event.target
const isTextInput =
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement &&
target.closest('.review-panel-add-comment-editor') !== null)
if (isTextInput) {
const entryBottom =
(entryRef.current?.offsetTop || 0) +
(entryRef.current?.offsetHeight || 0)
if (entryBottom > OFFSET_FOR_ENTRIES_ABOVE) {
// if the entry textarea is visible, no need to select the entry
// if the entry input is visible, no need to select the entry
// so that it doesn't scroll out of view as user types
setTextareaFocused(true)
return
@@ -3,12 +3,12 @@ import OLButton from '@/shared/components/ol/ol-button'
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import { PreventSelectingEntry } from './review-panel-prevent-selecting'
import { MentionBadge } from './mention-badge'
import { MentionBadge } from '@/shared/components/mention-badge'
import {
MentionSegment,
parseMentions,
sliceMentionSegments,
} from '../utils/parse-mentions'
} from '@/shared/utils/parse-mentions'
export const ExpandableContent = memo<{
className?: string
@@ -1,4 +1,4 @@
import { FC, useCallback, useState } from 'react'
import { FC, useCallback, useRef, useState } from 'react'
import {
CommentId,
ReviewPanelCommentThreadMessage,
@@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next'
import { FormatTimeBasedOnYear } from '@/shared/components/format-time-based-on-year'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import AutoExpandingTextArea from '@/shared/components/auto-expanding-text-area'
import { MentionsInput } from '@/shared/components/mentions-input'
import ReviewPanelCommentOptions from './review-panel-comment-options'
import { ExpandableContent } from './review-panel-expandable-content'
import ReviewPanelDeleteCommentModal from './review-panel-delete-comment-modal'
@@ -15,7 +15,7 @@ import { useUserContext } from '@/shared/context/user-context'
import ReviewPanelEntryUser from './review-panel-entry-user'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { PreventSelectingEntry } from './review-panel-prevent-selecting'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { mentionsFeatureEnabled } from '@/shared/utils/mentions'
export const ReviewPanelMessage: FC<{
message: ReviewPanelCommentThreadMessage
@@ -39,12 +39,12 @@ export const ReviewPanelMessage: FC<{
const { t } = useTranslation()
const [editing, setEditing] = useState(false)
const [deleting, setDeleting] = useState(false)
const [content, setContent] = useState(message.content)
// Guards against a double submit when Enter submits and the subsequent
// unmount of the editor also fires its blur handler.
const hasSubmittedRef = useRef(false)
const user = useUserContext()
const permissions = usePermissionsContext()
const mentionsEnabled =
isSplitTestEnabled('email-notifications') &&
isSplitTestEnabled('comment-mentions')
const mentionsEnabled = mentionsFeatureEnabled()
const isCommentAuthor = Boolean(message.user && user.id === message.user.id)
const canEdit = isCommentAuthor && permissions.comment
@@ -53,14 +53,27 @@ export const ReviewPanelMessage: FC<{
(permissions.resolveOwnComments && isCommentAuthor)
const canDelete = canResolve
const handleEditOption = useCallback(() => setEditing(true), [])
const handleEditOption = useCallback(() => {
hasSubmittedRef.current = false
setEditing(true)
}, [])
const showDeleteModal = useCallback(() => setDeleting(true), [])
const hideDeleteModal = useCallback(() => setDeleting(false), [])
const handleSubmit = useCallback(() => {
onEdit?.(message.id, content)
setEditing(false)
}, [content, message.id, onEdit])
const handleSubmit = useCallback(
(value: string) => {
if (hasSubmittedRef.current) {
return
}
hasSubmittedRef.current = true
// An emptied comment cancels the edit rather than saving a blank message.
if (value.trim().length > 0) {
onEdit?.(message.id, value)
}
setEditing(false)
},
[message.id, onEdit]
)
const handleDelete = useCallback(() => {
onDelete?.()
@@ -119,23 +132,12 @@ export const ReviewPanelMessage: FC<{
</div>
</div>
{editing ? (
<AutoExpandingTextArea
className="review-panel-comment-input review-panel-comment-edit"
<MentionsInput
className="review-panel-add-comment-editor review-panel-comment-input review-panel-comment-edit"
label={t('edit_comment')}
initialValue={message.content}
onSubmit={handleSubmit}
onBlur={handleSubmit}
onChange={e => setContent(e.target.value)}
onKeyPress={e => {
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.ctrlKey &&
!e.metaKey &&
content
) {
e.preventDefault()
;(e.target as HTMLTextAreaElement).blur()
}
}}
value={content}
autoFocus // eslint-disable-line jsx-a11y/no-autofocus
/>
) : (
@@ -1,6 +1,5 @@
import { FC } from 'react'
import { RangesProvider } from './ranges-context'
import { ChangesUsersProvider } from './changes-users-context'
import { TrackChangesStateProvider } from './track-changes-state-context'
import { ThreadsProvider } from './threads-context'
import { ReviewPanelViewProvider } from './review-panel-view-context'
@@ -16,13 +15,11 @@ export const ReviewPanelProviders: FC<React.PropsWithChildren> = ({
return (
<ReviewPanelViewProvider>
<ChangesUsersProvider>
<TrackChangesStateProvider>
<ThreadsProvider>
<RangesProvider>{children}</RangesProvider>
</ThreadsProvider>
</TrackChangesStateProvider>
</ChangesUsersProvider>
<TrackChangesStateProvider>
<ThreadsProvider>
<RangesProvider>{children}</RangesProvider>
</ThreadsProvider>
</TrackChangesStateProvider>
</ReviewPanelViewProvider>
)
}
@@ -1,7 +0,0 @@
export const isFormSubmitKeypressEvent = (
event: React.KeyboardEvent<HTMLTextAreaElement>
) => {
return (
event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.metaKey
)
}
@@ -0,0 +1,242 @@
import {
Decoration,
DecorationSet,
EditorView,
MatchDecorator,
ViewPlugin,
ViewUpdate,
WidgetType,
} from '@codemirror/view'
import { Completion, CompletionContext } from '@codemirror/autocomplete'
import { projectMembersInfo } from './project-members-info'
import { MENTION_REGEX } from '@/shared/utils/parse-mentions'
import { getBackgroundColorForUserId } from '@/shared/utils/colors'
class MentionsWidget extends WidgetType {
constructor(
readonly userId: string,
readonly name: string
) {
super()
}
toDOM(): HTMLElement {
const span = document.createElement('span')
span.className = 'ol-cm-mention'
span.textContent = '@' + this.name
return span
}
eq(other: MentionsWidget) {
return other.userId === this.userId && other.name === this.name
}
}
const matchDecorator = new MatchDecorator({
regexp: MENTION_REGEX,
decoration: (match, view) => {
const id = match[1]
const name =
view.state.field(projectMembersInfo).get(id)?.name ??
view.state.phrase('unknown')
return Decoration.replace({ widget: new MentionsWidget(id, name) })
},
})
// Autocompletion source for `@mention`s. Offers the project members and, on
// selection, inserts `[id]` right after the `@` so the stored text is `@[id]`.
export function mentionCompletions(context: CompletionContext) {
const before = context.matchBefore(/@\w*/)
if (!context.explicit && !before) {
return null
}
const members = context.state.field(projectMembersInfo)
const options = Array.from(members.entries()).map(([id, info]) => ({
label: info.name,
detail: info.email,
apply: `[${id}]`,
// Carried through to renderMentionAvatar, which colours the avatar circle
// by user id.
mentionId: id,
}))
return {
from: before ? before.from + 1 : context.pos,
options,
validFor: /^\w*$/,
}
}
// Marker class added to the mentions autocomplete tooltip (via the
// `tooltipClass` option in mentions-input.tsx). The tooltip is parented to
// `document.body`, so the source editor's global autocomplete base theme
// (auto-complete.ts) also lands on it. Scoping every rule under this class
// both isolates the mentions styling from the LaTeX autocomplete and raises
// specificity enough to win over those global `li[role="option"]` /
// `.cm-completionDetail` rules. The `li[role="option"]` selector is used for
// the same reason.
export const MENTIONS_TOOLTIP_CLASS = 'ol-cm-mentions-autocomplete'
export const mentionAutocompleteTheme = EditorView.baseTheme({
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete': {
marginLeft: '0',
border: 'none',
background: 'transparent',
borderRadius: 'var(--border-radius-large)',
},
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete > ul': {
maxWidth: 'min(360px, 90vw)',
whiteSpace: 'normal',
padding: 'var(--spacing-02)',
border: '1px solid var(--border-divider)',
borderRadius: 'var(--border-radius-large)',
background: 'var(--bg-light-primary)',
// Matches the `shadow-md` elevation mixin (tooltips/dropdowns).
boxShadow: '0 4px 12px 0 rgb(30 37 48 / 12%), 0 2px 4px rgb(30 37 48 / 8%)',
fontFamily: 'var(--font-sans)',
},
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete > ul > li[role="option"]':
{
display: 'grid',
gridTemplateColumns: 'auto 1fr',
gridTemplateRows: 'auto auto',
alignItems: 'center',
columnGap: 'var(--spacing-04)',
padding: 'var(--spacing-03) var(--spacing-04)',
overflowWrap: 'anywhere',
borderRadius: 'var(--border-radius-medium)',
lineHeight: '1.3',
},
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete > ul > li[role="option"][aria-selected]':
{
background: 'var(--bg-light-secondary)',
},
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete .ol-cm-mention-avatar':
{
gridColumn: '1',
gridRow: '1 / span 2',
alignSelf: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: 'var(--border-radius-full)',
color: 'var(--white)',
fontSize: 'var(--font-size-02)',
fontWeight: '600',
textTransform: 'uppercase',
boxSizing: 'border-box',
},
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete .cm-completionLabel':
{
gridColumn: '2',
gridRow: '1',
fontSize: 'var(--font-size-03)',
color: 'var(--content-primary)',
},
// The portion of the label matching what the user has typed.
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete .cm-completionMatchedText':
{
fontWeight: '700',
textDecoration: 'none',
},
// details
'.cm-tooltip.cm-tooltip-autocomplete.ol-cm-mentions-autocomplete .cm-completionDetail':
{
gridColumn: '2',
gridRow: '2',
margin: '0',
fontFamily: 'var(--font-sans)',
fontSize: 'var(--font-size-02)',
fontStyle: 'normal',
color: 'var(--content-secondary)',
},
})
// Renders a project member's avatar circle for the autocomplete option. The
// background uses the same per-user hue as the rest of the editor's
// collaborator colours (via getHueForUserId), so a user looks consistent
// across mentions, cursors, and the review panel. The colour is set inline
// per-user rather than generated as a theme, keeping the styling static (see
// `no-generated-editor-themes`). Wired in via the `addToOptions` option in
// mentions-input.tsx;
export function renderMentionAvatar(completion: Completion) {
const id = (completion as Completion & { mentionId?: string }).mentionId
const avatar = document.createElement('span')
avatar.className = 'ol-cm-mention-avatar'
avatar.style.backgroundColor = getBackgroundColorForUserId(id)
const label = completion.displayLabel ?? completion.label
avatar.textContent = label.trim().charAt(0)
return avatar
}
/**
* Editor extension that renders stored `@[<id>]` mention tokens as `@name`
* chips, resolving ids to names via the projectMembersInfo state field.
*/
export const mentions = () => {
return [
ViewPlugin.fromClass(
class {
decorations: DecorationSet
constructor(view: EditorView) {
this.decorations = matchDecorator.createDeco(view)
}
update(update: ViewUpdate) {
const namesChanged =
update.startState.field(projectMembersInfo) !==
update.state.field(projectMembersInfo)
if (update.docChanged || update.viewportChanged) {
this.decorations = matchDecorator.updateDeco(
update,
this.decorations
)
}
if (namesChanged) {
// full rebuild re-runs the `decoration` callback, re-resolving names
this.decorations = matchDecorator.createDeco(update.view)
}
}
},
{
decorations(value) {
return value.decorations
},
provide: value => {
return EditorView.atomicRanges.of(view => {
return view.plugin(value)?.decorations || Decoration.none
})
},
}
),
emptyLineFillerTheme,
mentionTheme,
]
}
// single atomicRange.
const mentionTheme = EditorView.baseTheme({
'.ol-cm-mention': {
fontFamily: 'inherit',
fontSize: 'inherit',
fontWeight: '500',
color: 'var(--link-ui-themed)',
whiteSpace: 'nowrap',
cursor: 'default',
},
'.ol-cm-mention::selection': {
color: 'var(--white)',
},
})
const emptyLineFillerTheme = EditorView.baseTheme({
'.ol-cm-filler': {
padding: '0 2px',
},
})
export default mentions
@@ -0,0 +1,28 @@
import { StateEffect, StateField } from '@codemirror/state'
export type MemberInfo = {
name: string
email: string
}
export const projectMembersInfo = StateField.define<Map<string, MemberInfo>>({
create: () => {
return new Map()
},
update(value, transaction) {
for (const effect of transaction.effects) {
if (effect.is(updateCollaboratorsEffect)) {
value = effect.value
}
}
return value
},
})
const updateCollaboratorsEffect = StateEffect.define<Map<string, MemberInfo>>()
export const setProjectMembersInfo = (idToNameMap: Map<string, MemberInfo>) => {
return {
effects: updateCollaboratorsEffect.of(idToNameMap),
}
}
@@ -1,8 +1,8 @@
import { useId, type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { UserId } from '../../../../../types/user'
import { useChangesUsersContext } from '../context/changes-users-context'
import { buildName } from '../utils/build-name'
import { UserId } from '@ol-types/user'
import { useChangesUsersContext } from '@/shared/context/changes-users-context'
import { buildName } from '@/shared/utils/build-name'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
export const MentionBadge: FC<{ userId: UserId }> = ({ userId }) => {
@@ -12,7 +12,7 @@ export const MentionBadge: FC<{ userId: UserId }> = ({ userId }) => {
const user = changesUsers?.get(userId)
if (!user) {
return <span className="review-panel-mention">@{t('unknown')}</span>
return <span className="mention-badge">@{t('unknown')}</span>
}
return (
@@ -22,7 +22,7 @@ export const MentionBadge: FC<{ userId: UserId }> = ({ userId }) => {
overlayProps={{ placement: 'bottom' }}
tooltipProps={{ className: 'review-panel-tooltip' }}
>
<span className="review-panel-mention">@{buildName(user)}</span>
<span className="mention-badge">@{buildName(user)}</span>
</OLTooltip>
)
}
@@ -0,0 +1,258 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import {
EditorView,
keymap,
tooltips,
drawSelection,
placeholder as placeholderExt,
} from '@codemirror/view'
import { Compartment, EditorSelection } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { autocompletion, completionStatus } from '@codemirror/autocomplete'
import classNames from 'classnames'
import mentions, {
mentionCompletions,
mentionAutocompleteTheme,
renderMentionAvatar,
MENTIONS_TOOLTIP_CLASS,
} from '@/features/source-editor/extensions/mentions'
import {
projectMembersInfo,
setProjectMembersInfo,
} from '@/features/source-editor/extensions/project-members-info'
import { useProjectContext } from '@/shared/context/project-context'
import { membersToIdMap, mentionsFeatureEnabled } from '@/shared/utils/mentions'
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).
clear: () => void
}
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
}
// Shared editor styling for the mentions input.
const mentionsInputTheme = EditorView.theme({
// double the specificity to override the base font family used by theme.ts regardless of extension load order
'.cm-content.cm-content': {
fontFamily: 'var(--bs-body-font-family)',
},
// when range in editor is focused and selected
'&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground': {
backgroundColor: 'var(--bg-info-01)',
},
'&.cm-focused .cm-content ::selection': {
color: 'var(--white)',
},
// when range in editor is exists but focus is outside editor, so ::selection is not applied
'.cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground': {
backgroundColor: 'color-mix(in srgb, var(--bg-info-01) 24%, transparent)',
},
})
// 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.
export const MentionsInput = forwardRef<
MentionsInputHandle,
MentionsInputProps
>(function MentionsInput(
{
onChange,
onSubmit,
onBlur,
onCancel,
onFocus,
placeholder = '',
label,
disabled = false,
initialValue = '',
autoFocus = false,
selectOnFocus = false,
className,
},
ref
) {
const containerRef = useRef<HTMLDivElement | null>(null)
const viewRef = useRef<EditorView | null>(null)
const editableConf = useRef(new Compartment())
const { project } = useProjectContext()
const { members, owner } = project || { members: [] }
// The keymap, update listener and event handlers are wired once on mount,
// so route them through refs to always call the latest props rather than
// stale closures.
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(
ref,
() => ({
focus: () => {
if (viewRef.current) {
viewRef.current.focus()
return true
}
return false
},
clear: () => {
const view = viewRef.current
if (view) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: '' },
})
}
},
}),
[]
)
const ariaLabel = label ?? placeholder
const mentionsEnabled = mentionsFeatureEnabled()
useEffect(() => {
if (!containerRef.current) {
return
}
const view = new EditorView({
doc: initialValue,
parent: containerRef.current,
extensions: [
projectMembersInfo,
EditorView.lineWrapping,
drawSelection(),
mentionsInputTheme,
placeholderExt(placeholder),
...(mentionsEnabled
? [
tooltips({ parent: document.body }),
mentions(),
autocompletion({
override: [mentionCompletions],
tooltipClass: () => MENTIONS_TOOLTIP_CLASS,
// Replace the default icon with our coloured avatar circle.
icons: false,
addToOptions: [{ render: renderMentionAvatar, position: 20 }],
}),
mentionAutocompleteTheme,
]
: []),
editableConf.current.of(EditorView.editable.of(!disabled)),
...(ariaLabel
? [EditorView.contentAttributes.of({ 'aria-label': ariaLabel })]
: []),
EditorView.updateListener.of(update => {
if (update.docChanged) {
onChangeRef.current?.(update.state.doc.toString())
}
}),
EditorView.domEventHandlers({
focus: () => {
onFocusRef.current?.()
return false
},
blur: (_event, view) => {
if (completionStatus(view.state) === 'active') {
return false
}
onBlurRef.current?.(view.state.doc.toString())
return false
},
}),
keymap.of([
{
key: 'Enter',
run: view => {
if (disabledRef.current) {
return true
}
onSubmitRef.current(view.state.doc.toString())
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,
]),
history(),
],
})
viewRef.current = view
if (autoFocus) {
view.focus()
if (selectOnFocus) {
view.dispatch({
selection: EditorSelection.range(0, view.state.doc.length),
})
}
}
return () => {
view.destroy()
viewRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Set the mentionable members on mount and keep them in sync. This effect
// runs right after the view is created above, so it also seeds the names.
useEffect(() => {
viewRef.current?.dispatch(
setProjectMembersInfo(membersToIdMap(members, owner))
)
}, [members, owner])
useEffect(() => {
viewRef.current?.dispatch({
effects: editableConf.current.reconfigure(
EditorView.editable.of(!disabled)
),
})
}, [disabled])
return <div ref={containerRef} className={classNames(className)} />
})
export default MentionsInput
@@ -8,7 +8,7 @@ import {
} from 'react'
import { getJSON } from '@/infrastructure/fetch-json'
import { useProjectContext } from '@/shared/context/project-context'
import { UserId } from '../../../../../types/user'
import { UserId } from '@ol-types/user'
import { useEditorContext } from '@/shared/context/editor-context'
import { debugConsole } from '@/utils/debugging'
import { captureException } from '@/infrastructure/error-reporter'
@@ -13,6 +13,15 @@ export type ProjectMember = {
pendingReviewer?: boolean
}
export type ProjectOwner = {
_id: UserId
email: string
first_name: string
last_name: string
privileges: string
signUpDate: string
}
export interface ProjectMetadata extends ProjectSettings {
_id: string
mainBibliographyDocId?: string
@@ -32,14 +41,7 @@ export interface ProjectMetadata extends ProjectSettings {
github?: boolean
}
publicAccessLevel?: PublicAccessLevel
owner: {
_id: UserId
email: string
first_name: string
last_name: string
privileges: string
signUpDate: string
}
owner: ProjectOwner
rootFolder?: Folder[]
trackChangesState?: false | TrackChangesStateData
}
@@ -0,0 +1,39 @@
import {
ProjectMember,
ProjectOwner,
} from '@/shared/context/types/project-metadata'
import { debugConsole } from '@/utils/debugging'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { buildName } from '@/shared/utils/build-name'
import { MemberInfo } from '@/features/source-editor/extensions/project-members-info'
export const membersToIdMap = (
members: ProjectMember[],
owner: ProjectOwner | undefined
) => {
const collaboratorIdToName = new Map<string, MemberInfo>()
if (!owner) {
debugConsole.error('no owner found for project')
return collaboratorIdToName
}
collaboratorIdToName.set(owner._id, {
name: buildName(owner),
email: owner.email,
})
for (const member of members) {
collaboratorIdToName.set(member._id, {
name: buildName(member),
email: member.email,
})
}
return collaboratorIdToName
}
// Mention display (parsing @[id] tokens into names) is gated behind both split
// tests, so chat and the review panel render mentions consistently.
export const mentionsFeatureEnabled = () =>
isSplitTestEnabled('email-notifications') &&
isSplitTestEnabled('comment-mentions')
@@ -1,10 +1,10 @@
import { UserId } from '../../../../../types/user'
import { UserId } from '@ol-types/user'
export type MentionSegment =
| { type: 'text'; value: string }
| { type: 'mention'; userId: UserId }
const MENTION_REGEX = /@\[([a-f0-9]{24})\]/g
export const MENTION_REGEX = /@\[([a-f0-9]{24})\]/g
export function parseMentions(content: string): MentionSegment[] {
const segments: MentionSegment[] = []
@@ -90,6 +90,35 @@
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);
}
}
}
}
@@ -221,6 +250,36 @@
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 {
@@ -373,29 +373,10 @@ del.review-panel-content-highlight {
}
}
.review-panel-mention {
.mention-badge {
color: var(--content-info-themed);
}
.review-panel-comment-input {
width: 100%;
font-size: var(--rp-base-font-size);
padding: 2px var(--spacing-03);
border-radius: var(--border-radius-base);
border: solid 1px var(--border-primary-themed);
resize: vertical;
color: var(--review-panel-color-secondary);
background-color: var(--review-panel-entry-bg-color);
height: 25px;
min-height: 25px;
overflow-x: hidden;
max-height: 400px;
&::placeholder {
color: var(--content-placeholder-themed);
}
}
.review-panel-comment-edit {
margin-top: var(--spacing-03);
}
@@ -507,16 +488,61 @@ del.review-panel-content-highlight {
}
}
.review-panel-add-comment-textarea {
padding: var(--spacing-01) var(--spacing-03);
resize: vertical;
min-height: 44px;
background: var(--bg-primary-themed);
color: var(--content-primary-themed);
.review-panel-add-comment-editor {
background: var(--review-panel-entry-bg-color);
color: var(--review-panel-color);
border: 1px solid var(--border-primary-themed);
border-radius: var(--border-radius-base);
overflow: hidden;
cursor: text;
&::placeholder {
&.review-panel-comment-input {
font-size: var(--rp-base-font-size);
color: var(--review-panel-color-secondary);
.cm-content {
// Default to two lines tall so long @mentions wrap into view
min-height: calc((2 * var(--line-height-02)) + (2 * var(--spacing-01)));
}
}
.cm-editor {
background: transparent;
color: inherit;
max-height: 200px;
&.cm-focused {
outline: none;
}
}
.cm-scroller {
font-family: inherit;
line-height: var(--line-height-02);
// Hide scrollbars so they don't consume layout width on systems that use
// non-overlay scrollbars (e.g. macOS "always show scrollbars"). A visible
// scrollbar causes a width change mid-init that fires CM's ResizeObserver,
// which triggers extra remeasure/reposition cycles — visible as a "dance".
// Scrolling still works via trackpad/keyboard.
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
.cm-content {
padding: var(--spacing-01) var(--spacing-03);
min-height: 44px;
}
.cm-cursor,
.cm-cursor-primary {
border-left-color: var(--review-panel-color);
}
.cm-placeholder {
color: var(--content-placeholder-themed);
}
}
+2
View File
@@ -740,6 +740,7 @@
"each_user_will_have_access_to": "Each user will have access to",
"easily_manage_your_project_files_everywhere": "Easily manage your project files, everywhere",
"edit": "Edit",
"edit_comment": "Edit comment",
"edit_comment_error_message": "There was an error editing the comment. Please try again in a few moments.",
"edit_comment_error_title": "Edit Comment Error",
"edit_content_directly": "Edit content directly",
@@ -747,6 +748,7 @@
"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",
@@ -0,0 +1,84 @@
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,6 +12,8 @@ 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,
@@ -27,15 +29,17 @@ describe('<MessageGroup />', function () {
}
return (
<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>
</EditorProviders>
)
}
@@ -152,7 +156,7 @@ describe('<MessageGroup />', function () {
expect(screen.queryByText('(edited)')).to.not.exist
})
it('renders message being edited with textarea and action buttons', function () {
it('renders message being edited with a CodeMirror input and action buttons', function () {
const messageBeingEdited: MessageType = {
content: 'original message content',
user: currentUser,
@@ -166,8 +170,8 @@ describe('<MessageGroup />', function () {
</ChatProviders>
)
const textarea = screen.getByDisplayValue('original message content')
expect(textarea.tagName.toLowerCase()).to.equal('textarea')
const editor = screen.getByRole('textbox', { name: 'Edit message' })
expect(editor.textContent).to.contain('original message content')
screen.getByRole('button', { name: 'Cancel' })
screen.getByRole('button', { name: 'Save' })
@@ -0,0 +1,55 @@
import MessageInput from '@/features/chat/components/message-input'
import {
EditorProviders,
makeProjectProvider,
} from '../../../helpers/editor-providers'
import { mockProject } from '../../source-editor/helpers/mock-project'
describe('<MessageInput />', function () {
const INPUT_LABEL = 'Send a message to your collaborators…'
beforeEach(function () {
window.metaAttributesCache.set('ol-preventCompileOnLoad', true)
})
function mountInput() {
cy.mount(
<EditorProviders
providers={{ ProjectProvider: makeProjectProvider(mockProject()) }}
>
<MessageInput
sendMessage={cy.stub().as('sendMessage')}
resetUnreadMessages={cy.stub().as('resetUnreadMessages')}
/>
</EditorProviders>
)
}
it('renders an accessible message input', function () {
mountInput()
cy.findByRole('textbox', { name: INPUT_LABEL }).should('exist')
})
it('sends a message after typing and hitting enter', function () {
mountInput()
cy.findByRole('textbox', { name: INPUT_LABEL }).type('hello world{enter}')
cy.get('@sendMessage').should('have.been.calledOnceWith', 'hello world')
})
it('clears the input after sending a message', function () {
mountInput()
cy.findByRole('textbox', { name: INPUT_LABEL }).type('hello world{enter}')
// The input shows its placeholder text when empty, so assert the sent
// message is gone rather than expecting exactly-empty text content.
cy.findByRole('textbox', { name: INPUT_LABEL }).should(
'not.contain',
'hello world'
)
})
it('resets the number of unread messages when the input is focused', function () {
mountInput()
cy.findByRole('textbox', { name: INPUT_LABEL }).focus()
cy.get('@resetUnreadMessages').should('have.been.called')
})
})
@@ -1,58 +0,0 @@
import { expect } from 'chai'
import sinon from 'sinon'
import { screen, render, fireEvent } from '@testing-library/react'
import MessageInput from '../../../../../frontend/js/features/chat/components/message-input'
describe('<MessageInput />', function () {
let resetUnreadMessages: () => void
let sendMessage: (content: string) => void
beforeEach(function () {
resetUnreadMessages = sinon.stub()
sendMessage = sinon.stub()
})
it('renders successfully', function () {
render(
<MessageInput
sendMessage={sendMessage}
resetUnreadMessages={resetUnreadMessages}
/>
)
screen.getByLabelText('Send a message to your collaborators…')
})
it('sends a message after typing and hitting enter', function () {
render(
<MessageInput
sendMessage={sendMessage}
resetUnreadMessages={resetUnreadMessages}
/>
)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'hello world' } })
fireEvent.keyDown(input, { key: 'Enter' })
expect(sendMessage).to.be.calledOnce
expect(sendMessage).to.be.calledWith('hello world')
})
it('resets the number of unread messages after clicking on the input', function () {
render(
<MessageInput
sendMessage={sendMessage}
resetUnreadMessages={resetUnreadMessages}
/>
)
const input = screen.getByPlaceholderText(
'Send a message to your collaborators…'
)
fireEvent.click(input)
expect(resetUnreadMessages).to.be.calledOnce
})
})
@@ -517,13 +517,13 @@ describe('<ReviewPanel />', function () {
it('can add comment', function () {
cy.get('@add-comment-button').click({ scrollBehavior: false })
cy.get('@review-panel').within(() => {
// TODO: Fix selector
cy.get('.review-panel-add-comment-textarea').type(
'a new comment{enter}',
{
scrollBehavior: false,
}
)
// The add-comment editor is the CM6 input that is not a reply/edit
// input (those also carry the review-panel-comment-input class).
cy.get(
'.review-panel-add-comment-editor:not(.review-panel-comment-input) .cm-content'
).type('a new comment{enter}', {
scrollBehavior: false,
})
})
cy.wait('@addNewComment')
// TODO : Figure out a way to plumb the websocket response back through
@@ -565,7 +565,9 @@ describe('<ReviewPanel />', function () {
scrollBehavior: false,
})
cy.get('@review-panel').within(() => {
cy.get('.review-panel-add-comment-textarea').should('exist')
cy.get(
'.review-panel-add-comment-editor:not(.review-panel-comment-input)'
).should('exist')
})
})
})
@@ -1,10 +1,10 @@
import { expect } from 'chai'
import { render, screen } from '@testing-library/react'
import { MentionBadge } from '@/features/review-panel/components/mention-badge'
import { MentionBadge } from '@/shared/components/mention-badge'
import {
ChangesUsersContext,
ChangesUsers,
} from '@/features/review-panel/context/changes-users-context'
} from '@/shared/context/changes-users-context'
import { UserId } from '@ol-types/user'
const userId = 'aabbccddeeff00112233aabb' as UserId
@@ -73,6 +73,6 @@ describe('<MentionBadge />', function () {
renderMentionBadge(users)
const badge = screen.getByText('@Jane Doe')
expect(badge.tagName).to.equal('SPAN')
expect(badge.classList.contains('review-panel-mention')).to.be.true
expect(badge.classList.contains('mention-badge')).to.be.true
})
})
@@ -2,7 +2,7 @@ import { expect } from 'chai'
import {
parseMentions,
sliceMentionSegments,
} from '@/features/review-panel/utils/parse-mentions'
} from '@/shared/utils/parse-mentions'
import { UserId } from '@ol-types/user'
describe('parseMentions', function () {