Skip to content

Commit

Permalink
feat(PeerChat): IsTyping indicator (#1575)
Browse files Browse the repository at this point in the history
Co-authored-by: Jonathan Lim-Breitbart <[email protected]>
  • Loading branch information
hirokiterashima and breity authored Jan 12, 2024
1 parent ba8c7a4 commit 64ae2cf
Show file tree
Hide file tree
Showing 11 changed files with 242 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,16 @@
></peer-chat-messages>
<peer-chat-message-input
*ngIf="isEnabled"
[component]="component"
[messageText]="response"
[peerGroup]="peerGroup"
(onSubmit)="submitResponse($event)"
(responseChangedEvent)="responseChanged($event)"
></peer-chat-message-input>
<peer-chat-member-typing-indicator
[component]="component"
[myWorkgroupId]="myWorkgroupId"
[peerGroup]="peerGroup"
class="mat-caption secondary-text"
></peer-chat-member-typing-indicator>
</mat-card>
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
.mat-mdc-card {
padding: 12px;
padding: 16px 16px 20px;
}

peer-chat-messages, peer-chat-members {
margin-bottom: 12px;
display: block;
}

peer-chat-member-typing-indicator {
padding: 0 8px;
position: absolute;
bottom: 2px;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ import { MatInputModule } from '@angular/material/input';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { PeerChatMembersComponent } from '../peer-chat-members/peer-chat-members.component';
import { PeerChatMessageInputComponent } from '../peer-chat-message-input/peer-chat-message-input.component';

import { PeerChatChatBoxComponent } from './peer-chat-chat-box.component';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { ConfigService } from '../../../services/configService';
import { StompService } from '../../../services/stompService';

class MockConfigService {}
describe('PeerChatChatBoxComponent', () => {
let component: PeerChatChatBoxComponent;
let fixture: ComponentFixture<PeerChatChatBoxComponent>;
Expand All @@ -26,7 +29,9 @@ describe('PeerChatChatBoxComponent', () => {
PeerChatChatBoxComponent,
PeerChatMembersComponent,
PeerChatMessageInputComponent
]
],
providers: [{ provide: ConfigService, useClass: MockConfigService }, StompService],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { PeerChatMessage } from '../PeerChatMessage';
import { PeerChatComponent } from '../PeerChatComponent';
import { PeerGroup } from '../PeerGroup';

@Component({
selector: 'peer-chat-chat-box',
templateUrl: './peer-chat-chat-box.component.html',
styleUrls: ['./peer-chat-chat-box.component.scss']
})
export class PeerChatChatBoxComponent implements OnInit {
@Input() component: PeerChatComponent;
@Input() isEnabled: boolean = true;
@Input() isGrading: boolean = false;
@Input() messages: PeerChatMessage[] = [];
@Input() myWorkgroupId: number;
@Input() peerGroup: PeerGroup;
@Input() response: string = '';
@Input() workgroupInfos: any = {};
workgroupInfosWithoutTeachers: any[];
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{{ message }}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PeerChatMemberTypingIndicatorComponent } from './peer-chat-member-typing-indicator.component';
import { ConfigService } from '../../../services/configService';
import { StompService } from '../../../services/stompService';
import { PeerGroup } from '../PeerGroup';
import { StudentDataService } from '../../../services/studentDataService';
import { Subject } from 'rxjs';

class MockConfigService {}
class MockStudentDataService {
private studentWorkReceivedSource: Subject<any> = new Subject<any>();
public studentWorkReceived$ = this.studentWorkReceivedSource.asObservable();
}
describe('PeerChatMemberTypingIndicatorComponent', () => {
let component: PeerChatMemberTypingIndicatorComponent;
let fixture: ComponentFixture<PeerChatMemberTypingIndicatorComponent>;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [PeerChatMemberTypingIndicatorComponent],
providers: [
{ provide: ConfigService, useClass: MockConfigService },
StompService,
{ provide: StudentDataService, useClass: MockStudentDataService }
]
});
fixture = TestBed.createComponent(PeerChatMemberTypingIndicatorComponent);
component = fixture.componentInstance;
component.peerGroup = new PeerGroup(1, [], null);
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Component, Input } from '@angular/core';
import { StompService } from '../../../services/stompService';
import { PeerChatComponent } from '../PeerChatComponent';
import { PeerGroup } from '../PeerGroup';
import { ConfigService } from '../../../services/configService';
import { Subscription } from 'rxjs';
import { StudentDataService } from '../../../services/studentDataService';

@Component({
selector: 'peer-chat-member-typing-indicator',
templateUrl: './peer-chat-member-typing-indicator.component.html'
})
export class PeerChatMemberTypingIndicatorComponent {
@Input() component: PeerChatComponent;
private intervalId: NodeJS.Timeout;
protected message: string;
@Input() myWorkgroupId: number;
@Input() peerGroup: PeerGroup;
private subscriptions: Subscription = new Subscription();
private typingDurationBuffer: number = 5000;
private workgroupToLastTypingTimestamp: Map<number, number> = new Map<number, number>();

constructor(
private configService: ConfigService,
private dataService: StudentDataService,
private stompService: StompService
) {}

ngOnInit(): void {
this.intervalId = setInterval(() => {
this.updateMessage();
}, 1000);
this.subscribeToIsTypingMessages();
this.subscribeToStudentWork();
}

ngOnDestroy(): void {
clearInterval(this.intervalId);
this.subscriptions.unsubscribe();
}

private updateMessage(): void {
const workgroupsTyping = [];
this.workgroupToLastTypingTimestamp.forEach((lastTypingTimestamp, workgroupId) => {
if (new Date().getTime() - lastTypingTimestamp < this.typingDurationBuffer) {
workgroupsTyping.push(workgroupId);
}
});
if (workgroupsTyping.length === 0) {
this.message = '';
} else {
const classmateNames = workgroupsTyping
.map((workgroupId) =>
this.configService.getStudentFirstNamesByWorkgroupId(workgroupId).join(', ')
)
.join(', ');
this.message = classmateNames.includes(',')
? $localize`${classmateNames} are typing...`
: $localize`${classmateNames} is typing...`;
}
}

private subscribeToIsTypingMessages(): void {
this.subscriptions.add(
this.stompService.rxStomp
.watch(`/topic/peer-group/${this.peerGroup.id}/is-typing`)
.subscribe(({ body }) => {
const { nodeId, componentId, workgroupId } = JSON.parse(JSON.parse(body).content);
if (
nodeId === this.component.nodeId &&
componentId === this.component.id &&
workgroupId !== this.myWorkgroupId
) {
this.workgroupToLastTypingTimestamp.set(workgroupId, new Date().getTime());
}
})
);
}

private subscribeToStudentWork(): void {
this.subscriptions.add(
this.dataService.studentWorkReceived$.subscribe((componentState) => {
if (this.isMessageFromPeer(componentState)) {
this.workgroupToLastTypingTimestamp.delete(componentState.workgroupId);
this.updateMessage();
}
})
);
}

private isMessageFromPeer(componentState: any): boolean {
return (
this.isForThisComponent(componentState) &&
this.isFromClassmate(componentState) &&
componentState.peerGroupId === this.peerGroup.id
);
}

private isForThisComponent(componentState: any): boolean {
return (
componentState.nodeId === this.component.nodeId &&
componentState.componentId === this.component.id
);
}

private isFromClassmate(componentState: any): boolean {
return componentState.workgroupId !== this.myWorkgroupId;
}
}
Original file line number Diff line number Diff line change
@@ -1,39 +1,71 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { StompService } from '../../../services/stompService';
import { ConfigService } from '../../../services/configService';
import { PeerChatComponent } from '../PeerChatComponent';
import { PeerGroup } from '../PeerGroup';

@Component({
selector: 'peer-chat-message-input',
templateUrl: './peer-chat-message-input.component.html'
})
export class PeerChatMessageInputComponent implements OnInit {
@Input() component: PeerChatComponent;
private intervalId: NodeJS.Timeout;
protected isSubmitEnabled: boolean = false;
private lastTypingTimestamp: number = 0;
@Input() messageText: string = '';
@Input() peerGroup: PeerGroup;
@Output() responseChangedEvent: EventEmitter<string> = new EventEmitter<string>();
@Output('onSubmit') submit: EventEmitter<string> = new EventEmitter<string>();
private typingDurationBuffer: number = 5000;

ngOnInit(): void {}
constructor(private configService: ConfigService, private stompService: StompService) {}

ngOnInit(): void {
this.intervalId = setInterval(() => {
this.broadcastTypingStatus();
}, 2500);
}

ngOnChanges(): void {
this.responseChanged();
}

ngOnDestroy(): void {
clearInterval(this.intervalId);
}

protected responseChanged(): void {
this.isSubmitEnabled = this.messageText?.length > 0;
this.responseChangedEvent.emit(this.messageText);
}

private broadcastTypingStatus(): void {
if (new Date().getTime() - this.lastTypingTimestamp < this.typingDurationBuffer) {
this.stompService.rxStomp.publish({
destination: `/app/api/peer-chat/${this.component.nodeId}/${this.component.id}/${
this.peerGroup.id
}/${this.configService.getWorkgroupId()}/is-typing`
});
}
}

protected keyPressed(event: any): void {
if (event.keyCode === 13) {
event.preventDefault();
if (this.isSubmitEnabled) {
this.submitResponse();
}
} else {
this.lastTypingTimestamp = new Date().getTime();
}
}

protected submitResponse(): void {
this.submit.emit(this.messageText);
this.messageText = '';
this.isSubmitEnabled = false;
this.lastTypingTimestamp = 0;
}

protected onFocus(event: any): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@
</div>
<div fxFlex fxFlex.gt-sm="60" fxFlexOrder="2" fxFlexOrder.gt-sm="1">
<peer-chat-chat-box
[component]="component"
[messages]="peerChatMessages"
[myWorkgroupId]="myWorkgroupId"
[peerGroup]="peerGroup"
[response]="response"
[workgroupInfos]="peerChatWorkgroupInfos"
(onSubmit)="submitStudentResponse($event)"
Expand Down
6 changes: 4 additions & 2 deletions src/assets/wise5/components/peerChat/peer-chat.module.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { NgModule } from '@angular/core';
import { PeerChatChatBoxComponent } from './peer-chat-chat-box/peer-chat-chat-box.component';
import { PeerChatMembersComponent } from './peer-chat-members/peer-chat-members.component';
import { PeerChatMemberTypingIndicatorComponent } from './peer-chat-member-typing-indicator/peer-chat-member-typing-indicator.component';
import { PeerChatMessageComponent } from './peer-chat-message/peer-chat-message.component';
import { PeerChatMessageInputComponent } from './peer-chat-message-input/peer-chat-message-input.component';
import { PeerChatMessagesComponent } from './peer-chat-messages/peer-chat-messages.component';
import { PeerChatQuestionBankComponent } from './peer-chat-question-bank/peer-chat-question-bank.component';
import { PeerChatMembersComponent } from './peer-chat-members/peer-chat-members.component';
import { StudentTeacherCommonModule } from '../../../../app/student-teacher-common.module';
import { QuestionBankService } from './peer-chat-question-bank/questionBank.service';
import { StudentTeacherCommonModule } from '../../../../app/student-teacher-common.module';

@NgModule({
declarations: [
PeerChatChatBoxComponent,
PeerChatMembersComponent,
PeerChatMemberTypingIndicatorComponent,
PeerChatMessageComponent,
PeerChatMessageInputComponent,
PeerChatMessagesComponent,
Expand Down
Loading

0 comments on commit 64ae2cf

Please sign in to comment.