Skip to content

Commit adaaa66

Browse files
andrest50asithade
andauthored
feat(meetings): add recording and summary features for past meetings (#125)
* feat(meetings): add recording display for past meetings - Add PastMeetingRecording interfaces to shared package - Implement OpenSearch query for past meeting recordings - Create recording modal with URL copy and view functionality - Add recording icon to past meeting cards - Select session with largest total_size for share URL - Add View Recording button to open URL directly in new tab [LFXV2-667] Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <[email protected]> * feat(meetings): add summary modal and fix recording URL display - Add PastMeetingSummary interfaces to shared package - Implement OpenSearch query for past meeting summaries - Create summary modal with HTML content rendering - Add summary icon to past meeting cards - Display AI-generated summary with proper formatting - Fix recording modal URL display to show beginning of URL - Replace input with div element to control scroll position - Add click-to-select functionality for URL copying [LFXV2-667] Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <[email protected]> * feat(meetings): add edit and approve functionality for meeting summaries Allow users to edit AI-generated meeting summaries and approve them for viewing by others. Summaries can be edited independently of approval status, and approval is a separate action requiring explicit user confirmation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Andres Tobon <[email protected]> * feat(meetings): add fragment identifier to meeting cards for deep linking Add id attribute to meeting cards using meeting UID to enable deep linking to specific meetings via URL hash fragments. Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <[email protected]> * refactor(meetings): address PR review comments for recording and summary features - Use toSignal instead of effect for recording/summary data loading - Move recording/summary initialization to ngOnInit lifecycle hook - Replace click handler with native href for recording links - Convert summary modal to use reactive forms with lfx-textarea - Move error handling from service to component callers Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon <[email protected]> --------- Signed-off-by: Andres Tobon <[email protected]> Co-authored-by: Asitha de Silva <[email protected]>
1 parent f89bab0 commit adaaa66

File tree

12 files changed

+929
-2
lines changed

12 files changed

+929
-2
lines changed

apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.html

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,28 @@
4949
(click)="copyMeetingLink()"
5050
tooltip="Copy Meeting"></lfx-button>
5151
}
52+
@if (pastMeeting() && hasRecording()) {
53+
<lfx-button
54+
icon="fa-light fa-video"
55+
[text]="true"
56+
[rounded]="true"
57+
size="small"
58+
severity="secondary"
59+
data-testid="view-recording-button"
60+
(click)="openRecordingModal()"
61+
tooltip="View Recording"></lfx-button>
62+
}
63+
@if (pastMeeting() && hasSummary()) {
64+
<lfx-button
65+
icon="fa-light fa-file-lines"
66+
[text]="true"
67+
[rounded]="true"
68+
size="small"
69+
severity="secondary"
70+
data-testid="view-summary-button"
71+
(click)="openSummaryModal()"
72+
tooltip="View Summary"></lfx-button>
73+
}
5274
@if (meeting().visibility === 'public') {
5375
<lfx-button
5476
icon="fa-light fa-share"

apps/lfx-one/src/app/modules/project/meetings/components/meeting-card/meeting-card.component.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
MeetingRegistrant,
2525
PastMeeting,
2626
PastMeetingParticipant,
27+
PastMeetingRecording,
28+
PastMeetingSummary,
2729
} from '@lfx-one/shared';
2830
import { MeetingTimePipe } from '@pipes/meeting-time.pipe';
2931
import { MeetingService } from '@services/meeting.service';
@@ -37,7 +39,9 @@ import { BehaviorSubject, catchError, filter, finalize, map, of, switchMap, take
3739

3840
import { MeetingCommitteeModalComponent } from '../meeting-committee-modal/meeting-committee-modal.component';
3941
import { MeetingDeleteConfirmationComponent, MeetingDeleteResult } from '../meeting-delete-confirmation/meeting-delete-confirmation.component';
42+
import { RecordingModalComponent } from '../recording-modal/recording-modal.component';
4043
import { RegistrantModalComponent } from '../registrant-modal/registrant-modal.component';
44+
import { SummaryModalComponent } from '../summary-modal/summary-modal.component';
4145

4246
@Component({
4347
selector: 'lfx-meeting-card',
@@ -85,6 +89,20 @@ export class MeetingCardComponent implements OnInit {
8589
public registrants = this.initRegistrantsList();
8690
public pastMeetingParticipants = this.initPastMeetingParticipantsList();
8791
public registrantsLabel: Signal<string> = this.initRegistrantsLabel();
92+
public recording: WritableSignal<PastMeetingRecording | null> = signal(null);
93+
public recordingShareUrl: Signal<string | null> = computed(() => {
94+
const recording = this.recording();
95+
return recording ? this.getLargestSessionShareUrl(recording) : null;
96+
});
97+
public hasRecording: Signal<boolean> = computed(() => this.recordingShareUrl() !== null);
98+
public summary: WritableSignal<PastMeetingSummary | null> = signal(null);
99+
public summaryContent: Signal<string | null> = computed(() => {
100+
const summary = this.summary();
101+
return summary?.summary_data ? summary.summary_data.edited_content || summary.summary_data.content : null;
102+
});
103+
public summaryUid: Signal<string | null> = computed(() => this.summary()?.uid || null);
104+
public summaryApproved: Signal<boolean> = computed(() => this.summary()?.approved || false);
105+
public hasSummary: Signal<boolean> = computed(() => this.summaryContent() !== null);
88106
public additionalRegistrantsCount: WritableSignal<number> = signal(0);
89107
public additionalParticipantsCount: WritableSignal<number> = signal(0);
90108
public actionMenuItems: Signal<MenuItem[]> = this.initializeActionMenuItems();
@@ -130,6 +148,8 @@ export class MeetingCardComponent implements OnInit {
130148

131149
public ngOnInit(): void {
132150
this.attachments = this.initAttachments();
151+
this.initRecording();
152+
this.initSummary();
133153
}
134154

135155
public onRegistrantsToggle(event: Event): void {
@@ -240,6 +260,74 @@ export class MeetingCardComponent implements OnInit {
240260
});
241261
}
242262

263+
public openRecordingModal(): void {
264+
if (!this.recordingShareUrl()) {
265+
return;
266+
}
267+
268+
this.dialogService.open(RecordingModalComponent, {
269+
header: 'Meeting Recording',
270+
width: '650px',
271+
modal: true,
272+
closable: true,
273+
dismissableMask: true,
274+
data: {
275+
shareUrl: this.recordingShareUrl(),
276+
meetingTitle: this.meeting().title,
277+
},
278+
});
279+
}
280+
281+
public openSummaryModal(): void {
282+
if (!this.summaryContent() || !this.summaryUid()) {
283+
return;
284+
}
285+
286+
const ref = this.dialogService.open(SummaryModalComponent, {
287+
header: 'Meeting Summary',
288+
width: '800px',
289+
modal: true,
290+
closable: true,
291+
dismissableMask: true,
292+
data: {
293+
summaryContent: this.summaryContent(),
294+
summaryUid: this.summaryUid(),
295+
pastMeetingUid: this.meeting().uid,
296+
meetingTitle: this.meeting().title,
297+
approved: this.summaryApproved(),
298+
},
299+
});
300+
301+
// Update local content and approval status when changes are made
302+
ref.onClose.pipe(take(1)).subscribe((result?: { updated: boolean; content: string; approved: boolean }) => {
303+
if (result && result.updated) {
304+
const currentSummary = this.summary();
305+
if (currentSummary) {
306+
this.summary.set({
307+
...currentSummary,
308+
approved: result.approved,
309+
summary_data: {
310+
...currentSummary.summary_data,
311+
edited_content: result.content,
312+
},
313+
});
314+
}
315+
}
316+
});
317+
}
318+
319+
private getLargestSessionShareUrl(recording: PastMeetingRecording): string | null {
320+
if (!recording.sessions || recording.sessions.length === 0) {
321+
return null;
322+
}
323+
324+
const largestSession = recording.sessions.reduce((largest, current) => {
325+
return current.total_size > largest.total_size ? current : largest;
326+
});
327+
328+
return largestSession.share_url || null;
329+
}
330+
243331
private initMeetingRegistrantCount(): Signal<number> {
244332
return computed(() => {
245333
if (this.pastMeeting()) {
@@ -482,6 +570,30 @@ export class MeetingCardComponent implements OnInit {
482570
});
483571
}
484572

573+
private initRecording(): void {
574+
runInInjectionContext(this.injector, () => {
575+
toSignal(
576+
this.meetingService.getPastMeetingRecording(this.meetingInput().uid).pipe(
577+
catchError(() => of(null)),
578+
tap((recording) => this.recording.set(recording))
579+
),
580+
{ initialValue: null }
581+
);
582+
});
583+
}
584+
585+
private initSummary(): void {
586+
runInInjectionContext(this.injector, () => {
587+
toSignal(
588+
this.meetingService.getPastMeetingSummary(this.meetingInput().uid).pipe(
589+
catchError(() => of(null)),
590+
tap((summary) => this.summary.set(summary))
591+
),
592+
{ initialValue: null }
593+
);
594+
});
595+
}
596+
485597
private initAttendancePercentage(): Signal<number> {
486598
return computed(() => {
487599
if (this.pastMeeting()) {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="space-y-4" data-testid="recording-modal-container">
5+
<!-- Meeting Title -->
6+
<div class="mb-4">
7+
<h4 class="text-lg font-semibold text-gray-900 mb-1">{{ meetingTitle }}</h4>
8+
<p class="text-sm text-gray-600">Share this recording URL with others to view the meeting recording.</p>
9+
</div>
10+
11+
<!-- Share URL Display -->
12+
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200" data-testid="share-url-container">
13+
<label class="block text-sm font-medium text-gray-700 mb-2">Recording Share URL</label>
14+
<div class="flex items-center gap-2">
15+
<div
16+
class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md bg-white text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500 overflow-x-auto whitespace-nowrap cursor-text select-all"
17+
data-testid="share-url-input">
18+
{{ shareUrl }}
19+
</div>
20+
<lfx-button label="Copy" icon="fa-light fa-copy" size="small" severity="secondary" data-testid="copy-url-button" (click)="copyShareUrl()"></lfx-button>
21+
</div>
22+
</div>
23+
24+
<!-- Action Buttons -->
25+
<div class="flex justify-between gap-2 mt-6">
26+
<lfx-button
27+
label="View Recording"
28+
icon="fa-light fa-external-link"
29+
size="small"
30+
severity="primary"
31+
data-testid="open-recording-button"
32+
[href]="shareUrl"
33+
target="_blank"></lfx-button>
34+
<lfx-button label="Close" size="small" severity="secondary" data-testid="close-button" (click)="onClose()"></lfx-button>
35+
</div>
36+
</div>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { Clipboard } from '@angular/cdk/clipboard';
5+
import { CommonModule } from '@angular/common';
6+
import { Component, inject } from '@angular/core';
7+
import { ButtonComponent } from '@components/button/button.component';
8+
import { MessageService } from 'primeng/api';
9+
import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog';
10+
11+
@Component({
12+
selector: 'lfx-recording-modal',
13+
standalone: true,
14+
imports: [CommonModule, ButtonComponent],
15+
templateUrl: './recording-modal.component.html',
16+
})
17+
export class RecordingModalComponent {
18+
// Injected services
19+
private readonly clipboard = inject(Clipboard);
20+
private readonly messageService = inject(MessageService);
21+
private readonly dialogRef = inject(DynamicDialogRef);
22+
private readonly dialogConfig = inject(DynamicDialogConfig);
23+
24+
// Inputs from dialog config
25+
public readonly shareUrl = this.dialogConfig.data.shareUrl as string;
26+
public readonly meetingTitle = this.dialogConfig.data.meetingTitle as string;
27+
28+
// Public methods
29+
public copyShareUrl(): void {
30+
const success = this.clipboard.copy(this.shareUrl);
31+
32+
if (success) {
33+
this.messageService.add({
34+
severity: 'success',
35+
summary: 'Success',
36+
detail: 'Recording URL copied to clipboard',
37+
});
38+
} else {
39+
this.messageService.add({
40+
severity: 'error',
41+
summary: 'Error',
42+
detail: 'Failed to copy recording URL',
43+
});
44+
}
45+
}
46+
47+
public onClose(): void {
48+
this.dialogRef.close();
49+
}
50+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
<div class="space-y-4" data-testid="summary-modal-container">
5+
<!-- Meeting Title -->
6+
<div class="mb-4">
7+
<h4 class="text-lg font-semibold text-gray-900 mb-1">{{ meetingTitle }}</h4>
8+
<p class="text-sm text-gray-600">AI-generated meeting summary</p>
9+
</div>
10+
11+
<!-- Summary Content Display -->
12+
@if (isEditMode()) {
13+
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200" data-testid="summary-edit-container">
14+
<label class="block text-sm font-medium text-gray-700 mb-2">Edit Summary</label>
15+
<lfx-textarea
16+
[form]="editForm"
17+
control="content"
18+
[rows]="20"
19+
[autoResize]="false"
20+
placeholder="Edit the meeting summary..."
21+
styleClass="w-full font-mono"
22+
dataTest="summary-edit-textarea"></lfx-textarea>
23+
</div>
24+
} @else {
25+
<div class="bg-gray-50 rounded-lg p-6 border border-gray-200 max-h-[500px] overflow-y-auto" data-testid="summary-content-container">
26+
<div class="prose prose-sm max-w-none text-gray-900 whitespace-pre-wrap" [innerHTML]="summaryContent()" data-testid="summary-content"></div>
27+
</div>
28+
}
29+
30+
<!-- Action Buttons -->
31+
<div class="flex justify-between gap-2 mt-6">
32+
@if (isEditMode()) {
33+
<div class="flex gap-2">
34+
<lfx-button label="Cancel" size="small" severity="secondary" data-testid="cancel-edit-button" (click)="cancelEdit()"></lfx-button>
35+
</div>
36+
<lfx-button label="Save" size="small" severity="primary" [loading]="isSaving()" data-testid="save-edit-button" (click)="saveEdit()"></lfx-button>
37+
} @else {
38+
<div class="flex gap-2">
39+
<lfx-button label="Edit" icon="fa-light fa-pen" size="small" severity="secondary" data-testid="edit-button" (click)="enterEditMode()"></lfx-button>
40+
@if (!isApproved()) {
41+
<lfx-button
42+
label="Approve"
43+
icon="fa-light fa-check"
44+
size="small"
45+
severity="success"
46+
[loading]="isApproving()"
47+
data-testid="approve-button"
48+
(click)="approve()"></lfx-button>
49+
} @else {
50+
<lfx-button
51+
label="Approved"
52+
icon="fa-light fa-check-circle"
53+
size="small"
54+
severity="success"
55+
[disabled]="true"
56+
data-testid="approved-badge"></lfx-button>
57+
}
58+
</div>
59+
<lfx-button label="Close" size="small" severity="secondary" data-testid="close-button" (click)="onClose()"></lfx-button>
60+
}
61+
</div>
62+
</div>

0 commit comments

Comments
 (0)