Many features
This commit is contained in:
@@ -68,8 +68,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
"maximumWarning": "4kB",
|
"maximumWarning": "10kB",
|
||||||
"maximumError": "8kB"
|
"maximumError": "12kB"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputHashing": "all"
|
"outputHashing": "all"
|
||||||
|
|||||||
BIN
client/public/SymphonyDing.mp3
Normal file
BIN
client/public/SymphonyDing.mp3
Normal file
Binary file not shown.
@@ -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"
|
||||||
|
|||||||
@@ -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,12 +94,12 @@
|
|||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-sidebar {
|
.peer-sidebar {
|
||||||
padding: 1rem;
|
padding:1rem;
|
||||||
border-radius: 1.3rem;
|
border-radius: 1.3rem;
|
||||||
border: 1px solid var(--surface-border-soft);
|
border: 1px solid var(--surface-border-soft);
|
||||||
background: var(--panel-soft-background);
|
background: var(--panel-soft-background);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
server/dist/index.js
vendored
4
server/dist/index.js
vendored
@@ -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':
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user