Skip to content

Commit 9eafa7f

Browse files
karlitscheknickvergessen
authored andcommitted
feat(ui): undo dismiss notification
Dismissing a notification now optimistically collapses it with a 280 ms animation and shows an "Undo" bar for 4 s before the DELETE request is actually fired. The item is kept out of the polling refresh during that grace period so it doesn't reappear and then vanish again. * NotificationItem.vue: dismiss now emits a "dismiss" event with the notification instead of issuing the DELETE itself * NotificationsApp.vue: handles the dismiss/undo state machine, tracks collapsing IDs, filters the polling response while a dismiss is pending, and re-inserts in order if the user undoes after the collapse timer already removed the item * onRemove(index) becomes onRemove(notification) so it can also be called from the dismiss flush path where index is unknown * styles.scss: undo bar + collapse keyframe Signed-off-by: Frank Karlitschek <frank@nextcloud.com> Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
1 parent 7c4c343 commit 9eafa7f

3 files changed

Lines changed: 119 additions & 17 deletions

File tree

src/Components/NotificationItem.vue

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,6 @@ import axios from '@nextcloud/axios'
9595
import { showError } from '@nextcloud/dialogs'
9696
import { emit } from '@nextcloud/event-bus'
9797
import { t } from '@nextcloud/l10n'
98-
import { generateOcsUrl } from '@nextcloud/router'
9998
import NcButton from '@nextcloud/vue/components/NcButton'
10099
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
101100
import NcRichText from '@nextcloud/vue/components/NcRichText'
@@ -154,7 +153,7 @@ export default {
154153
},
155154
},
156155
157-
emits: ['remove'],
156+
emits: ['remove', 'dismiss'],
158157
159158
data() {
160159
return {
@@ -271,14 +270,7 @@ export default {
271270
},
272271
273272
onDismissNotification() {
274-
axios
275-
.delete(generateOcsUrl('apps/notifications/api/v2/notifications/{id}', { id: this.notification.notificationId }))
276-
.then(() => {
277-
this.$emit('remove')
278-
})
279-
.catch(() => {
280-
showError(t('notifications', 'Failed to dismiss notification'))
281-
})
273+
this.$emit('dismiss', this.notification)
282274
},
283275
},
284276
}

src/NotificationsApp.vue

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,12 @@
3232
:key="-2016"
3333
:notification="fairUsePolicyNotification" />
3434
<NotificationItem
35-
v-for="(notification, index) in notifications"
35+
v-for="notification in notifications"
3636
:key="notification.notificationId"
37-
:class="{ 'notification--new': notification.notificationId > lastOpenMaxId }"
37+
:class="{ 'notification--new': notification.notificationId > lastOpenMaxId, 'notification--collapsing': collapsingIds.has(notification.notificationId) }"
3838
:notification="notification"
39-
@remove="onRemove(index)" />
39+
@remove="onRemove(notification)"
40+
@dismiss="onDismiss" />
4041
</transition-group>
4142

4243
<!-- No notifications -->
@@ -79,6 +80,16 @@
7980
</NcEmptyContent>
8081
</transition>
8182

83+
<!-- Undo dismiss bar -->
84+
<transition name="fade">
85+
<div v-if="pendingUndo" class="notification-undo-bar">
86+
<span>{{ t('notifications', 'Notification dismissed') }}</span>
87+
<NcButton variant="tertiary" @click="onUndoDismiss">
88+
{{ t('notifications', 'Undo') }}
89+
</NcButton>
90+
</div>
91+
</transition>
92+
8293
<!-- Dismiss all -->
8394
<div v-if="notifications.length > 0" class="dismiss-all">
8495
<NcButton
@@ -208,6 +219,10 @@ export default {
208219
lastOpenMaxId: 0,
209220
/** True for 5 s after all notifications are cleared — inbox zero celebration */
210221
showInboxZero: false,
222+
/** Pending optimistic dismiss: { notification, execute, timerId, collapseTimerId } */
223+
pendingUndo: null,
224+
/** Set of notification IDs currently playing collapse animation */
225+
collapsingIds: new Set(),
211226
}
212227
},
213228
@@ -379,8 +394,64 @@ export default {
379394
})
380395
},
381396
382-
onRemove(index) {
383-
this.notifications.splice(index, 1)
397+
onRemove(notification) {
398+
const idx = this.notifications.findIndex(n => n.notificationId === notification.notificationId)
399+
if (idx !== -1) {
400+
this.notifications.splice(idx, 1)
401+
}
402+
setCurrentTabAsActive(this.tabId)
403+
},
404+
405+
async onDismiss(notification) {
406+
// Flush any prior pending dismiss immediately
407+
if (this.pendingUndo) {
408+
clearTimeout(this.pendingUndo.timerId)
409+
clearTimeout(this.pendingUndo.collapseTimerId)
410+
const prevIdx = this.notifications.findIndex(n => n.notificationId === this.pendingUndo.notification.notificationId)
411+
if (prevIdx !== -1) this.notifications.splice(prevIdx, 1)
412+
this.collapsingIds.delete(this.pendingUndo.notification.notificationId)
413+
await this.pendingUndo.execute()
414+
}
415+
416+
setCurrentTabAsActive(this.tabId)
417+
418+
// Trigger collapse animation — item stays in array until animation finishes (280 ms)
419+
this.collapsingIds.add(notification.notificationId)
420+
421+
const execute = () => axios
422+
.delete(generateOcsUrl('apps/notifications/api/v2/notifications/{id}', { id: notification.notificationId }))
423+
.catch(() => showError(t('notifications', 'Failed to dismiss notification')))
424+
425+
const collapseTimerId = setTimeout(() => {
426+
const idx = this.notifications.findIndex(n => n.notificationId === notification.notificationId)
427+
if (idx !== -1) this.notifications.splice(idx, 1)
428+
this.collapsingIds.delete(notification.notificationId)
429+
}, 280)
430+
431+
this.pendingUndo = {
432+
notification,
433+
execute,
434+
collapseTimerId,
435+
timerId: setTimeout(() => {
436+
this.pendingUndo = null
437+
execute()
438+
}, 4000),
439+
}
440+
},
441+
442+
onUndoDismiss() {
443+
if (!this.pendingUndo) return
444+
clearTimeout(this.pendingUndo.timerId)
445+
clearTimeout(this.pendingUndo.collapseTimerId)
446+
const { notification } = this.pendingUndo
447+
this.pendingUndo = null
448+
this.collapsingIds.delete(notification.notificationId)
449+
// Re-insert if the collapse timer already removed it from the array
450+
if (!this.notifications.find(n => n.notificationId === notification.notificationId)) {
451+
const insertIdx = this.notifications.findIndex(n => n.notificationId < notification.notificationId)
452+
if (insertIdx === -1) this.notifications.push(notification)
453+
else this.notifications.splice(insertIdx, 0, notification)
454+
}
384455
setCurrentTabAsActive(this.tabId)
385456
},
386457
@@ -461,8 +532,13 @@ export default {
461532
this.userStatus = response.headers['x-nextcloud-user-status']
462533
this.lastETag = response.headers.etag
463534
this.lastTabId = response.tabId
464-
this.notifications = response.data
465-
this.processWebNotifications(response.data)
535+
// Keep the pending-undo item out of the list during its grace period
536+
const pendingId = this.pendingUndo?.notification?.notificationId
537+
const data = pendingId
538+
? response.data.filter(n => n.notificationId !== pendingId)
539+
: response.data
540+
this.notifications = data
541+
this.processWebNotifications(data)
466542
console.debug('Got notification data, restoring default polling interval.')
467543
this._setPollingInterval(this.pollIntervalBase)
468544
this._updateDocTitleOnNewNotifications(this.notifications)

src/styles/styles.scss

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,3 +251,37 @@ img.notification-icon {
251251
from { transform: rotate(calc(45deg * var(--confetti-i, 0))) translateY(0) scale(1); opacity: 1; }
252252
to { transform: rotate(calc(45deg * var(--confetti-i, 0))) translateY(-34px) scale(0); opacity: .8; }
253253
}
254+
255+
// Undo dismiss bar — appears below the list during the 4 s grace period
256+
.notification-undo-bar {
257+
display: flex;
258+
align-items: center;
259+
justify-content: space-between;
260+
padding: 4px 4px 4px 16px;
261+
border-top: 1px solid var(--color-border);
262+
background: color-mix(in srgb, var(--color-primary-element) 8%, var(--color-main-background));
263+
font-size: 13px;
264+
}
265+
266+
// Smooth collapse animation while the dismiss is pending
267+
#notifications .notification.notification--collapsing {
268+
overflow: hidden;
269+
pointer-events: none;
270+
animation: notification-collapse 280ms ease-in both;
271+
}
272+
273+
@keyframes notification-collapse {
274+
0% {
275+
opacity: 1;
276+
max-height: 600px;
277+
padding-bottom: 12px;
278+
}
279+
45% {
280+
opacity: 0;
281+
}
282+
100% {
283+
opacity: 0;
284+
max-height: 0;
285+
padding-bottom: 0;
286+
}
287+
}

0 commit comments

Comments
 (0)