From 3eeb549d201ad5ae011970f66be9730080f378cd Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Wed, 20 May 2026 18:15:46 +0100 Subject: [PATCH] fix: intercom bubble positioning properly (#6111) * feat: fix intercom properly * fix: positioning size + css transition * fix: lint * fix: ts * fix: nitpick --- apps/app-frontend/package.json | 1 - apps/app-frontend/src/App.vue | 134 +++----- .../src/composables/intercom-positioning.ts | 121 -------- apps/app-frontend/tsconfig.app.json | 1 + apps/app-frontend/vite.config.ts | 5 + apps/frontend/nuxt.config.ts | 13 +- apps/frontend/package.json | 1 - apps/frontend/src/layouts/default.vue | 29 ++ .../src/pages/hosting/manage/[id].vue | 8 - .../src/components/base/FloatingActionBar.vue | 29 +- .../ui/src/composables/hosting-intercom.ts | 288 ++++++++++++++++++ packages/ui/src/composables/index.ts | 1 + .../layouts/wrapped/hosting/manage/root.vue | 59 ---- pnpm-lock.yaml | 6 - 14 files changed, 403 insertions(+), 293 deletions(-) delete mode 100644 apps/app-frontend/src/composables/intercom-positioning.ts create mode 100644 packages/ui/src/composables/hosting-intercom.ts diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index b0572572b..a2e5bfaf5 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -13,7 +13,6 @@ "test": "vue-tsc --noEmit" }, "dependencies": { - "@intercom/messenger-js-sdk": "^0.0.14", "@modrinth/api-client": "workspace:^", "@modrinth/assets": "workspace:*", "@modrinth/ui": "workspace:*", diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 1939fa878..c841f5fc9 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1,5 +1,4 @@ diff --git a/packages/ui/src/composables/hosting-intercom.ts b/packages/ui/src/composables/hosting-intercom.ts new file mode 100644 index 000000000..6c9863998 --- /dev/null +++ b/packages/ui/src/composables/hosting-intercom.ts @@ -0,0 +1,288 @@ +import { + boot as bootIntercom, + Intercom, + shutdown as shutdownIntercom, + update as updateIntercom, +} from '@intercom/messenger-js-sdk' +import { + computed, + type MaybeRefOrGetter, + onBeforeUnmount, + onMounted, + ref, + toValue, + watch, +} from 'vue' + +import { useModalStack } from './modal-stack' + +type FetchIntercomToken = () => Promise<{ token: string }> + +export interface HostingIntercomIdentityUser { + id?: string | null +} + +export interface UseHostingIntercomOptions { + enabled: MaybeRefOrGetter + appId: MaybeRefOrGetter + fetchToken: FetchIntercomToken + identityKey: MaybeRefOrGetter + horizontalPadding?: MaybeRefOrGetter +} + +export function createHostingIntercomIdentityKey( + user: HostingIntercomIdentityUser | null | undefined, + serverId: string | null | undefined, +): string { + return `${user?.id ?? 'anonymous'}:${serverId ?? 'hosting'}` +} + +const DEFAULT_PADDING = 20 +const DEFAULT_LAUNCHER_WIDTH = 48 +const INTERCOM_STYLE_ID = 'modrinth-hosting-intercom-style' +const LAUNCHER_SELECTOR = + ".intercom-lightweight-app-launcher, .intercom-launcher-frame, iframe[name='intercom-launcher-frame']" +const RIGHT_VAR = '--modrinth-hosting-intercom-right' +const BOTTOM_VAR = '--modrinth-hosting-intercom-bottom' +const POINTER_EVENTS_VAR = '--modrinth-hosting-intercom-pointer-events' + +function sanitizePixels(value: number | undefined, fallback = DEFAULT_PADDING) { + if (typeof value !== 'number' || !Number.isFinite(value)) return fallback + return Math.max(0, Math.ceil(value)) +} + +function ensureIntercomStyle() { + if (typeof document === 'undefined' || document.getElementById(INTERCOM_STYLE_ID)) return + + const style = document.createElement('style') + style.id = INTERCOM_STYLE_ID + style.textContent = ` +.intercom-lightweight-app, +.intercom-lightweight-app-launcher, +.intercom-lightweight-app-messenger, +.intercom-launcher-frame, +.intercom-messenger-frame, +#intercom-container, +#intercom-frame, +iframe[name='intercom-launcher-frame'], +iframe[name='intercom-messenger-frame'] { + z-index: 98 !important; + pointer-events: var(${POINTER_EVENTS_VAR}, auto) !important; +} + +.intercom-lightweight-app-launcher, +.intercom-launcher-frame, +iframe[name='intercom-launcher-frame'] { + right: var(${RIGHT_VAR}, ${DEFAULT_PADDING}px) !important; + bottom: var(${BOTTOM_VAR}, ${DEFAULT_PADDING}px) !important; + transition: + right 0.12s ease-out, + bottom 0.12s ease-out !important; +} + +@media (prefers-reduced-motion: reduce) { + .intercom-lightweight-app-launcher, + .intercom-launcher-frame, + iframe[name='intercom-launcher-frame'] { + transition: none !important; + } +} +` + document.head.appendChild(style) +} + +export function useHostingIntercom(options: UseHostingIntercomOptions) { + const { stackCount } = useModalStack() + const horizontalPaddingRequests = new Map() + const verticalClearanceRequests = new Map() + const requestedHorizontalPadding = ref(null) + const requestedVerticalClearance = ref(null) + const launcherWidth = ref(DEFAULT_LAUNCHER_WIDTH) + let booted = false + let booting = false + let bootedIdentity: string | null = null + let bootRun = 0 + let syncAfterBoot = false + let stopSync: (() => void) | null = null + let stopPositionSync: (() => void) | null = null + let stopModalSync: (() => void) | null = null + let launcherObserver: ResizeObserver | null = null + let documentObserver: MutationObserver | null = null + let observedLauncher: Element | null = null + + const horizontalPadding = computed( + () => + requestedHorizontalPadding.value ?? + sanitizePixels(toValue(options.horizontalPadding), DEFAULT_PADDING), + ) + const verticalPadding = computed(() => requestedVerticalClearance.value ?? DEFAULT_PADDING) + const enabled = computed( + () => Boolean(toValue(options.enabled)) && Boolean(toValue(options.appId)), + ) + const identity = computed(() => String(toValue(options.identityKey) ?? 'hosting')) + + function requestFromMap( + requests: Map, + target: typeof requestedHorizontalPadding, + id: symbol, + value: number | null, + ) { + if (value === null || !Number.isFinite(value)) { + requests.delete(id) + } else { + requests.set(id, sanitizePixels(value)) + } + + target.value = requests.size > 0 ? Math.max(...requests.values()) : null + } + + function applyPosition(updateSdk = false) { + if (typeof document === 'undefined') return + + document.documentElement.style.setProperty(RIGHT_VAR, `${horizontalPadding.value}px`) + document.documentElement.style.setProperty(BOTTOM_VAR, `${verticalPadding.value}px`) + document.documentElement.style.setProperty( + POINTER_EVENTS_VAR, + stackCount.value > 0 ? 'none' : 'auto', + ) + + if (updateSdk && booted) { + updateIntercom({ + horizontal_padding: horizontalPadding.value, + vertical_padding: verticalPadding.value, + }) + } + } + + function updateLauncherWidth() { + if (typeof document === 'undefined') return + + const launcher = document.querySelector(LAUNCHER_SELECTOR) + if (launcher !== observedLauncher) { + launcherObserver?.disconnect() + observedLauncher = launcher + if (launcher) { + launcherObserver = new ResizeObserver(updateLauncherWidth) + launcherObserver.observe(launcher) + } + } + + const width = launcher?.getBoundingClientRect().width + launcherWidth.value = + typeof width === 'number' && width > 0 + ? sanitizePixels(width, DEFAULT_LAUNCHER_WIDTH) + : DEFAULT_LAUNCHER_WIDTH + } + + function clearPosition() { + if (typeof document === 'undefined') return + + document.documentElement.style.removeProperty(RIGHT_VAR) + document.documentElement.style.removeProperty(BOTTOM_VAR) + document.documentElement.style.removeProperty(POINTER_EVENTS_VAR) + } + + function stop() { + bootRun++ + syncAfterBoot = false + if (booted) shutdownIntercom() + booting = false + booted = false + bootedIdentity = null + } + + async function start(currentIdentity: string) { + const appId = toValue(options.appId) + if (!appId) return + + const run = ++bootRun + booting = true + + try { + const { token } = await options.fetchToken() + if (run !== bootRun || !enabled.value || identity.value !== currentIdentity) return + + const settings = { + app_id: appId, + intercom_user_jwt: token, + session_duration: 1000 * 60 * 60 * 24, + alignment: 'right', + horizontal_padding: horizontalPadding.value, + vertical_padding: verticalPadding.value, + } + if (typeof window !== 'undefined' && window.Intercom) { + bootIntercom(settings) + } else { + Intercom(settings) + } + booted = true + bootedIdentity = currentIdentity + applyPosition() + } catch (error) { + if (run === bootRun) { + console.warn('[HOSTING][INTERCOM] failed to initialize secure support chat', error) + } + } finally { + if (run === bootRun) { + booting = false + if (syncAfterBoot) { + syncAfterBoot = false + sync() + } + } + } + } + + function sync() { + if (!enabled.value) { + if (booted || booting) stop() + return + } + + if (booted && bootedIdentity === identity.value) { + applyPosition(true) + } else if (booting) { + syncAfterBoot = true + } else { + if (booted) stop() + void start(identity.value) + } + } + + onMounted(() => { + ensureIntercomStyle() + updateLauncherWidth() + documentObserver = new MutationObserver(updateLauncherWidth) + documentObserver.observe(document.body, { + childList: true, + subtree: true, + }) + applyPosition() + stopSync = watch([enabled, identity], sync, { + immediate: true, + }) + stopPositionSync = watch([horizontalPadding, verticalPadding], () => applyPosition(true)) + stopModalSync = watch(stackCount, () => applyPosition()) + }) + + onBeforeUnmount(() => { + stopSync?.() + stopPositionSync?.() + stopModalSync?.() + launcherObserver?.disconnect() + documentObserver?.disconnect() + stop() + clearPosition() + }) + + return { + intercomBubble: { + width: launcherWidth, + horizontalPadding, + requestHorizontalPadding: (id: symbol, value: number | null) => + requestFromMap(horizontalPaddingRequests, requestedHorizontalPadding, id, value), + requestVerticalClearance: (id: symbol, value: number | null) => + requestFromMap(verticalClearanceRequests, requestedVerticalClearance, id, value), + }, + } +} diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts index 40ed1744d..1fea76d0d 100644 --- a/packages/ui/src/composables/index.ts +++ b/packages/ui/src/composables/index.ts @@ -4,6 +4,7 @@ export * from './format-bytes' export * from './format-date-time' export * from './format-money' export * from './format-number' +export * from './hosting-intercom' export * from './how-ago' export * from './i18n' export * from './i18n-debug' diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue index b9b4a0661..36bf2a3e6 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue @@ -343,7 +343,6 @@