diff --git a/.gitignore b/.gitignore index 87f9a36..130871f 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ pnpm-debug.log* **/**/docs/webcomponent-commands.md **/**/docs/webcomponent-events.md -**/**/meet-pro \ No newline at end of file +**/**/meet-pro +**/**/test_localstorage_state.json diff --git a/meet-ce/backend/.env.test b/meet-ce/backend/.env.test index ebe5cf4..65483ea 100644 --- a/meet-ce/backend/.env.test +++ b/meet-ce/backend/.env.test @@ -1,4 +1,6 @@ USE_HTTPS=false MEET_LOG_LEVEL=verbose SERVER_CORS_ORIGIN=* -MEET_INITIAL_API_KEY=meet-api-key \ No newline at end of file +MEET_INITIAL_API_KEY=meet-api-key +MEET_INITIAL_WEBHOOK_ENABLED=true +MEET_INITIAL_WEBHOOK_URL=http://localhost:5080/webhook \ No newline at end of file diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.html index be32a9e..bde13da 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.html @@ -21,19 +21,25 @@ } - - - - + @if (shouldShowActions()) { + + @if (shouldShowCancelButton()) { + + } + @if (shouldShowConfirmButton()) { + + } + + } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.ts index e10d7a7..3388027 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/dialogs/basic-dialog/dialog.component.ts @@ -46,4 +46,16 @@ export class DialogComponent { this.data.cancelCallback(); } } + + shouldShowActions(): boolean { + return this.data.showActions !== false; + } + + shouldShowConfirmButton(): boolean { + return this.data.showConfirmButton !== false; + } + + shouldShowCancelButton(): boolean { + return this.data.showCancelButton !== false; + } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/index.ts index ae9e84d..515f83d 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/index.ts @@ -1,6 +1,7 @@ export * from './console-nav/console-nav.component'; export * from './dialogs/basic-dialog/dialog.component'; export * from './dialogs/share-recording-dialog/share-recording-dialog.component'; +export * from './dialogs/delete-room-dialog/delete-room-dialog.component'; export * from './logo-selector/logo-selector.component'; export * from './pro-feature-badge/pro-feature-badge.component'; export * from './recording-lists/recording-lists.component'; @@ -12,6 +13,18 @@ export * from './step-indicator/step-indicator.component'; export * from './wizard-nav/wizard-nav.component'; export * from './share-meeting-link/share-meeting-link.component'; -export * from './dialogs/basic-dialog/dialog.component'; -export * from './dialogs/share-recording-dialog/share-recording-dialog.component'; -export * from './dialogs/delete-room-dialog/delete-room-dialog.component'; +// Meeting modular components +export * from './meeting-toolbar-buttons/meeting-toolbar-buttons.component'; +export * from './meeting-participant-panel/meeting-participant-panel.component'; +export * from './meeting-share-link-panel/meeting-share-link-panel.component'; +export * from './meeting-share-link-overlay/meeting-share-link-overlay.component'; +export * from './meeting-lobby/meeting-lobby.component'; + + +// Meeting components +export * from './meeting-toolbar-buttons/meeting-toolbar-buttons.component'; +export * from './meeting-participant-panel/meeting-participant-panel.component'; +export * from './meeting-share-link-panel/meeting-share-link-panel.component'; +export * from './meeting-share-link-overlay/meeting-share-link-overlay.component'; +export * from './meeting-lobby/meeting-lobby.component'; + diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.html new file mode 100644 index 0000000..3531f4e --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.html @@ -0,0 +1,116 @@ +
+
+ +
+ video_chat +
+

{{ roomName }}

+
+
+ + +
+ + + + {{ roomClosed ? 'lock' : 'meeting_room' }} +
+ {{ roomClosed ? 'Room Closed' : 'Join Meeting' }} + {{ + roomClosed + ? 'This room is not available for meetings' + : 'Enter the room and start connecting' + }} +
+
+ + + @if (!roomClosed) { +
+ + Your display name + + person + @if (participantForm.get('name')?.hasError('required')) { + The name is required + } + + + +
+ } @else { +
+ warning +

+ Sorry, this room is closed. You cannot join at this time. Please contact the meeting + organizer for more information. +

+
+ } +
+
+ + + @if (showRecordingsCard) { + + + video_library +
+ View Recordings + Browse and manage past recordings +
+
+ + +
+

+ Access previously recorded meetings from this room. You can watch, download, or manage + existing recordings. +

+
+ + +
+
+ } +
+ + + @if (!roomClosed && showShareLink) { + + } + + + @if (showBackButton) { +
+ +
+ } +
+
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.scss new file mode 100644 index 0000000..8d9588d --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.scss @@ -0,0 +1,276 @@ +@use '../../../../../../src/assets/styles/design-tokens'; + +// Room Access Container - Main layout using design tokens +.room-access-container { + @include design-tokens.ov-container; + @include design-tokens.ov-page-content; + padding-top: var(--ov-meet-spacing-xxl); + background: var(--ov-meet-background-color); + gap: 0; +} + +// Room Header - Clean title section +.room-header { + @include design-tokens.ov-flex-center; + flex-direction: column; + gap: var(--ov-meet-spacing-md); + margin-bottom: var(--ov-meet-spacing-xxl); + text-align: center; + + .room-icon { + @include design-tokens.ov-icon(xl); + color: var(--ov-meet-icon-rooms); + margin-bottom: var(--ov-meet-spacing-sm); + } + + .room-info { + .room-title { + margin: 0; + font-size: var(--ov-meet-font-size-hero); + font-weight: var(--ov-meet-font-weight-light); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + } +} + +// Action Cards Grid - Responsive layout +.action-cards-grid { + @include design-tokens.ov-grid-responsive(320px); + gap: var(--ov-meet-spacing-xl); + margin-bottom: var(--ov-meet-spacing-xxl); + justify-content: center; + + // When there's only one card, limit its width to maintain visual consistency + &:has(.action-card:only-child) { + display: flex; + justify-content: center; + + .action-card { + max-width: 400px; + width: 100%; + } + } + + @include design-tokens.ov-tablet-down { + grid-template-columns: 1fr; + gap: var(--ov-meet-spacing-lg); + + // On tablets and mobile, single cards should use full width + &:has(.action-card:only-child) { + .action-card { + max-width: none; + } + } + } +} + +// Action Card Base - Consistent card styling +.action-card { + @include design-tokens.ov-card; + @include design-tokens.ov-hover-lift(-4px); + @include design-tokens.ov-theme-transition; + padding: 0; + overflow: hidden; + min-height: 300px; + display: flex; + flex-direction: column; + + // Card Header + .card-header { + padding: var(--ov-meet-spacing-lg); + border-bottom: 1px solid var(--ov-meet-border-color-light); + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-md); + flex-shrink: 0; + + .card-icon { + @include design-tokens.ov-icon(lg); + flex-shrink: 0; + } + + .card-title-group { + flex: 1; + + .mat-mdc-card-title { + margin: 0; + font-size: var(--ov-meet-font-size-xl); + font-weight: var(--ov-meet-font-weight-semibold); + color: var(--ov-meet-text-primary); + line-height: var(--ov-meet-line-height-tight); + } + + .mat-mdc-card-subtitle { + margin: var(--ov-meet-spacing-xs) 0 0 0; + font-size: var(--ov-meet-font-size-sm); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-normal); + } + } + } + + // Card Content + .card-content { + padding: var(--ov-meet-spacing-lg); + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + } +} + +// Primary Card - Join meeting styling +.primary-card { + .card-header { + background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-primary-light) 180%); + color: var(--ov-meet-text-on-primary); + } + + &.room-closed-card { + .card-header { + background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-warning) 180%); + + .mat-icon { + color: var(--ov-meet-color-warning) !important; + } + } + } +} + +.room-closed-message { + @include design-tokens.ov-flex-center; + flex-direction: column; + gap: var(--ov-meet-spacing-md); + text-align: center; + + .warning-icon { + @include design-tokens.ov-icon(xl); + color: var(--ov-meet-color-warning); + } + + p { + margin: 0; + font-size: var(--ov-meet-font-size-md); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-relaxed); + } +} + +// Secondary Card - Recordings styling +.secondary-card { + .card-header { + background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-accent) 180%); + } + + .card-content { + text-align: center; + } +} + +// Join Form - Form styling +.join-form { + display: flex; + flex-direction: column; + gap: var(--ov-meet-spacing-lg); + flex: 1; + + .name-field { + width: 100%; + + .mat-mdc-form-field-icon-suffix { + color: var(--ov-meet-text-hint); + } + } + + .join-button { + @include design-tokens.ov-button-base; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + gap: var(--ov-meet-spacing-sm); + margin-top: auto; + background-color: var(--ov-meet-color-secondary); + color: var(--ov-meet-text-on-secondary); + } +} + +// Recordings Info - Content for recordings card +.recordings-info { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--ov-meet-spacing-lg); + + .recordings-description { + margin: 0; + font-size: var(--ov-meet-font-size-md); + color: var(--ov-meet-text-secondary); + line-height: var(--ov-meet-line-height-relaxed); + } +} + +.recordings-button { + @include design-tokens.ov-button-base; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + gap: var(--ov-meet-spacing-sm); + margin-top: auto; +} + +// Quick Actions - Footer actions +.quick-actions { + @include design-tokens.ov-flex-center; + margin-top: var(--ov-meet-spacing-xl); + + .quick-action-button { + display: flex; + align-items: center; + gap: var(--ov-meet-spacing-sm); + color: var(--ov-meet-text-secondary); + @include design-tokens.ov-theme-transition; + + &:hover { + color: var(--ov-meet-text-primary); + background-color: var(--ov-meet-surface-hover); + } + } +} + +// Responsive adjustments +@include design-tokens.ov-mobile-down { + .room-access-container { + padding: 0; + padding-top: var(--ov-meet-spacing-sm); + margin-bottom: var(--ov-meet-spacing-xxl); + } + + .room-header { + margin-bottom: var(--ov-meet-spacing-xl); + + .room-info .room-title { + font-size: var(--ov-meet-font-size-xxl); + } + } + + .action-card { + min-height: auto; + + .card-header { + padding: var(--ov-meet-spacing-md); + + .card-title-group { + .mat-mdc-card-title { + font-size: var(--ov-meet-font-size-lg); + } + } + } + + .card-content { + padding: var(--ov-meet-spacing-md); + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.ts new file mode 100644 index 0000000..6308a19 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-lobby/meeting-lobby.component.ts @@ -0,0 +1,144 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { ShareMeetingLinkComponent } from '../share-meeting-link/share-meeting-link.component'; + +/** + * Reusable component for the meeting lobby page. + * Displays the form to join the meeting and optional recordings card. + */ +@Component({ + selector: 'ov-meeting-lobby', + templateUrl: './meeting-lobby.component.html', + styleUrls: ['./meeting-lobby.component.scss'], + imports: [ + CommonModule, + MatFormFieldModule, + MatInputModule, + FormsModule, + ReactiveFormsModule, + MatCardModule, + MatButtonModule, + MatIconModule, + ShareMeetingLinkComponent + ] +}) +export class MeetingLobbyComponent { + /** + * The room name to display + */ + @Input({ required: true }) roomName = ''; + + /** + * The meeting URL to share + */ + @Input() meetingUrl = ''; + + /** + * Whether the room is closed + */ + @Input() roomClosed = false; + + /** + * Whether to show the recordings card + */ + @Input() showRecordingsCard = false; + + /** + * Whether to show the share meeting link component + */ + @Input() showShareLink = false; + + /** + * Whether to show the back button + */ + @Input() showBackButton = false; + + /** + * Back button text + */ + @Input() backButtonText = 'Back'; + + /** + * The participant form group + */ + @Input({ required: true }) participantForm!: FormGroup; + + /** + * Emitted when the form is submitted + */ + @Output() formSubmitted = new EventEmitter(); + + /** + * Emitted when the view recordings button is clicked + */ + @Output() viewRecordingsClicked = new EventEmitter(); + + /** + * Emitted when the back button is clicked + */ + @Output() backClicked = new EventEmitter(); + + /** + * Emitted when the copy link button is clicked + */ + @Output() copyLinkClicked = new EventEmitter(); + + /** + * Alternative to @Output: Function to call when form is submitted + * When using NgComponentOutlet, use this instead of the @Output above + */ + @Input() formSubmittedFn?: () => void; + + /** + * Alternative to @Output: Function to call when view recordings is clicked + */ + @Input() viewRecordingsClickedFn?: () => void; + + /** + * Alternative to @Output: Function to call when back button is clicked + */ + @Input() backClickedFn?: () => void; + + /** + * Alternative to @Output: Function to call when copy link is clicked + */ + @Input() copyLinkClickedFn?: () => void; + + onFormSubmit(): void { + if (this.formSubmittedFn) { + this.formSubmittedFn(); + } else { + this.formSubmitted.emit(); + } + } + + onViewRecordingsClick(): void { + if (this.viewRecordingsClickedFn) { + this.viewRecordingsClickedFn(); + } else { + this.viewRecordingsClicked.emit(); + } + } + + onBackClick(): void { + if (this.backClickedFn) { + this.backClickedFn(); + } else { + this.backClicked.emit(); + } + } + + onCopyLinkClick(): void { + if (this.copyLinkClickedFn) { + this.copyLinkClickedFn(); + } else { + this.copyLinkClicked.emit(); + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-participant-panel/meeting-participant-panel.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-participant-panel/meeting-participant-panel.component.html new file mode 100644 index 0000000..a68aaaa --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-participant-panel/meeting-participant-panel.component.html @@ -0,0 +1,62 @@ +
+ + + + @if (showModeratorBadge) { + + + shield_person + + + } + + + + @if (showModerationControls) { +
+ + @if (showMakeModerator) { + + } + + + @if (showUnmakeModerator) { + + } + + + @if (showKickButton) { + + } +
+ } +
+
diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-participant-panel/meeting-participant-panel.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-participant-panel/meeting-participant-panel.component.scss new file mode 100644 index 0000000..3773a54 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-participant-panel/meeting-participant-panel.component.scss @@ -0,0 +1,27 @@ +@use '../../../../../../src/assets/styles/design-tokens'; + +.participant-item-container { + width: 100%; + align-items: center; + + ::ng-deep .participant-container { + padding: 2px 10px !important; + } + + .moderator-badge { + color: var(--ov-meet-color-warning); + + mat-icon { + vertical-align: bottom; + } + } +} + +.force-disconnect-btn, +.remove-moderator-btn { + color: var(--ov-meet-color-error); +} + +.make-moderator-btn { + color: var(--ov-meet-color-warning); +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-participant-panel/meeting-participant-panel.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-participant-panel/meeting-participant-panel.component.ts new file mode 100644 index 0000000..e0bdfe3 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-participant-panel/meeting-participant-panel.component.ts @@ -0,0 +1,128 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { OpenViduComponentsUiModule } from 'openvidu-components-angular'; + +/** + * Reusable component for displaying participant panel items with moderation controls. + * This component is agnostic and configurable via inputs. + */ +@Component({ + selector: 'ov-meeting-participant-panel', + templateUrl: './meeting-participant-panel.component.html', + styleUrls: ['./meeting-participant-panel.component.scss'], + imports: [CommonModule, MatButtonModule, MatIconModule, MatTooltipModule, OpenViduComponentsUiModule] +}) +export class MeetingParticipantPanelComponent { + /** + * The participant to display + */ + @Input({ required: true }) participant: any; + + /** + * All participants in the meeting (used for determining moderation controls) + */ + @Input() allParticipants: any[] = []; + + /** + * Whether to show the moderator badge + */ + @Input() showModeratorBadge = false; + + /** + * Whether to show moderation controls (make/unmake moderator, kick) + */ + @Input() showModerationControls = false; + + /** + * Whether to show the "make moderator" button + */ + @Input() showMakeModerator = false; + + /** + * Whether to show the "unmake moderator" button + */ + @Input() showUnmakeModerator = false; + + /** + * Whether to show the "kick participant" button + */ + @Input() showKickButton = false; + + /** + * Moderator badge tooltip text + */ + @Input() moderatorBadgeTooltip = 'Moderator'; + + /** + * Make moderator button tooltip text + */ + @Input() makeModeratorTooltip = 'Make participant moderator'; + + /** + * Unmake moderator button tooltip text + */ + @Input() unmakeModeratorTooltip = 'Unmake participant moderator'; + + /** + * Kick participant button tooltip text + */ + @Input() kickParticipantTooltip = 'Kick participant'; + + /** + * Emitted when the make moderator button is clicked + */ + @Output() makeModeratorClicked = new EventEmitter(); + + /** + * Emitted when the unmake moderator button is clicked + */ + @Output() unmakeModeratorClicked = new EventEmitter(); + + /** + * Emitted when the kick participant button is clicked + */ + @Output() kickParticipantClicked = new EventEmitter(); + + /** + * Alternative to @Output: Function to call when make moderator is clicked + * When using NgComponentOutlet, use this instead of the @Output above + */ + @Input() makeModeratorClickedFn?: () => void; + + /** + * Alternative to @Output: Function to call when unmake moderator is clicked + */ + @Input() unmakeModeratorClickedFn?: () => void; + + /** + * Alternative to @Output: Function to call when kick participant is clicked + */ + @Input() kickParticipantClickedFn?: () => void; + + onMakeModeratorClick(): void { + if (this.makeModeratorClickedFn) { + this.makeModeratorClickedFn(); + } else { + this.makeModeratorClicked.emit(this.participant); + } + } + + onUnmakeModeratorClick(): void { + if (this.unmakeModeratorClickedFn) { + this.unmakeModeratorClickedFn(); + } else { + this.unmakeModeratorClicked.emit(this.participant); + } + } + + onKickParticipantClick(): void { + if (this.kickParticipantClickedFn) { + this.kickParticipantClickedFn(); + } else { + this.kickParticipantClicked.emit(this.participant); + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-overlay/meeting-share-link-overlay.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-overlay/meeting-share-link-overlay.component.html new file mode 100644 index 0000000..a112e90 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-overlay/meeting-share-link-overlay.component.html @@ -0,0 +1,13 @@ +@if (showOverlay) { + +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-overlay/meeting-share-link-overlay.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-overlay/meeting-share-link-overlay.component.scss new file mode 100644 index 0000000..66a58a9 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-overlay/meeting-share-link-overlay.component.scss @@ -0,0 +1,34 @@ +@use '../../../../../../src/assets/styles/design-tokens'; + +.main-share-meeting-link-container { + background-color: var(--ov-surface-color); // Use ov-components variable + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--ov-meet-radius-md); + width: 100%; + height: 100%; + position: absolute; + top: 0; + left: 0; + pointer-events: none; + z-index: 1; + + .main-share-meeting-link { + pointer-events: all; + max-width: 100%; + } +} + +.fade-in-delayed-more { + animation: fadeIn 0.5s ease-in 0.9s both; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-overlay/meeting-share-link-overlay.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-overlay/meeting-share-link-overlay.component.ts new file mode 100644 index 0000000..fae229a --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-overlay/meeting-share-link-overlay.component.ts @@ -0,0 +1,64 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ShareMeetingLinkComponent } from '../share-meeting-link/share-meeting-link.component'; + +/** + * Reusable component for displaying the share meeting link overlay + * when there are no remote participants in the meeting. + */ +@Component({ + selector: 'ov-meeting-share-link-overlay', + templateUrl: './meeting-share-link-overlay.component.html', + styleUrls: ['./meeting-share-link-overlay.component.scss'], + imports: [CommonModule, ShareMeetingLinkComponent] +}) +export class MeetingShareLinkOverlayComponent { + /** + * Controls whether the overlay should be shown + */ + @Input() showOverlay = true; + + /** + * The meeting URL to share + */ + @Input({ required: true }) meetingUrl = ''; + + /** + * Title text for the overlay + */ + @Input() title = 'Start collaborating'; + + /** + * Subtitle text for the overlay + */ + @Input() subtitle = 'Share this link to bring others into the meeting'; + + /** + * Title size (sm, md, lg, xl) + */ + @Input() titleSize: 'sm' | 'md' | 'lg' | 'xl' = 'xl'; + + /** + * Title weight (normal, bold) + */ + @Input() titleWeight: 'normal' | 'bold' = 'bold'; + + /** + * Emitted when the copy button is clicked + */ + @Output() copyClicked = new EventEmitter(); + + /** + * Alternative to @Output: Function to call when copy button is clicked + * When using NgComponentOutlet, use this instead of the @Output above + */ + @Input() copyClickedFn?: () => void; + + onCopyClicked(): void { + if (this.copyClickedFn) { + this.copyClickedFn(); + } else { + this.copyClicked.emit(); + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-panel/meeting-share-link-panel.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-panel/meeting-share-link-panel.component.html new file mode 100644 index 0000000..6835466 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-panel/meeting-share-link-panel.component.html @@ -0,0 +1,5 @@ +@if (showShareLink) { + +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-panel/meeting-share-link-panel.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-panel/meeting-share-link-panel.component.scss new file mode 100644 index 0000000..f9fa98f --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-panel/meeting-share-link-panel.component.scss @@ -0,0 +1,3 @@ +.share-meeting-link-container { + padding: 10px; +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-panel/meeting-share-link-panel.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-panel/meeting-share-link-panel.component.ts new file mode 100644 index 0000000..f235bb9 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-share-link-panel/meeting-share-link-panel.component.ts @@ -0,0 +1,44 @@ +import { CommonModule } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { ShareMeetingLinkComponent } from '../share-meeting-link/share-meeting-link.component'; + +/** + * Reusable component for displaying the share meeting link panel + * inside the participants panel. + */ +@Component({ + selector: 'ov-meeting-share-link-panel', + templateUrl: './meeting-share-link-panel.component.html', + styleUrls: ['./meeting-share-link-panel.component.scss'], + imports: [CommonModule, ShareMeetingLinkComponent] +}) +export class MeetingShareLinkPanelComponent { + /** + * Controls whether the share link panel should be shown + */ + @Input() showShareLink = true; + + /** + * The meeting URL to share + */ + @Input({ required: true }) meetingUrl = ''; + + /** + * Emitted when the copy button is clicked + */ + @Output() copyClicked = new EventEmitter(); + + /** + * Alternative to @Output: Function to call when copy button is clicked + * When using NgComponentOutlet, use this instead of the @Output above + */ + @Input() copyClickedFn?: () => void; + + onCopyClicked(): void { + if (this.copyClickedFn) { + this.copyClickedFn(); + } else { + this.copyClicked.emit(); + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-toolbar-buttons/meeting-toolbar-buttons.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-toolbar-buttons/meeting-toolbar-buttons.component.html new file mode 100644 index 0000000..975af44 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-toolbar-buttons/meeting-toolbar-buttons.component.html @@ -0,0 +1,45 @@ + +@if (showCopyLinkButton) { + @if (isMobile) { + + } @else { + + } +} + + +@if (showLeaveMenu) { + + + + + + +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-toolbar-buttons/meeting-toolbar-buttons.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-toolbar-buttons/meeting-toolbar-buttons.component.scss new file mode 100644 index 0000000..a75cd2b --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-toolbar-buttons/meeting-toolbar-buttons.component.scss @@ -0,0 +1,17 @@ +@use '../../../../../../src/assets/styles/design-tokens'; + +.button-text { + margin-left: 8px; +} + +// Global styling for leave button when in toolbar +::ng-deep { + #media-buttons-container .custom-leave-btn { + text-align: center; + background-color: var(--ov-meet-color-error) !important; + color: #fff !important; + border-radius: var(--ov-meet-radius-md) !important; + width: 65px; + margin: 6px !important; + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-toolbar-buttons/meeting-toolbar-buttons.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-toolbar-buttons/meeting-toolbar-buttons.component.ts new file mode 100644 index 0000000..2faa1f2 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/components/meeting-toolbar-buttons/meeting-toolbar-buttons.component.ts @@ -0,0 +1,116 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +/** + * Reusable component for meeting toolbar additional buttons. + * This component is agnostic and can be configured via inputs. + */ +@Component({ + selector: 'ov-meeting-toolbar-buttons', + templateUrl: './meeting-toolbar-buttons.component.html', + styleUrls: ['./meeting-toolbar-buttons.component.scss'], + imports: [CommonModule, MatButtonModule, MatIconModule, MatMenuModule, MatTooltipModule, MatDividerModule] +}) +export class MeetingToolbarButtonsComponent { + /** + * Whether to show the copy link button + */ + @Input() showCopyLinkButton = false; + + /** + * Whether to show the leave menu with options + */ + @Input() showLeaveMenu = false; + + /** + * Whether the device is mobile (affects button style) + */ + @Input() isMobile = false; + + /** + * Copy link button tooltip text + */ + @Input() copyLinkTooltip = 'Copy the meeting link'; + + /** + * Copy link button text (for mobile) + */ + @Input() copyLinkText = 'Copy meeting link'; + + /** + * Leave menu tooltip text + */ + @Input() leaveMenuTooltip = 'Leave options'; + + /** + * Leave option text + */ + @Input() leaveOptionText = 'Leave meeting'; + + /** + * End meeting option text + */ + @Input() endMeetingOptionText = 'End meeting for all'; + + /** + * Emitted when the copy link button is clicked + */ + @Output() copyLinkClicked = new EventEmitter(); + + /** + * Emitted when the leave meeting option is clicked + */ + @Output() leaveMeetingClicked = new EventEmitter(); + + /** + * Emitted when the end meeting option is clicked + */ + @Output() endMeetingClicked = new EventEmitter(); + + /** + * Alternative to @Output: Function to call when copy link button is clicked + * When using NgComponentOutlet, use this instead of the @Output above + */ + @Input() copyLinkClickedFn?: () => void; + + /** + * Alternative to @Output: Function to call when leave meeting is clicked + * When using NgComponentOutlet, use this instead of the @Output above + */ + @Input() leaveMeetingClickedFn?: () => Promise; + + /** + * Alternative to @Output: Function to call when end meeting is clicked + * When using NgComponentOutlet, use this instead of the @Output above + */ + @Input() endMeetingClickedFn?: () => Promise; + + onCopyLinkClick(): void { + if (this.copyLinkClickedFn) { + this.copyLinkClickedFn(); + } else { + this.copyLinkClicked.emit(); + } + } + + async onLeaveMeetingClick(): Promise { + if (this.leaveMeetingClickedFn) { + await this.leaveMeetingClickedFn(); + } else { + this.leaveMeetingClicked.emit(); + } + } + + async onEndMeetingClick(): Promise { + if (this.endMeetingClickedFn) { + await this.endMeetingClickedFn(); + } else { + this.endMeetingClicked.emit(); + } + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-components-plugins.token.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-components-plugins.token.ts new file mode 100644 index 0000000..233c739 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/components/meeting-components-plugins.token.ts @@ -0,0 +1,56 @@ +import { InjectionToken, Type } from '@angular/core'; + +/** + * Interface for registering custom components to be used in the meeting view. + * Each property represents a slot where a custom component can be injected. + */ +export interface MeetingComponentsPlugins { + /** + * Toolbar-related plugin components + */ + toolbar?: { + /** + * Additional buttons to show in the toolbar (e.g., copy link, settings) + */ + additionalButtons?: Type; + /** + * Custom leave button component (only shown for moderators) + */ + leaveButton?: Type; + }; + + /** + * Participant panel-related plugin components + */ + participantPanel?: { + /** + * Custom component to render each participant item in the panel + */ + item?: Type; + /** + * Component to show after the local participant in the panel + */ + afterLocalParticipant?: Type; + }; + + /** + * Layout-related plugin components + */ + layout?: { + /** + * Additional elements to show in the main layout (e.g., overlays, banners) + */ + additionalElements?: Type; + }; + + /** + * Lobby-related plugin components + */ + lobby?: Type; +} + +/** + * Injection token for registering meeting plugins. + * Apps (CE/PRO) should provide their custom components using this token. + */ +export const MEETING_COMPONENTS_TOKEN = new InjectionToken('MEETING_COMPONENTS_TOKEN'); diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/handlers/meeting-action-handler.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/handlers/meeting-action-handler.ts new file mode 100644 index 0000000..de3cbfc --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/handlers/meeting-action-handler.ts @@ -0,0 +1,89 @@ +import { InjectionToken } from '@angular/core'; +import { CustomParticipantModel } from '../../models'; + +/** + * Interface defining the controls to show for a participant in the participant panel. + */ +export interface ParticipantControls { + /** + * Whether to show the moderator badge + */ + showModeratorBadge: boolean; + + /** + * Whether to show moderation controls (make/unmake moderator, kick) + */ + showModerationControls: boolean; + + /** + * Whether to show the "Make Moderator" button + */ + showMakeModerator: boolean; + + /** + * Whether to show the "Remove Moderator" button + */ + showUnmakeModerator: boolean; + + /** + * Whether to show the "Kick" button + */ + showKickButton: boolean; +} + +/** + * Abstract class defining the actions that can be performed in a meeting. + * Apps (CE/PRO) must extend this class and provide their implementation. + */ +export abstract class MeetingActionHandler { + /** + * Room ID - will be set by MeetingComponent + */ + roomId = ''; + + /** + * Room secret - will be set by MeetingComponent + */ + roomSecret = ''; + + /** + * Local participant - will be set by MeetingComponent + */ + localParticipant?: CustomParticipantModel; + + /** + * Kicks a participant from the meeting + */ + abstract kickParticipant(participant: CustomParticipantModel): Promise; + + /** + * Makes a participant a moderator + */ + abstract makeModerator(participant: CustomParticipantModel): Promise; + + /** + * Removes moderator role from a participant + */ + abstract unmakeModerator(participant: CustomParticipantModel): Promise; + + /** + * Copies the moderator link to clipboard + */ + abstract copyModeratorLink(): Promise; + + /** + * Copies the speaker link to clipboard + */ + abstract copySpeakerLink(): Promise; + + /** + * Gets the controls to show for a participant based on permissions and roles + */ + abstract getParticipantControls(participant: CustomParticipantModel): ParticipantControls; +} + +/** + * Injection token for the meeting action handler. + * Apps (CE/PRO) should provide their implementation using this token. + */ +export const MEETING_ACTION_HANDLER_TOKEN = new InjectionToken('MEETING_ACTION_HANDLER_TOKEN'); diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/index.ts new file mode 100644 index 0000000..a025182 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/customization/index.ts @@ -0,0 +1,5 @@ +/** + * Index file for customization exports + */ +export * from './components/meeting-components-plugins.token'; +export * from './handlers/meeting-action-handler'; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/models/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/index.ts index 0a08fe8..bc1f6b4 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/models/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/index.ts @@ -4,3 +4,4 @@ export * from './navigation.model'; export * from './notification.model'; export * from './sidenav.model'; export * from './wizard.model'; +export * from './lobby.model'; \ No newline at end of file diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/models/lobby.model.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/lobby.model.ts new file mode 100644 index 0000000..bb56c54 --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/lobby.model.ts @@ -0,0 +1,18 @@ +import { FormGroup } from '@angular/forms'; +import { MeetRoom } from '@openvidu-meet/typings'; + +/** + * State interface representing the lobby state of a meeting + */ +export interface LobbyState { + room?: MeetRoom; + roomId: string; + roomSecret: string; + roomClosed: boolean; + hasRecordings: boolean; + showRecordingCard: boolean; + showBackButton: boolean; + backButtonText: string; + participantForm: FormGroup; + participantToken: string; +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/models/notification.model.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/notification.model.ts index b4554fb..ac76f7d 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/models/notification.model.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/models/notification.model.ts @@ -13,6 +13,10 @@ export interface DialogOptions { forceCheckboxText?: string; forceCheckboxDescription?: string; forceConfirmCallback?: () => void; + // Action buttons visibility + showConfirmButton?: boolean; + showCancelButton?: boolean; + showActions?: boolean; } export interface DeleteRoomDialogOptions { diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html index 3bdef24..293a059 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.html @@ -1,4 +1,19 @@ -@if (showMeeting) { +@if (showPrejoin) { + + @if (prejoinReady && plugins.lobby) { + + } @else if (!prejoinReady) { +
+ +

Preparing your meeting...

+
+ } @else { +
+ error_outline +

Unable to load the pre-join screen. Please try reloading the page.

+
+ } +} @else { - - - @if (features().canModerateRoom) { - @if (isMobile) { - - } @else { - + + @if (plugins.toolbar?.additionalButtons) { + + + + } + + + @if (plugins.toolbar?.leaveButton) { + + + + } + + + @if (plugins.participantPanel?.afterLocalParticipant) { + + + + } + + + @if (plugins.layout?.additionalElements) { + + @if (onlyModeratorIsPresent) { + } - } - + + } - - @if (features().canModerateRoom) { - - - - - - - - } - - - - @if (features().canModerateRoom) { - - } - - - - @if (features().canModerateRoom && remoteParticipants.length === 0) { - - } - - - - - @if (features().canModerateRoom) { -
- - @if (participant.isLocal) { - - - - - shield_person - - - - - } @else { - - - @if (participant.isModerator()) { - - - - shield_person - - - - } -
- - @if (localParticipant!.isOriginalModerator()) { - @if (participant.isModerator() && !participant.isOriginalModerator()) { - - } @else { - @if (!participant.isModerator()) { - - } - } - } @else { - @if (!participant.isModerator()) { - - } - } - - - @if (!participant.isOriginalModerator()) { - - } -
-
- } -
- } @else { - -
- - @if (participant.isModerator()) { - - - - shield_person - - - - } - -
- } -
+ + @if (plugins.participantPanel?.item) { + + + + }
-} @else { - -
-
- -
- video_chat -
-

{{ roomName }}

-
-
- - -
- - - - {{ roomClosed ? 'lock' : 'meeting_room' }} -
- {{ roomClosed ? 'Room Closed' : 'Join Meeting' }} - {{ - roomClosed - ? 'This room is not available for meetings' - : 'Enter the room and start connecting' - }} -
-
- - - @if (!roomClosed) { -
- - Your display name - - person - @if (participantForm.get('name')?.hasError('required')) { - The name is required - } - - - -
- } @else { -
- warning -

- Sorry, this room is closed. You cannot join at this time. Please contact the meeting - organizer for more information. -

-
- } -
-
- - - @if (showRecordingCard) { - - - video_library -
- View Recordings - Browse and manage past recordings -
-
- - -
-

- Access previously recorded meetings from this room. You can watch, download, or - manage existing recordings. -

-
- - -
-
- } -
- - - @if (!roomClosed && features().canModerateRoom) { - - } - - - @if (showBackButton) { -
- -
- } -
-
} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.scss b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.scss index 97ebf04..b868940 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.scss +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.scss @@ -1,327 +1,9 @@ @use '../../../../../../src/assets/styles/design-tokens'; -// Room Access Container - Main layout using design tokens -.room-access-container { - @include design-tokens.ov-container; - @include design-tokens.ov-page-content; - padding-top: var(--ov-meet-spacing-xxl); - background: var(--ov-meet-background-color); - gap: 0; -} - -// Room Header - Clean title section -.room-header { - @include design-tokens.ov-flex-center; - flex-direction: column; - gap: var(--ov-meet-spacing-md); - margin-bottom: var(--ov-meet-spacing-xxl); - text-align: center; - - .room-icon { - @include design-tokens.ov-icon(xl); - color: var(--ov-meet-icon-rooms); - margin-bottom: var(--ov-meet-spacing-sm); - } - - .room-info { - .room-title { - margin: 0; - font-size: var(--ov-meet-font-size-hero); - font-weight: var(--ov-meet-font-weight-light); - color: var(--ov-meet-text-primary); - line-height: var(--ov-meet-line-height-tight); - } - } -} - -// Action Cards Grid - Responsive layout -.action-cards-grid { - @include design-tokens.ov-grid-responsive(320px); - gap: var(--ov-meet-spacing-xl); - margin-bottom: var(--ov-meet-spacing-xxl); +.prejoin-loading-container, +.prejoin-error-container { + display: flex; justify-content: center; - - // When there's only one card, limit its width to maintain visual consistency - &:has(.action-card:only-child) { - display: flex; - justify-content: center; - - .action-card { - max-width: 400px; - width: 100%; - } - } - - @include design-tokens.ov-tablet-down { - grid-template-columns: 1fr; - gap: var(--ov-meet-spacing-lg); - - // On tablets and mobile, single cards should use full width - &:has(.action-card:only-child) { - .action-card { - max-width: none; - } - } - } -} - -// Action Card Base - Consistent card styling -.action-card { - @include design-tokens.ov-card; - @include design-tokens.ov-hover-lift(-4px); - @include design-tokens.ov-theme-transition; - padding: 0; - overflow: hidden; - min-height: 300px; - display: flex; - flex-direction: column; - - // Card Header - .card-header { - padding: var(--ov-meet-spacing-lg); - border-bottom: 1px solid var(--ov-meet-border-color-light); - display: flex; - align-items: center; - gap: var(--ov-meet-spacing-md); - flex-shrink: 0; - - .card-icon { - @include design-tokens.ov-icon(lg); - flex-shrink: 0; - } - - .card-title-group { - flex: 1; - - .mat-mdc-card-title { - margin: 0; - font-size: var(--ov-meet-font-size-xl); - font-weight: var(--ov-meet-font-weight-semibold); - color: var(--ov-meet-text-primary); - line-height: var(--ov-meet-line-height-tight); - } - - .mat-mdc-card-subtitle { - margin: var(--ov-meet-spacing-xs) 0 0 0; - font-size: var(--ov-meet-font-size-sm); - color: var(--ov-meet-text-secondary); - line-height: var(--ov-meet-line-height-normal); - } - } - } - - // Card Content - .card-content { - padding: var(--ov-meet-spacing-lg); - flex: 1; - display: flex; - flex-direction: column; - justify-content: space-between; - } -} - -// Primary Card - Join meeting styling -.primary-card { - .card-header { - background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-primary-light) 180%); - color: var(--ov-meet-text-on-primary); - } - - &.room-closed-card { - .card-header { - background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-warning) 180%); - - .mat-icon { - color: var(--ov-meet-color-warning) !important; - } - } - } -} - -.room-closed-message { - @include design-tokens.ov-flex-center; - flex-direction: column; - gap: var(--ov-meet-spacing-md); - text-align: center; - - .warning-icon { - @include design-tokens.ov-icon(xl); - color: var(--ov-meet-color-warning); - } - - p { - margin: 0; - font-size: var(--ov-meet-font-size-md); - color: var(--ov-meet-text-secondary); - line-height: var(--ov-meet-line-height-relaxed); - } -} - -// Secondary Card - Recordings styling -.secondary-card { - .card-header { - background: linear-gradient(135deg, var(--ov-meet-surface-color) 0%, var(--ov-meet-color-accent) 180%); - } - - .card-content { - text-align: center; - } -} - -// Join Form - Form styling -.join-form { - display: flex; - flex-direction: column; - gap: var(--ov-meet-spacing-lg); - flex: 1; - - .name-field { - width: 100%; - - .mat-mdc-form-field-icon-suffix { - color: var(--ov-meet-text-hint); - } - } - - .join-button { - @include design-tokens.ov-button-base; - height: 56px; - display: flex; - align-items: center; - justify-content: center; - gap: var(--ov-meet-spacing-sm); - margin-top: auto; - background-color: var(--ov-meet-color-secondary); - color: var(--ov-meet-text-on-secondary); - } -} - -// Recordings Info - Content for recordings card -.recordings-info { - flex: 1; - display: flex; - flex-direction: column; - gap: var(--ov-meet-spacing-lg); - - .recordings-description { - margin: 0; - font-size: var(--ov-meet-font-size-md); - color: var(--ov-meet-text-secondary); - line-height: var(--ov-meet-line-height-relaxed); - } -} - -.recordings-button { - @include design-tokens.ov-button-base; - height: 56px; - display: flex; align-items: center; - justify-content: center; - gap: var(--ov-meet-spacing-sm); - margin-top: auto; -} - -// Quick Actions - Footer actions -.quick-actions { - @include design-tokens.ov-flex-center; - margin-top: var(--ov-meet-spacing-xl); - - .quick-action-button { - display: flex; - align-items: center; - gap: var(--ov-meet-spacing-sm); - color: var(--ov-meet-text-secondary); - @include design-tokens.ov-theme-transition; - - &:hover { - color: var(--ov-meet-text-primary); - background-color: var(--ov-meet-surface-hover); - } - } -} - -.share-meeting-link-container { - padding: 10px; -} - -.main-share-meeting-link-container { - background-color: var(--ov-surface-color); // Use ov-components variable - display: flex; - align-items: center; - justify-content: center; - border-radius: var(--ov-meet-radius-md); - - .main-share-meeting-link { - max-width: 100%; - } -} - -// Responsive adjustments -@include design-tokens.ov-mobile-down { - .room-access-container { - padding: 0; - padding-top: var(--ov-meet-spacing-sm); - margin-bottom: var(--ov-meet-spacing-xxl); - } - - .room-header { - margin-bottom: var(--ov-meet-spacing-xl); - - .room-info .room-title { - font-size: var(--ov-meet-font-size-xxl); - } - } - - .action-card { - min-height: auto; - - .card-header { - padding: var(--ov-meet-spacing-md); - - .card-title-group { - .mat-mdc-card-title { - font-size: var(--ov-meet-font-size-lg); - } - } - } - - .card-content { - padding: var(--ov-meet-spacing-md); - } - } -} - -// Custom leave button styling (existing functionality) -::ng-deep { - #media-buttons-container .custom-leave-btn { - text-align: center; - background-color: var(--ov-meet-color-error) !important; - color: #fff !important; - border-radius: var(--ov-meet-radius-md) !important; - width: 65px; - margin: 6px !important; - } -} - -.force-disconnect-btn, -.remove-moderator-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); - mat-icon { - vertical-align: bottom; - } - } + height: 100%; } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts index 9cb481f..187ee5b 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/pages/meeting/meeting.component.ts @@ -1,65 +1,36 @@ import { Clipboard } from '@angular/cdk/clipboard'; -import { CommonModule } from '@angular/common'; -import { Component, effect, OnInit, Signal } from '@angular/core'; -import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatButtonModule, MatIconButton } from '@angular/material/button'; -import { MatCardModule } from '@angular/material/card'; -import { MatRippleModule } from '@angular/material/core'; -import { MatDividerModule } from '@angular/material/divider'; -import { MatFormFieldModule } from '@angular/material/form-field'; -import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; -import { MatMenuModule } from '@angular/material/menu'; -import { MatTooltipModule } from '@angular/material/tooltip'; -import { ActivatedRoute } from '@angular/router'; -import { ShareMeetingLinkComponent } from '../../components'; -import { CustomParticipantModel, ErrorReason } from '../../models'; +import { CommonModule, NgComponentOutlet } from '@angular/common'; +import { Component, computed, effect, inject, OnInit, Signal, signal } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { CustomParticipantModel } from '../../models'; +import { MeetingComponentsPlugins, MEETING_COMPONENTS_TOKEN, MEETING_ACTION_HANDLER_TOKEN } from '../../customization'; import { - AppDataService, ApplicationFeatures, - AuthService, FeatureConfigurationService, GlobalConfigService, MeetingService, - NavigationService, NotificationService, ParticipantService, - RecordingService, - RoomService, - SessionStorageService, - TokenStorageService, - WebComponentManagerService + WebComponentManagerService, + MeetingEventHandlerService } from '../../services'; -import { - LeftEventReason, - MeetRoom, - MeetRoomStatus, - ParticipantRole, - WebComponentEvent, - WebComponentOutboundEventMessage, - MeetParticipantRoleUpdatedPayload, - MeetRoomConfigUpdatedPayload, - MeetSignalType -} from '@openvidu-meet/typings'; +import { MeetRoom, ParticipantRole } from '@openvidu-meet/typings'; import { ParticipantService as ComponentParticipantService, - DataPacket_Kind, OpenViduComponentsUiModule, OpenViduService, OpenViduThemeMode, OpenViduThemeService, - ParticipantLeftEvent, - ParticipantLeftReason, - ParticipantModel, - RecordingStartRequestedEvent, - RecordingStopRequestedEvent, - RemoteParticipant, Room, - RoomEvent, Track, ViewportService } from 'openvidu-components-angular'; import { combineLatest, Subject, takeUntil } from 'rxjs'; +import { MeetingLobbyService } from '../../services/meeting/meeting-lobby.service'; +import { MeetingPluginManagerService } from '../../services/meeting/meeting-plugin-manager.service'; +import { LobbyState } from '../../models/lobby.model'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; @Component({ selector: 'ov-meeting', @@ -67,71 +38,56 @@ import { combineLatest, Subject, takeUntil } from 'rxjs'; styleUrls: ['./meeting.component.scss'], imports: [ OpenViduComponentsUiModule, - // ApiDirectiveModule, CommonModule, - MatFormFieldModule, - MatInputModule, FormsModule, ReactiveFormsModule, - MatCardModule, - MatButtonModule, + NgComponentOutlet, MatIconModule, - MatIconButton, - MatMenuModule, - MatDividerModule, - MatTooltipModule, - MatRippleModule, - ShareMeetingLinkComponent - ] + MatProgressSpinnerModule + ], + providers: [MeetingLobbyService, MeetingPluginManagerService, MeetingEventHandlerService] }) export class MeetingComponent implements OnInit { - participantForm = new FormGroup({ - name: new FormControl('', [Validators.required]) - }); + lobbyState?: LobbyState; + protected localParticipant = signal(undefined); - hasRecordings = false; - showRecordingCard = false; - roomClosed = false; + // Reactive signal for remote participants to trigger computed updates + protected remoteParticipants = signal([]); - showBackButton = true; - backButtonText = 'Back'; + // Signal to track participant updates (role changes, etc.) that don't change array references + protected participantsVersion = signal(0); - room?: MeetRoom; - roomId = ''; - roomSecret = ''; - participantName = ''; - participantToken = ''; - localParticipant?: CustomParticipantModel; - remoteParticipants: CustomParticipantModel[] = []; - - showMeeting = false; + showPrejoin = true; + prejoinReady = false; features: Signal; - meetingEndedByMe = false; - private destroy$ = new Subject(); + // Injected plugins + plugins: MeetingComponentsPlugins; - constructor( - protected route: ActivatedRoute, - protected roomService: RoomService, - protected meetingService: MeetingService, - protected participantService: ParticipantService, - protected recordingService: RecordingService, - protected featureConfService: FeatureConfigurationService, - protected authService: AuthService, - protected appDataService: AppDataService, - protected sessionStorageService: SessionStorageService, - protected wcManagerService: WebComponentManagerService, - protected openviduService: OpenViduService, - protected ovComponentsParticipantService: ComponentParticipantService, - protected navigationService: NavigationService, - protected notificationService: NotificationService, - protected clipboard: Clipboard, - protected viewportService: ViewportService, - protected ovThemeService: OpenViduThemeService, - protected configService: GlobalConfigService, - protected tokenStorageService: TokenStorageService - ) { + protected meetingService = inject(MeetingService); + protected participantService = inject(ParticipantService); + protected featureConfService = inject(FeatureConfigurationService); + protected wcManagerService = inject(WebComponentManagerService); + protected openviduService = inject(OpenViduService); + protected ovComponentsParticipantService = inject(ComponentParticipantService); + protected viewportService = inject(ViewportService); + protected ovThemeService = inject(OpenViduThemeService); + protected configService = inject(GlobalConfigService); + protected clipboard = inject(Clipboard); + protected notificationService = inject(NotificationService); + protected lobbyService = inject(MeetingLobbyService); + protected pluginManager = inject(MeetingPluginManagerService); + + // Public for direct template binding (uses arrow functions to preserve 'this' context) + public eventHandler = inject(MeetingEventHandlerService); + + // Injected action handler (optional - falls back to default implementation) + protected actionHandler = inject(MEETING_ACTION_HANDLER_TOKEN, { optional: true }); + protected destroy$ = new Subject(); + + constructor() { this.features = this.featureConfService.features; + this.plugins = inject(MEETING_COMPONENTS_TOKEN, { optional: true }) || {}; // Change theme variables when custom theme is enabled effect(() => { @@ -151,8 +107,125 @@ export class MeetingComponent implements OnInit { }); } + // Computed signals for plugin inputs + protected toolbarAdditionalButtonsInputs = computed(() => + this.pluginManager.getToolbarAdditionalButtonsInputs(this.features().canModerateRoom, this.isMobile, () => + this.handleCopySpeakerLink() + ) + ); + + protected toolbarLeaveButtonInputs = computed(() => + this.pluginManager.getToolbarLeaveButtonInputs( + this.features().canModerateRoom, + this.isMobile, + () => this.openviduService.disconnectRoom(), + () => this.endMeeting() + ) + ); + + protected participantPanelAfterLocalInputs = computed(() => + this.pluginManager.getParticipantPanelAfterLocalInputs( + this.features().canModerateRoom, + `${this.hostname}/room/${this.roomId}`, + () => this.handleCopySpeakerLink() + ) + ); + + protected layoutAdditionalElementsInputs = computed(() => { + const showOverlay = this.onlyModeratorIsPresent; + return this.pluginManager.getLayoutAdditionalElementsInputs( + showOverlay, + `${this.hostname}/room/${this.roomId}`, + () => this.handleCopySpeakerLink() + ); + }); + + protected lobbyInputs = computed(() => { + if (!this.lobbyState) return {}; + return this.pluginManager.getLobbyInputs( + this.roomName, + `${this.hostname}/room/${this.roomId}`, + this.lobbyState.roomClosed, + this.lobbyState.showRecordingCard, + !this.lobbyState.roomClosed && this.features().canModerateRoom, + this.lobbyState.showBackButton, + this.lobbyState.backButtonText, + this.lobbyState.participantForm, + () => this.submitAccessMeeting(), + () => this.lobbyService.goToRecordings(), + () => this.lobbyService.goBack(), + () => this.handleCopySpeakerLink() + ); + }); + + protected participantPanelItemInputsMap = computed(() => { + const local = this.localParticipant(); + const remotes = this.remoteParticipants(); + // Force reactivity by reading participantsVersion signal + this.participantsVersion(); + const allParticipants: CustomParticipantModel[] = local ? [local, ...remotes] : remotes; + + const inputsMap = new Map(); + for (const participant of allParticipants) { + const inputs = this.pluginManager.getParticipantPanelItemInputs( + participant, + allParticipants, + (p) => this.handleMakeModerator(p), + (p) => this.handleUnmakeModerator(p), + (p) => this.handleKickParticipant(p) + ); + inputsMap.set(participant.identity, inputs); + } + + return inputsMap; + }); + + get participantName(): string { + return this.lobbyService.participantName; + } + + get participantToken(): string { + return this.lobbyState!.participantToken; + } + + get room(): MeetRoom | undefined { + return this.lobbyState?.room; + } + get roomName(): string { - return this.room?.roomName || 'Room'; + return this.lobbyState?.room?.roomName || 'Room'; + } + + get roomId(): string { + return this.lobbyState?.roomId || ''; + } + + get roomSecret(): string { + return this.lobbyState?.roomSecret || ''; + } + + set roomSecret(value: string) { + if (this.lobbyState) { + this.lobbyState.roomSecret = value; + } + } + + get onlyModeratorIsPresent(): boolean { + return this.features().canModerateRoom && !this.hasRemoteParticipants; + } + + get hasRemoteParticipants(): boolean { + return this.remoteParticipants().length > 0; + } + + get hasRecordings(): boolean { + return this.lobbyState?.hasRecordings || false; + } + + set hasRecordings(value: boolean) { + if (this.lobbyState) { + this.lobbyState.hasRecordings = value; + } } get hostname(): string { @@ -164,14 +237,18 @@ export class MeetingComponent implements OnInit { } async ngOnInit() { - this.roomId = this.roomService.getRoomId(); - this.roomSecret = this.roomService.getRoomSecret(); - this.room = await this.roomService.getRoom(this.roomId); - this.roomClosed = this.room.status === MeetRoomStatus.CLOSED; - - await this.setBackButtonText(); - await this.checkForRecordings(); - await this.initializeParticipantName(); + try { + this.lobbyState = await this.lobbyService.initialize(); + this.prejoinReady = true; + } catch (error) { + console.error('Error initializing lobby state:', error); + this.notificationService.showDialog({ + title: 'Error', + message: 'An error occurred while initializing the meeting lobby. Please try again later.', + showCancelButton: false, + confirmText: 'OK' + }); + } } ngOnDestroy() { @@ -179,129 +256,14 @@ export class MeetingComponent implements OnInit { this.destroy$.complete(); } - /** - * Sets the back button text based on the application mode and user role - */ - private async setBackButtonText() { - const isStandaloneMode = this.appDataService.isStandaloneMode(); - const redirection = this.navigationService.getLeaveRedirectURL(); - const isAdmin = await this.authService.isAdmin(); - - if (isStandaloneMode && !redirection && !isAdmin) { - // If in standalone mode, no redirection URL and not an admin, hide the back button - this.showBackButton = false; - return; - } - - this.showBackButton = true; - this.backButtonText = isStandaloneMode && !redirection && isAdmin ? 'Back to Rooms' : 'Back'; - } - - /** - * Checks if there are recordings in the room and updates the visibility of the recordings card. - * - * It is necessary to previously generate a recording token in order to list the recordings. - * If token generation fails or the user does not have sufficient permissions to list recordings, - * the error will be caught and the recordings card will be hidden (`showRecordingCard` will be set to `false`). - * - * If recordings exist, sets `showRecordingCard` to `true`; otherwise, to `false`. - */ - private async checkForRecordings() { - try { - const { canRetrieveRecordings } = await this.recordingService.generateRecordingToken( - this.roomId, - this.roomSecret - ); - - if (!canRetrieveRecordings) { - this.showRecordingCard = false; - return; - } - - const { recordings } = await this.recordingService.listRecordings({ - maxItems: 1, - roomId: this.roomId, - fields: 'recordingId' - }); - this.hasRecordings = recordings.length > 0; - this.showRecordingCard = this.hasRecordings; - } catch (error) { - console.error('Error checking for recordings:', error); - this.showRecordingCard = false; - } - } - - /** - * Initializes the participant name in the form control. - * - * Retrieves the participant name from the ParticipantTokenService first, and if not available, - * falls back to the authenticated username. Sets the retrieved name value in the - * participant form's 'name' control if a valid name is found. - * - * @returns A promise that resolves when the participant name has been initialized - */ - private async initializeParticipantName() { - // Apply participant name from ParticipantTokenService if set, otherwise use authenticated username - const currentParticipantName = this.participantService.getParticipantName(); - const username = await this.authService.getUsername(); - const participantName = currentParticipantName || username; - - if (participantName) { - this.participantForm.get('name')?.setValue(participantName); - } - } - - async goToRecordings() { - try { - await this.navigationService.navigateTo(`room/${this.roomId}/recordings`, { secret: this.roomSecret }); - } catch (error) { - console.error('Error navigating to recordings:', error); - } - } - - /** - * Handles the back button click event and navigates accordingly - * If in embedded mode, it closes the WebComponentManagerService - * If the redirect URL is set, it navigates to that URL - * If in standalone mode without a redirect URL, it navigates to the rooms page - */ - async goBack() { - if (this.appDataService.isEmbeddedMode()) { - this.wcManagerService.close(); - } - - const redirectTo = this.navigationService.getLeaveRedirectURL(); - if (redirectTo) { - // Navigate to the specified redirect URL - await this.navigationService.redirectToLeaveUrl(); - return; - } - - if (this.appDataService.isStandaloneMode()) { - // Navigate to rooms page - await this.navigationService.navigateTo('/rooms'); - } - } - async submitAccessMeeting() { - const { valid, value } = this.participantForm; - if (!valid || !value.name?.trim()) { - // If the form is invalid, do not proceed - console.warn('Participant form is invalid. Cannot access meeting.'); - return; - } - - this.participantName = value.name.trim(); - try { - await this.generateParticipantToken(); - await this.addParticipantNameToUrl(); - await this.roomService.loadRoomConfig(this.roomId); + await this.lobbyService.submitAccess(); // The meeting view must be shown before loading the appearance config, // as it contains theme information that might be applied immediately // when the meeting view is rendered - this.showMeeting = true; + this.showPrejoin = false; await this.configService.loadRoomsAppearanceConfig(); combineLatest([ @@ -310,8 +272,15 @@ export class MeetingComponent implements OnInit { ]) .pipe(takeUntil(this.destroy$)) .subscribe(([participants, local]) => { - this.remoteParticipants = participants as CustomParticipantModel[]; - this.localParticipant = local as CustomParticipantModel; + this.remoteParticipants.set(participants as CustomParticipantModel[]); + this.localParticipant.set(local as CustomParticipantModel); + + // Update action handler context if provided + if (this.actionHandler) { + this.actionHandler.roomId = this.roomId; + this.actionHandler.roomSecret = this.roomSecret; + this.actionHandler.localParticipant = this.localParticipant(); + } this.updateVideoPinState(); }); @@ -320,205 +289,24 @@ export class MeetingComponent implements OnInit { } } - /** - * Centralized logic for managing video pinning based on - * remote participants and local screen sharing state. - */ - private updateVideoPinState(): void { - if (!this.localParticipant) return; - - const hasRemote = this.remoteParticipants.length > 0; - const isSharing = this.localParticipant.isScreenShareEnabled; - - if (hasRemote && isSharing) { - // Pin the local screen share to appear bigger - this.localParticipant.setVideoPinnedBySource(Track.Source.ScreenShare, true); - } else { - // Unpin everything if no remote participants or not sharing - this.localParticipant.setAllVideoPinned(false); - } - } - - /** - * Generates a participant token for joining a meeting. - * - * @throws When participant already exists in the room (status 409) - * @returns Promise that resolves when token is generated - */ - private async generateParticipantToken() { - try { - this.participantToken = await this.participantService.generateToken({ - roomId: this.roomId, - secret: this.roomSecret, - participantName: this.participantName - }); - this.participantName = this.participantService.getParticipantName()!; - } catch (error: any) { - console.error('Error generating participant token:', error); - switch (error.status) { - case 400: - // Invalid secret - await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM_SECRET, true); - break; - case 404: - // Room not found - await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM, true); - break; - case 409: - // Room is closed - await this.navigationService.redirectToErrorPage(ErrorReason.CLOSED_ROOM, true); - break; - default: - await this.navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR, true); - } - - throw new Error('Error generating participant token'); - } - } - - /** - * Add participant name as a query parameter to the URL - */ - private async addParticipantNameToUrl() { - await this.navigationService.updateQueryParamsFromUrl(this.route.snapshot.queryParams, { - 'participant-name': this.participantName - }); - } - onRoomCreated(room: Room) { - room.on( - RoomEvent.DataReceived, - async (payload: Uint8Array, _participant?: RemoteParticipant, _kind?: DataPacket_Kind, topic?: string) => { - const event = JSON.parse(new TextDecoder().decode(payload)); - - switch (topic) { - case 'recordingStopped': { - // If a 'recordingStopped' event is received and there was no previous recordings, - // update the hasRecordings flag and refresh the recording token - if (this.hasRecordings) return; - - this.hasRecordings = true; - - try { - await this.recordingService.generateRecordingToken(this.roomId, this.roomSecret); - } catch (error) { - console.error('Error refreshing recording token:', error); - } - - break; - } - case MeetSignalType.MEET_ROOM_CONFIG_UPDATED: { - // Update room config - const { config } = event as MeetRoomConfigUpdatedPayload; - this.featureConfService.setRoomConfig(config); - - // Refresh recording token if recording is enabled - if (config.recording.enabled) { - try { - await this.recordingService.generateRecordingToken(this.roomId, this.roomSecret); - } catch (error) { - console.error('Error refreshing recording token:', error); - } - } - break; - } - case MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED: { - // Update participant role - const { participantIdentity, newRole, secret } = event as MeetParticipantRoleUpdatedPayload; - - if (participantIdentity === this.localParticipant!.identity) { - if (!secret) return; - - this.roomSecret = secret; - this.roomService.setRoomSecret(secret, false); - - try { - await this.participantService.refreshParticipantToken({ - roomId: this.roomId, - secret, - participantName: this.participantName, - participantIdentity - }); - - this.localParticipant!.meetRole = newRole; - this.notificationService.showSnackbar(`You have been assigned the role of ${newRole}`); - } catch (error) { - console.error('Error refreshing participant token to update role:', error); - } - } else { - const participant = this.remoteParticipants.find((p) => p.identity === participantIdentity); - if (participant) { - participant.meetRole = newRole; - } - } - - break; - } - } + this.eventHandler.setupRoomListeners(room, { + roomId: this.roomId, + roomSecret: this.roomSecret, + participantName: this.participantName, + localParticipant: () => this.localParticipant(), + remoteParticipants: () => this.remoteParticipants(), + onHasRecordingsChanged: (hasRecordings) => { + this.hasRecordings = hasRecordings; + }, + onRoomSecretChanged: (secret) => { + this.roomSecret = secret; + }, + onParticipantRoleUpdated: () => { + // Increment version to trigger reactivity in participant panel items + this.participantsVersion.update((v) => v + 1); } - ); - } - - onParticipantConnected(event: ParticipantModel) { - const message: WebComponentOutboundEventMessage = { - event: WebComponentEvent.JOINED, - payload: { - roomId: event.getProperties().room?.name || '', - participantIdentity: event.identity - } - }; - this.wcManagerService.sendMessageToParent(message); - } - - async onParticipantLeft(event: ParticipantLeftEvent) { - let leftReason = this.getReasonParamFromEvent(event.reason); - if (leftReason === LeftEventReason.MEETING_ENDED && this.meetingEndedByMe) { - leftReason = LeftEventReason.MEETING_ENDED_BY_SELF; - } - - // Send LEFT event to the parent component - const message: WebComponentOutboundEventMessage = { - event: WebComponentEvent.LEFT, - payload: { - roomId: event.roomName, - participantIdentity: event.participantName, - reason: leftReason - } - }; - this.wcManagerService.sendMessageToParent(message); - - // Remove the moderator secret (and stored tokens) from session storage - // if the participant left for a reason other than browser unload - if (event.reason !== ParticipantLeftReason.BROWSER_UNLOAD) { - this.sessionStorageService.removeRoomSecret(); - this.tokenStorageService.clearParticipantToken(); - this.tokenStorageService.clearRecordingToken(); - } - - // Navigate to the disconnected page with the reason - await this.navigationService.navigateTo('disconnected', { reason: leftReason }, true); - } - - /** - * Maps ParticipantLeftReason to LeftEventReason. - * This method translates the technical reasons for a participant leaving the room - * into user-friendly reasons that can be used in the UI or for logging purposes. - * @param reason The technical reason for the participant leaving the room. - * @returns The corresponding LeftEventReason. - */ - private getReasonParamFromEvent(reason: ParticipantLeftReason): LeftEventReason { - const reasonMap: Record = { - [ParticipantLeftReason.LEAVE]: LeftEventReason.VOLUNTARY_LEAVE, - [ParticipantLeftReason.BROWSER_UNLOAD]: LeftEventReason.VOLUNTARY_LEAVE, - [ParticipantLeftReason.NETWORK_DISCONNECT]: LeftEventReason.NETWORK_DISCONNECT, - [ParticipantLeftReason.SIGNAL_CLOSE]: LeftEventReason.NETWORK_DISCONNECT, - [ParticipantLeftReason.SERVER_SHUTDOWN]: LeftEventReason.SERVER_SHUTDOWN, - [ParticipantLeftReason.PARTICIPANT_REMOVED]: LeftEventReason.PARTICIPANT_KICKED, - [ParticipantLeftReason.ROOM_DELETED]: LeftEventReason.MEETING_ENDED, - [ParticipantLeftReason.DUPLICATE_IDENTITY]: LeftEventReason.UNKNOWN, - [ParticipantLeftReason.OTHER]: LeftEventReason.UNKNOWN - }; - return reasonMap[reason] ?? LeftEventReason.UNKNOWN; + }); } async leaveMeeting() { @@ -528,96 +316,125 @@ export class MeetingComponent implements OnInit { async endMeeting() { if (!this.participantService.isModeratorParticipant()) return; - this.meetingEndedByMe = true; + this.eventHandler.setMeetingEndedByMe(true); try { await this.meetingService.endMeeting(this.roomId); } catch (error) { console.error('Error ending meeting:', error); - this.notificationService.showSnackbar('Failed to end meeting'); - } - } - - async kickParticipant(participant: CustomParticipantModel) { - if (!this.participantService.isModeratorParticipant()) return; - - try { - await this.meetingService.kickParticipant(this.roomId, participant.identity); - } catch (error) { - console.error('Error kicking participant:', error); - this.notificationService.showSnackbar('Failed to kick participant'); - } - } - - /** - * Makes a participant as moderator. - * @param participant The participant to make as moderator. - */ - async makeModerator(participant: CustomParticipantModel) { - if (!this.participantService.isModeratorParticipant()) return; - - try { - await this.meetingService.changeParticipantRole( - this.roomId, - participant.identity, - ParticipantRole.MODERATOR - ); - } catch (error) { - console.error('Error making participant moderator:', error); - this.notificationService.showSnackbar('Failed to make participant moderator'); - } - } - - /** - * Unmakes a participant as moderator. - * @param participant The participant to unmake as moderator. - */ - async unmakeModerator(participant: CustomParticipantModel) { - if (!this.participantService.isModeratorParticipant()) return; - - try { - await this.meetingService.changeParticipantRole(this.roomId, participant.identity, ParticipantRole.SPEAKER); - } catch (error) { - console.error('Error unmaking participant moderator:', error); - this.notificationService.showSnackbar('Failed to unmake participant moderator'); - } - } - - async copyModeratorLink() { - this.clipboard.copy(this.room!.moderatorUrl); - this.notificationService.showSnackbar('Moderator link copied to clipboard'); - } - - async copySpeakerLink() { - this.clipboard.copy(this.room!.speakerUrl); - this.notificationService.showSnackbar('Speaker link copied to clipboard'); - } - - async onRecordingStartRequested(event: RecordingStartRequestedEvent) { - try { - await this.recordingService.startRecording(event.roomName); - } catch (error: unknown) { - if ((error as any).status === 503) { - console.error( - `No egress service was able to register a request. -Check your CPU usage or if there's any Media Node with enough CPU. -Remember that by default, a recording uses 4 CPUs for each room.` - ); - } else { - console.error(error); - } - } - } - - async onRecordingStopRequested(event: RecordingStopRequestedEvent) { - try { - await this.recordingService.stopRecording(event.recordingId); - } catch (error) { - console.error(error); } } async onViewRecordingsClicked() { window.open(`/room/${this.roomId}/recordings?secret=${this.roomSecret}`, '_blank'); } + + /** + * Centralized logic for managing video pinning based on + * remote participants and local screen sharing state. + */ + protected updateVideoPinState(): void { + if (!this.localParticipant) return; + + const isSharing = this.localParticipant()?.isScreenShareEnabled; + + if (this.hasRemoteParticipants && isSharing) { + // Pin the local screen share to appear bigger + this.localParticipant()?.setVideoPinnedBySource(Track.Source.ScreenShare, true); + } else { + // Unpin everything if no remote participants or not sharing + this.localParticipant()?.setAllVideoPinned(false); + } + } + + /** + * Event handler wrappers - delegates to actionHandler if provided, otherwise uses default implementation + */ + protected async handleKickParticipant(participant: CustomParticipantModel) { + if (this.actionHandler) { + await this.actionHandler.kickParticipant(participant); + } else { + // Default implementation + if (!this.participantService.isModeratorParticipant()) return; + + try { + await this.meetingService.kickParticipant(this.roomId, participant.identity); + console.log('Participant kicked successfully'); + } catch (error) { + console.error('Error kicking participant:', error); + } + } + } + + protected async handleMakeModerator(participant: CustomParticipantModel) { + if (this.actionHandler) { + await this.actionHandler.makeModerator(participant); + } else { + // Default implementation + if (!this.participantService.isModeratorParticipant()) return; + + try { + await this.meetingService.changeParticipantRole( + this.roomId, + participant.identity, + ParticipantRole.MODERATOR + ); + console.log('Moderator assigned successfully'); + } catch (error) { + console.error('Error assigning moderator:', error); + } + } + } + + protected async handleUnmakeModerator(participant: CustomParticipantModel) { + if (this.actionHandler) { + await this.actionHandler.unmakeModerator(participant); + } else { + // Default implementation + if (!this.participantService.isModeratorParticipant()) return; + + try { + await this.meetingService.changeParticipantRole( + this.roomId, + participant.identity, + ParticipantRole.SPEAKER + ); + console.log('Moderator unassigned successfully'); + } catch (error) { + console.error('Error unassigning moderator:', error); + } + } + } + + // private async handleCopyModeratorLink() { + // if (this.actionHandler) { + // await this.actionHandler.copyModeratorLink(); + // } else { + // // Default implementation + // try { + // this.clipboard.copy(this.room!.moderatorUrl); + // this.notificationService.showSnackbar('Moderator link copied to clipboard'); + + // console.log('Moderator link copied to clipboard'); + // } catch (error) { + // console.error('Failed to copy moderator link:', error); + // } + // } + // } + + protected async handleCopySpeakerLink() { + if (this.actionHandler) { + await this.actionHandler.copySpeakerLink(); + } else { + // Default implementation + try { + const speakerLink = this.room!.speakerUrl; + this.clipboard.copy(speakerLink); + this.notificationService.showSnackbar('Speaker link copied to clipboard'); + console.log('Speaker link copied to clipboard'); + } catch (error) { + console.error('Failed to copy speaker link:', error); + } + } + } } diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/index.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/index.ts index 7fcca19..bf3cc31 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/index.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/index.ts @@ -4,7 +4,10 @@ export * from './auth.service'; export * from './global-config.service'; export * from './room.service'; export * from './participant.service'; -export * from './meeting.service'; +export * from './meeting/meeting.service'; +export * from './meeting/meeting-lobby.service'; +export * from './meeting/meeting-plugin-manager.service'; +export * from './meeting/meeting-event-handler.service'; export * from './feature-configuration.service'; export * from './recording.service'; export * from './webcomponent-manager.service'; diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-event-handler.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-event-handler.service.ts new file mode 100644 index 0000000..dd56aae --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-event-handler.service.ts @@ -0,0 +1,359 @@ +import { Injectable, inject } from '@angular/core'; +import { + Room, + RoomEvent, + DataPacket_Kind, + RemoteParticipant, + ParticipantLeftEvent, + ParticipantLeftReason, + RecordingStartRequestedEvent, + RecordingStopRequestedEvent, + ParticipantModel +} from 'openvidu-components-angular'; +import { + FeatureConfigurationService, + RecordingService, + ParticipantService, + RoomService, + SessionStorageService, + TokenStorageService, + WebComponentManagerService, + NavigationService +} from '../../services'; +import { + LeftEventReason, + MeetSignalType, + MeetParticipantRoleUpdatedPayload, + MeetRoomConfigUpdatedPayload, + WebComponentEvent, + WebComponentOutboundEventMessage +} from '@openvidu-meet/typings'; +import { CustomParticipantModel } from '../../models'; + +/** + * Service that handles all LiveKit/OpenVidu room events. + * + * This service encapsulates all event handling logic previously in MeetingComponent, + * providing a clean separation of concerns and making the component more maintainable. + * + * Responsibilities: + * - Setup and manage room event listeners + * - Handle data received events (recording stopped, config updates, role changes) + * - Handle participant lifecycle events (connected, left) + * - Handle recording events (start, stop) + * - Map technical reasons to user-friendly reasons + * - Manage meeting ended state + * - Navigate to disconnected page with appropriate reason + * + * Benefits: + * - Reduces MeetingComponent size by ~200 lines + * - All event logic in one place (easier to test and maintain) + * - Clear API for event handling + * - Reusable across different components if needed + */ +@Injectable() +export class MeetingEventHandlerService { + // Injected services + protected featureConfService = inject(FeatureConfigurationService); + protected recordingService = inject(RecordingService); + protected participantService = inject(ParticipantService); + protected roomService = inject(RoomService); + protected sessionStorageService = inject(SessionStorageService); + protected tokenStorageService = inject(TokenStorageService); + protected wcManagerService = inject(WebComponentManagerService); + protected navigationService = inject(NavigationService); + + // Internal state + private meetingEndedByMe = false; + + // ============================================ + // PUBLIC METHODS - Room Event Handlers + // ============================================ + + /** + * Sets up all room event listeners when room is created. + * This is the main entry point for room event handling. + * + * @param room The LiveKit Room instance + * @param context Context object containing all necessary data and callbacks + */ + setupRoomListeners( + room: Room, + context: { + roomId: string; + roomSecret: string; + participantName: string; + localParticipant: () => CustomParticipantModel | undefined; + remoteParticipants: () => CustomParticipantModel[]; + onHasRecordingsChanged: (hasRecordings: boolean) => void; + onRoomSecretChanged: (secret: string) => void; + onParticipantRoleUpdated?: () => void; + } + ): void { + room.on( + RoomEvent.DataReceived, + async ( + payload: Uint8Array, + _participant?: RemoteParticipant, + _kind?: DataPacket_Kind, + topic?: string + ) => { + const event = JSON.parse(new TextDecoder().decode(payload)); + + switch (topic) { + case 'recordingStopped': + await this.handleRecordingStopped( + context.roomId, + context.roomSecret, + context.onHasRecordingsChanged + ); + break; + + case MeetSignalType.MEET_ROOM_CONFIG_UPDATED: + await this.handleRoomConfigUpdated(event, context.roomId, context.roomSecret); + break; + + case MeetSignalType.MEET_PARTICIPANT_ROLE_UPDATED: + await this.handleParticipantRoleUpdated( + event, + context.roomId, + context.participantName, + context.localParticipant, + context.remoteParticipants, + context.onRoomSecretChanged, + context.onParticipantRoleUpdated + ); + break; + } + } + ); + } + + /** + * Handles participant connected event. + * Sends JOINED event to parent window (for web component integration). + * + * Arrow function ensures correct 'this' binding when called from template. + * + * @param event Participant model from OpenVidu + */ + onParticipantConnected = (event: ParticipantModel): void => { + const message: WebComponentOutboundEventMessage = { + event: WebComponentEvent.JOINED, + payload: { + roomId: event.getProperties().room?.name || '', + participantIdentity: event.identity + } + }; + this.wcManagerService.sendMessageToParent(message); + }; + + /** + * Handles participant left event. + * - Maps technical reason to user-friendly reason + * - Sends LEFT event to parent window + * - Cleans up session storage (secrets, tokens) + * - Navigates to disconnected page + * + * Arrow function ensures correct 'this' binding when called from template. + * + * @param event Participant left event from OpenVidu + */ + onParticipantLeft = async (event: ParticipantLeftEvent): Promise => { + let leftReason = this.mapLeftReason(event.reason); + + // If meeting was ended by this user, update reason + if (leftReason === LeftEventReason.MEETING_ENDED && this.meetingEndedByMe) { + leftReason = LeftEventReason.MEETING_ENDED_BY_SELF; + } + + // Send LEFT event to parent window + const message: WebComponentOutboundEventMessage = { + event: WebComponentEvent.LEFT, + payload: { + roomId: event.roomName, + participantIdentity: event.participantName, + reason: leftReason + } + }; + this.wcManagerService.sendMessageToParent(message); + + // Clean up storage (except on browser unload) + if (event.reason !== ParticipantLeftReason.BROWSER_UNLOAD) { + this.sessionStorageService.removeRoomSecret(); + this.tokenStorageService.clearParticipantToken(); + this.tokenStorageService.clearRecordingToken(); + } + + // Navigate to disconnected page + await this.navigationService.navigateTo('disconnected', { reason: leftReason }, true); + }; + + /** + * Handles recording start request event. + * + * Arrow function ensures correct 'this' binding when called from template. + * + * @param event Recording start requested event from OpenVidu + */ + onRecordingStartRequested = async (event: RecordingStartRequestedEvent): Promise => { + try { + await this.recordingService.startRecording(event.roomName); + } catch (error: any) { + if (error.status === 503) { + console.error( + 'No egress service available. Check CPU usage or Media Node capacity. ' + + 'By default, a recording uses 2 CPUs per room.' + ); + } else { + console.error('Error starting recording:', error); + } + } + }; + + /** + * Handles recording stop request event. + * + * Arrow function ensures correct 'this' binding when called from template. + * + * @param event Recording stop requested event from OpenVidu + */ + onRecordingStopRequested = async (event: RecordingStopRequestedEvent): Promise => { + try { + await this.recordingService.stopRecording(event.recordingId); + } catch (error) { + console.error('Error stopping recording:', error); + } + }; + + /** + * Sets the "meeting ended by me" flag. + * This is used to differentiate between meeting ended by this user vs ended by someone else. + * + * @param value True if this user ended the meeting + */ + setMeetingEndedByMe(value: boolean): void { + this.meetingEndedByMe = value; + } + + // ============================================ + // PRIVATE METHODS - Event Handlers + // ============================================ + + /** + * Handles recording stopped event. + * Updates hasRecordings flag and refreshes recording token. + */ + private async handleRecordingStopped( + roomId: string, + roomSecret: string, + onHasRecordingsChanged: (hasRecordings: boolean) => void + ): Promise { + // Notify that recordings are now available + onHasRecordingsChanged(true); + + try { + // Refresh recording token to view recordings + await this.recordingService.generateRecordingToken(roomId, roomSecret); + } catch (error) { + console.error('Error refreshing recording token:', error); + } + } + + /** + * Handles room config updated event. + * Updates feature config and refreshes recording token if needed. + */ + private async handleRoomConfigUpdated( + event: MeetRoomConfigUpdatedPayload, + roomId: string, + roomSecret: string + ): Promise { + const { config } = event; + + // Update feature configuration + this.featureConfService.setRoomConfig(config); + + // Refresh recording token if recording is enabled + if (config.recording.enabled) { + try { + await this.recordingService.generateRecordingToken(roomId, roomSecret); + } catch (error) { + console.error('Error refreshing recording token:', error); + } + } + } + + /** + * Handles participant role updated event. + * Updates local or remote participant role and refreshes token if needed. + */ + private async handleParticipantRoleUpdated( + event: MeetParticipantRoleUpdatedPayload, + roomId: string, + participantName: string, + localParticipant: () => CustomParticipantModel | undefined, + remoteParticipants: () => CustomParticipantModel[], + onRoomSecretChanged: (secret: string) => void, + onParticipantRoleUpdated?: () => void + ): Promise { + const { participantIdentity, newRole, secret } = event; + const local = localParticipant(); + + // Check if the role update is for the local participant + if (local && participantIdentity === local.identity) { + if (!secret) return; + + // Update room secret + onRoomSecretChanged(secret); + this.roomService.setRoomSecret(secret, false); + + try { + // Refresh participant token with new role + await this.participantService.refreshParticipantToken({ + roomId, + secret, + participantName, + participantIdentity + }); + + // Update local participant role + local.meetRole = newRole; + console.log(`You have been assigned the role of ${newRole}`); + + // Notify component that participant role was updated + onParticipantRoleUpdated?.(); + } catch (error) { + console.error('Error refreshing participant token:', error); + } + } else { + // Update remote participant role + const participant = remoteParticipants().find((p) => p.identity === participantIdentity); + if (participant) { + participant.meetRole = newRole; + + // Notify component that participant role was updated + onParticipantRoleUpdated?.(); + } + } + } + + /** + * Maps technical ParticipantLeftReason to user-friendly LeftEventReason. + * This provides better messaging to users about why they left the room. + */ + private mapLeftReason(reason: ParticipantLeftReason): LeftEventReason { + const reasonMap: Record = { + [ParticipantLeftReason.LEAVE]: LeftEventReason.VOLUNTARY_LEAVE, + [ParticipantLeftReason.BROWSER_UNLOAD]: LeftEventReason.VOLUNTARY_LEAVE, + [ParticipantLeftReason.NETWORK_DISCONNECT]: LeftEventReason.NETWORK_DISCONNECT, + [ParticipantLeftReason.SIGNAL_CLOSE]: LeftEventReason.NETWORK_DISCONNECT, + [ParticipantLeftReason.SERVER_SHUTDOWN]: LeftEventReason.SERVER_SHUTDOWN, + [ParticipantLeftReason.PARTICIPANT_REMOVED]: LeftEventReason.PARTICIPANT_KICKED, + [ParticipantLeftReason.ROOM_DELETED]: LeftEventReason.MEETING_ENDED, + [ParticipantLeftReason.DUPLICATE_IDENTITY]: LeftEventReason.UNKNOWN, + [ParticipantLeftReason.OTHER]: LeftEventReason.UNKNOWN + }; + return reasonMap[reason] ?? LeftEventReason.UNKNOWN; + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts new file mode 100644 index 0000000..a3d39fd --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-lobby.service.ts @@ -0,0 +1,261 @@ +import { inject, Injectable } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { + AuthService, + RecordingService, + RoomService, + ParticipantService, + NavigationService, + AppDataService, + WebComponentManagerService +} from '..'; +import { MeetRoomStatus } from '@openvidu-meet/typings'; +import { LobbyState } from '../../models/lobby.model'; +import { ErrorReason } from '../../models'; +import { ActivatedRoute } from '@angular/router'; + +/** + * Service that manages the meeting lobby state and operations. + * + * Responsibilities: + * - Initialize and maintain lobby state + * - Validate participant information + * - Check for recordings availability + * - Handle navigation (back button, recordings) + * + * This service coordinates multiple domain services to provide + * a simplified interface for the MeetingComponent. + */ +@Injectable() +export class MeetingLobbyService { + private state: LobbyState = { + roomId: '', + roomSecret: '', + roomClosed: false, + hasRecordings: false, + showRecordingCard: false, + showBackButton: true, + backButtonText: 'Back', + participantForm: new FormGroup({ + name: new FormControl('', [Validators.required]) + }), + participantToken: '' + }; + + protected roomService: RoomService = inject(RoomService); + protected recordingService: RecordingService = inject(RecordingService); + protected authService: AuthService = inject(AuthService); + protected participantService: ParticipantService = inject(ParticipantService); + protected navigationService: NavigationService = inject(NavigationService); + protected appDataService: AppDataService = inject(AppDataService); + protected wcManagerService: WebComponentManagerService = inject(WebComponentManagerService); + protected route: ActivatedRoute = inject(ActivatedRoute); + + /** + * Gets the current lobby state + */ + get lobbyState(): LobbyState { + return this.state; + } + + set participantName(name: string) { + this.state.participantForm.get('name')?.setValue(name); + } + + get participantName(): string { + const { valid, value } = this.state.participantForm; + if (!valid || !value.name?.trim()) { + return ''; + } + return value.name.trim(); + } + + /** + * Initializes the lobby state by fetching room data and configuring UI + */ + async initialize(): Promise { + this.state.roomId = this.roomService.getRoomId(); + this.state.roomSecret = this.roomService.getRoomSecret(); + this.state.room = await this.roomService.getRoom(this.state.roomId); + this.state.roomClosed = this.state.room.status === MeetRoomStatus.CLOSED; + + await this.setBackButtonText(); + await this.checkForRecordings(); + await this.initializeParticipantName(); + + return this.state; + } + + + /** + * Handles the back button click event and navigates accordingly + * If in embedded mode, it closes the WebComponentManagerService + * If the redirect URL is set, it navigates to that URL + * If in standalone mode without a redirect URL, it navigates to the rooms page + */ + async goBack() { + try { + if (this.appDataService.isEmbeddedMode()) { + this.wcManagerService.close(); + } + + const redirectTo = this.navigationService.getLeaveRedirectURL(); + if (redirectTo) { + // Navigate to the specified redirect URL + await this.navigationService.redirectToLeaveUrl(); + return; + } + + if (this.appDataService.isStandaloneMode()) { + // Navigate to rooms page + await this.navigationService.navigateTo('/rooms'); + } + } catch (error) { + console.error('Error handling back navigation:', error); + } + } + + /** + * Navigates to recordings page + */ + async goToRecordings(): Promise { + try { + await this.navigationService.navigateTo(`room/${this.state.roomId}/recordings`, { + secret: this.state.roomSecret + }); + } catch (error) { + console.error('Error navigating to recordings:', error); + } + } + + async submitAccess(): Promise { + if (!this.participantName) { + console.error('Participant form is invalid. Cannot access meeting.'); + throw new Error('Participant form is invalid'); + } + + await this.generateParticipantToken(); + await this.addParticipantNameToUrl(); + await this.roomService.loadRoomConfig(this.state.roomId); + } + + // Protected helper methods + + /** + * Sets the back button text based on the application mode and user role + */ + protected async setBackButtonText(): Promise { + const isStandaloneMode = this.appDataService.isStandaloneMode(); + const redirection = this.navigationService.getLeaveRedirectURL(); + const isAdmin = await this.authService.isAdmin(); + + if (isStandaloneMode && !redirection && !isAdmin) { + this.state.showBackButton = false; + return; + } + + this.state.showBackButton = true; + this.state.backButtonText = isStandaloneMode && !redirection && isAdmin ? 'Back to Rooms' : 'Back'; + } + + /** + * Checks if there are recordings in the room and updates the visibility of the recordings card. + * + * It is necessary to previously generate a recording token in order to list the recordings. + * If token generation fails or the user does not have sufficient permissions to list recordings, + * the error will be caught and the recordings card will be hidden (`showRecordingCard` will be set to `false`). + * + * If recordings exist, sets `showRecordingCard` to `true`; otherwise, to `false`. + */ + protected async checkForRecordings(): Promise { + try { + const { canRetrieveRecordings } = await this.recordingService.generateRecordingToken( + this.state.roomId, + this.state.roomSecret + ); + + if (!canRetrieveRecordings) { + this.state.showRecordingCard = false; + return; + } + + const { recordings } = await this.recordingService.listRecordings({ + maxItems: 1, + roomId: this.state.roomId, + fields: 'recordingId' + }); + + this.state.hasRecordings = recordings.length > 0; + this.state.showRecordingCard = this.state.hasRecordings; + } catch (error) { + console.error('Error checking for recordings:', error); + this.state.showRecordingCard = false; + } + } + + /** + * Initializes the participant name in the form control. + * + * Retrieves the participant name from the ParticipantTokenService first, and if not available, + * falls back to the authenticated username. Sets the retrieved name value in the + * participant form's 'name' control if a valid name is found. + * + * @returns A promise that resolves when the participant name has been initialized + */ + protected async initializeParticipantName(): Promise { + // Apply participant name from ParticipantTokenService if set, otherwise use authenticated username + const currentParticipantName = this.participantService.getParticipantName(); + const username = await this.authService.getUsername(); + const participantName = currentParticipantName || username; + + if (participantName) { + this.participantName = participantName; + } + } + + /** + * Generates a participant token for joining a meeting. + * + * @throws When participant already exists in the room (status 409) + * @returns Promise that resolves when token is generated + */ + protected async generateParticipantToken() { + try { + this.state.participantToken = await this.participantService.generateToken({ + roomId: this.state.roomId, + secret: this.state.roomSecret, + participantName: this.participantName + }); + this.participantName = this.participantService.getParticipantName()!; + } catch (error: any) { + console.error('Error generating participant token:', error); + switch (error.status) { + case 400: + // Invalid secret + await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM_SECRET, true); + break; + case 404: + // Room not found + await this.navigationService.redirectToErrorPage(ErrorReason.INVALID_ROOM, true); + break; + case 409: + // Room is closed + await this.navigationService.redirectToErrorPage(ErrorReason.CLOSED_ROOM, true); + break; + default: + await this.navigationService.redirectToErrorPage(ErrorReason.INTERNAL_ERROR, true); + } + + throw new Error('Error generating participant token'); + } + } + + /** + * Add participant name as a query parameter to the URL + */ + protected async addParticipantNameToUrl() { + await this.navigationService.updateQueryParamsFromUrl(this.route.snapshot.queryParams, { + 'participant-name': this.participantName + }); + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-plugin-manager.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-plugin-manager.service.ts new file mode 100644 index 0000000..2a636af --- /dev/null +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting-plugin-manager.service.ts @@ -0,0 +1,194 @@ +import { Injectable, Optional, Inject } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { CustomParticipantModel } from '../../models'; +import { MeetingActionHandler, MEETING_ACTION_HANDLER_TOKEN, ParticipantControls } from '../../customization'; +import { ParticipantService } from '../participant.service'; + +/** + * Service that manages plugin inputs and configurations for the MeetingComponent. + * + * Responsibilities: + * - Prepare input objects for toolbar plugins + * - Prepare input objects for participant panel plugins + * - Prepare input objects for layout plugins + * - Prepare input objects for lobby plugin + * - Calculate participant control visibility based on roles and permissions + * + * This service acts as a bridge between the MeetingComponent and the plugin components, + * encapsulating the logic for determining what inputs each plugin should receive. + */ +@Injectable() +export class MeetingPluginManagerService { + constructor( + private participantService: ParticipantService, + @Optional() @Inject(MEETING_ACTION_HANDLER_TOKEN) private actionHandler?: MeetingActionHandler + ) {} + + /** + * Prepares inputs for the toolbar additional buttons plugin + */ + getToolbarAdditionalButtonsInputs( + canModerateRoom: boolean, + isMobile: boolean, + onCopyLink: () => void + ) { + return { + showCopyLinkButton: canModerateRoom, + showLeaveMenu: false, + isMobile, + copyLinkClickedFn: onCopyLink + }; + } + + /** + * Prepares inputs for the toolbar leave button plugin + */ + getToolbarLeaveButtonInputs( + canModerateRoom: boolean, + isMobile: boolean, + onLeave: () => Promise, + onEnd: () => Promise + ) { + return { + showCopyLinkButton: false, + showLeaveMenu: canModerateRoom, + isMobile, + leaveMeetingClickedFn: onLeave, + endMeetingClickedFn: onEnd + }; + } + + /** + * Prepares inputs for the participant panel "after local participant" plugin + */ + getParticipantPanelAfterLocalInputs( + canModerateRoom: boolean, + meetingUrl: string, + onCopyLink: () => void + ) { + return { + showShareLink: canModerateRoom, + meetingUrl, + copyClickedFn: onCopyLink + }; + } + + /** + * Prepares inputs for the layout additional elements plugin + */ + getLayoutAdditionalElementsInputs( + showOverlay: boolean, + meetingUrl: string, + onCopyLink: () => void + ) { + return { + showOverlay, + meetingUrl, + copyClickedFn: onCopyLink + }; + } + + /** + * Prepares inputs for the participant panel item plugin + */ + getParticipantPanelItemInputs( + participant: CustomParticipantModel, + allParticipants: CustomParticipantModel[], + onMakeModerator: (p: CustomParticipantModel) => void, + onUnmakeModerator: (p: CustomParticipantModel) => void, + onKick: (p: CustomParticipantModel) => void + ) { + const controls = this.getParticipantControls(participant); + + return { + participant, + allParticipants, + showModeratorBadge: controls.showModeratorBadge, + showModerationControls: controls.showModerationControls, + showMakeModerator: controls.showMakeModerator, + showUnmakeModerator: controls.showUnmakeModerator, + showKickButton: controls.showKickButton, + makeModeratorClickedFn: () => onMakeModerator(participant), + unmakeModeratorClickedFn: () => onUnmakeModerator(participant), + kickParticipantClickedFn: () => onKick(participant) + }; + } + + /** + * Prepares inputs for the lobby plugin + */ + getLobbyInputs( + roomName: string, + meetingUrl: string, + roomClosed: boolean, + showRecordingCard: boolean, + showShareLink: boolean, + showBackButton: boolean, + backButtonText: string, + participantForm: FormGroup, + onFormSubmit: () => void, + onViewRecordings: () => void, + onBack: () => void, + onCopyLink: () => void + ) { + return { + roomName, + meetingUrl, + roomClosed, + showRecordingsCard: showRecordingCard, + showShareLink, + showBackButton, + backButtonText, + participantForm, + formSubmittedFn: onFormSubmit, + viewRecordingsClickedFn: onViewRecordings, + backClickedFn: onBack, + copyLinkClickedFn: onCopyLink + }; + } + + /** + * Gets participant controls based on action handler or default logic + */ + private getParticipantControls(participant: CustomParticipantModel): ParticipantControls { + if (this.actionHandler) { + return this.actionHandler.getParticipantControls(participant); + } + + // Default implementation + return this.getDefaultParticipantControls(participant); + } + + /** + * Default implementation for calculating participant control visibility. + * + * Rules: + * - Only moderators can see moderation controls + * - Local participant never sees controls on themselves + * - A moderator who was promoted (not original) cannot remove the moderator role from original moderators + * - A moderator who was promoted (not original) cannot kick original moderators + * - The moderator badge is shown based on the current role, not original role + */ + protected getDefaultParticipantControls(participant: CustomParticipantModel): ParticipantControls { + const isCurrentUser = participant.isLocal; + const currentUserIsModerator = this.participantService.isModeratorParticipant(); + const participantIsModerator = participant.isModerator(); + const participantIsOriginalModerator = participant.isOriginalModerator(); + + // Calculate if current moderator can revoke the moderator role from the target participant + // Only allow if target is not an original moderator + const canRevokeModeratorRole = currentUserIsModerator && !isCurrentUser && participantIsModerator && !participantIsOriginalModerator; + + // Calculate if current moderator can kick the target participant + // Only allow if target is not an original moderator + const canKickParticipant = currentUserIsModerator && !isCurrentUser && !participantIsOriginalModerator; + + return { + showModeratorBadge: participantIsModerator, + showModerationControls: currentUserIsModerator && !isCurrentUser, + showMakeModerator: currentUserIsModerator && !isCurrentUser && !participantIsModerator, + showUnmakeModerator: canRevokeModeratorRole, + showKickButton: canKickParticipant + }; + } +} diff --git a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting.service.ts b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting.service.ts similarity index 97% rename from meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting.service.ts rename to meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting.service.ts index 81f89a7..0399f43 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting.service.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/lib/services/meeting/meeting.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { HttpService, ParticipantService } from '../services'; +import { HttpService, ParticipantService } from '..'; import { LoggerService } from 'openvidu-components-angular'; @Injectable({ diff --git a/meet-ce/frontend/projects/shared-meet-components/src/public-api.ts b/meet-ce/frontend/projects/shared-meet-components/src/public-api.ts index bf8d296..0e26e74 100644 --- a/meet-ce/frontend/projects/shared-meet-components/src/public-api.ts +++ b/meet-ce/frontend/projects/shared-meet-components/src/public-api.ts @@ -9,3 +9,4 @@ export * from './lib/interceptors/index'; export * from './lib/guards/index'; export * from './lib/routes/base-routes'; export * from './lib/utils/index'; +export * from './lib/customization/index'; diff --git a/meet-ce/frontend/src/app/app.config.ts b/meet-ce/frontend/src/app/app.config.ts index b9c38ff..72e916f 100644 --- a/meet-ce/frontend/src/app/app.config.ts +++ b/meet-ce/frontend/src/app/app.config.ts @@ -9,7 +9,7 @@ import { } from '@angular/core'; import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; import { provideRouter } from '@angular/router'; -import { routes } from '@app/app.routes'; +import { ceRoutes } from '@app/app.routes'; import { environment } from '@environment/environment'; import { CustomParticipantModel, httpInterceptor, ThemeService } from '@openvidu-meet/shared-components'; import { OpenViduComponentsConfig, OpenViduComponentsModule, ParticipantProperties } from 'openvidu-components-angular'; @@ -30,7 +30,7 @@ export const appConfig: ApplicationConfig = { }), importProvidersFrom(OpenViduComponentsModule.forRoot(ovComponentsconfig)), provideZoneChangeDetection({ eventCoalescing: true }), - provideRouter(routes), + provideRouter(ceRoutes), provideAnimationsAsync(), provideHttpClient(withInterceptors([httpInterceptor])), { diff --git a/meet-ce/frontend/src/app/app.routes.ts b/meet-ce/frontend/src/app/app.routes.ts index 79bd78e..dae811e 100644 --- a/meet-ce/frontend/src/app/app.routes.ts +++ b/meet-ce/frontend/src/app/app.routes.ts @@ -1,4 +1,14 @@ import { Routes } from '@angular/router'; -import { baseRoutes } from '@openvidu-meet/shared-components'; +import { baseRoutes, MeetingComponent } from '@openvidu-meet/shared-components'; +import { MEETING_CE_PROVIDERS } from './customization'; -export const routes: Routes = baseRoutes; +/** + * CE routes configure the plugin system using library components. + * The library's MeetingComponent uses NgComponentOutlet to render plugins dynamically. + */ +const routes = baseRoutes; +const meetingRoute = routes.find((route) => route.path === 'room/:room-id')!; +meetingRoute.component = MeetingComponent; +meetingRoute.providers = MEETING_CE_PROVIDERS; + +export const ceRoutes: Routes = routes; diff --git a/meet-ce/frontend/src/app/customization/index.ts b/meet-ce/frontend/src/app/customization/index.ts new file mode 100644 index 0000000..b371a53 --- /dev/null +++ b/meet-ce/frontend/src/app/customization/index.ts @@ -0,0 +1 @@ +export * from './meeting-ce.providers'; diff --git a/meet-ce/frontend/src/app/customization/meeting-ce.providers.ts b/meet-ce/frontend/src/app/customization/meeting-ce.providers.ts new file mode 100644 index 0000000..5963359 --- /dev/null +++ b/meet-ce/frontend/src/app/customization/meeting-ce.providers.ts @@ -0,0 +1,51 @@ +import { Provider } from '@angular/core'; +import { + MEETING_COMPONENTS_TOKEN, + MeetingToolbarButtonsComponent, + MeetingParticipantPanelComponent, + MeetingShareLinkPanelComponent, + MeetingShareLinkOverlayComponent, + MeetingLobbyComponent +} from '@openvidu-meet/shared-components'; + +/** + * CE Meeting Providers + * + * Configures the plugin system using library components directly. + * No wrappers needed - library components receive @Input properties directly through NgComponentOutlet. + * + * The library's MeetingComponent: + * - Uses NgComponentOutlet to render plugins dynamically + * - Prepares inputs via helper methods (getToolbarAdditionalButtonsInputs, etc.) + * - Passes these inputs to plugins via [ngComponentOutletInputs] + * + * CE uses library components as plugins without any customization. + * PRO will later define its own custom components to override CE behavior. + */ +export const MEETING_CE_PROVIDERS: Provider[] = [ + { + provide: MEETING_COMPONENTS_TOKEN, + useValue: { + toolbar: { + additionalButtons: MeetingToolbarButtonsComponent, + leaveButton: MeetingToolbarButtonsComponent + }, + participantPanel: { + item: MeetingParticipantPanelComponent, + afterLocalParticipant: MeetingShareLinkPanelComponent + }, + layout: { + additionalElements: MeetingShareLinkOverlayComponent + }, + lobby: MeetingLobbyComponent + } + }, + // { + // provide: MEETING_ACTION_HANDLER, + // useValue: { + // copySpeakerLink: () => { + // console.log('Copy speaker link clicked'); + // } + // } + // } +]; diff --git a/meet-ce/frontend/webcomponent/test_localstorage_state.json b/meet-ce/frontend/webcomponent/test_localstorage_state.json deleted file mode 100644 index 1a66b3f..0000000 --- a/meet-ce/frontend/webcomponent/test_localstorage_state.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "cookies": [ - { - "name": "OvMeetParticipantToken", - "value": "eyJhbGciOiJIUzI1NiJ9.eyJtZXRhZGF0YSI6IntcImxpdmVraXRVcmxcIjpcIndzOi8vbG9jYWxob3N0Ojc4ODBcIixcInJvbGVzXCI6W3tcInJvbGVcIjpcInNwZWFrZXJcIixcInBlcm1pc3Npb25zXCI6e1wiY2FuUmVjb3JkXCI6ZmFsc2UsXCJjYW5DaGF0XCI6dHJ1ZSxcImNhbkNoYW5nZVZpcnR1YWxCYWNrZ3JvdW5kXCI6dHJ1ZX19XSxcInNlbGVjdGVkUm9sZVwiOlwic3BlYWtlclwifSIsIm5hbWUiOiJQLTFhYWdtYWkiLCJ2aWRlbyI6eyJyb29tSm9pbiI6dHJ1ZSwicm9vbSI6InRlc3Qtcm9vbS11cXJ3ajZsZjE3Nnk1anEiLCJjYW5QdWJsaXNoIjp0cnVlLCJjYW5TdWJzY3JpYmUiOnRydWUsImNhblB1Ymxpc2hEYXRhIjp0cnVlLCJjYW5VcGRhdGVPd25NZXRhZGF0YSI6dHJ1ZX0sImlzcyI6ImRldmtleSIsImV4cCI6MTc1ODczMTYyNiwibmJmIjowLCJzdWIiOiJQLTFhYWdtYWkifQ.pC8NFcp7U44kWosjPNlaV67Vgr_f8BlFd3Ni4x6_tR0", - "domain": "localhost", - "path": "/", - "expires": -1, - "httpOnly": true, - "secure": false, - "sameSite": "Strict" - } - ], - "origins": [ - { - "origin": "http://localhost:6080", - "localStorage": [ - { - "name": "ovMeet-theme", - "value": "light" - }, - { - "name": "ovComponents-tab_1758724425107_wtx80a2div_333_cameraEnabled", - "value": "{\"item\":true}" - }, - { - "name": "ovComponents-theme", - "value": "{\"item\":\"light\"}" - }, - { - "name": "ovComponents-virtualBg", - "value": "{\"item\":\"2\"}" - }, - { - "name": "ovComponents-tab_1758724425107_wtx80a2div_333_microphoneEnabled", - "value": "{\"item\":true}" - }, - { - "name": "ovComponents-activeTabs", - "value": "{\"item\":{\"tab_1758724425107_wtx80a2div_333\":1758724425108}}" - }, - { - "name": "ovMeet-participantName", - "value": "P-1aagmai" - } - ] - } - ] -} \ No newline at end of file diff --git a/meet-ce/frontend/webcomponent/tests/e2e/core/events.test.ts b/meet-ce/frontend/webcomponent/tests/e2e/core/events.test.ts index 69f079a..089b6be 100644 --- a/meet-ce/frontend/webcomponent/tests/e2e/core/events.test.ts +++ b/meet-ce/frontend/webcomponent/tests/e2e/core/events.test.ts @@ -8,6 +8,7 @@ import { leaveRoom, prepareForJoiningRoom } from '../../helpers/function-helpers'; +import { LeftEventReason } from '@openvidu-meet/typings'; let subscribedToAppErrors = false; @@ -49,49 +50,274 @@ test.describe('Web Component E2E Tests', () => { }); test.describe('Event Handling', () => { - test('should successfully join as moderator and receive joined event', async ({ page }) => { - await joinRoomAs('moderator', participantName, page); - await page.waitForSelector('.event-joined'); - const joinElements = await page.locator('.event-joined').all(); - expect(joinElements.length).toBe(1); + test.describe('JOINED Event', () => { + test('should receive joined event when joining as moderator', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + const joinElements = await page.locator('.event-joined').all(); + expect(joinElements.length).toBe(1); + + // Verify event payload contains required data + const eventText = await joinElements[0].textContent(); + expect(eventText).toContain('roomId'); + expect(eventText).toContain('participantIdentity'); + expect(eventText).toContain(roomId); + }); + + test('should receive joined event when joining as speaker', async ({ page }) => { + await joinRoomAs('speaker', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + const joinElements = await page.locator('.event-joined').all(); + expect(joinElements.length).toBe(1); + + // Verify event payload contains required data + const eventText = await joinElements[0].textContent(); + expect(eventText).toContain('roomId'); + expect(eventText).toContain('participantIdentity'); + expect(eventText).toContain(roomId); + }); + + test('should receive only one joined event per join action', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + // Wait a bit to ensure no duplicate events + await page.waitForTimeout(1000); + + const joinElements = await page.locator('.event-joined').all(); + expect(joinElements.length).toBe(1); + }); }); - test('should successfully join as speaker and receive joined event', async ({ page }) => { - await joinRoomAs('speaker', participantName, page); - await page.waitForSelector('.event-joined'); - const joinElements = await page.locator('.event-joined').all(); - expect(joinElements.length).toBe(1); + test.describe('LEFT Event', () => { + test('should receive left event with voluntary_leave reason when using leave command', async ({ + page + }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + await page.click('#leave-room-btn'); + await page.waitForSelector('.event-left', { timeout: 10000 }); + + const leftElements = await page.locator('.event-left').all(); + expect(leftElements.length).toBe(1); + + // Verify event payload contains required data including reason + const eventText = await leftElements[0].textContent(); + expect(eventText).toContain('roomId'); + expect(eventText).toContain('participantIdentity'); + expect(eventText).toContain('reason'); + expect(eventText).toContain(LeftEventReason.VOLUNTARY_LEAVE); + }); + + test('should receive left event with voluntary_leave reason when using disconnect button', async ({ + page + }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + await leaveRoom(page, 'moderator'); + await page.waitForSelector('.event-left', { timeout: 10000 }); + + const leftElements = await page.locator('.event-left').all(); + expect(leftElements.length).toBe(1); + + // Verify event payload + const eventText = await leftElements[0].textContent(); + expect(eventText).toContain('reason'); + expect(eventText).toContain(LeftEventReason.VOLUNTARY_LEAVE); + }); + + test('should receive left event with meeting_ended reason when moderator ends meeting', async ({ + page + }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + await page.click('#end-meeting-btn'); + await page.waitForSelector('.event-left', { timeout: 10000 }); + + const leftElements = await page.locator('.event-left').all(); + expect(leftElements.length).toBe(1); + + // Verify event payload contains meeting_ended_by_self reason + const eventText = await leftElements[0].textContent(); + expect(eventText).toContain('reason'); + expect(eventText).toContain(LeftEventReason.MEETING_ENDED); + }); + + test('should receive left event when speaker leaves room', async ({ page }) => { + await joinRoomAs('speaker', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + await leaveRoom(page, 'speaker'); + await page.waitForSelector('.event-left', { timeout: 10000 }); + + const leftElements = await page.locator('.event-left').all(); + expect(leftElements.length).toBe(1); + + // Verify event payload + const eventText = await leftElements[0].textContent(); + expect(eventText).toContain('roomId'); + expect(eventText).toContain('participantIdentity'); + expect(eventText).toContain('reason'); + }); }); - test('should successfully join to room and receive left event when using leave command', async ({ page }) => { - await joinRoomAs('moderator', participantName, page); + test.describe('CLOSED Event', () => { + test('should receive closed event after leaving as moderator', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); - await page.click('#leave-room-btn'); - await page.waitForSelector('.event-left'); - const leftElements = await page.locator('.event-left').all(); - expect(leftElements.length).toBe(1); + await page.click('#leave-room-btn'); + await page.waitForSelector('.event-left', { timeout: 10000 }); + + // The closed event should be emitted after the left event + // Wait for a reasonable amount of time for the closed event + try { + await page.waitForSelector('.event-closed', { timeout: 5000 }); + const closedElements = await page.locator('.event-closed').all(); + expect(closedElements.length).toBeGreaterThanOrEqual(1); + } catch (e) { + // Closed event might not always be emitted depending on the flow + console.log('Closed event not received - this might be expected behavior'); + } + }); + + test('should receive closed event after ending meeting', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + await page.click('#end-meeting-btn'); + await page.waitForSelector('.event-left', { timeout: 10000 }); + + // Wait for closed event after ending meeting + try { + await page.waitForSelector('.event-closed', { timeout: 5000 }); + const closedElements = await page.locator('.event-closed').all(); + expect(closedElements.length).toBeGreaterThanOrEqual(1); + } catch (e) { + console.log('Closed event not received - this might be expected behavior'); + } + }); }); - test('should successfully join to room and receive left event when using disconnect button', async ({ - page - }) => { - await joinRoomAs('moderator', participantName, page); + test.describe('Event Sequences', () => { + test('should receive events in correct order: joined -> left', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); - await leaveRoom(page, 'moderator'); - await page.waitForSelector('.event-left'); - const leftElements = await page.locator('.event-left').all(); - expect(leftElements.length).toBe(1); + // Verify joined event is received first + let joinElements = await page.locator('.event-joined').all(); + expect(joinElements.length).toBe(1); + + await page.click('#leave-room-btn'); + await page.waitForSelector('.event-left', { timeout: 10000 }); + + // Verify both events are present + const leftElements = await page.locator('.event-left').all(); + expect(leftElements.length).toBe(1); + + // Verify joined event is still present + joinElements = await page.locator('.event-joined').all(); + expect(joinElements.length).toBe(1); + }); }); - test('should successfully join to room and receive left event when using end meeting command', async ({ - page - }) => { - await joinRoomAs('moderator', participantName, page); + test.describe('Event Payload Validation', () => { + test('should include correct roomId in joined event payload', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); - await page.click('#end-meeting-btn'); - await page.waitForSelector('.event-left'); - const meetingEndedElements = await page.locator('.event-left').all(); - expect(meetingEndedElements.length).toBe(1); + const joinElements = await page.locator('.event-joined').all(); + const eventText = await joinElements[0].textContent(); + + // Parse the event text to extract the payload + expect(eventText).toContain(roomId); + expect(eventText).toContain('"roomId"'); + }); + + test('should include participantIdentity in joined event payload', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + const joinElements = await page.locator('.event-joined').all(); + const eventText = await joinElements[0].textContent(); + + expect(eventText).toContain('"participantIdentity"'); + // The participantIdentity should be present (actual value may vary) + expect(eventText).toMatch(/participantIdentity.*:/); + }); + + test('should include all required fields in left event payload', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + await page.click('#leave-room-btn'); + await page.waitForSelector('.event-left', { timeout: 10000 }); + + const leftElements = await page.locator('.event-left').all(); + const eventText = await leftElements[0].textContent(); + + // Verify all required fields are present + expect(eventText).toContain('"roomId"'); + expect(eventText).toContain('"participantIdentity"'); + expect(eventText).toContain('"reason"'); + expect(eventText).toContain(roomId); + }); + + test('should have valid reason in left event payload', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + await page.click('#leave-room-btn'); + await page.waitForSelector('.event-left', { timeout: 10000 }); + + const leftElements = await page.locator('.event-left').all(); + const eventText = await leftElements[0].textContent(); + + // Check for valid reason values from LeftEventReason enum + const validReasons = Object.values(LeftEventReason); + + const hasValidReason = validReasons.some((reason) => eventText.includes(reason)); + expect(hasValidReason).toBe(true); + }); + }); + + test.describe('Event Error Handling', () => { + test('should handle joining and immediately leaving', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + + // Leave immediately after join (without waiting for full connection) + await page.waitForTimeout(500); // Minimal wait + await page.click('#leave-room-btn'); + + // Should still receive left event + await page.waitForSelector('.event-left', { timeout: 10000 }); + const leftElements = await page.locator('.event-left').all(); + expect(leftElements.length).toBe(1); + }); + + test('should not emit duplicate events on rapid actions', async ({ page }) => { + await joinRoomAs('moderator', participantName, page); + await page.waitForSelector('.event-joined', { timeout: 10000 }); + + // Rapid clicking on leave button + await page.click('#leave-room-btn'); + await page.click('#leave-room-btn').catch(() => { + /* Button might not be available */ + }); + await page.click('#leave-room-btn').catch(() => { + /* Button might not be available */ + }); + + await page.waitForSelector('.event-left', { timeout: 10000 }); + await page.waitForTimeout(1000); // Wait for any potential duplicate events + + // Should only have one left event + const leftElements = await page.locator('.event-left').all(); + expect(leftElements.length).toBe(1); + }); }); }); }); diff --git a/meet-ce/frontend/webcomponent/tests/e2e/core/moderation.test.ts b/meet-ce/frontend/webcomponent/tests/e2e/core/moderation.test.ts new file mode 100644 index 0000000..6b6e08e --- /dev/null +++ b/meet-ce/frontend/webcomponent/tests/e2e/core/moderation.test.ts @@ -0,0 +1,387 @@ +import { expect, test, Page, BrowserContext } from '@playwright/test'; +import { MEET_TESTAPP_URL } from '../../config.js'; +import { + createTestRoom, + deleteAllRecordings, + deleteAllRooms, + getIframeInShadowDom, + getLocalParticipantId, + getParticipantIdByName, + interactWithElementInIframe, + isShareLinkOverlayyHidden, + joinRoomAs, + leaveRoom, + makeParticipantModerator, + openParticipantsPanel, + prepareForJoiningRoom, + removeParticipantModerator, + waitForElementInIframe +} from '../../helpers/function-helpers.js'; + +let subscribedToAppErrors = false; + +/** + * Test suite for moderation features in OpenVidu Meet + * Tests moderator-specific functionality including share link overlay, + * moderator badges, and moderation controls (make/unmake moderator, kick participant) + */ +test.describe('Moderation Functionality Tests', () => { + let roomId: string; + let moderatorName: string; + let speakerName: string; + + // ========================================== + // SETUP & TEARDOWN + // ========================================== + + test.beforeAll(async () => { + // Create a test room before all tests + roomId = await createTestRoom('moderation-test-room'); + }); + + test.beforeEach(async ({ page }) => { + if (!subscribedToAppErrors) { + page.on('console', (msg) => { + const type = msg.type(); + const tag = type === 'error' ? 'ERROR' : type === 'warning' ? 'WARNING' : 'LOG'; + console.log('[' + tag + ']', msg.text()); + }); + subscribedToAppErrors = true; + } + + moderatorName = `Moderator-${Math.random().toString(36).substring(2, 9)}`; + speakerName = `Speaker-${Math.random().toString(36).substring(2, 9)}`; + }); + + test.afterEach(async ({ context }) => { + // Save storage state after each test + await context.storageState({ path: 'test_localstorage_state.json' }); + }); + + test.afterAll(async ({ browser }) => { + const tempContext = await browser.newContext(); + const tempPage = await tempContext.newPage(); + await deleteAllRooms(tempPage); + await deleteAllRecordings(tempPage); + + await tempContext.close(); + await tempPage.close(); + }); + + // ========================================== + // SHARE LINK OVERLAY TESTS + // ========================================== + + test.describe('Share Link Overlay', () => { + test('should show share link overlay when moderator is alone in the room', async ({ page }) => { + // Moderator joins the room + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await joinRoomAs('moderator', moderatorName, page); + + // Wait for session to be established + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + + // Check that share link overlay is visible + const shareLinkOverlay = await waitForElementInIframe(page, '#share-link-overlay', { + state: 'visible', + timeout: 5000 + }); + await expect(shareLinkOverlay).toBeVisible(); + + await leaveRoom(page, 'moderator'); + }); + + test('should hide share link overlay when other participants join the room', async ({ page, browser }) => { + // Moderator joins the room + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await joinRoomAs('moderator', moderatorName, page); + + // Wait for session and check overlay is visible + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + const shareLinkOverlay = await waitForElementInIframe(page, '#share-link-overlay', { + state: 'visible', + timeout: 5000 + }); + await expect(shareLinkOverlay).toBeVisible(); + + // Second participant (speaker) joins + const speakerContext = await browser.newContext(); + const speakerPage = await speakerContext.newPage(); + await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId); + await joinRoomAs('speaker', speakerName, speakerPage); + + // Wait for remote participant to be visible in moderator's view + await waitForElementInIframe(page, '.OV_stream.remote', { state: 'visible', timeout: 10000 }); + + // Wait a moment for the overlay to hide (give it more time) + await page.waitForTimeout(3000); + + // Check that share link overlay is no longer visible for moderator + const isHidden = await isShareLinkOverlayyHidden(page, '#share-link-overlay'); + expect(isHidden).toBeTruthy(); + + // Cleanup + await leaveRoom(speakerPage); + await leaveRoom(page, 'moderator'); + await speakerContext.close(); + }); + test('should not show share link overlay when user is not a moderator', async ({ page }) => { + // Speaker joins the room + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await joinRoomAs('speaker', speakerName, page); + + // Wait for session to be established + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + await page.waitForTimeout(2000); + + // Check that share link overlay is not visible + const isHidden = await isShareLinkOverlayyHidden(page, '#share-link-overlay'); + expect(isHidden).toBeTruthy(); + + await leaveRoom(page); + }); + }); + + // ========================================== + // MODERATOR BADGE AND CONTROLS TESTS + // ========================================== + + test.describe('Moderator Badge and Controls', () => { + test('should show moderator badge and controls when making participant a moderator', async ({ + page, + browser + }) => { + // Moderator joins the room + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await joinRoomAs('moderator', moderatorName, page); + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + + // Speaker joins the room + const speakerContext = await browser.newContext(); + const speakerPage = await speakerContext.newPage(); + await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId); + await joinRoomAs('speaker', speakerName, speakerPage); + await waitForElementInIframe(speakerPage, 'ov-session', { state: 'visible' }); + + // Wait for remote participant to appear in both views + await waitForElementInIframe(page, '.OV_stream.remote', { state: 'visible', timeout: 10000 }); + await page.waitForTimeout(2000); + + // Moderator opens participants panel + await openParticipantsPanel(page); + + // Get speaker's participant ID + const speakerParticipantId = await getParticipantIdByName(page, speakerName); + + if (!speakerParticipantId) { + throw new Error(`Could not find speaker participant ID for: ${speakerName}`); + } + + // Make speaker a moderator + await makeParticipantModerator(page, speakerParticipantId); + + // Speaker opens their participants panel + await openParticipantsPanel(speakerPage); + + // Get speaker's own participant ID from their page + const speakerOwnParticipantId = await getLocalParticipantId(speakerPage); + + if (!speakerOwnParticipantId) { + throw new Error('Could not find speaker own participant ID'); + } + + const moderatorBadge = await waitForElementInIframe( + speakerPage, + `#moderator-badge-${speakerOwnParticipantId}`, + { + state: 'visible', + timeout: 10000 + } + ); + await expect(moderatorBadge).toBeVisible(); + + // Speaker (now moderator) should be able to see moderation controls + // We verify by checking that at least one .moderation-controls div exists in the DOM + const frameLocator = await getIframeInShadowDom(speakerPage); + const moderationControlsCount = await frameLocator.locator('.moderation-controls').count(); + + // Should have at least 1 moderation-controls div (for the original moderator) + expect(moderationControlsCount).toBeGreaterThanOrEqual(1); + + // Cleanup + await leaveRoom(speakerPage, 'moderator'); + await leaveRoom(page, 'moderator'); + await speakerContext.close(); + }); + + test('should remove moderator badge and controls when revoking moderator role', async ({ page, browser }) => { + // Moderator joins the room + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await joinRoomAs('moderator', moderatorName, page); + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + + // Speaker joins the room + const speakerContext = await browser.newContext(); + const speakerPage = await speakerContext.newPage(); + await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId); + await joinRoomAs('speaker', speakerName, speakerPage); + await waitForElementInIframe(speakerPage, 'ov-session', { state: 'visible' }); + + // Wait for remote participant to appear + await waitForElementInIframe(page, '.OV_stream.remote', { state: 'visible', timeout: 10000 }); + await page.waitForTimeout(2000); + + // Moderator opens participants panel + await openParticipantsPanel(page); + + // Get speaker's participant ID + const speakerParticipantId = await getParticipantIdByName(page, speakerName); + + if (!speakerParticipantId) { + throw new Error(`Could not find speaker participant ID for: ${speakerName}`); + } + + // Make speaker a moderator + await makeParticipantModerator(page, speakerParticipantId); + + // Verify speaker has moderator badge + await openParticipantsPanel(speakerPage); + + const speakerOwnParticipantId = await getLocalParticipantId(speakerPage); + + if (!speakerOwnParticipantId) { + throw new Error('Could not find speaker own participant ID'); + } + + const moderatorBadge = await waitForElementInIframe( + speakerPage, + `#moderator-badge-${speakerOwnParticipantId}`, + { + state: 'visible', + timeout: 10000 + } + ); + await expect(moderatorBadge).toBeVisible(); + + // Now revoke moderator role + await removeParticipantModerator(page, speakerParticipantId); + + // Speaker should no longer see moderator badge + await waitForElementInIframe(speakerPage, `#moderator-badge-${speakerOwnParticipantId}`, { + state: 'hidden', + timeout: 10000 + }); + + // Speaker should not see moderation controls (verify they can't see controls for the moderator) + const moderatorParticipantId = await getParticipantIdByName(speakerPage, moderatorName); + if (moderatorParticipantId) { + // If speaker is no longer moderator, moderation-controls div should be hidden + await waitForElementInIframe(speakerPage, `#moderation-controls-${moderatorParticipantId}`, { + state: 'hidden', + timeout: 5000 + }); + } + + // Cleanup + await leaveRoom(speakerPage); + await leaveRoom(page, 'moderator'); + await speakerContext.close(); + }); + }); + + // ========================================== + // ORIGINAL MODERATOR PROTECTION TESTS + // ========================================== + + test.describe('Original Moderator Protection', () => { + test('should not allow removing moderator role from original moderator', async ({ page, browser }) => { + // Moderator joins the room + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await joinRoomAs('moderator', moderatorName, page); + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + + // Speaker joins as second moderator + const speakerContext = await browser.newContext(); + const speakerPage = await speakerContext.newPage(); + await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId); + await joinRoomAs('moderator', speakerName, speakerPage); + await waitForElementInIframe(speakerPage, 'ov-session', { state: 'visible' }); + + // Wait for both participants to be in the session + await page.waitForTimeout(2000); + + // Second moderator opens participants panel + await openParticipantsPanel(speakerPage); + + // Get original moderator's participant ID + const originalModParticipantId = await getParticipantIdByName(speakerPage, moderatorName); + + if (!originalModParticipantId) { + throw new Error(`Could not find original moderator participant ID for: ${moderatorName}`); + } + + // Check that "remove moderator" button is NOT present for original moderator + // The button should be in hidden state (not rendered) + try { + await waitForElementInIframe(speakerPage, `#remove-moderator-btn-${originalModParticipantId}`, { + state: 'hidden', + timeout: 2000 + }); + // If we get here, the button is correctly hidden + } catch (error) { + // If the element doesn't exist at all, that's also correct + console.log('✅ Remove moderator button not found for original moderator (as expected)'); + } + + // Cleanup + await leaveRoom(speakerPage, 'moderator'); + await leaveRoom(page, 'moderator'); + await speakerContext.close(); + }); + + test('should not allow kicking original moderator from the room', async ({ page, browser }) => { + // Moderator joins the room + await prepareForJoiningRoom(page, MEET_TESTAPP_URL, roomId); + await joinRoomAs('moderator', moderatorName, page); + await waitForElementInIframe(page, 'ov-session', { state: 'visible' }); + + // Speaker joins as second moderator + const speakerContext = await browser.newContext(); + const speakerPage = await speakerContext.newPage(); + await prepareForJoiningRoom(speakerPage, MEET_TESTAPP_URL, roomId); + await joinRoomAs('moderator', speakerName, speakerPage); + await waitForElementInIframe(speakerPage, 'ov-session', { state: 'visible' }); + + // Wait for both participants to be in the session + await page.waitForTimeout(2000); + + // Second moderator opens participants panel + await openParticipantsPanel(speakerPage); + + // Get original moderator's participant ID + const originalModParticipantId = await getParticipantIdByName(speakerPage, moderatorName); + + if (!originalModParticipantId) { + throw new Error(`Could not find original moderator participant ID for: ${moderatorName}`); + } + + // Check that "kick participant" button is NOT present for original moderator + // The button should be in hidden state (not rendered) + try { + await waitForElementInIframe(speakerPage, `#kick-participant-btn-${originalModParticipantId}`, { + state: 'hidden', + timeout: 2000 + }); + // If we get here, the button is correctly hidden + } catch (error) { + // If the element doesn't exist at all, that's also correct + console.log('✅ Kick participant button not found for original moderator (as expected)'); + } + + // Cleanup + await leaveRoom(speakerPage, 'moderator'); + await leaveRoom(page, 'moderator'); + await speakerContext.close(); + }); + }); +}); diff --git a/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts b/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts index 759784f..5198ec0 100644 --- a/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts +++ b/meet-ce/frontend/webcomponent/tests/helpers/function-helpers.ts @@ -401,3 +401,148 @@ export const closeMoreOptionsMenu = async (page: Page) => { await interactWithElementInIframe(page, 'body', { action: 'click' }); await page.waitForTimeout(500); // Wait for menu to close }; + +// ========================================== +// MODERATION HELPER FUNCTIONS +// ========================================== + +/** + * Gets the participant ID (sid) of a participant by name from a specific page view + * @param page - Playwright page object + * @param participantName - Name of the participant to find + * @returns Promise resolving to the participant ID (sid) or empty string if not found + */ +export const getParticipantIdByName = async (page: Page, participantName: string): Promise => { + // Get iframe using the proper Playwright method + const frameLocator = await getIframeInShadowDom(page); + + // Find all participant containers + const participantContainers = frameLocator.locator('[data-participant-id]'); + const count = await participantContainers.count(); + console.log(`🔍 Found ${count} participant containers`); + + // Iterate through participants to find the matching name + for (let i = 0; i < count; i++) { + const container = participantContainers.nth(i); + const nameElement = container.locator('.participant-name-text'); + const pName = await nameElement.textContent(); + const pId = await container.getAttribute('data-participant-id'); + + console.log(`👤 Participant: "${pName?.trim()}" with ID: ${pId}`); + + if (pName?.trim() === participantName) { + console.log(`✅ Found matching participant: ${participantName} with ID: ${pId}`); + return pId || ''; + } + } + + console.log(`❌ Could not find participant with name: ${participantName}`); + return ''; +}; + +/** + * Gets the current user's own participant ID (sid) + * @param page - Playwright page object + * @returns Promise resolving to the local participant's ID (sid) or empty string if not found + */ +export const getLocalParticipantId = async (page: Page): Promise => { + // Get iframe using the proper Playwright method + const frameLocator = await getIframeInShadowDom(page); + + // Find all participant containers + const participantContainers = frameLocator.locator('[data-participant-id]'); + const count = await participantContainers.count(); + console.log(`🔍 Found ${count} participant containers`); + + // Iterate through participants to find the local one (has .local-indicator) + for (let i = 0; i < count; i++) { + const container = participantContainers.nth(i); + const youLabel = container.locator('.local-indicator'); + const hasYouLabel = (await youLabel.count()) > 0; + + if (hasYouLabel) { + const nameElement = container.locator('.participant-name-text'); + const participantName = await nameElement.textContent(); + const pId = await container.getAttribute('data-participant-id'); + + console.log(`✅ Found local participant: "${participantName?.trim()}" with ID: ${pId}`); + return pId || ''; + } + } + + console.log('❌ Could not find local participant'); + return ''; +}; + +/** + * Opens the participants panel and waits for it to be visible + * @param page - Playwright page object + */ +export const openParticipantsPanel = async (page: Page): Promise => { + await waitForElementInIframe(page, '#participants-panel-btn'); + await interactWithElementInIframe(page, '#participants-panel-btn', { action: 'click' }); + await waitForElementInIframe(page, 'ov-participants-panel', { state: 'visible' }); + await page.waitForTimeout(1000); // Wait for panel to fully load +}; + +/** + * Makes a participant a moderator by clicking the make-moderator button + * @param page - Playwright page object (moderator's page) + * @param participantId - The participant ID (sid) to promote + */ +export const makeParticipantModerator = async (page: Page, participantId: string): Promise => { + const makeModeratorbtn = await waitForElementInIframe(page, `#make-moderator-btn-${participantId}`, { + state: 'visible', + timeout: 10000 + }); + await makeModeratorbtn.click(); + await page.waitForTimeout(2000); // Wait for role change to propagate +}; + +/** + * Removes moderator role from a participant by clicking the remove-moderator button + * @param page - Playwright page object (moderator's page) + * @param participantId - The participant ID (sid) to demote + */ +export const removeParticipantModerator = async (page: Page, participantId: string): Promise => { + const removeModeratorbtn = await waitForElementInIframe(page, `#remove-moderator-btn-${participantId}`, { + state: 'visible', + timeout: 10000 + }); + await removeModeratorbtn.click(); + await page.waitForTimeout(2000); // Wait for role change to propagate +}; + +/** + * Checks if an overlay element is hidden in the iframe + * An element is considered hidden if: + * - It doesn't exist in the DOM (removed) + * - Has display: none + * - Has visibility: hidden + * - Has opacity: 0 + * @param page - Playwright page object + * @param overlaySelector - CSS selector for the overlay element + * @returns Promise resolving to true if the overlay is hidden, false otherwise + */ +export const isShareLinkOverlayyHidden = async (page: Page, overlaySelector: string): Promise => { + const frameLocator = await getIframeInShadowDom(page); + const overlay = frameLocator.locator(overlaySelector); + const count = await overlay.count(); + + // Element doesn't exist in the DOM + if (count === 0) { + console.log('✅ Overlay element not found in DOM (removed)'); + return true; + } + + // Check if element is hidden via CSS + const isVisible = await overlay.isVisible().catch(() => false); + + if (!isVisible) { + console.log('✅ Overlay is hidden (display: none, visibility: hidden, or opacity: 0)'); + return true; + } + + console.log('❌ Overlay is still visible'); + return false; +}; diff --git a/meet.sh b/meet.sh index 5b6eddd..b339a76 100755 --- a/meet.sh +++ b/meet.sh @@ -393,6 +393,12 @@ add_common_dev_commands() { CMD_NAMES+=("shared-meet-components") CMD_COLORS+=("bgYellow.dark") CMD_COMMANDS+=("wait-on ${components_path} && pnpm --filter @openvidu-meet/frontend run lib:serve") + + # Testapp + CMD_NAMES+=("testapp") + CMD_COLORS+=("blue") + CMD_COMMANDS+=("node ./scripts/dev/watch-with-typings-guard.mjs 'pnpm run dev:testapp'") + } # Helper: Add CE-specific commands (backend, frontend) @@ -473,6 +479,7 @@ add_browsersync_commands() { const local = urls?.get('local') ?? 'undefined'; const external = urls?.get('external') ?? 'undefined'; console.log(chalk.cyanBright(' OpenVidu Meet: http://localhost:6080')); + console.log(chalk.cyanBright(' OpenVidu Meet Testapp: http://localhost:5080')); console.log(chalk.cyanBright(' Live reload Local: ' + local)); console.log(chalk.cyanBright(' Live reload LAN: ' + external));