Compare commits

..

No commits in common. "d61f2ed9388a69aaf89a406b1c74f67c457e0979" and "6996ecf48c2a910f3326c4ce2c8ba02098c82a3f" have entirely different histories.

20 changed files with 104 additions and 320 deletions

View File

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

View File

@ -1,32 +1,7 @@
.page-layout { .blocked {
display: flex; color: red;
flex-direction: row; }
width: 100%;
height: 100%; .date-time {
color: gray;
.server-list {
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,20 +2,16 @@ 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 {FullSizeComponent} from '@shared-components/full-size/full-size.component'; import {DatePipe} from '@angular/common';
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,
FullSizeComponent, MiniMessageComponent,
ChatBoxComponent, DatePipe
ServerListComponent,
ChatInfoComponent
], ],
templateUrl: './chat.component.html', templateUrl: './chat.component.html',
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
@ -25,8 +21,6 @@ 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}) => {
@ -36,10 +30,6 @@ export class ChatComponent implements OnInit, OnDestroy {
}); });
} }
public onSelectedServerChange(server: string) {
this.chatService.selectServer(server);
}
ngOnInit(): void { ngOnInit(): void {
this.chatService.connect(); this.chatService.connect();
@ -49,4 +39,15 @@ 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

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

View File

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

View File

@ -1,14 +0,0 @@
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

@ -1,8 +0,0 @@
@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

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

View File

@ -1,17 +0,0 @@
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

@ -1,31 +0,0 @@
@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

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

View File

@ -1,13 +0,0 @@
<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

@ -1,23 +0,0 @@
.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

@ -1,27 +0,0 @@
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

@ -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/mini-message.types'; import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from './mini-message.types';
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from '../../mini-message/mini-message.util'; import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from './mini-message.util';
import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive'; import {ObfuscatedDirective} from './obfuscated.directive';
import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service'; import {MiniMessageInteractionService} from './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,8 +20,51 @@ import {MiniMessageInteractionService} from '../../mini-message/mini-message-int
selector: 'mini-message', selector: 'mini-message',
standalone: true, standalone: true,
imports: [CommonModule, ObfuscatedDirective], imports: [CommonModule, ObfuscatedDirective],
templateUrl: './mini-message.component.html', template: `
styleUrl: './mini-message.component.scss', <ng-container *ngIf="node">
<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

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

View File

@ -1,10 +1,10 @@
import {computed, inject, Injectable, OnDestroy, signal} from '@angular/core'; import {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,11 +20,7 @@ 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 = computed(() => this._messages().filter((message) => message.server === this._selectedServer())); public readonly messages = this._messages.asReadonly()
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) {
@ -47,17 +43,21 @@ export class ChatService implements OnDestroy {
this.eventSource = source; this.eventSource = source;
this.on(source, 'connected', (event) => { this.on(source, 'connected', (event) => {
this.processChatMessages(event); 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]);
}); });
this.on(source, 'chat', (event) => { this.on(source, 'chat', (event) => {
const chatMessages = this.processChatMessages(event); const raw = JSON.parse(event.data) as RawChatMessage[];
chatMessages.forEach((message) => { const messages: ChatMessage[] = raw.map((m) => ({
const find = this.servers().find(server => message.server === server.server); ...m,
if (find) { messageJson: JSON.parse(m.messageJson) as MiniMessageComponent,
find.unreadMessages++; }));
} this._messages.update((old) => [...old, ...messages]);
})
}); });
source.onerror = (err) => { source.onerror = (err) => {
@ -65,30 +65,7 @@ export class ChatService implements OnDestroy {
}; };
} }
private processChatMessages(event: SsePayloadEvent): ChatMessage[] { // single, deliberate escape hatch from the broken DOM/polyfill type overlap —
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

@ -1,19 +0,0 @@
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

@ -1,21 +0,0 @@
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";
}

View File

@ -1,7 +1,10 @@
import {HttpInterceptorFn} from '@angular/common/http'; import {HttpInterceptorFn} from '@angular/common/http';
import {inject} from '@angular/core';
import {AuthService} from '@services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => { export const authInterceptor: HttpInterceptorFn = (req, next) => {
const jwt = localStorage.getItem('jwt') const authService = inject(AuthService);
const jwt = authService.getJwt();
if (jwt) { if (jwt) {
const authReq = req.clone({ const authReq = req.clone({