From c73874fb120fea9322b95063a94a841948327781 Mon Sep 17 00:00:00 2001 From: Federico Ruggi <1081051+ruggi@users.noreply.github.com> Date: Mon, 4 Dec 2023 12:18:17 +0100 Subject: [PATCH] basic comments resolution (#4594) --- editor/liveblocks.config.ts | 2 +- .../canvas/controls/comment-indicator.tsx | 8 +- .../canvas/controls/comment-popup.tsx | 2 + editor/src/components/editor/action-types.ts | 6 + .../editor/actions/action-creators.ts | 8 ++ .../components/editor/actions/action-utils.ts | 1 + .../src/components/editor/actions/actions.tsx | 5 + .../components/editor/store/editor-state.ts | 5 + .../components/editor/store/editor-update.tsx | 2 + .../store/store-deep-equality-instances.ts | 9 +- .../store/store-hook-substore-helpers.ts | 1 + .../inspector/sections/comment-section.tsx | 118 ++++++++++++++---- editor/src/core/commenting/comment-hooks.tsx | 53 +++++++- 13 files changed, 191 insertions(+), 29 deletions(-) diff --git a/editor/liveblocks.config.ts b/editor/liveblocks.config.ts index 24e45183f589..6c8029661dba 100644 --- a/editor/liveblocks.config.ts +++ b/editor/liveblocks.config.ts @@ -78,7 +78,6 @@ export type RoomEvent = { // Optionally, when using Comments, ThreadMetadata represents metadata on // each thread. Can only contain booleans, strings, and numbers. export type ThreadMetadata = { - // resolved: boolean; // quote: string; // time: number; type: 'canvas' @@ -86,6 +85,7 @@ export type ThreadMetadata = { y: number sceneId?: string remixLocationRoute?: string + resolved: boolean } export const { diff --git a/editor/src/components/canvas/controls/comment-indicator.tsx b/editor/src/components/canvas/controls/comment-indicator.tsx index 87a449a2a87a..f348ab0b045f 100644 --- a/editor/src/components/canvas/controls/comment-indicator.tsx +++ b/editor/src/components/canvas/controls/comment-indicator.tsx @@ -5,10 +5,11 @@ import type { ThreadData } from '@liveblocks/client' import { useAtom } from 'jotai' import React from 'react' import type { ThreadMetadata } from '../../../../liveblocks.config' -import { useEditThreadMetadata, useStorage, useThreads } from '../../../../liveblocks.config' +import { useEditThreadMetadata, useStorage } from '../../../../liveblocks.config' import { useCanvasLocationOfThread, useIsOnAnotherRemixRoute, + useActiveThreads, } from '../../../core/commenting/comment-hooks' import type { CanvasPoint, CanvasVector, WindowPoint } from '../../../core/shared/math-utils' import { @@ -55,7 +56,7 @@ export const CommentIndicators = React.memo(() => { CommentIndicators.displayName = 'CommentIndicators' const CommentIndicatorsInner = React.memo(() => { - const { threads } = useThreads() + const threads = useActiveThreads() return ( @@ -138,7 +139,8 @@ const CommentIndicator = React.memo(({ thread }: CommentIndicatorProps) => { position: 'fixed', top: position.y, left: position.x, - opacity: isOnAnotherRoute ? 0.25 : 1, + opacity: isOnAnotherRoute || thread.metadata.resolved ? 0.25 : 1, + filter: thread.metadata.resolved ? 'grayscale(1)' : undefined, width: IndicatorSize, '&:hover': { transform: 'scale(1.15)', diff --git a/editor/src/components/canvas/controls/comment-popup.tsx b/editor/src/components/canvas/controls/comment-popup.tsx index 62ba7815c256..609f259d4848 100644 --- a/editor/src/components/canvas/controls/comment-popup.tsx +++ b/editor/src/components/canvas/controls/comment-popup.tsx @@ -76,6 +76,7 @@ const CommentThread = React.memo(({ comment }: CommentThreadProps) => { return createThread({ body, metadata: { + resolved: false, type: 'canvas', x: comment.location.position.x, y: comment.location.position.y, @@ -86,6 +87,7 @@ const CommentThread = React.memo(({ comment }: CommentThreadProps) => { return createThread({ body, metadata: { + resolved: false, type: 'canvas', x: comment.location.offset.x, y: comment.location.offset.y, diff --git a/editor/src/components/editor/action-types.ts b/editor/src/components/editor/action-types.ts index 40f1fe2bed89..f432514dd36a 100644 --- a/editor/src/components/editor/action-types.ts +++ b/editor/src/components/editor/action-types.ts @@ -1077,6 +1077,11 @@ export interface UpdateImportsFromCollaborationUpdate { imports: MapLike } +export interface SetShowResolvedThreads { + action: 'SET_SHOW_RESOLVED_THREADS' + showResolvedThreads: boolean +} + export type EditorAction = | ClearSelection | InsertJSXElement @@ -1251,6 +1256,7 @@ export type EditorAction = | UpdateTopLevelElementsFromCollaborationUpdate | UpdateExportsDetailFromCollaborationUpdate | UpdateImportsFromCollaborationUpdate + | SetShowResolvedThreads export type DispatchPriority = | 'everyone' diff --git a/editor/src/components/editor/actions/action-creators.ts b/editor/src/components/editor/actions/action-creators.ts index 369d67353a5e..6166d92eb5c9 100644 --- a/editor/src/components/editor/actions/action-creators.ts +++ b/editor/src/components/editor/actions/action-creators.ts @@ -225,6 +225,7 @@ import type { DeleteFileFromCollaboration, UpdateExportsDetailFromCollaborationUpdate, UpdateImportsFromCollaborationUpdate, + SetShowResolvedThreads, } from '../action-types' import type { InsertionSubjectWrapper, Mode } from '../editor-modes' import { EditorModes, insertionSubject } from '../editor-modes' @@ -1707,3 +1708,10 @@ export function updateImportsFromCollaborationUpdate( imports: imports, } } + +export function setShowResolvedThreads(showResolvedThreads: boolean): SetShowResolvedThreads { + return { + action: 'SET_SHOW_RESOLVED_THREADS', + showResolvedThreads: showResolvedThreads, + } +} diff --git a/editor/src/components/editor/actions/action-utils.ts b/editor/src/components/editor/actions/action-utils.ts index 6e7942144bf8..0039e723d117 100644 --- a/editor/src/components/editor/actions/action-utils.ts +++ b/editor/src/components/editor/actions/action-utils.ts @@ -130,6 +130,7 @@ export function isTransientAction(action: EditorAction): boolean { case 'START_POST_ACTION_SESSION': case 'TRUNCATE_HISTORY': case 'UPDATE_PROJECT_SERVER_STATE': + case 'SET_SHOW_RESOLVED_THREADS': return true case 'TRUE_UP_ELEMENTS': diff --git a/editor/src/components/editor/actions/actions.tsx b/editor/src/components/editor/actions/actions.tsx index 4adf961d68b0..bf36dea05b32 100644 --- a/editor/src/components/editor/actions/actions.tsx +++ b/editor/src/components/editor/actions/actions.tsx @@ -317,6 +317,7 @@ import type { DeleteFileFromCollaboration, UpdateExportsDetailFromCollaborationUpdate, UpdateImportsFromCollaborationUpdate, + SetShowResolvedThreads, } from '../action-types' import { isLoggedIn } from '../action-types' import type { Mode } from '../editor-modes' @@ -920,6 +921,7 @@ export function restoreEditorState( internalClipboard: currentEditor.internalClipboard, filesModifiedByAnotherUser: currentEditor.filesModifiedByAnotherUser, activeFrames: currentEditor.activeFrames, + showResolvedThreads: currentEditor.showResolvedThreads, } } @@ -5527,6 +5529,9 @@ export const UPDATE_FNS = { } } }, + SET_SHOW_RESOLVED_THREADS: (action: SetShowResolvedThreads, editor: EditorModel): EditorModel => { + return { ...editor, showResolvedThreads: action.showResolvedThreads } + }, } function copySelectionToClipboardMutating( diff --git a/editor/src/components/editor/store/editor-state.ts b/editor/src/components/editor/store/editor-state.ts index c6cab6fe2591..30909a50f519 100644 --- a/editor/src/components/editor/store/editor-state.ts +++ b/editor/src/components/editor/store/editor-state.ts @@ -1479,6 +1479,7 @@ export interface EditorState { internalClipboard: InternalClipboard filesModifiedByAnotherUser: Array activeFrames: ActiveFrame[] + showResolvedThreads: boolean } export function editorState( @@ -1561,6 +1562,7 @@ export function editorState( internalClipboardData: InternalClipboard, filesModifiedByAnotherUser: Array, activeFrames: ActiveFrame[], + showResolvedThreads: boolean, ): EditorState { return { id: id, @@ -1642,6 +1644,7 @@ export function editorState( internalClipboard: internalClipboardData, filesModifiedByAnotherUser: filesModifiedByAnotherUser, activeFrames: activeFrames, + showResolvedThreads: showResolvedThreads, } } @@ -2518,6 +2521,7 @@ export function createEditorState(dispatch: EditorDispatch): EditorState { }, filesModifiedByAnotherUser: [], activeFrames: [], + showResolvedThreads: false, } } @@ -2894,6 +2898,7 @@ export function editorModelFromPersistentModel( }, filesModifiedByAnotherUser: [], activeFrames: [], + showResolvedThreads: false, } return editor } diff --git a/editor/src/components/editor/store/editor-update.tsx b/editor/src/components/editor/store/editor-update.tsx index ada97af6568d..916e7b6ba9cc 100644 --- a/editor/src/components/editor/store/editor-update.tsx +++ b/editor/src/components/editor/store/editor-update.tsx @@ -399,6 +399,8 @@ export function runSimpleLocalEditorAction( return UPDATE_FNS.UPDATE_EXPORTS_DETAIL_FROM_COLLABORATION_UPDATE(action, state, serverState) case 'UPDATE_IMPORTS_FROM_COLLABORATION_UPDATE': return UPDATE_FNS.UPDATE_IMPORTS_FROM_COLLABORATION_UPDATE(action, state, serverState) + case 'SET_SHOW_RESOLVED_THREADS': + return UPDATE_FNS.SET_SHOW_RESOLVED_THREADS(action, state) default: return state } diff --git a/editor/src/components/editor/store/store-deep-equality-instances.ts b/editor/src/components/editor/store/store-deep-equality-instances.ts index 6eed70c7d434..91b11487df15 100644 --- a/editor/src/components/editor/store/store-deep-equality-instances.ts +++ b/editor/src/components/editor/store/store-deep-equality-instances.ts @@ -4570,6 +4570,11 @@ export const EditorStateKeepDeepEquality: KeepDeepEqualityCall = ( newValue.activeFrames, ) + const showResolvedThreadsResults = BooleanKeepDeepEquality( + oldValue.showResolvedThreads, + newValue.showResolvedThreads, + ) + const areEqual = idResult.areEqual && vscodeBridgeIdResult.areEqual && @@ -4648,7 +4653,8 @@ export const EditorStateKeepDeepEquality: KeepDeepEqualityCall = ( colorSwatchesResults.areEqual && internalClipboardResults.areEqual && filesModifiedByAnotherUserResults.areEqual && - activeFramesResults.areEqual + activeFramesResults.areEqual && + showResolvedThreadsResults.areEqual if (areEqual) { return keepDeepEqualityResult(oldValue, true) @@ -4733,6 +4739,7 @@ export const EditorStateKeepDeepEquality: KeepDeepEqualityCall = ( internalClipboardResults.value, filesModifiedByAnotherUserResults.value, activeFramesResults.value, + showResolvedThreadsResults.value, ) return keepDeepEqualityResult(newEditorState, false) diff --git a/editor/src/components/editor/store/store-hook-substore-helpers.ts b/editor/src/components/editor/store/store-hook-substore-helpers.ts index fc620726cd1b..47d99a50a53f 100644 --- a/editor/src/components/editor/store/store-hook-substore-helpers.ts +++ b/editor/src/components/editor/store/store-hook-substore-helpers.ts @@ -173,4 +173,5 @@ export const EmptyEditorStateForKeysOnly: EditorState = { }, filesModifiedByAnotherUser: [], activeFrames: [], + showResolvedThreads: false, } diff --git a/editor/src/components/inspector/sections/comment-section.tsx b/editor/src/components/inspector/sections/comment-section.tsx index 15a5cadb6d39..237eaeab526d 100644 --- a/editor/src/components/inspector/sections/comment-section.tsx +++ b/editor/src/components/inspector/sections/comment-section.tsx @@ -1,14 +1,23 @@ import '@liveblocks/react-comments/styles.css' import React from 'react' -import { FlexColumn, FlexRow, InspectorSubsectionHeader, useColorTheme } from '../../../uuiui' +import { + Button, + FlexColumn, + FlexRow, + InspectorSubsectionHeader, + useColorTheme, +} from '../../../uuiui' import { Comment } from '@liveblocks/react-comments' import { stopPropagation } from '../common/inspector-utils' import type { ThreadMetadata } from '../../../../liveblocks.config' -import { useThreads } from '../../../../liveblocks.config' import type { ThreadData } from '@liveblocks/client' import { useDispatch } from '../../editor/store/dispatch-context' import { canvasPoint, canvasRectangle } from '../../../core/shared/math-utils' -import { scrollToPosition, switchEditorMode } from '../../editor/actions/action-creators' +import { + scrollToPosition, + setShowResolvedThreads, + switchEditorMode, +} from '../../editor/actions/action-creators' import { EditorModes, existingComment, @@ -18,9 +27,14 @@ import { import { MultiplayerWrapper } from '../../../utils/multiplayer-wrapper' import { useAtom } from 'jotai' import { RemixNavigationAtom } from '../../canvas/remix/utopia-remix-root-component' -import { useIsOnAnotherRemixRoute } from '../../../core/commenting/comment-hooks' +import { + useUnresolvedThreads, + useIsOnAnotherRemixRoute, + useResolveThread, + useResolvedThreads, +} from '../../../core/commenting/comment-hooks' import { Substores, useEditorState } from '../../editor/store/store-hook' -import { when } from '../../../utils/react-conditionals' +import { unless, when } from '../../../utils/react-conditionals' export const CommentSection = React.memo(() => { return ( @@ -45,13 +59,43 @@ export const CommentSection = React.memo(() => { CommentSection.displayName = 'CommentSection' const ThreadPreviews = React.memo(() => { - const { threads } = useThreads() + const dispatch = useDispatch() + const { threads: activeThreads } = useUnresolvedThreads() + const { threads: resolvedThreads } = useResolvedThreads() + + const showResolved = useEditorState( + Substores.restOfEditor, + (store) => store.editor.showResolvedThreads, + 'ThreadPreviews showResolvedThreads', + ) + + const toggleShowResolved = React.useCallback(() => { + dispatch([ + setShowResolvedThreads(!showResolved), + switchEditorMode(EditorModes.selectMode(null, false, 'none')), + ]) + }, [showResolved, dispatch]) return ( - {threads.map((thread) => ( + {activeThreads.map((thread) => ( ))} + {when( + resolvedThreads.length > 0, + , + )} + {when( + showResolved, + resolvedThreads.map((thread) => ), + )} ) }) @@ -100,6 +144,17 @@ const ThreadPreview = React.memo(({ thread }: ThreadPreviewProps) => { ]) }, [dispatch, remixNavigationState, isOnAnotherRoute, remixLocationRoute, point, thread.id]) + const resolveThread = useResolveThread() + + const onResolveThread = React.useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + resolveThread(thread) + dispatch([switchEditorMode(EditorModes.selectMode(null, false, 'none'))]) + }, + [resolveThread, dispatch, thread], + ) + const comment = thread.comments[0] if (comment == null) { return null @@ -110,23 +165,42 @@ const ThreadPreview = React.memo(({ thread }: ThreadPreviewProps) => {
- {when( - repliesCount > 0, -
- {repliesCount} {repliesCount > 1 ? 'replies' : 'reply'} -
, - )} +
+ {when( + repliesCount > 0, +
+ {repliesCount} {repliesCount > 1 ? 'replies' : 'reply'} +
, + )} + {unless(repliesCount > 0,
)} + +
) }) diff --git a/editor/src/core/commenting/comment-hooks.tsx b/editor/src/core/commenting/comment-hooks.tsx index 8bb659746afc..5e2ad72841d9 100644 --- a/editor/src/core/commenting/comment-hooks.tsx +++ b/editor/src/core/commenting/comment-hooks.tsx @@ -2,7 +2,13 @@ import React from 'react' import type { User } from '@liveblocks/client' import { LiveObject, type ThreadData } from '@liveblocks/client' import type { Presence, ThreadMetadata, UserMeta } from '../../../liveblocks.config' -import { useMutation, useSelf, useStorage, useThreads } from '../../../liveblocks.config' +import { + useEditThreadMetadata, + useMutation, + useSelf, + useStorage, + useThreads, +} from '../../../liveblocks.config' import { Substores, useEditorState } from '../../components/editor/store/store-hook' import { normalizeMultiplayerName, possiblyUniqueColor } from '../shared/multiplayer' import { isLoggedIn } from '../../common/user' @@ -23,7 +29,7 @@ export function useCanvasCommentThreadAndLocation(comment: CommentId): { location: CanvasPoint | null thread: ThreadData | null } { - const { threads } = useThreads() + const threads = useActiveThreads() const thread = React.useMemo(() => { switch (comment.type) { @@ -200,3 +206,46 @@ export function useCanvasLocationOfThread(thread: ThreadData): C } return getCanvasPointWithCanvasOffset(scene.globalFrame, localPoint(thread.metadata)) } + +export function useResolveThread() { + const editThreadMetadata = useEditThreadMetadata() + + const resolveThread = React.useCallback( + (thread: ThreadData) => { + editThreadMetadata({ threadId: thread.id, metadata: { resolved: !thread.metadata.resolved } }) + }, + [editThreadMetadata], + ) + + return resolveThread +} + +export function useActiveThreads() { + const { threads: unresolvedThreads } = useUnresolvedThreads() + const { threads: resolvedThreads } = useResolvedThreads() + const showResolved = useEditorState( + Substores.restOfEditor, + (store) => store.editor.showResolvedThreads, + 'useActiveThreads showResolved', + ) + if (!showResolved) { + return unresolvedThreads + } + return [...unresolvedThreads, ...resolvedThreads] +} + +export function useResolvedThreads() { + const threads = useThreads() + return { + ...threads, + threads: threads.threads.filter((t) => t.metadata.resolved === true), + } +} + +export function useUnresolvedThreads() { + const threads = useThreads() + return { + ...threads, + threads: threads.threads.filter((t) => t.metadata.resolved !== true), + } +}