Introduce MiniMessage rendering system for structured chat formatting and interaction
This commit is contained in:
parent
4708da0c43
commit
4b6a491919
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,42 @@
|
||||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||||
import {Subscription} from 'rxjs';
|
import {Subscription} from 'rxjs';
|
||||||
import {ChatService} from '@pages/altitude/chat/service/chat.service';
|
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({
|
@Component({
|
||||||
selector: 'app-chat',
|
selector: 'app-chat',
|
||||||
imports: [],
|
imports: [
|
||||||
|
HeaderComponent,
|
||||||
|
MiniMessageComponent,
|
||||||
|
JsonPipe
|
||||||
|
],
|
||||||
templateUrl: './chat.component.html',
|
templateUrl: './chat.component.html',
|
||||||
styleUrl: './chat.component.scss'
|
styleUrl: './chat.component.scss',
|
||||||
|
providers: [MiniMessageInteractionService]
|
||||||
})
|
})
|
||||||
export class ChatComponent implements OnInit, OnDestroy {
|
export class ChatComponent implements OnInit, OnDestroy {
|
||||||
private sub?: Subscription;
|
private sub?: Subscription;
|
||||||
private readonly liveEvents: ChatService = inject(ChatService)
|
private readonly chatService: ChatService = inject(ChatService)
|
||||||
|
protected readonly messages = this.chatService.messages;
|
||||||
|
|
||||||
ngOnInit(): void {
|
constructor(private interaction: MiniMessageInteractionService) {
|
||||||
this.liveEvents.connect();
|
interaction.clicks$.subscribe(({action, value}) => {
|
||||||
|
if (action === 'run_command') {
|
||||||
this.sub = this.liveEvents.onEvent().subscribe(event => {
|
alert(value);
|
||||||
//TODO enums for event types
|
|
||||||
if (event.type === 'chat') {
|
|
||||||
console.log(event.data)
|
|
||||||
} else if (event.type === 'connect') {
|
|
||||||
console.log(event.data)
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.chatService.connect();
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.sub?.unsubscribe();
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
|
||||||
|
|
||||||
|
export interface RawChatMessage extends Omit<ChatMessage, 'messageJson'> {
|
||||||
|
messageJson: string;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import {inject, Injectable, OnDestroy} from '@angular/core';
|
import {inject, Injectable, OnDestroy, signal} from '@angular/core';
|
||||||
import {Subject} from 'rxjs';
|
|
||||||
import {AuthService} from '@services/auth.service';
|
import {AuthService} from '@services/auth.service';
|
||||||
import {EventSourcePolyfill} from 'event-source-polyfill';
|
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 {
|
export interface ChatEvent {
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -15,8 +17,9 @@ interface SsePayloadEvent {
|
||||||
@Injectable({providedIn: 'root'})
|
@Injectable({providedIn: 'root'})
|
||||||
export class ChatService implements OnDestroy {
|
export class ChatService implements OnDestroy {
|
||||||
private eventSource?: EventSourcePolyfill;
|
private eventSource?: EventSourcePolyfill;
|
||||||
private events$ = new Subject<ChatEvent>();
|
|
||||||
private readonly authService: AuthService = inject(AuthService)
|
private readonly authService: AuthService = inject(AuthService)
|
||||||
|
private readonly _messages = signal<ChatMessage[]>([])
|
||||||
|
public readonly messages = this._messages.asReadonly()
|
||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
if (this.eventSource) {
|
if (this.eventSource) {
|
||||||
|
|
@ -39,11 +42,21 @@ export class ChatService implements OnDestroy {
|
||||||
this.eventSource = source;
|
this.eventSource = source;
|
||||||
|
|
||||||
this.on(source, 'connected', (event) => {
|
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.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) => {
|
source.onerror = (err) => {
|
||||||
|
|
@ -58,11 +71,6 @@ export class ChatService implements OnDestroy {
|
||||||
.addEventListener(eventName, handler);
|
.addEventListener(eventName, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onEvent() {
|
|
||||||
return this.events$.asObservable();
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
this.eventSource?.close();
|
this.eventSource?.close();
|
||||||
this.eventSource = undefined;
|
this.eventSource = undefined;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user