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
159 changes: 159 additions & 0 deletions apps/web/src/components/NightlyMobileBeta.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { parseSemver } from "@t3tools/shared/semver";
import { useNavigate } from "@tanstack/react-router";
import * as Schema from "effect/Schema";
import { SmartphoneIcon } from "lucide-react";
import { useEffect } from "react";

import { APP_VERSION, HOSTED_APP_CHANNEL } from "../branding";
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage";
import { AndroidIcon, AppleIcon } from "./Icons";
import { SettingsRow } from "./settings/settingsLayout";
import { Button } from "./ui/button";
import { Popover, PopoverPopup, PopoverTitle, PopoverTrigger } from "./ui/popover";
import { QRCodeSvg } from "./ui/qr-code";
import { toastManager } from "./ui/toast";

// Orchestrator V2 ships to Nightly first. The App Store and Google Play apps are
// still V1 and cannot connect to a V2 server, so Nightly users need the V2 beta
// app. Delete this file when the store apps move to V2. See #14871.

/** True on Nightly desktop, `npx t3@nightly`, and the hosted Nightly app. */
export const IS_NIGHTLY_BUILD =
parseSemver(APP_VERSION)?.prerelease[0] === "nightly" || HOSTED_APP_CHANNEL === "nightly";

const IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/XgaxaRtd";
const ANDROID_BETA_GROUP_URL = "https://groups.google.com/g/t3-code-v2-beta";
const ANDROID_PLAY_TESTING_URL = "https://play.google.com/apps/testing/com.t3tools.t3code";

const ROW_ID = "nightly-mobile-beta";
const NOTICE_DISMISSED_STORAGE_KEY = "t3code:nightly-mobile-beta-notice-dismissed:v1";

// Guards against a second toast from a remount or a Strict Mode effect replay.
let noticeShown = false;

function isNoticeDismissed(): boolean {
try {
return getLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, Schema.Boolean) === true;
} catch {
return false;
}
}

function dismissNotice() {
try {
setLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, true, Schema.Boolean);
} catch {
// Storage is unavailable. The notice shows again on the next launch.
}
}

/**
* One-time Nightly toast that points to the beta mobile app. Any close (the
* corner button, swipe, Dismiss, or the action) hides it for good on this client.
*/
export function NightlyMobileBetaNotice() {
const navigate = useNavigate();

useEffect(() => {
if (!IS_NIGHTLY_BUILD || noticeShown || isNoticeDismissed()) return;
noticeShown = true;
const toastId = toastManager.add({
title: "Nightly needs the beta mobile app",
description:
"Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.",
timeout: 0,
onClose: dismissNotice,
actionProps: {
children: "Get the beta app",
onClick: () => {
toastManager.close(toastId);
void navigate({ to: "/settings/general", hash: ROW_ID });
},
},
data: {
leadingIcon: <SmartphoneIcon className="size-4" />,
actionLayout: "stacked-end",
secondaryActionProps: {
children: "Dismiss",
onClick: () => toastManager.close(toastId),
},
secondaryActionVariant: "ghost",
},
});
}, [navigate]);

return null;
}

/** QR code plus a copy button for one beta link. */
function BetaLinkQr({ url, label }: { url: string; label: string }) {
const { copyToClipboard, isCopied } = useCopyToClipboard();
return (
<div className="flex flex-col items-center gap-2">
<div className="rounded-lg bg-white p-1.5">
<QRCodeSvg value={url} size={128} level="M" marginSize={1} title={label} />
</div>
<Button size="xs" variant="outline" onClick={() => copyToClipboard(url)}>
{isCopied ? "Copied" : "Copy link"}
</Button>
</div>
);
}

/** Settings → General → About row with the beta app links. Render it only for Nightly. */
export function NightlyMobileBetaRow() {
return (
<SettingsRow
id={ROW_ID}
title="Mobile app"
description="Nightly needs the beta app. The App Store and Google Play versions cannot connect."
control={
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger render={<Button size="sm" variant="outline" />}>
<AppleIcon />
iPhone
</PopoverTrigger>
<PopoverPopup align="end">
<div className="flex flex-col gap-3">
<div className="space-y-1">
<PopoverTitle>TestFlight beta</PopoverTitle>
<p className="text-xs text-muted-foreground">Scan with your iPhone camera.</p>
</div>
<BetaLinkQr url={IOS_TESTFLIGHT_URL} label="TestFlight beta link" />
</div>
</PopoverPopup>
</Popover>
<Popover>
<PopoverTrigger render={<Button size="sm" variant="outline" />}>
<AndroidIcon />
Android
</PopoverTrigger>
<PopoverPopup align="end">
<div className="flex flex-col gap-3">
<div className="space-y-1">
<PopoverTitle>Google Play beta</PopoverTitle>
<p className="max-w-72 text-xs text-muted-foreground">
Use the same Google account for both steps. Step 2 can take up to an hour to
work after you join the group.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-center text-xs font-medium">1. Join the group</p>
<BetaLinkQr url={ANDROID_BETA_GROUP_URL} label="Android beta group link" />
</div>
<div className="space-y-2">
<p className="text-center text-xs font-medium">2. Become a tester</p>
<BetaLinkQr url={ANDROID_PLAY_TESTING_URL} label="Google Play beta link" />
</div>
</div>
</div>
</PopoverPopup>
</Popover>
</div>
}
/>
);
}
16 changes: 12 additions & 4 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import * as Duration from "effect/Duration";
import * as Equal from "effect/Equal";
import * as Schema from "effect/Schema";
import { APP_VERSION, HOSTED_APP_CHANNEL, HOSTED_APP_CHANNEL_LABEL } from "../../branding";
import { IS_NIGHTLY_BUILD, NightlyMobileBetaRow } from "../NightlyMobileBeta";
import {
canCheckForUpdate,
getDesktopUpdateButtonTooltip,
Expand Down Expand Up @@ -282,6 +283,9 @@ function AboutVersionSection() {
const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
const selectedUpdateChannel = updateState?.channel ?? "latest";
const selectedHostedAppChannel = hasDesktopBridge ? null : HOSTED_APP_CHANNEL;
// Show the beta app links as soon as someone picks Nightly, before the update installs.
const showNightlyMobileBeta =
IS_NIGHTLY_BUILD || (hasDesktopBridge && selectedUpdateChannel === "nightly");

const handleUpdateChannelChange = useCallback(
(channel: DesktopUpdateChannel) => {
Expand Down Expand Up @@ -502,6 +506,7 @@ function AboutVersionSection() {
}
/>
) : null}
{showNightlyMobileBeta ? <NightlyMobileBetaRow /> : null}
</>
);
}
Expand Down Expand Up @@ -3329,10 +3334,13 @@ export function GeneralSettingsPanel() {
{isElectron || HOSTED_APP_CHANNEL ? (
<AboutVersionSection />
) : (
<SettingsRow
title={<AboutVersionTitle />}
description="Current version of the application."
/>
<>
<SettingsRow
title={<AboutVersionTitle />}
description="Current version of the application."
/>
{IS_NIGHTLY_BUILD ? <NightlyMobileBetaRow /> : null}
</>
)}
</SettingsSection>
<SettingsSection title="Diagnostics">
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive";
import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
Expand Down Expand Up @@ -242,6 +243,8 @@ function RootRouteView() {
<EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
) : null}
{primaryEnvironmentAuthenticated ? <ProviderUpdateLaunchNotification /> : null}
{/* Hosted Nightly is "hosted-static", not authenticated, and needs it too. */}
<NightlyMobileBetaNotice />
{appShell}
{/* Above the router: a theme draft is judged by walking the app, so the
editor has to survive navigation away from settings. */}
Expand Down
8 changes: 8 additions & 0 deletions docs/user/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ Install T3 Code from the
The phone connects to a server on another machine. Follow
[remote access](./remote-access.md) to link it through T3 Connect or a pairing URL.

Nightly builds need the beta app. The store apps cannot connect to them. A Nightly build also
shows these links as QR codes in **Settings → General → Mobile app**.

- **iPhone and iPad:** join the [TestFlight beta](https://testflight.apple.com/join/XgaxaRtd).
- **Android:** join the [beta group](https://groups.google.com/g/t3-code-v2-beta). With the same
Google account, open the [Google Play testing page](https://play.google.com/apps/testing/com.t3tools.t3code)
and become a tester.

If the app crashes during launch, open Settings → Diagnostics on the next launch
that succeeds. It lists startup crashes from the last 7 days with the error and
component stack that store crash reports leave out. Copy the report and paste it
Expand Down
Loading