feat: server image

This commit is contained in:
2026-04-29 18:54:08 +02:00
parent 3d81c34159
commit e1ac1d1bc0
27 changed files with 1340 additions and 615 deletions

View File

@@ -18,6 +18,8 @@ export interface ServerInfo {
ownerPublicKey?: string;
userCount: number;
maxUsers: number;
icon?: string;
iconUpdatedAt?: number;
hasPassword?: boolean;
isPrivate: boolean;
tags?: string[];

View File

@@ -101,8 +101,16 @@
(dblclick)="openServerCard(server)"
>
<div class="flex min-w-0 items-start gap-3">
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-secondary text-sm font-semibold text-foreground">
{{ server.name[0] || '?' }}
<div class="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-sm font-semibold text-foreground">
@if (server.icon) {
<img
[src]="server.icon"
[alt]="server.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {
{{ server.name[0] || '?' }}
}
</div>
<div class="min-w-0 flex-1">

View File

@@ -1,64 +1,30 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
effect,
inject,
OnInit,
signal
} from '@angular/core';
import { Component, effect, inject, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import {
debounceTime,
distinctUntilChanged,
firstValueFrom,
Subject
} from 'rxjs';
import { debounceTime, distinctUntilChanged, firstValueFrom, Subject } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideSearch,
lucideUsers,
lucideLock,
lucideGlobe,
lucidePlus,
lucideSettings,
lucideChevronDown
} from '@ng-icons/lucide';
import { lucideSearch, lucideUsers, lucideLock, lucideGlobe, lucidePlus, lucideSettings, lucideChevronDown } from '@ng-icons/lucide';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import {
selectSearchResults,
selectIsSearching,
selectRoomsError,
selectSavedRooms
} from '../../../../store/rooms/rooms.selectors';
import { selectSearchResults, selectIsSearching, selectRoomsError, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
import { Room, User } from '../../../../shared-kernel';
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { type ServerInfo } from '../../domain/models/server-directory.model';
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import {
ConfirmDialogComponent,
LeaveServerDialogComponent,
type LeaveServerDialogResult
} from '../../../../shared';
import { ConfirmDialogComponent, LeaveServerDialogComponent, type LeaveServerDialogResult } from '../../../../shared';
import { hasRoomBanForUser } from '../../../access-control';
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
import { RealtimeSessionFacade } from '../../../../core/realtime';
@Component({
selector: 'app-server-search',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent,
LeaveServerDialogComponent,
UserSearchListComponent
],
imports: [CommonModule, FormsModule, NgIcon, ConfirmDialogComponent, LeaveServerDialogComponent, UserSearchListComponent],
viewProviders: [
provideIcons({
lucideSearch,
@@ -82,6 +48,7 @@ export class ServerSearchComponent implements OnInit {
private settingsModal = inject(SettingsModalService);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0;
@@ -118,6 +85,7 @@ export class ServerSearchComponent implements OnInit {
const currentUser = this.currentUser();
void this.refreshBannedLookup(servers, currentUser ?? null);
void this.requestMissingServerIcons(servers, currentUser ?? null);
});
}
@@ -170,8 +138,7 @@ export class ServerSearchComponent implements OnInit {
/** Submit the new server creation form and dispatch the create action. */
createServer(): void {
if (!this.newServerName())
return;
if (!this.newServerName()) return;
const currentUserId = localStorage.getItem('metoyou_currentUserId');
@@ -225,7 +192,7 @@ export class ServerSearchComponent implements OnInit {
toggleJoinedServerMenu(event: Event, server: ServerInfo): void {
event.stopPropagation();
this.joinedServerMenuId.update((currentId) => currentId === server.id ? null : server.id);
this.joinedServerMenuId.update((currentId) => (currentId === server.id ? null : server.id));
}
closeJoinedServerMenu(): void {
@@ -255,10 +222,12 @@ export class ServerSearchComponent implements OnInit {
return;
}
this.store.dispatch(RoomsActions.forgetRoom({
roomId: room.id,
nextOwnerKey: result.nextOwnerKey
}));
this.store.dispatch(
RoomsActions.forgetRoom({
roomId: room.id,
nextOwnerKey: result.nextOwnerKey
})
);
this.leaveDialogRoom.set(null);
}
@@ -278,8 +247,7 @@ export class ServerSearchComponent implements OnInit {
async confirmPasswordJoin(): Promise<void> {
const server = this.passwordPromptServer();
if (!server)
return;
if (!server) return;
await this.attemptJoinServer(server, this.joinPassword());
}
@@ -291,8 +259,7 @@ export class ServerSearchComponent implements OnInit {
getServerUserCount(server: ServerInfo): number {
const candidate = server as ServerInfo & { currentUsers?: number };
if (typeof server.userCount === 'number')
return server.userCount;
if (typeof server.userCount === 'number') return server.userCount;
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
}
@@ -304,9 +271,7 @@ export class ServerSearchComponent implements OnInit {
getServerOwnerLabel(server: ServerInfo): string {
const joinedRoom = this.joinedRoomForServer(server);
const ownerKey = server.ownerId || joinedRoom?.hostId || '';
const ownerMember = joinedRoom?.members?.find((member) =>
member.id === ownerKey || member.oderId === ownerKey
);
const ownerMember = joinedRoom?.members?.find((member) => member.id === ownerKey || member.oderId === ownerKey);
return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || 'Unknown owner';
}
@@ -324,6 +289,8 @@ export class ServerSearchComponent implements OnInit {
hostName: room.hostId || 'Unknown',
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: room.isPrivate,
channels: room.channels,
@@ -348,32 +315,37 @@ export class ServerSearchComponent implements OnInit {
this.joinPasswordError.set(null);
try {
const response = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: server.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined
}, {
sourceId: server.sourceId,
sourceUrl: server.sourceUrl
}));
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource({
sourceId: response.server.sourceId ?? server.sourceId,
sourceName: response.server.sourceName ?? server.sourceName,
sourceUrl: response.server.sourceUrl ?? server.sourceUrl,
signalingUrl: response.signalingUrl,
fallbackName: response.server.sourceName ?? server.sourceName ?? server.name
}, {
ensureEndpoint: true
});
const response = await firstValueFrom(
this.serverDirectory.requestJoin(
{
roomId: server.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined
},
{
sourceId: server.sourceId,
sourceUrl: server.sourceUrl
}
)
);
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource(
{
sourceId: response.server.sourceId ?? server.sourceId,
sourceName: response.server.sourceName ?? server.sourceName,
sourceUrl: response.server.sourceUrl ?? server.sourceUrl,
signalingUrl: response.signalingUrl,
fallbackName: response.server.sourceName ?? server.sourceName ?? server.name
},
{
ensureEndpoint: true
}
);
const resolvedServer = {
...server,
...response.server,
channels:
Array.isArray(response.server.channels) && response.server.channels.length > 0
? response.server.channels
: server.channels,
channels: Array.isArray(response.server.channels) && response.server.channels.length > 0 ? response.server.channels : server.channels,
...resolvedSource,
signalingUrl: response.signalingUrl
};
@@ -409,6 +381,53 @@ export class ServerSearchComponent implements OnInit {
}
}
private async requestMissingServerIcons(servers: ServerInfo[], currentUser: User | null): Promise<void> {
if (!currentUser) {
return;
}
for (const server of servers) {
if (server.icon) {
continue;
}
const selector = this.serverDirectory.buildRoomSignalSelector(
{
sourceId: server.sourceId,
sourceName: server.sourceName,
sourceUrl: server.sourceUrl,
fallbackName: server.sourceName ?? server.name
},
{
ensureEndpoint: !!server.sourceUrl
}
);
if (!selector) {
continue;
}
const wsUrl = this.serverDirectory.getWebSocketUrl(selector);
try {
await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl));
this.webrtc.identify(currentUser.oderId || currentUser.id, currentUser.displayName || 'User', wsUrl, {
description: currentUser.description,
profileUpdatedAt: currentUser.profileUpdatedAt
});
this.webrtc.joinRoom(server.id, currentUser.oderId || currentUser.id, wsUrl);
this.webrtc.sendRawMessage({
type: 'server_icon_sync_request',
serverId: server.id,
iconUpdatedAt: 0
});
window.setTimeout(() => this.webrtc.leaveRoom(server.id), 15_000);
} catch {
/* discovery icons are best-effort */
}
}
}
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion;
@@ -427,8 +446,7 @@ export class ServerSearchComponent implements OnInit {
})
);
if (requestVersion !== this.banLookupRequestVersion)
return;
if (requestVersion !== this.banLookupRequestVersion) return;
this.bannedServerLookup.set(Object.fromEntries(entries));
}
@@ -437,8 +455,7 @@ export class ServerSearchComponent implements OnInit {
const currentUser = this.currentUser();
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUser && !currentUserId)
return false;
if (!currentUser && !currentUserId) return false;
const bans = await this.db.getBansForRoom(server.id);

View File

@@ -6,8 +6,16 @@
>
@if (panelMode() === 'channels') {
<div class="flex items-center gap-3">
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-sm font-semibold text-foreground">
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
<div class="grid h-9 w-9 place-items-center overflow-hidden rounded-md bg-secondary text-sm font-semibold text-foreground">
@if (currentRoom()?.icon) {
<img
[src]="currentRoom()!.icon"
[alt]="currentRoom()!.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
}
</div>
<div class="min-w-0 flex-1">

View File

@@ -43,8 +43,8 @@
<div class="h-full w-full overflow-hidden rounded-[inherit]">
@if (room.icon) {
<img
[ngSrc]="room.icon"
[alt]="room.name"
[src]="room.icon"
[alt]="room.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {

View File

@@ -1,31 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
DestroyRef,
Type,
computed,
effect,
inject,
signal
} from '@angular/core';
import { Component, DestroyRef, Type, computed, effect, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { CommonModule, NgOptimizedImage } from '@angular/common';
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 { lucidePlus } from '@ng-icons/lucide';
import {
EMPTY,
Subject,
catchError,
filter,
firstValueFrom,
from,
map,
switchMap,
tap
} from 'rxjs';
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';
@@ -38,11 +20,7 @@ import { NotificationsFacade } from '../../../domains/notifications';
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';
import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent } from '../../../shared';
@Component({
selector: 'app-servers-rail',
@@ -54,7 +32,6 @@ import {
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent,
NgOptimizedImage,
ThemeNodeDirective,
UserBarComponent
],
@@ -166,8 +143,7 @@ export class ServersRailComponent {
}
initial(name?: string): string {
if (!name)
return '?';
if (!name) return '?';
const ch = name.trim()[0]?.toUpperCase();
@@ -219,8 +195,7 @@ export class ServersRailComponent {
confirmPasswordJoin(): void {
const room = this.passwordPromptRoom();
if (!room)
return;
if (!room) return;
this.joinPasswordError.set(null);
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
@@ -260,8 +235,7 @@ export class ServersRailComponent {
confirmLeave(result: { nextOwnerKey?: string }): void {
const ctx = this.contextRoom();
if (!ctx)
return;
if (!ctx) return;
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
@@ -364,8 +338,7 @@ export class ServersRailComponent {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
if (!currentUserId)
return EMPTY;
if (!currentUserId) return EMPTY;
this.joinPasswordError.set(null);

View File

@@ -3,11 +3,74 @@
<section>
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-3">
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
</p>
<p class="text-xs text-muted-foreground mb-3">You are viewing this server's details without server-management permission.</p>
}
<div class="space-y-4">
<div class="rounded-lg border border-border bg-secondary/40 p-4">
<div class="flex items-center gap-3">
<div class="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-base font-semibold text-foreground">
@if (serverData()?.icon) {
<img
[src]="serverData()!.icon"
[alt]="serverData()!.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {
<ng-icon
name="lucideImage"
class="h-6 w-6 text-muted-foreground"
/>
}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">Server Image</p>
<p class="text-xs text-muted-foreground">Synced to members and shown in server discovery.</p>
@if (iconError()) {
<p class="mt-1 text-xs text-destructive">{{ iconError() }}</p>
}
</div>
@if (canManageIcon()) {
<div class="flex shrink-0 items-center gap-2">
<label
for="server-icon-upload"
class="grid h-9 w-9 cursor-pointer place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Upload image"
aria-label="Upload server image"
>
<ng-icon
name="lucideUpload"
class="h-4 w-4"
/>
</label>
<input
id="server-icon-upload"
type="file"
accept="image/*"
class="sr-only"
(change)="onServerIconSelected($event)"
/>
@if (serverData()?.icon) {
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Remove image"
aria-label="Remove server image"
(click)="removeServerIcon()"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
}
</div>
}
</div>
</div>
<div>
<label
for="room-name"
@@ -191,21 +254,23 @@
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
</button>
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<button
(click)="confirmDeleteRoom()"
type="button"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
>
<ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
@if (canDeleteServer()) {
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<button
(click)="confirmDeleteRoom()"
type="button"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
>
<ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
}
}
</div>

View File

@@ -12,9 +12,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import {
lucideCheck,
lucideImage,
lucideTrash2,
lucideLock,
lucideUnlock
lucideUnlock,
lucideUpload,
lucideX
} from '@ng-icons/lucide';
import { Room } from '../../../../shared-kernel';
@@ -34,9 +37,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
viewProviders: [
provideIcons({
lucideCheck,
lucideImage,
lucideTrash2,
lucideLock,
lucideUnlock
lucideUnlock,
lucideUpload,
lucideX
})
],
templateUrl: './server-settings.component.html'
@@ -49,6 +55,10 @@ export class ServerSettingsComponent {
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
/** Whether the current user can manage this server's icon. */
canManageIcon = input(false);
/** Whether the current user can delete this server. */
canDeleteServer = input(false);
roomName = '';
roomDescription = '';
@@ -59,6 +69,7 @@ export class ServerSettingsComponent {
roomPassword = '';
maxUsers = 0;
showDeleteConfirm = signal(false);
iconError = signal<string | null>(null);
saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -170,6 +181,64 @@ export class ServerSettingsComponent {
this.modal.navigate('network');
}
onServerIconSelected(event: Event): void {
const inputElement = event.target as HTMLInputElement;
const file = inputElement.files?.[0];
inputElement.value = '';
if (!file || !this.canManageIcon()) {
return;
}
if (!file.type.startsWith('image/')) {
this.iconError.set('Choose an image file.');
return;
}
if (file.size > 512 * 1024) {
this.iconError.set('Choose an image smaller than 512 KB.');
return;
}
const reader = new FileReader();
reader.onload = () => {
const room = this.server();
const icon = typeof reader.result === 'string' ? reader.result : '';
if (!room || !icon) {
this.iconError.set('Could not read that image.');
return;
}
this.iconError.set(null);
this.store.dispatch(RoomsActions.updateServerIcon({
roomId: room.id,
icon
}));
this.showSaveSuccess('icon');
};
reader.onerror = () => this.iconError.set('Could not read that image.');
reader.readAsDataURL(file);
}
removeServerIcon(): void {
const room = this.server();
if (!room || !this.canManageIcon()) {
return;
}
this.iconError.set(null);
this.store.dispatch(RoomsActions.updateServerIcon({
roomId: room.id,
icon: ''
}));
this.showSaveSuccess('icon');
}
private showSaveSuccess(key: string): void {
this.saveSuccess.set(key);

View File

@@ -316,7 +316,9 @@
@case ('server') {
<app-server-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerOwner()"
[isAdmin]="canManageSelectedServerSettings()"
[canManageIcon]="canManageSelectedServerIcon()"
[canDeleteServer]="isSelectedServerOwner()"
/>
}
@case ('serverPlugins') {

View File

@@ -165,6 +165,7 @@ export class SettingsModalComponent {
resolveRoomPermission(viewedRoom, user, 'manageServer') ||
resolveRoomPermission(viewedRoom, user, 'manageRoles') ||
resolveRoomPermission(viewedRoom, user, 'manageChannels') ||
resolveRoomPermission(viewedRoom, user, 'manageIcon') ||
resolveRoomPermission(viewedRoom, user, 'manageBans') ||
resolveRoomPermission(viewedRoom, user, 'kickMembers') ||
resolveRoomPermission(viewedRoom, user, 'banMembers')
@@ -208,6 +209,7 @@ export class SettingsModalComponent {
resolveRoomPermission(server, user, 'manageServer') ||
resolveRoomPermission(server, user, 'manageRoles') ||
resolveRoomPermission(server, user, 'manageChannels') ||
resolveRoomPermission(server, user, 'manageIcon') ||
resolveRoomPermission(server, user, 'manageBans') ||
resolveRoomPermission(server, user, 'kickMembers') ||
resolveRoomPermission(server, user, 'banMembers'))
@@ -252,6 +254,20 @@ export class SettingsModalComponent {
return this.selectedServerRole() === 'host';
});
canManageSelectedServerSettings = computed(() => {
const server = this.selectedServer();
const user = this.currentUser();
return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageServer'));
});
canManageSelectedServerIcon = computed(() => {
const server = this.selectedServer();
const user = this.currentUser();
return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageIcon'));
});
isSelectedServerCurrent = computed(() => {
const selectedServerId = this.selectedServerId();
const currentRoomId = this.currentRoom()?.id ?? null;

View File

@@ -34,6 +34,9 @@ export type IncomingSignalingMessage = Omit<Partial<SignalingMessage>, 'type' |
users?: SignalingUserSummary[];
displayName?: string;
fromUserId?: string;
icon?: string;
iconUpdatedAt?: number;
targetUserId?: string;
};
interface IncomingSignalingMessageHandlerDependencies {
@@ -60,9 +63,7 @@ export class IncomingSignalingMessageHandler {
/** Tracks when we first started waiting for a remote-initiated offer from each peer. */
private readonly nonInitiatorWaitStart = new Map<string, number>();
constructor(
private readonly dependencies: IncomingSignalingMessageHandlerDependencies
) {}
constructor(private readonly dependencies: IncomingSignalingMessageHandlerDependencies) {}
handleMessage(message: IncomingSignalingMessage, signalUrl: string): void {
this.dependencies.logger.info('Signaling message', {
@@ -76,6 +77,7 @@ export class IncomingSignalingMessageHandler {
return;
case SIGNALING_TYPE_SERVER_USERS:
case 'server_icon_sync_peers':
this.handleServerUsersSignalingMessage(message, signalUrl);
return;
@@ -138,11 +140,9 @@ export class IncomingSignalingMessageHandler {
}
for (const user of users) {
if (!user.oderId)
continue;
if (!user.oderId) continue;
if (localOderId && user.oderId === localOderId)
continue;
if (localOderId && user.oderId === localOderId) continue;
this.clearUserJoinedFallbackOffer(user.oderId);
@@ -295,9 +295,9 @@ export class IncomingSignalingMessageHandler {
const hasRemainingSharedServers = Array.isArray(message.serverIds)
? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, signalUrl, message.serverIds)
: (message.serverId
: message.serverId
? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, signalUrl, message.serverId)
: false);
: false;
if (!hasRemainingSharedServers) {
this.dependencies.peerManager.removePeer(message.oderId);
@@ -310,11 +310,9 @@ export class IncomingSignalingMessageHandler {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp)
return;
if (!fromUserId || !sdp) return;
if (fromUserId === this.dependencies.getLocalOderId())
return;
if (fromUserId === this.dependencies.getLocalOderId()) return;
this.clearUserJoinedFallbackOffer(fromUserId);
this.nonInitiatorWaitStart.delete(fromUserId);
@@ -334,11 +332,9 @@ export class IncomingSignalingMessageHandler {
const fromUserId = message.fromUserId;
const sdp = message.payload?.sdp;
if (!fromUserId || !sdp)
return;
if (!fromUserId || !sdp) return;
if (fromUserId === this.dependencies.getLocalOderId())
return;
if (fromUserId === this.dependencies.getLocalOderId()) return;
this.clearUserJoinedFallbackOffer(fromUserId);
@@ -350,11 +346,9 @@ export class IncomingSignalingMessageHandler {
const fromUserId = message.fromUserId;
const candidate = message.payload?.candidate;
if (!fromUserId || !candidate)
return;
if (!fromUserId || !candidate) return;
if (fromUserId === this.dependencies.getLocalOderId())
return;
if (fromUserId === this.dependencies.getLocalOderId()) return;
this.clearUserJoinedFallbackOffer(fromUserId);
@@ -513,18 +507,15 @@ export class IncomingSignalingMessageHandler {
}
private shouldInitiatePeer(peerId: string, localOderId: string | null = this.dependencies.getLocalOderId()): boolean {
if (!localOderId)
return false;
if (!localOderId) return false;
if (peerId === localOderId)
return false;
if (peerId === localOderId) return false;
return localOderId < peerId;
}
private hasActivePeerConnection(peer: PeerData | undefined): boolean {
if (!peer)
return false;
if (!peer) return false;
const connectionState = peer.connection?.connectionState;
@@ -532,13 +523,11 @@ export class IncomingSignalingMessageHandler {
}
private isPeerConnectionNegotiating(peer: PeerData | undefined): boolean {
if (!peer || this.hasActivePeerConnection(peer))
return false;
if (!peer || this.hasActivePeerConnection(peer)) return false;
const connectionState = peer.connection?.connectionState;
if (connectionState === 'closed' || connectionState === 'failed')
return false;
if (connectionState === 'closed' || connectionState === 'failed') return false;
const signalingState = peer.connection?.signalingState;
const ageMs = Date.now() - peer.createdAt;
@@ -546,13 +535,11 @@ export class IncomingSignalingMessageHandler {
// If a local offer (or pranswer) has already been sent, the peer is actively
// negotiating with the remote side. Use a much longer grace period so that
// a slow signaling round-trip does not trigger a premature teardown.
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer')
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
if (signalingState === 'have-local-offer' || signalingState === 'have-local-pranswer') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
// ICE negotiation in progress (offer/answer exchange already complete, candidates being checked).
// TURN relay can take 5-15 s on high-latency networks, so use the same extended grace.
if (connectionState === 'connecting')
return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
if (connectionState === 'connecting') return ageMs < PEER_NEGOTIATION_OFFER_SENT_GRACE_MS;
return ageMs < PEER_NEGOTIATION_GRACE_MS;
}

View File

@@ -308,18 +308,29 @@ export class RoomSettingsEffects {
updateServerIcon$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.updateServerIcon),
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
{ roomId, icon },
currentUser,
currentRoom
currentRoom,
savedRooms
]) => {
if (!currentUser || !currentRoom || currentRoom.id !== roomId) {
return of(RoomsActions.updateServerIconFailure({ error: 'Not in room' }));
if (!currentUser) {
return of(RoomsActions.updateServerIconFailure({ error: 'Not logged in' }));
}
const isOwner = currentRoom.hostId === currentUser.id;
const canByRole = resolveRoomPermission(currentRoom, currentUser, 'manageIcon');
const room = resolveRoom(roomId, currentRoom, savedRooms);
if (!room) {
return of(RoomsActions.updateServerIconFailure({ error: 'Room not found' }));
}
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const canByRole = resolveRoomPermission(room, currentUser, 'manageIcon');
if (!isOwner && !canByRole) {
return of(RoomsActions.updateServerIconFailure({ error: 'Permission denied' }));
@@ -329,15 +340,32 @@ export class RoomSettingsEffects {
const changes: Partial<Room> = { icon,
iconUpdatedAt };
this.db.updateRoom(roomId, changes);
this.db.updateRoom(room.id, changes);
this.webrtc.broadcastMessage({
type: 'server-icon-update',
roomId,
roomId: room.id,
icon,
iconUpdatedAt
});
this.webrtc.sendRawMessage({
type: 'server_icon_available',
serverId: room.id,
iconUpdatedAt
});
return of(RoomsActions.updateServerIconSuccess({ roomId,
this.serverDirectory.updateServer(room.id, {
currentOwnerId: currentUser.id,
actingRole: isOwner ? 'host' : undefined,
icon,
iconUpdatedAt
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
return of(RoomsActions.updateServerIconSuccess({ roomId: room.id,
icon,
iconUpdatedAt }));
})

View File

@@ -1,44 +1,17 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Store, type Action } from '@ngrx/store';
import {
of,
from,
EMPTY
} from 'rxjs';
import {
map,
mergeMap,
withLatestFrom,
tap,
switchMap,
catchError
} from 'rxjs/operators';
import { of, from, EMPTY } from 'rxjs';
import { map, mergeMap, withLatestFrom, tap, switchMap, catchError } from 'rxjs/operators';
import { RoomsActions } from './rooms.actions';
import { UsersActions } from '../users/users.actions';
import { selectCurrentUser, selectAllUsers } from '../users/users.selectors';
import {
selectActiveChannelId,
selectCurrentRoom,
selectSavedRooms
} from './rooms.selectors';
import { selectActiveChannelId, selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
import { resolveRoomPermission } from '../../domains/access-control';
import type {
ChatEvent,
Room,
RoomSettings,
RoomPermissions,
BanEntry,
User,
VoiceState
} from '../../shared-kernel';
import type { ChatEvent, Room, RoomSettings, RoomPermissions, BanEntry, User, VoiceState } from '../../shared-kernel';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { hasRoomBanForUser } from '../../domains/access-control';
import { RECONNECT_SOUND_GRACE_MS } from '../../core/constants';
@@ -55,6 +28,8 @@ import {
} from './rooms.helpers';
import type { RoomPresenceSignalingMessage } from './rooms.helpers';
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [1_500, 3_000, 5_000, 8_000];
/**
* NgRx effects for real-time state synchronisation: signaling presence
* events (server_users, user_joined, user_left, access_denied), P2P
@@ -75,6 +50,7 @@ export class RoomStateSyncEffects {
* preventing false join/leave sounds during state refreshes.
*/
private knownVoiceUsers = new Set<string>();
private pendingServerIconRequestsByPeer = new Map<string, Set<string>>();
/**
* When a user leaves (e.g. socket drops), record the timestamp so
* that a rapid re-join (reconnect) does not trigger a false
@@ -87,17 +63,8 @@ export class RoomStateSyncEffects {
/** Handles WebRTC signaling events for user presence (join, leave, server_users). */
signalingMessages$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe(
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
message,
currentUser,
currentRoom,
savedRooms
]) => {
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms)),
mergeMap(([message, currentUser, currentRoom, savedRooms]) => {
const signalingMessage: RoomPresenceSignalingMessage = message;
const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id;
@@ -106,8 +73,7 @@ export class RoomStateSyncEffects {
switch (signalingMessage.type) {
case 'server_users': {
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId)
return EMPTY;
if (!Array.isArray(signalingMessage.users) || !signalingMessage.serverId) return EMPTY;
const syncedUsers = signalingMessage.users
.filter((user) => user.oderId !== myId)
@@ -136,11 +102,9 @@ export class RoomStateSyncEffects {
}
case 'user_joined': {
if (!signalingMessage.serverId || signalingMessage.oderId === myId)
return EMPTY;
if (!signalingMessage.serverId || signalingMessage.oderId === myId) return EMPTY;
if (!signalingMessage.oderId)
return EMPTY;
if (!signalingMessage.oderId) return EMPTY;
const joinedUser = {
oderId: signalingMessage.oderId,
@@ -168,12 +132,9 @@ export class RoomStateSyncEffects {
}
case 'user_left': {
if (!signalingMessage.oderId)
return EMPTY;
if (!signalingMessage.oderId) return EMPTY;
const remainingServerIds = Array.isArray(signalingMessage.serverIds)
? signalingMessage.serverIds
: undefined;
const remainingServerIds = Array.isArray(signalingMessage.serverIds) ? signalingMessage.serverIds : undefined;
if (!remainingServerIds || remainingServerIds.length === 0) {
if (this.knownVoiceUsers.has(signalingMessage.oderId)) {
@@ -199,24 +160,15 @@ export class RoomStateSyncEffects {
}
case 'status_update': {
if (!signalingMessage.oderId || !signalingMessage.status)
return EMPTY;
if (!signalingMessage.oderId || !signalingMessage.status) return EMPTY;
const validStatuses = [
'online',
'away',
'busy',
'offline'
];
const validStatuses = ['online', 'away', 'busy', 'offline'];
if (!validStatuses.includes(signalingMessage.status))
return EMPTY;
if (!validStatuses.includes(signalingMessage.status)) return EMPTY;
// 'offline' from the server means the user chose Invisible;
// display them as disconnected to other users.
const mappedStatus = signalingMessage.status === 'offline'
? 'disconnected'
: signalingMessage.status as 'online' | 'away' | 'busy';
const mappedStatus = signalingMessage.status === 'offline' ? 'disconnected' : (signalingMessage.status as 'online' | 'away' | 'busy');
return [
UsersActions.updateRemoteUserStatus({
@@ -227,21 +179,75 @@ export class RoomStateSyncEffects {
}
case 'access_denied': {
if (isWrongServer(signalingMessage.serverId, viewedServerId))
return EMPTY;
if (isWrongServer(signalingMessage.serverId, viewedServerId)) return EMPTY;
if (signalingMessage.reason !== 'SERVER_NOT_FOUND')
return EMPTY;
if (signalingMessage.reason !== 'SERVER_NOT_FOUND') return EMPTY;
// When multiple signal URLs are configured, the room may already
// be successfully joined on a different signal server. Only show
// the reconnect notice when the room is not reachable at all.
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId))
return EMPTY;
if (signalingMessage.serverId && this.webrtc.hasJoinedServer(signalingMessage.serverId)) return EMPTY;
return [RoomsActions.setSignalServerReconnecting({ isReconnecting: true })];
}
case 'server_icon_sync_peers': {
if (!signalingMessage.serverId || !Array.isArray(signalingMessage.users)) {
return EMPTY;
}
const serverId = signalingMessage.serverId;
for (const user of signalingMessage.users) {
if (!user.oderId || user.oderId === myId) {
continue;
}
this.queueServerIconSyncRequest(user.oderId, serverId);
this.webrtc.sendRawMessage({
type: 'server_icon_peer_request',
targetUserId: user.oderId,
serverId
});
}
return EMPTY;
}
case 'server_icon_peer_request': {
const serverId = signalingMessage.serverId;
const targetUserId = signalingMessage.fromUserId;
const room = resolveRoom(serverId, currentRoom, savedRooms);
if (!serverId || !targetUserId || !room?.icon) {
return EMPTY;
}
this.webrtc.sendRawMessage({
type: 'server_icon_peer_data',
targetUserId,
serverId,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt || 0
});
return EMPTY;
}
case 'server_icon_peer_data': {
if (!signalingMessage.serverId || typeof signalingMessage.icon !== 'string') {
return EMPTY;
}
return of(
RoomsActions.receiveSearchServerIcon({
roomId: signalingMessage.serverId,
icon: signalingMessage.icon,
iconUpdatedAt: signalingMessage.iconUpdatedAt || Date.now()
})
);
}
default:
return EMPTY;
}
@@ -257,8 +263,7 @@ export class RoomStateSyncEffects {
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([peerId, room]) => {
if (!room)
return;
if (!room) return;
this.webrtc.sendToPeer(peerId, {
type: 'server-state-request',
@@ -273,12 +278,16 @@ export class RoomStateSyncEffects {
roomEntryServerStateSync$ = createEffect(
() =>
this.actions$.pipe(
ofType(
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess
),
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
tap(({ room }) => {
if (room.iconUpdatedAt) {
this.webrtc.sendRawMessage({
type: 'server_icon_available',
serverId: room.id,
iconUpdatedAt: room.iconUpdatedAt
});
}
for (const peerId of this.webrtc.getConnectedPeers()) {
try {
this.webrtc.sendToPeer(peerId, {
@@ -304,14 +313,7 @@ export class RoomStateSyncEffects {
this.store.select(selectCurrentUser),
this.store.select(selectActiveChannelId)
),
mergeMap(([
event,
currentRoom,
savedRooms,
allUsers,
currentUser,
activeChannelId
]) => {
mergeMap(([event, currentRoom, savedRooms, allUsers, currentUser, activeChannelId]) => {
switch (event.type) {
case 'voice-state':
return this.handleVoiceOrScreenState(event, allUsers, currentUser ?? null, 'voice');
@@ -351,8 +353,7 @@ export class RoomStateSyncEffects {
this.webrtc.onPeerConnected.pipe(
withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([_peerId, room]) => {
if (!room)
return;
if (!room) return;
const iconUpdatedAt = room.iconUpdatedAt || 0;
@@ -366,18 +367,29 @@ export class RoomStateSyncEffects {
{ dispatch: false }
);
/** Sends queued discovery icon requests as soon as a temporary peer channel opens. */
peerConnectedDiscoveryIconSync$ = createEffect(
() =>
this.webrtc.onPeerConnected.pipe(
tap((peerId) => {
const serverIds = this.pendingServerIconRequestsByPeer.get(peerId);
if (!serverIds) return;
for (const serverId of serverIds) {
this.sendServerIconSyncRequest(peerId, serverId);
}
})
),
{ dispatch: false }
);
// ── Voice / Screen / Camera handlers ───────────────────────────
private handleVoiceOrScreenState(
event: ChatEvent,
allUsers: User[],
currentUser: User | null,
kind: 'voice' | 'screen' | 'camera'
) {
private handleVoiceOrScreenState(event: ChatEvent, allUsers: User[], currentUser: User | null, kind: 'voice' | 'screen' | 'camera') {
const userId: string | undefined = event.fromPeerId ?? event.oderId;
if (!userId)
return EMPTY;
if (!userId) return EMPTY;
const existingUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
const userExists = !!existingUser;
@@ -385,18 +397,17 @@ export class RoomStateSyncEffects {
if (kind === 'voice') {
const vs = event.voiceState as Partial<VoiceState> | undefined;
if (!vs)
return EMPTY;
if (!vs) return EMPTY;
const presenceRefreshAction = vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
? UsersActions.userJoined({
user: buildSignalingUser(
{ oderId: userId,
displayName: event.displayName || existingUser?.displayName || 'User' },
{ presenceServerIds: [vs.serverId] }
)
})
: null;
const presenceRefreshAction =
vs.serverId && !existingUser?.presenceServerIds?.includes(vs.serverId)
? UsersActions.userJoined({
user: buildSignalingUser(
{ oderId: userId, displayName: event.displayName || existingUser?.displayName || 'User' },
{ presenceServerIds: [vs.serverId] }
)
})
: null;
// Detect voice-connection transitions to play join/leave sounds.
const weAreInVoice = this.webrtc.isVoiceConnected();
const nowConnected = vs.isConnected ?? false;
@@ -427,8 +438,7 @@ export class RoomStateSyncEffects {
return of(
UsersActions.userJoined({
user: buildSignalingUser(
{ oderId: userId,
displayName: event.displayName || 'User' },
{ oderId: userId, displayName: event.displayName || 'User' },
{
presenceServerIds: vs.serverId ? [vs.serverId] : undefined,
voiceState: {
@@ -453,8 +463,7 @@ export class RoomStateSyncEffects {
actions.push(presenceRefreshAction);
}
actions.push(UsersActions.updateVoiceState({ userId,
voiceState: vs }));
actions.push(UsersActions.updateVoiceState({ userId, voiceState: vs }));
return actions;
}
@@ -462,17 +471,12 @@ export class RoomStateSyncEffects {
if (kind === 'screen') {
const isSharing = event.isScreenSharing as boolean | undefined;
if (isSharing === undefined)
return EMPTY;
if (isSharing === undefined) return EMPTY;
if (!userExists) {
return of(
UsersActions.userJoined({
user: buildSignalingUser(
{ oderId: userId,
displayName: event.displayName || 'User' },
{ screenShareState: { isSharing } }
)
user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { screenShareState: { isSharing } })
})
);
}
@@ -487,17 +491,12 @@ export class RoomStateSyncEffects {
const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
if (isCameraEnabled === undefined)
return EMPTY;
if (isCameraEnabled === undefined) return EMPTY;
if (!userExists) {
return of(
UsersActions.userJoined({
user: buildSignalingUser(
{ oderId: userId,
displayName: event.displayName || 'User' },
{ cameraState: { isEnabled: isCameraEnabled } }
)
user: buildSignalingUser({ oderId: userId, displayName: event.displayName || 'User' }, { cameraState: { isEnabled: isCameraEnabled } })
})
);
}
@@ -510,12 +509,7 @@ export class RoomStateSyncEffects {
);
}
private handleVoiceChannelMove(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: User | null
) {
private handleVoiceChannelMove(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: User | null) {
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : null;
const serverId = typeof event.roomId === 'string' ? event.roomId : currentUser?.voiceState?.serverId;
const nextVoiceState = event.voiceState as Partial<VoiceState> | undefined;
@@ -566,22 +560,23 @@ export class RoomStateSyncEffects {
voiceState: updatedVoiceState
});
return of(UsersActions.updateVoiceState({
userId: currentUser.id,
voiceState: updatedVoiceState
}));
return of(
UsersActions.updateVoiceState({
userId: currentUser.id,
voiceState: updatedVoiceState
})
);
}
private isSameVoiceRoom(
voiceState: Partial<VoiceState> | undefined,
currentUserVoiceState: Partial<VoiceState> | undefined
): boolean {
return !!voiceState?.isConnected
&& !!currentUserVoiceState?.isConnected
&& !!voiceState.roomId
&& !!voiceState.serverId
&& voiceState.roomId === currentUserVoiceState.roomId
&& voiceState.serverId === currentUserVoiceState.serverId;
private isSameVoiceRoom(voiceState: Partial<VoiceState> | undefined, currentUserVoiceState: Partial<VoiceState> | undefined): boolean {
return (
!!voiceState?.isConnected &&
!!currentUserVoiceState?.isConnected &&
!!voiceState.roomId &&
!!voiceState.serverId &&
voiceState.roomId === currentUserVoiceState.roomId &&
voiceState.serverId === currentUserVoiceState.serverId
);
}
/**
@@ -614,8 +609,7 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms);
const fromPeerId = event.fromPeerId;
if (!room || !fromPeerId)
return EMPTY;
if (!room || !fromPeerId) return EMPTY;
return from(this.db.getBansForRoom(room.id)).pipe(
tap((bans) => {
@@ -630,18 +624,12 @@ export class RoomStateSyncEffects {
);
}
private handleServerStateFull(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
currentUser: { id: string; oderId: string } | null
) {
private handleServerStateFull(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], currentUser: { id: string; oderId: string } | null) {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms);
const incomingRoom = event.room as Partial<Room> | undefined;
if (!room || !incomingRoom)
return EMPTY;
if (!room || !incomingRoom) return EMPTY;
const roomChanges = {
...sanitizeRoomSnapshot(incomingRoom),
@@ -651,19 +639,17 @@ export class RoomStateSyncEffects {
return this.syncBansToLocalRoom(room.id, bans).pipe(
mergeMap(() => {
const actions: (ReturnType<typeof RoomsActions.updateRoom>
const actions: (
| ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.loadBansSuccess>
| ReturnType<typeof RoomsActions.forgetRoom>)[] = [
| ReturnType<typeof RoomsActions.forgetRoom>
)[] = [
RoomsActions.updateRoom({
roomId: room.id,
changes: roomChanges
})
];
const isCurrentUserBanned = hasRoomBanForUser(
bans,
currentUser,
getPersistedCurrentUserId()
);
const isCurrentUserBanned = hasRoomBanForUser(bans, currentUser, getPersistedCurrentUserId());
if (currentRoom?.id === room.id) {
actions.push(UsersActions.loadBansSuccess({ bans }));
@@ -684,8 +670,7 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms);
const settings = event.settings as Partial<RoomSettings> | undefined;
if (!room || !settings)
return EMPTY;
if (!room || !settings) return EMPTY;
return of(
RoomsActions.updateRoom({
@@ -699,7 +684,9 @@ export class RoomStateSyncEffects {
hasPassword:
typeof settings.hasPassword === 'boolean'
? settings.hasPassword
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
: typeof room.hasPassword === 'boolean'
? room.hasPassword
: !!room.password,
maxUsers: settings.maxUsers ?? room.maxUsers
}
})
@@ -712,17 +699,13 @@ export class RoomStateSyncEffects {
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
const incomingRoom = event.room as Partial<Room> | undefined;
if (!room || (!permissions && !incomingRoom))
return EMPTY;
if (!room || (!permissions && !incomingRoom)) return EMPTY;
return of(
RoomsActions.updateRoom({
roomId: room.id,
changes: {
permissions: permissions
? { ...(room.permissions || {}),
...permissions } as RoomPermissions
: room.permissions,
permissions: permissions ? ({ ...(room.permissions || {}), ...permissions } as RoomPermissions) : room.permissions,
roles: Array.isArray(incomingRoom?.roles) ? incomingRoom.roles : room.roles,
roleAssignments: Array.isArray(incomingRoom?.roleAssignments) ? incomingRoom.roleAssignments : room.roleAssignments,
channelPermissions: Array.isArray(incomingRoom?.channelPermissions) ? incomingRoom.channelPermissions : room.channelPermissions,
@@ -732,12 +715,7 @@ export class RoomStateSyncEffects {
);
}
private handleChannelsUpdate(
event: ChatEvent,
currentRoom: Room | null,
savedRooms: Room[],
activeChannelId: string
): Action[] {
private handleChannelsUpdate(event: ChatEvent, currentRoom: Room | null, savedRooms: Room[], activeChannelId: string): Action[] {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms);
const channels = Array.isArray(event.channels) ? event.channels : null;
@@ -754,8 +732,7 @@ export class RoomStateSyncEffects {
];
if (!channels.some((channel) => channel.id === activeChannelId)) {
const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id
?? 'general';
const fallbackChannelId = channels.find((channel) => channel.type === 'text')?.id ?? 'general';
actions.push(RoomsActions.selectChannel({ channelId: fallbackChannelId }));
}
@@ -769,8 +746,7 @@ export class RoomStateSyncEffects {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return EMPTY;
if (!room) return EMPTY;
const remoteUpdated = event.iconUpdatedAt || 0;
const localUpdated = room.iconUpdatedAt || 0;
@@ -789,8 +765,7 @@ export class RoomStateSyncEffects {
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
const room = resolveRoom(roomId, currentRoom, savedRooms);
if (!room)
return EMPTY;
if (!room) return EMPTY;
if (event.fromPeerId) {
this.webrtc.sendToPeer(event.fromPeerId, {
@@ -809,20 +784,17 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms);
const senderId = event.fromPeerId;
if (!room || typeof event.icon !== 'string' || !senderId)
return EMPTY;
if (!room || typeof event.icon !== 'string' || !senderId) return this.handleSearchResultIconData(event, roomId);
return this.store.select(selectAllUsers).pipe(
map((users) => users.find((user) => user.id === senderId)),
mergeMap((sender) => {
if (!sender)
return EMPTY;
if (!sender) return EMPTY;
const isOwner = room.hostId === sender.id;
const canByRole = resolveRoomPermission(room, sender, 'manageIcon');
if (!isOwner && !canByRole)
return EMPTY;
if (!isOwner && !canByRole) return EMPTY;
const updates: Partial<Room> = {
icon: event.icon,
@@ -830,23 +802,63 @@ export class RoomStateSyncEffects {
};
this.db.updateRoom(room.id, updates);
return of(RoomsActions.updateRoom({ roomId: room.id,
changes: updates }));
this.webrtc.sendRawMessage({
type: 'server_icon_available',
serverId: room.id,
iconUpdatedAt: updates.iconUpdatedAt
});
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
})
);
}
private handleSearchResultIconData(event: ChatEvent, roomId: string | undefined) {
if (!roomId || typeof event.icon !== 'string') {
return EMPTY;
}
const iconUpdatedAt = event.iconUpdatedAt || Date.now();
return of(
RoomsActions.receiveSearchServerIcon({
roomId,
icon: event.icon,
iconUpdatedAt
})
);
}
private queueServerIconSyncRequest(peerId: string, serverId: string): void {
const pendingServerIds = this.pendingServerIconRequestsByPeer.get(peerId) ?? new Set<string>();
pendingServerIds.add(serverId);
this.pendingServerIconRequestsByPeer.set(peerId, pendingServerIds);
this.scheduleServerIconSyncRequests(peerId, serverId);
}
private scheduleServerIconSyncRequests(peerId: string, serverId: string): void {
for (const delayMs of SERVER_ICON_SYNC_REQUEST_DELAYS_MS) {
setTimeout(() => {
this.sendServerIconSyncRequest(peerId, serverId);
}, delayMs);
}
}
private sendServerIconSyncRequest(peerId: string, serverId: string): void {
this.webrtc.sendToPeer(peerId, {
type: 'server-icon-request',
roomId: serverId
});
}
// ── Internal helpers ───────────────────────────────────────────
private syncBansToLocalRoom(roomId: string, bans: BanEntry[]) {
return from(this.db.getBansForRoom(roomId)).pipe(
switchMap((localBans) => {
const nextIds = new Set(bans.map((ban) => ban.oderId));
const removals = localBans
.filter((ban) => !nextIds.has(ban.oderId))
.map((ban) => this.db.removeBan(ban.oderId));
const saves = bans.map((ban) => this.db.saveBan({ ...ban,
roomId }));
const removals = localBans.filter((ban) => !nextIds.has(ban.oderId)).map((ban) => this.db.removeBan(ban.oderId));
const saves = bans.map((ban) => this.db.saveBan({ ...ban, roomId }));
return from(Promise.all([...removals, ...saves]));
})

View File

@@ -72,6 +72,7 @@ export const RoomsActions = createActionGroup({
'Update Server Icon': props<{ roomId: string; icon: string }>(),
'Update Server Icon Success': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
'Update Server Icon Failure': props<{ error: string }>(),
'Receive Search Server Icon': props<{ roomId: string; icon: string; iconUpdatedAt: number }>(),
'Set Current Room': props<{ room: Room }>(),
'Clear Current Room': emptyProps(),

View File

@@ -229,6 +229,8 @@ export class RoomsEffects {
isPrivate: room.isPrivate,
userCount: 1,
maxUsers: room.maxUsers || 50,
icon: room.icon,
iconUpdatedAt: room.iconUpdatedAt,
tags: [],
channels: room.channels ?? defaultChannels()
}, endpoint ? {
@@ -288,6 +290,8 @@ export class RoomsEffects {
const resolvedRoom: Room = {
...room,
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
icon: serverInfo?.icon ?? room.icon,
iconUpdatedAt: serverInfo?.iconUpdatedAt ?? room.iconUpdatedAt,
channels: resolveRoomChannels(room.channels, serverInfo?.channels),
slowModeInterval: serverInfo?.slowModeInterval ?? room.slowModeInterval,
roles: serverInfo?.roles ?? room.roles,
@@ -309,6 +313,8 @@ export class RoomsEffects {
roles: resolvedRoom.roles,
roleAssignments: resolvedRoom.roleAssignments,
channelPermissions: resolvedRoom.channelPermissions,
icon: resolvedRoom.icon,
iconUpdatedAt: resolvedRoom.iconUpdatedAt,
hasPassword: resolvedRoom.hasPassword,
isPrivate: resolvedRoom.isPrivate
});
@@ -337,6 +343,8 @@ export class RoomsEffects {
createdAt: Date.now(),
userCount: 1,
maxUsers: 50,
icon: serverInfo.icon,
iconUpdatedAt: serverInfo.iconUpdatedAt,
channels: resolveRoomChannels(undefined, serverInfo.channels),
slowModeInterval: serverInfo.slowModeInterval,
roles: serverInfo.roles,
@@ -372,6 +380,8 @@ export class RoomsEffects {
createdAt: serverData.createdAt || Date.now(),
userCount: serverData.userCount,
maxUsers: serverData.maxUsers,
icon: serverData.icon,
iconUpdatedAt: serverData.iconUpdatedAt,
channels: resolveRoomChannels(undefined, serverData.channels),
slowModeInterval: serverData.slowModeInterval,
roles: serverData.roles,
@@ -557,6 +567,8 @@ export class RoomsEffects {
hasPassword: !!serverData.hasPassword,
isPrivate: serverData.isPrivate,
maxUsers: serverData.maxUsers,
icon: serverData.icon ?? room.icon,
iconUpdatedAt: serverData.iconUpdatedAt ?? room.iconUpdatedAt,
channels: resolveRoomChannels(room.channels, serverData.channels),
slowModeInterval: serverData.slowModeInterval ?? room.slowModeInterval,
roles: serverData.roles ?? room.roles,

View File

@@ -1,30 +1,18 @@
import { v4 as uuidv4 } from 'uuid';
import {
Room,
BanEntry,
User
} from '../../shared-kernel';
import { Room, BanEntry, User } from '../../shared-kernel';
import { resolveLegacyRole, resolveRoomPermission } from '../../domains/access-control';
import { findRoomMember } from './room-members.helpers';
import { ROOM_URL_PATTERN } from '../../core/constants';
/** Build a minimal User object from signaling payload. */
export function buildSignalingUser(
data: { oderId: string; displayName?: string; status?: string },
extras: Record<string, unknown> = {}
) {
export function buildSignalingUser(data: { oderId: string; displayName?: string; status?: string }, extras: Record<string, unknown> = {}) {
const displayName = data.displayName?.trim() || 'User';
const rawStatus = ([
'online',
'away',
'busy',
'offline'
] as const).includes(data.status as 'online')
? data.status as 'online' | 'away' | 'busy' | 'offline'
const rawStatus = (['online', 'away', 'busy', 'offline'] as const).includes(data.status as 'online')
? (data.status as 'online' | 'away' | 'busy' | 'offline')
: 'online';
// 'offline' from the server means the user chose Invisible;
// display them as disconnected to other users.
const status = rawStatus === 'offline' ? 'disconnected' as const : rawStatus;
const status = rawStatus === 'offline' ? ('disconnected' as const) : rawStatus;
return {
oderId: data.oderId,
@@ -43,8 +31,7 @@ export function buildSignalingUser(
export function buildKnownUserExtras(room: Room | null, identifier: string): Record<string, unknown> {
const knownMember = room ? findRoomMember(room.members ?? [], identifier) : undefined;
if (!knownMember)
return {};
if (!knownMember) return {};
return {
username: knownMember.username,
@@ -60,10 +47,7 @@ export function buildKnownUserExtras(room: Room | null, identifier: string): Rec
}
/** Returns true when the message's server ID does not match the viewed server. */
export function isWrongServer(
msgServerId: string | undefined,
viewedServerId: string | undefined
): boolean {
export function isWrongServer(msgServerId: string | undefined, viewedServerId: string | undefined): boolean {
return !!(msgServerId && viewedServerId && msgServerId !== viewedServerId);
}
@@ -110,9 +94,7 @@ export function reconcileRoomSnapshotChannels(
}
if (hasPersistedChannels(cachedChannels) && hasPersistedChannels(incomingChannels)) {
return incomingChannels.length >= cachedChannels.length
? incomingChannels
: cachedChannels;
return incomingChannels.length >= cachedChannels.length ? incomingChannels : cachedChannels;
}
if (hasPersistedChannels(incomingChannels)) {
@@ -122,10 +104,7 @@ export function reconcileRoomSnapshotChannels(
return undefined;
}
export function resolveTextChannelId(
channels: Room['channels'] | undefined,
preferredChannelId?: string | null
): string | null {
export function resolveTextChannelId(channels: Room['channels'] | undefined, preferredChannelId?: string | null): string | null {
const textChannels = (channels ?? []).filter((channel) => channel.type === 'text');
if (preferredChannelId && textChannels.some((channel) => channel.id === preferredChannelId)) {
@@ -136,11 +115,9 @@ export function resolveTextChannelId(
}
export function resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
if (!roomId)
return currentRoom;
if (!roomId) return currentRoom;
if (currentRoom?.id === roomId)
return currentRoom;
if (currentRoom?.id === roomId) return currentRoom;
return savedRooms.find((room) => room.id === roomId) ?? null;
}
@@ -152,9 +129,7 @@ export function sanitizeRoomSnapshot(room: Partial<Room>): Partial<Room> {
topic: typeof room.topic === 'string' ? room.topic : undefined,
hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
hasPassword:
typeof room.hasPassword === 'boolean'
? room.hasPassword
: (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined),
typeof room.hasPassword === 'boolean' ? room.hasPassword : typeof room.password === 'string' ? room.password.trim().length > 0 : undefined,
isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined,
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
icon: typeof room.icon === 'string' ? room.icon : undefined,
@@ -173,8 +148,7 @@ export function sanitizeRoomSnapshot(room: Partial<Room>): Partial<Room> {
}
export function normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] {
if (!Array.isArray(bans))
return [];
if (!Array.isArray(bans)) return [];
const now = Date.now();
@@ -225,6 +199,9 @@ export interface RoomPresenceSignalingMessage {
oderId?: string;
displayName?: string;
description?: string;
fromUserId?: string;
icon?: string;
iconUpdatedAt?: number;
profileUpdatedAt?: number;
status?: string;
}

View File

@@ -4,11 +4,7 @@ import { normalizeRoomAccessControl } from '../../domains/access-control';
import { type ServerInfo } from '../../domains/server-directory';
import { RoomsActions } from './rooms.actions';
import { defaultChannels } from './room-channels.defaults';
import {
isChannelNameTaken,
normalizeChannelName,
normalizeRoomChannels
} from './room-channels.rules';
import { isChannelNameTaken, normalizeChannelName, normalizeRoomChannels } from './room-channels.rules';
import { pruneRoomMembers } from './room-members.helpers';
/** Deduplicate rooms by id, keeping the last occurrence */
@@ -35,9 +31,7 @@ function enrichRoom(room: Room): Room {
function resolveActiveTextChannelId(channels: Room['channels'], currentActiveChannelId: string): string {
const textChannels = (channels ?? []).filter((channel) => channel.type === 'text');
return textChannels.some((channel) => channel.id === currentActiveChannelId)
? currentActiveChannelId
: (textChannels[0]?.id ?? 'general');
return textChannels.some((channel) => channel.id === currentActiveChannelId) ? currentActiveChannelId : (textChannels[0]?.id ?? 'general');
}
function getDefaultTextChannelId(room: Room): string {
@@ -47,7 +41,7 @@ function getDefaultTextChannelId(room: Room): string {
/** Upsert a room into a saved-rooms list (add or replace by id) */
function upsertRoom(savedRooms: Room[], room: Room): Room[] {
const normalizedRoom = enrichRoom(room);
const idx = savedRooms.findIndex(existingRoom => existingRoom.id === room.id);
const idx = savedRooms.findIndex((existingRoom) => existingRoom.id === room.id);
if (idx >= 0) {
const updated = [...savedRooms];
@@ -250,8 +244,7 @@ export const roomsReducer = createReducer(
})),
on(RoomsActions.updateRoomSettingsSuccess, (state, { roomId, settings }) => {
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId)
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
if (!baseRoom) {
return {
@@ -270,9 +263,9 @@ export const roomsReducer = createReducer(
hasPassword:
typeof settings.hasPassword === 'boolean'
? settings.hasPassword
: (typeof settings.password === 'string'
: typeof settings.password === 'string'
? settings.password.trim().length > 0
: baseRoom.hasPassword),
: baseRoom.hasPassword,
maxUsers: settings.maxUsers
});
@@ -330,33 +323,28 @@ export const roomsReducer = createReducer(
// Update room
on(RoomsActions.updateRoom, (state, { roomId, changes }) => {
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId)
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
if (!baseRoom)
return state;
if (!baseRoom) return state;
const updatedRoom = enrichRoom({ ...baseRoom,
...changes });
const updatedRoom = enrichRoom({ ...baseRoom, ...changes });
return {
...state,
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
savedRooms: upsertRoom(state.savedRooms, updatedRoom),
activeChannelId: state.currentRoom?.id === roomId
? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId)
: state.activeChannelId
activeChannelId:
state.currentRoom?.id === roomId ? resolveActiveTextChannelId(updatedRoom.channels, state.activeChannelId) : state.activeChannelId
};
}),
// Update server icon success
on(RoomsActions.updateServerIconSuccess, (state, { roomId, icon, iconUpdatedAt }) => {
if (state.currentRoom?.id !== roomId)
return state;
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId) || (state.currentRoom?.id === roomId ? state.currentRoom : null);
const updatedRoom = enrichRoom({ ...state.currentRoom,
icon,
iconUpdatedAt });
if (!baseRoom) return state;
const updatedRoom = enrichRoom({ ...baseRoom, icon, iconUpdatedAt });
return {
...state,
@@ -365,13 +353,18 @@ export const roomsReducer = createReducer(
};
}),
on(RoomsActions.receiveSearchServerIcon, (state, { roomId, icon, iconUpdatedAt }) => ({
...state,
searchResults: state.searchResults.map((server) =>
server.id === roomId && (!server.icon || (server.iconUpdatedAt ?? 0) < iconUpdatedAt) ? { ...server, icon, iconUpdatedAt } : server
)
})),
// Receive room update
on(RoomsActions.receiveRoomUpdate, (state, { room }) => {
if (!state.currentRoom)
return state;
if (!state.currentRoom) return state;
const updatedRoom = enrichRoom({ ...state.currentRoom,
...room });
const updatedRoom = enrichRoom({ ...state.currentRoom, ...room });
return {
...state,
@@ -410,27 +403,17 @@ export const roomsReducer = createReducer(
})),
on(RoomsActions.addChannel, (state, { channel }) => {
if (!state.currentRoom)
return state;
if (!state.currentRoom) return state;
const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(channel.name);
if (
!normalizedName
|| existing.some((entry) => entry.id === channel.id)
|| isChannelNameTaken(existing, normalizedName, channel.type)
) {
if (!normalizedName || existing.some((entry) => entry.id === channel.id) || isChannelNameTaken(existing, normalizedName, channel.type)) {
return state;
}
const updatedChannels = [
...existing,
{ ...channel,
name: normalizedName }
];
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
const updatedChannels = [...existing, { ...channel, name: normalizedName }];
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return {
...state,
@@ -441,13 +424,11 @@ export const roomsReducer = createReducer(
}),
on(RoomsActions.removeChannel, (state, { channelId }) => {
if (!state.currentRoom)
return state;
if (!state.currentRoom) return state;
const existing = state.currentRoom.channels || defaultChannels();
const updatedChannels = existing.filter(channel => channel.id !== channelId);
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
const updatedChannels = existing.filter((channel) => channel.id !== channelId);
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return {
...state,
@@ -458,8 +439,7 @@ export const roomsReducer = createReducer(
}),
on(RoomsActions.renameChannel, (state, { channelId, name }) => {
if (!state.currentRoom)
return state;
if (!state.currentRoom) return state;
const existing = state.currentRoom.channels || defaultChannels();
const normalizedName = normalizeChannelName(name);
@@ -469,10 +449,8 @@ export const roomsReducer = createReducer(
return state;
}
const updatedChannels = existing.map(channel => channel.id === channelId ? { ...channel,
name: normalizedName } : channel);
const updatedRoom = { ...state.currentRoom,
channels: updatedChannels };
const updatedChannels = existing.map((channel) => (channel.id === channelId ? { ...channel, name: normalizedName } : channel));
const updatedRoom = { ...state.currentRoom, channels: updatedChannels };
return {
...state,