Add Chat on Server and client as draft

This commit is contained in:
Finn Stutzenstein 2020-12-04 06:51:01 +01:00 committed by Sean
parent 8e98966db2
commit 8e5b1fa99d
41 changed files with 1240 additions and 5 deletions

@ -1 +1 @@
Subproject commit 020bb29d9924ffb32c60e081e019acc2984ac42e
Subproject commit 3380911a7e9cb4a906d3729d30a164ed3d59fd22

View File

@ -6,6 +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 { ConfigService } from './core/ui-services/config.service';
import { ConstantsService } from './core/core-services/constants.service';
import { CountUsersService } from './core/ui-services/count-users.service';
@ -83,7 +84,8 @@ export class AppComponent {
loadFontService: LoadFontService,
dataStoreUpgradeService: DataStoreUpgradeService, // to start it.
routingState: RoutingStateService,
votingBannerService: VotingBannerService // needed for initialisation
votingBannerService: VotingBannerService, // needed for initialisation,
chatNotificationService: ChatNotificationService
) {
// manually add the supported languages
translate.addLangs(['en', 'de', 'cs', 'ru']);

View File

@ -3,6 +3,7 @@ import { Injectable, Injector } from '@angular/core';
import { AgendaAppConfig } from '../../site/agenda/agenda.config';
import { AppConfig, ModelEntry, SearchableModelEntry } from '../definitions/app-config';
import { BaseRepository } from 'app/core/repositories/base-repository';
import { ChatAppConfig } from 'app/site/chat/chat.config';
import { CinemaAppConfig } from 'app/site/cinema/cinema.config';
import { HistoryAppConfig } from 'app/site/history/history.config';
import { ProjectorAppConfig } from 'app/site/projector/projector.config';
@ -37,7 +38,8 @@ const appConfigs: AppConfig[] = [
HistoryAppConfig,
ProjectorAppConfig,
TopicsAppConfig,
CinemaAppConfig
CinemaAppConfig,
ChatAppConfig
];
/**

View File

@ -53,7 +53,8 @@ export enum Permission {
usersCanChangePassword = 'users.can_change_password',
usersCanManage = 'users.can_manage',
usersCanSeeExtraData = 'users.can_see_extra_data',
usersCanSeeName = 'users.can_see_name'
usersCanSeeName = 'users.can_see_name',
chatCanManage = 'chat.can_manage'
}
/**

View File

@ -0,0 +1,70 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { HttpService } from 'app/core/core-services/http.service';
import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { RelationDefinition } from 'app/core/definitions/relations';
import { ChatGroup } from 'app/shared/models/chat/chat-group';
import { ChatGroupTitleInformation, ViewChatGroup } from 'app/site/chat/models/view-chat-group';
import { ViewChatMessage } from 'app/site/chat/models/view-chat-message';
import { ViewGroup } from 'app/site/users/models/view-group';
import { BaseRepository } from '../base-repository';
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
import { DataSendService } from '../../core-services/data-send.service';
import { DataStoreService } from '../../core-services/data-store.service';
const ChatGroupRelations: RelationDefinition[] = [
{
type: 'M2M',
ownIdKey: 'access_groups_id',
ownKey: 'access_groups',
foreignViewModel: ViewGroup
}
];
@Injectable({
providedIn: 'root'
})
export class ChatGroupRepositoryService extends BaseRepository<ViewChatGroup, ChatGroup, ChatGroupTitleInformation> {
public constructor(
DS: DataStoreService,
dataSend: DataSendService,
mapperService: CollectionStringMapperService,
viewModelStoreService: ViewModelStoreService,
translate: TranslateService,
relationManager: RelationManagerService,
private http: HttpService
) {
super(
DS,
dataSend,
mapperService,
viewModelStoreService,
translate,
relationManager,
ChatGroup,
ChatGroupRelations
);
this.initSorting();
}
public getTitle = (titleInformation: ChatGroupTitleInformation) => {
return titleInformation.name;
};
public getVerboseName = (plural: boolean = false) => {
return this.translate.instant(plural ? 'Chat groups' : 'Chat group');
};
private initSorting(): void {
this.setSortFunction((a: ViewChatGroup, b: ViewChatGroup) => {
return this.languageCollator.compare(a.name, b.name);
});
}
public async clearMessages(chatGroup: ViewChatGroup): Promise<void> {
return this.http.post(`/rest/chat/chat-group/${chatGroup.id}/clear/`);
}
}

View File

@ -0,0 +1,67 @@
import { Injectable } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { RelationManagerService } from 'app/core/core-services/relation-manager.service';
import { ViewModelStoreService } from 'app/core/core-services/view-model-store.service';
import { RelationDefinition } from 'app/core/definitions/relations';
import { ChatMessage } from 'app/shared/models/chat/chat-message';
import { ViewChatGroup } from 'app/site/chat/models/view-chat-group';
import { ChatMessageTitleInformation, ViewChatMessage } from 'app/site/chat/models/view-chat-message';
import { BaseRepository } from '../base-repository';
import { CollectionStringMapperService } from '../../core-services/collection-string-mapper.service';
import { DataSendService } from '../../core-services/data-send.service';
import { DataStoreService } from '../../core-services/data-store.service';
const ChatMessageRelations: RelationDefinition[] = [
{
type: 'M2M',
ownIdKey: 'chatgroup_id',
ownKey: 'chatgroup',
foreignViewModel: ViewChatGroup
}
];
@Injectable({
providedIn: 'root'
})
export class ChatMessageRepositoryService extends BaseRepository<
ViewChatMessage,
ChatMessage,
ChatMessageTitleInformation
> {
public constructor(
DS: DataStoreService,
dataSend: DataSendService,
mapperService: CollectionStringMapperService,
viewModelStoreService: ViewModelStoreService,
translate: TranslateService,
relationManager: RelationManagerService
) {
super(
DS,
dataSend,
mapperService,
viewModelStoreService,
translate,
relationManager,
ChatMessage,
ChatMessageRelations
);
this.initSorting();
}
public getTitle = (titleInformation: ChatMessageTitleInformation) => {
return 'Chat message';
};
public getVerboseName = (plural: boolean = false) => {
return this.translate.instant(plural ? 'Chat messages' : 'Chat message');
};
private initSorting(): void {
this.setSortFunction((a: ViewChatMessage, b: ViewChatMessage) => {
return a.timestampAsDate > b.timestampAsDate ? 1 : -1;
});
}
}

View File

@ -0,0 +1,111 @@
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';
interface LastMessageTimestampsSeen {
[chatgroupId: number]: Date;
}
interface StorageLastMessageTimestampsSeen {
[chatgroupId: number]: string;
}
function isStorageLastMessageTimestampsSeen(obj: any): obj is StorageLastMessageTimestampsSeen {
if (!obj || typeof obj !== 'object') {
return false;
}
const _obj = obj as object;
return Object.keys(_obj).every(id => {
return +id > 0 && new Date(_obj[id]).getTime() !== NaN;
});
}
export interface NotificationAmount {
[chatgroupId: number]: number; // the amount of notifications per chat group.
}
const STORAGE_KEY = 'chat-notifications';
@Injectable({
providedIn: 'root'
})
export class ChatNotificationService {
private chatgroupNotifications = new BehaviorSubject<NotificationAmount>({});
public get chatgroupNotificationsObservable(): Observable<NotificationAmount> {
return this.chatgroupNotifications.asObservable();
}
private lastMessageTimestampSeen: LastMessageTimestampsSeen = {};
private oldMessageLength = 0;
private openChatgroupIds: number[] = [];
public constructor(private repo: ChatMessageRepositoryService, private storage: StorageService) {
this.setup();
}
private async setup(): Promise<void> {
await this.loadFromStorage();
this.repo.getViewModelListBehaviorSubject().subscribe(messages => {
if (messages && messages.length !== this.oldMessageLength) {
this.processChatMessageUpdate(messages);
this.oldMessageLength = messages.length;
}
});
}
private async loadFromStorage(): Promise<void> {
const lastTimestamps = await this.storage.get(STORAGE_KEY);
if (isStorageLastMessageTimestampsSeen(lastTimestamps)) {
Object.keys(lastTimestamps).forEach(id => {
this.lastMessageTimestampSeen[+id] = new Date(lastTimestamps[id]);
});
}
}
private processChatMessageUpdate(messages: ViewChatMessage[]): void {
const notifications: NotificationAmount = {};
messages.forEach(message => {
const lastTimestamp = this.lastMessageTimestampSeen[message.chatgroup_id];
if (
!this.openChatgroupIds.includes(message.chatgroup_id) &&
(!lastTimestamp || lastTimestamp < message.timestampAsDate)
) {
notifications[message.chatgroup_id] = (notifications[message.chatgroup_id] || 0) + 1;
}
});
this.chatgroupNotifications.next(notifications);
}
public openChat(chatgroupId: number): void {
this.openChatgroupIds.push(chatgroupId);
// clear notification
this.lastMessageTimestampSeen[chatgroupId] = new Date(); // set surrent date as new seen.
this.saveToStorage();
// mute notifications locally
const currentNotificationAmounts = this.chatgroupNotifications.getValue();
currentNotificationAmounts[chatgroupId] = 0;
this.chatgroupNotifications.next(currentNotificationAmounts);
}
public closeChat(chatgroupId: number): void {
// clear notification
this.lastMessageTimestampSeen[chatgroupId] = new Date(); // set surrent date as new seen.
this.saveToStorage();
// unmute notifications locally
this.openChatgroupIds = this.openChatgroupIds.filter(id => id !== chatgroupId);
}
private saveToStorage(): void {
const lastSeen: StorageLastMessageTimestampsSeen = {};
Object.keys(this.lastMessageTimestampSeen).forEach(id => {
lastSeen[id] = this.lastMessageTimestampSeen[+id].toISOString();
});
this.storage.set(STORAGE_KEY, lastSeen);
}
}

View File

@ -0,0 +1,50 @@
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';
interface OpenSlidesSettings {
ENABLE_CHAT: boolean;
}
@Injectable({
providedIn: 'root'
})
export class ChatService {
private chatEnabled = false;
private canSeeSomeChatGroup = false;
private canManage = false;
private canSeeChat = new BehaviorSubject<boolean>(false);
public get canSeeChatObservable(): Observable<boolean> {
return this.canSeeChat.asObservable();
}
public constructor(
private repo: ChatGroupRepositoryService,
private operator: OperatorService,
private constantsService: ConstantsService
) {
this.constantsService.get<OpenSlidesSettings>('Settings').subscribe(settings => {
this.chatEnabled = settings.ENABLE_CHAT;
this.update();
});
this.repo.getViewModelListBehaviorSubject().subscribe(groups => {
this.canSeeSomeChatGroup = !!groups && groups.length > 0;
this.update();
});
this.operator.getViewUserObservable().subscribe(() => {
this.canManage = this.operator.hasPerms(Permission.chatCanManage);
this.update();
});
}
private update(): void {
this.canSeeChat.next(this.chatEnabled && (this.canSeeSomeChatGroup || this.canManage));
}
}

View File

@ -0,0 +1,13 @@
import { BaseModel } from '../base/base-model';
export class ChatGroup extends BaseModel<ChatGroup> {
public static COLLECTIONSTRING = 'chat/chat-group';
public id: number;
public name: string;
public access_groups_id: number[];
public constructor(input?: any) {
super(ChatGroup.COLLECTIONSTRING, input);
}
}

View File

@ -0,0 +1,21 @@
import { BaseModel } from '../base/base-model';
export class ChatMessage extends BaseModel<ChatMessage> {
public static COLLECTIONSTRING = 'chat/chat-message';
public id: number;
public text: string;
public username: string;
public timestamp: string;
public chatgroup_id: number;
private _timestampAsDate: Date;
public get timestampAsDate(): Date {
return this._timestampAsDate;
}
public constructor(input?: any) {
super(ChatMessage.COLLECTIONSTRING, input);
this._timestampAsDate = new Date(this.timestamp);
}
}

View File

@ -0,0 +1,20 @@
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[] = [
{
path: '',
pathMatch: 'full',
component: ChatGroupListComponent
},
{ path: ':id', component: ChatGroupDetailComponent }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ChatRoutingModule {}

View File

@ -0,0 +1,23 @@
import { AppConfig } from '../../core/definitions/app-config';
import { ChatGroupRepositoryService } from 'app/core/repositories/chat/chat-group-repository.service';
import { ChatMessageRepositoryService } from 'app/core/repositories/chat/chat-message-repository.service';
import { ChatGroup } from 'app/shared/models/chat/chat-group';
import { ChatMessage } from 'app/shared/models/chat/chat-message';
import { ViewChatGroup } from './models/view-chat-group';
import { ViewChatMessage } from './models/view-chat-message';
export const ChatAppConfig: AppConfig = {
name: 'chat',
models: [
{
model: ChatGroup,
viewModel: ViewChatGroup,
repository: ChatGroupRepositoryService
},
{
model: ChatMessage,
viewModel: ViewChatMessage,
repository: ChatMessageRepositoryService
}
]
};

View File

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
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 { ChatRoutingModule } from './chat-routing.module';
import { SharedModule } from '../../shared/shared.module';
@NgModule({
imports: [CommonModule, ChatRoutingModule, SharedModule],
declarations: [ChatGroupListComponent, ChatGroupDetailComponent]
})
export class ChatModule {}

View File

@ -0,0 +1,53 @@
<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>
<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>
<!-- 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>
</mat-menu>

View File

@ -0,0 +1,79 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
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 { BaseViewComponentDirective } from 'app/site/base/base-view';
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']
})
export class ChatGroupDetailComponent extends BaseViewComponentDirective implements OnInit, OnDestroy {
public newMessageForm: FormGroup;
public chatgroup: ViewChatGroup;
public chatgroupId: number;
public chatMessages: ViewChatMessage[] = [];
public constructor(
titleService: Title,
protected translate: TranslateService,
matSnackBar: MatSnackBar,
private chatGroupRepo: ChatGroupRepositoryService,
private chatMessageRepo: ChatMessageRepositoryService,
private route: ActivatedRoute,
private formBuilder: FormBuilder,
private chatNotificationService: ChatNotificationService
) {
super(titleService, translate, matSnackBar);
this.newMessageForm = this.formBuilder.group({
text: ['', Validators.required]
});
this.chatgroupId = parseInt(this.route.snapshot.params.id, 10);
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);
})
);
}
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 ngOnDestroy(): void {
this.chatNotificationService.closeChat(this.chatgroupId);
}
}

View File

@ -0,0 +1,60 @@
<os-head-bar [hasMainButton]="canEdit" (mainEvent)="createNewChatGroup()">
<!-- Title -->
<div class="title-slot">
<h2>{{ 'Chat groups' | 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>

View File

@ -0,0 +1,120 @@
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatDialog, MatDialogRef } 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 { 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 { BaseViewComponentDirective } from 'app/site/base/base-view';
import { ViewGroup } from 'app/site/users/models/view-group';
import { ViewChatGroup } from '../../models/view-chat-group';
@Component({
selector: 'os-chat-group-list',
templateUrl: './chat-group-list.component.html',
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 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
) {
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');
}
public createNewChatGroup(): void {
if (this.isEdit) {
return;
}
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 => {
if (res) {
this.save();
}
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];
}
}

View File

@ -0,0 +1,19 @@
import { ChatGroup } from 'app/shared/models/chat/chat-group';
import { BaseViewModel } from '../../base/base-view-model';
import { ViewGroup } from '../../users/models/view-group';
export interface ChatGroupTitleInformation {
name: string;
}
export class ViewChatGroup extends BaseViewModel<ChatGroup> implements ChatGroupTitleInformation {
public static COLLECTIONSTRING = ChatGroup.COLLECTIONSTRING;
protected _collectionString = ChatGroup.COLLECTIONSTRING;
public get chatGroup(): ChatGroup {
return this._model;
}
}
export interface ViewChatGroup extends ChatGroup {
access_groups: ViewGroup[];
}

View File

@ -0,0 +1,17 @@
import { ChatMessage } from 'app/shared/models/chat/chat-message';
import { BaseViewModel } from '../../base/base-view-model';
import { ViewChatGroup } from './view-chat-group';
export interface ChatMessageTitleInformation {}
export class ViewChatMessage extends BaseViewModel<ChatMessage> implements ChatMessageTitleInformation {
public static COLLECTIONSTRING = ChatMessage.COLLECTIONSTRING;
protected _collectionString = ChatMessage.COLLECTIONSTRING;
public get chatMessage(): ChatMessage {
return this._model;
}
}
export interface ViewChatMessage extends ChatMessage {
chatgroup: ViewChatGroup;
}

View File

@ -39,6 +39,10 @@ const routes: Route[] = [
loadChildren: () => import('./mediafiles/mediafiles.module').then(m => m.MediafilesModule),
data: { basePerm: Permission.mediafilesCanSee }
},
{
path: 'chat',
loadChildren: () => import('./chat/chat.module').then(m => m.ChatModule)
},
{
path: 'motions',
loadChildren: () => import('./motions/motions.module').then(m => m.MotionsModule),

View File

@ -40,6 +40,19 @@
</a>
</span>
<mat-divider></mat-divider>
<a
[@navItemAnim]
*ngIf="canSeeChat"
mat-list-item
(click)="mobileAutoCloseNav()"
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>
</a>
<a
[@navItemAnim]
mat-list-item

View File

@ -141,3 +141,7 @@ mat-sidenav-container {
margin-top: auto;
}
}
.unread-chat {
color: red !important;
}

View File

@ -11,6 +11,8 @@ 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 { BaseComponent } from '../base.component';
@ -70,6 +72,9 @@ export class SiteComponent extends BaseComponent implements OnInit {
return this.mainMenuService.entries;
}
public chatNotificationAmount = 0;
public canSeeChat = false;
/**
* Constructor
* @param route
@ -94,7 +99,9 @@ export class SiteComponent extends BaseComponent implements OnInit {
public OSStatus: OpenSlidesStatusService,
public timeTravel: TimeTravelService,
private matSnackBar: MatSnackBar,
private overlayService: OverlayService
private overlayService: OverlayService,
private chatNotificationService: ChatNotificationService,
private chatService: ChatService
) {
super(title, translate);
overlayService.showSpinner(translate.instant('Loading data. Please wait ...'));
@ -116,6 +123,11 @@ export class SiteComponent extends BaseComponent implements OnInit {
this.showUpdateNotification();
}
});
this.chatNotificationService.chatgroupNotificationsObservable.subscribe(amounts => {
this.chatNotificationAmount = Object.keys(amounts).reduce((sum, key) => sum + amounts[key], 0);
});
this.chatService.canSeeChatObservable.subscribe(canSeeChat => (this.canSeeChat = canSeeChat));
}
/**

View File

@ -0,0 +1 @@
default_app_config = "openslides.chat.apps.ChatAppConfig"

View File

@ -0,0 +1,39 @@
from typing import Any, Dict, List
from openslides.utils.access_permissions import BaseAccessPermissions
from openslides.utils.auth import async_has_perm, async_in_some_groups
class ChatGroupAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for ChatGroup and ChatGroupViewSet.
No base perm: The access permissions are done with the access groups
"""
async def get_restricted_data(
self, full_data: List[Dict[str, Any]], user_id: int
) -> List[Dict[str, Any]]:
"""
Manage users can see all groups. Else, for each group either it has no access groups
or the user must be in an access group.
"""
data: List[Dict[str, Any]] = []
if await async_has_perm(user_id, "chat.can_manage"):
data = full_data
else:
for full in full_data:
access_groups = full.get("access_groups_id", [])
if len(
full.get("access_groups_id", [])
) == 0 or await async_in_some_groups(user_id, access_groups):
data.append(full)
return data
class ChatMessageAccessPermissions(ChatGroupAccessPermissions):
"""
Access permissions container for ChatMessage and ChatMessageViewSet.
It does exaclty the same as ChatGroupAccessPermissions
"""
pass

View File

@ -0,0 +1,32 @@
from django.apps import AppConfig
class ChatAppConfig(AppConfig):
name = "openslides.chat"
verbose_name = "OpenSlides Chat"
def ready(self):
# Import all required stuff.
from ..utils.rest_api import router
from . import serializers # noqa
from .views import (
ChatGroupViewSet,
ChatMessageViewSet,
)
# Register viewsets.
router.register(
self.get_model("ChatGroup").get_collection_string(),
ChatGroupViewSet,
)
router.register(
self.get_model("ChatMessage").get_collection_string(), ChatMessageViewSet
)
def get_startup_elements(self):
"""
Yields all Cachables required on startup i. e. opening the websocket
connection.
"""
yield self.get_model("ChatGroup")
yield self.get_model("ChatMessage")

View File

@ -0,0 +1,72 @@
# Generated by Django 2.2.15 on 2020-12-03 12:52
from django.db import migrations, models
import openslides.utils.models
class Migration(migrations.Migration):
initial = True
dependencies = [
("users", "0015_user_vote_delegated_to"),
]
operations = [
migrations.CreateModel(
name="ChatGroup",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=256)),
(
"access_groups",
models.ManyToManyField(
blank=True, related_name="chat_access_groups", to="users.Group"
),
),
],
options={
"permissions": (("can_manage", "Can manage chat"),),
"default_permissions": (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name="ChatMessage",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("text", models.CharField(max_length=512)),
("timestamp", models.DateTimeField(auto_now_add=True)),
("username", models.CharField(max_length=256)),
(
"chatgroup",
models.ForeignKey(
on_delete=openslides.utils.models.CASCADE_AND_AUTOUPDATE,
related_name="messages",
to="chat.ChatGroup",
),
),
],
options={
"default_permissions": (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
]

View File

@ -0,0 +1,86 @@
from django.conf import settings
from django.db import models
from openslides.utils.manager import BaseManager
from ..utils.auth import has_perm, in_some_groups
from ..utils.models import CASCADE_AND_AUTOUPDATE, RESTModelMixin
from .access_permissions import ChatGroupAccessPermissions, ChatMessageAccessPermissions
class ChatGroupManager(BaseManager):
"""
Customized model manager to support our get_prefetched_queryset method.
"""
def get_prefetched_queryset(self, *args, **kwargs):
""""""
return (
super()
.get_prefetched_queryset(*args, **kwargs)
.prefetch_related("access_groups")
)
class ChatGroup(RESTModelMixin, models.Model):
""""""
access_permissions = ChatGroupAccessPermissions()
objects = ChatGroupManager()
name = models.CharField(max_length=256)
access_groups = models.ManyToManyField(
settings.AUTH_GROUP_MODEL, blank=True, related_name="chat_access_groups"
)
class Meta:
default_permissions = ()
permissions = (("can_manage", "Can manage chat"),)
def __str__(self):
return self.name
def can_access(self, user):
if has_perm(user.id, "chat.can_manage"):
return True
if not self.access_groups.exists():
return True
return in_some_groups(user.id, self.access_groups.values_list(flat=True))
class ChatMessageManager(BaseManager):
"""
Customized model manager to support our get_prefetched_queryset method.
"""
def get_prefetched_queryset(self, *args, **kwargs):
""""""
return (
super()
.get_prefetched_queryset(*args, **kwargs)
.prefetch_related("chatgroup", "chatgroup__access_groups")
)
class ChatMessage(RESTModelMixin, models.Model):
""""""
access_permissions = ChatMessageAccessPermissions()
objects = ChatMessageManager()
text = models.CharField(max_length=512)
timestamp = models.DateTimeField(auto_now_add=True)
chatgroup = models.ForeignKey(
ChatGroup, on_delete=CASCADE_AND_AUTOUPDATE, related_name="messages"
)
username = models.CharField(max_length=256)
class Meta:
default_permissions = ()
def __str__(self):
return f"{self.username} ({self.timestamp}): {self.text}"

View File

@ -0,0 +1,58 @@
from openslides.utils.rest_api import (
IdPrimaryKeyRelatedField,
ModelSerializer,
SerializerMethodField,
)
from openslides.utils.validate import validate_html_strict
from ..utils.auth import get_group_model
from .models import ChatGroup, ChatMessage
class ChatGroupSerializer(ModelSerializer):
"""
Serializer for chat.models.ChatGroup objects.
"""
access_groups = IdPrimaryKeyRelatedField(
many=True, required=False, queryset=get_group_model().objects.all()
)
class Meta:
model = ChatGroup
fields = (
"id",
"name",
"access_groups",
)
class ChatMessageSerializer(ModelSerializer):
"""
Serializer for chat.models.ChatMessage objects.
"""
chatgroup = IdPrimaryKeyRelatedField(
required=False, queryset=ChatGroup.objects.all()
)
access_groups_id = SerializerMethodField()
class Meta:
model = ChatMessage
fields = (
"id",
"text",
"chatgroup",
"timestamp",
"username",
"access_groups_id",
)
read_only_fields = ("username",)
def validate(self, data):
if "text" in data:
data["text"] = validate_html_strict(data["text"])
return data
def get_access_groups_id(self, chatmessage):
return [group.id for group in chatmessage.chatgroup.access_groups.all()]

View File

@ -0,0 +1,17 @@
from django.apps import apps
def get_permission_change_data(sender, permissions, **kwargs):
"""
Yields all necessary collections from the topics app if
'agenda.can_see' permission changes, because topics are strongly
connected to the agenda items.
"""
chat_app = apps.get_app_config(app_label="chat")
for permission in permissions:
# There could be only one 'agenda.can_see' and then we want to return data.
if (
permission.content_type.app_label == "chat"
and permission.codename == "can_manage"
):
yield from chat_app.get_startup_elements()

View File

@ -0,0 +1,99 @@
from django.conf import settings
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.rest_api import (
CreateModelMixin,
GenericViewSet,
ListModelMixin,
ModelViewSet,
Response,
RetrieveModelMixin,
detail_route,
status,
)
from .access_permissions import ChatGroupAccessPermissions, ChatMessageAccessPermissions
from .models import ChatGroup, ChatMessage
ENABLE_CHAT = getattr(settings, "ENABLE_CHAT", False)
class ChatGroupViewSet(ModelViewSet):
"""
API endpoint for chat groups.
There are the following views: metadata, list, retrieve, create,
partial_update, update, destroy and clear.
"""
access_permissions = ChatGroupAccessPermissions()
queryset = ChatGroup.objects.all()
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
if self.action in ("list", "retrieve"):
result = True
else:
result = has_perm(self.request.user, "chat.can_manage")
return result and ENABLE_CHAT
def update(self, *args, **kwargs):
response = super().update(*args, **kwargs)
# Update all affected chatmessages to update their `access_groups_id` field,
# which is taken from the updated chatgroup.
inform_changed_data(ChatMessage.objects.filter(chatgroup=self.get_object()))
return response
@detail_route(methods=["POST"])
def clear(self, request, *args, **kwargs):
"""
Deletes all chat messages of the group.
"""
messages = self.get_object().messages.all()
messages_id = [message.id for message in messages]
messages.delete()
collection = ChatMessage.get_collection_string()
inform_deleted_data((collection, id) for id in messages_id)
return Response()
class ChatMessageViewSet(
ListModelMixin, RetrieveModelMixin, CreateModelMixin, GenericViewSet
):
"""
API endpoint for chat groups.
There are the following views: metadata, list, retrieve, create
"""
access_permissions = ChatMessageAccessPermissions()
queryset = ChatMessage.objects.all()
def check_view_permissions(self):
return ENABLE_CHAT
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
if not serializer.validated_data["chatgroup"].can_access(self.request.user):
self.permission_denied(self.request)
# Do not use the serializer.save since it will put the model in the history.
validated_data = {
**serializer.validated_data,
"username": self.request.user.short_name(),
}
chatmessage = ChatMessage(**validated_data)
chatmessage.save(disable_history=True)
return Response(
ReturnDict(id=chatmessage.id, serializer=serializer),
status=status.HTTP_201_CREATED,
)

View File

@ -130,6 +130,7 @@ class CoreAppConfig(AppConfig):
"JITSI_ROOM_NAME",
"JITSI_ROOM_PASSWORD",
"DEMO_USERS",
"ENABLE_CHAT",
]
client_settings_dict = {}
for key in client_settings_keys:

View File

@ -21,6 +21,7 @@ INSTALLED_APPS = [
"openslides.motions",
"openslides.assignments",
"openslides.mediafiles",
"openslides.chat",
]
INSTALLED_PLUGINS = collect_plugins() # Adds all automatically collected plugins

View File

@ -5,6 +5,9 @@ from django.contrib.auth.signals import user_logged_in
from .user_backend import DefaultUserBackend, user_backend_manager
ENABLE_CHAT = getattr(settings, "ENABLE_CHAT", False)
class UsersAppConfig(AppConfig):
name = "openslides.users"
verbose_name = "OpenSlides Users"
@ -60,6 +63,8 @@ class UsersAppConfig(AppConfig):
# Permissions
permissions = []
for permission in Permission.objects.all():
if permission.content_type.app_label == "chat" and not ENABLE_CHAT:
continue
permissions.append(
{
"display_name": permission.name,

View File

@ -226,6 +226,20 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser):
"Do not use user.has_perm() but use openslides.utils.auth.has_perm"
)
def short_name(self):
first_name = self.first_name.strip()
last_name = self.last_name.strip()
short_name = f"{first_name} {last_name}".strip()
if not short_name:
short_name = self.username
title = self.title.strip()
if title:
short_name = f"{title} {short_name}"
return short_name
def send_invitation_email(
self, connection, subject, message, skip_autoupdate=False
):

View File

@ -65,6 +65,7 @@ def create_builtin_groups_and_admin(**kwargs):
"users.can_manage",
"users.can_see_extra_data",
"users.can_see_name",
"chat.can_manage",
)
permission_query = Q()
permission_dict = {}

View File

@ -98,6 +98,7 @@ class RESTModelMixin:
self,
skip_autoupdate: bool = False,
no_delete_on_restriction: bool = False,
disable_history: bool = False,
*args: Any,
**kwargs: Any,
) -> Any:
@ -117,6 +118,7 @@ class RESTModelMixin:
inform_changed_data(
self.get_root_rest_element(),
no_delete_on_restriction=no_delete_on_restriction,
disable_history=disable_history,
)
return return_value

View File

@ -0,0 +1,33 @@
import pytest
from openslides.chat.models import ChatGroup, ChatMessage
from openslides.utils.auth import get_group_model
from tests.count_queries import count_queries
@pytest.mark.django_db(transaction=False)
def test_motion_db_queries():
"""
Tests that only the following db queries for chat groups are done:
* 1 request to get all chat groups
* 1 request to get all access groups
Tests that only the following db queries for chat messages are done:
* 1 request to fet all chat messages
* 1 request to get all chat groups
* 1 request to get all access groups
"""
group1 = get_group_model().objects.create(name="group1")
group2 = get_group_model().objects.create(name="group2")
for i1 in range(5):
chatgroup = ChatGroup.objects.create(name=f"motion{i1}")
chatgroup.access_groups.add(group1, group2)
for i2 in range(10):
ChatMessage.objects.create(
text=f"text-{i1}-{i2}", username=f"user-{i1}-{i2}", chatgroup=chatgroup
)
assert count_queries(ChatGroup.get_elements)() == 2
assert count_queries(ChatMessage.get_elements)() == 3