Extract display name logic to NameFormatService, refactor colored name parsing, and improve channel list scrolling.

This commit is contained in:
akastijn 2026-08-01 22:42:37 +02:00
parent 5987ee4133
commit 5be3072b26
9 changed files with 175 additions and 139 deletions

View File

@ -11,7 +11,7 @@
[class.selected]="selectedChannel()?.name === channel.name && selectedChannel()?.type === channel.type" [class.selected]="selectedChannel()?.name === channel.name && selectedChannel()?.type === channel.type"
(click)="selectedChannelChange.emit(channel)"> (click)="selectedChannelChange.emit(channel)">
<span class="channel-name"> <span class="channel-name">
@for (segment of parseColoredName(getDisplayName(channel)); track $index) { @for (segment of getDisplayName(channel); track $index) {
<span [style.color]="segment.color">{{ segment.text }}</span> <span [style.color]="segment.color">{{ segment.text }}</span>
} }
</span> </span>

View File

@ -1,6 +1,9 @@
.channel-list { .channel-list {
height: 100%; height: 100%;
background-color: #172133; background-color: #172133;
display: flex;
flex-direction: column;
min-height: 0;
.channel { .channel {
position: relative; position: relative;
@ -44,8 +47,13 @@
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
} }
}
.scroll { .scroll {
overflow: scroll; flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
box-sizing: border-box;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.35) transparent;
}
} }

View File

@ -1,12 +1,8 @@
import {Component, computed, effect, inject, input, output, signal} from '@angular/core'; import {Component, computed, effect, inject, input, output, signal} from '@angular/core';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object'; import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg'; import {CircleSvgComponent} from '@pages/altitude/chat/svg/circle.svg';
import {ChatService} from '@pages/altitude/chat/service/chat.service'; import {NameFormatService} from '@pages/altitude/chat/service/name-format.service';
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
interface ColoredNameSegment {
text: string;
color?: string;
}
@Component({ @Component({
selector: 'app-channel-list', selector: 'app-channel-list',
@ -17,7 +13,7 @@ interface ColoredNameSegment {
styleUrl: './channel-list.component.scss' styleUrl: './channel-list.component.scss'
}) })
export class ChannelListComponent { export class ChannelListComponent {
protected readonly chatService = inject(ChatService); protected readonly nameFormatService: NameFormatService = inject(NameFormatService);
public readonly channels = input.required<ChatChannel[]>(); public readonly channels = input.required<ChatChannel[]>();
public readonly selectedChannel = input.required<ChatChannel | null>(); public readonly selectedChannel = input.required<ChatChannel | null>();
public readonly selectedChannelChange = output<ChatChannel>(); public readonly selectedChannelChange = output<ChatChannel>();
@ -58,121 +54,7 @@ export class ChannelListComponent {
}); });
} }
protected getDisplayName(channel: ChatChannel): string { protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
if (channel.type === 'PARTY') { return this.nameFormatService.getDisplayName(chatChannel);
return this.chatService.partieMap().get(channel.name) ?? channel.name;
} }
if (channel.type === 'DM' || channel.type === 'SPY') {
return this.chatService.userNameMap().get(channel.name) ?? channel.name;
}
return channel.name;
}
protected parseColoredName(name: string): ColoredNameSegment[] {
const markerRegex = /\{#([0-9a-fA-F]{6})(<>|[<>]?)}/g;
const segments: ColoredNameSegment[] = [];
let currentColor: string | undefined;
let gradientStartColor: string | undefined;
let gradientStartSegmentIndex: number | undefined;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = markerRegex.exec(name)) !== null) {
this.addColoredTextSegment(segments, name.slice(lastIndex, match.index), currentColor);
const markerColor = `#${match[1]}`;
const markerType = match[2];
if (markerType === '>') {
currentColor = markerColor;
gradientStartColor = markerColor;
gradientStartSegmentIndex = segments.length;
} else if (markerType === '<') {
if (gradientStartColor !== undefined && gradientStartSegmentIndex !== undefined) {
this.applyGradient(segments, gradientStartSegmentIndex, gradientStartColor, markerColor);
}
currentColor = markerColor;
gradientStartColor = undefined;
gradientStartSegmentIndex = undefined;
} else if (markerType === '<>') {
if (gradientStartColor !== undefined && gradientStartSegmentIndex !== undefined) {
this.applyGradient(segments, gradientStartSegmentIndex, gradientStartColor, markerColor);
}
currentColor = markerColor;
gradientStartColor = markerColor;
gradientStartSegmentIndex = segments.length;
} else {
currentColor = markerColor;
gradientStartColor = undefined;
gradientStartSegmentIndex = undefined;
}
lastIndex = markerRegex.lastIndex;
}
this.addColoredTextSegment(segments, name.slice(lastIndex), currentColor);
return segments.length > 0 ? segments : [{text: name}];
}
private addColoredTextSegment(segments: ColoredNameSegment[], text: string, color?: string): void {
if (text.length === 0) {
return;
}
segments.push({text, color});
}
private applyGradient(
segments: ColoredNameSegment[],
startSegmentIndex: number,
startColor: string,
endColor: string
): void {
const text = segments.slice(startSegmentIndex).map((segment) => segment.text).join('');
const length = text.length;
const start = this.hexToRgb(startColor);
const end = this.hexToRgb(endColor);
if (length === 0 || start === null || end === null) {
return;
}
const gradientSegments: ColoredNameSegment[] = [];
for (let i = 0; i < length; i++) {
const ratio = length === 1 ? 0 : i / (length - 1);
gradientSegments.push({
text: text[i],
color: this.rgbToHex(
start.r + (end.r - start.r) * ratio,
start.g + (end.g - start.g) * ratio,
start.b + (end.b - start.b) * ratio
)
});
}
segments.splice(startSegmentIndex, segments.length - startSegmentIndex, ...gradientSegments);
}
private hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = /^#?([0-9a-fA-F]{6})$/.exec(hex);
if (!match) {
return null;
}
const value = Number.parseInt(match[1], 16);
return {
r: (value >> 16) & 255,
g: (value >> 8) & 255,
b: value & 255
};
}
private rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b]
.map((value) => Math.round(value).toString(16).padStart(2, '0'))
.join('')}`;
}
} }

View File

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

View File

@ -4,6 +4,8 @@ import {NotificationService} from '@pages/altitude/chat/service/chat-notificatio
import {NotificationEnabledSvgComponent} from '@pages/altitude/chat/svg/notification-enabled.svg'; import {NotificationEnabledSvgComponent} from '@pages/altitude/chat/svg/notification-enabled.svg';
import {NotificationDisabledSvgComponent} from '@pages/altitude/chat/svg/notification-disabled.svg'; import {NotificationDisabledSvgComponent} from '@pages/altitude/chat/svg/notification-disabled.svg';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object'; import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
import {NameFormatService} from '@pages/altitude/chat/service/name-format.service';
@Component({ @Component({
selector: 'app-chat-info', selector: 'app-chat-info',
@ -16,6 +18,7 @@ import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
}) })
export class ChatInfoComponent { export class ChatInfoComponent {
private readonly notificationService: NotificationService = inject(NotificationService); private readonly notificationService: NotificationService = inject(NotificationService);
private readonly nameFormatService: NameFormatService = inject(NameFormatService);
public readonly selectedChannel = input.required<ChatChannel | null>(); public readonly selectedChannel = input.required<ChatChannel | null>();
protected readonly channelKey = computed(() => { protected readonly channelKey = computed(() => {
@ -34,4 +37,8 @@ export class ChatInfoComponent {
this.notificationService.disableNotifications(this.channelKey()); this.notificationService.disableNotifications(this.channelKey());
} }
protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
return this.nameFormatService.getDisplayName(chatChannel);
}
} }

View File

@ -0,0 +1,4 @@
export interface ColoredNameSegment {
text: string;
color?: string;
}

View File

@ -0,0 +1,131 @@
import {inject, Injectable} from '@angular/core';
import {ChatChannel} from '@pages/altitude/chat/objects/chat-channel.object';
import {ColoredNameSegment} from '@pages/altitude/chat/objects/colored-name-segment.object';
import {ChatService} from "./chat.service";
@Injectable({providedIn: 'root'})
export class NameFormatService {
private readonly chatService: ChatService = inject(ChatService);
public getDisplayName(channel: ChatChannel): ColoredNameSegment[] {
return this.parseColoredName(this.getNameFromType(channel));
}
private getNameFromType(channel: ChatChannel): string {
if (channel.type === 'PARTY') {
return this.chatService.partieMap().get(channel.name) ?? channel.name;
}
if (channel.type === 'DM' || channel.type === 'SPY') {
return this.chatService.userNameMap().get(channel.name) ?? channel.name;
}
return channel.name;
}
private parseColoredName(name: string): ColoredNameSegment[] {
const markerRegex = /\{#([0-9a-fA-F]{6})(<>|[<>]?)}/g;
const segments: ColoredNameSegment[] = [];
let currentColor: string | undefined;
let gradientStartColor: string | undefined;
let gradientStartSegmentIndex: number | undefined;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = markerRegex.exec(name)) !== null) {
this.addColoredTextSegment(segments, name.slice(lastIndex, match.index), currentColor);
const markerColor = `#${match[1]}`;
const markerType = match[2];
if (markerType === '>') {
currentColor = markerColor;
gradientStartColor = markerColor;
gradientStartSegmentIndex = segments.length;
} else if (markerType === '<') {
if (gradientStartColor !== undefined && gradientStartSegmentIndex !== undefined) {
this.applyGradient(segments, gradientStartSegmentIndex, gradientStartColor, markerColor);
}
currentColor = markerColor;
gradientStartColor = undefined;
gradientStartSegmentIndex = undefined;
} else if (markerType === '<>') {
if (gradientStartColor !== undefined && gradientStartSegmentIndex !== undefined) {
this.applyGradient(segments, gradientStartSegmentIndex, gradientStartColor, markerColor);
}
currentColor = markerColor;
gradientStartColor = markerColor;
gradientStartSegmentIndex = segments.length;
} else {
currentColor = markerColor;
gradientStartColor = undefined;
gradientStartSegmentIndex = undefined;
}
lastIndex = markerRegex.lastIndex;
}
this.addColoredTextSegment(segments, name.slice(lastIndex), currentColor);
return segments.length > 0 ? segments : [{text: name}];
}
private addColoredTextSegment(segments: ColoredNameSegment[], text: string, color?: string): void {
if (text.length === 0) {
return;
}
segments.push({text, color});
}
private applyGradient(
segments: ColoredNameSegment[],
startSegmentIndex: number,
startColor: string,
endColor: string
): void {
const text = segments.slice(startSegmentIndex).map((segment) => segment.text).join('');
const length = text.length;
const start = this.hexToRgb(startColor);
const end = this.hexToRgb(endColor);
if (length === 0 || start === null || end === null) {
return;
}
const gradientSegments: ColoredNameSegment[] = [];
for (let i = 0; i < length; i++) {
const ratio = length === 1 ? 0 : i / (length - 1);
gradientSegments.push({
text: text[i],
color: this.rgbToHex(
start.r + (end.r - start.r) * ratio,
start.g + (end.g - start.g) * ratio,
start.b + (end.b - start.b) * ratio
)
});
}
segments.splice(startSegmentIndex, segments.length - startSegmentIndex, ...gradientSegments);
}
private hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const match = /^#?([0-9a-fA-F]{6})$/.exec(hex);
if (!match) {
return null;
}
const value = Number.parseInt(match[1], 16);
return {
r: (value >> 16) & 255,
g: (value >> 8) & 255,
b: value & 255
};
}
private rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b]
.map((value) => Math.round(value).toString(16).padStart(2, '0'))
.join('')}`;
}
}

View File

@ -6,7 +6,7 @@ export function getServerColor(server: string) {
} else if (server === 'creative') { } else if (server === 'creative') {
return "#FFA500"; return "#FFA500";
} }
return "white"; return "#0290e8";
} }
export function getServerTextColor(server: string) { export function getServerTextColor(server: string) {