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
78 changes: 77 additions & 1 deletion src/components/MessagesList/MessagesGroup/MessagesGroup.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { cloneDeep } from 'es-toolkit'
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { createStore } from 'vuex'
import IconCrownOutline from 'vue-material-design-icons/CrownOutline.vue'
import IconShieldOutline from 'vue-material-design-icons/ShieldOutline.vue'
import MessageItem from './Message/MessageItem.vue'
import MessagesGroup from './MessagesGroup.vue'
import { ATTENDEE, MESSAGE } from '../../../constants.ts'
import { ATTENDEE, CONVERSATION, MESSAGE, PARTICIPANT } from '../../../constants.ts'
import storeConfig from '../../../store/storeConfig.js'
import { useActorStore } from '../../../stores/actor.ts'
import { useGuestNameStore } from '../../../stores/guestName.ts'
Expand Down Expand Up @@ -200,4 +202,78 @@ describe('MessagesGroup.vue', () => {
actorDisplayNameWithFallback: 'Federated Actor',
})
})

describe('role of the author', () => {
/**
* @param {number|null} participantType Participant type of the author, null when they are no participant
* @param {number|undefined} conversationType Type of the conversation
*/
function mountWithAuthor(participantType, conversationType = CONVERSATION.TYPE.GROUP) {
testStoreConfig.modules.conversationsStore.getters.conversation = () => () => ({ type: conversationType })
testStoreConfig.modules.participantsStore.getters.findParticipant = () => () => {
return participantType === null ? null : { participantType }
}
store = createStore(testStoreConfig)

return shallowMount(MessagesGroup, {
global: {
plugins: [store],
provide: { 'messagesList:isSplitViewEnabled': false },
},
props: {
token: TOKEN,
previousMessageId: 90,
nextMessageId: 200,
messages: [{
id: 100,
token: TOKEN,
actorId: 'actor-1',
actorDisplayName: 'Alice',
actorType: ATTENDEE.ACTOR_TYPE.USERS,
message: 'first',
messageType: MESSAGE.TYPE.COMMENT,
messageParameters: {},
systemMessage: '',
timestamp: 100,
isReplyable: true,
}],
},
})
}

test('renders a crown for an owner', () => {
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.OWNER)
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeTruthy()
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeFalsy()
})

test('renders a shield for a moderator', () => {
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.MODERATOR)
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeTruthy()
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeFalsy()
})

test('renders a shield for a guest moderator', () => {
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.GUEST_MODERATOR)
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeTruthy()
})

test('renders no icon for a regular user', () => {
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.USER)
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeFalsy()
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeFalsy()
})

test('renders no icon when the author is no longer a participant', () => {
const wrapper = mountWithAuthor(null)
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeFalsy()
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeFalsy()
})

test('renders no icon in a one-to-one conversation', () => {
// Both participants of a one-to-one conversation are owners by design
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.OWNER, CONVERSATION.TYPE.ONE_TO_ONE)
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeFalsy()
})
})
})
62 changes: 55 additions & 7 deletions src/components/MessagesList/MessagesGroup/MessagesGroup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,18 @@
</div>
<div class="messages__content" :class="{ 'small-view': isSmallMobile || isSidebar }">
<li v-if="showAuthor" class="messages__author" aria-level="4">
{{ actorInfo }}
<span>{{ actorName }}</span>
<IconCrownOutline
v-if="showOwnerIcon"
class="messages__author-icon"
:size="16"
:title="t('spreed', 'owner')" />
<IconShieldOutline
v-else-if="showModeratorIcon"
class="messages__author-icon"
:size="16"
:title="t('spreed', 'moderator')" />
<span v-if="lastEditor">{{ lastEditor }}</span>
</li>
<ul class="messages" :class="{ 'messages-bubble': isSplitViewEnabled }">
<MessageItem
Expand All @@ -44,19 +55,25 @@
import { t } from '@nextcloud/l10n'
import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
import { computed, inject, toRefs } from 'vue'
import { useStore } from 'vuex'
import IconCrownOutline from 'vue-material-design-icons/CrownOutline.vue'
import IconShieldOutline from 'vue-material-design-icons/ShieldOutline.vue'
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
import MessageItem from './Message/MessageItem.vue'
import { useMessageInfo } from '../../../composables/useMessageInfo.ts'
import { ATTENDEE, AVATAR } from '../../../constants.ts'
import { useActorStore } from '../../../stores/actor.ts'
import { useChatExtrasStore } from '../../../stores/chatExtras.ts'
import { useGuestNameStore } from '../../../stores/guestName.ts'
import { getParticipantRole } from '../../../utils/participants.ts'

export default {
name: 'MessagesGroup',

components: {
AvatarWrapper,
IconCrownOutline,
IconShieldOutline,
MessageItem,
},

Expand Down Expand Up @@ -89,7 +106,8 @@ export default {
},

setup(props) {
const { messages } = toRefs(props)
const { messages, token } = toRefs(props)
const store = useStore()
const firstMessage = computed(() => messages.value[0])
const {
remoteServer,
Expand All @@ -99,11 +117,24 @@ export default {
} = useMessageInfo(firstMessage)
const isSidebar = inject('chatView:isSidebar', false)

const actorInfo = computed(() => {
return [actorDisplayNameWithFallback.value, remoteServer.value, lastEditor.value]
const actorName = computed(() => {
return [actorDisplayNameWithFallback.value, remoteServer.value]
.filter((value) => value).join(' ')
})

/**
* Messages do not carry the participant type of their author, so it is
* looked up in the participants list. Authors that are not (or no longer)
* a participant simply get no icon.
*/
const role = computed(() => {
const participant = store.getters.findParticipant(token.value, {
actorId: firstMessage.value?.actorId,
actorType: firstMessage.value?.actorType,
})
return getParticipantRole(participant?.participantType, store.getters.conversation(token.value)?.type)
})

const isSplitViewEnabled = inject('messagesList:isSplitViewEnabled', true)

return {
Expand All @@ -112,7 +143,10 @@ export default {
actorStore: useActorStore(),
chatExtrasStore: useChatExtrasStore(),
actorDisplayName,
actorInfo,
actorName,
lastEditor,
showOwnerIcon: computed(() => role.value === 'owner'),
showModeratorIcon: computed(() => role.value === 'moderator'),
isSmallMobile: useIsSmallMobile(),
isSidebar,
isSplitViewEnabled,
Expand Down Expand Up @@ -169,7 +203,7 @@ export default {
&.outgoing {

.messages__author {
text-align: end;
justify-content: flex-end;
padding-inline-end: var(--default-grid-baseline);
}

Expand Down Expand Up @@ -221,11 +255,25 @@ export default {
}

&__author {
display: flex;
align-items: center;
gap: var(--default-grid-baseline);
padding-inline-start: var(--default-grid-baseline);
color: var(--color-text-maxcontrast);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

> span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}

&__author-icon {
// @nextcloud/vue styles .material-design-icon as display: flex, which would
// put the icon on its own line when it is part of the inline text flow
flex: 0 0 auto;
}

// BEGIN Split view
Expand Down
125 changes: 120 additions & 5 deletions src/components/RightSidebar/Participants/Participant.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import IconVideoOutline from 'vue-material-design-icons/VideoOutline.vue'
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
import ParticipantItem from './ParticipantItem.vue'
import router from '../../../__mocks__/router.js'
import { ATTENDEE, PARTICIPANT, WEBINAR } from '../../../constants.ts'
import { ATTENDEE, CONVERSATION, PARTICIPANT, WEBINAR } from '../../../constants.ts'
import storeConfig from '../../../store/storeConfig.js'
import { useActorStore } from '../../../stores/actor.ts'
import { useParticipantActivityStore } from '../../../stores/participantActivity.ts'
Expand Down Expand Up @@ -181,8 +181,10 @@ describe('ParticipantItem.vue', () => {
['Alice', 'guest-id', ATTENDEE.ACTOR_TYPE.GUESTS, PARTICIPANT.TYPE.GUEST, 'Alice(guest)'],
['Alice', 'guest-id', ATTENDEE.ACTOR_TYPE.EMAILS, PARTICIPANT.TYPE.GUEST, 'Alice(guest)'],
['', 'guest-id', ATTENDEE.ACTOR_TYPE.GUESTS, PARTICIPANT.TYPE.GUEST, 'Guest(guest)'],
['Alice', 'alice', ATTENDEE.ACTOR_TYPE.USERS, PARTICIPANT.TYPE.MODERATOR, 'Alice(moderator)'],
['Alice', 'guest-id', ATTENDEE.ACTOR_TYPE.GUESTS, PARTICIPANT.TYPE.GUEST_MODERATOR, 'Alice(moderator)(guest)'],
// The role is rendered as an icon, its accessible name is part of the text content
['Alice', 'alice', ATTENDEE.ACTOR_TYPE.USERS, PARTICIPANT.TYPE.OWNER, 'Aliceowner'],
['Alice', 'alice', ATTENDEE.ACTOR_TYPE.USERS, PARTICIPANT.TYPE.MODERATOR, 'Alicemoderator'],
['Alice', 'guest-id', ATTENDEE.ACTOR_TYPE.GUESTS, PARTICIPANT.TYPE.GUEST_MODERATOR, 'Alicemoderator(guest)'],
['Bot', ATTENDEE.BRIDGE_BOT_ID, ATTENDEE.ACTOR_TYPE.USERS, PARTICIPANT.TYPE.USER, 'Bot(bot)'],
]

Expand All @@ -191,10 +193,23 @@ describe('ParticipantItem.vue', () => {
['Alice', 'guest-id', ATTENDEE.ACTOR_TYPE.GUESTS, PARTICIPANT.TYPE.GUEST, 'Alice(guest)(in the lobby)'],
['Alice', 'guest-id', ATTENDEE.ACTOR_TYPE.EMAILS, PARTICIPANT.TYPE.GUEST, 'Alice(guest)(in the lobby)'],
['', 'guest-id', ATTENDEE.ACTOR_TYPE.GUESTS, PARTICIPANT.TYPE.GUEST, 'Guest(guest)(in the lobby)'],
['Alice', 'alice', ATTENDEE.ACTOR_TYPE.USERS, PARTICIPANT.TYPE.MODERATOR, 'Alice(moderator)'],
['Alice', 'guest-id', ATTENDEE.ACTOR_TYPE.GUESTS, PARTICIPANT.TYPE.GUEST_MODERATOR, 'Alice(moderator)(guest)'],
// Owners and moderators can skip the lobby, so they get no lobby badge
['Alice', 'alice', ATTENDEE.ACTOR_TYPE.USERS, PARTICIPANT.TYPE.OWNER, 'Aliceowner'],
['Alice', 'alice', ATTENDEE.ACTOR_TYPE.USERS, PARTICIPANT.TYPE.MODERATOR, 'Alicemoderator'],
['Alice', 'guest-id', ATTENDEE.ACTOR_TYPE.GUESTS, PARTICIPANT.TYPE.GUEST_MODERATOR, 'Alicemoderator(guest)'],
]

it.each([
[CONVERSATION.TYPE.ONE_TO_ONE],
[CONVERSATION.TYPE.ONE_TO_ONE_FORMER],
[CONVERSATION.TYPE.CHANGELOG],
])('does not render a role icon in conversation type \'%d\'', (conversationType) => {
// Both participants of a one-to-one conversation are owners by design
conversation.type = conversationType
const wrapper = mountParticipant({ ...participant, participantType: PARTICIPANT.TYPE.OWNER })
expect(wrapper.find('.participant__user').text()).toBe('Alice')
})

it.each(testCases)(
'renders name and badges for participant \'%s\' - \'%s\' - \'%s\' - \'%d\'',
(displayName, actorId, actorType, participantType, regexp) => {
Expand Down Expand Up @@ -505,6 +520,106 @@ describe('ParticipantItem.vue', () => {
await testCannotPromote()
})
})
describe('changing ownership', () => {
let promoteToModeratorAction
let demoteFromModeratorAction

beforeEach(() => {
promoteToModeratorAction = vi.fn()
demoteFromModeratorAction = vi.fn()

testStoreConfig.modules.participantsStore.actions.promoteToModerator = promoteToModeratorAction
testStoreConfig.modules.participantsStore.actions.demoteFromModerator = demoteFromModeratorAction
store = createStore(testStoreConfig)

conversation.type = CONVERSATION.TYPE.GROUP
conversation.objectType = ''
conversation.participantType = PARTICIPANT.TYPE.OWNER
})

test('allows an owner to promote a user to owner', async () => {
const wrapper = mountParticipant(participant)
const actionButton = findNcActionButton(wrapper, 'Promote to owner')
expect(actionButton.exists()).toBeTruthy()

await actionButton.find('button').trigger('click')

expect(promoteToModeratorAction).toHaveBeenCalledWith(expect.anything(), {
token: TOKEN,
attendeeId: 'alice-attendee-id',
participantType: PARTICIPANT.TYPE.OWNER,
})
})

test('allows an owner to promote a moderator to owner', async () => {
participant.participantType = PARTICIPANT.TYPE.MODERATOR
const wrapper = mountParticipant(participant)
expect(findNcActionButton(wrapper, 'Promote to owner').exists()).toBeTruthy()
})

test('allows an owner to demote another owner to moderator or user', async () => {
participant.participantType = PARTICIPANT.TYPE.OWNER
const wrapper = mountParticipant(participant)

const toModerator = findNcActionButton(wrapper, 'Demote from owner to moderator')
expect(toModerator.exists()).toBeTruthy()
expect(findNcActionButton(wrapper, 'Demote from owner to user').exists()).toBeTruthy()

await toModerator.find('button').trigger('click')

expect(demoteFromModeratorAction).toHaveBeenCalledWith(expect.anything(), {
token: TOKEN,
attendeeId: 'alice-attendee-id',
participantType: PARTICIPANT.TYPE.MODERATOR,
})
})

test('allows an owner to step down to moderator but not to user', async () => {
participant.participantType = PARTICIPANT.TYPE.OWNER
participant.actorId = 'user-actor-id'
const wrapper = mountParticipant(participant)

expect(findNcActionButton(wrapper, 'Demote from owner to moderator').exists()).toBeTruthy()
expect(findNcActionButton(wrapper, 'Demote from owner to user').exists()).toBeFalsy()
})

test('does not allow a moderator to change ownership', async () => {
conversation.participantType = PARTICIPANT.TYPE.MODERATOR
const wrapper = mountParticipant(participant)
expect(findNcActionButton(wrapper, 'Promote to owner').exists()).toBeFalsy()
})

test('does not allow promoting a guest to owner', async () => {
participant.participantType = PARTICIPANT.TYPE.GUEST
participant.actorType = ATTENDEE.ACTOR_TYPE.GUESTS
const wrapper = mountParticipant(participant)
expect(findNcActionButton(wrapper, 'Promote to owner').exists()).toBeFalsy()
})

test('does not allow promoting a federated user to owner', async () => {
participant.actorType = ATTENDEE.ACTOR_TYPE.FEDERATED_USERS
const wrapper = mountParticipant(participant)
expect(findNcActionButton(wrapper, 'Promote to owner').exists()).toBeFalsy()
})

test('does not allow changing ownership in a one-to-one conversation', async () => {
conversation.type = CONVERSATION.TYPE.ONE_TO_ONE
const wrapper = mountParticipant(participant)
expect(findNcActionButton(wrapper, 'Promote to owner').exists()).toBeFalsy()
})

test('does not allow changing ownership in an object bound conversation', async () => {
conversation.objectType = CONVERSATION.OBJECT_TYPE.EVENT
const wrapper = mountParticipant(participant)
expect(findNcActionButton(wrapper, 'Promote to owner').exists()).toBeFalsy()
})

test('allows changing ownership in a classified conversation', async () => {
conversation.objectType = CONVERSATION.OBJECT_TYPE.CLASSIFIED
const wrapper = mountParticipant(participant)
expect(findNcActionButton(wrapper, 'Promote to owner').exists()).toBeTruthy()
})
})
describe('resending invitations', () => {
let resendInvitationsAction

Expand Down
Loading
Loading