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", "prefix": "app",
"architect": { "architect": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:application", "builder": "@angular/build:application",
"options": { "options": {
"outputPath": "dist/ui", "outputPath": "dist/ui",
"index": "src/index.html", "index": "src/index.html",
@ -33,9 +33,14 @@
], ],
"styles": [ "styles": [
"node_modules/ngx-toastr/toastr.css", "node_modules/ngx-toastr/toastr.css",
"node_modules/prismjs/themes/prism-okaidia.css",
"src/styles.scss" "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": { "configurations": {
"production": { "production": {
@ -57,7 +62,8 @@
"maximumError": "8kB" "maximumError": "8kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all",
"serviceWorker": "ngsw-config.json"
}, },
"development": { "development": {
"optimization": false, "optimization": false,
@ -68,7 +74,7 @@
"defaultConfiguration": "production" "defaultConfiguration": "production"
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular/build:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"buildTarget": "ui:build:production" "buildTarget": "ui:build:production"
@ -81,10 +87,10 @@
"defaultConfiguration": "development" "defaultConfiguration": "development"
}, },
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n" "builder": "@angular/build:extract-i18n"
}, },
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular/build:karma",
"options": { "options": {
"polyfills": [ "polyfills": [
"zone.js", "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)"
]
}
}
]
}

8645
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,25 +10,28 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "^19.2.14", "@angular/animations": "^20.0.2",
"@angular/cdk": "^19.2.18", "@angular/cdk": "^20.0.2",
"@angular/common": "^19.2.0", "@angular/common": "^20.0.2",
"@angular/compiler": "^19.2.0", "@angular/compiler": "^20.0.2",
"@angular/core": "^19.2.0", "@angular/core": "^20.0.2",
"@angular/forms": "^19.2.0", "@angular/forms": "^20.0.2",
"@angular/material": "^19.2.18", "@angular/material": "^20.0.2",
"@angular/platform-browser": "^19.2.0", "@angular/platform-browser": "^20.0.2",
"@angular/platform-browser-dynamic": "^19.2.0", "@angular/platform-browser-dynamic": "^20.0.2",
"@angular/router": "^19.2.0", "@angular/router": "^20.0.2",
"@angular/service-worker": "^20.0.2",
"ngx-markdown": "^20.0.0",
"ngx-toastr": "^19.0.0", "ngx-toastr": "^19.0.0",
"prismjs": "^1.30.0",
"rxjs": "~7.8.0", "rxjs": "~7.8.0",
"tslib": "^2.3.0", "tslib": "^2.3.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^19.2.3", "@angular/build": "^20.0.1",
"@angular/cli": "^19.2.3", "@angular/cli": "^20.0.1",
"@angular/compiler-cli": "^19.2.0", "@angular/compiler-cli": "^20.0.2",
"@types/jasmine": "~5.1.0", "@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0", "jasmine-core": "~5.6.0",
"karma": "~6.4.0", "karma": "~6.4.0",
@ -36,6 +39,6 @@
"karma-coverage": "~2.2.0", "karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0", "karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.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 { ApplicationConfig, provideZoneChangeDetection, isDevMode } from '@angular/core';
import { provideRouter } from '@angular/router'; import { provideRouter, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes'; import { routes } from './app.routes';
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { provideToastr } from 'ngx-toastr'; import { provideToastr } from 'ngx-toastr';
import { provideAnimations } from '@angular/platform-browser/animations'; import { provideAnimations } from '@angular/platform-browser/animations';
import { provideMarkdown } from 'ngx-markdown';
import { provideServiceWorker } from '@angular/service-worker';
export const appConfig: ApplicationConfig = { export const appConfig: ApplicationConfig = {
providers: [ providers: [
provideZoneChangeDetection({ eventCoalescing: true }), provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes), provideRouter(routes, withInMemoryScrolling({
anchorScrolling: "enabled",
scrollPositionRestoration: 'top'
})),
provideHttpClient(), provideHttpClient(),
provideAnimations(), provideAnimations(),
provideToastr({ provideToastr({
@ -17,5 +22,10 @@ export const appConfig: ApplicationConfig = {
positionClass: 'toast-top-right', positionClass: 'toast-top-right',
closeButton: true closeButton: true
}), }),
provideMarkdown(),
provideServiceWorker('ngsw-worker.js', {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000'
})
] ]
}; };

View File

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

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.notifications != 0) ? (this.notifications > this.NOTIFICATION_LIMIT) ? `${this.NOTIFICATION_LIMIT}+` : this.notifications : ''"
matBadgeSize="medium" matBadgeSize="medium"
[disabled]="this.selected"> [disabled]="this.selected">
{{channel.name}} {{channel.name}}

View File

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

View File

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

View File

@ -1,11 +1,28 @@
<div id="container"> <mat-drawer-container>
<app-toolbar />
<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 <button mat-button (click)="drawer.close()">
<mat-icon>close</mat-icon>Close
</button>
</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> [messages]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].messages : []"></app-feed>
<app-message-bar <app-message-bar
[channel]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].channel.id : 1"></app-message-bar> [channel]="(this.channels && this.channels.length) != 0 ? this.channels[this.selectedChannel].channel.id : 1"></app-message-bar>
</div> </mat-drawer-content>
</mat-drawer-container>

View File

@ -1,35 +1,12 @@
#container { mat-drawer-container {
height: 100%;
}
mat-drawer-content {
height: 100%; height: 100%;
display: grid; display: grid;
grid-template-columns: 150px 1fr; grid-template-columns: 1fr;
grid-template-rows: 65px 1fr auto; grid-template-rows: auto 1fr auto;
gap: 0px; 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 { ToastrService } from 'ngx-toastr';
import { FeedComponent } from './feed/feed.component'; import { FeedComponent } from './feed/feed.component';
import { ChannelListComponent } from './channel-list/channel-list.component'; import { ChannelListComponent } from './channel-list/channel-list.component';
import { MatDrawer } from '@angular/material/sidenav';
@Component({ @Component({
selector: 'app-chat', selector: 'app-chat',
@ -16,6 +17,7 @@ export class ChatComponent implements OnInit {
public selectedChannel: number = 0; public selectedChannel: number = 0;
public channels: { channel: Channel, messages: Message[] }[] = []; public channels: { channel: Channel, messages: Message[] }[] = [];
@ViewChild("drawer") drawer!: MatDrawer;
@ViewChild("feed") feed!: FeedComponent; @ViewChild("feed") feed!: FeedComponent;
@ViewChild("channelList") channelList!: ChannelListComponent; @ViewChild("channelList") channelList!: ChannelListComponent;
@ -39,10 +41,12 @@ export class ChatComponent implements OnInit {
private getMessages(): void { private getMessages(): void {
this.channels.forEach((channelObj, i) => { this.channels.forEach((channelObj, i) => {
this.chatService.GetMessages(channelObj.channel.id).subscribe({ this.chatService.GetMessageStream(channelObj.channel.id).subscribe({
next: messages => { next: message => {
channelObj.messages.push(messages); channelObj.messages.push(message);
this.feed.ScrollEventHandler(); this.feed.ScrollEventHandler();
if (this.chatService.GetChannelLastSelected(channelObj.channel.id) < message.time)
this.channelList.Notify(channelObj.channel.id); this.channelList.Notify(channelObj.channel.id);
}, },
error: _ => { 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[] { public GetChannelList(): Channel[] {
return this.channels.map(c => c.channel); return this.channels.map(c => c.channel);
} }
public Select(channel: number): void { public Select(channel: number): void {
this.selectedChannel = channel; this.selectedChannel = channel;
this.chatService.SetChannelSelected(this.channels[channel].channel.id);
this.feed.ScrollEventHandler(); 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" /> <app-message *ngFor="let message of this.messages" [message]="message" />
</div>

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'; import { Message } from '../../../models/message';
@Component({ @Component({
@ -9,6 +9,7 @@ import { Message } from '../../../models/message';
}) })
export class FeedComponent implements AfterViewInit { export class FeedComponent implements AfterViewInit {
@Input('messages') public messages?: Message[]; @Input('messages') public messages?: Message[];
@Output('outOfMessages') public outOfMessages: EventEmitter<void> = new EventEmitter<void>();
constructor(private element: ElementRef<HTMLElement>) { } constructor(private element: ElementRef<HTMLElement>) { }
@ -21,11 +22,38 @@ export class FeedComponent implements AfterViewInit {
return Math.abs(element.scrollHeight - element.clientHeight - element.scrollTop) <= 100; 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 { private scrollToBottom(): void {
setTimeout(() => this.element.nativeElement.scrollTop = this.element.nativeElement.scrollHeight, 100); 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 { public ScrollEventHandler(): void {
if (this.isAtBottom()) this.scrollToBottom(); 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" /> <app-profile-picture [username]="this.message.sender_name" />
<div class="message-inner-container"> <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>
</div> </div>

View File

@ -30,6 +30,24 @@ app-profile-picture {
.message-content { .message-content {
height: fit-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 { Message } from '../../../../models/message';
import { UserService } from '../../../../services/user.service';
@Component({ @Component({
selector: 'app-message', selector: 'app-message',
@ -9,4 +10,21 @@ import { Message } from '../../../../models/message';
}) })
export class MessageComponent { export class MessageComponent {
@Input('message') public message!: Message; @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 { form {
align-items: center; align-items: center;
padding: 10px; padding: 10px;
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;

View File

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

View File

@ -2,10 +2,20 @@ mat-toolbar {
background-color: var(--mat-sys-surface-container); background-color: var(--mat-sys-surface-container);
} }
.toolbar-right-side { #sidebar-button {
width: 100%; mat-icon {
width: fit-content;
margin: auto;
padding: 0;
}
}
#logo {
display: block;
margin: auto;
width: fit-content;
}
button { button {
float: right; 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 { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon'; import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
@ -14,6 +14,8 @@ import { NgIf } from '@angular/common';
styleUrl: './toolbar.component.scss' styleUrl: './toolbar.component.scss'
}) })
export class ToolbarComponent { export class ToolbarComponent {
@Output('sidebar') sidebar: EventEmitter<void> = new EventEmitter<void>();
constructor( constructor(
public authService: AuthService, public authService: AuthService,
private toastrService: ToastrService, 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`; let url = `${environment.apiBase}/auth/logout`;
localStorage.removeItem(this.LOCAL_USERNAME); localStorage.removeItem(this.LOCAL_USERNAME);
this.loggedIn = false;
return this.http.get(url, { withCredentials: true }).pipe( return this.http.get(url, { withCredentials: true }).pipe(
map(() => true), map(() => true),

View File

@ -14,6 +14,7 @@ export class ChatService {
private readonly CHANNEL: string = "channel_id"; private readonly CHANNEL: string = "channel_id";
private readonly CONTENT: string = "content"; private readonly CONTENT: string = "content";
private readonly CHANNEL_SELECT_PREFIX: string = "ch_select";
constructor(private http: HttpClient) { } constructor(private http: HttpClient) { }
@ -36,10 +37,9 @@ export class ChatService {
); );
} }
public GetMessages(channelID: number): Observable<Message> { public GetMessages(channelID: number, from: Date, limit: number = 10): Observable<Message[]> {
let url = `${environment.apiBase}/chat/messages/${channelID}`; let url = `${environment.apiBase}/chat/messages/${channelID}?limit=${limit}&from=${from.toISOString()}`;
let messages: Observable<Message[]> = return this.http.get<GetMessagesResponse>(url, { withCredentials: true })
this.http.get<GetMessagesResponse>(url, { withCredentials: true })
.pipe( .pipe(
map(response => { map(response => {
if (response.error) { if (response.error) {
@ -52,15 +52,37 @@ export class ChatService {
throw new Error("bad API response, missing messages with no error"); 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);
return msg;
})
}),
catchError(error => throwError(() => new Error(error.error.message))) catchError(error => throwError(() => new Error(error.error.message)))
); );
}
url = `${environment.apiBase}/chat/subscribe/${channelID}`; 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); let socket = webSocket<Message>(url);
return merge( return merge(
messages.pipe(mergeMap(msgArray => from(msgArray))), messages
socket.asObservable()); .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> { public SendMessage(channel: number, content: string): Observable<SendMessageResponse> {
@ -82,4 +104,13 @@ export class ChatService {
catchError(error => throwError(() => new Error(error.error.message))) 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 { UserRoutingModule } from './user-routing.module';
import { UserComponent } from './user/user.component'; 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({ @NgModule({
declarations: [ declarations: [
@ -11,7 +20,17 @@ import { UserComponent } from './user/user.component';
], ],
imports: [ imports: [
CommonModule, CommonModule,
UserRoutingModule UserRoutingModule,
MatFormFieldModule,
MatInputModule,
MatIconModule,
MatButtonModule,
MatSidenavModule,
MatCardModule,
ReactiveFormsModule,
ToolbarComponent,
BackButtonDirective,
MatDividerModule
] ]
}) })
export class UserModule { } 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 { 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({ @Component({
selector: 'app-user', selector: 'app-user',
@ -7,12 +13,76 @@ import { ActivatedRoute } from '@angular/router';
templateUrl: './user.component.html', templateUrl: './user.component.html',
styleUrl: './user.component.scss' styleUrl: './user.component.scss'
}) })
export class UserComponent { export class UserComponent implements OnInit, OnDestroy {
public username!: string; 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.route.paramMap.subscribe(params => {
this.username = params.get('username')!; 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> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Chat</title> <title>Chat</title>
<base href="/"> <base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico"> <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 href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link rel="manifest" href="manifest.webmanifest">
</head> </head>
<body class="mat-typography"> <body class="mat-typography">
<app-root></app-root> <app-root></app-root>
<noscript>Please enable JavaScript to continue using this application.</noscript>
</body> </body>
</html> </html>

View File

@ -22,3 +22,30 @@ body {
.force-dark-mode { .force-dark-mode {
color-scheme: dark !important; 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;
}
}
}