mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat: improve desktop app update UX (#401)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -447,6 +447,8 @@ export function AppShell() {
|
||||
>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="fixed right-[16px] top-[8px] z-50">
|
||||
<UpdateIndicator
|
||||
onOpenUpdates={() => handleOpenSettings("updates")}
|
||||
/>
|
||||
|
||||
@@ -3,9 +3,35 @@ import { Download, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
import { useUpdaterContext } from "./hooks/UpdaterProvider";
|
||||
import type { UpdateStatus } from "./hooks/use-updater";
|
||||
|
||||
const indicatorButtonClass =
|
||||
"relative h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground";
|
||||
"relative text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground";
|
||||
|
||||
const iconClass = "h-3.5 w-3.5";
|
||||
|
||||
const variants: Record<
|
||||
"available" | "ready",
|
||||
{ Icon: typeof Download; label: string; badgeColor: string }
|
||||
> = {
|
||||
available: {
|
||||
Icon: Download,
|
||||
label: "Update available",
|
||||
badgeColor: "bg-primary",
|
||||
},
|
||||
ready: {
|
||||
Icon: RefreshCw,
|
||||
label: "Restart to update",
|
||||
badgeColor: "bg-emerald-500",
|
||||
},
|
||||
};
|
||||
|
||||
function getVariant(state: UpdateStatus["state"]) {
|
||||
if (state === "available" || state === "ready") {
|
||||
return variants[state];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function UpdateIndicator({
|
||||
onOpenUpdates,
|
||||
@@ -13,36 +39,27 @@ export function UpdateIndicator({
|
||||
onOpenUpdates: () => void;
|
||||
}) {
|
||||
const { status } = useUpdaterContext();
|
||||
const variant = getVariant(status.state);
|
||||
|
||||
if (status.state === "available") {
|
||||
return (
|
||||
<Button
|
||||
aria-label="Update available"
|
||||
className={indicatorButtonClass}
|
||||
onClick={onOpenUpdates}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
</Button>
|
||||
);
|
||||
if (!variant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status.state === "ready") {
|
||||
return (
|
||||
<Button
|
||||
aria-label="Restart to update"
|
||||
className={indicatorButtonClass}
|
||||
onClick={onOpenUpdates}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
<span className="absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-500" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const { Icon, label, badgeColor } = variant;
|
||||
|
||||
return null;
|
||||
return (
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={indicatorButtonClass}
|
||||
onClick={onOpenUpdates}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<Icon className={iconClass} />
|
||||
{label}
|
||||
<span
|
||||
className={`absolute -top-0.5 -right-0.5 h-2 w-2 rounded-full ${badgeColor} animate-pulse`}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,14 +13,51 @@ export type UpdateStatus =
|
||||
| { state: "ready" }
|
||||
| { state: "error"; message: string };
|
||||
|
||||
const TOAST_ID = "update-available";
|
||||
|
||||
function toErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
function isUpdaterUnavailable(message: string): boolean {
|
||||
return (
|
||||
message.includes("plugin updater not found") ||
|
||||
message.includes("not initialized")
|
||||
);
|
||||
}
|
||||
|
||||
export function useUpdater() {
|
||||
const [status, setStatus] = useState<UpdateStatus>({ state: "idle" });
|
||||
const updateRef = useRef<Update | null>(null);
|
||||
|
||||
const closeUpdate = useCallback(async () => {
|
||||
if (updateRef.current) {
|
||||
await updateRef.current.close();
|
||||
const current = updateRef.current;
|
||||
if (current) {
|
||||
updateRef.current = null;
|
||||
await current.close();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
try {
|
||||
const update = updateRef.current;
|
||||
if (!update) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast.dismiss(TOAST_ID);
|
||||
setStatus({ state: "downloading" });
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === "Finished") {
|
||||
setStatus({ state: "installing" });
|
||||
}
|
||||
});
|
||||
|
||||
updateRef.current = null;
|
||||
setStatus({ state: "ready" });
|
||||
} catch (err) {
|
||||
setStatus({ state: "error", message: toErrorMessage(err) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -34,58 +71,32 @@ export function useUpdater() {
|
||||
updateRef.current = update;
|
||||
setStatus({ state: "available", version: update.version });
|
||||
toast("Update Available", {
|
||||
id: "update-available",
|
||||
id: TOAST_ID,
|
||||
description: `Version ${update.version} is ready to download.`,
|
||||
duration: Infinity,
|
||||
action: {
|
||||
label: "Download & install",
|
||||
onClick: () => downloadAndInstall(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
setStatus({ state: "up-to-date" });
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (
|
||||
msg.includes("plugin updater not found") ||
|
||||
msg.includes("not initialized")
|
||||
) {
|
||||
const message = toErrorMessage(err);
|
||||
if (isUpdaterUnavailable(message)) {
|
||||
setStatus({ state: "idle" });
|
||||
return;
|
||||
}
|
||||
setStatus({ state: "error", message: msg });
|
||||
setStatus({ state: "error", message });
|
||||
}
|
||||
}, [closeUpdate]);
|
||||
|
||||
const downloadAndInstall = useCallback(async () => {
|
||||
try {
|
||||
const update = updateRef.current;
|
||||
if (!update) {
|
||||
setStatus({ state: "up-to-date" });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({ state: "downloading" });
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === "Finished") {
|
||||
setStatus({ state: "installing" });
|
||||
}
|
||||
});
|
||||
|
||||
setStatus({ state: "ready" });
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
state: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
}, [closeUpdate, downloadAndInstall]);
|
||||
|
||||
const handleRelaunch = useCallback(async () => {
|
||||
try {
|
||||
await relaunch();
|
||||
} catch (err) {
|
||||
setStatus({
|
||||
state: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
setStatus({ state: "error", message: toErrorMessage(err) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user