fix: improve plugins functionality with server management

This commit is contained in:
2026-04-29 20:33:54 +02:00
parent b8f6d58d99
commit fa2cca6fa4
82 changed files with 1708 additions and 303 deletions

View File

@@ -1,17 +1,44 @@
/* 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';
@@ -28,7 +55,12 @@ 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];
const SERVER_ICON_SYNC_REQUEST_DELAYS_MS = [
1_500,
3_000,
5_000,
8_000
];
/**
* NgRx effects for real-time state synchronisation: signaling presence
@@ -64,7 +96,12 @@ export class RoomStateSyncEffects {
signalingMessages$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe(
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom), this.store.select(selectSavedRooms)),
mergeMap(([message, currentUser, currentRoom, savedRooms]) => {
mergeMap(([
message,
currentUser,
currentRoom,
savedRooms
]) => {
const signalingMessage: RoomPresenceSignalingMessage = message;
const myId = currentUser?.oderId || currentUser?.id;
const viewedServerId = currentRoom?.id;
@@ -73,7 +110,8 @@ 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)
@@ -102,9 +140,11 @@ 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,
@@ -132,7 +172,8 @@ 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;
@@ -160,11 +201,18 @@ 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.
@@ -179,14 +227,17 @@ 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 })];
}
@@ -263,7 +314,8 @@ 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',
@@ -313,7 +365,14 @@ 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');
@@ -353,7 +412,8 @@ 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;
@@ -374,7 +434,8 @@ export class RoomStateSyncEffects {
tap((peerId) => {
const serverIds = this.pendingServerIconRequestsByPeer.get(peerId);
if (!serverIds) return;
if (!serverIds)
return;
for (const serverId of serverIds) {
this.sendServerIconSyncRequest(peerId, serverId);
@@ -389,7 +450,8 @@ export class RoomStateSyncEffects {
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;
@@ -397,16 +459,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] }
)
})
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();
@@ -471,7 +534,8 @@ 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(
@@ -491,7 +555,8 @@ export class RoomStateSyncEffects {
const isCameraEnabled = event.isCameraEnabled as boolean | undefined;
if (isCameraEnabled === undefined) return EMPTY;
if (isCameraEnabled === undefined)
return EMPTY;
if (!userExists) {
return of(
@@ -609,7 +674,8 @@ 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) => {
@@ -629,7 +695,8 @@ export class RoomStateSyncEffects {
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),
@@ -670,7 +737,8 @@ 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 +767,8 @@ 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({
@@ -746,7 +815,8 @@ 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;
@@ -765,7 +835,8 @@ 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, {
@@ -784,17 +855,20 @@ export class RoomStateSyncEffects {
const room = resolveRoom(roomId, currentRoom, savedRooms);
const senderId = event.fromPeerId;
if (!room || typeof event.icon !== 'string' || !senderId) return this.handleSearchResultIconData(event, roomId);
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,
@@ -807,6 +881,7 @@ export class RoomStateSyncEffects {
serverId: room.id,
iconUpdatedAt: updates.iconUpdatedAt
});
return of(RoomsActions.updateRoom({ roomId: room.id, changes: updates }));
})
);