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

View File

@ -1,32 +1,7 @@
.page-layout {
display: flex;
flex-direction: row;
width: 100%;
height: 100%;
.server-list {
flex: 0 0 auto;
width: max-content;
min-width: 100px;
.blocked {
color: red;
}
.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;
}
}
.date-time {
color: gray;
}

View File

@ -2,20 +2,16 @@ import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {Subscription} from 'rxjs';
import {ChatService} from '@pages/altitude/chat/service/chat.service';
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 {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';
import {DatePipe} from '@angular/common';
@Component({
selector: 'app-chat',
imports: [
HeaderComponent,
FullSizeComponent,
ChatBoxComponent,
ServerListComponent,
ChatInfoComponent
MiniMessageComponent,
DatePipe
],
templateUrl: './chat.component.html',
styleUrl: './chat.component.scss',
@ -25,8 +21,6 @@ export class ChatComponent implements OnInit, OnDestroy {
private sub?: Subscription;
private readonly chatService: ChatService = inject(ChatService)
protected readonly messages = this.chatService.messages;
protected readonly servers = this.chatService.servers;
protected readonly selectedServer = this.chatService.selectedServer;
constructor(private interaction: MiniMessageInteractionService) {
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 {
this.chatService.connect();
@ -49,4 +39,15 @@ export class ChatComponent implements OnInit, OnDestroy {
this.sub?.unsubscribe();
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 {Component, Input, Optional} from '@angular/core';
import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from '../../mini-message/mini-message.types';
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from '../../mini-message/mini-message.util';
import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive';
import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service';
import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from './mini-message.types';
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from './mini-message.util';
import {ObfuscatedDirective} from './obfuscated.directive';
import {MiniMessageInteractionService} from './mini-message-interaction.service';
/**
* 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',
standalone: true,
imports: [CommonModule, ObfuscatedDirective],
templateUrl: './mini-message.component.html',
styleUrl: './mini-message.component.scss',
template: `
<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 {
@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 {EventSourcePolyfill} from 'event-source-polyfill';
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 {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util';
import {ChatServer} from '@pages/altitude/chat/objects/chat-server.object';
export interface ChatEvent {
type: string;
@ -20,11 +20,7 @@ export class ChatService implements OnDestroy {
private eventSource?: EventSourcePolyfill;
private readonly authService: AuthService = inject(AuthService)
private readonly _messages = signal<ChatMessage[]>([])
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()
public readonly messages = this._messages.asReadonly()
connect() {
if (this.eventSource) {
@ -47,45 +43,26 @@ export class ChatService implements OnDestroy {
this.eventSource = source;
this.on(source, 'connected', (event) => {
this.processChatMessages(event);
});
this.on(source, 'chat', (event) => {
const chatMessages = this.processChatMessages(event);
chatMessages.forEach((message) => {
const find = this.servers().find(server => message.server === server.server);
if (find) {
find.unreadMessages++;
}
})
});
source.onerror = (err) => {
console.error('SSE error, polyfill will auto-reconnect:', err);
};
}
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}]);
}
})
this.on(source, 'chat', (event) => {
const raw = JSON.parse(event.data) as RawChatMessage[];
const messages: ChatMessage[] = raw.map((m) => ({
...m,
messageJson: JSON.parse(m.messageJson) as MiniMessageComponent,
}));
this._messages.update((old) => [...old, ...messages]);
});
return messages;
}
public selectServer(server: string) {
this._selectedServer.set(server);
source.onerror = (err) => {
console.error('SSE error, polyfill will auto-reconnect:', err);
};
}
// single, deliberate escape hatch from the broken DOM/polyfill type overlap —

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 {inject} from '@angular/core';
import {AuthService} from '@services/auth.service';
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const jwt = localStorage.getItem('jwt')
const authService = inject(AuthService);
const jwt = authService.getJwt();
if (jwt) {
const authReq = req.clone({