Modularize chat page with new components (chat-box, server-list, chat-info). Introduce server filtering, real-time updates, and redesigned layout for enhanced usability.

This commit is contained in:
akastijn 2026-07-19 17:12:03 +02:00
parent 73132fae13
commit d61f2ed938
19 changed files with 318 additions and 99 deletions

View File

@ -1,5 +1,5 @@
<ng-container> <ng-container>
<app-header [current_page]="'chat'" height="460px" background_image="/public/img/backgrounds/staff.png" <app-header [current_page]="'chat'" height="200px" background_image="/public/img/backgrounds/staff.png"
[overlay_gradient]="0.5"> [overlay_gradient]="0.5">
<div class="title" header-content> <div class="title" header-content>
<h1>Chat</h1> <h1>Chat</h1>
@ -7,18 +7,25 @@
</app-header> </app-header>
<main> <main>
<section class="darkmodeSection"> <app-full-size [hideFooter]="true">
@for (message of messages(); track message.timestamp) { <section class="darkmodeSection full-height">
<p> <div class="page-layout">
<span class="date-time">[{{ message.timestamp | date: 'mediumTime' }}] </span> <div class="server-list">
@if (message.notBlocked) { <app-server-list [servers]="servers()" [selectedServer]="selectedServer()"
<span [style.color]=getServerColor(message.server)>[{{ message.server }}] </span> (selectedServerChange)="onSelectedServerChange($event)">
} @else {
<span class="blocked">[{{ message.server }}] </span> </app-server-list>
} </div>
<mini-message [node]="message.messageJson"></mini-message> <div class="chat">
</p> <div class="chat-info">
} <app-chat-info [selectedServer]="selectedServer()"></app-chat-info>
</div>
<div class="chat-box">
<app-chat-box [messages]="messages()"></app-chat-box>
</div>
</div>
</div>
</section> </section>
</app-full-size>
</main> </main>
</ng-container> </ng-container>

View File

@ -1,7 +1,32 @@
.blocked { .page-layout {
color: red; display: flex;
} flex-direction: row;
width: 100%;
height: 100%;
.date-time { .server-list {
color: gray; flex: 0 0 auto;
width: max-content;
min-width: 100px;
}
.chat {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
.chat-info {
flex: 0 0 auto;
padding: 5px 5px 2px 5px;
}
.chat-box {
flex: 1 1 auto;
min-width: 0;
padding: 0 5px 0 5px;
min-height: 0;
}
}
} }

View File

@ -2,16 +2,20 @@ import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {Subscription} from 'rxjs'; import {Subscription} from 'rxjs';
import {ChatService} from '@pages/altitude/chat/service/chat.service'; import {ChatService} from '@pages/altitude/chat/service/chat.service';
import {HeaderComponent} from '@header/header.component'; import {HeaderComponent} from '@header/header.component';
import {MiniMessageComponent} from '@pages/altitude/chat/mini-message/mini-message.component';
import {MiniMessageInteractionService} from '@pages/altitude/chat/mini-message/mini-message-interaction.service'; import {MiniMessageInteractionService} from '@pages/altitude/chat/mini-message/mini-message-interaction.service';
import {DatePipe} from '@angular/common'; import {FullSizeComponent} from '@shared-components/full-size/full-size.component';
import {ChatBoxComponent} from '@pages/altitude/chat/components/chat/chat-box.component';
import {ServerListComponent} from '@pages/altitude/chat/components/server-list/server-list.component';
import {ChatInfoComponent} from '@pages/altitude/chat/components/chat-info/chat-info.component';
@Component({ @Component({
selector: 'app-chat', selector: 'app-chat',
imports: [ imports: [
HeaderComponent, HeaderComponent,
MiniMessageComponent, FullSizeComponent,
DatePipe ChatBoxComponent,
ServerListComponent,
ChatInfoComponent
], ],
templateUrl: './chat.component.html', templateUrl: './chat.component.html',
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
@ -21,6 +25,8 @@ export class ChatComponent implements OnInit, OnDestroy {
private sub?: Subscription; private sub?: Subscription;
private readonly chatService: ChatService = inject(ChatService) private readonly chatService: ChatService = inject(ChatService)
protected readonly messages = this.chatService.messages; protected readonly messages = this.chatService.messages;
protected readonly servers = this.chatService.servers;
protected readonly selectedServer = this.chatService.selectedServer;
constructor(private interaction: MiniMessageInteractionService) { constructor(private interaction: MiniMessageInteractionService) {
interaction.clicks$.subscribe(({action, value}) => { interaction.clicks$.subscribe(({action, value}) => {
@ -30,6 +36,10 @@ export class ChatComponent implements OnInit, OnDestroy {
}); });
} }
public onSelectedServerChange(server: string) {
this.chatService.selectServer(server);
}
ngOnInit(): void { ngOnInit(): void {
this.chatService.connect(); this.chatService.connect();
@ -39,15 +49,4 @@ export class ChatComponent implements OnInit, OnDestroy {
this.sub?.unsubscribe(); this.sub?.unsubscribe();
this.chatService.disconnect(); // triggers onCompletion server-side, cleans up the emitter this.chatService.disconnect(); // triggers onCompletion server-side, cleans up the emitter
} }
protected getServerColor(server: string) {
if (server === 'bayou') {
return "#007FFF";
} else if (server === 'lobby') {
return "white";
} else if (server === 'creative') {
return "#FFA500";
}
return "white";
}
} }

View File

@ -0,0 +1,6 @@
<div class="full-width"
[style.background-color]="getServerColor(selectedServer())">
<span class="server" [style.color]="getServerTextColor(selectedServer())">
{{ selectedServer() }}
</span>
</div>

View File

@ -0,0 +1,6 @@
.server {
padding-left: 10px;
font-size: 25px;
font-family: minecraft-text, 'opensans-bold', sans-serif;
letter-spacing: 2px;
}

View File

@ -0,0 +1,14 @@
import {Component, input} from '@angular/core';
import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/server-color';
@Component({
selector: 'app-chat-info',
imports: [],
templateUrl: './chat-info.component.html',
styleUrl: './chat-info.component.scss'
})
export class ChatInfoComponent {
public readonly selectedServer = input.required<string>();
protected readonly getServerColor = getServerColor;
protected readonly getServerTextColor = getServerTextColor;
}

View File

@ -0,0 +1,8 @@
@for (message of messages(); track message.timestamp) {
<p>
<span class="date-time" [class.blocked]="!message.notBlocked">
[{{ message.timestamp * 1000 | date: 'mediumTime' }}]
</span>
<mini-message [node]="message.messageJson"></mini-message>
</p>
}

View File

@ -0,0 +1,7 @@
.blocked {
color: red;
}
.date-time {
color: gray;
}

View File

@ -0,0 +1,17 @@
import {Component, input} from '@angular/core';
import {DatePipe} from '@angular/common';
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
import {MiniMessageComponent} from '@pages/altitude/chat/components/mini-message/mini-message.component';
@Component({
selector: 'app-chat-box',
imports: [
MiniMessageComponent,
DatePipe
],
templateUrl: './chat-box.component.html',
styleUrl: './chat-box.component.scss'
})
export class ChatBoxComponent {
readonly messages = input.required<ChatMessage[]>();
}

View File

@ -0,0 +1,31 @@
@if (node) {
<ng-container>
<a
*ngIf="isUrlLink; else plain"
[ngStyle]="ngStyle"
[mmObfuscated]="style.obfuscated"
[attr.title]="hoverTitle"
[attr.href]="node.clickEvent?.value"
[attr.data-insertion]="node.insertion || null"
target="_blank"
rel="noopener noreferrer"
class="mm-node mm-link"
>{{ text }}</a>
<ng-template #plain>
<span
[ngStyle]="ngStyle"
[mmObfuscated]="style.obfuscated"
[attr.title]="hoverTitle"
[attr.data-insertion]="node.insertion || null"
[class.mm-clickable]="hasNonUrlClick"
class="mm-node"
(click)="onClick()"
>{{ text }}</span
>
</ng-template>
@for (child of node.extra; track child) {
<mini-message [node]="child" [parentStyle]="style"></mini-message>
}
</ng-container>
}

View File

@ -0,0 +1,12 @@
.mm-node {
white-space: pre-wrap;
}
.mm-clickable {
cursor: pointer;
}
.mm-link {
text-decoration: inherit;
cursor: pointer;
}

View File

@ -1,9 +1,9 @@
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import {Component, Input, Optional} from '@angular/core'; import {Component, Input, Optional} from '@angular/core';
import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from './mini-message.types'; import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from '../../mini-message/mini-message.types';
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from './mini-message.util'; import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from '../../mini-message/mini-message.util';
import {ObfuscatedDirective} from './obfuscated.directive'; import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive';
import {MiniMessageInteractionService} from './mini-message-interaction.service'; import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service';
/** /**
* Renders a Paper/Adventure component-JSON tree as styled HTML. * Renders a Paper/Adventure component-JSON tree as styled HTML.
@ -20,51 +20,8 @@ import {MiniMessageInteractionService} from './mini-message-interaction.service'
selector: 'mini-message', selector: 'mini-message',
standalone: true, standalone: true,
imports: [CommonModule, ObfuscatedDirective], imports: [CommonModule, ObfuscatedDirective],
template: ` templateUrl: './mini-message.component.html',
<ng-container *ngIf="node"> styleUrl: './mini-message.component.scss',
<a
*ngIf="isUrlLink; else plain"
[ngStyle]="ngStyle"
[mmObfuscated]="style.obfuscated"
[attr.title]="hoverTitle"
[attr.href]="node.clickEvent?.value"
[attr.data-insertion]="node.insertion || null"
target="_blank"
rel="noopener noreferrer"
class="mm-node mm-link"
>{{ text }}</a
>
<ng-template #plain>
<span
[ngStyle]="ngStyle"
[mmObfuscated]="style.obfuscated"
[attr.title]="hoverTitle"
[attr.data-insertion]="node.insertion || null"
[class.mm-clickable]="hasNonUrlClick"
class="mm-node"
(click)="onClick()"
>{{ text }}</span
>
</ng-template>
<mini-message *ngFor="let child of node.extra" [node]="child" [parentStyle]="style"></mini-message>
</ng-container>
`,
styles: [
`
.mm-node {
white-space: pre-wrap;
}
.mm-clickable {
cursor: pointer;
}
.mm-link {
text-decoration: inherit;
cursor: pointer;
}
`,
],
}) })
export class MiniMessageComponent { export class MiniMessageComponent {
@Input() node!: MiniMessageNode; @Input() node!: MiniMessageNode;

View File

@ -0,0 +1,13 @@
<div class="server-list">
<p>Servers</p>
@for (server of servers(); track server.server) {
<div class="server" (click)="selectedServerChange.emit(server.server)">
<span class="server-name">{{ server.server }}</span>
@if (server.unreadMessages > 0) {
<div class="notification">
<svg-circle></svg-circle>
</div>
}
</div>
}
</div>

View File

@ -0,0 +1,23 @@
.server-list {
height: 100%;
background-color: #172133;
.server {
position: relative;
display: flex;
padding: 2px 2px 2px 10px;
border-bottom: 1px solid #222;
.server-name {
color: white
}
.notification {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
color: blue;
}
}
}

View File

@ -0,0 +1,27 @@
import {Component, effect, input, output} from '@angular/core';
import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object';
import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg';
@Component({
selector: 'app-server-list',
imports: [
CircleSvgComponent
],
templateUrl: './server-list.component.html',
styleUrl: './server-list.component.scss'
})
export class ServerListComponent {
public readonly servers = input.required<ChatServer[]>();
public readonly selectedServer = input.required<string>();
public readonly selectedServerChange = output<string>();
constructor() {
effect(() => {
const chatServers = this.servers();
if (this.selectedServer() === '' && chatServers.length > 0) {
this.selectedServerChange.emit(chatServers[0].server);
}
});
}
}

View File

@ -0,0 +1,4 @@
export interface ChatServer {
server: string;
unreadMessages: number;
}

View File

@ -1,10 +1,10 @@
import {inject, Injectable, OnDestroy, signal} from '@angular/core'; import {computed, inject, Injectable, OnDestroy, signal} from '@angular/core';
import {AuthService} from '@services/auth.service'; import {AuthService} from '@services/auth.service';
import {EventSourcePolyfill} from 'event-source-polyfill'; import {EventSourcePolyfill} from 'event-source-polyfill';
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object'; import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
import {MiniMessageComponent} from '@pages/altitude/chat/mini-message/mini-message.types';
import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object'; import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object';
import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util'; import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util';
import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object';
export interface ChatEvent { export interface ChatEvent {
type: string; type: string;
@ -20,7 +20,11 @@ export class ChatService implements OnDestroy {
private eventSource?: EventSourcePolyfill; private eventSource?: EventSourcePolyfill;
private readonly authService: AuthService = inject(AuthService) private readonly authService: AuthService = inject(AuthService)
private readonly _messages = signal<ChatMessage[]>([]) private readonly _messages = signal<ChatMessage[]>([])
public readonly messages = this._messages.asReadonly() public readonly messages = computed(() => this._messages().filter((message) => message.server === this._selectedServer()));
private readonly _servers = signal<ChatServer[]>([])
public readonly servers = computed(() => this._servers().sort((a, b) => a.server.localeCompare(b.server)));
private readonly _selectedServer = signal<string>('');
public readonly selectedServer = this._selectedServer.asReadonly()
connect() { connect() {
if (this.eventSource) { if (this.eventSource) {
@ -43,21 +47,17 @@ export class ChatService implements OnDestroy {
this.eventSource = source; this.eventSource = source;
this.on(source, 'connected', (event) => { this.on(source, 'connected', (event) => {
const raw = JSON.parse(event.data) as RawChatMessage[]; this.processChatMessages(event);
const messages: ChatMessage[] = raw.map((m) => ({
...m,
messageJson: normalizeComponent(JSON.parse(m.messageJson)),
}));
this._messages.update((old) => [...old, ...messages]);
}); });
this.on(source, 'chat', (event) => { this.on(source, 'chat', (event) => {
const raw = JSON.parse(event.data) as RawChatMessage[]; const chatMessages = this.processChatMessages(event);
const messages: ChatMessage[] = raw.map((m) => ({ chatMessages.forEach((message) => {
...m, const find = this.servers().find(server => message.server === server.server);
messageJson: JSON.parse(m.messageJson) as MiniMessageComponent, if (find) {
})); find.unreadMessages++;
this._messages.update((old) => [...old, ...messages]); }
})
}); });
source.onerror = (err) => { source.onerror = (err) => {
@ -65,7 +65,30 @@ export class ChatService implements OnDestroy {
}; };
} }
// single, deliberate escape hatch from the broken DOM/polyfill type overlap — private processChatMessages(event: SsePayloadEvent): ChatMessage[] {
const raw = JSON.parse(event.data) as RawChatMessage[];
const messages: ChatMessage[] = raw.map((m) => ({
...m,
messageJson: normalizeComponent(JSON.parse(m.messageJson)),
}));
this._messages.update((old) => [...old, ...messages]);
//TODO [Stijn] [2026-07-19]: Handle servers by getting them from backend
messages.map((message) => message.server).forEach((server) => {
const found = this._servers().find(existing => existing.server === server);
if (!found) {
this._servers.update((old) => [...old, {server: server, unreadMessages: 0}]);
}
})
return messages;
}
public selectServer(server: string) {
this._selectedServer.set(server);
}
// single, deliberate escape hatch from the broken DOM/polyfill type overlap —
// everything else in this file stays fully typed // everything else in this file stays fully typed
private on(source: EventSourcePolyfill, eventName: string, handler: (event: SsePayloadEvent) => void): void { private on(source: EventSourcePolyfill, eventName: string, handler: (event: SsePayloadEvent) => void): void {
(source as unknown as { addEventListener: (type: string, listener: (event: SsePayloadEvent) => void) => void }) (source as unknown as { addEventListener: (type: string, listener: (event: SsePayloadEvent) => void) => void })

View File

@ -0,0 +1,19 @@
import {Component} from '@angular/core';
@Component({
selector: 'svg-circle',
imports: [],
template: `
<svg
width="10px"
height="10px" viewBox="0 0 6 6" id="meteor-icon-kit__solid-circle-xxxs" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M3 6C1.3431 6 0 4.6569 0 3C0 1.3431 1.3431 0 3 0C4.6569 0 6 1.3431 6 3C6 4.6569 4.6569 6 3 6z"
fill="currentColor"/>
</svg>
`
})
export class CircleSvgComponent {
}

View File

@ -0,0 +1,21 @@
export function getServerColor(server: string) {
if (server === 'bayou') {
return "#0290e8";
} else if (server === 'lobby') {
return "white";
} else if (server === 'creative') {
return "#FFA500";
}
return "white";
}
export function getServerTextColor(server: string) {
if (server === 'bayou') {
return "white";
} else if (server === 'lobby') {
return "black";
} else if (server === 'creative') {
return "black";
}
return "white";
}