Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0e2b60f43 | |||
| 0da98bfd96 | |||
| 189f989c0d | |||
| d2c4152ea7 | |||
| df309d088c | |||
| 506a824401 |
@@ -71,6 +71,13 @@ The repo also includes a multiplatform SwiftUI client in `apple-client/` for mac
|
|||||||
- Generate the Xcode project with `xcodegen generate --spec apple-client/project.yml --project-root apple-client`.
|
- Generate the Xcode project with `xcodegen generate --spec apple-client/project.yml --project-root apple-client`.
|
||||||
- A build of the Apple app automatically rebuilds the Angular client into `apple-client/WebApp/` before bundling it.
|
- A build of the Apple app automatically rebuilds the Angular client into `apple-client/WebApp/` before bundling it.
|
||||||
|
|
||||||
|
The backend also exposes the latest Angular browser build through an API that native clients can sync into their local `WKWebView` bundle cache:
|
||||||
|
|
||||||
|
- `GET /api/web-app/manifest`: Returns a bundle manifest with a stable `bundleId`, latest `generatedAt` timestamp, and a file list containing relative paths, SHA-256 hashes, MIME types, sizes, and download URLs.
|
||||||
|
- `GET /api/web-app/files/<relative-path>`: Streams an individual file from `client/dist/client/browser` with `ETag` and `Last-Modified` headers for native caching.
|
||||||
|
|
||||||
|
If the Angular build does not exist yet, those endpoints return `404`.
|
||||||
|
|
||||||
## Backend environment
|
## Backend environment
|
||||||
|
|
||||||
The backend accepts these environment variables:
|
The backend accepts these environment variables:
|
||||||
|
|||||||
@@ -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.
BIN
client/public/apple-touch-icon.png
Normal file
BIN
client/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
21
client/public/icon-source.svg
Normal file
21
client/public/icon-source.svg
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="64" y1="40" x2="448" y2="472" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#10233B"/>
|
||||||
|
<stop offset="1" stop-color="#06111D"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="accent" x1="124" y1="124" x2="389" y2="389" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#84F4D6"/>
|
||||||
|
<stop offset="1" stop-color="#56ABFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="120" fill="url(#bg)"/>
|
||||||
|
<circle cx="154" cy="148" r="96" fill="#8DF0DF" fill-opacity="0.16"/>
|
||||||
|
<circle cx="394" cy="118" r="78" fill="#58ABFF" fill-opacity="0.16"/>
|
||||||
|
<path d="M152 164C152 132.967 177.167 107.8 208.2 107.8H303.8C334.833 107.8 360 132.967 360 164V227.7C360 258.733 334.833 283.9 303.8 283.9H257.8L198.6 335.3C190.343 342.468 177.4 336.601 177.4 325.666V283.9H208.2C177.167 283.9 152 258.733 152 227.7V164Z" fill="url(#accent)"/>
|
||||||
|
<rect x="195" y="154" width="122" height="18" rx="9" fill="#062039" fill-opacity="0.9"/>
|
||||||
|
<rect x="195" y="196" width="86" height="18" rx="9" fill="#062039" fill-opacity="0.9"/>
|
||||||
|
<path d="M354.8 334.9C354.8 379.013 319.046 414.767 274.933 414.767C255.288 414.767 237.299 407.666 223.396 395.888L172.572 410.4C163.669 412.942 155.453 404.726 157.995 395.823L172.507 344.999C160.729 331.096 153.628 313.107 153.628 293.462C153.628 249.349 189.382 213.595 233.495 213.595C277.608 213.595 313.362 249.349 313.362 293.462C313.362 304.056 311.3 314.171 307.553 323.426L344.213 360.086C350.981 366.854 354.8 376.033 354.8 385.604V334.9Z" fill="#0F2540"/>
|
||||||
|
<circle cx="233.495" cy="293.462" r="52.895" fill="#E8F3FF"/>
|
||||||
|
<path d="M233.495 258.246C252.941 258.246 268.711 274.016 268.711 293.462C268.711 312.908 252.941 328.678 233.495 328.678C214.049 328.678 198.279 312.908 198.279 293.462C198.279 274.016 214.049 258.246 233.495 258.246Z" fill="#56ABFF"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
40
client/public/manifest.webmanifest
Normal file
40
client/public/manifest.webmanifest
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "PrivateChat",
|
||||||
|
"short_name": "PrivateChat",
|
||||||
|
"description": "Private peer-to-peer chat with Angular, Fastify, and WebRTC.",
|
||||||
|
"lang": "en",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#08111d",
|
||||||
|
"theme_color": "#08111d",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "pwa-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "pwa-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
client/public/maskable-192x192.png
Normal file
BIN
client/public/maskable-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
client/public/maskable-512x512.png
Normal file
BIN
client/public/maskable-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
client/public/pwa-192x192.png
Normal file
BIN
client/public/pwa-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
client/public/pwa-512x512.png
Normal file
BIN
client/public/pwa-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
77
client/public/service-worker.js
Normal file
77
client/public/service-worker.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
const APP_SHELL_CACHE = 'privatechat-app-shell-v1';
|
||||||
|
const APP_SHELL_FILES = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/manifest.webmanifest',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/apple-touch-icon.png',
|
||||||
|
'/pwa-192x192.png',
|
||||||
|
'/pwa-512x512.png',
|
||||||
|
'/maskable-192x192.png',
|
||||||
|
'/maskable-512x512.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(APP_SHELL_CACHE).then((cache) => cache.addAll(APP_SHELL_FILES)),
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => Promise.all(
|
||||||
|
cacheNames
|
||||||
|
.filter((cacheName) => cacheName !== APP_SHELL_CACHE)
|
||||||
|
.map((cacheName) => caches.delete(cacheName)),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/') || url.pathname === '/ws') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseCopy = response.clone();
|
||||||
|
void caches.open(APP_SHELL_CACHE).then((cache) => cache.put('/index.html', responseCopy));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cache = await caches.open(APP_SHELL_CACHE);
|
||||||
|
return cache.match('/index.html') || Response.error();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cachedResponse) => {
|
||||||
|
const networkFetch = fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
const responseCopy = response.clone();
|
||||||
|
void caches.open(APP_SHELL_CACHE).then((cache) => cache.put(request, responseCopy));
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => cachedResponse || Response.error());
|
||||||
|
|
||||||
|
return cachedResponse || networkFetch;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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,8 +223,28 @@
|
|||||||
}
|
}
|
||||||
</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) {
|
||||||
|
<div class="bubble-system-status">
|
||||||
|
<span class="bubble-spinner" aria-hidden="true"></span>
|
||||||
<p class="mb-0">{{ entry.text }}</p>
|
<p class="mb-0">{{ entry.text }}</p>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<p class="mb-0">{{ entry.text }}</p>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</article>
|
</article>
|
||||||
@@ -198,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"
|
||||||
@@ -211,6 +302,42 @@
|
|||||||
{{ 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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="composer-dictation"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
|
||||||
|
(click)="toggleDictation(composerTextarea)"
|
||||||
|
[title]="
|
||||||
|
isDictating()
|
||||||
|
? 'Stop dictation and transcribe'
|
||||||
|
: isTranscribingDictation()
|
||||||
|
? 'Transcribing dictated audio'
|
||||||
|
: 'Start dictation'
|
||||||
|
"
|
||||||
|
[attr.aria-label]="
|
||||||
|
isDictating()
|
||||||
|
? 'Stop dictation and transcribe'
|
||||||
|
: isTranscribingDictation()
|
||||||
|
? 'Transcribing dictated audio'
|
||||||
|
: 'Start dictation'
|
||||||
|
"
|
||||||
|
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
|
||||||
|
>
|
||||||
|
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
#fileInput
|
#fileInput
|
||||||
class="composer-file-input"
|
class="composer-file-input"
|
||||||
@@ -228,24 +355,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"
|
||||||
|
|||||||
@@ -16,6 +16,23 @@
|
|||||||
box-shadow: 0 20px 60px var(--shadow-color);
|
box-shadow: 0 20px 60px var(--shadow-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-page {
|
||||||
|
width: min(100%, 800px);
|
||||||
|
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 {
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -77,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);
|
||||||
@@ -151,6 +168,11 @@
|
|||||||
background: var(--surface-hover-background);
|
background: var(--surface-hover-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.peer-tile-unread {
|
||||||
|
border-color: #c62828;
|
||||||
|
box-shadow: inset 0 0 0 2px #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
.peer-tile-row {
|
.peer-tile-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -289,32 +311,40 @@
|
|||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-author {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-time {
|
.bubble-time {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bubble-system-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-spinner {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 0.15rem solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
opacity: 0.8;
|
||||||
|
animation: bubble-spin 700ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.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 {
|
||||||
@@ -326,6 +356,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer-camera,
|
.composer-camera,
|
||||||
|
.composer-call,
|
||||||
|
.composer-dictation,
|
||||||
|
.composer-hangup,
|
||||||
|
.composer-voice,
|
||||||
.composer-image-generate,
|
.composer-image-generate,
|
||||||
.composer-emoji-trigger,
|
.composer-emoji-trigger,
|
||||||
.composer-plus,
|
.composer-plus,
|
||||||
@@ -349,26 +383,48 @@
|
|||||||
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-dictation {
|
||||||
|
color: var(--page-text);
|
||||||
|
background: linear-gradient(135deg, #f6d8ff, #ffcadb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-dictation-active,
|
||||||
|
.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);
|
||||||
}
|
}
|
||||||
@@ -408,26 +464,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 {
|
||||||
@@ -467,6 +533,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes bubble-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.chat-layout {
|
.chat-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -479,4 +551,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, NgZone, 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,18 +15,41 @@ 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 ngZone = inject(NgZone);
|
||||||
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
||||||
initialValue: this.route.snapshot.paramMap,
|
initialValue: this.route.snapshot.paramMap,
|
||||||
});
|
});
|
||||||
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;
|
||||||
|
private dictationRecorder: MediaRecorder | null = null;
|
||||||
|
private dictationStream: MediaStream | null = null;
|
||||||
|
private dictationChunks: Blob[] = [];
|
||||||
|
private dictationBaseText = '';
|
||||||
|
private discardRecordedDictation = false;
|
||||||
|
private dictationCompletionPromise: Promise<void> | null = null;
|
||||||
|
private resolveDictationCompletion: (() => void) | null = null;
|
||||||
|
private dictationApplyToken = 0;
|
||||||
|
@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 isDictating = signal(false);
|
||||||
|
readonly isTranscribingDictation = signal(false);
|
||||||
readonly emojiOptions = [
|
readonly emojiOptions = [
|
||||||
'😀', '😁', '😂', '🤣', '😊',
|
'😀', '😁', '😂', '🤣', '😊',
|
||||||
'😉', '😍', '😘', '😎', '🤔',
|
'😉', '😍', '😘', '😎', '🤔',
|
||||||
@@ -40,15 +63,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 +155,17 @@ 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 {
|
||||||
|
void this.stopDictation(true);
|
||||||
|
this.stopVoiceRecording(true);
|
||||||
|
this.detachCallAudioSource();
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureConnection(): Promise<void> {
|
async ensureConnection(): Promise<void> {
|
||||||
@@ -102,6 +186,7 @@ export class ChatPageComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.stopDictation(false);
|
||||||
await this.session.sendText(peerId, this.messageText);
|
await this.session.sendText(peerId, this.messageText);
|
||||||
this.messageText = '';
|
this.messageText = '';
|
||||||
this.emojiPickerOpen.set(false);
|
this.emojiPickerOpen.set(false);
|
||||||
@@ -116,7 +201,18 @@ export class ChatPageComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.session.requestGeneratedImage(peerId, this.messageText);
|
await this.stopDictation(false);
|
||||||
|
const requested = await this.session.requestGeneratedImage(peerId, this.messageText);
|
||||||
|
|
||||||
|
if (!requested) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.messageText = '';
|
||||||
|
this.handleMessageTextChange('');
|
||||||
|
this.emojiPickerOpen.set(false);
|
||||||
|
this.composerSelectionStart = 0;
|
||||||
|
this.composerSelectionEnd = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleComposerEnter(event: Event): void {
|
handleComposerEnter(event: Event): void {
|
||||||
@@ -180,6 +276,162 @@ export class ChatPageComponent {
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toggleDictation(textarea: HTMLTextAreaElement): Promise<void> {
|
||||||
|
if (this.isDictating()) {
|
||||||
|
await this.stopDictation(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isTranscribingDictation()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const peerId = this.peerId();
|
||||||
|
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof MediaRecorder === 'undefined' || typeof navigator === 'undefined') {
|
||||||
|
this.session.error.set('This browser does not support dictation recording.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof navigator.mediaDevices?.getUserMedia !== 'function') {
|
||||||
|
this.session.error.set('This browser cannot access the microphone for dictation.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dictationBaseText = this.messageText;
|
||||||
|
this.discardRecordedDictation = false;
|
||||||
|
this.dictationApplyToken += 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
const preferredMimeType = this.preferredVoiceMimeType();
|
||||||
|
const recorder = preferredMimeType
|
||||||
|
? new MediaRecorder(stream, { mimeType: preferredMimeType })
|
||||||
|
: new MediaRecorder(stream);
|
||||||
|
const applyToken = this.dictationApplyToken;
|
||||||
|
|
||||||
|
this.dictationChunks = [];
|
||||||
|
this.dictationStream = stream;
|
||||||
|
this.dictationRecorder = recorder;
|
||||||
|
this.dictationCompletionPromise = new Promise<void>((resolve) => {
|
||||||
|
this.resolveDictationCompletion = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size > 0) {
|
||||||
|
this.dictationChunks.push(event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onerror = () => {
|
||||||
|
this.ngZone.run(() => {
|
||||||
|
this.session.error.set('Could not record dictation audio.');
|
||||||
|
this.cleanupDictationRecorder();
|
||||||
|
this.finishDictationCompletion();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.onstop = () => {
|
||||||
|
const shouldDiscard = this.discardRecordedDictation;
|
||||||
|
const mimeType = recorder.mimeType || preferredMimeType || 'audio/webm';
|
||||||
|
const blob = new Blob(this.dictationChunks, { type: mimeType });
|
||||||
|
|
||||||
|
this.ngZone.run(() => {
|
||||||
|
this.cleanupDictationRecorder();
|
||||||
|
|
||||||
|
if (shouldDiscard || blob.size === 0) {
|
||||||
|
this.finishDictationCompletion();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isTranscribingDictation.set(true);
|
||||||
|
void this.transcribeDictation(blob, textarea, applyToken);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
recorder.start();
|
||||||
|
this.isDictating.set(true);
|
||||||
|
this.session.error.set(null);
|
||||||
|
} catch {
|
||||||
|
this.session.error.set('Could not start dictation recording.');
|
||||||
|
this.cleanupDictationRecorder();
|
||||||
|
this.finishDictationCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
@@ -231,6 +483,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);
|
||||||
}
|
}
|
||||||
@@ -261,6 +541,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';
|
||||||
@@ -298,9 +582,186 @@ export class ChatPageComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.stopDictation(true);
|
||||||
|
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 async stopDictation(discard: boolean): Promise<void> {
|
||||||
|
const completion = this.dictationCompletionPromise;
|
||||||
|
|
||||||
|
if (discard) {
|
||||||
|
this.dictationApplyToken += 1;
|
||||||
|
this.messageText = this.dictationBaseText || this.messageText;
|
||||||
|
this.handleMessageTextChange(this.messageText);
|
||||||
|
this.isTranscribingDictation.set(false);
|
||||||
|
} else {
|
||||||
|
this.dictationBaseText = this.messageText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dictationRecorder) {
|
||||||
|
this.discardRecordedDictation = discard;
|
||||||
|
|
||||||
|
if (this.dictationRecorder.state !== 'inactive') {
|
||||||
|
this.dictationRecorder.stop();
|
||||||
|
} else {
|
||||||
|
this.cleanupDictationRecorder();
|
||||||
|
this.finishDictationCompletion();
|
||||||
|
}
|
||||||
|
} else if (!completion) {
|
||||||
|
this.dictationBaseText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completion) {
|
||||||
|
await completion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupDictationRecorder(): void {
|
||||||
|
if (this.dictationStream) {
|
||||||
|
for (const track of this.dictationStream.getTracks()) {
|
||||||
|
track.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dictationRecorder = null;
|
||||||
|
this.dictationStream = null;
|
||||||
|
this.dictationChunks = [];
|
||||||
|
this.discardRecordedDictation = false;
|
||||||
|
this.isDictating.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishDictationCompletion(): void {
|
||||||
|
this.resolveDictationCompletion?.();
|
||||||
|
this.resolveDictationCompletion = null;
|
||||||
|
this.dictationCompletionPromise = null;
|
||||||
|
this.dictationBaseText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async transcribeDictation(blob: Blob, textarea: HTMLTextAreaElement, applyToken: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const transcript = await this.session.requestSpeechTranscription(blob);
|
||||||
|
|
||||||
|
if (applyToken !== this.dictationApplyToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyDictatedText(this.mergeDictatedText(this.dictationBaseText, transcript), textarea);
|
||||||
|
} catch {
|
||||||
|
if (applyToken === this.dictationApplyToken) {
|
||||||
|
this.session.error.set('Dictation transcription failed.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (applyToken === this.dictationApplyToken) {
|
||||||
|
this.isTranscribingDictation.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.finishDictationCompletion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeDictatedText(baseText: string, transcript: string): string {
|
||||||
|
const trimmedTranscript = transcript.trim();
|
||||||
|
|
||||||
|
if (!trimmedTranscript) {
|
||||||
|
return baseText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!baseText.trim()) {
|
||||||
|
return trimmedTranscript;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${baseText.trimEnd()} ${trimmedTranscript}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyDictatedText(text: string, textarea: HTMLTextAreaElement): void {
|
||||||
|
this.messageText = text;
|
||||||
|
textarea.value = text;
|
||||||
|
this.composerSelectionStart = text.length;
|
||||||
|
this.composerSelectionEnd = text.length;
|
||||||
|
this.handleMessageTextChange(text);
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
textarea.focus();
|
||||||
|
textarea.setSelectionRange(text.length, text.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -157,14 +166,26 @@ export class ChatSessionService {
|
|||||||
private readonly outgoingTypingIdleTimeouts = new Map<string, number>();
|
private readonly outgoingTypingIdleTimeouts = new Map<string, number>();
|
||||||
private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>();
|
private readonly outgoingTypingStates = new Map<string, { active: boolean; lastSentAt: number }>();
|
||||||
private readonly messageStoreOperations = new Map<string, Promise<void>>();
|
private readonly messageStoreOperations = new Map<string, Promise<void>>();
|
||||||
private readonly pendingImageGenerationRequests = new Map<string, { peerId: string; prompt: string }>();
|
private readonly pendingImageGenerationRequests = new Map<
|
||||||
|
string,
|
||||||
|
{ peerId: string; prompt: string; waitMessageId: string }
|
||||||
|
>();
|
||||||
|
private readonly pendingSpeechTranscriptionRequests = new Map<
|
||||||
|
string,
|
||||||
|
{ resolve: (text: string) => void; reject: (reason?: unknown) => void }
|
||||||
|
>();
|
||||||
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;
|
||||||
@@ -324,13 +345,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;
|
||||||
@@ -341,7 +363,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,12 +463,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.');
|
||||||
@@ -533,7 +632,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) {
|
||||||
@@ -553,6 +652,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,
|
||||||
@@ -569,7 +669,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,
|
||||||
@@ -579,6 +679,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;
|
||||||
@@ -602,6 +714,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;
|
||||||
@@ -614,7 +727,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.');
|
||||||
}
|
}
|
||||||
@@ -772,33 +885,65 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestGeneratedImage(peerId: string, prompt: string): Promise<void> {
|
async requestGeneratedImage(peerId: string, prompt: string): Promise<boolean> {
|
||||||
const trimmedPrompt = prompt.trim();
|
const trimmedPrompt = prompt.trim();
|
||||||
|
|
||||||
if (!trimmedPrompt) {
|
if (!trimmedPrompt) {
|
||||||
this.error.set('Enter a text prompt before requesting an image.');
|
this.error.set('Enter a text prompt before requesting an image.');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 requesting an image.');
|
this.error.set('You must be connected to signaling before requesting an image.');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = crypto.randomUUID();
|
const requestId = crypto.randomUUID();
|
||||||
|
const waitMessageId = this.addSystemMessage(peerId, 'Generating image from prompt.', {
|
||||||
|
persistent: true,
|
||||||
|
showSpinner: true,
|
||||||
|
});
|
||||||
|
|
||||||
this.pendingImageGenerationRequests.set(requestId, {
|
this.pendingImageGenerationRequests.set(requestId, {
|
||||||
peerId,
|
peerId,
|
||||||
prompt: trimmedPrompt,
|
prompt: trimmedPrompt,
|
||||||
|
waitMessageId,
|
||||||
});
|
});
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.addSystemMessage(peerId, 'Generating image from prompt.');
|
|
||||||
this.websocket.send(JSON.stringify({
|
this.websocket.send(JSON.stringify({
|
||||||
type: 'image-generation',
|
type: 'image-generation',
|
||||||
requestId,
|
requestId,
|
||||||
peerId,
|
peerId,
|
||||||
prompt: trimmedPrompt,
|
prompt: trimmedPrompt,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestSpeechTranscription(audioBlob: Blob): Promise<string> {
|
||||||
|
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error('You must be connected to signaling before using dictation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const audioBase64 = await this.blobToBase64(audioBlob);
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
this.pendingSpeechTranscriptionRequests.set(requestId, { resolve, reject });
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.error.set(null);
|
||||||
|
this.websocket?.send(JSON.stringify({
|
||||||
|
type: 'speech-transcription',
|
||||||
|
requestId,
|
||||||
|
mimeType: audioBlob.type || 'audio/webm',
|
||||||
|
audioBase64,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
this.pendingSpeechTranscriptionRequests.delete(requestId);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadAccessKeys(): Promise<void> {
|
private async loadAccessKeys(): Promise<void> {
|
||||||
@@ -829,6 +974,8 @@ export class ChatSessionService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void this.preloadRingtone();
|
||||||
|
|
||||||
this.clearWebSocketReconnect();
|
this.clearWebSocketReconnect();
|
||||||
this.disconnectWebSocket();
|
this.disconnectWebSocket();
|
||||||
this.resetPeerConnections();
|
this.resetPeerConnections();
|
||||||
@@ -873,6 +1020,7 @@ export class ChatSessionService {
|
|||||||
const shouldReconnect = this.websocket === websocket && !this.suppressSocketReconnect;
|
const shouldReconnect = this.websocket === websocket && !this.suppressSocketReconnect;
|
||||||
|
|
||||||
this.stopWebSocketHeartbeat();
|
this.stopWebSocketHeartbeat();
|
||||||
|
this.rejectPendingSpeechTranscriptions('Signaling connection closed during dictation.');
|
||||||
this.signalingState.set('disconnected');
|
this.signalingState.set('disconnected');
|
||||||
this.status.set('Signaling connection closed.');
|
this.status.set('Signaling connection closed.');
|
||||||
|
|
||||||
@@ -897,6 +1045,7 @@ export class ChatSessionService {
|
|||||||
|
|
||||||
private disconnectWebSocket(): void {
|
private disconnectWebSocket(): void {
|
||||||
this.stopWebSocketHeartbeat();
|
this.stopWebSocketHeartbeat();
|
||||||
|
this.rejectPendingSpeechTranscriptions('Signaling connection closed during dictation.');
|
||||||
|
|
||||||
if (this.websocket) {
|
if (this.websocket) {
|
||||||
this.suppressSocketReconnect = true;
|
this.suppressSocketReconnect = true;
|
||||||
@@ -922,6 +1071,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);
|
||||||
}
|
}
|
||||||
@@ -936,6 +1087,12 @@ export class ChatSessionService {
|
|||||||
case 'image-generation-error':
|
case 'image-generation-error':
|
||||||
this.handleGeneratedImageError(event);
|
this.handleGeneratedImageError(event);
|
||||||
break;
|
break;
|
||||||
|
case 'speech-transcribed':
|
||||||
|
this.handleSpeechTranscribed(event);
|
||||||
|
break;
|
||||||
|
case 'speech-transcription-error':
|
||||||
|
this.handleSpeechTranscriptionError(event);
|
||||||
|
break;
|
||||||
case 'pong':
|
case 'pong':
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
@@ -972,6 +1129,10 @@ export class ChatSessionService {
|
|||||||
fileMimeType: event.mimeType,
|
fileMimeType: event.mimeType,
|
||||||
downloadUrl: URL.createObjectURL(imageBlob),
|
downloadUrl: URL.createObjectURL(imageBlob),
|
||||||
}, imageBlob);
|
}, imageBlob);
|
||||||
|
|
||||||
|
if (pendingRequest) {
|
||||||
|
this.removeMessageById(pendingRequest.waitMessageId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleGeneratedImageError(event: Extract<ServerEvent, { type: 'image-generation-error' }>): void {
|
private handleGeneratedImageError(event: Extract<ServerEvent, { type: 'image-generation-error' }>): void {
|
||||||
@@ -979,12 +1140,35 @@ export class ChatSessionService {
|
|||||||
|
|
||||||
if (pendingRequest) {
|
if (pendingRequest) {
|
||||||
this.pendingImageGenerationRequests.delete(event.requestId);
|
this.pendingImageGenerationRequests.delete(event.requestId);
|
||||||
|
this.removeMessageById(pendingRequest.waitMessageId);
|
||||||
this.addSystemMessage(pendingRequest.peerId, 'Image generation failed.');
|
this.addSystemMessage(pendingRequest.peerId, 'Image generation failed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.error.set(event.message);
|
this.error.set(event.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleSpeechTranscribed(event: Extract<ServerEvent, { type: 'speech-transcribed' }>): void {
|
||||||
|
const pendingRequest = this.pendingSpeechTranscriptionRequests.get(event.requestId);
|
||||||
|
|
||||||
|
if (!pendingRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingSpeechTranscriptionRequests.delete(event.requestId);
|
||||||
|
pendingRequest.resolve(event.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleSpeechTranscriptionError(event: Extract<ServerEvent, { type: 'speech-transcription-error' }>): void {
|
||||||
|
const pendingRequest = this.pendingSpeechTranscriptionRequests.get(event.requestId);
|
||||||
|
|
||||||
|
if (pendingRequest) {
|
||||||
|
this.pendingSpeechTranscriptionRequests.delete(event.requestId);
|
||||||
|
pendingRequest.reject(new Error(event.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error.set(event.message);
|
||||||
|
}
|
||||||
|
|
||||||
private async restoreSession(): Promise<void> {
|
private async restoreSession(): Promise<void> {
|
||||||
const token = this.token();
|
const token = this.token();
|
||||||
|
|
||||||
@@ -1135,6 +1319,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> {
|
||||||
@@ -1183,10 +1371,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);
|
||||||
@@ -1203,7 +1393,9 @@ export class ChatSessionService {
|
|||||||
}),
|
}),
|
||||||
pendingCandidates: [],
|
pendingCandidates: [],
|
||||||
pendingNegotiation: false,
|
pendingNegotiation: false,
|
||||||
|
announceConnectionEvents: announce,
|
||||||
cameraSenders: [],
|
cameraSenders: [],
|
||||||
|
audioSenders: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
bundle.pc.onicecandidate = (event) => {
|
bundle.pc.onicecandidate = (event) => {
|
||||||
@@ -1219,7 +1411,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.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1241,6 +1433,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) {
|
||||||
@@ -1264,6 +1458,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) {
|
||||||
@@ -1285,7 +1510,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 = () => {
|
||||||
@@ -1334,6 +1561,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,
|
||||||
@@ -1357,6 +1585,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1387,7 +1624,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,
|
||||||
@@ -1438,6 +1675,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;
|
||||||
@@ -1492,12 +1749,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;
|
||||||
@@ -1510,10 +1909,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);
|
||||||
@@ -1628,7 +2038,11 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private addSystemMessage(peerId: string, text: string): void {
|
private addSystemMessage(
|
||||||
|
peerId: string,
|
||||||
|
text: string,
|
||||||
|
options?: { persistent?: boolean; showSpinner?: boolean },
|
||||||
|
): string {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
this.pushMessage({
|
this.pushMessage({
|
||||||
@@ -1638,15 +2052,20 @@ export class ChatSessionService {
|
|||||||
kind: 'system',
|
kind: 'system',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
authorLabel: 'System',
|
authorLabel: 'System',
|
||||||
|
showSpinner: options?.showSpinner,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!options?.persistent) {
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
this.removeMessageById(id);
|
this.removeMessageById(id);
|
||||||
}, ChatSessionService.systemMessageLifetimeMs);
|
}, ChatSessionService.systemMessageLifetimeMs);
|
||||||
this.systemMessageTimeouts.set(id, timeoutId);
|
this.systemMessageTimeouts.set(id, timeoutId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
private isPolitePeer(peerId: string): boolean {
|
private isPolitePeer(peerId: string): boolean {
|
||||||
return (this.currentUser()?.id ?? '') > peerId;
|
return (this.currentUser()?.id ?? '') > peerId;
|
||||||
}
|
}
|
||||||
@@ -1665,6 +2084,18 @@ export class ChatSessionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private rejectPendingSpeechTranscriptions(message: string): void {
|
||||||
|
if (this.pendingSpeechTranscriptionRequests.size === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { reject } of this.pendingSpeechTranscriptionRequests.values()) {
|
||||||
|
reject(new Error(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pendingSpeechTranscriptionRequests.clear();
|
||||||
|
}
|
||||||
|
|
||||||
private clearLocalAuth(statusMessage: string): void {
|
private clearLocalAuth(statusMessage: string): void {
|
||||||
this.clearWebSocketReconnect();
|
this.clearWebSocketReconnect();
|
||||||
this.disconnectWebSocket();
|
this.disconnectWebSocket();
|
||||||
@@ -1672,10 +2103,18 @@ 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.rejectPendingSpeechTranscriptions('Session ended during dictation.');
|
||||||
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);
|
||||||
@@ -1694,6 +2133,19 @@ export class ChatSessionService {
|
|||||||
this.removeStorage('privatechat.user');
|
this.removeStorage('privatechat.user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async blobToBase64(blob: Blob): Promise<string> {
|
||||||
|
const buffer = await blob.arrayBuffer();
|
||||||
|
let binary = '';
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
|
||||||
|
for (let index = 0; index < bytes.length; index += chunkSize) {
|
||||||
|
binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
private async loadPersistedMessages(userId: string): Promise<void> {
|
private async loadPersistedMessages(userId: string): Promise<void> {
|
||||||
const messageEncryptionKey = this.messageEncryptionKey;
|
const messageEncryptionKey = this.messageEncryptionKey;
|
||||||
|
|
||||||
@@ -2119,6 +2571,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));
|
||||||
|
|
||||||
@@ -2127,6 +2593,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);
|
||||||
|
|
||||||
@@ -2257,7 +2876,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,9 +94,10 @@ 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;
|
||||||
text?: string;
|
text?: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
fileName?: string;
|
fileName?: string;
|
||||||
@@ -129,6 +130,16 @@ export type ServerEvent =
|
|||||||
peerId: string;
|
peerId: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'speech-transcribed';
|
||||||
|
requestId: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'speech-transcription-error';
|
||||||
|
requestId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
@@ -155,6 +166,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;
|
||||||
@@ -170,4 +182,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';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,13 @@
|
|||||||
<title>PrivateChat</title>
|
<title>PrivateChat</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="theme-color" content="#08111d">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="PrivateChat">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
|||||||
@@ -2,5 +2,13 @@ import { bootstrapApplication } from '@angular/platform-browser';
|
|||||||
import { appConfig } from './app/app.config';
|
import { appConfig } from './app/app.config';
|
||||||
import { App } from './app/app';
|
import { App } from './app/app';
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && 'serviceWorker' in navigator && window.isSecureContext) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
void navigator.serviceWorker.register('/service-worker.js').catch((error) => {
|
||||||
|
console.error('Service worker registration failed.', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
bootstrapApplication(App, appConfig)
|
bootstrapApplication(App, appConfig)
|
||||||
.catch((err) => console.error(err));
|
.catch((err) => console.error(err));
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
214
server/dist/index.js
vendored
214
server/dist/index.js
vendored
@@ -13,6 +13,7 @@ import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthe
|
|||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { SpeechTranscriber } from './speech-transcriber.js';
|
||||||
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
||||||
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
@@ -43,6 +44,9 @@ const approvePendingUserParamsSchema = z.object({
|
|||||||
const adminDeleteUserParamsSchema = z.object({
|
const adminDeleteUserParamsSchema = z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
const webBundleFileParamsSchema = z.object({
|
||||||
|
'*': z.string().min(1),
|
||||||
|
});
|
||||||
const wsQuerySchema = z.object({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -78,6 +82,12 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
|||||||
z.object({
|
z.object({
|
||||||
type: z.literal('ping'),
|
type: z.literal('ping'),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('speech-transcription'),
|
||||||
|
requestId: z.string().uuid(),
|
||||||
|
mimeType: z.string().trim().min(1).max(128),
|
||||||
|
audioBase64: z.string().min(1).max(32_000_000),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
const app = Fastify({ logger: true, trustProxy: true });
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
const approvalAdminUsername = 'ladparis';
|
const approvalAdminUsername = 'ladparis';
|
||||||
@@ -88,6 +98,9 @@ const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR
|
|||||||
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
||||||
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
||||||
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
||||||
|
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080';
|
||||||
|
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
|
||||||
|
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
|
||||||
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
||||||
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||||
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||||
@@ -98,6 +111,11 @@ const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
|||||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
||||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
|
const speechTranscriber = new SpeechTranscriber({
|
||||||
|
serviceUrl: speechTranscriptionServiceUrl,
|
||||||
|
language: speechTranscriptionLanguage,
|
||||||
|
requestTimeoutMs: speechTranscriptionTimeoutMs,
|
||||||
|
}, app.log);
|
||||||
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
||||||
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
|
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
|
||||||
@@ -240,6 +258,45 @@ else {
|
|||||||
app.log.warn({ frontendDistPath }, 'Angular frontend build not found. Build the client before serving it from the backend.');
|
app.log.warn({ frontendDistPath }, 'Angular frontend build not found. Build the client before serving it from the backend.');
|
||||||
}
|
}
|
||||||
app.get('/api/health', async () => ({ ok: true }));
|
app.get('/api/health', async () => ({ ok: true }));
|
||||||
|
app.get('/api/web-app/manifest', async (request, reply) => {
|
||||||
|
const manifest = getFrontendBundleManifest();
|
||||||
|
if (!manifest) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: 'Angular frontend build not found.',
|
||||||
|
frontendDistPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const etag = `"${manifest.bundleId}"`;
|
||||||
|
reply.header('Cache-Control', 'no-cache');
|
||||||
|
reply.header('ETag', etag);
|
||||||
|
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
|
||||||
|
return reply.code(304).send();
|
||||||
|
}
|
||||||
|
return manifest;
|
||||||
|
});
|
||||||
|
app.get('/api/web-app/files/*', async (request, reply) => {
|
||||||
|
const parsed = webBundleFileParamsSchema.safeParse(request.params);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid web bundle asset path.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const asset = resolveFrontendBundleAsset(parsed.data['*']);
|
||||||
|
if (!asset) {
|
||||||
|
return reply.code(404).send({ message: 'Frontend asset not found.' });
|
||||||
|
}
|
||||||
|
const etag = `W/"${asset.etag}"`;
|
||||||
|
reply.header('Cache-Control', 'public, max-age=300');
|
||||||
|
reply.header('ETag', etag);
|
||||||
|
reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString());
|
||||||
|
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
|
||||||
|
return reply.code(304).send();
|
||||||
|
}
|
||||||
|
reply.header('Content-Length', String(asset.size));
|
||||||
|
reply.type(asset.contentType);
|
||||||
|
return reply.send(fs.createReadStream(asset.absolutePath));
|
||||||
|
});
|
||||||
app.post('/api/auth/register', async (request, reply) => {
|
app.post('/api/auth/register', async (request, reply) => {
|
||||||
const parsed = registerSchema.safeParse(request.body);
|
const parsed = registerSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -677,6 +734,25 @@ async function handleSocketMessage(userId, sessionId, socket, rawMessage) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (parsed.type === 'speech-transcription') {
|
||||||
|
try {
|
||||||
|
const text = await transcribeAudioPayload(parsed.requestId, parsed.audioBase64, parsed.mimeType);
|
||||||
|
send(socket, {
|
||||||
|
type: 'speech-transcribed',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
app.log.warn({ err: error, userId }, 'Speech transcription failed');
|
||||||
|
send(socket, {
|
||||||
|
type: 'speech-transcription-error',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
message: error instanceof Error ? error.message : 'Speech transcription failed.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||||
if (recipientSockets) {
|
if (recipientSockets) {
|
||||||
@@ -1053,12 +1129,23 @@ function parseClientMessage(rawMessage) {
|
|||||||
prompt: parsed.data.prompt,
|
prompt: parsed.data.prompt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (parsed.data.type === 'speech-transcription') {
|
||||||
|
return {
|
||||||
|
type: 'speech-transcription',
|
||||||
|
requestId: parsed.data.requestId,
|
||||||
|
mimeType: parsed.data.mimeType,
|
||||||
|
audioBase64: parsed.data.audioBase64,
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'signal',
|
type: 'signal',
|
||||||
to: parsed.data.to,
|
to: parsed.data.to,
|
||||||
signal: normalizeSignal(parsed.data.signal),
|
signal: normalizeSignal(parsed.data.signal),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async function transcribeAudioPayload(requestId, audioBase64, mimeType) {
|
||||||
|
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
|
||||||
|
}
|
||||||
async function generateImageFromPrompt(prompt) {
|
async function generateImageFromPrompt(prompt) {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||||
@@ -1123,6 +1210,133 @@ function inferImageMimeType(imageBuffer) {
|
|||||||
}
|
}
|
||||||
return 'application/octet-stream';
|
return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
function getFrontendBundleManifest() {
|
||||||
|
if (!fs.existsSync(frontendIndexPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => {
|
||||||
|
const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath));
|
||||||
|
const stats = fs.statSync(absolutePath);
|
||||||
|
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex');
|
||||||
|
return {
|
||||||
|
path: relativePath,
|
||||||
|
size: stats.size,
|
||||||
|
sha256,
|
||||||
|
lastModified: stats.mtime.toISOString(),
|
||||||
|
contentType: detectBundleContentType(relativePath),
|
||||||
|
href: bundleAssetHref(relativePath),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
files.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
const generatedAt = files.reduce((latest, file) => (file.lastModified > latest ? file.lastModified : latest), new Date(0).toISOString());
|
||||||
|
const bundleId = files.reduce((hash, file) => {
|
||||||
|
hash.update(file.path);
|
||||||
|
hash.update(file.sha256);
|
||||||
|
hash.update(String(file.size));
|
||||||
|
return hash;
|
||||||
|
}, crypto.createHash('sha256')).digest('hex');
|
||||||
|
return {
|
||||||
|
bundleId,
|
||||||
|
generatedAt,
|
||||||
|
indexPath: 'index.html',
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function resolveFrontendBundleAsset(relativeAssetPath) {
|
||||||
|
if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalizedPath = toBundleRelativePath(relativeAssetPath);
|
||||||
|
if (normalizedPath.length === 0 ||
|
||||||
|
normalizedPath === '.' ||
|
||||||
|
normalizedPath.startsWith('../') ||
|
||||||
|
normalizedPath.startsWith('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const absolutePath = path.resolve(frontendDistPath, normalizedPath);
|
||||||
|
const relativeToRoot = path.relative(frontendDistPath, absolutePath);
|
||||||
|
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const stats = fs.statSync(absolutePath);
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
absolutePath,
|
||||||
|
contentType: detectBundleContentType(normalizedPath),
|
||||||
|
size: stats.size,
|
||||||
|
lastModifiedMs: stats.mtimeMs,
|
||||||
|
etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function listBundleFilePaths(rootPath) {
|
||||||
|
return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const entryPath = path.join(rootPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return listBundleFilePaths(entryPath);
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [entryPath];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bundleAssetHref(relativePath) {
|
||||||
|
return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`;
|
||||||
|
}
|
||||||
|
function toBundleRelativePath(inputPath) {
|
||||||
|
return path.posix.normalize(inputPath.replaceAll('\\', '/'));
|
||||||
|
}
|
||||||
|
function detectBundleContentType(assetPath) {
|
||||||
|
const extension = path.extname(assetPath).toLowerCase();
|
||||||
|
switch (extension) {
|
||||||
|
case '.mp3':
|
||||||
|
return 'audio/mpeg';
|
||||||
|
case '.m4a':
|
||||||
|
return 'audio/mp4';
|
||||||
|
case '.css':
|
||||||
|
return 'text/css; charset=utf-8';
|
||||||
|
case '.html':
|
||||||
|
return 'text/html; charset=utf-8';
|
||||||
|
case '.ico':
|
||||||
|
return 'image/x-icon';
|
||||||
|
case '.jpeg':
|
||||||
|
case '.jpg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case '.js':
|
||||||
|
return 'text/javascript; charset=utf-8';
|
||||||
|
case '.json':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.map':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.png':
|
||||||
|
return 'image/png';
|
||||||
|
case '.svg':
|
||||||
|
return 'image/svg+xml; charset=utf-8';
|
||||||
|
case '.txt':
|
||||||
|
return 'text/plain; charset=utf-8';
|
||||||
|
case '.webp':
|
||||||
|
return 'image/webp';
|
||||||
|
case '.webmanifest':
|
||||||
|
return 'application/manifest+json; charset=utf-8';
|
||||||
|
case '.woff':
|
||||||
|
return 'font/woff';
|
||||||
|
case '.woff2':
|
||||||
|
return 'font/woff2';
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function requestMatchesEtag(headerValue, etag) {
|
||||||
|
if (!headerValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const incomingEtags = Array.isArray(headerValue)
|
||||||
|
? headerValue
|
||||||
|
: headerValue.split(',').map((value) => value.trim());
|
||||||
|
return incomingEtags.includes(etag) || incomingEtags.includes('*');
|
||||||
|
}
|
||||||
function normalizeSignal(signal) {
|
function normalizeSignal(signal) {
|
||||||
if (signal.type === 'sdp') {
|
if (signal.type === 'sdp') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
124
server/dist/speech-transcriber.js
vendored
Normal file
124
server/dist/speech-transcriber.js
vendored
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import WebSocket from 'ws';
|
||||||
|
export class SpeechTranscriber {
|
||||||
|
options;
|
||||||
|
logger;
|
||||||
|
constructor(options, logger) {
|
||||||
|
this.options = options;
|
||||||
|
this.logger = logger;
|
||||||
|
}
|
||||||
|
async transcribe(requestId, audioBase64, mimeType) {
|
||||||
|
const audio = this.normalizeAudioPayload(audioBase64, mimeType);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const socket = new WebSocket(this.options.serviceUrl);
|
||||||
|
const finish = (handler) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
socket.removeAllListeners();
|
||||||
|
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
handler();
|
||||||
|
};
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
finish(() => {
|
||||||
|
reject(new Error(`The transcription service timed out after ${this.options.requestTimeoutMs}ms.`));
|
||||||
|
});
|
||||||
|
}, this.options.requestTimeoutMs);
|
||||||
|
socket.on('open', () => {
|
||||||
|
try {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'transcribe',
|
||||||
|
id: requestId,
|
||||||
|
language: this.options.language,
|
||||||
|
audio,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
finish(() => {
|
||||||
|
reject(error instanceof Error ? error : new Error('Could not send transcription request.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('message', (payload) => {
|
||||||
|
const event = this.parseEvent(payload);
|
||||||
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.id && event.id !== requestId) {
|
||||||
|
this.logger.warn({ requestId, event }, 'Ignored transcription event for another request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === 'start') {
|
||||||
|
this.logger.info({ requestId, model: event.model, language: event.language }, 'Speech transcription started');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === 'delta') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.type === 'done') {
|
||||||
|
finish(() => {
|
||||||
|
resolve(event.text.trim());
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finish(() => {
|
||||||
|
reject(new Error(event.message));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
finish(() => {
|
||||||
|
reject(error instanceof Error ? error : new Error('The transcription service connection failed.'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on('close', (code, reasonBuffer) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reason = reasonBuffer.toString().trim();
|
||||||
|
const detail = reason
|
||||||
|
? `The transcription service closed the connection unexpectedly (code=${code}, reason=${reason}).`
|
||||||
|
: `The transcription service closed the connection unexpectedly (code=${code}).`;
|
||||||
|
finish(() => {
|
||||||
|
reject(new Error(detail));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
normalizeAudioPayload(audioBase64, mimeType) {
|
||||||
|
const trimmedAudio = audioBase64.trim();
|
||||||
|
if (trimmedAudio.startsWith('data:')) {
|
||||||
|
return trimmedAudio;
|
||||||
|
}
|
||||||
|
const normalizedMimeType = mimeType.trim() || 'audio/webm';
|
||||||
|
return `data:${normalizedMimeType};base64,${trimmedAudio}`;
|
||||||
|
}
|
||||||
|
parseEvent(payload) {
|
||||||
|
const message = this.rawDataToString(payload).trim();
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(message);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
this.logger.warn({ transcriptionPayload: message }, 'Ignored non-JSON transcription event');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rawDataToString(payload) {
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
if (payload instanceof ArrayBuffer) {
|
||||||
|
return Buffer.from(payload).toString('utf8');
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return Buffer.concat(payload).toString('utf8');
|
||||||
|
}
|
||||||
|
return payload.toString('utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
1
server/package-lock.json
generated
1
server/package-lock.json
generated
@@ -16,6 +16,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.0",
|
||||||
|
"ws": "^8.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { Redis } from 'ioredis';
|
|||||||
import type WebSocket from 'ws';
|
import type WebSocket from 'ws';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { SpeechTranscriber } from './speech-transcriber.js';
|
||||||
|
|
||||||
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
||||||
|
|
||||||
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
||||||
@@ -120,6 +122,12 @@ type ClientMessage =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'ping';
|
type: 'ping';
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'speech-transcription';
|
||||||
|
requestId: string;
|
||||||
|
mimeType: string;
|
||||||
|
audioBase64: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ServerMessage =
|
type ServerMessage =
|
||||||
@@ -142,6 +150,16 @@ type ServerMessage =
|
|||||||
peerId: string;
|
peerId: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'speech-transcribed';
|
||||||
|
requestId: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'speech-transcription-error';
|
||||||
|
requestId: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
@@ -171,6 +189,22 @@ type AccessKeySummary = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type WebBundleFileEntry = {
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
lastModified: string;
|
||||||
|
contentType: string;
|
||||||
|
href: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebBundleManifest = {
|
||||||
|
bundleId: string;
|
||||||
|
generatedAt: string;
|
||||||
|
indexPath: string;
|
||||||
|
files: WebBundleFileEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
type PendingRegistration = {
|
type PendingRegistration = {
|
||||||
challenge: string;
|
challenge: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -233,6 +267,10 @@ const adminDeleteUserParamsSchema = z.object({
|
|||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const webBundleFileParamsSchema = z.object({
|
||||||
|
'*': z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
const wsQuerySchema = z.object({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -269,6 +307,12 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
|||||||
z.object({
|
z.object({
|
||||||
type: z.literal('ping'),
|
type: z.literal('ping'),
|
||||||
}),
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('speech-transcription'),
|
||||||
|
requestId: z.string().uuid(),
|
||||||
|
mimeType: z.string().trim().min(1).max(128),
|
||||||
|
audioBase64: z.string().min(1).max(32_000_000),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const app = Fastify({ logger: true, trustProxy: true });
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
@@ -287,6 +331,9 @@ const frontendDistPath = resolveProjectPath(
|
|||||||
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
||||||
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
||||||
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
||||||
|
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080';
|
||||||
|
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
|
||||||
|
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
|
||||||
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
||||||
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||||
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||||
@@ -300,6 +347,15 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
|||||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
|
|
||||||
|
const speechTranscriber = new SpeechTranscriber(
|
||||||
|
{
|
||||||
|
serviceUrl: speechTranscriptionServiceUrl,
|
||||||
|
language: speechTranscriptionLanguage,
|
||||||
|
requestTimeoutMs: speechTranscriptionTimeoutMs,
|
||||||
|
},
|
||||||
|
app.log,
|
||||||
|
);
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
||||||
|
|
||||||
@@ -461,6 +517,57 @@ if (hasFrontendBuild) {
|
|||||||
|
|
||||||
app.get('/api/health', async () => ({ ok: true }));
|
app.get('/api/health', async () => ({ ok: true }));
|
||||||
|
|
||||||
|
app.get('/api/web-app/manifest', async (request, reply) => {
|
||||||
|
const manifest = getFrontendBundleManifest();
|
||||||
|
|
||||||
|
if (!manifest) {
|
||||||
|
return reply.code(404).send({
|
||||||
|
message: 'Angular frontend build not found.',
|
||||||
|
frontendDistPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = `"${manifest.bundleId}"`;
|
||||||
|
reply.header('Cache-Control', 'no-cache');
|
||||||
|
reply.header('ETag', etag);
|
||||||
|
|
||||||
|
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
|
||||||
|
return reply.code(304).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifest;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/web-app/files/*', async (request, reply) => {
|
||||||
|
const parsed = webBundleFileParamsSchema.safeParse(request.params);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({
|
||||||
|
message: 'Invalid web bundle asset path.',
|
||||||
|
issues: parsed.error.flatten(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const asset = resolveFrontendBundleAsset(parsed.data['*']);
|
||||||
|
|
||||||
|
if (!asset) {
|
||||||
|
return reply.code(404).send({ message: 'Frontend asset not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = `W/"${asset.etag}"`;
|
||||||
|
reply.header('Cache-Control', 'public, max-age=300');
|
||||||
|
reply.header('ETag', etag);
|
||||||
|
reply.header('Last-Modified', new Date(asset.lastModifiedMs).toUTCString());
|
||||||
|
|
||||||
|
if (requestMatchesEtag(request.headers['if-none-match'], etag)) {
|
||||||
|
return reply.code(304).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Length', String(asset.size));
|
||||||
|
reply.type(asset.contentType);
|
||||||
|
return reply.send(fs.createReadStream(asset.absolutePath));
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/auth/register', async (request, reply) => {
|
app.post('/api/auth/register', async (request, reply) => {
|
||||||
const parsed = registerSchema.safeParse(request.body);
|
const parsed = registerSchema.safeParse(request.body);
|
||||||
|
|
||||||
@@ -1056,6 +1163,27 @@ async function handleSocketMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.type === 'speech-transcription') {
|
||||||
|
try {
|
||||||
|
const text = await transcribeAudioPayload(parsed.requestId, parsed.audioBase64, parsed.mimeType);
|
||||||
|
|
||||||
|
send(socket, {
|
||||||
|
type: 'speech-transcribed',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
app.log.warn({ err: error, userId }, 'Speech transcription failed');
|
||||||
|
send(socket, {
|
||||||
|
type: 'speech-transcription-error',
|
||||||
|
requestId: parsed.requestId,
|
||||||
|
message: error instanceof Error ? error.message : 'Speech transcription failed.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||||
|
|
||||||
@@ -1597,6 +1725,15 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parsed.data.type === 'speech-transcription') {
|
||||||
|
return {
|
||||||
|
type: 'speech-transcription',
|
||||||
|
requestId: parsed.data.requestId,
|
||||||
|
mimeType: parsed.data.mimeType,
|
||||||
|
audioBase64: parsed.data.audioBase64,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'signal',
|
type: 'signal',
|
||||||
to: parsed.data.to,
|
to: parsed.data.to,
|
||||||
@@ -1604,6 +1741,10 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function transcribeAudioPayload(requestId: string, audioBase64: string, mimeType: string): Promise<string> {
|
||||||
|
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
async function generateImageFromPrompt(prompt: string): Promise<{ imageBase64: string; mimeType: string }> {
|
async function generateImageFromPrompt(prompt: string): Promise<{ imageBase64: string; mimeType: string }> {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||||
@@ -1685,6 +1826,169 @@ function inferImageMimeType(imageBuffer: Buffer): string {
|
|||||||
return 'application/octet-stream';
|
return 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFrontendBundleManifest(): WebBundleManifest | null {
|
||||||
|
if (!fs.existsSync(frontendIndexPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = listBundleFilePaths(frontendDistPath).map((absolutePath) => {
|
||||||
|
const relativePath = toBundleRelativePath(path.relative(frontendDistPath, absolutePath));
|
||||||
|
const stats = fs.statSync(absolutePath);
|
||||||
|
const sha256 = crypto.createHash('sha256').update(fs.readFileSync(absolutePath)).digest('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: relativePath,
|
||||||
|
size: stats.size,
|
||||||
|
sha256,
|
||||||
|
lastModified: stats.mtime.toISOString(),
|
||||||
|
contentType: detectBundleContentType(relativePath),
|
||||||
|
href: bundleAssetHref(relativePath),
|
||||||
|
} satisfies WebBundleFileEntry;
|
||||||
|
});
|
||||||
|
|
||||||
|
files.sort((left, right) => left.path.localeCompare(right.path));
|
||||||
|
|
||||||
|
const generatedAt = files.reduce(
|
||||||
|
(latest, file) => (file.lastModified > latest ? file.lastModified : latest),
|
||||||
|
new Date(0).toISOString(),
|
||||||
|
);
|
||||||
|
const bundleId = files.reduce((hash, file) => {
|
||||||
|
hash.update(file.path);
|
||||||
|
hash.update(file.sha256);
|
||||||
|
hash.update(String(file.size));
|
||||||
|
return hash;
|
||||||
|
}, crypto.createHash('sha256')).digest('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
bundleId,
|
||||||
|
generatedAt,
|
||||||
|
indexPath: 'index.html',
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFrontendBundleAsset(relativeAssetPath: string): {
|
||||||
|
absolutePath: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
lastModifiedMs: number;
|
||||||
|
etag: string;
|
||||||
|
} | null {
|
||||||
|
if (!fs.existsSync(frontendIndexPath) || !fs.existsSync(frontendDistPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = toBundleRelativePath(relativeAssetPath);
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedPath.length === 0 ||
|
||||||
|
normalizedPath === '.' ||
|
||||||
|
normalizedPath.startsWith('../') ||
|
||||||
|
normalizedPath.startsWith('/')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const absolutePath = path.resolve(frontendDistPath, normalizedPath);
|
||||||
|
const relativeToRoot = path.relative(frontendDistPath, absolutePath);
|
||||||
|
|
||||||
|
if (relativeToRoot.startsWith('..') || path.isAbsolute(relativeToRoot) || !fs.existsSync(absolutePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = fs.statSync(absolutePath);
|
||||||
|
|
||||||
|
if (!stats.isFile()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
absolutePath,
|
||||||
|
contentType: detectBundleContentType(normalizedPath),
|
||||||
|
size: stats.size,
|
||||||
|
lastModifiedMs: stats.mtimeMs,
|
||||||
|
etag: `${stats.size}-${Math.floor(stats.mtimeMs)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBundleFilePaths(rootPath: string): string[] {
|
||||||
|
return fs.readdirSync(rootPath, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const entryPath = path.join(rootPath, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return listBundleFilePaths(entryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [entryPath];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bundleAssetHref(relativePath: string): string {
|
||||||
|
return `/api/web-app/files/${relativePath.split('/').map((segment) => encodeURIComponent(segment)).join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBundleRelativePath(inputPath: string): string {
|
||||||
|
return path.posix.normalize(inputPath.replaceAll('\\', '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectBundleContentType(assetPath: string): string {
|
||||||
|
const extension = path.extname(assetPath).toLowerCase();
|
||||||
|
|
||||||
|
switch (extension) {
|
||||||
|
case '.mp3':
|
||||||
|
return 'audio/mpeg';
|
||||||
|
case '.m4a':
|
||||||
|
return 'audio/mp4';
|
||||||
|
case '.css':
|
||||||
|
return 'text/css; charset=utf-8';
|
||||||
|
case '.html':
|
||||||
|
return 'text/html; charset=utf-8';
|
||||||
|
case '.ico':
|
||||||
|
return 'image/x-icon';
|
||||||
|
case '.jpeg':
|
||||||
|
case '.jpg':
|
||||||
|
return 'image/jpeg';
|
||||||
|
case '.js':
|
||||||
|
return 'text/javascript; charset=utf-8';
|
||||||
|
case '.json':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.map':
|
||||||
|
return 'application/json; charset=utf-8';
|
||||||
|
case '.png':
|
||||||
|
return 'image/png';
|
||||||
|
case '.svg':
|
||||||
|
return 'image/svg+xml; charset=utf-8';
|
||||||
|
case '.txt':
|
||||||
|
return 'text/plain; charset=utf-8';
|
||||||
|
case '.webp':
|
||||||
|
return 'image/webp';
|
||||||
|
case '.webmanifest':
|
||||||
|
return 'application/manifest+json; charset=utf-8';
|
||||||
|
case '.woff':
|
||||||
|
return 'font/woff';
|
||||||
|
case '.woff2':
|
||||||
|
return 'font/woff2';
|
||||||
|
default:
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMatchesEtag(headerValue: string | string[] | undefined, etag: string): boolean {
|
||||||
|
if (!headerValue) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incomingEtags = Array.isArray(headerValue)
|
||||||
|
? headerValue
|
||||||
|
: headerValue.split(',').map((value) => value.trim());
|
||||||
|
|
||||||
|
return incomingEtags.includes(etag) || incomingEtags.includes('*');
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeSignal(signal: Extract<ClientMessage, { type: 'signal' }>['signal']): SignalPayload {
|
function normalizeSignal(signal: Extract<ClientMessage, { type: 'signal' }>['signal']): SignalPayload {
|
||||||
if (signal.type === 'sdp') {
|
if (signal.type === 'sdp') {
|
||||||
return {
|
return {
|
||||||
|
|||||||
173
server/src/speech-transcriber.ts
Normal file
173
server/src/speech-transcriber.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import WebSocket, { type RawData } from 'ws';
|
||||||
|
|
||||||
|
type LoggerLike = {
|
||||||
|
info: (payload: unknown, message?: string) => void;
|
||||||
|
warn: (payload: unknown, message?: string) => void;
|
||||||
|
error: (payload: unknown, message?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SpeechTranscriberOptions = {
|
||||||
|
serviceUrl: string;
|
||||||
|
language: string;
|
||||||
|
requestTimeoutMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ServiceEvent =
|
||||||
|
| { type: 'start'; id: string; model: string; language: string }
|
||||||
|
| { type: 'delta'; id: string; text: string; fullText: string }
|
||||||
|
| { type: 'done'; id: string; text: string }
|
||||||
|
| { type: 'error'; id?: string; message: string };
|
||||||
|
|
||||||
|
export class SpeechTranscriber {
|
||||||
|
constructor(
|
||||||
|
private readonly options: SpeechTranscriberOptions,
|
||||||
|
private readonly logger: LoggerLike,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async transcribe(requestId: string, audioBase64: string, mimeType: string): Promise<string> {
|
||||||
|
const audio = this.normalizeAudioPayload(audioBase64, mimeType);
|
||||||
|
|
||||||
|
return await new Promise<string>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const socket = new WebSocket(this.options.serviceUrl);
|
||||||
|
|
||||||
|
const finish = (handler: () => void): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
socket.removeAllListeners();
|
||||||
|
|
||||||
|
if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
handler();
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
finish(() => {
|
||||||
|
reject(new Error(`The transcription service timed out after ${this.options.requestTimeoutMs}ms.`));
|
||||||
|
});
|
||||||
|
}, this.options.requestTimeoutMs);
|
||||||
|
|
||||||
|
socket.on('open', () => {
|
||||||
|
try {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'transcribe',
|
||||||
|
id: requestId,
|
||||||
|
language: this.options.language,
|
||||||
|
audio,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
finish(() => {
|
||||||
|
reject(error instanceof Error ? error : new Error('Could not send transcription request.'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('message', (payload) => {
|
||||||
|
const event = this.parseEvent(payload);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.id && event.id !== requestId) {
|
||||||
|
this.logger.warn({ requestId, event }, 'Ignored transcription event for another request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'start') {
|
||||||
|
this.logger.info(
|
||||||
|
{ requestId, model: event.model, language: event.language },
|
||||||
|
'Speech transcription started',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'delta') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'done') {
|
||||||
|
finish(() => {
|
||||||
|
resolve(event.text.trim());
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(() => {
|
||||||
|
reject(new Error(event.message));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
finish(() => {
|
||||||
|
reject(error instanceof Error ? error : new Error('The transcription service connection failed.'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', (code, reasonBuffer) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = reasonBuffer.toString().trim();
|
||||||
|
const detail = reason
|
||||||
|
? `The transcription service closed the connection unexpectedly (code=${code}, reason=${reason}).`
|
||||||
|
: `The transcription service closed the connection unexpectedly (code=${code}).`;
|
||||||
|
|
||||||
|
finish(() => {
|
||||||
|
reject(new Error(detail));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAudioPayload(audioBase64: string, mimeType: string): string {
|
||||||
|
const trimmedAudio = audioBase64.trim();
|
||||||
|
|
||||||
|
if (trimmedAudio.startsWith('data:')) {
|
||||||
|
return trimmedAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedMimeType = mimeType.trim() || 'audio/webm';
|
||||||
|
return `data:${normalizedMimeType};base64,${trimmedAudio}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseEvent(payload: RawData): ServiceEvent | null {
|
||||||
|
const message = this.rawDataToString(payload).trim();
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(message) as ServiceEvent;
|
||||||
|
} catch {
|
||||||
|
this.logger.warn({ transcriptionPayload: message }, 'Ignored non-JSON transcription event');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private rawDataToString(payload: RawData): string {
|
||||||
|
if (typeof payload === 'string') {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload instanceof ArrayBuffer) {
|
||||||
|
return Buffer.from(payload).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) {
|
||||||
|
return Buffer.concat(payload).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.toString('utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user