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