Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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`,
Expand All @@ -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
Expand Down Expand Up @@ -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.
24 changes: 24 additions & 0 deletions apps/web/res/css/start9/mobile.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/components/views/rooms/RoomHeader/RoomHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/utils/push/webPush.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/vector/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<link rel="icon" type="image/png" sizes="24x24" href="<%= require('../../res/vector-icons/24.png') %>">
<link rel="icon" type="image/png" sizes="144x144" href="<%= require('../../res/vector-icons/144.png') %>">
<link rel="icon" type="image/png" sizes="512x512" href="<%= require('../../res/vector-icons/512.png') %>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="apple-mobile-web-app-title" content="Element">
<meta name="application-name" content="Element">
<meta name="theme-color" content="#ffffff">
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/vector/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,8 +223,12 @@ async function start(): Promise<void> {
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"),
),
]);
}
}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/vector/init.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -85,6 +87,8 @@ export async function loadConfig(): Promise<void> {
SdkConfig.reset();
}
applyWebAppManifest();
fitRootToVisualViewport();
watchForStalePage();
}

export async function loadLanguage(): Promise<void> {
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/vector/phoneViewport.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, () => 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();
});
});
26 changes: 26 additions & 0 deletions apps/web/src/vector/phoneViewport.ts
Original file line number Diff line number Diff line change
@@ -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();
}
55 changes: 55 additions & 0 deletions apps/web/src/vector/stalePage.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Modal.createDialog>);
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();
});
});
Loading
Loading