diff --git a/.changeset/mobile-refresh-control.md b/.changeset/mobile-refresh-control.md index aa5e54c..95b24f8 100644 --- a/.changeset/mobile-refresh-control.md +++ b/.changeset/mobile-refresh-control.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Add mobile and tablet PWA refresh controls plus action palette commands for refreshing app data or reloading the page. +Add refresh controls when PI WEB is launched as a PWA, with action palette commands for refreshing app data or reloading the page. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 3de8cdf..e994b7c 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -21,6 +21,7 @@ import { themePackPlugin } from "../plugins/themes"; import { loadExternalPlugins } from "../plugins/external"; import { PluginRegistry } from "../plugins/registry"; import { queryNamespace, readNamespacedString, setNamespacedQueryKey } from "../namespacedQueryArgs"; +import { createPwaDisplayModeMedia, detectPwaDisplayMode } from "../pwaDisplayMode"; import { readRoute, writeRoute, type AppRoute } from "../route"; import "./ProjectList"; import "./WorkspaceList"; @@ -99,6 +100,7 @@ export class PiWebApp extends LitElement { private readonly terminalSelection = new InMemoryTerminalSelectionMemory(); private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined; private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined; + private readonly pwaDisplayModeMedia = createPwaDisplayModeMedia(); private observedContextItems: HTMLElement | undefined; private observedMobileTabs: HTMLElement | undefined; private contextItemsResizeObserver: ResizeObserver | undefined; @@ -113,6 +115,7 @@ export class PiWebApp extends LitElement { private suppressNextRefreshClick = false; @state() private activeThemeId: QualifiedContributionId = CLASSIC_THEME_ID; @state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; + @state() private isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); @state() private isRefreshingApp = false; @state() private refreshMenuOpen = false; @state() private refreshMenuStyle = ""; @@ -142,6 +145,9 @@ export class PiWebApp extends LitElement { private readonly onSystemLightThemeChange = () => { if (this.themePreference.auto) this.applyPreferredTheme(false); }; + private readonly onPwaDisplayModeChange = () => { + this.isPwaDisplayMode = detectPwaDisplayMode(this.pwaDisplayModeMedia); + }; private readonly onContextScroll = () => { this.updateContextScrollState(); }; @@ -177,6 +183,7 @@ export class PiWebApp extends LitElement { window.addEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange); this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange); + for (const media of this.pwaDisplayModeMedia) media.addEventListener("change", this.onPwaDisplayModeChange); this.applyPreferredTheme(false); this.connectRealtime(); this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS); @@ -194,6 +201,7 @@ export class PiWebApp extends LitElement { window.removeEventListener("keydown", this.onKeyDown, GLOBAL_SHORTCUT_LISTENER_OPTIONS); this.mobileNavigationMedia?.removeEventListener("change", this.onMobileNavigationMediaChange); this.systemLightThemeMedia?.removeEventListener("change", this.onSystemLightThemeChange); + for (const media of this.pwaDisplayModeMedia) media.removeEventListener("change", this.onPwaDisplayModeChange); this.keyboard.reset(); this.auth.dispose(); this.sessions.dispose(); @@ -486,7 +494,7 @@ export class PiWebApp extends LitElement {
PI WEB
- ${this.isMobileNavigationLayout ? null : this.renderAppRefresh()} + ${this.shouldShowAppRefreshInHeader() ? this.renderAppRefresh() : null}
@@ -848,6 +856,7 @@ export class PiWebApp extends LitElement { const workspace = this.state.selectedWorkspace; const session = this.state.selectedSession; const projectLabel = projectContextLabel(project); + const showRefresh = this.shouldShowAppRefreshInContextBar(); const workspaceLabel = workspaceContextLabel(workspace); const sessionLabel = sessionContextLabel(session); return html` @@ -873,13 +882,25 @@ export class PiWebApp extends LitElement { -
${this.isMobileNavigationLayout ? this.renderAppRefresh() : null}
+ ${showRefresh ? html`
${this.renderAppRefresh()}
` : null} `; } private contextBarClass(): string { - return `context-bar${this.contextCanScrollLeft ? " can-scroll-left" : ""}${this.contextCanScrollRight ? " can-scroll-right" : ""}`; + const classes = ["context-bar"]; + if (this.shouldShowAppRefreshInContextBar()) classes.push("has-context-actions"); + if (this.contextCanScrollLeft) classes.push("can-scroll-left"); + if (this.contextCanScrollRight) classes.push("can-scroll-right"); + return classes.join(" "); + } + + private shouldShowAppRefreshInHeader(): boolean { + return this.isPwaDisplayMode && !this.isMobileNavigationLayout; + } + + private shouldShowAppRefreshInContextBar(): boolean { + return this.isPwaDisplayMode && this.isMobileNavigationLayout; } private mobileTabsFrameClass(): string { diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 99def0c..9481127 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -64,7 +64,8 @@ export const appStyles = css` .context-bar::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } .context-bar.can-scroll-left::before, .context-bar.can-scroll-right::after { opacity: 1; } .context-bar-label { display: none; } - .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 52px 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px 52px; scrollbar-width: thin; } + .context-items { flex: 1 1 auto; min-width: 0; display: flex; align-items: stretch; gap: 5px; margin: 0; padding: 0 8px; list-style: none; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scroll-padding-inline: 8px; scrollbar-width: thin; } + .context-bar.has-context-actions .context-items { padding-right: 52px; scroll-padding-inline: 8px 52px; } .context-item { flex: 0 0 auto; min-width: 0; display: flex; } .context-actions { position: absolute; top: 6px; right: 0; bottom: 6px; z-index: 3; display: flex; align-items: center; padding: 0 8px 0 0; pointer-events: none; } .context-actions::after { content: ""; position: absolute; top: 0; right: 0; bottom: 0; z-index: 0; width: 26px; background: var(--pi-bg); pointer-events: none; } diff --git a/src/client/src/pwaDisplayMode.test.ts b/src/client/src/pwaDisplayMode.test.ts new file mode 100644 index 0000000..55fa4cb --- /dev/null +++ b/src/client/src/pwaDisplayMode.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { PWA_DISPLAY_MODE_QUERIES, detectPwaDisplayMode } from "./pwaDisplayMode"; + +describe("detectPwaDisplayMode", () => { + it("detects installed app display modes from media query matches", () => { + expect(detectPwaDisplayMode([{ matches: false }, { matches: true }], undefined)).toBe(true); + }); + + it("detects iOS standalone PWAs", () => { + expect(detectPwaDisplayMode([], { standalone: true })).toBe(true); + }); + + it("does not detect a normal browser tab as a PWA", () => { + expect(detectPwaDisplayMode([{ matches: false }], { standalone: false })).toBe(false); + }); + + it("checks the installed app display modes supported by the manifest", () => { + expect(PWA_DISPLAY_MODE_QUERIES).toEqual([ + "(display-mode: standalone)", + "(display-mode: fullscreen)", + "(display-mode: minimal-ui)", + ]); + }); +}); diff --git a/src/client/src/pwaDisplayMode.ts b/src/client/src/pwaDisplayMode.ts new file mode 100644 index 0000000..efbf615 --- /dev/null +++ b/src/client/src/pwaDisplayMode.ts @@ -0,0 +1,26 @@ +export const PWA_DISPLAY_MODE_QUERIES = [ + "(display-mode: standalone)", + "(display-mode: fullscreen)", + "(display-mode: minimal-ui)", +] as const; + +export interface DisplayModeMediaState { + readonly matches: boolean; +} + +export function createPwaDisplayModeMedia(): MediaQueryList[] { + if (typeof window === "undefined" || !("matchMedia" in window)) return []; + return PWA_DISPLAY_MODE_QUERIES.map((query) => window.matchMedia(query)); +} + +export function detectPwaDisplayMode(media: readonly DisplayModeMediaState[], navigatorObject = currentNavigator()): boolean { + return media.some((query) => query.matches) || isIosStandalonePwa(navigatorObject); +} + +function currentNavigator(): object | undefined { + return typeof navigator === "undefined" ? undefined : navigator; +} + +function isIosStandalonePwa(navigatorObject: object | undefined): boolean { + return navigatorObject !== undefined && "standalone" in navigatorObject && navigatorObject.standalone === true; +}