Compare commits

...

11 Commits

46 changed files with 3481 additions and 6094 deletions

42
.dockerignore Normal file
View File

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:23-alpine AS build
ARG config production
WORKDIR /src
COPY package*.json .
RUN npm install -g @angular/cli
RUN npm install
COPY . /src
RUN ng build \
--configuration=${config} \
--delete-output-path false
FROM nginx:alpine-slim
COPY --from=build /src/dist/ui/browser/ /usr/share/nginx/html
COPY nginx/default.conf /etc/nginx/conf.d/
EXPOSE 80

View File

@ -15,7 +15,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/ui",
"index": "src/index.html",
@ -33,9 +33,14 @@
],
"styles": [
"node_modules/ngx-toastr/toastr.css",
"node_modules/prismjs/themes/prism-okaidia.css",
"src/styles.scss"
],
"scripts": []
"scripts": [
"node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-csharp.min.js",
"node_modules/prismjs/components/prism-css.min.js"
]
},
"configurations": {
"production": {
@ -57,7 +62,8 @@
"maximumError": "8kB"
}
],
"outputHashing": "all"
"outputHashing": "all",
"serviceWorker": "ngsw-config.json"
},
"development": {
"optimization": false,
@ -68,7 +74,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "ui:build:production"
@ -81,10 +87,10 @@
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
@ -106,5 +112,34 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
},
"cli": {
"analytics": false
}
}
}

15
nginx/default.conf Normal file
View File

@ -0,0 +1,15 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

30
ngsw-config.json Normal file
View File

@ -0,0 +1,30 @@
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.csr.html",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

8649
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,25 +10,28 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^19.2.14",
"@angular/cdk": "^19.2.18",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/material": "^19.2.18",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"@angular/animations": "^20.0.2",
"@angular/cdk": "^20.0.2",
"@angular/common": "^20.0.2",
"@angular/compiler": "^20.0.2",
"@angular/core": "^20.0.2",
"@angular/forms": "^20.0.2",
"@angular/material": "^20.0.2",
"@angular/platform-browser": "^20.0.2",
"@angular/platform-browser-dynamic": "^20.0.2",
"@angular/router": "^20.0.2",
"@angular/service-worker": "^20.0.2",
"ngx-markdown": "^20.0.0",
"ngx-toastr": "^19.0.0",
"prismjs": "^1.30.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.3",
"@angular/cli": "^19.2.3",
"@angular/compiler-cli": "^19.2.0",
"@angular/build": "^20.0.1",
"@angular/cli": "^20.0.1",
"@angular/compiler-cli": "^20.0.2",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
@ -36,6 +39,6 @@
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
"typescript": "~5.8.3"
}
}

BIN
public/audio/pop.mp3 Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/icons/icon-72x72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
public/icons/icon-96x96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,57 @@
{
"name": "Chat",
"short_name": "Chat",
"display": "standalone",
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "icons/icon-72x72.png",
"sizes": "72x72",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-96x96.png",
"sizes": "96x96",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-128x128.png",
"sizes": "128x128",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-144x144.png",
"sizes": "144x144",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-152x152.png",
"sizes": "152x152",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-384x384.png",
"sizes": "384x384",
"type": "image/png",
"purpose": "maskable any"
},
{
"src": "icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable any"
}
]
}

View File

@ -1,15 +1,20 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { ApplicationConfig, provideZoneChangeDetection, isDevMode } from '@angular/core';
import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http';
import { provideToastr } from 'ngx-toastr';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideMarkdown } from 'ngx-markdown';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideRouter(routes, withInMemoryScrolling({
anchorScrolling: "enabled",
scrollPositionRestoration: 'top'
})),
provideHttpClient(),
provideAnimations(),
provideToastr({
@ -17,5 +22,10 @@ export const appConfig: ApplicationConfig = {
positionClass: 'toast-top-right',
closeButton: true
}),
provideMarkdown(),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
})
]
};

View File

@ -1,5 +1,5 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CommonModule, DatePipe } from '@angular/common';
import { ChatRoutingModule } from './chat-routing.module';
import { ChatComponent } from './chat/chat.component';
@ -17,6 +17,8 @@ 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';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MarkdownModule } from 'ngx-markdown'
@NgModule({
declarations: [
@ -38,7 +40,10 @@ import { ReactiveFormsModule } from '@angular/forms';
MatIconModule,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule
ReactiveFormsModule,
MatSidenavModule,
DatePipe,
MarkdownModule
]
})
export class ChatModule { }

View File

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

View File

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

View File

@ -1,5 +1,6 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { Channel } from '../../../../models/channel';
import { AudioService } from '../../../../services/audio.service';
@Component({
selector: 'app-channel-entry',
@ -8,11 +9,15 @@ import { Channel } from '../../../../models/channel';
styleUrl: './channel-entry.component.scss'
})
export class ChannelEntryComponent implements OnChanges {
public readonly NOTIFICATION_LIMIT: number = 10;
@Input("channel") public channel!: Channel;
@Input("selected") public selected!: boolean;
public notifications: number = 0;
constructor(private audioService: AudioService) { }
ngOnChanges(_: SimpleChanges): void {
if (this.selected) {
this.notifications = 0;
@ -20,6 +25,9 @@ export class ChannelEntryComponent implements OnChanges {
}
public Notify() {
if (!this.selected) this.notifications++;
if (!this.selected) {
this.notifications++;
this.audioService.PlayNotification();
}
}
}

View File

@ -1,3 +0,0 @@
:host {
background-color: var(--mat-sys-surface-container);
}

View File

@ -10,12 +10,13 @@ import { ChannelEntryComponent } from './channel-entry/channel-entry.component';
})
export class ChannelListComponent {
@Input('channels') public channels!: Channel[];
@Output("select") selectEmitter: EventEmitter<number> = new EventEmitter<number>();
@Output("select") public selectEmitter: EventEmitter<number> = new EventEmitter<number>();
@Output('close') public close: EventEmitter<void> = new EventEmitter<void>();
@ViewChildren("entry") entries!: QueryList<ChannelEntryComponent>;
private selectedChannel?: number;
public get SelectedChannel() : number {
public get SelectedChannel(): number {
return this.selectedChannel ?? 0;
}
@ -26,6 +27,6 @@ export class ChannelListComponent {
}
public Notify(channel: number): void {
this.entries.find(entry=>entry.channel.id == channel)?.Notify()
this.entries.find(entry => entry.channel.id == channel)?.Notify()
}
}

View File

@ -1,11 +1,28 @@
<div id="container">
<app-toolbar />
<mat-drawer-container>
<app-channel-list #channelList [channels]="this.GetChannelList()" (select)="Select($event)"></app-channel-list>
<mat-drawer #drawer>
<div id="logo">
<h1><mat-icon>chat</mat-icon>Chat</h1>
<app-feed #feed
[messages]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].messages : []"></app-feed>
<button mat-button (click)="drawer.close()">
<mat-icon>close</mat-icon>Close
</button>
</div>
<app-message-bar
[channel]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].channel.id : 1"></app-message-bar>
</div>
<h3>Channels</h3>
<app-channel-list #channelList [channels]="this.GetChannelList()" (select)="Select($event)"
(close)="drawer.close()"></app-channel-list>
</mat-drawer>
<mat-drawer-content>
<app-toolbar (sidebar)="drawer.toggle()" />
<app-feed #feed (outOfMessages)="this.GetMoreMessages()"
[messages]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].messages : []"></app-feed>
<app-message-bar
[channel]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].channel.id : 1"></app-message-bar>
</mat-drawer-content>
</mat-drawer-container>

View File

@ -1,35 +1,12 @@
#container {
mat-drawer-container {
height: 100%;
}
mat-drawer-content {
height: 100%;
display: grid;
grid-template-columns: 150px 1fr;
grid-template-rows: 65px 1fr auto;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
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

@ -5,6 +5,7 @@ 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';
import { MatDrawer } from '@angular/material/sidenav';
@Component({
selector: 'app-chat',
@ -16,6 +17,7 @@ export class ChatComponent implements OnInit {
public selectedChannel: number = 0;
public channels: { channel: Channel, messages: Message[] }[] = [];
@ViewChild("drawer") drawer!: MatDrawer;
@ViewChild("feed") feed!: FeedComponent;
@ViewChild("channelList") channelList!: ChannelListComponent;
@ -39,11 +41,13 @@ export class ChatComponent implements OnInit {
private getMessages(): void {
this.channels.forEach((channelObj, i) => {
this.chatService.GetMessages(channelObj.channel.id).subscribe({
next: messages => {
channelObj.messages.push(messages);
this.chatService.GetMessageStream(channelObj.channel.id).subscribe({
next: message => {
channelObj.messages.push(message);
this.feed.ScrollEventHandler();
this.channelList.Notify(channelObj.channel.id);
if (this.chatService.GetChannelLastSelected(channelObj.channel.id) < message.time)
this.channelList.Notify(channelObj.channel.id);
},
error: _ => {
this.toastrService.error(`Failed to fetch messages for channel ${channelObj.channel.name}.`, "Error");
@ -52,12 +56,26 @@ export class ChatComponent implements OnInit {
});
}
public GetMoreMessages(): void {
this.chatService.GetMessages(this.channels[this.selectedChannel].channel.id, this.channels[this.selectedChannel].messages[0]?.time ?? new Date())
.subscribe({
next: messages => {
let lastMessage = this.channels[this.selectedChannel].messages[0];
this.channels[this.selectedChannel].messages.unshift(...messages);
this.feed.ScrollToMessage(lastMessage.channel_id, lastMessage.id);
},
error: () => this.toastrService.error("Failed to fetch older messages.", "Error")
});
}
public GetChannelList(): Channel[] {
return this.channels.map(c => c.channel);
}
public Select(channel: number): void {
this.selectedChannel = channel;
this.chatService.SetChannelSelected(this.channels[channel].channel.id);
this.feed.ScrollEventHandler();
this.drawer.close();
}
}

View File

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

View File

@ -1,4 +1,4 @@
import { AfterViewInit, Component, DoCheck, ElementRef, EventEmitter, Input, IterableDiffer, IterableDiffers, Output } from '@angular/core';
import { AfterViewInit, Component, DoCheck, ElementRef, EventEmitter, HostListener, Input, IterableDiffer, IterableDiffers, Output, QueryList, ViewChildren } from '@angular/core';
import { Message } from '../../../models/message';
@Component({
@ -9,6 +9,7 @@ import { Message } from '../../../models/message';
})
export class FeedComponent implements AfterViewInit {
@Input('messages') public messages?: Message[];
@Output('outOfMessages') public outOfMessages: EventEmitter<void> = new EventEmitter<void>();
constructor(private element: ElementRef<HTMLElement>) { }
@ -21,11 +22,38 @@ export class FeedComponent implements AfterViewInit {
return Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 100;
}
private isAtTop(): boolean {
let element = this.element.nativeElement;
return element.scrollTop == 0;
}
private scrollToBottom(): void {
setTimeout(() => this.element.nativeElement.scrollTop = this.element.nativeElement.scrollHeight, 100);
}
// handles scroll to bottom events from the chat component when a new message arrives
public ScrollEventHandler(): void {
if (this.isAtBottom()) this.scrollToBottom();
}
@HostListener('scroll')
private detectOutOfMessages(): void {
if (this.isAtTop()) {
this.outOfMessages.emit();
}
}
public ScrollToMessage(channelID: number, messageID: number): void {
setTimeout(() => {
let id = `#c${channelID}m${messageID}`;
let target = this.element.nativeElement.querySelector(id) as HTMLDivElement | null;
if (!target) return;
this.element.nativeElement.scrollTo({
top: target.offsetTop - target.offsetHeight,
behavior: "instant"
});
}, 100);
}
}

View File

@ -1,9 +1,16 @@
<div class="message-container">
<div [id]="`c${message.channel_id}m${message.id}`" class="message-container">
<app-profile-picture [username]="this.message.sender_name" />
<div class="message-inner-container">
<div class="message-sender">{{this.message.sender_name}}</div>
<div class="message-content">{{this.message.content}}</div>
<div class="message-sender">
<a [matTooltip]="this.userStatus" matTooltipPosition="right"
[routerLink]="`/user/${this.message.sender_name}`">{{this.message.sender_name}}</a>
<span class="time">{{ this.message.time | date: GetMessageDateFormat() }}</span>
</div>
<markdown class="message-content" [data]="this.message.content"></markdown>
</div>
</div>

View File

@ -30,6 +30,24 @@ app-profile-picture {
.message-content {
height: fit-content;
margin-right: 15px;
::ng-deep img {
display: block;
max-width: 100%;
max-height: 500px;
}
}
}
}
a {
text-decoration: none;
color: inherit;
}
.time {
margin-left: 10px;
font-size: small;
color: var(--mat-sys-on-surface-variant)
}

View File

@ -1,5 +1,6 @@
import { Component, Input } from '@angular/core';
import { Component, HostListener, Input } from '@angular/core';
import { Message } from '../../../../models/message';
import { UserService } from '../../../../services/user.service';
@Component({
selector: 'app-message',
@ -9,4 +10,21 @@ import { Message } from '../../../../models/message';
})
export class MessageComponent {
@Input('message') public message!: Message;
public userStatus?: string;
constructor(private userService: UserService) { }
@HostListener('mouseenter')
private getStatus(): void {
if (!this.userStatus) {
this.userService.GetUser(this.message.sender_name).subscribe({
next: user => this.userStatus = user.status
});
}
}
public GetMessageDateFormat(): string {
return (new Date().getDay() == this.message.time.getDay()) ? 'HH:mm' : 'MMM d, HH:mm';
}
}

View File

@ -1,9 +1,11 @@
:host {
background-color: var(--mat-sys-surface-container);
}
form {
align-items: center;
padding: 10px;
display: grid;
grid-template-columns: 1fr auto;

View File

@ -1,18 +1,20 @@
<mat-toolbar>
<mat-toolbar-row>
<mat-icon>chat</mat-icon>Chat
<div class="toolbar-right-side">
<button
*ngIf="this.authService.IsLoggedIn()"
(click)="Logout()"
mat-stroked-button>
Logout
</button>
<button id="sidebar-button" *ngIf="this.sidebar.observers.length != 0" mat-button
(click)="this.sidebar.emit()">
<mat-icon>menu</mat-icon>
</button>
<div id="logo">
<mat-icon>chat</mat-icon>Chat
</div>
<button *ngIf="this.authService.IsLoggedIn()" (click)="Logout()" mat-stroked-button>
Logout
</button>
</mat-toolbar-row>
</mat-toolbar>

View File

@ -2,10 +2,20 @@ mat-toolbar {
background-color: var(--mat-sys-surface-container);
}
.toolbar-right-side {
width: 100%;
button {
float: right;
#sidebar-button {
mat-icon {
width: fit-content;
margin: auto;
padding: 0;
}
}
#logo {
display: block;
margin: auto;
width: fit-content;
}
button {
float: right;
}

View File

@ -1,4 +1,4 @@
import { Component } from '@angular/core';
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
@ -14,6 +14,8 @@ import { NgIf } from '@angular/common';
styleUrl: './toolbar.component.scss'
})
export class ToolbarComponent {
@Output('sidebar') sidebar: EventEmitter<void> = new EventEmitter<void>();
constructor(
public authService: AuthService,
private toastrService: ToastrService,

View File

@ -0,0 +1,15 @@
import { Directive, HostListener } from '@angular/core';
@Directive({
selector: '[back-button]'
})
export class BackButtonDirective {
constructor() { }
@HostListener('click')
onClick(): void {
window.history.back();
}
}

View File

@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class AudioService {
constructor() { }
public PlayNotification(): void {
let audio = new Audio();
audio.src = 'audio/pop.mp3'
audio.load();
audio.play();
}
}

View File

@ -46,6 +46,7 @@ export class AuthService {
let url = `${environment.apiBase}/auth/logout`;
localStorage.removeItem(this.LOCAL_USERNAME);
this.loggedIn = false;
return this.http.get(url, { withCredentials: true }).pipe(
map(() => true),

View File

@ -14,6 +14,7 @@ export class ChatService {
private readonly CHANNEL: string = "channel_id";
private readonly CONTENT: string = "content";
private readonly CHANNEL_SELECT_PREFIX: string = "ch_select";
constructor(private http: HttpClient) { }
@ -36,31 +37,52 @@ export class ChatService {
);
}
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);
}
public GetMessages(channelID: number, from: Date, limit: number = 10): Observable<Message[]> {
let url = `${environment.apiBase}/chat/messages/${channelID}?limit=${limit}&from=${from.toISOString()}`;
return this.http.get<GetMessagesResponse>(url, { withCredentials: true })
.pipe(
map(response => {
if (response.error) {
throw new Error(response.error);
}
if (response.messages) {
return response.messages;
}
if (response.messages) {
return response.messages;
}
throw new Error("bad API response, missing messages with no error");
}),
catchError(error => throwError(() => new Error(error.error.message)))
);
throw new Error("bad API response, missing messages with no error");
}),
// HttpClient doesn't parse Date objects by default
map(msgs => {
return msgs.map(msg => {
if (typeof msg.time == 'string')
msg.time = new Date(msg.time);
url = `${environment.apiBase}/chat/subscribe/${channelID}`;
return msg;
})
}),
catchError(error => throwError(() => new Error(error.error.message)))
);
}
public GetMessageStream(channelID: number, historyLimit: number = 10): Observable<Message> {
let messages: Observable<Message[]> = this.GetMessages(channelID, new Date(), historyLimit);
let url = `${environment.apiBase}/chat/subscribe/${channelID}`;
let socket = webSocket<Message>(url);
return merge(
messages.pipe(mergeMap(msgArray => from(msgArray))),
socket.asObservable());
messages
.pipe(mergeMap(msgArray => from(msgArray))),
socket.asObservable()
.pipe(map(msg => {
// HttpClient doesn't parse Date objects by default
if (typeof msg.time == 'string')
msg.time = new Date(msg.time);
return msg;
}))
);
}
public SendMessage(channel: number, content: string): Observable<SendMessageResponse> {
@ -82,4 +104,13 @@ export class ChatService {
catchError(error => throwError(() => new Error(error.error.message)))
);
}
public SetChannelSelected(channel: number): void {
localStorage.setItem(`${this.CHANNEL_SELECT_PREFIX}${channel.toString()}`, new Date().toISOString());
}
public GetChannelLastSelected(channel: number): Date {
let dateString = localStorage.getItem(`${this.CHANNEL_SELECT_PREFIX}${channel.toString()}`);
return dateString ? new Date(dateString) : new Date(0);
}
}

View File

@ -3,7 +3,16 @@ import { CommonModule } from '@angular/common';
import { UserRoutingModule } from './user-routing.module';
import { UserComponent } from './user/user.component';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatCardModule } from '@angular/material/card';
import { ReactiveFormsModule } from '@angular/forms';
import { ToolbarComponent } from "../common/toolbar/toolbar.component";
import { BackButtonDirective } from '../directives/back-button.directive';
import { MatDividerModule } from '@angular/material/divider';
@NgModule({
declarations: [
@ -11,7 +20,17 @@ import { UserComponent } from './user/user.component';
],
imports: [
CommonModule,
UserRoutingModule
UserRoutingModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatButtonModule,
MatSidenavModule,
MatCardModule,
ReactiveFormsModule,
ToolbarComponent,
BackButtonDirective,
MatDividerModule
]
})
export class UserModule { }

View File

@ -1 +1,103 @@
<p>{{username}}</p>
<mat-drawer-container>
<mat-drawer #drawer>
<div id="logo">
<h1><mat-icon>chat</mat-icon>Chat</h1>
<button mat-button (click)="drawer.close()">
<mat-icon>close</mat-icon>Close
</button>
</div>
<h3>Options</h3>
<button class="option" routerLink="/chat" mat-button>Chat</button>
<button class="option" back-button mat-button>Back</button>
</mat-drawer>
<mat-drawer-content>
<app-toolbar (sidebar)="drawer.toggle()"></app-toolbar>
<!-- display -->
<mat-card id="display-card" *ngIf="this.username != this.authService.GetUsername()">
<img *ngIf="this.user?.picture != ''" [src]="this.user?.picture" [alt]="this.username">
<h1>{{'@'}}{{this.user?.username}}</h1>
<h3>Status</h3>
<p>{{this.user?.status}}</p>
<h3>Bio</h3>
<p>{{this.user?.bio}}</p>
</mat-card>
<!-- edit -->
<mat-card id="edit-card" *ngIf="this.username == this.authService.GetUsername()">
<img [src]="this.user?.picture" [alt]="this.username">
<h1>{{'@'}}{{this.user?.username}}</h1>
<form (submit)="this.ChangePassword()" [formGroup]="passwordForm">
<h3>Change password</h3>
<mat-form-field>
<mat-label>New password</mat-label>
<input matInput required formControlName="newPassword">
</mat-form-field>
<mat-form-field>
<mat-label>Repeat new password</mat-label>
<input matInput required formControlName="repeatNewPassword">
</mat-form-field>
<mat-form-field>
<mat-label>Current password</mat-label>
<input matInput required formControlName="password">
</mat-form-field>
<button mat-button>
<mat-icon>key</mat-icon>Change password
</button>
</form>
<mat-divider />
<form (submit)="this.ChangeStatus()" [formGroup]="statusForm">
<h3>Change status</h3>
<mat-form-field>
<mat-label>Status</mat-label>
<textarea matInput required formControlName="status"></textarea>
</mat-form-field>
<button mat-button>
<mat-icon>edit</mat-icon>Change status
</button>
</form>
<mat-divider />
<form (submit)="this.ChangeBio()" [formGroup]="bioForm">
<h3>Change bio</h3>
<mat-form-field>
<mat-label>Bio</mat-label>
<textarea matInput required formControlName="bio"></textarea>
</mat-form-field>
<button mat-button>
<mat-icon>edit</mat-icon>Change bio
</button>
</form>
</mat-card>
</mat-drawer-content>
</mat-drawer-container>

View File

@ -0,0 +1,56 @@
mat-drawer-container {
height: 100%;
mat-drawer-content {
overflow-y: scroll;
}
}
.option {
min-width: 80%;
display: block;
margin: 10px auto;
}
mat-card {
margin: 30px auto;
max-width: 70%;
padding: 20px;
img {
width: 30%;
height: auto;
aspect-ratio: 1;
border-radius: 50%;
border: 2px solid var(--mat-sys-on-surface);
margin: 10px auto;
text-align: center;
}
h1 {
margin: auto;
}
p {
margin: 0 30px;
}
}
#edit-card {
form {
padding: 10px;
margin: 0;
* {
width: 100%;
}
button {
display: block;
margin: 10px auto;
width: fit-content;
}
}
}

View File

@ -1,5 +1,11 @@
import { Component } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { User } from '../../models/user';
import { UserService } from '../../services/user.service';
import { ToastrService } from 'ngx-toastr';
import { Subscription } from 'rxjs';
import { AuthService } from '../../services/auth.service';
import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms';
@Component({
selector: 'app-user',
@ -7,12 +13,76 @@ import { ActivatedRoute } from '@angular/router';
templateUrl: './user.component.html',
styleUrl: './user.component.scss'
})
export class UserComponent {
export class UserComponent implements OnInit, OnDestroy {
public username!: string;
public user?: User;
constructor(private route: ActivatedRoute) {
private sub?: Subscription;
public passwordForm!: FormGroup;
public statusForm!: FormGroup;
public bioForm!: FormGroup;
constructor(
private route: ActivatedRoute,
private userService: UserService,
public authService: AuthService,
private toastrService: ToastrService,
private formBuilder: FormBuilder) {
this.route.paramMap.subscribe(params => {
this.username = params.get('username')!;
});
}
ngOnInit(): void {
this.passwordForm = this.formBuilder.group({
newPassword: new FormControl('', [
Validators.required,
Validators.minLength(6),
Validators.maxLength(32)
]),
repeatNewPassword: new FormControl('', [
Validators.required,
Validators.minLength(6),
Validators.maxLength(32)
]),
password: new FormControl('', [
Validators.required
])
});
this.statusForm = this.formBuilder.group({
status: new FormControl('', [
Validators.required,
Validators.minLength(0),
Validators.maxLength(200)
]),
});
this.bioForm = this.formBuilder.group({
bio: new FormControl('', [
Validators.required,
Validators.minLength(0),
Validators.maxLength(300)
]),
});
this.sub = this.userService.GetUser(this.username)
.subscribe({
next: user => {
this.user = user;
this.statusForm.setValue({ "status": user.status });
this.bioForm.setValue({ "bio": user.bio });
},
error: () => this.toastrService.error("Failed to fectch user info.", "Error"),
});
}
ngOnDestroy(): void {
this.sub?.unsubscribe();
}
public ChangePassword(): void { }
public ChangeStatus(): void { }
public ChangeBio(): void { }
}

View File

@ -1,15 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Chat</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&amp;display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="manifest" href="manifest.webmanifest">
</head>
<body class="mat-typography">
<app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body>
</html>
</html>

View File

@ -21,4 +21,31 @@ body {
.force-dark-mode {
color-scheme: dark !important;
}
h1,
h2,
h3,
h4 {
margin: 30px 30px 10px;
width: fit-content;
}
#logo {
margin-top: 30px;
h1 {
display: inline;
}
button {
float: right;
margin-right: 10px;
mat-icon {
width: fit-content;
margin: auto;
padding: 0;
}
}
}