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
This commit is contained in:
Sean 2021-02-09 16:06:44 +01:00
parent 8e5b1fa99d
commit 69adc1d41c
40 changed files with 734 additions and 250 deletions

@ -1 +1 @@
Subproject commit 3380911a7e9cb4a906d3729d30a164ed3d59fd22
Subproject commit 756043511cc00b9fd4b42cc3a7ba0d8c16897895

View File

@ -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';

View File

@ -6,6 +6,11 @@ export class ChatMessage extends BaseModel<ChatMessage> {
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;

View File

@ -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);
}
}

View File

@ -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({

View File

@ -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 {}

View File

@ -1,53 +1,45 @@
<os-head-bar [nav]="false">
<!-- Title -->
<div *ngIf="chatgroup" class="title-slot">
<h2>{{ chatgroup.name }}</h2>
</div>
<!-- Menu -->
<div class="menu-slot" *osPerms="'chat.can_manage'">
<button type="button" mat-icon-button [matMenuTriggerFor]="chatgroupMenu">
<mat-icon>more_vert</mat-icon>
</button>
</div>
</os-head-bar>
<div class="messages">
<div *ngFor="let message of chatMessages">
{{ message.username }} ({{ message.timestamp }}): {{ message.text }}
</div>
<div class="chat-header" *osPerms="permission.chatCanManage">
<os-icon-container
icon="group"
*ngIf="chatGroup.access_groups.length"
matTooltip="{{ 'Access groups' | translate }}"
>
<span *ngFor="let group of chatGroup.access_groups | slice: 0:3; let last = last">
<span>{{ group.getTitle() | translate }}</span>
<span *ngIf="!last">, </span>
<span *ngIf="last && chatGroup.access_groups.length > 3">...</span>
</span>
</os-icon-container>
<button class="chat-options" mat-icon-button [matMenuTriggerFor]="chatgroupMenu">
<mat-icon> more_vert </mat-icon>
</button>
</div>
<div>
<form [formGroup]="newMessageForm">
<mat-form-field>
<input
type="text"
matInput
osAutofocus
required
formControlName="text"
/>
<mat-error *ngIf="newMessageForm.invalid">{{ 'Required' | translate }}</mat-error>
</mat-form-field>
</form>
<button
type="submit"
mat-button
[disabled]="!newMessageForm.valid"
color="accent"
(click)="send()"
>
<span>{{ 'Send' | translate }}</span>
</button>
<div class="chat-list-wrapper">
<cdk-virtual-scroll-viewport class="chat-message-list" itemSize="70">
<div *cdkVirtualFor="let message of chatMessages" class="chat-message">
<os-chat-message [message]="message" (deleteEvent)="deleteChatMessage(message)"></os-chat-message>
</div>
</cdk-virtual-scroll-viewport>
</div>
<!-- The menu content -->
<mat-menu #chatgroupMenu="matMenu">
<button mat-menu-item (click)="clearChat()" class="red-warning-text">
<mat-icon>delete</mat-icon>
<span>{{ 'Clear the chat' | translate }}</span>
</button>
<ng-container *osPerms="permission.chatCanManage">
<button mat-menu-item (click)="editChat()">
<mat-icon>edit</mat-icon>
<span>{{ 'Edit chat' | translate }}</span>
</button>
<!-- clear history -->
<button mat-menu-item (click)="clearChat()" class="red-warning-text">
<mat-icon>format_clear</mat-icon>
<span>{{ 'Clear chat' | translate }}</span>
</button>
<!-- delete -->
<button mat-menu-item (click)="deleteChatGroup()" class="red-warning-text">
<mat-icon>delete</mat-icon>
<span>{{ 'Delete chat' | translate }}</span>
</button>
</ng-container>
<!-- edit -->
</mat-menu>

View File

@ -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;
}

View File

@ -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<void> {
await this.repo.update(chatData as ChatGroup, this.chatGroup).catch(this.raiseError);
this.cd.markForCheck();
}
public async clearChat(): Promise<void> {
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<void> {
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<void> {
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();
}
}
}

View File

@ -1,60 +1,10 @@
<os-head-bar [hasMainButton]="canEdit" (mainEvent)="createNewChatGroup()">
<os-head-bar [hasMainButton]="canManage" (mainEvent)="createNewChatGroup()">
<!-- Title -->
<div class="title-slot">
<h2>{{ 'Chat groups' | translate }}</h2>
<h2>{{ 'Chat' | translate }}</h2>
</div>
</os-head-bar>
<div *ngFor="let chatGroup of chatGroups">
<a [routerLink]="'./' + chatGroup.id">{{ chatGroup.name }}</a><br/>
{{ chatGroup.access_groups.length ? chatGroup.access_groups : 'No access groups, public to all' }}<br/>
<button type="button" *osPerms="'chat.can_manage'" mat-icon-button (click)="edit(chatGroup)">
<mat-icon>create</mat-icon>
</button>
<div *ngIf="amountNotification(chatGroup)>0">{{ amountNotification(chatGroup) }} NEW MESSAGES</div>
<hr />
</div>
<!-- edit/update dialog -->
<ng-template #createUpdateDialog>
<h1 *ngIf="isCreateMode" mat-dialog-title>{{ 'Create new chat group' | translate }}</h1>
<h1 *ngIf="isEditMode" mat-dialog-title>{{ 'Edit details for' | translate }} {{ editModel.name }}</h1>
<div class="os-form-card-mobile" mat-dialog-content>
<form [formGroup]="createUpdateForm">
<mat-form-field>
<input
type="text"
matInput
osAutofocus
required
formControlName="name"
placeholder="{{ 'Name' | translate }}"
/>
<mat-error *ngIf="createUpdateForm.invalid">{{ 'Required' | translate }}</mat-error>
</mat-form-field>
<mat-form-field>
<os-search-value-selector
formControlName="access_groups_id"
[multiple]="true"
placeholder="{{ 'Access groups' | translate }}"
[inputListValues]="groupsBehaviorSubject"
></os-search-value-selector>
</mat-form-field>
</form>
</div>
<div mat-dialog-actions>
<button
type="submit"
mat-button
[disabled]="!createUpdateForm.valid"
[mat-dialog-close]="true"
color="accent"
>
<span>{{ 'Save' | translate }}</span>
</button>
<button type="button" mat-button [mat-dialog-close]="false">
<span>{{ 'Cancel' | translate }}</span>
</button>
</div>
</ng-template>
<mat-card class="os-card spacer-bottom-340">
<os-chat-tabs></os-chat-tabs>
</mat-card>

View File

@ -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<string>;
public createUpdateForm: FormGroup;
public groupsBehaviorSubject: BehaviorSubject<ViewGroup[]>;
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);
}
}

View File

@ -0,0 +1,21 @@
<div class="message-box" [ngClass]="{ 'incomming-message': !isOwnMessage, 'outgoind-message': isOwnMessage }">
<div class="author" *ngIf="!isOwnMessage">
{{ author }}
</div>
<div
class="chat-text"
[ngClass]="{ 'background-primary': !isOwnMessage, 'background-primary-darkest': isOwnMessage }"
[matMenuTriggerFor]="canDelete ? singleChatMenu : null"
>
{{ text }}
<div class="timestamp">{{ date | localizedDate }}</div>
</div>
</div>
<mat-menu #singleChatMenu="matMenu">
<!-- Delete this message -->
<button mat-menu-item (click)="onDeleteMessage()" *ngIf="canDelete">
<mat-icon color="warn">delete</mat-icon>
<span>{{ 'Delete' | translate }}</span>
</button>
</mat-menu>

View File

@ -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;
}

View File

@ -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<ChatMessageComponent>;
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();
});
});

View File

@ -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();
}
}

View File

@ -0,0 +1,41 @@
<mat-tab-group (selectedTabChange)="selectedTabChange($event)" *ngIf="chatGroupsExist()">
<mat-tab *ngFor="let chat of chatGroupSubject | async">
<ng-template mat-tab-label>
<span
[matBadgeHidden]="!getNotidficationsForChatId(chat.id)"
[matBadge]="getNotidficationsForChatId(chat.id)"
matBadgeColor="accent"
matBadgeOverlap="false"
>
{{ chat.name }}
</span>
</ng-template>
<ng-template matTabContent>
<os-chat-group-detail [chatGroup]="chat"> </os-chat-group-detail>
</ng-template>
</mat-tab>
</mat-tab-group>
<div *ngIf="!chatGroupsExist()">
<span>
{{ 'No chat groups available' | translate }}
</span>
</div>
<!-- send chat -->
<form [formGroup]="newMessageForm" (ngSubmit)="send()" *ngIf="chatGroupsExist()">
<mat-form-field appearance="outline" class="chat-form-field">
<input class="chat-input" type="text" matInput formControlName="text" />
<mat-label>{{ 'Message' | translate }}</mat-label>
<button
mat-icon-button
matSuffix
type="submit"
color="accent"
matTooltip=" {{ 'Send' | translate }}"
[disabled]="isChatMessageEmpty()"
>
<mat-icon>send</mat-icon>
</button>
</mat-form-field>
</form>

View File

@ -0,0 +1,7 @@
.chat-form-field {
width: 100%;
}
.chat-input {
// todo
}

View File

@ -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<ChatTabsComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ChatTabsComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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<ViewChatGroup[]>;
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();
}
}

View File

@ -0,0 +1,53 @@
<h1 mat-dialog-title>
<span *ngIf="createMode">
{{ 'Create new chat group' | translate }}
</span>
<span *ngIf="!createMode"> {{ 'Edit details for' | translate }} {{ previousChatGroupName }} </span>
</h1>
<form [formGroup]="createUpdateForm">
<div class="os-form-card-mobile" mat-dialog-content>
<!-- Name -->
<mat-form-field>
<input
type="text"
matInput
osAutofocus
required
formControlName="name"
placeholder="{{ 'Name' | translate }}"
/>
<mat-error *ngIf="createUpdateForm.invalid">{{ 'Required' | translate }}</mat-error>
</mat-form-field>
<!-- Groups -->
<mat-form-field>
<os-search-value-selector
formControlName="access_groups_id"
[multiple]="true"
placeholder="{{ 'Access groups' | translate }}"
[inputListValues]="groupsBehaviorSubject"
></os-search-value-selector>
</mat-form-field>
</div>
<div mat-dialog-actions>
<!-- save -->
<button
type="submit"
color="accent"
mat-button
[disabled]="createUpdateForm.invalid"
[mat-dialog-close]="createUpdateForm.value"
color="accent"
>
<span>{{ 'Save' | translate }}</span>
</button>
<!-- cancel -->
<button type="button" mat-button [mat-dialog-close]="false">
<span>{{ 'Cancel' | translate }}</span>
</button>
</div>
</form>

View File

@ -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<EditChatGroupDialogComponent>;
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();
});
});

View File

@ -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<ViewGroup[]>;
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();
}
}

View File

@ -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;

View File

@ -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;

View File

@ -48,10 +48,16 @@
routerLink="/chat"
routerLinkActive="active"
>
<mat-icon *ngIf="chatNotificationAmount <= 0">chat</mat-icon>
<mat-icon *ngIf="chatNotificationAmount > 0" class="unread-chat">notification_important</mat-icon>
<span>{{ 'Chat' | translate }}</span>
<span *ngIf="chatNotificationAmount > 0">({{ chatNotificationAmount }})</span>
<mat-icon
[matBadgeHidden]="chatNotificationAmount === 0"
[matBadge]="chatNotificationAmount"
matBadgeColor="accent"
matBadgeOverlap="false"
>chat</mat-icon
>
<span>
{{ 'Chat' | translate }}
</span>
</a>
<a
[@navItemAnim]

View File

@ -11,10 +11,10 @@ import { filter } from 'rxjs/operators';
import { navItemAnim } from '../shared/animations';
import { OfflineBroadcastService } from 'app/core/core-services/offline-broadcast.service';
import { ChatNotificationService } from 'app/core/ui-services/chat-notification.service';
import { ChatService } from 'app/core/ui-services/chat.service';
import { OverlayService } from 'app/core/ui-services/overlay.service';
import { UpdateService } from 'app/core/ui-services/update.service';
import { ChatNotificationService } from 'app/site/chat/services/chat-notification.service';
import { ChatService } from 'app/site/chat/services/chat.service';
import { BaseComponent } from '../base.component';
import { MainMenuEntry, MainMenuService } from '../core/core-services/main-menu.service';
import { OpenSlidesStatusService } from '../core/core-services/openslides-status.service';

View File

@ -173,10 +173,25 @@
background-color: rgba(0, 0, 0, 0.025);
}
.pbl-ngrid-header-row, .pbl-ngrid-row {
.pbl-ngrid-header-row,
.pbl-ngrid-row {
align-items: stretch;
}
/**
* CLEANUP:
* whole themes can be replaced using classes like this one
*/
.background-primary {
background: mat-color($primary);
color: mat-color($primary, default-contrast) !important;
}
.background-primary-darkest {
background: mat-color($primary, 900);
color: mat-color($primary, default-contrast) !important;
}
.primary-foreground {
color: mat-color($primary);
}

View File

@ -462,6 +462,10 @@ button.mat-menu-item.selected {
margin-bottom: 60px !important;
}
.spacer-bottom-340 {
margin-bottom: 340px !important;
}
.spacer-left-10 {
margin-left: 10px;
}

View File

@ -62,6 +62,7 @@ POSTFIX_RELAYHOST=
# Features
ENABLE_SAML=
ENABLE_ELECTRONIC_VOTING=
ENABLE_CHAT=
DEMO_USERS=
# Connections

View File

@ -70,6 +70,7 @@ x-osserver-env: &default-osserver-env
EMAIL_USE_TLS: "ifenvelse(`EMAIL_USE_TLS',)"
EMAIL_TIMEOUT: "ifenvelse(`EMAIL_TIMEOUT',)"
ENABLE_ELECTRONIC_VOTING: "ifenvelse(`ENABLE_ELECTRONIC_VOTING', False)"
ENABLE_CHAT: "ifenvelse(`ENABLE_CHAT', False)"
ENABLE_SAML: "ifenvelse(`ENABLE_SAML', False)"
INSTANCE_DOMAIN: "ifenvelse(`INSTANCE_DOMAIN', http://example.com:8000)"
JITSI_DOMAIN: "ifenvelse(`JITSI_DOMAIN',)"

View File

@ -69,6 +69,7 @@ x-osserver-env: &default-osserver-env
EMAIL_USE_TLS: "ifenvelse(`EMAIL_USE_TLS',)"
EMAIL_TIMEOUT: "ifenvelse(`EMAIL_TIMEOUT',)"
ENABLE_ELECTRONIC_VOTING: "ifenvelse(`ENABLE_ELECTRONIC_VOTING', False)"
ENABLE_CHAT: "ifenvelse(`ENABLE_CHAT', False)"
ENABLE_SAML: "ifenvelse(`ENABLE_SAML', False)"
INSTANCE_DOMAIN: "ifenvelse(`INSTANCE_DOMAIN', http://example.com:8000)"
JITSI_DOMAIN: "ifenvelse(`JITSI_DOMAIN',)"

View File

@ -65,6 +65,13 @@ To enable it, set::
ENABLE_ELECTRONIC_VOTING = True
Chat
=================
Is disabled by default. Enable the chatting feature::
ENABLE_CHAT = True
Jitsi integration
=================

View File

@ -125,6 +125,9 @@ if ENABLE_SAML:
# Controls if electronic voting (means non-analog polls) are enabled.
ENABLE_ELECTRONIC_VOTING = get_env("ENABLE_ELECTRONIC_VOTING", False, bool)
# Enable Chat
ENABLE_CHAT = get_env("ENABLE_CHAT", False, bool)
# Jitsi integration
JITSI_DOMAIN = get_env("JITSI_DOMAIN", None)
JITSI_ROOM_NAME = get_env("JITSI_ROOM_NAME", None)

View File

@ -55,6 +55,7 @@ class Migration(migrations.Migration):
("text", models.CharField(max_length=512)),
("timestamp", models.DateTimeField(auto_now_add=True)),
("username", models.CharField(max_length=256)),
("user_id", models.IntegerField()),
(
"chatgroup",
models.ForeignKey(

View File

@ -78,6 +78,7 @@ class ChatMessage(RESTModelMixin, models.Model):
ChatGroup, on_delete=CASCADE_AND_AUTOUPDATE, related_name="messages"
)
username = models.CharField(max_length=256)
user_id = models.IntegerField()
class Meta:
default_permissions = ()

View File

@ -45,9 +45,13 @@ class ChatMessageSerializer(ModelSerializer):
"chatgroup",
"timestamp",
"username",
"user_id",
"access_groups_id",
)
read_only_fields = ("username",)
read_only_fields = (
"username",
"user_id",
)
def validate(self, data):
if "text" in data:

View File

@ -1,10 +1,16 @@
from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from rest_framework.utils.serializer_helpers import ReturnDict
from openslides.utils.auth import has_perm
from openslides.utils.autoupdate import inform_changed_data, inform_deleted_data
from openslides.utils.autoupdate import (
disable_history,
inform_changed_data,
inform_deleted_data,
)
from openslides.utils.rest_api import (
CreateModelMixin,
DestroyModelMixin,
GenericViewSet,
ListModelMixin,
ModelViewSet,
@ -64,7 +70,11 @@ class ChatGroupViewSet(ModelViewSet):
class ChatMessageViewSet(
ListModelMixin, RetrieveModelMixin, CreateModelMixin, GenericViewSet
ListModelMixin,
RetrieveModelMixin,
CreateModelMixin,
DestroyModelMixin,
GenericViewSet,
):
"""
API endpoint for chat groups.
@ -76,7 +86,8 @@ class ChatMessageViewSet(
queryset = ChatMessage.objects.all()
def check_view_permissions(self):
return ENABLE_CHAT
# The permissions are checked in the view.
return ENABLE_CHAT and not isinstance(self.request.user, AnonymousUser)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
@ -89,6 +100,7 @@ class ChatMessageViewSet(
validated_data = {
**serializer.validated_data,
"username": self.request.user.short_name(),
"user_id": self.request.user.id,
}
chatmessage = ChatMessage(**validated_data)
chatmessage.save(disable_history=True)
@ -97,3 +109,14 @@ class ChatMessageViewSet(
ReturnDict(id=chatmessage.id, serializer=serializer),
status=status.HTTP_201_CREATED,
)
def destroy(self, request, *args, **kwargs):
if (
not has_perm(self.request.user, "chat.can_manage")
and self.get_object().user_id != self.request.user.id
):
self.permission_denied(request)
disable_history()
return super().destroy(request, *args, **kwargs)

View File

@ -110,6 +110,8 @@ if ENABLE_SAML:
# Controls if electronic voting (means non-analog polls) are enabled.
ENABLE_ELECTRONIC_VOTING = False
# Controls if chat should be enabled
ENABLE_CHAT = False
# Jitsi integration
# JITSI_DOMAIN = None

View File

@ -26,7 +26,10 @@ def test_motion_db_queries():
for i2 in range(10):
ChatMessage.objects.create(
text=f"text-{i1}-{i2}", username=f"user-{i1}-{i2}", chatgroup=chatgroup
text=f"text-{i1}-{i2}",
username=f"user-{i1}-{i2}",
user_id=i1 * 1000 + i2,
chatgroup=chatgroup,
)
assert count_queries(ChatGroup.get_elements)() == 2