feat: add update notification component with download and install functionality

This commit is contained in:
2026-02-22 00:59:14 +11:00
parent d9eae3f07c
commit b08b4b4129
4 changed files with 322 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ import { MatrixClient, RoomEvent, ClientEvent, MatrixEvent, Room, IPushRules } f
import { useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { WindowControls } from './WindowControls';
import { UpdateNotification } from '../update-notification';
import * as css from './TitleBar.css';
import { getUnreadInfos, getOrphanParents, guessPerfectParent, getAllParents } from '../../utils/room';
import { getCanonicalAliasOrRoomId, getMxIdLocalPart } from '../../utils/matrix';
@@ -615,6 +616,7 @@ export function TitleBar({ mx, children }: TitleBarProps) {
))}
{children}
</Box>
<UpdateNotification />
<WindowControls />
</Box>
);

View File

@@ -0,0 +1,76 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
export const CheckButton = style({
all: 'unset',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: config.space.S200,
borderRadius: config.radii.R300,
cursor: 'pointer',
color: color.Secondary.Main,
backgroundColor: 'transparent',
transition: 'background-color 0.15s',
minWidth: toRem(32),
minHeight: toRem(32),
opacity: 0.7,
':hover': {
backgroundColor: color.Surface.ContainerHover,
opacity: 1,
},
':active': {
backgroundColor: color.Surface.ContainerActive,
},
':focus-visible': {
outline: `2px solid ${color.Secondary.Main}`,
outlineOffset: '2px',
},
});
export const UpdateButton = style({
all: 'unset',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: config.space.S200,
borderRadius: config.radii.R300,
cursor: 'pointer',
color: color.Success.Main,
backgroundColor: 'transparent',
transition: 'background-color 0.15s',
minWidth: toRem(32),
minHeight: toRem(32),
':hover': {
backgroundColor: color.Surface.ContainerHover,
},
':active': {
backgroundColor: color.Surface.ContainerActive,
},
':focus-visible': {
outline: `2px solid ${color.Success.Main}`,
outlineOffset: '2px',
},
});
export const UpdateMenu = style({
minWidth: toRem(280),
maxWidth: toRem(320),
backgroundColor: color.Surface.Container,
borderRadius: config.radii.R400,
boxShadow: config.shadow.E400,
border: `1px solid ${color.Surface.ContainerLine}`,
});
export const ProgressText = style({
color: color.Success.Main,
fontWeight: 500,
minWidth: toRem(35),
textAlign: 'center',
});

View File

@@ -0,0 +1,243 @@
import React, { useState, useEffect } from 'react';
import { Box, IconButton, Spinner, Text, Menu, PopOut, Button, config } from 'folds';
import * as css from './UpdateNotification.css';
interface UpdateInfo {
version: string;
releaseNotes?: string;
releaseDate?: string;
}
interface DownloadProgress {
percent: number;
transferred: number;
total: number;
}
export function UpdateNotification() {
const [updateAvailable, setUpdateAvailable] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [downloading, setDownloading] = useState(false);
const [downloadProgress, setDownloadProgress] = useState(0);
const [updateReady, setUpdateReady] = useState(false);
const [checking, setChecking] = useState(false);
const [menuAnchor, setMenuAnchor] = useState<{ x: number; y: number; width: number; height: number } | undefined>(undefined);
useEffect(() => {
// Check if we're in Electron environment
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
return;
}
const { updater } = (window as any).electron;
// Listen for update available
updater.onUpdateAvailable((info: UpdateInfo) => {
console.log('Update available:', info.version);
setUpdateAvailable(true);
setUpdateInfo(info);
setDownloading(false);
setUpdateReady(false);
setChecking(false);
});
// Listen for download progress
updater.onUpdateDownloadProgress((progress: DownloadProgress) => {
setDownloadProgress(Math.round(progress.percent));
});
// Listen for update downloaded
updater.onUpdateDownloaded((info: UpdateInfo) => {
console.log('Update downloaded:', info.version);
setDownloading(false);
setUpdateReady(true);
});
// Cleanup - IPC listeners don't need manual cleanup in this case
}, []);
const handleCheckForUpdates = async () => {
setChecking(true);
try {
const result = await (window as any).electron.updater.checkForUpdates();
console.log('Update check result:', result);
// If no update found, show feedback
setTimeout(() => {
if (!updateAvailable) {
setChecking(false);
}
}, 2000);
} catch (error) {
console.error('Failed to check for updates:', error);
setChecking(false);
}
};
const handleDownload = async () => {
setDownloading(true);
setDownloadProgress(0);
try {
await (window as any).electron.updater.downloadUpdate();
} catch (error) {
console.error('Failed to download update:', error);
setDownloading(false);
}
setMenuAnchor(undefined);
};
const handleInstall = async () => {
try {
await (window as any).electron.updater.installUpdate();
} catch (error) {
console.error('Failed to install update:', error);
}
};
const handleMenuToggle = (event: React.MouseEvent<HTMLButtonElement>) => {
if (menuAnchor) {
setMenuAnchor(undefined);
} else {
const rect = event.currentTarget.getBoundingClientRect();
setMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height });
}
};
// Don't render anything if not in Electron
if (typeof window === 'undefined' || !(window as any).electron?.updater) {
return null;
}
// Show check button if no update status
if (!updateAvailable && !updateReady && !checking) {
return (
<Box gap="100" alignItems="Center">
<button
className={css.CheckButton}
onClick={handleCheckForUpdates}
aria-label="Check for updates"
title="Check for updates"
type="button"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</Box>
);
}
// Show checking state
if (checking) {
return (
<Box gap="100" alignItems="Center">
<button className={css.UpdateButton} disabled type="button">
<Spinner variant="Secondary" size="50" />
</button>
</Box>
);
}
// Show update available/ready state
if (!updateAvailable && !updateReady) {
return null;
}
return (
<Box gap="100" alignItems="Center">
<button
className={css.UpdateButton}
onClick={handleMenuToggle}
aria-label={updateReady ? 'Update ready' : 'Update available'}
title={updateReady ? 'Update downloaded - click to install' : 'New version available'}
type="button"
>
{downloading ? (
<Box alignItems="Center" gap="100">
<Spinner variant="Secondary" size="50" />
{downloadProgress > 0 && (
<Text size="T200" className={css.ProgressText}>
{downloadProgress}%
</Text>
)}
</Box>
) : (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M8 2V10M8 10L5 7M8 10L11 7"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 14H13"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
)}
</button>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={8}
content={
<Menu className={css.UpdateMenu}>
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
<Box direction="Column" gap="100">
<Text size="H5" priority="400">
{updateReady ? 'Update Ready' : 'Update Available'}
</Text>
{updateInfo && (
<Text size="T200" priority="300">
Version {updateInfo.version}
</Text>
)}
</Box>
{updateReady ? (
<Button
variant="Primary"
size="400"
onClick={handleInstall}
fill="Solid"
>
<Text size="B400">Install and Restart</Text>
</Button>
) : (
<Button
variant="Primary"
size="400"
onClick={handleDownload}
disabled={downloading}
fill="Solid"
>
<Text size="B400">
{downloading ? `Downloading ${downloadProgress}%` : 'Download Update'}
</Text>
</Button>
)}
</Box>
</Menu>
}
>
{null}
</PopOut>
</Box>
);
}

View File

@@ -0,0 +1 @@
export * from './UpdateNotification';