feat: Response mobile layout support v1
Some checks failed
Queue Release Build / prepare (push) Successful in 30s
Deploy Web Apps / deploy (push) Successful in 7m8s
Queue Release Build / build-windows (push) Successful in 28m11s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-linux (push) Has started running

This commit is contained in:
2026-05-18 02:25:16 +02:00
parent ecb1a4b3a0
commit 181fedc7ec
42 changed files with 2333 additions and 343 deletions

View File

@@ -0,0 +1,378 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
effect,
inject,
OnDestroy,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCamera,
lucideCheck,
lucideChevronDown,
lucideGamepad2,
lucideMessageCircle,
lucidePhone,
lucideUserMinus,
lucideUserPlus
} from '@ng-icons/lucide';
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
import { ThemeNodeDirective } from '../../../domains/theme';
import { User, UserStatus } from '../../../shared-kernel';
import { selectCurrentUser, selectUsersEntities } from '../../../store/users/users.selectors';
import { UsersActions } from '../../../store/users/users.actions';
import { DirectMessageService } from '../../../domains/direct-message/application/services/direct-message.service';
import { FriendService } from '../../../domains/direct-message/application/services/friend.service';
import { DirectCallService } from '../../../domains/direct-call/application/services/direct-call.service';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { UserStatusService } from '../../../core/services/user-status.service';
import {
EditableProfileAvatarSource,
PROFILE_AVATAR_ACCEPT_ATTRIBUTE,
ProcessedProfileAvatar,
ProfileAvatarEditorService,
ProfileAvatarFacade
} from '../../../domains/profile-avatar';
@Component({
selector: 'app-profile-card-mobile',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent,
ThemeNodeDirective
],
viewProviders: [
provideIcons({
lucideCamera,
lucideCheck,
lucideChevronDown,
lucideGamepad2,
lucideMessageCircle,
lucidePhone,
lucideUserMinus,
lucideUserPlus
})
],
templateUrl: './profile-card-mobile.component.html'
})
export class ProfileCardMobileComponent implements OnDestroy {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly editable = signal(false);
readonly closed = output<undefined>();
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
readonly avatarError = signal<string | null>(null);
readonly avatarSaving = signal(false);
readonly editingField = signal<'displayName' | 'description' | null>(null);
readonly displayNameDraft = signal('');
readonly descriptionDraft = signal('');
readonly showStatusMenu = signal(false);
readonly statusOptions: { value: UserStatus | null; label: string; color: string }[] = [
{ value: null, label: 'Online', color: 'bg-green-500' },
{ value: 'away', label: 'Away', color: 'bg-yellow-500' },
{ value: 'busy', label: 'Do Not Disturb', color: 'bg-red-500' },
{ value: 'offline', label: 'Invisible', color: 'bg-gray-500' }
];
private readonly store = inject(Store);
private readonly router = inject(Router);
private readonly directMessages = inject(DirectMessageService);
private readonly directCalls = inject(DirectCallService);
private readonly friendsService = inject(FriendService);
private readonly externalLinks = inject(ExternalLinkService);
private readonly userStatus = inject(UserStatusService);
private readonly profileAvatar = inject(ProfileAvatarFacade);
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
private readonly users = this.store.selectSignal(selectUsersEntities);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly displayedUser = computed(() => {
const snapshot = this.user();
const entities = this.users();
const liveUser = entities[snapshot.id] ?? entities[snapshot.oderId];
return liveUser ? { ...snapshot, ...liveUser } : snapshot;
});
readonly isSelf = computed(() => {
const me = this.currentUser();
const them = this.displayedUser();
if (!me)
return false;
return me.id === them.id || me.oderId === them.oderId;
});
readonly isFriend = computed(() => this.friendsService.friendIds().has(this.displayedUser().id));
readonly activityNow = signal(Date.now());
readonly busy = signal(false);
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
private readonly syncProfileDrafts = effect(
() => {
const user = this.displayedUser();
const editingField = this.editingField();
if (editingField !== 'displayName') {
this.displayNameDraft.set(user.displayName || '');
}
if (editingField !== 'description') {
this.descriptionDraft.set(user.description || '');
}
},
{ allowSignalWrites: true }
);
ngOnDestroy(): void {
clearInterval(this.activityTimer);
}
currentStatusColor(): string {
switch (this.displayedUser().status) {
case 'online':
return 'bg-green-500';
case 'away':
return 'bg-yellow-500';
case 'busy':
return 'bg-red-500';
default:
return 'bg-gray-500';
}
}
currentStatusLabel(): string {
switch (this.displayedUser().status) {
case 'online':
return 'Online';
case 'away':
return 'Away';
case 'busy':
return 'Do Not Disturb';
case 'offline':
return 'Invisible';
case 'disconnected':
return 'Offline';
default:
return 'Online';
}
}
gameActivityElapsed(): string {
const activity = this.displayedUser().gameActivity;
return activity ? formatGameActivityElapsed(activity.startedAt, this.activityNow()) : '';
}
openGameStore(event: Event): void {
event.stopPropagation();
const url = this.displayedUser().gameActivity?.store?.url;
if (url) {
this.externalLinks.open(url);
}
}
toggleStatusMenu(): void {
this.showStatusMenu.update((open) => !open);
}
setStatus(status: UserStatus | null): void {
this.userStatus.setManualStatus(status);
this.showStatusMenu.set(false);
}
isStatusOptionSelected(status: UserStatus | null): boolean {
const currentStatus = this.displayedUser().status;
return status === null ? currentStatus === 'online' : currentStatus === status;
}
onDisplayNameInput(event: Event): void {
this.displayNameDraft.set((event.target as HTMLInputElement).value);
}
onDescriptionInput(event: Event): void {
this.descriptionDraft.set((event.target as HTMLTextAreaElement).value);
}
startEdit(field: 'displayName' | 'description'): void {
if (!this.editable() || this.editingField() === field) {
return;
}
this.editingField.set(field);
}
finishEdit(field: 'displayName' | 'description'): void {
if (this.editingField() !== field) {
return;
}
this.commitProfileDrafts();
this.editingField.set(null);
}
pickAvatar(fileInput: HTMLInputElement): void {
if (!this.editable() || this.avatarSaving()) {
return;
}
this.avatarError.set(null);
fileInput.click();
}
async onAvatarSelected(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
let source: EditableProfileAvatarSource | null = null;
input.value = '';
if (!file) {
return;
}
const validationError = this.profileAvatar.validateFile(file);
if (validationError) {
this.avatarError.set(validationError);
return;
}
try {
source = await this.profileAvatar.prepareEditableSource(file);
const avatar = await this.profileAvatarEditor.open(source);
if (!avatar) {
return;
}
await this.applyAvatar(avatar);
} catch {
this.avatarError.set('Failed to open selected image.');
} finally {
this.profileAvatar.releaseEditableSource(source);
}
}
async applyAvatar(avatar: ProcessedProfileAvatar): Promise<void> {
const currentUser = this.displayedUser();
this.avatarSaving.set(true);
this.avatarError.set(null);
try {
await this.profileAvatar.persistProcessedAvatar(currentUser, avatar);
const updates = this.profileAvatar.buildAvatarUpdates(avatar);
this.store.dispatch(UsersActions.updateCurrentUserAvatar({ avatar: updates }));
this.user.update((user) => ({
...user,
...updates
}));
} catch {
this.avatarError.set('Failed to save profile image.');
} finally {
this.avatarSaving.set(false);
}
}
async startChat(): Promise<void> {
if (this.busy() || this.isSelf())
return;
this.busy.set(true);
try {
const conversation = await this.directMessages.createConversation(this.displayedUser());
await this.router.navigate(['/dm', conversation.id]);
this.closed.emit(undefined);
} finally {
this.busy.set(false);
}
}
async startCall(): Promise<void> {
if (this.busy() || this.isSelf())
return;
this.busy.set(true);
try {
await this.directCalls.startCall(this.displayedUser());
this.closed.emit(undefined);
} finally {
this.busy.set(false);
}
}
async toggleFriend(): Promise<void> {
if (this.busy() || this.isSelf())
return;
this.busy.set(true);
try {
await this.friendsService.toggleFriend(this.displayedUser().id);
} finally {
this.busy.set(false);
}
}
private commitProfileDrafts(): void {
if (!this.editable()) {
return;
}
const displayName = this.normalizeDisplayName(this.displayNameDraft());
if (!displayName) {
this.displayNameDraft.set(this.user().displayName || '');
return;
}
const user = this.displayedUser();
const description = this.normalizeDescription(this.descriptionDraft());
if (displayName === this.normalizeDisplayName(user.displayName) && description === this.normalizeDescription(user.description)) {
return;
}
const profile = {
displayName,
description,
profileUpdatedAt: Date.now()
};
this.store.dispatch(UsersActions.updateCurrentUserProfile({ profile }));
this.user.update((user) => ({
...user,
...profile
}));
}
private normalizeDisplayName(value: string | undefined): string {
return value?.trim().replace(/\s+/g, ' ') || '';
}
private normalizeDescription(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized || undefined;
}
}