Replace servers with channels in chat functionality: refactored UI components, service logic, and message model to support channels (SERVER, DM, PARTY, GAC).

This commit is contained in:
akastijn 2026-08-01 19:10:33 +02:00
parent 0d6a4ee8dd
commit f9f05dcc88
16 changed files with 152 additions and 102 deletions

View File

@ -24,15 +24,15 @@
<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)">
<div class="channel-list">
<app-channel-list [channels]="channels()" [selectedChannel]="selectedChannel()"
(selectedChannelChange)="onSelectedChannelChange($event)">
</app-server-list>
</app-channel-list>
</div>
<div class="chat">
<div class="chat-info">
<app-chat-info [selectedServer]="selectedServer()"></app-chat-info>
<app-chat-info [selectedChannel]="selectedChannel()"></app-chat-info>
</div>
<div class="chat-box">
<app-chat-box [messages]="messages()"></app-chat-box>

View File

@ -54,7 +54,7 @@
width: 100%;
height: 100%;
.server-list {
.channel-list {
flex: 0 0 auto;
width: max-content;
min-width: 100px;

View File

@ -1,11 +1,12 @@
import {AfterViewInit, Component, inject, OnDestroy, OnInit, signal} from '@angular/core';
import {Subscription} from 'rxjs';
import {ChatService} from '@pages/altitude/chat/service/chat.service';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {HeaderComponent} from '@header/header.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 {ChannelListComponent} from '@pages/altitude/chat/components/channel-list/channel-list.component';
import {ChatInfoComponent} from '@pages/altitude/chat/components/chat-info/chat-info.component';
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
@ -15,7 +16,7 @@ import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
HeaderComponent,
FullSizeComponent,
ChatBoxComponent,
ServerListComponent,
ChannelListComponent,
ChatInfoComponent
],
templateUrl: './chat.component.html',
@ -26,8 +27,8 @@ export class ChatComponent implements OnInit, AfterViewInit, 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;
protected readonly channels = this.chatService.channels;
protected readonly selectedChannel = this.chatService.selectedChannel;
protected readonly showFullscreenPrompt = signal(false);
protected readonly isMobile = signal(false);
@ -45,8 +46,8 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
});
}
public onSelectedServerChange(server: string) {
this.chatService.selectServer(server);
public onSelectedChannelChange(channel: ChatChannel) {
this.chatService.selectChannel(channel);
}
protected enterFullscreen(): void {

View File

@ -0,0 +1,14 @@
<div class="channel-list">
<p>Channels</p>
@for (channel of channels(); track channel.type + channel.name) {
<div class="channel" [class.selected]="selectedChannel()?.name === channel.name && selectedChannel()?.type === channel.type"
(click)="selectedChannelChange.emit(channel)">
<span class="channel-name">{{ channel.name }}</span>
@if (channel.unreadMessages > 0) {
<div class="notification">
<svg-circle></svg-circle>
</div>
}
</div>
}
</div>

View File

@ -1,8 +1,8 @@
.server-list {
.channel-list {
height: 100%;
background-color: #172133;
.server {
.channel {
position: relative;
display: flex;
align-items: center;
@ -15,7 +15,11 @@
background-color: #202c42;
}
.server-name {
&.selected {
background-color: #202c42;
}
.channel-name {
color: white;
font-weight: bold;
}

View File

@ -0,0 +1,27 @@
import {Component, effect, input, output} from '@angular/core';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg';
@Component({
selector: 'app-channel-list',
imports: [
CircleSvgComponent
],
templateUrl: './channel-list.component.html',
styleUrl: './channel-list.component.scss'
})
export class ChannelListComponent {
public readonly channels = input.required<ChatChannel[]>();
public readonly selectedChannel = input.required<ChatChannel | null>();
public readonly selectedChannelChange = output<ChatChannel>();
constructor() {
effect(() => {
const chatChannels = this.channels();
if (this.selectedChannel() === null && chatChannels.length > 0) {
this.selectedChannelChange.emit(chatChannels[0]);
}
});
}
}

View File

@ -1,7 +1,7 @@
<div class="full-width"
[style.background-color]="getServerColor(selectedServer())">
<span class="server" [style.color]="getServerTextColor(selectedServer())">
{{ selectedServer() }}
[style.background-color]="getServerColor(selectedChannel()?.name ?? '')">
<span class="channel" [style.color]="getServerTextColor(selectedChannel()?.name ?? '')">
{{ selectedChannel()?.name }}
</span>
<div class="notification-icon">
@if (notificationEnabled()) {

View File

@ -7,7 +7,7 @@
margin-bottom: 10px;
}
.server {
.channel {
font-size: 20px;
font-family: minecraft-text, 'opensans-bold', sans-serif;
letter-spacing: 1px;

View File

@ -3,6 +3,7 @@ import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/serv
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
import {NotificationEnabledSvgComponent} from '@pages/altitude/chat/svg/notification-enabled.svg';
import {NotificationDisabledSvgComponent} from '@pages/altitude/chat/svg/notification-disabled.svg';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
@Component({
selector: 'app-chat-info',
@ -16,17 +17,21 @@ import {NotificationDisabledSvgComponent} from '@pages/altitude/chat/svg/notific
export class ChatInfoComponent {
private readonly notificationService: NotificationService = inject(NotificationService);
public readonly selectedServer = input.required<string>();
protected readonly notificationEnabled = computed(() => this.notificationService.isNotificationEnabled(this.selectedServer()));
public readonly selectedChannel = input.required<ChatChannel | null>();
protected readonly channelKey = computed(() => {
const channel = this.selectedChannel();
return channel ? `${channel.type}:${channel.name}` : '';
});
protected readonly notificationEnabled = computed(() => this.notificationService.isNotificationEnabled(this.channelKey()));
protected readonly getServerColor = getServerColor;
protected readonly getServerTextColor = getServerTextColor;
public enableNotifications() {
this.notificationService.enableNotifications(this.selectedServer());
this.notificationService.enableNotifications(this.channelKey());
}
public disableNotifications() {
this.notificationService.disableNotifications(this.selectedServer());
this.notificationService.disableNotifications(this.channelKey());
}
}

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,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

@ -0,0 +1,5 @@
export interface ChatChannel {
name: string;
type: 'SERVER' | 'DM' | 'PARTY' | 'GAC';
unreadMessages: number;
}

View File

@ -9,4 +9,6 @@ export interface ChatMessage {
receiver: string;
messageJson: MiniMessageComponent;
notBlocked: boolean;
channelName?: string;
channelType?: 'SERVER' | 'DM' | 'PARTY' | 'GAC';
}

View File

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

View File

@ -9,8 +9,8 @@ export class NotificationService {
this.getStoredNotificationSettings()
);
public notify(server: string) {
if (!this.isNotificationEnabled(server)) {
public notify(channel: string) {
if (!this.isNotificationEnabled(channel)) {
return;
}
this.playSound();
@ -38,23 +38,23 @@ export class NotificationService {
});
}
public isNotificationEnabled(server: string): boolean {
return this._notificationEnabled()[server];
public isNotificationEnabled(channel: string): boolean {
return this._notificationEnabled()[channel];
}
public enableNotifications(server: string) {
this.setNotificationEnabled(server, true);
public enableNotifications(channel: string) {
this.setNotificationEnabled(channel, true);
this.enableSound()
}
public disableNotifications(server: string) {
this.setNotificationEnabled(server, false);
public disableNotifications(channel: string) {
this.setNotificationEnabled(channel, false);
}
private setNotificationEnabled(server: string, enabled: boolean) {
private setNotificationEnabled(channel: string, enabled: boolean) {
const notificationSettings = {
...this._notificationEnabled(),
[server]: enabled
[channel]: enabled
};
this._notificationEnabled.set(notificationSettings);

View File

@ -4,7 +4,7 @@ import {EventSourcePolyfill} from 'event-source-polyfill';
import {ChatMessage} from '@pages/altitude/chat/objects/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 {ChatServer} from '@pages/altitude/chat/objects/chat-server.object';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
interface SsePayloadEvent {
@ -17,11 +17,18 @@ export class ChatService implements OnDestroy {
private readonly notificationService: NotificationService = inject(NotificationService);
private readonly authService: AuthService = inject(AuthService)
private readonly _messages = signal<ChatMessage[]>([])
public readonly messages = computed(() => this._messages().filter((message) => message.server === this._selectedServer()).sort((a, b) => b.timestamp - a.timestamp));
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()
private readonly _channels = signal<ChatChannel[]>([])
public readonly channels = computed(() => this._channels().sort((a, b) => a.name.localeCompare(b.name)));
private readonly _selectedChannel = signal<ChatChannel | null>(null);
public readonly selectedChannel = this._selectedChannel.asReadonly()
public readonly messages = computed(() => {
const selected = this._selectedChannel();
if (!selected) return [];
return this._messages()
.filter((message) => message.channelName === selected.name && message.channelType === selected.type)
.sort((a, b) => b.timestamp - a.timestamp);
});
connect() {
if (this.eventSource) {
@ -50,14 +57,17 @@ export class ChatService implements OnDestroy {
this.on(source, 'chat', (event) => {
const chatMessages = this.processChatMessages(event);
chatMessages.forEach((message) => {
const find = this.servers().find(server => message.server === server.server && message.server !== this._selectedServer());
const selected = this._selectedChannel();
const isSelected = selected && selected.name === message.channelName && selected.type === message.channelType;
const find = this._channels().find(channel => message.channelName === channel.name && message.channelType === channel.type && !isSelected);
if (find) {
find.unreadMessages++;
}
})
new Set(chatMessages.map(message => message.server))
.forEach((server) => {
this.notificationService.notify(server);
new Set(chatMessages.map(message => `${message.channelType}:${message.channelName}`))
.forEach((channelKeyStr) => {
this.notificationService.notify(channelKeyStr);
});
});
@ -68,30 +78,56 @@ export class ChatService implements OnDestroy {
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)),
}));
const messages: ChatMessage[] = raw.map((m) => {
const message: ChatMessage = {
...m,
messageJson: normalizeComponent(JSON.parse(m.messageJson)),
};
const channelKey = this.getChannelKey(message);
message.channelName = channelKey.name;
message.channelType = channelKey.type;
return message;
});
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);
messages.forEach((message) => {
const found = this._channels().find(existing => existing.name === message.channelName && existing.type === message.channelType);
if (!found) {
this._servers.update((old) => [...old, {server: server, unreadMessages: 0}]);
this._channels.update((old) => [...old, {
name: message.channelName!,
type: message.channelType!,
unreadMessages: 0
}]);
}
})
return messages;
}
public selectServer(server: string) {
this._selectedServer.set(server);
this._servers.update((old) =>
old.map((chatServer) =>
chatServer.server === server
? {...chatServer, unreadMessages: 0}
: chatServer
private getChannelKey(message: ChatMessage): { name: string, type: ChatChannel['type'] } {
if (message.type === 'GAC') {
return {name: 'Global Admin Chat', type: 'GAC'};
}
if (message.type === 'PARTY') {
return {name: message.channel, type: 'PARTY'};
}
if (message.type === 'MSG') {
const uuid = this.authService.getUuid();
if (message.uuid === uuid) {
return {name: message.receiver, type: 'DM'};
}
return {name: message.uuid, type: 'DM'};
}
return {name: message.server, type: 'SERVER'};
}
public selectChannel(channel: ChatChannel) {
this._selectedChannel.set(channel);
this._channels.update((old) =>
old.map((c) =>
c.name === channel.name && c.type === channel.type
? {...c, unreadMessages: 0}
: c
)
);
}