Cleaning up comments
This commit is contained in:
@@ -29,7 +29,7 @@ import {
|
||||
selectCurrentUser,
|
||||
selectOnlineUsers
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { BanEntry, User } from '../../../core/models';
|
||||
import { BanEntry, User } from '../../../core/models/index';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { lucideLogIn } from '@ng-icons/lucide';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { User } from '../../../core/models';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -14,7 +14,7 @@ import { lucideUserPlus } from '@ng-icons/lucide';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { User } from '../../../core/models';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, max-statements-per-line, @typescript-eslint/prefer-for-of, @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-explicit-any, id-length, id-denylist, max-statements-per-line, @typescript-eslint/prefer-for-of */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
} from '../../../store/messages/messages.selectors';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentRoom, selectActiveChannelId } from '../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../core/models';
|
||||
import { Message } from '../../../core/models/index';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
@@ -112,10 +112,6 @@ const COMMON_EMOJIS = [
|
||||
'(document:keyup)': 'onDocKeyup($event)'
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Real-time chat messages view with infinite scroll, markdown rendering,
|
||||
* emoji reactions, file attachments, and image lightbox support.
|
||||
*/
|
||||
export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestroy {
|
||||
@ViewChild('messagesContainer') messagesContainer!: ElementRef;
|
||||
@ViewChild('messageInputRef') messageInputRef!: ElementRef<HTMLTextAreaElement>;
|
||||
@@ -128,7 +124,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
private markdown = inject(ChatMarkdownService);
|
||||
|
||||
/** Remark processor with GFM (tables, strikethrough, etc.) and line-break support */
|
||||
remarkProcessor = unified()
|
||||
.use(remarkParse)
|
||||
.use(remarkGfm)
|
||||
@@ -137,12 +132,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private allMessages = this.store.selectSignal(selectAllMessages);
|
||||
private activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
|
||||
// --- Infinite scroll (upwards) pagination ---
|
||||
private readonly PAGE_SIZE = 50;
|
||||
displayLimit = signal(this.PAGE_SIZE);
|
||||
loadingMore = signal(false);
|
||||
|
||||
/** All messages for the current channel (full list, unsliced) */
|
||||
private allChannelMessages = computed(() => {
|
||||
const channelId = this.activeChannelId();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
@@ -152,7 +145,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
);
|
||||
});
|
||||
|
||||
/** Paginated view - only the most recent `displayLimit` messages */
|
||||
messages = computed(() => {
|
||||
const all = this.allChannelMessages();
|
||||
const limit = this.displayLimit();
|
||||
@@ -163,7 +155,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return all.slice(all.length - limit);
|
||||
});
|
||||
|
||||
/** Whether there are older messages that can be loaded */
|
||||
hasMoreMessages = computed(() => this.allChannelMessages().length > this.displayLimit());
|
||||
loading = this.store.selectSignal(selectMessagesLoading);
|
||||
syncing = this.store.selectSignal(selectMessagesSyncing);
|
||||
@@ -192,7 +183,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private lastMessageCount = 0;
|
||||
private initialScrollPending = true;
|
||||
pendingFiles: File[] = [];
|
||||
// New messages snackbar state
|
||||
showNewMessagesBar = signal(false);
|
||||
// Plain (non-reactive) reference time used only by formatTimestamp.
|
||||
// Updated periodically but NOT a signal, so it won't re-render every message.
|
||||
@@ -208,9 +198,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
private boundCtrlDown: ((e: KeyboardEvent) => void) | null = null;
|
||||
private boundCtrlUp: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
// Image lightbox modal state
|
||||
lightboxAttachment = signal<Attachment | null>(null);
|
||||
// Image right-click context menu state
|
||||
imageContextMenu = signal<{ x: number; y: number; attachment: Attachment } | null>(null);
|
||||
private boundOnKeydown: ((event: KeyboardEvent) => void) | null = null;
|
||||
|
||||
@@ -224,7 +212,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.displayLimit.set(this.PAGE_SIZE);
|
||||
});
|
||||
|
||||
// Reset pagination when switching channels within the same room
|
||||
private onChannelChanged = effect(() => {
|
||||
void this.activeChannelId(); // track channel signal
|
||||
this.displayLimit.set(this.PAGE_SIZE);
|
||||
@@ -232,13 +219,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.showNewMessagesBar.set(false);
|
||||
this.lastMessageCount = 0;
|
||||
});
|
||||
// Re-render when attachments update (e.g. download progress from WebRTC callbacks)
|
||||
|
||||
private attachmentsUpdatedEffect = effect(() => {
|
||||
void this.attachmentsSvc.updated();
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
|
||||
// Track total channel messages (not paginated) for new-message detection
|
||||
private totalChannelMessagesLength = computed(() => this.allChannelMessages().length);
|
||||
messagesLength = computed(() => this.messages().length);
|
||||
private onMessagesChanged = effect(() => {
|
||||
@@ -359,7 +345,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.pendingKlipyGif.set(null);
|
||||
this.clearReply();
|
||||
this.shouldScrollToBottom = true;
|
||||
// Reset textarea height after sending
|
||||
requestAnimationFrame(() => this.autoResizeTextarea());
|
||||
this.showNewMessagesBar.set(false);
|
||||
|
||||
@@ -369,11 +354,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** Throttle and broadcast a typing indicator when the user types. */
|
||||
onInputChange(): void {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastTypingSentAt > 1000) { // throttle typing events
|
||||
if (now - this.lastTypingSentAt > 1000) {
|
||||
try {
|
||||
this.webrtc.sendRawMessage({ type: 'typing' });
|
||||
this.lastTypingSentAt = now;
|
||||
@@ -381,13 +365,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** Begin editing an existing message, populating the edit input. */
|
||||
startEdit(message: Message): void {
|
||||
this.editingMessageId.set(message.id);
|
||||
this.editContent = message.content;
|
||||
}
|
||||
|
||||
/** Save the edited message content and exit edit mode. */
|
||||
saveEdit(messageId: string): void {
|
||||
if (!this.editContent.trim())
|
||||
return;
|
||||
@@ -402,13 +384,11 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.cancelEdit();
|
||||
}
|
||||
|
||||
/** Cancel the current edit and clear the edit state. */
|
||||
cancelEdit(): void {
|
||||
this.editingMessageId.set(null);
|
||||
this.editContent = '';
|
||||
}
|
||||
|
||||
/** Delete a message (own or admin-delete if the user has admin privileges). */
|
||||
deleteMessage(message: Message): void {
|
||||
if (this.isOwnMessage(message)) {
|
||||
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
|
||||
@@ -417,22 +397,18 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** Set the message to reply to. */
|
||||
setReplyTo(message: Message): void {
|
||||
this.replyTo.set(message);
|
||||
}
|
||||
|
||||
/** Clear the current reply-to reference. */
|
||||
clearReply(): void {
|
||||
this.replyTo.set(null);
|
||||
}
|
||||
|
||||
/** Find the original message that a reply references. */
|
||||
getRepliedMessage(messageId: string): Message | undefined {
|
||||
return this.allMessages().find(message => message.id === messageId);
|
||||
}
|
||||
|
||||
/** Smooth-scroll to a specific message element and briefly highlight it. */
|
||||
scrollToMessage(messageId: string): void {
|
||||
const container = this.messagesContainer?.nativeElement;
|
||||
|
||||
@@ -450,14 +426,12 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the emoji picker for a message. */
|
||||
toggleEmojiPicker(messageId: string): void {
|
||||
this.showEmojiPicker.update((current) =>
|
||||
current === messageId ? null : messageId
|
||||
);
|
||||
}
|
||||
|
||||
/** Add a reaction emoji to a message. */
|
||||
addReaction(messageId: string, emoji: string): void {
|
||||
this.store.dispatch(MessagesActions.addReaction({ messageId,
|
||||
emoji }));
|
||||
@@ -465,7 +439,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.showEmojiPicker.set(null);
|
||||
}
|
||||
|
||||
/** Toggle the reaction for the current user on a message. */
|
||||
toggleReaction(messageId: string, emoji: string): void {
|
||||
const message = this.messages().find((msg) => msg.id === messageId);
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
@@ -486,12 +459,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether a message was sent by the current user. */
|
||||
isOwnMessage(message: Message): boolean {
|
||||
return message.senderId === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
/** Aggregate reactions by emoji, returning counts and whether the current user reacted. */
|
||||
getGroupedReactions(message: Message): { emoji: string; count: number; hasCurrentUser: boolean }[] {
|
||||
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
|
||||
const currentUserId = this.currentUser()?.id;
|
||||
@@ -512,7 +483,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}));
|
||||
}
|
||||
|
||||
/** Format a timestamp as a relative or absolute time string. */
|
||||
formatTimestamp(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date(this.nowRef);
|
||||
@@ -964,12 +934,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return droppedFiles;
|
||||
}
|
||||
|
||||
/** Return all file attachments associated with a message. */
|
||||
getAttachments(messageId: string): Attachment[] {
|
||||
return this.attachmentsSvc.getForMessage(messageId);
|
||||
}
|
||||
|
||||
/** Format a byte count into a human-readable size string (B, KB, MB, GB). */
|
||||
formatBytes(bytes: number): string {
|
||||
const units = [
|
||||
'B',
|
||||
@@ -986,7 +954,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return `${size.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Format a transfer speed in bytes/second to a human-readable string. */
|
||||
formatSpeed(bps?: number): string {
|
||||
if (!bps || bps <= 0)
|
||||
return '0 B/s';
|
||||
@@ -1006,23 +973,19 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** Whether an attachment can be played inline as video. */
|
||||
isVideoAttachment(att: Attachment): boolean {
|
||||
return att.mime.startsWith('video/');
|
||||
}
|
||||
|
||||
/** Whether an attachment can be played inline as audio. */
|
||||
isAudioAttachment(att: Attachment): boolean {
|
||||
return att.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
/** Whether the user must explicitly accept a media download before playback. */
|
||||
requiresMediaDownloadAcceptance(att: Attachment): boolean {
|
||||
return (this.isVideoAttachment(att) || this.isAudioAttachment(att)) &&
|
||||
att.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||
}
|
||||
|
||||
/** User-facing status copy for an unavailable audio/video attachment. */
|
||||
getMediaAttachmentStatusText(att: Attachment): string {
|
||||
if (att.requestError)
|
||||
return att.requestError;
|
||||
@@ -1038,7 +1001,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
: 'Waiting for audio source…';
|
||||
}
|
||||
|
||||
/** Action label for requesting an audio/video attachment. */
|
||||
getMediaAttachmentActionLabel(att: Attachment): string {
|
||||
if (this.requiresMediaDownloadAcceptance(att)) {
|
||||
return att.requestError ? 'Retry download' : 'Accept download';
|
||||
@@ -1047,7 +1009,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return att.requestError ? 'Retry' : 'Request';
|
||||
}
|
||||
|
||||
/** Remove a pending file from the upload queue. */
|
||||
removePendingFile(file: File): void {
|
||||
const idx = this.pendingFiles.findIndex((pending) => pending === file);
|
||||
|
||||
@@ -1056,7 +1017,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** Download a completed attachment to the user's device. */
|
||||
async downloadAttachment(att: Attachment): Promise<void> {
|
||||
if (!att.available || !att.objectUrl)
|
||||
return;
|
||||
@@ -1126,38 +1086,30 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
});
|
||||
}
|
||||
|
||||
/** Request a file attachment to be transferred from the uploader peer. */
|
||||
requestAttachment(att: Attachment, messageId: string): void {
|
||||
this.attachmentsSvc.requestFile(messageId, att);
|
||||
}
|
||||
|
||||
/** Cancel an in-progress attachment transfer request. */
|
||||
cancelAttachment(att: Attachment, messageId: string): void {
|
||||
this.attachmentsSvc.cancelRequest(messageId, att);
|
||||
}
|
||||
|
||||
/** Check whether the current user is the original uploader of an attachment. */
|
||||
isUploader(att: Attachment): boolean {
|
||||
const myUserId = this.currentUser()?.id;
|
||||
|
||||
return !!att.uploaderPeerId && !!myUserId && att.uploaderPeerId === myUserId;
|
||||
}
|
||||
|
||||
/** Open the image lightbox for a completed image attachment. */
|
||||
// ---- Image lightbox ----
|
||||
openLightbox(att: Attachment): void {
|
||||
if (att.available && att.objectUrl) {
|
||||
this.lightboxAttachment.set(att);
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the image lightbox. */
|
||||
closeLightbox(): void {
|
||||
this.lightboxAttachment.set(null);
|
||||
}
|
||||
|
||||
/** Open a context menu on right-click of an image attachment. */
|
||||
// ---- Image context menu ----
|
||||
openImageContextMenu(event: MouseEvent, att: Attachment): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -1166,12 +1118,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
attachment: att });
|
||||
}
|
||||
|
||||
/** Close the image context menu. */
|
||||
closeImageContextMenu(): void {
|
||||
this.imageContextMenu.set(null);
|
||||
}
|
||||
|
||||
/** Copy an image attachment to the system clipboard as PNG. */
|
||||
async copyImageToClipboard(att: Attachment): Promise<void> {
|
||||
this.closeImageContextMenu();
|
||||
|
||||
@@ -1185,9 +1135,7 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
const pngBlob = await this.convertToPng(blob);
|
||||
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
|
||||
} catch (_error) {
|
||||
// Failed to copy image to clipboard
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private convertToPng(blob: Blob): Promise<Blob> {
|
||||
@@ -1269,7 +1217,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
return `})`;
|
||||
}
|
||||
|
||||
/** Auto-resize the textarea to fit its content up to 520px, then allow scrolling. */
|
||||
autoResizeTextarea(): void {
|
||||
const el = this.messageInputRef?.nativeElement;
|
||||
|
||||
@@ -1282,7 +1229,6 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
this.updateScrollPadding();
|
||||
}
|
||||
|
||||
/** Keep scroll container bottom-padding in sync with the floating bottom bar height. */
|
||||
private updateScrollPadding(): void {
|
||||
requestAnimationFrame(() => {
|
||||
const bar = this.bottomBar?.nativeElement;
|
||||
@@ -1295,12 +1241,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
});
|
||||
}
|
||||
|
||||
/** Show the markdown toolbar when the input gains focus. */
|
||||
onInputFocus(): void {
|
||||
this.toolbarVisible.set(true);
|
||||
}
|
||||
|
||||
/** Hide the markdown toolbar after a brief delay when the input loses focus. */
|
||||
onInputBlur(): void {
|
||||
setTimeout(() => {
|
||||
if (!this.toolbarHovering) {
|
||||
@@ -1309,12 +1253,10 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}, 150);
|
||||
}
|
||||
|
||||
/** Track mouse entry on the toolbar to prevent premature hiding. */
|
||||
onToolbarMouseEnter(): void {
|
||||
this.toolbarHovering = true;
|
||||
}
|
||||
|
||||
/** Track mouse leave on the toolbar; hide if input is not focused. */
|
||||
onToolbarMouseLeave(): void {
|
||||
this.toolbarHovering = false;
|
||||
|
||||
@@ -1323,19 +1265,16 @@ export class ChatMessagesComponent implements AfterViewChecked, OnInit, OnDestro
|
||||
}
|
||||
}
|
||||
|
||||
/** Handle Ctrl key down for enabling manual resize. */
|
||||
onDocKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control')
|
||||
this.ctrlHeld.set(true);
|
||||
}
|
||||
|
||||
/** Handle Ctrl key up for disabling manual resize. */
|
||||
onDocKeyup(event: KeyboardEvent): void {
|
||||
if (event.key === 'Control')
|
||||
this.ctrlHeld.set(false);
|
||||
}
|
||||
|
||||
/** Scroll to the newest message and dismiss the new-messages snackbar. */
|
||||
readLatest(): void {
|
||||
this.shouldScrollToBottom = true;
|
||||
this.scrollToBottomSmooth();
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
RoomMember,
|
||||
Room,
|
||||
User
|
||||
} from '../../../core/models';
|
||||
} from '../../../core/models/index';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type TabView = 'channels' | 'users';
|
||||
@@ -84,9 +84,6 @@ type TabView = 'channels' | 'users';
|
||||
],
|
||||
templateUrl: './rooms-side-panel.component.html'
|
||||
})
|
||||
/**
|
||||
* Side panel listing text and voice channels, online users, and channel management actions.
|
||||
*/
|
||||
export class RoomsSidePanelComponent {
|
||||
private store = inject(Store);
|
||||
private webrtc = inject(WebRTCService);
|
||||
@@ -129,35 +126,28 @@ export class RoomsSidePanelComponent {
|
||||
return memberIds.size;
|
||||
});
|
||||
|
||||
// Channel context menu state
|
||||
showChannelMenu = signal(false);
|
||||
channelMenuX = signal(0);
|
||||
channelMenuY = signal(0);
|
||||
contextChannel = signal<Channel | null>(null);
|
||||
|
||||
// Rename state
|
||||
renamingChannelId = signal<string | null>(null);
|
||||
|
||||
// Create channel dialog state
|
||||
showCreateChannelDialog = signal(false);
|
||||
createChannelType = signal<'text' | 'voice'>('text');
|
||||
newChannelName = '';
|
||||
|
||||
// User context menu state
|
||||
showUserMenu = signal(false);
|
||||
userMenuX = signal(0);
|
||||
userMenuY = signal(0);
|
||||
contextMenuUser = signal<User | null>(null);
|
||||
|
||||
// Per-user volume context menu state
|
||||
showVolumeMenu = signal(false);
|
||||
volumeMenuX = signal(0);
|
||||
volumeMenuY = signal(0);
|
||||
volumeMenuPeerId = signal('');
|
||||
volumeMenuDisplayName = signal('');
|
||||
|
||||
/** Return online users excluding the current user. */
|
||||
// Filter out current user from online users list
|
||||
onlineUsersFiltered() {
|
||||
const current = this.currentUser();
|
||||
const currentId = current?.id;
|
||||
@@ -170,7 +160,6 @@ export class RoomsSidePanelComponent {
|
||||
return member.oderId || member.id;
|
||||
}
|
||||
|
||||
/** Check whether the current user has permission to manage channels. */
|
||||
canManageChannels(): boolean {
|
||||
const room = this.currentRoom();
|
||||
const user = this.currentUser();
|
||||
@@ -178,7 +167,6 @@ export class RoomsSidePanelComponent {
|
||||
if (!room || !user)
|
||||
return false;
|
||||
|
||||
// Owner always can
|
||||
if (room.hostId === user.id)
|
||||
return true;
|
||||
|
||||
@@ -193,17 +181,13 @@ export class RoomsSidePanelComponent {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Select a text channel (no-op if currently renaming). */
|
||||
// ---- Text channel selection ----
|
||||
selectTextChannel(channelId: string) {
|
||||
if (this.renamingChannelId())
|
||||
return; // don't switch while renaming
|
||||
return;
|
||||
|
||||
this.store.dispatch(RoomsActions.selectChannel({ channelId }));
|
||||
}
|
||||
|
||||
/** Open the context menu for a channel at the cursor position. */
|
||||
// ---- Channel context menu ----
|
||||
openChannelContextMenu(evt: MouseEvent, channel: Channel) {
|
||||
evt.preventDefault();
|
||||
this.contextChannel.set(channel);
|
||||
@@ -212,12 +196,10 @@ export class RoomsSidePanelComponent {
|
||||
this.showChannelMenu.set(true);
|
||||
}
|
||||
|
||||
/** Close the channel context menu. */
|
||||
closeChannelMenu() {
|
||||
this.showChannelMenu.set(false);
|
||||
}
|
||||
|
||||
/** Begin inline renaming of the context-menu channel. */
|
||||
startRename() {
|
||||
const ch = this.contextChannel();
|
||||
|
||||
@@ -228,7 +210,6 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit the channel rename from the inline input value. */
|
||||
confirmRename(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const name = input.value.trim();
|
||||
@@ -241,12 +222,10 @@ export class RoomsSidePanelComponent {
|
||||
this.renamingChannelId.set(null);
|
||||
}
|
||||
|
||||
/** Cancel the inline rename operation. */
|
||||
cancelRename() {
|
||||
this.renamingChannelId.set(null);
|
||||
}
|
||||
|
||||
/** Delete the context-menu channel. */
|
||||
deleteChannel() {
|
||||
const ch = this.contextChannel();
|
||||
|
||||
@@ -257,7 +236,6 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Trigger a message inventory re-sync from all connected peers. */
|
||||
resyncMessages() {
|
||||
this.closeChannelMenu();
|
||||
const room = this.currentRoom();
|
||||
@@ -266,36 +244,26 @@ export class RoomsSidePanelComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dispatch startSync for UI spinner
|
||||
this.store.dispatch(MessagesActions.startSync());
|
||||
|
||||
// Request inventory from all connected peers
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (peers.length === 0) {
|
||||
// No connected peers - sync will time out
|
||||
}
|
||||
|
||||
const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id };
|
||||
|
||||
peers.forEach((pid) => {
|
||||
try {
|
||||
this.webrtc.sendToPeer(pid, inventoryRequest);
|
||||
} catch (_error) {
|
||||
// Failed to send inventory request to this peer
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the create-channel dialog for the given channel type. */
|
||||
// ---- Create channel ----
|
||||
createChannel(type: 'text' | 'voice') {
|
||||
this.createChannelType.set(type);
|
||||
this.newChannelName = '';
|
||||
this.showCreateChannelDialog.set(true);
|
||||
}
|
||||
|
||||
/** Confirm channel creation and dispatch the add-channel action. */
|
||||
confirmCreateChannel() {
|
||||
const name = this.newChannelName.trim();
|
||||
|
||||
@@ -315,13 +283,10 @@ export class RoomsSidePanelComponent {
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
|
||||
/** Cancel channel creation and close the dialog. */
|
||||
cancelCreateChannel() {
|
||||
this.showCreateChannelDialog.set(false);
|
||||
}
|
||||
|
||||
/** Open the user context menu for admin actions (kick/role change). */
|
||||
// ---- User context menu (kick/role) ----
|
||||
openUserContextMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
|
||||
@@ -334,16 +299,12 @@ export class RoomsSidePanelComponent {
|
||||
this.showUserMenu.set(true);
|
||||
}
|
||||
|
||||
/** Close the user context menu. */
|
||||
closeUserMenu() {
|
||||
this.showUserMenu.set(false);
|
||||
}
|
||||
|
||||
/** Open the per-user volume context menu for a voice channel participant. */
|
||||
openVoiceUserVolumeMenu(evt: MouseEvent, user: User) {
|
||||
evt.preventDefault();
|
||||
|
||||
// Don't show volume menu for the local user
|
||||
const me = this.currentUser();
|
||||
|
||||
if (user.id === me?.id || user.oderId === me?.oderId)
|
||||
@@ -356,7 +317,6 @@ export class RoomsSidePanelComponent {
|
||||
this.showVolumeMenu.set(true);
|
||||
}
|
||||
|
||||
/** Change a user's role and broadcast the update to connected peers. */
|
||||
changeUserRole(role: 'admin' | 'moderator' | 'member') {
|
||||
const user = this.contextMenuUser();
|
||||
const roomId = this.currentRoom()?.id;
|
||||
@@ -365,7 +325,6 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role }));
|
||||
// Broadcast role change to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'role-change',
|
||||
roomId,
|
||||
@@ -375,7 +334,6 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Kick a user and broadcast the action to peers. */
|
||||
kickUserAction() {
|
||||
const user = this.contextMenuUser();
|
||||
|
||||
@@ -383,7 +341,6 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
if (user) {
|
||||
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
|
||||
// Broadcast kick to peers
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'kick',
|
||||
targetUserId: user.id,
|
||||
@@ -392,14 +349,10 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Join a voice channel, managing permissions and existing voice connections. */
|
||||
// ---- Voice ----
|
||||
joinVoice(roomId: string) {
|
||||
// Gate by room permissions
|
||||
const room = this.currentRoom();
|
||||
|
||||
if (room && room.permissions && room.permissions.allowVoice === false) {
|
||||
// Voice is disabled by room permissions
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -408,12 +361,8 @@ export class RoomsSidePanelComponent {
|
||||
|
||||
const current = this.currentUser();
|
||||
|
||||
// Check if already connected to voice in a DIFFERENT server - must disconnect first
|
||||
// Also handle stale voice state: if the store says connected but voice isn't actually active,
|
||||
// clear it so the user can join.
|
||||
if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) {
|
||||
if (!this.webrtc.isVoiceConnected()) {
|
||||
// Stale state - clear it so the user can proceed
|
||||
if (current.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
@@ -429,21 +378,16 @@ export class RoomsSidePanelComponent {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Already connected to voice in another server; must disconnect first
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If switching channels within the same server, just update the room
|
||||
const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId;
|
||||
// Enable microphone and broadcast voice-state
|
||||
const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice();
|
||||
|
||||
enableVoicePromise
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
.catch((_error) => {
|
||||
// Failed to join voice room
|
||||
});
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||
@@ -523,23 +467,18 @@ export class RoomsSidePanelComponent {
|
||||
});
|
||||
}
|
||||
|
||||
/** Leave a voice channel and broadcast the disconnect state. */
|
||||
leaveVoice(roomId: string) {
|
||||
const current = this.currentUser();
|
||||
|
||||
// Only leave if currently in this room
|
||||
if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId))
|
||||
return;
|
||||
|
||||
// Stop voice heartbeat
|
||||
this.webrtc.stopVoiceHeartbeat();
|
||||
|
||||
this.untrackCurrentUserMic();
|
||||
|
||||
// Disable voice locally
|
||||
this.webrtc.disableVoice();
|
||||
|
||||
// Update store voice state
|
||||
if (current?.id) {
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
@@ -555,7 +494,6 @@ export class RoomsSidePanelComponent {
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast disconnect
|
||||
this.webrtc.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
@@ -569,37 +507,31 @@ export class RoomsSidePanelComponent {
|
||||
}
|
||||
});
|
||||
|
||||
// End voice session
|
||||
this.voiceSessionService.endSession();
|
||||
}
|
||||
|
||||
/** Count the number of users connected to a voice channel in the current room. */
|
||||
voiceOccupancy(roomId: string): number {
|
||||
return this.voiceUsersInRoom(roomId).length;
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's screen share. */
|
||||
viewShare(userId: string) {
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
/** Dispatch a viewer:focus event to display a remote user's stream. */
|
||||
viewStream(userId: string) {
|
||||
const evt = new CustomEvent('viewer:focus', { detail: { userId } });
|
||||
|
||||
window.dispatchEvent(evt);
|
||||
}
|
||||
|
||||
/** Check whether the local user has muted a specific voice user. */
|
||||
isUserLocallyMuted(user: User): boolean {
|
||||
const peerId = user.oderId || user.id;
|
||||
|
||||
return this.voicePlayback.isUserMuted(peerId);
|
||||
}
|
||||
|
||||
/** Check whether a user is currently sharing their screen. */
|
||||
isUserSharing(userId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
|
||||
@@ -618,7 +550,6 @@ export class RoomsSidePanelComponent {
|
||||
return !!stream && stream.getVideoTracks().length > 0;
|
||||
}
|
||||
|
||||
/** Return all users currently connected to a specific voice channel, including the local user. */
|
||||
voiceUsersInRoom(roomId: string) {
|
||||
const room = this.currentRoom();
|
||||
const me = this.currentUser();
|
||||
@@ -626,13 +557,11 @@ export class RoomsSidePanelComponent {
|
||||
(user) => !!user.voiceState?.isConnected && user.voiceState?.roomId === roomId && user.voiceState?.serverId === room?.id
|
||||
);
|
||||
|
||||
// Include the local user at the top if they are in this voice channel
|
||||
if (
|
||||
me?.voiceState?.isConnected &&
|
||||
me.voiceState?.roomId === roomId &&
|
||||
me.voiceState?.serverId === room?.id
|
||||
) {
|
||||
// Avoid duplicates if the current user is already in onlineUsers
|
||||
const meId = me.id;
|
||||
const meOderId = me.oderId;
|
||||
const alreadyIncluded = remoteUsers.some(
|
||||
@@ -647,7 +576,6 @@ export class RoomsSidePanelComponent {
|
||||
return remoteUsers;
|
||||
}
|
||||
|
||||
/** Check whether the current user is connected to the specified voice channel. */
|
||||
isCurrentRoom(roomId: string): boolean {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
@@ -655,32 +583,18 @@ export class RoomsSidePanelComponent {
|
||||
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
||||
}
|
||||
|
||||
/** Check whether voice is enabled by the current room's permissions. */
|
||||
voiceEnabled(): boolean {
|
||||
const room = this.currentRoom();
|
||||
|
||||
return room?.permissions?.allowVoice !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the measured latency (ms) to a voice user.
|
||||
* Returns `null` when no measurement is available yet.
|
||||
*/
|
||||
getPeerLatency(user: User): number | null {
|
||||
const latencies = this.webrtc.peerLatencies();
|
||||
|
||||
// Try oderId first (primary peer key), then fall back to user id
|
||||
return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a Tailwind `bg-*` class representing the latency quality.
|
||||
* - green : < 100 ms
|
||||
* - yellow : 100-199 ms
|
||||
* - orange : 200-349 ms
|
||||
* - red : >= 350 ms
|
||||
* - gray : no data yet
|
||||
*/
|
||||
getPingColorClass(user: User): string {
|
||||
const ms = this.getPeerLatency(user);
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
selectRoomsError,
|
||||
selectSavedRooms
|
||||
} from '../../store/rooms/rooms.selectors';
|
||||
import { Room } from '../../core/models';
|
||||
import { ServerInfo } from '../../core/models';
|
||||
import { Room } from '../../core/models/index';
|
||||
import { ServerInfo } from '../../core/models/index';
|
||||
import { SettingsModalService } from '../../core/services/settings-modal.service';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePlus } from '@ng-icons/lucide';
|
||||
|
||||
import { Room } from '../../core/models';
|
||||
import { Room } from '../../core/models/index';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { VoiceSessionService } from '../../core/services/voice-session.service';
|
||||
@@ -31,9 +31,6 @@ import { ContextMenuComponent, LeaveServerDialogComponent } from '../../shared';
|
||||
viewProviders: [provideIcons({ lucidePlus })],
|
||||
templateUrl: './servers-rail.component.html'
|
||||
})
|
||||
/**
|
||||
* Vertical rail of saved server icons with context-menu actions for leaving/forgetting.
|
||||
*/
|
||||
export class ServersRailComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
@@ -42,15 +39,13 @@ export class ServersRailComponent {
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
// Context menu state
|
||||
showMenu = signal(false);
|
||||
menuX = signal(72); // default X: rail width (~64px) + padding
|
||||
menuY = signal(100); // default Y: arbitrary initial offset
|
||||
menuX = signal(72);
|
||||
menuY = signal(100);
|
||||
contextRoom = signal<Room | null>(null);
|
||||
showLeaveConfirm = signal(false);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
/** Return the first character of a server name as its icon initial. */
|
||||
initial(name?: string): string {
|
||||
if (!name)
|
||||
return '?';
|
||||
@@ -62,10 +57,7 @@ export class ServersRailComponent {
|
||||
|
||||
trackRoomId = (index: number, room: Room) => room.id;
|
||||
|
||||
/** Navigate to the server search view. Updates voice session state if applicable. */
|
||||
createServer(): void {
|
||||
// Navigate to server list (has create button)
|
||||
// Update voice session state if connected to voice
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId) {
|
||||
@@ -75,9 +67,7 @@ export class ServersRailComponent {
|
||||
this.router.navigate(['/search']);
|
||||
}
|
||||
|
||||
/** Join or switch to a saved room. Manages voice session and authentication state. */
|
||||
joinSavedRoom(room: Room): void {
|
||||
// Require auth: if no current user, go to login
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
@@ -85,24 +75,17 @@ export class ServersRailComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're navigating to a different server while in voice
|
||||
const voiceServerId = this.voiceSession.getVoiceServerId();
|
||||
|
||||
if (voiceServerId && voiceServerId !== room.id) {
|
||||
// User is switching to a different server while connected to voice
|
||||
// Update voice session to show floating controls (voice stays connected)
|
||||
this.voiceSession.setViewingVoiceServer(false);
|
||||
} else if (voiceServerId === room.id) {
|
||||
// Navigating back to the voice-connected server
|
||||
this.voiceSession.setViewingVoiceServer(true);
|
||||
}
|
||||
|
||||
// If we've already joined this server, just switch the view
|
||||
// (no user_joined broadcast, no leave from other servers)
|
||||
if (this.webrtc.hasJoinedServer(room.id)) {
|
||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
||||
} else {
|
||||
// First time joining this server
|
||||
this.store.dispatch(
|
||||
RoomsActions.joinRoom({
|
||||
roomId: room.id,
|
||||
@@ -116,23 +99,18 @@ export class ServersRailComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the context menu positioned near the cursor for a given room. */
|
||||
openContextMenu(evt: MouseEvent, room: Room): void {
|
||||
evt.preventDefault();
|
||||
this.contextRoom.set(room);
|
||||
// Offset 8px right to avoid overlapping the rail; floor at rail width (72px)
|
||||
this.menuX.set(Math.max(evt.clientX + 8, 72));
|
||||
this.menuY.set(evt.clientY);
|
||||
this.showMenu.set(true);
|
||||
}
|
||||
|
||||
/** Close the context menu (keeps contextRoom for potential confirmation). */
|
||||
closeMenu(): void {
|
||||
this.showMenu.set(false);
|
||||
// keep contextRoom for potential confirmation dialog
|
||||
}
|
||||
|
||||
/** Check whether the context-menu room is the currently active room. */
|
||||
isCurrentContextRoom(): boolean {
|
||||
const ctx = this.contextRoom();
|
||||
const cur = this.currentRoom();
|
||||
@@ -140,7 +118,6 @@ export class ServersRailComponent {
|
||||
return !!ctx && !!cur && ctx.id === cur.id;
|
||||
}
|
||||
|
||||
/** Open the unified leave-server confirmation dialog. */
|
||||
openLeaveConfirm(): void {
|
||||
this.closeMenu();
|
||||
|
||||
@@ -149,7 +126,6 @@ export class ServersRailComponent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Confirm the merged leave flow and remove the server locally. */
|
||||
confirmLeave(result: { nextOwnerKey?: string }): void {
|
||||
const ctx = this.contextRoom();
|
||||
|
||||
@@ -171,7 +147,6 @@ export class ServersRailComponent {
|
||||
this.contextRoom.set(null);
|
||||
}
|
||||
|
||||
/** Cancel the leave-server confirmation dialog. */
|
||||
cancelLeave(): void {
|
||||
this.showLeaveConfirm.set(false);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { lucideX } from '@ng-icons/lucide';
|
||||
|
||||
import { Room, BanEntry } from '../../../../core/models';
|
||||
import { Room, BanEntry } from '../../../../core/models/index';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectBannedUsers } from '../../../../store/users/users.selectors';
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { lucideUserX, lucideBan } from '@ng-icons/lucide';
|
||||
|
||||
import { Room, User } from '../../../../core/models';
|
||||
import { Room, User } from '../../../../core/models/index';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { WebRTCService } from '../../../../core/services/webrtc.service';
|
||||
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
|
||||
|
||||
@@ -11,7 +11,7 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { lucideCheck } from '@ng-icons/lucide';
|
||||
|
||||
import { Room } from '../../../../core/models';
|
||||
import { Room } from '../../../../core/models/index';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
lucideUnlock
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { Room } from '../../../../core/models';
|
||||
import { Room } from '../../../../core/models/index';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { Room } from '../../../core/models';
|
||||
import { Room } from '../../../core/models/index';
|
||||
|
||||
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
|
||||
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
|
||||
@@ -69,16 +69,13 @@ export class SettingsModalComponent {
|
||||
|
||||
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
|
||||
|
||||
// --- Selectors ---
|
||||
savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
// --- Modal state ---
|
||||
isOpen = this.modal.isOpen;
|
||||
activePage = this.modal.activePage;
|
||||
|
||||
// --- Side-nav items ---
|
||||
readonly globalPages: { id: SettingsPage; label: string; icon: string }[] = [
|
||||
{ id: 'network',
|
||||
label: 'Network',
|
||||
@@ -102,7 +99,6 @@ export class SettingsModalComponent {
|
||||
icon: 'lucideShield' }
|
||||
];
|
||||
|
||||
// ===== SERVER SELECTOR =====
|
||||
selectedServerId = signal<string | null>(null);
|
||||
selectedServer = computed<Room | null>(() => {
|
||||
const id = this.selectedServerId();
|
||||
@@ -113,12 +109,10 @@ export class SettingsModalComponent {
|
||||
return this.savedRooms().find((room) => room.id === id) ?? null;
|
||||
});
|
||||
|
||||
/** Whether the user can see server-admin tabs. */
|
||||
showServerTabs = computed(() => {
|
||||
return this.savedRooms().length > 0 && !!this.selectedServerId();
|
||||
});
|
||||
|
||||
/** Whether the current user is the host/owner of the selected server. */
|
||||
isSelectedServerAdmin = computed(() => {
|
||||
const server = this.selectedServer();
|
||||
const user = this.currentUser();
|
||||
@@ -129,12 +123,10 @@ export class SettingsModalComponent {
|
||||
return server.hostId === user.id || server.hostId === user.oderId;
|
||||
});
|
||||
|
||||
// Animation
|
||||
animating = signal(false);
|
||||
showThirdPartyLicenses = signal(false);
|
||||
|
||||
constructor() {
|
||||
// Sync selected server when modal opens with a target
|
||||
effect(() => {
|
||||
if (this.isOpen()) {
|
||||
const targetId = this.modal.targetServerId();
|
||||
@@ -153,7 +145,6 @@ export class SettingsModalComponent {
|
||||
}
|
||||
});
|
||||
|
||||
// When selected server changes, reload permissions data
|
||||
effect(() => {
|
||||
const server = this.selectedServer();
|
||||
|
||||
@@ -178,7 +169,6 @@ export class SettingsModalComponent {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MODAL CONTROLS =====
|
||||
close(): void {
|
||||
this.showThirdPartyLicenses.set(false);
|
||||
this.animating.set(false);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { DEFAULT_VOLUME } from '../../../core/constants';
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -18,13 +18,9 @@ export interface PlaybackOptions {
|
||||
* the GainNode -> AudioContext.destination path.
|
||||
*/
|
||||
interface PeerAudioPipeline {
|
||||
/** Muted <audio> element that "primes" the stream for Web Audio. */
|
||||
audioElement: HTMLAudioElement;
|
||||
/** AudioContext for this peer's pipeline. */
|
||||
context: AudioContext;
|
||||
/** Source node created from the remote stream. */
|
||||
sourceNode: MediaStreamAudioSourceNode;
|
||||
/** GainNode used to control per-user volume (0.0-2.0). */
|
||||
gainNode: GainNode;
|
||||
}
|
||||
|
||||
@@ -32,34 +28,18 @@ interface PeerAudioPipeline {
|
||||
export class VoicePlaybackService {
|
||||
private webrtc = inject(WebRTCService);
|
||||
|
||||
/** Active Web Audio pipelines keyed by peer ID. */
|
||||
private peerPipelines = new Map<string, PeerAudioPipeline>();
|
||||
private pendingRemoteStreams = new Map<string, MediaStream>();
|
||||
private rawRemoteStreams = new Map<string, MediaStream>();
|
||||
|
||||
/**
|
||||
* Per-user volume overrides (0-200 integer, maps to 0.0-2.0 gain).
|
||||
* Keyed by oderId so the setting persists across reconnections.
|
||||
*/
|
||||
private userVolumes = new Map<string, number>();
|
||||
|
||||
/** Per-user mute state. Keyed by oderId. */
|
||||
private userMuted = new Map<string, boolean>();
|
||||
|
||||
/** Global master output volume (0.0-1.0 from the settings slider). */
|
||||
private masterVolume = 1;
|
||||
|
||||
/** Whether the local user is deafened. */
|
||||
private deafened = false;
|
||||
|
||||
constructor() {
|
||||
this.loadPersistedVolumes();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API - stream lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
handleRemoteStream(peerId: string, stream: MediaStream, options: PlaybackOptions): void {
|
||||
if (!options.isConnected) {
|
||||
this.pendingRemoteStreams.set(peerId, stream);
|
||||
@@ -110,10 +90,6 @@ export class VoicePlaybackService {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global volume / deafen (master slider from settings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
updateOutputVolume(volume: number): void {
|
||||
this.masterVolume = volume;
|
||||
this.recalcAllGains();
|
||||
@@ -124,16 +100,10 @@ export class VoicePlaybackService {
|
||||
this.recalcAllGains();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-user volume (0-200%) and mute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Get the per-user volume for a peer (0-200). Defaults to 100. */
|
||||
getUserVolume(peerId: string): number {
|
||||
return this.userVolumes.get(peerId) ?? 100;
|
||||
}
|
||||
|
||||
/** Set per-user volume (0-200) and update the gain node in real time. */
|
||||
setUserVolume(peerId: string, volume: number): void {
|
||||
const clamped = Math.max(0, Math.min(200, volume));
|
||||
|
||||
@@ -142,22 +112,16 @@ export class VoicePlaybackService {
|
||||
this.persistVolumes();
|
||||
}
|
||||
|
||||
/** Whether a specific user is muted by the local user. */
|
||||
isUserMuted(peerId: string): boolean {
|
||||
return this.userMuted.get(peerId) ?? false;
|
||||
}
|
||||
|
||||
/** Toggle per-user mute. */
|
||||
setUserMuted(peerId: string, muted: boolean): void {
|
||||
this.userMuted.set(peerId, muted);
|
||||
this.applyGain(peerId);
|
||||
this.persistVolumes();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output device routing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
applyOutputDevice(deviceId: string): void {
|
||||
if (!deviceId)
|
||||
return;
|
||||
@@ -180,10 +144,6 @@ export class VoicePlaybackService {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Teardown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
teardownAll(): void {
|
||||
this.peerPipelines.forEach((_pipeline, peerId) => this.removePipeline(peerId));
|
||||
this.peerPipelines.clear();
|
||||
@@ -191,10 +151,6 @@ export class VoicePlaybackService {
|
||||
this.pendingRemoteStreams.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private - Web Audio pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the Web Audio graph for a remote peer:
|
||||
*
|
||||
@@ -205,14 +161,13 @@ export class VoicePlaybackService {
|
||||
* MediaStreamSource → GainNode → AudioContext.destination
|
||||
*/
|
||||
private createPipeline(peerId: string, stream: MediaStream): void {
|
||||
// 1) Chrome/Electron workaround: attach stream to a muted <audio>
|
||||
// Chromium/Electron needs a muted <audio> element before Web Audio can read the stream.
|
||||
const audioEl = new Audio();
|
||||
|
||||
audioEl.srcObject = stream;
|
||||
audioEl.muted = true; // silent - we route audio through Web Audio API
|
||||
audioEl.muted = true;
|
||||
audioEl.play().catch(() => {});
|
||||
|
||||
// 2) Set up Web Audio graph
|
||||
const ctx = new AudioContext();
|
||||
const sourceNode = ctx.createMediaStreamSource(stream);
|
||||
const gainNode = ctx.createGain();
|
||||
@@ -220,16 +175,13 @@ export class VoicePlaybackService {
|
||||
sourceNode.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
// 3) Store pipeline
|
||||
const pipeline: PeerAudioPipeline = { audioElement: audioEl, context: ctx, sourceNode, gainNode };
|
||||
|
||||
this.peerPipelines.set(peerId, pipeline);
|
||||
|
||||
// 4) Apply current gain
|
||||
this.applyGain(peerId);
|
||||
}
|
||||
|
||||
/** Disconnect and clean up all nodes for a single peer. */
|
||||
private removePipeline(peerId: string): void {
|
||||
const pipeline = this.peerPipelines.get(peerId);
|
||||
|
||||
@@ -253,14 +205,6 @@ export class VoicePlaybackService {
|
||||
this.peerPipelines.delete(peerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute and apply the effective gain for a peer.
|
||||
*
|
||||
* effectiveGain = masterVolume × (userVolume / 100)
|
||||
*
|
||||
* If the user is deafened or the peer is individually muted the gain
|
||||
* is set to 0.
|
||||
*/
|
||||
private applyGain(peerId: string): void {
|
||||
const pipeline = this.peerPipelines.get(peerId);
|
||||
|
||||
@@ -278,15 +222,10 @@ export class VoicePlaybackService {
|
||||
pipeline.gainNode.gain.value = effective;
|
||||
}
|
||||
|
||||
/** Recalculate gain for every active pipeline. */
|
||||
private recalcAllGains(): void {
|
||||
this.peerPipelines.forEach((_pipeline, peerId) => this.applyGain(peerId));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persistence helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private persistVolumes(): void {
|
||||
try {
|
||||
const data: Record<string, { volume: number; muted: boolean }> = {};
|
||||
@@ -331,10 +270,6 @@ export class VoicePlaybackService {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private hasAudio(stream: MediaStream): boolean {
|
||||
return stream.getAudioTracks().length > 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user