mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-13 21:12:06 +02:00
feat: automatically update browsers on new versions
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
* - Progress bars for downloads/updates
|
||||
* - Success/error states
|
||||
* - Customizable icons and content
|
||||
* - Auto-update notifications
|
||||
*
|
||||
* Usage Examples:
|
||||
*
|
||||
@@ -23,6 +24,11 @@
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Auto-update toast:
|
||||
* ```
|
||||
* showAutoUpdateToast("Firefox", "125.0.1");
|
||||
* ```
|
||||
*
|
||||
* Download progress toast:
|
||||
* ```
|
||||
* showToast({
|
||||
@@ -47,6 +53,7 @@ import {
|
||||
LuCheckCheck,
|
||||
LuDownload,
|
||||
LuRefreshCw,
|
||||
LuRocket,
|
||||
LuTriangleAlert,
|
||||
} from "react-icons/lu";
|
||||
|
||||
@@ -139,6 +146,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
|
||||
@@ -151,11 +162,33 @@ export function UnifiedToast(props: ToastProps) {
|
||||
const stage = "stage" in props ? props.stage : undefined;
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
// Check if this is an auto-update toast
|
||||
const isAutoUpdate = title.includes("update started");
|
||||
|
||||
return (
|
||||
<div className="flex items-start p-3 w-96 bg-white rounded-lg border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<div
|
||||
className={`flex items-start p-3 w-96 rounded-lg border shadow-lg ${
|
||||
isAutoUpdate
|
||||
? "bg-emerald-50 border-emerald-200 dark:bg-emerald-950 dark:border-emerald-800"
|
||||
: "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700"
|
||||
}`}
|
||||
data-toast-type={isAutoUpdate ? "auto-update" : "default"}
|
||||
>
|
||||
<div className="mr-3 mt-0.5">
|
||||
{isAutoUpdate ? (
|
||||
<LuRocket className="flex-shrink-0 w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
getToastIcon(type, stage)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-tight text-gray-900 dark:text-white">
|
||||
<p
|
||||
className={`text-sm font-medium leading-tight ${
|
||||
isAutoUpdate
|
||||
? "text-emerald-900 dark:text-emerald-100"
|
||||
: "text-gray-900 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
|
||||
@@ -225,7 +258,13 @@ export function UnifiedToast(props: ToastProps) {
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1 text-xs leading-tight text-gray-600 dark:text-gray-300">
|
||||
<p
|
||||
className={`mt-1 text-xs leading-tight ${
|
||||
isAutoUpdate
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -275,70 +274,6 @@ export function ProfilesDataTable({
|
||||
return browserA.localeCompare(browserB);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
header: "Version",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: ({ column }) => {
|
||||
const isSorted = column.getIsSorted();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="p-0 h-auto font-semibold hover:bg-transparent"
|
||||
>
|
||||
Status
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
|
||||
{isSorted === "desc" && (
|
||||
<LuChevronDown className="ml-2 w-4 h-4" />
|
||||
)}
|
||||
{!isSorted && (
|
||||
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
const isRunning = isClient && runningProfiles.has(profile.name);
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
variant={isRunning ? "default" : "secondary"}
|
||||
className="text-xs w-fit"
|
||||
>
|
||||
{isClient ? (isRunning ? "Running" : "Stopped") : "Loading..."}
|
||||
</Badge>
|
||||
{isClient && isRunning && profile.process_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
PID: {profile.process_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
// If not on client, sort by name only to ensure consistency
|
||||
if (!isClient) {
|
||||
return rowA.original.name.localeCompare(rowB.original.name);
|
||||
}
|
||||
|
||||
const isRunningA = runningProfiles.has(rowA.original.name);
|
||||
const isRunningB = runningProfiles.has(rowB.original.name);
|
||||
|
||||
// Running profiles come first, then stopped ones
|
||||
// Secondary sort by profile name
|
||||
if (isRunningA === isRunningB) {
|
||||
return rowA.original.name.localeCompare(rowB.original.name);
|
||||
}
|
||||
return isRunningA ? -1 : 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "proxy",
|
||||
header: "Proxy",
|
||||
|
||||
@@ -3,9 +3,7 @@ import {
|
||||
dismissToast,
|
||||
showDownloadToast,
|
||||
showErrorToast,
|
||||
showFetchingToast,
|
||||
showSuccessToast,
|
||||
showUnifiedVersionUpdateToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
@@ -45,15 +43,6 @@ interface BrowserVersionsResult {
|
||||
total_versions_count: number;
|
||||
}
|
||||
|
||||
interface VersionUpdateProgress {
|
||||
current_browser: string;
|
||||
total_browsers: number;
|
||||
completed_browsers: number;
|
||||
new_versions_found: number;
|
||||
browser_new_versions: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function useBrowserDownload() {
|
||||
const [availableVersions, setAvailableVersions] = useState<GithubRelease[]>(
|
||||
[],
|
||||
@@ -62,7 +51,6 @@ export function useBrowserDownload() {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] =
|
||||
useState<DownloadProgress | null>(null);
|
||||
const [isUpdatingVersions, setIsUpdatingVersions] = useState(false);
|
||||
|
||||
// Listen for download progress events
|
||||
useEffect(() => {
|
||||
@@ -128,70 +116,6 @@ export function useBrowserDownload() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Listen for version update progress events
|
||||
useEffect(() => {
|
||||
const unlisten = listen<VersionUpdateProgress>(
|
||||
"version-update-progress",
|
||||
(event) => {
|
||||
const progress = event.payload;
|
||||
|
||||
if (progress.status === "updating") {
|
||||
setIsUpdatingVersions(true);
|
||||
|
||||
// Show unified progress toast
|
||||
const currentBrowserName = progress.current_browser
|
||||
? getBrowserDisplayName(progress.current_browser)
|
||||
: undefined;
|
||||
|
||||
showUnifiedVersionUpdateToast("Checking for browser updates...", {
|
||||
description: currentBrowserName
|
||||
? `Fetching ${currentBrowserName} release information...`
|
||||
: "Initializing version check...",
|
||||
progress: {
|
||||
current: progress.completed_browsers,
|
||||
total: progress.total_browsers,
|
||||
found: progress.new_versions_found,
|
||||
current_browser: currentBrowserName,
|
||||
},
|
||||
});
|
||||
} else if (progress.status === "completed") {
|
||||
setIsUpdatingVersions(false);
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
if (progress.new_versions_found > 0) {
|
||||
showSuccessToast(
|
||||
`Found ${progress.new_versions_found} new browser versions!`,
|
||||
{
|
||||
duration: 4000,
|
||||
description:
|
||||
"Version information has been updated in the background",
|
||||
},
|
||||
);
|
||||
} else {
|
||||
showSuccessToast("No new browser versions found", {
|
||||
duration: 3000,
|
||||
description: "All browser versions are up to date",
|
||||
});
|
||||
}
|
||||
} else if (progress.status === "error") {
|
||||
setIsUpdatingVersions(false);
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
showErrorToast("Failed to check for new versions", {
|
||||
duration: 4000,
|
||||
description: "Check your internet connection and try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
void unlisten.then((fn) => {
|
||||
fn();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
return `${Math.round(seconds)}s`;
|
||||
@@ -217,10 +141,8 @@ export function useBrowserDownload() {
|
||||
const loadVersions = useCallback(async (browserStr: string) => {
|
||||
const browserName = getBrowserDisplayName(browserStr);
|
||||
|
||||
// Show fetching toast
|
||||
const toastId = showFetchingToast(browserName, {
|
||||
id: `fetch-${browserStr}`,
|
||||
});
|
||||
// Use a simple loading state instead of toast for version fetching
|
||||
console.log(`Fetching ${browserName} versions...`);
|
||||
|
||||
try {
|
||||
const versionInfos = await invoke<BrowserVersionInfo[]>(
|
||||
@@ -239,11 +161,9 @@ export function useBrowserDownload() {
|
||||
);
|
||||
|
||||
setAvailableVersions(githubReleases);
|
||||
dismissToast(toastId);
|
||||
return githubReleases;
|
||||
} catch (error) {
|
||||
console.error("Failed to load versions:", error);
|
||||
dismissToast(toastId);
|
||||
showErrorToast(`Failed to fetch ${browserName} versions`, {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
@@ -377,7 +297,6 @@ export function useBrowserDownload() {
|
||||
downloadedVersions,
|
||||
isDownloading,
|
||||
downloadProgress,
|
||||
isUpdatingVersions,
|
||||
loadVersions,
|
||||
loadVersionsWithNewCount,
|
||||
loadDownloadedVersions,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { UpdateNotificationComponent } from "@/components/update-notification";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import { showToast } from "@/lib/toast-utils";
|
||||
import { showAutoUpdateToast, showToast } from "@/lib/toast-utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface UpdateNotification {
|
||||
id: string;
|
||||
@@ -23,73 +21,82 @@ export function useUpdateNotifications(
|
||||
const [updatingBrowsers, setUpdatingBrowsers] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [dismissedNotifications, setDismissedNotifications] = useState<
|
||||
const [processedNotifications, setProcessedNotifications] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
// Add refs to track ongoing operations to prevent duplicates
|
||||
const isCheckingForUpdates = useRef(false);
|
||||
const activeDownloads = useRef<Set<string>>(new Set()); // Track "browser-version" keys
|
||||
|
||||
const checkForUpdates = useCallback(async () => {
|
||||
// Prevent multiple simultaneous calls
|
||||
if (isCheckingForUpdates.current) {
|
||||
console.log("Already checking for updates, skipping duplicate call");
|
||||
return;
|
||||
}
|
||||
|
||||
isCheckingForUpdates.current = true;
|
||||
|
||||
try {
|
||||
const updates = await invoke<UpdateNotification[]>(
|
||||
"check_for_browser_updates",
|
||||
);
|
||||
|
||||
// Filter out dismissed notifications unless they're for a newer version
|
||||
const filteredUpdates = updates.filter((notification) => {
|
||||
// Check if this exact notification was dismissed
|
||||
if (dismissedNotifications.has(notification.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we dismissed an older version for this browser
|
||||
const dismissedForBrowser = Array.from(dismissedNotifications).find(
|
||||
(dismissedId) => {
|
||||
const parts = dismissedId.split("_");
|
||||
if (parts.length >= 2) {
|
||||
const browser = parts[0];
|
||||
return browser === notification.browser;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
if (dismissedForBrowser) {
|
||||
// Extract the dismissed version to compare
|
||||
const dismissedParts = dismissedForBrowser.split("_to_");
|
||||
if (dismissedParts.length === 2) {
|
||||
const dismissedToVersion = dismissedParts[1];
|
||||
// Only show if this is a newer version than what was dismissed
|
||||
return notification.new_version !== dismissedToVersion;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
// Filter out already processed notifications
|
||||
const newUpdates = updates.filter((notification) => {
|
||||
return !processedNotifications.has(notification.id);
|
||||
});
|
||||
|
||||
setNotifications(filteredUpdates);
|
||||
setNotifications(newUpdates);
|
||||
|
||||
// Show toasts for new notifications - we'll define handleUpdate and handleDismiss separately
|
||||
// to avoid circular dependencies
|
||||
// Automatically start downloads for new update notifications
|
||||
for (const notification of newUpdates) {
|
||||
if (!processedNotifications.has(notification.id)) {
|
||||
setProcessedNotifications((prev) =>
|
||||
new Set(prev).add(notification.id),
|
||||
);
|
||||
// Start automatic update without user interaction
|
||||
void handleAutoUpdate(
|
||||
notification.browser,
|
||||
notification.new_version,
|
||||
notification.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check for updates:", error);
|
||||
} finally {
|
||||
isCheckingForUpdates.current = false;
|
||||
}
|
||||
}, [dismissedNotifications]);
|
||||
}, [processedNotifications]);
|
||||
|
||||
const handleAutoUpdate = useCallback(
|
||||
async (browser: string, newVersion: string, notificationId: string) => {
|
||||
const downloadKey = `${browser}-${newVersion}`;
|
||||
|
||||
// Check if this download is already in progress
|
||||
if (activeDownloads.current.has(downloadKey)) {
|
||||
console.log(
|
||||
`Download already in progress for ${downloadKey}, skipping duplicate`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark download as active
|
||||
activeDownloads.current.add(downloadKey);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (browser: string, newVersion: string) => {
|
||||
try {
|
||||
setUpdatingBrowsers((prev) => new Set(prev).add(browser));
|
||||
const browserDisplayName = getBrowserDisplayName(browser);
|
||||
|
||||
// Dismiss all notifications for this browser first
|
||||
const browserNotifications = notifications.filter(
|
||||
(n) => n.browser === browser,
|
||||
);
|
||||
for (const notification of browserNotifications) {
|
||||
toast.dismiss(notification.id);
|
||||
await invoke("dismiss_update_notification", {
|
||||
notificationId: notification.id,
|
||||
});
|
||||
}
|
||||
// Dismiss the notification in the backend
|
||||
await invoke("dismiss_update_notification", {
|
||||
notificationId,
|
||||
});
|
||||
|
||||
// Show auto-update started toast
|
||||
showAutoUpdateToast(browserDisplayName, newVersion);
|
||||
|
||||
try {
|
||||
// Check if browser already exists before downloading
|
||||
@@ -134,17 +141,19 @@ export function useUpdateNotifications(
|
||||
: `${updatedProfiles.length} profiles have been updated`;
|
||||
|
||||
showToast({
|
||||
id: `auto-update-success-${browser}-${newVersion}`,
|
||||
type: "success",
|
||||
title: `${browserDisplayName} update completed`,
|
||||
description: `${profileText} to version ${newVersion}. Running profiles were not updated and can be updated manually.`,
|
||||
description: `${profileText} to version ${newVersion}. To update running profiles, restart them.`,
|
||||
duration: 5000,
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
id: `auto-update-success-${browser}-${newVersion}`,
|
||||
type: "success",
|
||||
title: `${browserDisplayName} update ready`,
|
||||
description:
|
||||
"All affected profiles are currently running. Stop them and manually update their versions to use the new version.",
|
||||
"All affected profiles are currently running. To update them, restart them.",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
@@ -167,6 +176,7 @@ export function useUpdateNotifications(
|
||||
}
|
||||
|
||||
showToast({
|
||||
id: `auto-update-error-${browser}-${newVersion}`,
|
||||
type: "error",
|
||||
title: `Failed to download ${browserDisplayName} ${newVersion}`,
|
||||
description: String(downloadError),
|
||||
@@ -175,18 +185,21 @@ export function useUpdateNotifications(
|
||||
throw downloadError;
|
||||
}
|
||||
|
||||
// Refresh notifications to clear any remaining ones
|
||||
await checkForUpdates();
|
||||
// Don't call checkForUpdates() again here as it can cause recursion and duplicates
|
||||
// The periodic checks will handle finding any remaining updates
|
||||
} catch (error) {
|
||||
console.error("Failed to start update:", error);
|
||||
console.error("Failed to start auto-update:", error);
|
||||
const browserDisplayName = getBrowserDisplayName(browser);
|
||||
showToast({
|
||||
id: `auto-update-error-${browser}-${newVersion}`,
|
||||
type: "error",
|
||||
title: `Failed to update ${browserDisplayName}`,
|
||||
description: String(error),
|
||||
duration: 6000,
|
||||
});
|
||||
} finally {
|
||||
// Remove from active downloads and updating browsers
|
||||
activeDownloads.current.delete(downloadKey);
|
||||
setUpdatingBrowsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(browser);
|
||||
@@ -194,52 +207,18 @@ export function useUpdateNotifications(
|
||||
});
|
||||
}
|
||||
},
|
||||
[notifications, checkForUpdates, onProfilesUpdated],
|
||||
[onProfilesUpdated],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (notificationId: string) => {
|
||||
try {
|
||||
toast.dismiss(notificationId);
|
||||
await invoke("dismiss_update_notification", { notificationId });
|
||||
|
||||
// Track this notification as dismissed to prevent showing it again
|
||||
setDismissedNotifications((prev) => new Set(prev).add(notificationId));
|
||||
|
||||
await checkForUpdates();
|
||||
} catch (error) {
|
||||
console.error("Failed to dismiss notification:", error);
|
||||
}
|
||||
},
|
||||
[checkForUpdates],
|
||||
);
|
||||
|
||||
// Separate effect to show toasts when notifications change
|
||||
// Clean up notifications when they're no longer needed
|
||||
useEffect(() => {
|
||||
for (const notification of notifications) {
|
||||
const isUpdating = updatingBrowsers.has(notification.browser);
|
||||
|
||||
toast.custom(
|
||||
() => (
|
||||
<UpdateNotificationComponent
|
||||
notification={notification}
|
||||
onUpdate={handleUpdate}
|
||||
onDismiss={handleDismiss}
|
||||
isUpdating={isUpdating}
|
||||
/>
|
||||
),
|
||||
{
|
||||
id: notification.id,
|
||||
duration: Number.POSITIVE_INFINITY, // Persistent until user action
|
||||
position: "top-right",
|
||||
style: {
|
||||
zIndex: 99999, // Ensure notifications appear above dialogs
|
||||
pointerEvents: "auto", // Ensure notifications remain interactive
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [notifications, updatingBrowsers, handleUpdate, handleDismiss]);
|
||||
// Remove notifications that have been processed
|
||||
setNotifications((prev) =>
|
||||
prev.filter(
|
||||
(notification) => !processedNotifications.has(notification.id),
|
||||
),
|
||||
);
|
||||
}, [processedNotifications]);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
|
||||
@@ -68,14 +68,11 @@ export function useVersionUpdater() {
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
if (progress.new_versions_found > 0) {
|
||||
toast.success(
|
||||
`Found ${progress.new_versions_found} new browser versions!`,
|
||||
{
|
||||
duration: 4000,
|
||||
description:
|
||||
"Version information has been updated in the background",
|
||||
},
|
||||
);
|
||||
toast.success("Browser versions updated successfully", {
|
||||
duration: 4000,
|
||||
description:
|
||||
"Version information has been updated in the background",
|
||||
});
|
||||
} else {
|
||||
toast.success("No new browser versions found", {
|
||||
duration: 3000,
|
||||
|
||||
@@ -294,6 +294,26 @@ export function showTwilightUpdateToast(
|
||||
});
|
||||
}
|
||||
|
||||
export function showAutoUpdateToast(
|
||||
browserName: string,
|
||||
version: string,
|
||||
options?: {
|
||||
id?: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
},
|
||||
) {
|
||||
return showToast({
|
||||
type: "loading",
|
||||
title: `${browserName} update started`,
|
||||
description:
|
||||
options?.description ??
|
||||
`Automatically downloading ${browserName} ${version}. Progress will be shown in download notifications.`,
|
||||
id: options?.id ?? `auto-update-${browserName.toLowerCase()}-${version}`,
|
||||
duration: options?.duration ?? 4000,
|
||||
});
|
||||
}
|
||||
|
||||
// Generic helper for dismissing toasts
|
||||
export function dismissToast(id: string) {
|
||||
sonnerToast.dismiss(id);
|
||||
|
||||
Reference in New Issue
Block a user