fix: intercom bubble positioning properly (#6111)

* feat: fix intercom properly

* fix: positioning size + css transition

* fix: lint

* fix: ts

* fix: nitpick
This commit is contained in:
Calum H.
2026-05-20 18:15:46 +01:00
committed by GitHub
parent c3fe7b4232
commit 3eeb549d20
14 changed files with 403 additions and 293 deletions
-1
View File
@@ -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:*",
+41 -93
View File
@@ -1,5 +1,4 @@
<script setup>
import { Intercom, shutdown as shutdownIntercom } from '@intercom/messenger-js-sdk'
import {
AuthFeature,
ModrinthApiError,
@@ -53,6 +52,7 @@ import {
providePopupNotificationManager,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
@@ -88,7 +88,6 @@ import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import WindowControls from '@/components/ui/WindowControls.vue'
import { useIntercomPositioning } from '@/composables/intercom-positioning'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
@@ -132,15 +131,36 @@ import { AppPopupNotificationManager } from './providers/app-popup-notifications
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const intercomBubblePositioning = useIntercomPositioning({ route, themeStore })
const {
sidebarToggled,
forceSidebar,
sidebarVisible,
intercomBubblePosition,
updateIntercomBubbleStyles,
clearIntercomBubbleStyles,
} = intercomBubblePositioning
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const credentials = ref()
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const hostingIntercomIdentityKey = computed(() => {
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
const userId = credentials.value?.user_id ?? credentials.value?.user?.id ?? 'anonymous'
return `${userId}:${serverId ?? 'hosting'}`
})
const hostingIntercom = useHostingIntercom({
enabled: computed(() => hostingRouteActive.value && !!credentials.value?.session),
appId: 'ykeritl9',
fetchToken: fetchIntercomToken,
identityKey: hostingIntercomIdentityKey,
horizontalPadding: computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
),
})
const notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
@@ -175,7 +195,11 @@ provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
...intercomBubblePositioning.pageContext,
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
},
intercomBubble: hostingIntercom.intercomBubble,
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
@@ -259,8 +283,7 @@ onMounted(async () => {
onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
shutdownHostingIntercom()
clearIntercomBubbleStyles()
unsubscribeSidebarToggle()
await unlistenUpdateDownload?.()
})
@@ -593,8 +616,6 @@ const incompatibilityWarningModal = ref()
const installToPlayModal = ref()
const updateToPlayModal = ref()
const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
setupAuthProvider(credentials, async (_redirectPath) => {
@@ -664,10 +685,6 @@ const hasPlus = computed(
const showAd = computed(
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
let intercomBooting = false
let intercomBooted = false
async function fetchIntercomToken() {
const creds = await getCreds()
@@ -676,8 +693,10 @@ async function fetchIntercomToken() {
}
const params = new URLSearchParams()
if (route.path.startsWith('/hosting/manage/') && typeof route.params.id === 'string') {
params.set('server_id', route.params.id)
const rawServerId = route.params.id
const serverId = Array.isArray(rawServerId) ? rawServerId[0] : rawServerId
if (route.path.startsWith('/hosting/manage/') && typeof serverId === 'string') {
params.set('server_id', serverId)
}
const query = params.size > 0 ? `?${params.toString()}` : ''
@@ -693,69 +712,6 @@ async function fetchIntercomToken() {
return await response.json()
}
async function bootIntercom() {
if (
intercomBooting ||
intercomBooted ||
!hostingRouteActive.value ||
!credentials.value?.session
) {
return
}
intercomBooting = true
console.debug('[APP][INTERCOM] initializing secure support chat')
try {
const { token } = await fetchIntercomToken()
Intercom({
app_id: 'ykeritl9',
intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24,
alignment: 'right',
horizontal_padding: intercomBubblePosition.value.horizontalPadding,
vertical_padding: intercomBubblePosition.value.verticalPadding,
})
intercomBooted = true
} catch (error) {
console.warn('[APP][INTERCOM] failed to initialize secure support chat', error)
} finally {
intercomBooting = false
}
}
function shutdownHostingIntercom() {
if (!intercomBooted && !intercomBooting) return
shutdownIntercom()
intercomBooting = false
intercomBooted = false
}
watch(
intercomBubblePosition,
(position) => {
updateIntercomBubbleStyles(position)
if (intercomBooted) {
window.Intercom?.('update', {
horizontal_padding: position.horizontalPadding,
vertical_padding: position.verticalPadding,
})
}
},
{ immediate: true },
)
watch(
[hostingRouteActive, credentials],
([active]) => {
if (active) {
void bootIntercom()
} else {
shutdownHostingIntercom()
}
},
{ immediate: true },
)
watch(showAd, () => {
if (!showAd.value) {
hide_ads_window(true)
@@ -1822,14 +1778,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
--os-handle-bg-active: var(--color-scrollbar) !important;
}
.intercom-lightweight-app-launcher,
.intercom-launcher-frame,
iframe[name='intercom-launcher-frame'] {
right: var(--app-support-launcher-right, 20px) !important;
bottom: var(--app-support-launcher-bottom, 20px) !important;
z-index: 9 !important;
}
.mac {
.app-grid-statusbar {
padding-left: 5rem;
@@ -1,121 +0,0 @@
import { computed, onUnmounted, ref } from 'vue'
import type { RouteLocationNormalizedLoaded } from 'vue-router'
interface ThemeStore {
toggleSidebar: boolean
$subscribe: (callback: () => void) => () => void
}
interface IntercomBubblePosition {
horizontalPadding: number
verticalPadding: number
}
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
const INTERCOM_BUBBLE_WIDTH = 72
const INTERCOM_BUBBLE_RIGHT_VAR = '--app-support-launcher-right'
const INTERCOM_BUBBLE_BOTTOM_VAR = '--app-support-launcher-bottom'
export function useIntercomPositioning({
route,
themeStore,
}: {
route: RouteLocationNormalizedLoaded
themeStore: ThemeStore
}) {
const sidebarToggled = ref(true)
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
sidebarToggled.value = !themeStore.toggleSidebar
})
onUnmounted(unsubscribeSidebarToggle)
const forceSidebar = computed(
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
)
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
const defaultIntercomBubbleHorizontalPadding = computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
)
const intercomBubbleRequestedHorizontalPadding = ref<number | null>(null)
const intercomBubbleHorizontalPadding = computed(
() =>
intercomBubbleRequestedHorizontalPadding.value ??
defaultIntercomBubbleHorizontalPadding.value,
)
const intercomBubbleVerticalClearance = ref<number | null>(null)
const intercomBubblePosition = computed(() => ({
horizontalPadding: intercomBubbleHorizontalPadding.value,
verticalPadding: intercomBubbleVerticalClearance.value ?? INTERCOM_BUBBLE_DEFAULT_PADDING,
}))
const intercomBubbleHorizontalPaddingRequests = new Map<symbol, number>()
const intercomBubbleClearanceRequests = new Map<symbol, number>()
function requestIntercomBubbleHorizontalPadding(id: symbol, padding: number | null) {
if (padding === null) {
intercomBubbleHorizontalPaddingRequests.delete(id)
} else {
intercomBubbleHorizontalPaddingRequests.set(id, padding)
}
intercomBubbleRequestedHorizontalPadding.value =
intercomBubbleHorizontalPaddingRequests.size > 0
? Math.max(...intercomBubbleHorizontalPaddingRequests.values())
: null
}
function requestIntercomBubbleVerticalClearance(id: symbol, clearance: number | null) {
if (clearance === null) {
intercomBubbleClearanceRequests.delete(id)
} else {
intercomBubbleClearanceRequests.set(id, clearance)
}
intercomBubbleVerticalClearance.value =
intercomBubbleClearanceRequests.size > 0
? Math.max(...intercomBubbleClearanceRequests.values())
: null
}
function updateIntercomBubbleStyles({
horizontalPadding,
verticalPadding,
}: IntercomBubblePosition) {
if (typeof document === 'undefined') return
document.documentElement.style.setProperty(INTERCOM_BUBBLE_RIGHT_VAR, `${horizontalPadding}px`)
document.documentElement.style.setProperty(INTERCOM_BUBBLE_BOTTOM_VAR, `${verticalPadding}px`)
}
function clearIntercomBubbleStyles() {
if (typeof document === 'undefined') return
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_RIGHT_VAR)
document.documentElement.style.removeProperty(INTERCOM_BUBBLE_BOTTOM_VAR)
}
return {
sidebarToggled,
forceSidebar,
sidebarVisible,
intercomBubblePosition,
updateIntercomBubbleStyles,
clearIntercomBubbleStyles,
pageContext: {
floatingActionBarOffsets: {
left: ref(APP_LEFT_NAV_WIDTH),
right: computed(() => (sidebarVisible.value ? `${APP_SIDEBAR_WIDTH}px` : '0px')),
},
intercomBubble: {
width: ref(INTERCOM_BUBBLE_WIDTH),
horizontalPadding: intercomBubbleHorizontalPadding,
requestHorizontalPadding: requestIntercomBubbleHorizontalPadding,
requestVerticalClearance: requestIntercomBubbleVerticalClearance,
},
},
}
}
+1
View File
@@ -19,6 +19,7 @@
"types": ["vite/client"],
"paths": {
"@modrinth/api-client": ["../../packages/api-client/src/index.ts"],
"@/*": ["./src/*"]
}
},
+5
View File
@@ -8,6 +8,7 @@ import tauriConf from '../app/tauri.conf.json'
const projectRootDir = resolve(__dirname)
const appLibEnvDir = resolve(projectRootDir, '../../packages/app-lib')
const apiClientSource = resolve(projectRootDir, '../../packages/api-client/src/index.ts')
// Load .env from app-lib manually instead of using Vite's envDir, which would auto-load .env.local and override values
const envFilePath = resolve(appLibEnvDir, '.env')
@@ -37,6 +38,10 @@ export default defineConfig({
},
resolve: {
alias: [
{
find: '@modrinth/api-client',
replacement: apiClientSource,
},
{
find: '@',
replacement: resolve(projectRootDir, 'src'),
+12 -1
View File
@@ -1,10 +1,15 @@
import { GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
import serverSidedVue from '@vitejs/plugin-vue'
import fs from 'fs/promises'
import { defineNuxtConfig } from 'nuxt/config'
import { fileURLToPath } from 'url'
import svgLoader from 'vite-svg-loader'
import { GenericModrinthClient, type Labrinth } from '../../packages/api-client/src/index.ts'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
const API_CLIENT_SOURCE = fileURLToPath(
new URL('../../packages/api-client/src/index.ts', import.meta.url),
)
const preloadedFonts = [
'inter/Inter-Regular.woff2',
@@ -24,6 +29,9 @@ const STAGING_MODRINTH_URL = 'https://staging.modrinth.com'
export default defineNuxtConfig({
srcDir: 'src/',
alias: {
'@modrinth/api-client': API_CLIENT_SOURCE,
},
app: {
head: {
htmlAttrs: {
@@ -82,6 +90,9 @@ export default defineNuxtConfig({
},
cacheDir: '../../node_modules/.vite/apps/knossos',
resolve: {
alias: {
'@modrinth/api-client': API_CLIENT_SOURCE,
},
dedupe: ['vue'],
},
plugins: [
-1
View File
@@ -38,7 +38,6 @@
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@intercom/messenger-js-sdk": "^0.0.14",
"@ltd/j-toml": "^1.38.0",
"@modrinth/api-client": "workspace:*",
"@modrinth/assets": "workspace:*",
+29
View File
@@ -761,9 +761,13 @@ import {
commonMessages,
commonProjectTypeCategoryMessages,
commonSettingsMessages,
createHostingIntercomIdentityKey,
defineMessages,
injectModrinthClient,
injectPageContext,
OverflowMenu,
providePageContext,
useHostingIntercom,
useVIntl,
} from '@modrinth/ui'
import TeleportOverflowMenu from '@modrinth/ui/src/components/base/TeleportOverflowMenu.vue'
@@ -808,6 +812,25 @@ const router = useNativeRouter()
const signInRouteObj = computed(() => getSignInRouteObj(route))
const link = config.public.siteUrl + route.path.replace(/\/+$/, '')
const client = injectModrinthClient()
const pageContext = injectPageContext()
const hostingIntercomActive = computed(() => route.path.startsWith('/hosting') && !!auth.value.user)
const hostingIntercomServerId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : rawId
})
const hostingIntercom = useHostingIntercom({
enabled: hostingIntercomActive,
appId: computed(() => config.public.intercomAppId),
fetchToken: fetchIntercomToken,
identityKey: computed(() =>
createHostingIntercomIdentityKey(auth.value.user, hostingIntercomServerId.value),
),
})
providePageContext({
...pageContext,
intercomBubble: hostingIntercom.intercomBubble,
})
const { data: payoutBalance } = useQuery({
queryKey: ['payout', 'balance'],
@@ -836,6 +859,12 @@ const showTinMismatchBanner = computed(() => {
const basePopoutId = useId()
async function fetchIntercomToken() {
return $fetch('/api/intercom/messenger-jwt', {
query: hostingIntercomServerId.value ? { server_id: hostingIntercomServerId.value } : {},
})
}
const navMenuMessages = defineMessages({
home: {
id: 'layout.nav.home',
@@ -9,8 +9,6 @@
:site-url="config.public.siteUrl as string"
:products="products"
:auth-user="authUser"
:fetch-intercom-token="fetchIntercomToken"
:intercom-app-id="config.public.intercomAppId as string"
:navigate-to-billing="() => router.push('/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks="
@@ -77,12 +75,6 @@ const authUser = auth.value?.user
}
: undefined
async function fetchIntercomToken(): Promise<{ token: string }> {
return $fetch('/api/intercom/messenger-jwt', {
query: { server_id: serverId },
})
}
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
return {
userId: auth.value?.user?.id ?? null,
@@ -4,6 +4,14 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useModalStack } from '../../composables/modal-stack'
import { injectPageContext } from '../../providers'
const visibleFloatingActionBars = new Set<symbol>()
function updateFloatingActionBarBodyClass() {
if (typeof document === 'undefined') return
document.body.classList.toggle('floating-action-bar-shown', visibleFloatingActionBars.size > 0)
}
const props = defineProps<{
shown: boolean
ariaLabel?: string
@@ -20,6 +28,7 @@ const compact = ref(false)
const { stackCount } = useModalStack()
const pageContext = injectPageContext(null)
const shown = computed(() => props.shown && (!props.hideWhenModalOpen || stackCount.value === 0))
const floatingActionBarId = Symbol('floating-action-bar')
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
const leftOffset = computed(
@@ -60,7 +69,13 @@ function updateIntercomBubbleClearance() {
const intercomBubble = pageContext?.intercomBubble
if (!intercomBubble) return
if (typeof window === 'undefined' || !shown.value || !barEl.value || !toolbarEl.value) {
if (
typeof window === 'undefined' ||
!shown.value ||
stackCount.value > 0 ||
!barEl.value ||
!toolbarEl.value
) {
clearIntercomBubbleClearance()
return
}
@@ -86,7 +101,13 @@ function updateIntercomBubbleClearance() {
function updateBodyState(isShown = shown.value) {
if (typeof document === 'undefined') return
document.body.classList.toggle('floating-action-bar-shown', isShown)
if (isShown) {
visibleFloatingActionBars.add(floatingActionBarId)
} else {
visibleFloatingActionBars.delete(floatingActionBarId)
}
updateFloatingActionBarBodyClass()
if (!isShown) {
clearIntercomBubbleClearance()
}
@@ -138,6 +159,7 @@ watch(
shown,
leftOffset,
rightOffset,
stackCount,
() => pageContext?.intercomBubble?.horizontalPadding.value,
() => pageContext?.intercomBubble?.width.value,
],
@@ -157,8 +179,9 @@ onUnmounted(() => {
window.cancelAnimationFrame(updateFrame)
}
clearIntercomBubbleClearance()
visibleFloatingActionBars.delete(floatingActionBarId)
if (typeof document === 'undefined') return
document.body.classList.remove('floating-action-bar-shown')
updateFloatingActionBarBodyClass()
})
</script>
@@ -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<boolean>
appId: MaybeRefOrGetter<string | undefined>
fetchToken: FetchIntercomToken
identityKey: MaybeRefOrGetter<string | null | undefined>
horizontalPadding?: MaybeRefOrGetter<number | undefined>
}
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<symbol, number>()
const verticalClearanceRequests = new Map<symbol, number>()
const requestedHorizontalPadding = ref<number | null>(null)
const requestedVerticalClearance = ref<number | null>(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<symbol, number>,
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),
},
}
}
+1
View File
@@ -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'
@@ -343,7 +343,6 @@
</template>
<script setup lang="ts">
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
import type { Archon, Labrinth } from '@modrinth/api-client'
import { ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
import {
@@ -431,8 +430,6 @@ const props = withDefaults(
siteUrl?: string
products?: Labrinth.Billing.Internal.Product[]
authUser?: { id: string; username: string; email: string; created: string }
fetchIntercomToken?: () => Promise<{ token: string }>
intercomAppId?: string
navigateToBilling?: () => void
navigateToServers?: () => void
browseModpacks?: (args: {
@@ -455,8 +452,6 @@ const props = withDefaults(
siteUrl: undefined,
products: () => [],
authUser: undefined,
fetchIntercomToken: undefined,
intercomAppId: 'ykeritl9',
navigateToBilling: undefined,
navigateToServers: undefined,
browseModpacks: undefined,
@@ -1360,18 +1355,11 @@ function initializeServer() {
}
}
let intercomInitialized = false
const cleanup = () => {
isMounted.value = false
saveWsStateToCache()
if (intercomInitialized) {
shutdown()
intercomInitialized = false
}
cleanupCoreRuntime(props.serverId)
isReconnecting.value = false
@@ -1399,53 +1387,6 @@ onMounted(() => {
})
}
const tryInitIntercom = () => {
if (intercomInitialized) return
if (!props.authUser || !props.fetchIntercomToken) {
console.debug('[PYROSERVERS][INTERCOM] waiting for auth user and token fetcher', {
hasAuthUser: !!props.authUser,
hasFetchIntercomToken: !!props.fetchIntercomToken,
})
return
}
intercomInitialized = true
console.debug('[PYROSERVERS][INTERCOM] initializing secure support chat')
props
.fetchIntercomToken()
.then(({ token }) => {
console.debug('[PYROSERVERS][INTERCOM] fetched messenger JWT, booting widget')
Intercom({
app_id: props.intercomAppId!,
intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24,
})
window.setTimeout(() => {
const hasWidget = !!document.querySelector(
'.intercom-lightweight-app, #intercom-container, #intercom-frame',
)
if (!hasWidget) {
console.warn(
'[PYROSERVERS][INTERCOM] boot completed but no Intercom widget was detected',
)
}
}, 2500)
})
.catch((error) => {
intercomInitialized = false
console.warn('[PYROSERVERS][INTERCOM] failed to initialize secure support chat', error)
})
}
tryInitIntercom()
const stopIntercomWatch = watch(
() => props.authUser,
(user) => {
if (user) {
tryInitIntercom()
stopIntercomWatch()
}
},
)
DOMPurify.addHook(
'afterSanitizeAttributes',
(node: {
-6
View File
@@ -71,9 +71,6 @@ importers:
apps/app-frontend:
dependencies:
'@intercom/messenger-js-sdk':
specifier: ^0.0.14
version: 0.0.14
'@modrinth/api-client':
specifier: workspace:^
version: link:../../packages/api-client
@@ -257,9 +254,6 @@ importers:
'@formatjs/intl-localematcher':
specifier: ^0.5.4
version: 0.5.10
'@intercom/messenger-js-sdk':
specifier: ^0.0.14
version: 0.0.14
'@ltd/j-toml':
specifier: ^1.38.0
version: 1.38.0