Files
Toju/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts

699 lines
21 KiB
TypeScript

/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
DestroyRef,
computed,
effect,
inject,
signal
} from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePhone, lucidePlus } from '@ng-icons/lucide';
import {
EMPTY,
Subject,
catchError,
filter,
firstValueFrom,
from,
map,
switchMap,
tap
} from 'rxjs';
import { Room, User } from '../../../shared-kernel';
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
import { VoiceSessionFacade } from '../../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { DatabaseService } from '../../../infrastructure/persistence';
import { NotificationsFacade } from '../../../domains/notifications';
import { DirectCallService, DirectCallSession } from '../../../domains/direct-call';
import { DmRailComponent } from '../../../domains/direct-message/feature/dm-rail/dm-rail.component';
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
import { ThemeNodeDirective } from '../../../domains/theme';
import { hasRoomBanForUser } from '../../../domains/access-control';
import {
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent
} from '../../../shared';
@Component({
selector: 'app-servers-rail',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent,
ContextMenuComponent,
DmRailComponent,
LeaveServerDialogComponent,
ThemeNodeDirective,
UserBarComponent
],
viewProviders: [provideIcons({ lucidePhone, lucidePlus })],
templateUrl: './servers-rail.component.html'
})
export class ServersRailComponent {
private store = inject(Store);
private router = inject(Router);
private voiceSession = inject(VoiceSessionFacade);
private db = inject(DatabaseService);
private notifications = inject(NotificationsFacade);
readonly directCalls = inject(DirectCallService);
private serverDirectory = inject(ServerDirectoryFacade);
private destroyRef = inject(DestroyRef);
private banLookupRequestVersion = 0;
private visibleSavedRoomCache: Room[] = [];
private savedRoomJoinRequests = new Subject<{ room: Room; password?: string }>();
savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
showMenu = signal(false);
menuX = signal(72);
menuY = signal(100);
contextRoom = signal<Room | null>(null);
optimisticSelectedRoomId = signal<string | null>(null);
showLeaveConfirm = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
bannedRoomLookup = signal<Record<string, boolean>>({});
isOnSearch = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/search'))
),
{ initialValue: this.router.url.startsWith('/search') }
);
isOnDirectMessage = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => this.isDirectMessageUrl(navigationEvent.urlAfterRedirects))
),
{ initialValue: this.isDirectMessageUrl(this.router.url) }
);
isOnCall = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/call/'))
),
{ initialValue: this.router.url.startsWith('/call/') }
);
currentCallId = toSignal(
this.router.events.pipe(
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
map((navigationEvent) => this.callIdFromUrl(navigationEvent.urlAfterRedirects))
),
{ initialValue: this.callIdFromUrl(this.router.url) }
);
selectedCallIndex = computed(() => {
const routeCallId = this.currentCallId();
const visibleCalls = this.directCalls.visibleActiveSessions();
if (routeCallId) {
const routeMatchIndex = visibleCalls.findIndex((call) => call.callId === routeCallId || call.conversationId === routeCallId);
if (routeMatchIndex >= 0) {
return routeMatchIndex;
}
}
const currentSession = this.directCalls.currentSession();
if (!currentSession) {
return -1;
}
return visibleCalls.findIndex((call) => call.callId === currentSession.callId);
});
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
passwordPromptRoom = signal<Room | null>(null);
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
visibleSavedRooms = computed(() => this.stabilizeVisibleSavedRooms(this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room))));
voicePresenceByRoom = computed(() => {
const presence: Record<string, number> = {};
const seenByRoom = new Map<string, Set<string>>();
const addVoicePresence = (user: User | null | undefined): void => {
if (!user) {
return;
}
const voiceState = user?.voiceState;
const roomId = voiceState?.serverId;
if (!voiceState?.isConnected || !roomId) {
return;
}
const userKey = user.oderId || user.id;
let seenUsers = seenByRoom.get(roomId);
if (!seenUsers) {
seenUsers = new Set<string>();
seenByRoom.set(roomId, seenUsers);
}
if (seenUsers.has(userKey)) {
return;
}
seenUsers.add(userKey);
presence[roomId] = (presence[roomId] ?? 0) + 1;
};
for (const user of this.onlineUsers()) {
addVoicePresence(user);
}
addVoicePresence(this.currentUser());
return presence;
});
constructor() {
effect(() => {
const rooms = this.savedRooms();
const currentUser = this.currentUser();
void this.refreshBannedLookup(rooms, currentUser ?? null);
});
effect(() => {
const optimisticRoomId = this.optimisticSelectedRoomId();
if (!optimisticRoomId) {
return;
}
if (this.currentRoom()?.id === optimisticRoomId && !this.isOnDirectMessage() && !this.isOnCall()) {
this.optimisticSelectedRoomId.set(null);
}
});
this.savedRoomJoinRequests
.pipe(
switchMap(({ room, password }) => this.requestJoinInBackground(room, password)),
takeUntilDestroyed(this.destroyRef)
)
.subscribe();
}
initial(name?: string): string {
if (!name)
return '?';
const ch = name.trim()[0]?.toUpperCase();
return ch || '?';
}
trackRoomId = (index: number, room: Room) => room.id;
createServer(): void {
const voiceServerId = this.voiceSession.getVoiceServerId();
this.optimisticSelectedRoomId.set(null);
if (voiceServerId) {
this.voiceSession.setViewingVoiceServer(false);
}
this.router.navigate(['/search']);
}
joinSavedRoom(room: Room): void {
const targetRoom = this.savedRooms().find((savedRoom) => savedRoom.id === room.id) ?? room;
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
if (this.isRoomMarkedBanned(targetRoom)) {
this.bannedServerName.set(targetRoom.name);
this.showBannedDialog.set(true);
return;
}
this.optimisticSelectedRoomId.set(targetRoom.id);
this.activateSavedRoom(targetRoom);
this.savedRoomJoinRequests.next({ room: targetRoom });
}
openCall(callId: string): void {
this.optimisticSelectedRoomId.set(null);
void this.directCalls.openCallView(callId);
}
isSelectedCall(callIndex: number): boolean {
return this.selectedCallIndex() === callIndex;
}
callAvatarUrls(call: DirectCallSession): string[] {
if (call.participantIds.length <= 2) {
return [];
}
return Object.values(call.participants)
.filter((participant) => participant.joined)
.map((participant) => this.directCalls.userForParticipant(participant.userId)?.avatarUrl || participant.profile.avatarUrl)
.filter((avatarUrl): avatarUrl is string => !!avatarUrl)
.slice(0, 3);
}
closeBannedDialog(): void {
this.showBannedDialog.set(false);
this.bannedServerName.set('');
}
closePasswordDialog(): void {
this.showPasswordDialog.set(false);
this.passwordPromptRoom.set(null);
this.joinPassword.set('');
this.joinPasswordError.set(null);
}
confirmPasswordJoin(): void {
const room = this.passwordPromptRoom();
if (!room)
return;
this.joinPasswordError.set(null);
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
}
isRoomMarkedBanned(room: Room): boolean {
return !!this.bannedRoomLookup()[room.id];
}
private callIdFromUrl(url: string): string | null {
const path = url.split(/[?#]/, 1)[0];
const match = path.match(/^\/call\/([^/]+)/);
return match?.[1] ? decodeURIComponent(match[1]) : null;
}
openContextMenu(evt: MouseEvent, room: Room): void {
evt.preventDefault();
this.contextRoom.set(room);
this.menuX.set(Math.max(evt.clientX + 8, 72));
this.menuY.set(evt.clientY);
this.showMenu.set(true);
}
closeMenu(): void {
this.showMenu.set(false);
}
isCurrentContextRoom(): boolean {
const ctx = this.contextRoom();
const cur = this.currentRoom();
return !!ctx && !!cur && ctx.id === cur.id;
}
openLeaveConfirm(): void {
this.closeMenu();
if (this.contextRoom()) {
this.showLeaveConfirm.set(true);
}
}
confirmLeave(result: { nextOwnerKey?: string }): void {
const ctx = this.contextRoom();
if (!ctx)
return;
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
this.store.dispatch(
RoomsActions.forgetRoom({
roomId: ctx.id,
nextOwnerKey: result.nextOwnerKey
})
);
if (isCurrentRoom) {
this.optimisticSelectedRoomId.set(null);
this.router.navigate(['/search']);
}
this.showLeaveConfirm.set(false);
this.contextRoom.set(null);
}
cancelLeave(): void {
this.showLeaveConfirm.set(false);
}
toggleRoomNotifications(): void {
const room = this.contextRoom();
if (!room) {
return;
}
this.notifications.setRoomMuted(room.id, !this.notifications.isRoomMuted(room.id));
this.closeMenu();
}
isRoomNotificationsMuted(roomId: string): boolean {
return this.notifications.isRoomMuted(roomId);
}
roomUnreadCount(roomId: string): number {
return this.notifications.roomUnreadCount(roomId);
}
voicePresenceCount(roomId: string): number {
return this.voicePresenceByRoom()[roomId] ?? 0;
}
formatUnreadCount(count: number): string {
return count > 99 ? '99+' : String(count);
}
isSelectedRoom(room: Room): boolean {
if (this.isOnDirectMessage() || this.isOnCall()) {
return false;
}
const optimisticRoomId = this.optimisticSelectedRoomId();
if (optimisticRoomId) {
return optimisticRoomId === room.id;
}
return this.currentRoom()?.id === room.id;
}
private stabilizeVisibleSavedRooms(nextRooms: Room[]): Room[] {
const previousById = new Map(this.visibleSavedRoomCache.map((room) => [room.id, room]));
const stabilizedRooms = nextRooms.map((room) => {
const previousRoom = previousById.get(room.id);
return previousRoom && this.hasSameRailRoomView(previousRoom, room) ? previousRoom : room;
});
if (
stabilizedRooms.length === this.visibleSavedRoomCache.length
&& stabilizedRooms.every((room, index) => room === this.visibleSavedRoomCache[index])
) {
return this.visibleSavedRoomCache;
}
this.visibleSavedRoomCache = stabilizedRooms;
return stabilizedRooms;
}
private hasSameRailRoomView(previousRoom: Room, nextRoom: Room): boolean {
return previousRoom.id === nextRoom.id && previousRoom.name === nextRoom.name && previousRoom.icon === nextRoom.icon;
}
private isDirectMessageUrl(url: string): boolean {
const path = url.split(/[?#]/, 1)[0];
return path === '/dm' || path.startsWith('/dm/') || path === '/pm' || path.startsWith('/pm/');
}
private async refreshBannedLookup(rooms: Room[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion;
if (!currentUser || rooms.length === 0) {
this.bannedRoomLookup.set({});
return;
}
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
const entries = await Promise.all(
rooms.map(async (room) => {
const bans = await this.db.getBansForRoom(room.id);
return [room.id, hasRoomBanForUser(bans, currentUser, persistedUserId)] as const;
})
);
if (requestVersion !== this.banLookupRequestVersion) {
return;
}
this.bannedRoomLookup.set(Object.fromEntries(entries));
}
private prepareVoiceContext(room: Room): void {
const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId && voiceServerId !== room.id) {
this.voiceSession.setViewingVoiceServer(false);
} else if (voiceServerId === room.id) {
this.voiceSession.setViewingVoiceServer(true);
}
}
private activateSavedRoom(room: Room): void {
this.prepareVoiceContext(room);
this.closePasswordDialog();
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
this.store.dispatch(RoomsActions.viewServer({ room, skipBanCheck: true }));
}
private requestJoinInBackground(room: Room, password?: string) {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
if (!currentUserId)
return EMPTY;
this.joinPasswordError.set(null);
return from(this.resolveRoomJoinTarget(room)).pipe(
switchMap((joinTarget) => {
if (!joinTarget.selector) {
if (this.currentRoom()?.id === room.id) {
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
}
return EMPTY;
}
return this.serverDirectory
.requestJoin(
{
roomId: room.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined
},
joinTarget.selector
)
.pipe(
tap((response) => {
this.closePasswordDialog();
this.store.dispatch(
RoomsActions.updateRoom({
roomId: room.id,
changes: this.toRoomRefreshChanges(joinTarget.room, response.server, response.signalingUrl)
})
);
if (this.currentRoom()?.id === room.id) {
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: false }));
}
})
);
}),
catchError((error: unknown) => {
this.handleBackgroundJoinError(room, error);
return EMPTY;
})
);
}
private handleBackgroundJoinError(room: Room, error: unknown): void {
const serverError = error as {
error?: { error?: string; errorCode?: string };
status?: number;
};
const errorCode = serverError?.error?.errorCode;
const message = serverError?.error?.error || 'Failed to join server';
if (errorCode === 'PASSWORD_REQUIRED') {
this.passwordPromptRoom.set(room);
this.showPasswordDialog.set(true);
this.joinPasswordError.set(message);
return;
}
if (errorCode === 'BANNED') {
this.closePasswordDialog();
this.optimisticSelectedRoomId.set(null);
this.bannedRoomLookup.update((lookup) => ({
...lookup,
[room.id]: true
}));
this.bannedServerName.set(room.name);
this.showBannedDialog.set(true);
return;
}
if (this.shouldFallbackToOfflineView(error) && this.currentRoom()?.id === room.id) {
this.store.dispatch(RoomsActions.setSignalServerReconnecting({ isReconnecting: true }));
}
}
private shouldFallbackToOfflineView(error: unknown): boolean {
const serverError = error as {
error?: { errorCode?: string };
status?: number;
};
const errorCode = serverError?.error?.errorCode;
const status = serverError?.status;
return errorCode === 'SERVER_NOT_FOUND' || status === 0 || status === 404 || (typeof status === 'number' && status >= 500);
}
private toRoomRefreshChanges(room: Room, server: ServerInfo, signalingUrl?: string): Partial<Room> {
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource(
{
sourceId: server.sourceId ?? room.sourceId,
sourceName: server.sourceName ?? room.sourceName,
sourceUrl: server.sourceUrl ?? room.sourceUrl,
signalingUrl,
fallbackName: server.sourceName ?? room.sourceName ?? room.name
},
{
ensureEndpoint: true
}
);
return {
name: server.name,
description: server.description,
topic: server.topic ?? room.topic,
hostId: server.ownerId || room.hostId,
userCount: server.userCount,
maxUsers: server.maxUsers,
hasPassword:
typeof server.hasPassword === 'boolean' ? server.hasPassword : typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: server.isPrivate,
createdAt: server.createdAt,
channels: Array.isArray(server.channels) && server.channels.length > 0 ? server.channels : room.channels,
...resolvedSource
};
}
private async resolveRoomJoinTarget(room: Room): Promise<{
room: Room;
selector: ReturnType<ServerDirectoryFacade['buildRoomSignalSelector']>;
}> {
let resolvedRoom = this.applyResolvedRoomSource(
room,
this.serverDirectory.normaliseRoomSignalSource(
{
sourceId: room.sourceId,
sourceName: room.sourceName,
sourceUrl: room.sourceUrl,
fallbackName: room.sourceName ?? room.name
},
{
ensureEndpoint: !!room.sourceUrl
}
)
);
let selector = this.serverDirectory.buildRoomSignalSelector(
{
sourceId: resolvedRoom.sourceId,
sourceName: resolvedRoom.sourceName,
sourceUrl: resolvedRoom.sourceUrl,
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
},
{
ensureEndpoint: !!resolvedRoom.sourceUrl
}
);
const authoritativeServer = selector ? await firstValueFrom(this.serverDirectory.getServer(room.id, selector)) : null;
if (!authoritativeServer) {
return {
room: resolvedRoom,
selector
};
}
const authoritativeSource = this.serverDirectory.normaliseRoomSignalSource(
{
sourceId: authoritativeServer.sourceId ?? resolvedRoom.sourceId,
sourceName: authoritativeServer.sourceName ?? resolvedRoom.sourceName,
sourceUrl: authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl,
fallbackName: authoritativeServer.sourceName ?? resolvedRoom.sourceName ?? resolvedRoom.name
},
{
ensureEndpoint: !!(authoritativeServer.sourceUrl ?? resolvedRoom.sourceUrl)
}
);
resolvedRoom = this.applyResolvedRoomSource(resolvedRoom, authoritativeSource);
selector = this.serverDirectory.buildRoomSignalSelector(
{
sourceId: resolvedRoom.sourceId,
sourceName: resolvedRoom.sourceName,
sourceUrl: resolvedRoom.sourceUrl,
fallbackName: resolvedRoom.sourceName ?? resolvedRoom.name
},
{
ensureEndpoint: !!resolvedRoom.sourceUrl
}
);
return {
room: resolvedRoom,
selector
};
}
private applyResolvedRoomSource(room: Room, source: Pick<Room, 'sourceId' | 'sourceName' | 'sourceUrl'>): Room {
const nextRoom: Room = {
...room,
sourceId: source.sourceId,
sourceName: source.sourceName,
sourceUrl: source.sourceUrl
};
if (room.sourceId === nextRoom.sourceId && room.sourceName === nextRoom.sourceName && room.sourceUrl === nextRoom.sourceUrl) {
return room;
}
this.store.dispatch(
RoomsActions.updateRoom({
roomId: room.id,
changes: {
sourceId: nextRoom.sourceId,
sourceName: nextRoom.sourceName,
sourceUrl: nextRoom.sourceUrl
}
})
);
return nextRoom;
}
}