Cleaning up comments
This commit is contained in:
@@ -1,10 +1,3 @@
|
|||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* CQRS type definitions for the MetoYou electron main process. */
|
|
||||||
/* Commands mutate state; queries read state. */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
// --------------- Command types ---------------
|
|
||||||
|
|
||||||
export const CommandType = {
|
export const CommandType = {
|
||||||
SaveMessage: 'save-message',
|
SaveMessage: 'save-message',
|
||||||
DeleteMessage: 'delete-message',
|
DeleteMessage: 'delete-message',
|
||||||
@@ -27,8 +20,6 @@ export const CommandType = {
|
|||||||
|
|
||||||
export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
||||||
|
|
||||||
// --------------- Query types ---------------
|
|
||||||
|
|
||||||
export const QueryType = {
|
export const QueryType = {
|
||||||
GetMessages: 'get-messages',
|
GetMessages: 'get-messages',
|
||||||
GetMessageById: 'get-message-by-id',
|
GetMessageById: 'get-message-by-id',
|
||||||
@@ -46,8 +37,6 @@ export const QueryType = {
|
|||||||
|
|
||||||
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
|
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
|
||||||
|
|
||||||
// --------------- Payload interfaces ---------------
|
|
||||||
|
|
||||||
export interface MessagePayload {
|
export interface MessagePayload {
|
||||||
id: string;
|
id: string;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -129,8 +118,6 @@ export interface AttachmentPayload {
|
|||||||
savedPath?: string;
|
savedPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------- Command interfaces ---------------
|
|
||||||
|
|
||||||
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
|
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
|
||||||
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
|
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
|
||||||
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
|
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
|
||||||
@@ -168,8 +155,6 @@ export type Command =
|
|||||||
| DeleteAttachmentsForMessageCommand
|
| DeleteAttachmentsForMessageCommand
|
||||||
| ClearAllDataCommand;
|
| ClearAllDataCommand;
|
||||||
|
|
||||||
// --------------- Query interfaces ---------------
|
|
||||||
|
|
||||||
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
||||||
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
|
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
|
||||||
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import { contextBridge, ipcRenderer } from 'electron';
|
|||||||
import { Command, Query } from './cqrs/types';
|
import { Command, Query } from './cqrs/types';
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
// Window controls
|
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
maximizeWindow: () => void;
|
maximizeWindow: () => void;
|
||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
|
|
||||||
// System utilities
|
|
||||||
openExternal: (url: string) => Promise<boolean>;
|
openExternal: (url: string) => Promise<boolean>;
|
||||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||||
getAppDataPath: () => Promise<string>;
|
getAppDataPath: () => Promise<string>;
|
||||||
@@ -17,7 +15,6 @@ export interface ElectronAPI {
|
|||||||
fileExists: (filePath: string) => Promise<boolean>;
|
fileExists: (filePath: string) => Promise<boolean>;
|
||||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||||
|
|
||||||
// CQRS database operations
|
|
||||||
command: <T = unknown>(command: Command) => Promise<T>;
|
command: <T = unknown>(command: Command) => Promise<T>;
|
||||||
query: <T = unknown>(query: Query) => Promise<T>;
|
query: <T = unknown>(query: Query) => Promise<T>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
/**
|
|
||||||
* Thin service layer - binds every CQRS handler to `getDataSource()` so
|
|
||||||
* routes can call one-liner functions instead of manually constructing
|
|
||||||
* command/query objects and passing the DataSource every time.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getDataSource } from '../db';
|
import { getDataSource } from '../db';
|
||||||
import {
|
import {
|
||||||
CommandType,
|
CommandType,
|
||||||
@@ -25,8 +19,6 @@ import { handleGetServerById } from './queries/handlers/getServerById';
|
|||||||
import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById';
|
import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById';
|
||||||
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
|
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
|
||||||
|
|
||||||
// --------------- Commands ---------------
|
|
||||||
|
|
||||||
export const registerUser = (user: AuthUserPayload) =>
|
export const registerUser = (user: AuthUserPayload) =>
|
||||||
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
|
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
|
||||||
|
|
||||||
@@ -45,8 +37,6 @@ export const updateJoinRequestStatus = (requestId: string, status: JoinRequestPa
|
|||||||
export const deleteStaleJoinRequests = (maxAgeMs: number) =>
|
export const deleteStaleJoinRequests = (maxAgeMs: number) =>
|
||||||
handleDeleteStaleJoinRequests({ type: CommandType.DeleteStaleJoinRequests, payload: { maxAgeMs } }, getDataSource());
|
handleDeleteStaleJoinRequests({ type: CommandType.DeleteStaleJoinRequests, payload: { maxAgeMs } }, getDataSource());
|
||||||
|
|
||||||
// --------------- Queries ---------------
|
|
||||||
|
|
||||||
export const getUserByUsername = (username: string) =>
|
export const getUserByUsername = (username: string) =>
|
||||||
handleGetUserByUsername({ type: QueryType.GetUserByUsername, payload: { username } }, getDataSource());
|
handleGetUserByUsername({ type: QueryType.GetUserByUsername, payload: { username } }, getDataSource());
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* CQRS type definitions for the MetoYou server process. */
|
|
||||||
/* Commands mutate state; queries read state. */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
// --------------- Command types ---------------
|
|
||||||
|
|
||||||
export const CommandType = {
|
export const CommandType = {
|
||||||
RegisterUser: 'register-user',
|
RegisterUser: 'register-user',
|
||||||
UpsertServer: 'upsert-server',
|
UpsertServer: 'upsert-server',
|
||||||
@@ -16,8 +9,6 @@ export const CommandType = {
|
|||||||
|
|
||||||
export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
||||||
|
|
||||||
// --------------- Query types ---------------
|
|
||||||
|
|
||||||
export const QueryType = {
|
export const QueryType = {
|
||||||
GetUserByUsername: 'get-user-by-username',
|
GetUserByUsername: 'get-user-by-username',
|
||||||
GetUserById: 'get-user-by-id',
|
GetUserById: 'get-user-by-id',
|
||||||
@@ -29,8 +20,6 @@ export const QueryType = {
|
|||||||
|
|
||||||
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
|
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
|
||||||
|
|
||||||
// --------------- Payload interfaces ---------------
|
|
||||||
|
|
||||||
export interface AuthUserPayload {
|
export interface AuthUserPayload {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -63,8 +52,6 @@ export interface JoinRequestPayload {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------- Command interfaces ---------------
|
|
||||||
|
|
||||||
export interface RegisterUserCommand {
|
export interface RegisterUserCommand {
|
||||||
type: typeof CommandType.RegisterUser;
|
type: typeof CommandType.RegisterUser;
|
||||||
payload: { user: AuthUserPayload };
|
payload: { user: AuthUserPayload };
|
||||||
@@ -103,8 +90,6 @@ export type Command =
|
|||||||
| UpdateJoinRequestStatusCommand
|
| UpdateJoinRequestStatusCommand
|
||||||
| DeleteStaleJoinRequestsCommand;
|
| DeleteStaleJoinRequestsCommand;
|
||||||
|
|
||||||
// --------------- Query interfaces ---------------
|
|
||||||
|
|
||||||
export interface GetUserByUsernameQuery {
|
export interface GetUserByUsernameQuery {
|
||||||
type: typeof QueryType.GetUserByUsername;
|
type: typeof QueryType.GetUserByUsername;
|
||||||
payload: { username: string };
|
payload: { username: string };
|
||||||
|
|||||||
@@ -31,12 +31,6 @@ import {
|
|||||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
STORAGE_KEY_LAST_VISITED_ROUTE
|
||||||
} from './core/constants';
|
} from './core/constants';
|
||||||
|
|
||||||
/**
|
|
||||||
* Root application component.
|
|
||||||
*
|
|
||||||
* Initialises the database, loads persisted user and room data,
|
|
||||||
* handles route restoration, and tracks voice session navigation.
|
|
||||||
*/
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [
|
imports: [
|
||||||
@@ -61,30 +55,24 @@ export class App implements OnInit {
|
|||||||
private voiceSession = inject(VoiceSessionService);
|
private voiceSession = inject(VoiceSessionService);
|
||||||
private externalLinks = inject(ExternalLinkService);
|
private externalLinks = inject(ExternalLinkService);
|
||||||
|
|
||||||
/** Intercept all <a> clicks and open them externally. */
|
|
||||||
@HostListener('document:click', ['$event'])
|
@HostListener('document:click', ['$event'])
|
||||||
onGlobalLinkClick(evt: MouseEvent): void {
|
onGlobalLinkClick(evt: MouseEvent): void {
|
||||||
this.externalLinks.handleClick(evt);
|
this.externalLinks.handleClick(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
// Initialize database
|
|
||||||
await this.databaseService.initialize();
|
await this.databaseService.initialize();
|
||||||
|
|
||||||
// Initial time sync with active server
|
|
||||||
try {
|
try {
|
||||||
const apiBase = this.servers.getApiBaseUrl();
|
const apiBase = this.servers.getApiBaseUrl();
|
||||||
|
|
||||||
await this.timeSync.syncWithEndpoint(apiBase);
|
await this.timeSync.syncWithEndpoint(apiBase);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
// Load user data from local storage or create new user
|
|
||||||
this.store.dispatch(UsersActions.loadCurrentUser());
|
this.store.dispatch(UsersActions.loadCurrentUser());
|
||||||
|
|
||||||
// Load saved rooms
|
|
||||||
this.store.dispatch(RoomsActions.loadRooms());
|
this.store.dispatch(RoomsActions.loadRooms());
|
||||||
|
|
||||||
// If not authenticated, redirect to login; else restore last route
|
|
||||||
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -103,20 +91,15 @@ export class App implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist last visited on navigation and track voice session navigation
|
|
||||||
this.router.events.subscribe((evt) => {
|
this.router.events.subscribe((evt) => {
|
||||||
if (evt instanceof NavigationEnd) {
|
if (evt instanceof NavigationEnd) {
|
||||||
const url = evt.urlAfterRedirects || evt.url;
|
const url = evt.urlAfterRedirects || evt.url;
|
||||||
|
|
||||||
// Store room route or search
|
|
||||||
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
|
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
|
||||||
|
|
||||||
// Check if user navigated away from voice-connected server
|
|
||||||
// Extract roomId from URL if on a room route
|
|
||||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||||
|
|
||||||
// Update voice session service with current server context
|
|
||||||
this.voiceSession.checkCurrentRoute(currentRoomId);
|
this.voiceSession.checkCurrentRoute(currentRoomId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,11 @@
|
|||||||
/**
|
|
||||||
* Application-wide constants shared across multiple components and services.
|
|
||||||
*
|
|
||||||
* Centralises localStorage keys, common defaults, and UI thresholds
|
|
||||||
* so that magic strings and numbers are defined in one place.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Key used to persist the current user's ID in localStorage. */
|
|
||||||
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
|
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
|
||||||
|
|
||||||
/** Key used to persist the last visited route for session restore. */
|
|
||||||
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
||||||
|
|
||||||
/** Key used to persist signaling / API connection settings. */
|
|
||||||
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
||||||
|
|
||||||
/** Key used to persist voice settings (input/output devices, volume). */
|
|
||||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||||
|
|
||||||
/** Key used to persist per-user volume overrides (0-200%). */
|
|
||||||
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
|
||||||
|
|
||||||
/** Regex that extracts a roomId from a `/room/:roomId` URL path. */
|
|
||||||
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
|
||||||
|
|
||||||
/** Maximum number of actions retained by NgRx Store devtools. */
|
|
||||||
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
export const STORE_DEVTOOLS_MAX_AGE = 25;
|
||||||
|
|
||||||
/** Default maximum number of users allowed in a new room. */
|
|
||||||
export const DEFAULT_MAX_USERS = 50;
|
export const DEFAULT_MAX_USERS = 50;
|
||||||
|
|
||||||
/** Default audio bitrate in kbps for voice chat. */
|
|
||||||
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
|
||||||
|
|
||||||
/** Default volume level (0-100). */
|
|
||||||
export const DEFAULT_VOLUME = 100;
|
export const DEFAULT_VOLUME = 100;
|
||||||
|
|
||||||
/** Default search debounce time in milliseconds. */
|
|
||||||
export const SEARCH_DEBOUNCE_MS = 300;
|
export const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|||||||
1
src/app/core/models.ts
Normal file
1
src/app/core/models.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './models/index';
|
||||||
@@ -1,293 +1,143 @@
|
|||||||
/**
|
|
||||||
* Core domain models for the MetoYou P2P chat application.
|
|
||||||
*
|
|
||||||
* These interfaces define the data structures shared across
|
|
||||||
* services, store, and components.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Possible online-presence statuses for a user. */
|
|
||||||
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
||||||
|
|
||||||
/** Role hierarchy within a room/server. */
|
|
||||||
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||||
|
|
||||||
/** Channel type within a server. */
|
|
||||||
export type ChannelType = 'text' | 'voice';
|
export type ChannelType = 'text' | 'voice';
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents an authenticated user in the system.
|
|
||||||
* Users are identified by both a local `id` and a network-wide `oderId`.
|
|
||||||
*/
|
|
||||||
export interface User {
|
export interface User {
|
||||||
/** Local database identifier. */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** Network-wide unique identifier used for peer identification. */
|
|
||||||
oderId: string;
|
oderId: string;
|
||||||
/** Login username (unique per auth server). */
|
|
||||||
username: string;
|
username: string;
|
||||||
/** Human-readable display name shown in the UI. */
|
|
||||||
displayName: string;
|
displayName: string;
|
||||||
/** Optional URL to the user's avatar image. */
|
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
/** Current online-presence status. */
|
|
||||||
status: UserStatus;
|
status: UserStatus;
|
||||||
/** Role within the current room/server. */
|
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
/** Epoch timestamp (ms) when the user first joined. */
|
|
||||||
joinedAt: number;
|
joinedAt: number;
|
||||||
/** WebRTC peer identifier (transient, set when connected). */
|
|
||||||
peerId?: string;
|
peerId?: string;
|
||||||
/** Whether the user is currently connected. */
|
|
||||||
isOnline?: boolean;
|
isOnline?: boolean;
|
||||||
/** Whether the user holds admin-level privileges. */
|
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
/** Whether the user is the owner of the current room. */
|
|
||||||
isRoomOwner?: boolean;
|
isRoomOwner?: boolean;
|
||||||
/** Real-time voice connection state. */
|
|
||||||
voiceState?: VoiceState;
|
voiceState?: VoiceState;
|
||||||
/** Real-time screen-sharing state. */
|
|
||||||
screenShareState?: ScreenShareState;
|
screenShareState?: ScreenShareState;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Persisted membership record for a room/server.
|
|
||||||
*
|
|
||||||
* Unlike `User`, this survives when a member goes offline so the UI can
|
|
||||||
* continue to list known server members.
|
|
||||||
*/
|
|
||||||
export interface RoomMember {
|
export interface RoomMember {
|
||||||
/** The member's local application/database identifier. */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** Optional network-wide peer identifier. */
|
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
/** Login username (best effort; may be synthesized from display name). */
|
|
||||||
username: string;
|
username: string;
|
||||||
/** Human-readable display name shown in the UI. */
|
|
||||||
displayName: string;
|
displayName: string;
|
||||||
/** Optional avatar URL. */
|
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
/** Role within the room/server. */
|
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
/** Epoch timestamp (ms) when the member first joined. */
|
|
||||||
joinedAt: number;
|
joinedAt: number;
|
||||||
/** Epoch timestamp (ms) when the member was last seen online. */
|
|
||||||
lastSeenAt: number;
|
lastSeenAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A communication channel within a server (either text or voice).
|
|
||||||
*/
|
|
||||||
export interface Channel {
|
export interface Channel {
|
||||||
/** Unique channel identifier. */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** Display name of the channel. */
|
|
||||||
name: string;
|
name: string;
|
||||||
/** Whether this is a text chat or voice channel. */
|
|
||||||
type: ChannelType;
|
type: ChannelType;
|
||||||
/** Sort order within its type group (lower value = higher priority). */
|
|
||||||
position: number;
|
position: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A single chat message in a room's text channel.
|
|
||||||
*/
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
/** Unique message identifier. */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** The room this message belongs to. */
|
|
||||||
roomId: string;
|
roomId: string;
|
||||||
/** The text channel within the room (defaults to 'general'). */
|
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
/** Identifier of the user who sent the message. */
|
|
||||||
senderId: string;
|
senderId: string;
|
||||||
/** Display name of the sender at the time of sending. */
|
|
||||||
senderName: string;
|
senderName: string;
|
||||||
/** Markdown-formatted message body. */
|
|
||||||
content: string;
|
content: string;
|
||||||
/** Epoch timestamp (ms) when the message was created. */
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
/** Epoch timestamp (ms) of the last edit, if any. */
|
|
||||||
editedAt?: number;
|
editedAt?: number;
|
||||||
/** Emoji reactions attached to this message. */
|
|
||||||
reactions: Reaction[];
|
reactions: Reaction[];
|
||||||
/** Whether this message has been soft-deleted. */
|
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
/** If this is a reply, the ID of the parent message. */
|
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* An emoji reaction on a message.
|
|
||||||
*/
|
|
||||||
export interface Reaction {
|
export interface Reaction {
|
||||||
/** Unique reaction identifier. */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** The message this reaction is attached to. */
|
|
||||||
messageId: string;
|
messageId: string;
|
||||||
/** Network-wide user ID of the reactor. */
|
|
||||||
oderId: string;
|
oderId: string;
|
||||||
/** Alias for `oderId` (kept for backward compatibility). */
|
|
||||||
userId: string;
|
userId: string;
|
||||||
/** The emoji character(s) used. */
|
|
||||||
emoji: string;
|
emoji: string;
|
||||||
/** Epoch timestamp (ms) when the reaction was added. */
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A chat room (server) that users can join to communicate.
|
|
||||||
*/
|
|
||||||
export interface Room {
|
export interface Room {
|
||||||
/** Unique room identifier. */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** Display name of the room. */
|
|
||||||
name: string;
|
name: string;
|
||||||
/** Optional long-form description. */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Short topic/status line shown in the header. */
|
|
||||||
topic?: string;
|
topic?: string;
|
||||||
/** User ID of the room's creator/owner. */
|
|
||||||
hostId: string;
|
hostId: string;
|
||||||
/** Password required to join (if private). */
|
|
||||||
password?: string;
|
password?: string;
|
||||||
/** Whether the room requires a password to join. */
|
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
/** Epoch timestamp (ms) when the room was created. */
|
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
/** Current number of connected users. */
|
|
||||||
userCount: number;
|
userCount: number;
|
||||||
/** Maximum allowed concurrent users. */
|
|
||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
/** Server icon as a data-URL or remote URL. */
|
|
||||||
icon?: string;
|
icon?: string;
|
||||||
/** Epoch timestamp (ms) of the last icon update (for conflict resolution). */
|
|
||||||
iconUpdatedAt?: number;
|
iconUpdatedAt?: number;
|
||||||
/** Role-based management permission overrides. */
|
|
||||||
permissions?: RoomPermissions;
|
permissions?: RoomPermissions;
|
||||||
/** Text and voice channels within the server. */
|
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
/** Persisted member roster, including offline users. */
|
|
||||||
members?: RoomMember[];
|
members?: RoomMember[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Editable subset of room properties exposed in the settings UI.
|
|
||||||
*/
|
|
||||||
export interface RoomSettings {
|
export interface RoomSettings {
|
||||||
/** Room display name. */
|
|
||||||
name: string;
|
name: string;
|
||||||
/** Optional long-form description. */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Short topic/status line. */
|
|
||||||
topic?: string;
|
topic?: string;
|
||||||
/** Whether a password is required to join. */
|
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
/** Password for private rooms. */
|
|
||||||
password?: string;
|
password?: string;
|
||||||
/** Maximum allowed concurrent users. */
|
|
||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
/** Optional list of room rules. */
|
|
||||||
rules?: string[];
|
rules?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fine-grained permission toggles for a room.
|
|
||||||
* Controls which roles can perform management actions.
|
|
||||||
*/
|
|
||||||
export interface RoomPermissions {
|
export interface RoomPermissions {
|
||||||
/** Whether admins can create/modify rooms. */
|
|
||||||
adminsManageRooms?: boolean;
|
adminsManageRooms?: boolean;
|
||||||
/** Whether moderators can create/modify rooms. */
|
|
||||||
moderatorsManageRooms?: boolean;
|
moderatorsManageRooms?: boolean;
|
||||||
/** Whether admins can change the server icon. */
|
|
||||||
adminsManageIcon?: boolean;
|
adminsManageIcon?: boolean;
|
||||||
/** Whether moderators can change the server icon. */
|
|
||||||
moderatorsManageIcon?: boolean;
|
moderatorsManageIcon?: boolean;
|
||||||
/** Whether voice channels are enabled. */
|
|
||||||
allowVoice?: boolean;
|
allowVoice?: boolean;
|
||||||
/** Whether screen sharing is enabled. */
|
|
||||||
allowScreenShare?: boolean;
|
allowScreenShare?: boolean;
|
||||||
/** Whether file uploads are enabled. */
|
|
||||||
allowFileUploads?: boolean;
|
allowFileUploads?: boolean;
|
||||||
/** Minimum delay (seconds) between messages (0 = disabled). */
|
|
||||||
slowModeInterval?: number;
|
slowModeInterval?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A record of a user being banned from a room.
|
|
||||||
*/
|
|
||||||
export interface BanEntry {
|
export interface BanEntry {
|
||||||
/** Unique ban identifier (also used as the banned user's oderId). */
|
|
||||||
oderId: string;
|
oderId: string;
|
||||||
/** The banned user's local ID. */
|
|
||||||
userId: string;
|
userId: string;
|
||||||
/** The room the ban applies to. */
|
|
||||||
roomId: string;
|
roomId: string;
|
||||||
/** User ID of the admin who issued the ban. */
|
|
||||||
bannedBy: string;
|
bannedBy: string;
|
||||||
/** Display name of the banned user at the time of banning. */
|
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
/** Human-readable reason for the ban. */
|
|
||||||
reason?: string;
|
reason?: string;
|
||||||
/** Epoch timestamp (ms) when the ban expires (undefined = permanent). */
|
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
/** Epoch timestamp (ms) when the ban was issued. */
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracks the state of a WebRTC peer connection.
|
|
||||||
*/
|
|
||||||
export interface PeerConnection {
|
export interface PeerConnection {
|
||||||
/** Remote peer identifier. */
|
|
||||||
peerId: string;
|
peerId: string;
|
||||||
/** Local user identifier. */
|
|
||||||
userId: string;
|
userId: string;
|
||||||
/** Current connection lifecycle state. */
|
|
||||||
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
||||||
/** The RTCDataChannel used for P2P messaging. */
|
|
||||||
dataChannel?: RTCDataChannel;
|
dataChannel?: RTCDataChannel;
|
||||||
/** The underlying RTCPeerConnection. */
|
|
||||||
connection?: RTCPeerConnection;
|
connection?: RTCPeerConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Real-time voice connection state for a user in a voice channel.
|
|
||||||
*/
|
|
||||||
export interface VoiceState {
|
export interface VoiceState {
|
||||||
/** Whether the user is connected to a voice channel. */
|
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
/** Whether the user's microphone is muted (self or by admin). */
|
|
||||||
isMuted: boolean;
|
isMuted: boolean;
|
||||||
/** Whether the user has deafened themselves. */
|
|
||||||
isDeafened: boolean;
|
isDeafened: boolean;
|
||||||
/** Whether the user is currently speaking (voice activity detection). */
|
|
||||||
isSpeaking: boolean;
|
isSpeaking: boolean;
|
||||||
/** Whether the user was server-muted by an admin. */
|
|
||||||
isMutedByAdmin?: boolean;
|
isMutedByAdmin?: boolean;
|
||||||
/** User's output volume level (0-1). */
|
|
||||||
volume?: number;
|
volume?: number;
|
||||||
/** The voice channel ID within the server (e.g. 'vc-general'). */
|
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
/** The server ID the user is connected to voice in. */
|
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Real-time screen-sharing state for a user.
|
|
||||||
*/
|
|
||||||
export interface ScreenShareState {
|
export interface ScreenShareState {
|
||||||
/** Whether the user is actively sharing their screen. */
|
|
||||||
isSharing: boolean;
|
isSharing: boolean;
|
||||||
/** MediaStream ID of the screen capture. */
|
|
||||||
streamId?: string;
|
streamId?: string;
|
||||||
/** Desktop capturer source ID (Electron only). */
|
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
/** Human-readable name of the captured source. */
|
|
||||||
sourceName?: string;
|
sourceName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All signaling message types exchanged via the WebSocket relay. */
|
|
||||||
export type SignalingMessageType =
|
export type SignalingMessageType =
|
||||||
| 'offer'
|
| 'offer'
|
||||||
| 'answer'
|
| 'answer'
|
||||||
@@ -301,23 +151,14 @@ export type SignalingMessageType =
|
|||||||
| 'host-change'
|
| 'host-change'
|
||||||
| 'room-update';
|
| 'room-update';
|
||||||
|
|
||||||
/**
|
|
||||||
* A message exchanged via the signaling WebSocket server.
|
|
||||||
*/
|
|
||||||
export interface SignalingMessage {
|
export interface SignalingMessage {
|
||||||
/** The type of signaling event. */
|
|
||||||
type: SignalingMessageType;
|
type: SignalingMessageType;
|
||||||
/** Sender's peer ID. */
|
|
||||||
from: string;
|
from: string;
|
||||||
/** Optional target peer ID (for directed messages). */
|
|
||||||
to?: string;
|
to?: string;
|
||||||
/** Arbitrary payload specific to the message type. */
|
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
/** Epoch timestamp (ms) when the message was sent. */
|
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All P2P chat event types exchanged via RTCDataChannel. */
|
|
||||||
export type ChatEventType =
|
export type ChatEventType =
|
||||||
| 'message'
|
| 'message'
|
||||||
| 'chat-message'
|
| 'chat-message'
|
||||||
@@ -344,119 +185,61 @@ export type ChatEventType =
|
|||||||
| 'role-change'
|
| 'role-change'
|
||||||
| 'channels-update';
|
| 'channels-update';
|
||||||
|
|
||||||
/**
|
/** Optional fields depend on `type`. */
|
||||||
* A P2P event exchanged between peers via RTCDataChannel.
|
|
||||||
* The `type` field determines which optional fields are populated.
|
|
||||||
*/
|
|
||||||
export interface ChatEvent {
|
export interface ChatEvent {
|
||||||
/** The type of P2P event. */
|
|
||||||
type: ChatEventType;
|
type: ChatEventType;
|
||||||
/** Relevant message ID (for edits, deletes, reactions). */
|
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
/** Full message payload (for new messages). */
|
|
||||||
message?: Message;
|
message?: Message;
|
||||||
/** Reaction payload (for reaction events). */
|
|
||||||
reaction?: Reaction;
|
reaction?: Reaction;
|
||||||
/** Partial message updates (for edits). */
|
|
||||||
data?: Partial<Message>;
|
data?: Partial<Message>;
|
||||||
/** Event timestamp. */
|
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
/** Target user ID (for kick/ban). */
|
|
||||||
targetUserId?: string;
|
targetUserId?: string;
|
||||||
/** Room ID the event pertains to. */
|
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
/** Updated room host ID after an ownership change. */
|
|
||||||
hostId?: string;
|
hostId?: string;
|
||||||
/** Updated room host `oderId` after an ownership change. */
|
|
||||||
hostOderId?: string;
|
hostOderId?: string;
|
||||||
/** Previous room host ID before the ownership change. */
|
|
||||||
previousHostId?: string;
|
previousHostId?: string;
|
||||||
/** Previous room host `oderId` before the ownership change. */
|
|
||||||
previousHostOderId?: string;
|
previousHostOderId?: string;
|
||||||
/** User who issued a kick. */
|
|
||||||
kickedBy?: string;
|
kickedBy?: string;
|
||||||
/** User who issued a ban. */
|
|
||||||
bannedBy?: string;
|
bannedBy?: string;
|
||||||
/** Text content (for messages/edits). */
|
|
||||||
content?: string;
|
content?: string;
|
||||||
/** Edit timestamp. */
|
|
||||||
editedAt?: number;
|
editedAt?: number;
|
||||||
/** User who performed a delete. */
|
|
||||||
deletedBy?: string;
|
deletedBy?: string;
|
||||||
/** Network-wide user identifier. */
|
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
/** Display name of the event sender. */
|
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
/** Emoji character (for reactions). */
|
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
/** Ban/kick reason. */
|
|
||||||
reason?: string;
|
reason?: string;
|
||||||
/** Updated room settings. */
|
|
||||||
settings?: RoomSettings;
|
settings?: RoomSettings;
|
||||||
/** Partial voice state update. */
|
|
||||||
voiceState?: Partial<VoiceState>;
|
voiceState?: Partial<VoiceState>;
|
||||||
/** Screen-sharing flag. */
|
|
||||||
isScreenSharing?: boolean;
|
isScreenSharing?: boolean;
|
||||||
/** New role assignment. */
|
|
||||||
role?: UserRole;
|
role?: UserRole;
|
||||||
/** Updated channel list. */
|
|
||||||
channels?: Channel[];
|
channels?: Channel[];
|
||||||
/** Synced room member roster. */
|
|
||||||
members?: RoomMember[];
|
members?: RoomMember[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Server listing as returned by the directory API.
|
|
||||||
*/
|
|
||||||
export interface ServerInfo {
|
export interface ServerInfo {
|
||||||
/** Unique server identifier. */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** Display name. */
|
|
||||||
name: string;
|
name: string;
|
||||||
/** Optional description. */
|
|
||||||
description?: string;
|
description?: string;
|
||||||
/** Optional topic. */
|
|
||||||
topic?: string;
|
topic?: string;
|
||||||
/** Display name of the host. */
|
|
||||||
hostName: string;
|
hostName: string;
|
||||||
/** Owner's user ID. */
|
|
||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
/** Owner's public key / oderId. */
|
|
||||||
ownerPublicKey?: string;
|
ownerPublicKey?: string;
|
||||||
/** Current number of connected users. */
|
|
||||||
userCount: number;
|
userCount: number;
|
||||||
/** Maximum allowed users. */
|
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
/** Whether a password is required. */
|
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
/** Searchable tags. */
|
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
/** Epoch timestamp (ms) when the server was created. */
|
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Request payload for joining a server.
|
|
||||||
*/
|
|
||||||
export interface JoinRequest {
|
export interface JoinRequest {
|
||||||
/** Target room/server ID. */
|
|
||||||
roomId: string;
|
roomId: string;
|
||||||
/** Requesting user's ID. */
|
|
||||||
userId: string;
|
userId: string;
|
||||||
/** Requesting user's username. */
|
|
||||||
username: string;
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Top-level application state snapshot (used for diagnostics).
|
|
||||||
*/
|
|
||||||
export interface AppState {
|
export interface AppState {
|
||||||
/** The currently authenticated user, or null if logged out. */
|
|
||||||
currentUser: User | null;
|
currentUser: User | null;
|
||||||
/** The room the user is currently viewing, or null. */
|
|
||||||
currentRoom: Room | null;
|
currentRoom: Room | null;
|
||||||
/** Whether a connection attempt is in progress. */
|
|
||||||
isConnecting: boolean;
|
isConnecting: boolean;
|
||||||
/** Last error message, or null. */
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
Reaction,
|
Reaction,
|
||||||
BanEntry
|
BanEntry
|
||||||
} from '../models';
|
} from '../models/index';
|
||||||
|
|
||||||
/** IndexedDB database name for the MetoYou application. */
|
/** IndexedDB database name for the MetoYou application. */
|
||||||
const DATABASE_NAME = 'metoyou';
|
const DATABASE_NAME = 'metoyou';
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
Reaction,
|
Reaction,
|
||||||
BanEntry
|
BanEntry
|
||||||
} from '../models';
|
} from '../models/index';
|
||||||
import { PlatformService } from './platform.service';
|
import { PlatformService } from './platform.service';
|
||||||
import { BrowserDatabaseService } from './browser-database.service';
|
import { BrowserDatabaseService } from './browser-database.service';
|
||||||
import { ElectronDatabaseService } from './electron-database.service';
|
import { ElectronDatabaseService } from './electron-database.service';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
Reaction,
|
Reaction,
|
||||||
BanEntry
|
BanEntry
|
||||||
} from '../models';
|
} from '../models/index';
|
||||||
|
|
||||||
/** CQRS API exposed by the Electron preload script via `contextBridge`. */
|
/** CQRS API exposed by the Electron preload script via `contextBridge`. */
|
||||||
interface ElectronAPI {
|
interface ElectronAPI {
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects the runtime platform so other services can branch behaviour
|
|
||||||
* between Electron (desktop) and a plain browser tab.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class PlatformService {
|
export class PlatformService {
|
||||||
/** True when the app is hosted inside an Electron renderer process. */
|
|
||||||
readonly isElectron: boolean;
|
readonly isElectron: boolean;
|
||||||
|
|
||||||
/** True when the app is running in an ordinary browser (no Electron shell). */
|
|
||||||
readonly isBrowser: boolean;
|
readonly isBrowser: boolean;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
ServerInfo,
|
ServerInfo,
|
||||||
JoinRequest,
|
JoinRequest,
|
||||||
User
|
User
|
||||||
} from '../models';
|
} from '../models/index';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,38 +1,22 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
/**
|
|
||||||
* Pages available in the unified settings modal.
|
|
||||||
* Network & Voice are always visible; server-specific pages
|
|
||||||
* require an active room and admin role.
|
|
||||||
*/
|
|
||||||
export type SettingsPage = 'network' | 'voice' | 'server' | 'members' | 'bans' | 'permissions';
|
export type SettingsPage = 'network' | 'voice' | 'server' | 'members' | 'bans' | 'permissions';
|
||||||
|
|
||||||
/**
|
|
||||||
* Global service controlling the unified settings modal.
|
|
||||||
* Any component can open the modal to a specific page via `open()`.
|
|
||||||
*/
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class SettingsModalService {
|
export class SettingsModalService {
|
||||||
/** Whether the modal is currently visible. */
|
|
||||||
readonly isOpen = signal(false);
|
readonly isOpen = signal(false);
|
||||||
/** The currently active page within the side-nav. */
|
|
||||||
readonly activePage = signal<SettingsPage>('network');
|
readonly activePage = signal<SettingsPage>('network');
|
||||||
/** Optional server/room ID to pre-select in admin tabs. */
|
|
||||||
readonly targetServerId = signal<string | null>(null);
|
readonly targetServerId = signal<string | null>(null);
|
||||||
|
|
||||||
/** Open the modal to a specific page, optionally targeting a server. */
|
|
||||||
open(page: SettingsPage = 'network', serverId?: string): void {
|
open(page: SettingsPage = 'network', serverId?: string): void {
|
||||||
this.activePage.set(page);
|
this.activePage.set(page);
|
||||||
this.targetServerId.set(serverId ?? null);
|
this.targetServerId.set(serverId ?? null);
|
||||||
this.isOpen.set(true);
|
this.isOpen.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the modal. */
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.isOpen.set(false);
|
this.isOpen.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Navigate to a different page while the modal remains open. */
|
|
||||||
navigate(page: SettingsPage): void {
|
navigate(page: SettingsPage): void {
|
||||||
this.activePage.set(page);
|
this.activePage.set(page);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,32 +26,20 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import { WebRTCService } from './webrtc.service';
|
import { WebRTCService } from './webrtc.service';
|
||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, id-length, max-statements-per-line */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
|
||||||
|
|
||||||
/** RMS volume threshold (0-1) above which a user counts as "speaking". */
|
|
||||||
const SPEAKING_THRESHOLD = 0.015;
|
const SPEAKING_THRESHOLD = 0.015;
|
||||||
/** How many consecutive silent frames before we flip speaking → false. */
|
|
||||||
const SILENT_FRAME_GRACE = 8;
|
const SILENT_FRAME_GRACE = 8;
|
||||||
/** FFT size for the AnalyserNode (smaller = cheaper). */
|
|
||||||
const FFT_SIZE = 256;
|
const FFT_SIZE = 256;
|
||||||
|
|
||||||
/** Internal bookkeeping for a single tracked stream. */
|
|
||||||
interface TrackedStream {
|
interface TrackedStream {
|
||||||
/** The AudioContext used for analysis (one per stream to avoid cross-origin issues). */
|
|
||||||
ctx: AudioContext;
|
ctx: AudioContext;
|
||||||
/** Source node wired from the MediaStream. */
|
|
||||||
source: MediaStreamAudioSourceNode;
|
source: MediaStreamAudioSourceNode;
|
||||||
/** Analyser node that provides time-domain data. */
|
|
||||||
analyser: AnalyserNode;
|
analyser: AnalyserNode;
|
||||||
/** Reusable buffer for `getByteTimeDomainData`. */
|
|
||||||
dataArray: Uint8Array<ArrayBuffer>;
|
dataArray: Uint8Array<ArrayBuffer>;
|
||||||
/** Writable signal for the normalised volume (0-1). */
|
|
||||||
volumeSignal: ReturnType<typeof signal<number>>;
|
volumeSignal: ReturnType<typeof signal<number>>;
|
||||||
/** Writable signal for speaking state. */
|
|
||||||
speakingSignal: ReturnType<typeof signal<boolean>>;
|
speakingSignal: ReturnType<typeof signal<boolean>>;
|
||||||
/** Counter of consecutive silent frames. */
|
|
||||||
silentFrames: number;
|
silentFrames: number;
|
||||||
/** The MediaStream being analysed (for identity checks). */
|
|
||||||
stream: MediaStream;
|
stream: MediaStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,23 +47,14 @@ interface TrackedStream {
|
|||||||
export class VoiceActivityService implements OnDestroy {
|
export class VoiceActivityService implements OnDestroy {
|
||||||
private readonly webrtc = inject(WebRTCService);
|
private readonly webrtc = inject(WebRTCService);
|
||||||
|
|
||||||
/** All tracked streams keyed by user/peer ID. */
|
|
||||||
private readonly tracked = new Map<string, TrackedStream>();
|
private readonly tracked = new Map<string, TrackedStream>();
|
||||||
|
|
||||||
/** Animation frame handle. */
|
|
||||||
private animFrameId: number | null = null;
|
private animFrameId: number | null = null;
|
||||||
|
|
||||||
/** RxJS subscriptions managed by this service. */
|
|
||||||
private readonly subs: Subscription[] = [];
|
private readonly subs: Subscription[] = [];
|
||||||
|
|
||||||
/** Exposed map: userId → speaking (reactive snapshot). */
|
|
||||||
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
|
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
|
||||||
|
|
||||||
/** Reactive snapshot of all speaking users (for debugging / bulk consumption). */
|
|
||||||
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap;
|
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Wire up remote stream events
|
|
||||||
this.subs.push(
|
this.subs.push(
|
||||||
this.webrtc.onRemoteStream.subscribe(({ peerId, stream }) => {
|
this.webrtc.onRemoteStream.subscribe(({ peerId, stream }) => {
|
||||||
this.trackStream(peerId, stream);
|
this.trackStream(peerId, stream);
|
||||||
@@ -89,50 +68,23 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start monitoring the current user's local microphone stream.
|
|
||||||
* Should be called after voice is enabled (mic captured).
|
|
||||||
*
|
|
||||||
* @param userId - The local user's ID (used as the key in the speaking map).
|
|
||||||
* @param stream - The local {@link MediaStream} from `getUserMedia`.
|
|
||||||
*/
|
|
||||||
trackLocalMic(userId: string, stream: MediaStream): void {
|
trackLocalMic(userId: string, stream: MediaStream): void {
|
||||||
this.trackStream(userId, stream);
|
this.trackStream(userId, stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop monitoring the current user's local microphone.
|
|
||||||
*
|
|
||||||
* @param userId - The local user's ID.
|
|
||||||
*/
|
|
||||||
untrackLocalMic(userId: string): void {
|
untrackLocalMic(userId: string): void {
|
||||||
this.untrackStream(userId);
|
this.untrackStream(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a read-only signal that is `true` when the given user
|
|
||||||
* is currently speaking (audio level above threshold).
|
|
||||||
*
|
|
||||||
* If the user is not tracked yet, the returned signal starts as
|
|
||||||
* `false` and will become reactive once a stream is tracked.
|
|
||||||
*/
|
|
||||||
isSpeaking(userId: string): Signal<boolean> {
|
isSpeaking(userId: string): Signal<boolean> {
|
||||||
const entry = this.tracked.get(userId);
|
const entry = this.tracked.get(userId);
|
||||||
|
|
||||||
if (entry)
|
if (entry)
|
||||||
return entry.speakingSignal.asReadonly();
|
return entry.speakingSignal.asReadonly();
|
||||||
|
|
||||||
// Return a computed that re-checks the map so it becomes live
|
|
||||||
// once the stream is tracked.
|
|
||||||
return computed(() => this._speakingMap().get(userId) ?? false);
|
return computed(() => this._speakingMap().get(userId) ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a read-only signal with the normalised (0-1) volume
|
|
||||||
* for the given user.
|
|
||||||
*/
|
|
||||||
volume(userId: string): Signal<number> {
|
volume(userId: string): Signal<number> {
|
||||||
const entry = this.tracked.get(userId);
|
const entry = this.tracked.get(userId);
|
||||||
|
|
||||||
@@ -142,21 +94,12 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
return computed(() => 0);
|
return computed(() => 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stream tracking ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Begin analysing a {@link MediaStream} for audio activity.
|
|
||||||
*
|
|
||||||
* If a stream is already tracked for `id`, it is replaced.
|
|
||||||
*/
|
|
||||||
trackStream(id: string, stream: MediaStream): void {
|
trackStream(id: string, stream: MediaStream): void {
|
||||||
// If we already track this exact stream, skip.
|
|
||||||
const existing = this.tracked.get(id);
|
const existing = this.tracked.get(id);
|
||||||
|
|
||||||
if (existing && existing.stream === stream)
|
if (existing && existing.stream === stream)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Clean up any previous entry for this id.
|
|
||||||
if (existing)
|
if (existing)
|
||||||
this.disposeEntry(existing);
|
this.disposeEntry(existing);
|
||||||
|
|
||||||
@@ -165,10 +108,7 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
const analyser = ctx.createAnalyser();
|
const analyser = ctx.createAnalyser();
|
||||||
|
|
||||||
analyser.fftSize = FFT_SIZE;
|
analyser.fftSize = FFT_SIZE;
|
||||||
|
|
||||||
source.connect(analyser);
|
source.connect(analyser);
|
||||||
// Do NOT connect analyser to ctx.destination - we don't want to
|
|
||||||
// double-play audio; playback is handled elsewhere.
|
|
||||||
|
|
||||||
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
|
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
|
||||||
const volumeSignal = signal(0);
|
const volumeSignal = signal(0);
|
||||||
@@ -185,11 +125,9 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
stream
|
stream
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ensure the poll loop is running.
|
|
||||||
this.ensurePolling();
|
this.ensurePolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop tracking and dispose resources for a given ID. */
|
|
||||||
untrackStream(id: string): void {
|
untrackStream(id: string): void {
|
||||||
const entry = this.tracked.get(id);
|
const entry = this.tracked.get(id);
|
||||||
|
|
||||||
@@ -200,13 +138,10 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
this.tracked.delete(id);
|
this.tracked.delete(id);
|
||||||
this.publishSpeakingMap();
|
this.publishSpeakingMap();
|
||||||
|
|
||||||
// Stop polling when nothing is tracked.
|
|
||||||
if (this.tracked.size === 0)
|
if (this.tracked.size === 0)
|
||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Polling loop ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private ensurePolling(): void {
|
private ensurePolling(): void {
|
||||||
if (this.animFrameId !== null)
|
if (this.animFrameId !== null)
|
||||||
return;
|
return;
|
||||||
@@ -221,10 +156,6 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Single `requestAnimationFrame`-based loop that reads audio levels
|
|
||||||
* from every tracked analyser and updates signals accordingly.
|
|
||||||
*/
|
|
||||||
private poll = (): void => {
|
private poll = (): void => {
|
||||||
let mapDirty = false;
|
let mapDirty = false;
|
||||||
|
|
||||||
@@ -233,7 +164,6 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
|
|
||||||
analyser.getByteTimeDomainData(dataArray);
|
analyser.getByteTimeDomainData(dataArray);
|
||||||
|
|
||||||
// Compute RMS volume from time-domain data (values 0-255, centred at 128).
|
|
||||||
let sumSquares = 0;
|
let sumSquares = 0;
|
||||||
|
|
||||||
for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) {
|
for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) {
|
||||||
@@ -271,7 +201,6 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
this.animFrameId = requestAnimationFrame(this.poll);
|
this.animFrameId = requestAnimationFrame(this.poll);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Rebuild the public speaking-map signal from current entries. */
|
|
||||||
private publishSpeakingMap(): void {
|
private publishSpeakingMap(): void {
|
||||||
const map = new Map<string, boolean>();
|
const map = new Map<string, boolean>();
|
||||||
|
|
||||||
@@ -282,8 +211,6 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
this._speakingMap.set(map);
|
this._speakingMap.set(map);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Cleanup ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private disposeEntry(entry: TrackedStream): void {
|
private disposeEntry(entry: TrackedStream): void {
|
||||||
try { entry.source.disconnect(); } catch { /* already disconnected */ }
|
try { entry.source.disconnect(); } catch { /* already disconnected */ }
|
||||||
|
|
||||||
@@ -294,6 +221,6 @@ export class VoiceActivityService implements OnDestroy {
|
|||||||
this.stopPolling();
|
this.stopPolling();
|
||||||
this.tracked.forEach((entry) => this.disposeEntry(entry));
|
this.tracked.forEach((entry) => this.disposeEntry(entry));
|
||||||
this.tracked.clear();
|
this.tracked.clear();
|
||||||
this.subs.forEach((s) => s.unsubscribe());
|
this.subs.forEach((subscription) => subscription.unsubscribe());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Observable, Subject } from 'rxjs';
|
import { Observable, Subject } from 'rxjs';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { SignalingMessage, ChatEvent } from '../models';
|
import { SignalingMessage, ChatEvent } from '../models/index';
|
||||||
import { TimeSyncService } from './time-sync.service';
|
import { TimeSyncService } from './time-sync.service';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
selectCurrentUser,
|
selectCurrentUser,
|
||||||
selectOnlineUsers
|
selectOnlineUsers
|
||||||
} from '../../../store/users/users.selectors';
|
} from '../../../store/users/users.selectors';
|
||||||
import { BanEntry, User } from '../../../core/models';
|
import { BanEntry, User } from '../../../core/models/index';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { lucideLogIn } from '@ng-icons/lucide';
|
|||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { User } from '../../../core/models';
|
import { User } from '../../../core/models/index';
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { lucideUserPlus } from '@ng-icons/lucide';
|
|||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||||
import { UsersActions } from '../../../store/users/users.actions';
|
import { UsersActions } from '../../../store/users/users.actions';
|
||||||
import { User } from '../../../core/models';
|
import { User } from '../../../core/models/index';
|
||||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, id-denylist, max-statements-per-line, @typescript-eslint/prefer-for-of */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -45,7 +45,7 @@ import {
|
|||||||
} from '../../../store/messages/messages.selectors';
|
} from '../../../store/messages/messages.selectors';
|
||||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||||
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
||||||
import { Message } from '../../../core/models';
|
import { Message } from '../../../core/models/index';
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import {
|
import {
|
||||||
ChatAudioPlayerComponent,
|
ChatAudioPlayerComponent,
|
||||||
@@ -112,10 +112,6 @@ const COMMON_EMOJIS = [
|
|||||||
'(document:keyup)': 'onDocKeyup($event)'
|
'(document:keyup)': 'onDocKeyup($event)'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
/**
|
|
||||||
* Real-time chat messages view with infinite scroll, markdown rendering,
|
|
||||||
* emoji reactions, file attachments, and image lightbox support.
|
|
||||||
*/
|
|
||||||
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
||||||
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
|
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
|
||||||
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
|
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
|
||||||
@@ -128,7 +124,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
private cdr = inject(ChangeDetectorRef);
|
private cdr = inject(ChangeDetectorRef);
|
||||||
private markdown = inject(ChatMarkdownService);
|
private markdown = inject(ChatMarkdownService);
|
||||||
|
|
||||||
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
|
|
||||||
remarkProcessor = unified()
|
remarkProcessor = unified()
|
||||||
.use(remarkParse)
|
.use(remarkParse)
|
||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
@@ -137,12 +132,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
private allMessages = this.store.selectSignal(selectAllMessages);
|
private allMessages = this.store.selectSignal(selectAllMessages);
|
||||||
private activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
private activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||||
|
|
||||||
// --- Infinite scroll (upwards) pagination ---
|
|
||||||
private readonly PAGE_SIZE = 50;
|
private readonly PAGE_SIZE = 50;
|
||||||
displayLimit = signal(this.PAGE_SIZE);
|
displayLimit = signal(this.PAGE_SIZE);
|
||||||
loadingMore = signal(false);
|
loadingMore = signal(false);
|
||||||
|
|
||||||
/** All messages for the current channel (full list, unsliced) */
|
|
||||||
private allChannelMessages = computed(() => {
|
private allChannelMessages = computed(() => {
|
||||||
const channelId = this.activeChannelId();
|
const channelId = this.activeChannelId();
|
||||||
const roomId = this.currentRoom()?.id;
|
const roomId = this.currentRoom()?.id;
|
||||||
@@ -152,7 +145,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Paginated view - only the most recent `displayLimit` messages */
|
|
||||||
messages = computed(() => {
|
messages = computed(() => {
|
||||||
const all = this.allChannelMessages();
|
const all = this.allChannelMessages();
|
||||||
const limit = this.displayLimit();
|
const limit = this.displayLimit();
|
||||||
@@ -163,7 +155,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
return all.slice(all.length - limit);
|
return all.slice(all.length - limit);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Whether there are older messages that can be loaded */
|
|
||||||
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
|
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
|
||||||
loading = this.store.selectSignal(selectMessagesLoading);
|
loading = this.store.selectSignal(selectMessagesLoading);
|
||||||
syncing = this.store.selectSignal(selectMessagesSyncing);
|
syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||||
@@ -192,7 +183,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
private lastMessageCount = 0;
|
private lastMessageCount = 0;
|
||||||
private initialScrollPending = true;
|
private initialScrollPending = true;
|
||||||
pendingFiles: File[] = [];
|
pendingFiles: File[] = [];
|
||||||
// New messages snackbar state
|
|
||||||
showNewMessagesBar = signal(false);
|
showNewMessagesBar = signal(false);
|
||||||
// Plain (non-reactive) reference time used only by formatTimestamp.
|
// Plain (non-reactive) reference time used only by formatTimestamp.
|
||||||
// Updated periodically but NOT a signal, so it won't re-render every message.
|
// Updated periodically but NOT a signal, so it won't re-render every message.
|
||||||
@@ -208,9 +198,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
|
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
|
||||||
private boundCtrlUp: ((e: KeyboardEvent) => void) | null = null;
|
private boundCtrlUp: ((e: KeyboardEvent) => void) | null = null;
|
||||||
|
|
||||||
// Image lightbox modal state
|
|
||||||
lightboxAttachment = signal<Attachment | null>(null);
|
lightboxAttachment = signal<Attachment | null>(null);
|
||||||
// Image right-click context menu state
|
|
||||||
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
|
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
|
||||||
private boundOnKeydown: ((event: KeyboardEvent) => void) | null = null;
|
private boundOnKeydown: ((event: KeyboardEvent) => void) | null = null;
|
||||||
|
|
||||||
@@ -224,7 +212,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.displayLimit.set(this.PAGE_SIZE);
|
this.displayLimit.set(this.PAGE_SIZE);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset pagination when switching channels within the same room
|
|
||||||
private onChannelChanged = effect(() => {
|
private onChannelChanged = effect(() => {
|
||||||
void this.activeChannelId(); // track channel signal
|
void this.activeChannelId(); // track channel signal
|
||||||
this.displayLimit.set(this.PAGE_SIZE);
|
this.displayLimit.set(this.PAGE_SIZE);
|
||||||
@@ -232,13 +219,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.showNewMessagesBar.set(false);
|
this.showNewMessagesBar.set(false);
|
||||||
this.lastMessageCount = 0;
|
this.lastMessageCount = 0;
|
||||||
});
|
});
|
||||||
// Re-render when attachments update (e.g. download progress from WebRTC callbacks)
|
|
||||||
private attachmentsUpdatedEffect = effect(() => {
|
private attachmentsUpdatedEffect = effect(() => {
|
||||||
void this.attachmentsSvc.updated();
|
void this.attachmentsSvc.updated();
|
||||||
this.cdr.markForCheck();
|
this.cdr.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track total channel messages (not paginated) for new-message detection
|
|
||||||
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
|
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
|
||||||
messagesLength = computed(() => this.messages().length);
|
messagesLength = computed(() => this.messages().length);
|
||||||
private onMessagesChanged = effect(() => {
|
private onMessagesChanged = effect(() => {
|
||||||
@@ -359,7 +345,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.pendingKlipyGif.set(null);
|
this.pendingKlipyGif.set(null);
|
||||||
this.clearReply();
|
this.clearReply();
|
||||||
this.shouldScrollToBottom = true;
|
this.shouldScrollToBottom = true;
|
||||||
// Reset textarea height after sending
|
|
||||||
requestAnimationFrame(() => this.autoResizeTextarea());
|
requestAnimationFrame(() => this.autoResizeTextarea());
|
||||||
this.showNewMessagesBar.set(false);
|
this.showNewMessagesBar.set(false);
|
||||||
|
|
||||||
@@ -369,11 +354,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Throttle and broadcast a typing indicator when the user types. */
|
|
||||||
onInputChange(): void {
|
onInputChange(): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
|
if (now - this.lastTypingSentAt > 1000) {
|
||||||
try {
|
try {
|
||||||
this.webrtc.sendRawMessage({ type: 'typing' });
|
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||||
this.lastTypingSentAt = now;
|
this.lastTypingSentAt = now;
|
||||||
@@ -381,13 +365,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Begin editing an existing message, populating the edit input. */
|
|
||||||
startEdit(message: Message): void {
|
startEdit(message: Message): void {
|
||||||
this.editingMessageId.set(message.id);
|
this.editingMessageId.set(message.id);
|
||||||
this.editContent = message.content;
|
this.editContent = message.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save the edited message content and exit edit mode. */
|
|
||||||
saveEdit(messageId: string): void {
|
saveEdit(messageId: string): void {
|
||||||
if (!this.editContent.trim())
|
if (!this.editContent.trim())
|
||||||
return;
|
return;
|
||||||
@@ -402,13 +384,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.cancelEdit();
|
this.cancelEdit();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel the current edit and clear the edit state. */
|
|
||||||
cancelEdit(): void {
|
cancelEdit(): void {
|
||||||
this.editingMessageId.set(null);
|
this.editingMessageId.set(null);
|
||||||
this.editContent = '';
|
this.editContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete a message (own or admin-delete if the user has admin privileges). */
|
|
||||||
deleteMessage(message: Message): void {
|
deleteMessage(message: Message): void {
|
||||||
if (this.isOwnMessage(message)) {
|
if (this.isOwnMessage(message)) {
|
||||||
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
|
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
|
||||||
@@ -417,22 +397,18 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set the message to reply to. */
|
|
||||||
setReplyTo(message: Message): void {
|
setReplyTo(message: Message): void {
|
||||||
this.replyTo.set(message);
|
this.replyTo.set(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear the current reply-to reference. */
|
|
||||||
clearReply(): void {
|
clearReply(): void {
|
||||||
this.replyTo.set(null);
|
this.replyTo.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Find the original message that a reply references. */
|
|
||||||
getRepliedMessage(messageId: string): Message | undefined {
|
getRepliedMessage(messageId: string): Message | undefined {
|
||||||
return this.allMessages().find(message => message.id === messageId);
|
return this.allMessages().find(message => message.id === messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Smooth-scroll to a specific message element and briefly highlight it. */
|
|
||||||
scrollToMessage(messageId: string): void {
|
scrollToMessage(messageId: string): void {
|
||||||
const container = this.messagesContainer?.nativeElement;
|
const container = this.messagesContainer?.nativeElement;
|
||||||
|
|
||||||
@@ -450,14 +426,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle the emoji picker for a message. */
|
|
||||||
toggleEmojiPicker(messageId: string): void {
|
toggleEmojiPicker(messageId: string): void {
|
||||||
this.showEmojiPicker.update((current) =>
|
this.showEmojiPicker.update((current) =>
|
||||||
current === messageId ? null : messageId
|
current === messageId ? null : messageId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add a reaction emoji to a message. */
|
|
||||||
addReaction(messageId: string, emoji: string): void {
|
addReaction(messageId: string, emoji: string): void {
|
||||||
this.store.dispatch(MessagesActions.addReaction({ messageId,
|
this.store.dispatch(MessagesActions.addReaction({ messageId,
|
||||||
emoji }));
|
emoji }));
|
||||||
@@ -465,7 +439,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.showEmojiPicker.set(null);
|
this.showEmojiPicker.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle the reaction for the current user on a message. */
|
|
||||||
toggleReaction(messageId: string, emoji: string): void {
|
toggleReaction(messageId: string, emoji: string): void {
|
||||||
const message = this.messages().find((msg) => msg.id === messageId);
|
const message = this.messages().find((msg) => msg.id === messageId);
|
||||||
const currentUserId = this.currentUser()?.id;
|
const currentUserId = this.currentUser()?.id;
|
||||||
@@ -486,12 +459,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether a message was sent by the current user. */
|
|
||||||
isOwnMessage(message: Message): boolean {
|
isOwnMessage(message: Message): boolean {
|
||||||
return message.senderId === this.currentUser()?.id;
|
return message.senderId === this.currentUser()?.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Aggregate reactions by emoji, returning counts and whether the current user reacted. */
|
|
||||||
getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] {
|
getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] {
|
||||||
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
|
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
|
||||||
const currentUserId = this.currentUser()?.id;
|
const currentUserId = this.currentUser()?.id;
|
||||||
@@ -512,7 +483,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a timestamp as a relative or absolute time string. */
|
|
||||||
formatTimestamp(timestamp: number): string {
|
formatTimestamp(timestamp: number): string {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
const now = new Date(this.nowRef);
|
const now = new Date(this.nowRef);
|
||||||
@@ -964,12 +934,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
return droppedFiles;
|
return droppedFiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return all file attachments associated with a message. */
|
|
||||||
getAttachments(messageId: string): Attachment[] {
|
getAttachments(messageId: string): Attachment[] {
|
||||||
return this.attachmentsSvc.getForMessage(messageId);
|
return this.attachmentsSvc.getForMessage(messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
|
||||||
formatBytes(bytes: number): string {
|
formatBytes(bytes: number): string {
|
||||||
const units = [
|
const units = [
|
||||||
'B',
|
'B',
|
||||||
@@ -986,7 +954,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
return `${size.toFixed(1)} ${units[i]}`;
|
return `${size.toFixed(1)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a transfer speed in bytes/second to a human-readable string. */
|
|
||||||
formatSpeed(bps?: number): string {
|
formatSpeed(bps?: number): string {
|
||||||
if (!bps || bps <= 0)
|
if (!bps || bps <= 0)
|
||||||
return '0 B/s';
|
return '0 B/s';
|
||||||
@@ -1006,23 +973,19 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether an attachment can be played inline as video. */
|
|
||||||
isVideoAttachment(att: Attachment): boolean {
|
isVideoAttachment(att: Attachment): boolean {
|
||||||
return att.mime.startsWith('video/');
|
return att.mime.startsWith('video/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether an attachment can be played inline as audio. */
|
|
||||||
isAudioAttachment(att: Attachment): boolean {
|
isAudioAttachment(att: Attachment): boolean {
|
||||||
return att.mime.startsWith('audio/');
|
return att.mime.startsWith('audio/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the user must explicitly accept a media download before playback. */
|
|
||||||
requiresMediaDownloadAcceptance(att: Attachment): boolean {
|
requiresMediaDownloadAcceptance(att: Attachment): boolean {
|
||||||
return (this.isVideoAttachment(att) || this.isAudioAttachment(att)) &&
|
return (this.isVideoAttachment(att) || this.isAudioAttachment(att)) &&
|
||||||
att.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
att.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** User-facing status copy for an unavailable audio/video attachment. */
|
|
||||||
getMediaAttachmentStatusText(att: Attachment): string {
|
getMediaAttachmentStatusText(att: Attachment): string {
|
||||||
if (att.requestError)
|
if (att.requestError)
|
||||||
return att.requestError;
|
return att.requestError;
|
||||||
@@ -1038,7 +1001,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
: 'Waiting for audio source…';
|
: 'Waiting for audio source…';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Action label for requesting an audio/video attachment. */
|
|
||||||
getMediaAttachmentActionLabel(att: Attachment): string {
|
getMediaAttachmentActionLabel(att: Attachment): string {
|
||||||
if (this.requiresMediaDownloadAcceptance(att)) {
|
if (this.requiresMediaDownloadAcceptance(att)) {
|
||||||
return att.requestError ? 'Retry download' : 'Accept download';
|
return att.requestError ? 'Retry download' : 'Accept download';
|
||||||
@@ -1047,7 +1009,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
return att.requestError ? 'Retry' : 'Request';
|
return att.requestError ? 'Retry' : 'Request';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a pending file from the upload queue. */
|
|
||||||
removePendingFile(file: File): void {
|
removePendingFile(file: File): void {
|
||||||
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
||||||
|
|
||||||
@@ -1056,7 +1017,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Download a completed attachment to the user's device. */
|
|
||||||
async downloadAttachment(att: Attachment): Promise<void> {
|
async downloadAttachment(att: Attachment): Promise<void> {
|
||||||
if (!att.available || !att.objectUrl)
|
if (!att.available || !att.objectUrl)
|
||||||
return;
|
return;
|
||||||
@@ -1126,38 +1086,30 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Request a file attachment to be transferred from the uploader peer. */
|
|
||||||
requestAttachment(att: Attachment, messageId: string): void {
|
requestAttachment(att: Attachment, messageId: string): void {
|
||||||
this.attachmentsSvc.requestFile(messageId, att);
|
this.attachmentsSvc.requestFile(messageId, att);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel an in-progress attachment transfer request. */
|
|
||||||
cancelAttachment(att: Attachment, messageId: string): void {
|
cancelAttachment(att: Attachment, messageId: string): void {
|
||||||
this.attachmentsSvc.cancelRequest(messageId, att);
|
this.attachmentsSvc.cancelRequest(messageId, att);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether the current user is the original uploader of an attachment. */
|
|
||||||
isUploader(att: Attachment): boolean {
|
isUploader(att: Attachment): boolean {
|
||||||
const myUserId = this.currentUser()?.id;
|
const myUserId = this.currentUser()?.id;
|
||||||
|
|
||||||
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
|
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the image lightbox for a completed image attachment. */
|
|
||||||
// ---- Image lightbox ----
|
|
||||||
openLightbox(att: Attachment): void {
|
openLightbox(att: Attachment): void {
|
||||||
if (att.available && att.objectUrl) {
|
if (att.available && att.objectUrl) {
|
||||||
this.lightboxAttachment.set(att);
|
this.lightboxAttachment.set(att);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the image lightbox. */
|
|
||||||
closeLightbox(): void {
|
closeLightbox(): void {
|
||||||
this.lightboxAttachment.set(null);
|
this.lightboxAttachment.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a context menu on right-click of an image attachment. */
|
|
||||||
// ---- Image context menu ----
|
|
||||||
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1166,12 +1118,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
attachment: att });
|
attachment: att });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the image context menu. */
|
|
||||||
closeImageContextMenu(): void {
|
closeImageContextMenu(): void {
|
||||||
this.imageContextMenu.set(null);
|
this.imageContextMenu.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Copy an image attachment to the system clipboard as PNG. */
|
|
||||||
async copyImageToClipboard(att: Attachment): Promise<void> {
|
async copyImageToClipboard(att: Attachment): Promise<void> {
|
||||||
this.closeImageContextMenu();
|
this.closeImageContextMenu();
|
||||||
|
|
||||||
@@ -1185,9 +1135,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
const pngBlob = await this.convertToPng(blob);
|
const pngBlob = await this.convertToPng(blob);
|
||||||
|
|
||||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
||||||
} catch (_error) {
|
} catch {}
|
||||||
// Failed to copy image to clipboard
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private convertToPng(blob: Blob): Promise<Blob> {
|
private convertToPng(blob: Blob): Promise<Blob> {
|
||||||
@@ -1269,7 +1217,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
return `})`;
|
return `})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
|
|
||||||
autoResizeTextarea(): void {
|
autoResizeTextarea(): void {
|
||||||
const el = this.messageInputRef?.nativeElement;
|
const el = this.messageInputRef?.nativeElement;
|
||||||
|
|
||||||
@@ -1282,7 +1229,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
this.updateScrollPadding();
|
this.updateScrollPadding();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep scroll container bottom-padding in sync with the floating bottom bar height. */
|
|
||||||
private updateScrollPadding(): void {
|
private updateScrollPadding(): void {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const bar = this.bottomBar?.nativeElement;
|
const bar = this.bottomBar?.nativeElement;
|
||||||
@@ -1295,12 +1241,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show the markdown toolbar when the input gains focus. */
|
|
||||||
onInputFocus(): void {
|
onInputFocus(): void {
|
||||||
this.toolbarVisible.set(true);
|
this.toolbarVisible.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hide the markdown toolbar after a brief delay when the input loses focus. */
|
|
||||||
onInputBlur(): void {
|
onInputBlur(): void {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!this.toolbarHovering) {
|
if (!this.toolbarHovering) {
|
||||||
@@ -1309,12 +1253,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}, 150);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Track mouse entry on the toolbar to prevent premature hiding. */
|
|
||||||
onToolbarMouseEnter(): void {
|
onToolbarMouseEnter(): void {
|
||||||
this.toolbarHovering = true;
|
this.toolbarHovering = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Track mouse leave on the toolbar; hide if input is not focused. */
|
|
||||||
onToolbarMouseLeave(): void {
|
onToolbarMouseLeave(): void {
|
||||||
this.toolbarHovering = false;
|
this.toolbarHovering = false;
|
||||||
|
|
||||||
@@ -1323,19 +1265,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle Ctrl key down for enabling manual resize. */
|
|
||||||
onDocKeydown(event: KeyboardEvent): void {
|
onDocKeydown(event: KeyboardEvent): void {
|
||||||
if (event.key === 'Control')
|
if (event.key === 'Control')
|
||||||
this.ctrlHeld.set(true);
|
this.ctrlHeld.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Handle Ctrl key up for disabling manual resize. */
|
|
||||||
onDocKeyup(event: KeyboardEvent): void {
|
onDocKeyup(event: KeyboardEvent): void {
|
||||||
if (event.key === 'Control')
|
if (event.key === 'Control')
|
||||||
this.ctrlHeld.set(false);
|
this.ctrlHeld.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scroll to the newest message and dismiss the new-messages snackbar. */
|
|
||||||
readLatest(): void {
|
readLatest(): void {
|
||||||
this.shouldScrollToBottom = true;
|
this.shouldScrollToBottom = true;
|
||||||
this.scrollToBottomSmooth();
|
this.scrollToBottomSmooth();
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
selectCurrentUser,
|
selectCurrentUser,
|
||||||
selectIsCurrentUserAdmin
|
selectIsCurrentUserAdmin
|
||||||
} from '../../../store/users/users.selectors';
|
} from '../../../store/users/users.selectors';
|
||||||
import { User } from '../../../core/models';
|
import { User } from '../../../core/models/index';
|
||||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
inject,
|
inject,
|
||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
RoomMember,
|
RoomMember,
|
||||||
Room,
|
Room,
|
||||||
User
|
User
|
||||||
} from '../../../core/models';
|
} from '../../../core/models/index';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
type TabView = 'channels' | 'users';
|
type TabView = 'channels' | 'users';
|
||||||
@@ -84,9 +84,6 @@ type TabView = 'channels' | 'users';
|
|||||||
],
|
],
|
||||||
templateUrl: './rooms-side-panel.component.html'
|
templateUrl: './rooms-side-panel.component.html'
|
||||||
})
|
})
|
||||||
/**
|
|
||||||
* Side panel listing text and voice channels, online users, and channel management actions.
|
|
||||||
*/
|
|
||||||
export class RoomsSidePanelComponent {
|
export class RoomsSidePanelComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
@@ -129,35 +126,28 @@ export class RoomsSidePanelComponent {
|
|||||||
return memberIds.size;
|
return memberIds.size;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Channel context menu state
|
|
||||||
showChannelMenu = signal(false);
|
showChannelMenu = signal(false);
|
||||||
channelMenuX = signal(0);
|
channelMenuX = signal(0);
|
||||||
channelMenuY = signal(0);
|
channelMenuY = signal(0);
|
||||||
contextChannel = signal<Channel | null>(null);
|
contextChannel = signal<Channel | null>(null);
|
||||||
|
|
||||||
// Rename state
|
|
||||||
renamingChannelId = signal<string | null>(null);
|
renamingChannelId = signal<string | null>(null);
|
||||||
|
|
||||||
// Create channel dialog state
|
|
||||||
showCreateChannelDialog = signal(false);
|
showCreateChannelDialog = signal(false);
|
||||||
createChannelType = signal<'text' | 'voice'>('text');
|
createChannelType = signal<'text' | 'voice'>('text');
|
||||||
newChannelName = '';
|
newChannelName = '';
|
||||||
|
|
||||||
// User context menu state
|
|
||||||
showUserMenu = signal(false);
|
showUserMenu = signal(false);
|
||||||
userMenuX = signal(0);
|
userMenuX = signal(0);
|
||||||
userMenuY = signal(0);
|
userMenuY = signal(0);
|
||||||
contextMenuUser = signal<User | null>(null);
|
contextMenuUser = signal<User | null>(null);
|
||||||
|
|
||||||
// Per-user volume context menu state
|
|
||||||
showVolumeMenu = signal(false);
|
showVolumeMenu = signal(false);
|
||||||
volumeMenuX = signal(0);
|
volumeMenuX = signal(0);
|
||||||
volumeMenuY = signal(0);
|
volumeMenuY = signal(0);
|
||||||
volumeMenuPeerId = signal('');
|
volumeMenuPeerId = signal('');
|
||||||
volumeMenuDisplayName = signal('');
|
volumeMenuDisplayName = signal('');
|
||||||
|
|
||||||
/** Return online users excluding the current user. */
|
|
||||||
// Filter out current user from online users list
|
|
||||||
onlineUsersFiltered() {
|
onlineUsersFiltered() {
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
const currentId = current?.id;
|
const currentId = current?.id;
|
||||||
@@ -170,7 +160,6 @@ export class RoomsSidePanelComponent {
|
|||||||
return member.oderId || member.id;
|
return member.oderId || member.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether the current user has permission to manage channels. */
|
|
||||||
canManageChannels(): boolean {
|
canManageChannels(): boolean {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
@@ -178,7 +167,6 @@ export class RoomsSidePanelComponent {
|
|||||||
if (!room || !user)
|
if (!room || !user)
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// Owner always can
|
|
||||||
if (room.hostId === user.id)
|
if (room.hostId === user.id)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@@ -193,17 +181,13 @@ export class RoomsSidePanelComponent {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Select a text channel (no-op if currently renaming). */
|
|
||||||
// ---- Text channel selection ----
|
|
||||||
selectTextChannel(channelId: string) {
|
selectTextChannel(channelId: string) {
|
||||||
if (this.renamingChannelId())
|
if (this.renamingChannelId())
|
||||||
return; // don't switch while renaming
|
return;
|
||||||
|
|
||||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the context menu for a channel at the cursor position. */
|
|
||||||
// ---- Channel context menu ----
|
|
||||||
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
this.contextChannel.set(channel);
|
this.contextChannel.set(channel);
|
||||||
@@ -212,12 +196,10 @@ export class RoomsSidePanelComponent {
|
|||||||
this.showChannelMenu.set(true);
|
this.showChannelMenu.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the channel context menu. */
|
|
||||||
closeChannelMenu() {
|
closeChannelMenu() {
|
||||||
this.showChannelMenu.set(false);
|
this.showChannelMenu.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Begin inline renaming of the context-menu channel. */
|
|
||||||
startRename() {
|
startRename() {
|
||||||
const ch = this.contextChannel();
|
const ch = this.contextChannel();
|
||||||
|
|
||||||
@@ -228,7 +210,6 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Commit the channel rename from the inline input value. */
|
|
||||||
confirmRename(event: Event) {
|
confirmRename(event: Event) {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
@@ -241,12 +222,10 @@ export class RoomsSidePanelComponent {
|
|||||||
this.renamingChannelId.set(null);
|
this.renamingChannelId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel the inline rename operation. */
|
|
||||||
cancelRename() {
|
cancelRename() {
|
||||||
this.renamingChannelId.set(null);
|
this.renamingChannelId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete the context-menu channel. */
|
|
||||||
deleteChannel() {
|
deleteChannel() {
|
||||||
const ch = this.contextChannel();
|
const ch = this.contextChannel();
|
||||||
|
|
||||||
@@ -257,7 +236,6 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Trigger a message inventory re-sync from all connected peers. */
|
|
||||||
resyncMessages() {
|
resyncMessages() {
|
||||||
this.closeChannelMenu();
|
this.closeChannelMenu();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
@@ -266,36 +244,26 @@ export class RoomsSidePanelComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch startSync for UI spinner
|
|
||||||
this.store.dispatch(MessagesActions.startSync());
|
this.store.dispatch(MessagesActions.startSync());
|
||||||
|
|
||||||
// Request inventory from all connected peers
|
|
||||||
const peers = this.webrtc.getConnectedPeers();
|
const peers = this.webrtc.getConnectedPeers();
|
||||||
|
|
||||||
if (peers.length === 0) {
|
|
||||||
// No connected peers - sync will time out
|
|
||||||
}
|
|
||||||
|
|
||||||
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
|
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
|
||||||
|
|
||||||
peers.forEach((pid) => {
|
peers.forEach((pid) => {
|
||||||
try {
|
try {
|
||||||
this.webrtc.sendToPeer(pid, inventoryRequest);
|
this.webrtc.sendToPeer(pid, inventoryRequest);
|
||||||
} catch (_error) {
|
} catch {
|
||||||
// Failed to send inventory request to this peer
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the create-channel dialog for the given channel type. */
|
|
||||||
// ---- Create channel ----
|
|
||||||
createChannel(type: 'text' | 'voice') {
|
createChannel(type: 'text' | 'voice') {
|
||||||
this.createChannelType.set(type);
|
this.createChannelType.set(type);
|
||||||
this.newChannelName = '';
|
this.newChannelName = '';
|
||||||
this.showCreateChannelDialog.set(true);
|
this.showCreateChannelDialog.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confirm channel creation and dispatch the add-channel action. */
|
|
||||||
confirmCreateChannel() {
|
confirmCreateChannel() {
|
||||||
const name = this.newChannelName.trim();
|
const name = this.newChannelName.trim();
|
||||||
|
|
||||||
@@ -315,13 +283,10 @@ export class RoomsSidePanelComponent {
|
|||||||
this.showCreateChannelDialog.set(false);
|
this.showCreateChannelDialog.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel channel creation and close the dialog. */
|
|
||||||
cancelCreateChannel() {
|
cancelCreateChannel() {
|
||||||
this.showCreateChannelDialog.set(false);
|
this.showCreateChannelDialog.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the user context menu for admin actions (kick/role change). */
|
|
||||||
// ---- User context menu (kick/role) ----
|
|
||||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
||||||
@@ -334,16 +299,12 @@ export class RoomsSidePanelComponent {
|
|||||||
this.showUserMenu.set(true);
|
this.showUserMenu.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the user context menu. */
|
|
||||||
closeUserMenu() {
|
closeUserMenu() {
|
||||||
this.showUserMenu.set(false);
|
this.showUserMenu.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the per-user volume context menu for a voice channel participant. */
|
|
||||||
openVoiceUserVolumeMenu(evt: MouseEvent, user: User) {
|
openVoiceUserVolumeMenu(evt: MouseEvent, user: User) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
||||||
// Don't show volume menu for the local user
|
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
|
|
||||||
if (user.id === me?.id || user.oderId === me?.oderId)
|
if (user.id === me?.id || user.oderId === me?.oderId)
|
||||||
@@ -356,7 +317,6 @@ export class RoomsSidePanelComponent {
|
|||||||
this.showVolumeMenu.set(true);
|
this.showVolumeMenu.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Change a user's role and broadcast the update to connected peers. */
|
|
||||||
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
||||||
const user = this.contextMenuUser();
|
const user = this.contextMenuUser();
|
||||||
const roomId = this.currentRoom()?.id;
|
const roomId = this.currentRoom()?.id;
|
||||||
@@ -365,7 +325,6 @@ export class RoomsSidePanelComponent {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||||
// Broadcast role change to peers
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'role-change',
|
type: 'role-change',
|
||||||
roomId,
|
roomId,
|
||||||
@@ -375,7 +334,6 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kick a user and broadcast the action to peers. */
|
|
||||||
kickUserAction() {
|
kickUserAction() {
|
||||||
const user = this.contextMenuUser();
|
const user = this.contextMenuUser();
|
||||||
|
|
||||||
@@ -383,7 +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 }));
|
||||||
// Broadcast kick to peers
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'kick',
|
type: 'kick',
|
||||||
targetUserId: user.id,
|
targetUserId: user.id,
|
||||||
@@ -392,14 +349,10 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Join a voice channel, managing permissions and existing voice connections. */
|
|
||||||
// ---- Voice ----
|
|
||||||
joinVoice(roomId: string) {
|
joinVoice(roomId: string) {
|
||||||
// Gate by room permissions
|
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
|
|
||||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||||
// Voice is disabled by room permissions
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,12 +361,8 @@ export class RoomsSidePanelComponent {
|
|||||||
|
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
|
|
||||||
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
|
||||||
// Also handle stale voice state: if the store says connected but voice isn't actually active,
|
|
||||||
// clear it so the user can join.
|
|
||||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||||
if (!this.webrtc.isVoiceConnected()) {
|
if (!this.webrtc.isVoiceConnected()) {
|
||||||
// Stale state - clear it so the user can proceed
|
|
||||||
if (current.id) {
|
if (current.id) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
UsersActions.updateVoiceState({
|
UsersActions.updateVoiceState({
|
||||||
@@ -429,21 +378,16 @@ export class RoomsSidePanelComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Already connected to voice in another server; must disconnect first
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If switching channels within the same server, just update the room
|
|
||||||
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
|
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
|
||||||
// Enable microphone and broadcast voice-state
|
|
||||||
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
||||||
|
|
||||||
enableVoicePromise
|
enableVoicePromise
|
||||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||||
.catch((_error) => {
|
.catch(() => undefined);
|
||||||
// Failed to join voice room
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||||
@@ -523,23 +467,18 @@ export class RoomsSidePanelComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Leave a voice channel and broadcast the disconnect state. */
|
|
||||||
leaveVoice(roomId: string) {
|
leaveVoice(roomId: string) {
|
||||||
const current = this.currentUser();
|
const current = this.currentUser();
|
||||||
|
|
||||||
// Only leave if currently in this room
|
|
||||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
|
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Stop voice heartbeat
|
|
||||||
this.webrtc.stopVoiceHeartbeat();
|
this.webrtc.stopVoiceHeartbeat();
|
||||||
|
|
||||||
this.untrackCurrentUserMic();
|
this.untrackCurrentUserMic();
|
||||||
|
|
||||||
// Disable voice locally
|
|
||||||
this.webrtc.disableVoice();
|
this.webrtc.disableVoice();
|
||||||
|
|
||||||
// Update store voice state
|
|
||||||
if (current?.id) {
|
if (current?.id) {
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
UsersActions.updateVoiceState({
|
UsersActions.updateVoiceState({
|
||||||
@@ -555,7 +494,6 @@ export class RoomsSidePanelComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast disconnect
|
|
||||||
this.webrtc.broadcastMessage({
|
this.webrtc.broadcastMessage({
|
||||||
type: 'voice-state',
|
type: 'voice-state',
|
||||||
oderId: current?.oderId || current?.id,
|
oderId: current?.oderId || current?.id,
|
||||||
@@ -569,37 +507,31 @@ export class RoomsSidePanelComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// End voice session
|
|
||||||
this.voiceSessionService.endSession();
|
this.voiceSessionService.endSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Count the number of users connected to a voice channel in the current room. */
|
|
||||||
voiceOccupancy(roomId: string): number {
|
voiceOccupancy(roomId: string): number {
|
||||||
return this.voiceUsersInRoom(roomId).length;
|
return this.voiceUsersInRoom(roomId).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
|
||||||
viewShare(userId: string) {
|
viewShare(userId: string) {
|
||||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||||
|
|
||||||
window.dispatchEvent(evt);
|
window.dispatchEvent(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dispatch a viewer:focus event to display a remote user's stream. */
|
|
||||||
viewStream(userId: string) {
|
viewStream(userId: string) {
|
||||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||||
|
|
||||||
window.dispatchEvent(evt);
|
window.dispatchEvent(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether the local user has muted a specific voice user. */
|
|
||||||
isUserLocallyMuted(user: User): boolean {
|
isUserLocallyMuted(user: User): boolean {
|
||||||
const peerId = user.oderId || user.id;
|
const peerId = user.oderId || user.id;
|
||||||
|
|
||||||
return this.voicePlayback.isUserMuted(peerId);
|
return this.voicePlayback.isUserMuted(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether a user is currently sharing their screen. */
|
|
||||||
isUserSharing(userId: string): boolean {
|
isUserSharing(userId: string): boolean {
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
|
|
||||||
@@ -618,7 +550,6 @@ export class RoomsSidePanelComponent {
|
|||||||
return !!stream && stream.getVideoTracks().length > 0;
|
return !!stream && stream.getVideoTracks().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return all users currently connected to a specific voice channel, including the local user. */
|
|
||||||
voiceUsersInRoom(roomId: string) {
|
voiceUsersInRoom(roomId: string) {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
@@ -626,13 +557,11 @@ export class RoomsSidePanelComponent {
|
|||||||
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
|
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Include the local user at the top if they are in this voice channel
|
|
||||||
if (
|
if (
|
||||||
me?.voiceState?.isConnected &&
|
me?.voiceState?.isConnected &&
|
||||||
me.voiceState?.roomId === roomId &&
|
me.voiceState?.roomId === roomId &&
|
||||||
me.voiceState?.serverId === room?.id
|
me.voiceState?.serverId === room?.id
|
||||||
) {
|
) {
|
||||||
// Avoid duplicates if the current user is already in onlineUsers
|
|
||||||
const meId = me.id;
|
const meId = me.id;
|
||||||
const meOderId = me.oderId;
|
const meOderId = me.oderId;
|
||||||
const alreadyIncluded = remoteUsers.some(
|
const alreadyIncluded = remoteUsers.some(
|
||||||
@@ -647,7 +576,6 @@ export class RoomsSidePanelComponent {
|
|||||||
return remoteUsers;
|
return remoteUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether the current user is connected to the specified voice channel. */
|
|
||||||
isCurrentRoom(roomId: string): boolean {
|
isCurrentRoom(roomId: string): boolean {
|
||||||
const me = this.currentUser();
|
const me = this.currentUser();
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
@@ -655,32 +583,18 @@ export class RoomsSidePanelComponent {
|
|||||||
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether voice is enabled by the current room's permissions. */
|
|
||||||
voiceEnabled(): boolean {
|
voiceEnabled(): boolean {
|
||||||
const room = this.currentRoom();
|
const room = this.currentRoom();
|
||||||
|
|
||||||
return room?.permissions?.allowVoice !== false;
|
return room?.permissions?.allowVoice !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the measured latency (ms) to a voice user.
|
|
||||||
* Returns `null` when no measurement is available yet.
|
|
||||||
*/
|
|
||||||
getPeerLatency(user: User): number | null {
|
getPeerLatency(user: User): number | null {
|
||||||
const latencies = this.webrtc.peerLatencies();
|
const latencies = this.webrtc.peerLatencies();
|
||||||
|
|
||||||
// Try oderId first (primary peer key), then fall back to user id
|
|
||||||
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
|
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a Tailwind `bg-*` class representing the latency quality.
|
|
||||||
* - green : < 100 ms
|
|
||||||
* - yellow : 100-199 ms
|
|
||||||
* - orange : 200-349 ms
|
|
||||||
* - red : >= 350 ms
|
|
||||||
* - gray : no data yet
|
|
||||||
*/
|
|
||||||
getPingColorClass(user: User): string {
|
getPingColorClass(user: User): string {
|
||||||
const ms = this.getPeerLatency(user);
|
const ms = this.getPeerLatency(user);
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ import {
|
|||||||
selectRoomsError,
|
selectRoomsError,
|
||||||
selectSavedRooms
|
selectSavedRooms
|
||||||
} from '../../store/rooms/rooms.selectors';
|
} from '../../store/rooms/rooms.selectors';
|
||||||
import { Room } from '../../core/models';
|
import { Room } from '../../core/models/index';
|
||||||
import { ServerInfo } from '../../core/models';
|
import { ServerInfo } from '../../core/models/index';
|
||||||
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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';
|
import { Room } 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';
|
||||||
@@ -31,9 +31,6 @@ import { ContextMenuComponent, LeaveServerDialogComponent } from '../../shared';
|
|||||||
viewProviders: [provideIcons({ lucidePlus })],
|
viewProviders: [provideIcons({ lucidePlus })],
|
||||||
templateUrl: './servers-rail.component.html'
|
templateUrl: './servers-rail.component.html'
|
||||||
})
|
})
|
||||||
/**
|
|
||||||
* Vertical rail of saved server icons with context-menu actions for leaving/forgetting.
|
|
||||||
*/
|
|
||||||
export class ServersRailComponent {
|
export class ServersRailComponent {
|
||||||
private store = inject(Store);
|
private store = inject(Store);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
@@ -42,15 +39,13 @@ export class ServersRailComponent {
|
|||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
|
|
||||||
// Context menu state
|
|
||||||
showMenu = signal(false);
|
showMenu = signal(false);
|
||||||
menuX = signal(72); // default X: rail width (~64px) + padding
|
menuX = signal(72);
|
||||||
menuY = signal(100); // default Y: arbitrary initial offset
|
menuY = signal(100);
|
||||||
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);
|
||||||
|
|
||||||
/** Return the first character of a server name as its icon initial. */
|
|
||||||
initial(name?: string): string {
|
initial(name?: string): string {
|
||||||
if (!name)
|
if (!name)
|
||||||
return '?';
|
return '?';
|
||||||
@@ -62,10 +57,7 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
trackRoomId = (index: number, room: Room) => room.id;
|
trackRoomId = (index: number, room: Room) => room.id;
|
||||||
|
|
||||||
/** Navigate to the server search view. Updates voice session state if applicable. */
|
|
||||||
createServer(): void {
|
createServer(): void {
|
||||||
// Navigate to server list (has create button)
|
|
||||||
// Update voice session state if connected to voice
|
|
||||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||||
|
|
||||||
if (voiceServerId) {
|
if (voiceServerId) {
|
||||||
@@ -75,9 +67,7 @@ export class ServersRailComponent {
|
|||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/search']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Join or switch to a saved room. Manages voice session and authentication state. */
|
|
||||||
joinSavedRoom(room: Room): void {
|
joinSavedRoom(room: Room): void {
|
||||||
// Require auth: if no current user, go to login
|
|
||||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||||
|
|
||||||
if (!currentUserId) {
|
if (!currentUserId) {
|
||||||
@@ -85,24 +75,17 @@ export class ServersRailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're navigating to a different server while in voice
|
|
||||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||||
|
|
||||||
if (voiceServerId && voiceServerId !== room.id) {
|
if (voiceServerId && voiceServerId !== room.id) {
|
||||||
// User is switching to a different server while connected to voice
|
|
||||||
// Update voice session to show floating controls (voice stays connected)
|
|
||||||
this.voiceSession.setViewingVoiceServer(false);
|
this.voiceSession.setViewingVoiceServer(false);
|
||||||
} else if (voiceServerId === room.id) {
|
} else if (voiceServerId === room.id) {
|
||||||
// Navigating back to the voice-connected server
|
|
||||||
this.voiceSession.setViewingVoiceServer(true);
|
this.voiceSession.setViewingVoiceServer(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we've already joined this server, just switch the view
|
|
||||||
// (no user_joined broadcast, no leave from other servers)
|
|
||||||
if (this.webrtc.hasJoinedServer(room.id)) {
|
if (this.webrtc.hasJoinedServer(room.id)) {
|
||||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
this.store.dispatch(RoomsActions.viewServer({ room }));
|
||||||
} else {
|
} else {
|
||||||
// First time joining this server
|
|
||||||
this.store.dispatch(
|
this.store.dispatch(
|
||||||
RoomsActions.joinRoom({
|
RoomsActions.joinRoom({
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
@@ -116,23 +99,18 @@ export class ServersRailComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the context menu positioned near the cursor for a given room. */
|
|
||||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
this.contextRoom.set(room);
|
this.contextRoom.set(room);
|
||||||
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
|
|
||||||
this.menuX.set(Math.max(evt.clientX + 8, 72));
|
this.menuX.set(Math.max(evt.clientX + 8, 72));
|
||||||
this.menuY.set(evt.clientY);
|
this.menuY.set(evt.clientY);
|
||||||
this.showMenu.set(true);
|
this.showMenu.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the context menu (keeps contextRoom for potential confirmation). */
|
|
||||||
closeMenu(): void {
|
closeMenu(): void {
|
||||||
this.showMenu.set(false);
|
this.showMenu.set(false);
|
||||||
// keep contextRoom for potential confirmation dialog
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check whether the context-menu room is the currently active room. */
|
|
||||||
isCurrentContextRoom(): boolean {
|
isCurrentContextRoom(): boolean {
|
||||||
const ctx = this.contextRoom();
|
const ctx = this.contextRoom();
|
||||||
const cur = this.currentRoom();
|
const cur = this.currentRoom();
|
||||||
@@ -140,7 +118,6 @@ export class ServersRailComponent {
|
|||||||
return !!ctx && !!cur && ctx.id === cur.id;
|
return !!ctx && !!cur && ctx.id === cur.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open the unified leave-server confirmation dialog. */
|
|
||||||
openLeaveConfirm(): void {
|
openLeaveConfirm(): void {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
|
|
||||||
@@ -149,7 +126,6 @@ export class ServersRailComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Confirm the merged leave flow and remove the server locally. */
|
|
||||||
confirmLeave(result: { nextOwnerKey?: string }): void {
|
confirmLeave(result: { nextOwnerKey?: string }): void {
|
||||||
const ctx = this.contextRoom();
|
const ctx = this.contextRoom();
|
||||||
|
|
||||||
@@ -171,7 +147,6 @@ export class ServersRailComponent {
|
|||||||
this.contextRoom.set(null);
|
this.contextRoom.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel the leave-server confirmation dialog. */
|
|
||||||
cancelLeave(): void {
|
cancelLeave(): void {
|
||||||
this.showLeaveConfirm.set(false);
|
this.showLeaveConfirm.set(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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';
|
import { Room, BanEntry } from '../../../../core/models/index';
|
||||||
import { UsersActions } from '../../../../store/users/users.actions';
|
import { UsersActions } from '../../../../store/users/users.actions';
|
||||||
import { selectBannedUsers } from '../../../../store/users/users.selectors';
|
import { selectBannedUsers } from '../../../../store/users/users.selectors';
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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';
|
import { Room, User } from '../../../../core/models/index';
|
||||||
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, selectOnlineUsers } from '../../../../store/users/users.selectors';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
|||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
import { lucideCheck } from '@ng-icons/lucide';
|
import { lucideCheck } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { Room } from '../../../../core/models';
|
import { Room } from '../../../../core/models/index';
|
||||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
lucideUnlock
|
lucideUnlock
|
||||||
} from '@ng-icons/lucide';
|
} from '@ng-icons/lucide';
|
||||||
|
|
||||||
import { Room } from '../../../../core/models';
|
import { Room } from '../../../../core/models/index';
|
||||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||||
import { ConfirmDialogComponent } from '../../../../shared';
|
import { ConfirmDialogComponent } from '../../../../shared';
|
||||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ 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';
|
import { Room } from '../../../core/models/index';
|
||||||
|
|
||||||
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,16 +69,13 @@ export class SettingsModalComponent {
|
|||||||
|
|
||||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||||
|
|
||||||
// --- Selectors ---
|
|
||||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
|
|
||||||
// --- Modal state ---
|
|
||||||
isOpen = this.modal.isOpen;
|
isOpen = this.modal.isOpen;
|
||||||
activePage = this.modal.activePage;
|
activePage = this.modal.activePage;
|
||||||
|
|
||||||
// --- Side-nav items ---
|
|
||||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||||
{ id: 'network',
|
{ id: 'network',
|
||||||
label: 'Network',
|
label: 'Network',
|
||||||
@@ -102,7 +99,6 @@ export class SettingsModalComponent {
|
|||||||
icon: 'lucideShield' }
|
icon: 'lucideShield' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// ===== SERVER SELECTOR =====
|
|
||||||
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,12 +109,10 @@ export class SettingsModalComponent {
|
|||||||
return this.savedRooms().find((room) => room.id === id) ?? null;
|
return this.savedRooms().find((room) => room.id === id) ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Whether the user can see server-admin tabs. */
|
|
||||||
showServerTabs = computed(() => {
|
showServerTabs = computed(() => {
|
||||||
return this.savedRooms().length > 0 && !!this.selectedServerId();
|
return this.savedRooms().length > 0 && !!this.selectedServerId();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Whether the current user is the host/owner of the selected server. */
|
|
||||||
isSelectedServerAdmin = computed(() => {
|
isSelectedServerAdmin = computed(() => {
|
||||||
const server = this.selectedServer();
|
const server = this.selectedServer();
|
||||||
const user = this.currentUser();
|
const user = this.currentUser();
|
||||||
@@ -129,12 +123,10 @@ export class SettingsModalComponent {
|
|||||||
return server.hostId === user.id || server.hostId === user.oderId;
|
return server.hostId === user.id || server.hostId === user.oderId;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Animation
|
|
||||||
animating = signal(false);
|
animating = signal(false);
|
||||||
showThirdPartyLicenses = signal(false);
|
showThirdPartyLicenses = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// Sync selected server when modal opens with a target
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (this.isOpen()) {
|
if (this.isOpen()) {
|
||||||
const targetId = this.modal.targetServerId();
|
const targetId = this.modal.targetServerId();
|
||||||
@@ -153,7 +145,6 @@ export class SettingsModalComponent {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// When selected server changes, reload permissions data
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const server = this.selectedServer();
|
const server = this.selectedServer();
|
||||||
|
|
||||||
@@ -178,7 +169,6 @@ export class SettingsModalComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== MODAL CONTROLS =====
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.showThirdPartyLicenses.set(false);
|
this.showThirdPartyLicenses.set(false);
|
||||||
this.animating.set(false);
|
this.animating.set(false);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
|
|
||||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||||
import { selectOnlineUsers } from '../../../store/users/users.selectors';
|
import { selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||||
import { User } from '../../../core/models';
|
import { User } from '../../../core/models/index';
|
||||||
import { DEFAULT_VOLUME } from '../../../core/constants';
|
import { DEFAULT_VOLUME } from '../../../core/constants';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
|||||||
@@ -18,13 +18,9 @@ export interface PlaybackOptions {
|
|||||||
* the GainNode -> AudioContext.destination path.
|
* the GainNode -> AudioContext.destination path.
|
||||||
*/
|
*/
|
||||||
interface PeerAudioPipeline {
|
interface PeerAudioPipeline {
|
||||||
/** Muted <audio> element that "primes" the stream for Web Audio. */
|
|
||||||
audioElement: HTMLAudioElement;
|
audioElement: HTMLAudioElement;
|
||||||
/** AudioContext for this peer's pipeline. */
|
|
||||||
context: AudioContext;
|
context: AudioContext;
|
||||||
/** Source node created from the remote stream. */
|
|
||||||
sourceNode: MediaStreamAudioSourceNode;
|
sourceNode: MediaStreamAudioSourceNode;
|
||||||
/** GainNode used to control per-user volume (0.0-2.0). */
|
|
||||||
gainNode: GainNode;
|
gainNode: GainNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,34 +28,18 @@ interface PeerAudioPipeline {
|
|||||||
export class VoicePlaybackService {
|
export class VoicePlaybackService {
|
||||||
private webrtc = inject(WebRTCService);
|
private webrtc = inject(WebRTCService);
|
||||||
|
|
||||||
/** Active Web Audio pipelines keyed by peer ID. */
|
|
||||||
private peerPipelines = new Map<string, PeerAudioPipeline>();
|
private peerPipelines = new Map<string, PeerAudioPipeline>();
|
||||||
private pendingRemoteStreams = new Map<string, MediaStream>();
|
private pendingRemoteStreams = new Map<string, MediaStream>();
|
||||||
private rawRemoteStreams = new Map<string, MediaStream>();
|
private rawRemoteStreams = new Map<string, MediaStream>();
|
||||||
|
|
||||||
/**
|
|
||||||
* Per-user volume overrides (0-200 integer, maps to 0.0-2.0 gain).
|
|
||||||
* Keyed by oderId so the setting persists across reconnections.
|
|
||||||
*/
|
|
||||||
private userVolumes = new Map<string, number>();
|
private userVolumes = new Map<string, number>();
|
||||||
|
|
||||||
/** Per-user mute state. Keyed by oderId. */
|
|
||||||
private userMuted = new Map<string, boolean>();
|
private userMuted = new Map<string, boolean>();
|
||||||
|
|
||||||
/** Global master output volume (0.0-1.0 from the settings slider). */
|
|
||||||
private masterVolume = 1;
|
private masterVolume = 1;
|
||||||
|
|
||||||
/** Whether the local user is deafened. */
|
|
||||||
private deafened = false;
|
private deafened = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.loadPersistedVolumes();
|
this.loadPersistedVolumes();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Public API - stream lifecycle
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
||||||
if (!options.isConnected) {
|
if (!options.isConnected) {
|
||||||
this.pendingRemoteStreams.set(peerId, stream);
|
this.pendingRemoteStreams.set(peerId, stream);
|
||||||
@@ -110,10 +90,6 @@ export class VoicePlaybackService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Global volume / deafen (master slider from settings)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
updateOutputVolume(volume: number): void {
|
updateOutputVolume(volume: number): void {
|
||||||
this.masterVolume = volume;
|
this.masterVolume = volume;
|
||||||
this.recalcAllGains();
|
this.recalcAllGains();
|
||||||
@@ -124,16 +100,10 @@ export class VoicePlaybackService {
|
|||||||
this.recalcAllGains();
|
this.recalcAllGains();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-user volume (0-200%) and mute
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** Get the per-user volume for a peer (0-200). Defaults to 100. */
|
|
||||||
getUserVolume(peerId: string): number {
|
getUserVolume(peerId: string): number {
|
||||||
return this.userVolumes.get(peerId) ?? 100;
|
return this.userVolumes.get(peerId) ?? 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set per-user volume (0-200) and update the gain node in real time. */
|
|
||||||
setUserVolume(peerId: string, volume: number): void {
|
setUserVolume(peerId: string, volume: number): void {
|
||||||
const clamped = Math.max(0, Math.min(200, volume));
|
const clamped = Math.max(0, Math.min(200, volume));
|
||||||
|
|
||||||
@@ -142,22 +112,16 @@ export class VoicePlaybackService {
|
|||||||
this.persistVolumes();
|
this.persistVolumes();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a specific user is muted by the local user. */
|
|
||||||
isUserMuted(peerId: string): boolean {
|
isUserMuted(peerId: string): boolean {
|
||||||
return this.userMuted.get(peerId) ?? false;
|
return this.userMuted.get(peerId) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Toggle per-user mute. */
|
|
||||||
setUserMuted(peerId: string, muted: boolean): void {
|
setUserMuted(peerId: string, muted: boolean): void {
|
||||||
this.userMuted.set(peerId, muted);
|
this.userMuted.set(peerId, muted);
|
||||||
this.applyGain(peerId);
|
this.applyGain(peerId);
|
||||||
this.persistVolumes();
|
this.persistVolumes();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Output device routing
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
applyOutputDevice(deviceId: string): void {
|
applyOutputDevice(deviceId: string): void {
|
||||||
if (!deviceId)
|
if (!deviceId)
|
||||||
return;
|
return;
|
||||||
@@ -180,10 +144,6 @@ export class VoicePlaybackService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Teardown
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
teardownAll(): void {
|
teardownAll(): void {
|
||||||
this.peerPipelines.forEach((_pipeline, peerId) => this.removePipeline(peerId));
|
this.peerPipelines.forEach((_pipeline, peerId) => this.removePipeline(peerId));
|
||||||
this.peerPipelines.clear();
|
this.peerPipelines.clear();
|
||||||
@@ -191,10 +151,6 @@ export class VoicePlaybackService {
|
|||||||
this.pendingRemoteStreams.clear();
|
this.pendingRemoteStreams.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Private - Web Audio pipeline
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Web Audio graph for a remote peer:
|
* Build the Web Audio graph for a remote peer:
|
||||||
*
|
*
|
||||||
@@ -205,14 +161,13 @@ export class VoicePlaybackService {
|
|||||||
* MediaStreamSource → GainNode → AudioContext.destination
|
* MediaStreamSource → GainNode → AudioContext.destination
|
||||||
*/
|
*/
|
||||||
private createPipeline(peerId: string, stream: MediaStream): void {
|
private createPipeline(peerId: string, stream: MediaStream): void {
|
||||||
// 1) Chrome/Electron workaround: attach stream to a muted <audio>
|
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
|
||||||
const audioEl = new Audio();
|
const audioEl = new Audio();
|
||||||
|
|
||||||
audioEl.srcObject = stream;
|
audioEl.srcObject = stream;
|
||||||
audioEl.muted = true; // silent - we route audio through Web Audio API
|
audioEl.muted = true;
|
||||||
audioEl.play().catch(() => {});
|
audioEl.play().catch(() => {});
|
||||||
|
|
||||||
// 2) Set up Web Audio graph
|
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
const sourceNode = ctx.createMediaStreamSource(stream);
|
const sourceNode = ctx.createMediaStreamSource(stream);
|
||||||
const gainNode = ctx.createGain();
|
const gainNode = ctx.createGain();
|
||||||
@@ -220,16 +175,13 @@ export class VoicePlaybackService {
|
|||||||
sourceNode.connect(gainNode);
|
sourceNode.connect(gainNode);
|
||||||
gainNode.connect(ctx.destination);
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
// 3) Store pipeline
|
|
||||||
const pipeline: PeerAudioPipeline = { audioElement: audioEl, context: ctx, sourceNode, gainNode };
|
const pipeline: PeerAudioPipeline = { audioElement: audioEl, context: ctx, sourceNode, gainNode };
|
||||||
|
|
||||||
this.peerPipelines.set(peerId, pipeline);
|
this.peerPipelines.set(peerId, pipeline);
|
||||||
|
|
||||||
// 4) Apply current gain
|
|
||||||
this.applyGain(peerId);
|
this.applyGain(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Disconnect and clean up all nodes for a single peer. */
|
|
||||||
private removePipeline(peerId: string): void {
|
private removePipeline(peerId: string): void {
|
||||||
const pipeline = this.peerPipelines.get(peerId);
|
const pipeline = this.peerPipelines.get(peerId);
|
||||||
|
|
||||||
@@ -253,14 +205,6 @@ export class VoicePlaybackService {
|
|||||||
this.peerPipelines.delete(peerId);
|
this.peerPipelines.delete(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute and apply the effective gain for a peer.
|
|
||||||
*
|
|
||||||
* effectiveGain = masterVolume × (userVolume / 100)
|
|
||||||
*
|
|
||||||
* If the user is deafened or the peer is individually muted the gain
|
|
||||||
* is set to 0.
|
|
||||||
*/
|
|
||||||
private applyGain(peerId: string): void {
|
private applyGain(peerId: string): void {
|
||||||
const pipeline = this.peerPipelines.get(peerId);
|
const pipeline = this.peerPipelines.get(peerId);
|
||||||
|
|
||||||
@@ -278,15 +222,10 @@ export class VoicePlaybackService {
|
|||||||
pipeline.gainNode.gain.value = effective;
|
pipeline.gainNode.gain.value = effective;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Recalculate gain for every active pipeline. */
|
|
||||||
private recalcAllGains(): void {
|
private recalcAllGains(): void {
|
||||||
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
|
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Persistence helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private persistVolumes(): void {
|
private persistVolumes(): void {
|
||||||
try {
|
try {
|
||||||
const data: Record<string, { volume: number; muted: boolean }> = {};
|
const data: Record<string, { volume: number; muted: boolean }> = {};
|
||||||
@@ -331,10 +270,6 @@ export class VoicePlaybackService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Utility
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private hasAudio(stream: MediaStream): boolean {
|
private hasAudio(stream: MediaStream): boolean {
|
||||||
return stream.getAudioTracks().length > 0;
|
return stream.getAudioTracks().length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,24 +5,6 @@ import {
|
|||||||
HostListener
|
HostListener
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
/**
|
|
||||||
* Reusable confirmation dialog modal.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```html
|
|
||||||
* @if (showConfirm()) {
|
|
||||||
* <app-confirm-dialog
|
|
||||||
* title="Delete Room?"
|
|
||||||
* confirmLabel="Delete"
|
|
||||||
* variant="danger"
|
|
||||||
* (confirmed)="onDelete()"
|
|
||||||
* (cancelled)="showConfirm.set(false)"
|
|
||||||
* >
|
|
||||||
* <p>This will permanently delete the room.</p>
|
|
||||||
* </app-confirm-dialog>
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-confirm-dialog',
|
selector: 'app-confirm-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -32,19 +14,12 @@ import {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class ConfirmDialogComponent {
|
export class ConfirmDialogComponent {
|
||||||
/** Dialog title. */
|
|
||||||
title = input.required<string>();
|
title = input.required<string>();
|
||||||
/** Label for the confirm button. */
|
|
||||||
confirmLabel = input<string>('Confirm');
|
confirmLabel = input<string>('Confirm');
|
||||||
/** Label for the cancel button. */
|
|
||||||
cancelLabel = input<string>('Cancel');
|
cancelLabel = input<string>('Cancel');
|
||||||
/** Visual style of the confirm button. */
|
|
||||||
variant = input<'primary' | 'danger'>('primary');
|
variant = input<'primary' | 'danger'>('primary');
|
||||||
/** Tailwind width class for the dialog. */
|
|
||||||
widthClass = input<string>('w-[320px]');
|
widthClass = input<string>('w-[320px]');
|
||||||
/** Emitted when the user confirms. */
|
|
||||||
confirmed = output<undefined>();
|
confirmed = output<undefined>();
|
||||||
/** Emitted when the user cancels (backdrop click, Cancel button, or Escape). */
|
|
||||||
cancelled = output<undefined>();
|
cancelled = output<undefined>();
|
||||||
|
|
||||||
@HostListener('document:keydown.escape')
|
@HostListener('document:keydown.escape')
|
||||||
|
|||||||
@@ -10,30 +10,6 @@ import {
|
|||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic positioned context-menu overlay with automatic viewport clamping.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```html
|
|
||||||
* @if (showMenu()) {
|
|
||||||
* <app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-48'">
|
|
||||||
* <button (click)="doSomething()" class="context-menu-item">Action</button>
|
|
||||||
* </app-context-menu>
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* For pixel-based widths (e.g. sliders), use `[widthPx]` instead of `[width]`:
|
|
||||||
* ```html
|
|
||||||
* <app-context-menu [x]="menuX()" [y]="menuY()" [widthPx]="240" (closed)="closeMenu()">
|
|
||||||
* ...custom content...
|
|
||||||
* </app-context-menu>
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* Built-in item classes are available via the host styles:
|
|
||||||
* - `.context-menu-item` - normal item
|
|
||||||
* - `.context-menu-item-danger` - destructive (red) item
|
|
||||||
* - `.context-menu-divider` - horizontal separator
|
|
||||||
*/
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-context-menu',
|
selector: 'app-context-menu',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -42,34 +18,25 @@ import {
|
|||||||
})
|
})
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
export class ContextMenuComponent implements OnInit, AfterViewInit {
|
export class ContextMenuComponent implements OnInit, AfterViewInit {
|
||||||
/** Horizontal position (px from left). */
|
|
||||||
// eslint-disable-next-line id-length, id-denylist
|
// eslint-disable-next-line id-length, id-denylist
|
||||||
x = input.required<number>();
|
x = input.required<number>();
|
||||||
/** Vertical position (px from top). */
|
|
||||||
// eslint-disable-next-line id-length, id-denylist
|
// eslint-disable-next-line id-length, id-denylist
|
||||||
y = input.required<number>();
|
y = input.required<number>();
|
||||||
/** Tailwind width class for the panel (default `w-48`). Ignored when `widthPx` is set. */
|
|
||||||
width = input<string>('w-48');
|
width = input<string>('w-48');
|
||||||
/** Optional fixed width in pixels (overrides `width`). Useful for custom content like sliders. */
|
|
||||||
widthPx = input<number | null>(null);
|
widthPx = input<number | null>(null);
|
||||||
/** Emitted when the menu should close (backdrop click or Escape). */
|
|
||||||
closed = output<undefined>();
|
closed = output<undefined>();
|
||||||
|
|
||||||
@ViewChild('panel', { static: true }) panelRef!: ElementRef<HTMLDivElement>;
|
@ViewChild('panel', { static: true }) panelRef!: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
/** Viewport-clamped X position. */
|
|
||||||
clampedX = signal(0);
|
clampedX = signal(0);
|
||||||
/** Viewport-clamped Y position. */
|
|
||||||
clampedY = signal(0);
|
clampedY = signal(0);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
// Initial clamp with estimated dimensions
|
|
||||||
this.clampedX.set(this.clampX(this.x(), this.estimateWidth()));
|
this.clampedX.set(this.clampX(this.x(), this.estimateWidth()));
|
||||||
this.clampedY.set(this.clampY(this.y(), 80));
|
this.clampedY.set(this.clampY(this.y(), 80));
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
// Refine with actual rendered dimensions
|
|
||||||
const rect = this.panelRef.nativeElement.getBoundingClientRect();
|
const rect = this.panelRef.nativeElement.getBoundingClientRect();
|
||||||
|
|
||||||
this.clampedX.set(this.clampX(this.x(), rect.width));
|
this.clampedX.set(this.clampX(this.x(), rect.width));
|
||||||
@@ -87,7 +54,6 @@ export class ContextMenuComponent implements OnInit, AfterViewInit {
|
|||||||
if (px)
|
if (px)
|
||||||
return px;
|
return px;
|
||||||
|
|
||||||
// Parse Tailwind w-XX class to approximate pixel width
|
|
||||||
const match = this.width().match(/w-(\d+)/);
|
const match = this.width().match(/w-(\d+)/);
|
||||||
|
|
||||||
return match ? parseInt(match[1], 10) * 4 : 192;
|
return match ? parseInt(match[1], 10) * 4 : 192;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
RoomMember,
|
RoomMember,
|
||||||
User
|
User
|
||||||
} from '../../../core/models';
|
} from '../../../core/models/index';
|
||||||
|
|
||||||
export interface LeaveServerDialogResult {
|
export interface LeaveServerDialogResult {
|
||||||
nextOwnerKey?: string;
|
nextOwnerKey?: string;
|
||||||
|
|||||||
@@ -1,20 +1,6 @@
|
|||||||
import { NgOptimizedImage } from '@angular/common';
|
import { NgOptimizedImage } from '@angular/common';
|
||||||
import { Component, input } from '@angular/core';
|
import { Component, input } from '@angular/core';
|
||||||
|
|
||||||
/**
|
|
||||||
* Reusable user avatar circle.
|
|
||||||
*
|
|
||||||
* Displays the user's image when `avatarUrl` is provided, otherwise
|
|
||||||
* falls back to a colored circle with the first letter of `name`.
|
|
||||||
*
|
|
||||||
* Optional rings (e.g. voice-state colours) can be applied via `ringClass`.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```html
|
|
||||||
* <app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="md" />
|
|
||||||
* <app-user-avatar [name]="user.displayName" size="sm" ringClass="ring-2 ring-green-500" />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-avatar',
|
selector: 'app-user-avatar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -25,22 +11,16 @@ import { Component, input } from '@angular/core';
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
export class UserAvatarComponent {
|
export class UserAvatarComponent {
|
||||||
/** Display name - first character is used as fallback initial. */
|
|
||||||
name = input.required<string>();
|
name = input.required<string>();
|
||||||
/** Optional avatar image URL. */
|
|
||||||
avatarUrl = input<string | undefined | null>();
|
avatarUrl = input<string | undefined | null>();
|
||||||
/** Predefined size: `xs` (28px), `sm` (32px), `md` (40px), `lg` (48px). */
|
|
||||||
size = input<'xs' | 'sm' | 'md' | 'lg'>('sm');
|
size = input<'xs' | 'sm' | 'md' | 'lg'>('sm');
|
||||||
/** Extra ring classes, e.g. `'ring-2 ring-green-500'`. */
|
|
||||||
ringClass = input<string>('');
|
ringClass = input<string>('');
|
||||||
|
|
||||||
/** Compute the first-letter initial. */
|
|
||||||
initial(): string {
|
initial(): string {
|
||||||
return this.name()?.charAt(0)
|
return this.name()?.charAt(0)
|
||||||
?.toUpperCase() ?? '?';
|
?.toUpperCase() ?? '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map size token to Tailwind dimension classes. */
|
|
||||||
sizeClasses(): string {
|
sizeClasses(): string {
|
||||||
switch (this.size()) {
|
switch (this.size()) {
|
||||||
case 'xs': return 'w-7 h-7';
|
case 'xs': return 'w-7 h-7';
|
||||||
@@ -50,7 +30,6 @@ export class UserAvatarComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map size token to explicit pixel dimensions for image optimisation. */
|
|
||||||
sizePx(): number {
|
sizePx(): number {
|
||||||
switch (this.size()) {
|
switch (this.size()) {
|
||||||
case 'xs': return 28;
|
case 'xs': return 28;
|
||||||
@@ -60,7 +39,6 @@ export class UserAvatarComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map size token to text size for initials. */
|
|
||||||
textClass(): string {
|
textClass(): string {
|
||||||
switch (this.size()) {
|
switch (this.size()) {
|
||||||
case 'xs': return 'text-xs';
|
case 'xs': return 'text-xs';
|
||||||
|
|||||||
@@ -11,27 +11,6 @@ import { lucideVolume2, lucideVolumeX } from '@ng-icons/lucide';
|
|||||||
import { VoicePlaybackService } from '../../../features/voice/voice-controls/services/voice-playback.service';
|
import { VoicePlaybackService } from '../../../features/voice/voice-controls/services/voice-playback.service';
|
||||||
import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
||||||
|
|
||||||
/**
|
|
||||||
* Context-menu overlay that lets the local user adjust the playback
|
|
||||||
* volume of a specific remote voice-channel participant (0%-200%)
|
|
||||||
* and toggle per-user mute.
|
|
||||||
*
|
|
||||||
* Wraps `<app-context-menu>` for consistent positioning, backdrop,
|
|
||||||
* escape handling and viewport clamping.
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```html
|
|
||||||
* @if (showVolumeMenu()) {
|
|
||||||
* <app-user-volume-menu
|
|
||||||
* [x]="menuX()"
|
|
||||||
* [y]="menuY()"
|
|
||||||
* [peerId]="targetPeerId()"
|
|
||||||
* [displayName]="targetName()"
|
|
||||||
* (closed)="showVolumeMenu.set(false)"
|
|
||||||
* />
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-user-volume-menu',
|
selector: 'app-user-volume-menu',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -42,17 +21,12 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
|
|||||||
})
|
})
|
||||||
/* eslint-disable @typescript-eslint/member-ordering */
|
/* eslint-disable @typescript-eslint/member-ordering */
|
||||||
export class UserVolumeMenuComponent implements OnInit {
|
export class UserVolumeMenuComponent implements OnInit {
|
||||||
/** Horizontal position (px from left). */
|
|
||||||
// eslint-disable-next-line id-length, id-denylist
|
// eslint-disable-next-line id-length, id-denylist
|
||||||
x = input.required<number>();
|
x = input.required<number>();
|
||||||
/** Vertical position (px from top). */
|
|
||||||
// eslint-disable-next-line id-length, id-denylist
|
// eslint-disable-next-line id-length, id-denylist
|
||||||
y = input.required<number>();
|
y = input.required<number>();
|
||||||
/** Remote peer identifier (oderId). */
|
|
||||||
peerId = input.required<string>();
|
peerId = input.required<string>();
|
||||||
/** Display name shown in the header. */
|
|
||||||
displayName = input.required<string>();
|
displayName = input.required<string>();
|
||||||
/** Emitted when the menu should close. */
|
|
||||||
closed = output<undefined>();
|
closed = output<undefined>();
|
||||||
|
|
||||||
private playback = inject(VoicePlaybackService);
|
private playback = inject(VoicePlaybackService);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import { mergeMap } from 'rxjs/operators';
|
import { mergeMap } from 'rxjs/operators';
|
||||||
import { Action } from '@ngrx/store';
|
import { Action } from '@ngrx/store';
|
||||||
import { Message } from '../../core/models';
|
import { Message } from '../../core/models/index';
|
||||||
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 { AttachmentService } from '../../core/services/attachment.service';
|
import { AttachmentService } from '../../core/services/attachment.service';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
emptyProps,
|
emptyProps,
|
||||||
props
|
props
|
||||||
} from '@ngrx/store';
|
} from '@ngrx/store';
|
||||||
import { Message, Reaction } from '../../core/models';
|
import { Message, Reaction } from '../../core/models/index';
|
||||||
|
|
||||||
export const MessagesActions = createActionGroup({
|
export const MessagesActions = createActionGroup({
|
||||||
source: 'Messages',
|
source: 'Messages',
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { DatabaseService } from '../../core/services/database.service';
|
|||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { TimeSyncService } from '../../core/services/time-sync.service';
|
import { TimeSyncService } from '../../core/services/time-sync.service';
|
||||||
import { AttachmentService } from '../../core/services/attachment.service';
|
import { AttachmentService } from '../../core/services/attachment.service';
|
||||||
import { Message, Reaction } from '../../core/models';
|
import { Message, Reaction } from '../../core/models/index';
|
||||||
import { hydrateMessages } from './messages.helpers';
|
import { hydrateMessages } from './messages.helpers';
|
||||||
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Extracted from messages.effects.ts to improve readability, testability,
|
* Extracted from messages.effects.ts to improve readability, testability,
|
||||||
* and reuse across effects and handler files.
|
* and reuse across effects and handler files.
|
||||||
*/
|
*/
|
||||||
import { Message } from '../../core/models';
|
import { Message } from '../../core/models/index';
|
||||||
import { DatabaseService } from '../../core/services/database.service';
|
import { DatabaseService } from '../../core/services/database.service';
|
||||||
|
|
||||||
/** Maximum number of recent messages to include in sync inventories. */
|
/** Maximum number of recent messages to include in sync inventories. */
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
EntityAdapter,
|
EntityAdapter,
|
||||||
createEntityAdapter
|
createEntityAdapter
|
||||||
} from '@ngrx/entity';
|
} from '@ngrx/entity';
|
||||||
import { Message } from '../../core/models';
|
import { Message } from '../../core/models/index';
|
||||||
import { MessagesActions } from './messages.actions';
|
import { MessagesActions } from './messages.actions';
|
||||||
|
|
||||||
/** State shape for the messages feature slice, extending NgRx EntityState. */
|
/** State shape for the messages feature slice, extending NgRx EntityState. */
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
RoomMember,
|
RoomMember,
|
||||||
User
|
User
|
||||||
} from '../../core/models';
|
} from '../../core/models/index';
|
||||||
import { WebRTCService } from '../../core/services/webrtc.service';
|
import { WebRTCService } from '../../core/services/webrtc.service';
|
||||||
import { UsersActions } from '../users/users.actions';
|
import { UsersActions } from '../users/users.actions';
|
||||||
import { selectCurrentUser } from '../users/users.selectors';
|
import { selectCurrentUser } from '../users/users.selectors';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RoomMember, User } from '../../core/models';
|
import { RoomMember, User } from '../../core/models/index';
|
||||||
|
|
||||||
/** Remove members that have not been seen for roughly two months. */
|
/** Remove members that have not been seen for roughly two months. */
|
||||||
export const ROOM_MEMBER_STALE_MS = 1000 * 60 * 60 * 24 * 60;
|
export const ROOM_MEMBER_STALE_MS = 1000 * 60 * 60 * 24 * 60;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
ServerInfo,
|
ServerInfo,
|
||||||
RoomPermissions,
|
RoomPermissions,
|
||||||
Channel
|
Channel
|
||||||
} from '../../core/models';
|
} from '../../core/models/index';
|
||||||
|
|
||||||
export const RoomsActions = createActionGroup({
|
export const RoomsActions = createActionGroup({
|
||||||
source: 'Rooms',
|
source: 'Rooms',
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
RoomSettings,
|
RoomSettings,
|
||||||
RoomPermissions,
|
RoomPermissions,
|
||||||
VoiceState
|
VoiceState
|
||||||
} from '../../core/models';
|
} from '../../core/models/index';
|
||||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||||
import {
|
import {
|
||||||
findRoomMember,
|
findRoomMember,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
ServerInfo,
|
ServerInfo,
|
||||||
RoomSettings,
|
RoomSettings,
|
||||||
Channel
|
Channel
|
||||||
} from '../../core/models';
|
} from '../../core/models/index';
|
||||||
import { RoomsActions } from './rooms.actions';
|
import { RoomsActions } from './rooms.actions';
|
||||||
import { pruneRoomMembers } from './room-members.helpers';
|
import { pruneRoomMembers } from './room-members.helpers';
|
||||||
|
|
||||||
|
|||||||
@@ -1,102 +1,69 @@
|
|||||||
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
import { createFeatureSelector, createSelector } from '@ngrx/store';
|
||||||
import { RoomsState } from './rooms.reducer';
|
import { RoomsState } from './rooms.reducer';
|
||||||
|
|
||||||
/** Selects the top-level rooms feature state. */
|
|
||||||
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
|
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
|
||||||
|
|
||||||
/** Selects the room the user is currently viewing. */
|
|
||||||
export const selectCurrentRoom = createSelector(
|
export const selectCurrentRoom = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.currentRoom
|
(state) => state.currentRoom
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects the current room's settings (name, topic, privacy, etc.). */
|
|
||||||
export const selectRoomSettings = createSelector(
|
export const selectRoomSettings = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.roomSettings
|
(state) => state.roomSettings
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects server search results from the directory. */
|
|
||||||
export const selectSearchResults = createSelector(
|
export const selectSearchResults = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.searchResults
|
(state) => state.searchResults
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Whether a server directory search is currently in progress. */
|
|
||||||
export const selectIsSearching = createSelector(
|
export const selectIsSearching = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.isSearching
|
(state) => state.isSearching
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Whether a room connection is being established. */
|
|
||||||
export const selectIsConnecting = createSelector(
|
export const selectIsConnecting = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.isConnecting
|
(state) => state.isConnecting
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Whether the user is currently connected to a room. */
|
|
||||||
export const selectIsConnected = createSelector(
|
export const selectIsConnected = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.isConnected
|
(state) => state.isConnected
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects the most recent rooms-related error message. */
|
|
||||||
export const selectRoomsError = createSelector(
|
export const selectRoomsError = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.error
|
(state) => state.error
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects the ID of the current room, or null. */
|
|
||||||
export const selectCurrentRoomId = createSelector(
|
export const selectCurrentRoomId = createSelector(
|
||||||
selectCurrentRoom,
|
selectCurrentRoom,
|
||||||
(room) => room?.id ?? null
|
(room) => room?.id ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects the display name of the current room. */
|
|
||||||
export const selectCurrentRoomName = createSelector(
|
export const selectCurrentRoomName = createSelector(
|
||||||
selectCurrentRoom,
|
selectCurrentRoom,
|
||||||
(room) => room?.name ?? ''
|
(room) => room?.name ?? ''
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects the host ID of the current room (for ownership checks). */
|
|
||||||
export const selectIsCurrentUserHost = createSelector(
|
export const selectIsCurrentUserHost = createSelector(
|
||||||
selectCurrentRoom,
|
selectCurrentRoom,
|
||||||
(room) => room?.hostId
|
(room) => room?.hostId
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects all locally-saved rooms. */
|
|
||||||
export const selectSavedRooms = createSelector(
|
export const selectSavedRooms = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.savedRooms
|
(state) => state.savedRooms
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Whether rooms are currently being loaded from local storage. */
|
|
||||||
export const selectRoomsLoading = createSelector(
|
export const selectRoomsLoading = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.loading
|
(state) => state.loading
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects the ID of the currently active text channel. */
|
|
||||||
export const selectActiveChannelId = createSelector(
|
export const selectActiveChannelId = createSelector(
|
||||||
selectRoomsState,
|
selectRoomsState,
|
||||||
(state) => state.activeChannelId
|
(state) => state.activeChannelId
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects all channels defined on the current room. */
|
|
||||||
export const selectCurrentRoomChannels = createSelector(
|
export const selectCurrentRoomChannels = createSelector(
|
||||||
selectCurrentRoom,
|
selectCurrentRoom,
|
||||||
(room) => room?.channels ?? []
|
(room) => room?.channels ?? []
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects only text channels, sorted by position. */
|
|
||||||
export const selectTextChannels = createSelector(
|
export const selectTextChannels = createSelector(
|
||||||
selectCurrentRoomChannels,
|
selectCurrentRoomChannels,
|
||||||
(channels) => channels
|
(channels) => channels
|
||||||
.filter((channel) => channel.type === 'text')
|
.filter((channel) => channel.type === 'text')
|
||||||
.sort((channelA, channelB) => channelA.position - channelB.position)
|
.sort((channelA, channelB) => channelA.position - channelB.position)
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Selects only voice channels, sorted by position. */
|
|
||||||
export const selectVoiceChannels = createSelector(
|
export const selectVoiceChannels = createSelector(
|
||||||
selectCurrentRoomChannels,
|
selectCurrentRoomChannels,
|
||||||
(channels) => channels
|
(channels) => channels
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
BanEntry,
|
BanEntry,
|
||||||
VoiceState,
|
VoiceState,
|
||||||
ScreenShareState
|
ScreenShareState
|
||||||
} from '../../core/models';
|
} from '../../core/models/index';
|
||||||
|
|
||||||
export const UsersActions = createActionGroup({
|
export const UsersActions = createActionGroup({
|
||||||
source: 'Users',
|
source: 'Users',
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
import { selectCurrentRoom } from '../rooms/rooms.selectors';
|
import { selectCurrentRoom } 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';
|
import { BanEntry, User } from '../../core/models/index';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersEffects {
|
export class UsersEffects {
|
||||||
|
|||||||
@@ -4,20 +4,14 @@ import {
|
|||||||
EntityAdapter,
|
EntityAdapter,
|
||||||
createEntityAdapter
|
createEntityAdapter
|
||||||
} from '@ngrx/entity';
|
} from '@ngrx/entity';
|
||||||
import { User, BanEntry } from '../../core/models';
|
import { User, BanEntry } from '../../core/models/index';
|
||||||
import { UsersActions } from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
|
|
||||||
/** State shape for the users feature slice, extending NgRx EntityState. */
|
|
||||||
export interface UsersState extends EntityState<User> {
|
export interface UsersState extends EntityState<User> {
|
||||||
/** ID of the locally authenticated user. */
|
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
/** ID of the room host (owner). */
|
|
||||||
hostId: string | null;
|
hostId: string | null;
|
||||||
/** Whether a user-loading operation is in progress. */
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
/** Most recent error message from user operations. */
|
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** List of active bans for the current room. */
|
|
||||||
bans: BanEntry[];
|
bans: BanEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +30,6 @@ export const initialState: UsersState = usersAdapter.getInitialState({
|
|||||||
|
|
||||||
export const usersReducer = createReducer(
|
export const usersReducer = createReducer(
|
||||||
initialState,
|
initialState,
|
||||||
|
|
||||||
// Load current user
|
|
||||||
on(UsersActions.loadCurrentUser, (state) => ({
|
on(UsersActions.loadCurrentUser, (state) => ({
|
||||||
...state,
|
...state,
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -57,16 +49,12 @@ export const usersReducer = createReducer(
|
|||||||
loading: false,
|
loading: false,
|
||||||
error
|
error
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// Set current user
|
|
||||||
on(UsersActions.setCurrentUser, (state, { user }) =>
|
on(UsersActions.setCurrentUser, (state, { user }) =>
|
||||||
usersAdapter.upsertOne(user, {
|
usersAdapter.upsertOne(user, {
|
||||||
...state,
|
...state,
|
||||||
currentUserId: user.id
|
currentUserId: user.id
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
|
||||||
// Update current user
|
|
||||||
on(UsersActions.updateCurrentUser, (state, { updates }) => {
|
on(UsersActions.updateCurrentUser, (state, { updates }) => {
|
||||||
if (!state.currentUserId)
|
if (!state.currentUserId)
|
||||||
return state;
|
return state;
|
||||||
@@ -79,8 +67,6 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Load room users
|
|
||||||
on(UsersActions.loadRoomUsers, (state) => ({
|
on(UsersActions.loadRoomUsers, (state) => ({
|
||||||
...state,
|
...state,
|
||||||
loading: true,
|
loading: true,
|
||||||
@@ -99,18 +85,12 @@ export const usersReducer = createReducer(
|
|||||||
loading: false,
|
loading: false,
|
||||||
error
|
error
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// User joined
|
|
||||||
on(UsersActions.userJoined, (state, { user }) =>
|
on(UsersActions.userJoined, (state, { user }) =>
|
||||||
usersAdapter.upsertOne(user, state)
|
usersAdapter.upsertOne(user, state)
|
||||||
),
|
),
|
||||||
|
|
||||||
// User left
|
|
||||||
on(UsersActions.userLeft, (state, { userId }) =>
|
on(UsersActions.userLeft, (state, { userId }) =>
|
||||||
usersAdapter.removeOne(userId, state)
|
usersAdapter.removeOne(userId, state)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Update user
|
|
||||||
on(UsersActions.updateUser, (state, { userId, updates }) =>
|
on(UsersActions.updateUser, (state, { userId, updates }) =>
|
||||||
usersAdapter.updateOne(
|
usersAdapter.updateOne(
|
||||||
{
|
{
|
||||||
@@ -120,8 +100,6 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Update user role
|
|
||||||
on(UsersActions.updateUserRole, (state, { userId, role }) =>
|
on(UsersActions.updateUserRole, (state, { userId, role }) =>
|
||||||
usersAdapter.updateOne(
|
usersAdapter.updateOne(
|
||||||
{
|
{
|
||||||
@@ -131,13 +109,9 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Kick user
|
|
||||||
on(UsersActions.kickUserSuccess, (state, { userId }) =>
|
on(UsersActions.kickUserSuccess, (state, { userId }) =>
|
||||||
usersAdapter.removeOne(userId, state)
|
usersAdapter.removeOne(userId, state)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Ban user
|
|
||||||
on(UsersActions.banUserSuccess, (state, { userId, ban }) => {
|
on(UsersActions.banUserSuccess, (state, { userId, ban }) => {
|
||||||
const newState = usersAdapter.removeOne(userId, state);
|
const newState = usersAdapter.removeOne(userId, state);
|
||||||
|
|
||||||
@@ -146,20 +120,14 @@ export const usersReducer = createReducer(
|
|||||||
bans: [...state.bans, ban]
|
bans: [...state.bans, ban]
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Unban user
|
|
||||||
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
|
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
|
||||||
...state,
|
...state,
|
||||||
bans: state.bans.filter((ban) => ban.oderId !== oderId)
|
bans: state.bans.filter((ban) => ban.oderId !== oderId)
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// Load bans
|
|
||||||
on(UsersActions.loadBansSuccess, (state, { bans }) => ({
|
on(UsersActions.loadBansSuccess, (state, { bans }) => ({
|
||||||
...state,
|
...state,
|
||||||
bans
|
bans
|
||||||
})),
|
})),
|
||||||
|
|
||||||
// Admin mute
|
|
||||||
on(UsersActions.adminMuteUser, (state, { userId }) =>
|
on(UsersActions.adminMuteUser, (state, { userId }) =>
|
||||||
usersAdapter.updateOne(
|
usersAdapter.updateOne(
|
||||||
{
|
{
|
||||||
@@ -178,8 +146,6 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Admin unmute
|
|
||||||
on(UsersActions.adminUnmuteUser, (state, { userId }) =>
|
on(UsersActions.adminUnmuteUser, (state, { userId }) =>
|
||||||
usersAdapter.updateOne(
|
usersAdapter.updateOne(
|
||||||
{
|
{
|
||||||
@@ -198,8 +164,6 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Update voice state (generic)
|
|
||||||
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) => {
|
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) => {
|
||||||
const prev = state.entities[userId]?.voiceState || {
|
const prev = state.entities[userId]?.voiceState || {
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
@@ -228,8 +192,6 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Update screen share state
|
|
||||||
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
|
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
|
||||||
const prev = state.entities[userId]?.screenShareState || {
|
const prev = state.entities[userId]?.screenShareState || {
|
||||||
isSharing: false
|
isSharing: false
|
||||||
@@ -250,13 +212,9 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Sync users
|
|
||||||
on(UsersActions.syncUsers, (state, { users }) =>
|
on(UsersActions.syncUsers, (state, { users }) =>
|
||||||
usersAdapter.upsertMany(users, state)
|
usersAdapter.upsertMany(users, state)
|
||||||
),
|
),
|
||||||
|
|
||||||
// Clear users
|
|
||||||
on(UsersActions.clearUsers, (state) => {
|
on(UsersActions.clearUsers, (state) => {
|
||||||
const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId);
|
const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId);
|
||||||
|
|
||||||
@@ -265,10 +223,7 @@ export const usersReducer = createReducer(
|
|||||||
hostId: null
|
hostId: null
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Update host
|
|
||||||
on(UsersActions.updateHost, (state, { userId }) => {
|
on(UsersActions.updateHost, (state, { userId }) => {
|
||||||
// Update the old host's role to member
|
|
||||||
let newState = state;
|
let newState = state;
|
||||||
|
|
||||||
if (state.hostId && state.hostId !== userId) {
|
if (state.hostId && state.hostId !== userId) {
|
||||||
@@ -280,8 +235,6 @@ export const usersReducer = createReducer(
|
|||||||
state
|
state
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the new host's role
|
|
||||||
return usersAdapter.updateOne(
|
return usersAdapter.updateOne(
|
||||||
{
|
{
|
||||||
id: userId,
|
id: userId,
|
||||||
|
|||||||
Reference in New Issue
Block a user