Skip to content
Open
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
24 changes: 13 additions & 11 deletions src/components/Dashboard/TalkDashboard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,13 @@ import { CONVERSATION } from '../../constants.ts'
import { getTalkConfig, hasTalkFeature, localCapabilities } from '../../services/CapabilitiesManager.ts'
import { EventBus } from '../../services/EventBus.ts'
import { useActorStore } from '../../stores/actor.ts'
import { useDashboardStore } from '../../stores/dashboard.ts'
import { supportsTriggeredReminders, useDashboardStore } from '../../stores/dashboard.ts'
import { hasUnreadMentions } from '../../utils/conversation.ts'
import { convertToUnix } from '../../utils/formattedTime.ts'
import { copyConversationLinkToClipboard } from '../../utils/handleUrl.ts'

const supportsUpcomingReminders = hasTalkFeature('local', 'upcoming-reminders')
const supportsReminders = hasTalkFeature('local', 'upcoming-reminders') || supportsTriggeredReminders
const remindersTitle = supportsTriggeredReminders ? t('spreed', 'Reminders') : t('spreed', 'Upcoming reminders')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Placeholder element uses t('spreed', 'Message reminders'), maybe also reuse it here?

const canModerateSipDialOut = hasTalkFeature('local', 'sip-support-dialout')
&& getTalkConfig('local', 'call', 'sip-enabled')
&& getTalkConfig('local', 'call', 'sip-dialout-enabled')
Expand Down Expand Up @@ -71,9 +72,9 @@ const forwardScrollable = ref(false)
const backwardScrollable = ref(false)
const eventCardsWrapper = ref<HTMLDivElement | null>(null)
const eventRooms = computed(() => dashboardStore.eventRooms || [])
const upcomingReminders = computed(() => dashboardStore.upcomingReminders || [])
const reminders = computed(() => dashboardStore.reminders || [])
const eventsInitialised = computed(() => dashboardStore.eventRoomsInitialised)
const remindersInitialised = computed(() => dashboardStore.upcomingRemindersInitialised)
const remindersInitialised = computed(() => dashboardStore.remindersInitialised)
const conversationName = ref('')
let actualizeDataInterval: ReturnType<typeof setInterval> | null = null

Expand All @@ -85,7 +86,7 @@ let actualizeDataInterval: ReturnType<typeof setInterval> | null = null
async function actualizeData() {
await Promise.all([
dashboardStore.fetchDashboardEventRooms(),
dashboardStore.fetchUpcomingReminders(),
dashboardStore.fetchReminders(),
])
}

Expand Down Expand Up @@ -378,26 +379,27 @@ function scrollEventCards({ direction }: { direction: 'backward' | 'forward' })
:backgroundImage="illustration('mentions')" />
</div>
<div
v-if="supportsUpcomingReminders"
v-if="supportsReminders"
class="talk-dashboard__upcoming-reminders">
<DashboardSection
v-if="upcomingReminders.length > 0 || !remindersInitialised"
:title="t('spreed', 'Upcoming reminders')"
v-if="reminders.length > 0 || !remindersInitialised"
:title="remindersTitle"
:backgroundImage="illustration('reminders')">
<template #list>
<ul v-if="remindersInitialised" class="upcoming-reminders-list">
<SearchMessageItem
v-for="reminder in upcomingReminders"
:key="reminder.messageId"
v-for="reminder in reminders"
:key="reminder.notificationId ?? reminder.messageId"
:messageId="reminder.messageId"
:notificationId="reminder.notificationId"
:title="reminder.actorDisplayName"
:subline="reminder.message"
:messageParameters="reminder.messageParameters"
:token="reminder.roomToken"
:to="{
name: 'conversation',
params: { token: reminder.roomToken },
hash: `#message_${reminder.messageId}`,
hash: reminder.messageId ? `#message_${reminder.messageId}` : undefined,
}"
:actorId="reminder.actorId"
:actorType="reminder.actorType"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ const props = withDefaults(defineProps<{
timestamp: number
messageParameters?: ChatMessage['messageParameters']
isReminder?: boolean
notificationId?: number | null
compact?: boolean
}>(), {
messageParameters: () => ({}),
isReminder: false,
notificationId: null,
})

const router = useRouter()
Expand Down Expand Up @@ -117,7 +119,7 @@ function handleResultClick() {
<template v-if="isReminder" #actions>
<NcActionButton
closeAfterClick
@click.stop="dashboardStore.removeReminder(token, messageId)">
@click.stop="dashboardStore.removeReminder(token, messageId, notificationId)">
<template #icon>
<CloseCircleOutline :size="20" />
</template>
Expand Down
9 changes: 9 additions & 0 deletions src/services/CapabilitiesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,15 @@ export function hasTalkFeature(token: string = 'local', feature: string): boolea
}
}

/**
* Check whether the notifications app of the local server offers the given OCS endpoint feature
*
* @param feature endpoint capability in string format
*/
export function hasNotificationsFeature(feature: string): boolean {
return localCapabilities?.notifications?.['ocs-endpoints']?.includes(feature) ?? false
}

/**
* Get an according config value from local or remote capabilities
*
Expand Down
38 changes: 38 additions & 0 deletions src/services/notificationsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { getReminderNotificationsResponse } from '../types/index.ts'

import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'

/**
* Fetches the reminder notifications of the user, that is the reminders which already triggered
* and were not dismissed yet. Requires the `list-filter` capability of the notifications app.
*/
async function getReminderNotifications(): getReminderNotificationsResponse {
return axios.get(generateOcsUrl('apps/notifications/api/v2/notifications'), {
params: {
app: 'spreed',
objectType: 'reminder',
},
})
}

/**
* Dismisses a single notification of the user
*
* @param notificationId The id of the notification
*/
async function dismissNotification(notificationId: number) {
return axios.delete(generateOcsUrl('apps/notifications/api/v2/notifications/{notificationId}', {
notificationId,
}))
}

export {
dismissNotification,
getReminderNotifications,
}
220 changes: 220 additions & 0 deletions src/stores/__tests__/dashboard.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/*
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ATTENDEE } from '../../constants.ts'
import { generateOCSErrorResponse, generateOCSResponse } from '../../test-helpers.js'

vi.mock('../../services/CapabilitiesManager.ts', () => ({
hasNotificationsFeature: vi.fn(),
hasTalkFeature: vi.fn(),
}))
vi.mock('../../services/notificationsService.ts', () => ({
dismissNotification: vi.fn(),
getReminderNotifications: vi.fn(),
}))
vi.mock('../../services/remindersService.js', () => ({
getUpcomingReminders: vi.fn(),
removeMessageReminder: vi.fn(),
}))
vi.mock('../../services/dashboardService.ts', () => ({
getDashboardEventRooms: vi.fn(),
}))

/**
* Load a fresh dashboard store, as the supported reminder source is read from the capabilities
* once when the module is evaluated.
*
* @param {boolean} supportsListFilter Whether the notifications app can filter by app and object
*/
async function setupStore(supportsListFilter) {
vi.resetModules()
const { hasNotificationsFeature, hasTalkFeature } = await import('../../services/CapabilitiesManager.ts')
hasTalkFeature.mockReturnValue(true)
hasNotificationsFeature.mockReturnValue(supportsListFilter)

const notificationsService = await import('../../services/notificationsService.ts')
const remindersService = await import('../../services/remindersService.js')
const { useDashboardStore } = await import('../dashboard.ts')

setActivePinia(createPinia())
return { store: useDashboardStore(), notificationsService, remindersService }
}

describe('dashboardStore', () => {
const reminderNotification = {
notification_id: 9,
app: 'spreed',
user: 'remindertester',
datetime: '2026-09-03T15:39:03+00:00',
object_type: 'reminder',
object_id: 'u8u7if2f/3',
subject: 'Reminder: Other Person in private conversation',
message: 'Hello there, remember this one',
link: 'http://localhost/index.php/call/u8u7if2f#message_3',
subjectRichParameters: {
user: { type: 'user', id: 'reminderother', name: 'Other Person' },
call: { type: 'call', id: '1', name: 'Other Person' },
},
messageRich: 'Hello there, remember this one',
messageRichParameters: [],
}

const upcomingReminder = {
messageId: 3,
roomToken: 'u8u7if2f',
actorId: 'reminderother',
actorType: ATTENDEE.ACTOR_TYPE.USERS,
actorDisplayName: 'Other Person',
message: 'Hello there, remember this one',
messageParameters: {},
reminderTimestamp: 1788449943,
}

beforeEach(() => {
setActivePinia(createPinia())
})

afterEach(() => {
vi.clearAllMocks()
})

describe('fetching reminders', () => {
it('parses triggered reminders out of the notifications of the user', async () => {
// Arrange
const { store, notificationsService } = await setupStore(true)
notificationsService.getReminderNotifications.mockResolvedValueOnce(generateOCSResponse({ payload: [reminderNotification] }))

// Act
await store.fetchReminders()

// Assert
expect(notificationsService.getReminderNotifications).toHaveBeenCalled()
expect(store.remindersInitialised).toBe(true)
expect(store.reminders).toEqual([{
notificationId: 9,
roomToken: 'u8u7if2f',
messageId: 3,
actorId: 'reminderother',
actorType: ATTENDEE.ACTOR_TYPE.USERS,
actorDisplayName: 'Other Person',
message: 'Hello there, remember this one',
messageParameters: {},
reminderTimestamp: 1788449943,
}])
})

it('reads the message id and thread id out of the object id', async () => {
// Arrange
const { store, notificationsService } = await setupStore(true)
notificationsService.getReminderNotifications.mockResolvedValueOnce(generateOCSResponse({ payload: [{ ...reminderNotification, object_id: 'u8u7if2f/3/2' }] }))

// Act
await store.fetchReminders()

// Assert
expect(store.reminders[0].roomToken).toBe('u8u7if2f')
expect(store.reminders[0].messageId).toBe(3)
})

it('falls back to the subject when a sensitive conversation hides the message', async () => {
// Arrange
const { store, notificationsService } = await setupStore(true)
notificationsService.getReminderNotifications.mockResolvedValueOnce(generateOCSResponse({
payload: [{
...reminderNotification,
object_id: 'u8u7if2f',
subject: 'Reminder in a private conversation',
subjectRichParameters: [],
messageRich: '',
messageRichParameters: [],
}],
}))

// Act
await store.fetchReminders()

// Assert
expect(store.reminders[0].messageId).toBe(0)
expect(store.reminders[0].actorDisplayName).toBe('Reminder in a private conversation')
expect(store.reminders[0].messageParameters).toEqual({})
})

it('shows nothing when no app registered a notifier and the endpoint answers 204', async () => {
// Arrange
const { store, notificationsService } = await setupStore(true)
notificationsService.getReminderNotifications.mockResolvedValueOnce({ status: 204, data: '' })

// Act
await store.fetchReminders()

// Assert
expect(store.reminders).toEqual([])
expect(store.remindersInitialised).toBe(true)
})

it('falls back to upcoming reminders without the notifications capability', async () => {
// Arrange
const { store, notificationsService, remindersService } = await setupStore(false)
remindersService.getUpcomingReminders.mockResolvedValueOnce(generateOCSResponse({ payload: [upcomingReminder] }))

// Act
await store.fetchReminders()

// Assert
expect(notificationsService.getReminderNotifications).not.toHaveBeenCalled()
expect(store.reminders).toEqual([{ ...upcomingReminder, notificationId: null }])
})
})

describe('removing reminders', () => {
it('dismisses the notification of a reminder that already triggered', async () => {
// Arrange
const { store, notificationsService, remindersService } = await setupStore(true)
notificationsService.getReminderNotifications.mockResolvedValueOnce(generateOCSResponse({ payload: [reminderNotification] }))
await store.fetchReminders()

// Act
await store.removeReminder('u8u7if2f', 3, 9)

// Assert
expect(notificationsService.dismissNotification).toHaveBeenCalledWith(9)
expect(remindersService.removeMessageReminder).not.toHaveBeenCalled()
expect(store.reminders).toEqual([])
})

it('deletes the reminder itself when it did not trigger yet', async () => {
// Arrange
const { store, notificationsService, remindersService } = await setupStore(false)
remindersService.getUpcomingReminders.mockResolvedValueOnce(generateOCSResponse({ payload: [upcomingReminder] }))
await store.fetchReminders()

// Act
await store.removeReminder('u8u7if2f', 3)

// Assert
expect(remindersService.removeMessageReminder).toHaveBeenCalledWith('u8u7if2f', 3)
expect(notificationsService.dismissNotification).not.toHaveBeenCalled()
expect(store.reminders).toEqual([])
})

it('keeps the list untouched when dismissing fails', async () => {
// Arrange
const { store, notificationsService } = await setupStore(true)
notificationsService.getReminderNotifications.mockResolvedValueOnce(generateOCSResponse({ payload: [reminderNotification] }))
await store.fetchReminders()
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
notificationsService.dismissNotification.mockRejectedValueOnce(generateOCSErrorResponse({ payload: null, status: 404 }))

// Act
await store.removeReminder('u8u7if2f', 3, 9)

// Assert
expect(store.reminders).toHaveLength(1)
consoleErrorSpy.mockRestore()
})
})
})
Loading
Loading