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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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