frontend: implement custom participant model and role management in meeting component

This commit is contained in:
Carlos Santos 2025-08-07 18:36:25 +02:00
parent b6acebfa18
commit eb8ab3fe63
7 changed files with 190 additions and 22 deletions

View File

@ -0,0 +1,39 @@
import { MeetTokenMetadata, ParticipantRole } from '@lib/typings/ce';
import { ParticipantModel, ParticipantProperties } from 'openvidu-components-angular';
// Represents a participant in the application.
export class CustomParticipantModel extends ParticipantModel {
// Indicates the role of the participant.
private _meetRole: ParticipantRole;
// Creates a new instance of CustomParticipantModel.
constructor(props: ParticipantProperties) {
super(props);
const participant = props.participant;
this._meetRole = extractParticipantRole(participant.metadata);
}
// Sets the role of the participant.
set meetRole(role: ParticipantRole) {
this._meetRole = role;
}
// Checks if the participant is a moderator.
// Returns true if the participant's role is MODERATOR, otherwise false.
isModerator(): boolean {
return this._meetRole === ParticipantRole.MODERATOR;
}
}
const extractParticipantRole = (metadata: any): ParticipantRole => {
let parsedMetadata: MeetTokenMetadata = metadata;
try {
parsedMetadata = JSON.parse(metadata || '{}');
} catch (e) {
console.warn('Failed to parse participant metadata:', e);
}
if (!parsedMetadata || typeof parsedMetadata !== 'object') {
return ParticipantRole.PUBLISHER;
}
return parsedMetadata.selectedRole || ParticipantRole.PUBLISHER;
};

View File

@ -77,21 +77,6 @@
</mat-menu>
</div>
<!-- Participant Panel Item Elements -->
<div *ovParticipantPanelItemElements="let participant">
<!-- Kick participant -->
@if (!participant.isLocal) {
<button
mat-icon-button
(click)="forceDisconnectParticipant(participant)"
matTooltip="Disconnect participant"
class="force-disconnect-btn"
>
<mat-icon>call_end</mat-icon>
</button>
}
</div>
<div *ovParticipantPanelAfterLocalParticipant>
<div class="share-meeting-link-container">
<ov-share-meeting-link
@ -116,6 +101,78 @@
}
</ng-container>
}
<ng-container *ovParticipantPanelItem="let participant">
<!-- If Meet participant is moderator -->
@if (features().canModerateRoom) {
<div class="participant-item-container">
<!-- Local participant -->
@if (participant.isLocal) {
<ov-participant-panel-item [participant]="participant">
<ng-container *ovParticipantPanelParticipantBadge>
<span class="moderator-badge">
<mat-icon matTooltip="Moderator" class="material-symbols-outlined"
>shield_person</mat-icon
>
</span>
</ng-container>
</ov-participant-panel-item>
} @else {
<!-- Remote participant -->
<ov-participant-panel-item [participant]="participant">
@if (participant.isModerator()) {
<ng-container *ovParticipantPanelParticipantBadge>
<span class="moderator-badge">
<mat-icon matTooltip="Moderator" class="material-symbols-outlined"
>shield_person</mat-icon
>
</span>
</ng-container>
}
<div *ovParticipantPanelItemElements>
<!-- Button to make moderator if not -->
@if (!participant.isModerator()) {
<button
mat-icon-button
(click)="makeModerator(participant)"
matTooltip="Make participant moderator"
class="make-moderator-btn"
>
<mat-icon class="material-symbols-outlined">add_moderator</mat-icon>
</button>
}
<!-- Button to kick participant -->
<button
mat-icon-button
(click)="forceDisconnectParticipant(participant)"
matTooltip="Kick participant"
class="force-disconnect-btn"
>
<mat-icon>call_end</mat-icon>
</button>
</div>
</ov-participant-panel-item>
}
</div>
}
<!-- If I can't moderate the room -->
@else {
<div class="participant-item-container">
<ov-participant-panel-item [participant]="participant">
@if (participant.isModerator()) {
<ng-container *ovParticipantPanelParticipantBadge>
<span class="moderator-badge">
<mat-icon matTooltip="Moderator" class="material-symbols-outlined">
shield_person
</mat-icon>
</span>
</ng-container>
}
</ov-participant-panel-item>
</div>
}
</ng-container>
</ov-videoconference>
} @else {
<!-- Move this logic to prejoin meeting page -->

View File

@ -279,3 +279,20 @@
.force-disconnect-btn {
color: var(--ov-meet-color-error);
}
.make-moderator-btn {
color: var(--ov-meet-color-warning);
}
.participant-item-container {
align-items: center;
::ng-deep .participant-container {
padding: 2px 10px !important;
}
.moderator-badge {
color: var(--ov-meet-color-warning);
font-size: 24px;
margin-right: var(--ov-meet-spacing-xs);
}
}

View File

@ -13,6 +13,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute } from '@angular/router';
import { ShareMeetingLinkComponent } from '@lib/components/share-meeting-link/share-meeting-link.component';
import { ErrorReason } from '@lib/models';
import { CustomParticipantModel } from '@lib/models/custom-participant.model';
import {
AppDataService,
ApplicationFeatures,
@ -35,7 +36,7 @@ import {
WebComponentEvent,
WebComponentOutboundEventMessage
} from '@lib/typings/ce';
import { MeetSignalType } from '@lib/typings/ce/event.model';
import { MeetParticipantRoleUpdatedPayload, MeetSignalType } from '@lib/typings/ce/event.model';
import {
ApiDirectiveModule,
DataPacket_Kind,
@ -91,7 +92,8 @@ export class MeetingComponent implements OnInit {
participantName = '';
participantToken = '';
participantRole: ParticipantRole = ParticipantRole.SPEAKER;
remoteParticipants: ParticipantModel[] = [];
localParticipant?: CustomParticipantModel;
remoteParticipants: CustomParticipantModel[] = [];
showMeeting = false;
features: Signal<ApplicationFeatures>;
@ -258,7 +260,12 @@ export class MeetingComponent implements OnInit {
this.componentParticipantService.remoteParticipants$
.pipe(takeUntil(this.destroy$))
.subscribe((participants) => {
this.remoteParticipants = participants;
this.remoteParticipants = participants as CustomParticipantModel[];
});
this.componentParticipantService.localParticipant$
.pipe(takeUntil(this.destroy$))
.subscribe((participant) => {
this.localParticipant = participant as CustomParticipantModel;
});
} catch (error) {
console.error('Error accessing meeting:', error);
@ -321,6 +328,19 @@ export class MeetingComponent implements OnInit {
if (topic === MeetSignalType.MEET_ROOM_PREFERENCES_UPDATED) {
const roomPreferences: MeetRoomPreferences = event.preferences;
this.featureConfService.setRoomPreferences(roomPreferences);
} else if (topic === MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED) {
const { participantName, newRole, secret } = event as MeetParticipantRoleUpdatedPayload;
if (participantName === this.localParticipant!.name) {
this.localParticipant!.meetRole = newRole;
} else {
const participant = this.remoteParticipants.find((p) => p.name === participantName);
if (participant) {
participant.meetRole = newRole;
}
}
// !Request for new token with the new role
}
}
);
@ -397,8 +417,17 @@ export class MeetingComponent implements OnInit {
}
}
async forceDisconnectParticipant(participant: ParticipantModel) {
await this.meetingService.kickParticipant(this.roomId, participant.identity);
async forceDisconnectParticipant(participant: CustomParticipantModel) {
if (this.participantService.isModeratorParticipant()) {
await this.meetingService.kickParticipant(this.roomId, participant.identity);
}
}
async makeModerator(participant: CustomParticipantModel) {
if (this.participantService.isModeratorParticipant()) {
const newRole = ParticipantRole.MODERATOR;
await this.meetingService.changeParticipantRole(this.roomId, participant.identity, newRole);
}
}
async copyModeratorLink() {

View File

@ -38,4 +38,9 @@ export class HttpService {
statusCode: response.status
}));
}
async patchRequest<T>(path: string, body: any = {}, headers?: Record<string, string>): Promise<T> {
const options = headers ? { headers: new HttpHeaders(headers) } : {};
return lastValueFrom(this.http.patch<T>(path, body, options));
}
}

View File

@ -43,4 +43,23 @@ export class MeetingService {
await this.httpService.deleteRequest(path, headers);
this.log.d(`Participant '${participantId}' kicked from room ${roomId}`);
}
/**
* Changes the role of a participant in a meeting.
*
* @param roomId - The unique identifier of the meeting room
* @param participantId - The unique identifier of the participant whose role is to be changed
* @param newRole - The new role to be assigned to the participant
*/
async changeParticipantRole(
roomId: string,
participantId: string,
newRole: string
): Promise<void> {
const path = `${this.MEETINGS_API}/${roomId}/participants/${participantId}`;
const headers = this.participantService.getParticipantRoleHeader();
const body = { role: newRole };
await this.httpService.patchRequest(path, body, headers);
this.log.d(`Changed role of participant '${participantId}' to '${newRole}' in room ${roomId}`);
}
}

View File

@ -6,11 +6,13 @@ import { provideRouter } from '@angular/router';
import { routes } from '@app/app.routes';
import { environment } from '@environment/environment';
import { httpInterceptor } from '@lib/interceptors/index';
import { CustomParticipantModel } from '@lib/models/custom-participant.model';
import { ThemeService } from '@lib/services/theme.service';
import { OpenViduComponentsConfig, OpenViduComponentsModule } from 'openvidu-components-angular';
import { OpenViduComponentsConfig, OpenViduComponentsModule, ParticipantProperties } from 'openvidu-components-angular';
const ovComponentsconfig: OpenViduComponentsConfig = {
production: environment.production
production: environment.production,
participantFactory: (props: ParticipantProperties) => new CustomParticipantModel(props)
};
export const appConfig: ApplicationConfig = {