Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84745eb104 | |||
| ae59d3deac | |||
| 687bd56e42 | |||
| 03d3b75fb4 | |||
| 32084a66d1 | |||
| 64e03964e9 | |||
| 11cc5350c8 | |||
| 0e4c79b735 | |||
| ffdea4fe62 | |||
| 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`.
|
||||
- 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
|
||||
|
||||
The backend accepts these environment variables:
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
"maximumWarning": "10kB",
|
||||
"maximumError": "12kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
|
||||
14
client/package-lock.json
generated
14
client/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -5960,6 +5961,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ngx-extended-pdf-viewer": {
|
||||
"version": "25.6.4",
|
||||
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-25.6.4.tgz",
|
||||
"integrity": "sha512-eYIiWzatcupB7HKDtcOOZN7gcLFjqAkeIAlZOMIO6XyUJnTe+PUZLZGit/19mtO/8fAaH41lMyyh8MAcU8NAhA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=17.0.0 <22.0.0",
|
||||
"@angular/core": ">=17.0.0 <22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -1,59 +1,128 @@
|
||||
<main class="chat-shell py-4">
|
||||
<div class="container-lg">
|
||||
<section class="chat-page panel p-3 p-lg-4">
|
||||
<app-peer-video-modal
|
||||
[visible]="remoteVideoModalVisible()"
|
||||
[stream]="remoteVideoStream()"
|
||||
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
|
||||
(closeRequested)="closeRemoteVideoModal()"
|
||||
></app-peer-video-modal>
|
||||
<app-peer-call-modal
|
||||
[visible]="callModalVisible()"
|
||||
[peerName]="callModalPeer()?.displayName ?? 'Peer'"
|
||||
[callState]="callModalState()"
|
||||
[callMode]="callModalMode()"
|
||||
[statusText]="callModalStatusText()"
|
||||
[localStream]="localCallStream()"
|
||||
[remoteStream]="remoteCallVideoStream()"
|
||||
(acceptRequested)="callModalPeer() && acceptIncomingVoiceCall(callModalPeer()!.id)"
|
||||
(rejectRequested)="callModalPeer() && rejectIncomingVoiceCall(callModalPeer()!.id)"
|
||||
(hangupRequested)="callModalPeer() && endVoiceCall(callModalPeer()!.id)"
|
||||
></app-peer-call-modal>
|
||||
<audio #callAudioElement hidden autoplay playsinline></audio>
|
||||
|
||||
<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>
|
||||
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
||||
@if (currentUser(); as connectedUser) {
|
||||
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
|
||||
<div class="status-indicators mt-2">
|
||||
<div class="status-indicator">
|
||||
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
|
||||
<span>Signaling</span>
|
||||
</div>
|
||||
<button
|
||||
class="status-indicator status-indicator-action"
|
||||
type="button"
|
||||
[disabled]="!canReconnectWebRtc()"
|
||||
[attr.aria-label]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
||||
[title]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
||||
(click)="ensureConnection()"
|
||||
>
|
||||
<span class="status-led" [class.status-led-ok]="indicatorTone(webRtcState()) === 'ok'" [class.status-led-connecting]="indicatorTone(webRtcState()) === 'connecting'" [class.status-led-offline]="indicatorTone(webRtcState()) === 'offline'"></span>
|
||||
<span>WebRTC</span>
|
||||
@if (callChoicePeer(); as selectedCallPeer) {
|
||||
<div class="call-choice-backdrop" (click)="closeCallChoice()">
|
||||
<section class="call-choice-card panel p-4" (click)="$event.stopPropagation()">
|
||||
<p class="call-choice-eyebrow">Start a call</p>
|
||||
<h2 class="h5 mb-2">{{ selectedCallPeer.displayName }}</h2>
|
||||
<p class="small mb-3">Choose whether to place a full video call or audio only.</p>
|
||||
<div class="call-choice-actions">
|
||||
<button class="call-choice-button" type="button" (click)="startSelectedCall('video')">
|
||||
<span class="call-choice-icon">📹</span>
|
||||
<span>Video call</span>
|
||||
</button>
|
||||
<button class="call-choice-button" type="button" (click)="startSelectedCall('audio')">
|
||||
<span class="call-choice-icon">🎙️</span>
|
||||
<span>Audio only</span>
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<h1 class="h3 mb-1 mt-2">Not signed in</h1>
|
||||
<p class="small text-secondary mb-0">Return to the dashboard and sign in again.</p>
|
||||
}
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="closeCallChoice()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-layout">
|
||||
<aside class="peer-sidebar">
|
||||
|
||||
|
||||
<div class="peer-list">
|
||||
@if (session.peers().length === 0) {
|
||||
<div class="empty-chat empty-peers">
|
||||
No peers are currently connected.
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (conversationModalOpen()) {
|
||||
<div class="conversation-modal-backdrop" (click)="closeConversationModal()">
|
||||
<section class="conversation-modal panel p-3 p-lg-4" (click)="$event.stopPropagation()">
|
||||
<header class="conversation-modal-header">
|
||||
<div>
|
||||
<p class="conversation-modal-eyebrow mb-1">Fullscreen conversation</p>
|
||||
<h2 class="h5 mb-0">{{ peer()?.displayName ?? 'Conversation' }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="conversation-modal-close"
|
||||
type="button"
|
||||
(click)="closeConversationModal()"
|
||||
aria-label="Close fullscreen conversation"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div #fullscreenConversationContainer class="conversation conversation-modal-body">
|
||||
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="chat-header mb-4">
|
||||
@if (currentUser(); as connectedUser) {
|
||||
<div class="chat-header-main">
|
||||
<a class="back-link" routerLink="/" aria-label="Back to dashboard">←</a>
|
||||
<h1 class="chat-header-title mb-0">{{ connectedUser.displayName }}</h1>
|
||||
@if (displayedPeer(); as selectedPeer) {
|
||||
<div class="peer-dropdown" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
class="peer-dropdown-trigger peer-tile"
|
||||
type="button"
|
||||
[class.peer-tile-active]="true"
|
||||
[class.peer-tile-unread]="isPeerUnread(selectedPeer.id)"
|
||||
(click)="togglePeerDropdown()"
|
||||
[attr.aria-expanded]="peerDropdownOpen()"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Choose peer"
|
||||
>
|
||||
<span class="peer-tile-main text-start">
|
||||
<span class="peer-tile-row">
|
||||
<span class="peer-tile-title">
|
||||
<span class="fw-semibold">{{ selectedPeer.displayName }}</span>
|
||||
@if (isPeerTyping(selectedPeer.id)) {
|
||||
<span class="peer-typing-dots" aria-label="Typing">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="peer-tile-indicators">
|
||||
<span
|
||||
class="status-led peer-tile-status"
|
||||
[class.status-led-ok]="selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'"
|
||||
[class.status-led-offline]="selectedPeer.channelState !== 'open' && selectedPeer.connectionState !== 'connected'"
|
||||
[attr.aria-label]="
|
||||
selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'
|
||||
? 'Connected'
|
||||
: 'Disconnected'
|
||||
"
|
||||
></span>
|
||||
<span class="peer-dropdown-caret" [class.peer-dropdown-caret-open]="peerDropdownOpen()">▾</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (peerDropdownOpen()) {
|
||||
<div class="peer-dropdown-menu" role="listbox">
|
||||
@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
|
||||
class="peer-tile-main text-start"
|
||||
type="button"
|
||||
(click)="switchPeer(connectedPeer.id)"
|
||||
(click)="selectPeerFromDropdown(connectedPeer.id)"
|
||||
>
|
||||
<div class="peer-tile-row">
|
||||
<span class="peer-tile-title">
|
||||
@@ -90,125 +159,126 @@
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="chat-main">
|
||||
<div class="conversation">
|
||||
@if (conversation().length === 0) {
|
||||
<div class="empty-chat">
|
||||
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (entry of conversation(); track entry.id) {
|
||||
<article
|
||||
class="bubble"
|
||||
[class.bubble-incoming]="entry.direction === 'incoming'"
|
||||
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
||||
[class.bubble-system]="entry.direction === 'system'"
|
||||
>
|
||||
@if (entry.direction !== 'system') {
|
||||
<div class="bubble-actions">
|
||||
<div class="status-indicators">
|
||||
<div class="status-indicator">
|
||||
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
|
||||
<span>Signaling</span>
|
||||
</div>
|
||||
<button
|
||||
class="bubble-action"
|
||||
class="status-indicator status-indicator-action"
|
||||
type="button"
|
||||
(click)="toggleForwardMenu(entry, $event)"
|
||||
title="Forward message"
|
||||
aria-label="Forward message"
|
||||
[disabled]="!canReconnectWebRtc()"
|
||||
[attr.aria-label]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
||||
[title]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
||||
(click)="ensureConnection()"
|
||||
>
|
||||
⏩
|
||||
<span class="status-led" [class.status-led-ok]="indicatorTone(webRtcState()) === 'ok'" [class.status-led-connecting]="indicatorTone(webRtcState()) === 'connecting'" [class.status-led-offline]="indicatorTone(webRtcState()) === 'offline'"></span>
|
||||
<span>WebRTC</span>
|
||||
</button>
|
||||
<button
|
||||
class="bubble-action bubble-delete"
|
||||
class="status-indicator status-indicator-action"
|
||||
type="button"
|
||||
(click)="deleteMessage(entry)"
|
||||
title="Delete message"
|
||||
aria-label="Delete message"
|
||||
[disabled]="conversation().length === 0"
|
||||
aria-label="Open fullscreen conversation"
|
||||
title="Open fullscreen conversation"
|
||||
(click)="openConversationModal()"
|
||||
>
|
||||
×
|
||||
<span class="expand-action-icon" aria-hidden="true">⤢</span>
|
||||
</button>
|
||||
@if (isForwardMenuOpen(entry.id)) {
|
||||
<div class="bubble-forward-menu">
|
||||
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
|
||||
<option value="">Forward to…</option>
|
||||
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
|
||||
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="chat-header-main">
|
||||
<a class="back-link" routerLink="/" aria-label="Back to dashboard">←</a>
|
||||
<h1 class="chat-header-title mb-0">Not signed in</h1>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="bubble-meta">
|
||||
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
||||
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||
</div>
|
||||
|
||||
@switch (entry.kind) {
|
||||
@case ('text') {
|
||||
<p class="mb-0">{{ entry.text }}</p>
|
||||
}
|
||||
@case ('json') {
|
||||
<pre class="bubble-json mb-0">{{ entry.payload | json }}</pre>
|
||||
}
|
||||
@case ('file') {
|
||||
<div class="d-grid gap-3">
|
||||
@if (isImageEntry(entry)) {
|
||||
<img
|
||||
class="bubble-image"
|
||||
[src]="entry.downloadUrl"
|
||||
[alt]="entry.fileName || 'Shared image'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (isVideoEntry(entry)) {
|
||||
<video
|
||||
class="bubble-video"
|
||||
[src]="entry.downloadUrl"
|
||||
controls
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
></video>
|
||||
}
|
||||
|
||||
@if (isIncomingJsonFileEntry(entry)) {
|
||||
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
||||
}
|
||||
|
||||
<div>
|
||||
<div class="fw-semibold">{{ entry.fileName }}</div>
|
||||
@if (entry.fileSize) {
|
||||
<div class="small text-secondary-emphasis">{{ entry.fileSize | number }} bytes</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (entry.downloadUrl) {
|
||||
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<p class="mb-0">{{ entry.text }}</p>
|
||||
}
|
||||
}
|
||||
</article>
|
||||
}
|
||||
<div class="chat-layout">
|
||||
<div class="chat-main" (click)="closePeerDropdown()">
|
||||
<div #conversationContainer class="conversation">
|
||||
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
@if (peer(); as selectedPeer) {
|
||||
<textarea
|
||||
#composerTextarea
|
||||
class="form-control composer-textarea"
|
||||
rows="2"
|
||||
[(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">
|
||||
<div class="composer-actions">
|
||||
@if (peer(); as selectedPeer) {
|
||||
<button
|
||||
class="composer-camera"
|
||||
class="composer-call"
|
||||
type="button"
|
||||
[disabled]="selectedPeer.channelState !== 'open' && !isStreamingCameraToSelectedPeer()"
|
||||
(click)="toggleCameraStream(selectedPeer.id)"
|
||||
[title]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
||||
[attr.aria-label]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
||||
[disabled]="!canStartSelectedVoiceCall()"
|
||||
(click)="openCallChoice(selectedPeer.id)"
|
||||
title="Start call"
|
||||
aria-label="Start call"
|
||||
>
|
||||
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
|
||||
📞
|
||||
</button>
|
||||
|
||||
@if (canEndSelectedVoiceCall()) {
|
||||
<button
|
||||
class="composer-hangup"
|
||||
type="button"
|
||||
(click)="endVoiceCall(selectedPeer.id)"
|
||||
title="End call"
|
||||
aria-label="End call"
|
||||
>
|
||||
🛑
|
||||
</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
|
||||
@@ -228,24 +298,8 @@
|
||||
>
|
||||
+
|
||||
</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
|
||||
class="composer-image-generate"
|
||||
type="button"
|
||||
@@ -296,9 +350,166 @@
|
||||
✅
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (lastIncomingReceiveMetric(); as receiveMetric) {
|
||||
<div class="composer-receive-speed" title="Receive speed of the last completed incoming WebRTC message">
|
||||
<span class="composer-receive-speed-label">Rx</span>
|
||||
<span class="composer-receive-speed-value">{{ receiveMetric.mbps | number: '1.2-2' }} Mbit/s</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ng-template #conversationBubbles>
|
||||
@if (conversation().length === 0) {
|
||||
<div class="empty-chat">
|
||||
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (entry of conversation(); track entry.id) {
|
||||
<article
|
||||
class="bubble"
|
||||
[class.bubble-incoming]="entry.direction === 'incoming'"
|
||||
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
||||
[class.bubble-system]="entry.direction === 'system'"
|
||||
[class.bubble-emoji-only]="isEmojiOnlyEntry(entry)"
|
||||
>
|
||||
@if (entry.direction !== 'system' && !isEmojiOnlyEntry(entry)) {
|
||||
<div class="bubble-actions">
|
||||
@if (isGeneratedImageEntry(entry)) {
|
||||
<button
|
||||
class="bubble-action"
|
||||
type="button"
|
||||
(click)="sendGeneratedImage(entry)"
|
||||
title="Send image to peer"
|
||||
aria-label="Send image to peer"
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
class="bubble-action"
|
||||
type="button"
|
||||
(click)="toggleForwardMenu(entry, $event)"
|
||||
title="Forward message"
|
||||
aria-label="Forward message"
|
||||
>
|
||||
⏩
|
||||
</button>
|
||||
<button
|
||||
class="bubble-action bubble-delete"
|
||||
type="button"
|
||||
(click)="deleteMessage(entry)"
|
||||
title="Delete message"
|
||||
aria-label="Delete message"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@if (isForwardMenuOpen(entry.id)) {
|
||||
<div class="bubble-forward-menu">
|
||||
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
|
||||
<option value="">Forward to…</option>
|
||||
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
|
||||
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!isEmojiOnlyEntry(entry)) {
|
||||
<div class="bubble-meta">
|
||||
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
||||
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||
</div>
|
||||
}
|
||||
|
||||
@switch (entry.kind) {
|
||||
@case ('text') {
|
||||
<p class="mb-0" [class.emoji-only-text]="isEmojiOnlyEntry(entry)">{{ entry.text }}</p>
|
||||
}
|
||||
@case ('json') {
|
||||
<pre class="bubble-json mb-0">{{ entry.payload | json }}</pre>
|
||||
}
|
||||
@case ('file') {
|
||||
<div class="d-grid gap-3">
|
||||
@if (isImageEntry(entry)) {
|
||||
<img
|
||||
class="bubble-image"
|
||||
[src]="entry.downloadUrl"
|
||||
[alt]="entry.fileName || 'Shared image'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (isVideoEntry(entry)) {
|
||||
<video
|
||||
class="bubble-video"
|
||||
[src]="entry.downloadUrl"
|
||||
controls
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
></video>
|
||||
}
|
||||
|
||||
@if (isIncomingJsonFileEntry(entry)) {
|
||||
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
||||
}
|
||||
|
||||
<div>
|
||||
<div class="fw-semibold">{{ entry.fileName }}</div>
|
||||
@if (entry.fileSize) {
|
||||
<div class="small text-secondary-emphasis">{{ entry.fileSize | number }} bytes</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (entry.downloadUrl) {
|
||||
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
||||
}
|
||||
|
||||
@if (hasDocumentPreviewImage(entry)) {
|
||||
<div class="bubble-preview">
|
||||
<div class="bubble-preview-label">Preview</div>
|
||||
<img
|
||||
class="bubble-preview-image"
|
||||
[src]="documentPreviewImageUrl(entry)"
|
||||
[alt]="entry.fileName || 'Document preview'"
|
||||
/>
|
||||
</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 {
|
||||
@if (entry.showSpinner) {
|
||||
<div class="bubble-system-status">
|
||||
<span class="bubble-spinner" aria-hidden="true"></span>
|
||||
<p class="mb-0">{{ entry.text }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="mb-0">{{ entry.text }}</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -16,15 +16,171 @@
|
||||
box-shadow: 0 20px 60px var(--shadow-color);
|
||||
}
|
||||
|
||||
.chat-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(95vw, 95%);
|
||||
height: min(calc(100dvh - 2rem), 1024px);
|
||||
max-height: 1024px;
|
||||
margin-inline: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chat-header-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.chat-header-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2vw, 1.75rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.call-choice-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1240;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(3, 8, 14, 0.46);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.call-choice-card {
|
||||
width: min(100%, 25rem);
|
||||
}
|
||||
|
||||
.conversation-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1230;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0.75rem;
|
||||
background: rgba(3, 8, 14, 0.56);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.conversation-modal {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(100%, 96rem);
|
||||
height: min(100dvh - 1.5rem, 100%);
|
||||
max-height: 100dvh;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.conversation-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.conversation-modal-eyebrow {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--page-text-soft);
|
||||
}
|
||||
|
||||
.conversation-modal-close {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: var(--page-text);
|
||||
background: var(--badge-background);
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.conversation-modal-body {
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.call-choice-eyebrow {
|
||||
margin-bottom: 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--page-text-soft);
|
||||
}
|
||||
|
||||
.call-choice-actions {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.call-choice-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
width: 100%;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
color: var(--page-text);
|
||||
background: var(--surface-background);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.call-choice-button:hover,
|
||||
.call-choice-button:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
||||
background: var(--surface-hover-background);
|
||||
}
|
||||
|
||||
.call-choice-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--badge-background);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@@ -56,6 +212,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.expand-action-icon {
|
||||
font-size: 1.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-led {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
@@ -77,32 +238,36 @@
|
||||
|
||||
.chat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(15rem, 19rem) minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.peer-sidebar {
|
||||
padding: 1rem;
|
||||
border-radius: 1.3rem;
|
||||
border: 1px solid var(--surface-border-soft);
|
||||
background: var(--panel-soft-background);
|
||||
.peer-dropdown {
|
||||
position: relative;
|
||||
min-width: min(18rem, 42vw);
|
||||
}
|
||||
|
||||
.peer-count {
|
||||
display: inline-flex;
|
||||
min-width: 2rem;
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--badge-background);
|
||||
.peer-dropdown-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.peer-list {
|
||||
.peer-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.65rem);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
max-height: calc(100dvh - 17rem);
|
||||
max-height: calc(3 * 4.55rem + 1.5rem);
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-background);
|
||||
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.peer-tile {
|
||||
@@ -116,10 +281,12 @@
|
||||
border-radius: 1rem;
|
||||
color: inherit;
|
||||
background: var(--surface-background);
|
||||
font-size: 1.05em;
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.peer-tile-main {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -127,14 +294,31 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.peer-tile-indicators {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.peer-dropdown-caret {
|
||||
font-size: 4.02rem;
|
||||
line-height: 1;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.peer-dropdown-caret-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.peer-tile-delete {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
width: 1.54rem;
|
||||
height: 1.54rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -151,17 +335,22 @@
|
||||
background: var(--surface-hover-background);
|
||||
}
|
||||
|
||||
.peer-tile-unread {
|
||||
border-color: #c62828;
|
||||
box-shadow: inset 0 0 0 2px #c62828;
|
||||
}
|
||||
|
||||
.peer-tile-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
gap: 0.53rem;
|
||||
}
|
||||
|
||||
.peer-tile-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
gap: 0.32rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -173,8 +362,8 @@
|
||||
}
|
||||
|
||||
.peer-typing-dots span {
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
width: 0.27rem;
|
||||
height: 0.27rem;
|
||||
border-radius: 999px;
|
||||
background: var(--page-text);
|
||||
opacity: 0.28;
|
||||
@@ -194,15 +383,17 @@
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.conversation {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
align-content: start;
|
||||
min-height: 24rem;
|
||||
max-height: calc(100dvh - 20rem);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
@@ -281,6 +472,14 @@
|
||||
background: var(--badge-background);
|
||||
}
|
||||
|
||||
.bubble-emoji-only {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.bubble-meta {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
@@ -289,32 +488,77 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.bubble-author {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bubble-time {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.emoji-only-text {
|
||||
font-size: clamp(2.1rem, 5vw, 3.4rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 0.9rem;
|
||||
align-items: end;
|
||||
gap: 0.85rem;
|
||||
flex: 0 0 auto;
|
||||
padding-top: 1rem;
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--surface-border-soft);
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
.composer-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.composer-send {
|
||||
display: grid;
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.composer-receive-speed {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
color: var(--page-text-soft);
|
||||
}
|
||||
|
||||
.composer-receive-speed-label {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.composer-receive-speed-value {
|
||||
font-size: 0.92rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.composer-emoji-picker-shell {
|
||||
@@ -326,6 +570,10 @@
|
||||
}
|
||||
|
||||
.composer-camera,
|
||||
.composer-call,
|
||||
.composer-dictation,
|
||||
.composer-hangup,
|
||||
.composer-voice,
|
||||
.composer-image-generate,
|
||||
.composer-emoji-trigger,
|
||||
.composer-plus,
|
||||
@@ -349,24 +597,54 @@
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
|
||||
.composer-camera {
|
||||
color: var(--page-text);
|
||||
background: var(--badge-background);
|
||||
.composer-textarea {
|
||||
min-height: calc(2 * 1.5rem + 1.25rem);
|
||||
max-height: calc(6 * 1.5rem + 1.25rem);
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.composer-image-generate {
|
||||
.composer-call {
|
||||
color: var(--page-text);
|
||||
background: linear-gradient(135deg, #ffe6b0, #ffc8a8);
|
||||
background: linear-gradient(135deg, #bfe9ff, #96c3ff);
|
||||
}
|
||||
|
||||
.composer-emoji-trigger {
|
||||
.composer-camera,
|
||||
.composer-emoji-trigger,
|
||||
.composer-plus {
|
||||
color: var(--page-text);
|
||||
background: var(--badge-background);
|
||||
}
|
||||
|
||||
.composer-plus {
|
||||
font-size: 1.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.composer-dictation {
|
||||
color: var(--page-text);
|
||||
background: var(--badge-background);
|
||||
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 {
|
||||
color: var(--page-text);
|
||||
background: linear-gradient(135deg, #ffe6b0, #ffc8a8);
|
||||
}
|
||||
|
||||
.send-emoji {
|
||||
@@ -408,26 +686,57 @@
|
||||
background: var(--surface-hover-background);
|
||||
}
|
||||
|
||||
.bubble-image {
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 1rem;
|
||||
display: block;
|
||||
.bubble-author,
|
||||
.bubble-download,
|
||||
.voice-bubble-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bubble-preview {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.bubble-preview-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.bubble-preview-image {
|
||||
display: block;
|
||||
width: min(240px, 100%);
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bubble-image,
|
||||
.bubble-video {
|
||||
width: 200px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
border-radius: 1rem;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.bubble-download {
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
.bubble-video {
|
||||
background: #000;
|
||||
}
|
||||
.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 {
|
||||
@@ -467,16 +776,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bubble-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.chat-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.peer-list {
|
||||
max-height: 16rem;
|
||||
.peer-dropdown {
|
||||
min-width: min(100%, 18rem);
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.conversation-modal-backdrop {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.conversation-modal {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 88%;
|
||||
}
|
||||
|
||||
.composer-toolbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,221 @@
|
||||
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 { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { PeerVideoModalComponent } from './peer-video-modal.component';
|
||||
import { PeerCallModalComponent } from './peer-call-modal.component';
|
||||
import { ChatSessionService } from './chat-session.service';
|
||||
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
||||
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
|
||||
import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-page',
|
||||
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
JsonFileViewerComponent,
|
||||
PeerCallModalComponent,
|
||||
],
|
||||
templateUrl: './chat-page.component.html',
|
||||
styleUrl: './chat-page.component.scss',
|
||||
})
|
||||
export class ChatPageComponent {
|
||||
export class ChatPageComponent implements OnDestroy {
|
||||
private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
: null;
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
||||
initialValue: this.route.snapshot.paramMap,
|
||||
});
|
||||
private composerSelectionStart = 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;
|
||||
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
|
||||
@ViewChild('callAudioElement')
|
||||
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
||||
this.callAudioElement = value;
|
||||
this.syncCallAudioSource();
|
||||
}
|
||||
private callAudioElement?: ElementRef<HTMLAudioElement>;
|
||||
@ViewChild('conversationContainer')
|
||||
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
||||
this.conversationContainer = value;
|
||||
}
|
||||
private conversationContainer?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('fullscreenConversationContainer')
|
||||
set fullscreenConversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
||||
this.fullscreenConversationContainer = value;
|
||||
}
|
||||
private fullscreenConversationContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
messageText = '';
|
||||
readonly forwardingEntryId = signal<string | null>(null);
|
||||
readonly callChoicePeerId = signal<string | null>(null);
|
||||
readonly conversationModalOpen = signal(false);
|
||||
readonly peerDropdownOpen = signal(false);
|
||||
readonly emojiPickerOpen = signal(false);
|
||||
readonly isRecordingVoice = signal(false);
|
||||
readonly isDictating = signal(false);
|
||||
readonly isTranscribingDictation = signal(false);
|
||||
readonly emojiOptions = [
|
||||
'😀', '😁', '😂', '🤣', '😊',
|
||||
'😉', '😍', '😘', '😎', '🤔',
|
||||
'😅', '😭', '😡', '😴', '🙃',
|
||||
'👍', '👎', '👏', '🙏', '🤝',
|
||||
'🎉', '🔥', '❤️', '💡', '✅',
|
||||
'🚀', '👀', '📹', '📎', '💬',
|
||||
'🌍', '⚡', '⭐', '🎵', '📷',
|
||||
'🗑️', '⏩', '🛑', '🙌', '👌',
|
||||
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
|
||||
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
|
||||
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
|
||||
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🫠', '🤑', '😲',
|
||||
'☹️', '🙁', '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧',
|
||||
'😨', '😩', '🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪',
|
||||
'😵', '🥴', '😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮',
|
||||
'🤧', '😇', '🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐',
|
||||
'🤓', '😈', '👿', '👹', '👺', '💀', '☠️', '👻', '👽', '🤖',
|
||||
'💩', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾',
|
||||
'🙈', '🙉', '🙊', '💋', '💌', '💘', '💝', '💖', '💗', '💓',
|
||||
'💞', '💕', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||
'🤎', '💔', '❤️🔥', '❤️🩹', '❣️', '💯', '💢', '💥', '💫', '💦',
|
||||
'💨', '🕳️', '💬', '🗨️', '🗯️', '💭', '💤', '👋', '🤚', '🖐️',
|
||||
'✋', '🖖', '🫱', '🫲', '🫳', '🫴', '👌', '🤌', '🤏', '✌️',
|
||||
'🤞', '🫰', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👇', '☝️',
|
||||
'👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '🫶', '👐',
|
||||
'🤲', '🙏', '✍️', '💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶',
|
||||
'👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️',
|
||||
'👅', '👄', '🫦', '🌍', '🌎', '🌏', '🌕', '⭐', '🌟', '✨',
|
||||
'⚡', '🔥', '💧', '🌈', '☀️', '🌤️', '⛅', '🌧️', '⛈️', '🌩️',
|
||||
'❄️', '☃️', '☔', '🍎', '🍊', '🍋', '🍉', '🍇', '🍓', '🍒',
|
||||
'🍑', '🍍', '🥥', '🥑', '🍔', '🍕', '🌮', '🍣', '🍪', '🎂',
|
||||
'☕', '🍵', '🍹', '🎉', '🎈', '🎁', '🏆', '🚀', '📷', '🎵',
|
||||
];
|
||||
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
||||
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
||||
readonly displayedPeer = computed(() => this.peer() ?? this.session.peers()[0] ?? null);
|
||||
readonly currentUser = computed(() => this.session.currentUser());
|
||||
readonly callModalPeerId = computed(() =>
|
||||
this.session.activeVoiceCallPeerId()
|
||||
?? this.session.incomingVoiceCallPeerId()
|
||||
?? this.session.outgoingVoiceCallPeerId()
|
||||
?? null,
|
||||
);
|
||||
readonly callModalPeer = computed(() => {
|
||||
const peerId = this.callModalPeerId();
|
||||
|
||||
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
||||
});
|
||||
readonly callChoicePeer = computed(() => {
|
||||
const peerId = this.callChoicePeerId();
|
||||
|
||||
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
||||
});
|
||||
readonly conversation = computed(() =>
|
||||
this.session
|
||||
.messages()
|
||||
.filter((entry) => entry.peerId === this.peerId()),
|
||||
);
|
||||
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
|
||||
readonly remoteVideoModalVisible = computed(
|
||||
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
|
||||
readonly lastIncomingReceiveMetric = computed(() => {
|
||||
const metric = this.session.lastIncomingReceiveMetric();
|
||||
|
||||
return metric?.peerId === this.peerId() ? metric : null;
|
||||
});
|
||||
readonly remoteCallAudioStream = computed(() =>
|
||||
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
|
||||
);
|
||||
readonly callModalMode = computed<CallMode>(() => this.session.callModeForPeer(this.callModalPeerId() ?? '') ?? 'video');
|
||||
readonly localCallStream = computed(() => this.session.localCallStreamForPeer(this.callModalPeerId() ?? ''));
|
||||
readonly remoteCallVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.callModalPeerId() ?? ''));
|
||||
readonly callModalVisible = computed(() => !!this.callModalPeer());
|
||||
readonly callModalState = computed<'incoming' | 'outgoing' | 'active'>(() => {
|
||||
const peerId = this.callModalPeerId();
|
||||
|
||||
if (!peerId) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if (this.session.incomingVoiceCallPeerId() === peerId) {
|
||||
return 'incoming';
|
||||
}
|
||||
|
||||
if (this.session.outgoingVoiceCallPeerId() === peerId) {
|
||||
return 'outgoing';
|
||||
}
|
||||
|
||||
return 'active';
|
||||
});
|
||||
readonly callModalStatusText = computed(() => {
|
||||
const peer = this.callModalPeer();
|
||||
|
||||
if (!peer) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (this.callModalState()) {
|
||||
case 'incoming':
|
||||
return `${peer.displayName} is calling you${this.callModalMode() === 'audio' ? ' with audio only.' : '.'}`;
|
||||
case 'outgoing':
|
||||
return this.callModalMode() === 'audio'
|
||||
? 'Calling… your microphone is ready.'
|
||||
: 'Calling… your camera and microphone are ready.';
|
||||
default:
|
||||
return this.callModalMode() === 'audio'
|
||||
? 'Connected with live audio.'
|
||||
: 'Connected with live video and audio.';
|
||||
}
|
||||
});
|
||||
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>(() => {
|
||||
const selectedPeer = this.peer();
|
||||
|
||||
@@ -82,6 +249,43 @@ export class ChatPageComponent {
|
||||
this.session.selectPeer(peerId);
|
||||
void this.session.connectToPeer(peerId);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
this.remoteCallAudioStream();
|
||||
this.syncCallAudioSource();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const peerId = this.peerId();
|
||||
const entries = this.conversation();
|
||||
const snapshot = {
|
||||
peerId,
|
||||
length: entries.length,
|
||||
lastEntryId: entries.at(-1)?.id ?? null,
|
||||
};
|
||||
const previousSnapshot = this.lastConversationSnapshot;
|
||||
|
||||
this.lastConversationSnapshot = snapshot;
|
||||
|
||||
if (!peerId || !previousSnapshot || previousSnapshot.peerId !== peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasNewTailEntry = snapshot.length > previousSnapshot.length
|
||||
|| (snapshot.length > 0 && snapshot.lastEntryId !== previousSnapshot.lastEntryId);
|
||||
|
||||
if (!hasNewTailEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollConversationToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
void this.stopDictation(true);
|
||||
this.stopVoiceRecording(true);
|
||||
this.detachCallAudioSource();
|
||||
}
|
||||
|
||||
async ensureConnection(): Promise<void> {
|
||||
@@ -92,7 +296,7 @@ export class ChatPageComponent {
|
||||
}
|
||||
|
||||
this.session.selectPeer(peerId);
|
||||
await this.session.connectToPeer(peerId);
|
||||
await this.session.reconnectToPeer(peerId);
|
||||
}
|
||||
|
||||
async sendMessage(): Promise<void> {
|
||||
@@ -102,6 +306,7 @@ export class ChatPageComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stopDictation(false);
|
||||
await this.session.sendText(peerId, this.messageText);
|
||||
this.messageText = '';
|
||||
this.emojiPickerOpen.set(false);
|
||||
@@ -116,7 +321,18 @@ export class ChatPageComponent {
|
||||
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 {
|
||||
@@ -169,6 +385,29 @@ export class ChatPageComponent {
|
||||
});
|
||||
}
|
||||
|
||||
openCallChoice(peerId: string): void {
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.callChoicePeerId.set(peerId);
|
||||
}
|
||||
|
||||
closeCallChoice(): void {
|
||||
this.callChoicePeerId.set(null);
|
||||
}
|
||||
|
||||
async startSelectedCall(mode: CallMode): Promise<void> {
|
||||
const peerId = this.callChoicePeerId() ?? this.peerId();
|
||||
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.callChoicePeerId.set(null);
|
||||
await this.session.startVoiceCall(peerId, mode);
|
||||
}
|
||||
|
||||
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
|
||||
const file = input.files?.item(0);
|
||||
|
||||
@@ -180,10 +419,177 @@ export class ChatPageComponent {
|
||||
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> {
|
||||
await this.session.deleteMessage(entry);
|
||||
}
|
||||
|
||||
isEmojiOnlyEntry(entry: ChatEntry): boolean {
|
||||
if (entry.kind !== 'text' || entry.direction === 'system' || !entry.text?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entry.text
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.every((token) => this.isEmojiToken(token));
|
||||
}
|
||||
|
||||
async deleteConversation(peerId: string, event?: Event): Promise<void> {
|
||||
event?.stopPropagation();
|
||||
await this.session.deleteConversation(peerId);
|
||||
@@ -212,6 +618,33 @@ export class ChatPageComponent {
|
||||
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
|
||||
}
|
||||
|
||||
togglePeerDropdown(): void {
|
||||
if (this.session.peers().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.peerDropdownOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
closePeerDropdown(): void {
|
||||
this.peerDropdownOpen.set(false);
|
||||
}
|
||||
|
||||
openConversationModal(): void {
|
||||
this.closePeerDropdown();
|
||||
this.conversationModalOpen.set(true);
|
||||
this.scrollConversationToBottom();
|
||||
}
|
||||
|
||||
closeConversationModal(): void {
|
||||
this.conversationModalOpen.set(false);
|
||||
}
|
||||
|
||||
async selectPeerFromDropdown(peerId: string): Promise<void> {
|
||||
this.closePeerDropdown();
|
||||
await this.switchPeer(peerId);
|
||||
}
|
||||
|
||||
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
|
||||
if (!targetPeerId) {
|
||||
return;
|
||||
@@ -222,19 +655,44 @@ export class ChatPageComponent {
|
||||
this.forwardingEntryId.set(null);
|
||||
}
|
||||
|
||||
async toggleCameraStream(peerId: string): Promise<void> {
|
||||
if (this.session.isStreamingCameraToPeer(peerId)) {
|
||||
await this.session.stopCameraStream(peerId);
|
||||
async sendGeneratedImage(entry: ChatEntry): Promise<void> {
|
||||
const peerId = this.peerId();
|
||||
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.session.startCameraStream(peerId);
|
||||
await this.session.sendGeneratedImageToPeer(entry, peerId);
|
||||
}
|
||||
|
||||
async endVoiceCall(peerId: string): Promise<void> {
|
||||
await this.session.endVoiceCall(peerId);
|
||||
}
|
||||
|
||||
async acceptIncomingVoiceCall(peerId: string): Promise<void> {
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.session.acceptVoiceCall(peerId);
|
||||
}
|
||||
|
||||
rejectIncomingVoiceCall(peerId: string): void {
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.session.rejectVoiceCall(peerId);
|
||||
}
|
||||
|
||||
isImageEntry(entry: ChatEntry): boolean {
|
||||
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
||||
}
|
||||
|
||||
isGeneratedImageEntry(entry: ChatEntry): boolean {
|
||||
return this.isImageEntry(entry) && entry.generatedByAi === true;
|
||||
}
|
||||
|
||||
isVideoEntry(entry: ChatEntry): boolean {
|
||||
if (entry.kind !== 'file' || !entry.downloadUrl) {
|
||||
return false;
|
||||
@@ -257,10 +715,30 @@ export class ChatPageComponent {
|
||||
);
|
||||
}
|
||||
|
||||
hasDocumentPreviewImage(entry: ChatEntry): boolean {
|
||||
return (
|
||||
entry.kind === 'file' &&
|
||||
!!entry.previewDownloadUrl &&
|
||||
(entry.previewMimeType?.startsWith('image/') ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
documentPreviewImageUrl(entry: ChatEntry): string | null {
|
||||
if (!this.hasDocumentPreviewImage(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.previewDownloadUrl ?? null;
|
||||
}
|
||||
|
||||
isPeerTyping(peerId: string): boolean {
|
||||
return this.session.typingPeerIds().includes(peerId);
|
||||
}
|
||||
|
||||
isPeerUnread(peerId: string): boolean {
|
||||
return this.session.unreadPeerIds().includes(peerId);
|
||||
}
|
||||
|
||||
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
|
||||
if (state === 'connected') {
|
||||
return 'ok';
|
||||
@@ -274,23 +752,7 @@ export class ChatPageComponent {
|
||||
}
|
||||
|
||||
canReconnectWebRtc(): boolean {
|
||||
return this.indicatorTone(this.webRtcState()) === 'offline';
|
||||
}
|
||||
|
||||
isStreamingCameraToSelectedPeer(): boolean {
|
||||
const peerId = this.peerId();
|
||||
|
||||
return !!peerId && this.session.isStreamingCameraToPeer(peerId);
|
||||
}
|
||||
|
||||
closeRemoteVideoModal(): void {
|
||||
const peerId = this.peerId();
|
||||
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.session.dismissRemoteVideoModal(peerId);
|
||||
return !!this.peerId() && this.indicatorTone(this.webRtcState()) !== 'ok';
|
||||
}
|
||||
|
||||
async switchPeer(peerId: string): Promise<void> {
|
||||
@@ -298,9 +760,221 @@ export class ChatPageComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stopDictation(true);
|
||||
this.stopVoiceRecording(true);
|
||||
this.forwardingEntryId.set(null);
|
||||
this.callChoicePeerId.set(null);
|
||||
this.conversationModalOpen.set(false);
|
||||
this.peerDropdownOpen.set(false);
|
||||
this.emojiPickerOpen.set(false);
|
||||
this.session.selectPeer(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;
|
||||
}
|
||||
|
||||
private scrollConversationToBottom(): void {
|
||||
queueMicrotask(() => {
|
||||
requestAnimationFrame(() => {
|
||||
for (const container of [
|
||||
this.conversationContainer?.nativeElement,
|
||||
this.fullscreenConversationContainer?.nativeElement,
|
||||
]) {
|
||||
if (!container) {
|
||||
continue;
|
||||
}
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private isEmojiToken(token: string): boolean {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const graphemes = this.graphemeSegmenter
|
||||
? Array.from(this.graphemeSegmenter.segment(token), ({ segment }) => segment)
|
||||
: [token];
|
||||
|
||||
return graphemes.every((grapheme) =>
|
||||
/[\p{Emoji}\p{Extended_Pictographic}\u20E3]/u.test(grapheme)
|
||||
&& /^[\p{Emoji}\p{Emoji_Component}\p{Extended_Pictographic}\u200D\uFE0F\u20E3]+$/u.test(grapheme),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,8 @@
|
||||
border-radius: 2rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
.panel,
|
||||
.session-card {
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -77,15 +78,12 @@
|
||||
|
||||
.theme-toggle:hover,
|
||||
.theme-toggle:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border));
|
||||
background: var(--surface-hover-background);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.session-card {
|
||||
min-width: min(100%, 18rem);
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
.session-card { min-width: min(100%, 18rem); }
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
@@ -94,15 +92,19 @@
|
||||
background: var(--badge-background);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
.btn-accent,
|
||||
.btn-accent:hover,
|
||||
.btn-accent:focus-visible {
|
||||
color: #06111d;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background: var(--accent-gradient);
|
||||
}
|
||||
|
||||
.btn-accent:hover,
|
||||
.btn-accent:focus-visible {
|
||||
color: #06111d;
|
||||
background: var(--accent-gradient-hover);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
|
||||
.json-viewer-shell {
|
||||
width: min(95%, 480px);
|
||||
max-width: min(95%, 480px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
|
||||
@@ -94,17 +94,23 @@ export interface ChatEntry {
|
||||
id: string;
|
||||
peerId: string;
|
||||
direction: 'incoming' | 'outgoing' | 'system';
|
||||
kind: 'text' | 'json' | 'file' | 'system';
|
||||
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
||||
createdAt: number;
|
||||
authorLabel: string;
|
||||
generatedByAi?: boolean;
|
||||
showSpinner?: boolean;
|
||||
text?: string;
|
||||
payload?: unknown;
|
||||
fileName?: string;
|
||||
fileSize?: number;
|
||||
fileMimeType?: string;
|
||||
downloadUrl?: string;
|
||||
previewMimeType?: string;
|
||||
previewDownloadUrl?: string;
|
||||
}
|
||||
|
||||
export type CallMode = 'audio' | 'video';
|
||||
|
||||
export type SignalPayload =
|
||||
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
||||
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
||||
@@ -129,6 +135,16 @@ export type ServerEvent =
|
||||
peerId: string;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'speech-transcribed';
|
||||
requestId: string;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'speech-transcription-error';
|
||||
requestId: string;
|
||||
message: string;
|
||||
}
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
@@ -155,6 +171,7 @@ export type DataEnvelope =
|
||||
name: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
attachmentKind?: 'file' | 'voice';
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
sentAt: number;
|
||||
@@ -168,6 +185,13 @@ export type DataEnvelope =
|
||||
active: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'camera-state';
|
||||
active: boolean;
|
||||
type: 'voice-call-offer';
|
||||
mode: CallMode;
|
||||
}
|
||||
| {
|
||||
type: 'voice-call-response';
|
||||
accepted: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'voice-call-ended';
|
||||
};
|
||||
|
||||
155
client/src/app/peer-call-modal.component.scss
Normal file
155
client/src/app/peer-call-modal.component.scss
Normal file
@@ -0,0 +1,155 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.call-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1250;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(78, 114, 255, 0.18), transparent 34%),
|
||||
rgba(3, 8, 14, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.call-modal-card {
|
||||
width: min(100%, 72rem);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 1.75rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 16, 28, 0.98), rgba(4, 8, 16, 0.96));
|
||||
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.48);
|
||||
}
|
||||
|
||||
.call-modal-header,
|
||||
.call-modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem 1.25rem 0;
|
||||
}
|
||||
|
||||
.call-modal-footer {
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
}
|
||||
|
||||
.call-modal-eyebrow {
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
.call-modal-close {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.call-modal-stage {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.call-video-panel {
|
||||
position: relative;
|
||||
min-height: min(72vh, 42rem);
|
||||
overflow: hidden;
|
||||
border-radius: 1.35rem;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03));
|
||||
}
|
||||
|
||||
.call-video-panel-local {
|
||||
position: absolute;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
width: min(22vw, 12rem);
|
||||
min-height: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(10px);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.call-video-label {
|
||||
position: absolute;
|
||||
top: 0.85rem;
|
||||
left: 0.85rem;
|
||||
z-index: 1;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
background: rgba(0, 0, 0, 0.34);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.call-video-player,
|
||||
.call-video-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(140, 191, 255, 0.18), transparent 36%),
|
||||
#03070f;
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.call-video-player {
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.call-video-player-local {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.call-video-placeholder {
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.call-video-placeholder-local {
|
||||
min-height: 8rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.call-modal-backdrop {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.call-modal-card {
|
||||
border-radius: 1.4rem;
|
||||
}
|
||||
|
||||
.call-video-panel {
|
||||
min-height: 18rem;
|
||||
}
|
||||
|
||||
.call-video-panel-local {
|
||||
right: 0.75rem;
|
||||
bottom: 0.75rem;
|
||||
width: min(38vw, 8.5rem);
|
||||
}
|
||||
|
||||
.call-modal-header,
|
||||
.call-modal-footer {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
168
client/src/app/peer-call-modal.component.ts
Normal file
168
client/src/app/peer-call-modal.component.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import type { CallMode } from './models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-peer-call-modal',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
@if (visible) {
|
||||
<div class="call-modal-backdrop">
|
||||
<section class="call-modal-card" (click)="$event.stopPropagation()">
|
||||
<header class="call-modal-header">
|
||||
<div>
|
||||
<p class="call-modal-eyebrow">Private {{ callMode === 'audio' ? 'audio' : 'video' }} call</p>
|
||||
<h2 class="h4 mb-1">{{ peerName }}</h2>
|
||||
<p class="small mb-0">{{ statusText }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="call-modal-close"
|
||||
type="button"
|
||||
(click)="requestDismiss()"
|
||||
[attr.aria-label]="callState === 'incoming' ? 'Decline call' : 'End call'"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="call-modal-stage">
|
||||
<section class="call-video-panel call-video-panel-remote">
|
||||
<div class="call-video-label">{{ callMode === 'audio' ? 'Peer audio' : 'Peer' }}</div>
|
||||
@if (callMode === 'video' && remoteStream) {
|
||||
<video #remoteVideoElement class="call-video-player" autoplay playsinline></video>
|
||||
} @else {
|
||||
<div class="call-video-placeholder">
|
||||
{{
|
||||
callMode === 'audio'
|
||||
? 'Audio-only call in progress.'
|
||||
: callState === 'incoming'
|
||||
? 'Waiting for you to join.'
|
||||
: 'Waiting for peer video…'
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
|
||||
<section class="call-video-panel call-video-panel-local">
|
||||
<div class="call-video-label">You</div>
|
||||
@if (callMode === 'video' && localStream) {
|
||||
<video #localVideoElement class="call-video-player call-video-player-local" autoplay playsinline></video>
|
||||
} @else {
|
||||
<div class="call-video-placeholder call-video-placeholder-local">
|
||||
{{ callMode === 'audio' ? 'Audio only' : callState === 'incoming' ? 'Camera starts when you accept.' : 'Starting your camera…' }}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="call-modal-footer">
|
||||
@if (callState === 'incoming') {
|
||||
<button class="btn btn-success" type="button" (click)="acceptRequested.emit()">
|
||||
Accept
|
||||
</button>
|
||||
<button class="btn btn-outline-light" type="button" (click)="rejectRequested.emit()">
|
||||
Reject
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn btn-danger" type="button" (click)="hangupRequested.emit()">
|
||||
{{ callState === 'outgoing' ? 'Cancel call' : 'End call' }}
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styleUrl: './peer-call-modal.component.scss',
|
||||
})
|
||||
export class PeerCallModalComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
@Input() visible = false;
|
||||
@Input() peerName = 'Peer';
|
||||
@Input() callState: 'incoming' | 'outgoing' | 'active' = 'active';
|
||||
@Input() callMode: CallMode = 'video';
|
||||
@Input() statusText = '';
|
||||
@Input() localStream: MediaStream | null = null;
|
||||
@Input() remoteStream: MediaStream | null = null;
|
||||
@Output() readonly acceptRequested = new EventEmitter<void>();
|
||||
@Output() readonly rejectRequested = new EventEmitter<void>();
|
||||
@Output() readonly hangupRequested = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('localVideoElement')
|
||||
set localVideoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
|
||||
this.localVideoElement = value;
|
||||
this.syncVideoSources();
|
||||
}
|
||||
|
||||
@ViewChild('remoteVideoElement')
|
||||
set remoteVideoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
|
||||
this.remoteVideoElement = value;
|
||||
this.syncVideoSources();
|
||||
}
|
||||
|
||||
private localVideoElement?: ElementRef<HTMLVideoElement>;
|
||||
private remoteVideoElement?: ElementRef<HTMLVideoElement>;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.syncVideoSources();
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.syncVideoSources();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.detachVideo(this.localVideoElement?.nativeElement);
|
||||
this.detachVideo(this.remoteVideoElement?.nativeElement);
|
||||
}
|
||||
|
||||
requestDismiss(): void {
|
||||
if (this.callState === 'incoming') {
|
||||
this.rejectRequested.emit();
|
||||
return;
|
||||
}
|
||||
|
||||
this.hangupRequested.emit();
|
||||
}
|
||||
|
||||
private syncVideoSources(): void {
|
||||
this.syncVideo(this.localVideoElement?.nativeElement, this.visible ? this.localStream : null, true);
|
||||
this.syncVideo(this.remoteVideoElement?.nativeElement, this.visible ? this.remoteStream : null, true);
|
||||
}
|
||||
|
||||
private syncVideo(video: HTMLVideoElement | undefined, stream: MediaStream | null, muted: boolean): void {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.muted = muted;
|
||||
video.srcObject = stream;
|
||||
|
||||
if (stream) {
|
||||
void video.play().catch(() => {
|
||||
// Autoplay can be delayed until the next user gesture on some platforms.
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
video.pause();
|
||||
}
|
||||
|
||||
private detachVideo(video: HTMLVideoElement | undefined): void {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.pause();
|
||||
video.srcObject = null;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.video-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.5rem;
|
||||
background: rgba(3, 8, 14, 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.video-modal-card {
|
||||
width: min(100%, 56rem);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1.5rem;
|
||||
background: var(--panel-background);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.video-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.video-modal-close {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: var(--page-text);
|
||||
background: var(--badge-background);
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.video-modal-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.video-modal-player {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 1rem;
|
||||
background: #000;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { AfterViewInit, Component, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, ViewChild } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-peer-video-modal',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
@if (visible) {
|
||||
<div class="video-modal-backdrop" (click)="requestClose()">
|
||||
<section class="video-modal-card" (click)="$event.stopPropagation()">
|
||||
<div class="video-modal-header">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">{{ title }}</h2>
|
||||
<p class="small mb-0">Live webcam capture from your peer.</p>
|
||||
</div>
|
||||
<button class="video-modal-close" type="button" (click)="requestClose()" aria-label="Close live video">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="video-modal-body">
|
||||
<video #videoElement class="video-modal-player" autoplay playsinline></video>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styleUrl: './peer-video-modal.component.scss',
|
||||
})
|
||||
export class PeerVideoModalComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
@Input() visible = false;
|
||||
@Input() stream: MediaStream | null = null;
|
||||
@Input() title = 'Live webcam';
|
||||
@Output() readonly closeRequested = new EventEmitter<void>();
|
||||
@ViewChild('videoElement')
|
||||
set videoElementRef(value: ElementRef<HTMLVideoElement> | undefined) {
|
||||
this.videoElement = value;
|
||||
this.syncVideoSource();
|
||||
}
|
||||
|
||||
private videoElement?: ElementRef<HTMLVideoElement>;
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.syncVideoSource();
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.syncVideoSource();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.detachVideoSource();
|
||||
}
|
||||
|
||||
requestClose(): void {
|
||||
this.closeRequested.emit();
|
||||
}
|
||||
|
||||
private syncVideoSource(): void {
|
||||
const video = this.videoElement?.nativeElement;
|
||||
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.muted = true;
|
||||
video.srcObject = this.visible ? this.stream : null;
|
||||
|
||||
if (this.visible && this.stream) {
|
||||
void video.play().catch(() => {
|
||||
// Autoplay may be delayed until user interaction depending on platform policy.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private detachVideoSource(): void {
|
||||
const video = this.videoElement?.nativeElement;
|
||||
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.pause();
|
||||
video.srcObject = null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,13 @@
|
||||
<title>PrivateChat</title>
|
||||
<base href="/">
|
||||
<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.gstatic.com" crossorigin>
|
||||
<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 { 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)
|
||||
.catch((err) => console.error(err));
|
||||
|
||||
@@ -101,10 +101,6 @@
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
@@ -138,27 +134,30 @@ textarea {
|
||||
background: var(--badge-background) !important;
|
||||
}
|
||||
|
||||
.btn-outline-light {
|
||||
.btn-outline-light,
|
||||
.btn-outline-light:hover,
|
||||
.btn-outline-light:focus-visible {
|
||||
color: var(--page-text);
|
||||
border-color: var(--surface-border);
|
||||
}
|
||||
|
||||
.btn-outline-light:hover,
|
||||
.btn-outline-light:focus-visible {
|
||||
color: var(--page-text);
|
||||
border-color: var(--surface-border);
|
||||
background: var(--panel-soft-background);
|
||||
}
|
||||
|
||||
.btn-outline-light,
|
||||
.btn-outline-secondary {
|
||||
border-color: var(--surface-border);
|
||||
}
|
||||
|
||||
.btn-outline-secondary {
|
||||
color: var(--page-text-muted);
|
||||
border-color: var(--surface-border);
|
||||
}
|
||||
|
||||
.btn-outline-secondary:hover,
|
||||
.btn-outline-secondary:focus-visible {
|
||||
color: var(--page-text);
|
||||
border-color: var(--surface-border);
|
||||
background: var(--panel-soft-background);
|
||||
}
|
||||
|
||||
|
||||
324
server/dist/index.js
vendored
324
server/dist/index.js
vendored
@@ -1,18 +1,22 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { TextEncoder } from 'node:util';
|
||||
import { promisify, TextEncoder } from 'node:util';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import websocket from '@fastify/websocket';
|
||||
import dotenv from 'dotenv';
|
||||
import libreOffice from 'libreoffice-convert';
|
||||
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
||||
import Fastify from 'fastify';
|
||||
import { Redis } from 'ioredis';
|
||||
import { z } from 'zod';
|
||||
import { SpeechTranscriber } from './speech-transcriber.js';
|
||||
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
||||
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
||||
const registerSchema = z.object({
|
||||
@@ -43,6 +47,14 @@ const approvePendingUserParamsSchema = z.object({
|
||||
const adminDeleteUserParamsSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
const webBundleFileParamsSchema = z.object({
|
||||
'*': z.string().min(1),
|
||||
});
|
||||
const documentPreviewSchema = z.object({
|
||||
fileName: z.string().trim().min(1).max(256),
|
||||
mimeType: z.string().trim().min(1).max(256),
|
||||
fileBase64: z.string().min(1).max(96_000_000),
|
||||
});
|
||||
const wsQuerySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
@@ -78,6 +90,12 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
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 approvalAdminUsername = 'ladparis';
|
||||
@@ -88,6 +106,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 ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
||||
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 webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||
@@ -98,6 +119,13 @@ const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const speechTranscriber = new SpeechTranscriber({
|
||||
serviceUrl: speechTranscriptionServiceUrl,
|
||||
language: speechTranscriptionLanguage,
|
||||
requestTimeoutMs: speechTranscriptionTimeoutMs,
|
||||
}, app.log);
|
||||
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
||||
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
|
||||
@@ -240,6 +268,45 @@ else {
|
||||
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/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) => {
|
||||
const parsed = registerSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
@@ -405,6 +472,35 @@ app.get('/api/auth/session', async (request, reply) => {
|
||||
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
||||
};
|
||||
});
|
||||
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
if (!authContext) {
|
||||
return;
|
||||
}
|
||||
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({
|
||||
message: 'Invalid document preview payload.',
|
||||
issues: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||
}
|
||||
try {
|
||||
const previewImageBuffer = await createDocumentPreviewImage(parsed.data.fileName, parsed.data.mimeType, parsed.data.fileBase64);
|
||||
return {
|
||||
mimeType: 'image/png',
|
||||
imageBase64: previewImageBuffer.toString('base64'),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
|
||||
return reply.code(422).send({
|
||||
message: describeDocumentPreviewFailure(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
if (!authContext) {
|
||||
@@ -677,6 +773,25 @@ async function handleSocketMessage(userId, sessionId, socket, rawMessage) {
|
||||
}
|
||||
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;
|
||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||
if (recipientSockets) {
|
||||
@@ -759,6 +874,75 @@ async function authenticateTokenFromSession(userId, sessionId, decoded) {
|
||||
},
|
||||
};
|
||||
}
|
||||
async function convertOfficeDocumentToPdf(fileName, fileBase64) {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error('The uploaded office document is empty.');
|
||||
}
|
||||
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||
}
|
||||
async function createDocumentPreviewImage(fileName, mimeType, fileBase64) {
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
const pdfBuffer = normalizedMimeType === 'application/pdf'
|
||||
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
|
||||
: await convertOfficeDocumentToPdf(fileName, fileBase64);
|
||||
return renderPdfFirstPageToPng(pdfBuffer);
|
||||
}
|
||||
async function renderPdfFirstPageToPng(pdfBuffer) {
|
||||
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
|
||||
const pdfPath = path.join(tempDirectory, 'source.pdf');
|
||||
const outputBasePath = path.join(tempDirectory, 'page-preview');
|
||||
const imagePath = `${outputBasePath}.png`;
|
||||
try {
|
||||
await fs.promises.writeFile(pdfPath, pdfBuffer);
|
||||
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
|
||||
return await fs.promises.readFile(imagePath);
|
||||
}
|
||||
finally {
|
||||
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
function decodeBase64File(fileBase64, emptyMessage) {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error(emptyMessage);
|
||||
}
|
||||
return inputBuffer;
|
||||
}
|
||||
function isSupportedPreviewDocument(fileName, mimeType) {
|
||||
if (isPdfFile(fileName, mimeType)) {
|
||||
return true;
|
||||
}
|
||||
return isSupportedOfficeDocument(fileName, mimeType);
|
||||
}
|
||||
function isSupportedOfficeDocument(fileName, mimeType) {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
if (normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
|
||||
return true;
|
||||
}
|
||||
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||
}
|
||||
function isPdfFile(fileName, mimeType) {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
|
||||
}
|
||||
function normalizeOfficeDocumentFileName(fileName) {
|
||||
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||
}
|
||||
function describeDocumentPreviewFailure(error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return 'Document preview generation failed because a required conversion tool is missing on the server.';
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return `Document preview generation failed: ${error.message}`;
|
||||
}
|
||||
return 'Document preview generation failed.';
|
||||
}
|
||||
function createUser(input) {
|
||||
const createdAt = new Date().toISOString();
|
||||
const user = {
|
||||
@@ -1053,12 +1237,23 @@ function parseClientMessage(rawMessage) {
|
||||
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 {
|
||||
type: 'signal',
|
||||
to: parsed.data.to,
|
||||
signal: normalizeSignal(parsed.data.signal),
|
||||
};
|
||||
}
|
||||
async function transcribeAudioPayload(requestId, audioBase64, mimeType) {
|
||||
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
|
||||
}
|
||||
async function generateImageFromPrompt(prompt) {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||
@@ -1123,6 +1318,133 @@ function inferImageMimeType(imageBuffer) {
|
||||
}
|
||||
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) {
|
||||
if (signal.type === 'sdp') {
|
||||
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');
|
||||
}
|
||||
}
|
||||
30
server/package-lock.json
generated
30
server/package-lock.json
generated
@@ -16,6 +16,8 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.8.2",
|
||||
"ioredis": "^5.10.0",
|
||||
"libreoffice-convert": "^1.8.1",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1001,6 +1003,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -1535,6 +1543,19 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libreoffice-convert": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/libreoffice-convert/-/libreoffice-convert-1.8.1.tgz",
|
||||
"integrity": "sha512-iZ1DD/EMTlPvol8G++QQ/0w4pVecSwRuhMLXRm7nRim/gcaSscSXuTO9Tgbkieyw5UdJg7UXD+lkFT8SCi51Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^3.2.3",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -2028,6 +2049,15 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/toad-cache": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.8.2",
|
||||
"ioredis": "^5.10.0",
|
||||
"libreoffice-convert": "^1.8.1",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { TextEncoder } from 'node:util';
|
||||
import { promisify, TextEncoder } from 'node:util';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import cors from '@fastify/cors';
|
||||
@@ -10,6 +12,7 @@ import jwt from '@fastify/jwt';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import websocket from '@fastify/websocket';
|
||||
import dotenv from 'dotenv';
|
||||
import libreOffice from 'libreoffice-convert';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
@@ -23,6 +26,8 @@ import { Redis } from 'ioredis';
|
||||
import type WebSocket from 'ws';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SpeechTranscriber } from './speech-transcriber.js';
|
||||
|
||||
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
||||
|
||||
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
||||
@@ -120,6 +125,12 @@ type ClientMessage =
|
||||
}
|
||||
| {
|
||||
type: 'ping';
|
||||
}
|
||||
| {
|
||||
type: 'speech-transcription';
|
||||
requestId: string;
|
||||
mimeType: string;
|
||||
audioBase64: string;
|
||||
};
|
||||
|
||||
type ServerMessage =
|
||||
@@ -142,6 +153,16 @@ type ServerMessage =
|
||||
peerId: string;
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
type: 'speech-transcribed';
|
||||
requestId: string;
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
type: 'speech-transcription-error';
|
||||
requestId: string;
|
||||
message: string;
|
||||
}
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
@@ -171,6 +192,22 @@ type AccessKeySummary = {
|
||||
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 = {
|
||||
challenge: string;
|
||||
label: string;
|
||||
@@ -233,6 +270,16 @@ const adminDeleteUserParamsSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
});
|
||||
|
||||
const webBundleFileParamsSchema = z.object({
|
||||
'*': z.string().min(1),
|
||||
});
|
||||
|
||||
const documentPreviewSchema = z.object({
|
||||
fileName: z.string().trim().min(1).max(256),
|
||||
mimeType: z.string().trim().min(1).max(256),
|
||||
fileBase64: z.string().min(1).max(96_000_000),
|
||||
});
|
||||
|
||||
const wsQuerySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
@@ -269,6 +316,12 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
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 });
|
||||
@@ -287,6 +340,9 @@ const frontendDistPath = resolveProjectPath(
|
||||
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 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 webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||
@@ -299,6 +355,17 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
||||
);
|
||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const speechTranscriber = new SpeechTranscriber(
|
||||
{
|
||||
serviceUrl: speechTranscriptionServiceUrl,
|
||||
language: speechTranscriptionLanguage,
|
||||
requestTimeoutMs: speechTranscriptionTimeoutMs,
|
||||
},
|
||||
app.log,
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
||||
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
||||
@@ -461,6 +528,57 @@ if (hasFrontendBuild) {
|
||||
|
||||
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) => {
|
||||
const parsed = registerSchema.safeParse(request.body);
|
||||
|
||||
@@ -688,6 +806,45 @@ app.get('/api/auth/session', async (request, reply) => {
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
|
||||
if (!authContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({
|
||||
message: 'Invalid document preview payload.',
|
||||
issues: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const previewImageBuffer = await createDocumentPreviewImage(
|
||||
parsed.data.fileName,
|
||||
parsed.data.mimeType,
|
||||
parsed.data.fileBase64,
|
||||
);
|
||||
|
||||
return {
|
||||
mimeType: 'image/png',
|
||||
imageBase64: previewImageBuffer.toString('base64'),
|
||||
};
|
||||
} catch (error) {
|
||||
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
|
||||
return reply.code(422).send({
|
||||
message: describeDocumentPreviewFailure(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
|
||||
@@ -1056,6 +1213,27 @@ async function handleSocketMessage(
|
||||
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;
|
||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||
|
||||
@@ -1166,6 +1344,101 @@ async function authenticateTokenFromSession(
|
||||
};
|
||||
}
|
||||
|
||||
async function convertOfficeDocumentToPdf(fileName: string, fileBase64: string): Promise<Buffer> {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error('The uploaded office document is empty.');
|
||||
}
|
||||
|
||||
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||
}
|
||||
|
||||
async function createDocumentPreviewImage(
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
fileBase64: string,
|
||||
): Promise<Buffer> {
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
const pdfBuffer = normalizedMimeType === 'application/pdf'
|
||||
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
|
||||
: await convertOfficeDocumentToPdf(fileName, fileBase64);
|
||||
|
||||
return renderPdfFirstPageToPng(pdfBuffer);
|
||||
}
|
||||
|
||||
async function renderPdfFirstPageToPng(pdfBuffer: Buffer): Promise<Buffer> {
|
||||
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
|
||||
const pdfPath = path.join(tempDirectory, 'source.pdf');
|
||||
const outputBasePath = path.join(tempDirectory, 'page-preview');
|
||||
const imagePath = `${outputBasePath}.png`;
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(pdfPath, pdfBuffer);
|
||||
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
|
||||
return await fs.promises.readFile(imagePath);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64File(fileBase64: string, emptyMessage: string): Buffer {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error(emptyMessage);
|
||||
}
|
||||
|
||||
return inputBuffer;
|
||||
}
|
||||
|
||||
function isSupportedPreviewDocument(fileName: string, mimeType: string): boolean {
|
||||
if (isPdfFile(fileName, mimeType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isSupportedOfficeDocument(fileName, mimeType);
|
||||
}
|
||||
|
||||
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
|
||||
if (
|
||||
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||
}
|
||||
|
||||
function isPdfFile(fileName: string, mimeType: string): boolean {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
|
||||
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
|
||||
}
|
||||
|
||||
function normalizeOfficeDocumentFileName(fileName: string): string {
|
||||
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||
}
|
||||
|
||||
function describeDocumentPreviewFailure(error: unknown): string {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return 'Document preview generation failed because a required conversion tool is missing on the server.';
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return `Document preview generation failed: ${error.message}`;
|
||||
}
|
||||
|
||||
return 'Document preview generation failed.';
|
||||
}
|
||||
|
||||
function createUser(input: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
@@ -1597,6 +1870,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 {
|
||||
type: 'signal',
|
||||
to: parsed.data.to,
|
||||
@@ -1604,6 +1886,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 }> {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||
@@ -1685,6 +1971,169 @@ function inferImageMimeType(imageBuffer: Buffer): string {
|
||||
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 {
|
||||
if (signal.type === 'sdp') {
|
||||
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