diff --git a/.changeset/29458-native-connection-switcher.md b/.changeset/29458-native-connection-switcher.md new file mode 100644 index 00000000000..793d1292bfe --- /dev/null +++ b/.changeset/29458-native-connection-switcher.md @@ -0,0 +1,16 @@ +--- +'@object-ui/mobile': patch +'@object-ui/app-shell': patch +--- + +feat(mobile, app-shell): inside the ObjectStack mobile shell, the header's organization button opens the shell's native connection switcher, and the Console answers the shell's `connectionRemoved` event + +Clause-②: yes (widening) +Metadata-protocol: none + +The mobile shell's bridge (`window.ObjectStackNative`) gained `showConnectionSwitcher()` and the `objectstack-native:connectionRemoved` window event. The Console now uses both: + +- **Organization button.** When the shell announces `has('showConnectionSwitcher')`, `WorkspaceSwitcher`'s button calls `showConnectionSwitcher()`, which opens the shell's own sheet of saved organizations and company servers. The web organization menu does not open. In the shell the button renders whenever there is an active organization, at one membership too, since the sheet lists the shell's connections across company servers; `CurrentOrganizationIndicator` then renders nothing, so the organization is drawn once. In a plain browser, or in a shell that does not announce the capability, both render as before. +- **Removed connection.** `useNativeHostRouting` listens for `objectstack-native:connectionRemoved` while the bridge is present and calls `event.waitUntil()` with the promise its new optional `onConnectionRemoved` returns. Omitted, the promise is already resolved, so the shell is released at once. No Console path registers a device push token yet, so there is nothing to unregister today. The option is where that unregister step goes once registration exists. + +`@object-ui/mobile` adds `showConnectionSwitcher` to `NativeHostMethod` and, as an optional method (an older shell does not carry it), to `ObjectStackNative`, and exports `nativeShowConnectionSwitcher()`, `nativeHostCan(host, method)` (the wrappers' own feature-detect), `NATIVE_CONNECTION_REMOVED_EVENT` and the `NativeConnectionRemovedEvent` type. `@object-ui/app-shell` now depends on `@object-ui/mobile`. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff1f34e5f6d..e51617f1d1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3915,6 +3915,9 @@ importers: '@object-ui/layout': specifier: workspace:* version: link:../layout + '@object-ui/mobile': + specifier: workspace:* + version: link:../mobile '@object-ui/permissions': specifier: workspace:* version: link:../permissions diff --git a/ui/packages/app-shell/package.json b/ui/packages/app-shell/package.json index 31e8966668c..1424162b689 100644 --- a/ui/packages/app-shell/package.json +++ b/ui/packages/app-shell/package.json @@ -83,6 +83,7 @@ "@object-ui/fields": "workspace:*", "@object-ui/i18n": "workspace:*", "@object-ui/layout": "workspace:*", + "@object-ui/mobile": "workspace:*", "@object-ui/permissions": "workspace:*", "@object-ui/plugin-editor": "workspace:*", "@object-ui/providers": "workspace:*", diff --git a/ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx b/ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx index 0166a61a935..a89e9b98187 100644 --- a/ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx +++ b/ui/packages/app-shell/src/layout/CurrentOrganizationIndicator.tsx @@ -33,6 +33,10 @@ * `@objectstack/spec/security`, which measured +237 KB on the eager chunk. That * file's docblock carries the measurement and the reasoning. * + * Inside the mobile shell, when it announces `showConnectionSwitcher`, this + * renders nothing: `WorkspaceSwitcher` then draws the same badge and name, at + * the same widths, as the button that opens the shell's native switcher. + * * An absent or unrecognized posture (older server, config fetch still in * flight or failed) renders nothing, matching how every other posture-keyed * affordance in this repo fails toward today's rendering. @@ -40,6 +44,7 @@ import { useAuth } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; +import { nativeHostCan, useNativeHost } from '@object-ui/mobile'; import { OrgBadge } from './OrgBadge.js'; import { postureHasOrgWall, useTenancyPosture } from '../hooks/useTenancyPosture.js'; @@ -47,6 +52,10 @@ export function CurrentOrganizationIndicator() { const { t } = useObjectTranslation(); const { organizations, activeOrganization } = useAuth(); const posture = useTenancyPosture(); + const nativeSwitcher = nativeHostCan(useNativeHost(), 'showConnectionSwitcher'); + + // The shell's switcher button carries the organization instead. + if (nativeSwitcher) return null; // No organization wall in force ⇒ the top bar stays organization-silent, // exactly as it is today. Also covers the not-yet-resolved config. diff --git a/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx b/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx index 2289fa10ff3..f9e435e4b40 100644 --- a/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx +++ b/ui/packages/app-shell/src/layout/WorkspaceSwitcher.tsx @@ -4,9 +4,10 @@ * Header-left organization (workspace) switcher — the standard place users * expect "which org am I in / switch org" to live (Linear/Vercel/GitHub style). * - * - Single-org users (the vast majority): render NOTHING. With one org there is - * nothing to switch to, so a dropdown here would control nothing. The - * switcher is a multi-org affordance. Where a tenancy wall IS in force the + * - Single-org users (the vast majority): render NOTHING on the web. With one + * org there is nothing to switch to, so a dropdown here would control + * nothing. The web switcher is a multi-org affordance (the mobile shell's + * native switcher, below, is not). Where a tenancy wall IS in force the * org name still matters at one membership — `CurrentOrganizationIndicator` * renders it read-only there (objectui#5287), without a control. * - Multi-org users: the active org name + a dropdown to switch orgs inline @@ -14,6 +15,14 @@ * OrganizationsPage), plus shortcuts to manage members / create a workspace. * - No org context at all: renders nothing. * + * Inside the ObjectStack mobile shell (`@object-ui/mobile`), when the shell + * announces `showConnectionSwitcher`, the button opens the shell's native + * switcher instead of the dropdown, and it renders whenever there is an active + * organization, at any membership count: the sheet lists the connections the + * shell holds across company servers, which this server's membership list + * cannot count. In a plain browser, or a shell without the capability, the + * rules above apply unchanged. + * * Under `group` tenancy posture (ADR-0105) the switcher's meaning changes: * the active organization is only the WRITE target — reads span every * organization the member belongs to — so the dropdown labels it as the @@ -25,6 +34,7 @@ import { useNavigate } from 'react-router-dom'; import { useAuth } from '@object-ui/auth'; import type { AuthOrganization } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; +import { nativeHostCan, nativeShowConnectionSwitcher, useNativeHost } from '@object-ui/mobile'; import { DropdownMenu, DropdownMenuTrigger, @@ -45,6 +55,7 @@ export function WorkspaceSwitcher() { const { organizations, activeOrganization, switchOrganization, getAuthConfig } = useAuth(); const [multiOrgDisabled, setMultiOrgDisabled] = useState(false); const isGroupPosture = useTenancyPosture() === 'group'; + const nativeSwitcher = nativeHostCan(useNativeHost(), 'showConnectionSwitcher'); useEffect(() => { let cancelled = false; @@ -71,6 +82,38 @@ export function WorkspaceSwitcher() { // rather than an empty switcher or a guessed name. if (!current) return null; + const triggerClassName = + 'ml-2 inline-flex max-w-[14rem] items-center gap-1.5 rounded-md px-1.5 py-1 text-sm font-medium text-foreground/80 transition-colors hover:bg-accent hover:text-foreground'; + const triggerContent = ( + <> + + {current.name} + + + ); + + // The shell's sheet replaces the web menu; switching there is the shell's + // own flow, so nothing is sent from here. + if (nativeSwitcher) { + return ( + + ); + } + // Single-org: render nothing. With only one org there's nothing to switch to, // so a dropdown here would be a control that controls nothing. The switcher // appears only once a second org exists. On a walled deployment the org NAME @@ -102,13 +145,8 @@ export function WorkspaceSwitcher() { return ( - - - {current.name} - + + {triggerContent} diff --git a/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx b/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx new file mode 100644 index 00000000000..5e16af21092 --- /dev/null +++ b/ui/packages/app-shell/src/layout/__tests__/WorkspaceSwitcher.nativeConnectionSwitcher.test.tsx @@ -0,0 +1,208 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * WorkspaceSwitcher inside the ObjectStack mobile shell. + * + * When the shell's bridge (`window.ObjectStackNative`, read through + * `@object-ui/mobile`) announces `showConnectionSwitcher`, the header's + * organization button opens the shell's native switcher and the web menu does + * not open. With no bridge, or a bridge without that capability, the web menu + * opens exactly as before. The dropdown is the real one, opened by a real + * click, so "the web menu opens" is observed, not assumed. + * + * In the shell the button renders at ONE membership too: the sheet lists the + * shell's connections across servers, which this server's membership count + * cannot see. On a walled deployment `CurrentOrganizationIndicator` then steps + * aside, so the header carries the organization once, with the name shown at + * the same widths the indicator showed it. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + }), +})); + +vi.mock('react-router-dom', async (importOriginal) => ({ + ...(await importOriginal>()), + useNavigate: () => vi.fn(), +})); + +let authState: Record; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => authState, +})); + +import { NATIVE_HOST_GLOBAL } from '@object-ui/mobile'; +import { WorkspaceSwitcher } from '../WorkspaceSwitcher'; +import { CurrentOrganizationIndicator } from '../CurrentOrganizationIndicator'; +import { useTenancyPosture } from '../../hooks/useTenancyPosture'; + +const ORGS = [ + { id: 'org_a', name: 'Plant A', slug: 'plant-a' }, + { id: 'org_b', name: 'Plant B', slug: 'plant-b' }, +]; + +const BASE_METHODS = ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer']; + +function installBridge(capabilities: string[]) { + const showConnectionSwitcher = vi.fn(async () => null); + (window as unknown as Record)[NATIVE_HOST_GLOBAL] = { + version: 1, + platform: 'ios', + appVersion: '2.1.0', + capabilities, + safeArea: { top: 47, bottom: 34, left: 0, right: 0 }, + has: (capability: string) => capabilities.includes(capability), + getPushToken: vi.fn(async () => null), + haptic: vi.fn(async () => null), + openExternal: vi.fn(async () => null), + navigate: vi.fn(async () => null), + resetServer: vi.fn(async () => null), + showConnectionSwitcher, + ready: vi.fn(), + routeChanged: vi.fn(), + }; + return showConnectionSwitcher; +} + +beforeEach(() => { + delete (window as unknown as Record)[NATIVE_HOST_GLOBAL]; + authState = { + organizations: ORGS, + activeOrganization: ORGS[0], + switchOrganization: vi.fn(), + getAuthConfig: vi.fn().mockResolvedValue({ features: { multiOrgEnabled: true } }), + }; +}); + +afterEach(() => { + cleanup(); + delete (window as unknown as Record)[NATIVE_HOST_GLOBAL]; +}); + +describe('WorkspaceSwitcher in the mobile shell', () => { + it('with showConnectionSwitcher announced, the button opens the native switcher and no web menu', async () => { + const showConnectionSwitcher = installBridge([...BASE_METHODS, 'showConnectionSwitcher']); + render(); + + const button = screen.getByTestId('workspace-switcher'); + expect(button).toHaveAttribute('data-native-switcher'); + expect(button).not.toHaveAttribute('aria-haspopup'); + + await userEvent.click(button); + + expect(showConnectionSwitcher).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + expect(screen.queryByText('Plant B')).not.toBeInTheDocument(); + expect(authState.switchOrganization).not.toHaveBeenCalled(); + }); + + it('control: with no bridge, the button opens the web organization menu', async () => { + render(); + + const button = screen.getByTestId('workspace-switcher'); + expect(button).not.toHaveAttribute('data-native-switcher'); + + await userEvent.click(button); + + expect(await screen.findByRole('menu')).toBeInTheDocument(); + expect(screen.getByText('Switch organization')).toBeInTheDocument(); + expect(screen.getByText('Plant B')).toBeInTheDocument(); + }); + + it('control: a shell without the capability keeps the web menu and never calls the native switcher', async () => { + const showConnectionSwitcher = installBridge(BASE_METHODS); + render(); + + const button = screen.getByTestId('workspace-switcher'); + expect(button).not.toHaveAttribute('data-native-switcher'); + + await userEvent.click(button); + + expect(await screen.findByRole('menu')).toBeInTheDocument(); + expect(screen.getByText('Plant B')).toBeInTheDocument(); + expect(showConnectionSwitcher).not.toHaveBeenCalled(); + }); +}); + +describe('WorkspaceSwitcher in the mobile shell at one membership (walled deployment)', () => { + /** AppHeader's pair, in its order, plus the resolved posture to wait on. */ + function PostureProbe() { + return {String(useTenancyPosture() ?? 'unresolved')}; + } + function mountHeaderPair() { + return render( + <> + + + + , + ); + } + + beforeEach(() => { + authState = { + organizations: [ORGS[0]], + activeOrganization: ORGS[0], + switchOrganization: vi.fn(), + getAuthConfig: vi.fn().mockResolvedValue({ features: { multiOrgEnabled: true, tenancyPosture: 'isolated' } }), + }; + }); + + /** The name span of whichever surface carries the organization. */ + function nameSpan(container: HTMLElement): HTMLElement { + const spans = Array.from(container.querySelectorAll('span')).filter((el) => el.textContent === ORGS[0].name); + expect(spans).toHaveLength(1); + return spans[0] as HTMLElement; + } + + it('with the capability, the native button renders, opens the sheet, and the read-only indicator steps aside', async () => { + const showConnectionSwitcher = installBridge([...BASE_METHODS, 'showConnectionSwitcher']); + const { container } = mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('posture')).toHaveTextContent('isolated')); + + const button = screen.getByTestId('workspace-switcher'); + expect(button).toHaveAttribute('data-native-switcher'); + expect(screen.queryByTestId('current-organization-indicator')).not.toBeInTheDocument(); + // The organization is drawn once. + expect(nameSpan(container).closest('[data-testid="workspace-switcher"]')).toBe(button); + + await userEvent.click(button); + expect(showConnectionSwitcher).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('the native button shows the name at the same widths the indicator did', async () => { + const plain = mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument()); + const indicatorNameClass = nameSpan(plain.container).className; + plain.unmount(); + + installBridge([...BASE_METHODS, 'showConnectionSwitcher']); + const shell = mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('posture')).toHaveTextContent('isolated')); + expect(nameSpan(shell.container).className).toBe(indicatorNameClass); + }); + + it('control: with no bridge, one membership draws no switcher and the read-only indicator, as today', async () => { + mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument()); + expect(screen.queryByTestId('workspace-switcher')).not.toBeInTheDocument(); + }); + + it('control: a shell without the capability keeps today\'s single-membership rendering', async () => { + const showConnectionSwitcher = installBridge(BASE_METHODS); + mountHeaderPair(); + await waitFor(() => expect(screen.getByTestId('current-organization-indicator')).toBeInTheDocument()); + expect(screen.queryByTestId('workspace-switcher')).not.toBeInTheDocument(); + expect(showConnectionSwitcher).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/mobile/README.md b/ui/packages/mobile/README.md index 5669c0f6a90..7b32b6d51ef 100644 --- a/ui/packages/mobile/README.md +++ b/ui/packages/mobile/README.md @@ -330,15 +330,23 @@ shell; the bridge contract is owned by the mobile app (`docs/HYBRID-SHELL.md`, change, and the shell's cancelable `objectstack-native:navigate` window event moves the router (in-app paths only) instead of reloading the page. Outside the shell it installs nothing. +- The same hook answers the shell's `objectstack-native:connectionRemoved` window + event (`NATIVE_CONNECTION_REMOVED_EVENT`), sent when the user removes this + connection in the shell: it calls `event.waitUntil()` with the promise its optional + `onConnectionRemoved` returns (unregister the device's push token under the page's + own session), or with one already resolved when the option is omitted. - `ready()` means "the page has painted its own UI", which may be a loading fallback, such as an auth guard's spinner while the session is read. The shell lifts its loading cover on it, so the cover hands over to that spinner instead of staying up through the session round-trip. -- `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate` and - `nativeResetServer` check `has(capability)` and that the method exists before - calling. They never throw: each resolves `{ status: 'unavailable' }` without the - capability, `{ status: 'ok', value }` on success, or - `{ status: 'failed', code, message }` when the shell refuses the call. +- `nativeGetPushToken`, `nativeHaptic`, `nativeOpenExternal`, `nativeNavigate`, + `nativeResetServer` and `nativeShowConnectionSwitcher` check `has(capability)` and + that the method exists before calling. They never throw: each resolves + `{ status: 'unavailable' }` without the capability, `{ status: 'ok', value }` on + success, or `{ status: 'failed', code, message }` when the shell refuses the call. +- `nativeHostCan(host, method)` is that same check, for a control that picks its + native or web path before it renders (the header's organization button opens the + shell's switcher this way). ```tsx import { nativeHaptic } from '@object-ui/mobile'; diff --git a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx index 9a15880461c..a5104e7ce83 100644 --- a/ui/packages/mobile/src/__tests__/nativeHost.test.tsx +++ b/ui/packages/mobile/src/__tests__/nativeHost.test.tsx @@ -21,6 +21,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vite import { render, cleanup, act } from '@testing-library/react'; import { NATIVE_BRIDGE_VERSION, + NATIVE_CONNECTION_REMOVED_EVENT, NATIVE_HOST_GLOBAL, NATIVE_NAVIGATE_EVENT, getNativeHost, @@ -29,8 +30,10 @@ import { nativeHaptic, nativeNavigate, nativeOpenExternal, + nativeHostCan, nativeResetServer, nativeScan, + nativeShowConnectionSwitcher, postNativeReady, postNativeRouteChanged, resetNativeHostEventsForTests, @@ -44,7 +47,15 @@ import { useNativeHostRouting, } from '../useNativeHost'; -const ALL_METHODS: NativeHostMethod[] = ['getPushToken', 'haptic', 'openExternal', 'navigate', 'resetServer', 'scan']; +const ALL_METHODS: NativeHostMethod[] = [ + 'getPushToken', + 'haptic', + 'openExternal', + 'navigate', + 'resetServer', + 'scan', + 'showConnectionSwitcher', +]; function fakeBridge(capabilities: readonly string[] = ALL_METHODS): ObjectStackNative & { ready: Mock<() => void>; @@ -63,6 +74,7 @@ function fakeBridge(capabilities: readonly string[] = ALL_METHODS): ObjectStackN navigate: vi.fn(async () => null), resetServer: vi.fn(async () => null), scan: vi.fn(async () => 'ASSET-0042'), + showConnectionSwitcher: vi.fn(async () => null), ready: vi.fn<() => void>(), routeChanged: vi.fn<(route: string) => void>(), }; @@ -227,6 +239,7 @@ describe('typed call wrappers feature-detect and never throw', () => { ['navigate', () => nativeNavigate('/home')], ['resetServer', () => nativeResetServer()], ['scan', () => nativeScan()], + ['showConnectionSwitcher', () => nativeShowConnectionSwitcher()], ]; it.each(calls)('%s resolves "unavailable" without a bridge', async (_method, call) => { @@ -257,6 +270,17 @@ describe('typed call wrappers feature-detect and never throw', () => { expect(bridge.openExternal).toHaveBeenCalledWith('mailto:a@example.com'); await nativeNavigate('/apps/crm'); expect(bridge.navigate).toHaveBeenCalledWith('/apps/crm'); + await expect(nativeShowConnectionSwitcher()).resolves.toEqual({ status: 'ok', value: null }); + expect(bridge.showConnectionSwitcher).toHaveBeenCalledTimes(1); + }); + + it('nativeHostCan answers what a wrapper would: announced and carried, nothing else', () => { + expect(nativeHostCan(null, 'showConnectionSwitcher')).toBe(false); + expect(nativeHostCan(fakeBridge(), 'showConnectionSwitcher')).toBe(true); + expect(nativeHostCan(fakeBridge(ALL_METHODS.filter((m) => m !== 'showConnectionSwitcher')), 'showConnectionSwitcher')).toBe(false); + const missing = fakeBridge() as unknown as Record; + delete missing.showConnectionSwitcher; + expect(nativeHostCan(missing as unknown as ObjectStackNative, 'showConnectionSwitcher')).toBe(false); }); it('scan resolves "ok" with the decoded text, forwards the formats, and keeps the bridge as `this`', async () => { @@ -439,6 +463,95 @@ describe('useNativeHostRouting', () => { }); }); +describe('connectionRemoved', () => { + function Router({ onConnectionRemoved }: { onConnectionRemoved?: () => Promise }) { + useNativeHostRouting({ route: '/home', navigate: () => {}, onConnectionRemoved }); + return null; + } + + /** The shell's event: a window Event carrying `waitUntil`, which records the promise. */ + function removedEvent(): { event: Event; held: Promise[] } { + const held: Promise[] = []; + const event = Object.assign(new Event(NATIVE_CONNECTION_REMOVED_EVENT), { + waitUntil: vi.fn((promise: Promise) => { + held.push(promise); + }), + }); + return { event, held }; + } + + it('with no unregister step, the shell is held by a promise that resolves at once', async () => { + install(fakeBridge()); + render(); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + expect(held).toHaveLength(1); + await expect(held[0]).resolves.toBeUndefined(); + }); + + it('with an unregister step, the shell is held until it settles, and the step runs once', async () => { + install(fakeBridge()); + let finish!: () => void; + const unregister = vi.fn(() => new Promise((resolve) => (finish = resolve))); + render(); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + expect(held).toHaveLength(1); + + let settled = false; + void held[0].then(() => (settled = true)); + await act(async () => {}); + expect(unregister).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + finish(); + await held[0]; + expect(settled).toBe(true); + }); + + it('the latest handler runs, without re-installing the listener per render', () => { + install(fakeBridge()); + const add = vi.spyOn(window, 'addEventListener'); + const first = vi.fn(async () => {}); + const second = vi.fn(async () => {}); + const { rerender } = render(); + rerender(); + expect(add.mock.calls.filter(([type]) => type === NATIVE_CONNECTION_REMOVED_EVENT)).toHaveLength(1); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + return held[0].then(() => { + expect(second).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + }); + }); + + it('an event without waitUntil is left alone', () => { + install(fakeBridge()); + const unregister = vi.fn(async () => {}); + render(); + window.dispatchEvent(new Event(NATIVE_CONNECTION_REMOVED_EVENT)); + expect(unregister).not.toHaveBeenCalled(); + }); + + it('control: outside shell mode no connectionRemoved listener is installed', () => { + const add = vi.spyOn(window, 'addEventListener'); + render(); + expect(add.mock.calls.some(([type]) => type === NATIVE_CONNECTION_REMOVED_EVENT)).toBe(false); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + expect(held).toHaveLength(0); + }); + + it('removes its listener on unmount', () => { + install(fakeBridge()); + const { unmount } = render(); + unmount(); + const { event, held } = removedEvent(); + window.dispatchEvent(event); + expect(held).toHaveLength(0); + }); +}); + describe('isInAppRoute', () => { it('accepts a single-slash path with query and hash, refuses escapes', () => { expect(isInAppRoute('/')).toBe(true); diff --git a/ui/packages/mobile/src/index.ts b/ui/packages/mobile/src/index.ts index 0747f564b4e..003014ff675 100644 --- a/ui/packages/mobile/src/index.ts +++ b/ui/packages/mobile/src/index.ts @@ -66,6 +66,7 @@ export { export { NATIVE_HOST_GLOBAL, NATIVE_NAVIGATE_EVENT, + NATIVE_CONNECTION_REMOVED_EVENT, getNativeHost, isInAppRoute, nativeGetPushToken, @@ -74,10 +75,13 @@ export { nativeNavigate, nativeResetServer, nativeScan, + nativeShowConnectionSwitcher, + nativeHostCan, postNativeReady, postNativeRouteChanged, type ObjectStackNative, type NativeCallResult, + type NativeConnectionRemovedEvent, type NativeHapticKind, type NativeHostMethod, type NativePushToken, diff --git a/ui/packages/mobile/src/nativeHost.ts b/ui/packages/mobile/src/nativeHost.ts index 68379e4f476..0b9799e7e84 100644 --- a/ui/packages/mobile/src/nativeHost.ts +++ b/ui/packages/mobile/src/nativeHost.ts @@ -40,6 +40,22 @@ export const NATIVE_BRIDGE_VERSION = 1; */ export const NATIVE_NAVIGATE_EVENT = 'objectstack-native:navigate'; +/** + * The window event the shell dispatches to a loaded Console when the user + * removes this Console's connection in the shell. The page answers by calling + * `event.waitUntil(promise)` and settling the promise once it has unregistered + * this device's push token under its own session; the shell then clears the + * host's cookies. A page that does not settle within 10 s is recorded by the + * shell as having left the token registered. See `NativeConnectionRemovedEvent`. + */ +export const NATIVE_CONNECTION_REMOVED_EVENT = 'objectstack-native:connectionRemoved'; + +/** The `objectstack-native:connectionRemoved` event: a window `Event` carrying `waitUntil`. */ +export interface NativeConnectionRemovedEvent extends Event { + /** Hold the shell until `promise` settles (the shell waits at most 10 s). */ + waitUntil(promise: Promise): void; +} + /** Haptic feedback kinds the shell accepts. */ export type NativeHapticKind = | 'light' @@ -80,7 +96,8 @@ export type NativeHostMethod = | 'openExternal' | 'navigate' | 'resetServer' - | 'scan'; + | 'scan' + | 'showConnectionSwitcher'; /** `window.ObjectStackNative`, as the shell's bridge contract declares it. */ export interface ObjectStackNative { @@ -112,6 +129,13 @@ export interface ObjectStackNative { * the scanner does not carry it, so feature-detect with `has('scan')`. */ scan?(opts?: NativeScanOptions): Promise; + /** + * Open the shell's native connection switcher: the saved organizations and + * company servers, plus an add action. Optional: a shell older than the + * switcher does not carry it, so feature-detect with + * `has('showConnectionSwitcher')`. + */ + showConnectionSwitcher?(): Promise; /** * Event: the Console has painted its own UI, which may be a loading @@ -158,8 +182,13 @@ export function getNativeHost(): ObjectStackNative | null { return host as ObjectStackNative; } -/** Whether the shell announces `capability` and the bridge carries the method. */ -function callable(host: ObjectStackNative | null, method: NativeHostMethod): host is ObjectStackNative { +/** + * Whether the shell announces `method` as a capability and the bridge carries + * it, so a typed call wrapper would reach the shell. Lets a control choose its + * native or web path before it renders; the wrapper still answers + * `unavailable` on its own. + */ +export function nativeHostCan(host: ObjectStackNative | null, method: NativeHostMethod): host is ObjectStackNative { if (!host || typeof host.has !== 'function' || typeof host[method] !== 'function') return false; try { return host.has(method) === true; @@ -173,7 +202,7 @@ async function callNative( invoke: (host: ObjectStackNative) => Promise, ): Promise> { const host = getNativeHost(); - if (!callable(host, method)) return { status: 'unavailable' }; + if (!nativeHostCan(host, method)) return { status: 'unavailable' }; try { return { status: 'ok', value: await invoke(host) }; } catch (error) { @@ -221,6 +250,12 @@ export function nativeScan(opts?: NativeScanOptions): Promise host.scan!(opts)); } +/** Open the shell's native connection switcher (organizations and company servers). */ +export function nativeShowConnectionSwitcher(): Promise> { + // `callNative` calls this only once it has seen the method is a function. + return callNative('showConnectionSwitcher', (host) => host.showConnectionSwitcher!()); +} + // ── Events ────────────────────────────────────────────────────────────────── let readyPosted = false; diff --git a/ui/packages/mobile/src/useNativeHost.ts b/ui/packages/mobile/src/useNativeHost.ts index 7741feddc3d..2479544399a 100644 --- a/ui/packages/mobile/src/useNativeHost.ts +++ b/ui/packages/mobile/src/useNativeHost.ts @@ -6,13 +6,15 @@ * LICENSE file in the root directory of this source tree. */ -import { useEffect, useSyncExternalStore } from 'react'; +import { useEffect, useRef, useSyncExternalStore } from 'react'; import { + NATIVE_CONNECTION_REMOVED_EVENT, NATIVE_NAVIGATE_EVENT, getNativeHost, isInAppRoute, postNativeReady, postNativeRouteChanged, + type NativeConnectionRemovedEvent, type ObjectStackNative, } from './nativeHost.js'; @@ -93,11 +95,19 @@ export interface UseNativeHostRoutingOptions { route: string; /** Move the app's own router to an in-app route (no document reload). */ navigate: (route: string) => void; + /** + * Run when the shell removes this Console's connection + * (`objectstack-native:connectionRemoved`): unregister this device's push + * token under the Console's own session. The shell is held until the + * returned promise settles, at most 10 s. Omitted, the shell is released at + * once: there is no registered token to unregister. + */ + onConnectionRemoved?: () => Promise; } /** - * Wires the app's router to the native shell. Call it once, inside the router, - * from a component that renders beside the routes. In the shell it: + * Wires the app to the native shell. Call it once, inside the router, from a + * component that renders beside the routes. In the shell it: * * - posts `ready()` once per page load, after the first commit of the router * tree (or as soon as a late bridge appears). That commit is the Console's @@ -110,13 +120,27 @@ export interface UseNativeHostRoutingOptions { * - listens for the shell's `objectstack-native:navigate` event, moves the * router to `detail.route` when it is an in-app path, and cancels the event * so the shell does not reload the page. Anything else is left alone, and - * the shell's own full-load fallback runs. + * the shell's own full-load fallback runs; + * - answers the shell's `objectstack-native:connectionRemoved` event: calls + * `event.waitUntil()` with the promise `onConnectionRemoved` returns, or with + * one already resolved when it is omitted. An unregister that fails rejects + * that promise, so the shell is never held past the step itself. * * Outside the shell it does nothing and installs no listener. Returns the * bridge, or `null`. */ -export function useNativeHostRouting({ route, navigate }: UseNativeHostRoutingOptions): ObjectStackNative | null { +export function useNativeHostRouting({ + route, + navigate, + onConnectionRemoved, +}: UseNativeHostRoutingOptions): ObjectStackNative | null { const host = useNativeHost(); + // The latest handler, read when the event fires, so the listener does not + // re-install on every render that passes a new function. + const onConnectionRemovedRef = useRef(onConnectionRemoved); + useEffect(() => { + onConnectionRemovedRef.current = onConnectionRemoved; + }, [onConnectionRemoved]); useEffect(() => { if (host) postNativeReady(host); @@ -138,6 +162,18 @@ export function useNativeHostRouting({ route, navigate }: UseNativeHostRoutingOp return () => window.removeEventListener(NATIVE_NAVIGATE_EVENT, onNavigate); }, [host, navigate]); + useEffect(() => { + if (!host) return undefined; + const onRemoved = (event: Event) => { + const { waitUntil } = event as Partial; + if (typeof waitUntil !== 'function') return; + const handler = onConnectionRemovedRef.current; + waitUntil.call(event, handler ? Promise.resolve().then(handler) : Promise.resolve()); + }; + window.addEventListener(NATIVE_CONNECTION_REMOVED_EVENT, onRemoved); + return () => window.removeEventListener(NATIVE_CONNECTION_REMOVED_EVENT, onRemoved); + }, [host]); + return host; }