Merge branch 'main' into andrew/improve-ux-federated-comments

This commit is contained in:
andrewwallacespeckle
2025-03-14 11:44:23 +00:00
8 changed files with 94 additions and 36 deletions
@@ -97,7 +97,11 @@
:url="route.path"
/>
<Portal to="primary-actions">
<HeaderNavShare v-if="project" :resource-id-string="modelId" :project="project" />
<HeaderNavShare
v-if="project"
:resource-id-string="resourceIdString"
:project="project"
/>
</Portal>
</div>
</template>
@@ -113,17 +117,28 @@ import { useFilterUtilities } from '~/lib/viewer/composables/ui'
import { projectsRoute } from '~~/lib/common/helpers/route'
import { workspaceRoute } from '~/lib/common/helpers/route'
import { useMixpanel } from '~/lib/core/composables/mp'
import { writableAsyncComputed } from '~/lib/common/composables/async'
const emit = defineEmits<{
setup: [InjectableViewerState]
}>()
const router = useRouter()
const route = useRoute()
const isWorkspacesEnabled = useIsWorkspacesEnabled()
const modelId = computed(() => route.params.modelId as string)
const projectId = computed(() => route.params.id as string)
const resourceIdString = computed(() => route.params.modelId as string)
const projectId = writableAsyncComputed({
get: () => route.params.id as string,
set: async (value: string) => {
// Just rewrite route id param
await router.push({
params: { id: value }
})
},
initialState: route.params.id as string,
asyncRead: false
})
const state = useSetupViewer({
projectId
@@ -48,9 +48,13 @@ export const useBillingActions = () => {
const { mutate: cancelCheckoutSessionMutation } = useMutation(
settingsBillingCancelCheckoutSessionMutation
)
const logger = useLogger()
const billingPortalRedirect = async (workspaceId: MaybeNullOrUndefined<string>) => {
if (!workspaceId) return
if (!workspaceId) {
logger.error('[Billing Portal] No workspaceId provided, returning early')
return
}
mixpanel.track('Workspace Billing Portal Button Clicked', {
// eslint-disable-next-line camelcase
@@ -66,6 +70,11 @@ export const useBillingActions = () => {
if (result.data?.workspace.customerPortalUrl) {
window.open(result.data.workspace.customerPortalUrl, '_blank')
} else {
logger.warn(
'[Billing Portal] No portal URL returned, full response:',
result.data
)
}
}
@@ -82,13 +82,13 @@ export function useViewerUserActivityBroadcasting(
const apollo = useApolloClient().client
const { isEnabled: isEmbedEnabled } = useEmbed()
const isSameState = (
a: Optional<ViewerUserActivityMessageInput>,
b: Optional<ViewerUserActivityMessageInput>
const isSameMessage = (
previousSerializedMessage: Optional<string>,
newMessage: ViewerUserActivityMessageInput
) => {
if (xor(a, b)) return false
if (!a || !b) return false
return JSON.stringify(a) === JSON.stringify(b)
if (xor(previousSerializedMessage, newMessage)) return false
if (!previousSerializedMessage && !newMessage) return false
return previousSerializedMessage === JSON.stringify(newMessage)
}
const invokeMutation = async (message: ViewerUserActivityMessageInput) => {
@@ -106,14 +106,14 @@ export function useViewerUserActivityBroadcasting(
return result.data?.broadcastViewerUserActivity || false
}
let previousMessage: Optional<ViewerUserActivityMessageInput> = undefined
let serializedPreviousMessage: Optional<string> = undefined
const invokeObservabilityEvent = async (message: ViewerUserActivityMessageInput) => {
const dd = window.DD_RUM
if (!dd || !('addAction' in dd)) return
if (isSameState(previousMessage, message)) return
if (isSameMessage(serializedPreviousMessage, message)) return
previousMessage = message
serializedPreviousMessage = JSON.stringify(message)
dd.addAction('Viewer User Activity', { message })
}
@@ -138,6 +138,7 @@ export enum StateApplyMode {
export function useApplySerializedState() {
const {
projectId,
ui: {
camera: { position, target, isOrthoProjection },
sectionBox,
@@ -173,6 +174,16 @@ export function useApplySerializedState() {
return
}
if (state.projectId && state.projectId !== projectId.value) {
await projectId.update(state.projectId)
}
if (
[StateApplyMode.Spotlight, StateApplyMode.ThreadFullContextOpen].includes(mode)
) {
await resourceIdString.update(state.resources?.request?.resourceIdString || '')
}
position.value = new Vector3(
state.ui?.camera?.position?.[0],
state.ui?.camera?.position?.[1],
@@ -18,7 +18,6 @@ import {
type VisualDiffMode,
ViewMode
} from '@speckle/viewer'
import type { MaybeRef } from '@vueuse/shared'
import { inject, ref, provide } from 'vue'
import type { ComputedRef, WritableComputedRef, Raw, Ref, ShallowRef } from 'vue'
import { useScopedState } from '~~/lib/common/composables/scopedState'
@@ -82,7 +81,7 @@ export type InjectableViewerState = Readonly<{
/**
* The project which we're opening in the viewer (all loaded models should belong to it)
*/
projectId: ComputedRef<string>
projectId: AsyncWritableComputedRef<string>
/**
* User viewer session ID. The same user will have different IDs in different tabs if multiple are open.
* This is used to ignore user activity messages from the same tab.
@@ -400,8 +399,6 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
public: { viewerDebug }
} = useRuntimeConfig()
const projectId = computed(() => unref(params.projectId))
const sessionId = computed(() => nanoid())
const isInitialized = ref(false)
const { instance, initPromise, container } = useScopedState(
@@ -412,7 +409,7 @@ function setupInitialState(params: UseSetupViewerParams): InitialSetupState {
const hasDoneInitialLoad = ref(false)
return {
projectId,
projectId: params.projectId,
sessionId,
viewer: import.meta.server
? ({
@@ -1030,7 +1027,7 @@ function setupInterfaceState(
}
}
type UseSetupViewerParams = { projectId: MaybeRef<string> }
type UseSetupViewerParams = { projectId: AsyncWritableComputedRef<string> }
export function useSetupViewer(params: UseSetupViewerParams): InjectableViewerState {
// Initialize full state object - each subsequent state initialization depends on
@@ -1,12 +1,13 @@
import { ViewerEvent } from '@speckle/viewer'
import {
StateApplyMode,
useApplySerializedState
useApplySerializedState,
useStateSerialization
} from '~/lib/viewer/composables/serialization'
import { useInjectedViewerState } from '~~/lib/viewer/composables/setup'
import { useViewerEventListener } from '~~/lib/viewer/composables/viewer'
import type { SpeckleViewer } from '@speckle/shared'
import { get } from 'lodash-es'
import { get, isString } from 'lodash-es'
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function useDebugViewerEvents() {
@@ -18,38 +19,62 @@ function useDebugViewerEvents() {
}
function useDebugViewer() {
const state = useInjectedViewerState()
const fullViewerState = useInjectedViewerState()
const apply = useApplySerializedState()
const { serialize } = useStateSerialization()
const {
viewer: { instance }
} = state
} = fullViewerState
const ensureObj = <O>(obj: O | string): O => {
return isString(obj) ? JSON.parse(obj) : obj
}
const applyState = (
state: SpeckleViewer.ViewerState.SerializedViewerState | string
) => {
return apply(ensureObj(state), StateApplyMode.TheadFullContextOpen)
}
// Get current viewer instance
window.VIEWER = instance
// Get current viewer state
window.VIEWER_STATE = () => state
window.VIEWER_STATE = () => fullViewerState
// Get serialized version of current state
window.VIEWER_SERIALIZED_STATE = (...args: Parameters<typeof serialize>) => {
const serialized = serialize(...args)
return JSON.stringify(serialized)
}
// Apply viewer state
window.APPLY_VIEWER_STATE = (
state: SpeckleViewer.ViewerState.SerializedViewerState
) => apply(state, StateApplyMode.TheadFullContextOpen)
) => applyState(state)
// Apply DD user activity event
window.APPLY_VIEWER_DD_EVENT = (event: {
content: {
attributes: {
context: { message: { state: SpeckleViewer.ViewerState.SerializedViewerState } }
}
}
}) => {
window.APPLY_VIEWER_DD_EVENT = (
event:
| {
content: {
attributes: {
context: {
message: { state: SpeckleViewer.ViewerState.SerializedViewerState }
}
}
}
}
| string
) => {
event = ensureObj(event)
const path = 'content.attributes.context.message.state'
const state = get(event, path)
if (!state) {
throw new Error('Cant find serialized state at path: ' + path)
}
return apply(state, StateApplyMode.TheadFullContextOpen)
return applyState(state)
}
}
+1
View File
@@ -11,6 +11,7 @@ declare global {
// Debug keys, don't need to type properly cause we only use them manually from dev tools
VIEWER?: any
VIEWER_STATE?: any
VIEWER_SERIALIZED_STATE?: any
APPLY_VIEWER_STATE?: any
APPLY_VIEWER_DD_EVENT?: any
}
@@ -2,7 +2,7 @@ import { faker } from '@faker-js/faker'
import { RelativeURL } from '@speckle/shared'
import { expect } from 'chai'
import type { Express } from 'express'
import { has, isString, random } from 'lodash'
import { has, isString } from 'lodash'
import request from 'supertest'
export const appId = 'spklwebapp' // same values as on FE
@@ -233,7 +233,7 @@ export type LocalAuthRestApiHelpers = ReturnType<typeof localAuthRestApi>
export const generateRegistrationParams = (): RegisterParams => ({
challenge: faker.string.uuid(),
user: {
email: `${random(0, 1000)}@example.org`.toLowerCase(),
email: faker.internet.email().toLowerCase(),
password: faker.internet.password(),
name: faker.person.fullName()
}