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 @@