b6c21fd506
* webhooks perm minor fix * tryna get fileimport service to work * new comment policies - shared * BE done? * checks implemented in FE * lint fix * tests fix * readme fix
399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
import type { ApolloCache } from '@apollo/client/cache'
|
|
import type { JSONContent } from '@tiptap/core'
|
|
import { useApolloClient, useSubscription } from '@vue/apollo-composable'
|
|
import type { MaybeRef } from '@vueuse/core'
|
|
import dayjs from 'dayjs'
|
|
import type { Get } from 'type-fest'
|
|
import { useActiveUser } from '~~/lib/auth/composables/activeUser'
|
|
import type {
|
|
ArchiveCommentInput,
|
|
CommentContentInput,
|
|
CreateCommentReplyInput,
|
|
OnViewerCommentsUpdatedSubscription,
|
|
ViewerResourceItem
|
|
} from '~~/lib/common/generated/gql/graphql'
|
|
import {
|
|
convertThrowIntoFetchResult,
|
|
getCacheId,
|
|
getFirstErrorMessage
|
|
} from '~~/lib/common/helpers/graphql'
|
|
import {
|
|
archiveCommentMutation,
|
|
createCommentReplyMutation,
|
|
createCommentThreadMutation,
|
|
markCommentViewedMutation
|
|
} from '~~/lib/viewer/graphql/mutations'
|
|
import { onViewerCommentsUpdatedSubscription } from '~~/lib/viewer/graphql/subscriptions'
|
|
import {
|
|
useInjectedViewerState,
|
|
type LoadedCommentThread
|
|
} from '~~/lib/viewer/composables/setup'
|
|
import type { MaybeNullOrUndefined, SpeckleViewer } from '@speckle/shared'
|
|
import { ToastNotificationType, useGlobalToast } from '~~/lib/common/composables/toast'
|
|
import type { SuccessfullyUploadedFileItem } from '~~/lib/core/api/blobStorage'
|
|
import { isValidCommentContentInput } from '~~/lib/viewer/helpers/comments'
|
|
import {
|
|
useStateSerialization,
|
|
useApplySerializedState,
|
|
StateApplyMode
|
|
} from '~~/lib/viewer/composables/serialization'
|
|
import type { CommentBubbleModel } from '~/lib/viewer/composables/commentBubbles'
|
|
import { graphql } from '~/lib/common/generated/gql'
|
|
|
|
export function useViewerCommentUpdateTracking(
|
|
params: {
|
|
projectId: MaybeRef<string>
|
|
resourceIdString: MaybeRef<string>
|
|
loadedVersionsOnly?: MaybeRef<MaybeNullOrUndefined<boolean>>
|
|
},
|
|
handler?: (
|
|
data: NonNullable<
|
|
Get<OnViewerCommentsUpdatedSubscription, 'projectCommentsUpdated'>
|
|
>,
|
|
cache: ApolloCache<unknown>
|
|
) => void
|
|
) {
|
|
const apollo = useApolloClient().client
|
|
const { onResult: onViewerCommentUpdated } = useSubscription(
|
|
onViewerCommentsUpdatedSubscription,
|
|
() => ({
|
|
target: {
|
|
projectId: unref(params.projectId),
|
|
resourceIdString: unref(params.resourceIdString),
|
|
loadedVersionsOnly: unref(params.loadedVersionsOnly)
|
|
}
|
|
})
|
|
)
|
|
|
|
onViewerCommentUpdated((res) => {
|
|
if (!res.data?.projectCommentsUpdated) return
|
|
|
|
const event = res.data.projectCommentsUpdated
|
|
const cache = apollo.cache
|
|
|
|
handler?.(event, cache)
|
|
})
|
|
}
|
|
|
|
export function useMarkThreadViewed() {
|
|
const apollo = useApolloClient().client
|
|
const { isLoggedIn } = useActiveUser()
|
|
const logger = useLogger()
|
|
|
|
return async (projectId: string, threadId: string) => {
|
|
if (!isLoggedIn.value) return false
|
|
const { data, errors } = await apollo
|
|
.mutate({
|
|
mutation: markCommentViewedMutation,
|
|
variables: {
|
|
input: {
|
|
projectId,
|
|
commentId: threadId
|
|
}
|
|
},
|
|
update: (cache, { data }) => {
|
|
if (!data?.commentMutations.markViewed) return
|
|
|
|
cache.modify({
|
|
id: getCacheId('Comment', threadId),
|
|
fields: {
|
|
viewedAt: () => dayjs().toISOString()
|
|
}
|
|
})
|
|
}
|
|
})
|
|
.catch(convertThrowIntoFetchResult)
|
|
|
|
if (errors) {
|
|
logger.error('Marking thread as viewed failed', errors)
|
|
}
|
|
|
|
return !!data?.commentMutations.markViewed
|
|
}
|
|
}
|
|
|
|
export type CommentEditorValue = {
|
|
doc?: JSONContent | null
|
|
attachments?: SuccessfullyUploadedFileItem[] | null
|
|
}
|
|
|
|
export function useSubmitComment() {
|
|
const {
|
|
projectId,
|
|
resources: {
|
|
request: { resourceIdString }
|
|
},
|
|
viewer: { instance: viewerInstance }
|
|
} = useInjectedViewerState()
|
|
const { isLoggedIn } = useActiveUser()
|
|
const client = useApolloClient().client
|
|
const { triggerNotification } = useGlobalToast()
|
|
const { serialize } = useStateSerialization()
|
|
|
|
return async (content: CommentContentInput) => {
|
|
if (!isLoggedIn.value) return null
|
|
if (!isValidCommentContentInput(content)) return null
|
|
const screenshot = await viewerInstance.screenshot()
|
|
|
|
const { data, errors } = await client
|
|
.mutate({
|
|
mutation: createCommentThreadMutation,
|
|
variables: {
|
|
input: {
|
|
projectId: projectId.value,
|
|
resourceIdString: resourceIdString.value,
|
|
content,
|
|
viewerState: serialize({ concreteResourceIdString: true }),
|
|
screenshot
|
|
}
|
|
}
|
|
})
|
|
.catch(convertThrowIntoFetchResult)
|
|
|
|
if (data?.commentMutations.create) {
|
|
return data.commentMutations.create
|
|
}
|
|
|
|
const errMsg = getFirstErrorMessage(errors)
|
|
triggerNotification({
|
|
type: ToastNotificationType.Danger,
|
|
title: 'Comment creation failed',
|
|
description: errMsg
|
|
})
|
|
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function useSubmitReply() {
|
|
const { isLoggedIn } = useActiveUser()
|
|
const client = useApolloClient().client
|
|
const { triggerNotification } = useGlobalToast()
|
|
|
|
return async (input: CreateCommentReplyInput) => {
|
|
if (!isLoggedIn.value) return null
|
|
if (!isValidCommentContentInput(input.content)) return null
|
|
|
|
const { data, errors } = await client
|
|
.mutate({
|
|
mutation: createCommentReplyMutation,
|
|
variables: {
|
|
input
|
|
}
|
|
})
|
|
.catch(convertThrowIntoFetchResult)
|
|
|
|
if (data?.commentMutations.reply) {
|
|
return data.commentMutations.reply
|
|
}
|
|
|
|
const errMsg = getFirstErrorMessage(errors)
|
|
triggerNotification({
|
|
type: ToastNotificationType.Danger,
|
|
title: 'Reply creation failed',
|
|
description: errMsg
|
|
})
|
|
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function useArchiveComment() {
|
|
const { isLoggedIn } = useActiveUser()
|
|
const client = useApolloClient().client
|
|
const { triggerNotification } = useGlobalToast()
|
|
|
|
return async (input: ArchiveCommentInput) => {
|
|
const { commentId, projectId } = input
|
|
|
|
if (!isLoggedIn.value || !commentId || !projectId) return false
|
|
|
|
const { data, errors } = await client
|
|
.mutate({
|
|
mutation: archiveCommentMutation,
|
|
variables: {
|
|
input
|
|
}
|
|
})
|
|
.catch(convertThrowIntoFetchResult)
|
|
|
|
if (data?.commentMutations.archive) return true
|
|
|
|
const errMsg = getFirstErrorMessage(errors)
|
|
triggerNotification({
|
|
type: ToastNotificationType.Danger,
|
|
title: 'Comment archival failed',
|
|
description: errMsg
|
|
})
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
graphql(`
|
|
fragment UseCheckViewerCommentingAccess_Project on Project {
|
|
id
|
|
permissions {
|
|
canCreateComment {
|
|
...FullPermissionCheckResult
|
|
}
|
|
}
|
|
}
|
|
`)
|
|
|
|
export function useCheckViewerCommentingAccess() {
|
|
const {
|
|
resources: {
|
|
response: { project }
|
|
}
|
|
} = useInjectedViewerState()
|
|
|
|
return computed(() => {
|
|
return project.value?.permissions.canCreateComment.authorized
|
|
})
|
|
}
|
|
|
|
const useActiveThreadContext = () => {
|
|
type ThreadContext = {
|
|
threadId: string | null
|
|
previousState: SpeckleViewer.ViewerState.SerializedViewerState | null
|
|
}
|
|
return useState<ThreadContext>('thread-context', () => ({
|
|
threadId: null,
|
|
previousState: null
|
|
}))
|
|
}
|
|
|
|
export const useCommentContext = () => {
|
|
const applyState = useApplySerializedState()
|
|
const { serialize } = useStateSerialization()
|
|
const state = useInjectedViewerState()
|
|
const threadContext = useActiveThreadContext()
|
|
|
|
const thread = computed(() => state.ui.threads.openThread.thread.value)
|
|
|
|
const calculateThreadResourceStatus = (
|
|
threadData: LoadedCommentThread | CommentBubbleModel | null | undefined
|
|
) => {
|
|
if (!threadData) return { isLoaded: false }
|
|
const loadedResources = state.resources.response.resourceItems.value
|
|
const resourceLinks = threadData?.resources
|
|
|
|
if (!resourceLinks) {
|
|
return { isLoaded: false }
|
|
}
|
|
|
|
// Check if any of the thread's objects are loaded
|
|
const objectLinks = resourceLinks
|
|
.filter((l) => l.resourceType === 'object')
|
|
.map((l) => l.resourceId)
|
|
const commitLinks = resourceLinks
|
|
.filter((l) => l.resourceType === 'commit')
|
|
.map((l) => l.resourceId)
|
|
|
|
// Check if ALL of the thread's objects are loaded
|
|
const hasLoadedObjects =
|
|
objectLinks.length > 0 &&
|
|
objectLinks.every((objId) => loadedResources.some((lr) => lr.objectId === objId))
|
|
|
|
// Check if ALL of the thread's commits are loaded
|
|
const hasLoadedVersions =
|
|
commitLinks.length > 0 &&
|
|
commitLinks.every((commitId) =>
|
|
loadedResources.some((lr) => lr.versionId && lr.versionId === commitId)
|
|
)
|
|
|
|
// Resource is loaded, check versions and federation
|
|
const currentModels = state.resources.response.modelsAndVersionIds.value
|
|
const threadModels = threadData.viewerResources.filter(
|
|
(r): r is ViewerResourceItem & { modelId: string; versionId: string } =>
|
|
r.modelId !== null && r.versionId !== null
|
|
)
|
|
|
|
// Check if any thread models are not in current view (federated)
|
|
const hasFederatedModels = threadModels.some(
|
|
(threadModel) => !currentModels.some((m) => m.model.id === threadModel.modelId)
|
|
)
|
|
|
|
// For models that exist in both states, check version differences
|
|
const hasDifferentVersions = threadModels.some((threadModel) => {
|
|
const currentModel = currentModels.find((m) => m.model.id === threadModel.modelId)
|
|
return currentModel && currentModel.versionId !== threadModel.versionId
|
|
})
|
|
|
|
return {
|
|
isLoaded: hasLoadedObjects || hasLoadedVersions,
|
|
isDifferentVersion: hasDifferentVersions,
|
|
isFederatedModel: hasFederatedModels
|
|
}
|
|
}
|
|
|
|
const threadResourceStatus = computed(() =>
|
|
calculateThreadResourceStatus(thread.value)
|
|
)
|
|
|
|
const hasClickedFullContext = computed(() => {
|
|
const threadId = thread.value?.id
|
|
return threadContext.value.threadId === threadId
|
|
})
|
|
|
|
const loadContext = async (
|
|
mode: StateApplyMode.ThreadFullContextOpen | StateApplyMode.FederatedContext
|
|
) => {
|
|
const state = thread.value?.viewerState
|
|
const threadId = thread.value?.id ?? null
|
|
if (!state) return
|
|
|
|
// Store both current state and thread ID
|
|
threadContext.value = {
|
|
threadId,
|
|
previousState: serialize()
|
|
}
|
|
|
|
await applyState(state, mode)
|
|
}
|
|
|
|
const loadThreadVersionContext = () =>
|
|
loadContext(StateApplyMode.ThreadFullContextOpen)
|
|
const loadFederatedContext = () => loadContext(StateApplyMode.FederatedContext)
|
|
|
|
const handleContextClick = () => {
|
|
if (threadResourceStatus.value.isDifferentVersion) {
|
|
loadThreadVersionContext()
|
|
} else {
|
|
loadFederatedContext()
|
|
}
|
|
}
|
|
|
|
const goBack = async () => {
|
|
if (!threadContext.value.previousState) {
|
|
return
|
|
}
|
|
|
|
await applyState(
|
|
threadContext.value.previousState,
|
|
StateApplyMode.ThreadFullContextOpen
|
|
)
|
|
threadContext.value = {
|
|
threadId: null,
|
|
previousState: null
|
|
}
|
|
}
|
|
|
|
const cleanupThreadContext = () => {
|
|
threadContext.value = {
|
|
threadId: null,
|
|
previousState: null
|
|
}
|
|
}
|
|
|
|
return {
|
|
threadResourceStatus,
|
|
calculateThreadResourceStatus,
|
|
handleContextClick,
|
|
goBack,
|
|
hasClickedFullContext,
|
|
cleanupThreadContext
|
|
}
|
|
}
|