Cleaning up comments

This commit is contained in:
2026-03-06 05:21:41 +01:00
parent fe2347b54e
commit 10467dfccb
50 changed files with 51 additions and 885 deletions

View File

@@ -1,10 +1,3 @@
/* ------------------------------------------------------------------ */
/* CQRS type definitions for the MetoYou electron main process. */
/* Commands mutate state; queries read state. */
/* ------------------------------------------------------------------ */
// --------------- Command types ---------------
export const CommandType = { export const CommandType = {
SaveMessage: 'save-message', SaveMessage: 'save-message',
DeleteMessage: 'delete-message', DeleteMessage: 'delete-message',
@@ -27,8 +20,6 @@ export const CommandType = {
export type CommandTypeKey = typeof CommandType[keyof typeof CommandType]; export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
// --------------- Query types ---------------
export const QueryType = { export const QueryType = {
GetMessages: 'get-messages', GetMessages: 'get-messages',
GetMessageById: 'get-message-by-id', GetMessageById: 'get-message-by-id',
@@ -46,8 +37,6 @@ export const QueryType = {
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType]; export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
// --------------- Payload interfaces ---------------
export interface MessagePayload { export interface MessagePayload {
id: string; id: string;
roomId: string; roomId: string;
@@ -129,8 +118,6 @@ export interface AttachmentPayload {
savedPath?: string; savedPath?: string;
} }
// --------------- Command interfaces ---------------
export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } } export interface SaveMessageCommand { type: typeof CommandType.SaveMessage; payload: { message: MessagePayload } }
export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } } export interface DeleteMessageCommand { type: typeof CommandType.DeleteMessage; payload: { messageId: string } }
export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } } export interface UpdateMessageCommand { type: typeof CommandType.UpdateMessage; payload: { messageId: string; updates: Partial<MessagePayload> } }
@@ -168,8 +155,6 @@ export type Command =
| DeleteAttachmentsForMessageCommand | DeleteAttachmentsForMessageCommand
| ClearAllDataCommand; | ClearAllDataCommand;
// --------------- Query interfaces ---------------
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } } export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } } export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } } export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }

View File

@@ -2,12 +2,10 @@ import { contextBridge, ipcRenderer } from 'electron';
import { Command, Query } from './cqrs/types'; import { Command, Query } from './cqrs/types';
export interface ElectronAPI { export interface ElectronAPI {
// Window controls
minimizeWindow: () => void; minimizeWindow: () => void;
maximizeWindow: () => void; maximizeWindow: () => void;
closeWindow: () => void; closeWindow: () => void;
// System utilities
openExternal: (url: string) => Promise<boolean>; openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
getAppDataPath: () => Promise<string>; getAppDataPath: () => Promise<string>;
@@ -17,7 +15,6 @@ export interface ElectronAPI {
fileExists: (filePath: string) => Promise<boolean>; fileExists: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
// CQRS database operations
command: <T = unknown>(command: Command) => Promise<T>; command: <T = unknown>(command: Command) => Promise<T>;
query: <T = unknown>(query: Query) => Promise<T>; query: <T = unknown>(query: Query) => Promise<T>;
} }

View File

@@ -1,9 +1,3 @@
/**
* Thin service layer - binds every CQRS handler to `getDataSource()` so
* routes can call one-liner functions instead of manually constructing
* command/query objects and passing the DataSource every time.
*/
import { getDataSource } from '../db'; import { getDataSource } from '../db';
import { import {
CommandType, CommandType,
@@ -25,8 +19,6 @@ import { handleGetServerById } from './queries/handlers/getServerById';
import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById'; import { handleGetJoinRequestById } from './queries/handlers/getJoinRequestById';
import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer'; import { handleGetPendingRequestsForServer } from './queries/handlers/getPendingRequestsForServer';
// --------------- Commands ---------------
export const registerUser = (user: AuthUserPayload) => export const registerUser = (user: AuthUserPayload) =>
handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource()); handleRegisterUser({ type: CommandType.RegisterUser, payload: { user } }, getDataSource());
@@ -45,8 +37,6 @@ export const updateJoinRequestStatus = (requestId: string, status: JoinRequestPa
export const deleteStaleJoinRequests = (maxAgeMs: number) => export const deleteStaleJoinRequests = (maxAgeMs: number) =>
handleDeleteStaleJoinRequests({ type: CommandType.DeleteStaleJoinRequests, payload: { maxAgeMs } }, getDataSource()); handleDeleteStaleJoinRequests({ type: CommandType.DeleteStaleJoinRequests, payload: { maxAgeMs } }, getDataSource());
// --------------- Queries ---------------
export const getUserByUsername = (username: string) => export const getUserByUsername = (username: string) =>
handleGetUserByUsername({ type: QueryType.GetUserByUsername, payload: { username } }, getDataSource()); handleGetUserByUsername({ type: QueryType.GetUserByUsername, payload: { username } }, getDataSource());

View File

@@ -1,10 +1,3 @@
/* ------------------------------------------------------------------ */
/* CQRS type definitions for the MetoYou server process. */
/* Commands mutate state; queries read state. */
/* ------------------------------------------------------------------ */
// --------------- Command types ---------------
export const CommandType = { export const CommandType = {
RegisterUser: 'register-user', RegisterUser: 'register-user',
UpsertServer: 'upsert-server', UpsertServer: 'upsert-server',
@@ -16,8 +9,6 @@ export const CommandType = {
export type CommandTypeKey = typeof CommandType[keyof typeof CommandType]; export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
// --------------- Query types ---------------
export const QueryType = { export const QueryType = {
GetUserByUsername: 'get-user-by-username', GetUserByUsername: 'get-user-by-username',
GetUserById: 'get-user-by-id', GetUserById: 'get-user-by-id',
@@ -29,8 +20,6 @@ export const QueryType = {
export type QueryTypeKey = typeof QueryType[keyof typeof QueryType]; export type QueryTypeKey = typeof QueryType[keyof typeof QueryType];
// --------------- Payload interfaces ---------------
export interface AuthUserPayload { export interface AuthUserPayload {
id: string; id: string;
username: string; username: string;
@@ -63,8 +52,6 @@ export interface JoinRequestPayload {
createdAt: number; createdAt: number;
} }
// --------------- Command interfaces ---------------
export interface RegisterUserCommand { export interface RegisterUserCommand {
type: typeof CommandType.RegisterUser; type: typeof CommandType.RegisterUser;
payload: { user: AuthUserPayload }; payload: { user: AuthUserPayload };
@@ -103,8 +90,6 @@ export type Command =
| UpdateJoinRequestStatusCommand | UpdateJoinRequestStatusCommand
| DeleteStaleJoinRequestsCommand; | DeleteStaleJoinRequestsCommand;
// --------------- Query interfaces ---------------
export interface GetUserByUsernameQuery { export interface GetUserByUsernameQuery {
type: typeof QueryType.GetUserByUsername; type: typeof QueryType.GetUserByUsername;
payload: { username: string }; payload: { username: string };

View File

@@ -31,12 +31,6 @@ import {
STORAGE_KEY_LAST_VISITED_ROUTE STORAGE_KEY_LAST_VISITED_ROUTE
} from './core/constants'; } from './core/constants';
/**
* Root application component.
*
* Initialises the database, loads persisted user and room data,
* handles route restoration, and tracks voice session navigation.
*/
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [
@@ -61,30 +55,24 @@ export class App implements OnInit {
private voiceSession = inject(VoiceSessionService); private voiceSession = inject(VoiceSessionService);
private externalLinks = inject(ExternalLinkService); private externalLinks = inject(ExternalLinkService);
/** Intercept all <a> clicks and open them externally. */
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
onGlobalLinkClick(evt: MouseEvent): void { onGlobalLinkClick(evt: MouseEvent): void {
this.externalLinks.handleClick(evt); this.externalLinks.handleClick(evt);
} }
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
// Initialize database
await this.databaseService.initialize(); await this.databaseService.initialize();
// Initial time sync with active server
try { try {
const apiBase = this.servers.getApiBaseUrl(); const apiBase = this.servers.getApiBaseUrl();
await this.timeSync.syncWithEndpoint(apiBase); await this.timeSync.syncWithEndpoint(apiBase);
} catch {} } catch {}
// Load user data from local storage or create new user
this.store.dispatch(UsersActions.loadCurrentUser()); this.store.dispatch(UsersActions.loadCurrentUser());
// Load saved rooms
this.store.dispatch(RoomsActions.loadRooms()); this.store.dispatch(RoomsActions.loadRooms());
// If not authenticated, redirect to login; else restore last route
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID); const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) { if (!currentUserId) {
@@ -103,20 +91,15 @@ export class App implements OnInit {
} }
} }
// Persist last visited on navigation and track voice session navigation
this.router.events.subscribe((evt) => { this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) { if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url; const url = evt.urlAfterRedirects || evt.url;
// Store room route or search
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url); localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
// Check if user navigated away from voice-connected server
// Extract roomId from URL if on a room route
const roomMatch = url.match(ROOM_URL_PATTERN); const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null; const currentRoomId = roomMatch ? roomMatch[1] : null;
// Update voice session service with current server context
this.voiceSession.checkCurrentRoute(currentRoomId); this.voiceSession.checkCurrentRoute(currentRoomId);
} }
}); });

View File

@@ -1,39 +1,11 @@
/**
* Application-wide constants shared across multiple components and services.
*
* Centralises localStorage keys, common defaults, and UI thresholds
* so that magic strings and numbers are defined in one place.
*/
/** Key used to persist the current user's ID in localStorage. */
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId'; export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
/** Key used to persist the last visited route for session restore. */
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute'; export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
/** Key used to persist signaling / API connection settings. */
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings'; export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
/** Key used to persist voice settings (input/output devices, volume). */
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings'; export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
/** Key used to persist per-user volume overrides (0-200%). */
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
/** Regex that extracts a roomId from a `/room/:roomId` URL path. */
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/; export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
/** Maximum number of actions retained by NgRx Store devtools. */
export const STORE_DEVTOOLS_MAX_AGE = 25; export const STORE_DEVTOOLS_MAX_AGE = 25;
/** Default maximum number of users allowed in a new room. */
export const DEFAULT_MAX_USERS = 50; export const DEFAULT_MAX_USERS = 50;
/** Default audio bitrate in kbps for voice chat. */
export const DEFAULT_AUDIO_BITRATE_KBPS = 96; export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
/** Default volume level (0-100). */
export const DEFAULT_VOLUME = 100; export const DEFAULT_VOLUME = 100;
/** Default search debounce time in milliseconds. */
export const SEARCH_DEBOUNCE_MS = 300; export const SEARCH_DEBOUNCE_MS = 300;

1
src/app/core/models.ts Normal file
View File

@@ -0,0 +1 @@
export * from './models/index';

View File

@@ -1,293 +1,143 @@
/**
* Core domain models for the MetoYou P2P chat application.
*
* These interfaces define the data structures shared across
* services, store, and components.
*/
/** Possible online-presence statuses for a user. */
export type UserStatus = 'online' | 'away' | 'busy' | 'offline'; export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
/** Role hierarchy within a room/server. */
export type UserRole = 'host' | 'admin' | 'moderator' | 'member'; export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
/** Channel type within a server. */
export type ChannelType = 'text' | 'voice'; export type ChannelType = 'text' | 'voice';
/**
* Represents an authenticated user in the system.
* Users are identified by both a local `id` and a network-wide `oderId`.
*/
export interface User { export interface User {
/** Local database identifier. */
id: string; id: string;
/** Network-wide unique identifier used for peer identification. */
oderId: string; oderId: string;
/** Login username (unique per auth server). */
username: string; username: string;
/** Human-readable display name shown in the UI. */
displayName: string; displayName: string;
/** Optional URL to the user's avatar image. */
avatarUrl?: string; avatarUrl?: string;
/** Current online-presence status. */
status: UserStatus; status: UserStatus;
/** Role within the current room/server. */
role: UserRole; role: UserRole;
/** Epoch timestamp (ms) when the user first joined. */
joinedAt: number; joinedAt: number;
/** WebRTC peer identifier (transient, set when connected). */
peerId?: string; peerId?: string;
/** Whether the user is currently connected. */
isOnline?: boolean; isOnline?: boolean;
/** Whether the user holds admin-level privileges. */
isAdmin?: boolean; isAdmin?: boolean;
/** Whether the user is the owner of the current room. */
isRoomOwner?: boolean; isRoomOwner?: boolean;
/** Real-time voice connection state. */
voiceState?: VoiceState; voiceState?: VoiceState;
/** Real-time screen-sharing state. */
screenShareState?: ScreenShareState; screenShareState?: ScreenShareState;
} }
/**
* Persisted membership record for a room/server.
*
* Unlike `User`, this survives when a member goes offline so the UI can
* continue to list known server members.
*/
export interface RoomMember { export interface RoomMember {
/** The member's local application/database identifier. */
id: string; id: string;
/** Optional network-wide peer identifier. */
oderId?: string; oderId?: string;
/** Login username (best effort; may be synthesized from display name). */
username: string; username: string;
/** Human-readable display name shown in the UI. */
displayName: string; displayName: string;
/** Optional avatar URL. */
avatarUrl?: string; avatarUrl?: string;
/** Role within the room/server. */
role: UserRole; role: UserRole;
/** Epoch timestamp (ms) when the member first joined. */
joinedAt: number; joinedAt: number;
/** Epoch timestamp (ms) when the member was last seen online. */
lastSeenAt: number; lastSeenAt: number;
} }
/**
* A communication channel within a server (either text or voice).
*/
export interface Channel { export interface Channel {
/** Unique channel identifier. */
id: string; id: string;
/** Display name of the channel. */
name: string; name: string;
/** Whether this is a text chat or voice channel. */
type: ChannelType; type: ChannelType;
/** Sort order within its type group (lower value = higher priority). */
position: number; position: number;
} }
/**
* A single chat message in a room's text channel.
*/
export interface Message { export interface Message {
/** Unique message identifier. */
id: string; id: string;
/** The room this message belongs to. */
roomId: string; roomId: string;
/** The text channel within the room (defaults to 'general'). */
channelId?: string; channelId?: string;
/** Identifier of the user who sent the message. */
senderId: string; senderId: string;
/** Display name of the sender at the time of sending. */
senderName: string; senderName: string;
/** Markdown-formatted message body. */
content: string; content: string;
/** Epoch timestamp (ms) when the message was created. */
timestamp: number; timestamp: number;
/** Epoch timestamp (ms) of the last edit, if any. */
editedAt?: number; editedAt?: number;
/** Emoji reactions attached to this message. */
reactions: Reaction[]; reactions: Reaction[];
/** Whether this message has been soft-deleted. */
isDeleted: boolean; isDeleted: boolean;
/** If this is a reply, the ID of the parent message. */
replyToId?: string; replyToId?: string;
} }
/**
* An emoji reaction on a message.
*/
export interface Reaction { export interface Reaction {
/** Unique reaction identifier. */
id: string; id: string;
/** The message this reaction is attached to. */
messageId: string; messageId: string;
/** Network-wide user ID of the reactor. */
oderId: string; oderId: string;
/** Alias for `oderId` (kept for backward compatibility). */
userId: string; userId: string;
/** The emoji character(s) used. */
emoji: string; emoji: string;
/** Epoch timestamp (ms) when the reaction was added. */
timestamp: number; timestamp: number;
} }
/**
* A chat room (server) that users can join to communicate.
*/
export interface Room { export interface Room {
/** Unique room identifier. */
id: string; id: string;
/** Display name of the room. */
name: string; name: string;
/** Optional long-form description. */
description?: string; description?: string;
/** Short topic/status line shown in the header. */
topic?: string; topic?: string;
/** User ID of the room's creator/owner. */
hostId: string; hostId: string;
/** Password required to join (if private). */
password?: string; password?: string;
/** Whether the room requires a password to join. */
isPrivate: boolean; isPrivate: boolean;
/** Epoch timestamp (ms) when the room was created. */
createdAt: number; createdAt: number;
/** Current number of connected users. */
userCount: number; userCount: number;
/** Maximum allowed concurrent users. */
maxUsers?: number; maxUsers?: number;
/** Server icon as a data-URL or remote URL. */
icon?: string; icon?: string;
/** Epoch timestamp (ms) of the last icon update (for conflict resolution). */
iconUpdatedAt?: number; iconUpdatedAt?: number;
/** Role-based management permission overrides. */
permissions?: RoomPermissions; permissions?: RoomPermissions;
/** Text and voice channels within the server. */
channels?: Channel[]; channels?: Channel[];
/** Persisted member roster, including offline users. */
members?: RoomMember[]; members?: RoomMember[];
} }
/**
* Editable subset of room properties exposed in the settings UI.
*/
export interface RoomSettings { export interface RoomSettings {
/** Room display name. */
name: string; name: string;
/** Optional long-form description. */
description?: string; description?: string;
/** Short topic/status line. */
topic?: string; topic?: string;
/** Whether a password is required to join. */
isPrivate: boolean; isPrivate: boolean;
/** Password for private rooms. */
password?: string; password?: string;
/** Maximum allowed concurrent users. */
maxUsers?: number; maxUsers?: number;
/** Optional list of room rules. */
rules?: string[]; rules?: string[];
} }
/**
* Fine-grained permission toggles for a room.
* Controls which roles can perform management actions.
*/
export interface RoomPermissions { export interface RoomPermissions {
/** Whether admins can create/modify rooms. */
adminsManageRooms?: boolean; adminsManageRooms?: boolean;
/** Whether moderators can create/modify rooms. */
moderatorsManageRooms?: boolean; moderatorsManageRooms?: boolean;
/** Whether admins can change the server icon. */
adminsManageIcon?: boolean; adminsManageIcon?: boolean;
/** Whether moderators can change the server icon. */
moderatorsManageIcon?: boolean; moderatorsManageIcon?: boolean;
/** Whether voice channels are enabled. */
allowVoice?: boolean; allowVoice?: boolean;
/** Whether screen sharing is enabled. */
allowScreenShare?: boolean; allowScreenShare?: boolean;
/** Whether file uploads are enabled. */
allowFileUploads?: boolean; allowFileUploads?: boolean;
/** Minimum delay (seconds) between messages (0 = disabled). */
slowModeInterval?: number; slowModeInterval?: number;
} }
/**
* A record of a user being banned from a room.
*/
export interface BanEntry { export interface BanEntry {
/** Unique ban identifier (also used as the banned user's oderId). */
oderId: string; oderId: string;
/** The banned user's local ID. */
userId: string; userId: string;
/** The room the ban applies to. */
roomId: string; roomId: string;
/** User ID of the admin who issued the ban. */
bannedBy: string; bannedBy: string;
/** Display name of the banned user at the time of banning. */
displayName?: string; displayName?: string;
/** Human-readable reason for the ban. */
reason?: string; reason?: string;
/** Epoch timestamp (ms) when the ban expires (undefined = permanent). */
expiresAt?: number; expiresAt?: number;
/** Epoch timestamp (ms) when the ban was issued. */
timestamp: number; timestamp: number;
} }
/**
* Tracks the state of a WebRTC peer connection.
*/
export interface PeerConnection { export interface PeerConnection {
/** Remote peer identifier. */
peerId: string; peerId: string;
/** Local user identifier. */
userId: string; userId: string;
/** Current connection lifecycle state. */
status: 'connecting' | 'connected' | 'disconnected' | 'failed'; status: 'connecting' | 'connected' | 'disconnected' | 'failed';
/** The RTCDataChannel used for P2P messaging. */
dataChannel?: RTCDataChannel; dataChannel?: RTCDataChannel;
/** The underlying RTCPeerConnection. */
connection?: RTCPeerConnection; connection?: RTCPeerConnection;
} }
/**
* Real-time voice connection state for a user in a voice channel.
*/
export interface VoiceState { export interface VoiceState {
/** Whether the user is connected to a voice channel. */
isConnected: boolean; isConnected: boolean;
/** Whether the user's microphone is muted (self or by admin). */
isMuted: boolean; isMuted: boolean;
/** Whether the user has deafened themselves. */
isDeafened: boolean; isDeafened: boolean;
/** Whether the user is currently speaking (voice activity detection). */
isSpeaking: boolean; isSpeaking: boolean;
/** Whether the user was server-muted by an admin. */
isMutedByAdmin?: boolean; isMutedByAdmin?: boolean;
/** User's output volume level (0-1). */
volume?: number; volume?: number;
/** The voice channel ID within the server (e.g. 'vc-general'). */
roomId?: string; roomId?: string;
/** The server ID the user is connected to voice in. */
serverId?: string; serverId?: string;
} }
/**
* Real-time screen-sharing state for a user.
*/
export interface ScreenShareState { export interface ScreenShareState {
/** Whether the user is actively sharing their screen. */
isSharing: boolean; isSharing: boolean;
/** MediaStream ID of the screen capture. */
streamId?: string; streamId?: string;
/** Desktop capturer source ID (Electron only). */
sourceId?: string; sourceId?: string;
/** Human-readable name of the captured source. */
sourceName?: string; sourceName?: string;
} }
/** All signaling message types exchanged via the WebSocket relay. */
export type SignalingMessageType = export type SignalingMessageType =
| 'offer' | 'offer'
| 'answer' | 'answer'
@@ -301,23 +151,14 @@ export type SignalingMessageType =
| 'host-change' | 'host-change'
| 'room-update'; | 'room-update';
/**
* A message exchanged via the signaling WebSocket server.
*/
export interface SignalingMessage { export interface SignalingMessage {
/** The type of signaling event. */
type: SignalingMessageType; type: SignalingMessageType;
/** Sender's peer ID. */
from: string; from: string;
/** Optional target peer ID (for directed messages). */
to?: string; to?: string;
/** Arbitrary payload specific to the message type. */
payload: unknown; payload: unknown;
/** Epoch timestamp (ms) when the message was sent. */
timestamp: number; timestamp: number;
} }
/** All P2P chat event types exchanged via RTCDataChannel. */
export type ChatEventType = export type ChatEventType =
| 'message' | 'message'
| 'chat-message' | 'chat-message'
@@ -344,119 +185,61 @@ export type ChatEventType =
| 'role-change' | 'role-change'
| 'channels-update'; | 'channels-update';
/** /** Optional fields depend on `type`. */
* A P2P event exchanged between peers via RTCDataChannel.
* The `type` field determines which optional fields are populated.
*/
export interface ChatEvent { export interface ChatEvent {
/** The type of P2P event. */
type: ChatEventType; type: ChatEventType;
/** Relevant message ID (for edits, deletes, reactions). */
messageId?: string; messageId?: string;
/** Full message payload (for new messages). */
message?: Message; message?: Message;
/** Reaction payload (for reaction events). */
reaction?: Reaction; reaction?: Reaction;
/** Partial message updates (for edits). */
data?: Partial<Message>; data?: Partial<Message>;
/** Event timestamp. */
timestamp?: number; timestamp?: number;
/** Target user ID (for kick/ban). */
targetUserId?: string; targetUserId?: string;
/** Room ID the event pertains to. */
roomId?: string; roomId?: string;
/** Updated room host ID after an ownership change. */
hostId?: string; hostId?: string;
/** Updated room host `oderId` after an ownership change. */
hostOderId?: string; hostOderId?: string;
/** Previous room host ID before the ownership change. */
previousHostId?: string; previousHostId?: string;
/** Previous room host `oderId` before the ownership change. */
previousHostOderId?: string; previousHostOderId?: string;
/** User who issued a kick. */
kickedBy?: string; kickedBy?: string;
/** User who issued a ban. */
bannedBy?: string; bannedBy?: string;
/** Text content (for messages/edits). */
content?: string; content?: string;
/** Edit timestamp. */
editedAt?: number; editedAt?: number;
/** User who performed a delete. */
deletedBy?: string; deletedBy?: string;
/** Network-wide user identifier. */
oderId?: string; oderId?: string;
/** Display name of the event sender. */
displayName?: string; displayName?: string;
/** Emoji character (for reactions). */
emoji?: string; emoji?: string;
/** Ban/kick reason. */
reason?: string; reason?: string;
/** Updated room settings. */
settings?: RoomSettings; settings?: RoomSettings;
/** Partial voice state update. */
voiceState?: Partial<VoiceState>; voiceState?: Partial<VoiceState>;
/** Screen-sharing flag. */
isScreenSharing?: boolean; isScreenSharing?: boolean;
/** New role assignment. */
role?: UserRole; role?: UserRole;
/** Updated channel list. */
channels?: Channel[]; channels?: Channel[];
/** Synced room member roster. */
members?: RoomMember[]; members?: RoomMember[];
} }
/**
* Server listing as returned by the directory API.
*/
export interface ServerInfo { export interface ServerInfo {
/** Unique server identifier. */
id: string; id: string;
/** Display name. */
name: string; name: string;
/** Optional description. */
description?: string; description?: string;
/** Optional topic. */
topic?: string; topic?: string;
/** Display name of the host. */
hostName: string; hostName: string;
/** Owner's user ID. */
ownerId?: string; ownerId?: string;
/** Owner's public key / oderId. */
ownerPublicKey?: string; ownerPublicKey?: string;
/** Current number of connected users. */
userCount: number; userCount: number;
/** Maximum allowed users. */
maxUsers: number; maxUsers: number;
/** Whether a password is required. */
isPrivate: boolean; isPrivate: boolean;
/** Searchable tags. */
tags?: string[]; tags?: string[];
/** Epoch timestamp (ms) when the server was created. */
createdAt: number; createdAt: number;
} }
/**
* Request payload for joining a server.
*/
export interface JoinRequest { export interface JoinRequest {
/** Target room/server ID. */
roomId: string; roomId: string;
/** Requesting user's ID. */
userId: string; userId: string;
/** Requesting user's username. */
username: string; username: string;
} }
/**
* Top-level application state snapshot (used for diagnostics).
*/
export interface AppState { export interface AppState {
/** The currently authenticated user, or null if logged out. */
currentUser: User | null; currentUser: User | null;
/** The room the user is currently viewing, or null. */
currentRoom: Room | null; currentRoom: Room | null;
/** Whether a connection attempt is in progress. */
isConnecting: boolean; isConnecting: boolean;
/** Last error message, or null. */
error: string | null; error: string | null;
} }

View File

@@ -6,7 +6,7 @@ import {
Room, Room,
Reaction, Reaction,
BanEntry BanEntry
} from '../models'; } from '../models/index';
/** IndexedDB database name for the MetoYou application. */ /** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou'; const DATABASE_NAME = 'metoyou';

View File

@@ -10,7 +10,7 @@ import {
Room, Room,
Reaction, Reaction,
BanEntry BanEntry
} from '../models'; } from '../models/index';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
import { BrowserDatabaseService } from './browser-database.service'; import { BrowserDatabaseService } from './browser-database.service';
import { ElectronDatabaseService } from './electron-database.service'; import { ElectronDatabaseService } from './electron-database.service';

View File

@@ -5,7 +5,7 @@ import {
Room, Room,
Reaction, Reaction,
BanEntry BanEntry
} from '../models'; } from '../models/index';
/** CQRS API exposed by the Electron preload script via `contextBridge`. */ /** CQRS API exposed by the Electron preload script via `contextBridge`. */
interface ElectronAPI { interface ElectronAPI {

View File

@@ -1,15 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
/**
* Detects the runtime platform so other services can branch behaviour
* between Electron (desktop) and a plain browser tab.
*/
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PlatformService { export class PlatformService {
/** True when the app is hosted inside an Electron renderer process. */
readonly isElectron: boolean; readonly isElectron: boolean;
/** True when the app is running in an ordinary browser (no Electron shell). */
readonly isBrowser: boolean; readonly isBrowser: boolean;
constructor() { constructor() {

View File

@@ -16,7 +16,7 @@ import {
ServerInfo, ServerInfo,
JoinRequest, JoinRequest,
User User
} from '../models'; } from '../models/index';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
/** /**

View File

@@ -1,38 +1,22 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
/**
* Pages available in the unified settings modal.
* Network & Voice are always visible; server-specific pages
* require an active room and admin role.
*/
export type SettingsPage = 'network' | 'voice' | 'server' | 'members' | 'bans' | 'permissions'; export type SettingsPage = 'network' | 'voice' | 'server' | 'members' | 'bans' | 'permissions';
/**
* Global service controlling the unified settings modal.
* Any component can open the modal to a specific page via `open()`.
*/
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class SettingsModalService { export class SettingsModalService {
/** Whether the modal is currently visible. */
readonly isOpen = signal(false); readonly isOpen = signal(false);
/** The currently active page within the side-nav. */
readonly activePage = signal<SettingsPage>('network'); readonly activePage = signal<SettingsPage>('network');
/** Optional server/room ID to pre-select in admin tabs. */
readonly targetServerId = signal<string | null>(null); readonly targetServerId = signal<string | null>(null);
/** Open the modal to a specific page, optionally targeting a server. */
open(page: SettingsPage = 'network', serverId?: string): void { open(page: SettingsPage = 'network', serverId?: string): void {
this.activePage.set(page); this.activePage.set(page);
this.targetServerId.set(serverId ?? null); this.targetServerId.set(serverId ?? null);
this.isOpen.set(true); this.isOpen.set(true);
} }
/** Close the modal. */
close(): void { close(): void {
this.isOpen.set(false); this.isOpen.set(false);
} }
/** Navigate to a different page while the modal remains open. */
navigate(page: SettingsPage): void { navigate(page: SettingsPage): void {
this.activePage.set(page); this.activePage.set(page);
} }

View File

@@ -26,32 +26,20 @@ import {
} from '@angular/core'; } from '@angular/core';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { WebRTCService } from './webrtc.service'; import { WebRTCService } from './webrtc.service';
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, id-length, max-statements-per-line */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */
/** RMS volume threshold (0-1) above which a user counts as "speaking". */
const SPEAKING_THRESHOLD = 0.015; const SPEAKING_THRESHOLD = 0.015;
/** How many consecutive silent frames before we flip speaking → false. */
const SILENT_FRAME_GRACE = 8; const SILENT_FRAME_GRACE = 8;
/** FFT size for the AnalyserNode (smaller = cheaper). */
const FFT_SIZE = 256; const FFT_SIZE = 256;
/** Internal bookkeeping for a single tracked stream. */
interface TrackedStream { interface TrackedStream {
/** The AudioContext used for analysis (one per stream to avoid cross-origin issues). */
ctx: AudioContext; ctx: AudioContext;
/** Source node wired from the MediaStream. */
source: MediaStreamAudioSourceNode; source: MediaStreamAudioSourceNode;
/** Analyser node that provides time-domain data. */
analyser: AnalyserNode; analyser: AnalyserNode;
/** Reusable buffer for `getByteTimeDomainData`. */
dataArray: Uint8Array<ArrayBuffer>; dataArray: Uint8Array<ArrayBuffer>;
/** Writable signal for the normalised volume (0-1). */
volumeSignal: ReturnType<typeof signal<number>>; volumeSignal: ReturnType<typeof signal<number>>;
/** Writable signal for speaking state. */
speakingSignal: ReturnType<typeof signal<boolean>>; speakingSignal: ReturnType<typeof signal<boolean>>;
/** Counter of consecutive silent frames. */
silentFrames: number; silentFrames: number;
/** The MediaStream being analysed (for identity checks). */
stream: MediaStream; stream: MediaStream;
} }
@@ -59,23 +47,14 @@ interface TrackedStream {
export class VoiceActivityService implements OnDestroy { export class VoiceActivityService implements OnDestroy {
private readonly webrtc = inject(WebRTCService); private readonly webrtc = inject(WebRTCService);
/** All tracked streams keyed by user/peer ID. */
private readonly tracked = new Map<string, TrackedStream>(); private readonly tracked = new Map<string, TrackedStream>();
/** Animation frame handle. */
private animFrameId: number | null = null; private animFrameId: number | null = null;
/** RxJS subscriptions managed by this service. */
private readonly subs: Subscription[] = []; private readonly subs: Subscription[] = [];
/** Exposed map: userId → speaking (reactive snapshot). */
private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map()); private readonly _speakingMap = signal<ReadonlyMap<string, boolean>>(new Map());
/** Reactive snapshot of all speaking users (for debugging / bulk consumption). */
readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap; readonly speakingMap: Signal<ReadonlyMap<string, boolean>> = this._speakingMap;
constructor() { constructor() {
// Wire up remote stream events
this.subs.push( this.subs.push(
this.webrtc.onRemoteStream.subscribe(({ peerId, stream }) => { this.webrtc.onRemoteStream.subscribe(({ peerId, stream }) => {
this.trackStream(peerId, stream); this.trackStream(peerId, stream);
@@ -89,50 +68,23 @@ export class VoiceActivityService implements OnDestroy {
); );
} }
// ── Public API ──────────────────────────────────────────────────
/**
* Start monitoring the current user's local microphone stream.
* Should be called after voice is enabled (mic captured).
*
* @param userId - The local user's ID (used as the key in the speaking map).
* @param stream - The local {@link MediaStream} from `getUserMedia`.
*/
trackLocalMic(userId: string, stream: MediaStream): void { trackLocalMic(userId: string, stream: MediaStream): void {
this.trackStream(userId, stream); this.trackStream(userId, stream);
} }
/**
* Stop monitoring the current user's local microphone.
*
* @param userId - The local user's ID.
*/
untrackLocalMic(userId: string): void { untrackLocalMic(userId: string): void {
this.untrackStream(userId); this.untrackStream(userId);
} }
/**
* Returns a read-only signal that is `true` when the given user
* is currently speaking (audio level above threshold).
*
* If the user is not tracked yet, the returned signal starts as
* `false` and will become reactive once a stream is tracked.
*/
isSpeaking(userId: string): Signal<boolean> { isSpeaking(userId: string): Signal<boolean> {
const entry = this.tracked.get(userId); const entry = this.tracked.get(userId);
if (entry) if (entry)
return entry.speakingSignal.asReadonly(); return entry.speakingSignal.asReadonly();
// Return a computed that re-checks the map so it becomes live
// once the stream is tracked.
return computed(() => this._speakingMap().get(userId) ?? false); return computed(() => this._speakingMap().get(userId) ?? false);
} }
/**
* Returns a read-only signal with the normalised (0-1) volume
* for the given user.
*/
volume(userId: string): Signal<number> { volume(userId: string): Signal<number> {
const entry = this.tracked.get(userId); const entry = this.tracked.get(userId);
@@ -142,21 +94,12 @@ export class VoiceActivityService implements OnDestroy {
return computed(() => 0); return computed(() => 0);
} }
// ── Stream tracking ─────────────────────────────────────────────
/**
* Begin analysing a {@link MediaStream} for audio activity.
*
* If a stream is already tracked for `id`, it is replaced.
*/
trackStream(id: string, stream: MediaStream): void { trackStream(id: string, stream: MediaStream): void {
// If we already track this exact stream, skip.
const existing = this.tracked.get(id); const existing = this.tracked.get(id);
if (existing && existing.stream === stream) if (existing && existing.stream === stream)
return; return;
// Clean up any previous entry for this id.
if (existing) if (existing)
this.disposeEntry(existing); this.disposeEntry(existing);
@@ -165,10 +108,7 @@ export class VoiceActivityService implements OnDestroy {
const analyser = ctx.createAnalyser(); const analyser = ctx.createAnalyser();
analyser.fftSize = FFT_SIZE; analyser.fftSize = FFT_SIZE;
source.connect(analyser); source.connect(analyser);
// Do NOT connect analyser to ctx.destination - we don't want to
// double-play audio; playback is handled elsewhere.
const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>; const dataArray = new Uint8Array(analyser.fftSize) as Uint8Array<ArrayBuffer>;
const volumeSignal = signal(0); const volumeSignal = signal(0);
@@ -185,11 +125,9 @@ export class VoiceActivityService implements OnDestroy {
stream stream
}); });
// Ensure the poll loop is running.
this.ensurePolling(); this.ensurePolling();
} }
/** Stop tracking and dispose resources for a given ID. */
untrackStream(id: string): void { untrackStream(id: string): void {
const entry = this.tracked.get(id); const entry = this.tracked.get(id);
@@ -200,13 +138,10 @@ export class VoiceActivityService implements OnDestroy {
this.tracked.delete(id); this.tracked.delete(id);
this.publishSpeakingMap(); this.publishSpeakingMap();
// Stop polling when nothing is tracked.
if (this.tracked.size === 0) if (this.tracked.size === 0)
this.stopPolling(); this.stopPolling();
} }
// ── Polling loop ────────────────────────────────────────────────
private ensurePolling(): void { private ensurePolling(): void {
if (this.animFrameId !== null) if (this.animFrameId !== null)
return; return;
@@ -221,10 +156,6 @@ export class VoiceActivityService implements OnDestroy {
} }
} }
/**
* Single `requestAnimationFrame`-based loop that reads audio levels
* from every tracked analyser and updates signals accordingly.
*/
private poll = (): void => { private poll = (): void => {
let mapDirty = false; let mapDirty = false;
@@ -233,7 +164,6 @@ export class VoiceActivityService implements OnDestroy {
analyser.getByteTimeDomainData(dataArray); analyser.getByteTimeDomainData(dataArray);
// Compute RMS volume from time-domain data (values 0-255, centred at 128).
let sumSquares = 0; let sumSquares = 0;
for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) { for (let sampleIndex = 0; sampleIndex < dataArray.length; sampleIndex++) {
@@ -271,7 +201,6 @@ export class VoiceActivityService implements OnDestroy {
this.animFrameId = requestAnimationFrame(this.poll); this.animFrameId = requestAnimationFrame(this.poll);
}; };
/** Rebuild the public speaking-map signal from current entries. */
private publishSpeakingMap(): void { private publishSpeakingMap(): void {
const map = new Map<string, boolean>(); const map = new Map<string, boolean>();
@@ -282,8 +211,6 @@ export class VoiceActivityService implements OnDestroy {
this._speakingMap.set(map); this._speakingMap.set(map);
} }
// ── Cleanup ─────────────────────────────────────────────────────
private disposeEntry(entry: TrackedStream): void { private disposeEntry(entry: TrackedStream): void {
try { entry.source.disconnect(); } catch { /* already disconnected */ } try { entry.source.disconnect(); } catch { /* already disconnected */ }
@@ -294,6 +221,6 @@ export class VoiceActivityService implements OnDestroy {
this.stopPolling(); this.stopPolling();
this.tracked.forEach((entry) => this.disposeEntry(entry)); this.tracked.forEach((entry) => this.disposeEntry(entry));
this.tracked.clear(); this.tracked.clear();
this.subs.forEach((s) => s.unsubscribe()); this.subs.forEach((subscription) => subscription.unsubscribe());
} }
} }

View File

@@ -21,7 +21,7 @@ import {
} from '@angular/core'; } from '@angular/core';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { SignalingMessage, ChatEvent } from '../models'; import { SignalingMessage, ChatEvent } from '../models/index';
import { TimeSyncService } from './time-sync.service'; import { TimeSyncService } from './time-sync.service';
import { import {

View File

@@ -29,7 +29,7 @@ import {
selectCurrentUser, selectCurrentUser,
selectOnlineUsers selectOnlineUsers
} from '../../../store/users/users.selectors'; } from '../../../store/users/users.selectors';
import { BanEntry, User } from '../../../core/models'; import { BanEntry, User } from '../../../core/models/index';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';

View File

@@ -14,7 +14,7 @@ import { lucideLogIn } from '@ng-icons/lucide';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { ServerDirectoryService } from '../../../core/services/server-directory.service'; import { ServerDirectoryService } from '../../../core/services/server-directory.service';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { User } from '../../../core/models'; import { User } from '../../../core/models/index';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({ @Component({

View File

@@ -14,7 +14,7 @@ import { lucideUserPlus } from '@ng-icons/lucide';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { ServerDirectoryService } from '../../../core/services/server-directory.service'; import { ServerDirectoryService } from '../../../core/services/server-directory.service';
import { UsersActions } from '../../../store/users/users.actions'; import { UsersActions } from '../../../store/users/users.actions';
import { User } from '../../../core/models'; import { User } from '../../../core/models/index';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
@Component({ @Component({

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, id-denylist, max-statements-per-line, @typescript-eslint/prefer-for-of */
import { import {
Component, Component,
inject, inject,
@@ -45,7 +45,7 @@ import {
} from '../../../store/messages/messages.selectors'; } from '../../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
import { Message } from '../../../core/models'; import { Message } from '../../../core/models/index';
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
import { import {
ChatAudioPlayerComponent, ChatAudioPlayerComponent,
@@ -112,10 +112,6 @@ const COMMON_EMOJIS = [
'(document:keyup)': 'onDocKeyup($event)' '(document:keyup)': 'onDocKeyup($event)'
} }
}) })
/**
* Real-time chat messages view with infinite scroll, markdown rendering,
* emoji reactions, file attachments, and image lightbox support.
*/
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy { export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
@ViewChild('messagesContainer') messagesContainer!: ElementRef; @ViewChild('messagesContainer') messagesContainer!: ElementRef;
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>; @ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
@@ -128,7 +124,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private cdr = inject(ChangeDetectorRef); private cdr = inject(ChangeDetectorRef);
private markdown = inject(ChatMarkdownService); private markdown = inject(ChatMarkdownService);
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
remarkProcessor = unified() remarkProcessor = unified()
.use(remarkParse) .use(remarkParse)
.use(remarkGfm) .use(remarkGfm)
@@ -137,12 +132,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private allMessages = this.store.selectSignal(selectAllMessages); private allMessages = this.store.selectSignal(selectAllMessages);
private activeChannelId = this.store.selectSignal(selectActiveChannelId); private activeChannelId = this.store.selectSignal(selectActiveChannelId);
// --- Infinite scroll (upwards) pagination ---
private readonly PAGE_SIZE = 50; private readonly PAGE_SIZE = 50;
displayLimit = signal(this.PAGE_SIZE); displayLimit = signal(this.PAGE_SIZE);
loadingMore = signal(false); loadingMore = signal(false);
/** All messages for the current channel (full list, unsliced) */
private allChannelMessages = computed(() => { private allChannelMessages = computed(() => {
const channelId = this.activeChannelId(); const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id; const roomId = this.currentRoom()?.id;
@@ -152,7 +145,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
); );
}); });
/** Paginated view - only the most recent `displayLimit` messages */
messages = computed(() => { messages = computed(() => {
const all = this.allChannelMessages(); const all = this.allChannelMessages();
const limit = this.displayLimit(); const limit = this.displayLimit();
@@ -163,7 +155,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return all.slice(all.length - limit); return all.slice(all.length - limit);
}); });
/** Whether there are older messages that can be loaded */
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit()); hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
loading = this.store.selectSignal(selectMessagesLoading); loading = this.store.selectSignal(selectMessagesLoading);
syncing = this.store.selectSignal(selectMessagesSyncing); syncing = this.store.selectSignal(selectMessagesSyncing);
@@ -192,7 +183,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private lastMessageCount = 0; private lastMessageCount = 0;
private initialScrollPending = true; private initialScrollPending = true;
pendingFiles: File[] = []; pendingFiles: File[] = [];
// New messages snackbar state
showNewMessagesBar = signal(false); showNewMessagesBar = signal(false);
// Plain (non-reactive) reference time used only by formatTimestamp. // Plain (non-reactive) reference time used only by formatTimestamp.
// Updated periodically but NOT a signal, so it won't re-render every message. // Updated periodically but NOT a signal, so it won't re-render every message.
@@ -208,9 +198,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null; private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
private boundCtrlUp: ((e: KeyboardEvent) => void) | null = null; private boundCtrlUp: ((e: KeyboardEvent) => void) | null = null;
// Image lightbox modal state
lightboxAttachment = signal<Attachment | null>(null); lightboxAttachment = signal<Attachment | null>(null);
// Image right-click context menu state
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null); imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
private boundOnKeydown: ((event: KeyboardEvent) => void) | null = null; private boundOnKeydown: ((event: KeyboardEvent) => void) | null = null;
@@ -224,7 +212,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.displayLimit.set(this.PAGE_SIZE); this.displayLimit.set(this.PAGE_SIZE);
}); });
// Reset pagination when switching channels within the same room
private onChannelChanged = effect(() => { private onChannelChanged = effect(() => {
void this.activeChannelId(); // track channel signal void this.activeChannelId(); // track channel signal
this.displayLimit.set(this.PAGE_SIZE); this.displayLimit.set(this.PAGE_SIZE);
@@ -232,13 +219,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
this.lastMessageCount = 0; this.lastMessageCount = 0;
}); });
// Re-render when attachments update (e.g. download progress from WebRTC callbacks)
private attachmentsUpdatedEffect = effect(() => { private attachmentsUpdatedEffect = effect(() => {
void this.attachmentsSvc.updated(); void this.attachmentsSvc.updated();
this.cdr.markForCheck(); this.cdr.markForCheck();
}); });
// Track total channel messages (not paginated) for new-message detection
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length); private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
messagesLength = computed(() => this.messages().length); messagesLength = computed(() => this.messages().length);
private onMessagesChanged = effect(() => { private onMessagesChanged = effect(() => {
@@ -359,7 +345,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.pendingKlipyGif.set(null); this.pendingKlipyGif.set(null);
this.clearReply(); this.clearReply();
this.shouldScrollToBottom = true; this.shouldScrollToBottom = true;
// Reset textarea height after sending
requestAnimationFrame(() => this.autoResizeTextarea()); requestAnimationFrame(() => this.autoResizeTextarea());
this.showNewMessagesBar.set(false); this.showNewMessagesBar.set(false);
@@ -369,11 +354,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} }
} }
/** Throttle and broadcast a typing indicator when the user types. */
onInputChange(): void { onInputChange(): void {
const now = Date.now(); const now = Date.now();
if (now - this.lastTypingSentAt > 1000) { // throttle typing events if (now - this.lastTypingSentAt > 1000) {
try { try {
this.webrtc.sendRawMessage({ type: 'typing' }); this.webrtc.sendRawMessage({ type: 'typing' });
this.lastTypingSentAt = now; this.lastTypingSentAt = now;
@@ -381,13 +365,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} }
} }
/** Begin editing an existing message, populating the edit input. */
startEdit(message: Message): void { startEdit(message: Message): void {
this.editingMessageId.set(message.id); this.editingMessageId.set(message.id);
this.editContent = message.content; this.editContent = message.content;
} }
/** Save the edited message content and exit edit mode. */
saveEdit(messageId: string): void { saveEdit(messageId: string): void {
if (!this.editContent.trim()) if (!this.editContent.trim())
return; return;
@@ -402,13 +384,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.cancelEdit(); this.cancelEdit();
} }
/** Cancel the current edit and clear the edit state. */
cancelEdit(): void { cancelEdit(): void {
this.editingMessageId.set(null); this.editingMessageId.set(null);
this.editContent = ''; this.editContent = '';
} }
/** Delete a message (own or admin-delete if the user has admin privileges). */
deleteMessage(message: Message): void { deleteMessage(message: Message): void {
if (this.isOwnMessage(message)) { if (this.isOwnMessage(message)) {
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id })); this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
@@ -417,22 +397,18 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} }
} }
/** Set the message to reply to. */
setReplyTo(message: Message): void { setReplyTo(message: Message): void {
this.replyTo.set(message); this.replyTo.set(message);
} }
/** Clear the current reply-to reference. */
clearReply(): void { clearReply(): void {
this.replyTo.set(null); this.replyTo.set(null);
} }
/** Find the original message that a reply references. */
getRepliedMessage(messageId: string): Message | undefined { getRepliedMessage(messageId: string): Message | undefined {
return this.allMessages().find(message => message.id === messageId); return this.allMessages().find(message => message.id === messageId);
} }
/** Smooth-scroll to a specific message element and briefly highlight it. */
scrollToMessage(messageId: string): void { scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement; const container = this.messagesContainer?.nativeElement;
@@ -450,14 +426,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} }
} }
/** Toggle the emoji picker for a message. */
toggleEmojiPicker(messageId: string): void { toggleEmojiPicker(messageId: string): void {
this.showEmojiPicker.update((current) => this.showEmojiPicker.update((current) =>
current === messageId ? null : messageId current === messageId ? null : messageId
); );
} }
/** Add a reaction emoji to a message. */
addReaction(messageId: string, emoji: string): void { addReaction(messageId: string, emoji: string): void {
this.store.dispatch(MessagesActions.addReaction({ messageId, this.store.dispatch(MessagesActions.addReaction({ messageId,
emoji })); emoji }));
@@ -465,7 +439,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.showEmojiPicker.set(null); this.showEmojiPicker.set(null);
} }
/** Toggle the reaction for the current user on a message. */
toggleReaction(messageId: string, emoji: string): void { toggleReaction(messageId: string, emoji: string): void {
const message = this.messages().find((msg) => msg.id === messageId); const message = this.messages().find((msg) => msg.id === messageId);
const currentUserId = this.currentUser()?.id; const currentUserId = this.currentUser()?.id;
@@ -486,12 +459,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} }
} }
/** Check whether a message was sent by the current user. */
isOwnMessage(message: Message): boolean { isOwnMessage(message: Message): boolean {
return message.senderId === this.currentUser()?.id; return message.senderId === this.currentUser()?.id;
} }
/** Aggregate reactions by emoji, returning counts and whether the current user reacted. */
getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] { getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] {
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>(); const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
const currentUserId = this.currentUser()?.id; const currentUserId = this.currentUser()?.id;
@@ -512,7 +483,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
})); }));
} }
/** Format a timestamp as a relative or absolute time string. */
formatTimestamp(timestamp: number): string { formatTimestamp(timestamp: number): string {
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(this.nowRef); const now = new Date(this.nowRef);
@@ -964,12 +934,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return droppedFiles; return droppedFiles;
} }
/** Return all file attachments associated with a message. */
getAttachments(messageId: string): Attachment[] { getAttachments(messageId: string): Attachment[] {
return this.attachmentsSvc.getForMessage(messageId); return this.attachmentsSvc.getForMessage(messageId);
} }
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
formatBytes(bytes: number): string { formatBytes(bytes: number): string {
const units = [ const units = [
'B', 'B',
@@ -986,7 +954,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return `${size.toFixed(1)} ${units[i]}`; return `${size.toFixed(1)} ${units[i]}`;
} }
/** Format a transfer speed in bytes/second to a human-readable string. */
formatSpeed(bps?: number): string { formatSpeed(bps?: number): string {
if (!bps || bps <= 0) if (!bps || bps <= 0)
return '0 B/s'; return '0 B/s';
@@ -1006,23 +973,19 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`; return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
} }
/** Whether an attachment can be played inline as video. */
isVideoAttachment(att: Attachment): boolean { isVideoAttachment(att: Attachment): boolean {
return att.mime.startsWith('video/'); return att.mime.startsWith('video/');
} }
/** Whether an attachment can be played inline as audio. */
isAudioAttachment(att: Attachment): boolean { isAudioAttachment(att: Attachment): boolean {
return att.mime.startsWith('audio/'); return att.mime.startsWith('audio/');
} }
/** Whether the user must explicitly accept a media download before playback. */
requiresMediaDownloadAcceptance(att: Attachment): boolean { requiresMediaDownloadAcceptance(att: Attachment): boolean {
return (this.isVideoAttachment(att) || this.isAudioAttachment(att)) && return (this.isVideoAttachment(att) || this.isAudioAttachment(att)) &&
att.size > MAX_AUTO_SAVE_SIZE_BYTES; att.size > MAX_AUTO_SAVE_SIZE_BYTES;
} }
/** User-facing status copy for an unavailable audio/video attachment. */
getMediaAttachmentStatusText(att: Attachment): string { getMediaAttachmentStatusText(att: Attachment): string {
if (att.requestError) if (att.requestError)
return att.requestError; return att.requestError;
@@ -1038,7 +1001,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
: 'Waiting for audio source…'; : 'Waiting for audio source…';
} }
/** Action label for requesting an audio/video attachment. */
getMediaAttachmentActionLabel(att: Attachment): string { getMediaAttachmentActionLabel(att: Attachment): string {
if (this.requiresMediaDownloadAcceptance(att)) { if (this.requiresMediaDownloadAcceptance(att)) {
return att.requestError ? 'Retry download' : 'Accept download'; return att.requestError ? 'Retry download' : 'Accept download';
@@ -1047,7 +1009,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return att.requestError ? 'Retry' : 'Request'; return att.requestError ? 'Retry' : 'Request';
} }
/** Remove a pending file from the upload queue. */
removePendingFile(file: File): void { removePendingFile(file: File): void {
const idx = this.pendingFiles.findIndex((pending) => pending === file); const idx = this.pendingFiles.findIndex((pending) => pending === file);
@@ -1056,7 +1017,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} }
} }
/** Download a completed attachment to the user's device. */
async downloadAttachment(att: Attachment): Promise<void> { async downloadAttachment(att: Attachment): Promise<void> {
if (!att.available || !att.objectUrl) if (!att.available || !att.objectUrl)
return; return;
@@ -1126,38 +1086,30 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}); });
} }
/** Request a file attachment to be transferred from the uploader peer. */
requestAttachment(att: Attachment, messageId: string): void { requestAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.requestFile(messageId, att); this.attachmentsSvc.requestFile(messageId, att);
} }
/** Cancel an in-progress attachment transfer request. */
cancelAttachment(att: Attachment, messageId: string): void { cancelAttachment(att: Attachment, messageId: string): void {
this.attachmentsSvc.cancelRequest(messageId, att); this.attachmentsSvc.cancelRequest(messageId, att);
} }
/** Check whether the current user is the original uploader of an attachment. */
isUploader(att: Attachment): boolean { isUploader(att: Attachment): boolean {
const myUserId = this.currentUser()?.id; const myUserId = this.currentUser()?.id;
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId; return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
} }
/** Open the image lightbox for a completed image attachment. */
// ---- Image lightbox ----
openLightbox(att: Attachment): void { openLightbox(att: Attachment): void {
if (att.available && att.objectUrl) { if (att.available && att.objectUrl) {
this.lightboxAttachment.set(att); this.lightboxAttachment.set(att);
} }
} }
/** Close the image lightbox. */
closeLightbox(): void { closeLightbox(): void {
this.lightboxAttachment.set(null); this.lightboxAttachment.set(null);
} }
/** Open a context menu on right-click of an image attachment. */
// ---- Image context menu ----
openImageContextMenu(event: MouseEvent, att: Attachment): void { openImageContextMenu(event: MouseEvent, att: Attachment): void {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -1166,12 +1118,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
attachment: att }); attachment: att });
} }
/** Close the image context menu. */
closeImageContextMenu(): void { closeImageContextMenu(): void {
this.imageContextMenu.set(null); this.imageContextMenu.set(null);
} }
/** Copy an image attachment to the system clipboard as PNG. */
async copyImageToClipboard(att: Attachment): Promise<void> { async copyImageToClipboard(att: Attachment): Promise<void> {
this.closeImageContextMenu(); this.closeImageContextMenu();
@@ -1185,9 +1135,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
const pngBlob = await this.convertToPng(blob); const pngBlob = await this.convertToPng(blob);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]); await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
} catch (_error) { } catch {}
// Failed to copy image to clipboard
}
} }
private convertToPng(blob: Blob): Promise<Blob> { private convertToPng(blob: Blob): Promise<Blob> {
@@ -1269,7 +1217,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
return `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`; return `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
} }
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
autoResizeTextarea(): void { autoResizeTextarea(): void {
const el = this.messageInputRef?.nativeElement; const el = this.messageInputRef?.nativeElement;
@@ -1282,7 +1229,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
this.updateScrollPadding(); this.updateScrollPadding();
} }
/** Keep scroll container bottom-padding in sync with the floating bottom bar height. */
private updateScrollPadding(): void { private updateScrollPadding(): void {
requestAnimationFrame(() => { requestAnimationFrame(() => {
const bar = this.bottomBar?.nativeElement; const bar = this.bottomBar?.nativeElement;
@@ -1295,12 +1241,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}); });
} }
/** Show the markdown toolbar when the input gains focus. */
onInputFocus(): void { onInputFocus(): void {
this.toolbarVisible.set(true); this.toolbarVisible.set(true);
} }
/** Hide the markdown toolbar after a brief delay when the input loses focus. */
onInputBlur(): void { onInputBlur(): void {
setTimeout(() => { setTimeout(() => {
if (!this.toolbarHovering) { if (!this.toolbarHovering) {
@@ -1309,12 +1253,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
}, 150); }, 150);
} }
/** Track mouse entry on the toolbar to prevent premature hiding. */
onToolbarMouseEnter(): void { onToolbarMouseEnter(): void {
this.toolbarHovering = true; this.toolbarHovering = true;
} }
/** Track mouse leave on the toolbar; hide if input is not focused. */
onToolbarMouseLeave(): void { onToolbarMouseLeave(): void {
this.toolbarHovering = false; this.toolbarHovering = false;
@@ -1323,19 +1265,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
} }
} }
/** Handle Ctrl key down for enabling manual resize. */
onDocKeydown(event: KeyboardEvent): void { onDocKeydown(event: KeyboardEvent): void {
if (event.key === 'Control') if (event.key === 'Control')
this.ctrlHeld.set(true); this.ctrlHeld.set(true);
} }
/** Handle Ctrl key up for disabling manual resize. */
onDocKeyup(event: KeyboardEvent): void { onDocKeyup(event: KeyboardEvent): void {
if (event.key === 'Control') if (event.key === 'Control')
this.ctrlHeld.set(false); this.ctrlHeld.set(false);
} }
/** Scroll to the newest message and dismiss the new-messages snackbar. */
readLatest(): void { readLatest(): void {
this.shouldScrollToBottom = true; this.shouldScrollToBottom = true;
this.scrollToBottomSmooth(); this.scrollToBottomSmooth();

View File

@@ -28,7 +28,7 @@ import {
selectCurrentUser, selectCurrentUser,
selectIsCurrentUserAdmin selectIsCurrentUserAdmin
} from '../../../store/users/users.selectors'; } from '../../../store/users/users.selectors';
import { User } from '../../../core/models'; import { User } from '../../../core/models/index';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
@Component({ @Component({

View File

@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/member-ordering */
import { import {
Component, Component,
inject, inject,
@@ -51,7 +51,7 @@ import {
RoomMember, RoomMember,
Room, Room,
User User
} from '../../../core/models'; } from '../../../core/models/index';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
type TabView = 'channels' | 'users'; type TabView = 'channels' | 'users';
@@ -84,9 +84,6 @@ type TabView = 'channels' | 'users';
], ],
templateUrl: './rooms-side-panel.component.html' templateUrl: './rooms-side-panel.component.html'
}) })
/**
* Side panel listing text and voice channels, online users, and channel management actions.
*/
export class RoomsSidePanelComponent { export class RoomsSidePanelComponent {
private store = inject(Store); private store = inject(Store);
private webrtc = inject(WebRTCService); private webrtc = inject(WebRTCService);
@@ -129,35 +126,28 @@ export class RoomsSidePanelComponent {
return memberIds.size; return memberIds.size;
}); });
// Channel context menu state
showChannelMenu = signal(false); showChannelMenu = signal(false);
channelMenuX = signal(0); channelMenuX = signal(0);
channelMenuY = signal(0); channelMenuY = signal(0);
contextChannel = signal<Channel | null>(null); contextChannel = signal<Channel | null>(null);
// Rename state
renamingChannelId = signal<string | null>(null); renamingChannelId = signal<string | null>(null);
// Create channel dialog state
showCreateChannelDialog = signal(false); showCreateChannelDialog = signal(false);
createChannelType = signal<'text' | 'voice'>('text'); createChannelType = signal<'text' | 'voice'>('text');
newChannelName = ''; newChannelName = '';
// User context menu state
showUserMenu = signal(false); showUserMenu = signal(false);
userMenuX = signal(0); userMenuX = signal(0);
userMenuY = signal(0); userMenuY = signal(0);
contextMenuUser = signal<User | null>(null); contextMenuUser = signal<User | null>(null);
// Per-user volume context menu state
showVolumeMenu = signal(false); showVolumeMenu = signal(false);
volumeMenuX = signal(0); volumeMenuX = signal(0);
volumeMenuY = signal(0); volumeMenuY = signal(0);
volumeMenuPeerId = signal(''); volumeMenuPeerId = signal('');
volumeMenuDisplayName = signal(''); volumeMenuDisplayName = signal('');
/** Return online users excluding the current user. */
// Filter out current user from online users list
onlineUsersFiltered() { onlineUsersFiltered() {
const current = this.currentUser(); const current = this.currentUser();
const currentId = current?.id; const currentId = current?.id;
@@ -170,7 +160,6 @@ export class RoomsSidePanelComponent {
return member.oderId || member.id; return member.oderId || member.id;
} }
/** Check whether the current user has permission to manage channels. */
canManageChannels(): boolean { canManageChannels(): boolean {
const room = this.currentRoom(); const room = this.currentRoom();
const user = this.currentUser(); const user = this.currentUser();
@@ -178,7 +167,6 @@ export class RoomsSidePanelComponent {
if (!room || !user) if (!room || !user)
return false; return false;
// Owner always can
if (room.hostId === user.id) if (room.hostId === user.id)
return true; return true;
@@ -193,17 +181,13 @@ export class RoomsSidePanelComponent {
return false; return false;
} }
/** Select a text channel (no-op if currently renaming). */
// ---- Text channel selection ----
selectTextChannel(channelId: string) { selectTextChannel(channelId: string) {
if (this.renamingChannelId()) if (this.renamingChannelId())
return; // don't switch while renaming return;
this.store.dispatch(RoomsActions.selectChannel({ channelId })); this.store.dispatch(RoomsActions.selectChannel({ channelId }));
} }
/** Open the context menu for a channel at the cursor position. */
// ---- Channel context menu ----
openChannelContextMenu(evt: MouseEvent, channel: Channel) { openChannelContextMenu(evt: MouseEvent, channel: Channel) {
evt.preventDefault(); evt.preventDefault();
this.contextChannel.set(channel); this.contextChannel.set(channel);
@@ -212,12 +196,10 @@ export class RoomsSidePanelComponent {
this.showChannelMenu.set(true); this.showChannelMenu.set(true);
} }
/** Close the channel context menu. */
closeChannelMenu() { closeChannelMenu() {
this.showChannelMenu.set(false); this.showChannelMenu.set(false);
} }
/** Begin inline renaming of the context-menu channel. */
startRename() { startRename() {
const ch = this.contextChannel(); const ch = this.contextChannel();
@@ -228,7 +210,6 @@ export class RoomsSidePanelComponent {
} }
} }
/** Commit the channel rename from the inline input value. */
confirmRename(event: Event) { confirmRename(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
const name = input.value.trim(); const name = input.value.trim();
@@ -241,12 +222,10 @@ export class RoomsSidePanelComponent {
this.renamingChannelId.set(null); this.renamingChannelId.set(null);
} }
/** Cancel the inline rename operation. */
cancelRename() { cancelRename() {
this.renamingChannelId.set(null); this.renamingChannelId.set(null);
} }
/** Delete the context-menu channel. */
deleteChannel() { deleteChannel() {
const ch = this.contextChannel(); const ch = this.contextChannel();
@@ -257,7 +236,6 @@ export class RoomsSidePanelComponent {
} }
} }
/** Trigger a message inventory re-sync from all connected peers. */
resyncMessages() { resyncMessages() {
this.closeChannelMenu(); this.closeChannelMenu();
const room = this.currentRoom(); const room = this.currentRoom();
@@ -266,36 +244,26 @@ export class RoomsSidePanelComponent {
return; return;
} }
// Dispatch startSync for UI spinner
this.store.dispatch(MessagesActions.startSync()); this.store.dispatch(MessagesActions.startSync());
// Request inventory from all connected peers
const peers = this.webrtc.getConnectedPeers(); const peers = this.webrtc.getConnectedPeers();
if (peers.length === 0) {
// No connected peers - sync will time out
}
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id }; const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
peers.forEach((pid) => { peers.forEach((pid) => {
try { try {
this.webrtc.sendToPeer(pid, inventoryRequest); this.webrtc.sendToPeer(pid, inventoryRequest);
} catch (_error) { } catch {
// Failed to send inventory request to this peer return;
} }
}); });
} }
/** Open the create-channel dialog for the given channel type. */
// ---- Create channel ----
createChannel(type: 'text' | 'voice') { createChannel(type: 'text' | 'voice') {
this.createChannelType.set(type); this.createChannelType.set(type);
this.newChannelName = ''; this.newChannelName = '';
this.showCreateChannelDialog.set(true); this.showCreateChannelDialog.set(true);
} }
/** Confirm channel creation and dispatch the add-channel action. */
confirmCreateChannel() { confirmCreateChannel() {
const name = this.newChannelName.trim(); const name = this.newChannelName.trim();
@@ -315,13 +283,10 @@ export class RoomsSidePanelComponent {
this.showCreateChannelDialog.set(false); this.showCreateChannelDialog.set(false);
} }
/** Cancel channel creation and close the dialog. */
cancelCreateChannel() { cancelCreateChannel() {
this.showCreateChannelDialog.set(false); this.showCreateChannelDialog.set(false);
} }
/** Open the user context menu for admin actions (kick/role change). */
// ---- User context menu (kick/role) ----
openUserContextMenu(evt: MouseEvent, user: User) { openUserContextMenu(evt: MouseEvent, user: User) {
evt.preventDefault(); evt.preventDefault();
@@ -334,16 +299,12 @@ export class RoomsSidePanelComponent {
this.showUserMenu.set(true); this.showUserMenu.set(true);
} }
/** Close the user context menu. */
closeUserMenu() { closeUserMenu() {
this.showUserMenu.set(false); this.showUserMenu.set(false);
} }
/** Open the per-user volume context menu for a voice channel participant. */
openVoiceUserVolumeMenu(evt: MouseEvent, user: User) { openVoiceUserVolumeMenu(evt: MouseEvent, user: User) {
evt.preventDefault(); evt.preventDefault();
// Don't show volume menu for the local user
const me = this.currentUser(); const me = this.currentUser();
if (user.id === me?.id || user.oderId === me?.oderId) if (user.id === me?.id || user.oderId === me?.oderId)
@@ -356,7 +317,6 @@ export class RoomsSidePanelComponent {
this.showVolumeMenu.set(true); this.showVolumeMenu.set(true);
} }
/** Change a user's role and broadcast the update to connected peers. */
changeUserRole(role: 'admin' | 'moderator' | 'member') { changeUserRole(role: 'admin' | 'moderator' | 'member') {
const user = this.contextMenuUser(); const user = this.contextMenuUser();
const roomId = this.currentRoom()?.id; const roomId = this.currentRoom()?.id;
@@ -365,7 +325,6 @@ export class RoomsSidePanelComponent {
if (user) { if (user) {
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
// Broadcast role change to peers
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'role-change', type: 'role-change',
roomId, roomId,
@@ -375,7 +334,6 @@ export class RoomsSidePanelComponent {
} }
} }
/** Kick a user and broadcast the action to peers. */
kickUserAction() { kickUserAction() {
const user = this.contextMenuUser(); const user = this.contextMenuUser();
@@ -383,7 +341,6 @@ export class RoomsSidePanelComponent {
if (user) { if (user) {
this.store.dispatch(UsersActions.kickUser({ userId: user.id })); this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
// Broadcast kick to peers
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'kick', type: 'kick',
targetUserId: user.id, targetUserId: user.id,
@@ -392,14 +349,10 @@ export class RoomsSidePanelComponent {
} }
} }
/** Join a voice channel, managing permissions and existing voice connections. */
// ---- Voice ----
joinVoice(roomId: string) { joinVoice(roomId: string) {
// Gate by room permissions
const room = this.currentRoom(); const room = this.currentRoom();
if (room && room.permissions && room.permissions.allowVoice === false) { if (room && room.permissions && room.permissions.allowVoice === false) {
// Voice is disabled by room permissions
return; return;
} }
@@ -408,12 +361,8 @@ export class RoomsSidePanelComponent {
const current = this.currentUser(); const current = this.currentUser();
// Check if already connected to voice in a DIFFERENT server - must disconnect first
// Also handle stale voice state: if the store says connected but voice isn't actually active,
// clear it so the user can join.
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) { if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
if (!this.webrtc.isVoiceConnected()) { if (!this.webrtc.isVoiceConnected()) {
// Stale state - clear it so the user can proceed
if (current.id) { if (current.id) {
this.store.dispatch( this.store.dispatch(
UsersActions.updateVoiceState({ UsersActions.updateVoiceState({
@@ -429,21 +378,16 @@ export class RoomsSidePanelComponent {
); );
} }
} else { } else {
// Already connected to voice in another server; must disconnect first
return; return;
} }
} }
// If switching channels within the same server, just update the room
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId; const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
// Enable microphone and broadcast voice-state
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice(); const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
enableVoicePromise enableVoicePromise
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null)) .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
.catch((_error) => { .catch(() => undefined);
// Failed to join voice room
});
} }
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void { private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
@@ -523,23 +467,18 @@ export class RoomsSidePanelComponent {
}); });
} }
/** Leave a voice channel and broadcast the disconnect state. */
leaveVoice(roomId: string) { leaveVoice(roomId: string) {
const current = this.currentUser(); const current = this.currentUser();
// Only leave if currently in this room
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
return; return;
// Stop voice heartbeat
this.webrtc.stopVoiceHeartbeat(); this.webrtc.stopVoiceHeartbeat();
this.untrackCurrentUserMic(); this.untrackCurrentUserMic();
// Disable voice locally
this.webrtc.disableVoice(); this.webrtc.disableVoice();
// Update store voice state
if (current?.id) { if (current?.id) {
this.store.dispatch( this.store.dispatch(
UsersActions.updateVoiceState({ UsersActions.updateVoiceState({
@@ -555,7 +494,6 @@ export class RoomsSidePanelComponent {
); );
} }
// Broadcast disconnect
this.webrtc.broadcastMessage({ this.webrtc.broadcastMessage({
type: 'voice-state', type: 'voice-state',
oderId: current?.oderId || current?.id, oderId: current?.oderId || current?.id,
@@ -569,37 +507,31 @@ export class RoomsSidePanelComponent {
} }
}); });
// End voice session
this.voiceSessionService.endSession(); this.voiceSessionService.endSession();
} }
/** Count the number of users connected to a voice channel in the current room. */
voiceOccupancy(roomId: string): number { voiceOccupancy(roomId: string): number {
return this.voiceUsersInRoom(roomId).length; return this.voiceUsersInRoom(roomId).length;
} }
/** Dispatch a viewer:focus event to display a remote user's screen share. */
viewShare(userId: string) { viewShare(userId: string) {
const evt = new CustomEvent('viewer:focus', { detail: { userId } }); const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt); window.dispatchEvent(evt);
} }
/** Dispatch a viewer:focus event to display a remote user's stream. */
viewStream(userId: string) { viewStream(userId: string) {
const evt = new CustomEvent('viewer:focus', { detail: { userId } }); const evt = new CustomEvent('viewer:focus', { detail: { userId } });
window.dispatchEvent(evt); window.dispatchEvent(evt);
} }
/** Check whether the local user has muted a specific voice user. */
isUserLocallyMuted(user: User): boolean { isUserLocallyMuted(user: User): boolean {
const peerId = user.oderId || user.id; const peerId = user.oderId || user.id;
return this.voicePlayback.isUserMuted(peerId); return this.voicePlayback.isUserMuted(peerId);
} }
/** Check whether a user is currently sharing their screen. */
isUserSharing(userId: string): boolean { isUserSharing(userId: string): boolean {
const me = this.currentUser(); const me = this.currentUser();
@@ -618,7 +550,6 @@ export class RoomsSidePanelComponent {
return !!stream && stream.getVideoTracks().length > 0; return !!stream && stream.getVideoTracks().length > 0;
} }
/** Return all users currently connected to a specific voice channel, including the local user. */
voiceUsersInRoom(roomId: string) { voiceUsersInRoom(roomId: string) {
const room = this.currentRoom(); const room = this.currentRoom();
const me = this.currentUser(); const me = this.currentUser();
@@ -626,13 +557,11 @@ export class RoomsSidePanelComponent {
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id (user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
); );
// Include the local user at the top if they are in this voice channel
if ( if (
me?.voiceState?.isConnected && me?.voiceState?.isConnected &&
me.voiceState?.roomId === roomId && me.voiceState?.roomId === roomId &&
me.voiceState?.serverId === room?.id me.voiceState?.serverId === room?.id
) { ) {
// Avoid duplicates if the current user is already in onlineUsers
const meId = me.id; const meId = me.id;
const meOderId = me.oderId; const meOderId = me.oderId;
const alreadyIncluded = remoteUsers.some( const alreadyIncluded = remoteUsers.some(
@@ -647,7 +576,6 @@ export class RoomsSidePanelComponent {
return remoteUsers; return remoteUsers;
} }
/** Check whether the current user is connected to the specified voice channel. */
isCurrentRoom(roomId: string): boolean { isCurrentRoom(roomId: string): boolean {
const me = this.currentUser(); const me = this.currentUser();
const room = this.currentRoom(); const room = this.currentRoom();
@@ -655,32 +583,18 @@ export class RoomsSidePanelComponent {
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id); return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
} }
/** Check whether voice is enabled by the current room's permissions. */
voiceEnabled(): boolean { voiceEnabled(): boolean {
const room = this.currentRoom(); const room = this.currentRoom();
return room?.permissions?.allowVoice !== false; return room?.permissions?.allowVoice !== false;
} }
/**
* Get the measured latency (ms) to a voice user.
* Returns `null` when no measurement is available yet.
*/
getPeerLatency(user: User): number | null { getPeerLatency(user: User): number | null {
const latencies = this.webrtc.peerLatencies(); const latencies = this.webrtc.peerLatencies();
// Try oderId first (primary peer key), then fall back to user id
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null; return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
} }
/**
* Return a Tailwind `bg-*` class representing the latency quality.
* - green : < 100 ms
* - yellow : 100-199 ms
* - orange : 200-349 ms
* - red : >= 350 ms
* - gray : no data yet
*/
getPingColorClass(user: User): string { getPingColorClass(user: User): string {
const ms = this.getPeerLatency(user); const ms = this.getPeerLatency(user);

View File

@@ -31,8 +31,8 @@ import {
selectRoomsError, selectRoomsError,
selectSavedRooms selectSavedRooms
} from '../../store/rooms/rooms.selectors'; } from '../../store/rooms/rooms.selectors';
import { Room } from '../../core/models'; import { Room } from '../../core/models/index';
import { ServerInfo } from '../../core/models'; import { ServerInfo } from '../../core/models/index';
import { SettingsModalService } from '../../core/services/settings-modal.service'; import { SettingsModalService } from '../../core/services/settings-modal.service';
@Component({ @Component({

View File

@@ -10,7 +10,7 @@ import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core'; import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide'; import { lucidePlus } from '@ng-icons/lucide';
import { Room } from '../../core/models'; import { Room } from '../../core/models/index';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../store/users/users.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors';
import { VoiceSessionService } from '../../core/services/voice-session.service'; import { VoiceSessionService } from '../../core/services/voice-session.service';
@@ -31,9 +31,6 @@ import { ContextMenuComponent, LeaveServerDialogComponent } from '../../shared';
viewProviders: [provideIcons({ lucidePlus })], viewProviders: [provideIcons({ lucidePlus })],
templateUrl: './servers-rail.component.html' templateUrl: './servers-rail.component.html'
}) })
/**
* Vertical rail of saved server icons with context-menu actions for leaving/forgetting.
*/
export class ServersRailComponent { export class ServersRailComponent {
private store = inject(Store); private store = inject(Store);
private router = inject(Router); private router = inject(Router);
@@ -42,15 +39,13 @@ export class ServersRailComponent {
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
// Context menu state
showMenu = signal(false); showMenu = signal(false);
menuX = signal(72); // default X: rail width (~64px) + padding menuX = signal(72);
menuY = signal(100); // default Y: arbitrary initial offset menuY = signal(100);
contextRoom = signal<Room | null>(null); contextRoom = signal<Room | null>(null);
showLeaveConfirm = signal(false); showLeaveConfirm = signal(false);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
/** Return the first character of a server name as its icon initial. */
initial(name?: string): string { initial(name?: string): string {
if (!name) if (!name)
return '?'; return '?';
@@ -62,10 +57,7 @@ export class ServersRailComponent {
trackRoomId = (index: number, room: Room) => room.id; trackRoomId = (index: number, room: Room) => room.id;
/** Navigate to the server search view. Updates voice session state if applicable. */
createServer(): void { createServer(): void {
// Navigate to server list (has create button)
// Update voice session state if connected to voice
const voiceServerId = this.voiceSession.getVoiceServerId(); const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId) { if (voiceServerId) {
@@ -75,9 +67,7 @@ export class ServersRailComponent {
this.router.navigate(['/search']); this.router.navigate(['/search']);
} }
/** Join or switch to a saved room. Manages voice session and authentication state. */
joinSavedRoom(room: Room): void { joinSavedRoom(room: Room): void {
// Require auth: if no current user, go to login
const currentUserId = localStorage.getItem('metoyou_currentUserId'); const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) { if (!currentUserId) {
@@ -85,24 +75,17 @@ export class ServersRailComponent {
return; return;
} }
// Check if we're navigating to a different server while in voice
const voiceServerId = this.voiceSession.getVoiceServerId(); const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId && voiceServerId !== room.id) { if (voiceServerId && voiceServerId !== room.id) {
// User is switching to a different server while connected to voice
// Update voice session to show floating controls (voice stays connected)
this.voiceSession.setViewingVoiceServer(false); this.voiceSession.setViewingVoiceServer(false);
} else if (voiceServerId === room.id) { } else if (voiceServerId === room.id) {
// Navigating back to the voice-connected server
this.voiceSession.setViewingVoiceServer(true); this.voiceSession.setViewingVoiceServer(true);
} }
// If we've already joined this server, just switch the view
// (no user_joined broadcast, no leave from other servers)
if (this.webrtc.hasJoinedServer(room.id)) { if (this.webrtc.hasJoinedServer(room.id)) {
this.store.dispatch(RoomsActions.viewServer({ room })); this.store.dispatch(RoomsActions.viewServer({ room }));
} else { } else {
// First time joining this server
this.store.dispatch( this.store.dispatch(
RoomsActions.joinRoom({ RoomsActions.joinRoom({
roomId: room.id, roomId: room.id,
@@ -116,23 +99,18 @@ export class ServersRailComponent {
} }
} }
/** Open the context menu positioned near the cursor for a given room. */
openContextMenu(evt: MouseEvent, room: Room): void { openContextMenu(evt: MouseEvent, room: Room): void {
evt.preventDefault(); evt.preventDefault();
this.contextRoom.set(room); this.contextRoom.set(room);
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
this.menuX.set(Math.max(evt.clientX + 8, 72)); this.menuX.set(Math.max(evt.clientX + 8, 72));
this.menuY.set(evt.clientY); this.menuY.set(evt.clientY);
this.showMenu.set(true); this.showMenu.set(true);
} }
/** Close the context menu (keeps contextRoom for potential confirmation). */
closeMenu(): void { closeMenu(): void {
this.showMenu.set(false); this.showMenu.set(false);
// keep contextRoom for potential confirmation dialog
} }
/** Check whether the context-menu room is the currently active room. */
isCurrentContextRoom(): boolean { isCurrentContextRoom(): boolean {
const ctx = this.contextRoom(); const ctx = this.contextRoom();
const cur = this.currentRoom(); const cur = this.currentRoom();
@@ -140,7 +118,6 @@ export class ServersRailComponent {
return !!ctx && !!cur && ctx.id === cur.id; return !!ctx && !!cur && ctx.id === cur.id;
} }
/** Open the unified leave-server confirmation dialog. */
openLeaveConfirm(): void { openLeaveConfirm(): void {
this.closeMenu(); this.closeMenu();
@@ -149,7 +126,6 @@ export class ServersRailComponent {
} }
} }
/** Confirm the merged leave flow and remove the server locally. */
confirmLeave(result: { nextOwnerKey?: string }): void { confirmLeave(result: { nextOwnerKey?: string }): void {
const ctx = this.contextRoom(); const ctx = this.contextRoom();
@@ -171,7 +147,6 @@ export class ServersRailComponent {
this.contextRoom.set(null); this.contextRoom.set(null);
} }
/** Cancel the leave-server confirmation dialog. */
cancelLeave(): void { cancelLeave(): void {
this.showLeaveConfirm.set(false); this.showLeaveConfirm.set(false);
} }

View File

@@ -9,7 +9,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { lucideX } from '@ng-icons/lucide'; import { lucideX } from '@ng-icons/lucide';
import { Room, BanEntry } from '../../../../core/models'; import { Room, BanEntry } from '../../../../core/models/index';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { selectBannedUsers } from '../../../../store/users/users.selectors'; import { selectBannedUsers } from '../../../../store/users/users.selectors';

View File

@@ -10,7 +10,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { lucideUserX, lucideBan } from '@ng-icons/lucide'; import { lucideUserX, lucideBan } from '@ng-icons/lucide';
import { Room, User } from '../../../../core/models'; import { Room, User } from '../../../../core/models/index';
import { UsersActions } from '../../../../store/users/users.actions'; import { UsersActions } from '../../../../store/users/users.actions';
import { WebRTCService } from '../../../../core/services/webrtc.service'; import { WebRTCService } from '../../../../core/services/webrtc.service';
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors'; import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';

View File

@@ -11,7 +11,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { lucideCheck } from '@ng-icons/lucide'; import { lucideCheck } from '@ng-icons/lucide';
import { Room } from '../../../../core/models'; import { Room } from '../../../../core/models/index';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
@Component({ @Component({

View File

@@ -17,7 +17,7 @@ import {
lucideUnlock lucideUnlock
} from '@ng-icons/lucide'; } from '@ng-icons/lucide';
import { Room } from '../../../../core/models'; import { Room } from '../../../../core/models/index';
import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { ConfirmDialogComponent } from '../../../../shared'; import { ConfirmDialogComponent } from '../../../../shared';
import { SettingsModalService } from '../../../../core/services/settings-modal.service'; import { SettingsModalService } from '../../../../core/services/settings-modal.service';

View File

@@ -25,7 +25,7 @@ import {
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room } from '../../../core/models'; import { Room } from '../../../core/models/index';
import { NetworkSettingsComponent } from './network-settings/network-settings.component'; import { NetworkSettingsComponent } from './network-settings/network-settings.component';
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component'; import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
@@ -69,16 +69,13 @@ export class SettingsModalComponent {
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp'); private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
// --- Selectors ---
savedRooms = this.store.selectSignal(selectSavedRooms); savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom); currentRoom = this.store.selectSignal(selectCurrentRoom);
currentUser = this.store.selectSignal(selectCurrentUser); currentUser = this.store.selectSignal(selectCurrentUser);
// --- Modal state ---
isOpen = this.modal.isOpen; isOpen = this.modal.isOpen;
activePage = this.modal.activePage; activePage = this.modal.activePage;
// --- Side-nav items ---
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [ readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
{ id: 'network', { id: 'network',
label: 'Network', label: 'Network',
@@ -102,7 +99,6 @@ export class SettingsModalComponent {
icon: 'lucideShield' } icon: 'lucideShield' }
]; ];
// ===== SERVER SELECTOR =====
selectedServerId = signal<string | null>(null); selectedServerId = signal<string | null>(null);
selectedServer = computed<Room | null>(() => { selectedServer = computed<Room | null>(() => {
const id = this.selectedServerId(); const id = this.selectedServerId();
@@ -113,12 +109,10 @@ export class SettingsModalComponent {
return this.savedRooms().find((room) => room.id === id) ?? null; return this.savedRooms().find((room) => room.id === id) ?? null;
}); });
/** Whether the user can see server-admin tabs. */
showServerTabs = computed(() => { showServerTabs = computed(() => {
return this.savedRooms().length > 0 && !!this.selectedServerId(); return this.savedRooms().length > 0 && !!this.selectedServerId();
}); });
/** Whether the current user is the host/owner of the selected server. */
isSelectedServerAdmin = computed(() => { isSelectedServerAdmin = computed(() => {
const server = this.selectedServer(); const server = this.selectedServer();
const user = this.currentUser(); const user = this.currentUser();
@@ -129,12 +123,10 @@ export class SettingsModalComponent {
return server.hostId === user.id || server.hostId === user.oderId; return server.hostId === user.id || server.hostId === user.oderId;
}); });
// Animation
animating = signal(false); animating = signal(false);
showThirdPartyLicenses = signal(false); showThirdPartyLicenses = signal(false);
constructor() { constructor() {
// Sync selected server when modal opens with a target
effect(() => { effect(() => {
if (this.isOpen()) { if (this.isOpen()) {
const targetId = this.modal.targetServerId(); const targetId = this.modal.targetServerId();
@@ -153,7 +145,6 @@ export class SettingsModalComponent {
} }
}); });
// When selected server changes, reload permissions data
effect(() => { effect(() => {
const server = this.selectedServer(); const server = this.selectedServer();
@@ -178,7 +169,6 @@ export class SettingsModalComponent {
} }
} }
// ===== MODAL CONTROLS =====
close(): void { close(): void {
this.showThirdPartyLicenses.set(false); this.showThirdPartyLicenses.set(false);
this.animating.set(false); this.animating.set(false);

View File

@@ -21,7 +21,7 @@ import {
import { WebRTCService } from '../../../core/services/webrtc.service'; import { WebRTCService } from '../../../core/services/webrtc.service';
import { selectOnlineUsers } from '../../../store/users/users.selectors'; import { selectOnlineUsers } from '../../../store/users/users.selectors';
import { User } from '../../../core/models'; import { User } from '../../../core/models/index';
import { DEFAULT_VOLUME } from '../../../core/constants'; import { DEFAULT_VOLUME } from '../../../core/constants';
@Component({ @Component({

View File

@@ -18,13 +18,9 @@ export interface PlaybackOptions {
* the GainNode -> AudioContext.destination path. * the GainNode -> AudioContext.destination path.
*/ */
interface PeerAudioPipeline { interface PeerAudioPipeline {
/** Muted <audio> element that "primes" the stream for Web Audio. */
audioElement: HTMLAudioElement; audioElement: HTMLAudioElement;
/** AudioContext for this peer's pipeline. */
context: AudioContext; context: AudioContext;
/** Source node created from the remote stream. */
sourceNode: MediaStreamAudioSourceNode; sourceNode: MediaStreamAudioSourceNode;
/** GainNode used to control per-user volume (0.0-2.0). */
gainNode: GainNode; gainNode: GainNode;
} }
@@ -32,34 +28,18 @@ interface PeerAudioPipeline {
export class VoicePlaybackService { export class VoicePlaybackService {
private webrtc = inject(WebRTCService); private webrtc = inject(WebRTCService);
/** Active Web Audio pipelines keyed by peer ID. */
private peerPipelines = new Map<string, PeerAudioPipeline>(); private peerPipelines = new Map<string, PeerAudioPipeline>();
private pendingRemoteStreams = new Map<string, MediaStream>(); private pendingRemoteStreams = new Map<string, MediaStream>();
private rawRemoteStreams = new Map<string, MediaStream>(); private rawRemoteStreams = new Map<string, MediaStream>();
/**
* Per-user volume overrides (0-200 integer, maps to 0.0-2.0 gain).
* Keyed by oderId so the setting persists across reconnections.
*/
private userVolumes = new Map<string, number>(); private userVolumes = new Map<string, number>();
/** Per-user mute state. Keyed by oderId. */
private userMuted = new Map<string, boolean>(); private userMuted = new Map<string, boolean>();
/** Global master output volume (0.0-1.0 from the settings slider). */
private masterVolume = 1; private masterVolume = 1;
/** Whether the local user is deafened. */
private deafened = false; private deafened = false;
constructor() { constructor() {
this.loadPersistedVolumes(); this.loadPersistedVolumes();
} }
// ---------------------------------------------------------------------------
// Public API - stream lifecycle
// ---------------------------------------------------------------------------
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void { handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
if (!options.isConnected) { if (!options.isConnected) {
this.pendingRemoteStreams.set(peerId, stream); this.pendingRemoteStreams.set(peerId, stream);
@@ -110,10 +90,6 @@ export class VoicePlaybackService {
} }
} }
// ---------------------------------------------------------------------------
// Global volume / deafen (master slider from settings)
// ---------------------------------------------------------------------------
updateOutputVolume(volume: number): void { updateOutputVolume(volume: number): void {
this.masterVolume = volume; this.masterVolume = volume;
this.recalcAllGains(); this.recalcAllGains();
@@ -124,16 +100,10 @@ export class VoicePlaybackService {
this.recalcAllGains(); this.recalcAllGains();
} }
// ---------------------------------------------------------------------------
// Per-user volume (0-200%) and mute
// ---------------------------------------------------------------------------
/** Get the per-user volume for a peer (0-200). Defaults to 100. */
getUserVolume(peerId: string): number { getUserVolume(peerId: string): number {
return this.userVolumes.get(peerId) ?? 100; return this.userVolumes.get(peerId) ?? 100;
} }
/** Set per-user volume (0-200) and update the gain node in real time. */
setUserVolume(peerId: string, volume: number): void { setUserVolume(peerId: string, volume: number): void {
const clamped = Math.max(0, Math.min(200, volume)); const clamped = Math.max(0, Math.min(200, volume));
@@ -142,22 +112,16 @@ export class VoicePlaybackService {
this.persistVolumes(); this.persistVolumes();
} }
/** Whether a specific user is muted by the local user. */
isUserMuted(peerId: string): boolean { isUserMuted(peerId: string): boolean {
return this.userMuted.get(peerId) ?? false; return this.userMuted.get(peerId) ?? false;
} }
/** Toggle per-user mute. */
setUserMuted(peerId: string, muted: boolean): void { setUserMuted(peerId: string, muted: boolean): void {
this.userMuted.set(peerId, muted); this.userMuted.set(peerId, muted);
this.applyGain(peerId); this.applyGain(peerId);
this.persistVolumes(); this.persistVolumes();
} }
// ---------------------------------------------------------------------------
// Output device routing
// ---------------------------------------------------------------------------
applyOutputDevice(deviceId: string): void { applyOutputDevice(deviceId: string): void {
if (!deviceId) if (!deviceId)
return; return;
@@ -180,10 +144,6 @@ export class VoicePlaybackService {
}); });
} }
// ---------------------------------------------------------------------------
// Teardown
// ---------------------------------------------------------------------------
teardownAll(): void { teardownAll(): void {
this.peerPipelines.forEach((_pipeline, peerId) => this.removePipeline(peerId)); this.peerPipelines.forEach((_pipeline, peerId) => this.removePipeline(peerId));
this.peerPipelines.clear(); this.peerPipelines.clear();
@@ -191,10 +151,6 @@ export class VoicePlaybackService {
this.pendingRemoteStreams.clear(); this.pendingRemoteStreams.clear();
} }
// ---------------------------------------------------------------------------
// Private - Web Audio pipeline
// ---------------------------------------------------------------------------
/** /**
* Build the Web Audio graph for a remote peer: * Build the Web Audio graph for a remote peer:
* *
@@ -205,14 +161,13 @@ export class VoicePlaybackService {
* MediaStreamSource → GainNode → AudioContext.destination * MediaStreamSource → GainNode → AudioContext.destination
*/ */
private createPipeline(peerId: string, stream: MediaStream): void { private createPipeline(peerId: string, stream: MediaStream): void {
// 1) Chrome/Electron workaround: attach stream to a muted <audio> // Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
const audioEl = new Audio(); const audioEl = new Audio();
audioEl.srcObject = stream; audioEl.srcObject = stream;
audioEl.muted = true; // silent - we route audio through Web Audio API audioEl.muted = true;
audioEl.play().catch(() => {}); audioEl.play().catch(() => {});
// 2) Set up Web Audio graph
const ctx = new AudioContext(); const ctx = new AudioContext();
const sourceNode = ctx.createMediaStreamSource(stream); const sourceNode = ctx.createMediaStreamSource(stream);
const gainNode = ctx.createGain(); const gainNode = ctx.createGain();
@@ -220,16 +175,13 @@ export class VoicePlaybackService {
sourceNode.connect(gainNode); sourceNode.connect(gainNode);
gainNode.connect(ctx.destination); gainNode.connect(ctx.destination);
// 3) Store pipeline
const pipeline: PeerAudioPipeline = { audioElement: audioEl, context: ctx, sourceNode, gainNode }; const pipeline: PeerAudioPipeline = { audioElement: audioEl, context: ctx, sourceNode, gainNode };
this.peerPipelines.set(peerId, pipeline); this.peerPipelines.set(peerId, pipeline);
// 4) Apply current gain
this.applyGain(peerId); this.applyGain(peerId);
} }
/** Disconnect and clean up all nodes for a single peer. */
private removePipeline(peerId: string): void { private removePipeline(peerId: string): void {
const pipeline = this.peerPipelines.get(peerId); const pipeline = this.peerPipelines.get(peerId);
@@ -253,14 +205,6 @@ export class VoicePlaybackService {
this.peerPipelines.delete(peerId); this.peerPipelines.delete(peerId);
} }
/**
* Compute and apply the effective gain for a peer.
*
* effectiveGain = masterVolume × (userVolume / 100)
*
* If the user is deafened or the peer is individually muted the gain
* is set to 0.
*/
private applyGain(peerId: string): void { private applyGain(peerId: string): void {
const pipeline = this.peerPipelines.get(peerId); const pipeline = this.peerPipelines.get(peerId);
@@ -278,15 +222,10 @@ export class VoicePlaybackService {
pipeline.gainNode.gain.value = effective; pipeline.gainNode.gain.value = effective;
} }
/** Recalculate gain for every active pipeline. */
private recalcAllGains(): void { private recalcAllGains(): void {
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId)); this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
} }
// ---------------------------------------------------------------------------
// Persistence helpers
// ---------------------------------------------------------------------------
private persistVolumes(): void { private persistVolumes(): void {
try { try {
const data: Record<string, { volume: number; muted: boolean }> = {}; const data: Record<string, { volume: number; muted: boolean }> = {};
@@ -331,10 +270,6 @@ export class VoicePlaybackService {
} }
} }
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
private hasAudio(stream: MediaStream): boolean { private hasAudio(stream: MediaStream): boolean {
return stream.getAudioTracks().length > 0; return stream.getAudioTracks().length > 0;
} }

View File

@@ -5,24 +5,6 @@ import {
HostListener HostListener
} from '@angular/core'; } from '@angular/core';
/**
* Reusable confirmation dialog modal.
*
* Usage:
* ```html
* @if (showConfirm()) {
* <app-confirm-dialog
* title="Delete Room?"
* confirmLabel="Delete"
* variant="danger"
* (confirmed)="onDelete()"
* (cancelled)="showConfirm.set(false)"
* >
* <p>This will permanently delete the room.</p>
* </app-confirm-dialog>
* }
* ```
*/
@Component({ @Component({
selector: 'app-confirm-dialog', selector: 'app-confirm-dialog',
standalone: true, standalone: true,
@@ -32,19 +14,12 @@ import {
} }
}) })
export class ConfirmDialogComponent { export class ConfirmDialogComponent {
/** Dialog title. */
title = input.required<string>(); title = input.required<string>();
/** Label for the confirm button. */
confirmLabel = input<string>('Confirm'); confirmLabel = input<string>('Confirm');
/** Label for the cancel button. */
cancelLabel = input<string>('Cancel'); cancelLabel = input<string>('Cancel');
/** Visual style of the confirm button. */
variant = input<'primary' | 'danger'>('primary'); variant = input<'primary' | 'danger'>('primary');
/** Tailwind width class for the dialog. */
widthClass = input<string>('w-[320px]'); widthClass = input<string>('w-[320px]');
/** Emitted when the user confirms. */
confirmed = output<undefined>(); confirmed = output<undefined>();
/** Emitted when the user cancels (backdrop click, Cancel button, or Escape). */
cancelled = output<undefined>(); cancelled = output<undefined>();
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')

View File

@@ -10,30 +10,6 @@ import {
OnInit OnInit
} from '@angular/core'; } from '@angular/core';
/**
* Generic positioned context-menu overlay with automatic viewport clamping.
*
* Usage:
* ```html
* @if (showMenu()) {
* <app-context-menu [x]="menuX()" [y]="menuY()" (closed)="closeMenu()" [width]="'w-48'">
* <button (click)="doSomething()" class="context-menu-item">Action</button>
* </app-context-menu>
* }
* ```
*
* For pixel-based widths (e.g. sliders), use `[widthPx]` instead of `[width]`:
* ```html
* <app-context-menu [x]="menuX()" [y]="menuY()" [widthPx]="240" (closed)="closeMenu()">
* ...custom content...
* </app-context-menu>
* ```
*
* Built-in item classes are available via the host styles:
* - `.context-menu-item` - normal item
* - `.context-menu-item-danger` - destructive (red) item
* - `.context-menu-divider` - horizontal separator
*/
@Component({ @Component({
selector: 'app-context-menu', selector: 'app-context-menu',
standalone: true, standalone: true,
@@ -42,34 +18,25 @@ import {
}) })
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
export class ContextMenuComponent implements OnInit, AfterViewInit { export class ContextMenuComponent implements OnInit, AfterViewInit {
/** Horizontal position (px from left). */
// eslint-disable-next-line id-length, id-denylist // eslint-disable-next-line id-length, id-denylist
x = input.required<number>(); x = input.required<number>();
/** Vertical position (px from top). */
// eslint-disable-next-line id-length, id-denylist // eslint-disable-next-line id-length, id-denylist
y = input.required<number>(); y = input.required<number>();
/** Tailwind width class for the panel (default `w-48`). Ignored when `widthPx` is set. */
width = input<string>('w-48'); width = input<string>('w-48');
/** Optional fixed width in pixels (overrides `width`). Useful for custom content like sliders. */
widthPx = input<number | null>(null); widthPx = input<number | null>(null);
/** Emitted when the menu should close (backdrop click or Escape). */
closed = output<undefined>(); closed = output<undefined>();
@ViewChild('panel', { static: true }) panelRef!: ElementRef<HTMLDivElement>; @ViewChild('panel', { static: true }) panelRef!: ElementRef<HTMLDivElement>;
/** Viewport-clamped X position. */
clampedX = signal(0); clampedX = signal(0);
/** Viewport-clamped Y position. */
clampedY = signal(0); clampedY = signal(0);
ngOnInit(): void { ngOnInit(): void {
// Initial clamp with estimated dimensions
this.clampedX.set(this.clampX(this.x(), this.estimateWidth())); this.clampedX.set(this.clampX(this.x(), this.estimateWidth()));
this.clampedY.set(this.clampY(this.y(), 80)); this.clampedY.set(this.clampY(this.y(), 80));
} }
ngAfterViewInit(): void { ngAfterViewInit(): void {
// Refine with actual rendered dimensions
const rect = this.panelRef.nativeElement.getBoundingClientRect(); const rect = this.panelRef.nativeElement.getBoundingClientRect();
this.clampedX.set(this.clampX(this.x(), rect.width)); this.clampedX.set(this.clampX(this.x(), rect.width));
@@ -87,7 +54,6 @@ export class ContextMenuComponent implements OnInit, AfterViewInit {
if (px) if (px)
return px; return px;
// Parse Tailwind w-XX class to approximate pixel width
const match = this.width().match(/w-(\d+)/); const match = this.width().match(/w-(\d+)/);
return match ? parseInt(match[1], 10) * 4 : 192; return match ? parseInt(match[1], 10) * 4 : 192;

View File

@@ -13,7 +13,7 @@ import {
Room, Room,
RoomMember, RoomMember,
User User
} from '../../../core/models'; } from '../../../core/models/index';
export interface LeaveServerDialogResult { export interface LeaveServerDialogResult {
nextOwnerKey?: string; nextOwnerKey?: string;

View File

@@ -1,20 +1,6 @@
import { NgOptimizedImage } from '@angular/common'; import { NgOptimizedImage } from '@angular/common';
import { Component, input } from '@angular/core'; import { Component, input } from '@angular/core';
/**
* Reusable user avatar circle.
*
* Displays the user's image when `avatarUrl` is provided, otherwise
* falls back to a colored circle with the first letter of `name`.
*
* Optional rings (e.g. voice-state colours) can be applied via `ringClass`.
*
* Usage:
* ```html
* <app-user-avatar [name]="user.displayName" [avatarUrl]="user.avatarUrl" size="md" />
* <app-user-avatar [name]="user.displayName" size="sm" ringClass="ring-2 ring-green-500" />
* ```
*/
@Component({ @Component({
selector: 'app-user-avatar', selector: 'app-user-avatar',
standalone: true, standalone: true,
@@ -25,22 +11,16 @@ import { Component, input } from '@angular/core';
} }
}) })
export class UserAvatarComponent { export class UserAvatarComponent {
/** Display name - first character is used as fallback initial. */
name = input.required<string>(); name = input.required<string>();
/** Optional avatar image URL. */
avatarUrl = input<string | undefined | null>(); avatarUrl = input<string | undefined | null>();
/** Predefined size: `xs` (28px), `sm` (32px), `md` (40px), `lg` (48px). */
size = input<'xs' | 'sm' | 'md' | 'lg'>('sm'); size = input<'xs' | 'sm' | 'md' | 'lg'>('sm');
/** Extra ring classes, e.g. `'ring-2 ring-green-500'`. */
ringClass = input<string>(''); ringClass = input<string>('');
/** Compute the first-letter initial. */
initial(): string { initial(): string {
return this.name()?.charAt(0) return this.name()?.charAt(0)
?.toUpperCase() ?? '?'; ?.toUpperCase() ?? '?';
} }
/** Map size token to Tailwind dimension classes. */
sizeClasses(): string { sizeClasses(): string {
switch (this.size()) { switch (this.size()) {
case 'xs': return 'w-7 h-7'; case 'xs': return 'w-7 h-7';
@@ -50,7 +30,6 @@ export class UserAvatarComponent {
} }
} }
/** Map size token to explicit pixel dimensions for image optimisation. */
sizePx(): number { sizePx(): number {
switch (this.size()) { switch (this.size()) {
case 'xs': return 28; case 'xs': return 28;
@@ -60,7 +39,6 @@ export class UserAvatarComponent {
} }
} }
/** Map size token to text size for initials. */
textClass(): string { textClass(): string {
switch (this.size()) { switch (this.size()) {
case 'xs': return 'text-xs'; case 'xs': return 'text-xs';

View File

@@ -11,27 +11,6 @@ import { lucideVolume2, lucideVolumeX } from '@ng-icons/lucide';
import { VoicePlaybackService } from '../../../features/voice/voice-controls/services/voice-playback.service'; import { VoicePlaybackService } from '../../../features/voice/voice-controls/services/voice-playback.service';
import { ContextMenuComponent } from '../context-menu/context-menu.component'; import { ContextMenuComponent } from '../context-menu/context-menu.component';
/**
* Context-menu overlay that lets the local user adjust the playback
* volume of a specific remote voice-channel participant (0%-200%)
* and toggle per-user mute.
*
* Wraps `<app-context-menu>` for consistent positioning, backdrop,
* escape handling and viewport clamping.
*
* Usage:
* ```html
* @if (showVolumeMenu()) {
* <app-user-volume-menu
* [x]="menuX()"
* [y]="menuY()"
* [peerId]="targetPeerId()"
* [displayName]="targetName()"
* (closed)="showVolumeMenu.set(false)"
* />
* }
* ```
*/
@Component({ @Component({
selector: 'app-user-volume-menu', selector: 'app-user-volume-menu',
standalone: true, standalone: true,
@@ -42,17 +21,12 @@ import { ContextMenuComponent } from '../context-menu/context-menu.component';
}) })
/* eslint-disable @typescript-eslint/member-ordering */ /* eslint-disable @typescript-eslint/member-ordering */
export class UserVolumeMenuComponent implements OnInit { export class UserVolumeMenuComponent implements OnInit {
/** Horizontal position (px from left). */
// eslint-disable-next-line id-length, id-denylist // eslint-disable-next-line id-length, id-denylist
x = input.required<number>(); x = input.required<number>();
/** Vertical position (px from top). */
// eslint-disable-next-line id-length, id-denylist // eslint-disable-next-line id-length, id-denylist
y = input.required<number>(); y = input.required<number>();
/** Remote peer identifier (oderId). */
peerId = input.required<string>(); peerId = input.required<string>();
/** Display name shown in the header. */
displayName = input.required<string>(); displayName = input.required<string>();
/** Emitted when the menu should close. */
closed = output<undefined>(); closed = output<undefined>();
private playback = inject(VoicePlaybackService); private playback = inject(VoicePlaybackService);

View File

@@ -17,7 +17,7 @@ import {
} from 'rxjs'; } from 'rxjs';
import { mergeMap } from 'rxjs/operators'; import { mergeMap } from 'rxjs/operators';
import { Action } from '@ngrx/store'; import { Action } from '@ngrx/store';
import { Message } from '../../core/models'; import { Message } from '../../core/models/index';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
import { AttachmentService } from '../../core/services/attachment.service'; import { AttachmentService } from '../../core/services/attachment.service';

View File

@@ -9,7 +9,7 @@ import {
emptyProps, emptyProps,
props props
} from '@ngrx/store'; } from '@ngrx/store';
import { Message, Reaction } from '../../core/models'; import { Message, Reaction } from '../../core/models/index';
export const MessagesActions = createActionGroup({ export const MessagesActions = createActionGroup({
source: 'Messages', source: 'Messages',

View File

@@ -35,7 +35,7 @@ import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
import { TimeSyncService } from '../../core/services/time-sync.service'; import { TimeSyncService } from '../../core/services/time-sync.service';
import { AttachmentService } from '../../core/services/attachment.service'; import { AttachmentService } from '../../core/services/attachment.service';
import { Message, Reaction } from '../../core/models'; import { Message, Reaction } from '../../core/models/index';
import { hydrateMessages } from './messages.helpers'; import { hydrateMessages } from './messages.helpers';
import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers'; import { dispatchIncomingMessage, IncomingMessageContext } from './messages-incoming.handlers';

View File

@@ -4,7 +4,7 @@
* Extracted from messages.effects.ts to improve readability, testability, * Extracted from messages.effects.ts to improve readability, testability,
* and reuse across effects and handler files. * and reuse across effects and handler files.
*/ */
import { Message } from '../../core/models'; import { Message } from '../../core/models/index';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../core/services/database.service';
/** Maximum number of recent messages to include in sync inventories. */ /** Maximum number of recent messages to include in sync inventories. */

View File

@@ -4,7 +4,7 @@ import {
EntityAdapter, EntityAdapter,
createEntityAdapter createEntityAdapter
} from '@ngrx/entity'; } from '@ngrx/entity';
import { Message } from '../../core/models'; import { Message } from '../../core/models/index';
import { MessagesActions } from './messages.actions'; import { MessagesActions } from './messages.actions';
/** State shape for the messages feature slice, extending NgRx EntityState. */ /** State shape for the messages feature slice, extending NgRx EntityState. */

View File

@@ -17,7 +17,7 @@ import {
Room, Room,
RoomMember, RoomMember,
User User
} from '../../core/models'; } from '../../core/models/index';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
import { UsersActions } from '../users/users.actions'; import { UsersActions } from '../users/users.actions';
import { selectCurrentUser } from '../users/users.selectors'; import { selectCurrentUser } from '../users/users.selectors';

View File

@@ -1,4 +1,4 @@
import { RoomMember, User } from '../../core/models'; import { RoomMember, User } from '../../core/models/index';
/** Remove members that have not been seen for roughly two months. */ /** Remove members that have not been seen for roughly two months. */
export const ROOM_MEMBER_STALE_MS = 1000 * 60 * 60 * 24 * 60; export const ROOM_MEMBER_STALE_MS = 1000 * 60 * 60 * 24 * 60;

View File

@@ -12,7 +12,7 @@ import {
ServerInfo, ServerInfo,
RoomPermissions, RoomPermissions,
Channel Channel
} from '../../core/models'; } from '../../core/models/index';
export const RoomsActions = createActionGroup({ export const RoomsActions = createActionGroup({
source: 'Rooms', source: 'Rooms',

View File

@@ -38,7 +38,7 @@ import {
RoomSettings, RoomSettings,
RoomPermissions, RoomPermissions,
VoiceState VoiceState
} from '../../core/models'; } from '../../core/models/index';
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
import { import {
findRoomMember, findRoomMember,

View File

@@ -4,7 +4,7 @@ import {
ServerInfo, ServerInfo,
RoomSettings, RoomSettings,
Channel Channel
} from '../../core/models'; } from '../../core/models/index';
import { RoomsActions } from './rooms.actions'; import { RoomsActions } from './rooms.actions';
import { pruneRoomMembers } from './room-members.helpers'; import { pruneRoomMembers } from './room-members.helpers';

View File

@@ -1,102 +1,69 @@
import { createFeatureSelector, createSelector } from '@ngrx/store'; import { createFeatureSelector, createSelector } from '@ngrx/store';
import { RoomsState } from './rooms.reducer'; import { RoomsState } from './rooms.reducer';
/** Selects the top-level rooms feature state. */
export const selectRoomsState = createFeatureSelector<RoomsState>('rooms'); export const selectRoomsState = createFeatureSelector<RoomsState>('rooms');
/** Selects the room the user is currently viewing. */
export const selectCurrentRoom = createSelector( export const selectCurrentRoom = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.currentRoom (state) => state.currentRoom
); );
/** Selects the current room's settings (name, topic, privacy, etc.). */
export const selectRoomSettings = createSelector( export const selectRoomSettings = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.roomSettings (state) => state.roomSettings
); );
/** Selects server search results from the directory. */
export const selectSearchResults = createSelector( export const selectSearchResults = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.searchResults (state) => state.searchResults
); );
/** Whether a server directory search is currently in progress. */
export const selectIsSearching = createSelector( export const selectIsSearching = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.isSearching (state) => state.isSearching
); );
/** Whether a room connection is being established. */
export const selectIsConnecting = createSelector( export const selectIsConnecting = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.isConnecting (state) => state.isConnecting
); );
/** Whether the user is currently connected to a room. */
export const selectIsConnected = createSelector( export const selectIsConnected = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.isConnected (state) => state.isConnected
); );
/** Selects the most recent rooms-related error message. */
export const selectRoomsError = createSelector( export const selectRoomsError = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.error (state) => state.error
); );
/** Selects the ID of the current room, or null. */
export const selectCurrentRoomId = createSelector( export const selectCurrentRoomId = createSelector(
selectCurrentRoom, selectCurrentRoom,
(room) => room?.id ?? null (room) => room?.id ?? null
); );
/** Selects the display name of the current room. */
export const selectCurrentRoomName = createSelector( export const selectCurrentRoomName = createSelector(
selectCurrentRoom, selectCurrentRoom,
(room) => room?.name ?? '' (room) => room?.name ?? ''
); );
/** Selects the host ID of the current room (for ownership checks). */
export const selectIsCurrentUserHost = createSelector( export const selectIsCurrentUserHost = createSelector(
selectCurrentRoom, selectCurrentRoom,
(room) => room?.hostId (room) => room?.hostId
); );
/** Selects all locally-saved rooms. */
export const selectSavedRooms = createSelector( export const selectSavedRooms = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.savedRooms (state) => state.savedRooms
); );
/** Whether rooms are currently being loaded from local storage. */
export const selectRoomsLoading = createSelector( export const selectRoomsLoading = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.loading (state) => state.loading
); );
/** Selects the ID of the currently active text channel. */
export const selectActiveChannelId = createSelector( export const selectActiveChannelId = createSelector(
selectRoomsState, selectRoomsState,
(state) => state.activeChannelId (state) => state.activeChannelId
); );
/** Selects all channels defined on the current room. */
export const selectCurrentRoomChannels = createSelector( export const selectCurrentRoomChannels = createSelector(
selectCurrentRoom, selectCurrentRoom,
(room) => room?.channels ?? [] (room) => room?.channels ?? []
); );
/** Selects only text channels, sorted by position. */
export const selectTextChannels = createSelector( export const selectTextChannels = createSelector(
selectCurrentRoomChannels, selectCurrentRoomChannels,
(channels) => channels (channels) => channels
.filter((channel) => channel.type === 'text') .filter((channel) => channel.type === 'text')
.sort((channelA, channelB) => channelA.position - channelB.position) .sort((channelA, channelB) => channelA.position - channelB.position)
); );
/** Selects only voice channels, sorted by position. */
export const selectVoiceChannels = createSelector( export const selectVoiceChannels = createSelector(
selectCurrentRoomChannels, selectCurrentRoomChannels,
(channels) => channels (channels) => channels

View File

@@ -11,7 +11,7 @@ import {
BanEntry, BanEntry,
VoiceState, VoiceState,
ScreenShareState ScreenShareState
} from '../../core/models'; } from '../../core/models/index';
export const UsersActions = createActionGroup({ export const UsersActions = createActionGroup({
source: 'Users', source: 'Users',

View File

@@ -32,7 +32,7 @@ import {
import { selectCurrentRoom } from '../rooms/rooms.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service'; import { DatabaseService } from '../../core/services/database.service';
import { WebRTCService } from '../../core/services/webrtc.service'; import { WebRTCService } from '../../core/services/webrtc.service';
import { BanEntry, User } from '../../core/models'; import { BanEntry, User } from '../../core/models/index';
@Injectable() @Injectable()
export class UsersEffects { export class UsersEffects {

View File

@@ -4,20 +4,14 @@ import {
EntityAdapter, EntityAdapter,
createEntityAdapter createEntityAdapter
} from '@ngrx/entity'; } from '@ngrx/entity';
import { User, BanEntry } from '../../core/models'; import { User, BanEntry } from '../../core/models/index';
import { UsersActions } from './users.actions'; import { UsersActions } from './users.actions';
/** State shape for the users feature slice, extending NgRx EntityState. */
export interface UsersState extends EntityState<User> { export interface UsersState extends EntityState<User> {
/** ID of the locally authenticated user. */
currentUserId: string | null; currentUserId: string | null;
/** ID of the room host (owner). */
hostId: string | null; hostId: string | null;
/** Whether a user-loading operation is in progress. */
loading: boolean; loading: boolean;
/** Most recent error message from user operations. */
error: string | null; error: string | null;
/** List of active bans for the current room. */
bans: BanEntry[]; bans: BanEntry[];
} }
@@ -36,8 +30,6 @@ export const initialState: UsersState = usersAdapter.getInitialState({
export const usersReducer = createReducer( export const usersReducer = createReducer(
initialState, initialState,
// Load current user
on(UsersActions.loadCurrentUser, (state) => ({ on(UsersActions.loadCurrentUser, (state) => ({
...state, ...state,
loading: true, loading: true,
@@ -57,16 +49,12 @@ export const usersReducer = createReducer(
loading: false, loading: false,
error error
})), })),
// Set current user
on(UsersActions.setCurrentUser, (state, { user }) => on(UsersActions.setCurrentUser, (state, { user }) =>
usersAdapter.upsertOne(user, { usersAdapter.upsertOne(user, {
...state, ...state,
currentUserId: user.id currentUserId: user.id
}) })
), ),
// Update current user
on(UsersActions.updateCurrentUser, (state, { updates }) => { on(UsersActions.updateCurrentUser, (state, { updates }) => {
if (!state.currentUserId) if (!state.currentUserId)
return state; return state;
@@ -79,8 +67,6 @@ export const usersReducer = createReducer(
state state
); );
}), }),
// Load room users
on(UsersActions.loadRoomUsers, (state) => ({ on(UsersActions.loadRoomUsers, (state) => ({
...state, ...state,
loading: true, loading: true,
@@ -99,18 +85,12 @@ export const usersReducer = createReducer(
loading: false, loading: false,
error error
})), })),
// User joined
on(UsersActions.userJoined, (state, { user }) => on(UsersActions.userJoined, (state, { user }) =>
usersAdapter.upsertOne(user, state) usersAdapter.upsertOne(user, state)
), ),
// User left
on(UsersActions.userLeft, (state, { userId }) => on(UsersActions.userLeft, (state, { userId }) =>
usersAdapter.removeOne(userId, state) usersAdapter.removeOne(userId, state)
), ),
// Update user
on(UsersActions.updateUser, (state, { userId, updates }) => on(UsersActions.updateUser, (state, { userId, updates }) =>
usersAdapter.updateOne( usersAdapter.updateOne(
{ {
@@ -120,8 +100,6 @@ export const usersReducer = createReducer(
state state
) )
), ),
// Update user role
on(UsersActions.updateUserRole, (state, { userId, role }) => on(UsersActions.updateUserRole, (state, { userId, role }) =>
usersAdapter.updateOne( usersAdapter.updateOne(
{ {
@@ -131,13 +109,9 @@ export const usersReducer = createReducer(
state state
) )
), ),
// Kick user
on(UsersActions.kickUserSuccess, (state, { userId }) => on(UsersActions.kickUserSuccess, (state, { userId }) =>
usersAdapter.removeOne(userId, state) usersAdapter.removeOne(userId, state)
), ),
// Ban user
on(UsersActions.banUserSuccess, (state, { userId, ban }) => { on(UsersActions.banUserSuccess, (state, { userId, ban }) => {
const newState = usersAdapter.removeOne(userId, state); const newState = usersAdapter.removeOne(userId, state);
@@ -146,20 +120,14 @@ export const usersReducer = createReducer(
bans: [...state.bans, ban] bans: [...state.bans, ban]
}; };
}), }),
// Unban user
on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({ on(UsersActions.unbanUserSuccess, (state, { oderId }) => ({
...state, ...state,
bans: state.bans.filter((ban) => ban.oderId !== oderId) bans: state.bans.filter((ban) => ban.oderId !== oderId)
})), })),
// Load bans
on(UsersActions.loadBansSuccess, (state, { bans }) => ({ on(UsersActions.loadBansSuccess, (state, { bans }) => ({
...state, ...state,
bans bans
})), })),
// Admin mute
on(UsersActions.adminMuteUser, (state, { userId }) => on(UsersActions.adminMuteUser, (state, { userId }) =>
usersAdapter.updateOne( usersAdapter.updateOne(
{ {
@@ -178,8 +146,6 @@ export const usersReducer = createReducer(
state state
) )
), ),
// Admin unmute
on(UsersActions.adminUnmuteUser, (state, { userId }) => on(UsersActions.adminUnmuteUser, (state, { userId }) =>
usersAdapter.updateOne( usersAdapter.updateOne(
{ {
@@ -198,8 +164,6 @@ export const usersReducer = createReducer(
state state
) )
), ),
// Update voice state (generic)
on(UsersActions.updateVoiceState, (state, { userId, voiceState }) => { on(UsersActions.updateVoiceState, (state, { userId, voiceState }) => {
const prev = state.entities[userId]?.voiceState || { const prev = state.entities[userId]?.voiceState || {
isConnected: false, isConnected: false,
@@ -228,8 +192,6 @@ export const usersReducer = createReducer(
state state
); );
}), }),
// Update screen share state
on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => { on(UsersActions.updateScreenShareState, (state, { userId, screenShareState }) => {
const prev = state.entities[userId]?.screenShareState || { const prev = state.entities[userId]?.screenShareState || {
isSharing: false isSharing: false
@@ -250,13 +212,9 @@ export const usersReducer = createReducer(
state state
); );
}), }),
// Sync users
on(UsersActions.syncUsers, (state, { users }) => on(UsersActions.syncUsers, (state, { users }) =>
usersAdapter.upsertMany(users, state) usersAdapter.upsertMany(users, state)
), ),
// Clear users
on(UsersActions.clearUsers, (state) => { on(UsersActions.clearUsers, (state) => {
const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId); const idsToRemove = Object.keys(state.entities).filter((id) => id !== state.currentUserId);
@@ -265,10 +223,7 @@ export const usersReducer = createReducer(
hostId: null hostId: null
}); });
}), }),
// Update host
on(UsersActions.updateHost, (state, { userId }) => { on(UsersActions.updateHost, (state, { userId }) => {
// Update the old host's role to member
let newState = state; let newState = state;
if (state.hostId && state.hostId !== userId) { if (state.hostId && state.hostId !== userId) {
@@ -280,8 +235,6 @@ export const usersReducer = createReducer(
state state
); );
} }
// Update the new host's role
return usersAdapter.updateOne( return usersAdapter.updateOne(
{ {
id: userId, id: userId,