diff --git a/AGENTS.md b/AGENTS.md index e99b1c98f3c..b8f9e38d1e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,11 +11,13 @@ up encryption, sends phone browsers to the native apps, and lays out for the des ## Branches -- `master` is the fork: an upstream release tag with our patches on top. Commit to it directly; a release - is a tag, so `master` only ever feeds the `:master` test image. +- `master` is the fork: an upstream release tag with our patches on top. Work lands through a pull request + against it now that production runs a tag cut from it; `master` itself only ever feeds the `:master` test image. - Upstream is merged, never rebased, so history stays shared and each release lands as one merge. Merge release tags only, never `develop`. - `upstream` remote: `git remote add upstream https://github.com/element-hq/element-web.git`. +- `gh` sees a fork and aims pull requests at element-hq by default; `gh repo set-default Start9Labs/element-web` once + per clone, or pass `--repo Start9Labs/element-web`. ## Taking an upstream release @@ -107,22 +109,27 @@ Upstream files carrying a patch (under `apps/web/src/` unless noted): - `components/views/rooms/NewRoomIntro.tsx` — no "encryption isn't enabled" warning in a new DM; upstream already hides it once the well-known is known, this covers the first render after login. - `viewmodels/menus/UserMenuViewModel.ts` — no "Link new device" in the user menu without crypto. -- `components/views/rooms/RoomHeader/RoomHeader.tsx` — mounts `BackToRoomListButton`. -- `vector/index.ts` — imports the mobile stylesheet; no redirect of phone browsers to the native-app page. +- `components/views/rooms/RoomHeader/RoomHeader.tsx` — mounts `BackToRoomListButton`; no call buttons on phones, so + the room name keeps its width. +- `vector/index.ts` — imports the mobile stylesheet; no redirect of phone browsers to the native-app page; a chunk + that fails to load during start-up is reported as a stale page rather than an unexpected error. - `SdkConfig.ts` — no app-store links by default, so the unsupported-browser page offers none. - `webpack.config.ts` — the native-app guide page is not built. - `res/manifest.json` (under `apps/web/`) — no related native applications. - `components/views/auth/AuthFooter.tsx` — `branding.auth_footer_powered_by_matrix: false` drops the Matrix link. - `components/views/auth/PasswordLogin.tsx` and `RegistrationForm.tsx` — `disable_phone_login`; the registration form only promises discovery by email when `UIFeature.identityServer` is on. -- `vector/init.tsx` — applies `web_app_manifest` once the config is loaded. +- `vector/init.tsx` — applies `web_app_manifest` once the config is loaded, starts the visual-viewport fit, and watches + for a stale page. - `SupportedBrowser.ts` — phones are a supported device type, Samsung Internet is a supported browser, and "Mobile Safari" is judged as Safari, so a current phone browser gets no "unsupported browser" toast. - `serviceworker/index.ts` — imports the push handlers. - `BasePlatform.ts` — the client's own notifications carry the room id as their tag, so one from the service worker for the same room replaces it instead of doubling up. -- `vector/index.html` — the content security policy admits the generated manifest (`manifest-src blob:`). +- `vector/index.html` — the content security policy admits the generated manifest (`manifest-src blob:`); the + viewport meta asks for `viewport-fit=cover` and `interactive-widget=resizes-content`. - `packages/shared-types/lib/config.json.d.ts` — types for the keys above. +- `i18n/strings/en_EN.json` — the fork's strings live under one `start9` key. - `docker/nginx-templates/default.conf.template` (under `apps/web/`) — `sw.js` and `manifest.json` are served `no-cache` like `index.html`, so a deploy replaces the service worker on the next launch rather than within a day. - `.github/workflows/start9.yaml` — the only workflow that runs here. @@ -132,8 +139,8 @@ in `docs/fork.md`. Fork-only files: `utils/crypto/fetchShouldForceDisableEncryption.ts`, `hooks/useCryptoDisabled.ts`, `hooks/usePhoneLayout.ts`, `components/views/rooms/RoomHeader/BackToRoomListButton.tsx`, -`res/css/start9/mobile.pcss`, `vector/webAppManifest.ts`, `serviceworker/push.ts`, `utils/push/webPush.ts`, -`utils/push/protocol.ts`, and their tests. +`res/css/start9/mobile.pcss`, `vector/webAppManifest.ts`, `vector/phoneViewport.ts`, `vector/stalePage.tsx`, +`serviceworker/push.ts`, `utils/push/webPush.ts`, `utils/push/protocol.ts`, and their tests. ## Mobile layout @@ -145,6 +152,12 @@ whatever the specificity, so the file never fights upstream's selectors. `!impor group's inline sizes. The only React is `BackToRoomListButton` in the room header, which shows the home page with `context_switch` set so the active space survives, and `usePhoneLayout()`, which shares the breakpoint. +The keyboard is handled twice over: `interactive-widget=resizes-content` in the viewport meta makes Chromium shrink +the layout viewport, and `vector/phoneViewport.ts` does the same by hand for WebKit, which only shrinks the visual +viewport, by sizing the root to it while it is smaller than the window. `viewport-fit=cover` plus `env(safe-area-inset-*)` +padding on the app wrapper, the auth page and the fixed overlays keeps an installed app clear of the notch and home +indicator. Neither can be seen in headless Chromium; they are checked on a phone. + Rules for mobile work: - Layout goes in that stylesheet, keyed to upstream's structural classes (`mx_MatrixChat`, `mx_LeftPanel_panel`, @@ -154,6 +167,14 @@ Rules for mobile work: - Verify by screenshot at a phone viewport against a scratch Synapse before and after, and check a desktop viewport too: the stylesheet must be a no-op above the breakpoint. +## Stale pages + +A deploy replaces the build's files, and a page opened before it fails the next time it loads a chunk on demand: on +the register page that is the password strength check, so Register silently did nothing. `vector/stalePage.tsx` +turns the first `ChunkLoadError` into a dialog that says the page is out of date, offers Reload, and shows the +hard-refresh keys for the platform; the same error during start-up gets the same words on the error page. Keep the +guidance in step with the StartOS docs' hard-refresh instructions. + ## Installable app Upstream's `res/manifest.json`, touch icons and service worker already make the client installable; the fork only @@ -193,7 +214,5 @@ Rules for it: ## Roadmap -1. Mobile polish: message actions by tap, composer and keyboard behaviour with `interactive-widget` and safe-area - insets, touch-sized room list rows, a space switcher in the list header. -2. Install prompt: an in-app "install" entry from `beforeinstallprompt` where the browser fires it, and a one-time +1. Install prompt: an in-app "install" entry from `beforeinstallprompt` where the browser fires it, and a one-time Add to Home Screen hint on iOS Safari. diff --git a/apps/web/res/css/start9/mobile.pcss b/apps/web/res/css/start9/mobile.pcss index a1d8986c03a..a17206cd009 100644 --- a/apps/web/res/css/start9/mobile.pcss +++ b/apps/web/res/css/start9/mobile.pcss @@ -32,6 +32,9 @@ Please see LICENSE files in the repository root for full details. .mx_RightPanel_ResizeWrapper { position: fixed !important; inset: 0; + box-sizing: border-box; + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); width: auto !important; max-width: none !important; height: auto !important; @@ -98,12 +101,33 @@ Please see LICENSE files in the repository root for full details. margin-inline: 0; } + /* No keyboard, no shortcut hint in the search box. */ + .mx_RoomListPanel kbd { + display: none; + } + + /* A phone header has room for the name, threads and info; calls are dropped in RoomHeader.tsx. */ + .mx_RoomHeader .mx_FacePile { + display: none; + } + + /* Installed on a phone with a notch or home indicator, the app draws under both. */ + .mx_MatrixChat_wrapper, + .mx_AuthPage { + box-sizing: border-box; + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); + } + /* Dialogs fill the screen. */ .mx_Dialog_border { width: 100%; max-width: 100%; height: 100%; max-height: 100%; + box-sizing: border-box; + padding-top: env(safe-area-inset-top); + padding-bottom: env(safe-area-inset-bottom); } .mx_Dialog { diff --git a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx index ae937d51643..f0ca3d29faf 100644 --- a/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx +++ b/apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx @@ -50,6 +50,7 @@ import { type IOOBData } from "../../../../stores/ThreepidInviteStore.ts"; import { MainSplitContentType } from "../../../../contexts/RoomContext.ts"; import defaultDispatcher from "../../../../dispatcher/dispatcher.ts"; import { BackToRoomListButton } from "./BackToRoomListButton"; +import { usePhoneLayout } from "../../../../hooks/usePhoneLayout"; import { RoomSettingsTab } from "../../dialogs/RoomSettingsDialog-tab"; import { useScopedRoomContext } from "../../../../contexts/ScopedRoomContext.tsx"; import { ToggleableIcon } from "./toggle/ToggleableIcon.tsx"; @@ -285,11 +286,12 @@ function RoomHeaderButtons({ voiceCallButton = undefined; } - if (!showVideoCallButton) { + const phoneLayout = usePhoneLayout(); + if (!showVideoCallButton || phoneLayout) { videoCallButton = undefined; } - if (!showVoiceCallButton) { + if (!showVoiceCallButton || phoneLayout) { voiceCallButton = undefined; } diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 18fedfaf22f..82b2a4b7bf0 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -3221,6 +3221,15 @@ "spaces_title": "Spaces you're in", "start_group_chat_button": "Start a group chat" }, + "start9": { + "stale_page": { + "description": "%(brand)s has been updated since this page was opened, so this page no longer works.", + "keys": "Reload to get the new version. If it still misbehaves, hard refresh:", + "phone": "Reload to get the new version. In the installed app, close it fully and open it again.", + "startup": "This page is out of date. Reload it to get the new version; if that does not help, hard refresh with Ctrl+Shift+R, or Cmd+Shift+R on a Mac.", + "title": "This page is out of date" + } + }, "stickers": { "empty": "You don't currently have any stickerpacks enabled", "empty_add_prompt": "Add some now" diff --git a/apps/web/src/utils/push/webPush.test.ts b/apps/web/src/utils/push/webPush.test.ts index e8da3277853..563f2d355f4 100644 --- a/apps/web/src/utils/push/webPush.test.ts +++ b/apps/web/src/utils/push/webPush.test.ts @@ -13,6 +13,7 @@ import { ClientEvent, type MatrixClient, SyncState } from "matrix-js-sdk/src/mat import SdkConfig from "../../SdkConfig"; import SettingsStore from "../../settings/SettingsStore"; +import { SettingLevel } from "../../settings/SettingLevel"; import PlatformPeg from "../../PlatformPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; @@ -116,7 +117,7 @@ describe("startWebPush", () => { pushManager.getSubscription.mockResolvedValueOnce(null).mockResolvedValue(subscription); start(); const onSettingChange = vi.mocked(SettingsStore.watchSetting).mock.calls[0][2]; - onSettingChange("notificationsEnabled", null, "device", true, true); + onSettingChange("notificationsEnabled", null, SettingLevel.DEVICE, true, true); await vi.waitFor(() => expect(client.getPushers).toHaveBeenCalledTimes(2)); expect(pushManager.subscribe).toHaveBeenCalledTimes(1); expect(client.setPusher).toHaveBeenCalledTimes(1); diff --git a/apps/web/src/vector/index.html b/apps/web/src/vector/index.html index e37645a2a4a..6e052143988 100644 --- a/apps/web/src/vector/index.html +++ b/apps/web/src/vector/index.html @@ -12,7 +12,7 @@ - + diff --git a/apps/web/src/vector/index.ts b/apps/web/src/vector/index.ts index 546469e9b4c..46f115d086c 100644 --- a/apps/web/src/vector/index.ts +++ b/apps/web/src/vector/index.ts @@ -223,8 +223,12 @@ async function start(): Promise { logger.error(err); // Like the compatibility page, AWOOOOOGA at the user // This uses the default brand since the app config is unavailable. + const stale = err instanceof Error && err.name === "ChunkLoadError"; await showError(_t("error|misconfigured"), [ - extractErrorMessageFromError(err, _t("error|app_launch_unexpected_error")), + extractErrorMessageFromError( + err, + stale ? _t("start9|stale_page|startup") : _t("error|app_launch_unexpected_error"), + ), ]); } } diff --git a/apps/web/src/vector/init.tsx b/apps/web/src/vector/init.tsx index 6f0d72a4562..f806ef9f374 100644 --- a/apps/web/src/vector/init.tsx +++ b/apps/web/src/vector/init.tsx @@ -30,6 +30,8 @@ import { initRageshake, initRageshakeStore } from "./rageshakesetup"; import { ModuleApi } from "../modules/Api.ts"; import { type URLParams } from "./url_utils.ts"; import { applyWebAppManifest } from "./webAppManifest"; +import { fitRootToVisualViewport } from "./phoneViewport"; +import { watchForStalePage } from "./stalePage"; export const rageshakePromise = initRageshake(); @@ -85,6 +87,8 @@ export async function loadConfig(): Promise { SdkConfig.reset(); } applyWebAppManifest(); + fitRootToVisualViewport(); + watchForStalePage(); } export async function loadLanguage(): Promise { diff --git a/apps/web/src/vector/phoneViewport.test.ts b/apps/web/src/vector/phoneViewport.test.ts new file mode 100644 index 00000000000..de8a45c1f2a --- /dev/null +++ b/apps/web/src/vector/phoneViewport.test.ts @@ -0,0 +1,77 @@ +/* +Copyright 2026 Start9 Labs, Inc. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { fitRootToVisualViewport } from "./phoneViewport"; +import UIStore from "../stores/UIStore"; + +describe("fitRootToVisualViewport", () => { + const viewport = { height: 800, scale: 1, listeners: {} as Record void> }; + let phone = true; + + beforeEach(() => { + viewport.height = 800; + viewport.scale = 1; + phone = true; + UIStore.instance.windowHeight = 800; + vi.stubGlobal("visualViewport", { + get height() { + return viewport.height; + }, + get scale() { + return viewport.scale; + }, + addEventListener: (type: string, listener: () => void) => { + viewport.listeners[type] = listener; + }, + }); + vi.spyOn(window, "matchMedia").mockImplementation(() => ({ matches: phone }) as MediaQueryList); + vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + document.documentElement.style.height = ""; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("shrinks the root to the visual viewport while the keyboard is open, and lets go after", () => { + fitRootToVisualViewport(); + expect(document.documentElement.style.height).toBe(""); + viewport.height = 420; + viewport.listeners.resize(); + expect(document.documentElement.style.height).toBe("420px"); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + viewport.height = 800; + viewport.listeners.resize(); + expect(document.documentElement.style.height).toBe(""); + }); + + it("ignores a pinch zoom, which also shrinks the visual viewport", () => { + fitRootToVisualViewport(); + viewport.height = 420; + viewport.scale = 2; + viewport.listeners.resize(); + expect(document.documentElement.style.height).toBe(""); + }); + + it("leaves the root alone above the phone breakpoint", () => { + phone = false; + fitRootToVisualViewport(); + viewport.height = 420; + viewport.listeners.resize(); + expect(document.documentElement.style.height).toBe(""); + }); + + it("does nothing without a visual viewport", () => { + vi.stubGlobal("visualViewport", undefined); + expect(() => fitRootToVisualViewport()).not.toThrow(); + }); +}); diff --git a/apps/web/src/vector/phoneViewport.ts b/apps/web/src/vector/phoneViewport.ts new file mode 100644 index 00000000000..e11efea7ea6 --- /dev/null +++ b/apps/web/src/vector/phoneViewport.ts @@ -0,0 +1,26 @@ +/* +Copyright 2026 Start9 Labs, Inc. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { PHONE_LAYOUT_QUERY } from "../hooks/usePhoneLayout"; +import UIStore from "../stores/UIStore"; + +// iOS keeps the layout viewport under an open keyboard and scrolls the page instead; sizing the root to the +// visual viewport keeps the composer above the keyboard, which is what `interactive-widget` does elsewhere. +export function fitRootToVisualViewport(): void { + // eslint-disable-next-line no-restricted-properties + const viewport = window.visualViewport; + if (!viewport) return; + const fit = (): void => { + const phone = window.matchMedia(PHONE_LAYOUT_QUERY).matches; + const keyboardOpen = viewport.scale === 1 && viewport.height < UIStore.instance.windowHeight - 1; + document.documentElement.style.height = phone && keyboardOpen ? `${viewport.height}px` : ""; + if (phone && keyboardOpen) window.scrollTo(0, 0); + }; + viewport.addEventListener("resize", fit); + viewport.addEventListener("scroll", fit); + fit(); +} diff --git a/apps/web/src/vector/stalePage.test.tsx b/apps/web/src/vector/stalePage.test.tsx new file mode 100644 index 00000000000..ee78a770f21 --- /dev/null +++ b/apps/web/src/vector/stalePage.test.tsx @@ -0,0 +1,55 @@ +/* +Copyright 2026 Start9 Labs, Inc. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +// @vitest-environment happy-dom + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render } from "test-utils-rtl"; + +import Modal from "../Modal"; +import ErrorDialog from "../components/views/dialogs/ErrorDialog"; +import SdkConfig from "../SdkConfig"; +import { watchForStalePage } from "./stalePage"; + +function reject(reason: unknown): void { + window.dispatchEvent(Object.assign(new Event("unhandledrejection", { cancelable: true }), { reason })); +} + +describe("watchForStalePage", () => { + beforeEach(() => { + SdkConfig.put({ brand: "Support" }); + vi.spyOn(Modal, "createDialog").mockReturnValue({} as ReturnType); + vi.spyOn(window, "matchMedia").mockImplementation(() => ({ matches: false }) as MediaQueryList); + watchForStalePage(); + }); + + afterEach(() => { + SdkConfig.reset(); + vi.restoreAllMocks(); + }); + + it("tells the user to reload when a chunk fails to load, once", () => { + reject(Object.assign(new Error("Loading chunk 6685 failed"), { name: "ChunkLoadError" })); + expect(Modal.createDialog).toHaveBeenCalledTimes(1); + const [component, props] = vi.mocked(Modal.createDialog).mock.calls[0]; + expect(component).toBe(ErrorDialog); + expect(props).toMatchObject({ title: "This page is out of date", button: "Reload" }); + const { container } = render(<>{(props as { description: React.ReactNode }).description}); + expect(container.textContent).toContain("Support has been updated since this page was opened"); + expect(container.querySelectorAll("kbd").length).toBeGreaterThan(0); + + reject(Object.assign(new Error("Loading chunk 1 failed"), { name: "ChunkLoadError" })); + expect(Modal.createDialog).toHaveBeenCalledTimes(1); + }); + + it("ignores other rejections", () => { + reject(new Error("something else")); + reject("not an error"); + expect(Modal.createDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/vector/stalePage.tsx b/apps/web/src/vector/stalePage.tsx new file mode 100644 index 00000000000..44e73ababb6 --- /dev/null +++ b/apps/web/src/vector/stalePage.tsx @@ -0,0 +1,43 @@ +/* +Copyright 2026 Start9 Labs, Inc. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; + +import Modal from "../Modal"; +import ErrorDialog from "../components/views/dialogs/ErrorDialog"; +import { KeyboardShortcut } from "../components/views/settings/KeyboardShortcut"; +import { PHONE_LAYOUT_QUERY } from "../hooks/usePhoneLayout"; +import { _t } from "../languageHandler"; +import SdkConfig from "../SdkConfig"; + +let shown = false; + +// A deploy replaces the build's files, so a page opened before it fails the next time it loads a chunk on demand. +export function watchForStalePage(): void { + window.addEventListener("unhandledrejection", (event) => { + if (!(event.reason instanceof Error) || event.reason.name !== "ChunkLoadError" || shown) return; + shown = true; + event.preventDefault(); + Modal.createDialog(ErrorDialog, { + title: _t("start9|stale_page|title"), + description: , + button: _t("action|reload"), + onFinished: () => window.location.reload(), + }); + }); +} + +function StalePageDescription(): JSX.Element { + const phone = window.matchMedia(PHONE_LAYOUT_QUERY).matches; + return ( + <> +

{_t("start9|stale_page|description", { brand: SdkConfig.get().brand })}

+

{phone ? _t("start9|stale_page|phone") : _t("start9|stale_page|keys")}

+ {!phone && } + + ); +} diff --git a/docs/fork.md b/docs/fork.md index a7d2d1ad2dd..eb950587db7 100644 --- a/docs/fork.md +++ b/docs/fork.md @@ -15,8 +15,16 @@ unaffected. No configuration is needed beyond the well-known. Below 768px the client shows one pane at a time: the room list, the room, or a right-panel card such as a thread or room info, with a back button in the room header. Dialogs and the sign-in, register and forgot-password pages fit -the screen. Phone browsers are no longer redirected to a native-app page, the client offers no app-store links, and -a current phone browser (Chrome, Firefox, Safari, Edge, Samsung Internet) is not warned as unsupported. +the screen. The room header keeps the room name, threads and room info and drops the call buttons and member +avatars; the search box shows no keyboard shortcut. The composer stays above the on-screen keyboard, and an +installed app keeps clear of the notch and home indicator. Phone browsers are no longer redirected to a native-app +page, the client offers no app-store links, and a current phone browser (Chrome, Firefox, Safari, Edge, Samsung +Internet) is not warned as unsupported. + +## Stale pages + +A page left open across a deploy tells the user it is out of date and how to reload, instead of failing silently the +next time it needs part of the app it can no longer fetch. ## Installable app