fix: intercom bubble positioning with bulk action bars (#5952)

* fix: intercom bubble positioning + action bar positioning with app sidebar

* fix: docs

* del: story
This commit is contained in:
Calum H.
2026-04-30 18:04:39 +01:00
committed by GitHub
parent ea723f719c
commit 7e149e1cf1
4 changed files with 245 additions and 37 deletions

View File

@@ -86,6 +86,7 @@ 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'
@@ -125,6 +126,17 @@ import { AppNotificationManager } from './providers/app-notifications'
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 notificationManager = new AppNotificationManager()
provideNotificationManager(notificationManager)
@@ -158,6 +170,7 @@ provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
...intercomBubblePositioning.pageContext,
featureFlags: {
serverRamAsBytesAlwaysOn: computed(() =>
themeStore.getFeatureFlag('server_ram_as_bytes_always_on'),
@@ -242,6 +255,7 @@ onUnmounted(async () => {
document.querySelector('body').removeEventListener('click', handleClick)
document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
shutdownHostingIntercom()
clearIntercomBubbleStyles()
await unlistenUpdateDownload?.()
})
@@ -423,9 +437,6 @@ const handleClose = async () => {
await getCurrentWindow().close()
}
const router = useRouter()
const route = useRoute()
const loading = setupLoadingStateProvider()
loading.setEnabled(false)
let initialLoadToken = loading.begin()
@@ -643,22 +654,10 @@ const hasPlus = computed(
(credentials.value.user.badges & MIDAS_BITFLAG) === MIDAS_BITFLAG,
)
const sidebarToggled = ref(true)
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 showAd = computed(
() => sidebarVisible.value && !hasPlus.value && credentials.value !== undefined,
)
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
const INTERCOM_DEFAULT_PADDING = 20
const INTERCOM_APP_SIDEBAR_WIDTH = 300
let intercomBooting = false
let intercomBooted = false
@@ -706,10 +705,8 @@ async function bootIntercom() {
intercom_user_jwt: token,
session_duration: 1000 * 60 * 60 * 24,
alignment: 'right',
horizontal_padding: sidebarVisible.value
? INTERCOM_APP_SIDEBAR_WIDTH + INTERCOM_DEFAULT_PADDING
: INTERCOM_DEFAULT_PADDING,
vertical_padding: INTERCOM_DEFAULT_PADDING,
horizontal_padding: intercomBubblePosition.value.horizontalPadding,
vertical_padding: intercomBubblePosition.value.verticalPadding,
})
intercomBooted = true
} catch (error) {
@@ -727,14 +724,13 @@ function shutdownHostingIntercom() {
}
watch(
sidebarVisible,
(visible) => {
intercomBubblePosition,
(position) => {
updateIntercomBubbleStyles(position)
if (intercomBooted) {
window.Intercom?.('update', {
horizontal_padding: visible
? INTERCOM_APP_SIDEBAR_WIDTH + INTERCOM_DEFAULT_PADDING
: INTERCOM_DEFAULT_PADDING,
vertical_padding: INTERCOM_DEFAULT_PADDING,
horizontal_padding: position.horizontalPadding,
vertical_padding: position.verticalPadding,
})
}
},
@@ -1772,6 +1768,14 @@ 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;

View File

@@ -0,0 +1,100 @@
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 intercomBubbleHorizontalPadding = computed(() =>
sidebarVisible.value
? APP_SIDEBAR_WIDTH + INTERCOM_BUBBLE_DEFAULT_PADDING
: INTERCOM_BUBBLE_DEFAULT_PADDING,
)
const intercomBubbleVerticalClearance = ref<number | null>(null)
const intercomBubblePosition = computed(() => ({
horizontalPadding: intercomBubbleHorizontalPadding.value,
verticalPadding: intercomBubbleVerticalClearance.value ?? INTERCOM_BUBBLE_DEFAULT_PADDING,
}))
const intercomBubbleClearanceRequests = new Map<symbol, number>()
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,
requestVerticalClearance: requestIntercomBubbleVerticalClearance,
},
},
}
}

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useModalStack } from '../../composables/modal-stack'
import { injectPageContext } from '../../providers'
const props = defineProps<{
shown: boolean
@@ -9,11 +10,28 @@ const props = defineProps<{
belowModal?: boolean
}>()
const INTERCOM_BUBBLE_GAP = 8
const barEl = ref<HTMLElement | null>(null)
const toolbarEl = ref<HTMLElement | null>(null)
const compact = ref(false)
const { stackCount } = useModalStack()
const pageContext = injectPageContext(null)
const shown = computed(() => props.shown)
const intercomBubbleClearanceRequestId = Symbol('floating-action-bar')
const zIndex = computed(() => 100 + stackCount.value * 10 + 8 + (!props.belowModal ? 1 : 0))
const leftOffset = computed(
() => pageContext?.floatingActionBarOffsets?.left.value ?? 'var(--left-bar-width, 0px)',
)
const rightOffset = computed(
() => pageContext?.floatingActionBarOffsets?.right.value ?? 'var(--right-bar-width, 0px)',
)
const barStyle = computed(() => ({
zIndex: zIndex.value,
'--floating-action-bar-left-offset': leftOffset.value,
'--floating-action-bar-right-offset': rightOffset.value,
}))
function checkCompact() {
const el = toolbarEl.value
@@ -33,7 +51,60 @@ function checkCompact() {
compact.value = needsCompact
}
function clearIntercomBubbleClearance() {
pageContext?.intercomBubble?.requestVerticalClearance(intercomBubbleClearanceRequestId, null)
}
function updateIntercomBubbleClearance() {
const intercomBubble = pageContext?.intercomBubble
if (!intercomBubble) return
if (typeof window === 'undefined' || !shown.value || !barEl.value || !toolbarEl.value) {
clearIntercomBubbleClearance()
return
}
const barRect = barEl.value.getBoundingClientRect()
const toolbarRight = barRect.left + toolbarEl.value.offsetLeft + toolbarEl.value.offsetWidth
const bubbleLeft =
window.innerWidth - intercomBubble.horizontalPadding.value - intercomBubble.width.value
if (toolbarRight + INTERCOM_BUBBLE_GAP <= bubbleLeft) {
clearIntercomBubbleClearance()
return
}
const barStyle = window.getComputedStyle(barEl.value)
const bottomOffset = Number.parseFloat(barStyle.bottom) || 0
intercomBubble.requestVerticalClearance(
intercomBubbleClearanceRequestId,
Math.ceil(bottomOffset + barEl.value.offsetHeight + INTERCOM_BUBBLE_GAP),
)
}
function updateBodyState(shown = props.shown) {
if (typeof document === 'undefined') return
document.body.classList.toggle('floating-action-bar-shown', shown)
if (!shown) {
clearIntercomBubbleClearance()
}
}
let observer: ResizeObserver | null = null
let updateFrame: number | null = null
function scheduleIntercomBubbleClearanceUpdate() {
if (typeof window === 'undefined') return
if (updateFrame !== null) {
window.cancelAnimationFrame(updateFrame)
}
updateFrame = window.requestAnimationFrame(() => {
updateFrame = null
updateIntercomBubbleClearance()
})
}
watch(
toolbarEl,
@@ -42,24 +113,51 @@ watch(
if (!el) return
observer = new ResizeObserver(() => {
checkCompact()
scheduleIntercomBubbleClearanceUpdate()
})
observer.observe(el.parentElement!)
checkCompact()
scheduleIntercomBubbleClearanceUpdate()
},
{ immediate: true },
)
watch(
() => props.shown,
(shown) => {
document?.body.classList.toggle('floating-action-bar-shown', shown)
async (shown) => {
await nextTick()
updateBodyState(shown)
scheduleIntercomBubbleClearanceUpdate()
},
{ immediate: true },
)
watch(
[
shown,
leftOffset,
rightOffset,
() => pageContext?.intercomBubble?.horizontalPadding.value,
() => pageContext?.intercomBubble?.width.value,
],
() => scheduleIntercomBubbleClearanceUpdate(),
{ immediate: true },
)
onMounted(() => {
window.addEventListener('resize', scheduleIntercomBubbleClearanceUpdate)
scheduleIntercomBubbleClearanceUpdate()
})
onUnmounted(() => {
observer?.disconnect()
document?.body.classList.remove('floating-action-bar-shown')
window.removeEventListener('resize', scheduleIntercomBubbleClearanceUpdate)
if (updateFrame !== null) {
window.cancelAnimationFrame(updateFrame)
}
clearIntercomBubbleClearance()
if (typeof document === 'undefined') return
document.body.classList.remove('floating-action-bar-shown')
})
</script>
@@ -68,8 +166,9 @@ onUnmounted(() => {
<Transition name="floating-action-bar" appear>
<div
v-if="shown"
ref="barEl"
class="floating-action-bar drop-shadow-2xl fixed p-4 bottom-0"
:style="{ zIndex }"
:style="barStyle"
aria-live="polite"
>
<div
@@ -88,8 +187,8 @@ onUnmounted(() => {
<style scoped>
.floating-action-bar {
left: var(--left-bar-width, 0px);
right: var(--right-bar-width, 0px);
left: var(--floating-action-bar-left-offset, var(--left-bar-width, 0px));
right: var(--floating-action-bar-right-offset, var(--right-bar-width, 0px));
transition: bottom 0.25s ease-in-out;
}
@@ -127,10 +226,6 @@ onUnmounted(() => {
</style>
<style>
.intercom-lightweight-app-launcher {
z-index: 9 !important;
}
.bar-compact .bar-label {
display: none;
}

View File

@@ -1,4 +1,4 @@
import type { Ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import { createContext } from '.'
@@ -6,6 +6,15 @@ export interface PageContext {
// pages may render sidebar content in #sidebar-teleport-target instead of in the main layout when true
hierarchicalSidebarAvailable: Ref<boolean>
showAds: Ref<boolean>
floatingActionBarOffsets?: {
left: Ref<string> | ComputedRef<string>
right: Ref<string> | ComputedRef<string>
}
intercomBubble?: {
width: Ref<number> | ComputedRef<number>
horizontalPadding: Ref<number> | ComputedRef<number>
requestVerticalClearance: (id: symbol, clearance: number | null) => void
}
featureFlags?: {
serverRamAsBytesAlwaysOn?: Ref<boolean>
}