fix: various presentations mode fixes related to resetting (#5635)

* better workspace feature flag ops

* user activity is correctly tracked

* more fixes
This commit is contained in:
Kristaps Fabians Geikins
2025-10-03 11:39:59 +02:00
committed by GitHub
parent 42d0237952
commit d394e1cd9b
20 changed files with 258 additions and 146 deletions
@@ -74,7 +74,6 @@ import { SpeckleViewer } from '@speckle/shared'
import { keyboardClick } from '@speckle/ui-components'
import { graphql } from '~/lib/common/generated/gql/gql'
import type { HeaderNavShare_ProjectFragment } from '~~/lib/common/generated/gql/graphql'
import { useCopyModelLink } from '~~/lib/projects/composables/modelManagement'
graphql(`
fragment HeaderNavShare_Project on Project {
@@ -90,7 +89,6 @@ const props = defineProps<{
}>()
const { copy } = useClipboard()
const copyModelLink = useCopyModelLink()
const menuButtonId = useId()
const embedDialogOpen = ref(false)
@@ -99,37 +97,16 @@ const parsedResourceIds = computed(() =>
SpeckleViewer.ViewerRoute.parseUrlParameters(props.resourceIdString)
)
const firstResource = computed(() => parsedResourceIds.value[0] || {})
const versionId = computed(() => {
if (SpeckleViewer.ViewerRoute.isModelResource(firstResource.value)) {
return firstResource.value.versionId
}
return ''
})
const modelId = computed(() => {
if (SpeckleViewer.ViewerRoute.isModelResource(firstResource.value)) {
return firstResource.value.modelId // Assuming your firstResource object has a modelId property
}
return ''
})
const isFederated = computed(() => parsedResourceIds.value.length > 1)
const handleCopyId = () => {
copy(props.resourceIdString, { successMessage: 'ID copied to clipboard' })
const handleCopyId = async () => {
await copy(props.resourceIdString, { successMessage: 'ID copied to clipboard' })
}
const handleCopyLink = () => {
const modelIdValue = modelId.value
const versionIdValue = versionId.value ? versionId.value : undefined
void copyModelLink({
model: {
projectId: props.project.id,
id: modelIdValue
},
versionId: versionIdValue
const handleCopyLink = async () => {
if (import.meta.server) return
await copy(window.location.href, {
successMessage: 'Copied link to clipboard'
})
}
@@ -29,6 +29,7 @@ import { LucideChevronLeft, LucideChevronRight, LucideRotateCcw } from 'lucide-v
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { clamp } from 'lodash-es'
import { useEventListener } from '@vueuse/core'
import { useResetViewUtils } from '~/lib/presentations/composables/utils'
defineProps<{
hideUi?: boolean
@@ -36,8 +37,9 @@ defineProps<{
const {
ui: { slideIdx: currentVisibleIndex, slideCount },
viewer: { resetView, hasViewChanged }
viewer: { hasViewChanged }
} = useInjectedPresentationState()
const { resetView } = useResetViewUtils()
const disablePrevious = computed(() => currentVisibleIndex.value === 0)
const disableNext = computed(() =>
@@ -26,6 +26,7 @@ import { graphql } from '~~/lib/common/generated/gql'
import type { PresentationSlideListSlide_SavedViewFragment } from '~~/lib/common/generated/gql/graphql'
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { useAuthManager } from '~~/lib/auth/composables/auth'
import { useResetViewUtils } from '~/lib/presentations/composables/utils'
graphql(`
fragment PresentationSlideListSlide_SavedView on SavedView {
@@ -42,10 +43,10 @@ const props = defineProps<{
}>()
const {
ui: { slideIdx: currentSlideIdx, slide: currentSlide },
viewer: { resetView }
ui: { slideIdx: currentSlideIdx, slide: currentSlide }
} = useInjectedPresentationState()
const { presentationToken } = useAuthManager()
const { resetView } = useResetViewUtils()
const isCurrentSlide = computed(() => currentSlide.value?.id === props.slide.id)
@@ -0,0 +1,12 @@
<template>
<div class="presentation-viewer-post-setup h-full"><slot /></div>
</template>
<script setup lang="ts">
import { usePresentationViewerPostSetup } from '~/lib/presentations/composables/viewerPostSetup'
/**
* The only point of this component is to get around the stupid limitation where a component that injects() also can't provide() the same stuff back...
*/
usePresentationViewerPostSetup()
</script>
@@ -1,9 +1,11 @@
<template>
<ViewerStateSetup :init-params="initParams">
<PresentationViewerSetup
@loading-change="onLoadingChange"
@progress-change="onProgressChange"
/>
<PresentationViewerPostSetup>
<PresentationViewerSetup
@loading-change="onLoadingChange"
@progress-change="onProgressChange"
/>
</PresentationViewerPostSetup>
</ViewerStateSetup>
</template>
<script setup lang="ts">
@@ -159,7 +159,7 @@ const parentEl = ref(null as Nullable<HTMLElement>)
const { isLoggedIn } = useActiveUser()
const viewerState = useInjectedViewerState()
const { sessionId } = viewerState
const { users } = useViewerUserActivityTracking({ parentEl })
const { users } = useViewerUserActivityTracking({ anchoredPointsParentEl: parentEl })
const { isOpenThread, open, closeAllThreads } = useThreadUtilities()
const {
filters: { hasAnyFiltersApplied },
@@ -135,9 +135,11 @@ export type AddDomainToWorkspaceInput = {
workspaceId: Scalars['ID']['input'];
};
/** Either the ID or slug must be set */
export type AdminAccessToWorkspaceFeatureInput = {
featureFlagName: WorkspaceFeatureFlagName;
workspaceId: Scalars['ID']['input'];
workspaceId?: InputMaybe<Scalars['ID']['input']>;
workspaceSlug?: InputMaybe<Scalars['String']['input']>;
};
export type AdminInviteList = {
@@ -23,7 +23,7 @@ export function wrapRefWithTracking<R extends Ref<unknown>>(
},
set: (newVal) => {
if (!readsOnly) {
logger().debug(`debugging: '${name}' written to`, newVal, getTrace())
logger().debug(`debugging: '${name}' written to`, { newVal }, getTrace())
}
ref.value = newVal
@@ -8,8 +8,6 @@ import {
SavedViewVisibility
} from '~/lib/common/generated/gql/graphql'
import { projectPresentationPageQuery } from '~/lib/presentations/graphql/queries'
import { useEventBus } from '~/lib/core/composables/eventBus'
import { ViewerEventBusKeys } from '~/lib/viewer/helpers/eventBus'
import { useProjectSavedViewsUpdateTracking } from '~/lib/viewer/composables/savedViews/subscriptions'
type ResponseProject = Optional<Get<ProjectPresentationPageQuery, 'project'>>
@@ -44,10 +42,6 @@ export type InjectablePresentationState = Readonly<{
* active slide etc.
*/
resourceIdString: ComputedRef<string>
/**
* Reset the current view to the saved view state of the current slide
*/
resetView: () => void
/**
* Whether the current view has been changed from the saved view state
*/
@@ -101,8 +95,6 @@ const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
ui: { slideIdx }
} = initState
const { emit, on } = useEventBus()
const hasViewChanged = ref(false)
const resourceIdString = computed(() => {
@@ -113,32 +105,9 @@ const setupStateViewer = (initState: ResponseState & UiState): ViewerState => {
.toString()
})
const resetView = () => {
const slides = presentation.value?.views.items || []
const currentSlide = slides.at(slideIdx.value)
if (!currentSlide?.id) return
emit(ViewerEventBusKeys.ApplySavedView, {
id: currentSlide.id,
loadOriginal: false
})
hasViewChanged.value = false
}
on(ViewerEventBusKeys.UserChangedOpenedView, () => {
hasViewChanged.value = true
})
watch(slideIdx, () => {
hasViewChanged.value = false
})
return {
viewer: {
resourceIdString,
resetView,
hasViewChanged
}
}
@@ -0,0 +1,27 @@
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
export const useResetViewUtils = () => {
const {
response: { presentation },
ui: { slideIdx },
viewer: { hasViewChanged }
} = useInjectedPresentationState()
const { emit } = useEventBus()
const resetView = () => {
const slides = presentation.value?.views.items || []
const currentSlide = slides.at(slideIdx.value)
if (!currentSlide?.id) return
emit(ViewerEventBusKeys.ApplySavedView, {
id: currentSlide.id,
loadOriginal: false
})
hasViewChanged.value = false
}
return { resetView }
}
@@ -0,0 +1,44 @@
import { useInjectedPresentationState } from '~/lib/presentations/composables/setup'
import { useViewerUserActivityTracking } from '~/lib/viewer/composables/activity'
import { useInjectedViewerState } from '~/lib/viewer/composables/setup'
const useActivityTrackingIntegration = () => {
useViewerUserActivityTracking({
trackInternallyOnly: true
})
}
const useResetTrackingIntegration = () => {
const { on } = useEventBus()
const {
ui: { slideIdx },
viewer: { hasViewChanged }
} = useInjectedPresentationState()
const {
ui: {
savedViews: { savedViewStateId }
}
} = useInjectedViewerState()
on(ViewerEventBusKeys.UserChangedOpenedView, () => {
hasViewChanged.value = true
})
watch(
slideIdx,
() => {
savedViewStateId.value = undefined
hasViewChanged.value = false
},
{ flush: 'sync' }
)
}
/**
* Post setup work to run after the viewer (and presentation) states have been set up
*/
export const usePresentationViewerPostSetup = () => {
if (import.meta.server) return
useActivityTrackingIntegration()
useResetTrackingIntegration()
}
@@ -261,10 +261,19 @@ export type UserActivityModel = Merge<
/**
* Track other user activity and emit viewing/disconnected updates
*/
export function useViewerUserActivityTracking(params: {
parentEl: Ref<Nullable<HTMLElement>>
}) {
const { parentEl } = params
export function useViewerUserActivityTracking(
params?: Partial<{
/**
* Set if you need users to be positioned correctly in viewer world space in an overlaid anchored points element
*/
anchoredPointsParentEl: Ref<Nullable<HTMLElement>>
/**
* Whether to only track viewer state changes, without broadcasting it to other users or getting updates from them
*/
trackInternallyOnly: boolean
}>
) {
const { anchoredPointsParentEl: parentEl, trackInternallyOnly } = params || {}
const {
projectId,
@@ -275,10 +284,27 @@ export function useViewerUserActivityTracking(params: {
} = useInjectedViewerState()
const { isLoggedIn } = useActiveUser()
const { triggerNotification } = useGlobalToast()
const { update } = useViewerRealtimeActivityTracker()
const sendUpdate = useViewerUserActivityBroadcasting()
const { isEnabled: isEmbedEnabled } = useEmbed()
const { activeUser } = useActiveUser()
const processViewerViewing = async () => {
if (trackInternallyOnly) {
update({ status: ViewerUserActivityStatus.Viewing })
} else {
await sendUpdate.emitViewing()
}
}
const processViewerDisconnected = async () => {
if (trackInternallyOnly) {
update({ status: ViewerUserActivityStatus.Disconnected })
} else {
await sendUpdate.emitDisconnected()
}
}
// TODO: For some reason subscription is set up twice? Vue Apollo bug?
const { onResult: onUserActivity } = useSubscription(
onViewerUserActivityBroadcastedSubscription,
@@ -291,7 +317,7 @@ export function useViewerUserActivityTracking(params: {
sessionId: sessionId.value
}),
() => ({
enabled: isLoggedIn.value
enabled: isLoggedIn.value && !trackInternallyOnly
})
)
@@ -378,53 +404,61 @@ export function useViewerUserActivityTracking(params: {
}
})
useViewerAnchoredPoints<UserActivityModel, Partial<{ smoothTranslation: boolean }>>({
parentEl,
points: computed(() => Object.values(users.value)),
pointLocationGetter: (user) => {
const selection = user.state.ui.selection
const selectionVector = selection
? new Vector3(selection[0], selection[1], selection[2])
: null
if (parentEl) {
useViewerAnchoredPoints<UserActivityModel, Partial<{ smoothTranslation: boolean }>>(
{
parentEl,
points: computed(() => Object.values(users.value)),
pointLocationGetter: (user) => {
const selection = user.state.ui.selection
const selectionVector = selection
? new Vector3(selection[0], selection[1], selection[2])
: null
function getPointInBetweenByPerc(
pointA: Vector3,
pointB: Vector3,
percentage: number
) {
let dir = pointB.clone().sub(pointA)
const len = dir.length()
dir = dir.normalize().multiplyScalar(len * percentage)
return pointA.clone().add(dir)
}
function getPointInBetweenByPerc(
pointA: Vector3,
pointB: Vector3,
percentage: number
) {
let dir = pointB.clone().sub(pointA)
const len = dir.length()
dir = dir.normalize().multiplyScalar(len * percentage)
return pointA.clone().add(dir)
}
// If there is no selection location, return to a blended location based on the camera's target and location.
// This ensures that rotation and zoom will have an effect on the users' cursors and create a lively environment.
if (!selectionVector) {
const camPos = user.state.ui.camera.position
const camPosVector = new Vector3(camPos[0], camPos[1], camPos[2])
// If there is no selection location, return to a blended location based on the camera's target and location.
// This ensures that rotation and zoom will have an effect on the users' cursors and create a lively environment.
if (!selectionVector) {
const camPos = user.state.ui.camera.position
const camPosVector = new Vector3(camPos[0], camPos[1], camPos[2])
const camTarget = user.state.ui.camera.target
const camTargetVector = new Vector3(camTarget[0], camTarget[1], camTarget[2])
const camTarget = user.state.ui.camera.target
const camTargetVector = new Vector3(
camTarget[0],
camTarget[1],
camTarget[2]
)
return getPointInBetweenByPerc(camTargetVector, camPosVector, 0.2)
}
return getPointInBetweenByPerc(camTargetVector, camPosVector, 0.2)
}
return selectionVector.clone()
},
updatePositionCallback: (user, result, options) => {
user.isOccluded = result.isOccluded
user.style = {
...user.style,
target: {
...user.style.target,
...result.style,
transition: options?.smoothTranslation === false ? '' : 'all 0.1s ease'
// opacity: user.isOccluded ? '0.5' : user.isStale ? '0.2' : '1.0' // note: handled in component via css
return selectionVector.clone()
},
updatePositionCallback: (user, result, options) => {
user.isOccluded = result.isOccluded
user.style = {
...user.style,
target: {
...user.style.target,
...result.style,
transition: options?.smoothTranslation === false ? '' : 'all 0.1s ease'
// opacity: user.isOccluded ? '0.5' : user.isStale ? '0.2' : '1.0' // note: handled in component via css
}
}
}
}
}
})
)
}
const hideStaleUsers = () => {
if (!Object.values(users.value).length) return
@@ -451,7 +485,7 @@ export function useViewerUserActivityTracking(params: {
// Debounced disconnect function - 30 second delay
const debouncedDisconnect = debounce(
async () => {
await sendUpdate.emitDisconnected()
await processViewerDisconnected()
},
30 * 1000 // 30 seconds
)
@@ -463,13 +497,13 @@ export function useViewerUserActivityTracking(params: {
} else {
// Window regained focus - cancel any pending disconnect and emit viewing
debouncedDisconnect.cancel()
await sendUpdate.emitViewing()
await processViewerViewing()
}
})
const sendUpdateAndHideStaleUsers = () => {
if (!focused.value) return
hideStaleUsers()
sendUpdate.emitViewing()
processViewerViewing()
}
useIntervalFn(sendUpdateAndHideStaleUsers, OWN_ACTIVITY_UPDATE_INTERVAL)
@@ -484,22 +518,22 @@ export function useViewerUserActivityTracking(params: {
doubleClickCallback: selectionCallback
})
useViewerCameraControlEndTracker(() => sendUpdate.emitViewing())
useViewerCameraControlEndTracker(() => processViewerViewing())
useOnBeforeWindowUnload(async () => {
// Cancel any pending debounced disconnect since we're actually leaving
debouncedDisconnect.cancel()
await sendUpdate.emitDisconnected()
await processViewerDisconnected()
})
onMounted(() => {
sendUpdate.emitViewing()
processViewerViewing()
})
onBeforeUnmount(() => {
// Cancel any pending debounced disconnect
debouncedDisconnect.cancel()
sendUpdate.emitDisconnected()
void processViewerDisconnected()
})
const state = useInjectedViewerState()
@@ -519,7 +553,7 @@ export function useViewerUserActivityTracking(params: {
watch(resourceIdString, (newVal, oldVal) => {
if (newVal !== oldVal) {
sendUpdate.emitViewing()
void processViewerViewing()
}
})
@@ -24,16 +24,15 @@ export const useViewerSavedViewIntegration = () => {
},
response: { savedView }
},
urlHashState: { savedView: urlHashStateSavedViewSettings }
urlHashState: { savedView: urlHashStateSavedViewSettings },
ui: {
savedViews: { savedViewStateId }
}
} = useInjectedViewerState()
const applyState = useApplySerializedState()
const { serializedStateId } = useViewerRealtimeActivityTracker()
const { on, emit } = useEventBus()
// Saved View ID will be unset, once the user does anything to the viewer that
// changes it from the saved view
const savedViewStateId = ref<string>()
const validState = (state: unknown) => (isSerializedViewerState(state) ? state : null)
const apply = async () => {
@@ -131,6 +130,7 @@ export type SavedViewsUIState = ReturnType<typeof useBuildSavedViewsUIState>
export const useBuildSavedViewsUIState = () => {
const openedGroupState = ref<Map<string, true>>(new Map())
const savedViewStateId = ref<string>()
onUnmounted(() => {
openedGroupState.value = new Map()
@@ -140,7 +140,12 @@ export const useBuildSavedViewsUIState = () => {
/**
* Groups that should currently be expanded/open
*/
openedGroupState
openedGroupState,
/**
* A kind of a "viewer snapshot" ID associated w/ the saved view being loaded. Helps track
* if user has changed the view since loading the saved view
*/
savedViewStateId
}
}
@@ -342,7 +342,6 @@ export default defineNuxtPlugin(async (nuxtApp) => {
{
err: error,
info,
isAppError: true,
vm: _vm?.$options.name,
errString: errorToString(error),
vueErrorHandler: true,
@@ -698,8 +698,12 @@ enum WorkspaceFeatureFlagName {
presentations
}
"""
Either the ID or slug must be set
"""
input AdminAccessToWorkspaceFeatureInput {
workspaceId: ID!
workspaceId: ID
workspaceSlug: String
featureFlagName: WorkspaceFeatureFlagName!
}
@@ -157,9 +157,11 @@ export type AddDomainToWorkspaceInput = {
workspaceId: Scalars['ID']['input'];
};
/** Either the ID or slug must be set */
export type AdminAccessToWorkspaceFeatureInput = {
featureFlagName: WorkspaceFeatureFlagName;
workspaceId: Scalars['ID']['input'];
workspaceId?: InputMaybe<Scalars['ID']['input']>;
workspaceSlug?: InputMaybe<Scalars['String']['input']>;
};
export type AdminInviteList = {
@@ -26,9 +26,13 @@ export type {
GetWorkspaceRolesAndSeats
} from '@/modules/workspacesCore/domain/operations'
export type GetWorkspacePlan = (args: {
workspaceId: string
}) => Promise<WorkspacePlan | null>
export type GetWorkspacePlan = (
args:
| {
workspaceId: string
}
| { workspaceSlug: string }
) => Promise<WorkspacePlan | null>
export type GetWorkspacePlansByWorkspaceId = (args: {
workspaceIds: string[]
@@ -83,13 +83,24 @@ export const getWorkspaceWithPlanFactory =
export const getWorkspacePlanFactory =
({ db }: { db: Knex }): GetWorkspacePlan =>
async ({ workspaceId }) => {
const workspacePlan = await tables
async (params) => {
const q = tables
.workspacePlans(db)
.select()
.where({ workspaceId })
.select<WorkspacePlan[]>(WorkspacePlans.cols)
.first()
return workspacePlan ?? null
if ('workspaceId' in params) {
q.where({ workspaceId: params.workspaceId })
} else {
q.innerJoin(
Workspaces.name,
Workspaces.col.id,
WorkspacePlans.col.workspaceId
).where({ [Workspaces.col.slug]: params.workspaceSlug })
}
const ret = await q
return ret ?? null
}
export const getWorkspacePlansByWorkspaceIdFactory =
@@ -243,6 +243,7 @@ import {
} from '@/modules/core/services/projects'
import { WorkspacePlanNotFoundError } from '@/modules/gatekeeper/errors/billing'
import { deleteProjectCommitsFactory } from '@/modules/core/repositories/commits'
import { UserInputError } from '@/modules/core/errors/userinput'
const getServerInfo = getServerInfoFactory({ db })
const getUser = getUserFactory({ db })
@@ -623,13 +624,20 @@ export default FF_WORKSPACES_MODULE_ENABLED
return true
},
giveAccessToWorkspaceFeature: async (_parent, { input }, ctx) => {
const { workspaceId, featureFlagName } = input
const { workspaceId, featureFlagName, workspaceSlug } = input
const userId = ctx.userId
if (!userId) throw new UnauthorizedError()
if (!workspaceId && !workspaceSlug) {
throw new UserInputError(
'Either workspaceId or workspaceSlug must be provided'
)
}
const featureFlag = WorkspaceFeatureFlags[featureFlagName]
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
const getWorkspacePlan = getWorkspacePlanFactory({ db })
const workspacePlan = await (workspaceId
? getWorkspacePlan({ workspaceId })
: getWorkspacePlan({ workspaceSlug: workspaceSlug! }))
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
workspacePlan.featureFlags |= featureFlag
@@ -641,13 +649,21 @@ export default FF_WORKSPACES_MODULE_ENABLED
return true
},
removeAccessToWorkspaceFeature: async (_parent, { input }, ctx) => {
const { workspaceId, featureFlagName } = input
const { workspaceId, featureFlagName, workspaceSlug } = input
const userId = ctx.userId
if (!userId) throw new UnauthorizedError()
if (!workspaceId && !workspaceSlug) {
throw new UserInputError(
'Either workspaceId or workspaceSlug must be provided'
)
}
const featureFlag = WorkspaceFeatureFlags[featureFlagName]
const workspacePlan = await getWorkspacePlanFactory({ db })({ workspaceId })
const getWorkspacePlan = getWorkspacePlanFactory({ db })
const workspacePlan = await (workspaceId
? getWorkspacePlan({ workspaceId })
: getWorkspacePlan({ workspaceSlug: workspaceSlug! }))
if (!workspacePlan) throw new WorkspacePlanNotFoundError()
workspacePlan.featureFlags ^= featureFlag
+2 -1
View File
@@ -139,7 +139,8 @@
"vue.complete.casing.props": "kebab",
"vue.inlayHints.missingProps": true,
"circleci.persistedProjectSelection": ["gh/specklesystems/speckle-server"]
"circleci.persistedProjectSelection": ["gh/specklesystems/speckle-server"],
"vue.suggest.componentNameCasing": "alwaysPascalCase"
// "vitest.nodeEnv": {
// "DISABLE_ALL_FFS": "1"
// }