From ff67cb367d4f8cbb8970f3f8d5be485337d5e52d Mon Sep 17 00:00:00 2001 From: Eli Mallon Date: Tue, 29 Sep 2026 15:12:02 -0700 Subject: [PATCH] Fix native chat profile portal context and contain menu failures --- .maestro/07-chat-profile.yaml | 92 +++++++++++++++++++ .maestro/README.md | 21 ++++- .maestro/config.yaml | 1 + .../components/provider/provider.shared.tsx | 14 ++- .../src/components/chat/chat-message.tsx | 1 + .../src/components/chat/user-profile-card.tsx | 6 +- .../src/components/ui/dropdown.native.tsx | 46 +++++++--- js/components/src/lib/theme/theme.tsx | 6 +- js/e2e-web/flows/05-oauth-login.spec.ts | 34 ++++++- 9 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 .maestro/07-chat-profile.yaml diff --git a/.maestro/07-chat-profile.yaml b/.maestro/07-chat-profile.yaml new file mode 100644 index 000000000..ca0a8562d --- /dev/null +++ b/.maestro/07-chat-profile.yaml @@ -0,0 +1,92 @@ +appId: ${APP_ID} +--- +# Runs after 05-oauth-login and 06-chat-reply with the harness account signed in. +- launchApp +- extendedWaitUntil: + visible: + id: "home-stream-card" + timeout: 30000 +- tapOn: + id: "home-stream-card" +- extendedWaitUntil: + visible: + text: ".*e2e test stream.*" + timeout: 30000 +# Give this account a visible row even if earlier chat has scrolled away. +- retry: + maxRetries: 3 + commands: + - tapOn: + id: "chat-input" + - eraseText: 100 + - inputText: "profile target from 07-chat-profile" + - pressKey: Enter + - extendedWaitUntil: + visible: + text: ".*: profile target from 07-chat-profile" + timeout: 15000 +- tapOn: + id: "chat-profile-trigger-${ACCOUNT_HANDLE}" + index: 0 +- extendedWaitUntil: + visible: + id: "chat-profile-card" + timeout: 10000 +# Match the card's identity, not another occurrence in the chat underneath. +- assertVisible: + id: "chat-profile-handle" + text: "@${ACCOUNT_HANDLE}" +- assertVisible: "View Profile" +- assertNotVisible: + id: "menu-error" +- takeScreenshot: 07-01-profile +# The top of the backdrop is outside the sheet on both iOS and Android. +# Do not use Android Back: the regression must exercise shared dismissal. +- assertVisible: + id: "menu-backdrop" +- tapOn: + point: "50%,10%" +- extendedWaitUntil: + notVisible: + id: "chat-profile-card" + timeout: 10000 +- tapOn: + id: "chat-profile-trigger-${ACCOUNT_HANDLE}" + index: 0 +- extendedWaitUntil: + visible: + id: "chat-profile-handle" + text: "@${ACCOUNT_HANDLE}" + timeout: 10000 +- assertVisible: "View Profile" +- assertNotVisible: + id: "menu-error" +- takeScreenshot: 07-02-profile-reopened +- tapOn: + point: "50%,10%" +- extendedWaitUntil: + notVisible: + id: "chat-profile-card" + timeout: 10000 +# No retry/relaunch before this send: dismissing the sheet must leave chat usable. +- tapOn: + id: "chat-input" +- inputText: "chat after profile from 07-chat-profile" +- pressKey: Enter +- extendedWaitUntil: + visible: + text: ".*: chat after profile from 07-chat-profile" + timeout: 15000 +# Reload from server history so an optimistic local echo cannot pass the test. +- launchApp +- extendedWaitUntil: + visible: + id: "home-stream-card" + timeout: 30000 +- tapOn: + id: "home-stream-card" +- extendedWaitUntil: + visible: + text: ".*: chat after profile from 07-chat-profile" + timeout: 30000 +- takeScreenshot: 07-03-chat-history diff --git a/.maestro/README.md b/.maestro/README.md index a6470e6f9..c010f0704 100644 --- a/.maestro/README.md +++ b/.maestro/README.md @@ -10,9 +10,24 @@ these flows. Flows run in the order set by `config.yaml`: `00-server-setup` first (it points the app at the harness), then the flows that expect a logged-out app, then `05-oauth-login`, then the flows that expect a logged-in one -(`06-chat-reply`). There is no `03`: native builds hide the Go Live controls -that `03-go-live` covered, and `02-tabs` checks they stay hidden. The web -suite still has its `03-go-live`. +(`06-chat-reply`, `07-chat-profile`). There is no `03`: native builds hide the Go +Live controls that `03-go-live` covered, and `02-tabs` checks they stay hidden. +The web suite still has its `03-go-live`. + +`07-chat-profile` taps the signed-in chat author's name, checks that the profile +sheet contains that account's handle and the View Profile action, dismisses it +through the backdrop, and opens it again. It then sends another message without +restarting and verifies that message survives a relaunch from server history. +The dismissal uses the same backdrop interaction on iOS and Android, not Android +Back. The logged-in Playwright flow (`05-oauth-login.spec.ts`) covers the matching +open/dismiss/reopen/chat sequence in the Expo web app served by the default harness. + +Native dropdowns render through the default `@rn-primitives` portal host. That +host belongs below the app stores, i18n, branded theme and font providers so +profile content retains their context. The sheet also has its own error boundary: +unexpected content errors show a menu-local fallback with Try again and Dismiss, +rather than replacing the app. The profile flow requires actual profile content +and rejects that fallback; it does not inject errors or test the fallback actions. ## HTTPS, and logging in diff --git a/.maestro/config.yaml b/.maestro/config.yaml index 3e5d0ee17..defbe44bc 100644 --- a/.maestro/config.yaml +++ b/.maestro/config.yaml @@ -12,3 +12,4 @@ executionOrder: - 04-stream - 05-oauth-login - 06-chat-reply + - 07-chat-profile diff --git a/js/app/components/provider/provider.shared.tsx b/js/app/components/provider/provider.shared.tsx index d1db13a27..9eae243e8 100644 --- a/js/app/components/provider/provider.shared.tsx +++ b/js/app/components/provider/provider.shared.tsx @@ -7,6 +7,7 @@ import * as Sentry from "@sentry/react-native"; import { BrandedThemeProvider, I18nProvider, + PortalHost, ThemeProvider, StreamplaceProvider as ZustandStreamplaceProvider, } from "@streamplace/components"; @@ -97,13 +98,22 @@ function ProviderInner({ return ( - + - {children} + + {children} + {/* Native portals render in the host's context, not the + trigger's. Keep store, i18n and branding available. */} + {Platform.OS !== "web" && } + diff --git a/js/components/src/components/chat/chat-message.tsx b/js/components/src/components/chat/chat-message.tsx index 2d5d4d0f8..82410ff4b 100644 --- a/js/components/src/components/chat/chat-message.tsx +++ b/js/components/src/components/chat/chat-message.tsx @@ -259,6 +259,7 @@ const MessageBodyNative = ({ item }: { item: ChatMessageViewHydrated }) => { + {profile?.banner ? ( - @{author.handle} + @{author.handle} {Platform.OS === "web" && ( + - ); - })} + )} + > + {stack.map((level, index) => { + const isCurrent = index === stack.length - 1; + return ( + + {typeof level.content === "function" + ? level.content({ pressed: true }) + : level.content} + + ); + })} + diff --git a/js/components/src/lib/theme/theme.tsx b/js/components/src/lib/theme/theme.tsx index b5a3772f8..3664ff901 100644 --- a/js/components/src/lib/theme/theme.tsx +++ b/js/components/src/lib/theme/theme.tsx @@ -575,6 +575,9 @@ interface ThemeProviderProps { * provider; a branded provider nested inside an unbranded root must * take this over, or the root's defaults win over the brand. */ paintDocument?: boolean; + /** Mount the default native portal host here. Disable when the app places + * its host below additional providers needed by menu and dialog content. */ + portalHost?: boolean; children: ReactNode; defaultTheme?: "light" | "dark" | "system"; forcedTheme?: "light" | "dark"; @@ -606,6 +609,7 @@ export function ThemeProvider({ chromeColors, brandColors, paintDocument, + portalHost = true, }: ThemeProviderProps) { const systemColorScheme = useColorScheme(); const chrome = useMemo( @@ -766,7 +770,7 @@ export function ThemeProvider({ {isRoot ? ( {children} - + {portalHost && } ) : ( diff --git a/js/e2e-web/flows/05-oauth-login.spec.ts b/js/e2e-web/flows/05-oauth-login.spec.ts index 6be3a174e..3e6fb1974 100644 --- a/js/e2e-web/flows/05-oauth-login.spec.ts +++ b/js/e2e-web/flows/05-oauth-login.spec.ts @@ -4,10 +4,11 @@ import { loginThroughPds } from "./login"; // Log in the way a user does, then act as that user through chat. See // flows/login.ts for the OAuth machinery; it needs the harness's HTTPS mode. const HTTPS_URL = process.env.SERVER_HTTPS_URL; +const HANDLE = process.env.ACCOUNT_HANDLE; test.skip(!HTTPS_URL, "harness started without its HTTPS hostnames"); -test("05-oauth-login: log in through the PDS, then chat", async ({ page }) => { +test("05-oauth-login: log in, chat, and reopen a profile", async ({ page }) => { await loginThroughPds(page); // Now act as the user. A chat message is a record the node writes to the @@ -52,4 +53,35 @@ test("05-oauth-login: log in through the PDS, then chat", async ({ page }) => { await expect(page.getByText(message).first()).toBeVisible({ timeout: 30_000, }); + + // Exercise the real card rendered outside the chat row. Checking its own + // handle avoids accidentally matching the account settings or chat username. + const profileTrigger = page + .getByTestId(`chat-profile-trigger-${HANDLE}`) + .first(); + const profileCard = page.getByTestId("chat-profile-card"); + for (let opening = 0; opening < 2; opening++) { + await profileTrigger.click(); + await expect(profileCard).toBeVisible(); + await expect(profileCard.getByTestId("chat-profile-handle")).toHaveText( + `@${HANDLE}`, + ); + await page + .getByTestId("chat-profile-backdrop") + .click({ position: { x: 10, y: 10 } }); + await expect(profileCard).toBeHidden(); + } + + // Do not reload or retry the send here: closing the profile must leave the + // existing chat session usable. Reload only afterward to prove persistence. + const afterProfileMessage = `chat after profile ${Date.now()}`; + await chatInput.fill(afterProfileMessage); + await chatInput.press("Enter"); + await expect(page.getByText(afterProfileMessage).first()).toBeVisible({ + timeout: 15_000, + }); + await page.reload(); + await expect(page.getByText(afterProfileMessage).first()).toBeVisible({ + timeout: 30_000, + }); }); -- 2.51.2