1import { type EnvironmentId, SourceControlProviderKind } from "@t3tools/contracts";
2import { runAtomCommand } from "@t3tools/client-runtime/state/runtime";
3import * as Schema from "effect/Schema";
4import { StarIcon } from "lucide-react";
5
6import { getLocalStorageItem, setLocalStorageItem } from "../hooks/useLocalStorage";
7import { readLocalApi } from "../localApi";
8import { appAtomRegistry } from "../rpc/atomRegistry";
9import { sourceControlEnvironment } from "../state/sourceControl";
10import { toastManager } from "./ui/toast";
11
12// Separate keys so a count written by another tab can never undo a dismissal.
13const SENT_KEY = "t3code:github-star-prompt:sent";
14const DISMISSED_KEY = "t3code:github-star-prompt:dismissed";
15const REPOSITORY = "pingdotgg/t3code";
16const PROMPTS_BEFORE_ASKING = 5;
17const IDLE_MS = 30_000;
18// User-driven actions (send, navigate, type, scroll) start with one of these; touch fires pointerdown.
19const ACTIVITY_EVENTS = ["pointerdown", "keydown", "wheel", "focus"] as const;
20
21let shownThisSession = false;
22
23function dismiss() {
24 try {
25 setLocalStorageItem(DISMISSED_KEY, true, Schema.Boolean);
26 } catch {}
27}
28
29// Every close path, including both buttons, runs `onClose`, which saves the dismissal.
30function showStarPrompt(environmentId: EnvironmentId) {
31 const close = () => toastManager.close(id);
32 const openInBrowser = () => {
33 close();
34 void readLocalApi()
35 ?.shell.openExternal(`https://github.com/${REPOSITORY}`)
36 .catch(() => undefined);
37 };
38 const starInPlace = async () => {
39 toastManager.update(id, { actionProps: { children: "Starring…", disabled: true } });
40 if (await starRepository(environmentId)) {
41 // Thank them in place: a closing and opening toast would slide under the confetti.
42 dismiss();
43 toastManager.update(id, {
44 type: "success",
45 title: "Thanks for the star!",
46 description: "It really helps T3 Code grow.",
47 timeout: 5_000,
48 actionProps: undefined,
49 data: {},
50 });
51 celebrate();
52 return;
53 }
54 // Browsers block new tabs once the click's activation expires; desktop opens through its bridge.
55 if (window.desktopBridge || navigator.userActivation?.isActive !== false)
56 return openInBrowser();
57 toastManager.update(id, { actionProps: { children: "Open GitHub", onClick: openInBrowser } });
58 };
59 const id = toastManager.add({
60 title: "Enjoying T3 Code?",
61 description: "Give us a star on GitHub to let us know!",
62 timeout: 0,
63 onClose: dismiss,
64 actionProps: { children: "Star on GitHub", onClick: () => void starInPlace() },
65 data: {
66 actionLayout: "stacked-end",
67 leadingIcon: <StarIcon aria-hidden className="size-4 text-warning-foreground" />,
68 secondaryActionProps: { children: "Dismiss", onClick: close },
69 secondaryActionVariant: "ghost",
70 },
71 });
72}
73
74// One burst out of the toast. The library loads only here, and its canvas stops when done.
75function celebrate() {
76 void import("canvas-confetti")
77 .then(({ default: confetti }) =>
78 confetti({
79 particleCount: 160,
80 spread: 360,
81 startVelocity: 38,
82 gravity: 1.6,
83 ticks: 140,
84 origin: { x: 0.83, y: 0.15 },
85 disableForReducedMotion: true,
86 }),
87 )
88 .catch(() => undefined);
89}
90
91// Resolves after IDLE_MS without input while this window has focus.
92function waitForIdle() {
93 return new Promise<void>((resolve) => {
94 let timer: ReturnType<typeof setTimeout> | undefined;
95 const restart = () => {
96 clearTimeout(timer);
97 timer = setTimeout(() => {
98 // Input inside another app or an embedded frame never reaches us; the next focus restarts.
99 if (!document.hasFocus() || document.activeElement?.matches("iframe, webview")) return;
100 for (const type of ACTIVITY_EVENTS) window.removeEventListener(type, restart, true);
101 resolve();
102 }, IDLE_MS);
103 };
104 for (const type of ACTIVITY_EVENTS) {
105 window.addEventListener(type, restart, { capture: true, passive: true });
106 }
107 restart();
108 });
109}
110
111// The server's GitHub account stands in for the user's; it may not be theirs on a shared server.
112const starInput = (environmentId: EnvironmentId) => ({
113 environmentId,
114 input: {
115 provider: SourceControlProviderKind.make("github"),
116 host: "github.com",
117 repository: REPOSITORY,
118 },
119});
120
121async function alreadyStarred(environmentId: EnvironmentId) {
122 const result = await runAtomCommand(
123 appAtomRegistry,
124 sourceControlEnvironment.checkStarred,
125 starInput(environmentId),
126 { label: "check GitHub star", reportFailure: false, reportDefect: false },
127 );
128 return result._tag === "Success" ? result.value : undefined;
129}
130
131async function starRepository(environmentId: EnvironmentId) {
132 const result = await runAtomCommand(
133 appAtomRegistry,
134 sourceControlEnvironment.star,
135 starInput(environmentId),
136 { label: "star on GitHub", reportFailure: false, reportDefect: false },
137 );
138 return result._tag === "Success" && result.value;
139}
140
141/**
142 * Counts a sent prompt. After more than five, asks once per session for a star until dismissed,
143 * unless the environment's GitHub account already starred the repo. Waits until the user is idle.
144 */
145export async function recordPromptSent(environmentId: EnvironmentId): Promise<void> {
146 if (shownThisSession) return;
147 try {
148 if (getLocalStorageItem(DISMISSED_KEY, Schema.Boolean)) return;
149 const sent = (getLocalStorageItem(SENT_KEY, Schema.Number) ?? 0) + 1;
150 setLocalStorageItem(SENT_KEY, sent, Schema.Number);
151 if (sent <= PROMPTS_BEFORE_ASKING) return;
152 } catch {
153 // Storage that cannot be read or written could not keep a dismissal, so never ask.
154 return;
155 }
156 shownThisSession = true;
157 const starred = await alreadyStarred(environmentId);
158 if (starred === undefined) return;
159 if (starred) return dismiss();
160 await waitForIdle();
161 try {
162 // Another tab can dismiss while the check or the idle wait runs.
163 if (getLocalStorageItem(DISMISSED_KEY, Schema.Boolean)) return;
164 } catch {
165 return;
166 }
167 showStarPrompt(environmentId);
168}