diff --git a/frontend/src/app/pages/altitude/chat/chat.component.html b/frontend/src/app/pages/altitude/chat/chat.component.html
index cc26fb3..b0ce391 100644
--- a/frontend/src/app/pages/altitude/chat/chat.component.html
+++ b/frontend/src/app/pages/altitude/chat/chat.component.html
@@ -1 +1,18 @@
-
chat works!
+
+
+
+
Chat
+
+
+
+
+
+ @for (message of messages(); track message.timestamp) {
+
+
+
+ }
+
+
+
diff --git a/frontend/src/app/pages/altitude/chat/chat.component.ts b/frontend/src/app/pages/altitude/chat/chat.component.ts
index 8163e1b..f1ab55f 100644
--- a/frontend/src/app/pages/altitude/chat/chat.component.ts
+++ b/frontend/src/app/pages/altitude/chat/chat.component.ts
@@ -1,32 +1,42 @@
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 {JsonPipe} from '@angular/common';
@Component({
selector: 'app-chat',
- imports: [],
+ imports: [
+ HeaderComponent,
+ MiniMessageComponent,
+ JsonPipe
+ ],
templateUrl: './chat.component.html',
- styleUrl: './chat.component.scss'
+ styleUrl: './chat.component.scss',
+ providers: [MiniMessageInteractionService]
})
export class ChatComponent implements OnInit, OnDestroy {
private sub?: Subscription;
- private readonly liveEvents: ChatService = inject(ChatService)
+ private readonly chatService: ChatService = inject(ChatService)
+ protected readonly messages = this.chatService.messages;
- ngOnInit(): void {
- this.liveEvents.connect();
-
- this.sub = this.liveEvents.onEvent().subscribe(event => {
- //TODO enums for event types
- if (event.type === 'chat') {
- console.log(event.data)
- } else if (event.type === 'connect') {
- console.log(event.data)
+ constructor(private interaction: MiniMessageInteractionService) {
+ interaction.clicks$.subscribe(({action, value}) => {
+ if (action === 'run_command') {
+ alert(value);
}
});
}
+ ngOnInit(): void {
+ this.chatService.connect();
+
+ }
+
ngOnDestroy(): void {
this.sub?.unsubscribe();
- this.liveEvents.disconnect(); // triggers onCompletion server-side, cleans up the emitter
+ this.chatService.disconnect(); // triggers onCompletion server-side, cleans up the emitter
}
}
diff --git a/frontend/src/app/pages/altitude/chat/mini-message/mini-message-html.util.ts b/frontend/src/app/pages/altitude/chat/mini-message/mini-message-html.util.ts
new file mode 100644
index 0000000..4c38381
--- /dev/null
+++ b/frontend/src/app/pages/altitude/chat/mini-message/mini-message-html.util.ts
@@ -0,0 +1,57 @@
+import { MiniMessageComponent, ResolvedStyle } from './mini-message.types';
+import { hoverEventToTitle, mergeStyle, ownText, styleToNgStyle } from './mini-message.util';
+
+/**
+ * Escapes text for safe insertion into HTML. Always used on raw text content —
+ * never skip this when building the string yourself.
+ */
+function escapeHtml(input: string): string {
+ return input
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function ngStyleToCss(style: Record): string {
+ return Object.entries(style)
+ .map(([key, value]) => `${key}:${value}`)
+ .join(';');
+}
+
+/**
+ * Renders a component-JSON tree to an HTML string, e.g. for binding via
+ * [innerHTML]="miniMessageToHtml(json) | ..." with Angular's DomSanitizer, or for use
+ * outside Angular entirely. Prefer the component when you need click/hover
+ * interactivity or the obfuscated animation — this string form is static (obfuscated text
+ * renders as a fixed placeholder rather than an animated one).
+ */
+export function miniMessageToHtml(
+ node: MiniMessageComponent,
+ parentStyle?: ResolvedStyle
+): string {
+ const style = mergeStyle(parentStyle, node);
+ const css = ngStyleToCss(styleToNgStyle(style));
+ const text = escapeHtml(ownText(node));
+ const title = hoverEventToTitle(node.hoverEvent);
+ const titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
+ const insertionAttr = node.insertion ? ` data-insertion="${escapeHtml(node.insertion)}"` : '';
+
+ let ownHtml: string;
+ if (node.clickEvent?.action === 'open_url') {
+ ownHtml = `${text}`;
+ } else {
+ const clickAttrs = node.clickEvent
+ ? ` class="mm-node mm-clickable" data-click-action="${node.clickEvent.action}" data-click-value="${escapeHtml(
+ node.clickEvent.value
+ )}"`
+ : ' class="mm-node"';
+ ownHtml = `${text}`;
+ }
+
+ const childrenHtml = (node.extra ?? []).map((child) => miniMessageToHtml(child, style)).join('');
+ return ownHtml + childrenHtml;
+}
diff --git a/frontend/src/app/pages/altitude/chat/mini-message/mini-message-interaction.service.ts b/frontend/src/app/pages/altitude/chat/mini-message/mini-message-interaction.service.ts
new file mode 100644
index 0000000..e8953af
--- /dev/null
+++ b/frontend/src/app/pages/altitude/chat/mini-message/mini-message-interaction.service.ts
@@ -0,0 +1,19 @@
+import { Injectable } from '@angular/core';
+import { Subject } from 'rxjs';
+import { MiniMessageClickPayload } from './mini-message.types';
+
+/**
+ * Because renders itself recursively for `extra` children, a plain
+ * @Output() would need to be manually re-emitted at every level. Instead, every
+ * instance injects this single service (provided once at the root usage) and pushes
+ * clicks through it. Subscribe to `clicks$` wherever you render the top-level component.
+ */
+@Injectable()
+export class MiniMessageInteractionService {
+ private readonly clickSubject = new Subject();
+ readonly clicks$ = this.clickSubject.asObservable();
+
+ emitClick(payload: MiniMessageClickPayload): void {
+ this.clickSubject.next(payload);
+ }
+}
diff --git a/frontend/src/app/pages/altitude/chat/mini-message/mini-message.component.ts b/frontend/src/app/pages/altitude/chat/mini-message/mini-message.component.ts
new file mode 100644
index 0000000..54af95c
--- /dev/null
+++ b/frontend/src/app/pages/altitude/chat/mini-message/mini-message.component.ts
@@ -0,0 +1,110 @@
+import {CommonModule} from '@angular/common';
+import {Component, Input, Optional} from '@angular/core';
+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.
+ *
+ * Usage:
+ * providers: [MiniMessageInteractionService] // once, at the root that hosts
+ *
+ *
+ * Then subscribe to click events (run_command / suggest_command / copy_to_clipboard /
+ * change_page) via the injected MiniMessageInteractionService.clicks$; open_url is handled
+ * natively as a real link so no subscription is needed for that case.
+ */
+@Component({
+ selector: 'mini-message',
+ standalone: true,
+ imports: [CommonModule, ObfuscatedDirective],
+ template: `
+
+ {{ text }}
+
+ {{ text }}
+
+
+
+ `,
+ styles: [
+ `
+ .mm-node {
+ white-space: pre-wrap;
+ }
+
+ .mm-clickable {
+ cursor: pointer;
+ }
+
+ .mm-link {
+ text-decoration: inherit;
+ cursor: pointer;
+ }
+ `,
+ ],
+})
+export class MiniMessageComponent {
+ @Input() node!: MiniMessageNode;
+ @Input() parentStyle?: ResolvedStyle;
+
+ constructor(@Optional() private readonly interaction: MiniMessageInteractionService | null) {
+ }
+
+ get style(): ResolvedStyle {
+ return mergeStyle(this.parentStyle, this.node);
+ }
+
+ get ngStyle(): Record {
+ return styleToNgStyle(this.style);
+ }
+
+ get text(): string {
+ return ownText(this.node);
+ }
+
+ get hoverTitle(): string | null {
+ return hoverEventToTitle(this.node.hoverEvent);
+ }
+
+ get isUrlLink(): boolean {
+ return this.node.clickEvent?.action === 'open_url';
+ }
+
+ get hasNonUrlClick(): boolean {
+ return !!this.node.clickEvent && this.node.clickEvent.action !== 'open_url';
+ }
+
+ onClick(): void {
+ const click = this.node.clickEvent;
+ if (!click) return;
+
+ if (click.action === 'copy_to_clipboard') {
+ navigator.clipboard?.writeText(click.value);
+ }
+
+ this.interaction?.emitClick({action: click.action, value: click.value});
+ }
+}
diff --git a/frontend/src/app/pages/altitude/chat/mini-message/mini-message.pipe.ts b/frontend/src/app/pages/altitude/chat/mini-message/mini-message.pipe.ts
new file mode 100644
index 0000000..8c8d367
--- /dev/null
+++ b/frontend/src/app/pages/altitude/chat/mini-message/mini-message.pipe.ts
@@ -0,0 +1,25 @@
+import { Pipe, PipeTransform } from '@angular/core';
+import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
+import { MiniMessageComponent } from './mini-message.types';
+import { miniMessageToHtml } from './mini-message-html.util';
+
+/**
+ *
+ *
+ * Simpler than but static: no obfuscation animation, and clicks on
+ * non-URL actions (run_command, etc.) need to be wired up yourself via a (click) handler
+ * on the container using event delegation, reading data-click-action / data-click-value
+ * off event.target.
+ */
+@Pipe({
+ name: 'miniMessage',
+ standalone: true,
+})
+export class MiniMessagePipe implements PipeTransform {
+ constructor(private readonly sanitizer: DomSanitizer) {}
+
+ transform(node: MiniMessageComponent | null | undefined): SafeHtml {
+ if (!node) return '';
+ return this.sanitizer.bypassSecurityTrustHtml(miniMessageToHtml(node));
+ }
+}
diff --git a/frontend/src/app/pages/altitude/chat/mini-message/mini-message.types.ts b/frontend/src/app/pages/altitude/chat/mini-message/mini-message.types.ts
new file mode 100644
index 0000000..10f7b87
--- /dev/null
+++ b/frontend/src/app/pages/altitude/chat/mini-message/mini-message.types.ts
@@ -0,0 +1,90 @@
+/**
+ * Types for Paper/Adventure "Component" JSON — this is the format you get when a
+ * MiniMessage string is parsed on the server and serialized with GsonComponentSerializer.
+ * By the time it reaches your frontend, tags like , , , etc.
+ * have already been resolved into a tree of styled text runs (this is why you don't see
+ * "" anywhere in the JSON — you see {"color":"red", ...} instead).
+ */
+
+export type ClickAction =
+ | 'open_url'
+ | 'open_file'
+ | 'run_command'
+ | 'suggest_command'
+ | 'change_page'
+ | 'copy_to_clipboard';
+
+export interface MiniMessageClickEvent {
+ action: ClickAction;
+ value: string;
+}
+
+export type HoverAction = 'show_text' | 'show_item' | 'show_entity';
+
+export interface MiniMessageHoverEvent {
+ action: HoverAction;
+ /** Modern (1.16+) format */
+ value?: MiniMessageComponent | string;
+ /** Legacy format some plugins/serializers still emit for show_text */
+ contents?: MiniMessageComponent | string | Record;
+}
+
+/**
+ * A single node in the component tree. Every field is optional because children only need
+ * to specify what they *override* — everything else is inherited from the parent (color,
+ * bold, italic, underlined, strikethrough, obfuscated, font, clickEvent, hoverEvent, insertion).
+ */
+export interface MiniMessageComponent {
+ /** Plain text component */
+ text?: string;
+
+ /** Translatable component (e.g. vanilla "chat.type.text") */
+ translate?: string;
+ /** Substitution arguments for `translate`, in order */
+ with?: MiniMessageComponent[];
+ /** Fallback text if no translation is registered for `translate` */
+ fallback?: string;
+
+ /** Keybind component, e.g. "key.jump" */
+ keybind?: string;
+
+ /** Score component (scoreboard value) */
+ score?: { name: string; objective: string; value?: string };
+
+ /** Selector component, e.g. "@a" */
+ selector?: string;
+
+ /** Named color ("red", "aqua", ...), hex ("#RRGGBB"), or "reset" */
+ color?: string;
+ bold?: boolean;
+ italic?: boolean;
+ underlined?: boolean;
+ strikethrough?: boolean;
+ obfuscated?: boolean;
+ font?: string;
+
+ /** Text inserted into chat input on shift-click (Minecraft-client-only concept) */
+ insertion?: string;
+
+ clickEvent?: MiniMessageClickEvent;
+ hoverEvent?: MiniMessageHoverEvent;
+
+ /** Child components, styled relative to this node */
+ extra?: MiniMessageComponent[];
+}
+
+/** Fully resolved style at a given node, after inheriting from all ancestors */
+export interface ResolvedStyle {
+ color?: string;
+ bold: boolean;
+ italic: boolean;
+ underlined: boolean;
+ strikethrough: boolean;
+ obfuscated: boolean;
+ font?: string;
+}
+
+export interface MiniMessageClickPayload {
+ action: ClickAction;
+ value: string;
+}
diff --git a/frontend/src/app/pages/altitude/chat/mini-message/mini-message.util.ts b/frontend/src/app/pages/altitude/chat/mini-message/mini-message.util.ts
new file mode 100644
index 0000000..6fd929f
--- /dev/null
+++ b/frontend/src/app/pages/altitude/chat/mini-message/mini-message.util.ts
@@ -0,0 +1,134 @@
+import { MiniMessageComponent, MiniMessageHoverEvent, ResolvedStyle } from './mini-message.types';
+
+/** The 16 legacy Minecraft color names, as used by NamedTextColor / MiniMessage */
+const NAMED_COLORS: Record = {
+ black: '#000000',
+ dark_blue: '#0000AA',
+ dark_green: '#00AA00',
+ dark_aqua: '#00AAAA',
+ dark_red: '#AA0000',
+ dark_purple: '#AA00AA',
+ gold: '#FFAA00',
+ gray: '#AAAAAA',
+ dark_gray: '#555555',
+ blue: '#5555FF',
+ green: '#55FF55',
+ aqua: '#55FFFF',
+ red: '#FF5555',
+ light_purple: '#FF55FF',
+ yellow: '#FFFF55',
+ white: '#FFFFFF',
+};
+
+/** Some servers map custom fonts to real web fonts; extend this as needed */
+const FONT_MAP: Record = {
+ 'minecraft:default': 'inherit',
+ 'minecraft:uniform': 'inherit',
+ 'minecraft:alt': "'Minecraft Enchantment', inherit",
+};
+
+export const DEFAULT_STYLE: ResolvedStyle = {
+ color: undefined,
+ bold: false,
+ italic: false,
+ underlined: false,
+ strikethrough: false,
+ obfuscated: false,
+ font: undefined,
+};
+
+/**
+ * Resolves a color field ("red" | "#RRGGBB" | "reset" | undefined) to a CSS color or
+ * undefined (meaning: unset / inherit).
+ */
+export function resolveColor(color: string | undefined): string | undefined {
+ if (!color) return undefined;
+ if (color === 'reset') return undefined;
+ if (color.startsWith('#')) return color;
+ return NAMED_COLORS[color] ?? undefined;
+}
+
+function resolveFont(font: string | undefined): string | undefined {
+ if (!font) return undefined;
+ return FONT_MAP[font] ?? undefined;
+}
+
+/**
+ * Merges a node's own style declarations on top of its parent's resolved style.
+ * Adventure semantics: a field that is present on the node overrides the parent
+ * (including explicit `false`); a field that is absent (undefined) is inherited.
+ */
+export function mergeStyle(parent: ResolvedStyle | undefined, node: MiniMessageComponent): ResolvedStyle {
+ const base = parent ?? DEFAULT_STYLE;
+ return {
+ color: node.color !== undefined ? resolveColor(node.color) : base.color,
+ bold: node.bold !== undefined ? node.bold : base.bold,
+ italic: node.italic !== undefined ? node.italic : base.italic,
+ underlined: node.underlined !== undefined ? node.underlined : base.underlined,
+ strikethrough: node.strikethrough !== undefined ? node.strikethrough : base.strikethrough,
+ obfuscated: node.obfuscated !== undefined ? node.obfuscated : base.obfuscated,
+ font: node.font !== undefined ? resolveFont(node.font) : base.font,
+ };
+}
+
+/** Converts a resolved style into an Angular [ngStyle]-compatible object */
+export function styleToNgStyle(style: ResolvedStyle): Record {
+ const decorations: string[] = [];
+ if (style.underlined) decorations.push('underline');
+ if (style.strikethrough) decorations.push('line-through');
+
+ return {
+ color: style.color ?? 'inherit',
+ 'font-weight': style.bold ? 'bold' : 'normal',
+ 'font-style': style.italic ? 'italic' : 'normal',
+ 'text-decoration-line': decorations.length ? decorations.join(' ') : 'none',
+ 'font-family': style.font ?? 'inherit',
+ };
+}
+
+/** Returns the node's own displayable text (does not recurse into `extra`) */
+export function ownText(node: MiniMessageComponent): string {
+ if (node.text !== undefined) return node.text;
+ if (node.translate !== undefined) return node.fallback ?? `%${node.translate}%`;
+ if (node.keybind !== undefined) return `[${node.keybind}]`;
+ if (node.selector !== undefined) return node.selector;
+ if (node.score !== undefined) return node.score.value ?? '';
+ return '';
+}
+
+/** Recursively flattens a component tree (including `extra`) into plain text */
+export function extractPlainText(node: MiniMessageComponent | string | undefined): string {
+ if (node === undefined) return '';
+ if (typeof node === 'string') return node;
+ let out = ownText(node);
+ if (node.extra) {
+ for (const child of node.extra) {
+ out += extractPlainText(child);
+ }
+ }
+ return out;
+}
+
+/** Extracts plain text to use as a native `title` tooltip for a hoverEvent */
+export function hoverEventToTitle(hover: MiniMessageHoverEvent | undefined): string | null {
+ if (!hover) return null;
+ if (hover.action === 'show_text') {
+ const source = hover.value ?? hover.contents;
+ if (typeof source === 'string') return source;
+ if (source && typeof source === 'object' && ('text' in source || 'translate' in source || 'extra' in source)) {
+ return extractPlainText(source as MiniMessageComponent);
+ }
+ return null;
+ }
+ if (hover.action === 'show_item') {
+ const contents = hover.contents as Record | undefined;
+ const id = (contents?.['id'] as string) ?? (hover.value as unknown as { id?: string })?.id;
+ return id ? `Item: ${id}` : 'Item';
+ }
+ if (hover.action === 'show_entity') {
+ const contents = hover.contents as Record | undefined;
+ const type = (contents?.['type'] as string) ?? '';
+ return type ? `Entity: ${type}` : 'Entity';
+ }
+ return null;
+}
diff --git a/frontend/src/app/pages/altitude/chat/mini-message/obfuscated.directive.ts b/frontend/src/app/pages/altitude/chat/mini-message/obfuscated.directive.ts
new file mode 100644
index 0000000..25125b2
--- /dev/null
+++ b/frontend/src/app/pages/altitude/chat/mini-message/obfuscated.directive.ts
@@ -0,0 +1,65 @@
+import { Directive, ElementRef, Input, OnChanges, OnDestroy } from '@angular/core';
+
+const OBFUSCATION_CHARS =
+ '!@#$%^&*()_+-=[]{}|;:,.<>?/~`ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+/**
+ * Apply as `[mmObfuscated]="style.obfuscated"` on an element whose textContent is the
+ * text to obfuscate. While active, cycles every character (except spaces) through random
+ * glyphs, mimicking Minecraft's "magic" formatting. The real text is preserved in an
+ * aria-label so it stays accessible to screen readers, and is restored to the DOM when
+ * obfuscation is turned off.
+ */
+@Directive({
+ selector: '[mmObfuscated]',
+ standalone: true,
+})
+export class ObfuscatedDirective implements OnChanges, OnDestroy {
+ @Input('mmObfuscated') active = false;
+
+ private original: string | null = null;
+ private intervalId: ReturnType | undefined;
+
+ constructor(private readonly el: ElementRef) {}
+
+ ngOnChanges(): void {
+ if (this.original === null) {
+ this.original = this.el.nativeElement.textContent ?? '';
+ this.el.nativeElement.setAttribute('aria-label', this.original);
+ }
+ this.sync();
+ }
+
+ ngOnDestroy(): void {
+ this.stop();
+ }
+
+ private sync(): void {
+ if (this.active && !this.intervalId) {
+ this.start();
+ } else if (!this.active && this.intervalId) {
+ this.stop();
+ }
+ }
+
+ private start(): void {
+ this.intervalId = setInterval(() => {
+ const text = this.original ?? '';
+ const scrambled = text
+ .split('')
+ .map((ch) => (ch === ' ' ? ' ' : OBFUSCATION_CHARS[Math.floor(Math.random() * OBFUSCATION_CHARS.length)]))
+ .join('');
+ this.el.nativeElement.textContent = scrambled;
+ }, 50);
+ }
+
+ private stop(): void {
+ if (this.intervalId) {
+ clearInterval(this.intervalId);
+ this.intervalId = undefined;
+ }
+ if (this.original !== null) {
+ this.el.nativeElement.textContent = this.original;
+ }
+ }
+}
diff --git a/frontend/src/app/pages/altitude/chat/objects/chat-message.object.ts b/frontend/src/app/pages/altitude/chat/objects/chat-message.object.ts
new file mode 100644
index 0000000..46c7f40
--- /dev/null
+++ b/frontend/src/app/pages/altitude/chat/objects/chat-message.object.ts
@@ -0,0 +1,11 @@
+import {MiniMessageComponent} from '@pages/altitude/chat/mini-message/mini-message.types';
+
+export interface ChatMessage {
+ uuid: string;
+ timestamp: number;
+ server: string;
+ //TODO [Stijn] [2026-07-18]: Handle channel types
+ //channel: Channel;
+ messageJson: MiniMessageComponent;
+ notBlocked: boolean;
+}
diff --git a/frontend/src/app/pages/altitude/chat/objects/raw-chat-message.object.ts b/frontend/src/app/pages/altitude/chat/objects/raw-chat-message.object.ts
new file mode 100644
index 0000000..327b81f
--- /dev/null
+++ b/frontend/src/app/pages/altitude/chat/objects/raw-chat-message.object.ts
@@ -0,0 +1,5 @@
+import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
+
+export interface RawChatMessage extends Omit {
+ messageJson: string;
+}
diff --git a/frontend/src/app/pages/altitude/chat/service/chat.service.ts b/frontend/src/app/pages/altitude/chat/service/chat.service.ts
index 0cb8f4c..c3fa31e 100644
--- a/frontend/src/app/pages/altitude/chat/service/chat.service.ts
+++ b/frontend/src/app/pages/altitude/chat/service/chat.service.ts
@@ -1,7 +1,9 @@
-import {inject, Injectable, OnDestroy} from '@angular/core';
-import {Subject} from 'rxjs';
+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';
export interface ChatEvent {
type: string;
@@ -15,8 +17,9 @@ interface SsePayloadEvent {
@Injectable({providedIn: 'root'})
export class ChatService implements OnDestroy {
private eventSource?: EventSourcePolyfill;
- private events$ = new Subject();
private readonly authService: AuthService = inject(AuthService)
+ private readonly _messages = signal([])
+ public readonly messages = this._messages.asReadonly()
connect() {
if (this.eventSource) {
@@ -39,11 +42,21 @@ export class ChatService implements OnDestroy {
this.eventSource = source;
this.on(source, 'connected', (event) => {
- console.log('SSE connected:', event.data);
+ 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]);
});
this.on(source, 'chat-message', (event) => {
- this.events$.next({type: 'chat-message', data: JSON.parse(event.data)});
+ 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]);
});
source.onerror = (err) => {
@@ -58,11 +71,6 @@ export class ChatService implements OnDestroy {
.addEventListener(eventName, handler);
}
-
- onEvent() {
- return this.events$.asObservable();
- }
-
disconnect(): void {
this.eventSource?.close();
this.eventSource = undefined;