Skip to content

Commit 8c16f19

Browse files
committed
feat(moderators): Show moderator/owner icons on chat messages
Signed-off-by: Joas Schilling <coding@schilljs.com>
1 parent 9f9d138 commit 8c16f19

3 files changed

Lines changed: 137 additions & 17 deletions

File tree

src/components/MessagesList/MessagesGroup/MessagesGroup.spec.js

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import { cloneDeep } from 'es-toolkit'
88
import { createPinia, setActivePinia } from 'pinia'
99
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
1010
import { createStore } from 'vuex'
11+
import IconCrownOutline from 'vue-material-design-icons/CrownOutline.vue'
12+
import IconShieldOutline from 'vue-material-design-icons/ShieldOutline.vue'
1113
import MessageItem from './Message/MessageItem.vue'
1214
import MessagesGroup from './MessagesGroup.vue'
13-
import { ATTENDEE, MESSAGE } from '../../../constants.ts'
15+
import { ATTENDEE, CONVERSATION, MESSAGE, PARTICIPANT } from '../../../constants.ts'
1416
import storeConfig from '../../../store/storeConfig.js'
1517
import { useActorStore } from '../../../stores/actor.ts'
1618
import { useGuestNameStore } from '../../../stores/guestName.ts'
@@ -200,4 +202,78 @@ describe('MessagesGroup.vue', () => {
200202
actorDisplayNameWithFallback: 'Federated Actor',
201203
})
202204
})
205+
206+
describe('role of the author', () => {
207+
/**
208+
* @param {number|null} participantType Participant type of the author, null when they are no participant
209+
* @param {number|undefined} conversationType Type of the conversation
210+
*/
211+
function mountWithAuthor(participantType, conversationType = CONVERSATION.TYPE.GROUP) {
212+
testStoreConfig.modules.conversationsStore.getters.conversation = () => () => ({ type: conversationType })
213+
testStoreConfig.modules.participantsStore.getters.findParticipant = () => () => {
214+
return participantType === null ? null : { participantType }
215+
}
216+
store = createStore(testStoreConfig)
217+
218+
return shallowMount(MessagesGroup, {
219+
global: {
220+
plugins: [store],
221+
provide: { 'messagesList:isSplitViewEnabled': false },
222+
},
223+
props: {
224+
token: TOKEN,
225+
previousMessageId: 90,
226+
nextMessageId: 200,
227+
messages: [{
228+
id: 100,
229+
token: TOKEN,
230+
actorId: 'actor-1',
231+
actorDisplayName: 'Alice',
232+
actorType: ATTENDEE.ACTOR_TYPE.USERS,
233+
message: 'first',
234+
messageType: MESSAGE.TYPE.COMMENT,
235+
messageParameters: {},
236+
systemMessage: '',
237+
timestamp: 100,
238+
isReplyable: true,
239+
}],
240+
},
241+
})
242+
}
243+
244+
test('renders a crown for an owner', () => {
245+
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.OWNER)
246+
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeTruthy()
247+
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeFalsy()
248+
})
249+
250+
test('renders a shield for a moderator', () => {
251+
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.MODERATOR)
252+
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeTruthy()
253+
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeFalsy()
254+
})
255+
256+
test('renders a shield for a guest moderator', () => {
257+
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.GUEST_MODERATOR)
258+
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeTruthy()
259+
})
260+
261+
test('renders no icon for a regular user', () => {
262+
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.USER)
263+
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeFalsy()
264+
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeFalsy()
265+
})
266+
267+
test('renders no icon when the author is no longer a participant', () => {
268+
const wrapper = mountWithAuthor(null)
269+
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeFalsy()
270+
expect(wrapper.findComponent(IconShieldOutline).exists()).toBeFalsy()
271+
})
272+
273+
test('renders no icon in a one-to-one conversation', () => {
274+
// Both participants of a one-to-one conversation are owners by design
275+
const wrapper = mountWithAuthor(PARTICIPANT.TYPE.OWNER, CONVERSATION.TYPE.ONE_TO_ONE)
276+
expect(wrapper.findComponent(IconCrownOutline).exists()).toBeFalsy()
277+
})
278+
})
203279
})

src/components/MessagesList/MessagesGroup/MessagesGroup.vue

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,18 @@
2121
</div>
2222
<div class="messages__content" :class="{ 'small-view': isSmallMobile || isSidebar }">
2323
<li v-if="showAuthor" class="messages__author" aria-level="4">
24-
{{ actorInfo }}
24+
<span>{{ actorName }}</span>
25+
<IconCrownOutline
26+
v-if="showOwnerIcon"
27+
class="messages__author-icon"
28+
:size="16"
29+
:title="t('spreed', 'owner')" />
30+
<IconShieldOutline
31+
v-else-if="showModeratorIcon"
32+
class="messages__author-icon"
33+
:size="16"
34+
:title="t('spreed', 'moderator')" />
35+
<span v-if="lastEditor">{{ lastEditor }}</span>
2536
</li>
2637
<ul class="messages" :class="{ 'messages-bubble': isSplitViewEnabled }">
2738
<MessageItem
@@ -44,19 +55,25 @@
4455
import { t } from '@nextcloud/l10n'
4556
import { useIsSmallMobile } from '@nextcloud/vue/composables/useIsMobile'
4657
import { computed, inject, toRefs } from 'vue'
58+
import { useStore } from 'vuex'
59+
import IconCrownOutline from 'vue-material-design-icons/CrownOutline.vue'
60+
import IconShieldOutline from 'vue-material-design-icons/ShieldOutline.vue'
4761
import AvatarWrapper from '../../AvatarWrapper/AvatarWrapper.vue'
4862
import MessageItem from './Message/MessageItem.vue'
4963
import { useMessageInfo } from '../../../composables/useMessageInfo.ts'
5064
import { ATTENDEE, AVATAR } from '../../../constants.ts'
5165
import { useActorStore } from '../../../stores/actor.ts'
5266
import { useChatExtrasStore } from '../../../stores/chatExtras.ts'
5367
import { useGuestNameStore } from '../../../stores/guestName.ts'
68+
import { getParticipantRole } from '../../../utils/participants.ts'
5469
5570
export default {
5671
name: 'MessagesGroup',
5772
5873
components: {
5974
AvatarWrapper,
75+
IconCrownOutline,
76+
IconShieldOutline,
6077
MessageItem,
6178
},
6279
@@ -89,7 +106,8 @@ export default {
89106
},
90107
91108
setup(props) {
92-
const { messages } = toRefs(props)
109+
const { messages, token } = toRefs(props)
110+
const store = useStore()
93111
const firstMessage = computed(() => messages.value[0])
94112
const {
95113
remoteServer,
@@ -99,11 +117,24 @@ export default {
99117
} = useMessageInfo(firstMessage)
100118
const isSidebar = inject('chatView:isSidebar', false)
101119
102-
const actorInfo = computed(() => {
103-
return [actorDisplayNameWithFallback.value, remoteServer.value, lastEditor.value]
120+
const actorName = computed(() => {
121+
return [actorDisplayNameWithFallback.value, remoteServer.value]
104122
.filter((value) => value).join(' ')
105123
})
106124
125+
/**
126+
* Messages do not carry the participant type of their author, so it is
127+
* looked up in the participants list. Authors that are not (or no longer)
128+
* a participant simply get no icon.
129+
*/
130+
const role = computed(() => {
131+
const participant = store.getters.findParticipant(token.value, {
132+
actorId: firstMessage.value?.actorId,
133+
actorType: firstMessage.value?.actorType,
134+
})
135+
return getParticipantRole(participant?.participantType, store.getters.conversation(token.value)?.type)
136+
})
137+
107138
const isSplitViewEnabled = inject('messagesList:isSplitViewEnabled', true)
108139
109140
return {
@@ -112,7 +143,10 @@ export default {
112143
actorStore: useActorStore(),
113144
chatExtrasStore: useChatExtrasStore(),
114145
actorDisplayName,
115-
actorInfo,
146+
actorName,
147+
lastEditor,
148+
showOwnerIcon: computed(() => role.value === 'owner'),
149+
showModeratorIcon: computed(() => role.value === 'moderator'),
116150
isSmallMobile: useIsSmallMobile(),
117151
isSidebar,
118152
isSplitViewEnabled,
@@ -169,7 +203,7 @@ export default {
169203
&.outgoing {
170204
171205
.messages__author {
172-
text-align: end;
206+
justify-content: flex-end;
173207
padding-inline-end: var(--default-grid-baseline);
174208
}
175209
@@ -221,11 +255,25 @@ export default {
221255
}
222256
223257
&__author {
258+
display: flex;
259+
align-items: center;
260+
gap: var(--default-grid-baseline);
224261
padding-inline-start: var(--default-grid-baseline);
225262
color: var(--color-text-maxcontrast);
226263
white-space: nowrap;
227264
overflow: hidden;
228-
text-overflow: ellipsis;
265+
266+
> span {
267+
min-width: 0;
268+
overflow: hidden;
269+
text-overflow: ellipsis;
270+
}
271+
}
272+
273+
&__author-icon {
274+
// @nextcloud/vue styles .material-design-icon as display: flex, which would
275+
// put the icon on its own line when it is part of the inline text flow
276+
flex: 0 0 auto;
229277
}
230278
231279
// BEGIN Split view

src/components/RightSidebar/Participants/ParticipantItem.vue

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,7 @@ import { useParticipantActivityStore } from '../../../stores/participantActivity
432432
import { isChannelConversation } from '../../../utils/conversation.ts'
433433
import { formattedTime } from '../../../utils/formattedTime.ts'
434434
import { getDisplayNameWithFallback } from '../../../utils/getDisplayName.ts'
435+
import { getParticipantRole } from '../../../utils/participants.ts'
435436
import { readableNumber } from '../../../utils/readableNumber.ts'
436437
import { getPreloadedUserStatus, getStatusMessage } from '../../../utils/userStatus.ts'
437438
@@ -784,21 +785,16 @@ export default {
784785
return this.participantType === PARTICIPANT.TYPE.OWNER
785786
},
786787
787-
/**
788-
* Rank is only marked in conversations that actually have ranks. Both
789-
* participants of a one-to-one conversation are owners by design.
790-
*/
791-
showRoleIcon() {
792-
return this.isModerator
793-
&& ![CONVERSATION.TYPE.ONE_TO_ONE, CONVERSATION.TYPE.ONE_TO_ONE_FORMER, CONVERSATION.TYPE.CHANGELOG].includes(this.conversation.type)
788+
role() {
789+
return getParticipantRole(this.participantType, this.conversation.type)
794790
},
795791
796792
showOwnerIcon() {
797-
return this.showRoleIcon && this.isOwner
793+
return this.role === 'owner'
798794
},
799795
800796
showModeratorIcon() {
801-
return this.showRoleIcon && !this.isOwner
797+
return this.role === 'moderator'
802798
},
803799
804800
canBeModerated() {

0 commit comments

Comments
 (0)