diff --git a/src/cloud/components/Comments/CommentInput.tsx b/src/cloud/components/Comments/CommentInput.tsx index b017bfe08b..13d06f1a91 100644 --- a/src/cloud/components/Comments/CommentInput.tsx +++ b/src/cloud/components/Comments/CommentInput.tsx @@ -1,5 +1,4 @@ import React, { useState, useCallback, useRef, useMemo } from 'react' -import Button from '../../../design/components/atoms/Button' import styled from '../../../design/lib/styled' import { useEffectOnce } from 'react-use' import useSuggestions from '../../../design/lib/hooks/useSuggestions' @@ -11,27 +10,29 @@ import { toFragment, isMention, } from '../../lib/comments' -import { lngKeys } from '../../lib/i18n/types' -import { useI18n } from '../../lib/hooks/useI18n' -import Flexbox from '../../../design/components/atoms/Flexbox' +import Button from '../../../design/components/atoms/Button' +import { mdiSendOutline } from '@mdi/js' interface CommentInputProps { onSubmit: (comment: string) => any value?: string autoFocus?: boolean users: SerializedUser[] + placeholder: string } const smallUserIconStyle = { width: '20px', height: '20px', lineHeight: '17px' } -export function CommentInput({ + +function CommentInput({ onSubmit, value = '', autoFocus = false, users, + placeholder, }: CommentInputProps) { const [working, setWorking] = useState(false) const inputRef = useRef(null) - const { translate } = useI18n() + const [isInputEmpty, setIsInputEmpty] = useState(true) const onSuggestionSelect = useRef((item: SerializedUser, hint: string) => { if (inputRef.current == null) { return @@ -75,8 +76,6 @@ export function CommentInput({ inputRef.current.addEventListener('blur', closeSuggestions) if (value.length > 0) { inputRef.current.appendChild(toFragment(value)) - } else { - resetInitialContent(inputRef.current) } if (autoFocus) { inputRef.current.focus() @@ -84,18 +83,21 @@ export function CommentInput({ } }) - const submit = useCallback(async () => { - if (inputRef.current != null) { - try { - setWorking(true) - await onSubmit(fromNode(inputRef.current).trim()) - if (inputRef.current != null) { - resetInitialContent(inputRef.current) - inputRef.current.focus() - } - } finally { - setWorking(false) - } + const onPostCommentAction = useCallback(async () => { + if (inputRef.current == null) { + return + } + const inputContent = fromNode(inputRef.current).trim() + if (inputContent === '') { + return + } + try { + setWorking(true) + await onSubmit(fromNode(inputRef.current).trim()) + inputRef.current.innerHTML = '' + } finally { + setWorking(false) + inputRef.current.focus() } }, [onSubmit]) @@ -104,10 +106,12 @@ export function CommentInput({ onKeyDownListener(ev) if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { - ev.preventDefault() - ev.stopPropagation() - submit() - return + if (inputRef.current != null) { + ev.preventDefault() + ev.stopPropagation() + onPostCommentAction() + return + } } if (ev.key === 'Enter' && ev.shiftKey) { @@ -123,9 +127,21 @@ export function CommentInput({ } } }, - [submit, onKeyDownListener] + [onKeyDownListener, onPostCommentAction] ) + const onKeyUp = useCallback(() => { + const inputContent = + inputRef.current !== null ? fromNode(inputRef.current).trim() : '' + setIsInputEmpty(inputContent === '') + }, []) + + const onCommentInput = useCallback(() => { + if (inputRef.current != null) { + setIsInputEmpty(fromNode(inputRef.current).trim() === '') + } + }, []) + const selectSuggestion: React.MouseEventHandler = useCallback( (ev) => { ev.stopPropagation() @@ -152,20 +168,30 @@ export function CommentInput({ return ( -
- - - +
+
+
+
+
{state.type === 'enabled' && state.suggestions.length > 0 && (
theme.sizes.spaces.l}px; + + .comment__input__send_button { + position: absolute; + right: 30px; + bottom: 6px; + } + } + + & .comment__input__input__editable_container { + margin: auto; + width: 90%; border: 1px solid ${({ theme }) => theme.colors.border.second}; - min-height: 60px; + min-height: 30px; background-color: ${({ theme }) => theme.colors.background.secondary}; color: ${({ theme }) => theme.colors.text.primary}; padding: 5px 10px; - margin-bottom: ${({ theme }) => theme.sizes.spaces.df}px; + + border-radius: ${({ theme }) => theme.borders.radius}px; + } + + & .comment__input__editable { + white-space: pre-wrap; + resize: none; + margin-bottom: ${({ theme }) => theme.sizes.spaces.md}px; + + &:empty:before { + content: attr(data-placeholder); + } } & .comment__input__suggestions { @@ -234,16 +283,6 @@ const InputContainer = styled.div` } ` -function resetInitialContent(element: Element) { - for (let i = 0; i < element.childNodes.length; i++) { - element.removeChild(element.childNodes[i]) - } - - const child = document.createElement('div') - child.appendChild(document.createElement('br')) - element.appendChild(child) -} - function getMentionInSelection() { const selection = getSelection() if (selection == null) return null diff --git a/src/cloud/components/Comments/CommentList.tsx b/src/cloud/components/Comments/CommentList.tsx index 038ff11334..ab44100f04 100644 --- a/src/cloud/components/Comments/CommentList.tsx +++ b/src/cloud/components/Comments/CommentList.tsx @@ -4,12 +4,8 @@ import styled from '../../../design/lib/styled' import UserIcon from '../UserIcon' import { format } from 'date-fns' import Icon from '../../../design/components/atoms/Icon' -import { mdiDotsVertical, mdiClose } from '@mdi/js' +import { mdiClose, mdiPencil, mdiTrashCanOutline } from '@mdi/js' import { SerializedUser } from '../../interfaces/db/user' -import { - useContextMenu, - MenuTypes, -} from '../../../design/lib/stores/contextMenu' import CommentInput from './CommentInput' import sortBy from 'ramda/es/sortBy' import prop from 'ramda/es/prop' @@ -18,6 +14,7 @@ import { toText } from '../../lib/comments' interface CommentThreadProps { comments: Comment[] className: string + commentItemClassName: string updateComment: (comment: Comment, message: string) => Promise deleteComment: (comment: Comment) => Promise user?: SerializedUser @@ -27,6 +24,7 @@ interface CommentThreadProps { function CommentList({ comments, className, + commentItemClassName, updateComment, deleteComment, user, @@ -39,14 +37,15 @@ function CommentList({ return (
{sorted.map((comment) => ( - +
+ +
))}
) @@ -60,7 +59,8 @@ interface CommentItemProps { users: SerializedUser[] } -const smallUserIconStyle = { width: '32px', height: '32px', lineHeight: '28px' } +const smallUserIconStyle = { width: '28px', height: '28px', lineHeight: '22px' } + export function CommentItem({ comment, editable, @@ -69,25 +69,7 @@ export function CommentItem({ users, }: CommentItemProps) { const [editing, setEditing] = useState(false) - const { popup } = useContextMenu() - - const openContextMenu: React.MouseEventHandler = useCallback( - (event) => { - popup(event, [ - { - type: MenuTypes.Normal, - label: 'Edit', - onClick: () => setEditing(true), - }, - { - type: MenuTypes.Normal, - label: 'Delete', - onClick: () => deleteComment(comment), - }, - ]) - }, - [popup, comment, deleteComment] - ) + const [showingContextMenu, setShowingContextMenu] = useState(false) const submitComment = useCallback( async (message: string) => { @@ -106,13 +88,17 @@ export function CommentItem({
{' '}
-
+
setShowingContextMenu(true)} + onMouseLeave={() => setShowingContextMenu(false)} + >
{comment.user.displayName} - {format(comment.createdAt, 'do MMMM hh:mmaaa')} + {format(comment.createdAt, 'hh:mmaaa MMM do')} {editable && (editing ? ( @@ -120,13 +106,27 @@ export function CommentItem({
) : ( -
- -
+ showingContextMenu && ( +
+
setEditing(true)} + className='comment__meta__actions__edit' + > + +
+
deleteComment(comment)} + className='comment__meta__actions__remove' + > + +
+
+ ) ))}
{editing ? ( theme.sizes.spaces.xsm}px; - margin-right: ${({ theme }) => theme.sizes.spaces.df}px; + width: 39px; } .comment__content { @@ -156,13 +154,14 @@ const CommentItemContainer = styled.div` .comment__meta { display: flex; align-items: center; - margin-bottom: ${({ theme }) => theme.sizes.spaces.sm}px; + margin-bottom: 4px; + position: relative; & svg { - color: ${({ theme }) => theme.colors.icon.default} + color: ${({ theme }) => theme.colors.icon.default}; &:hover { - color: ${({ theme }) => theme.colors.icon.active} + color: ${({ theme }) => theme.colors.icon.active}; } } } @@ -191,6 +190,34 @@ const CommentItemContainer = styled.div` white-space: pre-wrap; word-break: break-word; } + + .comment__meta__actions { + display: flex; + flex-direction: row; + justify-self: flex-end; + align-self: center; + position: absolute; + right: 7px; + + padding: 4px; + gap: 4px; + border-radius: ${({ theme }) => theme.borders.radius}px; + + background-color: #1e2024; + + .comment__meta__actions__edit, + .comment__meta__actions__remove { + height: 20px; + margin: 3px; + + color: ${({ theme }) => theme.colors.text.subtle}; + + &:hover { + cursor: pointer; + color: ${({ theme }) => theme.colors.text.primary}; + } + } + } ` export default CommentList diff --git a/src/cloud/components/Comments/CommentManager.tsx b/src/cloud/components/Comments/CommentManager.tsx index 2f5e3c2c56..b9c0d55707 100644 --- a/src/cloud/components/Comments/CommentManager.tsx +++ b/src/cloud/components/Comments/CommentManager.tsx @@ -1,22 +1,17 @@ -import React, { useMemo, useState } from 'react' +import React, { useMemo } from 'react' import { Thread, Comment } from '../../interfaces/db/comments' import Spinner from '../../../design/components/atoms/Spinner' -import { mdiPlusBoxOutline, mdiArrowLeft } from '@mdi/js' +import { mdiArrowLeft } from '@mdi/js' import Icon from '../../../design/components/atoms/Icon' import CommentList from './CommentList' import styled from '../../../design/lib/styled' import CommentInput from './CommentInput' -import ThreadActionButton from './ThreadActionButton' -import Button from '../../../design/components/atoms/Button' import { CreateThreadRequestBody } from '../../api/comments/thread' import { SerializedUser } from '../../interfaces/db/user' import ThreadList from './ThreadList' -import ThreadStatusFilterControl, { - StatusFilter, -} from '../ThreadStatusFilterControl' -import { partitionOnStatus } from '../../../design/lib/utils/comments' import { useI18n } from '../../lib/hooks/useI18n' import { lngKeys } from '../../lib/i18n/types' +import Button from '../../../design/components/atoms/Button' export type State = | { mode: 'list_loading'; thread?: { id: string } } @@ -43,8 +38,6 @@ export interface Actions { createThread: ( data: Omit ) => Promise - reopenThread: (thread: Thread) => Promise - closeThread: (thread: Thread) => Promise deleteThread: (thread: Thread) => Promise threadOutdated: (thread: Thread) => Promise createComment: (thread: Thread, message: string) => Promise @@ -62,8 +55,6 @@ function CommentManager({ state, setMode, createThread, - reopenThread, - closeThread, deleteThread, createComment, updateComment, @@ -72,20 +63,6 @@ function CommentManager({ users, }: CommentManagerProps) { const { translate } = useI18n() - const [statusFilter, setStatusFitler] = useState('open') - const partitioned = useMemo(() => { - return partitionOnStatus(state.mode === 'list_loading' ? [] : state.threads) - }, [state]) - - const counts = useMemo(() => { - return { - all: state.mode === 'list_loading' ? 0 : state.threads.length, - open: partitioned.open.length, - closed: partitioned.closed.length, - outdated: partitioned.outdated.length, - } - }, [partitioned, state]) - const usersOrEmpty = useMemo(() => { return users != null ? users : [] }, [users]) @@ -100,27 +77,26 @@ function CommentManager({
) case 'list': { - const stateThreads = - statusFilter === 'all' ? state.threads : partitioned[statusFilter] - const threads = - state.filter != null - ? stateThreads.filter(state.filter) - : stateThreads return ( <> - setMode({ mode: 'thread', thread })} - onOpen={reopenThread} - onClose={closeThread} - onDelete={deleteThread} - /> -
setMode({ mode: 'new_thread' })} - > - {' '} - {translate(lngKeys.ThreadCreate)} +
+ setMode({ mode: 'thread', thread })} + onDelete={deleteThread} + users={usersOrEmpty} + updateComment={updateComment} + /> +
+
+ { + await createThread({ comment }) + }} + autoFocus={true} + users={usersOrEmpty} + />
) @@ -129,30 +105,33 @@ function CommentManager({ return (
+ +
{state.thread.context}
- {state.thread.status.type === 'open' && ( - createComment(state.thread, message)} - autoFocus={true} - users={usersOrEmpty} - /> - )} - {state.thread.status.type === 'closed' && ( - - )} + createComment(state.thread, message)} + autoFocus={true} + users={usersOrEmpty} + />
) @@ -160,8 +139,8 @@ function CommentManager({ case 'new_thread': { return (
-
{state.data.context}
{ await createThread({ ...state.data, comment }) }} @@ -173,11 +152,8 @@ function CommentManager({ } } }, [ - translate, state, createThread, - reopenThread, - closeThread, deleteThread, createComment, updateComment, @@ -185,37 +161,12 @@ function CommentManager({ setMode, user, usersOrEmpty, - statusFilter, - partitioned, ]) return (
- {(state.mode !== 'list' || state.filter != null) && ( -
setMode({ mode: 'list' })} - > - -
- )}

{translate(lngKeys.ThreadsTitle)}

- {state.mode === 'list' && ( - - )} - {state.mode === 'thread' && ( - - )}
{content}
@@ -225,22 +176,23 @@ function CommentManager({ const Container = styled.div` margin: auto; height: 100vh; - width: 350px; + overflow: hidden; + width: 480px; display: flex; flex-direction: column; border-left: 1px solid ${({ theme }) => theme.colors.border.main}; - border-radius: 0px; - background-color: ${({ theme }) => theme.colors.background.secondary}; + border-radius: 0; + background-color: ${({ theme }) => theme.colors.background.primary}; color: ${({ theme }) => theme.colors.text.primary}; font-size: ${({ theme }) => theme.sizes.fonts.md}px; position: relative; - scrollbar-width: thin; + &::-webkit-scrollbar { width: 6px; } .header { - padding: 0px ${({ theme }) => theme.sizes.spaces.df}px; + padding: 0 ${({ theme }) => theme.sizes.spaces.df}px; & h4 { margin: 0; } @@ -269,13 +221,24 @@ const Container = styled.div` display: flex; flex-direction: column-reverse; scrollbar-width: thin; - padding: 0px ${({ theme }) => theme.sizes.spaces.df}px; + padding: 0 ${({ theme }) => theme.sizes.spaces.df}px; margin-bottom: ${({ theme }) => theme.sizes.spaces.df}px; + + margin-left: ${({ theme }) => theme.sizes.spaces.sm}px; + + .thread__content__back_button { + margin-top: ${({ theme }) => theme.sizes.spaces.sm}px; + } + & .comment__list { & > div { margin-bottom: ${({ theme }) => theme.sizes.spaces.df}px; } + & .comment__list__comment__item:not(:first-child) { + margin-left: 35px; + } + &:hover { .comment__meta__menu { display: block; @@ -316,6 +279,18 @@ const Container = styled.div` left: 50%; transform: translate3d(-50%, -50%, 0); } + + .thread__list__container { + display: flex; + flex-direction: column; + flex: 1; + height: 100%; + overflow-y: auto; + } + + .thread__list__container__create__thread { + margin-top: ${({ theme }) => theme.sizes.spaces.df}px; + } ` export default CommentManager diff --git a/src/cloud/components/Comments/ThreadActionButton.tsx b/src/cloud/components/Comments/ThreadActionButton.tsx index 9c4925853d..9dc2d5df38 100644 --- a/src/cloud/components/Comments/ThreadActionButton.tsx +++ b/src/cloud/components/Comments/ThreadActionButton.tsx @@ -15,19 +15,12 @@ import Flexbox from '../../../design/components/atoms/Flexbox' interface ThreadActionButtonProps { thread: Thread - onClose: (thread: Thread) => any - onOpen: (thread: Thread) => any onDelete: (thread: Thread) => any } -function ThreadActionButton({ - thread, - onClose, - onOpen, - onDelete, -}: ThreadActionButtonProps) { +function ThreadActionButton({ thread, onDelete }: ThreadActionButtonProps) { const { popup } = useContextMenu() - const actions = useThreadActions({ thread, onClose, onOpen, onDelete }) + const actions = useThreadActions({ thread, onDelete }) const { getThreadStatusLabel } = useI18n() const openActionMenu: React.MouseEventHandler = useCallback( diff --git a/src/cloud/components/Comments/ThreadItem.tsx b/src/cloud/components/Comments/ThreadItem.tsx index 10089be334..4f0cffe4d5 100644 --- a/src/cloud/components/Comments/ThreadItem.tsx +++ b/src/cloud/components/Comments/ThreadItem.tsx @@ -1,79 +1,197 @@ -import React, { useCallback } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { isToday, format, formatDistanceToNow } from 'date-fns' -import { Thread } from '../../interfaces/db/comments' -import { - mdiAlertCircleOutline, - mdiDotsVertical, - mdiAlertCircleCheckOutline, -} from '@mdi/js' +import { Thread, Comment } from '../../interfaces/db/comments' import UserIcon from '../UserIcon' -import Icon from '../../../design/components/atoms/Icon' import styled from '../../../design/lib/styled' -import useThreadActions, { - ThreadActionProps, -} from '../../lib/hooks/useThreadMenuActions' -import { useContextMenu } from '../../../design/lib/stores/contextMenu' +import { ThreadActionProps } from '../../lib/hooks/useThreadMenuActions' import { lngKeys } from '../../lib/i18n/types' import { useI18n } from '../../lib/hooks/useI18n' +import { listThreadComments } from '../../api/comments/comment' +import { SerializedUser } from '../../interfaces/db/user' +import { + mdiClose, + mdiMessageReplyTextOutline, + mdiPencil, + mdiTrashCanOutline, +} from '@mdi/js' +import Icon from '../../../design/components/atoms/Icon' +import CommentInput from './CommentInput' export type ThreadListItemProps = ThreadActionProps & { onSelect: (thread: Thread) => void + users: SerializedUser[] + updateComment: (comment: Comment, message: string) => Promise } -const smallUserIconStyle = { width: '22px', height: '22px', lineHeight: '18px' } -function ThreadItem({ thread, onSelect, ...rest }: ThreadListItemProps) { - const actions = useThreadActions({ thread, ...rest }) - const { popup } = useContextMenu() +const smallUserIconStyle = { width: '28px', height: '28px', lineHeight: '22px' } +const smallerUserIconReplyStyle = { + width: '22px', + height: '22px', + lineHeight: '18px', +} + +function ThreadItem({ + thread, + onSelect, + onDelete, + updateComment, + users, +}: ThreadListItemProps) { const { translate } = useI18n() + const [editing, setEditing] = useState(false) + + const [threadComments, setThreadComments] = useState(null) + const [showingContextMenu, setShowingContextMenu] = useState(false) + + const reloadComments = useCallback(() => { + listThreadComments({ id: thread.id }).then((comments) => { + setThreadComments(comments) + }) + }, [thread.id]) + + useEffect(() => { + reloadComments() + }, [reloadComments, thread.id]) + + const showReplyForm = useCallback(() => { + if (threadComments == null || threadComments.length > 1) { + return + } - const openActionMenu: React.MouseEventHandler = useCallback( - (event) => { - event.preventDefault() - event.stopPropagation() - popup(event, actions) + setShowingContextMenu(true) + }, [threadComments]) + + const hideReplyForm = useCallback(() => { + setShowingContextMenu(false) + }, []) + + const submitComment = useCallback( + async (message: string) => { + if (threadComments == null || threadComments.length == 0) { + return + } + await updateComment(threadComments[0], message) + setEditing(false) + reloadComments() }, - [actions, popup] + [reloadComments, threadComments, updateComment] ) + const onCommentDelete = useCallback( + (thread) => { + onDelete(thread) + reloadComments() + }, + [onDelete, reloadComments] + ) + + const threadCommentedUser = useMemo(() => { + if (threadComments == null || threadComments.length == 0) { + return + } + + for (const user of users) { + if (threadComments[0].user.id == user.id) { + return user + } + } + + return threadComments[0].user + }, [threadComments, users]) + return ( - onSelect(thread)}> -
-
- -
- {thread.selection != null - ? thread.context - : translate(lngKeys.ThreadFullDocLabel)} + +
+
+
+
+ {threadComments && threadComments.length > 0 && ( +
+ {thread.contributors[0].displayName} + + {formatDate(thread.lastCommentTime)} + + {editing ? ( + + ) : ( +
+ {threadComments[0].message} +
+ )} + + {threadComments && threadComments.length > 1 && ( +
+ +
onSelect(thread)} + className={'thread__comment__line__replies__link'} + > + {translate(lngKeys.ThreadReplies, { + count: thread.commentCount, + })} +
+ + {formatDate(thread.lastCommentTime)} + +
+ )} +
+ )}
-
- -
-
-
-
- {thread.contributors.map((user) => ( - - ))} - {translate(lngKeys.ThreadReplies, { count: thread.commentCount })} - - {formatDate(thread.lastCommentTime)} - -
+ {editing ? ( +
setEditing(false)}> + +
+ ) : ( + showingContextMenu && ( +
+
onSelect(thread)} + className='comment__meta__actions__comment' + > + +
+
setEditing(true)} + className='comment__meta__actions__edit' + > + +
+
onCommentDelete(thread)} + className='comment__meta__actions__remove' + > + +
+
+ ) + )}
) @@ -81,16 +199,19 @@ function ThreadItem({ thread, onSelect, ...rest }: ThreadListItemProps) { const StyledListItem = styled.div` padding: ${({ theme }) => theme.sizes.spaces.df}px 0; - border-bottom: 1px solid ${({ theme }) => theme.colors.border.main}; cursor: default; - .thread__info__line__date { - color: ${({ theme }) => theme.colors.text.subtle}; - font-size: ${({ theme }) => theme.sizes.fonts.sm}px; - padding-left: 4px; + + .thread { + display: flex; + flex-direction: row; + + justify-content: space-between; + position: relative; } - &:hover .thread__action { - opacity: 1; + .thread__info { + display: flex; + width: 100%; } & .thread__row { @@ -102,44 +223,76 @@ const StyledListItem = styled.div` & .thread__info__line { display: flex; - align-items: baseline; - overflow: hidden; + + .thread__info__line__icon { + width: 39px; + } + & > * { - margin-right: ${({ theme }) => theme.sizes.spaces.xsm}px; + margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; } - & > .thread__item__context { - margin: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &.thread__item__context--highlighted { - color: white; - background-color: #705400; + } + + & .thread__comment__line { + width: 100%; + align-items: center; + + & .thread__comment__line__replies__link { + margin-left: ${({ theme }) => theme.sizes.spaces.sm}px; + margin-top: ${({ theme }) => theme.sizes.spaces.xsm}px; + align-self: center; + color: #519aba; + + &:hover { + color: #65afd0; } } - } - & .thread__status { - flex-shrink: 0; - &.thread__status--open { - color: ${({ theme }) => theme.colors.variants.success.base}; + & .thread__comment__line__icon { + width: 39px; + margin-top: ${({ theme }) => theme.sizes.spaces.xsm}px; } - &.thread__status--closed { - color: ${({ theme }) => theme.colors.variants.danger.base}; + & .thread__comment__line__date { + align-self: center; + color: ${({ theme }) => theme.colors.text.subtle}; + font-size: ${({ theme }) => theme.sizes.fonts.sm}px; + padding-left: 4px; + margin-top: ${({ theme }) => theme.sizes.spaces.xsm}px; } - &.thread__status--outdated { - color: ${({ theme }) => theme.colors.icon.default}; + & .thread__comment__line_more_replies_container { + display: flex; + align-items: center; } } - & .thread__action { - height: 20px; - opacity: 0; - color: ${({ theme }) => theme.colors.text.subtle}; - &:hover { - color: ${({ theme }) => theme.colors.text.primary}; + + .comment__meta__actions { + position: absolute; + right: 12px; + top: -8px; + display: flex; + flex-direction: row; + justify-self: flex-start; + align-self: center; + + gap: 4px; + border-radius: ${({ theme }) => theme.borders.radius}px; + + background-color: #1e2024; + + .comment__meta__actions__comment, + .comment__meta__actions__edit, + .comment__meta__actions__remove { + height: 20px; + margin: 5px; + + color: ${({ theme }) => theme.colors.text.subtle}; + + &:hover { + cursor: pointer; + color: ${({ theme }) => theme.colors.text.primary}; + } } } ` diff --git a/src/cloud/components/Comments/ThreadList.tsx b/src/cloud/components/Comments/ThreadList.tsx index 350ba89a27..06db198b09 100644 --- a/src/cloud/components/Comments/ThreadList.tsx +++ b/src/cloud/components/Comments/ThreadList.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from 'react' import ThreadItem, { ThreadListItemProps } from './ThreadItem' -import { Thread } from '../../interfaces/db/comments' +import { Comment, Thread } from '../../interfaces/db/comments' import { sortBy } from 'ramda' import { highlightComment, @@ -10,14 +10,15 @@ import styled from '../../../design/lib/styled' interface ThreadListProps extends Omit { threads: Thread[] + updateComment: (comment: Comment, message: string) => Promise } function ThreadList({ threads, onSelect, - onOpen, - onClose, onDelete, + users, + updateComment, }: ThreadListProps) { const sorted = useMemo(() => { return sortBy((thread) => thread.lastCommentTime, threads).reverse() @@ -35,9 +36,9 @@ function ThreadList({
))} @@ -47,7 +48,7 @@ function ThreadList({ const Container = styled.div` & > div { - padding: 0px ${({ theme }) => theme.sizes.spaces.df}px; + padding: 0 ${({ theme }) => theme.sizes.spaces.df}px; &:hover { background-color: ${({ theme }) => theme.colors.background.tertiary}; } diff --git a/src/cloud/components/Editor/index.tsx b/src/cloud/components/Editor/index.tsx index f01d1282ec..c548420923 100644 --- a/src/cloud/components/Editor/index.tsx +++ b/src/cloud/components/Editor/index.tsx @@ -10,11 +10,7 @@ import attachFileHandlerToCodeMirrorEditor, { OnFileCallback, } from '../../lib/editor/plugins/fileHandler' import { uploadFile, buildTeamFileUrl } from '../../api/teams/files' -import { - createRelativePositionFromTypeIndex, - createAbsolutePositionFromRelativePosition, - YEvent, -} from 'yjs' +import { YEvent } from 'yjs' import { useGlobalKeyDownHandler, preventKeyboardEventPropagation, @@ -43,7 +39,6 @@ import { mdiPencil, mdiEyeOutline, mdiViewSplitVertical, - mdiCommentTextOutline, mdiDotsHorizontal, } from '@mdi/js' import EditorToolButton from './EditorToolButton' @@ -66,7 +61,6 @@ import { } from '../../lib/utils/events' import { ScrollSync, scrollSyncer } from '../../lib/editor/scrollSync' import CodeMirrorEditor from '../../lib/editor/components/CodeMirrorEditor' -import { SelectionContext } from '../MarkdownView' import { usePage } from '../../lib/stores/pageStore' import { useToast } from '../../../design/lib/stores/toast' import { LoadingButton } from '../../../design/components/atoms/Button' @@ -78,7 +72,6 @@ import PresenceIcons from '../Topbar/PresenceIcons' import { TopbarControlProps } from '../../../design/components/organisms/Topbar' import CommentManager from '../Comments/CommentManager' import useCommentManagerState from '../../lib/hooks/useCommentManagerState' -import { HighlightRange } from '../../lib/rehypeHighlight' import { getDocLinkHref } from '../Link/DocLink' import throttle from 'lodash.throttle' import { useI18n } from '../../lib/hooks/useI18n' @@ -234,7 +227,6 @@ const Editor = ({ const previewRef = useRef(null) const syncScroll = useRef() const [scrollSync, setScrollSync] = useState(true) - const [viewComments, setViewComments] = useState([]) const { settings } = useSettings() const fontSize = settings['general.editorFontSize'] @@ -319,81 +311,6 @@ const Editor = ({ } }) - const newRangeThread = useCallback( - (selection: SelectionContext) => { - if (realtime == null) { - return - } - const text = realtime.doc.getText('content') - const anchor = createRelativePositionFromTypeIndex(text, selection.start) - const head = createRelativePositionFromTypeIndex(text, selection.end) - setPreferences({ docContextMode: 'comment' }) - commentActions.setMode({ - mode: 'new_thread', - context: selection.text, - selection: { - anchor, - head, - }, - }) - }, - [realtime, commentActions, setPreferences] - ) - - const calculatePositions = useCallback(() => { - if (commentState.mode === 'list_loading' || realtime == null) { - return - } - - const comments: HighlightRange[] = [] - for (const thread of commentState.threads) { - if (thread.selection != null && thread.status.type !== 'outdated') { - const absoluteAnchor = createAbsolutePositionFromRelativePosition( - thread.selection.anchor, - realtime.doc - ) - const absoluteHead = createAbsolutePositionFromRelativePosition( - thread.selection.head, - realtime.doc - ) - - if ( - absoluteAnchor != null && - absoluteHead != null && - absoluteAnchor.index !== absoluteHead.index - ) { - if (thread.status.type === 'open') { - comments.push({ - id: thread.id, - start: absoluteAnchor.index, - end: absoluteHead.index, - active: - commentState.mode === 'thread' && - thread.id === commentState.thread.id, - }) - } - } else if (connState === 'synced') { - commentActions.threadOutdated(thread) - } - } - } - setViewComments(comments) - }, [commentState, realtime, commentActions, connState]) - - const commentClick = useCallback( - (ids: string[]) => { - if (commentState.mode !== 'list_loading') { - const idSet = new Set(ids) - setPreferences({ docContextMode: 'comment' }) - commentActions.setMode({ - mode: 'list', - filter: (thread) => idSet.has(thread.id), - }) - } - }, - [commentState, commentActions, setPreferences] - ) - const changeEditorLayout = useCallback( (target: LayoutMode) => { setEditorLayout(target) @@ -855,18 +772,6 @@ const Editor = ({ } }, [focusEditorHeading]) - useEffect(() => { - if (realtime != null) { - realtime.doc.on('update', calculatePositions) - return () => realtime.doc.off('update', calculatePositions) - } - return undefined - }, [realtime, calculatePositions]) - - useEffect(() => { - calculatePositions() - }, [calculatePositions]) - useEffect(() => { if (!mountedRef.current) return if (docRef.current !== doc.id) { @@ -1159,15 +1064,6 @@ const Editor = ({ className='scroller' getEmbed={getEmbed} scrollerRef={previewRef} - comments={viewComments} - commentClick={commentClick} - SelectionMenu={({ selection }) => ( - -
newRangeThread(selection)}> - -
-
- )} /> @@ -1363,27 +1259,20 @@ const StyledPreview = styled.div` } ` -const StyledSelectionMenu = styled.div` - display: flex; - padding: 8px; - max-height: 37px; - cursor: pointer; -` - const StyledEditor = styled.div` display: flex; justify-content: center; flex-grow: 1; position: relative; top: 0; - bottom: 0px; + bottom: 0; width: 100%; height: auto; min-height: 0; font-size: 15px; &.preview, .preview { - ${rightSidePageLayout} + ${rightSidePageLayout}; margin: auto; } & .CodeMirrorWrapper { diff --git a/src/cloud/components/MarkdownView/CustomizedMarkdownPreviewer.tsx b/src/cloud/components/MarkdownView/CustomizedMarkdownPreviewer.tsx index d0a3f5ce55..e56042a796 100644 --- a/src/cloud/components/MarkdownView/CustomizedMarkdownPreviewer.tsx +++ b/src/cloud/components/MarkdownView/CustomizedMarkdownPreviewer.tsx @@ -2,7 +2,6 @@ import React from 'react' import MarkdownView, { SelectionState } from './index' import { usePreviewStyle } from '../../../lib/preview' import { EmbedDoc } from '../../lib/docEmbedPlugin' -import { HighlightRange } from '../../lib/rehypeHighlight' import { useSettings } from '../../lib/stores/settings' interface CustomizedMarkdownViewProps { @@ -20,8 +19,6 @@ interface CustomizedMarkdownViewProps { ) => Promise | EmbedDoc | undefined scrollerRef?: React.RefObject SelectionMenu?: React.ComponentType<{ selection: SelectionState['context'] }> - comments?: HighlightRange[] - commentClick?: (id: string[]) => void codeFence?: boolean previewStyle?: string } @@ -35,9 +32,6 @@ const CustomizedMarkdownPreviewer = ({ className, getEmbed, scrollerRef, - SelectionMenu, - comments, - commentClick, codeFence = true, }: CustomizedMarkdownViewProps) => { const { previewStyle } = usePreviewStyle() @@ -53,9 +47,6 @@ const CustomizedMarkdownPreviewer = ({ className={className} getEmbed={getEmbed} scrollerRef={scrollerRef} - SelectionMenu={SelectionMenu} - comments={comments} - commentClick={commentClick} codeFence={codeFence} previewStyle={previewStyle} codeBlockTheme={settings['general.codeBlockTheme']} diff --git a/src/cloud/components/MarkdownView/index.tsx b/src/cloud/components/MarkdownView/index.tsx index 01751d1165..3a84dfdc6d 100644 --- a/src/cloud/components/MarkdownView/index.tsx +++ b/src/cloud/components/MarkdownView/index.tsx @@ -35,11 +35,6 @@ import SelectionTooltip from './SelectionTooltip' import useSelectionLocation, { Rect, } from '../../lib/selection/useSelectionLocation' -import rehypeHighlight, { HighlightRange } from '../../lib/rehypeHighlight' -import rehypeGutters from '../../lib/rehypeGutters' -import { Node as UnistNode } from 'unist' -import { mdiCommentTextOutline } from '@mdi/js' -import Icon from '../../../design/components/atoms/Icon' import styled from '../../../design/lib/styled' import throttle from 'lodash.throttle' import CodeFence from '../../../design/components/atoms/markdown/CodeFence' @@ -117,8 +112,6 @@ interface MarkdownViewProps { ) => Promise | EmbedDoc | undefined scrollerRef?: React.RefObject SelectionMenu?: React.ComponentType<{ selection: SelectionState['context'] }> - comments?: HighlightRange[] - commentClick?: (id: string[]) => void codeFence?: boolean previewStyle?: string codeBlockTheme?: CodeMirrorEditorTheme @@ -134,8 +127,6 @@ const MarkdownView = ({ getEmbed, scrollerRef, SelectionMenu, - comments, - commentClick, codeFence = true, previewStyle, codeBlockTheme = 'default', @@ -250,19 +241,6 @@ const MarkdownView = ({ ) } : shortcodeHandler, - comment_count: (props: any) => { - return props.count != null && props.comments != null ? ( -
- commentClick && commentClick(props.comments.split(' ')) - } - > - {' '} - {props.count} -
- ) : null - }, }, } @@ -304,8 +282,6 @@ const MarkdownView = ({ theme: codeBlockTheme, }) .use(rehypeMermaid) - .use(rehypeHighlight, comments || []) - .use(rehypeGutters, makeCommentGutters(comments || [])) .use(rehypePosition) .use(rehypeReact, rehypeReactConfig) @@ -316,10 +292,8 @@ const MarkdownView = ({ headerLinks, getEmbed, codeBlockTheme, - comments, updateContent, content, - commentClick, ]) const processorRef = useRef(markdownProcessor) @@ -526,37 +500,4 @@ const StyledTooltipContent = styled.div` max-height: 50px; ` -function makeCommentGutters(highlights: HighlightRange[]) { - return (node: UnistNode): UnistNode | null => { - // todo: [komediruzecki-2021-11-20] End known to be null - if (node.position?.end == null) { - return null - } - const posStart = node.position?.start.offset - const posEnd = node.position?.end.offset - if (posStart != null && posEnd != null) { - const allHighlights = highlights.filter( - (highlight) => highlight.start >= posStart && highlight.start <= posEnd - ) - if (allHighlights.length > 0) { - return { - type: 'element', - tagName: 'div', - children: [ - { - type: 'element', - tagName: 'comment_count', - properties: { - count: allHighlights.length, - comments: allHighlights.map(({ id }) => id), - }, - }, - ], - } - } - } - return null - } -} - export default MarkdownView diff --git a/src/cloud/components/UserIcon.tsx b/src/cloud/components/UserIcon.tsx index ebdcff5cc5..078a1c8baf 100644 --- a/src/cloud/components/UserIcon.tsx +++ b/src/cloud/components/UserIcon.tsx @@ -25,7 +25,7 @@ const UserIcon = ({ user, style, className }: UserIconProps) => { export default UserIcon export const StyledUserIcon = styled.div` - ${userIconStyle} + ${userIconStyle}; width: 30px; height: 30px; border: 2px solid currentColor; diff --git a/src/cloud/lib/hooks/useCommentManagerState.ts b/src/cloud/lib/hooks/useCommentManagerState.ts index 3a55fa2da1..0d8d63e468 100644 --- a/src/cloud/lib/hooks/useCommentManagerState.ts +++ b/src/cloud/lib/hooks/useCommentManagerState.ts @@ -1,5 +1,5 @@ import { useComments } from '../stores/comments' -import { Thread, Comment } from '../../../cloud/interfaces/db/comments' +import { Thread, Comment } from '../../interfaces/db/comments' import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { State, @@ -54,7 +54,7 @@ function useCommentManagerState(docId: string): [State, Actions] { async (data) => { const thread = await threadActions.create({ doc: docId, ...data }) if (!(thread instanceof Error)) { - setMode({ mode: 'thread', thread }) + setMode({ mode: 'list' }) } return thread }, @@ -65,8 +65,6 @@ function useCommentManagerState(docId: string): [State, Actions] { return { setMode, createThread, - reopenThread: threadActions.reopen, - closeThread: threadActions.close, deleteThread: threadActions.delete, threadOutdated: threadActions.outdated, createComment: commentActions.create, diff --git a/src/cloud/lib/hooks/useThreadMenuActions.tsx b/src/cloud/lib/hooks/useThreadMenuActions.tsx index 740251f16b..1ba38d561c 100644 --- a/src/cloud/lib/hooks/useThreadMenuActions.tsx +++ b/src/cloud/lib/hooks/useThreadMenuActions.tsx @@ -1,29 +1,17 @@ import React, { useMemo } from 'react' import { Thread } from '../../interfaces/db/comments' import Icon from '../../../design/components/atoms/Icon' -import styled from '../../../design/lib/styled' import { MenuItem, MenuTypes } from '../../../design/lib/stores/contextMenu' -import { - mdiAlertCircleOutline, - mdiAlertCircleCheckOutline, - mdiTrashCanOutline, -} from '@mdi/js' +import { mdiTrashCanOutline } from '@mdi/js' import { useI18n } from './useI18n' import { lngKeys } from '../i18n/types' export interface ThreadActionProps { thread: Thread - onClose: (thread: Thread) => any - onOpen: (thread: Thread) => any onDelete: (thread: Thread) => any } -function useThreadActions({ - thread, - onClose, - onOpen, - onDelete, -}: ThreadActionProps) { +function useThreadActions({ thread, onDelete }: ThreadActionProps) { const { translate } = useI18n() const actions: MenuItem[] = useMemo(() => { const deleteAction: MenuItem = { @@ -33,40 +21,9 @@ function useThreadActions({ onClick: () => onDelete(thread), } - if (thread.status.type === 'outdated') { - return [deleteAction] - } - - return thread.status.type === 'closed' - ? [ - { - icon: , - type: MenuTypes.Normal, - label: translate(lngKeys.GeneralOpenVerb), - onClick: () => onOpen(thread), - }, - deleteAction, - ] - : [ - { - icon: , - type: MenuTypes.Normal, - label: translate(lngKeys.GeneralCloseVerb), - onClick: () => onClose(thread), - }, - deleteAction, - ] - }, [thread, onClose, onOpen, onDelete, translate]) - + return [deleteAction] + }, [onDelete, thread, translate]) return actions } -const SuccessIcon = styled(Icon)` - color: ${({ theme }) => theme.colors.variants.success.base}; -` - -const WarningIcon = styled(Icon)` - color: ${({ theme }) => theme.colors.variants.danger.base}; -` - export default useThreadActions diff --git a/src/cloud/lib/i18n/enUS.ts b/src/cloud/lib/i18n/enUS.ts index 0d15d7be84..7e81503060 100644 --- a/src/cloud/lib/i18n/enUS.ts +++ b/src/cloud/lib/i18n/enUS.ts @@ -377,7 +377,7 @@ const enTranslation: TranslationSource = { [lngKeys.ExpirationDate]: 'expiration date', [lngKeys.SeeFullHistory]: 'See full history', [lngKeys.SeeLimitedHistory]: 'See last {{days}} days', - [lngKeys.ThreadsTitle]: 'Threads', + [lngKeys.ThreadsTitle]: 'Comments', [lngKeys.ThreadPost]: 'Post', [lngKeys.ThreadFullDocLabel]: 'Full doc thread', [lngKeys.ThreadCreate]: 'Create a new thread', diff --git a/src/mobile/components/pages/DocViewPage.tsx b/src/mobile/components/pages/DocViewPage.tsx index b6ca2a7b42..135448ab71 100644 --- a/src/mobile/components/pages/DocViewPage.tsx +++ b/src/mobile/components/pages/DocViewPage.tsx @@ -3,14 +3,10 @@ import { SerializedDocWithSupplemental, SerializedDoc, } from '../../../cloud/interfaces/db/doc' -import { usePreferences } from '../../lib/preferences' import { SerializedUser } from '../../../cloud/interfaces/db/user' import useRealtime from '../../../cloud/lib/editor/hooks/useRealtime' import { buildIconUrl } from '../../../cloud/api/files' import { getColorFromString } from '../../../cloud/lib/utils/string' -import { createAbsolutePositionFromRelativePosition } from 'yjs' -import useCommentManagerState from '../../../cloud/lib/hooks/useCommentManagerState' -import { HighlightRange } from '../../../cloud/lib/rehypeHighlight' import Spinner from '../../../design/components/atoms/Spinner' import AppLayout from '../layouts/AppLayout' import NavigationBarButton from '../atoms/NavigationBarButton' @@ -39,7 +35,6 @@ const ViewPage = ({ contributors, backLinks, }: ViewPageProps) => { - const { setPreferences } = usePreferences() const { openModal } = useModal() const initialRenderDone = useRef(false) const previewRef = useRef(null) @@ -72,53 +67,6 @@ const ViewPage = ({ } }) - const [commentState, commentActions] = useCommentManagerState(doc.id) - - const [viewComments, setViewComments] = useState([]) - const calculatePositions = useCallback(() => { - if (commentState.mode === 'list_loading' || realtime == null) { - return - } - - const comments: HighlightRange[] = [] - for (const thread of commentState.threads) { - if (thread.selection != null && thread.status.type !== 'outdated') { - const absoluteAnchor = createAbsolutePositionFromRelativePosition( - thread.selection.anchor, - realtime.doc - ) - const absoluteHead = createAbsolutePositionFromRelativePosition( - thread.selection.head, - realtime.doc - ) - - if ( - absoluteAnchor != null && - absoluteHead != null && - absoluteAnchor.index !== absoluteHead.index - ) { - if (thread.status.type === 'open') { - comments.push({ - id: thread.id, - start: absoluteAnchor.index, - end: absoluteHead.index, - active: - commentState.mode === 'thread' && - thread.id === commentState.thread.id, - }) - } - } else if (connState === 'synced') { - commentActions.threadOutdated(thread) - } - } - } - setViewComments(comments) - }, [commentState, realtime, commentActions, connState]) - - useEffect(() => { - calculatePositions() - }, [calculatePositions]) - const updateContent = useCallback(() => { if (realtime == null) { return @@ -133,31 +81,15 @@ const ViewPage = ({ useEffect(() => { if (realtime != null) { realtime.doc.on('update', () => { - calculatePositions() updateContent() }) return () => realtime.doc.off('update', () => { - calculatePositions updateContent() }) } return undefined - }, [realtime, calculatePositions, updateContent]) - - const commentClick = useCallback( - (ids: string[]) => { - if (commentState.mode !== 'list_loading') { - const idSet = new Set(ids) - setPreferences({ docContextMode: 'comment' }) - commentActions.setMode({ - mode: 'list', - filter: (thread) => idSet.has(thread.id), - }) - } - }, - [commentState, commentActions, setPreferences] - ) + }, [realtime, updateContent]) useEffect(() => { if (connState === 'synced' || connState === 'loaded') { @@ -210,8 +142,6 @@ const ViewPage = ({ onRender={onRender.current} className='scroller' scrollerRef={previewRef} - comments={viewComments} - commentClick={commentClick} /> ) : ( <> @@ -227,23 +157,6 @@ const ViewPage = ({ ) } -const StyledLoadingView = styled.div` - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - text-align: center; - & span { - width: 100%; - height: 38px; - position: relative; - } -` - -const StyledPlaceholderContent = styled.div` - color: ${({ theme }) => theme.colors.text.subtle}; -` - const Container = styled.div` margin: 0; padding: 0; @@ -270,17 +183,17 @@ const Container = styled.div` ${({ theme }) => theme.sizes.spaces.xl}px ); font-size: 15px; - ${rightSidePageLayout} + ${rightSidePageLayout}; margin: auto; padding: 0 ${({ theme }) => theme.sizes.spaces.xl}px; } .view__content { height: 100%; - width: 50%; + width: 100%; padding-top: ${({ theme }) => theme.sizes.spaces.sm}px; + margin: 0 auto; - width: 100%; & .inline-comment.active, .inline-comment.hv-active { @@ -289,4 +202,21 @@ const Container = styled.div` } ` +const StyledLoadingView = styled.div` + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + text-align: center; + & span { + width: 100%; + height: 38px; + position: relative; + } +` + +const StyledPlaceholderContent = styled.div` + color: ${({ theme }) => theme.colors.text.subtle}; +` + export default ViewPage