feat: add hide and show download items (#7106)

* feat: add hide and show download items

* pnpm prepr
This commit is contained in:
Truman Gao
2026-08-12 16:18:02 +00:00
committed by GitHub
parent 6ec979a076
commit 4758cabbf9
5 changed files with 252 additions and 46 deletions
@@ -1,15 +1,16 @@
<template> <template>
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<IconButton <div v-if="downloadState.total > 0 || hasActiveLoadingBars" class="relative">
v-if="hasActiveLoadingBars && !hasVisibleActiveDownloadToasts" <IconButton
v-tooltip="formatMessage(messages.viewActiveDownloads)" v-tooltip="downloadToggleLabel"
type="quiet" :color="downloadState.hidden > 0 ? 'brand' : undefined"
color="brand" type="quiet"
:label="formatMessage(messages.viewActiveDownloads)" :label="downloadToggleLabel"
@click="openDownloadToast()" @click="toggleDownloadNotifications"
> >
<DownloadIcon /> <DownloadIcon />
</IconButton> </IconButton>
</div>
<div v-if="offline" class="flex items-center gap-1"> <div v-if="offline" class="flex items-center gap-1">
<UnplugIcon class="text-secondary" /> <UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span> <span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
@@ -218,8 +219,35 @@ const messages = defineMessages({
id: 'app.action-bar.view-active-downloads', id: 'app.action-bar.view-active-downloads',
defaultMessage: 'View active downloads', defaultMessage: 'View active downloads',
}, },
hideDownloads: {
id: 'app.action-bar.hide-downloads',
defaultMessage: 'Hide active downloads',
},
showDownloads: {
id: 'app.action-bar.show-downloads',
defaultMessage: 'Show active downloads',
},
}) })
const downloadState = computed(() => popupNotificationManager.getDownloadState())
const downloadToggleLabel = computed(() =>
formatMessage(
downloadState.value.hidden > 0
? messages.showDownloads
: downloadState.value.total > 0
? messages.hideDownloads
: messages.viewActiveDownloads,
),
)
function toggleDownloadNotifications(): void {
if (downloadState.value.total > 0) {
popupNotificationManager.toggleDownloadNotifications()
} else if (hasActiveLoadingBars.value) {
openDownloadToast()
}
}
const currentProcesses = ref<RunningProcess[]>([]) const currentProcesses = ref<RunningProcess[]>([])
const selectedProcess = ref<RunningProcess | undefined>() const selectedProcess = ref<RunningProcess | undefined>()
@@ -296,6 +324,7 @@ function goToTerminal(instanceId?: string) {
const currentLoadingBars = ref<LoadingBar[]>([]) const currentLoadingBars = ref<LoadingBar[]>([])
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({}) const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
const notificationId = ref<string | number | null>(null) const notificationId = ref<string | number | null>(null)
const terminalNotificationIds = new Map<string, string | number>()
const dismissed = ref(false) const dismissed = ref(false)
function getLoadingBarKey(loadingBar: LoadingBar): string { function getLoadingBarKey(loadingBar: LoadingBar): string {
@@ -341,6 +370,44 @@ function removeNotification(): void {
notificationId.value = null notificationId.value = null
} }
function syncTerminalNotifications(): void {
const terminalNotifications = installJobNotifications.terminalNotifications.value
const currentJobIds = new Set(terminalNotifications.map((notification) => notification.id))
for (const terminal of terminalNotifications) {
const popupId = terminalNotificationIds.get(terminal.id)
let notification = popupId
? popupNotificationManager.getNotifications().find((candidate) => candidate.id === popupId)
: undefined
if (!notification) {
notification = popupNotificationManager.addPopupNotification({
title: terminal.title,
text: terminal.text,
type: terminal.type,
buttons: terminal.buttons,
onDismiss: terminal.onDismiss,
autoCloseMs: null,
})
terminalNotificationIds.set(terminal.id, notification.id)
continue
}
notification.title = terminal.title
notification.text = terminal.text
notification.type = terminal.type
notification.buttons = terminal.buttons
notification.onDismiss = terminal.onDismiss
}
for (const [jobId, popupId] of terminalNotificationIds) {
if (!currentJobIds.has(jobId)) {
popupNotificationManager.removeNotification(popupId)
terminalNotificationIds.delete(jobId)
}
}
}
function buildDownloadItems(): PopupNotificationProgressItem[] { function buildDownloadItems(): PopupNotificationProgressItem[] {
return [ return [
...installJobNotifications.progressItems.value, ...installJobNotifications.progressItems.value,
@@ -358,12 +425,13 @@ function buildDownloadItems(): PopupNotificationProgressItem[] {
] ]
} }
const hasVisibleActiveDownloadToasts = computed(() => !!getNotification())
const hasActiveLoadingBars = computed( const hasActiveLoadingBars = computed(
() => currentLoadingBars.value.length > 0 || installJobNotifications.active.value, () => currentLoadingBars.value.length > 0 || installJobNotifications.active.value,
) )
function updateNotification(resummon = false): void { function updateNotification(resummon = false): void {
syncTerminalNotifications()
if (resummon) { if (resummon) {
dismissed.value = false dismissed.value = false
} }
@@ -392,7 +460,6 @@ function updateNotification(resummon = false): void {
: formatMessage(messages.downloads) : formatMessage(messages.downloads)
notif.text = undefined notif.text = undefined
notif.progressItems = progressItems notif.progressItems = progressItems
notif.buttons = installJobNotifications.buttons.value
notif.progress = undefined notif.progress = undefined
notif.waiting = undefined notif.waiting = undefined
} else { } else {
@@ -403,7 +470,6 @@ function updateNotification(resummon = false): void {
type: 'download', type: 'download',
autoCloseMs: null, autoCloseMs: null,
progressItems, progressItems,
buttons: installJobNotifications.buttons.value,
}) })
notificationId.value = notif.id notificationId.value = notif.id
} }
@@ -502,6 +568,8 @@ function selectProcess(process: RunningProcess) {
onBeforeUnmount(() => { onBeforeUnmount(() => {
removeNotification() removeNotification()
terminalNotificationIds.forEach((id) => popupNotificationManager.removeNotification(id))
terminalNotificationIds.clear()
dismissed.value = false dismissed.value = false
window.removeEventListener('offline', handleOffline) window.removeEventListener('offline', handleOffline)
window.removeEventListener('online', handleOnline) window.removeEventListener('online', handleOnline)
@@ -404,8 +404,6 @@ export async function useInstallJobNotifications(opts: {
} }
function getProgress(job: InstallJobSnapshot): number { function getProgress(job: InstallJobSnapshot): number {
if (job.status === 'succeeded') return 1
if (job.status === 'failed' || job.status === 'interrupted') return 0
const progress = getEffectiveProgress(job) const progress = getEffectiveProgress(job)
if (!progress || progress.total <= 0) return 0 if (!progress || progress.total <= 0) return 0
return Math.max(0, Math.min(1, progress.current / progress.total)) return Math.max(0, Math.min(1, progress.current / progress.total))
@@ -526,8 +524,12 @@ export async function useInstallJobNotifications(opts: {
) )
} }
const activeJobs = computed(() =>
jobs.value.filter((job) => job.status === 'queued' || job.status === 'running'),
)
const progressItems = computed<PopupNotificationProgressItem[]>(() => const progressItems = computed<PopupNotificationProgressItem[]>(() =>
jobs.value.map((job) => { activeJobs.value.map((job) => {
const progress = getEffectiveProgress(job) const progress = getEffectiveProgress(job)
return { return {
@@ -536,20 +538,26 @@ export async function useInstallJobNotifications(opts: {
text: getText(job), text: getText(job),
iconUrl: iconUrls.value[job.job_id] ?? null, iconUrl: iconUrls.value[job.job_id] ?? null,
progress: getProgress(job), progress: getProgress(job),
waiting: !job.progress && ['queued', 'running'].includes(job.status), waiting: !job.progress && job.status === 'running',
showProgress: !isTerminalJob(job), showProgress: job.status === 'running',
wrapText: isTerminalJob(job), progressType: getProgressType(job),
progressType: isTerminalJob(job) ? undefined : getProgressType(job), progressCurrent: progress?.current,
progressCurrent: isTerminalJob(job) ? undefined : progress?.current, progressTotal: progress?.total,
progressTotal: isTerminalJob(job) ? undefined : progress?.total,
buttons: getButtons(job), buttons: getButtons(job),
dismissible: isTerminalJob(job),
onDismiss: getDismissHandler(job),
} }
}), }),
) )
const buttons = computed<PopupNotificationButton[] | undefined>(() => undefined) const terminalNotifications = computed(() =>
jobs.value.filter(isTerminalJob).map((job) => ({
id: job.job_id,
title: getTitle(job),
text: getText(job),
type: job.status === 'failed' ? ('error' as const) : ('warning' as const),
buttons: getButtons(job),
onDismiss: getDismissHandler(job),
})),
)
async function refreshMetadata(notify = true) { async function refreshMetadata(notify = true) {
const request = ++metadataRequest const request = ++metadataRequest
@@ -631,10 +639,10 @@ export async function useInstallJobNotifications(opts: {
await refresh(false) await refresh(false)
return { return {
active: computed(() => jobs.value.length > 0), active: computed(() => activeJobs.value.length > 0),
title: computed(() => formatMessage(messages.installs)), title: computed(() => formatMessage(messages.installs)),
progressItems, progressItems,
buttons, terminalNotifications,
refresh, refresh,
dispose: () => { dispose: () => {
for (const timeout of copiedResetTimeouts.values()) { for (const timeout of copiedResetTimeouts.values()) {
@@ -8,6 +8,9 @@
"app.action-bar.downloads": { "app.action-bar.downloads": {
"message": "Downloads" "message": "Downloads"
}, },
"app.action-bar.hide-downloads": {
"message": "Hide active downloads"
},
"app.action-bar.hide-more-running-instances": { "app.action-bar.hide-more-running-instances": {
"message": "Hide more running instances" "message": "Hide more running instances"
}, },
@@ -110,6 +113,9 @@
"app.action-bar.reload-to-update": { "app.action-bar.reload-to-update": {
"message": "Reload to update" "message": "Reload to update"
}, },
"app.action-bar.show-downloads": {
"message": "Show active downloads"
},
"app.action-bar.show-more-running-instances": { "app.action-bar.show-more-running-instances": {
"message": "Show more running instances" "message": "Show more running instances"
}, },
@@ -17,6 +17,7 @@
> >
<NotificationToast <NotificationToast
v-if="item.toast" v-if="item.toast"
class="min-w-full"
:type="item.toast.type" :type="item.toast.type"
:action-loading="toastActionLoading(item.id)" :action-loading="toastActionLoading(item.id)"
:actor-name="item.toast.actorName" :actor-name="item.toast.actorName"
@@ -37,9 +38,15 @@
@open-actor="item.toast.onOpenActor?.()" @open-actor="item.toast.onOpenActor?.()"
@open-instance="handleToastAction(item, item.toast.onOpenInstance)" @open-instance="handleToastAction(item, item.toast.onOpenInstance)"
/> />
<div v-else-if="isDownloadNotification(item)" class="flex flex-col gap-4"> <TransitionGroup
v-else-if="isDownloadNotification(item)"
name="popup-downloads"
tag="div"
class="flex flex-col gap-3"
>
<div v-for="progressItem in downloadToastItems(item)" :key="progressItem.id"> <div v-for="progressItem in downloadToastItems(item)" :key="progressItem.id">
<NotificationToast <NotificationToast
class="min-w-full"
type="instance-download" type="instance-download"
:entity-name="progressItem.title || item.title" :entity-name="progressItem.title || item.title"
:entity-icon-url="progressItem.iconUrl ?? item.iconUrl ?? MinecraftServerIcon" :entity-icon-url="progressItem.iconUrl ?? item.iconUrl ?? MinecraftServerIcon"
@@ -54,10 +61,10 @@
:actions="progressItem.buttons" :actions="progressItem.buttons"
:dismissible="progressItem.dismissible" :dismissible="progressItem.dismissible"
@dismiss="handleProgressItemDismiss(item, progressItem)" @dismiss="handleProgressItemDismiss(item, progressItem)"
@action="(index) => handleProgressItemAction(progressItem, index)" @action="(index) => handleProgressItemAction(item, progressItem, index)"
/> />
</div> </div>
</div> </TransitionGroup>
<div <div
v-else v-else
class="flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised shadow-xl border-surface-5 border-solid border p-4" class="flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised shadow-xl border-surface-5 border-solid border p-4"
@@ -100,7 +107,7 @@
type="quiet" type="quiet"
label="Close" label="Close"
class="-m-1.5" class="-m-1.5"
@click="dismiss(item.id)" @click="handleNotificationDismiss(item)"
> >
<XIcon /> <XIcon />
</IconButton> </IconButton>
@@ -201,7 +208,7 @@ import NotificationToast from '../notifications/NotificationToast.vue'
const popupNotificationManager = injectPopupNotificationManager() const popupNotificationManager = injectPopupNotificationManager()
const notifications = computed<PopupNotification[]>(() => const notifications = computed<PopupNotification[]>(() =>
popupNotificationManager.getNotifications(), popupNotificationManager.getVisibleNotifications(),
) )
const { stackCount } = useModalStack() const { stackCount } = useModalStack()
const hasModalActive = computed(() => stackCount.value > 0) const hasModalActive = computed(() => stackCount.value > 0)
@@ -213,7 +220,6 @@ const activeToastActions = ref<Record<string, 'accept'>>({})
const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n) const stopTimer = (n: PopupNotification) => popupNotificationManager.stopNotificationTimer(n)
const setNotificationTimer = (n: PopupNotification) => const setNotificationTimer = (n: PopupNotification) =>
popupNotificationManager.setNotificationTimer(n) popupNotificationManager.setNotificationTimer(n)
const dismiss = (id: string | number) => popupNotificationManager.removeNotification(id)
const toastActionLoading = (id: string | number) => activeToastActions.value[String(id)] ?? null const toastActionLoading = (id: string | number) => activeToastActions.value[String(id)] ?? null
function isDownloadNotification(item: PopupNotification) { function isDownloadNotification(item: PopupNotification) {
@@ -225,7 +231,7 @@ function isDownloadNotification(item: PopupNotification) {
function downloadToastItems(item: PopupNotification): PopupNotificationProgressItem[] { function downloadToastItems(item: PopupNotification): PopupNotificationProgressItem[] {
if (item.progressItems?.length) { if (item.progressItems?.length) {
return item.progressItems return popupNotificationManager.getVisibleDownloadProgressItems(item)
} }
return [ return [
@@ -242,35 +248,32 @@ function downloadToastItems(item: PopupNotification): PopupNotificationProgressI
] ]
} }
async function handleProgressItemDismiss( function handleProgressItemDismiss(
item: PopupNotification, item: PopupNotification,
progressItem: PopupNotificationProgressItem, progressItem: PopupNotificationProgressItem,
) { ) {
if (progressItem.onDismiss) { popupNotificationManager.hideDownloadItem(item.id, progressItem.id)
await progressItem.onDismiss()
return
}
dismiss(item.id)
} }
async function handleProgressItemAction( async function handleProgressItemAction(
item: PopupNotification,
progressItem: PopupNotificationProgressItem, progressItem: PopupNotificationProgressItem,
index: number, index: number,
) { ) {
const button = progressItem.buttons?.[index] const button = progressItem.buttons?.[index]
if (button) { if (button) {
await handleProgressItemButtonClick(progressItem, button) await handleProgressItemButtonClick(item, progressItem, button)
} }
} }
async function handleProgressItemButtonClick( async function handleProgressItemButtonClick(
item: PopupNotification,
progressItem: PopupNotificationProgressItem, progressItem: PopupNotificationProgressItem,
btn: PopupNotificationButton, btn: PopupNotificationButton,
) { ) {
await btn.action() await btn.action()
if (!btn.keepOpen) { if (!btn.keepOpen) {
await progressItem.onDismiss?.() popupNotificationManager.hideDownloadItem(item.id, progressItem.id)
} }
} }
@@ -281,6 +284,11 @@ async function handleButtonClick(id: string | number, btn: PopupNotificationButt
} }
} }
async function handleNotificationDismiss(item: PopupNotification) {
await item.onDismiss?.()
popupNotificationManager.removeNotification(item.id)
}
async function handleToastAction(item: PopupNotification, action?: () => void | Promise<void>) { async function handleToastAction(item: PopupNotification, action?: () => void | Promise<void>) {
popupNotificationManager.removeNotification(item.id) popupNotificationManager.removeNotification(item.id)
await action?.() await action?.()
@@ -381,6 +389,21 @@ withDefaults(
.popup-notifs-leave-to { .popup-notifs-leave-to {
opacity: 0; opacity: 0;
transform: translateX(100%) scale(0.8); transform: translateX(100%);
}
.popup-downloads-move {
transition: transform 0.3s ease-in-out;
}
.popup-downloads-leave-active {
transition:
opacity 0.3s ease-in-out,
transform 0.3s ease-in-out;
}
.popup-downloads-leave-to {
opacity: 0;
transform: translateX(100%);
} }
</style> </style>
@@ -1,4 +1,4 @@
import type { Component } from 'vue' import { type Component, type Ref, ref } from 'vue'
import { createContext } from '.' import { createContext } from '.'
@@ -25,7 +25,6 @@ export interface PopupNotificationProgressItem {
progressCurrent?: number progressCurrent?: number
progressTotal?: number progressTotal?: number
dismissible?: boolean dismissible?: boolean
onDismiss?: () => void | Promise<void>
buttons?: PopupNotificationButton[] buttons?: PopupNotificationButton[]
} }
@@ -73,15 +72,51 @@ export interface PopupNotification {
buttons?: PopupNotificationButton[] buttons?: PopupNotificationButton[]
toast?: PopupNotificationToast toast?: PopupNotificationToast
dismissible?: boolean dismissible?: boolean
onDismiss?: () => void | Promise<void>
autoCloseMs?: number | null autoCloseMs?: number | null
timer?: NodeJS.Timeout timer?: NodeJS.Timeout
} }
export interface PopupNotificationDownloadState {
total: number
hidden: number
}
export abstract class AbstractPopupNotificationManager { export abstract class AbstractPopupNotificationManager {
protected readonly DEFAULT_AUTO_CLOSE_MS = 30 * 1000 protected readonly DEFAULT_AUTO_CLOSE_MS = 30 * 1000
private readonly hiddenDownloadItemKeys: Ref<Set<string>> = ref(new Set())
abstract getNotifications(): PopupNotification[] abstract getNotifications(): PopupNotification[]
getDownloadState = (): PopupNotificationDownloadState => {
const itemKeys = this.getDownloadNotifications().flatMap((notification) =>
this.getDownloadItemKeys(notification),
)
return {
total: itemKeys.length,
hidden: itemKeys.filter((key) => this.hiddenDownloadItemKeys.value.has(key)).length,
}
}
getVisibleNotifications = (): PopupNotification[] =>
this.getNotifications().filter(
(notification) =>
!this.isDownloadNotification(notification) ||
this.getDownloadItemKeys(notification).some(
(key) => !this.hiddenDownloadItemKeys.value.has(key),
),
)
getVisibleDownloadProgressItems = (
notification: PopupNotification,
): PopupNotificationProgressItem[] =>
(notification.progressItems ?? []).filter(
(progressItem) =>
!this.hiddenDownloadItemKeys.value.has(
this.getDownloadItemKey(notification.id, progressItem.id),
),
)
protected abstract addNotificationToStorage(notification: PopupNotification): void protected abstract addNotificationToStorage(notification: PopupNotification): void
protected abstract removeNotificationFromStorage(id: string | number): void protected abstract removeNotificationFromStorage(id: string | number): void
protected abstract clearAllNotificationsFromStorage(): void protected abstract clearAllNotificationsFromStorage(): void
@@ -103,6 +138,9 @@ export abstract class AbstractPopupNotificationManager {
const notification = notifications.find((n) => n.id === id) const notification = notifications.find((n) => n.id === id)
if (notification) { if (notification) {
this.clearNotificationTimer(notification) this.clearNotificationTimer(notification)
this.getDownloadItemKeys(notification).forEach((key) =>
this.hiddenDownloadItemKeys.value.delete(key),
)
this.removeNotificationFromStorage(id) this.removeNotificationFromStorage(id)
} }
} }
@@ -110,6 +148,45 @@ export abstract class AbstractPopupNotificationManager {
clearAllNotifications = (): void => { clearAllNotifications = (): void => {
this.getNotifications().forEach((n) => this.clearNotificationTimer(n)) this.getNotifications().forEach((n) => this.clearNotificationTimer(n))
this.clearAllNotificationsFromStorage() this.clearAllNotificationsFromStorage()
this.hiddenDownloadItemKeys.value.clear()
}
hideDownloadItem = (notificationId: string | number, progressItemId: string): void => {
const notification = this.getDownloadNotifications().find(
(candidate) => candidate.id === notificationId,
)
if (!notification?.progressItems?.some((item) => item.id === progressItemId)) return
this.hiddenDownloadItemKeys.value.add(this.getDownloadItemKey(notificationId, progressItemId))
if (
this.getDownloadItemKeys(notification).every((key) =>
this.hiddenDownloadItemKeys.value.has(key),
)
) {
this.clearNotificationTimer(notification)
}
}
toggleDownloadNotifications = (): void => {
const downloadNotifications = this.getDownloadNotifications()
const hasHiddenDownloads = downloadNotifications.some((notification) =>
this.getDownloadItemKeys(notification).some((key) =>
this.hiddenDownloadItemKeys.value.has(key),
),
)
if (hasHiddenDownloads) {
this.hiddenDownloadItemKeys.value.clear()
downloadNotifications.forEach((notification) => this.setNotificationTimer(notification))
return
}
downloadNotifications.forEach((notification) => {
this.getDownloadItemKeys(notification).forEach((key) =>
this.hiddenDownloadItemKeys.value.add(key),
)
this.clearNotificationTimer(notification)
})
} }
setNotificationTimer = (notification: PopupNotification): void => { setNotificationTimer = (notification: PopupNotification): void => {
@@ -134,6 +211,30 @@ export abstract class AbstractPopupNotificationManager {
notification.timer = undefined notification.timer = undefined
} }
} }
private isDownloadNotification(notification: PopupNotification): boolean {
return notification.type === 'download' || notification.toast?.type === 'instance-download'
}
private getDownloadNotifications(): PopupNotification[] {
return this.getNotifications().filter((notification) =>
this.isDownloadNotification(notification),
)
}
private getDownloadItemKeys(notification: PopupNotification): string[] {
if (!this.isDownloadNotification(notification)) return []
if (notification.progressItems?.length) {
return notification.progressItems.map((progressItem) =>
this.getDownloadItemKey(notification.id, progressItem.id),
)
}
return [this.getDownloadItemKey(notification.id)]
}
private getDownloadItemKey(notificationId: string | number, progressItemId?: string): string {
return JSON.stringify([typeof notificationId, notificationId, progressItemId ?? null])
}
} }
export const [injectPopupNotificationManager, providePopupNotificationManager] = export const [injectPopupNotificationManager, providePopupNotificationManager] =