frontend: update participant moderation controls and badge handling in meeting component

Refactors participant moderation and badge display logic

Unifies participant badge handling to support multiple roles (owner, admin, moderator) and updates control visibility based on user permissions. Simplifies template context, centralizes moderation action checks, and refines role change logic for better maintainability and scalability of participant controls.
This commit is contained in:
juancarmore 2026-03-06 11:44:39 +01:00
parent f70bd04497
commit 8a8951c120
5 changed files with 158 additions and 81 deletions

View File

@ -1,14 +1,20 @@
<ng-template #template let-participantContext="participant" let-localParticipant="localParticipant">
@let ctx = getDisplayProperties(participantContext, localParticipant);
<ng-template #template let-participant="participant">
@let ctx = getDisplayProperties(participant);
<div class="participant-item-container">
<ov-participant-panel-item [participant]="participantContext">
<!-- Moderator Badge -->
<ov-participant-panel-item [participant]="participant">
<!-- Participant Badge -->
<ng-container *ovParticipantPanelParticipantBadge>
@if (ctx.showModeratorBadge) {
<span class="moderator-badge" [attr.id]="'moderator-badge-' + participantContext.sid">
<mat-icon [matTooltip]="'Moderator'" class="material-symbols-outlined">
shield_person
@if (ctx.showBadge) {
<span
[class]="getParticipantBadgeClass(participant)"
[attr.id]="'participant-badge-' + participant.sid"
>
<mat-icon
[matTooltip]="getParticipantBadgeTooltip(participant)"
class="material-symbols-outlined"
>
{{ getParticipantBadgeIcon(participant) }}
</mat-icon>
</span>
}
@ -19,16 +25,16 @@
<div
*ovParticipantPanelItemElements
class="moderation-controls"
[attr.id]="'moderation-controls-' + participantContext.sid"
[attr.id]="'moderation-controls-' + participant.sid"
>
<!-- Make Moderator Button -->
@if (ctx.showMakeModeratorButton) {
<button
mat-icon-button
(click)="onMakeModeratorClick(participantContext, localParticipant)"
(click)="onMakeModeratorClick(participant)"
[matTooltip]="'Make participant moderator'"
class="make-moderator-btn"
[attr.id]="'make-moderator-btn-' + participantContext.sid"
[attr.id]="'make-moderator-btn-' + participant.sid"
>
<mat-icon class="material-symbols-outlined">add_moderator</mat-icon>
</button>
@ -38,10 +44,10 @@
@if (ctx.showUnmakeModeratorButton) {
<button
mat-icon-button
(click)="onUnmakeModeratorClick(participantContext, localParticipant)"
(click)="onUnmakeModeratorClick(participant)"
[matTooltip]="'Unmake participant moderator'"
class="remove-moderator-btn"
[attr.id]="'remove-moderator-btn-' + participantContext.sid"
[attr.id]="'remove-moderator-btn-' + participant.sid"
>
<mat-icon class="material-symbols-outlined">remove_moderator</mat-icon>
</button>
@ -51,10 +57,10 @@
@if (ctx.showKickButton) {
<button
mat-icon-button
(click)="onKickParticipantClick(participantContext, localParticipant)"
(click)="onKickParticipantClick(participant)"
[matTooltip]="'Kick participant'"
class="force-disconnect-btn"
[attr.id]="'kick-participant-btn-' + participantContext.sid"
[attr.id]="'kick-participant-btn-' + participant.sid"
>
<mat-icon>call_end</mat-icon>
</button>

View File

@ -3,8 +3,9 @@ import { Component, TemplateRef, ViewChild, inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MeetRoomMemberRole } from '@openvidu-meet/typings';
import { MeetParticipantModerationAction, MeetRoomMemberUIBadge } from '@openvidu-meet/typings';
import { LoggerService, OpenViduComponentsUiModule } from 'openvidu-components-angular';
import { RoomMemberContextService } from '../../../room-members/services/room-member-context.service';
import { CustomParticipantModel, ParticipantDisplayProperties } from '../../models/custom-participant.model';
import { MeetingService } from '../../services/meeting.service';
@ -23,39 +24,101 @@ export class MeetingParticipantItemComponent {
@ViewChild('template', { static: true }) template!: TemplateRef<any>;
protected meetingService = inject(MeetingService);
protected roomMemberContextService = inject(RoomMemberContextService);
protected loggerService = inject(LoggerService);
protected log = this.loggerService.get('OpenVidu Meet - MeetingParticipantItem');
/**
* Get or compute display properties for a participant
*/
getDisplayProperties(
participant: CustomParticipantModel,
localParticipant: CustomParticipantModel
): ParticipantDisplayProperties {
// Compute all display properties once
const isLocalModerator = localParticipant.isModerator();
const isParticipantLocal = participant.isLocal;
const isParticipantModerator = participant.isModerator();
const isParticipantOriginalModerator = participant.isOriginalModerator();
return {
showModeratorBadge: isParticipantModerator,
showModerationControls: isLocalModerator && !isParticipantLocal,
showMakeModeratorButton: isLocalModerator && !isParticipantLocal && !isParticipantModerator,
showUnmakeModeratorButton:
isLocalModerator && !isParticipantLocal && isParticipantModerator && !isParticipantOriginalModerator,
showKickButton: isLocalModerator && !isParticipantLocal && !isParticipantOriginalModerator
getDisplayProperties(participant: CustomParticipantModel): ParticipantDisplayProperties {
const hasBadge = participant.hasBadge();
const displayProperties: ParticipantDisplayProperties = {
showBadge: hasBadge,
showModerationControls: false,
showMakeModeratorButton: false,
showUnmakeModeratorButton: false,
showKickButton: false
};
// Moderation controls are only shown for remote participants
// Skip if participant is local (current user)
if (participant.isLocal) {
return displayProperties;
}
const canMakeModerator = this.roomMemberContextService.hasPermission('canMakeModerator');
const canKickParticipants = this.roomMemberContextService.hasPermission('canKickParticipants');
// If user doesn't have any moderation permissions, no need to compute further
if (!canMakeModerator && !canKickParticipants) {
return displayProperties;
}
const isPromotedModerator = participant.isPromotedModerator();
if (isPromotedModerator) {
// Show unmake moderator and/or kick participant buttons if user has correct permission and
// this participant is a promoted moderator (not an original moderator, who cannot be kicked or unmade moderator)
displayProperties.showUnmakeModeratorButton = canMakeModerator;
displayProperties.showKickButton = canKickParticipants;
} else {
// Show make moderator and/or kick participant buttons if user has correct permission
// and this participant doesn't have any badge (is not owner/admin/moderator)
displayProperties.showMakeModeratorButton = canMakeModerator && !hasBadge;
displayProperties.showKickButton = canKickParticipants && !hasBadge;
}
// Show moderation controls container if any of the buttons should be shown
displayProperties.showModerationControls =
displayProperties.showMakeModeratorButton ||
displayProperties.showUnmakeModeratorButton ||
displayProperties.showKickButton;
return displayProperties;
}
async onMakeModeratorClick(
participantContext: CustomParticipantModel,
localParticipant: CustomParticipantModel
): Promise<void> {
if (!localParticipant.isModerator()) return;
getParticipantBadgeIcon(participant: CustomParticipantModel): string {
switch (participant.getBadge()) {
case MeetRoomMemberUIBadge.OWNER:
return 'workspace_premium';
case MeetRoomMemberUIBadge.ADMIN:
return 'admin_panel_settings';
case MeetRoomMemberUIBadge.MODERATOR:
return 'shield_person';
default:
return '';
}
}
const roomId = localParticipant.roomName;
getParticipantBadgeTooltip(participant: CustomParticipantModel): string {
switch (participant.getBadge()) {
case MeetRoomMemberUIBadge.OWNER:
return 'Owner';
case MeetRoomMemberUIBadge.ADMIN:
return 'Admin';
case MeetRoomMemberUIBadge.MODERATOR:
return 'Moderator';
default:
return '';
}
}
getParticipantBadgeClass(participant: CustomParticipantModel): string {
switch (participant.getBadge()) {
case MeetRoomMemberUIBadge.OWNER:
return 'owner-badge';
case MeetRoomMemberUIBadge.ADMIN:
return 'admin-badge';
case MeetRoomMemberUIBadge.MODERATOR:
return 'moderator-badge';
default:
return '';
}
}
async onMakeModeratorClick(participant: CustomParticipantModel): Promise<void> {
if (!this.roomMemberContextService.hasPermission('canMakeModerator')) return;
const roomId = participant.roomName;
if (!roomId) {
this.log.e('Cannot change participant role: local participant room name is undefined');
return;
@ -64,8 +127,8 @@ export class MeetingParticipantItemComponent {
try {
await this.meetingService.changeParticipantRole(
roomId,
participantContext.identity,
MeetRoomMemberRole.MODERATOR
participant.identity,
MeetParticipantModerationAction.UPGRADE
);
this.log.d('Moderator assigned successfully');
} catch (error) {
@ -73,13 +136,10 @@ export class MeetingParticipantItemComponent {
}
}
async onUnmakeModeratorClick(
participantContext: CustomParticipantModel,
localParticipant: CustomParticipantModel
): Promise<void> {
if (!localParticipant.isModerator()) return;
async onUnmakeModeratorClick(participant: CustomParticipantModel): Promise<void> {
if (!this.roomMemberContextService.hasPermission('canMakeModerator')) return;
const roomId = localParticipant.roomName;
const roomId = participant.roomName;
if (!roomId) {
this.log.e('Cannot change participant role: local participant room name is undefined');
return;
@ -88,8 +148,8 @@ export class MeetingParticipantItemComponent {
try {
await this.meetingService.changeParticipantRole(
roomId,
participantContext.identity,
MeetRoomMemberRole.SPEAKER
participant.identity,
MeetParticipantModerationAction.DOWNGRADE
);
this.log.d('Moderator unassigned successfully');
} catch (error) {
@ -97,20 +157,17 @@ export class MeetingParticipantItemComponent {
}
}
async onKickParticipantClick(
participantContext: CustomParticipantModel,
localParticipant: CustomParticipantModel
): Promise<void> {
if (!localParticipant.isModerator()) return;
async onKickParticipantClick(participant: CustomParticipantModel): Promise<void> {
if (!this.roomMemberContextService.hasPermission('canKickParticipants')) return;
const roomId = localParticipant.roomName;
const roomId = participant.roomName;
if (!roomId) {
this.log.e('Cannot kick participant: local participant room name is undefined');
return;
}
try {
await this.meetingService.kickParticipant(roomId, participantContext.identity);
await this.meetingService.kickParticipant(roomId, participant.identity);
this.log.d('Participant kicked successfully');
} catch (error) {
this.log.e('Error kicking participant:', error);

View File

@ -1,11 +1,11 @@
import { MeetRoomMemberRole, MeetRoomMemberTokenMetadata } from '@openvidu-meet/typings';
import { MeetRoomMemberTokenMetadata, MeetRoomMemberUIBadge } from '@openvidu-meet/typings';
import { ParticipantModel, ParticipantProperties } from 'openvidu-components-angular';
/**
* Interface for computed participant display properties
*/
export interface ParticipantDisplayProperties {
showModeratorBadge: boolean;
showBadge: boolean;
showModerationControls: boolean;
showMakeModeratorButton: boolean;
showUnmakeModeratorButton: boolean;
@ -14,49 +14,64 @@ export interface ParticipantDisplayProperties {
// Represents a participant in the application.
export class CustomParticipantModel extends ParticipantModel {
// Indicates the original role of the participant.
private _meetOriginalRole: MeetRoomMemberRole;
// Indicates the current role of the participant.
private _meetRole: MeetRoomMemberRole;
private _meetBadge = MeetRoomMemberUIBadge.OTHER;
private _isPromotedModerator = false;
constructor(props: ParticipantProperties) {
super(props);
const participant = props.participant;
this._meetOriginalRole = extractParticipantRole(participant.metadata);
this._meetRole = this._meetOriginalRole;
this.updateModerationMetadata(props.participant.metadata);
}
set meetRole(role: MeetRoomMemberRole) {
this._meetRole = role;
set meetBadge(badge: MeetRoomMemberUIBadge) {
this._meetBadge = badge;
}
set promotedModerator(isPromoted: boolean) {
this._isPromotedModerator = isPromoted;
}
private updateModerationMetadata(metadata: unknown): void {
const parsedMetadata = parseParticipantMetadata(metadata);
this._meetBadge = parsedMetadata?.badge || MeetRoomMemberUIBadge.OTHER;
this._isPromotedModerator = Boolean(parsedMetadata?.isPromotedModerator);
}
/**
* Checks if the current role of the participant is moderator.
* @returns True if the current role is moderator, false otherwise.
* Gets the participant's badge.
* @returns The MeetRoomMemberUIBadge representing the participant's badge.
*/
isModerator(): boolean {
return this._meetRole === MeetRoomMemberRole.MODERATOR;
getBadge(): MeetRoomMemberUIBadge {
return this._meetBadge;
}
/**
* Checks if the original role of the participant is moderator.
* @returns True if the original role is moderator, false otherwise.
* Checks if the participant has a badge other than OTHER.
* @returns True if the participant has a badge, false otherwise.
*/
isOriginalModerator(): boolean {
return this._meetOriginalRole === MeetRoomMemberRole.MODERATOR;
hasBadge(): boolean {
return this._meetBadge !== MeetRoomMemberUIBadge.OTHER;
}
/**
* Checks if the participant is a promoted moderator (not an original moderator).
* @returns True if the participant is a promoted moderator, false otherwise.
*/
isPromotedModerator(): boolean {
return this._isPromotedModerator;
}
}
const extractParticipantRole = (metadata: any): MeetRoomMemberRole => {
const parseParticipantMetadata = (metadata: unknown): MeetRoomMemberTokenMetadata | undefined => {
let parsedMetadata: MeetRoomMemberTokenMetadata | undefined;
try {
parsedMetadata = JSON.parse(metadata || '{}');
parsedMetadata = JSON.parse((metadata as string) || '{}');
} catch (e) {
console.warn('Failed to parse participant metadata:', e);
}
if (!parsedMetadata || typeof parsedMetadata !== 'object') {
return MeetRoomMemberRole.SPEAKER;
return undefined;
}
return parsedMetadata.baseRole || MeetRoomMemberRole.SPEAKER;
return parsedMetadata;
};

View File

@ -83,7 +83,7 @@
<div *ovParticipantPanelItem="let participant">
<ng-container
[ngTemplateOutlet]="participantItemTemplate()"
[ngTemplateOutletContext]="{ participant: participant, localParticipant: localParticipant() }"
[ngTemplateOutletContext]="{ participant: participant }"
>
</ng-container>
</div>

View File

@ -60,7 +60,6 @@ export class MeetingComponent implements OnInit {
roomName = this.lobbyService.roomName;
roomMemberToken = this.lobbyService.roomMemberToken;
e2eeKey = this.lobbyService.e2eeKeyValue;
localParticipant = this.meetingContextService.localParticipant;
features = this.meetingContextService.meetingUI;
hasRecordings = this.meetingContextService.hasRecordings;