vikunja/frontend/src/components/notifications/Notifications.vue

386 lines
9.4 KiB
Vue

<template>
<div class="notifications">
<slot
name="trigger"
toggle-open="() => showNotifications = !showNotifications"
:has-unread-notifications="unreadNotifications > 0"
>
<BaseButton
class="trigger-button"
@click.stop="showNotifications = !showNotifications"
>
<span class="is-sr-only">{{ $t('notification.title') }}</span>
<span
v-if="unreadNotifications > 0"
class="unread-indicator"
/>
<Icon icon="bell" />
</BaseButton>
</slot>
<CustomTransition name="fade">
<div
v-if="showNotifications"
ref="popup"
class="notifications-list"
>
<div class="head">
<span>{{ $t('notification.title') }}</span>
<BaseButton
v-tooltip="$t('notification.subscribeFeed')"
class="feed-link"
:to="{name: 'user.settings.feeds'}"
@click="showNotifications = false"
>
<span class="is-sr-only">{{ $t('notification.subscribeFeed') }}</span>
<Icon icon="rss" />
</BaseButton>
</div>
<div
v-for="(n, index) in notifications"
:key="n.id"
class="single-notification"
:class="{'is-clickable': notificationHasRoute(n)}"
@click="() => notificationHasRoute(n) && to(n, index)()"
>
<div
class="read-indicator"
:class="{'read': n.readAt !== null}"
/>
<User
v-if="n.notification.doer"
:user="n.notification.doer"
:show-username="false"
:avatar-size="16"
/>
<div class="detail">
<div>
<span
v-if="n.notification.doer"
class="has-text-weight-bold mie-1"
>
{{ getDisplayName(n.notification.doer) }}
</span>
{{ n.toText(userInfo) }}
</div>
<span
v-tooltip="formatDateLong(n.created)"
class="created"
>
{{ formatDisplayDate(n.created) }}
</span>
</div>
</div>
<XButton
v-if="notifications.length > 0 && unreadNotifications > 0"
variant="tertiary"
class="mbs-2 is-fullwidth"
@click="markAllRead"
>
{{ $t('notification.markAllRead') }}
</XButton>
<p
v-if="notifications.length === 0"
class="nothing"
>
{{ $t('notification.none') }}<br>
<span class="explainer">
{{ $t('notification.explainer') }}
</span>
</p>
</div>
</CustomTransition>
</div>
</template>
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {useRouter, isNavigationFailure, NavigationFailureType, RouteLocationRaw} from 'vue-router'
import NotificationService from '@/services/notification'
import NotificationModel from '@/models/notification'
import BaseButton from '@/components/base/BaseButton.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import User from '@/components/misc/User.vue'
import {NOTIFICATION_NAMES as names, type INotification} from '@/modelTypes/INotification'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {formatDateLong, formatDisplayDate} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {useAuthStore} from '@/stores/auth'
import {useWebSocket} from '@/composables/useWebSocket'
import XButton from '@/components/input/Button.vue'
import {success} from '@/message'
import {useI18n} from 'vue-i18n'
const {subscribe, connected: wsConnected} = useWebSocket()
const authStore = useAuthStore()
const router = useRouter()
const {t} = useI18n()
const allNotifications = ref<INotification[]>([])
const showNotifications = ref(false)
const popup = ref(null)
const unreadNotifications = computed(() => {
return notifications.value.filter(n => n.readAt === null).length
})
const notifications = computed(() => {
return allNotifications.value ? allNotifications.value.filter(n => n.name !== '') : []
})
const userInfo = computed(() => authStore.info)
let unsubscribeWs: (() => void) | null = null
let pollInterval: ReturnType<typeof setInterval> | null = null
const POLL_INTERVAL = 10000
onMounted(async () => {
// Initial load via REST - wrapped in try/catch so the rest of setup
// (click handler, WS subscription, polling) still runs if this fails
try {
await loadNotifications()
} catch (e) {
console.warn('Failed to load initial notifications:', e)
}
document.addEventListener('click', hidePopup)
// Subscribe to real-time notifications
unsubscribeWs = subscribe('notification.created', (msg) => {
if (msg.event === 'notification.created' && msg.data) {
const notification = new NotificationModel(msg.data as Partial<INotification>)
// Avoid duplicates if the same notification was already loaded via REST
const exists = allNotifications.value.some(n => n.id === notification.id)
if (!exists) {
allNotifications.value = [notification, ...allNotifications.value]
}
}
})
// Fallback polling when WebSocket is not available
startPollingFallback()
})
// Reload notifications when WebSocket disconnects to catch any events
// that may have been missed during the disconnect window
watch(wsConnected, (isConnected, wasConnected) => {
if (wasConnected && !isConnected) {
loadNotifications().catch(e => console.warn('Failed to reload notifications after WS disconnect:', e))
}
})
onUnmounted(() => {
document.removeEventListener('click', hidePopup)
unsubscribeWs?.()
stopPollingFallback()
})
function startPollingFallback() {
pollInterval = setInterval(async () => {
if (!wsConnected.value && document.visibilityState === 'visible') {
await loadNotifications()
}
}, POLL_INTERVAL)
}
function stopPollingFallback() {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
async function loadNotifications() {
const notificationService = new NotificationService()
allNotifications.value = await notificationService.getAll()
}
function hidePopup(e) {
if (showNotifications.value) {
closeWhenClickedOutside(e, popup.value, () => showNotifications.value = false)
}
}
function getNotificationRoute(n: INotification): RouteLocationRaw | null {
switch (n.name) {
case names.TASK_COMMENT:
case names.TASK_ASSIGNED:
case names.TASK_REMINDER:
case names.TASK_MENTIONED:
return {name: 'task.detail', params: {id: (n.notification as {task: {id: number}}).task.id}}
case names.PROJECT_CREATED:
return {name: 'task.index', params: {projectId: (n.notification as {project: {id: number}}).project.id}}
case names.TEAM_MEMBER_ADDED:
return {name: 'teams.edit', params: {id: (n.notification as {team: {id: number}}).team.id}}
default:
return null
}
}
function notificationHasRoute(n: INotification): boolean {
return getNotificationRoute(n) !== null
}
function to(n: INotification, index: number) {
return async () => {
const route = getNotificationRoute(n)
if (route === null) return
const failure = await router.push(route)
if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
router.go(0)
}
n.read = true
if (allNotifications.value[index]) {
const notificationService = new NotificationService()
Object.assign(allNotifications.value[index], await notificationService.update(n))
}
showNotifications.value = false
}
}
async function markAllRead() {
const notificationService = new NotificationService()
await notificationService.markAllRead()
success({message: t('notification.markAllReadSuccess')})
notifications.value.forEach(n => n.readAt = new Date())
}
</script>
<style lang="scss" scoped>
.notifications {
display: flex;
.trigger-button {
inline-size: 100%;
position: relative;
}
.unread-indicator {
position: absolute;
inset-block-start: 1rem;
inset-inline-end: .5rem;
inline-size: .75rem;
block-size: .75rem;
background: var(--primary);
border-radius: 100%;
border: 2px solid var(--white);
}
.notifications-list {
position: absolute;
inset-inline-end: 1rem;
inset-block-start: calc(100% + 1rem);
max-block-size: 400px;
overflow-y: auto;
background: var(--white);
inline-size: 350px;
max-inline-size: calc(100vw - 2rem);
padding: .75rem .25rem;
border-radius: $radius;
box-shadow: var(--shadow-sm);
font-size: .85rem;
@media screen and (max-width: $tablet) {
max-block-size: calc(100vh - 1rem - #{$navbar-height});
}
.head {
font-family: $vikunja-font;
font-size: 1rem;
padding: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
.feed-link {
color: var(--grey-500);
transition: color $transition;
&:hover,
&:focus {
color: var(--primary);
}
}
}
.single-notification {
display: flex;
align-items: center;
padding: 0.25rem 0;
transition: background-color $transition;
&.is-clickable {
cursor: pointer;
}
&:hover {
background: var(--grey-100);
border-radius: $radius;
}
.read-indicator {
inline-size: .35rem;
block-size: .35rem;
background: var(--primary);
border-radius: 100%;
margin: 0 .5rem;
flex-shrink: 0;
&.read {
background: transparent;
}
}
.user {
display: inline-flex;
align-items: center;
inline-size: auto;
margin: 0 .5rem;
span {
font-family: $family-sans-serif;
}
.avatar {
block-size: 16px;
}
img {
margin-inline-end: 0;
}
}
.created {
color: var(--grey-400);
}
&:last-child {
margin-block-end: .25rem;
}
a {
color: var(--grey-800);
}
}
.nothing {
text-align: center;
padding: 1rem 0;
color: var(--grey-500);
.explainer {
font-size: .75rem;
}
}
}
}
</style>