Compare commits

..

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

24 changed files with 209 additions and 303 deletions

View File

@ -74,8 +74,7 @@
"buildTarget": "ui:build:production" "buildTarget": "ui:build:production"
}, },
"development": { "development": {
"buildTarget": "ui:build:development", "buildTarget": "ui:build:development"
"proxyConfig": "proxy.conf.json"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"

View File

@ -1,8 +0,0 @@
{
"/api": {
"target": "http://localhost:5000",
"secure": false,
"ws": true,
"changeOrigin": true
}
}

View File

@ -13,7 +13,8 @@
button { button {
display: block; display: block;
margin-bottom: 10px 0;
} }
} }
} }

View File

@ -11,7 +11,7 @@ import { Router } from '@angular/router';
styleUrls: ['../auth.module.scss', './login.component.scss'] styleUrls: ['../auth.module.scss', './login.component.scss']
}) })
export class LoginComponent implements OnInit { export class LoginComponent implements OnInit {
public loginForm!: FormGroup; loginForm!: FormGroup;
constructor( constructor(
private authService: AuthService, private authService: AuthService,
@ -41,7 +41,7 @@ export class LoginComponent implements OnInit {
this.router.navigateByUrl('chat'); this.router.navigateByUrl('chat');
} }
}, },
error: () => { error: _ => {
this.toastrService.error("API error", "Error"); this.toastrService.error("API error", "Error");
} }
}) })

View File

@ -12,11 +12,6 @@ import { ToolbarComponent } from '../common/toolbar/toolbar.component';
import { MatBadgeModule } from '@angular/material/badge'; import { MatBadgeModule } from '@angular/material/badge';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip'; import { MatTooltipModule } from '@angular/material/tooltip';
import { MatIconModule } from '@angular/material/icon';
import { MessageBarComponent } from './chat/message-bar/message-bar.component';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ReactiveFormsModule } from '@angular/forms';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -25,8 +20,7 @@ import { ReactiveFormsModule } from '@angular/forms';
FeedComponent, FeedComponent,
ChannelEntryComponent, ChannelEntryComponent,
MessageComponent, MessageComponent,
ProfilePictureComponent, ProfilePictureComponent
MessageBarComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@ -34,11 +28,7 @@ import { ReactiveFormsModule } from '@angular/forms';
ToolbarComponent, ToolbarComponent,
MatBadgeModule, MatBadgeModule,
MatButtonModule, MatButtonModule,
MatTooltipModule, MatTooltipModule
MatIconModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
] ]
}) })
export class ChatModule { } export class ChatModule { }

View File

@ -1,5 +1,5 @@
button { button {
width: 95%; width: 70%;
display: block; display: block;
margin: 5px auto; margin: 10px auto;
} }

View File

@ -1,4 +1,4 @@
import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; import { Component, EventEmitter, OnChanges, OnDestroy, OnInit, Output } from '@angular/core';
import { Channel } from '../../../models/channel'; import { Channel } from '../../../models/channel';
import { ChatService } from '../../../services/chat.service'; import { ChatService } from '../../../services/chat.service';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -20,7 +20,7 @@ export class ChannelListComponent implements OnInit, OnDestroy {
constructor(private chatService: ChatService) { } constructor(private chatService: ChatService) { }
ngOnInit() { ngOnInit() {
this.chatService.ListAvailableChannels() this.chatService.ListChannels()
.pipe(takeUntil(this.destroy)) .pipe(takeUntil(this.destroy))
.subscribe(channels => { .subscribe(channels => {
this.channels = channels; this.channels = channels;

View File

@ -1,9 +1,6 @@
<app-toolbar />
<div id="container"> <div id="container">
<app-toolbar />
<app-channel-list (select)="Select($event)"></app-channel-list> <app-channel-list (select)="Select($event)"></app-channel-list>
<app-feed [channel]="this.selectedChannel"></app-feed> <app-feed [channel]="this.selectedChannel"></app-feed>
<app-message-bar [channel]="this.selectedChannel?.id ?? 1"></app-message-bar>
</div> </div>

View File

@ -1,35 +1,5 @@
#container { #container {
height: 100%;
display: grid; display: grid;
grid-template-columns: 150px 1fr; grid-template-columns: minmax(auto, 150px) 1fr;
grid-template-rows: 65px 1fr auto; height: 100%;
gap: 0px;
app-toolbar {
grid-column: span 2 / span 2;
}
app-channel-list {
grid-row: span 2 / span 2;
grid-row-start: 2;
}
app-feed {
grid-row-start: 2;
border-left: 2px solid var(--mat-sys-surface-variant);
border-top: 2px solid var(--mat-sys-surface-variant);
border-radius: 10px 0 0 0;
background-color: var(--mat-sys-background);
}
app-message-bar {
grid-column-start: 2;
grid-row-start: 3;
border-left: 2px solid var(--mat-sys-surface-variant);
}
} }

View File

@ -1,3 +1,4 @@
<div #scrollable id="scrollable"> <app-message
<app-message *ngFor="let message of this.messages" [message]="message" /> *ngFor="let message of this.messages"
</div> [message]="message"
/>

View File

@ -1,3 +1,7 @@
:host { :host {
overflow-y: scroll; border-left: 2px solid var(--mat-sys-surface-variant);
border-top: 2px solid var(--mat-sys-surface-variant);
border-radius: 10px 0;
background-color: var(--mat-sys-background);
} }

View File

@ -1,6 +1,7 @@
import { AfterContentInit, AfterViewChecked, AfterViewInit, Component, ElementRef, Input, OnChanges, OnDestroy, ViewChild } from '@angular/core'; import { Component, Input, OnChanges, OnDestroy, OnInit } from '@angular/core';
import { Message } from '../../../models/message'; import { Message } from '../../../models/message';
import { ChatService } from '../../../services/chat.service'; import { ChatService } from '../../../services/chat.service';
import { UserService } from '../../../services/user.service';
import { Channel } from '../../../models/channel'; import { Channel } from '../../../models/channel';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
@ -10,7 +11,7 @@ import { Subscription } from 'rxjs';
templateUrl: './feed.component.html', templateUrl: './feed.component.html',
styleUrl: './feed.component.scss' styleUrl: './feed.component.scss'
}) })
export class FeedComponent implements OnChanges, OnDestroy, AfterViewInit { export class FeedComponent implements OnChanges, OnDestroy {
private readonly DEFAULT_CHANNEL_ID: number = 1; private readonly DEFAULT_CHANNEL_ID: number = 1;
@Input('channel') public channel?: Channel; @Input('channel') public channel?: Channel;
@ -18,13 +19,9 @@ export class FeedComponent implements OnChanges, OnDestroy, AfterViewInit {
public subscription?: Subscription; public subscription?: Subscription;
constructor(private chatService: ChatService, private element: ElementRef<HTMLElement>) { } constructor(private chatService: ChatService) { }
ngAfterViewInit(): void { ngOnChanges() {
this.scrollToBottom();
}
ngOnChanges(): void {
if (this.subscription) { if (this.subscription) {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
this.messages = []; this.messages = [];
@ -32,11 +29,7 @@ export class FeedComponent implements OnChanges, OnDestroy, AfterViewInit {
this.subscription = this.subscription =
this.chatService.GetMessages(this.channel?.id ?? this.DEFAULT_CHANNEL_ID) this.chatService.GetMessages(this.channel?.id ?? this.DEFAULT_CHANNEL_ID)
.subscribe(message => { .subscribe(message => this.messages.push(message));
this.messages.push(message);
if (this.isAtBottom()) this.scrollToBottom();
});
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@ -44,13 +37,4 @@ export class FeedComponent implements OnChanges, OnDestroy, AfterViewInit {
this.subscription.unsubscribe(); this.subscription.unsubscribe();
} }
} }
private isAtBottom(): boolean {
let element = this.element.nativeElement;
return Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 100;
}
private scrollToBottom(): void {
setTimeout(() => this.element.nativeElement.scrollTop = this.element.nativeElement.scrollHeight, 100);
}
} }

View File

@ -1,8 +1,8 @@
<div class="message-container"> <div class="message-container">
<app-profile-picture [username]="this.message.sender_name" /> <app-profile-picture [username]="this.message.sender" />
<div class="message-inner-container"> <div class="message-inner-container">
<div class="message-sender">{{this.message.sender_name}}</div> <div class="message-sender">{{this.message.sender}}</div>
<div class="message-content">{{this.message.content}}</div> <div class="message-content">{{this.message.content}}</div>
</div> </div>

View File

@ -1,2 +1 @@
<img *ngIf="this.url != ''" [src]="this.url" [alt]="this.username"> <img [src]="this.url" [alt]="this.username">
<mat-icon *ngIf="this.url == ''">face</mat-icon>

View File

@ -1,23 +1,7 @@
:host { img {
display: inline-block; display: inline-block;
width: 100%; width: 100%;
aspect-ratio: 1;
border-radius: 50%; border-radius: 50%;
box-shadow: 0px 0px 2px var(--mat-sys-on-background); box-shadow: 0px 0px 5px var(--mat-sys-on-background);
}
// style alt for missing images
img {
display: flex;
justify-content: center;
padding-top: 15px;
overflow: hidden;
}
mat-icon {
width: 100% !important;
height: 100% !important;
text-align: center;
font-size: 50px;
} }

View File

@ -1,12 +0,0 @@
<form [formGroup]="messageForm" (submit)="Send()">
<mat-form-field>
<mat-label>Message</mat-label>
<textarea type="textarea" required matInput formControlName="content"></textarea>
</mat-form-field>
<button mat-button type="button" (click)="Send()">
<mat-icon>send</mat-icon>
</button>
</form>

View File

@ -1,23 +0,0 @@
form {
align-items: center;
padding: 10px;
display: grid;
grid-template-columns: 1fr auto;
mat-form-field {
padding-top: 5px;
}
}
button {
margin: 0 5px;
transform: translateY(-10px);
mat-icon {
margin: auto;
vertical-align: middle;
}
}

View File

@ -1,44 +0,0 @@
import { Component, Input, OnInit } from '@angular/core';
import { ChatService } from '../../../services/chat.service';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
@Component({
selector: 'app-message-bar',
standalone: false,
templateUrl: './message-bar.component.html',
styleUrl: './message-bar.component.scss'
})
export class MessageBarComponent implements OnInit {
private readonly MAX_MESSAGE_LENGTH: number = 120;
@Input('channel') channel!: number;
public messageForm!: FormGroup;
constructor(private chatService: ChatService,
private formBuilder: FormBuilder,
private toastrService: ToastrService) { }
ngOnInit(): void {
this.messageForm = this.formBuilder.group({
content: new FormControl('', [
Validators.required,
Validators.minLength(1),
Validators.maxLength(this.MAX_MESSAGE_LENGTH)
])
});
}
public Send(): void {
if (this.messageForm.valid) {
this.chatService.SendMessage(
this.channel,
this.messageForm.get("content")!.value).subscribe(
{
next: () => this.messageForm.setValue({ "content": "" }),
error: () => this.toastrService.error("Failed to send message.", "Error")
});
}
}
}

View File

@ -1,7 +1,9 @@
import { Timestamp } from "rxjs"
export class Message { export class Message {
public id!: number public id!: number
public sender_name!: string public sender!: string
public channel_id!: number public channel!: number
public time!: Date public time!: Date
public content!: string public content!: string
} }

View File

@ -1,6 +1,6 @@
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { inject, Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { catchError, map, Observable, of, tap } from 'rxjs'; import { catchError, map, Observable, of } from 'rxjs';
import { LoginResponse, RegisterResponse } from './responses/auth'; import { LoginResponse, RegisterResponse } from './responses/auth';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router'; import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router';
import { environment } from '../../environment/environment'; import { environment } from '../../environment/environment';
@ -14,8 +14,6 @@ export class AuthService {
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 loggedIn: boolean = false;
constructor(private http: HttpClient) { } constructor(private http: HttpClient) { }
public Login(username: string, password: string): Observable<LoginResponse> { public Login(username: string, password: string): Observable<LoginResponse> {
@ -53,40 +51,29 @@ export class AuthService {
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))
tap(result => this.loggedIn = result)
); );
} }
public HasToken(): boolean { public IsLoggedIn(): boolean {
let cookies = document.cookie.split(';'); let cookies = document.cookie.split(';');
let found = false; let found = false;
cookies.forEach(cookie => { cookies.forEach(cookie => {
cookie = cookie.trim(); cookie = cookie.trim();
found = found || cookie.startsWith(this.SESSION_COOKIE + "=") && cookie.split('=', 2)[1] != ''; found = found || cookie.startsWith(this.SESSION_COOKIE + "=") && cookie.split('=', 2)[1] != '';
}); });
return found; return found;
} }
public IsLoggedIn(): boolean { return this.loggedIn; }
} }
export const IsLoggedInCanActivate: CanActivateFn = ( export const IsLoggedInCanActivate: CanActivateFn = (
_: ActivatedRouteSnapshot, _: ActivatedRouteSnapshot,
__: RouterStateSnapshot __: RouterStateSnapshot
): Observable<boolean> => { ) => {
const authService = inject(AuthService); if (inject(AuthService).IsLoggedIn()) {
const router = inject(Router); return true;
} else {
return authService.Bump().pipe( inject(Router).navigateByUrl("auth/login");
map(isValid => { return false;
if (isValid) { }
return true; }
} else {
router.navigate(['auth/login']);
return false;
}
})
);
};

View File

@ -1,85 +1,173 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { catchError, from, map, merge, mergeMap, Observable, throwError } from 'rxjs'; import { from, Observable } from 'rxjs';
import { webSocket } from 'rxjs/webSocket';
import { Channel } from '../models/channel'; import { Channel } from '../models/channel';
import { Message } from '../models/message'; import { Message } from '../models/message';
import { HttpClient } from '@angular/common/http';
import { environment } from '../../environment/environment';
import { GetMessagesResponse, ListAvailableChannelsResponse, SendMessageResponse } from './responses/chat';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class ChatService { export class ChatService {
private readonly CHANNEL: string = "channel_id"; constructor() { }
private readonly CONTENT: string = "content";
constructor(private http: HttpClient) { } // TODO: implement
public ListChannels(): Observable<Channel[]> {
public ListAvailableChannels(): Observable<Channel[]> { return new Observable<Channel[]>(subscriber => {
let url = `${environment.apiBase}/chat/channels` subscriber.next([
return this.http.get<ListAvailableChannelsResponse>(url, { withCredentials: true }) {
.pipe( id: 0,
map(response => { name: 'default',
if (response.error) { description: 'this is the default channel'
throw new Error(response.error); },
} {
id: 1,
if (response.channels) { name: 'XIV. Leo',
return response.channels; description: 'this is another channel'
} },
]);
throw new Error("bad API response, missing channels with no error"); subscriber.complete();
}), });
catchError(error => throwError(() => new Error(error.error.message)))
);
} }
// TODO: implement
// TODO: refactor this so it first returns the n last messages,
// then listens for incoming messages and forwards them as they come
public GetMessages(channelID: number): Observable<Message> { public GetMessages(channelID: number): Observable<Message> {
let url = `${environment.apiBase}/chat/messages/${channelID}`;
let messages: Observable<Message[]> =
this.http.get<GetMessagesResponse>(url, { withCredentials: true })
.pipe(
map(response => {
if (response.error) {
throw new Error(response.error);
}
if (response.messages) { return from([
return response.messages; {
} id: 1,
sender: 'admin',
throw new Error("bad API response, missing messages with no error"); channel: 1,
}), time: new Date(),
catchError(error => throwError(() => new Error(error.error.message))) content: 'this is my first message'
); },
{
url = `${environment.apiBase}/chat/subscribe/${channelID}`; id: 1,
let socket = webSocket<Message>(url); sender: 'admin',
channel: 1,
return merge( time: new Date(),
messages.pipe(mergeMap(msgArray => from(msgArray))), content: 'this is my first message'
socket.asObservable()); },
} {
id: 1,
public SendMessage(channel: number, content: string): Observable<SendMessageResponse> { sender: 'admin',
let url = `${environment.apiBase}/chat/send`; channel: 1,
time: new Date(),
let data = new FormData(); content: 'this is my first message'
data.append(this.CHANNEL, channel.toString()); },
data.append(this.CONTENT, content); {
id: 1,
return this.http.post<SendMessageResponse>(url, data, { withCredentials: true }) sender: 'admin',
.pipe( channel: 1,
map(response => { time: new Date(),
if (response.error) { content: 'this is my first message'
throw new Error(response.error); },
} {
id: 1,
return response; sender: 'admin',
}), channel: 1,
catchError(error => throwError(() => new Error(error.error.message))) time: new Date(),
); content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 1,
sender: 'admin',
channel: 1,
time: new Date(),
content: 'this is my first message'
},
{
id: 2,
sender: 'admin',
channel: 2,
time: new Date(),
content: 'this is my second message'
}
]);
} }
} }

View File

@ -1,13 +0,0 @@
import { Channel } from "../../models/channel";
import { Message } from "../../models/message";
import { APIResponse } from "./basic";
export class ListAvailableChannelsResponse extends APIResponse {
public channels?: Channel[];
}
export class GetMessagesResponse extends APIResponse {
public messages?: Message[];
}
export class SendMessageResponse extends APIResponse { }

View File

@ -1,4 +1,4 @@
export const environment = { export const environment = {
production: false, production: false,
apiBase: "/api" apiBase: "http://localhost:5000"
} }

View File

@ -2,7 +2,7 @@
html, html,
body { body {
height: 100dvh; height: 100%;
margin: 0; margin: 0;
font-family: var(--mat-sys-body-medium-font); font-family: var(--mat-sys-body-medium-font);