Skip to content
Draft
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
2 changes: 2 additions & 0 deletions apps/web/src/components/sidebar/SidebarChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "../ui/sidebar";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { readPullRequestListPreferences } from "../pullRequest/pullRequestListPreferences";
import { SidebarIosPromo } from "./SidebarIosPromo";
import { SidebarProviderUpdatePill } from "./SidebarProviderUpdatePill";
import { SidebarUpdateArchitectureWarning, SidebarUpdatePill } from "./SidebarUpdatePill";
import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons";
Expand Down Expand Up @@ -230,6 +231,7 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter({
<SidebarFooter className="px-[var(--sidebar-content-inset)] py-1">
<SidebarProviderUpdatePill />
<SidebarUpdateArchitectureWarning />
<SidebarIosPromo />
<SidebarUtilityMenu
settledViewOpen={settledViewOpen}
onToggleSettledView={onToggleSettledView}
Expand Down
58 changes: 58 additions & 0 deletions apps/web/src/components/sidebar/SidebarIosPromo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ExternalLinkIcon, SmartphoneIcon, XIcon } from "lucide-react";
import { memo, useCallback } from "react";

import { readLocalApi } from "../../localApi";
import { useUiStateStore } from "../../uiStateStore";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";

// TODO(ios-promo): placeholder. Swap in the real TestFlight public link before
// merging.
export const IOS_TESTFLIGHT_URL = "https://testflight.apple.com/join/REPLACE_ME";

export const SidebarIosPromo = memo(function SidebarIosPromo() {
const dismissed = useUiStateStore((state) => state.iosPromoDismissed);
const dismiss = useUiStateStore((state) => state.dismissIosPromo);

const openTestFlight = useCallback(() => {
void readLocalApi()?.shell.openExternal(IOS_TESTFLIGHT_URL);
}, []);

if (dismissed) return null;

return (
<div className="group/ios-promo relative flex items-center gap-2 rounded-lg border border-sidebar-border bg-sidebar-control-surface px-2.5 py-2">
<SmartphoneIcon aria-hidden className="size-4 shrink-0 text-sidebar-muted-foreground" />
<div className="min-w-0 flex-1 pr-4">
<div className="truncate text-xs leading-4 font-medium text-sidebar-foreground">
Control Plane for iPhone
</div>
<a
href={IOS_TESTFLIGHT_URL}
onClick={(event) => {
event.preventDefault();
openTestFlight();
}}
className="inline-flex items-center gap-1 rounded-sm text-[11px] leading-4 text-sidebar-muted-foreground underline decoration-dotted underline-offset-2 outline-none transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
Join the TestFlight beta
<ExternalLinkIcon aria-hidden className="size-3 shrink-0" strokeWidth={2.25} />
</a>
</div>
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label="Dismiss"
onClick={dismiss}
className="absolute top-1 right-1 inline-flex size-5 cursor-pointer items-center justify-center rounded-md text-sidebar-muted-foreground outline-none transition-colors hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<XIcon aria-hidden className="size-3" />
</button>
}
/>
<TooltipPopup side="top">Dismiss</TooltipPopup>
</Tooltip>
</div>
);
});
27 changes: 27 additions & 0 deletions apps/web/src/uiStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ProjectId, ThreadId } from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import {
dismissIosPromo,
legacyProjectCwdPreferenceKey,
markThreadUnread,
markThreadVisited,
Expand Down Expand Up @@ -33,6 +34,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
pullRequestMergeMethod: "merge",
iosPromoDismissed: false,
...overrides,
};
}
Expand Down Expand Up @@ -202,6 +204,15 @@ describe("uiStateStore pure functions", () => {
});
});

describe("dismissIosPromo", () => {
it("marks the promo dismissed and is idempotent", () => {
const dismissed = dismissIosPromo(makeUiState());

expect(dismissed.iosPromoDismissed).toBe(true);
expect(dismissIosPromo(dismissed)).toBe(dismissed);
});
});

describe("parsePersistedState", () => {
it("hydrates the last selected pull request merge method", () => {
const parsed = parsePersistedState({
Expand Down Expand Up @@ -249,6 +260,7 @@ describe("parsePersistedState", () => {
defaultAdvertisedEndpointKey: "desktop-core:lan:http",
sidebarProjectScopeKey: null,
pullRequestMergeMethod: "merge",
iosPromoDismissed: false,
threadChangedFilesExpandedById: {
"environment:thread-1": {
"turn-1": false,
Expand Down Expand Up @@ -396,12 +408,27 @@ describe("uiStateStore persistence", () => {
},
},
pullRequestMergeMethod: "merge",
iosPromoDismissed: false,
});
expect(parsePersistedState(persisted)).toEqual({
...state,
});
});

it("restores the iOS promo dismissal across reloads", () => {
persistState(dismissIosPromo(makeUiState()));

const persisted = JSON.parse(
localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
) as PersistedUiState;

expect(parsePersistedState(persisted).iosPromoDismissed).toBe(true);
expect(parsePersistedState({}).iosPromoDismissed).toBe(false);
expect(parsePersistedState({ iosPromoDismissed: "yes" as never }).iosPromoDismissed).toBe(
false,
);
});

it("restores the sidebar project scope across reloads", () => {
persistState(makeUiState({ sidebarProjectScopeKey: "github.com/pingdotgg/t3code" }));

Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/uiStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface PersistedUiState {
threadChangedFilesExpansionVersion?: number;
threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
pullRequestMergeMethod?: string;
iosPromoDismissed?: boolean;
}

export interface UiProjectState {
Expand Down Expand Up @@ -59,8 +60,13 @@ export interface UiPullRequestState {
pullRequestMergeMethod: PullRequestMergeMethod;
}

export interface UiPromoState {
// The sidebar's "Control Plane for iPhone" card, once the user has closed it.
iosPromoDismissed: boolean;
}

export interface UiState
extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState {}
extends UiProjectState, UiThreadState, UiEndpointState, UiPullRequestState, UiPromoState {}

const initialState: UiState = {
projectExpandedById: {},
Expand All @@ -71,6 +77,7 @@ const initialState: UiState = {
threadChangedFilesExpandedById: {},
defaultAdvertisedEndpointKey: null,
pullRequestMergeMethod: "merge",
iosPromoDismissed: false,
};

const LEGACY_PROJECT_CWD_PREFERENCE_PREFIX = "legacy-project-cwd:";
Expand Down Expand Up @@ -173,6 +180,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {
pullRequestMergeMethod: isPullRequestMergeMethod(parsed.pullRequestMergeMethod)
? parsed.pullRequestMergeMethod
: initialState.pullRequestMergeMethod,
iosPromoDismissed: parsed.iosPromoDismissed === true,
};
}

Expand Down Expand Up @@ -248,6 +256,7 @@ export function persistState(state: UiState): void {
threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION,
threadChangedFilesExpandedById: state.threadChangedFilesExpandedById,
pullRequestMergeMethod: state.pullRequestMergeMethod,
iosPromoDismissed: state.iosPromoDismissed,
} satisfies PersistedUiState),
);
if (!legacyKeysCleanedUp) {
Expand Down Expand Up @@ -362,6 +371,10 @@ function setPullRequestMergeMethod(state: UiState, method: PullRequestMergeMetho
: { ...state, pullRequestMergeMethod: method };
}

export function dismissIosPromo(state: UiState): UiState {
return state.iosPromoDismissed ? state : { ...state, iosPromoDismissed: true };
}

export function resolveProjectExpanded(
projectExpandedById: Readonly<Record<string, boolean>>,
preferenceKeys: readonly string[],
Expand Down Expand Up @@ -477,6 +490,7 @@ interface UiStateStore extends UiState {
setDefaultAdvertisedEndpointKey: (key: string | null) => void;
setSidebarProjectScopeKey: (projectKey: string | null) => void;
setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void;
dismissIosPromo: () => void;
setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;
reorderProjects: (
currentProjectOrder: readonly string[],
Expand All @@ -503,6 +517,7 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
setSidebarProjectScopeKey: (projectKey) =>
set((state) => setSidebarProjectScopeKey(state, projectKey)),
setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)),
dismissIosPromo: () => set((state) => dismissIosPromo(state)),
setProjectExpanded: (projectIds, expanded) =>
set((state) => setProjectExpanded(state, projectIds, expanded)),
reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>
Expand Down
Loading