commit
aac8ec8f2e
@ -1 +1 @@
|
|||||||
Subproject commit 020bb29d9924ffb32c60e081e019acc2984ac42e
|
Subproject commit 756043511cc00b9fd4b42cc3a7ba0d8c16897895
|
@ -6,6 +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 './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';
|
||||||
@ -83,7 +84,8 @@ export class AppComponent {
|
|||||||
loadFontService: LoadFontService,
|
loadFontService: LoadFontService,
|
||||||
dataStoreUpgradeService: DataStoreUpgradeService, // to start it.
|
dataStoreUpgradeService: DataStoreUpgradeService, // to start it.
|
||||||
routingState: RoutingStateService,
|
routingState: RoutingStateService,
|
||||||
votingBannerService: VotingBannerService // needed for initialisation
|
votingBannerService: VotingBannerService, // needed for initialisation,
|
||||||
|
chatNotificationService: ChatNotificationService
|
||||||
) {
|
) {
|
||||||
// manually add the supported languages
|
// manually add the supported languages
|
||||||
translate.addLangs(['en', 'de', 'cs', 'ru']);
|
translate.addLangs(['en', 'de', 'cs', 'ru']);
|
||||||
|
@ -3,6 +3,7 @@ import { Injectable, Injector } from '@angular/core';
|
|||||||
import { AgendaAppConfig } from '../../site/agenda/agenda.config';
|
import { AgendaAppConfig } from '../../site/agenda/agenda.config';
|
||||||
import { AppConfig, ModelEntry, SearchableModelEntry } from '../definitions/app-config';
|
import { AppConfig, ModelEntry, SearchableModelEntry } from '../definitions/app-config';
|
||||||
import { BaseRepository } from 'app/core/repositories/base-repository';
|
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 { CinemaAppConfig } from 'app/site/cinema/cinema.config';
|
||||||
import { HistoryAppConfig } from 'app/site/history/history.config';
|
import { HistoryAppConfig } from 'app/site/history/history.config';
|
||||||
import { ProjectorAppConfig } from 'app/site/projector/projector.config';
|
import { ProjectorAppConfig } from 'app/site/projector/projector.config';
|
||||||
@ -37,7 +38,8 @@ const appConfigs: AppConfig[] = [
|
|||||||
HistoryAppConfig,
|
HistoryAppConfig,
|
||||||
ProjectorAppConfig,
|
ProjectorAppConfig,
|
||||||
TopicsAppConfig,
|
TopicsAppConfig,
|
||||||
CinemaAppConfig
|
CinemaAppConfig,
|
||||||
|
ChatAppConfig
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -53,7 +53,8 @@ export enum Permission {
|
|||||||
usersCanChangePassword = 'users.can_change_password',
|
usersCanChangePassword = 'users.can_change_password',
|
||||||
usersCanManage = 'users.can_manage',
|
usersCanManage = 'users.can_manage',
|
||||||
usersCanSeeExtraData = 'users.can_see_extra_data',
|
usersCanSeeExtraData = 'users.can_see_extra_data',
|
||||||
usersCanSeeName = 'users.can_see_name'
|
usersCanSeeName = 'users.can_see_name',
|
||||||
|
chatCanManage = 'chat.can_manage'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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/`);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
13
client/src/app/shared/models/chat/chat-group.ts
Normal file
13
client/src/app/shared/models/chat/chat-group.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
26
client/src/app/shared/models/chat/chat-message.ts
Normal file
26
client/src/app/shared/models/chat/chat-message.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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;
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
private _timestampAsDate: Date;
|
||||||
|
public get timestampAsDate(): Date {
|
||||||
|
return this._timestampAsDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public constructor(input?: any) {
|
||||||
|
super(ChatMessage.COLLECTIONSTRING, input);
|
||||||
|
this._timestampAsDate = new Date(this.timestamp);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
18
client/src/app/site/chat/chat-routing.module.ts
Normal file
18
client/src/app/site/chat/chat-routing.module.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Route, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { ChatGroupListComponent } from './components/chat-group-list/chat-group-list.component';
|
||||||
|
|
||||||
|
const routes: Route[] = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
pathMatch: 'full',
|
||||||
|
component: ChatGroupListComponent
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class ChatRoutingModule {}
|
23
client/src/app/site/chat/chat.config.ts
Normal file
23
client/src/app/site/chat/chat.config.ts
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
22
client/src/app/site/chat/chat.module.ts
Normal file
22
client/src/app/site/chat/chat.module.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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 { 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,
|
||||||
|
ChatTabsComponent,
|
||||||
|
EditChatGroupDialogComponent,
|
||||||
|
ChatMessageComponent
|
||||||
|
]
|
||||||
|
})
|
||||||
|
export class ChatModule {}
|
@ -0,0 +1,45 @@
|
|||||||
|
<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 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">
|
||||||
|
<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>
|
@ -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;
|
||||||
|
}
|
@ -0,0 +1,157 @@
|
|||||||
|
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 { 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 { 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'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
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 repo: ChatGroupRepositoryService,
|
||||||
|
private chatMessageRepo: ChatMessageRepositoryService,
|
||||||
|
private chatNotificationService: ChatNotificationService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private promptService: PromptService,
|
||||||
|
private cd: ChangeDetectorRef
|
||||||
|
) {
|
||||||
|
super(titleService, translate, matSnackBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
this.chatGroupId = this.chatGroup.id;
|
||||||
|
this.chatNotificationService.openChat(this.chatGroupId);
|
||||||
|
this.subscriptions.push(
|
||||||
|
this.chatMessageRepo.getViewModelListBehaviorSubject().subscribe(chatMessages => {
|
||||||
|
this.chatMessages = chatMessages.filter(message => {
|
||||||
|
return message.chatgroup_id === this.chatGroup.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.isOnBottomOfChat) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
this.cd.markForCheck();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngAfterViewInit(): void {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy(): void {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
<os-head-bar [hasMainButton]="canManage" (mainEvent)="createNewChatGroup()">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="title-slot">
|
||||||
|
<h2>{{ 'Chat' | translate }}</h2>
|
||||||
|
</div>
|
||||||
|
</os-head-bar>
|
||||||
|
|
||||||
|
<mat-card class="os-card spacer-bottom-340">
|
||||||
|
<os-chat-tabs></os-chat-tabs>
|
||||||
|
</mat-card>
|
@ -0,0 +1,61 @@
|
|||||||
|
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 { OperatorService } from 'app/core/core-services/operator.service';
|
||||||
|
import { ChatGroupRepositoryService } from 'app/core/repositories/chat/chat-group-repository.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, 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({
|
||||||
|
selector: 'os-chat-group-list',
|
||||||
|
templateUrl: './chat-group-list.component.html',
|
||||||
|
styleUrls: ['./chat-group-list.component.scss']
|
||||||
|
})
|
||||||
|
export class ChatGroupListComponent extends BaseViewComponentDirective implements OnInit {
|
||||||
|
public get canManage(): boolean {
|
||||||
|
return this.operator.hasPerms(this.permission.chatCanManage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
titleService: Title,
|
||||||
|
protected translate: TranslateService,
|
||||||
|
matSnackBar: MatSnackBar,
|
||||||
|
private repo: ChatGroupRepositoryService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private operator: OperatorService
|
||||||
|
) {
|
||||||
|
super(titleService, translate, matSnackBar);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
|
super.setTitle('Chat');
|
||||||
|
}
|
||||||
|
|
||||||
|
public createNewChatGroup(): void {
|
||||||
|
const dialogRef = this.dialog.open(EditChatGroupDialogComponent, {
|
||||||
|
data: null,
|
||||||
|
...infoDialogSettings
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef.afterClosed().subscribe((res: ChatGroupData) => {
|
||||||
|
if (res) {
|
||||||
|
this.save(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public save(createData: ChatGroupData): void {
|
||||||
|
this.repo.create(createData as ChatGroup).catch(this.raiseError);
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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;
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -0,0 +1,7 @@
|
|||||||
|
.chat-form-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-input {
|
||||||
|
// todo
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
19
client/src/app/site/chat/models/view-chat-group.ts
Normal file
19
client/src/app/site/chat/models/view-chat-group.ts
Normal 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[];
|
||||||
|
}
|
17
client/src/app/site/chat/models/view-chat-message.ts
Normal file
17
client/src/app/site/chat/models/view-chat-message.ts
Normal 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;
|
||||||
|
}
|
111
client/src/app/site/chat/services/chat-notification.service.ts
Normal file
111
client/src/app/site/chat/services/chat-notification.service.ts
Normal 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 '../../../core/repositories/chat/chat-message-repository.service';
|
||||||
|
import { StorageService } from '../../../core/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);
|
||||||
|
}
|
||||||
|
}
|
50
client/src/app/site/chat/services/chat.service.ts
Normal file
50
client/src/app/site/chat/services/chat.service.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { BehaviorSubject, Observable } from 'rxjs';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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));
|
||||||
|
}
|
||||||
|
}
|
@ -39,6 +39,10 @@ const routes: Route[] = [
|
|||||||
loadChildren: () => import('./mediafiles/mediafiles.module').then(m => m.MediafilesModule),
|
loadChildren: () => import('./mediafiles/mediafiles.module').then(m => m.MediafilesModule),
|
||||||
data: { basePerm: Permission.mediafilesCanSee }
|
data: { basePerm: Permission.mediafilesCanSee }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'chat',
|
||||||
|
loadChildren: () => import('./chat/chat.module').then(m => m.ChatModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'motions',
|
path: 'motions',
|
||||||
loadChildren: () => import('./motions/motions.module').then(m => m.MotionsModule),
|
loadChildren: () => import('./motions/motions.module').then(m => m.MotionsModule),
|
||||||
|
@ -40,6 +40,25 @@
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<mat-divider></mat-divider>
|
<mat-divider></mat-divider>
|
||||||
|
<a
|
||||||
|
[@navItemAnim]
|
||||||
|
*ngIf="canSeeChat"
|
||||||
|
mat-list-item
|
||||||
|
(click)="mobileAutoCloseNav()"
|
||||||
|
routerLink="/chat"
|
||||||
|
routerLinkActive="active"
|
||||||
|
>
|
||||||
|
<mat-icon
|
||||||
|
[matBadgeHidden]="chatNotificationAmount === 0"
|
||||||
|
[matBadge]="chatNotificationAmount"
|
||||||
|
matBadgeColor="accent"
|
||||||
|
matBadgeOverlap="false"
|
||||||
|
>chat</mat-icon
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ 'Chat' | translate }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
<a
|
<a
|
||||||
[@navItemAnim]
|
[@navItemAnim]
|
||||||
mat-list-item
|
mat-list-item
|
||||||
|
@ -141,3 +141,7 @@ mat-sidenav-container {
|
|||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unread-chat {
|
||||||
|
color: red !important;
|
||||||
|
}
|
||||||
|
@ -13,6 +13,8 @@ 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 { 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';
|
||||||
@ -70,6 +72,9 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
|||||||
return this.mainMenuService.entries;
|
return this.mainMenuService.entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public chatNotificationAmount = 0;
|
||||||
|
public canSeeChat = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor
|
* Constructor
|
||||||
* @param route
|
* @param route
|
||||||
@ -94,7 +99,9 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
|||||||
public OSStatus: OpenSlidesStatusService,
|
public OSStatus: OpenSlidesStatusService,
|
||||||
public timeTravel: TimeTravelService,
|
public timeTravel: TimeTravelService,
|
||||||
private matSnackBar: MatSnackBar,
|
private matSnackBar: MatSnackBar,
|
||||||
private overlayService: OverlayService
|
private overlayService: OverlayService,
|
||||||
|
private chatNotificationService: ChatNotificationService,
|
||||||
|
private chatService: ChatService
|
||||||
) {
|
) {
|
||||||
super(title, translate);
|
super(title, translate);
|
||||||
overlayService.showSpinner(translate.instant('Loading data. Please wait ...'));
|
overlayService.showSpinner(translate.instant('Loading data. Please wait ...'));
|
||||||
@ -116,6 +123,11 @@ export class SiteComponent extends BaseComponent implements OnInit {
|
|||||||
this.showUpdateNotification();
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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',)"
|
||||||
|
@ -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',)"
|
||||||
|
@ -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
|
||||||
=================
|
=================
|
||||||
|
@ -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)
|
||||||
|
1
server/openslides/chat/__init__.py
Normal file
1
server/openslides/chat/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
default_app_config = "openslides.chat.apps.ChatAppConfig"
|
39
server/openslides/chat/access_permissions.py
Normal file
39
server/openslides/chat/access_permissions.py
Normal 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
|
32
server/openslides/chat/apps.py
Normal file
32
server/openslides/chat/apps.py
Normal 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")
|
73
server/openslides/chat/migrations/0001_initial.py
Normal file
73
server/openslides/chat/migrations/0001_initial.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# 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)),
|
||||||
|
("user_id", models.IntegerField()),
|
||||||
|
(
|
||||||
|
"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),
|
||||||
|
),
|
||||||
|
]
|
0
server/openslides/chat/migrations/__init__.py
Normal file
0
server/openslides/chat/migrations/__init__.py
Normal file
87
server/openslides/chat/models.py
Normal file
87
server/openslides/chat/models.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
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)
|
||||||
|
user_id = models.IntegerField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
default_permissions = ()
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.username} ({self.timestamp}): {self.text}"
|
62
server/openslides/chat/serializers.py
Normal file
62
server/openslides/chat/serializers.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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",
|
||||||
|
"user_id",
|
||||||
|
"access_groups_id",
|
||||||
|
)
|
||||||
|
read_only_fields = (
|
||||||
|
"username",
|
||||||
|
"user_id",
|
||||||
|
)
|
||||||
|
|
||||||
|
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()]
|
17
server/openslides/chat/signals.py
Normal file
17
server/openslides/chat/signals.py
Normal 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()
|
122
server/openslides/chat/views.py
Normal file
122
server/openslides/chat/views.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
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 (
|
||||||
|
disable_history,
|
||||||
|
inform_changed_data,
|
||||||
|
inform_deleted_data,
|
||||||
|
)
|
||||||
|
from openslides.utils.rest_api import (
|
||||||
|
CreateModelMixin,
|
||||||
|
DestroyModelMixin,
|
||||||
|
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,
|
||||||
|
DestroyModelMixin,
|
||||||
|
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):
|
||||||
|
# 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)
|
||||||
|
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(),
|
||||||
|
"user_id": self.request.user.id,
|
||||||
|
}
|
||||||
|
chatmessage = ChatMessage(**validated_data)
|
||||||
|
chatmessage.save(disable_history=True)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
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)
|
@ -130,6 +130,7 @@ class CoreAppConfig(AppConfig):
|
|||||||
"JITSI_ROOM_NAME",
|
"JITSI_ROOM_NAME",
|
||||||
"JITSI_ROOM_PASSWORD",
|
"JITSI_ROOM_PASSWORD",
|
||||||
"DEMO_USERS",
|
"DEMO_USERS",
|
||||||
|
"ENABLE_CHAT",
|
||||||
]
|
]
|
||||||
client_settings_dict = {}
|
client_settings_dict = {}
|
||||||
for key in client_settings_keys:
|
for key in client_settings_keys:
|
||||||
|
@ -21,6 +21,7 @@ INSTALLED_APPS = [
|
|||||||
"openslides.motions",
|
"openslides.motions",
|
||||||
"openslides.assignments",
|
"openslides.assignments",
|
||||||
"openslides.mediafiles",
|
"openslides.mediafiles",
|
||||||
|
"openslides.chat",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_PLUGINS = collect_plugins() # Adds all automatically collected plugins
|
INSTALLED_PLUGINS = collect_plugins() # Adds all automatically collected plugins
|
||||||
|
@ -5,6 +5,9 @@ from django.contrib.auth.signals import user_logged_in
|
|||||||
from .user_backend import DefaultUserBackend, user_backend_manager
|
from .user_backend import DefaultUserBackend, user_backend_manager
|
||||||
|
|
||||||
|
|
||||||
|
ENABLE_CHAT = getattr(settings, "ENABLE_CHAT", False)
|
||||||
|
|
||||||
|
|
||||||
class UsersAppConfig(AppConfig):
|
class UsersAppConfig(AppConfig):
|
||||||
name = "openslides.users"
|
name = "openslides.users"
|
||||||
verbose_name = "OpenSlides Users"
|
verbose_name = "OpenSlides Users"
|
||||||
@ -60,6 +63,8 @@ class UsersAppConfig(AppConfig):
|
|||||||
# Permissions
|
# Permissions
|
||||||
permissions = []
|
permissions = []
|
||||||
for permission in Permission.objects.all():
|
for permission in Permission.objects.all():
|
||||||
|
if permission.content_type.app_label == "chat" and not ENABLE_CHAT:
|
||||||
|
continue
|
||||||
permissions.append(
|
permissions.append(
|
||||||
{
|
{
|
||||||
"display_name": permission.name,
|
"display_name": permission.name,
|
||||||
|
@ -226,6 +226,20 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser):
|
|||||||
"Do not use user.has_perm() but use openslides.utils.auth.has_perm"
|
"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(
|
def send_invitation_email(
|
||||||
self, connection, subject, message, skip_autoupdate=False
|
self, connection, subject, message, skip_autoupdate=False
|
||||||
):
|
):
|
||||||
|
@ -65,6 +65,7 @@ def create_builtin_groups_and_admin(**kwargs):
|
|||||||
"users.can_manage",
|
"users.can_manage",
|
||||||
"users.can_see_extra_data",
|
"users.can_see_extra_data",
|
||||||
"users.can_see_name",
|
"users.can_see_name",
|
||||||
|
"chat.can_manage",
|
||||||
)
|
)
|
||||||
permission_query = Q()
|
permission_query = Q()
|
||||||
permission_dict = {}
|
permission_dict = {}
|
||||||
|
@ -98,6 +98,7 @@ class RESTModelMixin:
|
|||||||
self,
|
self,
|
||||||
skip_autoupdate: bool = False,
|
skip_autoupdate: bool = False,
|
||||||
no_delete_on_restriction: bool = False,
|
no_delete_on_restriction: bool = False,
|
||||||
|
disable_history: bool = False,
|
||||||
*args: Any,
|
*args: Any,
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
@ -117,6 +118,7 @@ class RESTModelMixin:
|
|||||||
inform_changed_data(
|
inform_changed_data(
|
||||||
self.get_root_rest_element(),
|
self.get_root_rest_element(),
|
||||||
no_delete_on_restriction=no_delete_on_restriction,
|
no_delete_on_restriction=no_delete_on_restriction,
|
||||||
|
disable_history=disable_history,
|
||||||
)
|
)
|
||||||
return return_value
|
return return_value
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
36
server/tests/integration/chat/test_chat.py
Normal file
36
server/tests/integration/chat/test_chat.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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}",
|
||||||
|
user_id=i1 * 1000 + i2,
|
||||||
|
chatgroup=chatgroup,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert count_queries(ChatGroup.get_elements)() == 2
|
||||||
|
assert count_queries(ChatMessage.get_elements)() == 3
|
Loading…
Reference in New Issue
Block a user