diff --git a/src/components/NewMessage/NewMessage.vue b/src/components/NewMessage/NewMessage.vue index b127a3ce98d..4bf9ecb15dd 100644 --- a/src/components/NewMessage/NewMessage.vue +++ b/src/components/NewMessage/NewMessage.vue @@ -218,6 +218,7 @@ import { getTalkConfig, hasTalkFeature } from '../../services/CapabilitiesManage import { EventBus } from '../../services/EventBus.ts' import { shareFile } from '../../services/filesSharingServices.js' import { useChatExtrasStore } from '../../stores/chatExtras.js' +import { useGroupwareStore } from '../../stores/groupware.ts' import { useSettingsStore } from '../../stores/settings.js' import { fetchClipboardContent } from '../../utils/clipboard.js' import { parseSpecialSymbols } from '../../utils/textParse.ts' @@ -312,6 +313,7 @@ export default { const { createTemporaryMessage } = useTemporaryMessage() return { chatExtrasStore: useChatExtrasStore(), + groupwareStore: useGroupwareStore(), settingsStore: useSettingsStore(), supportTypingStatus, autoComplete, @@ -474,7 +476,7 @@ export default { }, userAbsence() { - return this.chatExtrasStore.absence[this.token] + return this.groupwareStore.absence[this.token] }, showChatSummary() { @@ -988,13 +990,13 @@ export default { // TODO replace with status message id 'vacationing' if (this.conversation.status === 'dnd') { // Fetch actual absence status from server - await this.chatExtrasStore.getUserAbsence({ + await this.groupwareStore.getUserAbsence({ token: this.token, userId: this.conversation.name, }) } else { // Remove stored absence status - this.chatExtrasStore.removeUserAbsence(this.token) + this.groupwareStore.removeUserAbsence(this.token) } }, diff --git a/src/components/TopBar/TopBar.vue b/src/components/TopBar/TopBar.vue index 7808665eca3..d6db40ead24 100644 --- a/src/components/TopBar/TopBar.vue +++ b/src/components/TopBar/TopBar.vue @@ -144,7 +144,7 @@ import ConversationIcon from '../ConversationIcon.vue' import { useGetParticipants } from '../../composables/useGetParticipants.js' import { AVATAR, CONVERSATION } from '../../constants.js' import { getTalkConfig } from '../../services/CapabilitiesManager.ts' -import { useChatExtrasStore } from '../../stores/chatExtras.js' +import { useGroupwareStore } from '../../stores/groupware.ts' import { useSidebarStore } from '../../stores/sidebar.js' import { getStatusMessage } from '../../utils/userStatus.ts' import { localCallParticipantModel, localMediaModel } from '../../utils/webrtc/index.js' @@ -193,7 +193,7 @@ export default { AVATAR, localCallParticipantModel, localMediaModel, - chatExtrasStore: useChatExtrasStore(), + groupwareStore: useGroupwareStore(), sidebarStore: useSidebarStore(), isMobile: useIsMobile(), CONVERSATION, @@ -284,7 +284,7 @@ export default { }, nextEvent() { - return this.chatExtrasStore.getNextEvent(this.token) + return this.groupwareStore.getNextEvent(this.token) }, eventInfo() { @@ -318,7 +318,7 @@ export default { // Do not fetch upcoming events for guests (401 unauthorzied) or in sidebar return } - this.chatExtrasStore.getUpcomingEvents(value) + this.groupwareStore.getUpcomingEvents(value) } }, }, diff --git a/src/services/conversationsService.js b/src/services/conversationsService.js index 1a595acef8d..200a65f64e9 100644 --- a/src/services/conversationsService.js +++ b/src/services/conversationsService.js @@ -21,19 +21,6 @@ const fetchConversations = async function(options) { return axios.get(generateOcsUrl('apps/spreed/api/v4/room'), options) } -/** - * fetch future events for a given conversation within the next 31 days. - * - * @param {string} location room's absolute url - */ -const getUpcomingEvents = async (location) => { - return axios.get(generateOcsUrl('/apps/dav/api/v1/events/upcoming'), { - params: { - location, - }, - }) -} - /** * Fetches a conversation from the server. * @@ -356,7 +343,6 @@ export { fetchConversations, fetchConversation, fetchNoteToSelfConversation, - getUpcomingEvents, searchListedConversations, createOneToOneConversation, createGroupConversation, diff --git a/src/services/coreService.ts b/src/services/coreService.ts index a9f155905b3..5010d0dfa05 100644 --- a/src/services/coreService.ts +++ b/src/services/coreService.ts @@ -9,7 +9,6 @@ import { generateOcsUrl } from '@nextcloud/router' import { getTalkConfig, hasTalkFeature } from './CapabilitiesManager.ts' import { SHARE } from '../constants.js' import type { - OutOfOfficeResponse, TaskProcessingResponse, } from '../types/index.ts' @@ -64,18 +63,8 @@ const deleteTaskById = async function(id: number, options?: object): Promise { - return axios.get(generateOcsUrl('/apps/dav/api/v1/outOfOffice/{userId}/now', { userId })) -} - export { autocompleteQuery, getTaskById, deleteTaskById, - getUserAbsence, } diff --git a/src/services/groupwareService.ts b/src/services/groupwareService.ts new file mode 100644 index 00000000000..4a1a9cd53e9 --- /dev/null +++ b/src/services/groupwareService.ts @@ -0,0 +1,37 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +import type { + OutOfOfficeResponse, + UpcomingEventsResponse, +} from '../types/index.ts' + +/** + * Get upcoming events for a given conversation within the next 31 days. + * @param location conversation's absolute URL + */ +const getUpcomingEvents = async (location: string): UpcomingEventsResponse => { + return axios.get(generateOcsUrl('/apps/dav/api/v1/events/upcoming'), { + params: { + location, + }, + }) +} + +/** + * Get absence information for a user (in a given 1-1 conversation). + * @param userId user id + */ +const getUserAbsence = async (userId: string): OutOfOfficeResponse => { + return axios.get(generateOcsUrl('/apps/dav/api/v1/outOfOffice/{userId}/now', { userId })) +} + +export { + getUpcomingEvents, + getUserAbsence, +} diff --git a/src/store/conversationsStore.js b/src/store/conversationsStore.js index 76979c6556e..d87d4b05168 100644 --- a/src/store/conversationsStore.js +++ b/src/store/conversationsStore.js @@ -63,6 +63,7 @@ import { talkBroadcastChannel } from '../services/talkBroadcastChannel.js' import { useBreakoutRoomsStore } from '../stores/breakoutRooms.ts' import { useChatExtrasStore } from '../stores/chatExtras.js' import { useFederationStore } from '../stores/federation.ts' +import { useGroupwareStore } from '../stores/groupware.ts' import { useReactionsStore } from '../stores/reactions.js' import { useTalkHashStore } from '../stores/talkHash.js' @@ -370,6 +371,8 @@ const actions = { // FIXME: rename to deleteConversationsFromStore or a better name const chatExtrasStore = useChatExtrasStore() chatExtrasStore.purgeChatExtras(token) + const groupwareStore = useGroupwareStore() + groupwareStore.purgeGroupwareStore(token) const reactionsStore = useReactionsStore() reactionsStore.purgeReactionsStore(token) context.dispatch('purgeMessagesStore', token) diff --git a/src/stores/__tests__/chatExtras.spec.js b/src/stores/__tests__/chatExtras.spec.js index b330991daff..41af7af03fa 100644 --- a/src/stores/__tests__/chatExtras.spec.js +++ b/src/stores/__tests__/chatExtras.spec.js @@ -5,19 +5,11 @@ import { setActivePinia, createPinia } from 'pinia' import BrowserStorage from '../../services/BrowserStorage.js' -import { getUserAbsence } from '../../services/coreService.ts' import { EventBus } from '../../services/EventBus.ts' -import { generateOCSErrorResponse, generateOCSResponse } from '../../test-helpers.js' import { useChatExtrasStore } from '../chatExtras.js' -jest.mock('../../services/coreService', () => ({ - getUserAbsence: jest.fn(), -})) - describe('chatExtrasStore', () => { const token = 'TOKEN' - const userId = 'alice' - const payload = { id: 1, userId: 'alice', firstDay: '2023-11-15', lastDay: '2023-11-17', status: 'absence status', message: 'absence message' } let chatExtrasStore beforeEach(async () => { @@ -29,57 +21,6 @@ describe('chatExtrasStore', () => { jest.clearAllMocks() }) - describe('absence status', () => { - it('processes a response from server and stores absence status', async () => { - // Arrange - const response = generateOCSResponse({ payload }) - getUserAbsence.mockResolvedValueOnce(response) - - // Act - await chatExtrasStore.getUserAbsence({ token, userId }) - - // Assert - expect(getUserAbsence).toHaveBeenCalledWith(userId) - expect(chatExtrasStore.absence[token]).toEqual(payload) - }) - - it('does not show error if absence status is not found', async () => { - // Arrange - const errorNotFound = generateOCSErrorResponse({ payload: null, status: 404 }) - const errorOther = generateOCSErrorResponse({ payload: null, status: 500 }) - getUserAbsence - .mockRejectedValueOnce(errorNotFound) - .mockRejectedValueOnce(errorOther) - console.error = jest.fn() - - // Act - await chatExtrasStore.getUserAbsence({ token, userId }) - await chatExtrasStore.getUserAbsence({ token, userId }) - - // Assert - expect(getUserAbsence).toHaveBeenCalledTimes(2) - expect(console.error).toHaveBeenCalledTimes(1) - expect(chatExtrasStore.absence[token]).toEqual(null) - }) - - it('removes absence status from the store', async () => { - // Arrange - const response = generateOCSResponse({ payload }) - getUserAbsence.mockResolvedValueOnce(response) - const token2 = 'TOKEN_2' - - // Act - await chatExtrasStore.getUserAbsence({ token, userId }) - chatExtrasStore.removeUserAbsence(token) - chatExtrasStore.removeUserAbsence(token2) - - // Assert - expect(chatExtrasStore.absence[token]).not.toBeDefined() - expect(chatExtrasStore.absence[token2]).not.toBeDefined() - }) - - }) - describe('reply message', () => { it('adds reply message id to the store', () => { // Act @@ -171,10 +112,6 @@ describe('chatExtrasStore', () => { describe('purge store', () => { it('clears store for provided token', async () => { // Arrange - const response = generateOCSResponse({ payload }) - getUserAbsence.mockResolvedValueOnce(response) - - await chatExtrasStore.getUserAbsence({ token: 'token-1', userId }) chatExtrasStore.setParentIdToReply({ token: 'token-1', id: 101 }) chatExtrasStore.setChatInput({ token: 'token-1', text: 'message-1' }) @@ -182,7 +119,6 @@ describe('chatExtrasStore', () => { chatExtrasStore.purgeChatExtras('token-1') // Assert - expect(chatExtrasStore.absence['token-1']).not.toBeDefined() expect(chatExtrasStore.parentToReply['token-1']).not.toBeDefined() expect(chatExtrasStore.chatInput['token-1']).not.toBeDefined() }) diff --git a/src/stores/__tests__/groupware.spec.js b/src/stores/__tests__/groupware.spec.js new file mode 100644 index 00000000000..21cc22438b8 --- /dev/null +++ b/src/stores/__tests__/groupware.spec.js @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { setActivePinia, createPinia } from 'pinia' + +import { getUserAbsence } from '../../services/groupwareService.ts' +import { generateOCSErrorResponse, generateOCSResponse } from '../../test-helpers.js' +import { useGroupwareStore } from '../groupware.ts' + +jest.mock('../../services/groupwareService', () => ({ + getUserAbsence: jest.fn(), +})) + +describe('groupwareStore', () => { + const token = 'TOKEN' + const userId = 'alice' + const payload = { id: 1, userId: 'alice', firstDay: '2023-11-15', lastDay: '2023-11-17', status: 'absence status', message: 'absence message' } + let groupwareStore + + beforeEach(async () => { + setActivePinia(createPinia()) + groupwareStore = useGroupwareStore() + }) + + afterEach(async () => { + jest.clearAllMocks() + }) + + describe('absence status', () => { + it('processes a response from server and stores absence status', async () => { + // Arrange + const response = generateOCSResponse({ payload }) + getUserAbsence.mockResolvedValueOnce(response) + + // Act + await groupwareStore.getUserAbsence({ token, userId }) + + // Assert + expect(getUserAbsence).toHaveBeenCalledWith(userId) + expect(groupwareStore.absence[token]).toEqual(payload) + }) + + it('does not show error if absence status is not found', async () => { + // Arrange + const errorNotFound = generateOCSErrorResponse({ payload: null, status: 404 }) + const errorOther = generateOCSErrorResponse({ payload: null, status: 500 }) + getUserAbsence + .mockRejectedValueOnce(errorNotFound) + .mockRejectedValueOnce(errorOther) + console.error = jest.fn() + + // Act + await groupwareStore.getUserAbsence({ token, userId }) + await groupwareStore.getUserAbsence({ token, userId }) + + // Assert + expect(getUserAbsence).toHaveBeenCalledTimes(2) + expect(console.error).toHaveBeenCalledTimes(1) + expect(groupwareStore.absence[token]).toEqual(null) + }) + + it('removes absence status from the store', async () => { + // Arrange + const response = generateOCSResponse({ payload }) + getUserAbsence.mockResolvedValueOnce(response) + const token2 = 'TOKEN_2' + + // Act + await groupwareStore.getUserAbsence({ token, userId }) + groupwareStore.removeUserAbsence(token) + groupwareStore.removeUserAbsence(token2) + + // Assert + expect(groupwareStore.absence[token]).not.toBeDefined() + expect(groupwareStore.absence[token2]).not.toBeDefined() + }) + }) + + describe('purge store', () => { + it('clears store for provided token', async () => { + // Arrange + const response = generateOCSResponse({ payload }) + getUserAbsence.mockResolvedValueOnce(response) + + await groupwareStore.getUserAbsence({ token: 'token-1', userId }) + + // Act + groupwareStore.purgeGroupwareStore('token-1') + + // Assert + expect(groupwareStore.absence['token-1']).not.toBeDefined() + }) + }) +}) diff --git a/src/stores/chatExtras.js b/src/stores/chatExtras.js index 099ed652e9a..057b1b4dfd1 100644 --- a/src/stores/chatExtras.js +++ b/src/stores/chatExtras.js @@ -7,11 +7,8 @@ import { defineStore } from 'pinia' import Vue from 'vue' import { t } from '@nextcloud/l10n' -import { generateUrl, getBaseUrl } from '@nextcloud/router' import BrowserStorage from '../services/BrowserStorage.js' -import { getUpcomingEvents } from '../services/conversationsService.js' -import { getUserAbsence } from '../services/coreService.ts' import { EventBus } from '../services/EventBus.ts' import { summarizeChat } from '../services/messagesService.ts' import { parseSpecialSymbols, parseMentions } from '../utils/textParse.ts' @@ -22,7 +19,6 @@ import { parseSpecialSymbols, parseMentions } from '../utils/textParse.ts' /** * @typedef {object} State - * @property {{[key: Token]: object}} absence - The absence status per conversation. * @property {{[key: Token]: number}} parentToReply - The parent message id to reply per conversation. * @property {{[key: Token]: string}} chatInput -The input value per conversation. */ @@ -35,9 +31,7 @@ import { parseSpecialSymbols, parseMentions } from '../utils/textParse.ts' */ export const useChatExtrasStore = defineStore('chatExtras', { state: () => ({ - absence: {}, parentToReply: {}, - upcomingEvents: {}, chatInput: {}, messageIdToEdit: {}, chatEditInput: {}, @@ -61,10 +55,6 @@ export const useChatExtrasStore = defineStore('chatExtras', { return state.messageIdToEdit[token] }, - getNextEvent: (state) => (token) => { - return state.upcomingEvents[token]?.[0] - }, - getChatSummaryTaskQueue: (state) => (token) => { return Object.values(Object(state.chatSummary[token])) }, @@ -93,50 +83,6 @@ export const useChatExtrasStore = defineStore('chatExtras', { return this.chatInput[token] ?? '' }, - /** - * Fetch an absence status for user and save to store - * - * @param {object} payload action payload - * @param {string} payload.token The conversation token - * @param {string} payload.userId The id of user - * - */ - async getUserAbsence({ token, userId }) { - try { - const response = await getUserAbsence(userId) - Vue.set(this.absence, token, response.data.ocs.data) - return this.absence[token] - } catch (error) { - if (error?.response?.status === 404) { - Vue.set(this.absence, token, null) - return null - } - console.error(error) - } - }, - - async getUpcomingEvents(token) { - const location = generateUrl('call/{token}', { token }, { baseURL: getBaseUrl() }) - try { - const response = await getUpcomingEvents(location) - Vue.set(this.upcomingEvents, token, response.data.ocs.data.events) - } catch (error) { - console.error(error) - } - }, - - /** - * Drop an absence status from the store - * - * @param {string} token The conversation token - * - */ - removeUserAbsence(token) { - if (this.absence[token]) { - Vue.delete(this.absence, token) - } - }, - /** * Add a reply message id to the store * @@ -255,7 +201,6 @@ export const useChatExtrasStore = defineStore('chatExtras', { */ purgeChatExtras(token) { this.removeParentIdToReply(token) - this.removeUserAbsence(token) this.removeChatInput(token) }, diff --git a/src/stores/groupware.ts b/src/stores/groupware.ts new file mode 100644 index 00000000000..b51c7a591dd --- /dev/null +++ b/src/stores/groupware.ts @@ -0,0 +1,105 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineStore } from 'pinia' +import Vue from 'vue' + +import type { AxiosError } from '@nextcloud/axios' +import { generateUrl, getBaseUrl } from '@nextcloud/router' + +import { + getUpcomingEvents, + getUserAbsence, +} from '../services/groupwareService.ts' +import type { + OutOfOfficeResult, + UpcomingEvent, +} from '../types/index.ts' + +type State = { + absence: Record + upcomingEvents: Record +} + +export const useGroupwareStore = defineStore('groupware', { + state: (): State => ({ + absence: {}, + upcomingEvents: {}, + }), + + getters: { + getAllEvents: (state) => (token: string) => { + return state.upcomingEvents[token] + }, + getNextEvent: (state) => (token: string) => { + return state.upcomingEvents[token]?.[0] + }, + }, + + actions: { + /** + * Fetch an absence status for user and save to store + * @param payload action payload + * @param payload.token The conversation token + * @param payload.userId The id of user + */ + async getUserAbsence({ token, userId } : { token: string, userId: string}) { + try { + const response = await getUserAbsence(userId) + Vue.set(this.absence, token, response.data.ocs.data) + return this.absence[token] + } catch (error) { + if ((error as AxiosError)?.response?.status === 404) { + Vue.set(this.absence, token, null) + return null + } + console.error(error) + } + }, + + /** + * Fetch upcoming events for conversation and save to store + * @param token The conversation token + */ + async getUpcomingEvents(token: string) { + const location = generateUrl('call/{token}', { token }, { baseURL: getBaseUrl() }) + try { + const response = await getUpcomingEvents(location) + Vue.set(this.upcomingEvents, token, response.data.ocs.data.events) + } catch (error) { + console.error(error) + } + }, + + /** + * Drop an absence status from the store + * @param token The conversation token + */ + removeUserAbsence(token: string) { + if (this.absence[token]) { + Vue.delete(this.absence, token) + } + }, + + /** + * Drop upcoming events from the store + * @param token The conversation token + */ + removeUpcomingEvents(token: string) { + if (this.upcomingEvents[token]) { + Vue.delete(this.upcomingEvents, token) + } + }, + + /** + * Clears store for a deleted conversation + * @param token The conversation token + */ + purgeGroupwareStore(token: string) { + this.removeUserAbsence(token) + this.removeUpcomingEvents(token) + }, + }, +}) diff --git a/src/types/index.ts b/src/types/index.ts index 37f7269ab12..22c5447a934 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -231,20 +231,34 @@ export type TaskProcessingResponse = ApiResponseUnwrapped<{ } }> +// Groupware +// Upcoming events response +// From https://github.com/nextcloud/server/blob/master/apps/dav/lib/CalDAV/UpcomingEvent.php +export type UpcomingEvent = { + uri: string, + calendarUri: string, + /** Format: int64 */ + start: number | null, + summary: string | null, + location: string | null, + recurrenceId?: number | null, + calendarAppUrl?: string | null, +}; +export type UpcomingEventsResponse = ApiResponseUnwrapped<{ events: UpcomingEvent[] }> + // Out of office response // From https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-out-of-office-api.html -export type OutOfOfficeResponse = ApiResponseUnwrapped<{ - task: { - id: string, - userId: string, - startDate: number, - endDate: number, - shortMessage: string, - message: string, - replacementUserId?: string|null, - replacementUserDisplayName?: string|null, - } -}> +export type OutOfOfficeResult = { + id: string, + userId: string, + startDate: number, + endDate: number, + shortMessage: string, + message: string, + replacementUserId?: string|null, + replacementUserDisplayName?: string|null, +} +export type OutOfOfficeResponse = ApiResponseUnwrapped // User preferences response // from https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-user-preferences-api.html