Introduce MiniMessage rendering system for structured chat formatting and interaction

This commit is contained in:
akastijn 2026-07-19 00:59:04 +02:00
parent 4708da0c43
commit 4b6a491919
12 changed files with 575 additions and 24 deletions

View File

@ -1 +1,18 @@
<p>chat works!</p>
<ng-container>
<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>
</div>
</app-header>
<main>
<section class="darkmodeSection">
@for (message of messages(); track message.timestamp) {
<p>
<mini-message [node]="message.messageJson"></mini-message>
</p>
}
</section>
</main>
</ng-container>

View File

@ -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
}
}

View File

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function ngStyleToCss(style: Record<string, string>): 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 <mini-message> 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 = `<a href="${escapeHtml(
node.clickEvent.value
)}" target="_blank" rel="noopener noreferrer" class="mm-node mm-link" style="${css}"${titleAttr}${insertionAttr}>${text}</a>`;
} 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 = `<span${clickAttrs} style="${css}"${titleAttr}${insertionAttr}>${text}</span>`;
}
const childrenHtml = (node.extra ?? []).map((child) => miniMessageToHtml(child, style)).join('');
return ownHtml + childrenHtml;
}

View File

@ -0,0 +1,19 @@
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
import { MiniMessageClickPayload } from './mini-message.types';
/**
* Because <mini-message> 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<MiniMessageClickPayload>();
readonly clicks$ = this.clickSubject.asObservable();
emitClick(payload: MiniMessageClickPayload): void {
this.clickSubject.next(payload);
}
}

View File

@ -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 <mini-message>
* <mini-message [node]="myComponentJson"></mini-message>
*
* 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: `
<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;
@Input() parentStyle?: ResolvedStyle;
constructor(@Optional() private readonly interaction: MiniMessageInteractionService | null) {
}
get style(): ResolvedStyle {
return mergeStyle(this.parentStyle, this.node);
}
get ngStyle(): Record<string, string> {
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});
}
}

View File

@ -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';
/**
* <div [innerHTML]="componentJson | miniMessage"></div>
*
* Simpler than <mini-message> 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));
}
}

View File

@ -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 <gradient>, <rainbow>, <color>, etc.
* have already been resolved into a tree of styled text runs (this is why you don't see
* "<red>" 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<string, unknown>;
}
/**
* 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;
}

View File

@ -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<string, string> = {
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<string, string> = {
'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<string, string> {
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<string, unknown> | 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<string, unknown> | undefined;
const type = (contents?.['type'] as string) ?? '';
return type ? `Entity: ${type}` : 'Entity';
}
return null;
}

View File

@ -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<typeof setInterval> | undefined;
constructor(private readonly el: ElementRef<HTMLElement>) {}
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;
}
}
}

View File

@ -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;
}

View File

@ -0,0 +1,5 @@
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
export interface RawChatMessage extends Omit<ChatMessage, 'messageJson'> {
messageJson: string;
}

View File

@ -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<ChatEvent>();
private readonly authService: AuthService = inject(AuthService)
private readonly _messages = signal<ChatMessage[]>([])
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;