Change klippy window behavour, Fix user management behavour, clean up search server page
@@ -4,7 +4,7 @@ import {
|
|||||||
destroyDatabase,
|
destroyDatabase,
|
||||||
getDataSource
|
getDataSource
|
||||||
} from '../db/database';
|
} from '../db/database';
|
||||||
import { createWindow } from '../window/create-window';
|
import { createWindow, getDockIconPath } from '../window/create-window';
|
||||||
import {
|
import {
|
||||||
setupCqrsHandlers,
|
setupCqrsHandlers,
|
||||||
setupSystemHandlers,
|
setupSystemHandlers,
|
||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
|
|
||||||
export function registerAppLifecycle(): void {
|
export function registerAppLifecycle(): void {
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
|
const dockIconPath = getDockIconPath();
|
||||||
|
|
||||||
|
if (process.platform === 'darwin' && dockIconPath)
|
||||||
|
app.dock?.setIcon(dockIconPath);
|
||||||
|
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
setupCqrsHandlers();
|
setupCqrsHandlers();
|
||||||
setupWindowControlHandlers();
|
setupWindowControlHandlers();
|
||||||
|
|||||||
@@ -12,5 +12,5 @@ export async function handleIsUserBanned(query: IsUserBannedQuery, dataSource: D
|
|||||||
.andWhere('(ban.expiresAt IS NULL OR ban.expiresAt > :now)', { now })
|
.andWhere('(ban.expiresAt IS NULL OR ban.expiresAt > :now)', { now })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
return rows.some((row) => row.oderId === userId);
|
return rows.some((row) => row.userId === userId || (!row.userId && row.oderId === userId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,44 @@
|
|||||||
import { BrowserWindow, shell } from 'electron';
|
import { app, BrowserWindow, shell } from 'electron';
|
||||||
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
|
|
||||||
|
function getAssetPath(...segments: string[]): string {
|
||||||
|
const basePath = app.isPackaged
|
||||||
|
? path.join(process.resourcesPath, 'images')
|
||||||
|
: path.join(__dirname, '..', '..', '..', 'images');
|
||||||
|
|
||||||
|
return path.join(basePath, ...segments);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExistingAssetPath(...segments: string[]): string | undefined {
|
||||||
|
const assetPath = getAssetPath(...segments);
|
||||||
|
|
||||||
|
return fs.existsSync(assetPath) ? assetPath : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWindowIconPath(): string | undefined {
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
return getExistingAssetPath('windows', 'icon.ico');
|
||||||
|
|
||||||
|
if (process.platform === 'linux')
|
||||||
|
return getExistingAssetPath('icon.png');
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDockIconPath(): string | undefined {
|
||||||
|
return getExistingAssetPath('macos', '1024x1024.png');
|
||||||
|
}
|
||||||
|
|
||||||
export function getMainWindow(): BrowserWindow | null {
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
return mainWindow;
|
return mainWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createWindow(): Promise<void> {
|
export async function createWindow(): Promise<void> {
|
||||||
|
const windowIconPath = getWindowIconPath();
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 900,
|
height: 900,
|
||||||
@@ -16,6 +47,7 @@ export async function createWindow(): Promise<void> {
|
|||||||
frame: false,
|
frame: false,
|
||||||
titleBarStyle: 'hidden',
|
titleBarStyle: 'hidden',
|
||||||
backgroundColor: '#0a0a0f',
|
backgroundColor: '#0a0a0f',
|
||||||
|
...(windowIconPath ? { icon: windowIconPath } : {}),
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
|||||||
BIN
images/icon.png
Normal file
|
After Width: | Height: | Size: 529 KiB |
BIN
images/linux/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
images/linux/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 917 B |
BIN
images/linux/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
images/linux/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
images/linux/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
images/linux/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
images/linux/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
images/macos/1024x1024.png
Normal file
|
After Width: | Height: | Size: 781 KiB |
BIN
images/macos/128x128.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
images/macos/16x16.png
Normal file
|
After Width: | Height: | Size: 926 B |
BIN
images/macos/256x256.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
images/macos/32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
images/macos/512x512.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
images/macos/64x64.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
images/macos/icon.icns
Normal file
BIN
images/windows/128x128.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
images/windows/16x16.png
Normal file
|
After Width: | Height: | Size: 927 B |
BIN
images/windows/256x256.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
images/windows/32x32.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
images/windows/48x48.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
images/windows/64x64.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
images/windows/icon.ico
Normal file
|
After Width: | Height: | Size: 72 KiB |
18
package.json
@@ -119,16 +119,27 @@
|
|||||||
"!node_modules/**/*.d.ts",
|
"!node_modules/**/*.d.ts",
|
||||||
"!node_modules/**/*.map"
|
"!node_modules/**/*.map"
|
||||||
],
|
],
|
||||||
|
"extraResources": [
|
||||||
|
{
|
||||||
|
"from": "images",
|
||||||
|
"to": "images",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"nodeGypRebuild": false,
|
"nodeGypRebuild": false,
|
||||||
"buildDependenciesFromSource": false,
|
"buildDependenciesFromSource": false,
|
||||||
"npmRebuild": false,
|
"npmRebuild": false,
|
||||||
"mac": {
|
"mac": {
|
||||||
"category": "public.app-category.social-networking",
|
"category": "public.app-category.social-networking",
|
||||||
"target": "dmg"
|
"target": "dmg",
|
||||||
|
"icon": "images/macos/icon.icns"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis",
|
"target": "nsis",
|
||||||
"artifactName": "${productName}-${version}-${arch}.${ext}"
|
"artifactName": "${productName}-${version}-${arch}.${ext}",
|
||||||
|
"icon": "images/windows/icon.ico"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
@@ -143,7 +154,8 @@
|
|||||||
"executableName": "metoyou",
|
"executableName": "metoyou",
|
||||||
"executableArgs": [
|
"executableArgs": [
|
||||||
"--no-sandbox"
|
"--no-sandbox"
|
||||||
]
|
],
|
||||||
|
"icon": "images/linux/icons"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 72 KiB |
@@ -4,6 +4,7 @@ import { ServerPayload, JoinRequestPayload } from '../cqrs/types';
|
|||||||
import {
|
import {
|
||||||
getAllPublicServers,
|
getAllPublicServers,
|
||||||
getServerById,
|
getServerById,
|
||||||
|
getUserById,
|
||||||
upsertServer,
|
upsertServer,
|
||||||
deleteServer,
|
deleteServer,
|
||||||
createJoinRequest,
|
createJoinRequest,
|
||||||
@@ -13,6 +14,16 @@ import { notifyServerOwner } from '../websocket/broadcast';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
async function enrichServer(server: ServerPayload) {
|
||||||
|
const owner = await getUserById(server.ownerId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...server,
|
||||||
|
ownerName: owner?.displayName,
|
||||||
|
userCount: server.currentUsers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const { q, tags, limit = 20, offset = 0 } = req.query;
|
const { q, tags, limit = 20, offset = 0 } = req.query;
|
||||||
|
|
||||||
@@ -37,7 +48,9 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
results = results.slice(Number(offset), Number(offset) + Number(limit));
|
||||||
|
|
||||||
res.json({ servers: results, total, limit: Number(limit), offset: Number(offset) });
|
const enrichedResults = await Promise.all(results.map((server) => enrichServer(server)));
|
||||||
|
|
||||||
|
res.json({ servers: enrichedResults, total, limit: Number(limit), offset: Number(offset) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', async (req, res) => {
|
||||||
|
|||||||
50
src/app/core/helpers/room-ban.helpers.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import {
|
||||||
|
BanEntry,
|
||||||
|
User
|
||||||
|
} from '../models/index';
|
||||||
|
|
||||||
|
type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined;
|
||||||
|
|
||||||
|
/** Build the set of user identifiers that may appear in room ban entries. */
|
||||||
|
export function getRoomBanCandidateIds(user: BanAwareUser, persistedUserId?: string | null): string[] {
|
||||||
|
const candidates = [
|
||||||
|
user?.id,
|
||||||
|
user?.oderId,
|
||||||
|
persistedUserId
|
||||||
|
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
|
||||||
|
|
||||||
|
return Array.from(new Set(candidates));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the user identifier stored by a ban entry, with legacy fallback support. */
|
||||||
|
export function getRoomBanTargetId(ban: Pick<BanEntry, 'userId' | 'oderId'>): string {
|
||||||
|
if (typeof ban.userId === 'string' && ban.userId.trim().length > 0) {
|
||||||
|
return ban.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ban.oderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return true when the given ban targets the provided user. */
|
||||||
|
export function isRoomBanMatch(
|
||||||
|
ban: Pick<BanEntry, 'userId' | 'oderId'>,
|
||||||
|
user: BanAwareUser,
|
||||||
|
persistedUserId?: string | null
|
||||||
|
): boolean {
|
||||||
|
const candidateIds = getRoomBanCandidateIds(user, persistedUserId);
|
||||||
|
|
||||||
|
if (candidateIds.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidateIds.includes(getRoomBanTargetId(ban));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return true when any active ban entry targets the provided user. */
|
||||||
|
export function hasRoomBanForUser(
|
||||||
|
bans: Array<Pick<BanEntry, 'userId' | 'oderId'>>,
|
||||||
|
user: BanAwareUser,
|
||||||
|
persistedUserId?: string | null
|
||||||
|
): boolean {
|
||||||
|
return bans.some((ban) => isRoomBanMatch(ban, user, persistedUserId));
|
||||||
|
}
|
||||||
@@ -183,6 +183,14 @@ export type ChatEventType =
|
|||||||
| 'state-request'
|
| 'state-request'
|
||||||
| 'screen-state'
|
| 'screen-state'
|
||||||
| 'role-change'
|
| 'role-change'
|
||||||
|
| 'room-permissions-update'
|
||||||
|
| 'server-icon-summary'
|
||||||
|
| 'server-icon-request'
|
||||||
|
| 'server-icon-full'
|
||||||
|
| 'server-icon-update'
|
||||||
|
| 'server-state-request'
|
||||||
|
| 'server-state-full'
|
||||||
|
| 'unban'
|
||||||
| 'channels-update';
|
| 'channels-update';
|
||||||
|
|
||||||
/** Optional fields depend on `type`. */
|
/** Optional fields depend on `type`. */
|
||||||
@@ -209,11 +217,17 @@ export interface ChatEvent {
|
|||||||
emoji?: string;
|
emoji?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
settings?: RoomSettings;
|
settings?: RoomSettings;
|
||||||
|
permissions?: Partial<RoomPermissions>;
|
||||||
voiceState?: Partial<VoiceState>;
|
voiceState?: Partial<VoiceState>;
|
||||||
isScreenSharing?: boolean;
|
isScreenSharing?: boolean;
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
|
room?: Room;
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
members?: RoomMember[];
|
members?: RoomMember[];
|
||||||
|
ban?: BanEntry;
|
||||||
|
bans?: BanEntry[];
|
||||||
|
banOderId?: string;
|
||||||
|
expiresAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
@@ -223,12 +237,15 @@ export interface ServerInfo {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
hostName: string;
|
hostName: string;
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
|
ownerName?: string;
|
||||||
ownerPublicKey?: string;
|
ownerPublicKey?: string;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
sourceId?: string;
|
||||||
|
sourceName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JoinRequest {
|
export interface JoinRequest {
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export class BrowserDatabaseService {
|
|||||||
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
||||||
const activeBans = await this.getBansForRoom(roomId);
|
const activeBans = await this.getBansForRoom(roomId);
|
||||||
|
|
||||||
return activeBans.some((ban) => ban.oderId === userId);
|
return activeBans.some((ban) => ban.userId === userId || (!ban.userId && ban.oderId === userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persist an attachment metadata record. */
|
/** Persist an attachment metadata record. */
|
||||||
|
|||||||
@@ -289,7 +289,7 @@ export class ServerDirectoryService {
|
|||||||
return this.searchAllEndpoints(query);
|
return this.searchAllEndpoints(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
|
return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Retrieve the full list of public servers. */
|
/** Retrieve the full list of public servers. */
|
||||||
@@ -301,7 +301,7 @@ export class ServerDirectoryService {
|
|||||||
return this.http
|
return this.http
|
||||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => this.unwrapServersResponse(response)),
|
map((response) => this.normalizeServerList(response, this.activeServer())),
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
console.error('Failed to get servers:', error);
|
console.error('Failed to get servers:', error);
|
||||||
return of([]);
|
return of([]);
|
||||||
@@ -314,6 +314,7 @@ export class ServerDirectoryService {
|
|||||||
return this.http
|
return this.http
|
||||||
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
|
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
map((server) => this.normalizeServerInfo(server, this.activeServer())),
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
console.error('Failed to get server:', error);
|
console.error('Failed to get server:', error);
|
||||||
return of(null);
|
return of(null);
|
||||||
@@ -471,14 +472,15 @@ export class ServerDirectoryService {
|
|||||||
/** Search a single endpoint for servers matching a query. */
|
/** Search a single endpoint for servers matching a query. */
|
||||||
private searchSingleEndpoint(
|
private searchSingleEndpoint(
|
||||||
query: string,
|
query: string,
|
||||||
apiBaseUrl: string
|
apiBaseUrl: string,
|
||||||
|
source?: ServerEndpoint | null
|
||||||
): Observable<ServerInfo[]> {
|
): Observable<ServerInfo[]> {
|
||||||
const params = new HttpParams().set('q', query);
|
const params = new HttpParams().set('q', query);
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => this.unwrapServersResponse(response)),
|
map((response) => this.normalizeServerList(response, source)),
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
console.error('Failed to search servers:', error);
|
console.error('Failed to search servers:', error);
|
||||||
return of([]);
|
return of([]);
|
||||||
@@ -493,19 +495,11 @@ export class ServerDirectoryService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (onlineEndpoints.length === 0) {
|
if (onlineEndpoints.length === 0) {
|
||||||
return this.searchSingleEndpoint(query, this.buildApiBaseUrl());
|
return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer());
|
||||||
}
|
}
|
||||||
|
|
||||||
const requests = onlineEndpoints.map((endpoint) =>
|
const requests = onlineEndpoints.map((endpoint) =>
|
||||||
this.searchSingleEndpoint(query, `${endpoint.url}/api`).pipe(
|
this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint)
|
||||||
map((results) =>
|
|
||||||
results.map((server) => ({
|
|
||||||
...server,
|
|
||||||
sourceId: endpoint.id,
|
|
||||||
sourceName: endpoint.name
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return forkJoin(requests).pipe(
|
return forkJoin(requests).pipe(
|
||||||
@@ -524,7 +518,7 @@ export class ServerDirectoryService {
|
|||||||
return this.http
|
return this.http
|
||||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
.get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`)
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => this.unwrapServersResponse(response)),
|
map((response) => this.normalizeServerList(response, this.activeServer())),
|
||||||
catchError(() => of([]))
|
catchError(() => of([]))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -533,15 +527,7 @@ export class ServerDirectoryService {
|
|||||||
this.http
|
this.http
|
||||||
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => {
|
map((response) => this.normalizeServerList(response, endpoint)),
|
||||||
const results = this.unwrapServersResponse(response);
|
|
||||||
|
|
||||||
return results.map((server) => ({
|
|
||||||
...server,
|
|
||||||
sourceId: endpoint.id,
|
|
||||||
sourceName: endpoint.name
|
|
||||||
}));
|
|
||||||
}),
|
|
||||||
catchError(() => of([] as ServerInfo[]))
|
catchError(() => of([] as ServerInfo[]))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -562,6 +548,57 @@ export class ServerDirectoryService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private normalizeServerList(
|
||||||
|
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
|
||||||
|
source?: ServerEndpoint | null
|
||||||
|
): ServerInfo[] {
|
||||||
|
return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeServerInfo(
|
||||||
|
server: ServerInfo | Record<string, unknown>,
|
||||||
|
source?: ServerEndpoint | null
|
||||||
|
): ServerInfo {
|
||||||
|
const candidate = server as Record<string, unknown>;
|
||||||
|
const userCount = typeof candidate['userCount'] === 'number'
|
||||||
|
? candidate['userCount']
|
||||||
|
: (typeof candidate['currentUsers'] === 'number' ? candidate['currentUsers'] : 0);
|
||||||
|
const maxUsers = typeof candidate['maxUsers'] === 'number' ? candidate['maxUsers'] : 0;
|
||||||
|
const isPrivate = typeof candidate['isPrivate'] === 'boolean'
|
||||||
|
? candidate['isPrivate']
|
||||||
|
: candidate['isPrivate'] === 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof candidate['id'] === 'string' ? candidate['id'] : '',
|
||||||
|
name: typeof candidate['name'] === 'string' ? candidate['name'] : 'Unnamed server',
|
||||||
|
description: typeof candidate['description'] === 'string' ? candidate['description'] : undefined,
|
||||||
|
topic: typeof candidate['topic'] === 'string' ? candidate['topic'] : undefined,
|
||||||
|
hostName:
|
||||||
|
typeof candidate['hostName'] === 'string'
|
||||||
|
? candidate['hostName']
|
||||||
|
: (typeof candidate['sourceName'] === 'string'
|
||||||
|
? candidate['sourceName']
|
||||||
|
: (source?.name ?? 'Unknown API')),
|
||||||
|
ownerId: typeof candidate['ownerId'] === 'string' ? candidate['ownerId'] : undefined,
|
||||||
|
ownerName: typeof candidate['ownerName'] === 'string' ? candidate['ownerName'] : undefined,
|
||||||
|
ownerPublicKey:
|
||||||
|
typeof candidate['ownerPublicKey'] === 'string' ? candidate['ownerPublicKey'] : undefined,
|
||||||
|
userCount,
|
||||||
|
maxUsers,
|
||||||
|
isPrivate,
|
||||||
|
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
|
||||||
|
createdAt: typeof candidate['createdAt'] === 'number' ? candidate['createdAt'] : Date.now(),
|
||||||
|
sourceId:
|
||||||
|
typeof candidate['sourceId'] === 'string'
|
||||||
|
? candidate['sourceId']
|
||||||
|
: source?.id,
|
||||||
|
sourceName:
|
||||||
|
typeof candidate['sourceName'] === 'string'
|
||||||
|
? candidate['sourceName']
|
||||||
|
: source?.name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */
|
/** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */
|
||||||
private loadEndpoints(): void {
|
private loadEndpoints(): void {
|
||||||
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
|
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);
|
||||||
|
|||||||
@@ -130,9 +130,9 @@ export class AdminPanelComponent {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
RoomsActions.updateRoom({
|
RoomsActions.updateRoomSettings({
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
changes: {
|
settings: {
|
||||||
name: this.roomName,
|
name: this.roomName,
|
||||||
description: this.roomDescription,
|
description: this.roomDescription,
|
||||||
isPrivate: this.isPrivate(),
|
isPrivate: this.isPrivate(),
|
||||||
@@ -168,7 +168,8 @@ export class AdminPanelComponent {
|
|||||||
|
|
||||||
/** Remove a user's ban entry. */
|
/** Remove a user's ban entry. */
|
||||||
unbanUser(ban: BanEntry): void {
|
unbanUser(ban: BanEntry): void {
|
||||||
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
|
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
|
||||||
|
oderId: ban.oderId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show the delete-room confirmation dialog. */
|
/** Show the delete-room confirmation dialog. */
|
||||||
@@ -203,7 +204,7 @@ export class AdminPanelComponent {
|
|||||||
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
|
return this.onlineUsers().filter(user => user.id !== me?.id && user.oderId !== me?.oderId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Change a member's role and broadcast the update to all peers. */
|
/** Change a member's role and notify connected peers. */
|
||||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
||||||
const roomId = this.currentRoom()?.id;
|
const roomId = this.currentRoom()?.id;
|
||||||
|
|
||||||
@@ -218,23 +219,13 @@ export class AdminPanelComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kick a member from the server and broadcast the action to peers. */
|
/** Kick a member from the server. */
|
||||||
kickMember(user: User): void {
|
kickMember(user: User): void {
|
||||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'kick',
|
|
||||||
targetUserId: user.id,
|
|
||||||
kickedBy: this.currentUser()?.id
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ban a member from the server and broadcast the action to peers. */
|
/** Ban a member from the server. */
|
||||||
banMember(user: User): void {
|
banMember(user: User): void {
|
||||||
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'ban',
|
|
||||||
targetUserId: user.id,
|
|
||||||
bannedBy: this.currentUser()?.id
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,41 @@
|
|||||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
|
||||||
<app-chat-message-composer
|
<app-chat-message-composer
|
||||||
[replyTo]="replyTo()"
|
[replyTo]="replyTo()"
|
||||||
|
[showKlipyGifPicker]="showKlipyGifPicker()"
|
||||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||||
(typingStarted)="handleTypingStarted()"
|
(typingStarted)="handleTypingStarted()"
|
||||||
(replyCleared)="clearReply()"
|
(replyCleared)="clearReply()"
|
||||||
(heightChanged)="handleComposerHeightChanged($event)"
|
(heightChanged)="handleComposerHeightChanged($event)"
|
||||||
|
(klipyGifPickerToggleRequested)="toggleKlipyGifPicker()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (showKlipyGifPicker()) {
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[89]"
|
||||||
|
(click)="closeKlipyGifPicker()"
|
||||||
|
(keydown.enter)="closeKlipyGifPicker()"
|
||||||
|
(keydown.space)="closeKlipyGifPicker()"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-label="Close GIF picker"
|
||||||
|
style="-webkit-app-region: no-drag"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="pointer-events-none fixed inset-0 z-[90]">
|
||||||
|
<div
|
||||||
|
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
|
||||||
|
[style.bottom.px]="composerBottomPadding() + 8"
|
||||||
|
[style.right.px]="klipyGifPickerAnchorRight()"
|
||||||
|
>
|
||||||
|
<app-klipy-gif-picker
|
||||||
|
(gifSelected)="handleKlipyGifSelected($event)"
|
||||||
|
(closed)="closeKlipyGifPicker()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<app-chat-message-overlays
|
<app-chat-message-overlays
|
||||||
[lightboxAttachment]="lightboxAttachment()"
|
[lightboxAttachment]="lightboxAttachment()"
|
||||||
[imageContextMenu]="imageContextMenu()"
|
[imageContextMenu]="imageContextMenu()"
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
HostListener,
|
||||||
|
ViewChild,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { Attachment, AttachmentService } from '../../../core/services/attachment.service';
|
import { Attachment, AttachmentService } from '../../../core/services/attachment.service';
|
||||||
|
import { KlipyGif } from '../../../core/services/klipy.service';
|
||||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||||
import {
|
import {
|
||||||
selectAllMessages,
|
selectAllMessages,
|
||||||
@@ -18,6 +21,7 @@ import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/r
|
|||||||
import { Message } from '../../../core/models';
|
import { Message } from '../../../core/models';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
||||||
|
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||||
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
||||||
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
|
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +38,7 @@ import {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
ChatMessageComposerComponent,
|
ChatMessageComposerComponent,
|
||||||
|
KlipyGifPickerComponent,
|
||||||
ChatMessageListComponent,
|
ChatMessageListComponent,
|
||||||
ChatMessageOverlaysComponent
|
ChatMessageOverlaysComponent
|
||||||
],
|
],
|
||||||
@@ -41,6 +46,8 @@ import {
|
|||||||
styleUrl: './chat-messages.component.scss'
|
styleUrl: './chat-messages.component.scss'
|
||||||
})
|
})
|
||||||
export class ChatMessagesComponent {
|
export class ChatMessagesComponent {
|
||||||
|
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
||||||
|
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
private readonly webrtc = inject(WebRTCService);
|
private readonly webrtc = inject(WebRTCService);
|
||||||
private readonly attachmentsSvc = inject(AttachmentService);
|
private readonly attachmentsSvc = inject(AttachmentService);
|
||||||
@@ -69,10 +76,19 @@ export class ChatMessagesComponent {
|
|||||||
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
|
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
|
||||||
);
|
);
|
||||||
readonly composerBottomPadding = signal(140);
|
readonly composerBottomPadding = signal(140);
|
||||||
|
readonly klipyGifPickerAnchorRight = signal(16);
|
||||||
readonly replyTo = signal<Message | null>(null);
|
readonly replyTo = signal<Message | null>(null);
|
||||||
|
readonly showKlipyGifPicker = signal(false);
|
||||||
readonly lightboxAttachment = signal<Attachment | null>(null);
|
readonly lightboxAttachment = signal<Attachment | null>(null);
|
||||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||||
|
|
||||||
|
@HostListener('window:resize')
|
||||||
|
onWindowResize(): void {
|
||||||
|
if (this.showKlipyGifPicker()) {
|
||||||
|
this.syncKlipyGifPickerAnchor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
|
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
MessagesActions.sendMessage({
|
MessagesActions.sendMessage({
|
||||||
@@ -163,6 +179,57 @@ export class ChatMessagesComponent {
|
|||||||
this.composerBottomPadding.set(height + 20);
|
this.composerBottomPadding.set(height + 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleKlipyGifPicker(): void {
|
||||||
|
const nextState = !this.showKlipyGifPicker();
|
||||||
|
|
||||||
|
this.showKlipyGifPicker.set(nextState);
|
||||||
|
|
||||||
|
if (nextState) {
|
||||||
|
requestAnimationFrame(() => this.syncKlipyGifPickerAnchor());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeKlipyGifPicker(): void {
|
||||||
|
this.showKlipyGifPicker.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKlipyGifSelected(gif: KlipyGif): void {
|
||||||
|
this.closeKlipyGifPicker();
|
||||||
|
this.composer?.handleKlipyGifSelected(gif);
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncKlipyGifPickerAnchor(): void {
|
||||||
|
const triggerRect = this.composer?.getKlipyTriggerRect();
|
||||||
|
|
||||||
|
if (!triggerRect) {
|
||||||
|
this.klipyGifPickerAnchorRight.set(16);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const popupWidth = this.getKlipyGifPickerWidth(viewportWidth);
|
||||||
|
const preferredRight = viewportWidth - triggerRect.right;
|
||||||
|
const minRight = 16;
|
||||||
|
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
||||||
|
|
||||||
|
this.klipyGifPickerAnchorRight.set(
|
||||||
|
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getKlipyGifPickerWidth(viewportWidth: number): number {
|
||||||
|
if (viewportWidth >= 1280)
|
||||||
|
return 52 * 16;
|
||||||
|
|
||||||
|
if (viewportWidth >= 768)
|
||||||
|
return 42 * 16;
|
||||||
|
|
||||||
|
if (viewportWidth >= 640)
|
||||||
|
return 34 * 16;
|
||||||
|
|
||||||
|
return Math.max(0, viewportWidth - 32);
|
||||||
|
}
|
||||||
|
|
||||||
openLightbox(attachment: Attachment): void {
|
openLightbox(attachment: Attachment): void {
|
||||||
if (attachment.available && attachment.objectUrl) {
|
if (attachment.available && attachment.objectUrl) {
|
||||||
this.lightboxAttachment.set(attachment);
|
this.lightboxAttachment.set(attachment);
|
||||||
|
|||||||
@@ -135,8 +135,9 @@
|
|||||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2">
|
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2">
|
||||||
@if (klipy.isEnabled()) {
|
@if (klipy.isEnabled()) {
|
||||||
<button
|
<button
|
||||||
|
#klipyTrigger
|
||||||
type="button"
|
type="button"
|
||||||
(click)="openKlipyGifPicker()"
|
(click)="toggleKlipyGifPicker()"
|
||||||
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
|
||||||
[class.border-primary]="showKlipyGifPicker()"
|
[class.border-primary]="showKlipyGifPicker()"
|
||||||
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
|
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
|
||||||
@@ -250,10 +251,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (showKlipyGifPicker() && klipy.isEnabled()) {
|
|
||||||
<app-klipy-gif-picker
|
|
||||||
(gifSelected)="handleKlipyGifSelected($event)"
|
|
||||||
(closed)="closeKlipyGifPicker()"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
import { KlipyGif, KlipyService } from '../../../../../core/services/klipy.service';
|
import { KlipyGif, KlipyService } from '../../../../../core/services/klipy.service';
|
||||||
import { Message } from '../../../../../core/models';
|
import { Message } from '../../../../../core/models';
|
||||||
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
||||||
import { KlipyGifPickerComponent } from '../../../klipy-gif-picker/klipy-gif-picker.component';
|
|
||||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||||
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
|
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
KlipyGifPickerComponent,
|
|
||||||
TypingIndicatorComponent
|
TypingIndicatorComponent
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
@@ -54,19 +52,21 @@ import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.model
|
|||||||
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||||
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
|
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
|
||||||
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
|
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
|
||||||
|
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
|
||||||
|
|
||||||
readonly replyTo = input<Message | null>(null);
|
readonly replyTo = input<Message | null>(null);
|
||||||
|
readonly showKlipyGifPicker = input(false);
|
||||||
|
|
||||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||||
readonly typingStarted = output();
|
readonly typingStarted = output();
|
||||||
readonly replyCleared = output();
|
readonly replyCleared = output();
|
||||||
readonly heightChanged = output<number>();
|
readonly heightChanged = output<number>();
|
||||||
|
readonly klipyGifPickerToggleRequested = output();
|
||||||
|
|
||||||
readonly klipy = inject(KlipyService);
|
readonly klipy = inject(KlipyService);
|
||||||
private readonly markdown = inject(ChatMarkdownService);
|
private readonly markdown = inject(ChatMarkdownService);
|
||||||
|
|
||||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||||
readonly showKlipyGifPicker = signal(false);
|
|
||||||
readonly toolbarVisible = signal(false);
|
readonly toolbarVisible = signal(false);
|
||||||
readonly dragActive = signal(false);
|
readonly dragActive = signal(false);
|
||||||
readonly inputHovered = signal(false);
|
readonly inputHovered = signal(false);
|
||||||
@@ -194,20 +194,19 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
|||||||
this.setSelection(result.selectionStart, result.selectionEnd);
|
this.setSelection(result.selectionStart, result.selectionEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
openKlipyGifPicker(): void {
|
toggleKlipyGifPicker(): void {
|
||||||
if (!this.klipy.isEnabled())
|
if (!this.klipy.isEnabled())
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.showKlipyGifPicker.set(true);
|
this.klipyGifPickerToggleRequested.emit();
|
||||||
}
|
}
|
||||||
|
|
||||||
closeKlipyGifPicker(): void {
|
getKlipyTriggerRect(): DOMRect | null {
|
||||||
this.showKlipyGifPicker.set(false);
|
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKlipyGifSelected(gif: KlipyGif): void {
|
handleKlipyGifSelected(gif: KlipyGif): void {
|
||||||
this.pendingKlipyGif.set(gif);
|
this.pendingKlipyGif.set(gif);
|
||||||
this.closeKlipyGifPicker();
|
|
||||||
|
|
||||||
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
|
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
|
||||||
this.sendMessage();
|
this.sendMessage();
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export class ChatMarkdownService {
|
|||||||
const before = content.slice(0, start);
|
const before = content.slice(0, start);
|
||||||
const selected = content.slice(start, end) || 'code';
|
const selected = content.slice(start, end) || 'code';
|
||||||
const after = content.slice(end);
|
const after = content.slice(end);
|
||||||
const fenced = `\n\n\`\`\`\n${selected}\n\`\`\`\n\n`;
|
const fenced = `\`\`\`\n${selected}\n\`\`\`\n\n`;
|
||||||
const text = `${before}${fenced}${after}`;
|
const text = `${before}${fenced}${after}`;
|
||||||
const cursor = before.length + fenced.length;
|
const cursor = before.length + fenced.length;
|
||||||
|
|
||||||
|
|||||||
@@ -1,144 +1,133 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-[94] bg-black/70 backdrop-blur-sm"
|
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
|
||||||
(click)="close()"
|
role="dialog"
|
||||||
(keydown.enter)="close()"
|
aria-label="KLIPY GIF picker"
|
||||||
(keydown.space)="close()"
|
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
|
||||||
role="button"
|
>
|
||||||
tabindex="0"
|
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
|
||||||
aria-label="Close GIF picker"
|
<div>
|
||||||
></div>
|
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
|
||||||
<div class="fixed inset-0 z-[95] flex items-center justify-center p-4 pointer-events-none">
|
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
|
||||||
<div
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
class="pointer-events-auto flex h-[min(80vh,48rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-2xl"
|
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
|
||||||
role="dialog"
|
</p>
|
||||||
aria-modal="true"
|
</div>
|
||||||
aria-label="KLIPY GIF picker"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
|
||||||
<div>
|
|
||||||
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
|
|
||||||
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">
|
|
||||||
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="close()"
|
(click)="close()"
|
||||||
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
|
||||||
aria-label="Close GIF picker"
|
aria-label="Close GIF picker"
|
||||||
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideX"
|
||||||
|
class="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
|
||||||
|
<label class="relative block">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideSearch"
|
||||||
|
class="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
#searchInput
|
||||||
|
type="text"
|
||||||
|
[ngModel]="searchQuery"
|
||||||
|
(ngModelChange)="onSearchQueryChanged($event)"
|
||||||
|
placeholder="Search KLIPY"
|
||||||
|
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
@if (errorMessage()) {
|
||||||
|
<div
|
||||||
|
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<ng-icon
|
<span>{{ errorMessage() }}</span>
|
||||||
name="lucideX"
|
|
||||||
class="h-4 w-4"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-b border-border px-5 py-4">
|
|
||||||
<label class="relative block">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideSearch"
|
|
||||||
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
#searchInput
|
|
||||||
type="text"
|
|
||||||
[ngModel]="searchQuery"
|
|
||||||
(ngModelChange)="onSearchQueryChanged($event)"
|
|
||||||
placeholder="Search KLIPY"
|
|
||||||
class="w-full rounded-xl border border-border bg-background px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
|
||||||
@if (errorMessage()) {
|
|
||||||
<div
|
|
||||||
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
|
||||||
>
|
|
||||||
<span>{{ errorMessage() }}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="retry()"
|
|
||||||
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (loading() && results().length === 0) {
|
|
||||||
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
|
|
||||||
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
|
|
||||||
<p class="text-sm">Loading GIFs from KLIPY…</p>
|
|
||||||
</div>
|
|
||||||
} @else if (results().length === 0) {
|
|
||||||
<div
|
|
||||||
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border bg-secondary/10 px-6 text-center text-muted-foreground"
|
|
||||||
>
|
|
||||||
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
||||||
<ng-icon
|
|
||||||
name="lucideImage"
|
|
||||||
class="h-5 w-5"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-foreground">No GIFs found</p>
|
|
||||||
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
|
|
||||||
@for (gif of results(); track gif.id) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
(click)="selectGif(gif)"
|
|
||||||
class="group overflow-hidden rounded-2xl border border-border bg-secondary/10 text-left transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="relative overflow-hidden bg-secondary/30"
|
|
||||||
[style.aspect-ratio]="gifAspectRatio(gif)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
[src]="gifPreviewUrl(gif)"
|
|
||||||
[alt]="gif.title || 'KLIPY GIF'"
|
|
||||||
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
|
||||||
>
|
|
||||||
KLIPY
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="px-3 py-2">
|
|
||||||
<p class="truncate text-xs font-medium text-foreground">
|
|
||||||
{{ gif.title || 'KLIPY GIF' }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4 border-t border-border px-5 py-4">
|
|
||||||
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
|
|
||||||
|
|
||||||
@if (hasNext()) {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
(click)="loadMore()"
|
(click)="retry()"
|
||||||
[disabled]="loading()"
|
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
|
||||||
class="rounded-full border border-border px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
>
|
||||||
{{ loading() ? 'Loading…' : 'Load more' }}
|
Retry
|
||||||
</button>
|
</button>
|
||||||
}
|
</div>
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
@if (loading() && results().length === 0) {
|
||||||
|
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
|
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
|
||||||
|
<p class="text-sm">Loading GIFs from KLIPY…</p>
|
||||||
|
</div>
|
||||||
|
} @else if (results().length === 0) {
|
||||||
|
<div
|
||||||
|
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/80 bg-secondary/10 px-6 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||||
|
<ng-icon
|
||||||
|
name="lucideImage"
|
||||||
|
class="h-5 w-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">No GIFs found</p>
|
||||||
|
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
@for (gif of results(); track gif.id) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="selectGif(gif)"
|
||||||
|
class="group overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative overflow-hidden bg-secondary/30"
|
||||||
|
[style.aspect-ratio]="gifAspectRatio(gif)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
[src]="gifPreviewUrl(gif)"
|
||||||
|
[alt]="gif.title || 'KLIPY GIF'"
|
||||||
|
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
KLIPY
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 py-2">
|
||||||
|
<p class="truncate text-xs font-medium text-foreground">
|
||||||
|
{{ gif.title || 'KLIPY GIF' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
|
||||||
|
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
|
||||||
|
|
||||||
|
@if (hasNext()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="loadMore()"
|
||||||
|
[disabled]="loading()"
|
||||||
|
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{{ loading() ? 'Loading…' : 'Load more' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -341,11 +341,6 @@ export class RoomsSidePanelComponent {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||||
this.webrtc.broadcastMessage({
|
|
||||||
type: 'kick',
|
|
||||||
targetUserId: user.id,
|
|
||||||
kickedBy: this.currentUser()?.id
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,15 +84,35 @@
|
|||||||
<button
|
<button
|
||||||
(click)="joinServer(server)"
|
(click)="joinServer(server)"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full p-4 bg-card rounded-lg border border-border hover:border-primary/50 hover:bg-card/80 transition-all text-left group"
|
class="w-full p-4 bg-card rounded-lg border transition-all text-left group"
|
||||||
|
[class.border-border]="!isServerMarkedBanned(server)"
|
||||||
|
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
|
||||||
|
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
|
||||||
|
[class.border-destructive/40]="isServerMarkedBanned(server)"
|
||||||
|
[class.bg-destructive/5]="isServerMarkedBanned(server)"
|
||||||
|
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
|
||||||
|
[attr.aria-label]="isServerMarkedBanned(server) ? 'Banned server' : 'Join server'"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h3 class="font-semibold text-foreground group-hover:text-primary transition-colors">
|
<h3
|
||||||
|
class="font-semibold transition-colors"
|
||||||
|
[class.text-foreground]="!isServerMarkedBanned(server)"
|
||||||
|
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
|
||||||
|
[class.text-destructive]="isServerMarkedBanned(server)"
|
||||||
|
>
|
||||||
{{ server.name }}
|
{{ server.name }}
|
||||||
</h3>
|
</h3>
|
||||||
@if (server.isPrivate) {
|
@if (isServerMarkedBanned(server)) {
|
||||||
|
<ng-icon
|
||||||
|
name="lucideLock"
|
||||||
|
class="w-4 h-4 text-destructive"
|
||||||
|
/>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
|
||||||
|
>Banned</span
|
||||||
|
>
|
||||||
|
} @else if (server.isPrivate) {
|
||||||
<ng-icon
|
<ng-icon
|
||||||
name="lucideLock"
|
name="lucideLock"
|
||||||
class="w-4 h-4 text-muted-foreground"
|
class="w-4 h-4 text-muted-foreground"
|
||||||
@@ -120,10 +140,20 @@
|
|||||||
name="lucideUsers"
|
name="lucideUsers"
|
||||||
class="w-4 h-4"
|
class="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span>{{ server.userCount }}/{{ server.maxUsers }}</span>
|
<span>{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 space-y-1 text-xs">
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Users: <span class="text-foreground/80">{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Listed by: <span class="text-foreground/80">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 text-xs text-muted-foreground">Hosted by {{ server.hostName }}</div>
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -137,6 +167,20 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (showBannedDialog()) {
|
||||||
|
<app-confirm-dialog
|
||||||
|
title="Banned"
|
||||||
|
confirmLabel="OK"
|
||||||
|
cancelLabel="Close"
|
||||||
|
variant="danger"
|
||||||
|
[widthClass]="'w-96 max-w-[90vw]'"
|
||||||
|
(confirmed)="closeBannedDialog()"
|
||||||
|
(cancelled)="closeBannedDialog()"
|
||||||
|
>
|
||||||
|
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
|
||||||
|
</app-confirm-dialog>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Create Server Dialog -->
|
<!-- Create Server Dialog -->
|
||||||
@if (showCreateDialog()) {
|
@if (showCreateDialog()) {
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
OnInit,
|
OnInit,
|
||||||
signal
|
signal
|
||||||
@@ -31,9 +32,16 @@ import {
|
|||||||
selectRoomsError,
|
selectRoomsError,
|
||||||
selectSavedRooms
|
selectSavedRooms
|
||||||
} from '../../store/rooms/rooms.selectors';
|
} from '../../store/rooms/rooms.selectors';
|
||||||
import { Room } from '../../core/models/index';
|
import {
|
||||||
import { ServerInfo } from '../../core/models/index';
|
Room,
|
||||||
|
ServerInfo,
|
||||||
|
User
|
||||||
|
} from '../../core/models/index';
|
||||||
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
||||||
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
|
import { ConfirmDialogComponent } from '../../shared';
|
||||||
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-server-search',
|
selector: 'app-server-search',
|
||||||
@@ -41,7 +49,8 @@ import { SettingsModalService } from '../../core/services/settings-modal.service
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgIcon
|
NgIcon,
|
||||||
|
ConfirmDialogComponent
|
||||||
],
|
],
|
||||||
viewProviders: [
|
viewProviders: [
|
||||||
provideIcons({
|
provideIcons({
|
||||||
@@ -63,13 +72,19 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private settingsModal = inject(SettingsModalService);
|
private settingsModal = inject(SettingsModalService);
|
||||||
|
private db = inject(DatabaseService);
|
||||||
private searchSubject = new Subject<string>();
|
private searchSubject = new Subject<string>();
|
||||||
|
private banLookupRequestVersion = 0;
|
||||||
|
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
searchResults = this.store.selectSignal(selectSearchResults);
|
searchResults = this.store.selectSignal(selectSearchResults);
|
||||||
isSearching = this.store.selectSignal(selectIsSearching);
|
isSearching = this.store.selectSignal(selectIsSearching);
|
||||||
error = this.store.selectSignal(selectRoomsError);
|
error = this.store.selectSignal(selectRoomsError);
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
bannedServerLookup = signal<Record<string, boolean>>({});
|
||||||
|
bannedServerName = signal('');
|
||||||
|
showBannedDialog = signal(false);
|
||||||
|
|
||||||
// Create dialog state
|
// Create dialog state
|
||||||
showCreateDialog = signal(false);
|
showCreateDialog = signal(false);
|
||||||
@@ -79,6 +94,15 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
newServerPrivate = signal(false);
|
newServerPrivate = signal(false);
|
||||||
newServerPassword = signal('');
|
newServerPassword = signal('');
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const servers = this.searchResults();
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
|
void this.refreshBannedLookup(servers, currentUser ?? null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Initialize server search, load saved rooms, and set up debounced search. */
|
/** Initialize server search, load saved rooms, and set up debounced search. */
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Initial load
|
// Initial load
|
||||||
@@ -97,7 +121,7 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Join a server from the search results. Redirects to login if unauthenticated. */
|
/** Join a server from the search results. Redirects to login if unauthenticated. */
|
||||||
joinServer(server: ServerInfo): void {
|
async joinServer(server: ServerInfo): Promise<void> {
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -105,13 +129,19 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await this.isServerBanned(server)) {
|
||||||
|
this.bannedServerName.set(server.name);
|
||||||
|
this.showBannedDialog.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
RoomsActions.joinRoom({
|
RoomsActions.joinRoom({
|
||||||
roomId: server.id,
|
roomId: server.id,
|
||||||
serverInfo: {
|
serverInfo: {
|
||||||
name: server.name,
|
name: server.name,
|
||||||
description: server.description,
|
description: server.description,
|
||||||
hostName: server.hostName
|
hostName: server.sourceName || server.hostName
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -160,7 +190,29 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
|
|
||||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||||
joinSavedRoom(room: Room): void {
|
joinSavedRoom(room: Room): void {
|
||||||
this.joinServer(this.toServerInfo(room));
|
void this.joinServer(this.toServerInfo(room));
|
||||||
|
}
|
||||||
|
|
||||||
|
closeBannedDialog(): void {
|
||||||
|
this.showBannedDialog.set(false);
|
||||||
|
this.bannedServerName.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
isServerMarkedBanned(server: ServerInfo): boolean {
|
||||||
|
return !!this.bannedServerLookup()[server.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerUserCount(server: ServerInfo): number {
|
||||||
|
const candidate = server as ServerInfo & { currentUsers?: number };
|
||||||
|
|
||||||
|
if (typeof server.userCount === 'number')
|
||||||
|
return server.userCount;
|
||||||
|
|
||||||
|
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerCapacityLabel(server: ServerInfo): string {
|
||||||
|
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
|
||||||
}
|
}
|
||||||
|
|
||||||
private toServerInfo(room: Room): ServerInfo {
|
private toServerInfo(room: Room): ServerInfo {
|
||||||
@@ -169,13 +221,50 @@ export class ServerSearchComponent implements OnInit {
|
|||||||
name: room.name,
|
name: room.name,
|
||||||
description: room.description,
|
description: room.description,
|
||||||
hostName: room.hostId || 'Unknown',
|
hostName: room.hostId || 'Unknown',
|
||||||
userCount: room.userCount,
|
userCount: room.userCount ?? 0,
|
||||||
maxUsers: room.maxUsers ?? 50,
|
maxUsers: room.maxUsers ?? 50,
|
||||||
isPrivate: !!room.password,
|
isPrivate: !!room.password,
|
||||||
createdAt: room.createdAt
|
createdAt: room.createdAt,
|
||||||
|
ownerId: room.hostId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
||||||
|
const requestVersion = ++this.banLookupRequestVersion;
|
||||||
|
|
||||||
|
if (!currentUser || servers.length === 0) {
|
||||||
|
this.bannedServerLookup.set({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
const entries = await Promise.all(
|
||||||
|
servers.map(async (server) => {
|
||||||
|
const bans = await this.db.getBansForRoom(server.id);
|
||||||
|
const isBanned = hasRoomBanForUser(bans, currentUser, currentUserId);
|
||||||
|
|
||||||
|
return [server.id, isBanned] as const;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (requestVersion !== this.banLookupRequestVersion)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.bannedServerLookup.set(Object.fromEntries(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async isServerBanned(server: ServerInfo): Promise<boolean> {
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
|
||||||
|
if (!currentUser && !currentUserId)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const bans = await this.db.getBansForRoom(server.id);
|
||||||
|
|
||||||
|
return hasRoomBanForUser(bans, currentUser, currentUserId);
|
||||||
|
}
|
||||||
|
|
||||||
private resetCreateForm(): void {
|
private resetCreateForm(): void {
|
||||||
this.newServerName.set('');
|
this.newServerName.set('');
|
||||||
this.newServerDescription.set('');
|
this.newServerDescription.set('');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<!-- Saved servers icons -->
|
<!-- Saved servers icons -->
|
||||||
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
<div class="flex-1 w-full overflow-y-auto flex flex-col items-center gap-2 mt-2">
|
||||||
@for (room of savedRooms(); track room.id) {
|
@for (room of visibleSavedRooms(); track room.id) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
class="w-10 h-10 flex-shrink-0 rounded-2xl overflow-hidden border border-border hover:border-primary/60 hover:shadow-sm transition-all"
|
||||||
@@ -56,6 +56,20 @@
|
|||||||
</app-context-menu>
|
</app-context-menu>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (showBannedDialog()) {
|
||||||
|
<app-confirm-dialog
|
||||||
|
title="Banned"
|
||||||
|
confirmLabel="OK"
|
||||||
|
cancelLabel="Close"
|
||||||
|
variant="danger"
|
||||||
|
[widthClass]="'w-96 max-w-[90vw]'"
|
||||||
|
(confirmed)="closeBannedDialog()"
|
||||||
|
(cancelled)="closeBannedDialog()"
|
||||||
|
>
|
||||||
|
<p>You are banned from {{ bannedServerName() || 'this server' }}.</p>
|
||||||
|
</app-confirm-dialog>
|
||||||
|
}
|
||||||
|
|
||||||
@if (showLeaveConfirm() && contextRoom()) {
|
@if (showLeaveConfirm() && contextRoom()) {
|
||||||
<app-leave-server-dialog
|
<app-leave-server-dialog
|
||||||
[room]="contextRoom()!"
|
[room]="contextRoom()!"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -10,13 +12,22 @@ import { Router } from '@angular/router';
|
|||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { lucidePlus } from '@ng-icons/lucide';
|
import { lucidePlus } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { Room } from '../../core/models/index';
|
import {
|
||||||
|
Room,
|
||||||
|
User
|
||||||
|
} from '../../core/models/index';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||||
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||||
import { ContextMenuComponent, LeaveServerDialogComponent } from '../../shared';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
|
import {
|
||||||
|
ConfirmDialogComponent,
|
||||||
|
ContextMenuComponent,
|
||||||
|
LeaveServerDialogComponent
|
||||||
|
} from '../../shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-servers-rail',
|
selector: 'app-servers-rail',
|
||||||
@@ -24,6 +35,7 @@ import { ContextMenuComponent, LeaveServerDialogComponent } from '../../shared';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
NgIcon,
|
NgIcon,
|
||||||
|
ConfirmDialogComponent,
|
||||||
ContextMenuComponent,
|
ContextMenuComponent,
|
||||||
LeaveServerDialogComponent,
|
LeaveServerDialogComponent,
|
||||||
NgOptimizedImage
|
NgOptimizedImage
|
||||||
@@ -36,6 +48,8 @@ export class ServersRailComponent {
|
|||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionService);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
|
private db = inject(DatabaseService);
|
||||||
|
private banLookupRequestVersion = 0;
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
@@ -45,6 +59,19 @@ export class ServersRailComponent {
|
|||||||
contextRoom = signal<Room | null>(null);
|
contextRoom = signal<Room | null>(null);
|
||||||
showLeaveConfirm = signal(false);
|
showLeaveConfirm = signal(false);
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||||
|
bannedServerName = signal('');
|
||||||
|
showBannedDialog = signal(false);
|
||||||
|
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const rooms = this.savedRooms();
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
|
||||||
|
void this.refreshBannedLookup(rooms, currentUser ?? null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
initial(name?: string): string {
|
initial(name?: string): string {
|
||||||
if (!name)
|
if (!name)
|
||||||
@@ -67,7 +94,7 @@ export class ServersRailComponent {
|
|||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
}
|
}
|
||||||
|
|
||||||
joinSavedRoom(room: Room): void {
|
async joinSavedRoom(room: Room): Promise<void> {
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -75,6 +102,12 @@ export class ServersRailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await this.isRoomBanned(room)) {
|
||||||
|
this.bannedServerName.set(room.name);
|
||||||
|
this.showBannedDialog.set(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||||
|
|
||||||
if (voiceServerId && voiceServerId !== room.id) {
|
if (voiceServerId && voiceServerId !== room.id) {
|
||||||
@@ -99,6 +132,15 @@ export class ServersRailComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeBannedDialog(): void {
|
||||||
|
this.showBannedDialog.set(false);
|
||||||
|
this.bannedServerName.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
isRoomMarkedBanned(room: Room): boolean {
|
||||||
|
return !!this.bannedRoomLookup()[room.id];
|
||||||
|
}
|
||||||
|
|
||||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
this.contextRoom.set(room);
|
this.contextRoom.set(room);
|
||||||
@@ -150,4 +192,41 @@ export class ServersRailComponent {
|
|||||||
cancelLeave(): void {
|
cancelLeave(): void {
|
||||||
this.showLeaveConfirm.set(false);
|
this.showLeaveConfirm.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 async isRoomBanned(room: Room): Promise<boolean> {
|
||||||
|
const currentUser = this.currentUser();
|
||||||
|
const persistedUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
|
||||||
|
if (!currentUser && !persistedUserId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bans = await this.db.getBansForRoom(room.id);
|
||||||
|
|
||||||
|
return hasRoomBanForUser(bans, currentUser, persistedUserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
effect,
|
||||||
inject,
|
inject,
|
||||||
input
|
input,
|
||||||
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
Actions,
|
||||||
|
ofType
|
||||||
|
} from '@ngrx/effects';
|
||||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { lucideX } from '@ng-icons/lucide';
|
import { lucideX } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { Room, BanEntry } from '../../../../core/models/index';
|
import { Room, BanEntry } from '../../../../core/models/index';
|
||||||
|
import { DatabaseService } from '../../../../core/services/database.service';
|
||||||
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { selectBannedUsers } from '../../../../store/users/users.selectors';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-bans-settings',
|
selector: 'app-bans-settings',
|
||||||
@@ -26,16 +34,54 @@ import { selectBannedUsers } from '../../../../store/users/users.selectors';
|
|||||||
})
|
})
|
||||||
export class BansSettingsComponent {
|
export class BansSettingsComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
private actions$ = inject(Actions);
|
||||||
|
private db = inject(DatabaseService);
|
||||||
|
|
||||||
/** The currently selected server, passed from the parent. */
|
/** The currently selected server, passed from the parent. */
|
||||||
server = input<Room | null>(null);
|
server = input<Room | null>(null);
|
||||||
/** Whether the current user is admin of this server. */
|
/** Whether the current user is admin of this server. */
|
||||||
isAdmin = input(false);
|
isAdmin = input(false);
|
||||||
|
|
||||||
bannedUsers = this.store.selectSignal(selectBannedUsers);
|
bannedUsers = signal<BanEntry[]>([]);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const roomId = this.server()?.id;
|
||||||
|
|
||||||
|
if (!roomId) {
|
||||||
|
this.bannedUsers.set([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.loadBansForServer(roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.actions$
|
||||||
|
.pipe(
|
||||||
|
ofType(
|
||||||
|
UsersActions.banUserSuccess,
|
||||||
|
UsersActions.unbanUserSuccess,
|
||||||
|
UsersActions.loadBansSuccess,
|
||||||
|
RoomsActions.updateRoom
|
||||||
|
),
|
||||||
|
takeUntilDestroyed()
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
const roomId = this.server()?.id;
|
||||||
|
|
||||||
|
if (roomId) {
|
||||||
|
void this.loadBansForServer(roomId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
unbanUser(ban: BanEntry): void {
|
unbanUser(ban: BanEntry): void {
|
||||||
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
|
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
|
||||||
|
oderId: ban.oderId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBansForServer(roomId: string): Promise<void> {
|
||||||
|
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
|
||||||
}
|
}
|
||||||
|
|
||||||
formatExpiry(timestamp: number): string {
|
formatExpiry(timestamp: number): string {
|
||||||
|
|||||||
@@ -1,59 +1,68 @@
|
|||||||
@if (server()) {
|
@if (server()) {
|
||||||
<div class="space-y-3 max-w-xl">
|
<div class="space-y-3 max-w-xl">
|
||||||
@if (membersFiltered().length === 0) {
|
@if (members().length === 0) {
|
||||||
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
|
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
|
||||||
} @else {
|
} @else {
|
||||||
@for (user of membersFiltered(); track user.id) {
|
@for (member of members(); track member.oderId || member.id) {
|
||||||
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
|
||||||
<app-user-avatar
|
<app-user-avatar
|
||||||
[name]="user.displayName || '?'"
|
[name]="member.displayName || '?'"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<p class="text-sm font-medium text-foreground truncate">
|
<p class="text-sm font-medium text-foreground truncate">
|
||||||
{{ user.displayName }}
|
{{ member.displayName }}
|
||||||
</p>
|
</p>
|
||||||
@if (user.role === 'host') {
|
@if (member.isOnline) {
|
||||||
|
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded">Online</span>
|
||||||
|
}
|
||||||
|
@if (member.role === 'host') {
|
||||||
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
|
||||||
} @else if (user.role === 'admin') {
|
} @else if (member.role === 'admin') {
|
||||||
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
|
||||||
} @else if (user.role === 'moderator') {
|
} @else if (member.role === 'moderator') {
|
||||||
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (user.role !== 'host' && isAdmin()) {
|
@if (member.role !== 'host' && isAdmin()) {
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<select
|
@if (canChangeRoles()) {
|
||||||
[ngModel]="user.role"
|
<select
|
||||||
(ngModelChange)="changeRole(user, $event)"
|
[ngModel]="member.role"
|
||||||
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
(ngModelChange)="changeRole(member, $event)"
|
||||||
>
|
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
<option value="member">Member</option>
|
>
|
||||||
<option value="moderator">Moderator</option>
|
<option value="member">Member</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="moderator">Moderator</option>
|
||||||
</select>
|
<option value="admin">Admin</option>
|
||||||
<button
|
</select>
|
||||||
(click)="kickMember(user)"
|
}
|
||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
@if (canKickMembers()) {
|
||||||
title="Kick"
|
<button
|
||||||
>
|
(click)="kickMember(member)"
|
||||||
<ng-icon
|
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
name="lucideUserX"
|
title="Kick"
|
||||||
class="w-4 h-4"
|
>
|
||||||
/>
|
<ng-icon
|
||||||
</button>
|
name="lucideUserX"
|
||||||
<button
|
class="w-4 h-4"
|
||||||
(click)="banMember(user)"
|
/>
|
||||||
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
</button>
|
||||||
title="Ban"
|
}
|
||||||
>
|
@if (canBanMembers()) {
|
||||||
<ng-icon
|
<button
|
||||||
name="lucideBan"
|
(click)="banMember(member)"
|
||||||
class="w-4 h-4"
|
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
|
||||||
/>
|
title="Ban"
|
||||||
</button>
|
>
|
||||||
|
<ng-icon
|
||||||
|
name="lucideBan"
|
||||||
|
class="w-4 h-4"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input
|
input
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
@@ -10,12 +11,23 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { lucideUserX, lucideBan } from '@ng-icons/lucide';
|
import { lucideUserX, lucideBan } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { Room, User } from '../../../../core/models/index';
|
import {
|
||||||
|
Room,
|
||||||
|
RoomMember,
|
||||||
|
User,
|
||||||
|
UserRole
|
||||||
|
} from '../../../../core/models/index';
|
||||||
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||||
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors';
|
||||||
|
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||||
import { UserAvatarComponent } from '../../../../shared';
|
import { UserAvatarComponent } from '../../../../shared';
|
||||||
|
|
||||||
|
interface ServerMemberView extends RoomMember {
|
||||||
|
isOnline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-members-settings',
|
selector: 'app-members-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -41,45 +53,104 @@ export class MembersSettingsComponent {
|
|||||||
server = input<Room | null>(null);
|
server = input<Room | null>(null);
|
||||||
/** Whether the current user is admin of this server. */
|
/** Whether the current user is admin of this server. */
|
||||||
isAdmin = input(false);
|
isAdmin = input(false);
|
||||||
|
accessRole = input<UserRole | null>(null);
|
||||||
|
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
usersEntities = this.store.selectSignal(selectUsersEntities);
|
||||||
|
|
||||||
membersFiltered(): User[] {
|
members = computed<ServerMemberView[]>(() => {
|
||||||
|
const room = this.server();
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
|
const currentRoom = this.currentRoom();
|
||||||
|
const usersEntities = this.usersEntities();
|
||||||
|
|
||||||
return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId);
|
if (!room)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return (room.members ?? [])
|
||||||
|
.filter((member) => member.id !== me?.id && member.oderId !== me?.oderId)
|
||||||
|
.map((member) => {
|
||||||
|
const liveUser = currentRoom?.id === room.id
|
||||||
|
? (usersEntities[member.id]
|
||||||
|
|| Object.values(usersEntities).find((user) => !!user && user.oderId === member.oderId)
|
||||||
|
|| null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
avatarUrl: liveUser?.avatarUrl || member.avatarUrl,
|
||||||
|
displayName: liveUser?.displayName || member.displayName,
|
||||||
|
isOnline: !!liveUser && (liveUser.isOnline === true || liveUser.status !== 'offline')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
canChangeRoles(): boolean {
|
||||||
|
const role = this.accessRole();
|
||||||
|
|
||||||
|
return role === 'host' || role === 'admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
|
canKickMembers(): boolean {
|
||||||
const roomId = this.server()?.id;
|
const role = this.accessRole();
|
||||||
|
|
||||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
|
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||||
role }));
|
}
|
||||||
|
|
||||||
|
canBanMembers(): boolean {
|
||||||
|
const role = this.accessRole();
|
||||||
|
|
||||||
|
return role === 'host' || role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
changeRole(member: ServerMemberView, role: 'admin' | 'moderator' | 'member'): void {
|
||||||
|
const room = this.server();
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const members = (room.members ?? []).map((existingMember) =>
|
||||||
|
existingMember.id === member.id || existingMember.oderId === member.oderId
|
||||||
|
? { ...existingMember,
|
||||||
|
role }
|
||||||
|
: existingMember
|
||||||
|
);
|
||||||
|
|
||||||
|
this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id,
|
||||||
|
changes: { members } }));
|
||||||
|
|
||||||
|
if (this.currentRoom()?.id === room.id) {
|
||||||
|
this.store.dispatch(UsersActions.updateUserRole({ userId: member.id,
|
||||||
|
role }));
|
||||||
|
}
|
||||||
|
|
||||||
this.webrtcService.broadcastMessage({
|
this.webrtcService.broadcastMessage({
|
||||||
type: 'role-change',
|
type: 'role-change',
|
||||||
roomId,
|
roomId: room.id,
|
||||||
targetUserId: user.id,
|
targetUserId: member.id,
|
||||||
role
|
role
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
kickMember(user: User): void {
|
kickMember(member: ServerMemberView): void {
|
||||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
const room = this.server();
|
||||||
this.webrtcService.broadcastMessage({
|
|
||||||
type: 'kick',
|
if (!room)
|
||||||
targetUserId: user.id,
|
return;
|
||||||
kickedBy: this.currentUser()?.id
|
|
||||||
});
|
this.store.dispatch(UsersActions.kickUser({ userId: member.id,
|
||||||
|
roomId: room.id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
banMember(user: User): void {
|
banMember(member: ServerMemberView): void {
|
||||||
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
|
const room = this.server();
|
||||||
this.webrtcService.broadcastMessage({
|
|
||||||
type: 'ban',
|
if (!room)
|
||||||
targetUserId: user.id,
|
return;
|
||||||
bannedBy: this.currentUser()?.id
|
|
||||||
});
|
this.store.dispatch(UsersActions.banUser({ userId: member.id,
|
||||||
|
roomId: room.id,
|
||||||
|
displayName: member.displayName }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ export class ServerSettingsComponent {
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
RoomsActions.updateRoom({
|
RoomsActions.updateRoomSettings({
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
changes: {
|
settings: {
|
||||||
name: this.roomName,
|
name: this.roomName,
|
||||||
description: this.roomDescription,
|
description: this.roomDescription,
|
||||||
isPrivate: this.isPrivate(),
|
isPrivate: this.isPrivate(),
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<!-- Server section -->
|
<!-- Server section -->
|
||||||
@if (savedRooms().length > 0) {
|
@if (manageableRooms().length > 0) {
|
||||||
<div class="mt-3 pt-3 border-t border-border">
|
<div class="mt-3 pt-3 border-t border-border">
|
||||||
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
|
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
|
||||||
|
|
||||||
@@ -69,13 +69,13 @@
|
|||||||
(change)="onServerSelect($event)"
|
(change)="onServerSelect($event)"
|
||||||
>
|
>
|
||||||
<option value="">Select a server…</option>
|
<option value="">Select a server…</option>
|
||||||
@for (room of savedRooms(); track room.id) {
|
@for (room of manageableRooms(); track room.id) {
|
||||||
<option [value]="room.id">{{ room.name }}</option>
|
<option [value]="room.id">{{ room.name }}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (selectedServerId() && isSelectedServerAdmin()) {
|
@if (selectedServerId() && canAccessSelectedServer()) {
|
||||||
@for (page of serverPages; track page.id) {
|
@for (page of serverPages; track page.id) {
|
||||||
<button
|
<button
|
||||||
(click)="navigate(page.id)"
|
(click)="navigate(page.id)"
|
||||||
@@ -166,26 +166,27 @@
|
|||||||
@case ('server') {
|
@case ('server') {
|
||||||
<app-server-settings
|
<app-server-settings
|
||||||
[server]="selectedServer()"
|
[server]="selectedServer()"
|
||||||
[isAdmin]="isSelectedServerAdmin()"
|
[isAdmin]="isSelectedServerOwner()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@case ('members') {
|
@case ('members') {
|
||||||
<app-members-settings
|
<app-members-settings
|
||||||
[server]="selectedServer()"
|
[server]="selectedServer()"
|
||||||
[isAdmin]="isSelectedServerAdmin()"
|
[isAdmin]="canManageSelectedMembers()"
|
||||||
|
[accessRole]="selectedServerRole()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@case ('bans') {
|
@case ('bans') {
|
||||||
<app-bans-settings
|
<app-bans-settings
|
||||||
[server]="selectedServer()"
|
[server]="selectedServer()"
|
||||||
[isAdmin]="isSelectedServerAdmin()"
|
[isAdmin]="canManageSelectedBans()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@case ('permissions') {
|
@case ('permissions') {
|
||||||
<app-permissions-settings
|
<app-permissions-settings
|
||||||
#permissionsComp
|
#permissionsComp
|
||||||
[server]="selectedServer()"
|
[server]="selectedServer()"
|
||||||
[isAdmin]="isSelectedServerAdmin()"
|
[isAdmin]="isSelectedServerOwner()"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ import {
|
|||||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||||
import { Room } from '../../../core/models/index';
|
import {
|
||||||
|
Room,
|
||||||
|
UserRole
|
||||||
|
} from '../../../core/models/index';
|
||||||
|
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
|
||||||
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
|
|
||||||
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
||||||
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
|
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
|
||||||
@@ -69,7 +74,9 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
|
|||||||
export class SettingsModalComponent {
|
export class SettingsModalComponent {
|
||||||
readonly modal = inject(SettingsModalService);
|
readonly modal = inject(SettingsModalService);
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
|
private webrtc = inject(WebRTCService);
|
||||||
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
|
||||||
|
private lastRequestedServerId: string | null = null;
|
||||||
|
|
||||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||||
|
|
||||||
@@ -106,6 +113,19 @@ export class SettingsModalComponent {
|
|||||||
icon: 'lucideShield' }
|
icon: 'lucideShield' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
manageableRooms = computed<Room[]>(() => {
|
||||||
|
const user = this.currentUser();
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
return this.savedRooms().filter((room) => {
|
||||||
|
const role = this.getUserRoleForRoom(room, user.id, user.oderId, this.currentRoom()?.id === room.id ? user.role : null);
|
||||||
|
|
||||||
|
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
selectedServerId = signal<string | null>(null);
|
selectedServerId = signal<string | null>(null);
|
||||||
selectedServer = computed<Room | null>(() => {
|
selectedServer = computed<Room | null>(() => {
|
||||||
const id = this.selectedServerId();
|
const id = this.selectedServerId();
|
||||||
@@ -113,21 +133,48 @@ export class SettingsModalComponent {
|
|||||||
if (!id)
|
if (!id)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return this.savedRooms().find((room) => room.id === id) ?? null;
|
return this.manageableRooms().find((room) => room.id === id) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
showServerTabs = computed(() => {
|
showServerTabs = computed(() => {
|
||||||
return this.savedRooms().length > 0 && !!this.selectedServerId();
|
return this.manageableRooms().length > 0 && !!this.selectedServerId();
|
||||||
});
|
});
|
||||||
|
|
||||||
isSelectedServerAdmin = computed(() => {
|
selectedServerRole = computed<UserRole | null>(() => {
|
||||||
const server = this.selectedServer();
|
const server = this.selectedServer();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
|
|
||||||
if (!server || !user)
|
if (!server || !user)
|
||||||
return false;
|
return null;
|
||||||
|
|
||||||
return server.hostId === user.id || server.hostId === user.oderId;
|
return this.getUserRoleForRoom(
|
||||||
|
server,
|
||||||
|
user.id,
|
||||||
|
user.oderId,
|
||||||
|
this.currentRoom()?.id === server.id ? user.role : null
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
canAccessSelectedServer = computed(() => {
|
||||||
|
const role = this.selectedServerRole();
|
||||||
|
|
||||||
|
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||||
|
});
|
||||||
|
|
||||||
|
canManageSelectedMembers = computed(() => {
|
||||||
|
const role = this.selectedServerRole();
|
||||||
|
|
||||||
|
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||||
|
});
|
||||||
|
|
||||||
|
canManageSelectedBans = computed(() => {
|
||||||
|
const role = this.selectedServerRole();
|
||||||
|
|
||||||
|
return role === 'host' || role === 'admin';
|
||||||
|
});
|
||||||
|
|
||||||
|
isSelectedServerOwner = computed(() => {
|
||||||
|
return this.selectedServerRole() === 'host';
|
||||||
});
|
});
|
||||||
|
|
||||||
animating = signal(false);
|
animating = signal(false);
|
||||||
@@ -135,21 +182,27 @@ export class SettingsModalComponent {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.isOpen()) {
|
if (!this.isOpen()) {
|
||||||
const targetId = this.modal.targetServerId();
|
this.lastRequestedServerId = null;
|
||||||
|
return;
|
||||||
if (targetId) {
|
|
||||||
this.selectedServerId.set(targetId);
|
|
||||||
} else {
|
|
||||||
const currentRoom = this.currentRoom();
|
|
||||||
|
|
||||||
if (currentRoom) {
|
|
||||||
this.selectedServerId.set(currentRoom.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.animating.set(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rooms = this.manageableRooms();
|
||||||
|
const targetId = this.modal.targetServerId();
|
||||||
|
const currentRoomId = this.currentRoom()?.id ?? null;
|
||||||
|
const selectedId = this.selectedServerId();
|
||||||
|
|
||||||
|
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
|
||||||
|
|
||||||
|
if (!hasSelected) {
|
||||||
|
const fallbackId = [targetId, currentRoomId].find((candidateId) =>
|
||||||
|
!!candidateId && rooms.some((room) => room.id === candidateId)
|
||||||
|
) ?? rooms[0]?.id ?? null;
|
||||||
|
|
||||||
|
this.selectedServerId.set(fallbackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animating.set(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -163,7 +216,48 @@ export class SettingsModalComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
if (!this.isOpen())
|
||||||
|
return;
|
||||||
|
|
||||||
|
const serverId = this.selectedServerId();
|
||||||
|
|
||||||
|
if (!serverId || this.lastRequestedServerId === serverId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.lastRequestedServerId = serverId;
|
||||||
|
|
||||||
|
for (const peerId of this.webrtc.getConnectedPeers()) {
|
||||||
|
try {
|
||||||
|
this.webrtc.sendToPeer(peerId, {
|
||||||
|
type: 'server-state-request',
|
||||||
|
roomId: serverId
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* peer may have disconnected */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getUserRoleForRoom(
|
||||||
|
room: Room,
|
||||||
|
userId: string,
|
||||||
|
userOderId: string,
|
||||||
|
currentRole: UserRole | null
|
||||||
|
): UserRole | null {
|
||||||
|
if (room.hostId === userId || room.hostId === userOderId)
|
||||||
|
return 'host';
|
||||||
|
|
||||||
|
if (currentRole)
|
||||||
|
return currentRole;
|
||||||
|
|
||||||
|
return findRoomMember(room.members ?? [], userId)?.role
|
||||||
|
|| findRoomMember(room.members ?? [], userOderId)?.role
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
onEscapeKey(): void {
|
onEscapeKey(): void {
|
||||||
if (this.showThirdPartyLicenses()) {
|
if (this.showThirdPartyLicenses()) {
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export const RoomsActions = createActionGroup({
|
|||||||
'Forget Room': props<{ roomId: string; nextOwnerKey?: string }>(),
|
'Forget Room': props<{ roomId: string; nextOwnerKey?: string }>(),
|
||||||
'Forget Room Success': props<{ roomId: string }>(),
|
'Forget Room Success': props<{ roomId: string }>(),
|
||||||
|
|
||||||
'Update Room Settings': props<{ settings: Partial<RoomSettings> }>(),
|
'Update Room Settings': props<{ roomId: string; settings: Partial<RoomSettings> }>(),
|
||||||
'Update Room Settings Success': props<{ settings: RoomSettings }>(),
|
'Update Room Settings Success': props<{ roomId: string; settings: RoomSettings }>(),
|
||||||
'Update Room Settings Failure': props<{ error: string }>(),
|
'Update Room Settings Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
|
'Update Room Permissions': props<{ roomId: string; permissions: Partial<RoomPermissions> }>(),
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
RoomSettings,
|
RoomSettings,
|
||||||
RoomPermissions,
|
RoomPermissions,
|
||||||
|
BanEntry,
|
||||||
VoiceState
|
VoiceState
|
||||||
} from '../../core/models/index';
|
} from '../../core/models/index';
|
||||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||||
|
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
|
||||||
import {
|
import {
|
||||||
findRoomMember,
|
findRoomMember,
|
||||||
removeRoomMember,
|
removeRoomMember,
|
||||||
@@ -189,55 +191,64 @@ export class RoomsEffects {
|
|||||||
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check local DB
|
return from(this.getBlockedRoomAccessActions(roomId, currentUser)).pipe(
|
||||||
return from(this.db.getRoom(roomId)).pipe(
|
switchMap((blockedActions) => {
|
||||||
switchMap((room) => {
|
if (blockedActions.length > 0) {
|
||||||
if (room) {
|
return from(blockedActions);
|
||||||
return of(RoomsActions.joinRoomSuccess({ room }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not in local DB but we have server info from search, create a room entry
|
// First check local DB
|
||||||
if (serverInfo) {
|
return from(this.db.getRoom(roomId)).pipe(
|
||||||
const newRoom: Room = {
|
switchMap((room) => {
|
||||||
id: roomId,
|
if (room) {
|
||||||
name: serverInfo.name,
|
return of(RoomsActions.joinRoomSuccess({ room }));
|
||||||
description: serverInfo.description,
|
}
|
||||||
hostId: '', // Unknown, will be determined via signaling
|
|
||||||
isPrivate: !!password,
|
|
||||||
password,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
userCount: 1,
|
|
||||||
maxUsers: 50
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save to local DB for future reference
|
// If not in local DB but we have server info from search, create a room entry
|
||||||
this.db.saveRoom(newRoom);
|
if (serverInfo) {
|
||||||
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get room info from server
|
|
||||||
return this.serverDirectory.getServer(roomId).pipe(
|
|
||||||
switchMap((serverData) => {
|
|
||||||
if (serverData) {
|
|
||||||
const newRoom: Room = {
|
const newRoom: Room = {
|
||||||
id: serverData.id,
|
id: roomId,
|
||||||
name: serverData.name,
|
name: serverInfo.name,
|
||||||
description: serverData.description,
|
description: serverInfo.description,
|
||||||
hostId: serverData.ownerId || '',
|
hostId: '', // Unknown, will be determined via signaling
|
||||||
isPrivate: serverData.isPrivate,
|
isPrivate: !!password,
|
||||||
password,
|
password,
|
||||||
createdAt: serverData.createdAt || Date.now(),
|
createdAt: Date.now(),
|
||||||
userCount: serverData.userCount,
|
userCount: 1,
|
||||||
maxUsers: serverData.maxUsers
|
maxUsers: 50
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Save to local DB for future reference
|
||||||
this.db.saveRoom(newRoom);
|
this.db.saveRoom(newRoom);
|
||||||
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return of(RoomsActions.joinRoomFailure({ error: 'Room not found' }));
|
// Try to get room info from server
|
||||||
|
return this.serverDirectory.getServer(roomId).pipe(
|
||||||
|
switchMap((serverData) => {
|
||||||
|
if (serverData) {
|
||||||
|
const newRoom: Room = {
|
||||||
|
id: serverData.id,
|
||||||
|
name: serverData.name,
|
||||||
|
description: serverData.description,
|
||||||
|
hostId: serverData.ownerId || '',
|
||||||
|
isPrivate: serverData.isPrivate,
|
||||||
|
password,
|
||||||
|
createdAt: serverData.createdAt || Date.now(),
|
||||||
|
userCount: serverData.userCount,
|
||||||
|
maxUsers: serverData.maxUsers
|
||||||
|
};
|
||||||
|
|
||||||
|
this.db.saveRoom(newRoom);
|
||||||
|
return of(RoomsActions.joinRoomSuccess({ room: newRoom }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(RoomsActions.joinRoomFailure({ error: 'Room not found' }));
|
||||||
|
}),
|
||||||
|
catchError(() => of(RoomsActions.joinRoomFailure({ error: 'Room not found' })))
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
catchError(() => of(RoomsActions.joinRoomFailure({ error: 'Room not found' })))
|
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
||||||
@@ -285,16 +296,29 @@ export class RoomsEffects {
|
|||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.viewServer),
|
ofType(RoomsActions.viewServer),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser)),
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
mergeMap(([{ room }, user]) => {
|
switchMap(([{ room }, user]) => {
|
||||||
const oderId = user?.oderId || this.webrtc.peerId();
|
if (!user) {
|
||||||
|
return of(RoomsActions.joinRoomFailure({ error: 'Not logged in' }));
|
||||||
if (this.webrtc.isConnected()) {
|
|
||||||
this.webrtc.setCurrentServer(room.id);
|
|
||||||
this.webrtc.switchServer(room.id, oderId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.router.navigate(['/room', room.id]);
|
return from(this.getBlockedRoomAccessActions(room.id, user)).pipe(
|
||||||
return of(RoomsActions.viewServerSuccess({ room }));
|
switchMap((blockedActions) => {
|
||||||
|
if (blockedActions.length > 0) {
|
||||||
|
return from(blockedActions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const oderId = user.oderId || this.webrtc.peerId();
|
||||||
|
|
||||||
|
if (this.webrtc.isConnected()) {
|
||||||
|
this.webrtc.setCurrentServer(room.id);
|
||||||
|
this.webrtc.switchServer(room.id, oderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['/room', room.id]);
|
||||||
|
return of(RoomsActions.viewServerSuccess({ room }));
|
||||||
|
}),
|
||||||
|
catchError((error) => of(RoomsActions.joinRoomFailure({ error: error.message })))
|
||||||
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -426,18 +450,29 @@ export class RoomsEffects {
|
|||||||
updateRoomSettings$ = createEffect(() =>
|
updateRoomSettings$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.updateRoomSettings),
|
ofType(RoomsActions.updateRoomSettings),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
withLatestFrom(
|
||||||
|
this.store.select(selectCurrentUser),
|
||||||
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectSavedRooms)
|
||||||
|
),
|
||||||
mergeMap(([
|
mergeMap(([
|
||||||
{ settings },
|
{ roomId, settings },
|
||||||
currentUser,
|
currentUser,
|
||||||
currentRoom
|
currentRoom,
|
||||||
|
savedRooms
|
||||||
]) => {
|
]) => {
|
||||||
if (!currentUser || !currentRoom) {
|
if (!currentUser)
|
||||||
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not in a room' }));
|
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Not logged in' }));
|
||||||
}
|
|
||||||
|
|
||||||
// Only host/admin can update settings
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
if (currentRoom.hostId !== currentUser.id && currentUser.role !== 'admin') {
|
|
||||||
|
if (!room)
|
||||||
|
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' }));
|
||||||
|
|
||||||
|
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
|
||||||
|
const canManageCurrentRoom = currentRoom?.id === room.id && (currentUser.role === 'host' || currentUser.role === 'admin');
|
||||||
|
|
||||||
|
if (!isOwner && !canManageCurrentRoom) {
|
||||||
return of(
|
return of(
|
||||||
RoomsActions.updateRoomSettingsFailure({
|
RoomsActions.updateRoomSettingsFailure({
|
||||||
error: 'Permission denied'
|
error: 'Permission denied'
|
||||||
@@ -446,24 +481,36 @@ export class RoomsEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updatedSettings: RoomSettings = {
|
const updatedSettings: RoomSettings = {
|
||||||
name: settings.name ?? currentRoom.name,
|
name: settings.name ?? room.name,
|
||||||
description: settings.description ?? currentRoom.description,
|
description: settings.description ?? room.description,
|
||||||
topic: settings.topic ?? currentRoom.topic,
|
topic: settings.topic ?? room.topic,
|
||||||
isPrivate: settings.isPrivate ?? currentRoom.isPrivate,
|
isPrivate: settings.isPrivate ?? room.isPrivate,
|
||||||
password: settings.password ?? currentRoom.password,
|
password: settings.password ?? room.password,
|
||||||
maxUsers: settings.maxUsers ?? currentRoom.maxUsers
|
maxUsers: settings.maxUsers ?? room.maxUsers
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update local DB
|
this.db.updateRoom(room.id, updatedSettings);
|
||||||
this.db.updateRoom(currentRoom.id, updatedSettings);
|
|
||||||
|
|
||||||
// Broadcast to all peers
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'room-settings-update',
|
type: 'room-settings-update',
|
||||||
|
roomId: room.id,
|
||||||
settings: updatedSettings
|
settings: updatedSettings
|
||||||
});
|
});
|
||||||
|
|
||||||
return of(RoomsActions.updateRoomSettingsSuccess({ settings: updatedSettings }));
|
if (isOwner) {
|
||||||
|
this.serverDirectory.updateServer(room.id, {
|
||||||
|
currentOwnerId: currentUser.id,
|
||||||
|
name: updatedSettings.name,
|
||||||
|
description: updatedSettings.description,
|
||||||
|
isPrivate: updatedSettings.isPrivate,
|
||||||
|
maxUsers: updatedSettings.maxUsers
|
||||||
|
}).subscribe({
|
||||||
|
error: () => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(RoomsActions.updateRoomSettingsSuccess({ roomId: room.id,
|
||||||
|
settings: updatedSettings }));
|
||||||
}),
|
}),
|
||||||
catchError((error) => of(RoomsActions.updateRoomSettingsFailure({ error: error.message })))
|
catchError((error) => of(RoomsActions.updateRoomSettingsFailure({ error: error.message })))
|
||||||
)
|
)
|
||||||
@@ -485,34 +532,45 @@ export class RoomsEffects {
|
|||||||
updateRoomPermissions$ = createEffect(() =>
|
updateRoomPermissions$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.updateRoomPermissions),
|
ofType(RoomsActions.updateRoomPermissions),
|
||||||
withLatestFrom(this.store.select(selectCurrentUser), this.store.select(selectCurrentRoom)),
|
withLatestFrom(
|
||||||
filter(
|
this.store.select(selectCurrentUser),
|
||||||
([
|
this.store.select(selectCurrentRoom),
|
||||||
{ roomId },
|
this.store.select(selectSavedRooms)
|
||||||
currentUser,
|
|
||||||
currentRoom
|
|
||||||
]) =>
|
|
||||||
!!currentUser &&
|
|
||||||
!!currentRoom &&
|
|
||||||
currentRoom.id === roomId &&
|
|
||||||
currentRoom.hostId === currentUser.id
|
|
||||||
),
|
),
|
||||||
mergeMap(([
|
mergeMap(([
|
||||||
{ roomId, permissions }, , currentRoom
|
{ roomId, permissions },
|
||||||
|
currentUser,
|
||||||
|
currentRoom,
|
||||||
|
savedRooms
|
||||||
]) => {
|
]) => {
|
||||||
|
if (!currentUser)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const isOwner =
|
||||||
|
room.hostId === currentUser.id ||
|
||||||
|
room.hostId === currentUser.oderId ||
|
||||||
|
(currentRoom?.id === room.id && currentUser.role === 'host');
|
||||||
|
|
||||||
|
if (!isOwner)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
const updated: Partial<Room> = {
|
const updated: Partial<Room> = {
|
||||||
permissions: { ...(currentRoom!.permissions || {}),
|
permissions: { ...(room.permissions || {}),
|
||||||
...permissions } as RoomPermissions
|
...permissions } as RoomPermissions
|
||||||
};
|
};
|
||||||
|
|
||||||
this.db.updateRoom(roomId, updated);
|
|
||||||
// Broadcast to peers
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'room-permissions-update',
|
type: 'room-permissions-update',
|
||||||
|
roomId: room.id,
|
||||||
permissions: updated.permissions
|
permissions: updated.permissions
|
||||||
} as any);
|
});
|
||||||
|
|
||||||
return of(RoomsActions.updateRoom({ roomId,
|
return of(RoomsActions.updateRoom({ roomId: room.id,
|
||||||
changes: updated }));
|
changes: updated }));
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -667,32 +725,85 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Processes incoming P2P room and icon-sync events. */
|
/** Request a full room-state snapshot whenever a peer data channel opens. */
|
||||||
|
peerConnectedServerStateSync$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.webrtc.onPeerConnected.pipe(
|
||||||
|
withLatestFrom(this.store.select(selectCurrentRoom)),
|
||||||
|
tap(([peerId, room]) => {
|
||||||
|
if (!room)
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.webrtc.sendToPeer(peerId, {
|
||||||
|
type: 'server-state-request',
|
||||||
|
roomId: room.id
|
||||||
|
});
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ dispatch: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Re-request the latest room-state snapshot whenever the user enters or views a server. */
|
||||||
|
roomEntryServerStateSync$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(
|
||||||
|
RoomsActions.createRoomSuccess,
|
||||||
|
RoomsActions.joinRoomSuccess,
|
||||||
|
RoomsActions.viewServerSuccess
|
||||||
|
),
|
||||||
|
tap(({ room }) => {
|
||||||
|
for (const peerId of this.webrtc.getConnectedPeers()) {
|
||||||
|
try {
|
||||||
|
this.webrtc.sendToPeer(peerId, {
|
||||||
|
type: 'server-state-request',
|
||||||
|
roomId: room.id
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* peer may have disconnected */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ dispatch: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Processes incoming P2P room-state, room-sync, and icon-sync events. */
|
||||||
incomingRoomEvents$ = createEffect(() =>
|
incomingRoomEvents$ = createEffect(() =>
|
||||||
this.webrtc.onMessageReceived.pipe(
|
this.webrtc.onMessageReceived.pipe(
|
||||||
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectAllUsers)),
|
withLatestFrom(
|
||||||
filter(([, room]) => !!room),
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectSavedRooms),
|
||||||
|
this.store.select(selectAllUsers),
|
||||||
|
this.store.select(selectCurrentUser)
|
||||||
|
),
|
||||||
mergeMap(([
|
mergeMap(([
|
||||||
event,
|
event,
|
||||||
currentRoom,
|
currentRoom,
|
||||||
allUsers]: [any, any, any[]
|
savedRooms,
|
||||||
|
allUsers,
|
||||||
|
currentUser
|
||||||
]) => {
|
]) => {
|
||||||
const room = currentRoom as Room;
|
|
||||||
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'voice-state':
|
case 'voice-state':
|
||||||
return this.handleVoiceOrScreenState(event, allUsers, 'voice');
|
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, 'voice') : EMPTY;
|
||||||
case 'screen-state':
|
case 'screen-state':
|
||||||
return this.handleVoiceOrScreenState(event, allUsers, 'screen');
|
return currentRoom ? this.handleVoiceOrScreenState(event, allUsers, 'screen') : EMPTY;
|
||||||
|
case 'server-state-request':
|
||||||
|
return this.handleServerStateRequest(event, currentRoom, savedRooms);
|
||||||
|
case 'server-state-full':
|
||||||
|
return this.handleServerStateFull(event, currentRoom, savedRooms, currentUser ?? null);
|
||||||
case 'room-settings-update':
|
case 'room-settings-update':
|
||||||
return this.handleRoomSettingsUpdate(event, room);
|
return this.handleRoomSettingsUpdate(event, currentRoom, savedRooms);
|
||||||
|
case 'room-permissions-update':
|
||||||
|
return this.handleRoomPermissionsUpdate(event, currentRoom, savedRooms);
|
||||||
case 'server-icon-summary':
|
case 'server-icon-summary':
|
||||||
return this.handleIconSummary(event, room);
|
return this.handleIconSummary(event, currentRoom, savedRooms);
|
||||||
case 'server-icon-request':
|
case 'server-icon-request':
|
||||||
return this.handleIconRequest(event, room);
|
return this.handleIconRequest(event, currentRoom, savedRooms);
|
||||||
case 'server-icon-full':
|
case 'server-icon-full':
|
||||||
case 'server-icon-update':
|
case 'server-icon-update':
|
||||||
return this.handleIconData(event, room);
|
return this.handleIconData(event, currentRoom, savedRooms);
|
||||||
default:
|
default:
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
@@ -790,17 +901,187 @@ export class RoomsEffects {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleRoomSettingsUpdate(event: any, room: Room) {
|
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
|
||||||
const settings: RoomSettings | undefined = event.settings;
|
if (!roomId)
|
||||||
|
return currentRoom;
|
||||||
|
|
||||||
if (!settings)
|
if (currentRoom?.id === roomId)
|
||||||
return EMPTY;
|
return currentRoom;
|
||||||
|
|
||||||
this.db.updateRoom(room.id, settings);
|
return savedRooms.find((room) => room.id === roomId) ?? null;
|
||||||
return of(RoomsActions.receiveRoomUpdate({ room: { ...settings } as Partial<Room> }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIconSummary(event: any, room: Room) {
|
private sanitizeRoomSnapshot(room: Partial<Room>): Partial<Room> {
|
||||||
|
return {
|
||||||
|
name: typeof room.name === 'string' ? room.name : undefined,
|
||||||
|
description: typeof room.description === 'string' ? room.description : undefined,
|
||||||
|
topic: typeof room.topic === 'string' ? room.topic : undefined,
|
||||||
|
hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
|
||||||
|
password: typeof room.password === 'string' ? room.password : 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,
|
||||||
|
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
|
||||||
|
permissions: room.permissions ? { ...room.permissions } : undefined,
|
||||||
|
channels: Array.isArray(room.channels) ? room.channels : undefined,
|
||||||
|
members: Array.isArray(room.members) ? room.members : undefined
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeIncomingBans(roomId: string, bans: unknown): BanEntry[] {
|
||||||
|
if (!Array.isArray(bans))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
return bans
|
||||||
|
.filter((ban): ban is Partial<BanEntry> => !!ban && typeof ban === 'object')
|
||||||
|
.map((ban) => ({
|
||||||
|
oderId: typeof ban.oderId === 'string' ? ban.oderId : uuidv4(),
|
||||||
|
userId: typeof ban.userId === 'string' ? ban.userId : '',
|
||||||
|
roomId,
|
||||||
|
bannedBy: typeof ban.bannedBy === 'string' ? ban.bannedBy : '',
|
||||||
|
displayName: typeof ban.displayName === 'string' ? ban.displayName : undefined,
|
||||||
|
reason: typeof ban.reason === 'string' ? ban.reason : undefined,
|
||||||
|
expiresAt: typeof ban.expiresAt === 'number' ? ban.expiresAt : undefined,
|
||||||
|
timestamp: typeof ban.timestamp === 'number' ? ban.timestamp : now
|
||||||
|
}))
|
||||||
|
.filter((ban) => !!ban.userId && !!ban.bannedBy && (!ban.expiresAt || ban.expiresAt > now));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }));
|
||||||
|
|
||||||
|
return from(Promise.all([...removals, ...saves]));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleServerStateRequest(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
if (!room || !event.fromPeerId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
return from(this.db.getBansForRoom(room.id)).pipe(
|
||||||
|
tap((bans) => {
|
||||||
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||||
|
type: 'server-state-full',
|
||||||
|
roomId: room.id,
|
||||||
|
room,
|
||||||
|
bans
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
mergeMap(() => EMPTY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleServerStateFull(
|
||||||
|
event: any,
|
||||||
|
currentRoom: Room | null,
|
||||||
|
savedRooms: Room[],
|
||||||
|
currentUser: { id: string; oderId: string } | null
|
||||||
|
) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const incomingRoom = event.room as Partial<Room> | undefined;
|
||||||
|
|
||||||
|
if (!room || !incomingRoom)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const roomChanges = this.sanitizeRoomSnapshot(incomingRoom);
|
||||||
|
const bans = this.normalizeIncomingBans(room.id, event.bans);
|
||||||
|
|
||||||
|
return this.syncBansToLocalRoom(room.id, bans).pipe(
|
||||||
|
mergeMap(() => {
|
||||||
|
const actions: Array<
|
||||||
|
ReturnType<typeof RoomsActions.updateRoom>
|
||||||
|
| ReturnType<typeof UsersActions.loadBansSuccess>
|
||||||
|
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||||
|
> = [
|
||||||
|
RoomsActions.updateRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
changes: roomChanges
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
const isCurrentUserBanned = hasRoomBanForUser(
|
||||||
|
bans,
|
||||||
|
currentUser,
|
||||||
|
this.getPersistedCurrentUserId()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (currentRoom?.id === room.id) {
|
||||||
|
actions.push(UsersActions.loadBansSuccess({ bans }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCurrentUserBanned) {
|
||||||
|
actions.push(RoomsActions.forgetRoom({ roomId: room.id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}),
|
||||||
|
catchError(() => EMPTY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRoomSettingsUpdate(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const settings = event.settings as Partial<RoomSettings> | undefined;
|
||||||
|
|
||||||
|
if (!room || !settings)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
return of(
|
||||||
|
RoomsActions.updateRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
changes: {
|
||||||
|
name: settings.name ?? room.name,
|
||||||
|
description: settings.description ?? room.description,
|
||||||
|
topic: settings.topic ?? room.topic,
|
||||||
|
isPrivate: settings.isPrivate ?? room.isPrivate,
|
||||||
|
password: settings.password ?? room.password,
|
||||||
|
maxUsers: settings.maxUsers ?? room.maxUsers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRoomPermissionsUpdate(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const permissions = event.permissions as Partial<RoomPermissions> | undefined;
|
||||||
|
|
||||||
|
if (!room || !permissions)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
return of(
|
||||||
|
RoomsActions.updateRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
changes: {
|
||||||
|
permissions: { ...(room.permissions || {}),
|
||||||
|
...permissions } as RoomPermissions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIconSummary(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
const remoteUpdated = event.iconUpdatedAt || 0;
|
const remoteUpdated = event.iconUpdatedAt || 0;
|
||||||
const localUpdated = room.iconUpdatedAt || 0;
|
const localUpdated = room.iconUpdatedAt || 0;
|
||||||
|
|
||||||
@@ -814,7 +1095,13 @@ export class RoomsEffects {
|
|||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIconRequest(event: any, room: Room) {
|
private handleIconRequest(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
if (event.fromPeerId) {
|
if (event.fromPeerId) {
|
||||||
this.webrtc.sendToPeer(event.fromPeerId, {
|
this.webrtc.sendToPeer(event.fromPeerId, {
|
||||||
type: 'server-icon-full',
|
type: 'server-icon-full',
|
||||||
@@ -827,10 +1114,12 @@ export class RoomsEffects {
|
|||||||
return EMPTY;
|
return EMPTY;
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleIconData(event: any, room: Room) {
|
private handleIconData(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
const senderId = event.fromPeerId as string | undefined;
|
const senderId = event.fromPeerId as string | undefined;
|
||||||
|
|
||||||
if (typeof event.icon !== 'string' || !senderId)
|
if (!room || typeof event.icon !== 'string' || !senderId)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
return this.store.select(selectAllUsers).pipe(
|
return this.store.select(selectAllUsers).pipe(
|
||||||
@@ -880,4 +1169,30 @@ export class RoomsEffects {
|
|||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private getPersistedCurrentUserId(): string | null {
|
||||||
|
return localStorage.getItem('metoyou_currentUserId');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getBlockedRoomAccessActions(
|
||||||
|
roomId: string,
|
||||||
|
currentUser: { id: string; oderId: string } | null
|
||||||
|
): Promise<Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.joinRoomFailure>>> {
|
||||||
|
const bans = await this.db.getBansForRoom(roomId);
|
||||||
|
|
||||||
|
if (!hasRoomBanForUser(bans, currentUser, this.getPersistedCurrentUserId())) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedActions: Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof RoomsActions.joinRoomFailure>> = [
|
||||||
|
RoomsActions.joinRoomFailure({ error: 'You are banned from this server' })
|
||||||
|
];
|
||||||
|
const storedRoom = await this.db.getRoom(roomId);
|
||||||
|
|
||||||
|
if (storedRoom) {
|
||||||
|
blockedActions.unshift(RoomsActions.forgetRoom({ roomId }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return blockedActions;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,36 +235,34 @@ export const roomsReducer = createReducer(
|
|||||||
error: null
|
error: null
|
||||||
})),
|
})),
|
||||||
|
|
||||||
on(RoomsActions.updateRoomSettingsSuccess, (state, { settings }) => ({
|
on(RoomsActions.updateRoomSettingsSuccess, (state, { roomId, settings }) => {
|
||||||
...state,
|
const baseRoom = state.savedRooms.find((savedRoom) => savedRoom.id === roomId)
|
||||||
roomSettings: settings,
|
|| (state.currentRoom?.id === roomId ? state.currentRoom : null);
|
||||||
currentRoom: state.currentRoom
|
|
||||||
? enrichRoom({
|
if (!baseRoom) {
|
||||||
...state.currentRoom,
|
return {
|
||||||
name: settings.name,
|
...state,
|
||||||
description: settings.description,
|
roomSettings: state.currentRoom?.id === roomId ? settings : state.roomSettings
|
||||||
topic: settings.topic,
|
};
|
||||||
isPrivate: settings.isPrivate,
|
}
|
||||||
password: settings.password,
|
|
||||||
maxUsers: settings.maxUsers
|
const updatedRoom = enrichRoom({
|
||||||
})
|
...baseRoom,
|
||||||
: null,
|
name: settings.name,
|
||||||
savedRooms:
|
description: settings.description,
|
||||||
state.currentRoom
|
topic: settings.topic,
|
||||||
? upsertRoom(
|
isPrivate: settings.isPrivate,
|
||||||
state.savedRooms,
|
password: settings.password,
|
||||||
{
|
maxUsers: settings.maxUsers
|
||||||
...state.currentRoom,
|
});
|
||||||
name: settings.name,
|
|
||||||
description: settings.description,
|
return {
|
||||||
topic: settings.topic,
|
...state,
|
||||||
isPrivate: settings.isPrivate,
|
roomSettings: state.currentRoom?.id === roomId ? settings : state.roomSettings,
|
||||||
password: settings.password,
|
currentRoom: state.currentRoom?.id === roomId ? updatedRoom : state.currentRoom,
|
||||||
maxUsers: settings.maxUsers
|
savedRooms: upsertRoom(state.savedRooms, updatedRoom)
|
||||||
}
|
};
|
||||||
)
|
}),
|
||||||
: state.savedRooms
|
|
||||||
})),
|
|
||||||
|
|
||||||
on(RoomsActions.updateRoomSettingsFailure, (state, { error }) => ({
|
on(RoomsActions.updateRoomSettingsFailure, (state, { error }) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ export const UsersActions = createActionGroup({
|
|||||||
'Update User': props<{ userId: string; updates: Partial<User> }>(),
|
'Update User': props<{ userId: string; updates: Partial<User> }>(),
|
||||||
'Update User Role': props<{ userId: string; role: User['role'] }>(),
|
'Update User Role': props<{ userId: string; role: User['role'] }>(),
|
||||||
|
|
||||||
'Kick User': props<{ userId: string }>(),
|
'Kick User': props<{ userId: string; roomId?: string }>(),
|
||||||
'Kick User Success': props<{ userId: string }>(),
|
'Kick User Success': props<{ userId: string; roomId: string }>(),
|
||||||
|
|
||||||
'Ban User': props<{ userId: string; reason?: string; expiresAt?: number }>(),
|
'Ban User': props<{ userId: string; roomId?: string; displayName?: string; reason?: string; expiresAt?: number }>(),
|
||||||
'Ban User Success': props<{ userId: string; ban: BanEntry }>(),
|
'Ban User Success': props<{ userId: string; roomId: string; ban: BanEntry }>(),
|
||||||
'Unban User': props<{ oderId: string }>(),
|
'Unban User': props<{ roomId: string; oderId: string }>(),
|
||||||
'Unban User Success': props<{ oderId: string }>(),
|
'Unban User Success': props<{ oderId: string }>(),
|
||||||
|
|
||||||
'Load Bans': emptyProps(),
|
'Load Bans': emptyProps(),
|
||||||
|
|||||||
@@ -24,15 +24,28 @@ import {
|
|||||||
} from 'rxjs/operators';
|
} from 'rxjs/operators';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { UsersActions } from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
|
import { RoomsActions } from '../rooms/rooms.actions';
|
||||||
import {
|
import {
|
||||||
|
selectAllUsers,
|
||||||
selectCurrentUser,
|
selectCurrentUser,
|
||||||
selectCurrentUserId,
|
selectCurrentUserId,
|
||||||
selectHostId
|
selectHostId
|
||||||
} from './users.selectors';
|
} from './users.selectors';
|
||||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
import {
|
||||||
|
selectCurrentRoom,
|
||||||
|
selectSavedRooms
|
||||||
|
} from '../rooms/rooms.selectors';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { BanEntry, User } from '../../core/models/index';
|
import {
|
||||||
|
BanEntry,
|
||||||
|
Room,
|
||||||
|
User
|
||||||
|
} from '../../core/models/index';
|
||||||
|
import {
|
||||||
|
findRoomMember,
|
||||||
|
removeRoomMember
|
||||||
|
} from '../rooms/room-members.helpers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersEffects {
|
export class UsersEffects {
|
||||||
@@ -121,32 +134,48 @@ export class UsersEffects {
|
|||||||
ofType(UsersActions.kickUser),
|
ofType(UsersActions.kickUser),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectSavedRooms)
|
||||||
),
|
),
|
||||||
mergeMap(([
|
mergeMap(([
|
||||||
{ userId },
|
{ userId, roomId },
|
||||||
currentUser,
|
currentUser,
|
||||||
currentRoom
|
currentRoom,
|
||||||
|
savedRooms
|
||||||
]) => {
|
]) => {
|
||||||
if (!currentUser || !currentRoom)
|
if (!currentUser)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
const canKick =
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
currentUser.role === 'host' ||
|
|
||||||
currentUser.role === 'admin' ||
|
if (!room)
|
||||||
currentUser.role === 'moderator';
|
return EMPTY;
|
||||||
|
|
||||||
|
const canKick = this.canKickInRoom(room, currentUser, currentRoom);
|
||||||
|
|
||||||
if (!canKick)
|
if (!canKick)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
|
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
|
||||||
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'kick',
|
type: 'kick',
|
||||||
targetUserId: userId,
|
targetUserId: userId,
|
||||||
roomId: currentRoom.id,
|
roomId: room.id,
|
||||||
kickedBy: currentUser.id
|
kickedBy: currentUser.id
|
||||||
});
|
});
|
||||||
|
|
||||||
return of(UsersActions.kickUserSuccess({ userId }));
|
return currentRoom?.id === room.id
|
||||||
|
? [
|
||||||
|
RoomsActions.updateRoom({ roomId: room.id,
|
||||||
|
changes: { members: nextMembers } }),
|
||||||
|
UsersActions.kickUserSuccess({ userId,
|
||||||
|
roomId: room.id })
|
||||||
|
]
|
||||||
|
: of(
|
||||||
|
RoomsActions.updateRoom({ roomId: room.id,
|
||||||
|
changes: { members: nextMembers } })
|
||||||
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -157,56 +186,110 @@ export class UsersEffects {
|
|||||||
ofType(UsersActions.banUser),
|
ofType(UsersActions.banUser),
|
||||||
withLatestFrom(
|
withLatestFrom(
|
||||||
this.store.select(selectCurrentUser),
|
this.store.select(selectCurrentUser),
|
||||||
this.store.select(selectCurrentRoom)
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectSavedRooms),
|
||||||
|
this.store.select(selectAllUsers)
|
||||||
),
|
),
|
||||||
mergeMap(([
|
mergeMap(([
|
||||||
{ userId, reason, expiresAt },
|
{ userId, roomId, displayName, reason, expiresAt },
|
||||||
currentUser,
|
currentUser,
|
||||||
currentRoom
|
currentRoom,
|
||||||
|
savedRooms,
|
||||||
|
allUsers
|
||||||
]) => {
|
]) => {
|
||||||
if (!currentUser || !currentRoom)
|
if (!currentUser)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
const canBan = currentUser.role === 'host' || currentUser.role === 'admin';
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
if (!room)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const canBan = this.canBanInRoom(room, currentUser, currentRoom);
|
||||||
|
|
||||||
if (!canBan)
|
if (!canBan)
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
|
|
||||||
|
const targetUser = allUsers.find((user) => user.id === userId || user.oderId === userId);
|
||||||
|
const targetMember = findRoomMember(room.members ?? [], userId);
|
||||||
|
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
|
||||||
|
|
||||||
const ban: BanEntry = {
|
const ban: BanEntry = {
|
||||||
oderId: uuidv4(),
|
oderId: uuidv4(),
|
||||||
userId,
|
userId,
|
||||||
roomId: currentRoom.id,
|
roomId: room.id,
|
||||||
bannedBy: currentUser.id,
|
bannedBy: currentUser.id,
|
||||||
|
displayName: displayName || targetUser?.displayName || targetMember?.displayName,
|
||||||
reason,
|
reason,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
this.db.saveBan(ban);
|
return from(this.db.saveBan(ban)).pipe(
|
||||||
this.webrtc.broadcastMessage({
|
tap(() => {
|
||||||
type: 'ban',
|
this.webrtc.broadcastMessage({
|
||||||
targetUserId: userId,
|
type: 'ban',
|
||||||
roomId: currentRoom.id,
|
targetUserId: userId,
|
||||||
bannedBy: currentUser.id,
|
roomId: room.id,
|
||||||
reason
|
bannedBy: currentUser.id,
|
||||||
});
|
ban
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
mergeMap(() => {
|
||||||
|
const actions: Array<
|
||||||
|
ReturnType<typeof RoomsActions.updateRoom>
|
||||||
|
| ReturnType<typeof UsersActions.banUserSuccess>
|
||||||
|
> = [
|
||||||
|
RoomsActions.updateRoom({ roomId: room.id,
|
||||||
|
changes: { members: nextMembers } })
|
||||||
|
];
|
||||||
|
|
||||||
return of(UsersActions.banUserSuccess({ userId,
|
if (currentRoom?.id === room.id) {
|
||||||
ban }));
|
actions.push(UsersActions.banUserSuccess({ userId,
|
||||||
|
roomId: room.id,
|
||||||
|
ban }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}),
|
||||||
|
catchError(() => EMPTY)
|
||||||
|
);
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Removes a ban entry from the local database. */
|
/** Removes a ban entry locally and broadcasts the change to peers in the same room. */
|
||||||
unbanUser$ = createEffect(() =>
|
unbanUser$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(UsersActions.unbanUser),
|
ofType(UsersActions.unbanUser),
|
||||||
switchMap(({ oderId }) =>
|
withLatestFrom(
|
||||||
from(this.db.removeBan(oderId)).pipe(
|
this.store.select(selectCurrentUser),
|
||||||
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectSavedRooms)
|
||||||
|
),
|
||||||
|
switchMap(([
|
||||||
|
{ roomId, oderId },
|
||||||
|
currentUser,
|
||||||
|
currentRoom,
|
||||||
|
savedRooms
|
||||||
|
]) => {
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
if (!currentUser || !room || !this.canModerateRoom(room, currentUser, currentRoom))
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
return from(this.db.removeBan(oderId)).pipe(
|
||||||
|
tap(() => {
|
||||||
|
this.webrtc.broadcastMessage({
|
||||||
|
type: 'unban',
|
||||||
|
roomId: room.id,
|
||||||
|
banOderId: oderId
|
||||||
|
});
|
||||||
|
}),
|
||||||
map(() => UsersActions.unbanUserSuccess({ oderId })),
|
map(() => UsersActions.unbanUserSuccess({ oderId })),
|
||||||
catchError(() => EMPTY)
|
catchError(() => EMPTY)
|
||||||
)
|
);
|
||||||
)
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -228,6 +311,37 @@ export class UsersEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Applies incoming moderation events from peers to local persistence and UI state. */
|
||||||
|
incomingModerationEvents$ = createEffect(() =>
|
||||||
|
this.webrtc.onMessageReceived.pipe(
|
||||||
|
withLatestFrom(
|
||||||
|
this.store.select(selectCurrentUser),
|
||||||
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectSavedRooms)
|
||||||
|
),
|
||||||
|
mergeMap(([
|
||||||
|
event,
|
||||||
|
currentUser,
|
||||||
|
currentRoom,
|
||||||
|
savedRooms
|
||||||
|
]) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'kick':
|
||||||
|
return this.handleIncomingKick(event, currentUser ?? null, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
case 'ban':
|
||||||
|
return this.handleIncomingBan(event, currentUser ?? null, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
case 'unban':
|
||||||
|
return this.handleIncomingUnban(event, currentRoom, savedRooms);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
/** Elects the current user as host if the previous host leaves. */
|
/** Elects the current user as host if the previous host leaves. */
|
||||||
handleHostLeave$ = createEffect(() =>
|
handleHostLeave$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
@@ -268,4 +382,195 @@ export class UsersEffects {
|
|||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null {
|
||||||
|
if (!roomId)
|
||||||
|
return currentRoom;
|
||||||
|
|
||||||
|
if (currentRoom?.id === roomId)
|
||||||
|
return currentRoom;
|
||||||
|
|
||||||
|
return savedRooms.find((room) => room.id === roomId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
||||||
|
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
||||||
|
|
||||||
|
return role === 'host' || role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
private canKickInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
||||||
|
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
||||||
|
|
||||||
|
return role === 'host' || role === 'admin' || role === 'moderator';
|
||||||
|
}
|
||||||
|
|
||||||
|
private canBanInRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
|
||||||
|
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);
|
||||||
|
|
||||||
|
return role === 'host' || role === 'admin';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
|
||||||
|
return (
|
||||||
|
room.hostId === currentUser.id || room.hostId === currentUser.oderId
|
||||||
|
)
|
||||||
|
? 'host'
|
||||||
|
: (currentRoom?.id === room.id
|
||||||
|
? currentUser.role
|
||||||
|
: (findRoomMember(room.members ?? [], currentUser.id)?.role
|
||||||
|
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|
||||||
|
|| null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeMemberFromRoom(room: Room, targetUserId: string): Partial<Room> {
|
||||||
|
return {
|
||||||
|
members: removeRoomMember(room.members ?? [], targetUserId, targetUserId)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveIncomingModerationActions(
|
||||||
|
room: Room,
|
||||||
|
targetUserId: string,
|
||||||
|
currentRoom: Room | null,
|
||||||
|
extra: Array<ReturnType<typeof RoomsActions.forgetRoom> | ReturnType<typeof UsersActions.kickUserSuccess> | ReturnType<typeof UsersActions.banUserSuccess>> = []
|
||||||
|
) {
|
||||||
|
const actions: Array<
|
||||||
|
ReturnType<typeof RoomsActions.updateRoom>
|
||||||
|
| ReturnType<typeof RoomsActions.forgetRoom>
|
||||||
|
| ReturnType<typeof UsersActions.kickUserSuccess>
|
||||||
|
| ReturnType<typeof UsersActions.banUserSuccess>
|
||||||
|
> = [
|
||||||
|
RoomsActions.updateRoom({
|
||||||
|
roomId: room.id,
|
||||||
|
changes: this.removeMemberFromRoom(room, targetUserId)
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
if (currentRoom?.id === room.id) {
|
||||||
|
actions.push(...extra);
|
||||||
|
} else {
|
||||||
|
actions.push(...extra.filter((action) => action.type === RoomsActions.forgetRoom.type));
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldAffectVisibleUsers(room: Room, currentRoom: Room | null): boolean {
|
||||||
|
return currentRoom?.id === room.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private canForgetForTarget(targetUserId: string, currentUser: User | null): ReturnType<typeof RoomsActions.forgetRoom> | null {
|
||||||
|
return this.isCurrentUserTarget(targetUserId, currentUser)
|
||||||
|
? RoomsActions.forgetRoom({ roomId: '' })
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isCurrentUserTarget(targetUserId: string, currentUser: User | null): boolean {
|
||||||
|
return !!currentUser && (targetUserId === currentUser.id || targetUserId === currentUser.oderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildIncomingBan(event: any, targetUserId: string, roomId: string): BanEntry {
|
||||||
|
const payloadBan = event.ban && typeof event.ban === 'object'
|
||||||
|
? event.ban as Partial<BanEntry>
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
oderId: typeof payloadBan?.oderId === 'string' ? payloadBan.oderId : uuidv4(),
|
||||||
|
userId: typeof payloadBan?.userId === 'string' ? payloadBan.userId : targetUserId,
|
||||||
|
roomId,
|
||||||
|
bannedBy:
|
||||||
|
typeof payloadBan?.bannedBy === 'string'
|
||||||
|
? payloadBan.bannedBy
|
||||||
|
: (typeof event.bannedBy === 'string' ? event.bannedBy : 'unknown'),
|
||||||
|
displayName:
|
||||||
|
typeof payloadBan?.displayName === 'string'
|
||||||
|
? payloadBan.displayName
|
||||||
|
: (typeof event.displayName === 'string' ? event.displayName : undefined),
|
||||||
|
reason:
|
||||||
|
typeof payloadBan?.reason === 'string'
|
||||||
|
? payloadBan.reason
|
||||||
|
: (typeof event.reason === 'string' ? event.reason : undefined),
|
||||||
|
expiresAt:
|
||||||
|
typeof payloadBan?.expiresAt === 'number'
|
||||||
|
? payloadBan.expiresAt
|
||||||
|
: (typeof event.expiresAt === 'number' ? event.expiresAt : undefined),
|
||||||
|
timestamp: typeof payloadBan?.timestamp === 'number' ? payloadBan.timestamp : Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIncomingKick(
|
||||||
|
event: any,
|
||||||
|
currentUser: User | null,
|
||||||
|
currentRoom: Room | null,
|
||||||
|
savedRooms: Room[]
|
||||||
|
) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : '';
|
||||||
|
|
||||||
|
if (!room || !targetUserId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const actions = this.resolveIncomingModerationActions(
|
||||||
|
room,
|
||||||
|
targetUserId,
|
||||||
|
currentRoom,
|
||||||
|
this.isCurrentUserTarget(targetUserId, currentUser)
|
||||||
|
? [RoomsActions.forgetRoom({ roomId: room.id })]
|
||||||
|
: [UsersActions.kickUserSuccess({ userId: targetUserId,
|
||||||
|
roomId: room.id })]
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIncomingBan(
|
||||||
|
event: any,
|
||||||
|
currentUser: User | null,
|
||||||
|
currentRoom: Room | null,
|
||||||
|
savedRooms: Room[]
|
||||||
|
) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const targetUserId = typeof event.targetUserId === 'string' ? event.targetUserId : '';
|
||||||
|
|
||||||
|
if (!room || !targetUserId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
const ban = this.buildIncomingBan(event, targetUserId, room.id);
|
||||||
|
const actions = this.resolveIncomingModerationActions(
|
||||||
|
room,
|
||||||
|
targetUserId,
|
||||||
|
currentRoom,
|
||||||
|
this.isCurrentUserTarget(targetUserId, currentUser)
|
||||||
|
? [RoomsActions.forgetRoom({ roomId: room.id })]
|
||||||
|
: [UsersActions.banUserSuccess({ userId: targetUserId,
|
||||||
|
roomId: room.id,
|
||||||
|
ban })]
|
||||||
|
);
|
||||||
|
|
||||||
|
return from(this.db.saveBan(ban)).pipe(
|
||||||
|
mergeMap(() => (actions.length > 0 ? actions : EMPTY)),
|
||||||
|
catchError(() => EMPTY)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleIncomingUnban(event: any, currentRoom: Room | null, savedRooms: Room[]) {
|
||||||
|
const roomId = typeof event.roomId === 'string' ? event.roomId : currentRoom?.id;
|
||||||
|
const room = this.resolveRoom(roomId, currentRoom, savedRooms);
|
||||||
|
const banOderId = typeof event.banOderId === 'string'
|
||||||
|
? event.banOderId
|
||||||
|
: (typeof event.oderId === 'string' ? event.oderId : '');
|
||||||
|
|
||||||
|
if (!room || !banOderId)
|
||||||
|
return EMPTY;
|
||||||
|
|
||||||
|
return from(this.db.removeBan(banOderId)).pipe(
|
||||||
|
mergeMap(() => (currentRoom?.id === room.id
|
||||||
|
? of(UsersActions.unbanUserSuccess({ oderId: banOderId }))
|
||||||
|
: EMPTY)),
|
||||||
|
catchError(() => EMPTY)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||