Add user list toggle with responsive visibility in ChatService and ChatInfoComponent, implement UserListComponent, and update styles for user list and toggles.

This commit is contained in:
akastijn 2026-08-09 00:59:44 +02:00
parent 2705cf14ab
commit 8c02543bca
14 changed files with 223 additions and 17 deletions

View File

@ -24,20 +24,29 @@
<app-full-size [hideFooter]="true">
<section class="darkmodeSection full-height">
<div class="page-layout">
<div class="channel-list">
<app-channel-list [channels]="channels()" [selectedChannel]="selectedChannel()"
(selectedChannelChange)="onSelectedChannelChange($event)">
@if (!isMobile() || !userListVisible()) {
<div class="channel-list">
<app-channel-list [channels]="channels()" [selectedChannel]="selectedChannel()"
(selectedChannelChange)="onSelectedChannelChange($event)">
</app-channel-list>
</div>
</app-channel-list>
</div>
}
<div class="chat">
<div class="chat-info">
<app-chat-info [selectedChannel]="selectedChannel()"></app-chat-info>
<div class="chat-info-container">
<app-chat-info [selectedChannel]="selectedChannel()" [isMobile]="isMobile()"></app-chat-info>
</div>
</div>
<div class="chat-box">
<app-chat-box [messages]="messages()"></app-chat-box>
</div>
</div>
@if (userListVisible()) {
<div class="user-list">
<app-user-list></app-user-list>
</div>
}
</div>
</section>
</app-full-size>

View File

@ -60,6 +60,12 @@
min-width: 100px;
}
.user-list {
flex: 0 0 auto;
width: max-content;
min-width: 100px;
}
.chat {
display: flex;
flex-direction: column;
@ -70,6 +76,16 @@
.chat-info {
flex: 0 0 auto;
padding: 5px 5px 2px 5px;
.chat-info-container {
display: flex;
align-items: center;
gap: 10px;
app-chat-info {
flex: 1 1 auto;
}
}
}
.chat-box {

View File

@ -8,6 +8,7 @@ import {FullSizeComponent} from '@shared-components/full-size/full-size.componen
import {ChatBoxComponent} from '@pages/altitude/chat/components/chat/chat-box.component';
import {ChannelListComponent} from '@pages/altitude/chat/components/channel-list/channel-list.component';
import {ChatInfoComponent} from '@pages/altitude/chat/components/chat-info/chat-info.component';
import {UserListComponent} from '@pages/altitude/chat/components/user-list/user-list.component';
import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
@Component({
@ -17,7 +18,8 @@ import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout';
FullSizeComponent,
ChatBoxComponent,
ChannelListComponent,
ChatInfoComponent
ChatInfoComponent,
UserListComponent
],
templateUrl: './chat.component.html',
styleUrl: './chat.component.scss',
@ -31,6 +33,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
protected readonly selectedChannel = this.chatService.selectedChannel;
protected readonly showFullscreenPrompt = signal(false);
protected readonly isMobile = signal(false);
protected readonly userListVisible = this.chatService.userListVisible;
constructor(private interaction: MiniMessageInteractionService, private breakpointObserver: BreakpointObserver) {
interaction.clicks$.subscribe(({action, value}) => {
@ -41,8 +44,14 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
this.breakpointObserver
.observe([Breakpoints.Handset])
.subscribe(result => {
this.isMobile.set(result.matches);
this.showFullscreenPrompt.set(this.isMobile());
const isMobile = result.matches;
this.isMobile.set(isMobile);
this.showFullscreenPrompt.set(isMobile);
if (isMobile) {
this.userListVisible.set(false);
} else {
this.userListVisible.set(true);
}
});
}
@ -50,6 +59,10 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
this.chatService.selectChannel(channel);
}
protected toggleUserList(): void {
this.chatService.toggleUserList();
}
protected enterFullscreen(): void {
this.showFullscreenPrompt.set(false);

View File

@ -7,11 +7,19 @@
}
<span class="message-count">({{ chatChannel.totalMessages }})</span>
</span>
<div class="user-list-toggle" (click)="toggleUserList()" [class.active]="userListVisible()">
<svg-users [style.color]="getServerTextColor(chatChannel.name)"/>
@if (isMobile()) {
<span [style.color]="getServerTextColor(chatChannel.name)">
{{ userListVisible() ? 'Channels' : 'Users' }}
</span>
}
</div>
<div class="notification-icon">
@if (notificationEnabled()) {
<svg-notification-enabled (click)="disableNotifications()"/>
<svg-notification-enabled (click)="disableNotifications()" [style.color]="getServerTextColor(chatChannel.name)"/>
} @else {
<svg-notification-disabled (click)="enableNotifications()"/>
<svg-notification-disabled (click)="enableNotifications()" [style.color]="getServerTextColor(chatChannel.name)"/>
}
</div>
</div>

View File

@ -20,10 +20,40 @@
}
}
.user-list-toggle {
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
margin-right: 15px;
opacity: 0.8;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
&.active {
opacity: 1;
}
span {
font-weight: bold;
font-size: 0.9rem;
text-transform: uppercase;
}
}
.notification-icon {
cursor: pointer;
width: 20px;
height: 20px;
margin-left: auto;
margin-right: 10px;
margin-right: 15px;
opacity: 0.8;
transition: opacity 0.2s;
&:hover {
opacity: 1;
}
}

View File

@ -3,15 +3,18 @@ import {getServerColor, getServerTextColor} from '@pages/altitude/chat/util/serv
import {NotificationService} from '@pages/altitude/chat/service/chat-notification.service';
import {NotificationEnabledSvgComponent} from '@pages/altitude/chat/svg/notification-enabled.svg';
import {NotificationDisabledSvgComponent} from '@pages/altitude/chat/svg/notification-disabled.svg';
import {UsersSvgComponent} from '@pages/altitude/chat/svg/users.svg';
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';
import {ChatService} from '@pages/altitude/chat/service/chat.service';
@Component({
selector: 'app-chat-info',
imports: [
NotificationEnabledSvgComponent,
NotificationDisabledSvgComponent
NotificationDisabledSvgComponent,
UsersSvgComponent
],
templateUrl: './chat-info.component.html',
styleUrl: './chat-info.component.scss'
@ -19,8 +22,11 @@ import {NameFormatService} from '@pages/altitude/chat/service/name-format.servic
export class ChatInfoComponent {
private readonly notificationService: NotificationService = inject(NotificationService);
private readonly nameFormatService: NameFormatService = inject(NameFormatService);
private readonly chatService: ChatService = inject(ChatService);
public readonly selectedChannel = input.required<ChatChannel | null>();
public readonly isMobile = input<boolean>(false);
protected readonly userListVisible = this.chatService.userListVisible;
protected readonly channelKey = computed(() => {
const channel = this.selectedChannel();
return channel ? `${channel.type}:${channel.name}` : '';
@ -37,6 +43,10 @@ export class ChatInfoComponent {
this.notificationService.disableNotifications(this.channelKey());
}
public toggleUserList() {
this.chatService.toggleUserList();
}
protected getDisplayName(chatChannel: ChatChannel): ColoredNameSegment[] {
return this.nameFormatService.getDisplayName(chatChannel);
}

View File

@ -0,0 +1,16 @@
<div class="user-list">
<div class="scroll">
@for (server of serverState()?.servers; track server.name) {
<p class="server-title">{{ server.name }} ({{ server.players.length }})</p>
@for (player of server.players; track player.uuid) {
<div class="user indented">
<span class="user-name">
@for (segment of getDisplayName(player); track $index) {
<span [style.color]="segment.color">{{ segment.text }}</span>
}
</span>
</div>
}
}
</div>
</div>

View File

@ -0,0 +1,47 @@
.user-list {
height: 100%;
background-color: #172133;
display: flex;
flex-direction: column;
min-height: 0;
.user {
position: relative;
display: flex;
align-items: center;
padding: 8px 20px;
border-bottom: 1px solid #222;
cursor: default;
.user-name {
color: white;
font-weight: bold;
}
}
.user.indented {
padding-left: 40px;
}
.server-title {
padding: 10px 20px;
color: #888;
font-size: 0.8rem;
font-weight: bold;
text-transform: uppercase;
user-select: none;
}
p {
margin: 0;
}
.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

@ -0,0 +1,35 @@
import {Component, computed, inject} from '@angular/core';
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';
import {User} from '@pages/altitude/chat/objects/server-state.object';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [],
templateUrl: './user-list.component.html',
styleUrl: './user-list.component.scss'
})
export class UserListComponent {
private readonly chatService = inject(ChatService);
private readonly nameFormatService = inject(NameFormatService);
protected readonly serverState = computed(() => {
const state = this.chatService.serverState();
if (!state) return null;
return {
servers: [...state.servers]
.sort((a, b) => a.name.localeCompare(b.name))
.map(server => ({
...server,
players: [...server.players].sort((a, b) => a.name.localeCompare(b.name))
}))
};
});
protected getDisplayName(user: User): ColoredNameSegment[] {
return this.nameFormatService.parseColoredName(user.styledName || user.name);
}
}

View File

@ -29,9 +29,14 @@ export class ChatService implements OnDestroy {
public readonly serverState = this._serverState.asReadonly();
public readonly partieMap = signal<Map<string, string>>(new Map());
public readonly userNameMap = signal<Map<string, string>>(new Map());
public readonly userListVisible = signal(true);
private readonly pendingPartyNameRequests = new Set<string>();
private readonly pendingUserNameRequests = new Set<string>();
public toggleUserList(): void {
this.userListVisible.update(v => !v);
}
public readonly messages = computed(() => {
const selected = this._selectedChannel();
if (!selected) return [];

View File

@ -44,7 +44,7 @@ export class NameFormatService {
return channel.name;
}
private parseColoredName(name: string): ColoredNameSegment[] {
public parseColoredName(name: string): ColoredNameSegment[] {
const markerRegex = /\{#([0-9a-fA-F]{6})(<>|[<>]?)\}|&([0-9a-fA-F])/g;
const segments: ColoredNameSegment[] = [];
let currentColor: string | undefined;

View File

@ -7,7 +7,7 @@ import {Component} from '@angular/core';
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 3L21 21M9.37747 3.56325C10.1871 3.19604 11.0827 3 12 3C13.5913 3 15.1174 3.59 16.2426 4.6402C17.3679 5.69041 18 7.11479 18 8.6C18 10.3566 18.2892 11.7759 18.712 12.9122M17 17H15M6.45339 6.46451C6.15686 7.13542 6 7.86016 6 8.6C6 11.2862 5.3238 13.1835 4.52745 14.4866C3.75616 15.7486 3.37051 16.3797 3.38485 16.5436C3.40095 16.7277 3.43729 16.7925 3.58603 16.9023C3.71841 17 4.34762 17 5.60605 17H9M9 17V18C9 19.6569 10.3431 21 12 21C13.6569 21 15 19.6569 15 18V17M9 17H15"
stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
})

View File

@ -7,7 +7,7 @@ import {Component} from '@angular/core';
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M9.00195 17H5.60636C4.34793 17 3.71872 17 3.58633 16.9023C3.4376 16.7925 3.40126 16.7277 3.38515 16.5436C3.37082 16.3797 3.75646 15.7486 4.52776 14.4866C5.32411 13.1835 6.00031 11.2862 6.00031 8.6C6.00031 7.11479 6.63245 5.69041 7.75766 4.6402C8.88288 3.59 10.409 3 12.0003 3C13.5916 3 15.1177 3.59 16.2429 4.6402C17.3682 5.69041 18.0003 7.11479 18.0003 8.6C18.0003 11.2862 18.6765 13.1835 19.4729 14.4866C20.2441 15.7486 20.6298 16.3797 20.6155 16.5436C20.5994 16.7277 20.563 16.7925 20.4143 16.9023C20.2819 17 19.6527 17 18.3943 17H15.0003M9.00195 17L9.00031 18C9.00031 19.6569 10.3435 21 12.0003 21C13.6572 21 15.0003 19.6569 15.0003 18V17M9.00195 17H15.0003"
stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
})

View File

@ -0,0 +1,17 @@
import {Component} from '@angular/core';
@Component({
selector: 'svg-users',
standalone: true,
imports: [],
template: `
<svg width="20px" height="20px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 21V19C16 17.9391 15.5786 16.9217 14.8284 16.1716C14.0783 15.4214 13.0609 15 12 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 11C10.7091 11 12.5 9.20914 12.5 7C12.5 4.79086 10.7091 3 8.5 3C6.29086 3 4.5 4.79086 4.5 7C4.5 9.20914 6.29086 11 8.5 11Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23 21V19C22.9993 18.1137 22.7044 17.2525 22.1614 16.5523C21.6184 15.8521 20.8581 15.3516 20 15.13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M17 3.13C17.8604 3.35031 18.623 3.85071 19.1676 4.55163C19.7122 5.25254 20.0078 6.11514 20.0078 7.005C20.0078 7.89486 19.7122 8.75746 19.1676 9.45837C18.623 10.1593 17.8604 10.6597 17 10.88" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
`
})
export class UsersSvgComponent {
}