Compare commits

..

No commits in common. "a393bd91aba2ed9c576527e775cd41612012e553" and "ebbaf6716d92a80ecc3818ffd948f2358f9199aa" have entirely different histories.

16 changed files with 78 additions and 180 deletions

View File

@ -11,11 +11,6 @@ export const routes: Routes = [
loadChildren: () => import('./chat/chat.module').then(m => m.ChatModule), loadChildren: () => import('./chat/chat.module').then(m => m.ChatModule),
canActivate: [IsLoggedInCanActivate] canActivate: [IsLoggedInCanActivate]
}, },
{
path: 'user',
loadChildren: () => import('./user/user.module').then(m => m.UserModule),
canActivate: [IsLoggedInCanActivate]
},
{ path: '', redirectTo: 'chat', pathMatch: 'full' }, { path: '', redirectTo: 'chat', pathMatch: 'full' },
{ path: '**', redirectTo: 'chat', pathMatch: 'full' } { path: '**/*', redirectTo: '', pathMatch: 'full' }
]; ];

View File

@ -6,8 +6,7 @@ import { RegisterComponent } from './register/register.component';
const routes: Routes = [ const routes: Routes = [
{ path: 'login', component: LoginComponent }, { path: 'login', component: LoginComponent },
{ path: 'register', component: RegisterComponent }, { path: 'register', component: RegisterComponent },
{ path: '', redirectTo: 'login', pathMatch: 'full' }, { path: '', redirectTo: 'login', pathMatch: 'full' }
{ path: '**', redirectTo: 'login', pathMatch: "full" }
]; ];
@NgModule({ @NgModule({

View File

@ -4,7 +4,6 @@ import { ChatComponent } from './chat/chat.component';
const routes: Routes = [ const routes: Routes = [
{ path: '', component: ChatComponent }, { path: '', component: ChatComponent },
{ path: '**', redirectTo: '', pathMatch: "full" }
]; ];
@NgModule({ @NgModule({

View File

@ -3,7 +3,7 @@
mat-stroked-button mat-stroked-button
[matTooltip]="channel.description" [matTooltip]="channel.description"
matTooltipPosition="right" matTooltipPosition="right"
[matBadge]="this.notifications != 0 ? this.notifications : ''" [matBadge]="this.hasAlert ? '1' : ''"
matBadgeSize="medium" matBadgeSize="medium"
[disabled]="this.selected"> [disabled]="this.selected">
{{channel.name}} {{channel.name}}

View File

@ -11,15 +11,14 @@ export class ChannelEntryComponent implements OnChanges {
@Input("channel") public channel!: Channel; @Input("channel") public channel!: Channel;
@Input("selected") public selected!: boolean; @Input("selected") public selected!: boolean;
public notifications: number = 0; public hasAlert: boolean = false;
ngOnChanges(_: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (this.selected) { if (this.selected) {
this.notifications = 0; this.hasAlert = false;
} }
} }
public Notify() { // TODO: subsribe to message alerts and display them
if (!this.selected) this.notifications++; // unsubscirbe when leaving
}
} }

View File

@ -1,4 +1,6 @@
<ng-container *ngIf="this.channels && this.channels.length != 0"> <app-channel-entry
<app-channel-entry #entry *ngFor="let channel of this.channels; let i = index" [channel]="channel" (click)="Select(i)" *ngFor="let channel of this.channels; let i = index"
[selected]="i == SelectedChannel" /> [channel]="channels[i]"
</ng-container> (click)="Select(i)"
[selected]="channels[i].id == selectedChannel?.id"
/>

View File

@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, Output, QueryList, ViewChildren } from '@angular/core'; import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
import { Channel } from '../../../models/channel'; import { Channel } from '../../../models/channel';
import { ChannelEntryComponent } from './channel-entry/channel-entry.component'; import { ChatService } from '../../../services/chat.service';
import { Subject, takeUntil } from 'rxjs';
@Component({ @Component({
selector: 'app-channel-list', selector: 'app-channel-list',
@ -8,24 +9,31 @@ import { ChannelEntryComponent } from './channel-entry/channel-entry.component';
templateUrl: './channel-list.component.html', templateUrl: './channel-list.component.html',
styleUrl: './channel-list.component.scss' styleUrl: './channel-list.component.scss'
}) })
export class ChannelListComponent { export class ChannelListComponent implements OnInit, OnDestroy {
@Input('channels') public channels!: Channel[]; @Output("select") selectEmitter: EventEmitter<Channel> = new EventEmitter<Channel>();
@Output("select") selectEmitter: EventEmitter<number> = new EventEmitter<number>();
@ViewChildren("entry") entries!: QueryList<ChannelEntryComponent>; public channels!: Channel[];
public selectedChannel?: Channel;
private selectedChannel?: number; private destroy = new Subject<void>();
public get SelectedChannel() : number {
return this.selectedChannel ?? 0; constructor(private chatService: ChatService) { }
ngOnInit() {
this.chatService.ListAvailableChannels()
.pipe(takeUntil(this.destroy))
.subscribe(channels => {
this.channels = channels;
this.selectedChannel = this.channels[0];
});
} }
constructor() { } ngOnDestroy(): void {
this.destroy.next();
this.destroy.complete();
}
public Select(index: number): void { public Select(index: number): void {
this.selectEmitter.emit(this.selectedChannel = index); this.selectEmitter.emit(this.selectedChannel = this.channels[index]);
}
public Notify(channel: number): void {
this.entries.find(entry=>entry.channel.id == channel)?.Notify()
} }
} }

View File

@ -1,11 +1,9 @@
<div id="container"> <div id="container">
<app-toolbar /> <app-toolbar />
<app-channel-list #channelList [channels]="this.GetChannelList()" (select)="Select($event)"></app-channel-list> <app-channel-list (select)="Select($event)"></app-channel-list>
<app-feed #feed <app-feed [channel]="this.selectedChannel"></app-feed>
[messages]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].messages : []"></app-feed>
<app-message-bar <app-message-bar [channel]="this.selectedChannel?.id ?? 1"></app-message-bar>
[channel]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].channel.id : 1"></app-message-bar>
</div> </div>

View File

@ -1,10 +1,5 @@
import { Component, OnInit, ViewChild } from '@angular/core'; import { Component } from '@angular/core';
import { Channel } from '../../models/channel'; import { Channel } from '../../models/channel';
import { Message } from '../../models/message';
import { ChatService } from '../../services/chat.service';
import { ToastrService } from 'ngx-toastr';
import { FeedComponent } from './feed/feed.component';
import { ChannelListComponent } from './channel-list/channel-list.component';
@Component({ @Component({
selector: 'app-chat', selector: 'app-chat',
@ -12,52 +7,10 @@ import { ChannelListComponent } from './channel-list/channel-list.component';
templateUrl: './chat.component.html', templateUrl: './chat.component.html',
styleUrl: './chat.component.scss' styleUrl: './chat.component.scss'
}) })
export class ChatComponent implements OnInit { export class ChatComponent {
public selectedChannel: number = 0; public selectedChannel?: Channel;
public channels: { channel: Channel, messages: Message[] }[] = [];
@ViewChild("feed") feed!: FeedComponent; public Select(channel: Channel): void {
@ViewChild("channelList") channelList!: ChannelListComponent;
constructor(private chatService: ChatService, private toastrService: ToastrService) { }
ngOnInit(): void {
this.getChannels();
}
private getChannels(): void {
this.chatService.ListAvailableChannels().subscribe({
next: channels => {
this.channels = channels.map(channel => { return { channel: channel, messages: [] as Message[] } });
this.getMessages();
},
error: _ => {
this.toastrService.error("Failed to fetch channels.", "Error");
}
})
}
private getMessages(): void {
this.channels.forEach((channelObj, i) => {
this.chatService.GetMessages(channelObj.channel.id).subscribe({
next: messages => {
channelObj.messages.push(messages);
this.feed.ScrollEventHandler();
this.channelList.Notify(channelObj.channel.id);
},
error: _ => {
this.toastrService.error(`Failed to fetch messages for channel ${channelObj.channel.name}.`, "Error");
}
});
});
}
public GetChannelList(): Channel[] {
return this.channels.map(c => c.channel);
}
public Select(channel: number): void {
this.selectedChannel = channel; this.selectedChannel = channel;
this.feed.ScrollEventHandler();
} }
} }

View File

@ -1,5 +1,8 @@
import { AfterViewInit, Component, DoCheck, ElementRef, EventEmitter, Input, IterableDiffer, IterableDiffers, Output } from '@angular/core'; import { AfterContentInit, AfterViewChecked, AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, ViewChild } from '@angular/core';
import { Message } from '../../../models/message'; import { Message } from '../../../models/message';
import { ChatService } from '../../../services/chat.service';
import { Channel } from '../../../models/channel';
import { Subscription } from 'rxjs';
@Component({ @Component({
selector: 'app-feed', selector: 'app-feed',
@ -7,15 +10,41 @@ import { Message } from '../../../models/message';
templateUrl: './feed.component.html', templateUrl: './feed.component.html',
styleUrl: './feed.component.scss' styleUrl: './feed.component.scss'
}) })
export class FeedComponent implements AfterViewInit { export class FeedComponent implements OnChanges, OnDestroy, AfterViewInit {
@Input('messages') public messages?: Message[]; private readonly DEFAULT_CHANNEL_ID: number = 1;
constructor(private element: ElementRef<HTMLElement>) { } @Input('channel') public channel?: Channel;
public messages: Message[] = [];
public subscription?: Subscription;
constructor(private chatService: ChatService, private element: ElementRef<HTMLElement>) { }
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.scrollToBottom(); this.scrollToBottom();
} }
ngOnChanges(): void {
if (this.subscription) {
this.subscription.unsubscribe();
this.messages = [];
}
this.subscription =
this.chatService.GetMessages(this.channel?.id ?? this.DEFAULT_CHANNEL_ID)
.subscribe(message => {
this.messages.push(message);
if (this.isAtBottom()) this.scrollToBottom();
});
}
ngOnDestroy(): void {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
private isAtBottom(): boolean { private isAtBottom(): boolean {
let element = this.element.nativeElement; let element = this.element.nativeElement;
return Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 100; return Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 100;
@ -24,8 +53,4 @@ export class FeedComponent implements AfterViewInit {
private scrollToBottom(): void { private scrollToBottom(): void {
setTimeout(() => this.element.nativeElement.scrollTop = this.element.nativeElement.scrollHeight, 100); setTimeout(() => this.element.nativeElement.scrollTop = this.element.nativeElement.scrollHeight, 100);
} }
public ScrollEventHandler(): void {
if (this.isAtBottom()) this.scrollToBottom();
}
} }

View File

@ -13,7 +13,6 @@ export class AuthService {
private readonly USERNAME_FIELD: string = "username"; private readonly USERNAME_FIELD: string = "username";
private readonly PASSWORD_FIELD: string = "password"; private readonly PASSWORD_FIELD: string = "password";
private readonly REPEAT_PASSWORD_FIELD: string = "repeatPassword"; private readonly REPEAT_PASSWORD_FIELD: string = "repeatPassword";
private readonly LOCAL_USERNAME: string = "last_username";
private loggedIn: boolean = false; private loggedIn: boolean = false;
@ -26,9 +25,7 @@ export class AuthService {
formData.append(this.USERNAME_FIELD, username); formData.append(this.USERNAME_FIELD, username);
formData.append(this.PASSWORD_FIELD, password); formData.append(this.PASSWORD_FIELD, password);
localStorage.setItem(this.LOCAL_USERNAME, username); return this.http.post<LoginResponse>(url, formData, { withCredentials: true })
return this.http.post<LoginResponse>(url, formData, { withCredentials: true });
} }
public Register(username: string, password: string, repeatPassword: string): Observable<RegisterResponse> { public Register(username: string, password: string, repeatPassword: string): Observable<RegisterResponse> {
@ -45,8 +42,6 @@ export class AuthService {
public Logout(): Observable<boolean> { public Logout(): Observable<boolean> {
let url = `${environment.apiBase}/auth/logout`; let url = `${environment.apiBase}/auth/logout`;
localStorage.removeItem(this.LOCAL_USERNAME);
return this.http.get(url, { withCredentials: true }).pipe( return this.http.get(url, { withCredentials: true }).pipe(
map(() => true), map(() => true),
catchError(() => of(false)) catchError(() => of(false))
@ -75,11 +70,6 @@ export class AuthService {
} }
public IsLoggedIn(): boolean { return this.loggedIn; } public IsLoggedIn(): boolean { return this.loggedIn; }
public GetUsername(): string {
let username = localStorage.getItem(this.LOCAL_USERNAME)
if (username) return username;
else throw new Error("not logged in");
}
} }
export const IsLoggedInCanActivate: CanActivateFn = ( export const IsLoggedInCanActivate: CanActivateFn = (
@ -100,18 +90,3 @@ export const IsLoggedInCanActivate: CanActivateFn = (
}) })
); );
}; };
export const UsernameResolver: CanActivateFn = (
route: ActivatedRouteSnapshot,
__: RouterStateSnapshot
): boolean => {
const authService = inject(AuthService);
const router = inject(Router);
try {
router.navigateByUrl(`user/${authService.GetUsername()}`);
} catch (err) {
console.log(err);
router.navigateByUrl("auth/login");
}
return false;
};

View File

@ -1,19 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { UserComponent } from './user/user.component';
import { UsernameResolver } from '../services/auth.service';
const routes: Routes = [
{
path: ':username', component: UserComponent
},
{
path: '', canActivate: [UsernameResolver], component: UserComponent, pathMatch: "full"
},
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class UserRoutingModule { }

View File

@ -1,17 +0,0 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { UserRoutingModule } from './user-routing.module';
import { UserComponent } from './user/user.component';
@NgModule({
declarations: [
UserComponent
],
imports: [
CommonModule,
UserRoutingModule
]
})
export class UserModule { }

View File

@ -1 +0,0 @@
<p>{{username}}</p>

View File

@ -1,18 +0,0 @@
import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-user',
standalone: false,
templateUrl: './user.component.html',
styleUrl: './user.component.scss'
})
export class UserComponent {
public username!: string;
constructor(private route: ActivatedRoute) {
this.route.paramMap.subscribe(params => {
this.username = params.get('username')!;
});
}
}