diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7c369c53322..04ff60435a8 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "8.11.0" + ".": "8.12.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff4674d680..f7f7912e7e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ This project adheres to [Semantic Versioning](http://semver.org/). This CHANGELOG follows conventions [outlined here](http://keepachangelog.com/). +## [8.12.0](https://github.com/ParabolInc/parabol/compare/v8.11.0...v8.12.0) (2024-12-11) + + +### Added + +* move Comment to TipTap ([#10576](https://github.com/ParabolInc/parabol/issues/10576)) ([2fa20b1](https://github.com/ParabolInc/parabol/commit/2fa20b164907f3849fc10dff74de35f732791fb7)) + + +### Changed + +* Properly ignore supposedly ignored errors ([#10580](https://github.com/ParabolInc/parabol/issues/10580)) ([9572815](https://github.com/ParabolInc/parabol/commit/9572815497b2e6f9c74cf0f7d4dd6502153c6fe3)) + ## [8.11.0](https://github.com/ParabolInc/parabol/compare/v8.10.0...v8.11.0) (2024-12-10) diff --git a/codegen.json b/codegen.json index a24a1e5abf1..be3000b8483 100644 --- a/codegen.json +++ b/codegen.json @@ -82,7 +82,7 @@ "CreateStripeSubscriptionSuccess": "./types/CreateStripeSubscriptionSuccess#CreateStripeSubscriptionSuccessSource", "CreateTaskPayload": "./types/CreateTaskPayload#CreateTaskPayloadSource", "DeleteCommentSuccess": "./types/DeleteCommentSuccess#DeleteCommentSuccessSource", - "Discussion": "../../postgres/queries/generated/getDiscussionsByIdsQuery#IGetDiscussionsByIdsQueryResult", + "Discussion": "../../postgres/types/index#Discussion", "DomainJoinRequest": "../../database/types/DomainJoinRequest#default as DomainJoinRequestDB", "EndTeamPromptSuccess": "./types/EndTeamPromptSuccess#EndTeamPromptSuccessSource", "File": "./types/File#TFile", diff --git a/package.json b/package.json index dee2b886000..f533aca456f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "description": "An open-source app for building smarter, more agile teams.", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "8.11.0", + "version": "8.12.0", "repository": { "type": "git", "url": "https://github.com/ParabolInc/parabol" diff --git a/packages/chronos/package.json b/packages/chronos/package.json index b8222e95ec0..87cd3397301 100644 --- a/packages/chronos/package.json +++ b/packages/chronos/package.json @@ -1,6 +1,6 @@ { "name": "chronos", - "version": "8.11.0", + "version": "8.12.0", "description": "A cron job scheduler", "author": "Matt Krick ", "homepage": "https://github.com/ParabolInc/parabol/tree/master/packages/chronos#readme", @@ -25,6 +25,6 @@ }, "dependencies": { "cron": "^2.3.1", - "parabol-server": "8.11.0" + "parabol-server": "8.12.0" } } diff --git a/packages/client/Atmosphere.ts b/packages/client/Atmosphere.ts index 1e36534f1f4..6d34782ae72 100644 --- a/packages/client/Atmosphere.ts +++ b/packages/client/Atmosphere.ts @@ -217,7 +217,9 @@ export default class Atmosphere extends Environment { : value _next(nextObj) } - this.handleSubscribePromise(operation, variables, _cacheConfig, sink).catch() + this.handleSubscribePromise(operation, variables, _cacheConfig, sink).catch(() => { + /*ignore*/ + }) }) } @@ -297,7 +299,9 @@ export default class Atmosphere extends Environment { let data = request.id if (!__PRODUCTION__) { try { - const queryMap = await import('../../queryMap.json').catch() + const queryMap = await import('../../queryMap.json').catch(() => { + /*ignore*/ + }) data = queryMap[request.id as keyof typeof queryMap] as string } catch (e) { return diff --git a/packages/client/components/ActionMeetingSidebar.tsx b/packages/client/components/ActionMeetingSidebar.tsx index a6470ef2b78..88809db9700 100644 --- a/packages/client/components/ActionMeetingSidebar.tsx +++ b/packages/client/components/ActionMeetingSidebar.tsx @@ -86,7 +86,9 @@ const ActionMeetingSidebar = (props: Props) => { } = itemStage || {} const canNavigate = isViewerFacilitator ? isNavigableByFacilitator : isNavigable const handleClick = () => { - gotoStageId(itemStageId).catch() + gotoStageId(itemStageId).catch(() => { + /*ignore*/ + }) handleMenuClick() } const phaseCount = diff --git a/packages/client/components/ActionSidebarAgendaItemsSection.tsx b/packages/client/components/ActionSidebarAgendaItemsSection.tsx index 4063bf42a36..e24c18d0bf8 100644 --- a/packages/client/components/ActionSidebarAgendaItemsSection.tsx +++ b/packages/client/components/ActionSidebarAgendaItemsSection.tsx @@ -35,7 +35,9 @@ const ActionSidebarAgendaItemsSection = (props: Props) => { ) const {team} = meeting const handleClick = async (stageId: string) => { - gotoStageId(stageId).catch() + gotoStageId(stageId).catch(() => { + /*ignore*/ + }) handleMenuClick() } // show agenda (no blur) at all times if the updates phase isNavigable diff --git a/packages/client/components/AddPollButton.tsx b/packages/client/components/AddPollButton.tsx index df3c718d224..e2de4c34b00 100644 --- a/packages/client/components/AddPollButton.tsx +++ b/packages/client/components/AddPollButton.tsx @@ -29,15 +29,14 @@ const AddPollLabel = styled('div')({ interface Props { onClick: () => void - dataCy: string disabled?: boolean } const AddPollButton = (props: Props) => { - const {onClick, dataCy, disabled} = props + const {onClick, disabled} = props return ( - + Add a poll diff --git a/packages/client/components/AddTaskButton.tsx b/packages/client/components/AddTaskButton.tsx index e57cc3a86d4..9a61c86c6b8 100644 --- a/packages/client/components/AddTaskButton.tsx +++ b/packages/client/components/AddTaskButton.tsx @@ -29,15 +29,14 @@ const AddTaskLabel = styled('div')({ interface Props { onClick: () => void - dataCy: string disabled?: boolean } const AddTaskButton = (props: Props) => { - const {onClick, dataCy, disabled} = props + const {onClick, disabled} = props return ( - + Add a task diff --git a/packages/client/components/AnalyticsPage.tsx b/packages/client/components/AnalyticsPage.tsx index e27a528c60a..e7cf088e3aa 100644 --- a/packages/client/components/AnalyticsPage.tsx +++ b/packages/client/components/AnalyticsPage.tsx @@ -155,7 +155,9 @@ const AnalyticsPage = () => { window.localStorage.setItem(LocalStorageKey.EMAIL, email) safeIdentify(atmosphere.viewerId, email) } - cacheEmail().catch() + cacheEmail().catch(() => { + /*ignore*/ + }) }, []) useEffect(() => { diff --git a/packages/client/components/AtmosphereProvider/AtmosphereProvider.tsx b/packages/client/components/AtmosphereProvider/AtmosphereProvider.tsx index 6e974b3ab82..ef02f711475 100644 --- a/packages/client/components/AtmosphereProvider/AtmosphereProvider.tsx +++ b/packages/client/components/AtmosphereProvider/AtmosphereProvider.tsx @@ -15,7 +15,9 @@ class AtmosphereProvider extends Component { constructor(props: Props) { super(props) if (props.getLocalAtmosphere) { - this.loadDemo().catch() + this.loadDemo().catch(() => { + /*ignore*/ + }) } else { this.atmosphere = new Atmosphere() this.atmosphere.getAuthToken(window) @@ -23,9 +25,7 @@ class AtmosphereProvider extends Component { } async loadDemo() { - const LocalAtmosphere = await this.props.getLocalAtmosphere!() - .then((mod) => mod.default) - .catch() + const LocalAtmosphere = await this.props.getLocalAtmosphere!().then((mod) => mod.default) this.atmosphere = new LocalAtmosphere() this.forceUpdate() } diff --git a/packages/client/components/AuthProvider.tsx b/packages/client/components/AuthProvider.tsx index 786c099b18f..5b30c0b1922 100644 --- a/packages/client/components/AuthProvider.tsx +++ b/packages/client/components/AuthProvider.tsx @@ -26,7 +26,9 @@ const AuthProvider = () => { setError('Error logging in') } } - callOpener().catch() + callOpener().catch(() => { + /*ignore*/ + }) }, []) if (!error) return null diff --git a/packages/client/components/CommentAuthorOptionsButton.tsx b/packages/client/components/CommentAuthorOptionsButton.tsx index 216f75ef658..af45b1ddfbf 100644 --- a/packages/client/components/CommentAuthorOptionsButton.tsx +++ b/packages/client/components/CommentAuthorOptionsButton.tsx @@ -38,16 +38,14 @@ const StyledIcon = styled(MoreVert)({ interface Props { commentId: string editComment: () => void - dataCy: string meetingId: string } const CommentAuthorOptionsButton = (props: Props) => { - const {commentId, editComment, dataCy, meetingId} = props + const {commentId, editComment, meetingId} = props const {togglePortal, originRef, menuPortal, menuProps} = useMenu(MenuPosition.UPPER_RIGHT) return ( { ) } - const [editorState] = useEditorState(comment.content) - + const {editor} = useTipTapCommentEditor(comment.content, { + readOnly: true + }) + if (!editor) return null return ( { notification={notification} action={} > - - { - /*noop*/ - }} - /> - +
+ +
) } diff --git a/packages/client/components/DiscussionThread.tsx b/packages/client/components/DiscussionThread.tsx index 812a820e67d..81ddd3cc537 100644 --- a/packages/client/components/DiscussionThread.tsx +++ b/packages/client/components/DiscussionThread.tsx @@ -53,8 +53,6 @@ const DiscussionThread = (props: Props) => { } = props const {viewerId} = useAtmosphere() const isDrawer = !!width // hack to say this is in a poker meeting - const listRef = useRef(null) - const editorRef = useRef(null) const ref = useRef(null) const data = usePreloadedQuery( graphql` @@ -66,7 +64,9 @@ const DiscussionThread = (props: Props) => { ...DiscussionThreadInput_discussion ...DiscussionThreadList_discussion id - replyingToCommentId + replyingTo { + id + } commentors { id preferredName @@ -108,7 +108,7 @@ const DiscussionThread = (props: Props) => { return
No discussion found!
} - const {replyingToCommentId, thread} = discussion + const {replyingTo, thread} = discussion const edges = thread?.edges ?? [] // should never happen, but Terry reported it in demo. likely relay error const threadables = edges.map(({node}) => node) const getMaxSortOrder = () => { @@ -119,13 +119,10 @@ const DiscussionThread = (props: Props) => { return ( { {!showTranscription && ( (({isDisabled, isReply}) => ({ - display: 'flex', - flexDirection: 'column', - borderRadius: isReply ? '4px 0 0 4px' : undefined, - boxShadow: isReply ? Elevation.Z2 : Elevation.DISCUSSION_INPUT, - opacity: isDisabled ? 0.5 : undefined, - marginLeft: isReply ? -12 : undefined, - marginTop: isReply ? 8 : undefined, - pointerEvents: isDisabled ? 'none' : undefined, - // required for the shadow to overlay draft-js in the task cards - zIndex: 0 -})) - -const CommentContainer = styled('div')({ - display: 'flex', - flex: 1, - padding: 4 -}) - -const EditorWrap = styled('div')({ - flex: 1, - margin: '14px 0', - overflowWrap: 'break-word', - // width below the required size does not have effect - width: 0 -}) - -const ActionsContainer = styled('div')({ - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - borderTop: `1px solid ${PALETTE.SLATE_200}`, - padding: 6 +const makeReplyTo = ({id, preferredName}: {id: string; preferredName: string}) => ({ + type: 'doc', + content: [ + { + type: 'paragraph', + content: [ + { + type: 'mention', + attrs: { + id, + label: preferredName + } + }, + { + text: ' ', + type: 'text' + } + ] + } + ] }) interface Props { allowedThreadables: DiscussionThreadables[] - editorRef: RefObject getMaxSortOrder: () => number discussion: DiscussionThreadInput_discussion$key viewer: DiscussionThreadInput_viewer$key - onSubmitCommentSuccess?: () => void - threadParentId?: string isReply?: boolean isDisabled?: boolean - setReplyMention?: SetReplyMention - replyMention?: ReplyMention - dataCy: string isCreatingPoll?: boolean } -const DiscussionThreadInput = forwardRef((props: Props, ref: any) => { +const DiscussionThreadInput = (props: Props) => { const { allowedThreadables, - editorRef, getMaxSortOrder, discussion: discussionRef, - onSubmitCommentSuccess, - threadParentId, - replyMention, - setReplyMention, - dataCy, viewer: viewerRef, isCreatingPoll } = props @@ -109,10 +78,18 @@ const DiscussionThreadInput = forwardRef((props: Props, ref: any) => { id meetingId isAnonymousComment - discussionTopicType + editingTaskId team { id } + replyingTo { + id + createdByUser { + id + preferredName + } + threadParentId + } } `, discussionRef @@ -120,20 +97,54 @@ const DiscussionThreadInput = forwardRef((props: Props, ref: any) => { const {picture} = viewer const isReply = !!props.isReply const isDisabled = !!props.isDisabled - const {id: discussionId, meetingId, isAnonymousComment, team, discussionTopicType} = discussion + const { + id: discussionId, + editingTaskId, + meetingId, + isAnonymousComment, + team, + replyingTo + } = discussion const {id: teamId} = team - const [editorState, setEditorState] = useReplyEditorState(replyMention, setReplyMention) const atmosphere = useAtmosphere() - const {submitting, onError, onCompleted, submitMutation} = useMutationProps() - const [isCommenting, setIsCommenting] = useState(false) - const [isCreatingTask, setIsCreatingTask] = useState(false) - const placeholder = isAnonymousComment ? 'Comment anonymously' : 'Comment publicly' - const [lastTypedTimestamp, setLastTypedTimestamp] = useState() + + const clearReplyingTo = () => { + if (!isReply) return + commitLocalUpdate(atmosphere, (store) => { + store + .getRoot() + .getLinkedRecord('viewer') + ?.getLinkedRecord('discussion', {id: discussionId}) + ?.setValue(null, 'replyingTo') + }) + } + const [initialContent] = useState(() => { + return replyingTo?.createdByUser && !!replyingTo?.threadParentId + ? JSON.stringify(makeReplyTo(replyingTo.createdByUser)) + : convertTipTapTaskContent('') + }) + const allowTasks = allowedThreadables.includes('task') const allowComments = allowedThreadables.includes('comment') const allowPolls = false // TODO: change to "allowedThreadables.includes('poll')" once feature is done + const onSubmit = () => { + if (submitting || !editor || editor.isEmpty) return + ensureNotCommenting() + addComment(JSON.stringify(editor.getJSON())) + } + const {editor, setLinkState, linkState} = useTipTapCommentEditor(initialContent, { + readOnly: !allowComments, + atmosphere, + teamId, + placeholder: isAnonymousComment ? 'Comment anonymously' : 'Comment publicly', + onEnter: onSubmit, + onEscape: clearReplyingTo + }) + + const {submitting, onError, onCompleted, submitMutation} = useMutationProps() + const [isCommenting, setIsCommenting] = useState(false) + const [lastTypedTimestamp, setLastTypedTimestamp] = useState() useInitialLocalState(discussionId, 'isAnonymousComment', false) - useInitialLocalState(discussionId, 'replyingToCommentId', '') useBeforeUnload(() => { EditCommentingMutation( atmosphere, @@ -173,24 +184,14 @@ const DiscussionThreadInput = forwardRef((props: Props, ref: any) => { if (!discussion) return discussion.setValue(!discussion.getValue('isAnonymousComment'), 'isAnonymousComment') }) - editorRef.current?.focus() + editor?.commands.focus('end') } - const ensureHasText = (value: string) => value.trim().length - const getCurrentText = () => { - const editorEl = editorRef.current - if (isAndroid) { - if (!editorEl || editorEl.type !== 'textarea') return '' - return editorEl.value - } - - return editorState.getCurrentContent().getPlainText() - } - const hasText = ensureHasText(getCurrentText()) - const commentSubmitState = hasText ? 'typing' : 'idle' + const commentSubmitState = editor?.isEmpty ? 'idle' : 'typing' const addComment = (rawContent: string) => { submitMutation() + const threadParentId = replyingTo?.threadParentId ?? replyingTo?.id const comment = { content: rawContent, isAnonymous: isAnonymousComment, @@ -199,13 +200,8 @@ const DiscussionThreadInput = forwardRef((props: Props, ref: any) => { threadSortOrder: getMaxSortOrder() + SORT_STEP } AddCommentMutation(atmosphere, {comment}, {onError, onCompleted}) - // move focus to end is very important! otherwise ghost chars appear - setEditorState( - EditorState.moveFocusToEnd( - EditorState.push(editorState, ContentState.createFromText(''), 'remove-range') - ) - ) - onSubmitCommentSuccess?.() + editor?.commands.clearContent() + clearReplyingTo() } const ensureCommenting = () => { @@ -236,27 +232,9 @@ const DiscussionThreadInput = forwardRef((props: Props, ref: any) => { setIsCommenting(false) } - const onSubmit = () => { - if (submitting) return - ensureNotCommenting() - const editorEl = editorRef.current - if (isAndroid) { - if (!editorEl || editorEl.type !== 'textarea') return - const {value} = editorEl - if (!ensureHasText(value)) return - const text = value.trim() - const contentState = ContentState.createFromText(text) - const rawText = convertStateToRaw(contentState) - addComment(rawText) - return - } - const content = editorState.getCurrentContent() - if (!ensureHasText(content.getPlainText())) return - addComment(JSON.stringify(convertToRaw(content))) - } - const addTask = () => { const {viewerId} = atmosphere + const threadParentId = replyingTo?.threadParentId ?? replyingTo?.id const newTask = { status: 'active', sortOrder: dndNoise(), @@ -275,69 +253,46 @@ const DiscussionThreadInput = forwardRef((props: Props, ref: any) => { createLocalPoll(atmosphere, discussionId, threadSortOrder) } - useEffect(() => { - const focusListener = () => { - setIsCreatingTask(isViewerTypingInTask()) - } - - document.addEventListener('blur', focusListener, true) - document.addEventListener('focus', focusListener, true) - return () => { - document.removeEventListener('blur', focusListener, true) - document.removeEventListener('focus', focusListener, true) - } - }, []) - const isActionsContainerVisible = allowTasks || allowPolls - const isActionsContainerDisabled = isCreatingTask || isCreatingPoll + const isActionsContainerDisabled = !!editingTaskId || isCreatingPoll const avatar = isAnonymousComment ? anonymousAvatar : picture - + const inputBottomRef = useRef(null) + useEffect(() => { + containerRef.current?.scrollIntoView({behavior: 'smooth', block: 'center'}) + }, []) + const containerRef = useRef(null) + useClickAway(containerRef, clearReplyingTo) + if (!editor) return null return ( - - +
+
- - + - - - +
+ +
{isActionsContainerVisible && ( - - {allowTasks && ( - - )} - {allowPolls && ( - - )} - +
+ {allowTasks && } + {allowPolls && } +
)} -
+
+ ) -}) +} export default DiscussionThreadInput diff --git a/packages/client/components/DiscussionThreadList.tsx b/packages/client/components/DiscussionThreadList.tsx index ff1fe0034bf..5cfeb5c0634 100644 --- a/packages/client/components/DiscussionThreadList.tsx +++ b/packages/client/components/DiscussionThreadList.tsx @@ -1,11 +1,10 @@ import styled from '@emotion/styled' import graphql from 'babel-plugin-relay/macro' -import {forwardRef, ReactNode, RefObject} from 'react' +import {ReactNode, useEffect, useRef, useState} from 'react' import {useFragment} from 'react-relay' import {DiscussionThreadList_discussion$key} from '~/__generated__/DiscussionThreadList_discussion.graphql' import {DiscussionThreadList_threadables$key} from '~/__generated__/DiscussionThreadList_threadables.graphql' import {DiscussionThreadList_viewer$key} from '~/__generated__/DiscussionThreadList_viewer.graphql' -import useScrollThreadList from '~/hooks/useScrollThreadList' import {RetroDiscussPhase_meeting$data} from '../__generated__/RetroDiscussPhase_meeting.graphql' import {PALETTE} from '../styles/paletteV3' import CommentingStatusText from './CommentingStatusText' @@ -13,27 +12,6 @@ import LabelHeading from './LabelHeading/LabelHeading' import ThreadedItem from './ThreadedItem' import Transcription from './Transcription' -const EmptyWrapper = styled('div')({ - alignItems: 'center', - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - flex: 1, - overflow: 'auto' -}) - -const Wrapper = styled('div')({ - flex: 1, - display: 'flex', - flexDirection: 'column', - overflow: 'auto' -}) - -// https://stackoverflow.com/questions/36130760/use-justify-content-flex-end-and-to-have-vertical-scrollbar -const PusherDowner = styled('div')({ - margin: '0 0 auto' -}) - export const Header = styled(LabelHeading)({ borderBottom: `1px solid ${PALETTE.SLATE_300}`, margin: '0 0 8px', @@ -42,34 +20,25 @@ export const Header = styled(LabelHeading)({ width: '100%' }) -const CommentingStatusBlock = styled('div')({ - height: 36, - width: '100%' -}) - export type DiscussionThreadables = 'task' | 'comment' | 'poll' interface Props { allowedThreadables: DiscussionThreadables[] - editorRef: RefObject discussion: DiscussionThreadList_discussion$key preferredNames: string[] | null threadables: DiscussionThreadList_threadables$key viewer: DiscussionThreadList_viewer$key - dataCy: string header?: ReactNode emptyState?: ReactNode transcription?: RetroDiscussPhase_meeting$data['transcription'] showTranscription?: boolean } -const DiscussionThreadList = forwardRef((props: Props, ref: any) => { +const DiscussionThreadList = (props: Props) => { const { allowedThreadables, - editorRef, discussion: discussionRef, threadables: threadablesRef, - dataCy, preferredNames, viewer: viewerRef, header, @@ -100,32 +69,47 @@ const DiscussionThreadList = forwardRef((props: Props, ref: any) => { ... on Poll { updatedAt } + threadParentId id } `, threadablesRef ) - const isEmpty = showTranscription ? !transcription || transcription.length === 0 : threadables.length === 0 - useScrollThreadList(threadables, editorRef, ref, preferredNames) + + // Scroll to the new message at bottom if the viewer is already at the bottom + const listRef = useRef(null) + const threadBottomRef = useRef(null) + const [isAtBottom, setIsAtBottom] = useState(true) + const handleScroll = () => { + const listEl = listRef.current + if (!listEl) return + const {scrollTop, scrollHeight, clientHeight} = listEl + setIsAtBottom(scrollTop + clientHeight >= scrollHeight - 20) + } + useEffect(() => { + if (!isAtBottom) return + threadBottomRef.current?.scrollIntoView({behavior: 'smooth'}) + }, [threadables]) + if (isEmpty && emptyState) { return ( - +
{header} {emptyState} - +
- - +
+
) } - return ( - +
{header} - + {/* https://stackoverflow.com/questions/36130760/use-justify-content-flex-end-and-to-have-vertical-scrollbar */} +
{showTranscription && transcription ? ( ) : ( @@ -142,9 +126,10 @@ const DiscussionThreadList = forwardRef((props: Props, ref: any) => { ) }) )} +
- +
) -}) +} export default DiscussionThreadList diff --git a/packages/client/components/MassInvitationTokenLink.tsx b/packages/client/components/MassInvitationTokenLink.tsx index 5834127a73f..e0b25e9fc66 100644 --- a/packages/client/components/MassInvitationTokenLink.tsx +++ b/packages/client/components/MassInvitationTokenLink.tsx @@ -65,7 +65,9 @@ const MassInvitationTokenLink = (props: Props) => { submitMutation() CreateMassInvitationMutation(atmosphere, {meetingId, teamId}, {onError, onCompleted}) } - doFetch().catch() + doFetch().catch(() => { + /*ignore*/ + }) }, [isTokenValid, submitting]) const onCopy = () => { SendClientSideEvent(atmosphere, 'Copied Invite Link', { diff --git a/packages/client/components/MeetingSidebarTeamMemberStageItems.tsx b/packages/client/components/MeetingSidebarTeamMemberStageItems.tsx index a0cd52f1196..a1120d11b97 100644 --- a/packages/client/components/MeetingSidebarTeamMemberStageItems.tsx +++ b/packages/client/components/MeetingSidebarTeamMemberStageItems.tsx @@ -60,7 +60,9 @@ const MeetingSidebarTeamMemberStageItems = (props: Props) => { const teamMemberStage = sidebarPhase && sidebarPhase.stages.find((stage) => stage.teamMemberId === teamMemberId) const teamMemberStageId = (teamMemberStage && teamMemberStage.id) || '' - gotoStageId(teamMemberStageId).catch() + gotoStageId(teamMemberStageId).catch(() => { + /*ignore*/ + }) handleMenuClick() } const atmosphere = useAtmosphere() diff --git a/packages/client/components/NullableTask/NullableTask.tsx b/packages/client/components/NullableTask/NullableTask.tsx index bffff47fba6..c9218dd8e52 100644 --- a/packages/client/components/NullableTask/NullableTask.tsx +++ b/packages/client/components/NullableTask/NullableTask.tsx @@ -15,7 +15,6 @@ interface Props { isAgenda?: boolean isDraggingOver?: TaskStatusEnum task: NullableTask_task$key - dataCy: string isViewerMeetingSection?: boolean meetingId?: string } @@ -27,7 +26,6 @@ const NullableTask = (props: Props) => { isAgenda, task: taskRef, isDraggingOver, - dataCy, isViewerMeetingSection, meetingId } = props @@ -69,7 +67,6 @@ const NullableTask = (props: Props) => { editor && (!editor.isEmpty || createdBy === atmosphere.viewerId || isIntegration) return showOutcome ? ( { } = itemStage || {} const canNavigate = isViewerFacilitator ? isNavigableByFacilitator : isNavigable const handleClick = () => { - gotoStageId(itemStageId).catch() + gotoStageId(itemStageId).catch(() => { + /*ignore*/ + }) handleMenuClick() } const estimatePhase = phases.find((phase) => { diff --git a/packages/client/components/PokerSidebarEstimateSection.tsx b/packages/client/components/PokerSidebarEstimateSection.tsx index 34454fc20d4..a422d56ad8d 100644 --- a/packages/client/components/PokerSidebarEstimateSection.tsx +++ b/packages/client/components/PokerSidebarEstimateSection.tsx @@ -112,7 +112,9 @@ const PokerSidebarEstimateSection = (props: Props) => { const handleClick = (stageIds: string[]) => { // if the facilitator is at one of the stages, go there if (stageIds.includes(facilitatorStageId)) { - gotoStageId(facilitatorStageId).catch() + gotoStageId(facilitatorStageId).catch(() => { + /*ignore*/ + }) } else { // goto the first stage that the user hasn't voted on const summaryStages = stageIds.map((id) => stages.find((stage) => stage.id === id)) diff --git a/packages/client/components/ResponseReplied.tsx b/packages/client/components/ResponseReplied.tsx index 0eb41ed7f97..81a4fdba5e3 100644 --- a/packages/client/components/ResponseReplied.tsx +++ b/packages/client/components/ResponseReplied.tsx @@ -1,24 +1,12 @@ -import styled from '@emotion/styled' import graphql from 'babel-plugin-relay/macro' -import {Editor} from 'draft-js' import {useFragment} from 'react-relay' import NotificationAction from '~/components/NotificationAction' import {ResponseReplied_notification$key} from '../__generated__/ResponseReplied_notification.graphql' -import useEditorState from '../hooks/useEditorState' import useRouter from '../hooks/useRouter' -import {cardShadow} from '../styles/elevation' +import {useTipTapCommentEditor} from '../hooks/useTipTapCommentEditor' import anonymousAvatar from '../styles/theme/images/anonymous-avatar.svg' import NotificationTemplate from './NotificationTemplate' - -const EditorWrapper = styled('div')({ - backgroundColor: '#fff', - borderRadius: 4, - boxShadow: cardShadow, - fontSize: 14, - lineHeight: '20px', - margin: '4px 0 0', - padding: 8 -}) +import {TipTapEditor} from './promptResponse/TipTapEditor' interface Props { notification: ResponseReplied_notification$key @@ -58,7 +46,10 @@ const ResponseReplied = (props: Props) => { history.push(`/meet/${meetingId}/responses?responseId=${encodeURIComponent(response.id)}`) } - const [editorState] = useEditorState(comment.content) + const {editor, setLinkState, linkState} = useTipTapCommentEditor(comment.content, { + readOnly: true + }) + if (!editor) return null return ( { notification={notification} action={} > - - { - /*noop*/ - }} - /> - +
+ +
) } diff --git a/packages/client/components/RetroMeetingSidebar.tsx b/packages/client/components/RetroMeetingSidebar.tsx index 40a32dbab38..e12dbf460d5 100644 --- a/packages/client/components/RetroMeetingSidebar.tsx +++ b/packages/client/components/RetroMeetingSidebar.tsx @@ -116,7 +116,9 @@ const RetroMeetingSidebar = (props: Props) => { confirmingPhase === phaseType ) { setConfirmingPhase(null) - gotoStageId(itemStageId).catch() + gotoStageId(itemStageId).catch(() => { + /*ignore*/ + }) handleMenuClick() } else { setConfirmingPhase(phaseType) diff --git a/packages/client/components/RetroSidebarDiscussSection.tsx b/packages/client/components/RetroSidebarDiscussSection.tsx index c56b74d4ee6..4f1e1bedfe2 100644 --- a/packages/client/components/RetroSidebarDiscussSection.tsx +++ b/packages/client/components/RetroSidebarDiscussSection.tsx @@ -125,7 +125,9 @@ const RetroSidebarDiscussSection = (props: Props) => { } const handleClick = (id: string) => { - gotoStageId(id).catch() + gotoStageId(id).catch(() => { + /*ignore*/ + }) handleMenuClick() } return ( diff --git a/packages/client/components/SendCommentButton.tsx b/packages/client/components/SendCommentButton.tsx index 6371ecec9b1..518f0d716a8 100644 --- a/packages/client/components/SendCommentButton.tsx +++ b/packages/client/components/SendCommentButton.tsx @@ -44,11 +44,10 @@ const SendIcon = styled(ArrowUpward, { interface Props { commentSubmitState: CommentSubmitState onSubmit: () => void - dataCy: string } const SendCommentButton = (props: Props) => { - const {commentSubmitState, onSubmit, dataCy} = props + const {commentSubmitState, onSubmit} = props const { tooltipPortal, openTooltip, @@ -67,7 +66,6 @@ const SendCommentButton = (props: Props) => { return ( <> import(/* webpackChunkName: 'AndroidEditorFallback' */ '../AndroidEditorFallback') -) - -const TaskEditorFallback = styled(AndroidEditorFallback)({ - padding: 0 -}) - -type DraftProps = Pick< - EditorProps, - | 'editorState' - | 'handleBeforeInput' - | 'handleKeyCommand' - | 'keyBindingFn' - | 'readOnly' - | 'onFocus' - | 'onBlur' -> - -interface Props extends DraftProps { - editorRef: RefObject - ensureCommenting?: () => void - placeholder: string - setEditorState: (newEditorState: EditorState) => void - onSubmit: () => void - teamId: string - dataCy: string - discussionId?: string - autofocus?: boolean -} - -const CommentEditor = (props: Props) => { - const { - editorRef, - editorState, - ensureCommenting, - placeholder, - readOnly, - setEditorState, - onSubmit, - onBlur, - onFocus, - discussionId, - autofocus, - dataCy - } = props - const entityPasteStartRef = useRef<{anchorOffset: number; anchorKey: string} | undefined>() - const { - removeModal, - renderModal, - handleChange, - handleReturn, - handleBeforeInput, - handleKeyCommand, - keyBindingFn - } = useCommentPlugins({...props}) - - const onRemoveModal = () => { - if (removeModal) { - removeModal() - } - } - - const onChange = (editorState: EditorState) => { - const {current: entityPasteStart} = entityPasteStartRef - if (entityPasteStart) { - const {anchorOffset, anchorKey} = entityPasteStart - const selectionState = editorState.getSelection().merge({ - anchorOffset, - anchorKey - }) - const contentState = entitizeText(editorState.getCurrentContent(), selectionState) - entityPasteStartRef.current = undefined - if (contentState) { - setEditorState(EditorState.push(editorState, contentState, 'apply-entity')) - return - } - } - if (!editorState.getSelection().getHasFocus()) { - onRemoveModal() - } else if (handleChange) { - handleChange(editorState) - } - setEditorState(editorState) - } - - const onReturn: EditorProps['handleReturn'] = (e) => { - if (handleReturn) { - return handleReturn(e, editorState) - } - if (!e.shiftKey && !renderModal) { - onSubmit() - return 'handled' - } - return 'not-handled' - } - - const nextKeyCommand = (command: DraftEditorCommand) => { - if (handleKeyCommand) { - return handleKeyCommand(command, editorState, Date.now()) - } - return 'not-handled' - } - - const onKeyBindingFn: EditorProps['keyBindingFn'] = (e) => { - if (ensureCommenting) { - ensureCommenting() - } - if (keyBindingFn) { - const result = keyBindingFn(e) - if (result) { - return result - } - } - if (e.key === 'Escape') { - e.preventDefault() - onRemoveModal() - return 'not-handled' - } - return getDefaultKeyBinding(e) - } - - const onBeforeInput = (char: string) => { - if (handleBeforeInput) { - return handleBeforeInput(char, editorState, Date.now()) - } - return 'not-handled' - } - - const onPastedText = (text: string): DraftHandleValue => { - if (text) { - textTags.forEach((tag) => { - if (text.indexOf(tag) !== -1) { - const selection = editorState.getSelection() - entityPasteStartRef.current = { - anchorOffset: selection.getAnchorOffset(), - anchorKey: selection.getAnchorKey() - } - } - }) - } - const links = linkify.match(text) - const url = links && links[0]!.url.trim() - const trimmedText = text.trim() - if (url === trimmedText) { - const nextEditorState = completeEntity(editorState, 'LINK', {href: url}, trimmedText, { - keepSelection: true - }) - setEditorState(nextEditorState) - return 'handled' - } - return 'not-handled' - } - - const onKeyDownFallback = (e: React.KeyboardEvent) => { - if (ensureCommenting) { - ensureCommenting() - } - if (e.key !== 'Enter' || e.shiftKey) return - e.preventDefault() - } - - const handleBlur = (e: React.FocusEvent) => { - if (renderModal || !onBlur) return - onBlur(e) - } - - useEffect(() => { - if (!autofocus) { - return - } - - if (editorRef.current) { - editorRef.current.focus() - } - }, [discussionId, autofocus]) - - const useFallback = isAndroid && !readOnly - const showFallback = useFallback && !isRichDraft(editorState) - return ( - - {showFallback ? ( - }> - - - ) : ( - - )} - {renderModal && renderModal()} - - ) -} - -export default CommentEditor diff --git a/packages/client/components/TaskEditor/__tests__/TaskEditor.test.js b/packages/client/components/TaskEditor/__tests__/TaskEditor.test.js deleted file mode 100644 index 80d17c0b337..00000000000 --- a/packages/client/components/TaskEditor/__tests__/TaskEditor.test.js +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-env jest */ -// import {StyleSheetTestUtils} from 'aphrodite-local-styles'; -// import {EditorState} from 'draft-js'; -// import {mount} from 'enzyme'; -// import React from 'react'; -// // import TaskEditor from '../TaskEditor'; -// -// console.error = jest.fn(); -// -// // https://github.com/Khan/aphrodite/issues/62 -// afterEach(() => { -// return new Promise((resolve) => { -// StyleSheetTestUtils.clearBufferAndResumeStyleInjection(); -// return process.nextTick(resolve); -// }); -// }); -// -// class EditorProps { -// constructor() { -// this.editorRef = undefined; -// this.editorState = EditorState.createEmpty(); -// this.handleCardUpdate = jest.fn(); -// this.isDragging = false; -// this.teamMembers = [ -// { -// preferredName: 'matt', -// picture: 'foo' -// } -// ]; -// } -// -// setEditorRef = (c) => { -// this.editorRef = c; -// } -// setEditorState = (es) => { -// this.editorState = es; -// } -// } - -describe('TaskEditor', () => { - test('gains focus when clicked', () => { - // const props = new EditorProps(); - // const component = ; - // const wrapper = mount(component); - // wrapper - // .find('.public-DraftEditor-content') - // .simulate('beforeInput', { - // data: 'S' - // }); - // expect(props.editorState.getCurrentContent().getPlainText()).toEqual('S'); - // expect(props.editorRef).toBeDefined(); - expect(true).toBe(true) - }) - // test.only('open suggestions when triggered by #', () => { - // const props = new EditorProps(); - // const component = ; - // const wrapper = mount(component); - // wrapper - // .find('.public-DraftEditor-content') - // .simulate('beforeInput', { - // data: '#' - // }); - // const wrapper2 = mount(); - // - // console.log('wra', wrapper2.html()) - // //expect(props.editorState.getCurrentContent().getPlainText()).toEqual('S'); - // expect(wrapper2.prop('renderModal')).toBeDefined(); - // }); -}) diff --git a/packages/client/components/TaskIntegrationLink.tsx b/packages/client/components/TaskIntegrationLink.tsx index e0b47a79346..1520087e980 100644 --- a/packages/client/components/TaskIntegrationLink.tsx +++ b/packages/client/components/TaskIntegrationLink.tsx @@ -22,14 +22,13 @@ const StyledLink = styled('a')({ interface Props { integration: TaskIntegrationLink_integration$key | null - dataCy: string className?: string children?: ReactNode showJiraLabelPrefix?: boolean } const TaskIntegrationLink = (props: Props) => { - const {integration: integrationRef, dataCy, className, children, showJiraLabelPrefix} = props + const {integration: integrationRef, className, children, showJiraLabelPrefix} = props const integration = useFragment( graphql` fragment TaskIntegrationLink_integration on TaskIntegration { @@ -48,7 +47,6 @@ const TaskIntegrationLink = (props: Props) => { const {issueKey, projectKey, cloudName} = integration return ( { emitGA4SignUpEvent(ga4Args) AcceptTeamInvitationMutation(atmosphere, {invitationToken}, {history, onCompleted, onError}) } - loginWithSAML().catch() + loginWithSAML().catch(() => { + /*ignore*/ + }) }, []) useDocumentTitle('SSO Login | Team Invitation', 'Team Invitation') diff --git a/packages/client/components/TeamPrompt/WorkDrawer/ParabolTasksPanel.tsx b/packages/client/components/TeamPrompt/WorkDrawer/ParabolTasksPanel.tsx index 224b14ee4ab..0cd4706bf19 100644 --- a/packages/client/components/TeamPrompt/WorkDrawer/ParabolTasksPanel.tsx +++ b/packages/client/components/TeamPrompt/WorkDrawer/ParabolTasksPanel.tsx @@ -82,7 +82,7 @@ const ParabolTasksPanel = (props: Props) => {
- +
) diff --git a/packages/client/components/TeamPrompt/WorkDrawer/ParabolTasksResults.tsx b/packages/client/components/TeamPrompt/WorkDrawer/ParabolTasksResults.tsx index b767290faf9..4c5b340b844 100644 --- a/packages/client/components/TeamPrompt/WorkDrawer/ParabolTasksResults.tsx +++ b/packages/client/components/TeamPrompt/WorkDrawer/ParabolTasksResults.tsx @@ -55,7 +55,6 @@ const ParabolTasksResults = (props: Props) => { diff --git a/packages/client/components/ThreadedCommentBase.tsx b/packages/client/components/ThreadedCommentBase.tsx index 6858560a03b..30c6edc3659 100644 --- a/packages/client/components/ThreadedCommentBase.tsx +++ b/packages/client/components/ThreadedCommentBase.tsx @@ -1,71 +1,49 @@ -import styled from '@emotion/styled' import graphql from 'babel-plugin-relay/macro' -import {convertToRaw, EditorState} from 'draft-js' -import {ReactNode, useEffect, useRef, useState} from 'react' +import {ReactNode, useEffect} from 'react' import {commitLocalUpdate, useFragment} from 'react-relay' import {ThreadedCommentBase_comment$key} from '~/__generated__/ThreadedCommentBase_comment.graphql' import {ThreadedCommentBase_discussion$key} from '~/__generated__/ThreadedCommentBase_discussion.graphql' import {ThreadedCommentBase_viewer$key} from '~/__generated__/ThreadedCommentBase_viewer.graphql' import useAtmosphere from '~/hooks/useAtmosphere' -import useEditorState from '~/hooks/useEditorState' import useMutationProps from '~/hooks/useMutationProps' import AddReactjiToReactableMutation from '~/mutations/AddReactjiToReactableMutation' import UpdateCommentContentMutation from '~/mutations/UpdateCommentContentMutation' -import convertToTaskContent from '~/utils/draftjs/convertToTaskContent' -import isAndroid from '~/utils/draftjs/isAndroid' import isTempId from '~/utils/relay/isTempId' +import {useTipTapCommentEditor} from '../hooks/useTipTapCommentEditor' import anonymousAvatar from '../styles/theme/images/anonymous-avatar.svg' import deletedAvatar from '../styles/theme/images/deleted-avatar-placeholder.svg' import {PARABOL_AI_USER_ID} from '../utils/constants' import SendClientSideEvent from '../utils/SendClientSideEvent' +import DiscussionThreadInput from './DiscussionThreadInput' import {DiscussionThreadables} from './DiscussionThreadList' -import CommentEditor from './TaskEditor/CommentEditor' +import {TipTapEditor} from './promptResponse/TipTapEditor' import ThreadedAvatarColumn from './ThreadedAvatarColumn' import ThreadedCommentFooter from './ThreadedCommentFooter' import ThreadedCommentHeader from './ThreadedCommentHeader' -import {ReplyMention, SetReplyMention} from './ThreadedItem' -import ThreadedItemReply from './ThreadedItemReply' import ThreadedItemWrapper from './ThreadedItemWrapper' -import useFocusedReply from './useFocusedReply' - -const BodyCol = styled('div')({ - display: 'flex', - flexDirection: 'column', - paddingBottom: 8, - width: 'calc(100% - 56px)' -}) - -const EditorWrapper = styled('div')({ - paddingRight: 16 -}) interface Props { allowedThreadables: DiscussionThreadables[] comment: ThreadedCommentBase_comment$key - children?: ReactNode // the replies, listed here to avoid a circular reference discussion: ThreadedCommentBase_discussion$key - isReply?: boolean // this comment is a reply & should be indented - setReplyMention: SetReplyMention - replyMention?: ReplyMention - dataCy: string viewer: ThreadedCommentBase_viewer$key + repliesList?: ReactNode + getMaxSortOrder: () => number } const ThreadedCommentBase = (props: Props) => { const { allowedThreadables, - children, comment: commentRef, - replyMention, - setReplyMention, discussion: discussionRef, - dataCy, - viewer: viewerRef + viewer: viewerRef, + repliesList, + getMaxSortOrder } = props const viewer = useFragment( graphql` fragment ThreadedCommentBase_viewer on User { - ...ThreadedItemReply_viewer + ...DiscussionThreadInput_viewer billingTier } `, @@ -75,12 +53,13 @@ const ThreadedCommentBase = (props: Props) => { graphql` fragment ThreadedCommentBase_discussion on Discussion { ...DiscussionThreadInput_discussion - ...ThreadedItemReply_discussion id meetingId - replyingToCommentId teamId discussionTopicId + replyingTo { + id + } } `, discussionRef @@ -89,7 +68,6 @@ const ThreadedCommentBase = (props: Props) => { graphql` fragment ThreadedCommentBase_comment on Comment { ...ThreadedCommentHeader_comment - ...ThreadedItemReply_threadable id isActive content @@ -103,37 +81,42 @@ const ThreadedCommentBase = (props: Props) => { id isViewerReactji } - threadParentId } `, commentRef ) - const isReply = !!props.isReply - const {id: discussionId, meetingId, replyingToCommentId, teamId, discussionTopicId} = discussion - const { - id: commentId, - content, - createdByUserNullable, - isActive, - reactjis, - threadParentId - } = comment - const ownerId = threadParentId || commentId + const isReply = !repliesList + const {id: discussionId, meetingId, teamId, discussionTopicId, replyingTo} = discussion + const {id: commentId, content, createdByUserNullable, isActive, reactjis} = comment const picture = isActive ? (createdByUserNullable?.picture ?? anonymousAvatar) : deletedAvatar const {submitMutation, submitting, onError, onCompleted} = useMutationProps() - const [editorState, setEditorState] = useEditorState(content) - const editorRef = useRef(null) - const ref = useRef(null) - const replyEditorRef = useRef(null) - const [isEditing, setIsEditing] = useState(false) const atmosphere = useAtmosphere() - useFocusedReply(ownerId, replyingToCommentId, ref, replyEditorRef) + const onSubmit = () => { + if (submitting || isTempId(commentId) || !editor || editor.isEmpty) return + editor.setEditable(false) + const nextContent = JSON.stringify(editor.getJSON()) + if (content === nextContent) return + submitMutation() + UpdateCommentContentMutation( + atmosphere, + {commentId, content: nextContent, meetingId}, + {onError, onCompleted} + ) + } + const {editor, setLinkState, linkState} = useTipTapCommentEditor(content, { + readOnly: true, + atmosphere, + teamId, + onEnter: onSubmit, + onEscape: () => { + editor?.commands.setContent(JSON.parse(content)) + editor?.setEditable(false) + } + }) const editComment = () => { - setIsEditing(true) - setImmediate(() => { - setEditorState(EditorState.moveFocusToEnd(editorState)) - editorRef.current?.focus() - }) + if (!editor) return + editor.setEditable(true) + editor.commands.focus() } useEffect(() => { @@ -164,65 +147,26 @@ const ThreadedCommentBase = (props: Props) => { }, {onCompleted, onError} ) - // when the reactjis move to the bottom & increase the height, make sure they're visible - setImmediate(() => ref.current?.scrollIntoView({behavior: 'smooth'})) } const onReply = () => { - if (createdByUserNullable && threadParentId) { - const {id: userId, preferredName} = createdByUserNullable - setReplyMention({userId, preferredName}) - } - commitLocalUpdate(atmosphere, (store) => { + const comment = store.get(commentId) + if (!comment) return store .getRoot() .getLinkedRecord('viewer') ?.getLinkedRecord('discussion', {id: discussionId}) - ?.setValue(ownerId, 'replyingToCommentId') + ?.setLinkedRecord(comment, 'replyingTo') }) } - const ensureHasText = (value: string) => value.trim().length - - const onSubmit = () => { - if (submitting || isTempId(commentId)) return - const editorEl = editorRef.current - if (isAndroid) { - if (!editorEl || editorEl.type !== 'textarea') return - const {value} = editorEl - if (!ensureHasText(value)) return - const initialContentState = editorState.getCurrentContent() - const initialText = initialContentState.getPlainText() - setIsEditing(false) - if (initialText === value) return - submitMutation() - UpdateCommentContentMutation( - atmosphere, - {commentId, content: convertToTaskContent(value), meetingId}, - {onError, onCompleted} - ) - return - } - const contentState = editorState.getCurrentContent() - if (!ensureHasText(contentState.getPlainText())) return - const nextContent = JSON.stringify(convertToRaw(contentState)) - setIsEditing(false) - if (content === nextContent) return - submitMutation() - UpdateCommentContentMutation( - atmosphere, - {commentId, content: nextContent, meetingId}, - {onError, onCompleted} - ) - } - + if (!editor) return null return ( - + - +
{ onReply={onReply} /> {isActive && ( - - + - +
)} {isActive && ( { onReply={onReply} /> )} - {children} - -
+ {repliesList} + {replyingTo?.id === comment.id && ( + + )} +
) } diff --git a/packages/client/components/ThreadedCommentFooter.tsx b/packages/client/components/ThreadedCommentFooter.tsx index 390759bdd1e..7d1bf3ca3b3 100644 --- a/packages/client/components/ThreadedCommentFooter.tsx +++ b/packages/client/components/ThreadedCommentFooter.tsx @@ -1,5 +1,6 @@ import styled from '@emotion/styled' import graphql from 'babel-plugin-relay/macro' +import {useLayoutEffect, useRef} from 'react' import {useFragment} from 'react-relay' import {ThreadedCommentFooter_reactjis$key} from '~/__generated__/ThreadedCommentFooter_reactjis.graphql' import {PALETTE} from '~/styles/paletteV3' @@ -37,10 +38,20 @@ const ThreadedCommentFooter = (props: Props) => { reactjisRef ) const hasReactjis = reactjis.length > 0 + const hadReactjisRef = useRef(hasReactjis) + const ref = useRef(null) + useLayoutEffect(() => { + if (hasReactjis && !hadReactjisRef.current) { + hadReactjisRef.current = true + ref.current?.scrollIntoView({behavior: 'smooth', block: 'end'}) + } else if (!hasReactjis) { + hadReactjisRef.current = false + } + }, [hasReactjis]) if (!hasReactjis) return null return ( - - + + ) diff --git a/packages/client/components/ThreadedCommentHeader.tsx b/packages/client/components/ThreadedCommentHeader.tsx index 1de83cc71d7..7eca0bf5140 100644 --- a/packages/client/components/ThreadedCommentHeader.tsx +++ b/packages/client/components/ThreadedCommentHeader.tsx @@ -30,7 +30,6 @@ interface Props { editComment: () => void onToggleReactji: (emojiId: string) => void onReply: () => void - dataCy: string meetingId: string } @@ -42,7 +41,7 @@ const getName = (comment: ThreadedCommentHeader_comment$data) => { } const ThreadedCommentHeader = (props: Props) => { - const {comment: commentRef, onReply, editComment, onToggleReactji, dataCy, meetingId} = props + const {comment: commentRef, onReply, editComment, onToggleReactji, meetingId} = props const comment = useFragment( graphql` fragment ThreadedCommentHeader_comment on Comment { @@ -80,12 +79,11 @@ const ThreadedCommentHeader = (props: Props) => { {!hasReactjis && ( <> - + )} {isEditable && ( { __typename replies { ...ThreadedRepliesList_replies + threadSortOrder } } `, threadableRef ) const {__typename, replies} = threadable - const [replyMention, setReplyMention] = useState(null) - const child = ( + const getMaxSortOrder = () => { + return replies ? Math.max(0, ...replies.map((reply) => reply.threadSortOrder || 0)) : 0 + } + const repliesList = ( ) @@ -82,15 +82,12 @@ export const ThreadedItem = (props: Props) => { return ( - {child} - + repliesList={repliesList} + getMaxSortOrder={getMaxSortOrder} + /> ) } if (__typename === 'Poll') { @@ -105,15 +102,12 @@ export const ThreadedItem = (props: Props) => { return ( - {child} - + repliesList={repliesList} + getMaxSortOrder={getMaxSortOrder} + /> ) } diff --git a/packages/client/components/ThreadedItemReply.tsx b/packages/client/components/ThreadedItemReply.tsx deleted file mode 100644 index b7572c92c6f..00000000000 --- a/packages/client/components/ThreadedItemReply.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import graphql from 'babel-plugin-relay/macro' -import {Editor} from 'draft-js' -import {RefObject, useRef} from 'react' -import {commitLocalUpdate, useFragment} from 'react-relay' -import {ThreadedItemReply_discussion$key} from '~/__generated__/ThreadedItemReply_discussion.graphql' -import {ThreadedItemReply_threadable$key} from '~/__generated__/ThreadedItemReply_threadable.graphql' -import {ThreadedItemReply_viewer$key} from '~/__generated__/ThreadedItemReply_viewer.graphql' -import useAtmosphere from '~/hooks/useAtmosphere' -import useClickAway from '~/hooks/useClickAway' -import isAndroid from '~/utils/draftjs/isAndroid' -import DiscussionThreadInput from './DiscussionThreadInput' -import {DiscussionThreadables} from './DiscussionThreadList' -import {ReplyMention, SetReplyMention} from './ThreadedItem' - -interface Props { - allowedThreadables: DiscussionThreadables[] - threadable: ThreadedItemReply_threadable$key - editorRef: RefObject - discussion: ThreadedItemReply_discussion$key - viewer: ThreadedItemReply_viewer$key - replyMention?: ReplyMention - setReplyMention: SetReplyMention - dataCy: string -} - -const ThreadedItemReply = (props: Props) => { - const { - allowedThreadables, - replyMention, - threadable: threadableRef, - editorRef, - discussion: discussionRef, - setReplyMention, - dataCy, - viewer: viewerRef - } = props - const viewer = useFragment( - graphql` - fragment ThreadedItemReply_viewer on User { - ...DiscussionThreadInput_viewer - } - `, - viewerRef - ) - const threadable = useFragment( - graphql` - fragment ThreadedItemReply_threadable on Threadable { - id - replies { - id - threadSortOrder - } - } - `, - threadableRef - ) - const discussion = useFragment( - graphql` - fragment ThreadedItemReply_discussion on Discussion { - ...DiscussionThreadInput_discussion - id - replyingToCommentId - } - `, - discussionRef - ) - const {id: threadableId, replies} = threadable - const {id: discussionId, replyingToCommentId} = discussion - const isReplying = replyingToCommentId === threadableId - const replyRef = useRef(null) - const atmosphere = useAtmosphere() - const clearReplyingToCommentId = () => { - commitLocalUpdate(atmosphere, (store) => { - store - .getRoot() - .getLinkedRecord('viewer') - ?.getLinkedRecord('discussion', {id: discussionId}) - ?.setValue('', 'replyingToCommentId') - }) - } - - const listeningRef = isReplying ? replyRef : null - useClickAway(listeningRef, () => { - const editorEl = editorRef.current - if (!editorEl) return - const hasText = isAndroid - ? editorEl.value - : (editorEl as any as Editor).props.editorState.getCurrentContent().hasText() - if (!hasText) { - clearReplyingToCommentId() - } - }) - - if (!isReplying) return null - const getMaxSortOrder = () => { - return replies ? Math.max(0, ...replies.map((reply) => reply.threadSortOrder || 0)) : 0 - } - return ( - - ) -} - -export default ThreadedItemReply diff --git a/packages/client/components/ThreadedRepliesList.tsx b/packages/client/components/ThreadedRepliesList.tsx index 13978e840b7..573807f938b 100644 --- a/packages/client/components/ThreadedRepliesList.tsx +++ b/packages/client/components/ThreadedRepliesList.tsx @@ -5,15 +5,12 @@ import {ThreadedRepliesList_replies$key} from '~/__generated__/ThreadedRepliesLi import {ThreadedRepliesList_viewer$key} from '~/__generated__/ThreadedRepliesList_viewer.graphql' import {DiscussionThreadables} from './DiscussionThreadList' import ThreadedCommentBase from './ThreadedCommentBase' -import {SetReplyMention} from './ThreadedItem' import ThreadedTaskBase from './ThreadedTaskBase' interface Props { allowedThreadables: DiscussionThreadables[] discussion: ThreadedRepliesList_discussion$key replies: ThreadedRepliesList_replies$key - setReplyMention: SetReplyMention - dataCy: string viewer: ThreadedRepliesList_viewer$key } @@ -21,9 +18,7 @@ const ThreadedRepliesList = (props: Props) => { const { allowedThreadables, replies: repliesRef, - setReplyMention, discussion: discussionRef, - dataCy, viewer: viewerRef } = props const viewer = useFragment( @@ -51,38 +46,35 @@ const ThreadedRepliesList = (props: Props) => { ...ThreadedCommentBase_comment __typename id + threadSortOrder } `, repliesRef ) - // https://sentry.io/organizations/parabol/issues/1569570376/?project=107196&query=is%3Aunresolved - // not sure why this is required addComment and createTask but request replies - if (!replies) return null + const getMaxSortOrder = () => { + return replies ? Math.max(0, ...replies.map((reply) => reply.threadSortOrder || 0)) : 0 + } return ( <> - {replies.map((reply) => { + {replies?.map((reply) => { const {__typename, id} = reply return __typename === 'Task' ? ( ) : ( ) })} diff --git a/packages/client/components/ThreadedReplyButton.tsx b/packages/client/components/ThreadedReplyButton.tsx index c1ae0b38b9a..a243aab9f60 100644 --- a/packages/client/components/ThreadedReplyButton.tsx +++ b/packages/client/components/ThreadedReplyButton.tsx @@ -13,22 +13,17 @@ const Reply = styled(PlainButton)({ interface Props { onReply: () => void - dataCy: string } const ThreadedReplyButton = (props: Props) => { - const {onReply, dataCy} = props + const {onReply} = props const onClick = (e: React.MouseEvent) => { // stop propagating so the new reply is not immediately cancelled e.stopPropagation() onReply() } - return ( - - Reply - - ) + return Reply } export default ThreadedReplyButton diff --git a/packages/client/components/ThreadedTaskBase.tsx b/packages/client/components/ThreadedTaskBase.tsx index 62c48deea4f..6185efee089 100644 --- a/packages/client/components/ThreadedTaskBase.tsx +++ b/packages/client/components/ThreadedTaskBase.tsx @@ -7,15 +7,13 @@ import {ThreadedTaskBase_task$key} from '~/__generated__/ThreadedTaskBase_task.g import {ThreadedTaskBase_viewer$key} from '~/__generated__/ThreadedTaskBase_viewer.graphql' import useAtmosphere from '~/hooks/useAtmosphere' import {PALETTE} from '~/styles/paletteV3' +import DiscussionThreadInput from './DiscussionThreadInput' import {DiscussionThreadables} from './DiscussionThreadList' import NullableTask from './NullableTask/NullableTask' import ThreadedAvatarColumn from './ThreadedAvatarColumn' -import {ReplyMention, SetReplyMention} from './ThreadedItem' import ThreadedItemHeaderDescription from './ThreadedItemHeaderDescription' -import ThreadedItemReply from './ThreadedItemReply' import ThreadedItemWrapper from './ThreadedItemWrapper' import ThreadedReplyButton from './ThreadedReplyButton' -import useFocusedReply from './useFocusedReply' const BodyCol = styled('div')({ display: 'flex', @@ -37,30 +35,25 @@ const StyledNullableTask = styled(NullableTask)({ interface Props { allowedThreadables: DiscussionThreadables[] task: ThreadedTaskBase_task$key - children?: ReactNode + repliesList?: ReactNode discussion: ThreadedTaskBase_discussion$key - isReply?: boolean // this comment is a reply & should be indented - setReplyMention: SetReplyMention - replyMention?: ReplyMention - dataCy: string viewer: ThreadedTaskBase_viewer$key + getMaxSortOrder: () => number } const ThreadedTaskBase = (props: Props) => { const { allowedThreadables, - children, + repliesList, + getMaxSortOrder, discussion: discussionRef, - setReplyMention, - replyMention, task: taskRef, - dataCy, viewer: viewerRef } = props const viewer = useFragment( graphql` fragment ThreadedTaskBase_viewer on User { - ...ThreadedItemReply_viewer + ...DiscussionThreadInput_viewer } `, viewerRef @@ -68,9 +61,11 @@ const ThreadedTaskBase = (props: Props) => { const discussion = useFragment( graphql` fragment ThreadedTaskBase_discussion on Discussion { - ...ThreadedItemReply_discussion + ...DiscussionThreadInput_discussion id - replyingToCommentId + replyingTo { + id + } } `, discussionRef @@ -79,7 +74,6 @@ const ThreadedTaskBase = (props: Props) => { graphql` fragment ThreadedTaskBase_task on Task { ...NullableTask_task - ...ThreadedItemReply_threadable id content createdByUser { @@ -91,41 +85,40 @@ const ThreadedTaskBase = (props: Props) => { `, taskRef ) - const isReply = !!props.isReply - const {id: discussionId, replyingToCommentId} = discussion + const {id: discussionId, replyingTo} = discussion + const isReply = !repliesList const {id: taskId, createdByUser, threadParentId} = task const {picture, preferredName} = createdByUser const atmosphere = useAtmosphere() const ref = useRef(null) - const replyEditorRef = useRef(null) const ownerId = threadParentId || taskId const onReply = () => { commitLocalUpdate(atmosphere, (store) => { - store.get(discussionId)?.setValue(ownerId, 'replyingToCommentId') + const owner = store.get(ownerId) + if (!owner) return + store.get(discussionId)?.setLinkedRecord(owner, 'replyingTo') }) } - useFocusedReply(ownerId, replyingToCommentId, ref, replyEditorRef) return ( - + - + - - {children} - + + {repliesList} + {replyingTo?.id === task.id && ( + + )} ) diff --git a/packages/client/components/promptResponse/TipTapEditor.tsx b/packages/client/components/promptResponse/TipTapEditor.tsx index a7a231cfa01..597b94225c4 100644 --- a/packages/client/components/promptResponse/TipTapEditor.tsx +++ b/packages/client/components/promptResponse/TipTapEditor.tsx @@ -1,8 +1,9 @@ -import {Editor, EditorContent} from '@tiptap/react' +import {Editor, EditorContent, type EditorContentProps} from '@tiptap/react' +import {cn} from '../../ui/cn' import {StandardBubbleMenu} from './StandardBubbleMenu' import TipTapLinkMenu, {LinkMenuState} from './TipTapLinkMenu' -interface Props extends React.ButtonHTMLAttributes { +interface Props extends EditorContentProps { editor: Editor linkState?: LinkMenuState setLinkState?: (linkState: LinkMenuState) => void @@ -10,9 +11,10 @@ interface Props extends React.ButtonHTMLAttributes { useLinkEditor?: () => void } export const TipTapEditor = (props: Props) => { - const {editor, linkState, setLinkState, showBubbleMenu, useLinkEditor} = props + const {className, editor, linkState, setLinkState, showBubbleMenu, useLinkEditor, ref, ...rest} = + props return ( -
+ <> {showBubbleMenu && setLinkState && ( )} @@ -27,7 +29,12 @@ export const TipTapEditor = (props: Props) => { useLinkEditor={useLinkEditor} /> )} - -
+ + ) } diff --git a/packages/client/components/useFocusedReply.ts b/packages/client/components/useFocusedReply.ts deleted file mode 100644 index c56d080f64a..00000000000 --- a/packages/client/components/useFocusedReply.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {RefObject, useEffect, useRef} from 'react' - -const useFocusedReply = ( - ownerId: string, - replyingToCommentId: string | null, - commentRef: RefObject, - replyEditorRef: RefObject -) => { - const wasReplyingRef = useRef(false) - const isReplying = replyingToCommentId === ownerId - useEffect(() => { - if (isReplying && !wasReplyingRef.current) { - commentRef.current?.scrollIntoView({behavior: 'smooth'}) - replyEditorRef.current?.focus() - } - }, [isReplying]) -} -export default useFocusedReply diff --git a/packages/client/containers/TaskCard/DraggableTask.tsx b/packages/client/containers/TaskCard/DraggableTask.tsx index 3b1322084d8..bb49277d5e5 100644 --- a/packages/client/containers/TaskCard/DraggableTask.tsx +++ b/packages/client/containers/TaskCard/DraggableTask.tsx @@ -44,7 +44,6 @@ const DraggableTask = (props: Props) => { > { const {id: meetingId, endedAt, viewerMeetingMember} = meeting const subscribeToMeeting = () => { if (atmosphere.registerQuery) { - atmosphere.registerQuery(queryKey, MeetingSubscription, {meetingId}, router).catch() + atmosphere.registerQuery(queryKey, MeetingSubscription, {meetingId}, router).catch(() => { + /*ignore*/ + }) } } if (viewerMeetingMember) { diff --git a/packages/client/hooks/useGotoNext.ts b/packages/client/hooks/useGotoNext.ts index 0b36f685c4a..2d35c9e65c1 100644 --- a/packages/client/hooks/useGotoNext.ts +++ b/packages/client/hooks/useGotoNext.ts @@ -38,7 +38,9 @@ export const useGotoNext = ( const {stage} = nextStageRes const {id: nextStageId} = stage if (!options.isHotkey || currentStageRes.stage.isComplete) { - gotoStageId(nextStageId).catch() + gotoStageId(nextStageId).catch(() => { + /*ignore*/ + }) } else if (options.isHotkey) { ref.current && ref.current.focus() } diff --git a/packages/client/hooks/useGotoPrev.ts b/packages/client/hooks/useGotoPrev.ts index a0868e63763..d621b0fcc95 100644 --- a/packages/client/hooks/useGotoPrev.ts +++ b/packages/client/hooks/useGotoPrev.ts @@ -33,7 +33,9 @@ export const useGotoPrev = ( const { stage: {id: nextStageId} } = nextStageRes - gotoStageId(nextStageId).catch() + gotoStageId(nextStageId).catch(() => { + /*ignore*/ + }) }, [gotoStageId, meeting]) } diff --git a/packages/client/hooks/useReplyEditorState.ts b/packages/client/hooks/useReplyEditorState.ts deleted file mode 100644 index 3563bcc4ee7..00000000000 --- a/packages/client/hooks/useReplyEditorState.ts +++ /dev/null @@ -1,34 +0,0 @@ -import {ContentState, EditorState, Modifier} from 'draft-js' -import {useEffect} from 'react' -import useEditorState from '~/hooks/useEditorState' -import {ReplyMention, SetReplyMention} from '../components/ThreadedItem' - -const useReplyEditorState = ( - replyMention: ReplyMention | undefined, - setReplyMention: SetReplyMention | undefined -) => { - const [editorState, setEditorState] = useEditorState() - useEffect(() => { - if (replyMention) { - const {userId, preferredName} = replyMention - setTimeout(() => { - const empty = EditorState.push(editorState, ContentState.createFromText(''), 'remove-range') - const cs = empty.getCurrentContent().createEntity('MENTION', 'IMMUTABLE', {userId}) - const nextContentState = Modifier.insertText( - cs, - empty.getSelection(), - preferredName, - undefined, - cs.getLastCreatedEntityKey() - ) - setEditorState( - EditorState.moveFocusToEnd(EditorState.push(empty, nextContentState, 'apply-entity')) - ) - setReplyMention!(null) - }) - } - }, [replyMention]) - return [editorState, setEditorState] as ReturnType -} - -export default useReplyEditorState diff --git a/packages/client/hooks/useSVG.ts b/packages/client/hooks/useSVG.ts index 8adaaf00aba..9b2913c34ac 100644 --- a/packages/client/hooks/useSVG.ts +++ b/packages/client/hooks/useSVG.ts @@ -24,7 +24,9 @@ const useSVG = (src: string, onLoad?: (el: SVGElement) => void) => { setSVG(res2) } } - fetchSVG().catch() + fetchSVG().catch(() => { + /*ignore*/ + }) return () => { isMountedRef.current = false } diff --git a/packages/client/hooks/useScrollThreadList.ts b/packages/client/hooks/useScrollThreadList.ts deleted file mode 100644 index 1c90f3b884c..00000000000 --- a/packages/client/hooks/useScrollThreadList.ts +++ /dev/null @@ -1,53 +0,0 @@ -import {RefObject, useEffect, useLayoutEffect, useRef} from 'react' -import useInitialRender from '~/hooks/useInitialRender' - -const useScrollThreadList = ( - threadables: readonly any[], - editorRef: RefObject, - wrapperRef: RefObject, - preferredNames: string[] | null -) => { - const isInit = useInitialRender() - // if we're at or near the bottom of the scroll container - // and the body is the active element - // then scroll to the bottom whenever threadables changes - const oldScrollHeightRef = useRef(0) - - useLayoutEffect(() => { - const {current: el} = wrapperRef - if (!el) return - - const {scrollTop, scrollHeight, clientHeight} = el - if (isInit) { - if (el.scrollTo) { - el.scrollTo({top: scrollHeight}) - } else { - el.scrollTop = el.scrollHeight - } - return - } - // get the element for the draft-js el or android fallback - const edEl = (editorRef.current as any)?.editor || editorRef.current - - // if i'm writing something or i'm almost at the bottom or i've reduced the - // wrapper height, i.e. closed video in poker meeting, go to the bottom - if ( - document.activeElement === edEl || - scrollTop + clientHeight > oldScrollHeightRef.current - 20 - ) { - setTimeout(() => { - if (el.scrollTo) { - el.scrollTo({top: scrollHeight, behavior: 'smooth'}) - } else { - el.scrollTop = el.scrollHeight - } - // the delay is required for new task cards, not sure why height is determined async - }, 50) - } - }, [isInit, threadables, preferredNames]) - useEffect(() => { - oldScrollHeightRef.current = wrapperRef.current?.scrollHeight ?? 0 - }, [threadables]) -} - -export default useScrollThreadList diff --git a/packages/client/hooks/useServiceWorkerUpdater.ts b/packages/client/hooks/useServiceWorkerUpdater.ts index cf21d4751e7..6ec801da279 100644 --- a/packages/client/hooks/useServiceWorkerUpdater.ts +++ b/packages/client/hooks/useServiceWorkerUpdater.ts @@ -28,7 +28,9 @@ const useServiceWorkerUpdater = () => { }) } if ('serviceWorker' in navigator) { - setFirstServiceWorker().catch() + setFirstServiceWorker().catch(() => { + /*ignore*/ + }) navigator.serviceWorker.addEventListener('controllerchange', onServiceWorkerChange) return () => { navigator.serviceWorker.removeEventListener('controllerchange', onServiceWorkerChange) diff --git a/packages/client/hooks/useSlackChannels.ts b/packages/client/hooks/useSlackChannels.ts index 3b0d359b060..061025a8c9d 100644 --- a/packages/client/hooks/useSlackChannels.ts +++ b/packages/client/hooks/useSlackChannels.ts @@ -41,7 +41,9 @@ const useSlackChannels = ( availableChannels.unshift({...botChannel, name: '@Parabol'}) setChannels(availableChannels) } - getChannels().catch() + getChannels().catch(() => { + /*ignore*/ + }) return () => { isMounted = false } diff --git a/packages/client/hooks/useSubscription.ts b/packages/client/hooks/useSubscription.ts index 346c849fb81..0bbc44fed59 100644 --- a/packages/client/hooks/useSubscription.ts +++ b/packages/client/hooks/useSubscription.ts @@ -15,7 +15,9 @@ const useSubscription = ( const router = {history, location} useEffect(() => { if (atmosphere.registerQuery) { - atmosphere.registerQuery(queryKey, subscription, variables, router).catch() + atmosphere.registerQuery(queryKey, subscription, variables, router).catch(() => { + /*ignore*/ + }) } return () => { if (atmosphere.scheduleUnregisterQuery) { diff --git a/packages/client/hooks/useTipTapCommentEditor.ts b/packages/client/hooks/useTipTapCommentEditor.ts new file mode 100644 index 00000000000..f3ad1df916e --- /dev/null +++ b/packages/client/hooks/useTipTapCommentEditor.ts @@ -0,0 +1,88 @@ +import Mention from '@tiptap/extension-mention' +import Placeholder from '@tiptap/extension-placeholder' +import {Extension, useEditor} from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import {useRef, useState} from 'react' +import Atmosphere from '../Atmosphere' +import {LoomExtension} from '../components/promptResponse/loomExtension' +import {TiptapLinkExtension} from '../components/promptResponse/TiptapLinkExtension' +import {LinkMenuState} from '../components/promptResponse/TipTapLinkMenu' +import {mentionConfig} from '../shared/tiptap/serverTipTapExtensions' +import {tiptapEmojiConfig} from '../utils/tiptapEmojiConfig' +import {tiptapMentionConfig} from '../utils/tiptapMentionConfig' +import {tiptapTagConfig} from '../utils/tiptapTagConfig' +import {useTipTapEditorContent} from './useTipTapEditorContent' + +const isValid = (obj: T | undefined | null | boolean): obj is T => { + return !!obj +} + +export const useTipTapCommentEditor = ( + content: string, + options: { + atmosphere?: Atmosphere + teamId?: string + readOnly?: boolean + placeholder?: string + onEnter?: () => void + onEscape?: () => void + } +) => { + const {atmosphere, teamId, readOnly, placeholder, onEnter, onEscape} = options + const [contentJSON, editorRef] = useTipTapEditorContent(content) + const [linkState, setLinkState] = useState(null) + const placeholderRef = useRef(placeholder) + // Keeping it in a ref means we don't have to re-initialize the editor, so content is preserved + placeholderRef.current = placeholder + editorRef.current = useEditor( + { + content: contentJSON, + extensions: [ + StarterKit, + LoomExtension, + Placeholder.configure({ + showOnlyWhenEditable: false, + placeholder: () => { + return placeholderRef.current || 'Edit your comment' + } + }), + Mention.extend({name: 'taskTag'}).configure(tiptapTagConfig), + Mention.configure( + atmosphere && teamId ? tiptapMentionConfig(atmosphere, teamId) : mentionConfig + ), + Mention.extend({name: 'emojiMention'}).configure(tiptapEmojiConfig), + TiptapLinkExtension.configure({ + openOnClick: false, + popover: { + setLinkState + } + }), + onEnter && + onEscape && + Extension.create({ + name: 'commentKeyboardShortcuts', + addKeyboardShortcuts(this) { + return { + Enter: () => { + onEnter() + return true + }, + Escape: () => { + onEscape() + return true + } + } + } + }) + ].filter(isValid), + editable: !readOnly, + autofocus: true, + onCreate: ({editor}) => { + // Focus the editor and move the cursor to the end of the content + editor.commands.focus('end') + } + }, + [readOnly] + ) + return {editor: editorRef.current, linkState, setLinkState} +} diff --git a/packages/client/hooks/useTipTapIcebreakerEditor.ts b/packages/client/hooks/useTipTapIcebreakerEditor.ts index 6df7e3a4245..32925009279 100644 --- a/packages/client/hooks/useTipTapIcebreakerEditor.ts +++ b/packages/client/hooks/useTipTapIcebreakerEditor.ts @@ -1,8 +1,9 @@ import Mention from '@tiptap/extension-mention' import Placeholder from '@tiptap/extension-placeholder' -import {Extension, generateText, useEditor} from '@tiptap/react' +import {generateText, useEditor} from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' +import {BlurOnSubmit} from '../utils/tiptap/BlurOnSubmit' import {tiptapEmojiConfig} from '../utils/tiptapEmojiConfig' import {useTipTapEditorContent} from './useTipTapEditorContent' @@ -19,19 +20,7 @@ export const useTipTapIcebreakerEditor = (content: string, options: {readOnly?: placeholder: 'e.g. How are you?' }), Mention.extend({name: 'emojiMention'}).configure(tiptapEmojiConfig), - Extension.create({ - name: 'blurOnSubmit', - addKeyboardShortcuts(this) { - const submit = () => { - this.editor.commands.blur() - return true - } - return { - Enter: submit, - Tab: submit - } - } - }) + BlurOnSubmit ], editable: !readOnly, autofocus: generateText(contentJSON, serverTipTapExtensions).length === 0 diff --git a/packages/client/hooks/useTrebuchetEvents.ts b/packages/client/hooks/useTrebuchetEvents.ts index 0f2f9e3865e..dce94f692cb 100644 --- a/packages/client/hooks/useTrebuchetEvents.ts +++ b/packages/client/hooks/useTrebuchetEvents.ts @@ -68,7 +68,9 @@ const useTrebuchetEvents = () => { serverVersionRef.current = obj.version if ('serviceWorker' in navigator) { const registration = await navigator.serviceWorker.getRegistration() - registration?.update().catch() + registration?.update().catch(() => { + /*ignore*/ + }) } } else if (recentDisconnectsRef.current.length > 0) { // retry if reconnect and versions are the same diff --git a/packages/client/modules/demo/initDB.ts b/packages/client/modules/demo/initDB.ts index 7744fcc783d..fd825dcb911 100644 --- a/packages/client/modules/demo/initDB.ts +++ b/packages/client/modules/demo/initDB.ts @@ -3,12 +3,12 @@ import {PALETTE} from '~/styles/paletteV3' import {Task as ITask} from '../../../server/postgres/types/index.d' import {RetrospectiveMeeting} from '../../../server/postgres/types/Meeting' import JiraProjectId from '../../shared/gqlIds/JiraProjectId' +import {convertTipTapTaskContent} from '../../shared/tiptap/convertTipTapTaskContent' import demoUserAvatar from '../../styles/theme/images/avatar-user.svg' import {ExternalLinks, MeetingSettingsThreshold, RetroDemo} from '../../types/constEnums' import {DISCUSS, GROUP, REFLECT, RETROSPECTIVE, VOTE} from '../../utils/constants' import getDemoAvatar from '../../utils/getDemoAvatar' import toTeamMemberId from '../../utils/relay/toTeamMemberId' -import normalizeRawDraftJS from '../../validation/normalizeRawDraftJS' import {DemoReflection, DemoReflectionGroup, DemoTask} from './ClientGraphQLServer' import DemoDiscussStage from './DemoDiscussStage' import DemoGenericMeetingStage from './DemoGenericMeetingStage' @@ -433,7 +433,7 @@ export class DemoComment { }, db: RetroDemoDB ) { - this.content = normalizeRawDraftJS(content) + this.content = convertTipTapTaskContent(content) this.createdAt = new Date().toJSON() this.updatedAt = new Date().toJSON() this.createdBy = isAnonymous ? null : userId diff --git a/packages/client/modules/email/components/EmailNotifications/EmailDiscussionMentioned.tsx b/packages/client/modules/email/components/EmailNotifications/EmailDiscussionMentioned.tsx index 3e5ac111192..1254e820d9d 100644 --- a/packages/client/modules/email/components/EmailNotifications/EmailDiscussionMentioned.tsx +++ b/packages/client/modules/email/components/EmailNotifications/EmailDiscussionMentioned.tsx @@ -1,10 +1,9 @@ +import {generateHTML} from '@tiptap/html' import graphql from 'babel-plugin-relay/macro' -import {convertFromRaw, Editor, EditorState} from 'draft-js' import {EmailDiscussionMentioned_notification$key} from 'parabol-client/__generated__/EmailDiscussionMentioned_notification.graphql' -import editorDecorators from 'parabol-client/components/TaskEditor/decorators' import * as React from 'react' -import {useMemo, useRef} from 'react' import {useFragment} from 'react-relay' +import {serverTipTapExtensions} from '../../../../shared/tiptap/serverTipTapExtensions' import {cardShadow} from '../../../../styles/elevation' import {PALETTE} from '../../../../styles/paletteV3' import anonymousAvatar from '../../../../styles/theme/images/anonymous-avatar.png' @@ -88,16 +87,7 @@ const EmailDiscussionMentioned = (props: Props) => { searchParams }) - const contentState = useMemo(() => convertFromRaw(JSON.parse(comment.content)), [comment.content]) - const editorStateRef = useRef() - const getEditorState = () => { - return editorStateRef.current - } - editorStateRef.current = EditorState.createWithContent( - contentState, - editorDecorators(getEditorState) - ) - + const htmlContent = generateHTML(JSON.parse(comment.content), serverTipTapExtensions) return ( { linkUrl={linkUrl} >
- { - /**/ - }} - /> +
) diff --git a/packages/client/modules/email/components/EmailNotifications/EmailResponseReplied.tsx b/packages/client/modules/email/components/EmailNotifications/EmailResponseReplied.tsx index d016c31d4e3..7e7f8a7bba4 100644 --- a/packages/client/modules/email/components/EmailNotifications/EmailResponseReplied.tsx +++ b/packages/client/modules/email/components/EmailNotifications/EmailResponseReplied.tsx @@ -1,9 +1,8 @@ +import {generateHTML} from '@tiptap/html' import graphql from 'babel-plugin-relay/macro' -import {convertFromRaw, Editor, EditorState} from 'draft-js' import {EmailResponseReplied_notification$key} from 'parabol-client/__generated__/EmailResponseReplied_notification.graphql' -import editorDecorators from 'parabol-client/components/TaskEditor/decorators' -import {useMemo, useRef} from 'react' import {useFragment} from 'react-relay' +import {serverTipTapExtensions} from '../../../../shared/tiptap/serverTipTapExtensions' import {cardShadow} from '../../../../styles/elevation' import {PALETTE} from '../../../../styles/paletteV3' import anonymousAvatar from '../../../../styles/theme/images/anonymous-avatar.png' @@ -71,15 +70,7 @@ const EmailResponseReplied = (props: Props) => { } }) - const contentState = useMemo(() => convertFromRaw(JSON.parse(comment.content)), [comment.content]) - const editorStateRef = useRef() - const getEditorState = () => { - return editorStateRef.current - } - editorStateRef.current = EditorState.createWithContent( - contentState, - editorDecorators(getEditorState) - ) + const htmlContent = generateHTML(JSON.parse(comment.content), serverTipTapExtensions) return ( { linkUrl={linkUrl} >
- { - /**/ - }} - /> +
) diff --git a/packages/client/modules/email/components/SummaryEmail/ExportToCSV.tsx b/packages/client/modules/email/components/SummaryEmail/ExportToCSV.tsx index a03e79aa24f..4da7ed8eff3 100644 --- a/packages/client/modules/email/components/SummaryEmail/ExportToCSV.tsx +++ b/packages/client/modules/email/components/SummaryEmail/ExportToCSV.tsx @@ -8,7 +8,6 @@ import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractText import withMutationProps, {WithMutationProps} from 'parabol-client/utils/relay/withMutationProps' import {useEffect} from 'react' import useAtmosphere from '~/hooks/useAtmosphere' -import {isDraftJSContent} from '../../../../shared/tiptap/isDraftJSContent' import {serverTipTapExtensions} from '../../../../shared/tiptap/serverTipTapExtensions' import {ExternalLinks, PokerCards} from '../../../../types/constEnums' import {CorsOptions} from '../../../../types/cors' @@ -177,7 +176,9 @@ const imageStyle = { const ExportToCSV = (props: Props) => { useEffect(() => { if (props.urlAction === 'csv') { - exportToCSV().catch() + exportToCSV().catch(() => { + /*ignore*/ + }) } }, [props.urlAction]) const atmosphere = useAtmosphere() @@ -246,9 +247,7 @@ const ExportToCSV = (props: Props) => { const {createdAt, createdByUser, __typename: type, replies, content} = node const author = createdByUser?.preferredName ?? 'Anonymous' const contentJSON = JSON.parse(content!) - const discussionThread = isDraftJSContent(contentJSON) - ? extractTextFromDraftString(content!) - : generateText(contentJSON, serverTipTapExtensions) + const discussionThread = generateText(contentJSON, serverTipTapExtensions) rows.push({ reflectionGroup: title!, author, diff --git a/packages/client/modules/outcomeCard/components/OutcomeCard/OutcomeCard.tsx b/packages/client/modules/outcomeCard/components/OutcomeCard/OutcomeCard.tsx index b9a7f0fb2b8..eb3f071f8ce 100644 --- a/packages/client/modules/outcomeCard/components/OutcomeCard/OutcomeCard.tsx +++ b/packages/client/modules/outcomeCard/components/OutcomeCard/OutcomeCard.tsx @@ -2,7 +2,7 @@ import styled from '@emotion/styled' import {Editor} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' import {memo} from 'react' -import {useFragment} from 'react-relay' +import {commitLocalUpdate, useFragment} from 'react-relay' import {OutcomeCard_task$key} from '~/__generated__/OutcomeCard_task.graphql' import {AreaEnum, TaskStatusEnum} from '~/__generated__/UpdateTaskMutation.graphql' import EditingStatus from '~/components/EditingStatus/EditingStatus' @@ -67,7 +67,6 @@ interface Props { isDraggingOver: TaskStatusEnum | undefined task: OutcomeCard_task$key useTaskChild: UseTaskChild - dataCy: string addTaskChild(name: string): void removeTaskChild(name: string): void } @@ -86,13 +85,13 @@ const OutcomeCard = memo((props: Props) => { isAgenda, isDraggingOver, task: taskRef, - useTaskChild, - dataCy + useTaskChild } = props const task = useFragment( graphql` fragment OutcomeCard_task on Task @argumentDefinitions(meetingId: {type: "ID"}) { ...IntegratedTaskContent_task + discussionId editors { userId } @@ -137,7 +136,7 @@ const OutcomeCard = memo((props: Props) => { const nextContent = JSON.stringify(editor.getJSON()) UpdateTaskMutation(atmosphere, {updatedTask: {id: taskId, content: nextContent}}, {}) } - const {integration, status, id: taskId, isHighlighted, editors} = task + const {integration, status, id: taskId, isHighlighted, editors, discussionId} = task const atmosphere = useAtmosphere() const {viewerId} = atmosphere const otherEditors = editors.filter((editor) => editor.userId !== viewerId) @@ -149,6 +148,12 @@ const OutcomeCard = memo((props: Props) => { const statusIndicatorTitle = `${statusTitle}${isPrivate ? privateTitle : ''}${ isArchived ? archivedTitle : '' }` + const onFocusChange = (isFocus: boolean) => () => { + if (!discussionId) return + commitLocalUpdate(atmosphere, (store) => { + store.get(discussionId)?.setValue(isFocus ? taskId : null, 'editingTaskId') + }) + } return ( { task={task} useTaskChild={useTaskChild} > - + {isPrivate && } {isArchived && } @@ -184,15 +189,14 @@ const OutcomeCard = memo((props: Props) => { editor={editor} linkState={linkState} setLinkState={setLinkState} - useLinkEditor={() => { - useTaskChild('editor-link-changer') - }} + useLinkEditor={() => useTaskChild('editor-link-changer')} + onBlur={onFocusChange(false)} + onFocus={onFocusChange(true)} /> )} - + { - const {area, cardIsActive, toggleTag, isAgenda, task: taskRef, useTaskChild, dataCy} = props + const {area, cardIsActive, toggleTag, isAgenda, task: taskRef, useTaskChild} = props const task = useFragment( graphql` fragment TaskFooter_task on Task { @@ -131,7 +130,6 @@ const TaskFooter = (props: Props) => { ) : ( { ) : ( { - const {mutationProps, task, useTaskChild, dataCy} = props + const {mutationProps, task, useTaskChild} = props const {togglePortal, originRef, menuPortal, menuProps, loadingWidth, loadingDelay} = useMenu( MenuPosition.UPPER_RIGHT, { @@ -41,7 +40,6 @@ const TaskFooterIntegrateToggle = (props: Props) => { onClick={togglePortal} ref={originRef} onMouseEnter={TaskFooterIntegrateMenuRoot.preload} - dataCy={`${dataCy}-button`} > { - const {area, toggleTag, isAgenda, mutationProps, task, useTaskChild, dataCy} = props + const {area, toggleTag, isAgenda, mutationProps, task, useTaskChild} = props const {togglePortal, originRef, menuPortal, menuProps} = useMenu(MenuPosition.UPPER_RIGHT) const { tooltipPortal, @@ -34,12 +33,7 @@ const TaskFooterTagMenuToggle = (props: Props) => { } = useTooltip(MenuPosition.UPPER_CENTER) return ( <> - + void - dataCy: string isViewerMeetingSection?: boolean meetingId?: string } @@ -45,7 +44,6 @@ const OutcomeCardContainer = memo((props: Props) => { task: taskRef, area, isAgenda, - dataCy, isViewerMeetingSection, meetingId } = props @@ -108,7 +106,6 @@ const OutcomeCardContainer = memo((props: Props) => { ref={ref} > { key={`cardBlockFor${task.id}`} style={{...style, width: CARD_WIDTH, padding: '1rem 0.5rem'}} > - + ) }} diff --git a/packages/client/mutations/AddCommentMutation.ts b/packages/client/mutations/AddCommentMutation.ts index b55d6f1a0bb..d89268343db 100644 --- a/packages/client/mutations/AddCommentMutation.ts +++ b/packages/client/mutations/AddCommentMutation.ts @@ -1,10 +1,11 @@ +import {generateJSON} from '@tiptap/core' import graphql from 'babel-plugin-relay/macro' import {commitMutation} from 'react-relay' import {AddCommentMutation_meeting$data} from '~/__generated__/AddCommentMutation_meeting.graphql' -import makeEmptyStr from '~/utils/draftjs/makeEmptyStr' import addNodeToArray from '~/utils/relay/addNodeToArray' import createProxyRecord from '~/utils/relay/createProxyRecord' import {AddCommentMutation as TAddCommentMutation} from '../__generated__/AddCommentMutation.graphql' +import {serverTipTapExtensions} from '../shared/tiptap/serverTipTapExtensions' import {SharedUpdater, StandardMutation} from '../types/relayMutations' import getDiscussionThreadConn from './connections/getDiscussionThreadConn' import safePutNodeInConn from './handlers/safePutNodeInConn' @@ -84,7 +85,7 @@ const AddCommentMutation: StandardMutation = ( createdAt: now, updatedAt: now, createdBy: isAnonymous ? null : viewerId, - comtent: comment.content || makeEmptyStr(), + comtent: comment.content || JSON.stringify(generateJSON('

', serverTipTapExtensions)), isActive: true, isViewerComment: true }) diff --git a/packages/client/mutations/CreateTaskMutation.ts b/packages/client/mutations/CreateTaskMutation.ts index 4e238bd6647..29ef91a1c8f 100644 --- a/packages/client/mutations/CreateTaskMutation.ts +++ b/packages/client/mutations/CreateTaskMutation.ts @@ -32,7 +32,6 @@ graphql` fragment CreateTaskMutation_task on CreateTaskPayload { task { ...CompleteTaskFrag @relay(mask: false) - ...ThreadedItemReply_threadable ...ThreadedItem_threadable discussionId threadSortOrder diff --git a/packages/client/mutations/DeleteCommentMutation.ts b/packages/client/mutations/DeleteCommentMutation.ts index 4eaee0f1d9a..92dad99164d 100644 --- a/packages/client/mutations/DeleteCommentMutation.ts +++ b/packages/client/mutations/DeleteCommentMutation.ts @@ -2,10 +2,10 @@ import graphql from 'babel-plugin-relay/macro' import {commitMutation} from 'react-relay' import {RecordProxy, RecordSourceSelectorProxy} from 'relay-runtime' import {DeleteCommentMutation_meeting$data} from '~/__generated__/DeleteCommentMutation_meeting.graphql' -import convertToTaskContent from '~/utils/draftjs/convertToTaskContent' import safeRemoveNodeFromArray from '~/utils/relay/safeRemoveNodeFromArray' import safeRemoveNodeFromConn from '~/utils/relay/safeRemoveNodeFromConn' import {DeleteCommentMutation as TDeleteCommentMutation} from '../__generated__/DeleteCommentMutation.graphql' +import {convertTipTapTaskContent} from '../shared/tiptap/convertTipTapTaskContent' import {SharedUpdater, SimpleMutation} from '../types/relayMutations' import getDiscussionThreadConn from './connections/getDiscussionThreadConn' @@ -62,7 +62,7 @@ const handleDeleteComment = ( return } if (replies && replies.length > 0) { - const TOMBSTONE = convertToTaskContent('[deleted]') + const TOMBSTONE = convertTipTapTaskContent('[deleted]') comment.setValue(TOMBSTONE, 'content') comment.setValue(false, 'isActive') } else { diff --git a/packages/client/mutations/EditCommentingMutation.ts b/packages/client/mutations/EditCommentingMutation.ts index 058d1b0258c..ba007c2c88f 100644 --- a/packages/client/mutations/EditCommentingMutation.ts +++ b/packages/client/mutations/EditCommentingMutation.ts @@ -19,6 +19,11 @@ const mutation = graphql` mutation EditCommentingMutation($isCommenting: Boolean!, $discussionId: ID!) { editCommenting(isCommenting: $isCommenting, discussionId: $discussionId) { ...EditCommentingMutation_meeting @relay(mask: false) + ... on ErrorPayload { + error { + message + } + } } } ` diff --git a/packages/client/mutations/NavigateMeetingMutation.ts b/packages/client/mutations/NavigateMeetingMutation.ts index fff2549f231..395d6bbe13f 100644 --- a/packages/client/mutations/NavigateMeetingMutation.ts +++ b/packages/client/mutations/NavigateMeetingMutation.ts @@ -13,7 +13,7 @@ import isInterruptingChickenPhase from '../utils/isInterruptingChickenPhase' import getBaseRecord from '../utils/relay/getBaseRecord' import safeProxy from '../utils/relay/safeProxy' import {setLocalStageAndPhase} from '../utils/relay/updateLocalStage' -import {isViewerTypingInComment, isViewerTypingInTask} from '../utils/viewerTypingUtils' +import {isViewerTyping} from '../utils/viewerTypingUtils' import handleRemoveReflectionGroups from './handlers/handleRemoveReflectionGroups' graphql` @@ -130,8 +130,7 @@ export const navigateMeetingTeamUpdater: SharedUpdater (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "8.11.0", + "version": "8.12.0", "repository": { "type": "git", "url": "https://github.com/ParabolInc/parabol" diff --git a/packages/client/schemaExtensions/clientSchema.graphql b/packages/client/schemaExtensions/clientSchema.graphql index 63541038006..834cb7eec44 100644 --- a/packages/client/schemaExtensions/clientSchema.graphql +++ b/packages/client/schemaExtensions/clientSchema.graphql @@ -137,7 +137,9 @@ extend type TeamPromptResponseStage { extend type Discussion { isAnonymousComment: Boolean! - replyingToCommentId: String! + replyingTo: Threadable + # the taskId if the Viewer is ecurrently editing one in the context of a discussion + editingTaskId: ID } extend type Organization { diff --git a/packages/client/styles/theme/global.css b/packages/client/styles/theme/global.css index 4f33567f322..0d36add3f84 100644 --- a/packages/client/styles/theme/global.css +++ b/packages/client/styles/theme/global.css @@ -176,10 +176,6 @@ } /** Customize TipTap */ -.ProseMirror { - min-height: 40px; - line-height: 1.25; -} .ProseMirror :is(ul, ol) { list-style-position: outside; diff --git a/packages/client/utils/draftjs/getTypeFromEntityMap.ts b/packages/client/utils/draftjs/getTypeFromEntityMap.ts deleted file mode 100644 index e51297cf593..00000000000 --- a/packages/client/utils/draftjs/getTypeFromEntityMap.ts +++ /dev/null @@ -1,22 +0,0 @@ -import {RawDraftContentState} from 'draft-js' - -const keyLookup = { - TAG: 'value', - MENTION: 'userId' -} - -const getTypeFromEntityMap = ( - type: keyof typeof keyLookup, - entityMap: RawDraftContentState['entityMap'] -) => { - const typeSet = new Set() - const id = keyLookup[type] - Object.values(entityMap).forEach((entity) => { - if (entity.type === type) { - typeSet.add(entity.data[id]) - } - }) - return Array.from(typeSet) -} - -export default getTypeFromEntityMap diff --git a/packages/client/utils/tiptap/BlurOnSubmit.ts b/packages/client/utils/tiptap/BlurOnSubmit.ts new file mode 100644 index 00000000000..151f18935c2 --- /dev/null +++ b/packages/client/utils/tiptap/BlurOnSubmit.ts @@ -0,0 +1,15 @@ +import {Extension} from '@tiptap/core' + +export const BlurOnSubmit = Extension.create({ + name: 'blurOnSubmit', + addKeyboardShortcuts(this) { + const submit = () => { + this.editor.commands.blur() + return true + } + return { + Enter: submit, + Tab: submit + } + } +}) diff --git a/packages/client/utils/viewerTypingUtils.ts b/packages/client/utils/viewerTypingUtils.ts index c944501a751..1f14b6d7c8e 100644 --- a/packages/client/utils/viewerTypingUtils.ts +++ b/packages/client/utils/viewerTypingUtils.ts @@ -3,6 +3,19 @@ import {AriaLabels} from '../types/constEnums' export const getActiveElement = () => document.activeElement as HTMLElement export const getAriaLabel = (htmlElement: HTMLElement) => htmlElement.getAttribute('aria-label') +export const isViewerTyping = () => { + const activeElement = getActiveElement() + if (activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement) { + return activeElement.value.trim().length > 0 + } + + if (activeElement instanceof HTMLElement && activeElement.isContentEditable) { + return activeElement.innerText.trim().length > 0 + } + + return false +} + export const isViewerTypingInTask = () => { const activeElement = getActiveElement() const ariaLabel = getAriaLabel(activeElement) diff --git a/packages/embedder/package.json b/packages/embedder/package.json index 3bb6046b62c..7fc27ac875c 100644 --- a/packages/embedder/package.json +++ b/packages/embedder/package.json @@ -1,6 +1,6 @@ { "name": "parabol-embedder", - "version": "8.11.0", + "version": "8.12.0", "description": "A service that computes embedding vectors from Parabol objects", "author": "Jordan Husney ", "homepage": "https://github.com/ParabolInc/parabol/tree/master/packages/embedder#readme", diff --git a/packages/gql-executor/package.json b/packages/gql-executor/package.json index 1853dd36103..a20a60c79ed 100644 --- a/packages/gql-executor/package.json +++ b/packages/gql-executor/package.json @@ -1,6 +1,6 @@ { "name": "gql-executor", - "version": "8.11.0", + "version": "8.12.0", "description": "A Stateless GraphQL Executor", "author": "Matt Krick ", "homepage": "https://github.com/ParabolInc/parabol/tree/master/packages/gqlExecutor#readme", @@ -25,8 +25,8 @@ }, "dependencies": { "dd-trace": "^5.0.0", - "parabol-client": "8.11.0", - "parabol-server": "8.11.0", + "parabol-client": "8.12.0", + "parabol-server": "8.12.0", "undici": "^5.26.2" } } diff --git a/packages/integration-tests/package.json b/packages/integration-tests/package.json index e2a8c9422d8..01bca340edd 100644 --- a/packages/integration-tests/package.json +++ b/packages/integration-tests/package.json @@ -2,7 +2,7 @@ "name": "integration-tests", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "8.11.0", + "version": "8.12.0", "description": "", "main": "index.js", "scripts": { diff --git a/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts b/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts index c6fdc078694..2dfd4a7b112 100644 --- a/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts +++ b/packages/integration-tests/tests/retrospective-demo/step4-discuss.test.ts @@ -148,7 +148,7 @@ test.describe('retrospective-demo / discuss page', () => { } for await (const comment of comments || []) { - await expect(page.locator(`[data-cy=comment-wrapper] :text('${comment}')`)).toBeVisible({ + await expect(page.locator(`:text('${comment}')`)).toBeVisible({ timeout: 30_000 }) } diff --git a/packages/server/billing/helpers/adjustUserCount.ts b/packages/server/billing/helpers/adjustUserCount.ts index 36623042be4..a5d6769b465 100644 --- a/packages/server/billing/helpers/adjustUserCount.ts +++ b/packages/server/billing/helpers/adjustUserCount.ts @@ -149,6 +149,8 @@ export default async function adjustUserCount( const organizations = await dataLoader.get('organizations').loadMany(orgIds) const paidOrgs = organizations.filter(isValid).filter((org) => org.stripeSubscriptionId) - handleEnterpriseOrgQuantityChanges(paidOrgs, dataLoader).catch() + handleEnterpriseOrgQuantityChanges(paidOrgs, dataLoader).catch(() => { + /*ignore*/ + }) handleTeamOrgQuantityChanges(paidOrgs).catch(Logger.error) } diff --git a/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts b/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts index 30b991ed905..a1e1a622e0f 100644 --- a/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts +++ b/packages/server/billing/helpers/handleEnterpriseOrgQuantityChanges.ts @@ -25,8 +25,10 @@ const sendEnterpriseOverageEvent = async ( ({role}) => role && ['BILLING_LEADER', 'ORG_ADMIN'].includes(role) )! const {id: userId} = billingLeaderOrgUser - const user = await dataLoader.get('users').loadNonNull(userId) - analytics.enterpriseOverUserLimit(user, orgId) + const user = await dataLoader.get('users').load(userId) + if (user) { + analytics.enterpriseOverUserLimit(user, orgId) + } } } @@ -37,7 +39,9 @@ const handleEnterpriseOrgQuantityChanges = async ( const enterpriseOrgs = paidOrgs.filter((org) => org.tier === 'enterprise') if (enterpriseOrgs.length === 0) return for (const org of enterpriseOrgs) { - sendEnterpriseOverageEvent(org, dataLoader).catch() + sendEnterpriseOverageEvent(org, dataLoader).catch(() => { + /*ignore*/ + }) } } diff --git a/packages/server/dataloader/primaryKeyLoaderMakers.ts b/packages/server/dataloader/primaryKeyLoaderMakers.ts index 2e0c20e33a0..ad797541829 100644 --- a/packages/server/dataloader/primaryKeyLoaderMakers.ts +++ b/packages/server/dataloader/primaryKeyLoaderMakers.ts @@ -1,5 +1,4 @@ import getKysely from '../postgres/getKysely' -import {getDiscussionsByIds} from '../postgres/queries/getDiscussionsByIds' import {getDomainJoinRequestsByIds} from '../postgres/queries/getDomainJoinRequestsByIds' import getMeetingSeriesByIds from '../postgres/queries/getMeetingSeriesByIds' import getMeetingTemplatesByIds from '../postgres/queries/getMeetingTemplatesByIds' @@ -8,6 +7,7 @@ import {getUsersByIds} from '../postgres/queries/getUsersByIds' import { selectAgendaItems, selectComments, + selectDiscussion, selectMassInvitations, selectMeetingMembers, selectMeetingSettings, @@ -36,7 +36,9 @@ export const users = primaryKeyLoaderMaker(getUsersByIds) export const teams = primaryKeyLoaderMaker((ids: readonly string[]) => { return selectTeams().where('id', 'in', ids).execute() }) -export const discussions = primaryKeyLoaderMaker(getDiscussionsByIds) +export const discussions = primaryKeyLoaderMaker((ids: readonly string[]) => { + return selectDiscussion().where('id', 'in', ids).execute() +}) export const templateRefs = primaryKeyLoaderMaker(getTemplateRefsByIds) export const templateScaleRefs = primaryKeyLoaderMaker((ids: readonly string[]) => { return selectTemplateScaleRef().where('id', 'in', ids).execute() diff --git a/packages/server/graphql/mutations/createTask.ts b/packages/server/graphql/mutations/createTask.ts index ae62f285935..72cc7456941 100644 --- a/packages/server/graphql/mutations/createTask.ts +++ b/packages/server/graphql/mutations/createTask.ts @@ -235,7 +235,9 @@ export default { handleAddTaskNotifications(teamMembers, task, viewerId, teamId, { operationId, mutatorId - }).catch() + }).catch(() => { + /*ignore*/ + }) const meeting = meetingId ? await dataLoader.get('newMeetings').load(meetingId) : undefined const taskProperties = { diff --git a/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts b/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts index 35237177bec..16b91ea61ae 100644 --- a/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts +++ b/packages/server/graphql/mutations/helpers/addAIGeneratedContentToThreads.ts @@ -1,11 +1,12 @@ +import {generateText, type JSONContent} from '@tiptap/core' +import {generateJSON} from '@tiptap/html' import {Insertable} from 'kysely' +import {serverTipTapExtensions} from '../../../../client/shared/tiptap/serverTipTapExtensions' import {PARABOL_AI_USER_ID} from '../../../../client/utils/constants' -import extractTextFromDraftString from '../../../../client/utils/draftjs/extractTextFromDraftString' import DiscussStage from '../../../database/types/DiscussStage' import generateUID from '../../../generateUID' import getKysely from '../../../postgres/getKysely' import {Comment} from '../../../postgres/types/pg' -import {convertHtmlToTaskContent} from '../../../utils/draftjs/convertHtmlToTaskContent' import {DataLoaderWorker} from '../../graphql' export const buildCommentContentBlock = ( @@ -14,15 +15,15 @@ export const buildCommentContentBlock = ( explainerText?: string ) => { const explainerBlock = explainerText ? `${explainerText}
` : '' - const html = `${explainerBlock}

${title}

${content}

` - return convertHtmlToTaskContent(html) + const html = `${explainerBlock}

${title}

${content}

` + return generateJSON(html, serverTipTapExtensions) as JSONContent } -export const createAIComment = (discussionId: string, content: string, order: number) => ({ +export const createAIComment = (discussionId: string, jsonContent: JSONContent, order: number) => ({ id: generateUID(), discussionId, - content, - plaintextContent: extractTextFromDraftString(content), + content: JSON.stringify(jsonContent), + plaintextContent: generateText(jsonContent, serverTipTapExtensions), threadSortOrder: order, createdBy: PARABOL_AI_USER_ID }) diff --git a/packages/server/graphql/public/mutations/addComment.ts b/packages/server/graphql/public/mutations/addComment.ts index 6fd9250283f..075c65ada0c 100644 --- a/packages/server/graphql/public/mutations/addComment.ts +++ b/packages/server/graphql/public/mutations/addComment.ts @@ -1,62 +1,42 @@ +import {generateText, type JSONContent} from '@tiptap/core' import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' import MeetingMemberId from '../../../../client/shared/gqlIds/MeetingMemberId' import TeamMemberId from '../../../../client/shared/gqlIds/TeamMemberId' -import extractTextFromDraftString from '../../../../client/utils/draftjs/extractTextFromDraftString' -import getTypeFromEntityMap from '../../../../client/utils/draftjs/getTypeFromEntityMap' +import {getAllNodesAttributesByType} from '../../../../client/shared/tiptap/getAllNodesAttributesByType' +import {serverTipTapExtensions} from '../../../../client/shared/tiptap/serverTipTapExtensions' import GenericMeetingPhase, { NewMeetingPhaseTypeEnum } from '../../../database/types/GenericMeetingPhase' import GenericMeetingStage from '../../../database/types/GenericMeetingStage' import generateUID from '../../../generateUID' import getKysely from '../../../postgres/getKysely' -import {IGetDiscussionsByIdsQueryResult} from '../../../postgres/queries/generated/getDiscussionsByIdsQuery' +import type {Discussion} from '../../../postgres/types' import {analytics} from '../../../utils/analytics/analytics' import {getUserId} from '../../../utils/authorization' +import {convertToTipTap} from '../../../utils/convertToTipTap' import publish from '../../../utils/publish' import {IntegrationNotifier} from '../../mutations/helpers/notifications/IntegrationNotifier' import {MutationResolvers} from '../resolverTypes' import publishNotification from './helpers/publishNotification' const getMentionNotifications = ( - content: string, + jsonContent: JSONContent, viewerId: string, - discussion: IGetDiscussionsByIdsQueryResult, + discussion: Discussion, commentId: string, meetingId: string ) => { - let parsedContent: any - try { - parsedContent = JSON.parse(content) - } catch { - // If we can't parse the content, assume no new notifications. - return [] - } - - const {entityMap} = parsedContent - return getTypeFromEntityMap('MENTION', entityMap) - .filter((mentionedUserId) => { - if (mentionedUserId === viewerId) { - return false - } - - if (discussion.discussionTopicType === 'teamPromptResponse') { - const {userId: responseUserId} = TeamMemberId.split(discussion.discussionTopicId) - if (responseUserId === mentionedUserId) { - // The mentioned user will already receive a 'RESPONSE_REPLIED' notification for this - // comment - return false - } - } - - // :TODO: (jmtaber129): Consider limiting these to when the mentionee is *not* on the - // relevant page. - return true - }) - .map((mentioneeUserId) => ({ + const subjectUserId = + discussion.discussionTopicType === 'teamPromptResponse' + ? TeamMemberId.split(discussion.discussionTopicId).userId + : null + + return getAllNodesAttributesByType<{id: string; label: string}>(jsonContent, 'mention') + .filter((mention) => ![viewerId, subjectUserId].includes(mention.id)) + .map((mention) => ({ id: generateUID(), type: 'DISCUSSION_MENTIONED' as const, - userId: mentioneeUserId, + userId: mention.id, meetingId: meetingId, authorId: viewerId, commentId, @@ -96,7 +76,8 @@ const addComment: MutationResolvers['addComment'] = async ( } // VALIDATION - const content = normalizeRawDraftJS(comment.content) + const content = convertToTipTap(comment.content) + const plaintextContent = generateText(content, serverTipTapExtensions) const commentId = generateUID() await getKysely() @@ -105,7 +86,7 @@ const addComment: MutationResolvers['addComment'] = async ( id: commentId, content, isAnonymous: isAnonymous ?? undefined, - plaintextContent: extractTextFromDraftString(content), + plaintextContent, createdBy: viewerId, threadSortOrder, threadParentId, diff --git a/packages/server/graphql/public/mutations/updateCommentContent.ts b/packages/server/graphql/public/mutations/updateCommentContent.ts index 9ab6cb85b45..784b98e3b90 100644 --- a/packages/server/graphql/public/mutations/updateCommentContent.ts +++ b/packages/server/graphql/public/mutations/updateCommentContent.ts @@ -1,10 +1,11 @@ +import {generateText} from '@tiptap/core' import {SubscriptionChannel} from 'parabol-client/types/constEnums' -import extractTextFromDraftString from 'parabol-client/utils/draftjs/extractTextFromDraftString' import toTeamMemberId from 'parabol-client/utils/relay/toTeamMemberId' -import normalizeRawDraftJS from 'parabol-client/validation/normalizeRawDraftJS' +import {serverTipTapExtensions} from '../../../../client/shared/tiptap/serverTipTapExtensions' import {PARABOL_AI_USER_ID} from '../../../../client/utils/constants' import getKysely from '../../../postgres/getKysely' import {getUserId} from '../../../utils/authorization' +import {convertToTipTap} from '../../../utils/convertToTipTap' import publish from '../../../utils/publish' import standardError from '../../../utils/standardError' import {MutationResolvers} from '../resolverTypes' @@ -40,10 +41,10 @@ const updateCommentContent: MutationResolvers['updateCommentContent'] = async ( } // VALIDATION - const normalizedContent = normalizeRawDraftJS(content) + const normalizedContent = convertToTipTap(content) + const plaintextContent = generateText(normalizedContent, serverTipTapExtensions) // RESOLUTION - const plaintextContent = extractTextFromDraftString(normalizedContent) await getKysely() .updateTable('Comment') .set({content: normalizedContent, plaintextContent}) diff --git a/packages/server/graphql/public/typeDefs/Discussion.graphql b/packages/server/graphql/public/typeDefs/Discussion.graphql index ecb0b3ebd22..e9c8e764d4c 100644 --- a/packages/server/graphql/public/typeDefs/Discussion.graphql +++ b/packages/server/graphql/public/typeDefs/Discussion.graphql @@ -17,7 +17,7 @@ type Discussion { createdAt: DateTime! """ - The partial foreign key that references the object that is the topic of the discussion. E.g. AgendaItemId, TaskId, ReflectionGroupId + The partial foreign key that references the object that is the topic of the discussion. E.g. AgendaItemId, TaskId, ReflectionGroupId, teamMemberId """ discussionTopicId: ID! diff --git a/packages/server/graphql/public/types/CheckInPhase.ts b/packages/server/graphql/public/types/CheckInPhase.ts index 702111fca08..00ada6608ea 100644 --- a/packages/server/graphql/public/types/CheckInPhase.ts +++ b/packages/server/graphql/public/types/CheckInPhase.ts @@ -1,17 +1,7 @@ -import {isDraftJSContent} from '../../../../client/shared/tiptap/isDraftJSContent' -import {convertKnownDraftToTipTap} from '../../../utils/convertToTipTap' import {CheckInPhaseResolvers} from '../resolverTypes' const CheckInPhase: CheckInPhaseResolvers = { - __isTypeOf: ({phaseType}) => phaseType === 'checkin', - checkInQuestion: async ({checkInQuestion}) => { - const contentJSON = JSON.parse(checkInQuestion) - if (!isDraftJSContent(contentJSON)) return checkInQuestion - // this is Draft-JS Content. convert it and send it down - // We can get rid of this resolver once we migrate legacy draft-js content to TipTap - const tipTapContent = convertKnownDraftToTipTap(contentJSON) - return JSON.stringify(tipTapContent) - } + __isTypeOf: ({phaseType}) => phaseType === 'checkin' } export default CheckInPhase diff --git a/packages/server/graphql/public/types/Comment.ts b/packages/server/graphql/public/types/Comment.ts index bede0ac3a81..e1fa01a6b78 100644 --- a/packages/server/graphql/public/types/Comment.ts +++ b/packages/server/graphql/public/types/Comment.ts @@ -1,13 +1,17 @@ -import convertToTaskContent from 'parabol-client/utils/draftjs/convertToTaskContent' +import {convertTipTapTaskContent} from '../../../../client/shared/tiptap/convertTipTapTaskContent' +import {isDraftJSContent} from '../../../../client/shared/tiptap/isDraftJSContent' import {getUserId} from '../../../utils/authorization' +import {convertKnownDraftToTipTap} from '../../../utils/convertToTipTap' import resolveReactjis from '../../resolvers/resolveReactjis' import {CommentResolvers} from '../resolverTypes' -const TOMBSTONE = convertToTaskContent('[deleted]') +const TOMBSTONE = convertTipTapTaskContent('[deleted]') const Comment: CommentResolvers = { content: ({isActive, content}) => { - return isActive ? JSON.stringify(content) : TOMBSTONE + if (!isActive) return TOMBSTONE + const validContent = isDraftJSContent(content) ? convertKnownDraftToTipTap(content) : content + return JSON.stringify(validContent) }, createdBy: ({createdBy, isAnonymous}) => { diff --git a/packages/server/graphql/subscribeGraphQL.ts b/packages/server/graphql/subscribeGraphQL.ts index 5128c5f1c11..24d1e0da395 100644 --- a/packages/server/graphql/subscribeGraphQL.ts +++ b/packages/server/graphql/subscribeGraphQL.ts @@ -128,7 +128,9 @@ const subscribeGraphQL = async (req: SubscribeRequest) => { if (resubIdx !== -1) { // reinitialize the subscription connectionContext.availableResubs.splice(resubIdx, 1) - subscribeGraphQL({...req, hideErrors: true}).catch() + subscribeGraphQL({...req, hideErrors: true}).catch(() => { + /*ignore*/ + }) } else { sendGQLMessage(connectionContext, opId, 'complete', false) } diff --git a/packages/server/package.json b/packages/server/package.json index da0cc703891..348b446d58e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -3,7 +3,7 @@ "description": "An open-source app for building smarter, more agile teams.", "author": "Parabol Inc. (http://github.com/ParabolInc)", "license": "AGPL-3.0", - "version": "8.11.0", + "version": "8.12.0", "repository": { "type": "git", "url": "https://github.com/ParabolInc/parabol" @@ -131,7 +131,7 @@ "oauth-1.0a": "^2.2.6", "openai": "^4.53.0", "oy-vey": "^0.12.1", - "parabol-client": "8.11.0", + "parabol-client": "8.12.0", "pg": "^8.5.1", "react": "^17.0.2", "react-dom": "^17.0.2", diff --git a/packages/server/postgres/queries/getDiscussionsByIds.ts b/packages/server/postgres/queries/getDiscussionsByIds.ts deleted file mode 100644 index 946051e8db8..00000000000 --- a/packages/server/postgres/queries/getDiscussionsByIds.ts +++ /dev/null @@ -1,7 +0,0 @@ -import getPg from '../getPg' -import {getDiscussionsByIdsQuery} from './generated/getDiscussionsByIdsQuery' - -export const getDiscussionsByIds = async (ids: readonly string[]) => { - const discussions = await getDiscussionsByIdsQuery.run({ids}, getPg()) - return discussions -} diff --git a/packages/server/postgres/queries/src/getDiscussionsByIdsQuery.sql b/packages/server/postgres/queries/src/getDiscussionsByIdsQuery.sql deleted file mode 100644 index 97b39a73a33..00000000000 --- a/packages/server/postgres/queries/src/getDiscussionsByIdsQuery.sql +++ /dev/null @@ -1,6 +0,0 @@ -/* - @name getDiscussionsByIdsQuery - @param ids -> (...) -*/ -SELECT * FROM "Discussion" -WHERE id in :ids; diff --git a/packages/server/postgres/select.ts b/packages/server/postgres/select.ts index 6223dbaecb9..772ace41b45 100644 --- a/packages/server/postgres/select.ts +++ b/packages/server/postgres/select.ts @@ -1,11 +1,20 @@ import type {JSONContent} from '@tiptap/core' -import {NotNull, sql} from 'kysely' +import {NotNull, sql, type SelectQueryBuilder} from 'kysely' import {NewMeetingPhaseTypeEnum} from '../graphql/public/resolverTypes' import getKysely from './getKysely' import {JiraDimensionField, ReactjiDB, TaskTag} from './types' import {AnyMeeting, AnyMeetingMember} from './types/Meeting' import {AnyNotification} from './types/Notification' import {AnyTaskIntegration} from './types/TaskIntegration' + +// This type is to allow us to perform a selectAll & then overwrite any column with another type +// e.g. a column might be of type string[] but when calling to_json it will be {id: string}[] +// since string[] && {id: string}[] do not intersect, we can't do this natively within kysely with $assertType +type AssertedQuery = + Q extends SelectQueryBuilder + ? SelectQueryBuilder & K> + : never + export const selectTimelineEvent = () => { return getKysely().selectFrom('TimelineEvent').selectAll().$narrowType< | { @@ -18,6 +27,10 @@ export const selectTimelineEvent = () => { >() } +export const selectDiscussion = () => { + return getKysely().selectFrom('Discussion').selectAll() +} + export const selectTeamMemberIntegrationAuth = () => { return getKysely().selectFrom('TeamMemberIntegrationAuth').selectAll() } @@ -202,23 +215,14 @@ export const selectSlackAuths = () => getKysely().selectFrom('SlackAuth').select export const selectSlackNotifications = () => getKysely().selectFrom('SlackNotification').selectAll() -export const selectComments = () => - getKysely() +export const selectComments = () => { + const query = getKysely() .selectFrom('Comment') - .select([ - 'id', - 'createdAt', - 'isActive', - 'isAnonymous', - 'threadParentId', - 'updatedAt', - 'content', - 'createdBy', - 'plaintextContent', - 'discussionId', - 'threadSortOrder' - ]) + .selectAll() .select(({fn}) => [fn('to_json', ['reactjis']).as('reactjis')]) + .$narrowType<{content: JSONContent}>() + return query as AssertedQuery +} export const selectReflectPrompts = () => getKysely().selectFrom('ReflectPrompt').selectAll() diff --git a/packages/server/postgres/types/index.d.ts b/packages/server/postgres/types/index.d.ts index af0ff428cf4..4b2953f312d 100644 --- a/packages/server/postgres/types/index.d.ts +++ b/packages/server/postgres/types/index.d.ts @@ -17,7 +17,8 @@ import { selectTeamPromptResponses, selectTeams, selectTemplateScale, - selectTemplateScaleRef + selectTemplateScaleRef, + type selectDiscussion } from '../select' import { Discussion as DiscussionPG, @@ -98,3 +99,5 @@ export type NewFeature = ExtractTypeFromQueryBuilderSelect export type Task = ExtractTypeFromQueryBuilderSelect export type TaskEstimate = Selectable + +export type Discussion = ExtractTypeFromQueryBuilderSelect diff --git a/packages/server/utils/draftjs/convertHtmlToTaskContent.ts b/packages/server/utils/draftjs/convertHtmlToTaskContent.ts deleted file mode 100644 index df7e0c7f5b9..00000000000 --- a/packages/server/utils/draftjs/convertHtmlToTaskContent.ts +++ /dev/null @@ -1,16 +0,0 @@ -import {ContentState, convertFromHTML} from 'draft-js' -import {convertStateToRaw, removeSpaces} from 'parabol-client/utils/draftjs/convertToTaskContent' -import simpleDOMBuilder from './simpleDOMBuilder' - -export const convertHtmlToTaskContent = (spacedHtml: string) => { - const markup = removeSpaces(spacedHtml) - // ref: [convertFromHTML document is not defined on server side rendering · Issue #1361 · facebook/draft-js](https://github.com/facebook/draft-js/issues/1361) - const DOMBuilder = typeof document === 'undefined' ? simpleDOMBuilder : undefined - const blocksFromHTML = convertFromHTML(markup, DOMBuilder) - - const contentState = ContentState.createFromBlockArray( - blocksFromHTML.contentBlocks, - blocksFromHTML.entityMap - ) - return convertStateToRaw(contentState) -} diff --git a/packages/server/utils/draftjs/simpleDOMBuilder.ts b/packages/server/utils/draftjs/simpleDOMBuilder.ts deleted file mode 100644 index 238371c5d58..00000000000 --- a/packages/server/utils/draftjs/simpleDOMBuilder.ts +++ /dev/null @@ -1,14 +0,0 @@ -import {JSDOM} from 'jsdom' - -function simpleDOMBuilder(html: string) { - const {document, HTMLElement, HTMLAnchorElement} = new JSDOM(``).window - - global.HTMLElement = HTMLElement - global.HTMLAnchorElement = HTMLAnchorElement - - const doc = document.implementation.createHTMLDocument('foo') - doc.documentElement.innerHTML = html - return doc.getElementsByTagName('body')[0] -} - -export default simpleDOMBuilder diff --git a/tailwind.config.js b/tailwind.config.js index b0dde7a1e3e..f5b9ccf6a8c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -10,7 +10,9 @@ module.exports = { content: ['./packages/client/**/!(*node_modules*)/**/*.{ts,tsx,js,jsx,html}', './template.html'], theme: { data: { - highlighted: 'highlighted=true' + highlighted: 'highlighted=true', + // for elements where disabled doesn't exist, e.g. divs + disabled: 'disabled=true' }, fontFamily: { sans: ['IBM Plex Sans', ...defaultTheme.fontFamily.sans], @@ -32,7 +34,10 @@ module.exports = { 'card-1': '0px 6px 10px rgba(68, 66, 88, 0.14), 0px 1px 18px rgba(68, 66, 88, 0.12), 0px 3px 5px rgba(68, 66, 88, 0.2)', dialog: - '0px 11px 15px -7px rgba(0,0,0,.2), 0px 24px 38px 3px rgba(0,0,0,.14), 0px 9px 46px 8px rgba(0,0,0,.12)' + '0px 11px 15px -7px rgba(0,0,0,.2), 0px 24px 38px 3px rgba(0,0,0,.14), 0px 9px 46px 8px rgba(0,0,0,.12)', + 'discussion-input': '0px 0px 16px 0px rgba(0,0,0,0.3)', + 'discussion-thread': + 'rgba(0,0,0,.2) 0px 3px 1px -2px, rgba(0,0,0,.14) 0px 2px 2px 0px, rgba(0,0,0,.12) 0px 1px 5px 0px' }, borderRadius: { card: '4px'