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 { TranslateService } from '@ngx-translate/core';
import { filter, take } from 'rxjs/operators'; 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 { ConfigService } from './core/ui-services/config.service';
import { ConstantsService } from './core/core-services/constants.service'; import { ConstantsService } from './core/core-services/constants.service';
import { CountUsersService } from './core/ui-services/count-users.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 id: number;
public text: string; public text: string;
public username: 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 timestamp: string;
public chatgroup_id: number; public chatgroup_id: number;

View File

@ -5,6 +5,7 @@ import * as moment from 'moment';
/** /**
* pipe to convert and translate dates * pipe to convert and translate dates
* requires a "date"object
*/ */
@Pipe({ @Pipe({
name: 'localizedDate', name: 'localizedDate',
@ -13,13 +14,13 @@ import * as moment from 'moment';
export class LocalizedDatePipe implements PipeTransform { export class LocalizedDatePipe implements PipeTransform {
public constructor(private translate: TranslateService) {} 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; const lang = this.translate.currentLang ? this.translate.currentLang : this.translate.defaultLang;
if (!value) { if (!date) {
return ''; return '';
} }
moment.locale(lang); moment.locale(lang);
const dateLocale = moment.unix(value).local(); const dateLocale = moment.unix(date.getTime() / 1000).local();
return dateLocale.format(dateFormat); return dateLocale.format(dateFormat);
} }
} }

View File

@ -1,7 +1,6 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { Route, RouterModule } from '@angular/router'; 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'; import { ChatGroupListComponent } from './components/chat-group-list/chat-group-list.component';
const routes: Route[] = [ const routes: Route[] = [
@ -9,8 +8,7 @@ const routes: Route[] = [
path: '', path: '',
pathMatch: 'full', pathMatch: 'full',
component: ChatGroupListComponent component: ChatGroupListComponent
}, }
{ path: ':id', component: ChatGroupDetailComponent }
]; ];
@NgModule({ @NgModule({

View File

@ -3,11 +3,20 @@ import { NgModule } from '@angular/core';
import { ChatGroupDetailComponent } from './components/chat-group-detail/chat-group-detail.component'; import { ChatGroupDetailComponent } from './components/chat-group-detail/chat-group-detail.component';
import { ChatGroupListComponent } from './components/chat-group-list/chat-group-list.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 { 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'; import { SharedModule } from '../../shared/shared.module';
@NgModule({ @NgModule({
imports: [CommonModule, ChatRoutingModule, SharedModule], imports: [CommonModule, ChatRoutingModule, SharedModule],
declarations: [ChatGroupListComponent, ChatGroupDetailComponent] declarations: [
ChatGroupListComponent,
ChatGroupDetailComponent,
ChatTabsComponent,
EditChatGroupDialogComponent,
ChatMessageComponent
]
}) })
export class ChatModule {} export class ChatModule {}

View File

@ -1,53 +1,45 @@
<os-head-bar [nav]="false"> <div class="chat-header" *osPerms="permission.chatCanManage">
<!-- Title --> <os-icon-container
<div *ngIf="chatgroup" class="title-slot"> icon="group"
<h2>{{ chatgroup.name }}</h2> *ngIf="chatGroup.access_groups.length"
</div> matTooltip="{{ 'Access groups' | translate }}"
>
<!-- Menu --> <span *ngFor="let group of chatGroup.access_groups | slice: 0:3; let last = last">
<div class="menu-slot" *osPerms="'chat.can_manage'"> <span>{{ group.getTitle() | translate }}</span>
<button type="button" mat-icon-button [matMenuTriggerFor]="chatgroupMenu"> <span *ngIf="!last">, </span>
<mat-icon>more_vert</mat-icon> <span *ngIf="last && chatGroup.access_groups.length > 3">...</span>
</button> </span>
</div> </os-icon-container>
</os-head-bar> <button class="chat-options" mat-icon-button [matMenuTriggerFor]="chatgroupMenu">
<mat-icon> more_vert </mat-icon>
</button>
<div class="messages">
<div *ngFor="let message of chatMessages">
{{ message.username }} ({{ message.timestamp }}): {{ message.text }}
</div>
</div> </div>
<div class="chat-list-wrapper">
<div> <cdk-virtual-scroll-viewport class="chat-message-list" itemSize="70">
<form [formGroup]="newMessageForm"> <div *cdkVirtualFor="let message of chatMessages" class="chat-message">
<mat-form-field> <os-chat-message [message]="message" (deleteEvent)="deleteChatMessage(message)"></os-chat-message>
<input </div>
type="text" </cdk-virtual-scroll-viewport>
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> </div>
<!-- The menu content --> <!-- The menu content -->
<mat-menu #chatgroupMenu="matMenu"> <mat-menu #chatgroupMenu="matMenu">
<button mat-menu-item (click)="clearChat()" class="red-warning-text"> <ng-container *osPerms="permission.chatCanManage">
<mat-icon>delete</mat-icon> <button mat-menu-item (click)="editChat()">
<span>{{ 'Clear the chat' | translate }}</span> <mat-icon>edit</mat-icon>
</button> <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> </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 { CdkVirtualScrollViewport, ExtendedScrollToOptions } from '@angular/cdk/scrolling';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; 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 { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { ChatGroupRepositoryService } from 'app/core/repositories/chat/chat-group-repository.service'; import { ChatGroupRepositoryService } from 'app/core/repositories/chat/chat-group-repository.service';
import { ChatMessageRepositoryService } from 'app/core/repositories/chat/chat-message-repository.service'; import { ChatMessageRepositoryService } from 'app/core/repositories/chat/chat-message-repository.service';
import { ChatNotificationService } from 'app/core/ui-services/chat-notification.service'; import { PromptService } from 'app/core/ui-services/prompt.service';
import { ChatMessage } from 'app/shared/models/chat/chat-message'; 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 { 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 { ViewChatGroup } from '../../models/view-chat-group';
import { ViewChatMessage } from '../../models/view-chat-message'; import { ViewChatMessage } from '../../models/view-chat-message';
@Component({ @Component({
selector: 'os-chat-group-detail', selector: 'os-chat-group-detail',
templateUrl: './chat-group-detail.component.html', 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 { export class ChatGroupDetailComponent extends BaseViewComponentDirective implements OnInit, AfterViewInit, OnDestroy {
public newMessageForm: FormGroup; @Input()
public chatgroup: ViewChatGroup; public chatGroup: ViewChatGroup;
public chatgroupId: number;
@ViewChild(CdkVirtualScrollViewport)
public virtualScrollViewport?: CdkVirtualScrollViewport;
public chatGroupId: number;
public chatMessages: ViewChatMessage[] = []; public chatMessages: ViewChatMessage[] = [];
public get isOnBottomOfChat(): boolean {
const isOnBottom = this.virtualScrollViewport?.measureScrollOffset('bottom') === 0;
return isOnBottom;
}
public constructor( public constructor(
titleService: Title, titleService: Title,
protected translate: TranslateService, protected translate: TranslateService,
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
private chatGroupRepo: ChatGroupRepositoryService, private repo: ChatGroupRepositoryService,
private chatMessageRepo: ChatMessageRepositoryService, private chatMessageRepo: ChatMessageRepositoryService,
private route: ActivatedRoute, private chatNotificationService: ChatNotificationService,
private formBuilder: FormBuilder, private dialog: MatDialog,
private chatNotificationService: ChatNotificationService private promptService: PromptService,
private cd: ChangeDetectorRef
) { ) {
super(titleService, translate, matSnackBar); super(titleService, translate, matSnackBar);
}
this.newMessageForm = this.formBuilder.group({ public ngOnInit(): void {
text: ['', Validators.required] this.chatGroupId = this.chatGroup.id;
}); this.chatNotificationService.openChat(this.chatGroupId);
this.chatgroupId = parseInt(this.route.snapshot.params.id, 10);
this.subscriptions.push( 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.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 { public ngAfterViewInit(): void {
super.setTitle('Chat group'); this.scrollToBottom();
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 ngOnDestroy(): void { 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 --> <!-- Title -->
<div class="title-slot"> <div class="title-slot">
<h2>{{ 'Chat groups' | translate }}</h2> <h2>{{ 'Chat' | translate }}</h2>
</div> </div>
</os-head-bar> </os-head-bar>
<div *ngFor="let chatGroup of chatGroups"> <mat-card class="os-card spacer-bottom-340">
<a [routerLink]="'./' + chatGroup.id">{{ chatGroup.name }}</a><br/> <os-chat-tabs></os-chat-tabs>
{{ chatGroup.access_groups.length ? chatGroup.access_groups : 'No access groups, public to all' }}<br/> </mat-card>
<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>

View File

@ -1,19 +1,20 @@
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { MatDialog } from '@angular/material/dialog';
import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core'; 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 { 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 { 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 { 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'; import { ViewChatGroup } from '../../models/view-chat-group';
@Component({ @Component({
@ -22,99 +23,39 @@ import { ViewChatGroup } from '../../models/view-chat-group';
styleUrls: ['./chat-group-list.component.scss'] styleUrls: ['./chat-group-list.component.scss']
}) })
export class ChatGroupListComponent extends BaseViewComponentDirective implements OnInit { export class ChatGroupListComponent extends BaseViewComponentDirective implements OnInit {
@ViewChild('createUpdateDialog', { static: true }) public get canManage(): boolean {
private createUpdateDialog: TemplateRef<string>; return this.operator.hasPerms(this.permission.chatCanManage);
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 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( public constructor(
titleService: Title, titleService: Title,
protected translate: TranslateService, protected translate: TranslateService,
matSnackBar: MatSnackBar, matSnackBar: MatSnackBar,
private repo: ChatGroupRepositoryService, private repo: ChatGroupRepositoryService,
private formBuilder: FormBuilder,
private groupRepo: GroupRepositoryService,
private dialog: MatDialog, private dialog: MatDialog,
private operator: OperatorService, private operator: OperatorService
private chatNotificationService: ChatNotificationService
) { ) {
super(titleService, translate, matSnackBar); 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 { public ngOnInit(): void {
super.setTitle('Chat groups'); super.setTitle('Chat');
} }
public createNewChatGroup(): void { public createNewChatGroup(): void {
if (this.isEdit) { const dialogRef = this.dialog.open(EditChatGroupDialogComponent, {
return; data: null,
} ...infoDialogSettings
});
this.isEdit = true; dialogRef.afterClosed().subscribe((res: ChatGroupData) => {
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 => {
if (res) { if (res) {
this.save(); this.save(res);
} }
this.isEdit = false;
}); });
} }
public save(): void { public save(createData: ChatGroupData): void {
if (this.isCreateMode) { this.repo.create(createData as ChatGroup).catch(this.raiseError);
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];
} }
} }

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 { BehaviorSubject, Observable } from 'rxjs';
import { ViewChatMessage } from 'app/site/chat/models/view-chat-message'; import { ViewChatMessage } from 'app/site/chat/models/view-chat-message';
import { ChatMessageRepositoryService } from '../repositories/chat/chat-message-repository.service'; import { ChatMessageRepositoryService } from '../../../core/repositories/chat/chat-message-repository.service';
import { StorageService } from '../core-services/storage.service'; import { StorageService } from '../../../core/core-services/storage.service';
interface LastMessageTimestampsSeen { interface LastMessageTimestampsSeen {
[chatgroupId: number]: Date; [chatgroupId: number]: Date;

View File

@ -2,9 +2,9 @@ import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { ChatGroupRepositoryService } from '../repositories/chat/chat-group-repository.service'; import { ChatGroupRepositoryService } from '../../../core/repositories/chat/chat-group-repository.service';
import { ConstantsService } from '../core-services/constants.service'; import { ConstantsService } from '../../../core/core-services/constants.service';
import { OperatorService, Permission } from '../core-services/operator.service'; import { OperatorService, Permission } from '../../../core/core-services/operator.service';
interface OpenSlidesSettings { interface OpenSlidesSettings {
ENABLE_CHAT: boolean; ENABLE_CHAT: boolean;

View File

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

View File

@ -11,10 +11,10 @@ import { filter } from 'rxjs/operators';
import { navItemAnim } from '../shared/animations'; import { navItemAnim } from '../shared/animations';
import { OfflineBroadcastService } from 'app/core/core-services/offline-broadcast.service'; 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 { OverlayService } from 'app/core/ui-services/overlay.service';
import { UpdateService } from 'app/core/ui-services/update.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 { BaseComponent } from '../base.component';
import { MainMenuEntry, MainMenuService } from '../core/core-services/main-menu.service'; import { MainMenuEntry, MainMenuService } from '../core/core-services/main-menu.service';
import { OpenSlidesStatusService } from '../core/core-services/openslides-status.service'; import { OpenSlidesStatusService } from '../core/core-services/openslides-status.service';

View File

@ -173,10 +173,25 @@
background-color: rgba(0, 0, 0, 0.025); 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; 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 { .primary-foreground {
color: mat-color($primary); color: mat-color($primary);
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -125,6 +125,9 @@ if ENABLE_SAML:
# Controls if electronic voting (means non-analog polls) are enabled. # Controls if electronic voting (means non-analog polls) are enabled.
ENABLE_ELECTRONIC_VOTING = get_env("ENABLE_ELECTRONIC_VOTING", False, bool) ENABLE_ELECTRONIC_VOTING = get_env("ENABLE_ELECTRONIC_VOTING", False, bool)
# Enable Chat
ENABLE_CHAT = get_env("ENABLE_CHAT", False, bool)
# Jitsi integration # Jitsi integration
JITSI_DOMAIN = get_env("JITSI_DOMAIN", None) JITSI_DOMAIN = get_env("JITSI_DOMAIN", None)
JITSI_ROOM_NAME = get_env("JITSI_ROOM_NAME", 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)), ("text", models.CharField(max_length=512)),
("timestamp", models.DateTimeField(auto_now_add=True)), ("timestamp", models.DateTimeField(auto_now_add=True)),
("username", models.CharField(max_length=256)), ("username", models.CharField(max_length=256)),
("user_id", models.IntegerField()),
( (
"chatgroup", "chatgroup",
models.ForeignKey( models.ForeignKey(

View File

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

View File

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

View File

@ -1,10 +1,16 @@
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from rest_framework.utils.serializer_helpers import ReturnDict from rest_framework.utils.serializer_helpers import ReturnDict
from openslides.utils.auth import has_perm 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 ( from openslides.utils.rest_api import (
CreateModelMixin, CreateModelMixin,
DestroyModelMixin,
GenericViewSet, GenericViewSet,
ListModelMixin, ListModelMixin,
ModelViewSet, ModelViewSet,
@ -64,7 +70,11 @@ class ChatGroupViewSet(ModelViewSet):
class ChatMessageViewSet( class ChatMessageViewSet(
ListModelMixin, RetrieveModelMixin, CreateModelMixin, GenericViewSet ListModelMixin,
RetrieveModelMixin,
CreateModelMixin,
DestroyModelMixin,
GenericViewSet,
): ):
""" """
API endpoint for chat groups. API endpoint for chat groups.
@ -76,7 +86,8 @@ class ChatMessageViewSet(
queryset = ChatMessage.objects.all() queryset = ChatMessage.objects.all()
def check_view_permissions(self): 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): def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
@ -89,6 +100,7 @@ class ChatMessageViewSet(
validated_data = { validated_data = {
**serializer.validated_data, **serializer.validated_data,
"username": self.request.user.short_name(), "username": self.request.user.short_name(),
"user_id": self.request.user.id,
} }
chatmessage = ChatMessage(**validated_data) chatmessage = ChatMessage(**validated_data)
chatmessage.save(disable_history=True) chatmessage.save(disable_history=True)
@ -97,3 +109,14 @@ class ChatMessageViewSet(
ReturnDict(id=chatmessage.id, serializer=serializer), ReturnDict(id=chatmessage.id, serializer=serializer),
status=status.HTTP_201_CREATED, 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. # Controls if electronic voting (means non-analog polls) are enabled.
ENABLE_ELECTRONIC_VOTING = False ENABLE_ELECTRONIC_VOTING = False
# Controls if chat should be enabled
ENABLE_CHAT = False
# Jitsi integration # Jitsi integration
# JITSI_DOMAIN = None # JITSI_DOMAIN = None

View File

@ -26,7 +26,10 @@ def test_motion_db_queries():
for i2 in range(10): for i2 in range(10):
ChatMessage.objects.create( 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 assert count_queries(ChatGroup.get_elements)() == 2