Skip to content
Closed
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
40 changes: 34 additions & 6 deletions apps/desktop/src/components/main/sidebar/banner/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export function Banner({
banner: BannerType;
onDismiss?: () => void;
}) {
const hasProgress = banner.progress !== undefined && banner.progress >= 0;

return (
<div className="overflow-hidden p-1">
<div
Expand Down Expand Up @@ -50,12 +52,22 @@ export function Banner({

<div className="flex flex-col gap-2 mt-1">
{banner.primaryAction && (
<button
onClick={banner.primaryAction.onClick}
className="w-full py-2 rounded-full bg-gradient-to-t from-stone-600 to-stone-500 text-white text-sm font-medium duration-150 hover:scale-[1.01] active:scale-[0.99]"
>
{banner.primaryAction.label}
</button>
<div className="relative w-full overflow-hidden rounded-full">
<button
onClick={banner.primaryAction.onClick}
className="relative w-full py-2 rounded-full bg-gradient-to-t from-stone-600 to-stone-500 text-white text-sm font-medium duration-150 hover:scale-[1.01] active:scale-[0.99] z-10"
>
<span className="relative z-10">
{banner.primaryAction.label}
</span>
</button>
{hasProgress && (
<div
className="absolute inset-0 bg-gradient-to-t from-stone-700 to-stone-600 transition-all duration-300"
style={{ width: `${banner.progress}%` }}
/>
)}
</div>
)}
{banner.secondaryAction && (
<button
Expand All @@ -65,6 +77,22 @@ export function Banner({
{banner.secondaryAction.label}
</button>
)}
{!banner.primaryAction && !banner.secondaryAction && hasProgress && (
<div className="relative w-full h-9 rounded-full overflow-hidden bg-gradient-to-t from-stone-200 to-stone-100">
<div
className="absolute inset-0 bg-gradient-to-t from-stone-600 to-stone-500 transition-all duration-300"
style={{ width: `${banner.progress}%` }}
/>
<div
className={cn([
"absolute inset-0 flex items-center justify-center text-sm font-medium transition-colors duration-300",
(banner.progress ?? 0) > 50 ? "text-white" : "text-stone-700",
])}
>
{Math.round(banner.progress ?? 0)}%
</div>
</div>
)}
</div>
</div>
</div>
Expand Down
89 changes: 84 additions & 5 deletions apps/desktop/src/components/main/sidebar/banner/index.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useMemo, useState } from "react";

import {
events as localSttEvents,
type SupportedSttModel,
} from "@hypr/plugin-local-stt";
import { cn } from "@hypr/utils";

import { useAuth } from "../../../../auth";
import { useConfigValues } from "../../../../config/use-config";
import { useTabs } from "../../../../store/zustand/tabs";
import { Banner } from "./component";
import { createBannerRegistry, getBannerToShow } from "./registry";
import type { BannerType } from "./types";
import { useDismissedBanners } from "./useDismissedBanners";

export function BannerArea({
Expand Down Expand Up @@ -90,11 +95,26 @@ export function BannerArea({
],
);

const currentBanner = useMemo(
const registryBanner = useMemo(
() => getBannerToShow(registry, isDismissed),
[registry, isDismissed],
);

const downloadProgress = useDownloadProgress();

const currentBanner: BannerType | null = useMemo(() => {
if (downloadProgress.isDownloading && downloadProgress.model) {
return {
id: "download-progress",
title: "Downloading model",
description: `${downloadProgress.modelDisplayName} is being downloaded...`,
dismissible: false,
progress: downloadProgress.progress,
};
}
return registryBanner;
}, [downloadProgress, registryBanner]);

const handleDismiss = useCallback(() => {
if (currentBanner) {
dismissBanner(currentBanner.id);
Expand Down Expand Up @@ -127,18 +147,77 @@ export function BannerArea({
);
}

let globalShowBanner = false;
let globalBannerTimer: NodeJS.Timeout | null = null;

function useShouldShowBanner(isProfileExpanded: boolean) {
const BANNER_CHECK_DELAY_MS = 3000;

const [showBanner, setShowBanner] = useState(false);
const [showBanner, setShowBanner] = useState(globalShowBanner);

useEffect(() => {
const timer = setTimeout(() => {
if (!globalShowBanner && !globalBannerTimer) {
globalBannerTimer = setTimeout(() => {
globalShowBanner = true;
setShowBanner(true);
globalBannerTimer = null;
}, BANNER_CHECK_DELAY_MS);
} else if (globalShowBanner) {
setShowBanner(true);
}, BANNER_CHECK_DELAY_MS);
}

return () => clearTimeout(timer);
return () => {};
Comment on lines +159 to +169
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Memory leak: The cleanup function no longer clears the timer when the component unmounts. If the component unmounts before BANNER_CHECK_DELAY_MS expires, the timer will continue to run and call setShowBanner(true) on an unmounted component, causing a React warning and potential memory leak.

Fix:

return () => {
  if (globalBannerTimer) {
    clearTimeout(globalBannerTimer);
    globalBannerTimer = null;
  }
};

The global timer needs to be properly cleaned up to prevent the callback from executing after unmount.

Suggested change
if (!globalShowBanner && !globalBannerTimer) {
globalBannerTimer = setTimeout(() => {
globalShowBanner = true;
setShowBanner(true);
globalBannerTimer = null;
}, BANNER_CHECK_DELAY_MS);
} else if (globalShowBanner) {
setShowBanner(true);
}, BANNER_CHECK_DELAY_MS);
}
return () => clearTimeout(timer);
return () => {};
if (!globalShowBanner && !globalBannerTimer) {
globalBannerTimer = setTimeout(() => {
globalShowBanner = true;
setShowBanner(true);
globalBannerTimer = null;
}, BANNER_CHECK_DELAY_MS);
} else if (globalShowBanner) {
setShowBanner(true);
}
return () => {
if (globalBannerTimer) {
clearTimeout(globalBannerTimer);
globalBannerTimer = null;
}
};

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

}, []);

return !isProfileExpanded && showBanner;
}

const MODEL_DISPLAY_NAMES: Record<SupportedSttModel, string> = {
"am-parakeet-v2": "Parakeet v2",
"am-parakeet-v3": "Parakeet v3",
"am-whisper-large-v3": "Whisper Large v3",
QuantizedTiny: "Whisper Tiny",
QuantizedTinyEn: "Whisper Tiny (English)",
QuantizedBase: "Whisper Base",
QuantizedBaseEn: "Whisper Base (English)",
QuantizedSmall: "Whisper Small",
QuantizedSmallEn: "Whisper Small (English)",
QuantizedLargeTurbo: "Whisper Large Turbo",
};

function useDownloadProgress() {
const [model, setModel] = useState<SupportedSttModel | null>(null);
const [progress, setProgress] = useState(0);
const [isDownloading, setIsDownloading] = useState(false);

useEffect(() => {
const unlisten = localSttEvents.downloadProgressPayload.listen((event) => {
const { model: eventModel, progress: eventProgress } = event.payload;

if (eventProgress < 0) {
setIsDownloading(false);
setModel(null);
setProgress(0);
} else if (eventProgress >= 100) {
setIsDownloading(false);
setModel(null);
setProgress(0);
} else {
setIsDownloading(true);
setModel(eventModel);
setProgress(Math.max(0, Math.min(100, eventProgress)));
}
});

return () => {
void unlisten.then((fn) => fn());
};
}, []);

return {
model,
modelDisplayName: model ? MODEL_DISPLAY_NAMES[model] : "",
progress,
isDownloading,
};
}
1 change: 1 addition & 0 deletions apps/desktop/src/components/main/sidebar/banner/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export type BannerType = {
primaryAction?: BannerAction;
secondaryAction?: BannerAction;
dismissible: boolean;
progress?: number;
};

export type BannerCondition = () => boolean;
Loading