Skip to content

Commit 3e66ae7

Browse files
Fix desktop Pro feature gating (#4799)
* fix: gate playback rate controls to pro * fix: require pro for google calendar connects * fix: clear hosted llm selection after billing expiry * fix: gate folder features to pro
1 parent 95be706 commit 3e66ae7

File tree

15 files changed

+178
-58
lines changed

15 files changed

+178
-58
lines changed

apps/desktop/src/audio-player/provider.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import WaveSurfer from "wavesurfer.js";
1414

1515
import { commands as fsSyncCommands } from "@hypr/plugin-fs-sync";
1616

17+
import { useBillingAccess } from "~/auth/billing";
18+
1719
type AudioPlayerState = "playing" | "paused" | "stopped";
1820

1921
interface TimeSnapshot {
@@ -102,6 +104,7 @@ export function AudioPlayerProvider({
102104
children: ReactNode;
103105
}) {
104106
const queryClient = useQueryClient();
107+
const { isPro } = useBillingAccess();
105108
const [container, setContainer] = useState<HTMLDivElement | null>(null);
106109
const [wavesurfer, setWavesurfer] = useState<WaveSurfer | null>(null);
107110
const [state, setState] = useState<AudioPlayerState>("stopped");
@@ -257,14 +260,27 @@ export function AudioPlayerProvider({
257260

258261
const setPlaybackRate = useCallback(
259262
(rate: number) => {
263+
if (!isPro && rate !== 1) {
264+
return;
265+
}
260266
if (wavesurfer) {
261267
wavesurfer.setPlaybackRate(rate);
262268
}
263269
setPlaybackRateState(rate);
264270
},
265-
[wavesurfer],
271+
[isPro, wavesurfer],
266272
);
267273

274+
useEffect(() => {
275+
if (isPro || playbackRate === 1) {
276+
return;
277+
}
278+
if (wavesurfer) {
279+
wavesurfer.setPlaybackRate(1);
280+
}
281+
setPlaybackRateState(1);
282+
}, [isPro, playbackRate, wavesurfer]);
283+
268284
const deleteRecordingMutation = useMutation({
269285
mutationFn: async () => {
270286
const result = await fsSyncCommands.audioDelete(sessionId);

apps/desktop/src/audio-player/timeline.tsx

Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { cn } from "@hypr/utils";
55

66
import { useAudioPlayer, useAudioTime } from "./provider";
77

8+
import { useBillingAccess } from "~/auth/billing";
89
import { useNativeContextMenu } from "~/shared/hooks/useNativeContextMenu";
910

1011
const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2];
1112

1213
export function Timeline() {
14+
const { isPro } = useBillingAccess();
1315
const {
1416
registerContainer,
1517
state,
@@ -116,49 +118,51 @@ export function Timeline() {
116118
<span>{formatTime(time.total)}</span>
117119
</div>
118120

119-
<div className="relative shrink-0" ref={rateMenuRef}>
120-
<button
121-
onClick={() => setShowRateMenu((prev) => !prev)}
122-
className={cn([
123-
"flex items-center justify-center",
124-
"h-6 rounded-md px-1.5",
125-
"border border-neutral-200 bg-white",
126-
"transition-colors hover:bg-neutral-100",
127-
"font-mono text-xs text-neutral-700 select-none",
128-
"shadow-xs",
129-
])}
130-
>
131-
{playbackRate}x
132-
</button>
133-
{showRateMenu && (
134-
<div
121+
{isPro ? (
122+
<div className="relative shrink-0" ref={rateMenuRef}>
123+
<button
124+
onClick={() => setShowRateMenu((prev) => !prev)}
135125
className={cn([
136-
"absolute right-0 bottom-full mb-1",
137-
"rounded-lg border border-neutral-200 bg-white shadow-md",
138-
"z-50 py-1",
126+
"flex items-center justify-center",
127+
"h-6 rounded-md px-1.5",
128+
"border border-neutral-200 bg-white",
129+
"transition-colors hover:bg-neutral-100",
130+
"font-mono text-xs text-neutral-700 select-none",
131+
"shadow-xs",
139132
])}
140133
>
141-
{PLAYBACK_RATES.map((rate) => (
142-
<button
143-
key={rate}
144-
onClick={() => {
145-
setPlaybackRate(rate);
146-
setShowRateMenu(false);
147-
}}
148-
className={cn([
149-
"block w-full px-3 py-1 text-left font-mono text-xs select-none",
150-
"transition-colors hover:bg-neutral-100",
151-
rate === playbackRate
152-
? "font-semibold text-neutral-900"
153-
: "text-neutral-600",
154-
])}
155-
>
156-
{rate}x
157-
</button>
158-
))}
159-
</div>
160-
)}
161-
</div>
134+
{playbackRate}x
135+
</button>
136+
{showRateMenu && (
137+
<div
138+
className={cn([
139+
"absolute right-0 bottom-full mb-1",
140+
"rounded-lg border border-neutral-200 bg-white shadow-md",
141+
"z-50 py-1",
142+
])}
143+
>
144+
{PLAYBACK_RATES.map((rate) => (
145+
<button
146+
key={rate}
147+
onClick={() => {
148+
setPlaybackRate(rate);
149+
setShowRateMenu(false);
150+
}}
151+
className={cn([
152+
"block w-full px-3 py-1 text-left font-mono text-xs select-none",
153+
"transition-colors hover:bg-neutral-100",
154+
rate === playbackRate
155+
? "font-semibold text-neutral-900"
156+
: "text-neutral-600",
157+
])}
158+
>
159+
{rate}x
160+
</button>
161+
))}
162+
</div>
163+
)}
164+
</div>
165+
) : null}
162166

163167
<div
164168
ref={registerContainer}

apps/desktop/src/auth/billing.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
type ReactNode,
55
useCallback,
66
useContext,
7+
useEffect,
78
useMemo,
89
} from "react";
910

@@ -21,6 +22,8 @@ import { env } from "../env";
2122
import { buildWebAppUrl } from "../shared/utils";
2223
import { useAuth } from "./context";
2324

25+
import * as settings from "~/store/tinybase/store/settings";
26+
2427
async function getClaimsFromToken(
2528
accessToken: string,
2629
): Promise<SupabaseJwtPayload | null> {
@@ -49,6 +52,8 @@ const BillingContext = createContext<BillingContextValue | null>(null);
4952

5053
export function BillingProvider({ children }: { children: ReactNode }) {
5154
const auth = useAuth();
55+
const settingsStore = settings.UI.useStore(settings.STORE_ID);
56+
const { current_llm_provider } = settings.UI.useValues(settings.STORE_ID);
5257

5358
const claimsQuery = useQuery({
5459
queryKey: ["tokenInfo", auth?.session?.access_token ?? ""],
@@ -89,6 +94,25 @@ export function BillingProvider({ children }: { children: ReactNode }) {
8994
void openerCommands.openUrl(url, null);
9095
}, []);
9196

97+
useEffect(() => {
98+
if (!auth?.session?.user.id || !isReady || billing.isPaid) {
99+
return;
100+
}
101+
102+
if (current_llm_provider !== "hyprnote") {
103+
return;
104+
}
105+
106+
settingsStore?.setValue("current_llm_provider", "");
107+
settingsStore?.setValue("current_llm_model", "");
108+
}, [
109+
auth?.session?.user.id,
110+
billing.isPaid,
111+
current_llm_provider,
112+
isReady,
113+
settingsStore,
114+
]);
115+
92116
const value = useMemo<BillingContextValue>(
93117
() => ({
94118
...billing,

apps/desktop/src/calendar/components/oauth/provider-content.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import { openIntegrationUrl } from "~/shared/integration";
2121

2222
export function OAuthProviderContent({ config }: { config: CalendarProvider }) {
2323
const auth = useAuth();
24-
const { isPaid, upgradeToPro } = useBillingAccess();
25-
const { data: connections, isError } = useConnections(isPaid);
24+
const { isPro, upgradeToPro } = useBillingAccess();
25+
const { data: connections, isError } = useConnections(isPro);
2626
const providerConnections = useMemo(
2727
() =>
2828
connections?.filter(
@@ -62,7 +62,7 @@ export function OAuthProviderContent({ config }: { config: CalendarProvider }) {
6262
);
6363
}
6464

65-
if (!isPaid) {
65+
if (!isPro) {
6666
return (
6767
<div className="pt-1 pb-2">
6868
<button

apps/desktop/src/folders/index.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { FolderIcon, FoldersIcon, StickyNoteIcon } from "lucide-react";
22
import { useCallback, useMemo, useState } from "react";
33

4+
import { Button } from "@hypr/ui/components/ui/button";
45
import { cn } from "@hypr/utils";
56

67
import { Section } from "./shared";
78

9+
import { useBillingAccess } from "~/auth/billing";
810
import { StandardTabWrapper } from "~/shared/main";
911
import { type TabItem, TabItemBase } from "~/shared/tabs";
1012
import {
@@ -138,10 +140,20 @@ const TabItemFolderSpecific: TabItem<Extract<Tab, { type: "folders" }>> = ({
138140
};
139141

140142
export function TabContentFolder({ tab }: { tab: Tab }) {
143+
const { isPro, upgradeToPro } = useBillingAccess();
144+
141145
if (tab.type !== "folders") {
142146
return null;
143147
}
144148

149+
if (!isPro) {
150+
return (
151+
<StandardTabWrapper>
152+
<FolderUpgradeState onUpgrade={upgradeToPro} />
153+
</StandardTabWrapper>
154+
);
155+
}
156+
145157
return (
146158
<StandardTabWrapper>
147159
{tab.id === null ? (
@@ -153,6 +165,23 @@ export function TabContentFolder({ tab }: { tab: Tab }) {
153165
);
154166
}
155167

168+
function FolderUpgradeState({ onUpgrade }: { onUpgrade: () => void }) {
169+
return (
170+
<div className="flex h-full items-center justify-center p-8">
171+
<div className="flex max-w-sm flex-col items-center gap-3 text-center">
172+
<FoldersIcon className="h-10 w-10 text-neutral-500" />
173+
<h1 className="text-lg font-semibold text-neutral-900">
174+
Folders are available on Pro
175+
</h1>
176+
<p className="text-sm text-neutral-600">
177+
Upgrade to Pro to open folder tabs and organize notes into folders.
178+
</p>
179+
<Button onClick={onUpgrade}>Upgrade to Pro</Button>
180+
</div>
181+
</div>
182+
);
183+
}
184+
156185
function TabContentFolderTopLevel() {
157186
return (
158187
<div className="justify-left flex h-full items-start p-8">

apps/desktop/src/onboarding/calendar.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,8 @@ function addIntegrationMenus({
244244

245245
function GoogleCalendarProvider({ onSignIn }: { onSignIn: () => void }) {
246246
const auth = useAuth();
247-
const { isPaid, isReady, upgradeToPro } = useBillingAccess();
248-
const { data: connections, isPending, isError } = useConnections(isPaid);
247+
const { isPro, isReady, upgradeToPro } = useBillingAccess();
248+
const { data: connections, isPending, isError } = useConnections(isPro);
249249
const providerConnections = useMemo(
250250
() =>
251251
connections?.filter(
@@ -261,7 +261,7 @@ function GoogleCalendarProvider({ onSignIn }: { onSignIn: () => void }) {
261261
return;
262262
}
263263

264-
if (!isPaid) {
264+
if (!isPro) {
265265
upgradeToPro();
266266
return;
267267
}
@@ -271,7 +271,7 @@ function GoogleCalendarProvider({ onSignIn }: { onSignIn: () => void }) {
271271
undefined,
272272
"connect",
273273
);
274-
}, [auth.session, isPaid, onSignIn, upgradeToPro]);
274+
}, [auth.session, isPro, onSignIn, upgradeToPro]);
275275

276276
if (!GOOGLE_PROVIDER) {
277277
return null;

apps/desktop/src/session/components/note-input/enhanced/config-error.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ function getMessageForStatus(status: LLMConnectionStatus): string {
4444
return "You need to sign in to use Char's language model";
4545
}
4646

47+
if (status.status === "error" && status.reason === "not_pro") {
48+
return "Your Char plan has expired. Configure another language model or renew your plan";
49+
}
50+
4751
if (status.status === "error" && status.reason === "missing_config") {
4852
const missing = status.missing;
4953
if (missing.includes("api_key") && missing.includes("base_url")) {

apps/desktop/src/session/components/note-input/enhanced/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const Enhanced = forwardRef<
3232
llmStatus.status === "pending" ||
3333
(llmStatus.status === "error" &&
3434
(llmStatus.reason === "missing_config" ||
35+
llmStatus.reason === "not_pro" ||
3536
llmStatus.reason === "unauthenticated"));
3637

3738
if (status === "idle" && isConfigError && !hasContent) {

apps/desktop/src/session/components/note-input/header.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,6 +1083,7 @@ function useEnhanceLogic(sessionId: string, enhancedNoteId: string) {
10831083
llmStatus.status === "pending" ||
10841084
(llmStatus.status === "error" &&
10851085
(llmStatus.reason === "missing_config" ||
1086+
llmStatus.reason === "not_pro" ||
10861087
llmStatus.reason === "unauthenticated"));
10871088

10881089
const isIdleWithConfigError = enhanceTask.isIdle && isConfigError;

apps/desktop/src/session/components/outer-header/folder/index.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import { Button } from "@hypr/ui/components/ui/button";
1212

1313
import { SearchableFolderDropdown } from "./searchable-dropdown";
1414

15+
import { useBillingAccess } from "~/auth/billing";
1516
import { FolderBreadcrumb } from "~/shared/ui/folder-breadcrumb";
1617
import * as main from "~/store/tinybase/store/main";
1718
import { useSessionTitle } from "~/store/zustand/live-title";
1819
import { useTabs } from "~/store/zustand/tabs";
1920

2021
export function FolderChain({ sessionId }: { sessionId: string }) {
22+
const { isPro } = useBillingAccess();
2123
const folderId = main.UI.useCell(
2224
"sessions",
2325
sessionId,
@@ -40,6 +42,20 @@ export function FolderChain({ sessionId }: { sessionId: string }) {
4042
main.STORE_ID,
4143
);
4244

45+
if (!isPro) {
46+
return (
47+
<Breadcrumb className="ml-1.5 w-full min-w-0">
48+
<BreadcrumbList className="w-full flex-nowrap gap-0.5 overflow-hidden text-xs text-neutral-700">
49+
<BreadcrumbItem className="min-w-0 flex-1 overflow-hidden">
50+
<BreadcrumbPage className="block w-full min-w-0">
51+
<TitleInput title={title} handleChangeTitle={handleChangeTitle} />
52+
</BreadcrumbPage>
53+
</BreadcrumbItem>
54+
</BreadcrumbList>
55+
</Breadcrumb>
56+
);
57+
}
58+
4359
return (
4460
<Breadcrumb className="ml-1.5 w-full min-w-0">
4561
<BreadcrumbList className="w-full flex-nowrap gap-0.5 overflow-hidden text-xs text-neutral-700">

0 commit comments

Comments
 (0)