mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-04-29 15:26:05 +02:00
refactor: fetch release information the same way for manual and automatic checks
This commit is contained in:
+18
-3
@@ -276,10 +276,25 @@ pub fn run() {
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let version_updater = get_version_updater();
|
||||
let mut updater_guard = version_updater.lock().await;
|
||||
|
||||
updater_guard.set_app_handle(app_handle.clone()).await;
|
||||
updater_guard.start_background_updates().await;
|
||||
// Set the app handle
|
||||
{
|
||||
let mut updater_guard = version_updater.lock().await;
|
||||
updater_guard.set_app_handle(app_handle);
|
||||
}
|
||||
|
||||
// Run startup check without holding the lock
|
||||
{
|
||||
let updater_guard = version_updater.lock().await;
|
||||
if let Err(e) = updater_guard.start_background_updates().await {
|
||||
eprintln!("Failed to start background updates: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start the background update task separately
|
||||
tauri::async_runtime::spawn(async move {
|
||||
version_updater::VersionUpdater::run_background_task().await;
|
||||
});
|
||||
|
||||
let app_handle_update = app.handle().clone();
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::fs::{self, create_dir_all};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::version_updater;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TableSortingSettings {
|
||||
@@ -210,9 +211,7 @@ pub async fn clear_all_version_cache_and_refetch(
|
||||
.clear_all_cache()
|
||||
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
|
||||
|
||||
// Use the version updater to trigger a proper update with progress events
|
||||
use crate::version_updater::get_version_updater;
|
||||
let updater = get_version_updater();
|
||||
let updater = version_updater::get_version_updater();
|
||||
let updater_guard = updater.lock().await;
|
||||
|
||||
updater_guard
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::browser_version_service::BrowserVersionService;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -8,7 +7,9 @@ use std::sync::OnceLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{interval, Interval};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::browser_version_service::BrowserVersionService;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct VersionUpdateProgress {
|
||||
@@ -46,22 +47,21 @@ impl Default for BackgroundUpdateState {
|
||||
|
||||
pub struct VersionUpdater {
|
||||
version_service: BrowserVersionService,
|
||||
app_handle: Arc<Mutex<Option<tauri::AppHandle>>>,
|
||||
update_interval: Interval,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
}
|
||||
|
||||
impl VersionUpdater {
|
||||
pub fn new() -> Self {
|
||||
let mut update_interval = interval(Duration::from_secs(5 * 60)); // Check every 5 minutes
|
||||
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
Self {
|
||||
version_service: BrowserVersionService::new(),
|
||||
app_handle: Arc::new(Mutex::new(None)),
|
||||
update_interval,
|
||||
app_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_app_handle(&mut self, app_handle: tauri::AppHandle) {
|
||||
self.app_handle = Some(app_handle);
|
||||
}
|
||||
|
||||
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let app_name = if cfg!(debug_assertions) {
|
||||
@@ -143,11 +143,6 @@ impl VersionUpdater {
|
||||
should_update
|
||||
}
|
||||
|
||||
pub async fn set_app_handle(&self, app_handle: tauri::AppHandle) {
|
||||
let mut handle = self.app_handle.lock().await;
|
||||
*handle = Some(app_handle);
|
||||
}
|
||||
|
||||
pub async fn check_and_run_startup_update(
|
||||
&self,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -157,15 +152,10 @@ impl VersionUpdater {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let app_handle = {
|
||||
let handle_guard = self.app_handle.lock().await;
|
||||
handle_guard.clone()
|
||||
};
|
||||
|
||||
if let Some(handle) = app_handle {
|
||||
if let Some(ref app_handle) = self.app_handle {
|
||||
println!("Running startup version update...");
|
||||
|
||||
match self.update_all_browser_versions(&handle).await {
|
||||
match self.update_all_browser_versions(app_handle).await {
|
||||
Ok(_) => {
|
||||
// Update the persistent state after successful update
|
||||
let state = BackgroundUpdateState {
|
||||
@@ -191,7 +181,9 @@ impl VersionUpdater {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_background_updates(&mut self) {
|
||||
pub async fn start_background_updates(
|
||||
&self,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!(
|
||||
"Starting background version update service (checking every 5 minutes for 3-hour intervals)"
|
||||
);
|
||||
@@ -201,41 +193,54 @@ impl VersionUpdater {
|
||||
eprintln!("Startup version update failed: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_background_task() {
|
||||
let mut update_interval = interval(Duration::from_secs(5 * 60)); // Check every 5 minutes
|
||||
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
self.update_interval.tick().await;
|
||||
update_interval.tick().await;
|
||||
|
||||
// Check if we should run an update based on persistent state
|
||||
if !Self::should_run_background_update() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we have an app handle
|
||||
let app_handle = {
|
||||
let handle_guard = self.app_handle.lock().await;
|
||||
handle_guard.clone()
|
||||
};
|
||||
println!("Starting background version update...");
|
||||
|
||||
if let Some(handle) = app_handle {
|
||||
println!("Starting background version update...");
|
||||
// Get the updater instance for this update cycle
|
||||
let updater = get_version_updater();
|
||||
let result = {
|
||||
let updater_guard = updater.lock().await;
|
||||
if let Some(ref app_handle) = updater_guard.app_handle {
|
||||
updater_guard.update_all_browser_versions(app_handle).await
|
||||
} else {
|
||||
Err("App handle not available for background update".into())
|
||||
}
|
||||
}; // Release the lock here
|
||||
|
||||
match self.update_all_browser_versions(&handle).await {
|
||||
Ok(_) => {
|
||||
// Update the persistent state after successful update
|
||||
let state = BackgroundUpdateState {
|
||||
last_update_time: Self::get_current_timestamp(),
|
||||
update_interval_hours: 3,
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// Update the persistent state after successful update
|
||||
let state = BackgroundUpdateState {
|
||||
last_update_time: Self::get_current_timestamp(),
|
||||
update_interval_hours: 3,
|
||||
};
|
||||
|
||||
if let Err(e) = Self::save_background_update_state(&state) {
|
||||
eprintln!("Failed to save background update state: {e}");
|
||||
} else {
|
||||
println!("Background version update completed successfully");
|
||||
}
|
||||
if let Err(e) = Self::save_background_update_state(&state) {
|
||||
eprintln!("Failed to save background update state: {e}");
|
||||
} else {
|
||||
println!("Background version update completed successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Background version update failed: {e}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Background version update failed: {e}");
|
||||
|
||||
// Emit error event
|
||||
// Try to emit error event if we have an app handle
|
||||
let updater_guard = updater.lock().await;
|
||||
if let Some(ref app_handle) = updater_guard.app_handle {
|
||||
let progress = VersionUpdateProgress {
|
||||
current_browser: "".to_string(),
|
||||
total_browsers: 0,
|
||||
@@ -244,11 +249,9 @@ impl VersionUpdater {
|
||||
browser_new_versions: 0,
|
||||
status: "error".to_string(),
|
||||
};
|
||||
let _ = handle.emit("version-update-progress", &progress);
|
||||
let _ = app_handle.emit("version-update-progress", &progress);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("App handle not available, skipping background update");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,7 +333,6 @@ impl VersionUpdater {
|
||||
println!("Emitted progress event for browser: {browser}");
|
||||
}
|
||||
|
||||
// Check if individual browser cache is expired before updating
|
||||
if !self.version_service.should_update_cache(browser) {
|
||||
println!("Skipping {browser} - cache is still fresh");
|
||||
|
||||
@@ -395,7 +397,7 @@ impl VersionUpdater {
|
||||
println!("Emitted completion progress event");
|
||||
}
|
||||
|
||||
println!("Background version update completed. Found {total_new_versions} new versions total");
|
||||
println!("Version update completed. Found {total_new_versions} new versions total");
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, ProxySettings } from "@/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -85,6 +86,9 @@ export default function Home() {
|
||||
const updateNotifications = useUpdateNotifications(loadProfiles);
|
||||
const { checkForUpdates, isUpdating } = updateNotifications;
|
||||
|
||||
// Version updater for handling version fetching progress events
|
||||
useVersionUpdater();
|
||||
|
||||
// Profiles loader with update check (for initial load and manual refresh)
|
||||
const loadProfilesWithUpdateCheck = useCallback(async () => {
|
||||
try {
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import {
|
||||
LuCheckCheck,
|
||||
LuCircleAlert,
|
||||
LuClock,
|
||||
LuRefreshCw,
|
||||
} from "react-icons/lu";
|
||||
|
||||
export function VersionUpdateSettings() {
|
||||
const {
|
||||
isUpdating,
|
||||
lastUpdateTime,
|
||||
timeUntilNextUpdate,
|
||||
updateProgress,
|
||||
triggerManualUpdate,
|
||||
formatTimeUntilUpdate,
|
||||
formatLastUpdateTime,
|
||||
} = useVersionUpdater();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2 items-center">
|
||||
<LuRefreshCw className="w-5 h-5" />
|
||||
Background Version Updates
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Browser versions are automatically checked every 3 hours in the
|
||||
background. New versions are cached and ready when you need them.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Current Status */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2 items-center text-sm font-medium">
|
||||
<LuClock className="w-4 h-4" />
|
||||
Last Update
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatLastUpdateTime(lastUpdateTime)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2 items-center text-sm font-medium">
|
||||
<LuCheckCheck className="w-4 h-4" />
|
||||
Next Update
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{timeUntilNextUpdate <= 0
|
||||
? "Now"
|
||||
: `In ${formatTimeUntilUpdate(timeUntilNextUpdate)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
{isUpdating && updateProgress && (
|
||||
<Alert>
|
||||
<LuRefreshCw className="w-4 h-4 animate-spin" />
|
||||
<AlertTitle>Updating Browser Versions</AlertTitle>
|
||||
<AlertDescription>
|
||||
{updateProgress.current_browser ? (
|
||||
<>
|
||||
Looking for updates for {updateProgress.current_browser} (
|
||||
{updateProgress.completed_browsers}/
|
||||
{updateProgress.total_browsers})
|
||||
</>
|
||||
) : (
|
||||
"Starting version update..."
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Manual update button */}
|
||||
<div className="flex justify-between items-center pt-2 border-t">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Manual Update</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Check for new browser versions now
|
||||
</div>
|
||||
</div>
|
||||
<LoadingButton
|
||||
isLoading={isUpdating}
|
||||
onClick={() => {
|
||||
void triggerManualUpdate();
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isUpdating}
|
||||
>
|
||||
<LuRefreshCw className="mr-2 w-4 h-4" />
|
||||
{isUpdating ? "Updating..." : "Check Now"}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
{/* Information about background updates */}
|
||||
<Alert>
|
||||
<LuCircleAlert className="w-4 h-4" />
|
||||
<AlertTitle>How it works</AlertTitle>
|
||||
<AlertDescription className="text-xs">
|
||||
• Version information is checked automatically every 3 hours
|
||||
<br />• New versions are added to the cache without removing old
|
||||
ones
|
||||
<br />• When creating profiles or changing versions, you'll see
|
||||
how many new versions were found
|
||||
<br />• This keeps the app responsive while ensuring you have the
|
||||
latest information
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import { dismissToast, showToast } from "@/lib/toast-utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
interface UpdateNotification {
|
||||
id: string;
|
||||
@@ -25,6 +25,11 @@ export function useUpdateNotifications(
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
const isUpdating = useCallback(
|
||||
(browser: string) => updatingBrowsers.has(browser),
|
||||
[updatingBrowsers],
|
||||
);
|
||||
|
||||
// Add refs to track ongoing operations to prevent duplicates
|
||||
const isCheckingForUpdates = useRef(false);
|
||||
const activeDownloads = useRef<Set<string>>(new Set()); // Track "browser-version" keys
|
||||
@@ -85,10 +90,9 @@ export function useUpdateNotifications(
|
||||
|
||||
// Mark download as active and disable browser
|
||||
activeDownloads.current.add(downloadKey);
|
||||
setUpdatingBrowsers((prev) => new Set(prev).add(browser));
|
||||
|
||||
try {
|
||||
// Set browser as updating FIRST before any async operations
|
||||
setUpdatingBrowsers((prev) => new Set(prev).add(browser));
|
||||
const browserDisplayName = getBrowserDisplayName(browser);
|
||||
|
||||
// Dismiss the notification in the backend
|
||||
@@ -136,7 +140,6 @@ export function useUpdateNotifications(
|
||||
});
|
||||
|
||||
// Download the browser - this will trigger download progress events automatically
|
||||
// The use-browser-download hook will handle showing the download progress toasts
|
||||
await invoke("download_browser", {
|
||||
browserStr: browser,
|
||||
version: newVersion,
|
||||
@@ -193,21 +196,11 @@ export function useUpdateNotifications(
|
||||
});
|
||||
throw downloadError;
|
||||
}
|
||||
|
||||
// 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 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: 8000,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
// Remove from active downloads and updating browsers
|
||||
// Clean up
|
||||
activeDownloads.current.delete(downloadKey);
|
||||
setUpdatingBrowsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -219,19 +212,9 @@ export function useUpdateNotifications(
|
||||
[onProfilesUpdated],
|
||||
);
|
||||
|
||||
// Clean up notifications when they're no longer needed
|
||||
useEffect(() => {
|
||||
// Remove notifications that have been processed
|
||||
setNotifications((prev) =>
|
||||
prev.filter(
|
||||
(notification) => !processedNotifications.has(notification.id),
|
||||
),
|
||||
);
|
||||
}, [processedNotifications]);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
isUpdating,
|
||||
checkForUpdates,
|
||||
isUpdating: (browser: string) => updatingBrowsers.has(browser),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import { dismissToast, showUnifiedVersionUpdateToast } from "@/lib/toast-utils";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showUnifiedVersionUpdateToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface VersionUpdateProgress {
|
||||
current_browser: string;
|
||||
@@ -80,12 +84,12 @@ export function useVersionUpdater() {
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
if (progress.new_versions_found > 0) {
|
||||
toast.success("Browser versions updated successfully", {
|
||||
showSuccessToast("Browser versions updated successfully", {
|
||||
duration: 5000,
|
||||
description: `Found ${progress.new_versions_found} new browser versions. Update notifications will appear shortly.`,
|
||||
description: "Updates will start automatically.",
|
||||
});
|
||||
} else {
|
||||
toast.success("No new browser versions found", {
|
||||
showSuccessToast("No new browser versions found", {
|
||||
duration: 3000,
|
||||
description: "All browser versions are up to date",
|
||||
});
|
||||
@@ -98,7 +102,7 @@ export function useVersionUpdater() {
|
||||
setUpdateProgress(null);
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
toast.error("Failed to update browser versions", {
|
||||
showErrorToast("Failed to update browser versions", {
|
||||
duration: 6000,
|
||||
description: "Check your internet connection and try again",
|
||||
});
|
||||
@@ -146,17 +150,17 @@ export function useVersionUpdater() {
|
||||
).length;
|
||||
|
||||
if (failedUpdates > 0) {
|
||||
toast.warning("Update completed with some errors", {
|
||||
showErrorToast("Update completed with some errors", {
|
||||
description: `${totalNewVersions} new versions found, ${failedUpdates} browsers failed to update`,
|
||||
duration: 5000,
|
||||
});
|
||||
} else if (totalNewVersions > 0) {
|
||||
toast.success("Browser versions updated successfully", {
|
||||
description: `Updated ${successfulUpdates} browsers successfully`,
|
||||
showSuccessToast("Browser versions updated successfully", {
|
||||
description: `Found ${totalNewVersions} new versions across ${successfulUpdates} browsers. Updates will start automatically.`,
|
||||
duration: 4000,
|
||||
});
|
||||
} else {
|
||||
toast.success("No new browser versions found", {
|
||||
showSuccessToast("No new browser versions found", {
|
||||
description: "All browser versions are up to date",
|
||||
duration: 3000,
|
||||
});
|
||||
@@ -166,7 +170,7 @@ export function useVersionUpdater() {
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error("Failed to trigger manual update:", error);
|
||||
toast.error("Failed to update browser versions", {
|
||||
showErrorToast("Failed to update browser versions", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
duration: 4000,
|
||||
@@ -188,7 +192,7 @@ export function useVersionUpdater() {
|
||||
// Show notification about new versions if any were found
|
||||
if (result.new_versions_count && result.new_versions_count > 0) {
|
||||
const browserName = getBrowserDisplayName(browserStr);
|
||||
toast.success(
|
||||
showSuccessToast(
|
||||
`Found ${result.new_versions_count} new ${browserName} versions!`,
|
||||
{
|
||||
duration: 3000,
|
||||
@@ -207,18 +211,15 @@ export function useVersionUpdater() {
|
||||
);
|
||||
|
||||
const formatTimeUntilUpdate = useCallback((seconds: number): string => {
|
||||
if (seconds <= 0) return "Update overdue";
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
if (seconds < 60) {
|
||||
return `${seconds} seconds`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) {
|
||||
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||
}
|
||||
return "< 1m";
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours} hour${hours === 1 ? "" : "s"}`;
|
||||
}, []);
|
||||
|
||||
const formatLastUpdateTime = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user