| author | |
| committer | |
| log | f1dcd93931b322d587a7253856f44625a6afbce0 |
| tree | 39ac5c68254d88cf8aeeead8f6c9da48162e4096 |
| parent | 5e35272fda7cc94d9c140377bc8db70580295133 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 @@ | ||
| 1 | import { parseSemver } from "@t3tools/shared/semver"; | ||
| 2 | import { useNavigate } from "@tanstack/react-router"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import { SmartphoneIcon } from "lucide-react"; | ||
| 5 | import { useEffect } from "react"; | ||
| 6 | |||
| 7 | import { APP_VERSION, HOSTED_APP_CHANNEL } from "../branding"; | ||
| 8 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; | ||
| 9 | import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage"; | ||
| 10 | import { AndroidIcon, AppleIcon } from "./Icons"; | ||
| 11 | import { SettingsRow } from "./settings/settingsLayout"; | ||
| 12 | import { Button } from "./ui/button"; | ||
| 13 | import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover"; | ||
| 14 | import { QRCodeSvg } from "./ui/qr-code"; | ||
| 15 | import { 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. */ | ||
| 22 | export const IS_NIGHTLY_BUILD = | ||
| 23 | parseSemver(APP_VERSION)?.prerelease[0] === "nightly" || HOSTED_APP_CHANNEL === "nightly"; | ||
| 24 | |||
| 25 | const IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/XgaxaRtd"; | ||
| 26 | const ANDROID_BETA_GROUP_URL = "https://groups.google.com/g/t3-code-v2-beta"; | ||
| 27 | const ANDROID_PLAY_TESTING_URL = "https://play.google.com/apps/testing/com.t3tools.t3code"; | ||
| 28 | |||
| 29 | const ROW_ID = "nightly-mobile-beta"; | ||
| 30 | const 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. | ||
| 33 | let noticeShown = false; | ||
| 34 | |||
| 35 | function isNoticeDismissed(): boolean { | ||
| 36 | try { | ||
| 37 | return getLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, Schema.Boolean) === true; | ||
| 38 | } catch { | ||
| 39 | return false; | ||
| 40 | } | ||
| 41 | } | ||
| 42 | |||
| 43 | function 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 | */ | ||
| 55 | export 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. */ | ||
| 90 | function 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. */ | ||
| 105 | export 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"; |
| 52 | import * as Equal from "effect/Equal"; | 52 | import * as Equal from "effect/Equal"; |
| 53 | import * as Schema from "effect/Schema"; | 53 | import * as Schema from "effect/Schema"; |
| 54 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; | 54 | import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding"; |
| 55 | import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta"; | ||
| 55 | import { | 56 | import { |
| 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"); | ||
| 285 | 289 | ||
| 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 | <SettingsRow | 3337 | <> |
| 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"; |
| 28 | import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; | 28 | import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; |
| 29 | import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; | 29 | import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; |
| 30 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; | 30 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 31 | import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta"; | ||
| 31 | import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; | 32 | import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast"; |
| 32 | import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; | 33 | import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; |
| 33 | import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; | 34 | import { 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 the | 249 | {/* 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 |
| 98 | The phone connects to a server on another machine. Follow | 98 | The 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. |
| 100 | 100 | ||
| 101 | Nightly builds need the beta app. The store apps cannot connect to them. A Nightly build also | ||
| 102 | shows 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 | |||
| 101 | If the app crashes during launch, open Settings → Diagnostics on the next launch | 109 | If the app crashes during launch, open Settings → Diagnostics on the next launch |
| 102 | that succeeds. It lists startup crashes from the last 7 days with the error and | 110 | that succeeds. It lists startup crashes from the last 7 days with the error and |
| 103 | component stack that store crash reports leave out. Copy the report and paste it | 111 | component stack that store crash reports leave out. Copy the report and paste it |