Many features

This commit is contained in:
2026-03-10 22:36:21 +01:00
parent df309d088c
commit d2c4152ea7
12 changed files with 1034 additions and 120 deletions

View File

@@ -68,8 +68,8 @@
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "10kB",
"maximumError": "8kB" "maximumError": "12kB"
} }
], ],
"outputHashing": "all" "outputHashing": "all"

Binary file not shown.

View File

@@ -7,6 +7,36 @@
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'" [title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
(closeRequested)="closeRemoteVideoModal()" (closeRequested)="closeRemoteVideoModal()"
></app-peer-video-modal> ></app-peer-video-modal>
<audio #callAudioElement hidden autoplay playsinline></audio>
@if (incomingVoiceCallPeer(); as callingPeer) {
<div class="call-modal-backdrop">
<section class="panel p-4" style="width:min(100%,24rem)" (click)="$event.stopPropagation()">
<div class="mb-3">
<div>
<h2 class="h5 mb-1">Incoming voice call</h2>
<p class="small mb-0">{{ callingPeer.displayName }} is calling you.</p>
</div>
</div>
<div class="d-flex flex-wrap gap-2 justify-content-end">
<button
class="btn btn-success"
type="button"
(click)="acceptIncomingVoiceCall(callingPeer.id)"
>
Accept
</button>
<button
class="btn btn-outline-secondary"
type="button"
(click)="rejectIncomingVoiceCall(callingPeer.id)"
>
Reject
</button>
</div>
</section>
</div>
}
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4"> <div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
<div> <div>
@@ -49,7 +79,11 @@
} }
@for (connectedPeer of session.peers(); track connectedPeer.id) { @for (connectedPeer of session.peers(); track connectedPeer.id) {
<article class="peer-tile" [class.peer-tile-active]="connectedPeer.id === peerId()"> <article
class="peer-tile"
[class.peer-tile-active]="connectedPeer.id === peerId()"
[class.peer-tile-unread]="isPeerUnread(connectedPeer.id)"
>
<button <button
class="peer-tile-main text-start" class="peer-tile-main text-start"
type="button" type="button"
@@ -189,6 +223,19 @@
} }
</div> </div>
} }
@case ('voice') {
<div class="voice-bubble">
<div class="voice-bubble-label">Voice message</div>
@if (entry.downloadUrl) {
<audio
class="voice-player"
[src]="entry.downloadUrl"
controls
preload="metadata"
></audio>
}
</div>
}
@default { @default {
@if (entry.showSpinner) { @if (entry.showSpinner) {
<div class="bubble-system-status"> <div class="bubble-system-status">
@@ -205,8 +252,45 @@
</div> </div>
<div class="composer"> <div class="composer">
<textarea
#composerTextarea
class="form-control composer-textarea"
rows="3"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
(click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)"
[disabled]="!session.isSelectedPeerReady()"
placeholder="Write a text message to your peer"
></textarea>
<div class="composer-toolbar">
@if (peer(); as selectedPeer) { @if (peer(); as selectedPeer) {
<div class="composer-actions"> <button
class="composer-call"
type="button"
[disabled]="!canStartSelectedVoiceCall()"
(click)="startVoiceCall(selectedPeer.id)"
title="Start voice call"
aria-label="Start voice call"
>
📞
</button>
@if (canEndSelectedVoiceCall()) {
<button
class="composer-hangup"
type="button"
(click)="endVoiceCall(selectedPeer.id)"
title="End voice call"
aria-label="End voice call"
>
🛑
</button>
}
<button <button
class="composer-camera" class="composer-camera"
type="button" type="button"
@@ -218,6 +302,18 @@
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }} {{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
</button> </button>
<button
class="composer-voice"
type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
(click)="toggleVoiceRecording()"
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
[class.composer-voice-recording]="isRecordingVoice()"
>
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
</button>
<input <input
#fileInput #fileInput
class="composer-file-input" class="composer-file-input"
@@ -235,24 +331,8 @@
> >
+ +
</button> </button>
</div>
} }
<textarea
#composerTextarea
class="form-control composer-textarea"
rows="3"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
(click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)"
[disabled]="!session.isSelectedPeerReady()"
placeholder="Write a text message to your peer"
></textarea>
<div class="composer-send">
<button <button
class="composer-image-generate" class="composer-image-generate"
type="button" type="button"

View File

@@ -17,8 +17,20 @@
} }
.chat-page { .chat-page {
width: min(100%, 95vw); width: min(100%, 800px);
margin-inline: auto; margin-inline: auto;
overflow-x: hidden;
}
.call-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1250;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(3, 8, 14, 0.52);
backdrop-filter: blur(8px);
} }
.back-link { .back-link {
@@ -82,7 +94,7 @@
.chat-layout { .chat-layout {
display: grid; display: grid;
grid-template-columns: minmax(15rem, 19rem) minmax(0, 1fr); grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr);
gap:1.25rem; gap:1.25rem;
} }
@@ -156,6 +168,11 @@
background: var(--surface-hover-background); background: var(--surface-hover-background);
} }
.peer-tile-unread {
border-color: rgba(222, 143, 170, 0.45);
background: rgba(255, 233, 240, 0.92);
}
.peer-tile-row { .peer-tile-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -294,10 +311,6 @@
opacity: 0.7; opacity: 0.7;
} }
.bubble-author {
font-weight: 600;
}
.bubble-time { .bubble-time {
display: block; display: block;
} }
@@ -321,22 +334,17 @@
.composer { .composer {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; gap: 0.85rem;
gap: 0.9rem;
align-items: end;
padding-top: 1rem; padding-top: 1rem;
margin-top: 1rem; margin-top: 1rem;
border-top: 1px solid var(--surface-border-soft); border-top: 1px solid var(--surface-border-soft);
} }
.composer-actions { .composer-toolbar {
display: grid; display: flex;
gap: 0.6rem; flex-wrap: wrap;
}
.composer-send {
display: grid;
gap: 0.6rem; gap: 0.6rem;
align-items: center;
} }
.composer-emoji-picker-shell { .composer-emoji-picker-shell {
@@ -348,6 +356,9 @@
} }
.composer-camera, .composer-camera,
.composer-call,
.composer-hangup,
.composer-voice,
.composer-image-generate, .composer-image-generate,
.composer-emoji-trigger, .composer-emoji-trigger,
.composer-plus, .composer-plus,
@@ -371,26 +382,42 @@
color: var(--placeholder-color); color: var(--placeholder-color);
} }
.composer-camera { .composer-textarea {
min-height: 7rem;
}
.composer-call {
color: var(--page-text);
background: linear-gradient(135deg, #bfe9ff, #96c3ff);
}
.composer-camera,
.composer-emoji-trigger,
.composer-plus {
color: var(--page-text); color: var(--page-text);
background: var(--badge-background); background: var(--badge-background);
} }
.composer-hangup,
.composer-voice-recording {
color: #fff;
background: linear-gradient(135deg, #ff7d63, #dc3e5d);
}
.composer-voice {
color: var(--page-text);
background: linear-gradient(135deg, #ffd8bf, #ff9b8a);
}
.composer-voice-recording {
box-shadow: 0 0 0 0.2rem rgba(220, 62, 93, 0.18);
}
.composer-image-generate { .composer-image-generate {
color: var(--page-text); color: var(--page-text);
background: linear-gradient(135deg, #ffe6b0, #ffc8a8); background: linear-gradient(135deg, #ffe6b0, #ffc8a8);
} }
.composer-emoji-trigger {
color: var(--page-text);
background: var(--badge-background);
}
.composer-plus {
color: var(--page-text);
background: var(--badge-background);
}
.send-emoji { .send-emoji {
background: linear-gradient(135deg, #def7dd, #9bd5ff); background: linear-gradient(135deg, #def7dd, #9bd5ff);
} }
@@ -430,26 +457,36 @@
background: var(--surface-hover-background); background: var(--surface-hover-background);
} }
.bubble-image { .bubble-author,
width: 200px; .bubble-download,
max-width: 100%; .voice-bubble-label {
height: auto; font-weight: 600;
border-radius: 1rem;
display: block;
} }
.bubble-image,
.bubble-video { .bubble-video {
width: 200px; width: 200px;
max-width: 100%; max-width: 100%;
height: auto; height: auto;
display: block; display: block;
border-radius: 1rem; border-radius: 1rem;
background: #000;
} }
.bubble-download { .bubble-video {
color: inherit; background: #000;
font-weight: 600; }
.bubble-download { color: inherit; }
.voice-bubble {
display: grid;
gap: 0.65rem;
}
.voice-bubble-label { font-size: 0.88rem; }
.voice-player {
display: block;
width: min(100%, 18rem);
} }
.bubble-json { .bubble-json {
@@ -507,4 +544,8 @@
.bubble { .bubble {
max-width: 88%; max-width: 88%;
} }
.composer-toolbar {
justify-content: flex-start;
}
} }

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, computed, effect, inject, signal } from '@angular/core'; import { Component, computed, effect, ElementRef, inject, OnDestroy, signal, ViewChild } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -15,7 +15,7 @@ import type { ChatEntry, ConnectionState, PeerSummary } from './models';
templateUrl: './chat-page.component.html', templateUrl: './chat-page.component.html',
styleUrl: './chat-page.component.scss', styleUrl: './chat-page.component.scss',
}) })
export class ChatPageComponent { export class ChatPageComponent implements OnDestroy {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly routeParamMap = toSignal(this.route.paramMap, { private readonly routeParamMap = toSignal(this.route.paramMap, {
@@ -23,10 +23,22 @@ export class ChatPageComponent {
}); });
private composerSelectionStart = 0; private composerSelectionStart = 0;
private composerSelectionEnd = 0; private composerSelectionEnd = 0;
private voiceRecorder: MediaRecorder | null = null;
private voiceStream: MediaStream | null = null;
private voiceChunks: Blob[] = [];
private discardRecordedVoice = false;
private recordingPeerId: string | null = null;
@ViewChild('callAudioElement')
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
this.callAudioElement = value;
this.syncCallAudioSource();
}
private callAudioElement?: ElementRef<HTMLAudioElement>;
messageText = ''; messageText = '';
readonly forwardingEntryId = signal<string | null>(null); readonly forwardingEntryId = signal<string | null>(null);
readonly emojiPickerOpen = signal(false); readonly emojiPickerOpen = signal(false);
readonly isRecordingVoice = signal(false);
readonly emojiOptions = [ readonly emojiOptions = [
'😀', '😁', '😂', '🤣', '😊', '😀', '😁', '😂', '🤣', '😊',
'😉', '😍', '😘', '😎', '🤔', '😉', '😍', '😘', '😎', '🤔',
@@ -40,15 +52,65 @@ export class ChatPageComponent {
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? ''); readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null); readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
readonly currentUser = computed(() => this.session.currentUser()); readonly currentUser = computed(() => this.session.currentUser());
readonly incomingVoiceCallPeer = computed(() => {
const peerId = this.session.incomingVoiceCallPeerId();
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
});
readonly conversation = computed(() => readonly conversation = computed(() =>
this.session this.session
.messages() .messages()
.filter((entry) => entry.peerId === this.peerId()), .filter((entry) => entry.peerId === this.peerId()),
); );
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId())); readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
readonly remoteCallAudioStream = computed(() =>
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''),
);
readonly remoteVideoModalVisible = computed( readonly remoteVideoModalVisible = computed(
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(), () => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
); );
readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => {
const peerId = this.peerId();
if (!peerId) {
return 'idle';
}
if (this.session.activeVoiceCallPeerId() === peerId) {
return 'active';
}
if (this.session.outgoingVoiceCallPeerId() === peerId) {
return 'outgoing';
}
if (this.session.incomingVoiceCallPeerId() === peerId) {
return 'incoming';
}
return 'idle';
});
readonly canStartSelectedVoiceCall = computed(() => {
const selectedPeer = this.peer();
if (!selectedPeer || selectedPeer.channelState !== 'open') {
return false;
}
const activePeerId = this.session.activeVoiceCallPeerId();
const outgoingPeerId = this.session.outgoingVoiceCallPeerId();
const incomingPeerId = this.session.incomingVoiceCallPeerId();
return !activePeerId && !outgoingPeerId && !incomingPeerId;
});
readonly canEndSelectedVoiceCall = computed(() => {
const peerId = this.peerId();
return !!peerId && (
this.session.activeVoiceCallPeerId() === peerId ||
this.session.outgoingVoiceCallPeerId() === peerId
);
});
readonly webRtcState = computed<ConnectionState>(() => { readonly webRtcState = computed<ConnectionState>(() => {
const selectedPeer = this.peer(); const selectedPeer = this.peer();
@@ -82,6 +144,16 @@ export class ChatPageComponent {
this.session.selectPeer(peerId); this.session.selectPeer(peerId);
void this.session.connectToPeer(peerId); void this.session.connectToPeer(peerId);
}); });
effect(() => {
this.remoteCallAudioStream();
this.syncCallAudioSource();
});
}
ngOnDestroy(): void {
this.stopVoiceRecording(true);
this.detachCallAudioSource();
} }
async ensureConnection(): Promise<void> { async ensureConnection(): Promise<void> {
@@ -190,6 +262,76 @@ export class ChatPageComponent {
input.value = ''; input.value = '';
} }
async toggleVoiceRecording(): Promise<void> {
if (this.isRecordingVoice()) {
this.stopVoiceRecording(false);
return;
}
const peerId = this.peerId();
if (!peerId) {
return;
}
if (typeof MediaRecorder === 'undefined' || typeof navigator === 'undefined') {
this.session.error.set('This browser does not support voice recording.');
return;
}
if (typeof navigator.mediaDevices?.getUserMedia !== 'function') {
this.session.error.set('This browser cannot access the microphone.');
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferredMimeType = this.preferredVoiceMimeType();
const recorder = preferredMimeType
? new MediaRecorder(stream, { mimeType: preferredMimeType })
: new MediaRecorder(stream);
this.voiceChunks = [];
this.discardRecordedVoice = false;
this.recordingPeerId = peerId;
this.voiceStream = stream;
this.voiceRecorder = recorder;
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.voiceChunks.push(event.data);
}
};
recorder.onerror = () => {
this.session.error.set('Could not record voice message.');
this.cleanupVoiceRecording();
};
recorder.onstop = () => {
const shouldDiscard = this.discardRecordedVoice;
const targetPeerId = this.recordingPeerId;
const mimeType = recorder.mimeType || preferredMimeType || 'audio/webm';
const blob = new Blob(this.voiceChunks, { type: mimeType });
this.cleanupVoiceRecording();
if (shouldDiscard || !targetPeerId || blob.size === 0) {
return;
}
void this.session.sendVoiceMessage(targetPeerId, blob, mimeType);
};
recorder.start();
this.isRecordingVoice.set(true);
this.session.error.set(null);
} catch {
this.session.error.set('Could not start microphone recording.');
this.cleanupVoiceRecording();
}
}
async deleteMessage(entry: ChatEntry): Promise<void> { async deleteMessage(entry: ChatEntry): Promise<void> {
await this.session.deleteMessage(entry); await this.session.deleteMessage(entry);
} }
@@ -241,6 +383,34 @@ export class ChatPageComponent {
await this.session.startCameraStream(peerId); await this.session.startCameraStream(peerId);
} }
async startVoiceCall(peerId: string): Promise<void> {
await this.session.startVoiceCall(peerId);
}
async endVoiceCall(peerId: string): Promise<void> {
await this.session.endVoiceCall(peerId);
}
async acceptIncomingVoiceCall(peerId: string): Promise<void> {
if (!peerId) {
return;
}
if (peerId !== this.peerId()) {
await this.router.navigate(['/chat', peerId]);
}
await this.session.acceptVoiceCall(peerId);
}
rejectIncomingVoiceCall(peerId: string): void {
if (!peerId) {
return;
}
this.session.rejectVoiceCall(peerId);
}
isImageEntry(entry: ChatEntry): boolean { isImageEntry(entry: ChatEntry): boolean {
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false); return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
} }
@@ -271,6 +441,10 @@ export class ChatPageComponent {
return this.session.typingPeerIds().includes(peerId); return this.session.typingPeerIds().includes(peerId);
} }
isPeerUnread(peerId: string): boolean {
return this.session.unreadPeerIds().includes(peerId);
}
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' { indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
if (state === 'connected') { if (state === 'connected') {
return 'ok'; return 'ok';
@@ -308,9 +482,85 @@ export class ChatPageComponent {
return; return;
} }
this.stopVoiceRecording(true);
this.forwardingEntryId.set(null); this.forwardingEntryId.set(null);
this.emojiPickerOpen.set(false); this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId); this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]); await this.router.navigate(['/chat', peerId]);
} }
private stopVoiceRecording(discard: boolean): void {
const recorder = this.voiceRecorder;
if (!recorder) {
this.discardRecordedVoice = discard;
this.cleanupVoiceRecording();
return;
}
this.discardRecordedVoice = discard;
if (recorder.state !== 'inactive') {
recorder.stop();
return;
}
this.cleanupVoiceRecording();
}
private cleanupVoiceRecording(): void {
if (this.voiceStream) {
for (const track of this.voiceStream.getTracks()) {
track.stop();
}
}
this.voiceRecorder = null;
this.voiceStream = null;
this.voiceChunks = [];
this.recordingPeerId = null;
this.isRecordingVoice.set(false);
}
private preferredVoiceMimeType(): string {
if (typeof MediaRecorder === 'undefined' || typeof MediaRecorder.isTypeSupported !== 'function') {
return '';
}
const candidates = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/ogg'];
return candidates.find((candidate) => MediaRecorder.isTypeSupported(candidate)) ?? '';
}
private syncCallAudioSource(): void {
const audio = this.callAudioElement?.nativeElement;
if (!audio) {
return;
}
const stream = this.remoteCallAudioStream();
audio.srcObject = stream;
if (stream) {
void audio.play().catch(() => {
// Autoplay may wait for a browser gesture.
});
return;
}
audio.pause();
}
private detachCallAudioSource(): void {
const audio = this.callAudioElement?.nativeElement;
if (!audio) {
return;
}
audio.pause();
audio.srcObject = null;
}
} }

View File

@@ -25,13 +25,18 @@ type PeerBundle = {
channel?: RTCDataChannel; channel?: RTCDataChannel;
pendingCandidates: RTCIceCandidateInit[]; pendingCandidates: RTCIceCandidateInit[];
pendingNegotiation: boolean; pendingNegotiation: boolean;
announceConnectionEvents: boolean;
localCameraStream?: MediaStream; localCameraStream?: MediaStream;
cameraSenders: RTCRtpSender[]; cameraSenders: RTCRtpSender[];
remoteCameraStream?: MediaStream; remoteCameraStream?: MediaStream;
localAudioStream?: MediaStream;
audioSenders: RTCRtpSender[];
remoteAudioStream?: MediaStream;
}; };
type IncomingFileTransfer = { type IncomingFileTransfer = {
id: string; id: string;
kind: 'file' | 'voice';
name: string; name: string;
mimeType: string; mimeType: string;
size: number; size: number;
@@ -113,6 +118,7 @@ export class ChatSessionService {
private static readonly typingIndicatorLifetimeMs = 1800; private static readonly typingIndicatorLifetimeMs = 1800;
private static readonly typingIdleMs = 1200; private static readonly typingIdleMs = 1200;
private static readonly typingHeartbeatMs = 900; private static readonly typingHeartbeatMs = 900;
private static readonly incomingCallRingtoneFileName = 'SymphonyDing.mp3';
readonly serverUrl = signal(this.readStorage('privatechat.serverUrl') ?? readDefaultServerUrl()); readonly serverUrl = signal(this.readStorage('privatechat.serverUrl') ?? readDefaultServerUrl());
readonly currentUser = signal<UserProfile | null>(this.readUserStorage()); readonly currentUser = signal<UserProfile | null>(this.readUserStorage());
@@ -123,6 +129,9 @@ export class ChatSessionService {
readonly unreadPeerIds = signal<string[]>([]); readonly unreadPeerIds = signal<string[]>([]);
readonly typingPeerIds = signal<string[]>([]); readonly typingPeerIds = signal<string[]>([]);
readonly remoteVideoModalPeerId = signal<string | null>(null); readonly remoteVideoModalPeerId = signal<string | null>(null);
readonly incomingVoiceCallPeerId = signal<string | null>(null);
readonly outgoingVoiceCallPeerId = signal<string | null>(null);
readonly activeVoiceCallPeerId = signal<string | null>(null);
readonly signalingState = signal<ConnectionState>('disconnected'); readonly signalingState = signal<ConnectionState>('disconnected');
readonly status = signal('Disconnected from signaling server.'); readonly status = signal('Disconnected from signaling server.');
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
@@ -162,12 +171,17 @@ export class ChatSessionService {
{ peerId: string; prompt: string; waitMessageId: string } { peerId: string; prompt: string; waitMessageId: string }
>(); >();
private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]); private readonly remoteVideoStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly remoteAudioStreams = signal<Array<{ peerId: string; stream: MediaStream }>>([]);
private readonly activeCameraPeerId = signal<string | null>(null); private readonly activeCameraPeerId = signal<string | null>(null);
private readonly activeAudioPeerId = signal<string | null>(null);
private sessionKeepaliveIntervalId: number | null = null; private sessionKeepaliveIntervalId: number | null = null;
private websocketHeartbeatIntervalId: number | null = null; private websocketHeartbeatIntervalId: number | null = null;
private websocketReconnectTimeoutId: number | null = null; private websocketReconnectTimeoutId: number | null = null;
private websocketReconnectAttempt = 0; private websocketReconnectAttempt = 0;
private suppressSocketReconnect = false; private suppressSocketReconnect = false;
private ringtoneAudio: HTMLAudioElement | null = null;
private ringtoneAudioUrl: string = this.resolveIncomingCallRingtoneUrl();
private ringtonePreloadPromise: Promise<void> | null = null;
private messageEncryptionKey: CryptoKey | null = null; private messageEncryptionKey: CryptoKey | null = null;
private messageDatabasePromise: Promise<IDBDatabase | null> | null = null; private messageDatabasePromise: Promise<IDBDatabase | null> | null = null;
private websocket: WebSocket | null = null; private websocket: WebSocket | null = null;
@@ -327,13 +341,14 @@ export class ChatSessionService {
this.outgoingTypingIdleTimeouts.set(peerId, timeoutId); this.outgoingTypingIdleTimeouts.set(peerId, timeoutId);
} }
async connectToPeer(peerId: string): Promise<void> { async connectToPeer(peerId: string, options?: { announce?: boolean }): Promise<void> {
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
this.error.set('You must be connected to signaling before opening a peer session.'); this.error.set('You must be connected to signaling before opening a peer session.');
return; return;
} }
const bundle = this.ensurePeerBundle(peerId, true); const announce = options?.announce ?? true;
const bundle = this.ensurePeerBundle(peerId, true, announce);
if (bundle.channel?.readyState === 'open') { if (bundle.channel?.readyState === 'open') {
return; return;
@@ -344,7 +359,9 @@ export class ChatSessionService {
} }
this.patchPeer(peerId, { connectionState: 'connecting', channelState: 'connecting' }); this.patchPeer(peerId, { connectionState: 'connecting', channelState: 'connecting' });
if (announce) {
this.addSystemMessage(peerId, 'Opening WebRTC data channel.'); this.addSystemMessage(peerId, 'Opening WebRTC data channel.');
}
await this.negotiatePeer(peerId, bundle); await this.negotiatePeer(peerId, bundle);
} }
@@ -442,12 +459,87 @@ export class ChatSessionService {
return this.remoteVideoStreams().find((entry) => entry.peerId === peerId)?.stream ?? null; return this.remoteVideoStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
} }
remoteAudioStreamForPeer(peerId: string): MediaStream | null {
return this.remoteAudioStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
}
dismissRemoteVideoModal(peerId: string): void { dismissRemoteVideoModal(peerId: string): void {
if (this.remoteVideoModalPeerId() === peerId) { if (this.remoteVideoModalPeerId() === peerId) {
this.remoteVideoModalPeerId.set(null); this.remoteVideoModalPeerId.set(null);
} }
} }
async startVoiceCall(peerId: string): Promise<void> {
const channel = this.requireOpenChannel(peerId);
if (!channel) {
return;
}
if (this.hasVoiceCallConflict(peerId) || this.outgoingVoiceCallPeerId() === peerId || this.activeVoiceCallPeerId() === peerId) {
this.error.set('Finish the current voice call before starting another one.');
return;
}
this.error.set(null);
this.incomingVoiceCallPeerId.set(null);
this.outgoingVoiceCallPeerId.set(peerId);
channel.send(JSON.stringify({ type: 'voice-call-offer' } satisfies DataEnvelope));
this.addSystemMessage(peerId, 'Calling peer.');
}
async acceptVoiceCall(peerId: string): Promise<void> {
if (this.incomingVoiceCallPeerId() !== peerId) {
return;
}
const bundle = await this.ensureLocalAudioStream(peerId);
if (!bundle) {
this.sendVoiceCallResponse(peerId, false);
this.clearVoiceCallSignals(peerId);
return;
}
this.stopRingtone();
this.incomingVoiceCallPeerId.set(null);
this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(peerId);
this.sendVoiceCallResponse(peerId, true);
this.addSystemMessage(peerId, 'Voice call connected.');
await this.negotiatePeer(peerId, bundle);
}
rejectVoiceCall(peerId: string): void {
if (this.incomingVoiceCallPeerId() !== peerId) {
return;
}
this.sendVoiceCallResponse(peerId, false);
this.clearVoiceCallSignals(peerId);
this.addSystemMessage(peerId, 'Voice call rejected.');
}
async endVoiceCall(peerId: string, notifyPeer = true): Promise<void> {
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|| this.outgoingVoiceCallPeerId() === peerId
|| this.activeVoiceCallPeerId() === peerId
|| this.activeAudioPeerId() === peerId;
if (!hadVoiceCall) {
return;
}
if (notifyPeer) {
this.sendVoiceCallEnded(peerId);
}
await this.stopLocalAudioStream(peerId, true);
this.clearRemoteAudioState(peerId);
this.clearVoiceCallSignals(peerId);
this.addSystemMessage(peerId, 'Voice call ended.');
}
async registerAccessKey(label: string): Promise<void> { async registerAccessKey(label: string): Promise<void> {
if (!this.webAuthnSupported()) { if (!this.webAuthnSupported()) {
this.error.set('This browser does not support WebAuthn access keys.'); this.error.set('This browser does not support WebAuthn access keys.');
@@ -536,7 +628,7 @@ export class ChatSessionService {
this.sendJsonEnvelope(peerId, channel, parsedPayload); this.sendJsonEnvelope(peerId, channel, parsedPayload);
} }
async sendFile(peerId: string, file: File): Promise<void> { async sendFile(peerId: string, file: File, attachmentKind: 'file' | 'voice' = 'file'): Promise<void> {
const channel = this.requireOpenChannel(peerId); const channel = this.requireOpenChannel(peerId);
if (!channel) { if (!channel) {
@@ -556,6 +648,7 @@ export class ChatSessionService {
name: file.name, name: file.name,
mimeType: file.type || 'application/octet-stream', mimeType: file.type || 'application/octet-stream',
size: file.size, size: file.size,
attachmentKind,
authorId: this.currentUser()!.id, authorId: this.currentUser()!.id,
authorName: this.currentUser()!.displayName, authorName: this.currentUser()!.displayName,
sentAt, sentAt,
@@ -572,7 +665,7 @@ export class ChatSessionService {
id: transferId, id: transferId,
peerId, peerId,
direction: 'outgoing', direction: 'outgoing',
kind: 'file', kind: attachmentKind,
createdAt: sentAt, createdAt: sentAt,
authorLabel: 'You', authorLabel: 'You',
fileName: file.name, fileName: file.name,
@@ -582,6 +675,18 @@ export class ChatSessionService {
}, file); }, file);
} }
async sendVoiceMessage(peerId: string, blob: Blob, mimeType?: string): Promise<void> {
const resolvedMimeType = mimeType || blob.type || 'audio/webm';
const extension = this.fileExtensionForMimeType(resolvedMimeType);
const file = new File(
[blob],
`voice-message-${new Date().toISOString().replace(/[:.]/g, '-')}.${extension}`,
{ type: resolvedMimeType },
);
await this.sendFile(peerId, file, 'voice');
}
async forwardMessage(targetPeerId: string, entry: ChatEntry): Promise<void> { async forwardMessage(targetPeerId: string, entry: ChatEntry): Promise<void> {
if (entry.kind === 'system' || entry.direction === 'system') { if (entry.kind === 'system' || entry.direction === 'system') {
return; return;
@@ -605,6 +710,7 @@ export class ChatSessionService {
this.sendJsonEnvelope(targetPeerId, channel, entry.payload); this.sendJsonEnvelope(targetPeerId, channel, entry.payload);
return; return;
case 'file': case 'file':
case 'voice':
if (!entry.downloadUrl) { if (!entry.downloadUrl) {
this.error.set('This file cannot be forwarded because its data is unavailable.'); this.error.set('This file cannot be forwarded because its data is unavailable.');
return; return;
@@ -617,7 +723,7 @@ export class ChatSessionService {
type: entry.fileMimeType || blob.type || 'application/octet-stream', type: entry.fileMimeType || blob.type || 'application/octet-stream',
}); });
await this.sendFile(targetPeerId, file); await this.sendFile(targetPeerId, file, entry.kind === 'voice' ? 'voice' : 'file');
} catch { } catch {
this.error.set('Could not forward this file.'); this.error.set('Could not forward this file.');
} }
@@ -838,6 +944,8 @@ export class ChatSessionService {
return; return;
} }
void this.preloadRingtone();
this.clearWebSocketReconnect(); this.clearWebSocketReconnect();
this.disconnectWebSocket(); this.disconnectWebSocket();
this.resetPeerConnections(); this.resetPeerConnections();
@@ -931,6 +1039,8 @@ export class ChatSessionService {
this.clearUnreadPeer(event.peerId); this.clearUnreadPeer(event.peerId);
this.clearPeerTyping(event.peerId); this.clearPeerTyping(event.peerId);
this.clearRemoteVideoState(event.peerId); this.clearRemoteVideoState(event.peerId);
this.clearRemoteAudioState(event.peerId);
this.clearVoiceCallSignals(event.peerId);
if (this.activePeerId() === event.peerId) { if (this.activePeerId() === event.peerId) {
this.activePeerId.set(this.peers()[0]?.id ?? null); this.activePeerId.set(this.peers()[0]?.id ?? null);
} }
@@ -1149,6 +1259,10 @@ export class ChatSessionService {
if (!this.activePeerId() && nextPeers.length > 0) { if (!this.activePeerId() && nextPeers.length > 0) {
this.activePeerId.set(nextPeers[0].id); this.activePeerId.set(nextPeers[0].id);
} }
queueMicrotask(() => {
void this.connectToAvailablePeers();
});
} }
private async handleSignal(peerId: string, signal: SignalPayload): Promise<void> { private async handleSignal(peerId: string, signal: SignalPayload): Promise<void> {
@@ -1197,10 +1311,12 @@ export class ChatSessionService {
await this.flushPendingCandidates(bundle); await this.flushPendingCandidates(bundle);
} }
private ensurePeerBundle(peerId: string, initiator: boolean): PeerBundle { private ensurePeerBundle(peerId: string, initiator: boolean, announce = false): PeerBundle {
const existing = this.peerBundles.get(peerId); const existing = this.peerBundles.get(peerId);
if (existing && existing.pc.connectionState !== 'closed' && existing.pc.connectionState !== 'failed') { if (existing && existing.pc.connectionState !== 'closed' && existing.pc.connectionState !== 'failed') {
existing.announceConnectionEvents = existing.announceConnectionEvents || announce;
if (initiator && !existing.channel) { if (initiator && !existing.channel) {
const channel = existing.pc.createDataChannel('privatechat'); const channel = existing.pc.createDataChannel('privatechat');
this.attachDataChannel(peerId, channel, existing); this.attachDataChannel(peerId, channel, existing);
@@ -1217,7 +1333,9 @@ export class ChatSessionService {
}), }),
pendingCandidates: [], pendingCandidates: [],
pendingNegotiation: false, pendingNegotiation: false,
announceConnectionEvents: announce,
cameraSenders: [], cameraSenders: [],
audioSenders: [],
}; };
bundle.pc.onicecandidate = (event) => { bundle.pc.onicecandidate = (event) => {
@@ -1233,7 +1351,7 @@ export class ChatSessionService {
const state = this.mapConnectionState(bundle.pc.connectionState); const state = this.mapConnectionState(bundle.pc.connectionState);
this.patchPeer(peerId, { connectionState: state }); this.patchPeer(peerId, { connectionState: state });
if (state === 'connected') { if (state === 'connected' && bundle.announceConnectionEvents) {
this.addSystemMessage(peerId, 'Peer connection established.'); this.addSystemMessage(peerId, 'Peer connection established.');
} }
@@ -1255,6 +1373,8 @@ export class ChatSessionService {
bundle.pc.ontrack = (event) => { bundle.pc.ontrack = (event) => {
const [stream] = event.streams; const [stream] = event.streams;
if (event.track.kind === 'video') {
const remoteStream = stream ?? bundle.remoteCameraStream ?? new MediaStream(); const remoteStream = stream ?? bundle.remoteCameraStream ?? new MediaStream();
if (!stream) { if (!stream) {
@@ -1278,6 +1398,37 @@ export class ChatSessionService {
this.clearRemoteVideoState(peerId); this.clearRemoteVideoState(peerId);
} }
}; };
return;
}
if (event.track.kind !== 'audio') {
return;
}
const remoteStream = stream ?? bundle.remoteAudioStream ?? new MediaStream();
if (!stream) {
remoteStream.addTrack(event.track);
}
bundle.remoteAudioStream = remoteStream;
this.upsertRemoteAudioStream(peerId, remoteStream);
this.activeVoiceCallPeerId.set(peerId);
event.track.onended = () => {
if (!bundle.remoteAudioStream) {
return;
}
const remainingLiveTracks = bundle.remoteAudioStream
.getAudioTracks()
.filter((track) => track.readyState === 'live' && track !== event.track);
if (remainingLiveTracks.length === 0) {
this.clearRemoteAudioState(peerId);
}
};
}; };
if (initiator) { if (initiator) {
@@ -1299,7 +1450,9 @@ export class ChatSessionService {
channel.onopen = () => { channel.onopen = () => {
this.patchPeer(peerId, { connectionState: 'connected', channelState: 'open' }); this.patchPeer(peerId, { connectionState: 'connected', channelState: 'open' });
if (bundle.announceConnectionEvents) {
this.addSystemMessage(peerId, 'Secure data channel is open.'); this.addSystemMessage(peerId, 'Secure data channel is open.');
}
}; };
channel.onclose = () => { channel.onclose = () => {
@@ -1348,6 +1501,7 @@ export class ChatSessionService {
case 'file-meta': case 'file-meta':
this.incomingFiles.set(peerId, { this.incomingFiles.set(peerId, {
id: envelope.id, id: envelope.id,
kind: envelope.attachmentKind ?? 'file',
name: envelope.name, name: envelope.name,
mimeType: envelope.mimeType, mimeType: envelope.mimeType,
size: envelope.size, size: envelope.size,
@@ -1371,6 +1525,15 @@ export class ChatSessionService {
this.clearRemoteVideoState(peerId); this.clearRemoteVideoState(peerId);
} }
break; break;
case 'voice-call-offer':
this.handleIncomingVoiceCallOffer(peerId);
break;
case 'voice-call-response':
void this.handleVoiceCallResponse(peerId, envelope.accepted);
break;
case 'voice-call-ended':
void this.handleRemoteVoiceCallEnded(peerId);
break;
} }
} }
@@ -1401,7 +1564,7 @@ export class ChatSessionService {
id: transfer.id, id: transfer.id,
peerId, peerId,
direction: 'incoming', direction: 'incoming',
kind: 'file', kind: transfer.kind,
createdAt: transfer.sentAt, createdAt: transfer.sentAt,
authorLabel: transfer.authorName, authorLabel: transfer.authorName,
fileName: transfer.name, fileName: transfer.name,
@@ -1452,6 +1615,26 @@ export class ChatSessionService {
channel.send(JSON.stringify({ type: 'camera-state', active } satisfies DataEnvelope)); channel.send(JSON.stringify({ type: 'camera-state', active } satisfies DataEnvelope));
} }
private sendVoiceCallResponse(peerId: string, accepted: boolean): void {
const channel = this.peerBundles.get(peerId)?.channel;
if (!channel || channel.readyState !== 'open') {
return;
}
channel.send(JSON.stringify({ type: 'voice-call-response', accepted } satisfies DataEnvelope));
}
private sendVoiceCallEnded(peerId: string): void {
const channel = this.peerBundles.get(peerId)?.channel;
if (!channel || channel.readyState !== 'open') {
return;
}
channel.send(JSON.stringify({ type: 'voice-call-ended' } satisfies DataEnvelope));
}
private sendSignal(peerId: string, signal: SignalPayload): void { private sendSignal(peerId: string, signal: SignalPayload): void {
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
return; return;
@@ -1506,12 +1689,154 @@ export class ChatSessionService {
return null; return null;
} }
private async connectToAvailablePeers(): Promise<void> {
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
return;
}
await Promise.allSettled(
this.peers().map((peer) => this.connectToPeer(peer.id, { announce: false })),
);
}
private hasVoiceCallConflict(peerId: string): boolean {
return [this.incomingVoiceCallPeerId(), this.outgoingVoiceCallPeerId(), this.activeVoiceCallPeerId()]
.some((candidatePeerId) => !!candidatePeerId && candidatePeerId !== peerId);
}
private async ensureLocalAudioStream(peerId: string): Promise<PeerBundle | null> {
if (typeof navigator === 'undefined' || typeof navigator.mediaDevices?.getUserMedia !== 'function') {
this.error.set('This browser does not support microphone capture.');
return null;
}
if (this.activeAudioPeerId() && this.activeAudioPeerId() !== peerId) {
this.error.set('Finish the current voice call before starting another one.');
return null;
}
const bundle = this.ensurePeerBundle(peerId, true);
if (bundle.localAudioStream) {
return bundle;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
video: false,
});
bundle.localAudioStream = stream;
bundle.audioSenders = stream.getTracks().map((track) => {
track.onended = () => {
void this.endVoiceCall(peerId);
};
return bundle.pc.addTrack(track, stream);
});
this.activeAudioPeerId.set(peerId);
return bundle;
} catch {
this.error.set('Could not start microphone capture for the voice call.');
return null;
}
}
private async stopLocalAudioStream(peerId: string, renegotiate: boolean): Promise<void> {
const bundle = this.peerBundles.get(peerId);
if (!bundle?.localAudioStream && this.activeAudioPeerId() !== peerId) {
return;
}
if (bundle) {
for (const sender of bundle.audioSenders) {
bundle.pc.removeTrack(sender);
}
bundle.audioSenders = [];
if (bundle.localAudioStream) {
for (const track of bundle.localAudioStream.getTracks()) {
track.onended = null;
track.stop();
}
}
bundle.localAudioStream = undefined;
}
if (this.activeAudioPeerId() === peerId) {
this.activeAudioPeerId.set(null);
}
if (renegotiate && bundle) {
await this.negotiatePeer(peerId, bundle);
}
}
private handleIncomingVoiceCallOffer(peerId: string): void {
if (this.hasVoiceCallConflict(peerId) || this.activeAudioPeerId()) {
this.sendVoiceCallResponse(peerId, false);
return;
}
this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(null);
this.incomingVoiceCallPeerId.set(peerId);
this.startRingtone();
this.addSystemMessage(peerId, 'Incoming voice call.');
}
private async handleVoiceCallResponse(peerId: string, accepted: boolean): Promise<void> {
if (this.outgoingVoiceCallPeerId() !== peerId) {
return;
}
this.outgoingVoiceCallPeerId.set(null);
if (!accepted) {
this.addSystemMessage(peerId, 'Voice call declined.');
return;
}
this.activeVoiceCallPeerId.set(peerId);
const bundle = await this.ensureLocalAudioStream(peerId);
if (!bundle) {
await this.endVoiceCall(peerId);
return;
}
this.addSystemMessage(peerId, 'Voice call connected.');
await this.negotiatePeer(peerId, bundle);
}
private async handleRemoteVoiceCallEnded(peerId: string): Promise<void> {
const hadVoiceCall = this.incomingVoiceCallPeerId() === peerId
|| this.outgoingVoiceCallPeerId() === peerId
|| this.activeVoiceCallPeerId() === peerId
|| this.activeAudioPeerId() === peerId;
await this.stopLocalAudioStream(peerId, true);
this.clearRemoteAudioState(peerId);
this.clearVoiceCallSignals(peerId);
if (hadVoiceCall) {
this.addSystemMessage(peerId, 'Voice call ended.');
}
}
private releasePeerBundle(peerId: string, preservePeerState: boolean): void { private releasePeerBundle(peerId: string, preservePeerState: boolean): void {
const bundle = this.peerBundles.get(peerId); const bundle = this.peerBundles.get(peerId);
this.clearPeerTyping(peerId); this.clearPeerTyping(peerId);
this.clearOutgoingTyping(peerId); this.clearOutgoingTyping(peerId);
this.clearRemoteVideoState(peerId); this.clearRemoteVideoState(peerId);
this.clearRemoteAudioState(peerId);
this.clearVoiceCallSignals(peerId);
if (!bundle) { if (!bundle) {
return; return;
@@ -1524,10 +1849,21 @@ export class ChatSessionService {
} }
} }
if (bundle.localAudioStream) {
for (const track of bundle.localAudioStream.getTracks()) {
track.onended = null;
track.stop();
}
}
if (this.activeCameraPeerId() === peerId) { if (this.activeCameraPeerId() === peerId) {
this.activeCameraPeerId.set(null); this.activeCameraPeerId.set(null);
} }
if (this.activeAudioPeerId() === peerId) {
this.activeAudioPeerId.set(null);
}
bundle.channel?.close(); bundle.channel?.close();
bundle.pc.close(); bundle.pc.close();
this.peerBundles.delete(peerId); this.peerBundles.delete(peerId);
@@ -1695,10 +2031,17 @@ export class ChatSessionService {
this.stopSessionKeepalive(); this.stopSessionKeepalive();
this.clearSystemMessageTimeouts(); this.clearSystemMessageTimeouts();
this.clearTypingTimeouts(); this.clearTypingTimeouts();
this.stopRingtone();
this.releasePreloadedRingtone();
this.pendingImageGenerationRequests.clear(); this.pendingImageGenerationRequests.clear();
this.remoteVideoStreams.set([]); this.remoteVideoStreams.set([]);
this.remoteAudioStreams.set([]);
this.remoteVideoModalPeerId.set(null); this.remoteVideoModalPeerId.set(null);
this.activeCameraPeerId.set(null); this.activeCameraPeerId.set(null);
this.activeAudioPeerId.set(null);
this.incomingVoiceCallPeerId.set(null);
this.outgoingVoiceCallPeerId.set(null);
this.activeVoiceCallPeerId.set(null);
this.messageEncryptionKey = null; this.messageEncryptionKey = null;
this.revokeMessageDownloads(this.messages()); this.revokeMessageDownloads(this.messages());
this.currentUser.set(null); this.currentUser.set(null);
@@ -2142,6 +2485,20 @@ export class ChatSessionService {
}); });
} }
private upsertRemoteAudioStream(peerId: string, stream: MediaStream): void {
this.remoteAudioStreams.update((entries) => {
const existingIndex = entries.findIndex((entry) => entry.peerId === peerId);
if (existingIndex === -1) {
return [...entries, { peerId, stream }];
}
const nextEntries = [...entries];
nextEntries[existingIndex] = { peerId, stream };
return nextEntries;
});
}
private clearRemoteVideoState(peerId: string): void { private clearRemoteVideoState(peerId: string): void {
this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId)); this.remoteVideoStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
@@ -2150,6 +2507,159 @@ export class ChatSessionService {
} }
} }
private clearRemoteAudioState(peerId: string): void {
this.remoteAudioStreams.update((entries) => entries.filter((entry) => entry.peerId !== peerId));
}
private clearVoiceCallSignals(peerId: string): void {
if (this.incomingVoiceCallPeerId() === peerId) {
this.incomingVoiceCallPeerId.set(null);
this.stopRingtone();
}
if (this.outgoingVoiceCallPeerId() === peerId) {
this.outgoingVoiceCallPeerId.set(null);
}
if (this.activeVoiceCallPeerId() === peerId) {
this.activeVoiceCallPeerId.set(null);
}
}
private startRingtone(): void {
if (typeof Audio === 'undefined') {
return;
}
if (!this.ringtoneAudio) {
const ringtone = new Audio(this.ringtoneAudioUrl);
ringtone.loop = true;
ringtone.preload = 'auto';
this.ringtoneAudio = ringtone;
}
this.ringtoneAudio.currentTime = 0;
void this.ringtoneAudio.play().catch(() => {
// Ring playback may wait for a browser gesture.
});
}
private stopRingtone(): void {
if (!this.ringtoneAudio) {
return;
}
this.ringtoneAudio.pause();
this.ringtoneAudio.currentTime = 0;
}
private preloadRingtone(): Promise<void> {
if (this.ringtonePreloadPromise) {
return this.ringtonePreloadPromise;
}
if (typeof fetch !== 'function') {
this.ringtonePreloadPromise = Promise.resolve();
return this.ringtonePreloadPromise;
}
this.ringtonePreloadPromise = this.fetchPreloadedRingtoneUrl()
.then((nextUrl) => {
if (!nextUrl) {
return;
}
if (this.ringtoneAudioUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.ringtoneAudioUrl);
}
this.ringtoneAudioUrl = nextUrl;
if (this.ringtoneAudio) {
this.ringtoneAudio.src = nextUrl;
this.ringtoneAudio.load();
}
})
.catch((error) => {
console.warn('Could not preload incoming-call ringtone.', error);
});
return this.ringtonePreloadPromise;
}
private releasePreloadedRingtone(): void {
if (this.ringtoneAudio) {
this.ringtoneAudio.pause();
this.ringtoneAudio.src = '';
this.ringtoneAudio = null;
}
if (this.ringtoneAudioUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.ringtoneAudioUrl);
}
this.ringtoneAudioUrl = this.resolveIncomingCallRingtoneUrl();
this.ringtonePreloadPromise = null;
}
private resolveIncomingCallRingtoneUrl(): string {
if (typeof document === 'undefined') {
return `/${ChatSessionService.incomingCallRingtoneFileName}`;
}
return new URL(ChatSessionService.incomingCallRingtoneFileName, document.baseURI).toString();
}
private resolveIncomingCallRingtoneFallbackUrl(): string {
if (typeof window === 'undefined') {
return `/api/web-app/files/${encodeURIComponent(ChatSessionService.incomingCallRingtoneFileName)}`;
}
return new URL(
`/api/web-app/files/${encodeURIComponent(ChatSessionService.incomingCallRingtoneFileName)}`,
window.location.origin,
).toString();
}
private async fetchPreloadedRingtoneUrl(): Promise<string | null> {
for (const ringtoneUrl of this.incomingCallRingtoneCandidateUrls()) {
try {
const response = await fetch(ringtoneUrl);
if (!response.ok) {
continue;
}
const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
if (!contentType.startsWith('audio/')) {
continue;
}
const blob = await response.blob();
if (!blob.size) {
continue;
}
return URL.createObjectURL(blob);
} catch {
// Try the next candidate URL.
}
}
return null;
}
private incomingCallRingtoneCandidateUrls(): string[] {
const candidates = [
this.resolveIncomingCallRingtoneUrl(),
this.resolveIncomingCallRingtoneFallbackUrl(),
];
return candidates.filter((value, index) => candidates.indexOf(value) === index);
}
private setPeerTyping(peerId: string, active: boolean): void { private setPeerTyping(peerId: string, active: boolean): void {
const existingTimeoutId = this.typingIndicatorTimeouts.get(peerId); const existingTimeoutId = this.typingIndicatorTimeouts.get(peerId);
@@ -2280,7 +2790,22 @@ export class ChatSessionService {
} }
private fileExtensionForMimeType(mimeType: string): string { private fileExtensionForMimeType(mimeType: string): string {
switch (mimeType) { const normalizedMimeType = mimeType.split(';', 1)[0]?.trim().toLowerCase() || 'application/octet-stream';
switch (normalizedMimeType) {
case 'audio/webm':
return 'webm';
case 'audio/ogg':
return 'ogg';
case 'audio/mp4':
case 'audio/x-m4a':
return 'm4a';
case 'audio/mpeg':
return 'mp3';
case 'audio/wav':
case 'audio/wave':
case 'audio/x-wav':
return 'wav';
case 'image/png': case 'image/png':
return 'png'; return 'png';
case 'image/jpeg': case 'image/jpeg':

View File

@@ -22,7 +22,8 @@
border-radius: 2rem; border-radius: 2rem;
} }
.panel { .panel,
.session-card {
border-radius: 1.5rem; border-radius: 1.5rem;
} }
@@ -77,15 +78,12 @@
.theme-toggle:hover, .theme-toggle:hover,
.theme-toggle:focus-visible { .theme-toggle:focus-visible {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border)); border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border));
background: var(--surface-hover-background); background: var(--surface-hover-background);
transform: translateY(-1px);
} }
.session-card { .session-card { min-width: min(100%, 18rem); }
min-width: min(100%, 18rem);
border-radius: 1.5rem;
}
.status-pill { .status-pill {
display: inline-flex; display: inline-flex;
@@ -94,15 +92,19 @@
background: var(--badge-background); background: var(--badge-background);
} }
.btn-accent { .btn-accent,
.btn-accent:hover,
.btn-accent:focus-visible {
color: #06111d; color: #06111d;
border: 0; border: 0;
}
.btn-accent {
background: var(--accent-gradient); background: var(--accent-gradient);
} }
.btn-accent:hover, .btn-accent:hover,
.btn-accent:focus-visible { .btn-accent:focus-visible {
color: #06111d;
background: var(--accent-gradient-hover); background: var(--accent-gradient-hover);
} }

View File

@@ -5,8 +5,6 @@
.json-viewer-shell { .json-viewer-shell {
width: min(95%, 480px); width: min(95%, 480px);
max-width: min(95%, 480px);
min-width: 0;
overflow: hidden; overflow: hidden;
border-radius: 0.9rem; border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);

View File

@@ -94,7 +94,7 @@ export interface ChatEntry {
id: string; id: string;
peerId: string; peerId: string;
direction: 'incoming' | 'outgoing' | 'system'; direction: 'incoming' | 'outgoing' | 'system';
kind: 'text' | 'json' | 'file' | 'system'; kind: 'text' | 'json' | 'file' | 'voice' | 'system';
createdAt: number; createdAt: number;
authorLabel: string; authorLabel: string;
showSpinner?: boolean; showSpinner?: boolean;
@@ -156,6 +156,7 @@ export type DataEnvelope =
name: string; name: string;
mimeType: string; mimeType: string;
size: number; size: number;
attachmentKind?: 'file' | 'voice';
authorId: string; authorId: string;
authorName: string; authorName: string;
sentAt: number; sentAt: number;
@@ -171,4 +172,14 @@ export type DataEnvelope =
| { | {
type: 'camera-state'; type: 'camera-state';
active: boolean; active: boolean;
}
| {
type: 'voice-call-offer';
}
| {
type: 'voice-call-response';
accepted: boolean;
}
| {
type: 'voice-call-ended';
}; };

View File

@@ -101,10 +101,6 @@
color-scheme: dark; color-scheme: dark;
} }
:root[data-theme='light'] {
color-scheme: light;
}
html, html,
body { body {
min-height: 100dvh; min-height: 100dvh;
@@ -138,27 +134,30 @@ textarea {
background: var(--badge-background) !important; background: var(--badge-background) !important;
} }
.btn-outline-light { .btn-outline-light,
.btn-outline-light:hover,
.btn-outline-light:focus-visible {
color: var(--page-text); color: var(--page-text);
border-color: var(--surface-border); border-color: var(--surface-border);
} }
.btn-outline-light:hover, .btn-outline-light:hover,
.btn-outline-light:focus-visible { .btn-outline-light:focus-visible {
color: var(--page-text);
border-color: var(--surface-border);
background: var(--panel-soft-background); background: var(--panel-soft-background);
} }
.btn-outline-light,
.btn-outline-secondary {
border-color: var(--surface-border);
}
.btn-outline-secondary { .btn-outline-secondary {
color: var(--page-text-muted); color: var(--page-text-muted);
border-color: var(--surface-border);
} }
.btn-outline-secondary:hover, .btn-outline-secondary:hover,
.btn-outline-secondary:focus-visible { .btn-outline-secondary:focus-visible {
color: var(--page-text); color: var(--page-text);
border-color: var(--surface-border);
background: var(--panel-soft-background); background: var(--panel-soft-background);
} }

View File

@@ -1246,6 +1246,10 @@ function toBundleRelativePath(inputPath) {
function detectBundleContentType(assetPath) { function detectBundleContentType(assetPath) {
const extension = path.extname(assetPath).toLowerCase(); const extension = path.extname(assetPath).toLowerCase();
switch (extension) { switch (extension) {
case '.mp3':
return 'audio/mpeg';
case '.m4a':
return 'audio/mp4';
case '.css': case '.css':
return 'text/css; charset=utf-8'; return 'text/css; charset=utf-8';
case '.html': case '.html':

View File

@@ -1869,6 +1869,10 @@ function detectBundleContentType(assetPath: string): string {
const extension = path.extname(assetPath).toLowerCase(); const extension = path.extname(assetPath).toLowerCase();
switch (extension) { switch (extension) {
case '.mp3':
return 'audio/mpeg';
case '.m4a':
return 'audio/mp4';
case '.css': case '.css':
return 'text/css; charset=utf-8'; return 'text/css; charset=utf-8';
case '.html': case '.html':