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";
5252import * as Equal from "effect/Equal";
5353import * as Schema from "effect/Schema";
5454import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
55import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta";
5556import {
5657 canCheckForUpdate,
5758 getDesktopUpdateButtonTooltip,
......@@ -282,6 +283,9 @@ function AboutVersionSection() {
282283 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
283284 const selectedUpdateChannel = updateState?.channel ?? "latest";
284285 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
286290 const handleUpdateChannelChange = useCallback(
287291 (channel: DesktopUpdateChannel) => {
......@@ -502,6 +506,7 @@ function AboutVersionSection() {
502506 }
503507 />
504508 ) : null}
509 {showNightlyMobileBeta ? <NightlyMobileBetaRow /> : null}
505510 </>
506511 );
507512}
......@@ -3329,10 +3334,13 @@ export function GeneralSettingsPanel() {
33293334 {isElectron || HOSTED_APP_CHANNEL ? (
33303335 <AboutVersionSection />
33313336 ) : (
3332 <SettingsRow
3333 title={<AboutVersionTitle />}
3334 description="Current version of the application."
3335 />
3337 <>
3338 <SettingsRow
3339 title={<AboutVersionTitle />}
3340 description="Current version of the application."
3341 />
3342 {IS_NIGHTLY_BUILD ? <NightlyMobileBetaRow /> : null}
3343 </>
33363344 )}
33373345 </SettingsSection>
33383346 <SettingsSection title="Diagnostics">
apps/web/src/routes/__root.tsx+3
......@@ -28,6 +28,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
2828import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
2929import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
3030import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
31import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
3132import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
3233import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
3334import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
......@@ -242,6 +243,8 @@ function RootRouteView() {
242243 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
243244 ) : null}
244245 {primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
246 {/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */}
247 <NightlyMobileBetaNotice />
245248 {appShell}
246249 {/* Above the router: a theme draft is judged by walking the app, so the
247250 editor has to survive navigation away from settings. */}
docs/user/install.md+8
......@@ -98,6 +98,14 @@ Install T3 Code from the
9898The phone connects to a server on another machine. Follow
9999[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
101109If the app crashes during launch, open Settings → Diagnostics on the next launch
102110that succeeds. It lists startup crashes from the last 7 days with the error and
103111component stack that store crash reports leave out. Copy the report and paste it