Compare commits

..

No commits in common. "90eb40caa7f9a097bbf6d74ee46510e559b73ee3" and "819739ff609b2e60269471d85e059ddb1e8e4628" have entirely different histories.

10 changed files with 61 additions and 86 deletions

View File

@ -1,11 +1,10 @@
@if (selectedChannel(); as chatChannel) { @if (selectedChannel(); as chatChannel) {
<div class="full-width" <div class="full-width"
[style.background-color]="getServerColor(chatChannel.name)"> [style.background-color]="getServerColor(chatChannel.name ?? '')">
<span class="channel" [style.color]="getServerTextColor(chatChannel.name)"> <span class="channel" [style.color]="getServerTextColor(chatChannel.name ?? '')">
@for (segment of getDisplayName(chatChannel); track $index) { @for (segment of getDisplayName(chatChannel); track $index) {
<span [style.color]="segment.color">{{ segment.text }}</span> <span [style.color]="segment.color">{{ segment.text }}</span>
} }
<span class="message-count">({{ chatChannel.totalMessages }})</span>
</span> </span>
<div class="notification-icon"> <div class="notification-icon">
@if (notificationEnabled()) { @if (notificationEnabled()) {

View File

@ -13,10 +13,6 @@
letter-spacing: 1px; letter-spacing: 1px;
font-weight: bold; font-weight: bold;
padding-left: 10px; padding-left: 10px;
.message-count {
padding-left: 5px;
}
} }
.notification-icon { .notification-icon {

View File

@ -4,11 +4,7 @@
<span class="date-time" [class.blocked]="!message.notBlocked"> <span class="date-time" [class.blocked]="!message.notBlocked">
[{{ message.timestamp | date: 'mediumTime' }}] [{{ message.timestamp | date: 'mediumTime' }}]
</span> </span>
@defer (on viewport; prefetch on idle) {
<mini-message [node]="message.messageJson"></mini-message> <mini-message [node]="message.messageJson"></mini-message>
} @placeholder {
<span></span>
}
</p> </p>
} }
</div> </div>

View File

@ -1,28 +1,31 @@
@if (node) { @if (node) {
@if (node.isUrlLink) { <ng-container>
<a <a
[style]="node.ngStyle" *ngIf="isUrlLink; else plain"
[mmObfuscated]="node.style.obfuscated" [ngStyle]="ngStyle"
[attr.title]="node.hoverTitle" [mmObfuscated]="style.obfuscated"
[attr.title]="hoverTitle"
[attr.href]="node.clickEvent?.value" [attr.href]="node.clickEvent?.value"
[attr.data-insertion]="node.insertion || null" [attr.data-insertion]="node.insertion || null"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="mm-node mm-link" class="mm-node mm-link"
>{{ node.text }}</a> >{{ text }}</a>
} @else {
<ng-template #plain>
<span <span
[style]="node.ngStyle" [ngStyle]="ngStyle"
[mmObfuscated]="node.style.obfuscated" [mmObfuscated]="style.obfuscated"
[attr.title]="node.hoverTitle" [attr.title]="hoverTitle"
[attr.data-insertion]="node.insertion || null" [attr.data-insertion]="node.insertion || null"
[class.mm-clickable]="node.hasNonUrlClick" [class.mm-clickable]="hasNonUrlClick"
class="mm-node" class="mm-node"
(click)="onClick()" (click)="onClick()"
>{{ node.text }}</span> >{{ text }}</span
} >
</ng-template>
@for (child of node.extra; track child) { @for (child of node.extra; track child) {
<mini-message [node]="child"></mini-message> <mini-message [node]="child" [parentStyle]="style"></mini-message>
} }
</ng-container>
} }

View File

@ -1,6 +1,7 @@
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import {Component, Input, Optional} from '@angular/core'; import {Component, Input, Optional} from '@angular/core';
import {ProcessedMiniMessageNode} from '../../mini-message/mini-message.types'; import {MiniMessageComponent as MiniMessageNode, ResolvedStyle} from '../../mini-message/mini-message.types';
import {hoverEventToTitle, mergeStyle, ownText, styleToNgStyle} from '../../mini-message/mini-message.util';
import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive'; import {ObfuscatedDirective} from '../../mini-message/obfuscated.directive';
import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service'; import {MiniMessageInteractionService} from '../../mini-message/mini-message-interaction.service';
@ -9,7 +10,7 @@ import {MiniMessageInteractionService} from '../../mini-message/mini-message-int
* *
* Usage: * Usage:
* providers: [MiniMessageInteractionService] // once, at the root that hosts <mini-message> * providers: [MiniMessageInteractionService] // once, at the root that hosts <mini-message>
* <mini-message [node]="myProcessedComponentJson"></mini-message> * <mini-message [node]="myComponentJson"></mini-message>
* *
* Then subscribe to click events (run_command / suggest_command / copy_to_clipboard / * Then subscribe to click events (run_command / suggest_command / copy_to_clipboard /
* change_page) via the injected MiniMessageInteractionService.clicks$; open_url is handled * change_page) via the injected MiniMessageInteractionService.clicks$; open_url is handled
@ -23,17 +24,42 @@ import {MiniMessageInteractionService} from '../../mini-message/mini-message-int
styleUrl: './mini-message.component.scss', styleUrl: './mini-message.component.scss',
}) })
export class MiniMessageComponent { export class MiniMessageComponent {
@Input() node!: ProcessedMiniMessageNode; @Input() node!: MiniMessageNode;
@Input() parentStyle?: ResolvedStyle;
constructor(@Optional() private readonly interaction: MiniMessageInteractionService | null) { 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 { onClick(): void {
const click = this.node.clickEvent; const click = this.node.clickEvent;
if (!click) return; if (!click) return;
if (click.action === 'copy_to_clipboard') { if (click.action === 'copy_to_clipboard') {
navigator.clipboard?.writeText(click.value).then(); navigator.clipboard?.writeText(click.value);
} }
this.interaction?.emitClick({action: click.action, value: click.value}); this.interaction?.emitClick({action: click.action, value: click.value});

View File

@ -73,19 +73,6 @@ export interface MiniMessageComponent {
extra?: MiniMessageComponent[]; extra?: MiniMessageComponent[];
} }
/** Fully resolved node, pre-computed for rendering */
export interface ProcessedMiniMessageNode {
text: string;
style: ResolvedStyle;
ngStyle: Record<string, string>;
hoverTitle: string | null;
isUrlLink: boolean;
hasNonUrlClick: boolean;
clickEvent?: MiniMessageClickEvent;
insertion?: string;
extra: ProcessedMiniMessageNode[];
}
/** Fully resolved style at a given node, after inheriting from all ancestors */ /** Fully resolved style at a given node, after inheriting from all ancestors */
export interface ResolvedStyle { export interface ResolvedStyle {
color?: string; color?: string;

View File

@ -1,9 +1,4 @@
import { import { MiniMessageComponent, MiniMessageHoverEvent, ResolvedStyle } from './mini-message.types';
MiniMessageComponent,
MiniMessageHoverEvent,
ProcessedMiniMessageNode,
ResolvedStyle
} from './mini-message.types';
/** The 16 legacy Minecraft color names, as used by NamedTextColor / MiniMessage */ /** The 16 legacy Minecraft color names, as used by NamedTextColor / MiniMessage */
const NAMED_COLORS: Record<string, string> = { const NAMED_COLORS: Record<string, string> = {
@ -137,25 +132,3 @@ export function hoverEventToTitle(hover: MiniMessageHoverEvent | undefined): str
} }
return null; return null;
} }
/**
* Pre-computes a component tree into a ProcessedMiniMessageNode tree.
* This resolves all styles, texts, and attributes once so they don't have to be
* re-computed on every render.
*/
export function precomputeNode(node: MiniMessageComponent, parentStyle?: ResolvedStyle): ProcessedMiniMessageNode {
const style = mergeStyle(parentStyle, node);
const isUrlLink = node.clickEvent?.action === 'open_url';
return {
text: ownText(node),
style,
ngStyle: styleToNgStyle(style),
hoverTitle: hoverEventToTitle(node.hoverEvent),
isUrlLink,
hasNonUrlClick: !!node.clickEvent && !isUrlLink,
clickEvent: node.clickEvent,
insertion: node.insertion,
extra: (node.extra ?? []).map((child) => precomputeNode(child, style)),
};
}

View File

@ -2,5 +2,4 @@ export interface ChatChannel {
name: string; name: string;
type: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY'; type: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY';
unreadMessages: number; unreadMessages: number;
totalMessages: number;
} }

View File

@ -1,4 +1,4 @@
import {ProcessedMiniMessageNode} from '@pages/altitude/chat/mini-message/mini-message.types'; import {MiniMessageComponent} from '@pages/altitude/chat/mini-message/mini-message.types';
export interface ChatMessage { export interface ChatMessage {
uuid: string; uuid: string;
@ -7,7 +7,7 @@ export interface ChatMessage {
type: string; type: string;
channel: string; channel: string;
receiver: string; receiver: string;
messageJson: ProcessedMiniMessageNode; messageJson: MiniMessageComponent;
notBlocked: boolean; notBlocked: boolean;
channelName?: string; channelName?: string;
channelType?: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY'; channelType?: 'SERVER' | 'DM' | 'PARTY' | 'GAC' | 'SPY';

View File

@ -4,7 +4,6 @@ import {EventSourcePolyfill} from 'event-source-polyfill';
import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object'; import {ChatMessage} from '@pages/altitude/chat/objects/chat-message.object';
import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object'; import {RawChatMessage} from '@pages/altitude/chat/objects/raw-chat-message.object';
import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util'; import {normalizeComponent} from '@pages/altitude/chat/mini-message/normalize.util';
import {precomputeNode} from '@pages/altitude/chat/mini-message/mini-message.util';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object'; import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service'; import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
import {ChatInfoService} from '@api'; import {ChatInfoService} from '@api';
@ -88,8 +87,8 @@ export class ChatService implements OnDestroy {
const messages: ChatMessage[] = raw.map((m) => { const messages: ChatMessage[] = raw.map((m) => {
const message: ChatMessage = { const message: ChatMessage = {
...m, ...m,
messageJson: precomputeNode(normalizeComponent(JSON.parse(m.messageJson))), messageJson: normalizeComponent(JSON.parse(m.messageJson)),
} as ChatMessage; };
const channelKey = this.getChannelKey(message); const channelKey = this.getChannelKey(message);
if (channelKey === null) { if (channelKey === null) {
return null; return null;
@ -106,11 +105,8 @@ export class ChatService implements OnDestroy {
this._channels.update((old) => [...old, { this._channels.update((old) => [...old, {
name: message.channelName!, name: message.channelName!,
type: message.channelType!, type: message.channelType!,
unreadMessages: 0, unreadMessages: 0
totalMessages: 0
}]); }]);
} else {
found.totalMessages++;
} }
}) })