diff --git a/README.md b/README.md index 801fb63..59dd396 100644 --- a/README.md +++ b/README.md @@ -97,12 +97,40 @@ The message format used: "data": { "originator": ..., - "timestamp": ..., "message": ... } } ``` +This component can be used as a simple chatbot as well. + +You can provide a list of messages to queue and they will be automatically displayed one after the other. +There is a wait time between each message so that the user can properly read them. +This wait time is calculated from the length of the last message, and can be configured using the `messages.messageQueueWPM` key in `config.ts`. + +You can queue messages like so: + +``` +{ + "key": "queueMessages", + "data": + { + "messages": + [ + { + "originator": ... + "message": ... + }, + { + "originator": ... + "message": ... + }, + ... + ] + } +} +``` + You can use the `MessageSender` class to send messages from the TFW server or event handlers written in Python. ### Web – customisable component diff --git a/src/app/config.ts b/src/app/config.ts index e5844ab..ee07ebc 100644 --- a/src/app/config.ts +++ b/src/app/config.ts @@ -38,7 +38,8 @@ export const config = { }, messages: { route: 'messages', - showNextButton: false + showNextButton: false, + messageQueueWPM: 150 }, console: { route: 'console', diff --git a/src/app/dashboard/dashboard.component.html b/src/app/dashboard/dashboard.component.html index 77d23d3..c50353a 100644 --- a/src/app/dashboard/dashboard.component.html +++ b/src/app/dashboard/dashboard.component.html @@ -4,8 +4,10 @@
-
- +
+
diff --git a/src/app/dashboard/dashboard.component.scss b/src/app/dashboard/dashboard.component.scss index c6394f2..33d3ede 100644 --- a/src/app/dashboard/dashboard.component.scss +++ b/src/app/dashboard/dashboard.component.scss @@ -58,6 +58,11 @@ } } + .tfw-messages::-webkit-scrollbar { + width: 0px; + background: transparent; + } + .tfw-ide { background-color: #1e1e1e; // vscode dark theme } diff --git a/src/app/dashboard/dashboard.component.ts b/src/app/dashboard/dashboard.component.ts index 6d56a58..71136d5 100644 --- a/src/app/dashboard/dashboard.component.ts +++ b/src/app/dashboard/dashboard.component.ts @@ -20,6 +20,7 @@ export class DashboardComponent implements OnInit, OnDestroy { deploying = false; deploymentNotificationSubscription: Subscription; @ViewChild('webiframe') webiframe: ElementRef; + @ViewChild('tfwmessages') messages: ElementRef; layout: string = config.dashboard.currentLayout; hideMessages: boolean = config.dashboard.hideMessages; @@ -125,4 +126,11 @@ export class DashboardComponent implements OnInit, OnDestroy { this.selectTerminalMenuItem('console'); } } + + scrollMessagesToBottom(): void { + const element = this.messages.nativeElement; + // This must be done in the Angular event loop to avoid messing up + // change detection (not in the template like ConsoleComponent does) + element.scrollTop = element.scrollHeight; + } } diff --git a/src/app/message-types/message-metadata.ts b/src/app/message-types/message-metadata.ts new file mode 100644 index 0000000..02058cf --- /dev/null +++ b/src/app/message-types/message-metadata.ts @@ -0,0 +1,7 @@ +// Copyright (C) 2018 Avatao.com Innovative Learning Kft. +// All Rights Reserved. See LICENSE file for details. + +export class MessageMetadata { + originator: string; + timestamp?: Date; +} diff --git a/src/app/message-types/message-queue-message.ts b/src/app/message-types/message-queue-message.ts new file mode 100644 index 0000000..540cebb --- /dev/null +++ b/src/app/message-types/message-queue-message.ts @@ -0,0 +1,8 @@ +// Copyright (C) 2018 Avatao.com Innovative Learning Kft. +// All Rights Reserved. See LICENSE file for details. + +import { MessagesMessage } from './messages-message'; + +export class MessageQueueMessage { + messages: Array; +} diff --git a/src/app/message-types/messages-message.ts b/src/app/message-types/messages-message.ts index d8b1d0a..0894522 100644 --- a/src/app/message-types/messages-message.ts +++ b/src/app/message-types/messages-message.ts @@ -1,8 +1,8 @@ // Copyright (C) 2018 Avatao.com Innovative Learning Kft. // All Rights Reserved. See LICENSE file for details. -export class MessagesMessage { - originator: string; - timestamp: Date; +import { MessageMetadata } from './message-metadata'; + +export class MessagesMessage extends MessageMetadata { message: string; } diff --git a/src/app/messages/messages.component.html b/src/app/messages/messages.component.html index 0a3b24b..784fc6e 100644 --- a/src/app/messages/messages.component.html +++ b/src/app/messages/messages.component.html @@ -2,11 +2,9 @@ All Rights Reserved. See LICENSE file for details. -->
-
-
Instructions
-
-
-
+
{{message.originator}}
@@ -14,4 +12,16 @@
+
+
+
+
+
+
+ +
diff --git a/src/app/messages/messages.component.scss b/src/app/messages/messages.component.scss index 307aeef..9b39263 100644 --- a/src/app/messages/messages.component.scss +++ b/src/app/messages/messages.component.scss @@ -3,18 +3,8 @@ @import "../../assets/scss/variables.scss"; -.tfw-messages-main { - .tfw-grid-messages-header { - display: grid; - grid-template-columns: 1fr 1fr; - margin-bottom: $small; - - span { - color: $tao-blue-500; - font-weight: 500; - font-size: $font-size-h3; - } - } +.tfw-next-button { + text-align: center; } .tfw-grid-message { @@ -28,6 +18,51 @@ padding: $tiny; font-size: $font-size-base; margin-bottom: $hair; + + animation-name: inflate; + animation-duration: 0.5s; + animation-timing-function: cubic-bezier(0.01, 0.1, 0, 1); +} + +.highlighted-message { + box-shadow: 0px 0px 3px 0px rgba($tao-gray-300,0.65); +} + +@keyframes inflate { + 0% { transform: scale(0,0); } + 100% { transform: scale(1,1); } +} + +.jumping-circle-container { + display: flex; + padding-top: 1.3em; + padding-bottom: 1em; +} + +.jumping-circle { + width: 0.35em; + height: 0.35em; + border-radius: 50%; + background-color: gray; + margin-top: 0.3em; + margin-left: 0.3em; + + animation-name: float; + animation-duration: 1.7s; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; +} + +#jc2 { animation-delay: 0.2s; } +#jc3 { animation-delay: 0.45s; } + +@keyframes float { + 0% { transform: translateY(0em); } + 30% { transform: translateY(-0.5em); } + 60% { transform: translateY(0.2em); } + 80% { transform: translateY(0em); } + 90% { transform: translateY(-0.5em); } + 100% { transform: translateY(0em); } } .tfw-grid-message-header { diff --git a/src/app/messages/messages.component.ts b/src/app/messages/messages.component.ts index 3a8105e..53a42eb 100644 --- a/src/app/messages/messages.component.ts +++ b/src/app/messages/messages.component.ts @@ -1,7 +1,7 @@ // Copyright (C) 2018 Avatao.com Innovative Learning Kft. // All Rights Reserved. See LICENSE file for details. -import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, OnInit, EventEmitter, Output } from '@angular/core'; import { MarkdownService } from '../services/markdown.service'; import { WebSocketService } from '../services/websocket.service'; @@ -9,6 +9,8 @@ import { MessagesMessage } from '../message-types/messages-message'; import { MessagesControlCommand } from '../message-types/messages-control-command'; import { config } from '../config'; import { CommandMessage } from '../message-types/command-message'; +import { MessageQueueMessage } from '../message-types/message-queue-message'; +import { Subject, Observer, BehaviorSubject } from 'rxjs'; @Component({ selector: 'app-messages', @@ -16,7 +18,12 @@ import { CommandMessage } from '../message-types/command-message'; styleUrls: ['./messages.component.scss'] }) export class MessagesComponent implements OnInit { + @Output() newMessageEvent: EventEmitter = new EventEmitter(); + newMessage: Subject = new Subject(); + messageInQueue = true; + messages: MessagesMessage[] = []; + messageQueueAttender: MessageQueueAttender; showNextButton: boolean = config.messages.showNextButton; command_handlers = { @@ -27,23 +34,50 @@ export class MessagesComponent implements OnInit { private markdownService: MarkdownService, private websocketService: WebSocketService, private changeDetectorRef: ChangeDetectorRef - ) {} - - ngOnInit() { - this.websocketService.connect(); - this.websocketService.observeKey('message').subscribe( - (event) => { - this.messages.push(event.data); - event.data.message = this.convert(event.data.message); - this.changeDetectorRef.detectChanges(); - }); - this.websocketService.observeKey('messagecontrol').subscribe( - (event) => { - this.command_handlers[event.data.command](event.data); - }); + ) { + this.messageQueueAttender = new MessageQueueAttender(this.newMessage); } - convert(text: string) { + ngOnInit() { + this.newMessage.subscribe( + (message) => { + this.writeMessage(message); + this.newMessageEvent.emit(); + }); + this.messageQueueAttender.messageInQueue.subscribe( + (value) => this.messageInQueue = value + ); + + this.websocketService.connect(); + this.websocketService.observeKey('message').subscribe( + (event) => this.newMessage.next(event.data) + ); + this.websocketService.observeKey('queueMessages').subscribe( + (event) => this.handleQueueMessage(event.data) + ); + this.websocketService.observeKey('messagecontrol').subscribe( + (event) => this.command_handlers[event.data.command](event.data) + ); + } + + writeMessage(message: MessagesMessage) { + this.transformMessage(message); + this.messages.push(message); + this.changeDetectorRef.detectChanges(); + } + + transformMessage(message: MessagesMessage) { + message.message = this.convertMarkdownToHTML(message.message); + if (!message.timestamp) { + message.timestamp = new Date(); + } + } + + handleQueueMessage(data: MessageQueueMessage) { + this.messageQueueAttender.queueMessages(data.messages); + } + + convertMarkdownToHTML(text: string) { return this.markdownService.convertToHtml(text); } @@ -55,3 +89,48 @@ export class MessagesComponent implements OnInit { this.websocketService.sendJSON({key: '', trigger: 'step_next'}); } } + + +class MessageQueueAttender { + public messageInQueue = new BehaviorSubject(false); + + private readonly charPerSecond: number; + private lastMessageLength = 0; + private queue: MessagesMessage[] = []; + + constructor(private messageEmitter: Observer, wordPerMinute: number = config.messages.messageQueueWPM) { + const charPerMinute = wordPerMinute * 5; + this.charPerSecond = charPerMinute / 60; + } + + queueMessages(messages: Array) { + this.queue = this.queue.concat(messages); + this.attendQueue(); + } + + private processMessage() { + if (this.queue.length > 0) { + const lastMessage = this.queue.shift(); + this.lastMessageLength = lastMessage.message.length; + this.messageEmitter.next(lastMessage); + } + if (this.queue.length === 0) { + this.lastMessageLength = 0; + this.messageInQueue.next(false); + } + } + + private attendQueue() { + if (this.queue.length > 0) { + this.messageInQueue.next(true); + const timeoutSeconds = this.lastMessageLength / this.charPerSecond; + setTimeout( + () => { + this.processMessage(); + this.attendQueue(); + }, + timeoutSeconds * 1000 + ); + } + } +}