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 {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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user