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;