Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 114 additions & 17 deletions src/NotificationsApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,50 @@

<!-- Notifications list content -->
<div class="notification-container">
<!-- Filter tabs: only shown when notifications span 3+ categories -->
<div v-if="showFilterTabs" class="notification-filter-tabs" role="tablist">
<button
v-for="tab in filterTabs"
:key="tab.id"
role="tab"
:aria-selected="activeFilter === tab.id"
:class="['notification-filter-tab', { 'notification-filter-tab--active': activeFilter === tab.id }]"

Check failure on line 31 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Static class "notification-filter-tab" should be in a static `class` attribute
@click="activeFilter = tab.id">
{{ tab.label }}
<span v-if="tab.count" class="notification-filter-count">{{ tab.count }}</span>
</button>
</div>

<transition name="fade" mode="out-in">
<transition-group
v-if="notifications.length > 0"
class="notification-wrapper"
name="list"
tag="ul">
<ul v-if="notificationGroups.length > 0" class="notification-wrapper">
<NotificationItem
v-if="hasThrottledPushNotifications"
:key="-2016"
:notification="fairUsePolicyNotification" />
<NotificationItem
v-for="(notification, index) in notifications"
:key="notification.notificationId"
:notification="notification"
@remove="onRemove(index)" />
</transition-group>
<template v-for="(group, groupIdx) in notificationGroups" :key="group.app">
<!-- Group header: only shown when there are multiple apps -->
<li
v-if="notificationGroups.length > 1"
class="notification-group-header"
role="button"
tabindex="0"
@click="toggleGroup(group.app)"
@keydown.enter="toggleGroup(group.app)"
@keydown.space.prevent="toggleGroup(group.app)">
<span class="notification-group-name">{{ formatAppName(group.app) }}</span>
<span class="notification-group-badge">{{ group.items.length }}</span>
<IconChevronDown
:size="14"
:class="{ 'notification-group-chevron--collapsed': collapsedGroups.has(group.app) }" />
</li>
<NotificationItem
v-for="(notification, itemIdx) in group.items"
v-show="!collapsedGroups.has(group.app)"
:key="`${notification.notificationId}-${activeFilter}`"
:style="{ '--anim-index': visibleIndex(groupIdx, itemIdx) }"
:notification="notification"
@remove="onRemove(notification)" />
</template>
</ul>

<!-- No notifications -->
<NcEmptyContent
Expand Down Expand Up @@ -64,7 +92,7 @@
</transition>

<!-- Dismiss all -->
<div v-if="notifications.length > 0" class="dismiss-all">
<div v-if="notificationGroups.length > 0" class="dismiss-all">
<NcButton
variant="tertiary"
wide
Expand Down Expand Up @@ -93,6 +121,7 @@
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
import IconBellOutline from 'vue-material-design-icons/BellOutline.vue'
import IconChevronDown from 'vue-material-design-icons/ChevronDown.vue'
import IconClose from 'vue-material-design-icons/Close.vue'
import IconMessageOutline from 'vue-material-design-icons/MessageOutline.vue'
import IconNotification from './Components/IconNotification.vue'
Expand Down Expand Up @@ -132,6 +161,7 @@

components: {
IconBellOutline,
IconChevronDown,
IconClose,
IconMessageOutline,
IconNotification,
Expand Down Expand Up @@ -187,6 +217,11 @@
pushEndpoints: null,

open: false,

/** Set of app names whose group is currently collapsed */
collapsedGroups: new Set(),
/** Active filter tab: 'all' | 'mentions' | 'files' | 'other' */
activeFilter: 'all',
}
},

Expand All @@ -202,11 +237,12 @@
if (this.webNotificationsGranted === null) {
return t('notifications', 'Requesting browser permissions to show notifications')
}

if (this.hasThrottledPushNotifications) {
return this.fairUsePolicyNotification.subject
}

if (this.activeFilter !== 'all' && this.notifications.length > 0) {
return t('notifications', 'No notifications in this category')
}
return t('notifications', 'No notifications')
},

Expand All @@ -217,6 +253,44 @@

return ''
},

notificationGroups() {
const groups = new Map()
for (const n of this.notifications) {
if (this.activeFilter === 'mentions' && n.objectType !== 'mention') continue

Check failure on line 260 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected { after 'if' condition
if (this.activeFilter === 'files' && !['files', 'files_sharing'].includes(n.app)) continue

Check failure on line 261 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected { after 'if' condition
if (this.activeFilter === 'other' && (n.objectType === 'mention' || ['files', 'files_sharing'].includes(n.app))) continue

Check failure on line 262 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected { after 'if' condition
if (!groups.has(n.app)) groups.set(n.app, [])

Check failure on line 263 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected { after 'if' condition
groups.get(n.app).push(n)
}
return [...groups.entries()].map(([app, items]) => ({ app, items }))
},

filterTabs() {
const counts = { mentions: 0, files: 0, other: 0 }
for (const n of this.notifications) {
if (n.objectType === 'mention') counts.mentions++

Check failure on line 272 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected { after 'if' condition
else if (['files', 'files_sharing'].includes(n.app)) counts.files++

Check failure on line 273 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected { after 'if' condition
else counts.other++

Check failure on line 274 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected { after 'else'
}
const tabs = [{ id: 'all', label: t('notifications', 'All') }]
if (counts.mentions) tabs.push({ id: 'mentions', label: t('notifications', 'Mentions'), count: counts.mentions })

Check failure on line 277 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected { after 'if' condition
if (counts.files) tabs.push({ id: 'files', label: t('notifications', 'Files'), count: counts.files })

Check failure on line 278 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Expected { after 'if' condition
if (counts.other) tabs.push({ id: 'other', label: t('notifications', 'Other'), count: counts.other })
return tabs
},

showFilterTabs() {
return this.filterTabs.length > 2
},
},

watch: {
notificationGroups(groups) {
if (groups.length === 0 && this.activeFilter !== 'all') {
this.activeFilter = 'all'
}
},
},

mounted() {
Expand Down Expand Up @@ -342,11 +416,34 @@
})
},

onRemove(index) {
this.notifications.splice(index, 1)
onRemove(notification) {
const idx = this.notifications.findIndex(n => n.notificationId === notification.notificationId)
if (idx !== -1) {
this.notifications.splice(idx, 1)
}
setCurrentTabAsActive(this.tabId)
},

formatAppName(app) {
return app.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
},

toggleGroup(app) {
if (this.collapsedGroups.has(app)) {
this.collapsedGroups.delete(app)
} else {
this.collapsedGroups.add(app)
}
},

visibleIndex(groupIdx, itemIdx) {
let idx = 0
for (let g = 0; g < groupIdx; g++) {
idx += this.notificationGroups[g].items.length
}
return idx + itemIdx
},

/**
* Update the title to show * if there are new notifications
*
Expand Down Expand Up @@ -404,7 +501,7 @@
this._fetch(true)
},

/**

Check warning on line 504 in src/NotificationsApp.vue

View workflow job for this annotation

GitHub Actions / NPM lint

Missing JSDoc @param "force" declaration
* Performs the AJAX request to retrieve the notifications
*/
async _fetch(force = false) {
Expand Down
124 changes: 124 additions & 0 deletions src/styles/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,127 @@ svg {
}
}
}

// Filter tabs (only rendered when notifications span 3+ categories)
.notification-filter-tabs {
display: flex;
gap: 2px;
padding: 6px 8px;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
scrollbar-width: none;

&::-webkit-scrollbar { display: none; }
}

.notification-filter-tab {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border: none;
border-radius: 20px;
background: transparent;
color: var(--color-text-maxcontrast);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
white-space: nowrap;

&:hover {
background: var(--color-background-hover);
color: var(--color-main-text);
}

&--active {
background: var(--color-primary-element);
color: var(--color-primary-element-text);

&:hover { background: var(--color-primary-element); }

.notification-filter-count {
background: rgba(255, 255, 255, 0.25);
color: inherit;
}
}
}

.notification-filter-count {
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--color-background-dark);
color: var(--color-text-maxcontrast);
font-size: 10px;
line-height: 16px;
text-align: center;
box-sizing: border-box;
}

// Group header — collapsible per-app section title
.notification-group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-maxcontrast);
background: var(--color-background-hover);
cursor: pointer;
user-select: none;
list-style: none;

&:hover {
background: var(--color-background-dark);
}

.notification-group-name {
flex: 1;
}

.notification-group-badge {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--color-primary-element);
color: var(--color-primary-element-text);
font-size: 10px;
line-height: 18px;
text-align: center;
text-transform: none;
letter-spacing: 0;
box-sizing: border-box;
}

.material-design-icon {
transition: transform 0.2s ease;

&.notification-group-chevron--collapsed {
transform: rotate(-90deg);
}
}
}

// Subtle staggered entrance animation per notification item
#notifications.header-menu--opened .notification {
animation: nc-notif-item-in 0.3s cubic-bezier(0.2, 0, 0, 1) both;
animation-delay: calc(100ms + var(--anim-index, 0) * 40ms);
}

@keyframes nc-notif-item-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
Loading