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