diff --git a/.vscode/settings.json b/.vscode/settings.json index c31d0e61..8342d1c0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,6 +20,8 @@ "litellm", "networkidle", "nonexistentproject", + "noopener", + "noreferrer", "PostgreSQL", "PostgREST", "primeng", diff --git a/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.html b/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.html index fbc67a83..aaec9b61 100644 --- a/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.html +++ b/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.html @@ -8,7 +8,9 @@
@if (project()?.logo_url) { - {{ project()?.name }} +
+ {{ project()?.name }} +
} @@ -210,40 +212,55 @@

{{ user.name }}

- - -
-
- @if (hasAutoJoined()) { - Meeting opened in a new tab. If it didn't open, use the button to join manually. - } @else if (canJoinMeeting()) { - Ready to join as {{ user.name }} - } @else { -
- You may only join the meeting up to {{ meeting().early_join_time_minutes || 10 }} minutes before - the start time. + @if (joinUrlError()) { + + + {{ joinUrlError() }} + + + } @else { + + +
+
+ @if (isLoadingJoinUrl()) { +
+ + Loading meeting link... +
+ } @else if (canJoinMeeting()) { + Ready to join as {{ user.name }} + } @else { +
+ You may only join the meeting up to {{ meeting().early_join_time_minutes || 10 }} minutes before + the start time. +
+ } +
+ @if (!isLoadingJoinUrl() && !joinUrlError()) { +
+
}
-
- -
-
- - + + + }

@@ -332,15 +349,20 @@

Enter your information

} -
+
+ @if (joinUrlError()) { + {{ joinUrlError() }} + } + [href]="fetchedJoinUrl()" + [disabled]="!fetchedJoinUrl() || isLoadingJoinUrl()" + [icon]="isLoadingJoinUrl() ? 'fa-light fa-spinner-third fa-spin' : 'fa-light fa-sign-in'" + target="_blank" + rel="noopener noreferrer" + [attr.data-testid]="'join-meeting-button-form'">
diff --git a/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.ts b/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.ts index 22be3a43..17b643a6 100644 --- a/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.ts +++ b/apps/lfx-one/src/app/modules/meeting/meeting-join/meeting-join.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { HttpParams } from '@angular/common/http'; -import { Component, computed, effect, inject, OnDestroy, signal, Signal, WritableSignal } from '@angular/core'; +import { Component, computed, inject, signal, Signal, WritableSignal } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -23,7 +23,7 @@ import { UserService } from '@services/user.service'; import { MessageService } from 'primeng/api'; import { ToastModule } from 'primeng/toast'; import { TooltipModule } from 'primeng/tooltip'; -import { catchError, combineLatest, finalize, map, of, switchMap, tap } from 'rxjs'; +import { catchError, combineLatest, debounceTime, filter, map, Observable, of, startWith, switchMap, take, tap } from 'rxjs'; @Component({ selector: 'lfx-meeting-join', @@ -46,7 +46,7 @@ import { catchError, combineLatest, finalize, map, of, switchMap, tap } from 'rx providers: [], templateUrl: './meeting-join.component.html', }) -export class MeetingJoinComponent implements OnDestroy { +export class MeetingJoinComponent { // Injected services private readonly messageService = inject(MessageService); private readonly activatedRoute = inject(ActivatedRoute); @@ -55,7 +55,6 @@ export class MeetingJoinComponent implements OnDestroy { private readonly userService = inject(UserService); // Class variables with types - public isJoining: WritableSignal; public authenticated: WritableSignal; public user: Signal = this.userService.user; public joinForm: FormGroup; @@ -68,18 +67,19 @@ export class MeetingJoinComponent implements OnDestroy { public password: WritableSignal = signal(null); public canJoinMeeting: Signal; public joinUrlWithParams: Signal; + public fetchedJoinUrl: Signal; + public isLoadingJoinUrl: WritableSignal = signal(false); + public joinUrlError: WritableSignal = signal(null); public attachments: Signal; - public hasAutoJoined: WritableSignal = signal(false); - private autoJoinTimeout: ReturnType | null = null; public messageSeverity: Signal<'success' | 'info' | 'warn'>; public messageIcon: Signal; + private hasAutoJoined: WritableSignal = signal(false); // Form value signals for reactivity private formValues: Signal<{ name: string; email: string; organization: string }>; public constructor() { // Initialize all class variables - this.isJoining = signal(false); this.authenticated = this.userService.authenticated; this.meeting = this.initializeMeeting(); this.currentOccurrence = this.initializeCurrentOccurrence(); @@ -90,130 +90,58 @@ export class MeetingJoinComponent implements OnDestroy { this.returnTo = this.initializeReturnTo(); this.canJoinMeeting = this.initializeCanJoinMeeting(); this.joinUrlWithParams = this.initializeJoinUrlWithParams(); + this.fetchedJoinUrl = this.initializeFetchedJoinUrl(); this.attachments = this.initializeAttachments(); this.messageSeverity = this.initializeMessageSeverity(); this.messageIcon = this.initializeMessageIcon(); - - // Auto-join effect for signed-in users - use allowSignalWrites for state updates - effect( - () => { - const authenticated = this.authenticated(); - const user = this.user(); - const canJoinMeeting = this.canJoinMeeting(); - const hasAutoJoined = this.hasAutoJoined(); - const meeting = this.meeting(); - - // Clear any existing timeout - if (this.autoJoinTimeout) { - clearTimeout(this.autoJoinTimeout); - this.autoJoinTimeout = null; - } - - // Schedule auto-join only if conditions are met - if (authenticated && user && user.email && canJoinMeeting && !hasAutoJoined && meeting && meeting.uid) { - // Set a timeout to prevent rapid-fire execution - this.autoJoinTimeout = setTimeout(() => { - this.performAutoJoin(); - }, 500); // Small delay to let all signals settle - } - }, - { allowSignalWrites: true } - ); - } - - public ngOnDestroy(): void { - // Cleanup timeout on component destroy - if (this.autoJoinTimeout) { - clearTimeout(this.autoJoinTimeout); - this.autoJoinTimeout = null; - } + this.initializeAutoJoin(); } - public onJoinMeeting(): void { - if (!this.canJoinMeeting()) { - this.messageService.add({ - severity: 'warn', - summary: 'Meeting Not Available', - detail: 'The meeting has not started yet.', - }); - return; - } - - this.isJoining.set(true); - - this.meetingService - .getPublicMeetingJoinUrl(this.meeting().uid, this.meeting().password, { - email: this.authenticated() ? this.user()?.email : this.joinForm.get('email')?.value, - }) - .pipe(finalize(() => this.isJoining.set(false))) - .subscribe({ - next: (res) => { - this.meeting().join_url = res.join_url; - const joinUrlWithParams = this.buildJoinUrlWithParams(res.join_url); - this.openMeetingSecurely(joinUrlWithParams); - }, - error: ({ error }) => { - this.messageService.add({ severity: 'error', summary: 'Error', detail: error.error }); - }, - }); - } - - private performAutoJoin(): void { - // Double-check conditions before performing auto-join - const authenticated = this.authenticated(); - const user = this.user(); - const canJoinMeeting = this.canJoinMeeting(); - const hasAutoJoined = this.hasAutoJoined(); - const meeting = this.meeting(); - - if (!authenticated || !user || !user.email || !canJoinMeeting || hasAutoJoined || !meeting || !meeting.uid) { - return; // Conditions no longer met, abort - } - - // Auto-joining meeting for authenticated user - - // Mark as auto-joined immediately to prevent multiple attempts - this.hasAutoJoined.set(true); + private initializeAutoJoin(): void { + // Use toObservable to create an Observable from the signals, then subscribe once + // This executes only when all conditions are met + toObservable(this.fetchedJoinUrl) + .pipe( + // Take only the first emission where we have a valid URL and haven't auto-joined yet + filter((url) => { + const authenticated = this.authenticated(); + const user = this.user(); + const canJoin = this.canJoinMeeting(); + const alreadyJoined = this.hasAutoJoined(); + + return !!url && authenticated && !!user && !!user.email && canJoin && !alreadyJoined; + }), + // Take only the first valid URL + take(1) + ) + .subscribe((url) => { + // Mark as auto-joined to prevent multiple attempts + this.hasAutoJoined.set(true); - // Show a notification that we're auto-joining - this.messageService.add({ - severity: 'info', - summary: 'Auto-joining Meeting', - detail: 'Automatically opening the meeting for you...', - life: 3000, - }); + // Open the meeting URL in a new tab + if (typeof window !== 'undefined' && url) { + const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); - // If meeting has a direct join URL, use it - if (meeting.join_url) { - const joinUrlWithParams = this.buildJoinUrlWithParams(meeting.join_url); - this.openMeetingSecurely(joinUrlWithParams); - } else { - // Otherwise, fetch the join URL first - this.meetingService - .getPublicMeetingJoinUrl(meeting.uid, meeting.password, { - email: user.email, - }) - .subscribe({ - next: (res) => { - if (res.join_url) { - meeting.join_url = res.join_url; - const joinUrlWithParams = this.buildJoinUrlWithParams(res.join_url); - this.openMeetingSecurely(joinUrlWithParams); - } else { - throw new Error('No join URL received'); - } - }, - error: () => { + // Check if popup was blocked + if (!newWindow || newWindow.closed || typeof newWindow.closed === 'undefined') { + // Popup was blocked this.messageService.add({ - severity: 'error', - summary: 'Auto-join Failed', - detail: 'Could not automatically join the meeting. Please use the Join Meeting button.', + severity: 'warn', + summary: 'Popup Blocked', + detail: 'Your browser blocked the meeting window. Please click the "Join Meeting" button to open it manually.', life: 5000, }); - // Don't reset auto-join flag - we should only try once automatically - }, - }); - } + } else { + // Popup opened successfully + this.messageService.add({ + severity: 'success', + summary: 'Meeting Opened', + detail: 'The meeting has been opened in a new tab.', + life: 3000, + }); + } + } + }); } private initializeMeeting() { @@ -405,12 +333,8 @@ export class MeetingJoinComponent implements OnDestroy { private initializeMessageSeverity(): Signal<'success' | 'info' | 'warn'> { return computed(() => { - const hasAutoJoined = this.hasAutoJoined(); const canJoinMeeting = this.canJoinMeeting(); - if (hasAutoJoined) { - return 'success'; - } if (canJoinMeeting) { return 'info'; } @@ -420,12 +344,8 @@ export class MeetingJoinComponent implements OnDestroy { private initializeMessageIcon(): Signal { return computed(() => { - const hasAutoJoined = this.hasAutoJoined(); const canJoinMeeting = this.canJoinMeeting(); - if (hasAutoJoined) { - return 'fa-light fa-external-link'; - } if (canJoinMeeting) { return 'fa-light fa-check-circle'; } @@ -433,19 +353,72 @@ export class MeetingJoinComponent implements OnDestroy { }); } - private openMeetingSecurely(url: string): void { - // Check if we're running in the browser (not SSR) - if (typeof window === 'undefined') { - return; - } + private initializeFetchedJoinUrl(): Signal { + return toSignal( + combineLatest([toObservable(this.canJoinMeeting), this.joinForm.statusChanges.pipe(debounceTime(300), startWith(this.joinForm.status))]).pipe( + switchMap(([canJoin, formStatus]) => { + const meeting = this.meeting(); + const authenticated = this.authenticated(); + const user = this.user(); + + // Reset error state + this.joinUrlError.set(null); + + // Only fetch when meeting is joinable and we have necessary user info + if (!canJoin || !meeting?.uid) { + this.isLoadingJoinUrl.set(false); + return of(undefined); + } - // Try to open the meeting URL securely - const newWindow = window.open(url, '_blank', 'noopener,noreferrer'); + // Determine email based on authentication status + let email: string | undefined; - // Clear opener reference for security (prevent tabnabbing) - if (newWindow) { - newWindow.opener = null; - } + if (authenticated) { + // For authenticated users, use their email from user profile + email = user?.email; + if (!email) { + this.isLoadingJoinUrl.set(false); + return of(undefined); + } + } else { + // For unauthenticated users, form must be valid + if (formStatus !== 'VALID') { + this.isLoadingJoinUrl.set(false); + return of(undefined); + } + // Use email from form + email = this.joinForm.get('email')?.value; + if (!email) { + this.isLoadingJoinUrl.set(false); + return of(undefined); + } + } + + // Fetch join URL with the determined email + return this.fetchJoinUrl(meeting, email); + }) + ), + { initialValue: undefined } + ); + } + + private fetchJoinUrl(meeting: Meeting, email: string): Observable { + this.isLoadingJoinUrl.set(true); + + return this.meetingService.getPublicMeetingJoinUrl(meeting.uid, meeting.password, { email }).pipe( + map((res) => { + this.isLoadingJoinUrl.set(false); + if (res.join_url) { + return this.buildJoinUrlWithParams(res.join_url); + } + return undefined; + }), + catchError((error) => { + this.isLoadingJoinUrl.set(false); + this.joinUrlError.set(error?.error?.error || 'Failed to load meeting join URL. Please try again.'); + return of(undefined); + }) + ); } private initializeAttachments(): Signal { diff --git a/apps/lfx-one/src/app/shared/components/button/button.component.html b/apps/lfx-one/src/app/shared/components/button/button.component.html index e6e0c765..f9865a10 100644 --- a/apps/lfx-one/src/app/shared/components/button/button.component.html +++ b/apps/lfx-one/src/app/shared/components/button/button.component.html @@ -5,6 +5,8 @@ (undefined); public readonly href = input(undefined); + public readonly target = input(undefined); + public readonly rel = input(undefined); // Events public readonly onClick = output();