2026-03-09 19:35:08 +01:00
|
|
|
import { CommonModule } from '@angular/common';
|
2026-03-10 02:49:27 +01:00
|
|
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
2026-03-09 19:35:08 +01:00
|
|
|
import { toSignal } from '@angular/core/rxjs-interop';
|
|
|
|
|
import { FormsModule } from '@angular/forms';
|
|
|
|
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
|
|
|
|
2026-03-10 02:49:27 +01:00
|
|
|
import { PeerVideoModalComponent } from './peer-video-modal.component';
|
2026-03-09 19:35:08 +01:00
|
|
|
import { ChatSessionService } from './chat-session.service';
|
2026-03-09 20:40:21 +01:00
|
|
|
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
2026-03-10 02:49:27 +01:00
|
|
|
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
|
2026-03-09 19:35:08 +01:00
|
|
|
|
|
|
|
|
@Component({
|
|
|
|
|
selector: 'app-chat-page',
|
2026-03-10 02:49:27 +01:00
|
|
|
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
|
2026-03-09 19:35:08 +01:00
|
|
|
templateUrl: './chat-page.component.html',
|
|
|
|
|
styleUrl: './chat-page.component.scss',
|
|
|
|
|
})
|
|
|
|
|
export class ChatPageComponent {
|
|
|
|
|
private readonly route = inject(ActivatedRoute);
|
|
|
|
|
private readonly router = inject(Router);
|
|
|
|
|
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
|
|
|
|
initialValue: this.route.snapshot.paramMap,
|
|
|
|
|
});
|
2026-03-10 02:49:27 +01:00
|
|
|
private composerSelectionStart = 0;
|
|
|
|
|
private composerSelectionEnd = 0;
|
2026-03-09 19:35:08 +01:00
|
|
|
|
|
|
|
|
messageText = '';
|
2026-03-10 02:49:27 +01:00
|
|
|
readonly forwardingEntryId = signal<string | null>(null);
|
|
|
|
|
readonly emojiPickerOpen = signal(false);
|
|
|
|
|
readonly emojiOptions = [
|
|
|
|
|
'😀', '😁', '😂', '🤣', '😊',
|
|
|
|
|
'😉', '😍', '😘', '😎', '🤔',
|
|
|
|
|
'😅', '😭', '😡', '😴', '🙃',
|
|
|
|
|
'👍', '👎', '👏', '🙏', '🤝',
|
|
|
|
|
'🎉', '🔥', '❤️', '💡', '✅',
|
|
|
|
|
'🚀', '👀', '📹', '📎', '💬',
|
|
|
|
|
'🌍', '⚡', '⭐', '🎵', '📷',
|
|
|
|
|
'🗑️', '⏩', '🛑', '🙌', '👌',
|
|
|
|
|
];
|
2026-03-09 19:35:08 +01:00
|
|
|
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
|
|
|
|
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
|
|
|
|
readonly currentUser = computed(() => this.session.currentUser());
|
|
|
|
|
readonly conversation = computed(() =>
|
|
|
|
|
this.session
|
|
|
|
|
.messages()
|
|
|
|
|
.filter((entry) => entry.peerId === this.peerId()),
|
|
|
|
|
);
|
2026-03-10 02:49:27 +01:00
|
|
|
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
|
|
|
|
|
readonly remoteVideoModalVisible = computed(
|
|
|
|
|
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
|
|
|
|
|
);
|
2026-03-09 19:35:08 +01:00
|
|
|
readonly webRtcState = computed<ConnectionState>(() => {
|
|
|
|
|
const selectedPeer = this.peer();
|
|
|
|
|
|
|
|
|
|
if (!selectedPeer) {
|
|
|
|
|
return 'disconnected';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected') {
|
|
|
|
|
return 'connected';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (selectedPeer.channelState === 'connecting' || selectedPeer.connectionState === 'connecting') {
|
|
|
|
|
return 'connecting';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'disconnected';
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
constructor(readonly session: ChatSessionService) {
|
|
|
|
|
if (!this.session.currentUser()) {
|
|
|
|
|
void this.router.navigateByUrl('/');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
effect(() => {
|
|
|
|
|
const peerId = this.peerId();
|
|
|
|
|
|
|
|
|
|
if (!peerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.session.selectPeer(peerId);
|
|
|
|
|
void this.session.connectToPeer(peerId);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ensureConnection(): Promise<void> {
|
|
|
|
|
const peerId = this.peerId();
|
|
|
|
|
|
|
|
|
|
if (!peerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.session.selectPeer(peerId);
|
|
|
|
|
await this.session.connectToPeer(peerId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async sendMessage(): Promise<void> {
|
|
|
|
|
const peerId = this.peerId();
|
|
|
|
|
|
|
|
|
|
if (!peerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.session.sendText(peerId, this.messageText);
|
|
|
|
|
this.messageText = '';
|
2026-03-10 02:49:27 +01:00
|
|
|
this.emojiPickerOpen.set(false);
|
|
|
|
|
this.composerSelectionStart = 0;
|
|
|
|
|
this.composerSelectionEnd = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async requestGeneratedImage(): Promise<void> {
|
|
|
|
|
const peerId = this.peerId();
|
|
|
|
|
|
|
|
|
|
if (!peerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.session.requestGeneratedImage(peerId, this.messageText);
|
2026-03-09 19:35:08 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleComposerEnter(event: Event): void {
|
|
|
|
|
if (!(event instanceof KeyboardEvent) || event.shiftKey) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
void this.sendMessage();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleMessageTextChange(text: string): void {
|
|
|
|
|
const peerId = this.peerId();
|
|
|
|
|
|
|
|
|
|
if (!peerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.session.notifyTypingActivity(peerId, text);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:49:27 +01:00
|
|
|
trackComposerSelection(textarea: HTMLTextAreaElement): void {
|
|
|
|
|
this.composerSelectionStart = textarea.selectionStart ?? this.messageText.length;
|
|
|
|
|
this.composerSelectionEnd = textarea.selectionEnd ?? this.composerSelectionStart;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleEmojiPicker(event?: Event): void {
|
|
|
|
|
event?.stopPropagation();
|
|
|
|
|
this.emojiPickerOpen.update((open) => !open);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
insertEmoji(emoji: string, textarea: HTMLTextAreaElement): void {
|
|
|
|
|
const selectionStart = textarea.selectionStart ?? this.composerSelectionStart;
|
|
|
|
|
const selectionEnd = textarea.selectionEnd ?? this.composerSelectionEnd;
|
|
|
|
|
const before = this.messageText.slice(0, selectionStart);
|
|
|
|
|
const after = this.messageText.slice(selectionEnd);
|
|
|
|
|
|
|
|
|
|
this.messageText = `${before}${emoji}${after}`;
|
|
|
|
|
this.emojiPickerOpen.set(false);
|
|
|
|
|
this.handleMessageTextChange(this.messageText);
|
|
|
|
|
|
|
|
|
|
const nextSelection = selectionStart + emoji.length;
|
|
|
|
|
this.composerSelectionStart = nextSelection;
|
|
|
|
|
this.composerSelectionEnd = nextSelection;
|
|
|
|
|
|
|
|
|
|
queueMicrotask(() => {
|
|
|
|
|
textarea.focus();
|
|
|
|
|
textarea.setSelectionRange(nextSelection, nextSelection);
|
|
|
|
|
this.trackComposerSelection(textarea);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
|
|
|
|
|
const file = input.files?.item(0);
|
|
|
|
|
|
|
|
|
|
if (!file) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.session.sendFile(peerId, file);
|
|
|
|
|
input.value = '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async deleteMessage(entry: ChatEntry): Promise<void> {
|
|
|
|
|
await this.session.deleteMessage(entry);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:40:21 +01:00
|
|
|
async deleteConversation(peerId: string, event?: Event): Promise<void> {
|
|
|
|
|
event?.stopPropagation();
|
|
|
|
|
await this.session.deleteConversation(peerId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:49:27 +01:00
|
|
|
toggleForwardMenu(entry: ChatEntry, event?: Event): void {
|
|
|
|
|
event?.stopPropagation();
|
|
|
|
|
|
|
|
|
|
if (entry.kind === 'system' || entry.direction === 'system' || this.forwardTargets(entry).length === 0) {
|
|
|
|
|
this.forwardingEntryId.set(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.forwardingEntryId.update((currentEntryId) => (currentEntryId === entry.id ? null : entry.id));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isForwardMenuOpen(entryId: string): boolean {
|
|
|
|
|
return this.forwardingEntryId() === entryId;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
forwardTargets(entry: ChatEntry): PeerSummary[] {
|
|
|
|
|
if (entry.kind === 'system' || entry.direction === 'system') {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
|
|
|
|
|
if (!targetPeerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.session.forwardMessage(targetPeerId, entry);
|
|
|
|
|
select.value = '';
|
|
|
|
|
this.forwardingEntryId.set(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async toggleCameraStream(peerId: string): Promise<void> {
|
|
|
|
|
if (this.session.isStreamingCameraToPeer(peerId)) {
|
|
|
|
|
await this.session.stopCameraStream(peerId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.session.startCameraStream(peerId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
isImageEntry(entry: ChatEntry): boolean {
|
|
|
|
|
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:49:27 +01:00
|
|
|
isVideoEntry(entry: ChatEntry): boolean {
|
|
|
|
|
if (entry.kind !== 'file' || !entry.downloadUrl) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entry.fileMimeType?.startsWith('video/')) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return /\.(mp4|webm|ogg|ogv|mov|m4v)$/i.test(entry.fileName ?? '');
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:40:21 +01:00
|
|
|
isIncomingJsonFileEntry(entry: ChatEntry): boolean {
|
|
|
|
|
return (
|
|
|
|
|
entry.kind === 'file' &&
|
|
|
|
|
entry.direction === 'incoming' &&
|
|
|
|
|
!!entry.downloadUrl &&
|
|
|
|
|
!!entry.fileName &&
|
|
|
|
|
entry.fileName.toLowerCase().endsWith('.json')
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
isPeerTyping(peerId: string): boolean {
|
|
|
|
|
return this.session.typingPeerIds().includes(peerId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
|
|
|
|
|
if (state === 'connected') {
|
|
|
|
|
return 'ok';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state === 'connecting') {
|
|
|
|
|
return 'connecting';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'offline';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 20:09:46 +01:00
|
|
|
canReconnectWebRtc(): boolean {
|
|
|
|
|
return this.indicatorTone(this.webRtcState()) === 'offline';
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:49:27 +01:00
|
|
|
isStreamingCameraToSelectedPeer(): boolean {
|
|
|
|
|
const peerId = this.peerId();
|
|
|
|
|
|
|
|
|
|
return !!peerId && this.session.isStreamingCameraToPeer(peerId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
closeRemoteVideoModal(): void {
|
|
|
|
|
const peerId = this.peerId();
|
|
|
|
|
|
|
|
|
|
if (!peerId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.session.dismissRemoteVideoModal(peerId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
async switchPeer(peerId: string): Promise<void> {
|
|
|
|
|
if (!peerId || peerId === this.peerId()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-10 02:49:27 +01:00
|
|
|
this.forwardingEntryId.set(null);
|
|
|
|
|
this.emojiPickerOpen.set(false);
|
2026-03-09 19:35:08 +01:00
|
|
|
this.session.selectPeer(peerId);
|
|
|
|
|
await this.router.navigate(['/chat', peerId]);
|
|
|
|
|
}
|
|
|
|
|
}
|