authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 14:15:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 14:15:20-07:00
logf1dcd93931b322d587a7253856f44625a6afbce0
tree39ac5c68254d88cf8aeeead8f6c9da48162e4096
parent5e35272fda7cc94d9c140377bc8db70580295133
signature Signed by PGP key B5690EEEBB952194

feat(web): Nightly tells you to get the beta mobile app (#15070)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 182 insertions(+), 4 deletions(-)

apps/web/src/components/NightlyMobileBeta.tsx created+159
...@@ -0,0 +1,159 @@
1import { parseSemver } from "@t3tools/shared/semver";
2import { useNavigate } from "@tanstack/react-router";
3import * as Schema from "effect/Schema";
4import { SmartphoneIcon } from "lucide-react";
5import { useEffect } from "react";
6
7import { APP_VERSION, HOSTED_APP_CHANNEL } from "../branding";
8import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
9import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage";
10import { AndroidIcon, AppleIcon } from "./Icons";
11import { SettingsRow } from "./settings/settingsLayout";
12import { Button } from "./ui/button";
13import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";
14import { QRCodeSvg } from "./ui/qr-code";
15import { toastManager } from "./ui/toast";
16
17// Orchestrator V2 ships to Nightly first. The App Store and Google Play apps are
18// still V1 and cannot connect to a V2 server, so Nightly users need the V2 beta
19// app. Delete this file when the store apps move to V2. See #14871.
20
21/** True on Nightly desktop, `npx t3@nightly`, and the hosted Nightly app. */
22export const IS_NIGHTLY_BUILD =
23 parseSemver(APP_VERSION)?.prerelease[0] === "nightly" || HOSTED_APP_CHANNEL === "nightly";
24
25const IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/XgaxaRtd";
26const ANDROID_BETA_GROUP_URL = "https://groups.google.com/g/t3-code-v2-beta";
27const ANDROID_PLAY_TESTING_URL = "https://play.google.com/apps/testing/com.t3tools.t3code";
28
29const ROW_ID = "nightly-mobile-beta";
30const NOTICE_DISMISSED_STORAGE_KEY = "t3code:nightly-mobile-beta-notice-dismissed:v1";
31
32// Guards against a second toast from a remount or a Strict Mode effect replay.
33let noticeShown = false;
34
35function isNoticeDismissed(): boolean {
36 try {
37 return getLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, Schema.Boolean) === true;
38 } catch {
39 return false;
40 }
41}
42
43function dismissNotice() {
44 try {
45 setLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, true, Schema.Boolean);
46 } catch {
47 // Storage is unavailable. The notice shows again on the next launch.
48 }
49}
50
51/**
52 * One-time Nightly toast that points to the beta mobile app. Any close (the
53 * corner button, swipe, Dismiss, or the action) hides it for good on this client.
54 */
55export function NightlyMobileBetaNotice() {
56 const navigate = useNavigate();
57
58 useEffect(() => {
59 if (!IS_NIGHTLY_BUILD || noticeShown || isNoticeDismissed()) return;
60 noticeShown = true;
61 const toastId = toastManager.add({
62 title: "Nightly needs the beta mobile app",
63 description:
64 "Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.",
65 timeout: 0,
66 onClose: dismissNotice,
67 actionProps: {
68 children: "Get the beta app",
69 onClick: () => {
70 toastManager.close(toastId);
71 void navigate({ to: "/settings/general", hash: ROW_ID });
72 },
73 },
74 data: {
75 leadingIcon: <SmartphoneIcon className="size-4" />,
76 actionLayout: "stacked-end",
77 secondaryActionProps: {
78 children: "Dismiss",
79 onClick: () => toastManager.close(toastId),
80 },
81 secondaryActionVariant: "ghost",
82 },
83 });
84 }, [navigate]);
85
86 return null;
87}
88
89/** QR code plus a copy button for one beta link. */
90function BetaLinkQr({ url, label }: { url: string; label: string }) {
91 const { copyToClipboard, isCopied } = useCopyToClipboard();
92 return (
93 <div className="flex flex-col items-center gap-2">
94 <div className="rounded-lg bg-white p-1.5">
95 <QRCodeSvg value={url} size={128} level="M" marginSize={1} title={label} />
96 </div>
97 <Button size="xs" variant="outline" onClick={() => copyToClipboard(url)}>
98 {isCopied ? "Copied" : "Copy link"}
99 </Button>
100 </div>
101 );
102}
103
104/** Settings → General → About row with the beta app links. Render it only for Nightly. */
105export function NightlyMobileBetaRow() {
106 return (
107 <SettingsRow
108 id={ROW_ID}
109 title="Mobile app"
110 description="Nightly needs the beta app. The App Store and Google Play versions cannot connect."
111 control={
112 <div className="flex items-center gap-2">
113 <Popover>
114 <PopoverTrigger render={<Button size="sm" variant="outline" />}>
115 <AppleIcon />
116 iPhone
117 </PopoverTrigger>
118 <PopoverPopup align="end">
119 <div className="flex flex-col gap-3">
120 <div className="space-y-1">
121 <PopoverTitle>TestFlight beta</PopoverTitle>
122 <p className="text-xs text-muted-foreground">Scan with your iPhone camera.</p>
123 </div>
124 <BetaLinkQr url={IOS_TESTFLIGHT_URL} label="TestFlight beta link" />
125 </div>
126 </PopoverPopup>
127 </Popover>
128 <Popover>
129 <PopoverTrigger render={<Button size="sm" variant="outline" />}>
130 <AndroidIcon />
131 Android
132 </PopoverTrigger>
133 <PopoverPopup align="end">
134 <div className="flex flex-col gap-3">
135 <div className="space-y-1">
136 <PopoverTitle>Google Play beta</PopoverTitle>
137 <p className="max-w-72 text-xs text-muted-foreground">
138 Use the same Google account for both steps. Step 2 can take up to an hour to
139 work after you join the group.
140 </p>
141 </div>
142 <div className="grid grid-cols-2 gap-4">
143 <div className="space-y-2">
144 <p className="text-center text-xs font-medium">1. Join the group</p>
145 <BetaLinkQr url={ANDROID_BETA_GROUP_URL} label="Android beta group link" />
146 </div>
147 <div className="space-y-2">
148 <p className="text-center text-xs font-medium">2. Become a tester</p>
149 <BetaLinkQr url={ANDROID_PLAY_TESTING_URL} label="Google Play beta link" />
150 </div>
151 </div>
152 </div>
153 </PopoverPopup>
154 </Popover>
155 </div>
156 }
157 />
158 );
159}
apps/web/src/components/settings/SettingsPanels.tsx+12-4
...@@ -52,6 +52,7 @@ import * as Duration from "effect/Duration";...@@ -52,6 +52,7 @@ import * as Duration from "effect/Duration";
52import * as Equal from "effect/Equal";52import * as Equal from "effect/Equal";
53import * as Schema from "effect/Schema";53import * as Schema from "effect/Schema";
54import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";54import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
55import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta";
55import {56import {
56 canCheckForUpdate,57 canCheckForUpdate,
57 getDesktopUpdateButtonTooltip,58 getDesktopUpdateButtonTooltip,
...@@ -282,6 +283,9 @@ function AboutVersionSection() {...@@ -282,6 +283,9 @@ function AboutVersionSection() {
282 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);283 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
283 const selectedUpdateChannel = updateState?.channel ?? "latest";284 const selectedUpdateChannel = updateState?.channel ?? "latest";
284 const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL;285 const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL;
286 // Show the beta app links as soon as someone picks Nightly, before the update installs.
287 const showNightlyMobileBeta =
288 IS_NIGHTLY_BUILD || (hasDesktopBridge && selectedUpdateChannel === "nightly");
285289
286 const handleUpdateChannelChange = useCallback(290 const handleUpdateChannelChange = useCallback(
287 (channel: DesktopUpdateChannel) => {291 (channel: DesktopUpdateChannel) => {
...@@ -502,6 +506,7 @@ function AboutVersionSection() {...@@ -502,6 +506,7 @@ function AboutVersionSection() {
502 }506 }
503 />507 />
504 ) : null}508 ) : null}
509 {showNightlyMobileBeta ? <NightlyMobileBetaRow /> : null}
505 </>510 </>
506 );511 );
507}512}
...@@ -3329,10 +3334,13 @@ export function GeneralSettingsPanel() {...@@ -3329,10 +3334,13 @@ export function GeneralSettingsPanel() {
3329 {isElectron || HOSTED_APP_CHANNEL ? (3334 {isElectron || HOSTED_APP_CHANNEL ? (
3330 <AboutVersionSection />3335 <AboutVersionSection />
3331 ) : (3336 ) : (
3332 <SettingsRow3337 <>
3333 title={<AboutVersionTitle />}3338 <SettingsRow
3334 description="Current version of the application."3339 title={<AboutVersionTitle />}
3335 />3340 description="Current version of the application."
3341 />
3342 {IS_NIGHTLY_BUILD ? <NightlyMobileBetaRow /> : null}
3343 </>
3336 )}3344 )}
3337 </SettingsSection>3345 </SettingsSection>
3338 <SettingsSection title="Diagnostics">3346 <SettingsSection title="Diagnostics">
apps/web/src/routes/__root.tsx+3
...@@ -28,6 +28,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";...@@ -28,6 +28,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
28import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";28import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
29import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";29import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
30import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";30import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
31import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
31import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";32import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
32import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";33import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
33import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";34import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
...@@ -242,6 +243,8 @@ function RootRouteView() {...@@ -242,6 +243,8 @@ function RootRouteView() {
242 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />243 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
243 ) : null}244 ) : null}
244 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}245 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
246 {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */}
247 <NightlyMobileBetaNotice />
245 {appShell}248 {appShell}
246 {/* Above the router: a theme draft is judged by walking the app, so the249 {/* Above the router: a theme draft is judged by walking the app, so the
247 editor has to survive navigation away from settings. */}250 editor has to survive navigation away from settings. */}
docs/user/install.md+8
...@@ -98,6 +98,14 @@ Install T3 Code from the...@@ -98,6 +98,14 @@ Install T3 Code from the
98The phone connects to a server on another machine. Follow98The phone connects to a server on another machine. Follow
99[remote access](./remote-access.md) to link it through T3 Connect or a pairing URL.99[remote access](./remote-access.md) to link it through T3 Connect or a pairing URL.
100100
101Nightly builds need the beta app. The store apps cannot connect to them. A Nightly build also
102shows these links as QR codes in **Settings → General → Mobile app**.
103
104- **iPhone and iPad:** join the [TestFlight beta](https://testflight.apple.com/join/XgaxaRtd).
105- **Android:** join the [beta group](https://groups.google.com/g/t3-code-v2-beta). With the same
106 Google account, open the [Google Play testing page](https://play.google.com/apps/testing/com.t3tools.t3code)
107 and become a tester.
108
101If the app crashes during launch, open Settings → Diagnostics on the next launch109If the app crashes during launch, open Settings → Diagnostics on the next launch
102that succeeds. It lists startup crashes from the last 7 days with the error and110that succeeds. It lists startup crashes from the last 7 days with the error and
103component stack that store crash reports leave out. Copy the report and paste it111component stack that store crash reports leave out. Copy the report and paste it