Skip to content

Commit 6a48b7c

Browse files
committed
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>
1 parent b2f1b24 commit 6a48b7c

3 files changed

Lines changed: 110 additions & 16 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: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +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--collapsing': collapsingIds.has(notification.notificationId) }"
3738
:notification="notification"
38-
@remove="onRemove(index)" />
39+
@remove="onRemove(notification)"
40+
@dismiss="onDismiss" />
3941
</transition-group>
4042

4143
<!-- No notifications -->
@@ -63,6 +65,16 @@
6365
</NcEmptyContent>
6466
</transition>
6567

68+
<!-- Undo dismiss bar -->
69+
<transition name="fade">
70+
<div v-if="pendingUndo" class="notification-undo-bar">
71+
<span>{{ t('notifications', 'Notification dismissed') }}</span>
72+
<NcButton variant="tertiary" @click="onUndoDismiss">
73+
{{ t('notifications', 'Undo') }}
74+
</NcButton>
75+
</div>
76+
</transition>
77+
6678
<!-- Dismiss all -->
6779
<div v-if="notifications.length > 0" class="dismiss-all">
6880
<NcButton
@@ -187,6 +199,11 @@ export default {
187199
pushEndpoints: null,
188200
189201
open: false,
202+
203+
/** Pending optimistic dismiss: { notification, execute, timerId, collapseTimerId } */
204+
pendingUndo: null,
205+
/** Set of notification IDs currently playing collapse animation */
206+
collapsingIds: new Set(),
190207
}
191208
},
192209
@@ -342,8 +359,64 @@ export default {
342359
})
343360
},
344361
345-
onRemove(index) {
346-
this.notifications.splice(index, 1)
362+
onRemove(notification) {
363+
const idx = this.notifications.findIndex(n => n.notificationId === notification.notificationId)
364+
if (idx !== -1) {
365+
this.notifications.splice(idx, 1)
366+
}
367+
setCurrentTabAsActive(this.tabId)
368+
},
369+
370+
async onDismiss(notification) {
371+
// Flush any prior pending dismiss immediately
372+
if (this.pendingUndo) {
373+
clearTimeout(this.pendingUndo.timerId)
374+
clearTimeout(this.pendingUndo.collapseTimerId)
375+
const prevIdx = this.notifications.findIndex(n => n.notificationId === this.pendingUndo.notification.notificationId)
376+
if (prevIdx !== -1) this.notifications.splice(prevIdx, 1)
377+
this.collapsingIds.delete(this.pendingUndo.notification.notificationId)
378+
await this.pendingUndo.execute()
379+
}
380+
381+
setCurrentTabAsActive(this.tabId)
382+
383+
// Trigger collapse animation — item stays in array until animation finishes (280 ms)
384+
this.collapsingIds.add(notification.notificationId)
385+
386+
const execute = () => axios
387+
.delete(generateOcsUrl('apps/notifications/api/v2/notifications/{id}', { id: notification.notificationId }))
388+
.catch(() => showError(t('notifications', 'Failed to dismiss notification')))
389+
390+
const collapseTimerId = setTimeout(() => {
391+
const idx = this.notifications.findIndex(n => n.notificationId === notification.notificationId)
392+
if (idx !== -1) this.notifications.splice(idx, 1)
393+
this.collapsingIds.delete(notification.notificationId)
394+
}, 280)
395+
396+
this.pendingUndo = {
397+
notification,
398+
execute,
399+
collapseTimerId,
400+
timerId: setTimeout(() => {
401+
this.pendingUndo = null
402+
execute()
403+
}, 4000),
404+
}
405+
},
406+
407+
onUndoDismiss() {
408+
if (!this.pendingUndo) return
409+
clearTimeout(this.pendingUndo.timerId)
410+
clearTimeout(this.pendingUndo.collapseTimerId)
411+
const { notification } = this.pendingUndo
412+
this.pendingUndo = null
413+
this.collapsingIds.delete(notification.notificationId)
414+
// Re-insert if the collapse timer already removed it from the array
415+
if (!this.notifications.find(n => n.notificationId === notification.notificationId)) {
416+
const insertIdx = this.notifications.findIndex(n => n.notificationId < notification.notificationId)
417+
if (insertIdx === -1) this.notifications.push(notification)
418+
else this.notifications.splice(insertIdx, 0, notification)
419+
}
347420
setCurrentTabAsActive(this.tabId)
348421
},
349422
@@ -422,8 +495,13 @@ export default {
422495
this.userStatus = response.headers['x-nextcloud-user-status']
423496
this.lastETag = response.headers.etag
424497
this.lastTabId = response.tabId
425-
this.notifications = response.data
426-
this.processWebNotifications(response.data)
498+
// Keep the pending-undo item out of the list during its grace period
499+
const pendingId = this.pendingUndo?.notification?.notificationId
500+
const data = pendingId
501+
? response.data.filter(n => n.notificationId !== pendingId)
502+
: response.data
503+
this.notifications = data
504+
this.processWebNotifications(data)
427505
console.debug('Got notification data, restoring default polling interval.')
428506
this._setPollingInterval(this.pollIntervalBase)
429507
this._updateDocTitleOnNewNotifications(this.notifications)

src/styles/styles.scss

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,27 @@ svg {
123123
}
124124
}
125125
}
126+
127+
// Undo dismiss bar — appears below the list during the 4 s grace period
128+
.notification-undo-bar {
129+
display: flex;
130+
align-items: center;
131+
justify-content: space-between;
132+
padding: 4px 4px 4px 16px;
133+
border-top: 1px solid var(--color-border);
134+
background: color-mix(in srgb, var(--color-primary-element) 8%, var(--color-main-background));
135+
font-size: 13px;
136+
}
137+
138+
// Smooth collapse animation while the dismiss is pending
139+
#notifications .notification.notification--collapsing {
140+
overflow: hidden;
141+
pointer-events: none;
142+
animation: notification-collapse 280ms ease-in both;
143+
}
144+
145+
@keyframes notification-collapse {
146+
0% { opacity: 1; max-height: 600px; padding-bottom: 12px; }
147+
45% { opacity: 0; }
148+
100% { opacity: 0; max-height: 0; padding-bottom: 0; }
149+
}

0 commit comments

Comments
 (0)