From 69adc1d41c3abea0a8b0e45fc4ab84161c559e07 Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 9 Feb 2021 16:06:44 +0100 Subject: [PATCH] Add Chat UI Components Add Chat User Interface Restructure some services Virtual Scrolling Manual change detection for message updates Enhanced Date pipe Message layout Tabbed reusable chat window Deleting messages Further permission checks Delete-prompts Mobile friendly chat usage automatically scroll to bottom --- autoupdate | 2 +- client/src/app/app.component.ts | 2 +- .../app/shared/models/chat/chat-message.ts | 5 + .../app/shared/pipes/localized-date.pipe.ts | 7 +- .../src/app/site/chat/chat-routing.module.ts | 4 +- client/src/app/site/chat/chat.module.ts | 11 +- .../chat-group-detail.component.html | 84 ++++----- .../chat-group-detail.component.scss | 21 +++ .../chat-group-detail.component.ts | 164 +++++++++++++----- .../chat-group-list.component.html | 60 +------ .../chat-group-list.component.ts | 101 +++-------- .../chat-message/chat-message.component.html | 21 +++ .../chat-message/chat-message.component.scss | 37 ++++ .../chat-message.component.spec.ts | 27 +++ .../chat-message/chat-message.component.ts | 41 +++++ .../chat-tabs/chat-tabs.component.html | 41 +++++ .../chat-tabs/chat-tabs.component.scss | 7 + .../chat-tabs/chat-tabs.component.spec.ts | 26 +++ .../chat-tabs/chat-tabs.component.ts | 86 +++++++++ .../edit-chat-group-dialog.component.html | 53 ++++++ .../edit-chat-group-dialog.component.scss | 0 .../edit-chat-group-dialog.component.spec.ts | 35 ++++ .../edit-chat-group-dialog.component.ts | 43 +++++ .../services}/chat-notification.service.ts | 4 +- .../chat/services}/chat.service.ts | 6 +- client/src/app/site/site.component.html | 14 +- client/src/app/site/site.component.ts | 4 +- .../styles/global-components-style.scss | 17 +- client/src/styles.scss | 4 + docker/.env | 1 + docker/docker-compose.yml.m4 | 1 + docker/docker-stack.yml.m4 | 1 + server/SETTINGS.rst | 7 + server/docker/settings.py | 3 + .../chat/migrations/0001_initial.py | 1 + server/openslides/chat/models.py | 1 + server/openslides/chat/serializers.py | 6 +- server/openslides/chat/views.py | 29 +++- server/openslides/utils/settings.py.tpl | 2 + server/tests/integration/chat/test_chat.py | 5 +- 40 files changed, 734 insertions(+), 250 deletions(-) create mode 100644 client/src/app/site/chat/components/chat-message/chat-message.component.html create mode 100644 client/src/app/site/chat/components/chat-message/chat-message.component.scss create mode 100644 client/src/app/site/chat/components/chat-message/chat-message.component.spec.ts create mode 100644 client/src/app/site/chat/components/chat-message/chat-message.component.ts create mode 100644 client/src/app/site/chat/components/chat-tabs/chat-tabs.component.html create mode 100644 client/src/app/site/chat/components/chat-tabs/chat-tabs.component.scss create mode 100644 client/src/app/site/chat/components/chat-tabs/chat-tabs.component.spec.ts create mode 100644 client/src/app/site/chat/components/chat-tabs/chat-tabs.component.ts create mode 100644 client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.html create mode 100644 client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.scss create mode 100644 client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.spec.ts create mode 100644 client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.ts rename client/src/app/{core/ui-services => site/chat/services}/chat-notification.service.ts (95%) rename client/src/app/{core/ui-services => site/chat/services}/chat.service.ts (82%) diff --git a/autoupdate b/autoupdate index 3380911a7..756043511 160000 --- a/autoupdate +++ b/autoupdate @@ -1 +1 @@ -Subproject commit 3380911a7e9cb4a906d3729d30a164ed3d59fd22 +Subproject commit 756043511cc00b9fd4b42cc3a7ba0d8c16897895 diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts index b12617e2b..698657ad2 100644 --- a/client/src/app/app.component.ts +++ b/client/src/app/app.component.ts @@ -6,7 +6,7 @@ import { Router } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { filter, take } from 'rxjs/operators'; -import { ChatNotificationService } from './core/ui-services/chat-notification.service'; +import { ChatNotificationService } from './site/chat/services/chat-notification.service'; import { ConfigService } from './core/ui-services/config.service'; import { ConstantsService } from './core/core-services/constants.service'; import { CountUsersService } from './core/ui-services/count-users.service'; diff --git a/client/src/app/shared/models/chat/chat-message.ts b/client/src/app/shared/models/chat/chat-message.ts index 320d3b5cc..5520d9d7d 100644 --- a/client/src/app/shared/models/chat/chat-message.ts +++ b/client/src/app/shared/models/chat/chat-message.ts @@ -6,6 +6,11 @@ export class ChatMessage extends BaseModel { public id: number; public text: string; public username: string; + /** + * Note: Do not expect, that this user is known in the client. + * Use this id just as a numerical value. + */ + public user_id: number; public timestamp: string; public chatgroup_id: number; diff --git a/client/src/app/shared/pipes/localized-date.pipe.ts b/client/src/app/shared/pipes/localized-date.pipe.ts index 61606961e..7e891f25c 100644 --- a/client/src/app/shared/pipes/localized-date.pipe.ts +++ b/client/src/app/shared/pipes/localized-date.pipe.ts @@ -5,6 +5,7 @@ import * as moment from 'moment'; /** * pipe to convert and translate dates + * requires a "date"object */ @Pipe({ name: 'localizedDate', @@ -13,13 +14,13 @@ import * as moment from 'moment'; export class LocalizedDatePipe implements PipeTransform { public constructor(private translate: TranslateService) {} - public transform(value: any, dateFormat: string = 'lll'): any { + public transform(date: Date, dateFormat: string = 'lll'): any { const lang = this.translate.currentLang ? this.translate.currentLang : this.translate.defaultLang; - if (!value) { + if (!date) { return ''; } moment.locale(lang); - const dateLocale = moment.unix(value).local(); + const dateLocale = moment.unix(date.getTime() / 1000).local(); return dateLocale.format(dateFormat); } } diff --git a/client/src/app/site/chat/chat-routing.module.ts b/client/src/app/site/chat/chat-routing.module.ts index 013456dfc..67b6dceac 100644 --- a/client/src/app/site/chat/chat-routing.module.ts +++ b/client/src/app/site/chat/chat-routing.module.ts @@ -1,7 +1,6 @@ import { NgModule } from '@angular/core'; import { Route, RouterModule } from '@angular/router'; -import { ChatGroupDetailComponent } from './components/chat-group-detail/chat-group-detail.component'; import { ChatGroupListComponent } from './components/chat-group-list/chat-group-list.component'; const routes: Route[] = [ @@ -9,8 +8,7 @@ const routes: Route[] = [ path: '', pathMatch: 'full', component: ChatGroupListComponent - }, - { path: ':id', component: ChatGroupDetailComponent } + } ]; @NgModule({ diff --git a/client/src/app/site/chat/chat.module.ts b/client/src/app/site/chat/chat.module.ts index d9a5c6327..e76142fd9 100644 --- a/client/src/app/site/chat/chat.module.ts +++ b/client/src/app/site/chat/chat.module.ts @@ -3,11 +3,20 @@ import { NgModule } from '@angular/core'; import { ChatGroupDetailComponent } from './components/chat-group-detail/chat-group-detail.component'; import { ChatGroupListComponent } from './components/chat-group-list/chat-group-list.component'; +import { ChatMessageComponent } from './components/chat-message/chat-message.component'; import { ChatRoutingModule } from './chat-routing.module'; +import { ChatTabsComponent } from './components/chat-tabs/chat-tabs.component'; +import { EditChatGroupDialogComponent } from './components/edit-chat-group-dialog/edit-chat-group-dialog.component'; import { SharedModule } from '../../shared/shared.module'; @NgModule({ imports: [CommonModule, ChatRoutingModule, SharedModule], - declarations: [ChatGroupListComponent, ChatGroupDetailComponent] + declarations: [ + ChatGroupListComponent, + ChatGroupDetailComponent, + ChatTabsComponent, + EditChatGroupDialogComponent, + ChatMessageComponent + ] }) export class ChatModule {} diff --git a/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.html b/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.html index be51c5c5f..c93bd4e1b 100644 --- a/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.html +++ b/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.html @@ -1,53 +1,45 @@ - - -
-

{{ chatgroup.name }}

-
- - - -
- - -
-
- {{ message.username }} ({{ message.timestamp }}): {{ message.text }} -
+
+ + + {{ group.getTitle() | translate }} + , + ... + + +
- -
-
- - - {{ 'Required' | translate }} - -
- +
+ +
+ +
+
- + + + + + + + + diff --git a/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.scss b/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.scss index e69de29bb..b13ddb7e6 100644 --- a/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.scss +++ b/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.scss @@ -0,0 +1,21 @@ +.chat-header { + display: flex; + justify-content: space-between; + + .chat-options { + margin-left: auto; + } +} + +.chat-list-wrapper { + min-height: 300px; + height: 50vh; + + .chat-message-list { + height: 100%; + } +} + +.chat-message { + height: auto; +} diff --git a/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.ts b/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.ts index f7ab1ffa7..6e7dde54b 100644 --- a/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.ts +++ b/client/src/app/site/chat/components/chat-group-detail/chat-group-detail.component.ts @@ -1,79 +1,157 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { CdkVirtualScrollViewport, ExtendedScrollToOptions } from '@angular/cdk/scrolling'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Title } from '@angular/platform-browser'; -import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { ChatGroupRepositoryService } from 'app/core/repositories/chat/chat-group-repository.service'; import { ChatMessageRepositoryService } from 'app/core/repositories/chat/chat-message-repository.service'; -import { ChatNotificationService } from 'app/core/ui-services/chat-notification.service'; -import { ChatMessage } from 'app/shared/models/chat/chat-message'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { ChatGroup } from 'app/shared/models/chat/chat-group'; +import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; import { BaseViewComponentDirective } from 'app/site/base/base-view'; +import { ChatNotificationService } from 'app/site/chat/services/chat-notification.service'; +import { + ChatGroupData, + EditChatGroupDialogComponent +} from '../edit-chat-group-dialog/edit-chat-group-dialog.component'; import { ViewChatGroup } from '../../models/view-chat-group'; import { ViewChatMessage } from '../../models/view-chat-message'; @Component({ selector: 'os-chat-group-detail', templateUrl: './chat-group-detail.component.html', - styleUrls: ['./chat-group-detail.component.scss'] + styleUrls: ['./chat-group-detail.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) -export class ChatGroupDetailComponent extends BaseViewComponentDirective implements OnInit, OnDestroy { - public newMessageForm: FormGroup; - public chatgroup: ViewChatGroup; - public chatgroupId: number; +export class ChatGroupDetailComponent extends BaseViewComponentDirective implements OnInit, AfterViewInit, OnDestroy { + @Input() + public chatGroup: ViewChatGroup; + + @ViewChild(CdkVirtualScrollViewport) + public virtualScrollViewport?: CdkVirtualScrollViewport; + + public chatGroupId: number; + public chatMessages: ViewChatMessage[] = []; + public get isOnBottomOfChat(): boolean { + const isOnBottom = this.virtualScrollViewport?.measureScrollOffset('bottom') === 0; + return isOnBottom; + } + public constructor( titleService: Title, protected translate: TranslateService, matSnackBar: MatSnackBar, - private chatGroupRepo: ChatGroupRepositoryService, + private repo: ChatGroupRepositoryService, private chatMessageRepo: ChatMessageRepositoryService, - private route: ActivatedRoute, - private formBuilder: FormBuilder, - private chatNotificationService: ChatNotificationService + private chatNotificationService: ChatNotificationService, + private dialog: MatDialog, + private promptService: PromptService, + private cd: ChangeDetectorRef ) { super(titleService, translate, matSnackBar); + } - this.newMessageForm = this.formBuilder.group({ - text: ['', Validators.required] - }); - - this.chatgroupId = parseInt(this.route.snapshot.params.id, 10); - + public ngOnInit(): void { + this.chatGroupId = this.chatGroup.id; + this.chatNotificationService.openChat(this.chatGroupId); this.subscriptions.push( - this.chatGroupRepo.getViewModelObservable(this.chatgroupId).subscribe(chatGroup => { - if (chatGroup) { - super.setTitle(`${this.translate.instant('Chat group')} - ${chatGroup.getTitle()}`); - this.chatgroup = chatGroup; - } - }), this.chatMessageRepo.getViewModelListBehaviorSubject().subscribe(chatMessages => { - this.chatMessages = chatMessages.filter(message => message.chatgroup_id === this.chatgroup.id); + this.chatMessages = chatMessages.filter(message => { + return message.chatgroup_id === this.chatGroup.id; + }); + + if (this.isOnBottomOfChat) { + this.scrollToBottom(); + } + this.cd.markForCheck(); }) ); } - public ngOnInit(): void { - super.setTitle('Chat group'); - this.chatNotificationService.openChat(this.chatgroupId); - } - - public send(): void { - const payload = { - text: this.newMessageForm.value.text, - chatgroup_id: this.chatgroup.id - }; - this.chatMessageRepo.create(payload as ChatMessage).catch(this.raiseError); - } - - public clearChat(): void { - this.chatGroupRepo.clearMessages(this.chatgroup).catch(this.raiseError); + public ngAfterViewInit(): void { + this.scrollToBottom(); } public ngOnDestroy(): void { - this.chatNotificationService.closeChat(this.chatgroupId); + this.chatNotificationService.closeChat(this.chatGroupId); + } + + private scrollToBottom(): void { + /** + * I am aware that this is ugly, but that is the only way to get to + * the bottom reliably + * https://stackoverflow.com/questions/64932671/scroll-to-bottom-with-cdk-virtual-scroll-angular-8/65069130 + */ + const scrollTarget: ExtendedScrollToOptions = { + bottom: 0, + behavior: 'auto' + }; + setTimeout(() => { + this.virtualScrollViewport.scrollTo(scrollTarget); + }, 0); + setTimeout(() => { + this.virtualScrollViewport.scrollTo(scrollTarget); + }, 100); + } + + public editChat(): void { + const chatData: ChatGroupData = { + name: this.chatGroup.name, + access_groups_id: this.chatGroup.access_groups_id + }; + + const dialogRef = this.dialog.open(EditChatGroupDialogComponent, { + data: chatData, + ...infoDialogSettings + }); + + dialogRef.afterClosed().subscribe((res: ChatGroupData) => { + if (res) { + this.save(res); + } + }); + } + + public async save(chatData: ChatGroupData): Promise { + await this.repo.update(chatData as ChatGroup, this.chatGroup).catch(this.raiseError); + this.cd.markForCheck(); + } + + public async clearChat(): Promise { + const title = this.translate.instant('Are you sure you want to clear this chats history?'); + if (await this.promptService.open(title)) { + await this.repo.clearMessages(this.chatGroup).catch(this.raiseError); + this.cd.markForCheck(); + } + } + + public async deleteChatGroup(): Promise { + const title = this.translate.instant('Are you sure you want to delete this chat?'); + if (await this.promptService.open(title)) { + await this.repo.delete(this.chatGroup).catch(this.raiseError); + this.cd.markForCheck(); + } + } + + public async deleteChatMessage(message: ViewChatMessage): Promise { + const title = this.translate.instant('Are you sure you want to delete this message?'); + if (await this.promptService.open(title)) { + await this.chatMessageRepo.delete(message).catch(this.raiseError); + this.cd.markForCheck(); + } } } diff --git a/client/src/app/site/chat/components/chat-group-list/chat-group-list.component.html b/client/src/app/site/chat/components/chat-group-list/chat-group-list.component.html index dbac93a3e..c9075b0f2 100644 --- a/client/src/app/site/chat/components/chat-group-list/chat-group-list.component.html +++ b/client/src/app/site/chat/components/chat-group-list/chat-group-list.component.html @@ -1,60 +1,10 @@ - +
-

{{ 'Chat groups' | translate }}

+

{{ 'Chat' | translate }}

-
- {{ chatGroup.name }}
- {{ chatGroup.access_groups.length ? chatGroup.access_groups : 'No access groups, public to all' }}
- -
{{ amountNotification(chatGroup) }} NEW MESSAGES
-
-
- - - -

{{ 'Create new chat group' | translate }}

-

{{ 'Edit details for' | translate }} {{ editModel.name }}

-
-
- - - {{ 'Required' | translate }} - - - - - -
-
-
- - -
-
+ + + diff --git a/client/src/app/site/chat/components/chat-group-list/chat-group-list.component.ts b/client/src/app/site/chat/components/chat-group-list/chat-group-list.component.ts index e8d18cf23..0d8194f94 100644 --- a/client/src/app/site/chat/components/chat-group-list/chat-group-list.component.ts +++ b/client/src/app/site/chat/components/chat-group-list/chat-group-list.component.ts @@ -1,19 +1,20 @@ -import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; -import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Component, OnInit } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Title } from '@angular/platform-browser'; import { TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject } from 'rxjs'; -import { OperatorService, Permission } from 'app/core/core-services/operator.service'; +import { OperatorService } from 'app/core/core-services/operator.service'; import { ChatGroupRepositoryService } from 'app/core/repositories/chat/chat-group-repository.service'; -import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; -import { ChatNotificationService, NotificationAmount } from 'app/core/ui-services/chat-notification.service'; import { ChatGroup } from 'app/shared/models/chat/chat-group'; +import { infoDialogSettings } from 'app/shared/utils/dialog-settings'; import { BaseViewComponentDirective } from 'app/site/base/base-view'; -import { ViewGroup } from 'app/site/users/models/view-group'; +import { ChatNotificationService, NotificationAmount } from 'app/site/chat/services/chat-notification.service'; +import { + ChatGroupData, + EditChatGroupDialogComponent +} from '../edit-chat-group-dialog/edit-chat-group-dialog.component'; import { ViewChatGroup } from '../../models/view-chat-group'; @Component({ @@ -22,99 +23,39 @@ import { ViewChatGroup } from '../../models/view-chat-group'; styleUrls: ['./chat-group-list.component.scss'] }) export class ChatGroupListComponent extends BaseViewComponentDirective implements OnInit { - @ViewChild('createUpdateDialog', { static: true }) - private createUpdateDialog: TemplateRef; - - public createUpdateForm: FormGroup; - public groupsBehaviorSubject: BehaviorSubject; - - private isEdit = false; - public editModel: ViewChatGroup | null = null; - - public get isCreateMode(): boolean { - return this.isEdit && this.editModel === null; + public get canManage(): boolean { + return this.operator.hasPerms(this.permission.chatCanManage); } - public get isEditMode(): boolean { - return this.isEdit && this.editModel !== null; - } - - public chatGroups: ViewChatGroup[] = []; - - public get canEdit(): boolean { - return this.operator.hasPerms(Permission.chatCanManage); - } - - public notificationAmounts: NotificationAmount = {}; - public constructor( titleService: Title, protected translate: TranslateService, matSnackBar: MatSnackBar, private repo: ChatGroupRepositoryService, - private formBuilder: FormBuilder, - private groupRepo: GroupRepositoryService, private dialog: MatDialog, - private operator: OperatorService, - private chatNotificationService: ChatNotificationService + private operator: OperatorService ) { super(titleService, translate, matSnackBar); - this.groupsBehaviorSubject = this.groupRepo.getViewModelListBehaviorSubject(); - - this.repo.getViewModelListBehaviorSubject().subscribe(list => (this.chatGroups = list)); - this.chatNotificationService.chatgroupNotificationsObservable.subscribe(n => (this.notificationAmounts = n)); - - this.createUpdateForm = this.formBuilder.group({ - name: ['', Validators.required], - access_groups_id: [[]] - }); } public ngOnInit(): void { - super.setTitle('Chat groups'); + super.setTitle('Chat'); } public createNewChatGroup(): void { - if (this.isEdit) { - return; - } + const dialogRef = this.dialog.open(EditChatGroupDialogComponent, { + data: null, + ...infoDialogSettings + }); - this.isEdit = true; - this.editModel = null; - this.createUpdateForm.reset(); - this.openDialog(); - } - - public edit(chatGroup: ViewChatGroup): void { - if (this.isEdit) { - return; - } - - this.isEdit = true; - this.editModel = chatGroup; - this.createUpdateForm.patchValue({ name: chatGroup.name, access_groups_id: chatGroup.access_groups_id }); - this.openDialog(); - } - - private openDialog(): void { - const dialogRef = this.dialog.open(this.createUpdateDialog); - dialogRef.afterClosed().subscribe(res => { + dialogRef.afterClosed().subscribe((res: ChatGroupData) => { if (res) { - this.save(); + this.save(res); } - this.isEdit = false; }); } - public save(): void { - if (this.isCreateMode) { - this.repo.create(this.createUpdateForm.value as ChatGroup).catch(this.raiseError); - } else if (this.isEditMode) { - this.repo.update(this.createUpdateForm.value as ChatGroup, this.editModel).catch(this.raiseError); - } - } - - public amountNotification(chatGroup: ViewChatGroup): number { - return this.notificationAmounts[chatGroup.id]; + public save(createData: ChatGroupData): void { + this.repo.create(createData as ChatGroup).catch(this.raiseError); } } diff --git a/client/src/app/site/chat/components/chat-message/chat-message.component.html b/client/src/app/site/chat/components/chat-message/chat-message.component.html new file mode 100644 index 000000000..cf0bcdca6 --- /dev/null +++ b/client/src/app/site/chat/components/chat-message/chat-message.component.html @@ -0,0 +1,21 @@ +
+
+ {{ author }} +
+
+ {{ text }} +
{{ date | localizedDate }}
+
+
+ + + + + diff --git a/client/src/app/site/chat/components/chat-message/chat-message.component.scss b/client/src/app/site/chat/components/chat-message/chat-message.component.scss new file mode 100644 index 000000000..0a33d265a --- /dev/null +++ b/client/src/app/site/chat/components/chat-message/chat-message.component.scss @@ -0,0 +1,37 @@ +$message-box-radius: 20px; + +.incomming-message { + .chat-text { + border-top-left-radius: 0; + } +} + +.outgoind-message { + .chat-text { + border-bottom-right-radius: 0; + } + * { + margin-left: auto; + } +} + +.message-box { + margin: 1em 0.5em 0 0.5em; +} + +.author { + font-size: small; +} + +.chat-text { + padding: 0.5em; + width: fit-content; + max-width: 90%; + border-radius: $message-box-radius; +} + +.timestamp { + font-size: small; + opacity: 0.5; + width: fit-content; +} diff --git a/client/src/app/site/chat/components/chat-message/chat-message.component.spec.ts b/client/src/app/site/chat/components/chat-message/chat-message.component.spec.ts new file mode 100644 index 000000000..110301cf3 --- /dev/null +++ b/client/src/app/site/chat/components/chat-message/chat-message.component.spec.ts @@ -0,0 +1,27 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { ChatMessageComponent } from './chat-message.component'; + +describe('ChatMessageComponent', () => { + let component: ChatMessageComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [ChatMessageComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatMessageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/chat/components/chat-message/chat-message.component.ts b/client/src/app/site/chat/components/chat-message/chat-message.component.ts new file mode 100644 index 000000000..454d9b7af --- /dev/null +++ b/client/src/app/site/chat/components/chat-message/chat-message.component.ts @@ -0,0 +1,41 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { OperatorService, Permission } from 'app/core/core-services/operator.service'; +import { ViewChatMessage } from '../../models/view-chat-message'; + +@Component({ + selector: 'os-chat-message', + templateUrl: './chat-message.component.html', + styleUrls: ['./chat-message.component.scss'] +}) +export class ChatMessageComponent { + @Input() private message: ViewChatMessage; + + @Output() public deleteEvent = new EventEmitter(); + + public get isOwnMessage(): boolean { + return this.operator?.user?.id === this.message?.user_id || false; + } + + public get canDelete(): boolean { + return this.isOwnMessage || this.operator.hasPerms(Permission.chatCanManage); + } + + public get author(): string { + return this.message?.username || ''; + } + + public get text(): string { + return this.message?.text || ''; + } + + public get date(): Date { + return this.message?.timestampAsDate || undefined; + } + + public constructor(private operator: OperatorService) {} + + public onDeleteMessage(): void { + this.deleteEvent.next(); + } +} diff --git a/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.html b/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.html new file mode 100644 index 000000000..44db6b585 --- /dev/null +++ b/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.html @@ -0,0 +1,41 @@ + + + + + {{ chat.name }} + + + + + + + + +
+ + {{ 'No chat groups available' | translate }} + +
+ + +
+ + + {{ 'Message' | translate }} + + +
diff --git a/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.scss b/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.scss new file mode 100644 index 000000000..c4e2b592c --- /dev/null +++ b/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.scss @@ -0,0 +1,7 @@ +.chat-form-field { + width: 100%; +} + +.chat-input { + // todo +} diff --git a/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.spec.ts b/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.spec.ts new file mode 100644 index 000000000..e4489d6b8 --- /dev/null +++ b/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { ChatTabsComponent } from './chat-tabs.component'; + +describe('ChatTabsComponent', () => { + let component: ChatTabsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ChatTabsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.ts b/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.ts new file mode 100644 index 000000000..e634144c5 --- /dev/null +++ b/client/src/app/site/chat/components/chat-tabs/chat-tabs.component.ts @@ -0,0 +1,86 @@ +import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTabChangeEvent } from '@angular/material/tabs'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, Observable } from 'rxjs'; + +import { ChatGroupRepositoryService } from 'app/core/repositories/chat/chat-group-repository.service'; +import { ChatMessageRepositoryService } from 'app/core/repositories/chat/chat-message-repository.service'; +import { ChatMessage } from 'app/shared/models/chat/chat-message'; +import { BaseViewComponentDirective } from 'app/site/base/base-view'; +import { ChatNotificationService, NotificationAmount } from '../../services/chat-notification.service'; +import { ViewChatGroup } from '../../models/view-chat-group'; + +@Component({ + selector: 'os-chat-tabs', + templateUrl: './chat-tabs.component.html', + styleUrls: ['./chat-tabs.component.scss'], + encapsulation: ViewEncapsulation.None +}) +export class ChatTabsComponent extends BaseViewComponentDirective implements OnInit { + public chatGroupSubject: BehaviorSubject; + public newMessageForm: FormGroup; + private selectedTabIndex = 0; + + private notifications: NotificationAmount; + + public constructor( + titleService: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + private repo: ChatGroupRepositoryService, + private chatMessageRepo: ChatMessageRepositoryService, + private chatNotificationService: ChatNotificationService, + formBuilder: FormBuilder + ) { + super(titleService, translate, matSnackBar); + + this.newMessageForm = formBuilder.group({ + text: [''] + }); + } + + public ngOnInit(): void { + this.chatGroupSubject = this.repo.getViewModelListBehaviorSubject(); + + this.chatNotificationService.chatgroupNotificationsObservable.subscribe(notifications => { + this.notifications = notifications; + }); + } + + public selectedTabChange(event: MatTabChangeEvent): void { + this.selectedTabIndex = event.index; + } + + public getNotidficationsForChatId(chatId: number): number { + return this.notifications?.[chatId] ?? 0; + } + + public chatGroupsExist(): boolean { + return this.chatGroupSubject.value.length > 0; + } + + public isChatMessageEmpty(): boolean { + return !this.newMessageForm?.value?.text?.trim(); + } + + public send(): void { + const payload = { + text: this.newMessageForm.value.text, + chatgroup_id: this.chatGroupSubject.value[this.selectedTabIndex].id + }; + this.chatMessageRepo + .create(payload as ChatMessage) + .then(() => { + this.clearTextInput(); + }) + .catch(this.raiseError); + } + + private clearTextInput(): void { + this.newMessageForm.reset(); + } +} diff --git a/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.html b/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.html new file mode 100644 index 000000000..9320b025e --- /dev/null +++ b/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.html @@ -0,0 +1,53 @@ +

+ + {{ 'Create new chat group' | translate }} + + + {{ 'Edit details for' | translate }} {{ previousChatGroupName }} +

+ +
+
+ + + + {{ 'Required' | translate }} + + + + + + +
+ +
+ + + + + +
+
diff --git a/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.scss b/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.spec.ts b/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.spec.ts new file mode 100644 index 000000000..8ce5c9c70 --- /dev/null +++ b/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.spec.ts @@ -0,0 +1,35 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; + +import { E2EImportsModule } from 'e2e-imports.module'; + +import { EditChatGroupDialogComponent } from './edit-chat-group-dialog.component'; + +describe('EditChatGroupDialogComponent', () => { + let component: EditChatGroupDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [ + { provide: MatDialogRef, useValue: {} }, + { + provide: MAT_DIALOG_DATA, + useValue: null + } + ], + declarations: [EditChatGroupDialogComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(EditChatGroupDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.ts b/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.ts new file mode 100644 index 000000000..8f505d037 --- /dev/null +++ b/client/src/app/site/chat/components/edit-chat-group-dialog/edit-chat-group-dialog.component.ts @@ -0,0 +1,43 @@ +import { Component, Inject } from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; + +import { TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; + +import { GroupRepositoryService } from 'app/core/repositories/users/group-repository.service'; +import { ViewGroup } from 'app/site/users/models/view-group'; + +export interface ChatGroupData { + name: string; + access_groups_id: number[]; +} + +@Component({ + selector: 'os-edit-chat-group-dialog', + templateUrl: './edit-chat-group-dialog.component.html', + styleUrls: ['./edit-chat-group-dialog.component.scss'] +}) +export class EditChatGroupDialogComponent { + public createUpdateForm: FormGroup; + public groupsBehaviorSubject: BehaviorSubject; + + public createMode: boolean; + + public get previousChatGroupName(): string { + return this.data?.name || ''; + } + + public constructor( + groupRepo: GroupRepositoryService, + formBuilder: FormBuilder, + @Inject(MAT_DIALOG_DATA) public data?: ChatGroupData + ) { + this.createMode = !data; + this.createUpdateForm = formBuilder.group({ + name: [data?.name || '', Validators.required], + access_groups_id: [data?.access_groups_id || []] + }); + this.groupsBehaviorSubject = groupRepo.getViewModelListBehaviorSubject(); + } +} diff --git a/client/src/app/core/ui-services/chat-notification.service.ts b/client/src/app/site/chat/services/chat-notification.service.ts similarity index 95% rename from client/src/app/core/ui-services/chat-notification.service.ts rename to client/src/app/site/chat/services/chat-notification.service.ts index 0754a18a5..7c59dc57c 100644 --- a/client/src/app/core/ui-services/chat-notification.service.ts +++ b/client/src/app/site/chat/services/chat-notification.service.ts @@ -3,8 +3,8 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; import { ViewChatMessage } from 'app/site/chat/models/view-chat-message'; -import { ChatMessageRepositoryService } from '../repositories/chat/chat-message-repository.service'; -import { StorageService } from '../core-services/storage.service'; +import { ChatMessageRepositoryService } from '../../../core/repositories/chat/chat-message-repository.service'; +import { StorageService } from '../../../core/core-services/storage.service'; interface LastMessageTimestampsSeen { [chatgroupId: number]: Date; diff --git a/client/src/app/core/ui-services/chat.service.ts b/client/src/app/site/chat/services/chat.service.ts similarity index 82% rename from client/src/app/core/ui-services/chat.service.ts rename to client/src/app/site/chat/services/chat.service.ts index a1716e921..e6c31f444 100644 --- a/client/src/app/core/ui-services/chat.service.ts +++ b/client/src/app/site/chat/services/chat.service.ts @@ -2,9 +2,9 @@ import { Injectable } from '@angular/core'; import { BehaviorSubject, Observable } from 'rxjs'; -import { ChatGroupRepositoryService } from '../repositories/chat/chat-group-repository.service'; -import { ConstantsService } from '../core-services/constants.service'; -import { OperatorService, Permission } from '../core-services/operator.service'; +import { ChatGroupRepositoryService } from '../../../core/repositories/chat/chat-group-repository.service'; +import { ConstantsService } from '../../../core/core-services/constants.service'; +import { OperatorService, Permission } from '../../../core/core-services/operator.service'; interface OpenSlidesSettings { ENABLE_CHAT: boolean; diff --git a/client/src/app/site/site.component.html b/client/src/app/site/site.component.html index a5981994e..d3d03bc27 100644 --- a/client/src/app/site/site.component.html +++ b/client/src/app/site/site.component.html @@ -48,10 +48,16 @@ routerLink="/chat" routerLinkActive="active" > - chat - notification_important - {{ 'Chat' | translate }} - ({{ chatNotificationAmount }}) + chat + + {{ 'Chat' | translate }} +