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";
26// Google banned the Play testing group, so new testers can't join it. CI mirrors
27// the Play-signed beta APK to this release instead (mobile-android-apk.yml).
28const ANDROID_APK_RELEASE_URL = "https://github.com/pingdotgg/t3code/releases/tag/android-nightly";
29
30const ROW_ID = "nightly-mobile-beta";
31const NOTICE_DISMISSED_STORAGE_KEY = "t3code:nightly-mobile-beta-notice-dismissed:v1";
32
33// Guards against a second toast from a remount or a Strict Mode effect replay.
34let noticeShown = false;
35
36function isNoticeDismissed(): boolean {
37 try {
38 return getLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, Schema.Boolean) === true;
39 } catch {
40 return false;
41 }
42}
43
44function dismissNotice() {
45 try {
46 setLocalStorageItem(NOTICE_DISMISSED_STORAGE_KEY, true, Schema.Boolean);
47 } catch {
48 // Storage is unavailable. The notice shows again on the next launch.
49 }
50}
51
52/**
53 * One-time Nightly toast that points to the beta mobile app. Any close (the
54 * corner button, swipe, Dismiss, or the action) hides it for good on this client.
55 */
56export function NightlyMobileBetaNotice() {
57 const navigate = useNavigate();
58
59 useEffect(() => {
60 if (!IS_NIGHTLY_BUILD || noticeShown || isNoticeDismissed()) return;
61 noticeShown = true;
62 const toastId = toastManager.add({
63 title: "Nightly needs the beta mobile app",
64 description:
65 "Nightly uses the new orchestrator. The App Store and Google Play versions of T3 Code cannot connect to it.",
66 timeout: 0,
67 onClose: dismissNotice,
68 actionProps: {
69 children: "Get the beta app",
70 onClick: () => {
71 toastManager.close(toastId);
72 void navigate({ to: "/settings/general", hash: ROW_ID });
73 },
74 },
75 data: {
76 leadingIcon: <SmartphoneIcon className="size-4" />,
77 actionLayout: "stacked-end",
78 secondaryActionProps: {
79 children: "Dismiss",
80 onClick: () => toastManager.close(toastId),
81 },
82 secondaryActionVariant: "ghost",
83 },
84 });
85 }, [navigate]);
86
87 return null;
88}
89
90/** QR code plus a copy button for one beta link. */
91function BetaLinkQr({ url, label }: { url: string; label: string }) {
92 const { copyToClipboard, isCopied } = useCopyToClipboard();
93 return (
94 <div className="flex flex-col items-center gap-2">
95 <div className="rounded-lg bg-white p-1.5">
96 <QRCodeSvg value={url} size={128} level="M" marginSize={1} title={label} />
97 </div>
98 <Button size="xs" variant="outline" onClick={() => copyToClipboard(url)}>
99 {isCopied ? "Copied" : "Copy link"}
100 </Button>
101 </div>
102 );
103}
104
105/** Settings → General → About row with the beta app links. Render it only for Nightly. */
106export function NightlyMobileBetaRow() {
107 return (
108 <SettingsRow
109 id={ROW_ID}
110 title="Mobile app"
111 description="Nightly needs the beta app. The App Store and Google Play versions cannot connect."
112 control={
113 <div className="flex items-center gap-2">
114 <Popover>
115 <PopoverTrigger render={<Button size="sm" variant="outline" />}>
116 <AppleIcon />
117 iPhone
118 </PopoverTrigger>
119 <PopoverPopup align="end">
120 <div className="flex flex-col gap-3">
121 <div className="space-y-1">
122 <PopoverTitle>TestFlight beta</PopoverTitle>
123 <p className="text-xs text-muted-foreground">Scan with your iPhone camera.</p>
124 </div>
125 <BetaLinkQr url={IOS_TESTFLIGHT_URL} label="TestFlight beta link" />
126 </div>
127 </PopoverPopup>
128 </Popover>
129 <Popover>
130 <PopoverTrigger render={<Button size="sm" variant="outline" />}>
131 <AndroidIcon />
132 Android
133 </PopoverTrigger>
134 <PopoverPopup align="end">
135 <div className="flex flex-col gap-3">
136 <div className="space-y-1">
137 <PopoverTitle>Android beta APK</PopoverTitle>
138 <p className="max-w-56 text-xs text-muted-foreground">
139 Scan with your phone, then download and install the APK.
140 </p>
141 </div>
142 <BetaLinkQr url={ANDROID_APK_RELEASE_URL} label="Android beta APK link" />
143 </div>
144 </PopoverPopup>
145 </Popover>
146 </div>
147 }
148 />
149 );
150}