From 3b644d1419dc2d1f3d8033c4c0beda37a1d24477 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 25 Oct 2024 20:00:33 +0200 Subject: [PATCH 1/3] fix: show first letter for e-mail guests avatars - show mail icon for default name - refactor tests Signed-off-by: Maksim Sukharev --- .../AvatarWrapper/AvatarWrapper.spec.js | 122 +++++++----------- .../AvatarWrapper/AvatarWrapper.vue | 54 ++++---- src/constants.js | 1 + 3 files changed, 75 insertions(+), 102 deletions(-) diff --git a/src/components/AvatarWrapper/AvatarWrapper.spec.js b/src/components/AvatarWrapper/AvatarWrapper.spec.js index a446b23a9b0..de838d96b50 100644 --- a/src/components/AvatarWrapper/AvatarWrapper.spec.js +++ b/src/components/AvatarWrapper/AvatarWrapper.spec.js @@ -3,8 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import { shallowMount } from '@vue/test-utils' -import { cloneDeep } from 'lodash' -import Vuex from 'vuex' import { t } from '@nextcloud/l10n' @@ -12,25 +10,16 @@ import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js' import AvatarWrapper from './AvatarWrapper.vue' -import { AVATAR } from '../../constants.js' -import storeConfig from '../../store/storeConfig.js' +import { ATTENDEE, AVATAR } from '../../constants.js' describe('AvatarWrapper.vue', () => { - let testStoreConfig - let store const USER_ID = 'user-id' const USER_NAME = 'John Doe' const PRELOADED_USER_STATUS = { status: 'online', message: null, icon: null } - beforeEach(() => { - testStoreConfig = cloneDeep(storeConfig) - store = new Vuex.Store(testStoreConfig) - }) - describe('render user avatar', () => { test('component renders NcAvatar with standard size by default', () => { const wrapper = shallowMount(AvatarWrapper, { - store, propsData: { name: USER_NAME, }, @@ -43,10 +32,22 @@ describe('AvatarWrapper.vue', () => { test('component does not render NcAvatar for non-users', () => { const wrapper = shallowMount(AvatarWrapper, { - store, propsData: { - name: 'emails', - source: 'emails', + name: 'Email Guest', + source: ATTENDEE.ACTOR_TYPE.EMAILS, + }, + }) + + const avatar = wrapper.findComponent(NcAvatar) + expect(avatar.exists()).toBeFalsy() + }) + + test('component does not render NcAvatar for federated users', () => { + const wrapper = shallowMount(AvatarWrapper, { + propsData: { + token: 'XXXTOKENXXX', + name: 'Federated User', + source: ATTENDEE.ACTOR_TYPE.FEDERATED_USERS, }, }) @@ -57,7 +58,6 @@ describe('AvatarWrapper.vue', () => { test('component renders NcAvatar with specified size', () => { const size = 22 const wrapper = shallowMount(AvatarWrapper, { - store, propsData: { name: USER_NAME, size, @@ -70,10 +70,10 @@ describe('AvatarWrapper.vue', () => { test('component pass props to NcAvatar correctly', async () => { const wrapper = shallowMount(AvatarWrapper, { - store, propsData: { id: USER_ID, name: USER_NAME, + source: ATTENDEE.ACTOR_TYPE.USERS, showUserStatus: true, preloadedUserStatus: PRELOADED_USER_STATUS, }, @@ -92,75 +92,45 @@ describe('AvatarWrapper.vue', () => { }) describe('render specific icons', () => { - test('component render emails icon properly', () => { - const wrapper = shallowMount(AvatarWrapper, { - store, - propsData: { - name: 'emails', - source: 'emails', - }, - }) - - const icon = wrapper.find('.icon') - expect(icon.exists()).toBeTruthy() - expect(icon.classes('icon-mail')).toBeTruthy() - }) - - test('component render groups icon properly', () => { + const testCases = [ + [null, ATTENDEE.CHANGELOG_BOT_ID, 'Talk updates', ATTENDEE.ACTOR_TYPE.BOTS, 'icon-changelog'], + [null, 'federated_user/id', USER_NAME, ATTENDEE.ACTOR_TYPE.FEDERATED_USERS, 'icon-user'], + [null, 'guest/id', '', ATTENDEE.ACTOR_TYPE.GUESTS, 'icon-user'], + [null, 'guest/id', t('spreed', 'Guest'), ATTENDEE.ACTOR_TYPE.GUESTS, 'icon-user'], + [null, 'guest/id', t('spreed', 'Guest'), ATTENDEE.ACTOR_TYPE.EMAILS, 'icon-user'], + ['new', 'guest/id', 'test@mail.com', ATTENDEE.ACTOR_TYPE.EMAILS, 'icon-mail'], + [null, 'sha-phone', '+12345...', ATTENDEE.ACTOR_TYPE.PHONES, 'icon-phone'], + [null, 'team/id', 'Team', ATTENDEE.ACTOR_TYPE.CIRCLES, 'icon-team'], + [null, 'group/id', 'Group', ATTENDEE.ACTOR_TYPE.GROUPS, 'icon-contacts'], + ] + + it.each(testCases)('renders for token \'%s\', id \'%s\', name \'%s\' and source \'%s\' icon \'%s\'', (token, id, name, source, result) => { const wrapper = shallowMount(AvatarWrapper, { - store, - propsData: { - name: 'groups', - source: 'groups', - }, + propsData: { token, id, name, source }, }) - const icon = wrapper.find('.icon') - expect(icon.exists()).toBeTruthy() - expect(icon.classes('icon-contacts')).toBeTruthy() + const avatar = wrapper.find('.avatar') + expect(avatar.exists()).toBeTruthy() + expect(avatar.classes(result)).toBeTruthy() }) }) - describe('render guests', () => { - test('component render icon of guest properly', () => { - const wrapper = shallowMount(AvatarWrapper, { - store, - propsData: { - name: t('spreed', 'Guest'), - source: 'guests', - }, - }) - - const guest = wrapper.find('.guest') - expect(guest.exists()).toBeTruthy() - expect(guest.text()).toBe('?') - }) + describe('render specific symbols', () => { + const testCases = [ + ['guest/id', USER_NAME, ATTENDEE.ACTOR_TYPE.GUESTS, USER_NAME.charAt(0)], + ['guest/id', USER_NAME, ATTENDEE.ACTOR_TYPE.EMAILS, USER_NAME.charAt(0)], + ['deleted_users', USER_NAME, ATTENDEE.ACTOR_TYPE.DELETED_USERS, 'X'], + ['bot-id', USER_NAME, ATTENDEE.ACTOR_TYPE.BOTS, '>_'], + ] - test('component render icon of guest with name properly', () => { + it.each(testCases)('renders for id \'%s\', name \'%s\' and source \'%s\' symbol \'%s\'', (id, name, source, result) => { const wrapper = shallowMount(AvatarWrapper, { - store, - propsData: { - name: USER_NAME, - source: 'guests', - }, + propsData: { name, source }, }) - const guest = wrapper.find('.guest') - expect(guest.text()).toBe(USER_NAME.charAt(0)) - }) - - test('component render icon of deleted user properly', () => { - const wrapper = shallowMount(AvatarWrapper, { - store, - propsData: { - name: USER_NAME, - source: 'deleted_users', - }, - }) - - const deleted = wrapper.find('.guest') - expect(deleted.exists()).toBeTruthy() - expect(deleted.text()).toBe('X') + const avatar = wrapper.find('.avatar') + expect(avatar.exists()).toBeTruthy() + expect(avatar.text()).toBe(result) }) }) }) diff --git a/src/components/AvatarWrapper/AvatarWrapper.vue b/src/components/AvatarWrapper/AvatarWrapper.vue index f098a1fcb36..034468273d8 100644 --- a/src/components/AvatarWrapper/AvatarWrapper.vue +++ b/src/components/AvatarWrapper/AvatarWrapper.vue @@ -6,7 +6,7 @@ @@ -454,7 +454,7 @@ export default { if (this.isBridgeBotUser) { text += ' (' + t('spreed', 'bot') + ')' } - if (this.isGuest) { + if (this.isGuestActor || this.isEmailActor) { text += ' (' + t('spreed', 'guest') + ')' } return text @@ -547,7 +547,7 @@ export default { computedName() { const displayName = this.participant.displayName.trim() - if (displayName === '' && this.isGuest) { + if (displayName === '' && (this.isGuestActor || this.isEmailActor)) { return t('spreed', 'Guest') } @@ -646,10 +646,6 @@ export default { && (hasTalkFeature(this.token, 'federation-v2') || !hasTalkFeature(this.token, 'federation-v1') || (!this.conversation.remoteServer && !this.isFederatedActor)) }, - isGuest() { - return [PARTICIPANT.TYPE.GUEST, PARTICIPANT.TYPE.GUEST_MODERATOR].includes(this.participantType) - }, - isModerator() { return this.participantTypeIsModerator(this.participantType) }, From 9698c341b2f0ffc67e7cfd1a233edda902eb9854 Mon Sep 17 00:00:00 2001 From: Maksim Sukharev Date: Fri, 25 Oct 2024 21:00:29 +0200 Subject: [PATCH 3/3] fix: show e-mail in subline of e-mail guests for moderators - show e-mail in toast when resend invitation - refactor tests Signed-off-by: Maksim Sukharev --- .../LinkShareSettings.vue | 7 +-- .../Participants/Participant.spec.js | 50 +++++++++++-------- .../RightSidebar/Participants/Participant.vue | 18 +++---- .../Participants/ParticipantsTab.vue | 5 +- src/services/participantsService.js | 4 +- src/store/participantsStore.js | 22 ++++++-- src/store/participantsStore.spec.js | 2 +- 7 files changed, 64 insertions(+), 44 deletions(-) diff --git a/src/components/ConversationSettings/LinkShareSettings.vue b/src/components/ConversationSettings/LinkShareSettings.vue index f2e5c229618..e64dd30a7fe 100644 --- a/src/components/ConversationSettings/LinkShareSettings.vue +++ b/src/components/ConversationSettings/LinkShareSettings.vue @@ -214,12 +214,7 @@ export default { async handleResendInvitations() { this.isSendingInvitations = true - try { - await this.$store.dispatch('resendInvitations', { token: this.token }) - showSuccess(t('spreed', 'Invitations sent')) - } catch (e) { - showError(t('spreed', 'Error occurred when sending invitations')) - } + await this.$store.dispatch('resendInvitations', { token: this.token }) this.isSendingInvitations = false }, }, diff --git a/src/components/RightSidebar/Participants/Participant.spec.js b/src/components/RightSidebar/Participants/Participant.spec.js index c89f053179e..6802f2ef45c 100644 --- a/src/components/RightSidebar/Participants/Participant.spec.js +++ b/src/components/RightSidebar/Participants/Participant.spec.js @@ -227,39 +227,43 @@ describe('Participant.vue', () => { /** * Check which status is currently rendered * @param {object} participant participant object - * @param {string|null} status status which expected to be rendered + * @param {string} [status] status which expected to be rendered */ async function checkUserSubnameRendered(participant, status) { const wrapper = mountParticipant(participant) await flushPromises() + const userSubname = wrapper.find('.participant__status') if (status) { - expect(wrapper.find('.participant__status').exists()).toBeTruthy() - expect(wrapper.find('.participant__status').text()).toBe(status) + expect(userSubname.exists()).toBeTruthy() + expect(userSubname.text()).toBe(status) } else { - expect(wrapper.find('.participant__status').exists()).toBeFalsy() + expect(userSubname.exists()).toBeFalsy() } } - test('renders user status', async () => { - await checkUserSubnameRendered(participant, '🌧️ rainy') - }) - - test('does not render user status when not set', async () => { - participant.statusIcon = '' - participant.statusMessage = '' - await checkUserSubnameRendered(participant, null) - }) + const testCases = [ + ['online', '', '', undefined], + ['online', '🌧️', 'Rainy', '🌧️ Rainy'], + ['dnd', '🌧️', 'Rainy', '🌧️ Rainy'], + ['dnd', '🌧️', '', '🌧️ Do not disturb'], + ['away', '🌧️', '', '🌧️ Away'], + ] - test('renders dnd status', async () => { - participant.statusMessage = '' - participant.status = 'dnd' - await checkUserSubnameRendered(participant, '🌧️ Do not disturb') - }) + it.each(testCases)('renders status for participant \'%s\', \'%s\', \'%s\' - \'%s\'', + (status, statusIcon, statusMessage, result) => { + checkUserSubnameRendered({ + ...participant, + status, + statusIcon, + statusMessage, + }, result) + }) - test('renders away status', async () => { - participant.statusMessage = '' - participant.status = 'away' - await checkUserSubnameRendered(participant, '🌧️ Away') + it('renders e-mail as status for e-mail guest', async () => { + participant.actorType = ATTENDEE.ACTOR_TYPE.EMAILS + participant.participantType = PARTICIPANT.TYPE.GUEST + participant.invitedActorId = 'test@mail.com' + await checkUserSubnameRendered(participant, 'test@mail.com') }) }) @@ -522,6 +526,7 @@ describe('Participant.vue', () => { test('allows moderators to resend invitations for email participants', async () => { conversation.participantType = PARTICIPANT.TYPE.MODERATOR participant.actorType = ATTENDEE.ACTOR_TYPE.EMAILS + participant.invitedActorId = 'alice@mail.com' const wrapper = mountParticipant(participant) const actionButton = findNcActionButton(wrapper, 'Resend invitation') expect(actionButton.exists()).toBe(true) @@ -531,6 +536,7 @@ describe('Participant.vue', () => { expect(resendInvitationsAction).toHaveBeenCalledWith(expect.anything(), { token: 'current-token', attendeeId: 'alice-attendee-id', + actorId: 'alice@mail.com', }) }) diff --git a/src/components/RightSidebar/Participants/Participant.vue b/src/components/RightSidebar/Participants/Participant.vue index b1ae97a57b2..3dd10c18489 100644 --- a/src/components/RightSidebar/Participants/Participant.vue +++ b/src/components/RightSidebar/Participants/Participant.vue @@ -512,6 +512,10 @@ export default { : '💬 ' + t('spreed', '{time} talking time', { time: formattedTime(this.timeSpeaking, true) }) } + if (this.isEmailActor && this.participant?.invitedActorId) { + return this.participant.invitedActorId + } + return getStatusMessage(this.participant) }, @@ -815,15 +819,11 @@ export default { }, async resendInvitation() { - try { - await this.$store.dispatch('resendInvitations', { - token: this.token, - attendeeId: this.attendeeId, - }) - showSuccess(t('spreed', 'Invitation was sent to {actorId}', { actorId: this.participant.actorId })) - } catch (error) { - showError(t('spreed', 'Could not send invitation to {actorId}', { actorId: this.participant.actorId })) - } + await this.$store.dispatch('resendInvitations', { + token: this.token, + attendeeId: this.attendeeId, + actorId: this.participant.invitedActorId ?? this.participant.actorId, + }) }, async sendCallNotification() { diff --git a/src/components/RightSidebar/Participants/ParticipantsTab.vue b/src/components/RightSidebar/Participants/ParticipantsTab.vue index 95058066a01..ce866f90b5d 100644 --- a/src/components/RightSidebar/Participants/ParticipantsTab.vue +++ b/src/components/RightSidebar/Participants/ParticipantsTab.vue @@ -65,7 +65,7 @@ import { ref, toRefs } from 'vue' import IconInformationOutline from 'vue-material-design-icons/InformationOutline.vue' -import { showError } from '@nextcloud/dialogs' +import { showError, showSuccess } from '@nextcloud/dialogs' import { subscribe, unsubscribe } from '@nextcloud/event-bus' import { t } from '@nextcloud/l10n' @@ -311,6 +311,9 @@ export default { async addParticipants(item) { try { await addParticipant(this.token, item.id, item.source) + if (item.source === ATTENDEE.ACTOR_TYPE.EMAILS) { + showSuccess(t('spreed', 'Invitation was sent to {actorId}', { actorId: item.id })) + } this.abortSearch() this.cancelableGetParticipants() } catch (exception) { diff --git a/src/services/participantsService.js b/src/services/participantsService.js index 1cc6be514e1..cc44122cde3 100644 --- a/src/services/participantsService.js +++ b/src/services/participantsService.js @@ -158,9 +158,9 @@ const setGuestUserName = async (token, userName) => { * If no userId is set, send to all applicable participants. * * @param {string} token conversation token - * @param {number} attendeeId attendee id to target, or null for all + * @param {number|null} [attendeeId] attendee id to target, or null for all */ -const resendInvitations = async (token, { attendeeId = null }) => { +const resendInvitations = async (token, attendeeId = null) => { await axios.post(generateOcsUrl('apps/spreed/api/v4/room/{token}/participants/resend-invitations', { token }), { attendeeId, }) diff --git a/src/store/participantsStore.js b/src/store/participantsStore.js index 9badbed70b6..576d572d94a 100644 --- a/src/store/participantsStore.js +++ b/src/store/participantsStore.js @@ -883,10 +883,26 @@ const actions = { * @param {object} _ - unused. * @param {object} data - the wrapping object. * @param {string} data.token - conversation token. - * @param {number} data.attendeeId - attendee id to target, or null for all. + * @param {number} [data.attendeeId] - attendee id to target, or null for all. + * @param {string} [data.actorId] - if attendee is provided, the actorId (e-mail) to show in the message. */ - async resendInvitations(_, { token, attendeeId }) { - await resendInvitations(token, { attendeeId }) + async resendInvitations(_, { token, attendeeId, actorId }) { + if (attendeeId) { + try { + await resendInvitations(token, attendeeId) + showSuccess(t('spreed', 'Invitation was sent to {actorId}', { actorId })) + } catch (error) { + showError(t('spreed', 'Could not send invitation to {actorId}', { actorId })) + } + } else { + try { + await resendInvitations(token) + showSuccess(t('spreed', 'Invitations sent')) + } catch (e) { + showError(t('spreed', 'Error occurred when sending invitations')) + } + } + }, /** diff --git a/src/store/participantsStore.spec.js b/src/store/participantsStore.spec.js index 06c0df290a7..8ff40b7be38 100644 --- a/src/store/participantsStore.spec.js +++ b/src/store/participantsStore.spec.js @@ -693,7 +693,7 @@ describe('participantsStore', () => { attendeeId: 1, }) - expect(resendInvitations).toHaveBeenCalledWith(TOKEN, { attendeeId: 1 }) + expect(resendInvitations).toHaveBeenCalledWith(TOKEN, 1) }) describe('joining conversation', () => {