Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 080931ac07 | |||
| c859ded3f7 | |||
| b8e9fb96c3 | |||
| f2bf70bc7d | |||
| cc14b4d1b7 | |||
| b27656bb43 | |||
| 2fb6bd3783 | |||
| 24bf3e38a7 | |||
| f13c04e809 | |||
| fd888c9ed1 | |||
| 17b606e1be | |||
| 84745eb104 | |||
| ae59d3deac | |||
| 687bd56e42 | |||
| 03d3b75fb4 | |||
| 32084a66d1 | |||
| 64e03964e9 | |||
| 11cc5350c8 | |||
| 0e4c79b735 | |||
| ffdea4fe62 |
0
.aidesigner/.gitkeep
Normal file
0
.aidesigner/.gitkeep
Normal file
11
.code-workspace.code-workspace
Normal file
11
.code-workspace.code-workspace
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "../Speech2Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -8,3 +8,17 @@ server/server/data/privatechat.sqlite-wal
|
|||||||
server/server/data/master.key
|
server/server/data/master.key
|
||||||
client/dist/*
|
client/dist/*
|
||||||
client/apple-client/WebApp/**
|
client/apple-client/WebApp/**
|
||||||
|
server/data/master.key
|
||||||
|
.vscode/extensions.json
|
||||||
|
.aidesigner/*
|
||||||
|
!.aidesigner/.gitkeep
|
||||||
|
CLAUDE.md
|
||||||
|
.mcp.json
|
||||||
|
.agents/skills/aidesigner-frontend/SKILL.md
|
||||||
|
.agents/skills/aidesigner-frontend/references/api.md
|
||||||
|
.agents/skills/aidesigner-frontend/references/frontend-rubric.md
|
||||||
|
.claude/agents/aidesigner-frontend.md
|
||||||
|
.claude/commands/aidesigner.md
|
||||||
|
.claude/skills/aidesigner-frontend/SKILL.md
|
||||||
|
.claude/skills/aidesigner-frontend/references/api.md
|
||||||
|
.claude/skills/aidesigner-frontend/references/frontend-rubric.md
|
||||||
|
|||||||
@@ -49,6 +49,16 @@
|
|||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "public"
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "magick.wasm",
|
||||||
|
"input": "node_modules/@imagemagick/magick-wasm/dist",
|
||||||
|
"output": "/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "ort-wasm-simd-threaded.jsep.*",
|
||||||
|
"input": "node_modules/@huggingface/transformers/dist",
|
||||||
|
"output": "/transformers-wasm"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
@@ -63,8 +73,8 @@
|
|||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
"type": "initial",
|
"type": "initial",
|
||||||
"maximumWarning": "700kB",
|
"maximumWarning": "1MB",
|
||||||
"maximumError": "1MB"
|
"maximumError": "2MB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "anyComponentStyle",
|
"type": "anyComponentStyle",
|
||||||
|
|||||||
1777
client/package-lock.json
generated
1777
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,22 +12,25 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@11.10.1",
|
"packageManager": "npm@11.10.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^21.2.0",
|
"@angular/common": "^21.2.8",
|
||||||
"@angular/compiler": "^21.2.0",
|
"@angular/compiler": "^21.2.8",
|
||||||
"@angular/core": "^21.2.0",
|
"@angular/core": "^21.2.8",
|
||||||
"@angular/forms": "^21.2.0",
|
"@angular/forms": "^21.2.8",
|
||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.8",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.8",
|
||||||
|
"@huggingface/transformers": "^3.8.1",
|
||||||
|
"@imagemagick/magick-wasm": "^0.0.39",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"rxjs": "~7.8.0",
|
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||||
"tslib": "^2.3.0"
|
"rxjs": "~7.8.2",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.2.1",
|
"@angular/build": "^21.2.7",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.2.7",
|
||||||
"@angular/compiler-cli": "^21.2.0",
|
"@angular/compiler-cli": "^21.2.8",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.2",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.2",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
window.__PRIVATECHAT_ENV__ = {
|
window.__PRIVATECHAT_ENV__ = {
|
||||||
"PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr"
|
"PRIVATECHAT_CLIENT_SERVER_URL": "https://chatter.dubertrand.fr",
|
||||||
|
"PRIVATECHAT_CLIENT_WHISPER_MODEL": "Xenova/whisper-small",
|
||||||
|
"PRIVATECHAT_CLIENT_WHISPER_LANGUAGE": "auto",
|
||||||
|
"PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH": "/transformers-wasm/"
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
client/public/notif.mp3
Normal file
BIN
client/public/notif.mp3
Normal file
Binary file not shown.
@@ -9,6 +9,10 @@ dotenv.config({ path: rootEnvPath });
|
|||||||
|
|
||||||
const runtimeEnv = {
|
const runtimeEnv = {
|
||||||
PRIVATECHAT_CLIENT_SERVER_URL: process.env.PRIVATECHAT_CLIENT_SERVER_URL ?? 'http://localhost:3000',
|
PRIVATECHAT_CLIENT_SERVER_URL: process.env.PRIVATECHAT_CLIENT_SERVER_URL ?? 'http://localhost:3000',
|
||||||
|
PRIVATECHAT_CLIENT_WHISPER_MODEL: process.env.PRIVATECHAT_CLIENT_WHISPER_MODEL ?? 'Xenova/whisper-small',
|
||||||
|
PRIVATECHAT_CLIENT_WHISPER_LANGUAGE: process.env.PRIVATECHAT_CLIENT_WHISPER_LANGUAGE ?? 'auto',
|
||||||
|
PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH:
|
||||||
|
process.env.PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH ?? '/transformers-wasm/',
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileContents = `window.__PRIVATECHAT_ENV__ = ${JSON.stringify(runtimeEnv, null, 2)};\n`;
|
const fileContents = `window.__PRIVATECHAT_ENV__ = ${JSON.stringify(runtimeEnv, null, 2)};\n`;
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
.approval-card {
|
.approval-card {
|
||||||
border: 1px solid var(--surface-border-soft);
|
border: 1px solid var(--surface-border-soft);
|
||||||
border-radius: 1rem;
|
border-radius: 1.15rem;
|
||||||
background: var(--panel-soft-background);
|
background: var(--surface-background);
|
||||||
|
transition: border-color 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.approval-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|||||||
273
client/src/app/browser-speech-transcriber.service.ts
Normal file
273
client/src/app/browser-speech-transcriber.service.ts
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
import { ChatSessionService } from './chat-session.service';
|
||||||
|
import type { DictationLanguage } from './models';
|
||||||
|
|
||||||
|
type PrivateChatRuntimeEnv = {
|
||||||
|
PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH?: string;
|
||||||
|
PRIVATECHAT_CLIENT_WHISPER_LANGUAGE?: string;
|
||||||
|
PRIVATECHAT_CLIENT_WHISPER_MODEL?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutomaticSpeechRecognitionOutput = {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AutomaticSpeechRecognitionPipeline = (
|
||||||
|
audio: Float32Array,
|
||||||
|
options?: {
|
||||||
|
chunk_length_s?: number;
|
||||||
|
stride_length_s?: number;
|
||||||
|
task?: 'transcribe';
|
||||||
|
language?: string;
|
||||||
|
},
|
||||||
|
) => Promise<AutomaticSpeechRecognitionOutput | AutomaticSpeechRecognitionOutput[]>;
|
||||||
|
|
||||||
|
type TransformersModule = {
|
||||||
|
env: {
|
||||||
|
backends: {
|
||||||
|
onnx: {
|
||||||
|
wasm?: {
|
||||||
|
wasmPaths?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
pipeline: (
|
||||||
|
task: string,
|
||||||
|
model: string,
|
||||||
|
options?: {
|
||||||
|
device?: 'wasm' | 'webgpu';
|
||||||
|
dtype?: 'fp32';
|
||||||
|
model_file_name?: string;
|
||||||
|
subfolder?: string;
|
||||||
|
},
|
||||||
|
) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const whisperTargetSampleRate = 16_000;
|
||||||
|
const defaultWhisperModel = 'Xenova/whisper-small';
|
||||||
|
const defaultTransformersWasmPath = '/transformers-wasm/';
|
||||||
|
const defaultChunkLengthSeconds = 30;
|
||||||
|
const defaultStrideLengthSeconds = 5;
|
||||||
|
const whisperLanguageNames: Record<DictationLanguage, string> = {
|
||||||
|
en: 'english',
|
||||||
|
fr: 'french',
|
||||||
|
es: 'spanish',
|
||||||
|
};
|
||||||
|
|
||||||
|
function readRuntimeEnv(): PrivateChatRuntimeEnv {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (window as typeof window & { __PRIVATECHAT_ENV__?: PrivateChatRuntimeEnv }).__PRIVATECHAT_ENV__ ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAudioContextConstructor(): typeof AudioContext | null {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.AudioContext
|
||||||
|
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||||
|
?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BrowserSpeechTranscriberService {
|
||||||
|
private readonly session = inject(ChatSessionService);
|
||||||
|
private readonly runtimeEnv = readRuntimeEnv();
|
||||||
|
private readonly modelId = this.runtimeEnv.PRIVATECHAT_CLIENT_WHISPER_MODEL?.trim() || defaultWhisperModel;
|
||||||
|
private readonly fallbackLanguage = this.normalizeLanguage(
|
||||||
|
this.runtimeEnv.PRIVATECHAT_CLIENT_WHISPER_LANGUAGE,
|
||||||
|
);
|
||||||
|
private transformersModulePromise: Promise<TransformersModule> | null = null;
|
||||||
|
private pipelinePromise: Promise<AutomaticSpeechRecognitionPipeline> | null = null;
|
||||||
|
|
||||||
|
async preload(): Promise<void> {
|
||||||
|
await this.getPipeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
async transcribe(audioBlob: Blob): Promise<string> {
|
||||||
|
if (audioBlob.size === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const waveform = await this.decodeToWhisperWaveform(audioBlob);
|
||||||
|
const transcriber = await this.getPipeline();
|
||||||
|
const inputLanguage = this.session.currentUser()
|
||||||
|
? this.session.dictationLanguage()
|
||||||
|
: this.resolveFallbackInputLanguage();
|
||||||
|
const output = await transcriber(waveform, {
|
||||||
|
chunk_length_s: defaultChunkLengthSeconds,
|
||||||
|
stride_length_s: defaultStrideLengthSeconds,
|
||||||
|
task: 'transcribe',
|
||||||
|
language: whisperLanguageNames[inputLanguage],
|
||||||
|
});
|
||||||
|
const transcription = Array.isArray(output) ? output[0] : output;
|
||||||
|
return transcription.text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getPipeline(): Promise<AutomaticSpeechRecognitionPipeline> {
|
||||||
|
if (!this.pipelinePromise) {
|
||||||
|
this.pipelinePromise = this.createPreferredPipeline<AutomaticSpeechRecognitionPipeline>(
|
||||||
|
'automatic-speech-recognition',
|
||||||
|
this.modelId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.pipelinePromise!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTransformersModule(): Promise<TransformersModule> {
|
||||||
|
if (!this.transformersModulePromise) {
|
||||||
|
this.transformersModulePromise = import('@huggingface/transformers') as Promise<TransformersModule>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformersModule = await this.transformersModulePromise;
|
||||||
|
const onnxWasmEnv = transformersModule.env.backends.onnx.wasm;
|
||||||
|
|
||||||
|
if (onnxWasmEnv && !onnxWasmEnv.wasmPaths) {
|
||||||
|
onnxWasmEnv.wasmPaths =
|
||||||
|
this.runtimeEnv.PRIVATECHAT_CLIENT_TRANSFORMERS_WASM_PATH?.trim() || defaultTransformersWasmPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformersModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createPreferredPipeline<T>(
|
||||||
|
task: string,
|
||||||
|
model: string,
|
||||||
|
options?: {
|
||||||
|
dtype?: 'fp32';
|
||||||
|
model_file_name?: string;
|
||||||
|
subfolder?: string;
|
||||||
|
},
|
||||||
|
): Promise<T> {
|
||||||
|
const transformersModule = await this.getTransformersModule();
|
||||||
|
const candidateDevices: Array<'webgpu' | 'wasm'> = this.browserSupportsWebGpu()
|
||||||
|
? ['webgpu', 'wasm']
|
||||||
|
: ['wasm'];
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (const device of candidateDevices) {
|
||||||
|
try {
|
||||||
|
const pipeline = await transformersModule.pipeline(task, model, {
|
||||||
|
...options,
|
||||||
|
device,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info(`[dictation] Loaded ${task} pipeline for ${model} on ${device}.`);
|
||||||
|
return pipeline as T;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
console.warn(`[dictation] Could not load ${task} pipeline for ${model} on ${device}.`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error ? lastError : new Error(`Could not load ${task} pipeline for ${model}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async decodeToWhisperWaveform(audioBlob: Blob): Promise<Float32Array> {
|
||||||
|
const audioContextConstructor = resolveAudioContextConstructor();
|
||||||
|
|
||||||
|
if (!audioContextConstructor) {
|
||||||
|
throw new Error('This browser cannot decode recorded audio for dictation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await audioBlob.arrayBuffer();
|
||||||
|
const audioContext = new audioContextConstructor();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer.slice(0));
|
||||||
|
const monoChannel = this.mixToMono(audioBuffer);
|
||||||
|
|
||||||
|
if (audioBuffer.sampleRate === whisperTargetSampleRate) {
|
||||||
|
return monoChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resampleMonoChannel(monoChannel, audioBuffer.sampleRate, whisperTargetSampleRate);
|
||||||
|
} catch (error) {
|
||||||
|
throw error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error('Could not decode the recorded dictation audio.');
|
||||||
|
} finally {
|
||||||
|
await audioContext.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mixToMono(audioBuffer: AudioBuffer): Float32Array {
|
||||||
|
const mixed = new Float32Array(audioBuffer.length);
|
||||||
|
|
||||||
|
for (let channelIndex = 0; channelIndex < audioBuffer.numberOfChannels; channelIndex += 1) {
|
||||||
|
const channel = audioBuffer.getChannelData(channelIndex);
|
||||||
|
|
||||||
|
for (let sampleIndex = 0; sampleIndex < channel.length; sampleIndex += 1) {
|
||||||
|
mixed[sampleIndex] += channel[sampleIndex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioBuffer.numberOfChannels > 1) {
|
||||||
|
for (let sampleIndex = 0; sampleIndex < mixed.length; sampleIndex += 1) {
|
||||||
|
mixed[sampleIndex] /= audioBuffer.numberOfChannels;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resampleMonoChannel(
|
||||||
|
monoChannel: Float32Array,
|
||||||
|
sourceSampleRate: number,
|
||||||
|
targetSampleRate: number,
|
||||||
|
): Float32Array {
|
||||||
|
if (sourceSampleRate === targetSampleRate) {
|
||||||
|
return monoChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLength = Math.max(1, Math.round(monoChannel.length * targetSampleRate / sourceSampleRate));
|
||||||
|
const resampled = new Float32Array(targetLength);
|
||||||
|
const positionRatio = sourceSampleRate / targetSampleRate;
|
||||||
|
|
||||||
|
for (let sampleIndex = 0; sampleIndex < targetLength; sampleIndex += 1) {
|
||||||
|
const sourcePosition = sampleIndex * positionRatio;
|
||||||
|
const sourceIndex = Math.floor(sourcePosition);
|
||||||
|
const nextSourceIndex = Math.min(sourceIndex + 1, monoChannel.length - 1);
|
||||||
|
const interpolationWeight = sourcePosition - sourceIndex;
|
||||||
|
const currentValue = monoChannel[sourceIndex] ?? 0;
|
||||||
|
const nextValue = monoChannel[nextSourceIndex] ?? currentValue;
|
||||||
|
|
||||||
|
resampled[sampleIndex] = currentValue + ((nextValue - currentValue) * interpolationWeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resampled;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLanguage(language: string | undefined): string | null {
|
||||||
|
const trimmedLanguage = language?.trim();
|
||||||
|
|
||||||
|
if (!trimmedLanguage || trimmedLanguage.toLowerCase() === 'auto') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmedLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
private browserSupportsWebGpu(): boolean {
|
||||||
|
return typeof navigator !== 'undefined' && 'gpu' in navigator;
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveFallbackInputLanguage(): DictationLanguage {
|
||||||
|
switch (this.fallbackLanguage?.toLowerCase()) {
|
||||||
|
case 'french':
|
||||||
|
case 'fr':
|
||||||
|
return 'fr';
|
||||||
|
case 'spanish':
|
||||||
|
case 'es':
|
||||||
|
return 'es';
|
||||||
|
default:
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +1,168 @@
|
|||||||
<main class="chat-shell py-4">
|
<main class="chat-shell py-4">
|
||||||
<div class="container-lg">
|
<div class="container-lg">
|
||||||
<section class="chat-page panel p-3 p-lg-4">
|
<section class="chat-page panel p-3 p-lg-4">
|
||||||
<app-peer-video-modal
|
<app-peer-call-modal
|
||||||
[visible]="remoteVideoModalVisible()"
|
[visible]="callModalVisible()"
|
||||||
[stream]="remoteVideoStream()"
|
[peerName]="callModalPeer()?.displayName ?? 'Peer'"
|
||||||
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
|
[callState]="callModalState()"
|
||||||
(closeRequested)="closeRemoteVideoModal()"
|
[callMode]="callModalMode()"
|
||||||
></app-peer-video-modal>
|
[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>
|
<audio #callAudioElement hidden autoplay playsinline></audio>
|
||||||
|
|
||||||
@if (incomingVoiceCallPeer(); as callingPeer) {
|
@if (callChoicePeer(); as selectedCallPeer) {
|
||||||
<div class="call-modal-backdrop">
|
<div class="call-choice-backdrop" (click)="closeCallChoice()">
|
||||||
<section class="panel p-4" style="width:min(100%,24rem)" (click)="$event.stopPropagation()">
|
<section class="call-choice-card panel p-4" (click)="$event.stopPropagation()">
|
||||||
<div class="mb-3">
|
<p class="call-choice-eyebrow">Start a call</p>
|
||||||
<div>
|
<h2 class="h5 mb-2">{{ selectedCallPeer.displayName }}</h2>
|
||||||
<h2 class="h5 mb-1">Incoming voice call</h2>
|
<p class="small mb-3">Choose whether to place a full video call or audio only.</p>
|
||||||
<p class="small mb-0">{{ callingPeer.displayName }} is calling you.</p>
|
<div class="call-choice-actions">
|
||||||
</div>
|
<button class="call-choice-button" type="button" (click)="startSelectedCall('video')">
|
||||||
</div>
|
<span class="call-choice-icon">📹</span>
|
||||||
<div class="d-flex flex-wrap gap-2 justify-content-end">
|
<span>Video call</span>
|
||||||
<button
|
|
||||||
class="btn btn-success"
|
|
||||||
type="button"
|
|
||||||
(click)="acceptIncomingVoiceCall(callingPeer.id)"
|
|
||||||
>
|
|
||||||
Accept
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="call-choice-button" type="button" (click)="startSelectedCall('audio')">
|
||||||
class="btn btn-outline-secondary"
|
<span class="call-choice-icon">🎙️</span>
|
||||||
type="button"
|
<span>Audio only</span>
|
||||||
(click)="rejectIncomingVoiceCall(callingPeer.id)"
|
</button>
|
||||||
>
|
</div>
|
||||||
Reject
|
<div class="d-flex justify-content-end mt-3">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="closeCallChoice()">
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
|
@if (conversationModalOpen()) {
|
||||||
<div>
|
<div class="conversation-modal-backdrop" (click)="closeConversationModal()">
|
||||||
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
<section class="conversation-modal panel p-3 p-lg-4" (click)="$event.stopPropagation()">
|
||||||
@if (currentUser(); as connectedUser) {
|
<header class="conversation-modal-header">
|
||||||
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
|
<div>
|
||||||
<div class="status-indicators mt-2">
|
<p class="conversation-modal-eyebrow mb-1">Fullscreen conversation</p>
|
||||||
|
<h2 class="h5 mb-0">{{ displayedPeer()?.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 (dropdownPeer of dropdownPeers(); track dropdownPeer.id) {
|
||||||
|
<article
|
||||||
|
class="peer-tile"
|
||||||
|
[class.peer-tile-active]="dropdownPeer.id === peerId()"
|
||||||
|
[class.peer-tile-unread]="isPeerUnread(dropdownPeer.id)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="peer-tile-main text-start"
|
||||||
|
type="button"
|
||||||
|
(click)="selectPeerFromDropdown(dropdownPeer.id)"
|
||||||
|
>
|
||||||
|
<div class="peer-tile-row">
|
||||||
|
<span class="peer-tile-title">
|
||||||
|
<span class="fw-semibold">{{ dropdownPeer.displayName }}</span>
|
||||||
|
@if (isPeerTyping(dropdownPeer.id)) {
|
||||||
|
<span class="peer-typing-dots" aria-label="Typing">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="status-led peer-tile-status"
|
||||||
|
[class.status-led-ok]="dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'"
|
||||||
|
[class.status-led-offline]="dropdownPeer.channelState !== 'open' && dropdownPeer.connectionState !== 'connected'"
|
||||||
|
[attr.aria-label]="
|
||||||
|
dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'
|
||||||
|
? 'Connected'
|
||||||
|
: 'Disconnected'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="peer-tile-delete"
|
||||||
|
type="button"
|
||||||
|
title="Delete conversation"
|
||||||
|
aria-label="Delete conversation"
|
||||||
|
(click)="deleteConversation(dropdownPeer.id, $event)"
|
||||||
|
>
|
||||||
|
🗑️
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="status-indicators">
|
||||||
<div class="status-indicator">
|
<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 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>
|
<span>Signaling</span>
|
||||||
@@ -59,357 +178,344 @@
|
|||||||
<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 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>
|
<span>WebRTC</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="status-indicator status-indicator-action"
|
||||||
|
type="button"
|
||||||
|
[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>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
</div>
|
||||||
<h1 class="h3 mb-1 mt-2">Not signed in</h1>
|
} @else {
|
||||||
<p class="small text-secondary mb-0">Return to the dashboard and sign in again.</p>
|
<div class="chat-header-main">
|
||||||
}
|
<a class="back-link" routerLink="/" aria-label="Back to dashboard">←</a>
|
||||||
</div>
|
<h1 class="chat-header-title mb-0">Not signed in</h1>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-layout">
|
<div class="chat-layout">
|
||||||
<aside class="peer-sidebar">
|
<div class="chat-main" (click)="closePeerDropdown()">
|
||||||
|
<div #conversationContainer class="conversation">
|
||||||
|
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||||
<div class="peer-list">
|
|
||||||
@if (session.peers().length === 0) {
|
|
||||||
<div class="empty-chat empty-peers">
|
|
||||||
No peers are currently connected.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
|
||||||
<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)"
|
|
||||||
>
|
|
||||||
<div class="peer-tile-row">
|
|
||||||
<span class="peer-tile-title">
|
|
||||||
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
|
|
||||||
@if (isPeerTyping(connectedPeer.id)) {
|
|
||||||
<span class="peer-typing-dots" aria-label="Typing">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
class="status-led peer-tile-status"
|
|
||||||
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'"
|
|
||||||
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'"
|
|
||||||
[attr.aria-label]="
|
|
||||||
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
|
|
||||||
? 'Connected'
|
|
||||||
: 'Disconnected'
|
|
||||||
"
|
|
||||||
></span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="peer-tile-delete"
|
|
||||||
type="button"
|
|
||||||
title="Delete conversation"
|
|
||||||
aria-label="Delete conversation"
|
|
||||||
(click)="deleteConversation(connectedPeer.id, $event)"
|
|
||||||
>
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</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">
|
|
||||||
<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>
|
|
||||||
}
|
|
||||||
<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>
|
|
||||||
}
|
|
||||||
@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>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="composer">
|
<div class="composer">
|
||||||
<textarea
|
<textarea
|
||||||
#composerTextarea
|
#composerTextarea
|
||||||
class="form-control composer-textarea"
|
class="form-control composer-textarea"
|
||||||
rows="3"
|
rows="2"
|
||||||
[(ngModel)]="messageText"
|
[(ngModel)]="messageText"
|
||||||
(ngModelChange)="handleMessageTextChange($event)"
|
(ngModelChange)="handleMessageTextChange($event)"
|
||||||
(keydown.enter)="handleComposerEnter($event)"
|
(keydown.enter)="handleComposerEnter($event)"
|
||||||
(click)="trackComposerSelection(composerTextarea)"
|
(click)="trackComposerSelection(composerTextarea)"
|
||||||
(keyup)="trackComposerSelection(composerTextarea)"
|
(keyup)="trackComposerSelection(composerTextarea)"
|
||||||
(select)="trackComposerSelection(composerTextarea)"
|
(select)="trackComposerSelection(composerTextarea)"
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
[disabled]="!peerId()"
|
||||||
placeholder="Write a text message to your peer"
|
placeholder="Write a text message to your peer, even if they are offline"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<div class="composer-toolbar">
|
<div class="composer-toolbar">
|
||||||
@if (peer(); as selectedPeer) {
|
<div class="composer-actions">
|
||||||
<button
|
@if (peerId(); as selectedPeerId) {
|
||||||
class="composer-call"
|
@if (peer(); as livePeer) {
|
||||||
type="button"
|
<button
|
||||||
[disabled]="!canStartSelectedVoiceCall()"
|
class="composer-call"
|
||||||
(click)="startVoiceCall(selectedPeer.id)"
|
type="button"
|
||||||
title="Start voice call"
|
[disabled]="!canStartSelectedVoiceCall()"
|
||||||
aria-label="Start voice call"
|
(click)="openCallChoice(livePeer.id)"
|
||||||
>
|
title="Start call"
|
||||||
📞
|
aria-label="Start call"
|
||||||
</button>
|
>
|
||||||
|
📞
|
||||||
|
</button>
|
||||||
|
|
||||||
@if (canEndSelectedVoiceCall()) {
|
@if (canEndSelectedVoiceCall()) {
|
||||||
<button
|
<button
|
||||||
class="composer-hangup"
|
class="composer-hangup"
|
||||||
type="button"
|
type="button"
|
||||||
(click)="endVoiceCall(selectedPeer.id)"
|
(click)="endVoiceCall(livePeer.id)"
|
||||||
title="End voice call"
|
title="End call"
|
||||||
aria-label="End voice call"
|
aria-label="End call"
|
||||||
>
|
>
|
||||||
🛑
|
🛑
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="composer-camera"
|
class="composer-voice"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="selectedPeer.channelState !== 'open' && !isStreamingCameraToSelectedPeer()"
|
[disabled]="livePeer.channelState !== 'open' && !isRecordingVoice()"
|
||||||
(click)="toggleCameraStream(selectedPeer.id)"
|
(click)="toggleVoiceRecording()"
|
||||||
[title]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||||
[attr.aria-label]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||||
>
|
[class.composer-voice-recording]="isRecordingVoice()"
|
||||||
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
|
>
|
||||||
</button>
|
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="composer-voice"
|
class="composer-dictation"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
|
[disabled]="!selectedPeerId || isTranscribingDictation()"
|
||||||
(click)="toggleVoiceRecording()"
|
(click)="toggleDictation(composerTextarea)"
|
||||||
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
[title]="
|
||||||
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
isDictating()
|
||||||
[class.composer-voice-recording]="isRecordingVoice()"
|
? 'Stop dictation and transcribe'
|
||||||
>
|
: isTranscribingDictation()
|
||||||
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
|
? 'Transcribing dictated audio'
|
||||||
</button>
|
: '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>
|
||||||
|
}
|
||||||
|
|
||||||
<button
|
<input
|
||||||
class="composer-dictation"
|
#fileInput
|
||||||
type="button"
|
class="composer-file-input"
|
||||||
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
|
type="file"
|
||||||
(click)="toggleDictation(composerTextarea)"
|
[disabled]="!selectedPeerId"
|
||||||
[title]="
|
(change)="sendFile(selectedPeerId, fileInput)"
|
||||||
isDictating()
|
/>
|
||||||
? 'Stop dictation and transcribe'
|
<button
|
||||||
: isTranscribingDictation()
|
class="composer-plus"
|
||||||
? 'Transcribing dictated audio'
|
type="button"
|
||||||
: 'Start dictation'
|
[disabled]="!selectedPeerId"
|
||||||
"
|
(click)="fileInput.click()"
|
||||||
[attr.aria-label]="
|
title="Send file"
|
||||||
isDictating()
|
aria-label="Send file"
|
||||||
? 'Stop dictation and transcribe'
|
>
|
||||||
: isTranscribingDictation()
|
+
|
||||||
? 'Transcribing dictated audio'
|
</button>
|
||||||
: 'Start dictation'
|
}
|
||||||
"
|
|
||||||
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
|
|
||||||
>
|
|
||||||
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<input
|
<button
|
||||||
#fileInput
|
class="composer-image-generate"
|
||||||
class="composer-file-input"
|
type="button"
|
||||||
type="file"
|
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
|
||||||
[disabled]="selectedPeer.channelState !== 'open'"
|
(click)="requestGeneratedImage()"
|
||||||
(change)="sendFile(selectedPeer.id, fileInput)"
|
title="Generate image from prompt"
|
||||||
/>
|
aria-label="Generate image from prompt"
|
||||||
<button
|
>
|
||||||
class="composer-plus"
|
🖼️
|
||||||
type="button"
|
</button>
|
||||||
[disabled]="selectedPeer.channelState !== 'open'"
|
|
||||||
(click)="fileInput.click()"
|
|
||||||
title="Send file"
|
|
||||||
aria-label="Send file"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button
|
<div class="composer-emoji-picker-shell">
|
||||||
class="composer-image-generate"
|
@if (emojiPickerOpen()) {
|
||||||
type="button"
|
<div class="composer-emoji-picker">
|
||||||
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
|
@for (emoji of emojiOptions; track emoji) {
|
||||||
(click)="requestGeneratedImage()"
|
<button
|
||||||
title="Generate image from prompt"
|
class="composer-emoji-option"
|
||||||
aria-label="Generate image from prompt"
|
type="button"
|
||||||
>
|
(click)="insertEmoji(emoji, composerTextarea)"
|
||||||
🖼️
|
[attr.aria-label]="'Insert ' + emoji"
|
||||||
</button>
|
[title]="'Insert ' + emoji"
|
||||||
|
>
|
||||||
|
{{ emoji }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<button
|
||||||
|
class="composer-emoji-trigger"
|
||||||
|
type="button"
|
||||||
|
[disabled]="!peerId()"
|
||||||
|
(click)="toggleEmojiPicker($event)"
|
||||||
|
title="Insert emoji"
|
||||||
|
aria-label="Insert emoji"
|
||||||
|
>
|
||||||
|
😀
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="composer-emoji-picker-shell">
|
<button
|
||||||
@if (emojiPickerOpen()) {
|
class="send-emoji"
|
||||||
<div class="composer-emoji-picker">
|
type="button"
|
||||||
@for (emoji of emojiOptions; track emoji) {
|
[disabled]="!peerId()"
|
||||||
<button
|
(click)="sendMessage()"
|
||||||
class="composer-emoji-option"
|
title="Send message"
|
||||||
type="button"
|
aria-label="Send message"
|
||||||
(click)="insertEmoji(emoji, composerTextarea)"
|
>
|
||||||
[attr.aria-label]="'Insert ' + emoji"
|
✅
|
||||||
[title]="'Insert ' + emoji"
|
</button>
|
||||||
>
|
</div>
|
||||||
{{ emoji }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
class="composer-emoji-trigger"
|
|
||||||
type="button"
|
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
|
||||||
(click)="toggleEmojiPicker($event)"
|
|
||||||
title="Insert emoji"
|
|
||||||
aria-label="Insert emoji"
|
|
||||||
>
|
|
||||||
😀
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
@if (lastIncomingReceiveMetric(); as receiveMetric) {
|
||||||
class="send-emoji"
|
<div class="composer-receive-speed" title="Receive speed of the last completed incoming WebRTC message">
|
||||||
type="button"
|
<span class="composer-receive-speed-label">Rx</span>
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
<span class="composer-receive-speed-value">{{ receiveMetric.mbps | number: '1.2-2' }} Mbit/s</span>
|
||||||
(click)="sendMessage()"
|
</div>
|
||||||
title="Send message"
|
}
|
||||||
aria-label="Send message"
|
</div>
|
||||||
>
|
|
||||||
✅
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<ng-template #conversationBubbles>
|
||||||
|
@if (conversation().length === 0) {
|
||||||
|
<div class="empty-chat">
|
||||||
|
No text messages yet. Messages and files can be queued here and will send when the peer reconnects.
|
||||||
|
</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-pending]="isPendingOutgoingEntry(entry)"
|
||||||
|
[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>
|
||||||
|
@if (isPendingOutgoingEntry(entry)) {
|
||||||
|
<span class="bubble-delivery-state">Queued</span>
|
||||||
|
}
|
||||||
|
</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]="imageDisplayUrl(entry)"
|
||||||
|
[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>
|
||||||
|
|||||||
@@ -8,259 +8,182 @@
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Panel ──────────────────────────────────────────────────────────────── */
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 1.75rem;
|
border-radius: 2rem;
|
||||||
background: var(--panel-background);
|
background: var(--panel-background);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(22px);
|
||||||
box-shadow: 0 20px 60px var(--shadow-color);
|
-webkit-backdrop-filter: blur(22px);
|
||||||
|
box-shadow:
|
||||||
|
0 28px 72px var(--shadow-color),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.07);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Chat page shell ────────────────────────────────────────────────────── */
|
||||||
.chat-page {
|
.chat-page {
|
||||||
width: min(100%, 800px);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(96vw, 98%);
|
||||||
|
height: min(calc(100dvh - 2rem), 1024px);
|
||||||
|
max-height: 1024px;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.call-modal-backdrop {
|
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||||
position: fixed;
|
.chat-header { flex: 0 0 auto; }
|
||||||
inset: 0;
|
|
||||||
z-index: 1250;
|
.chat-header-main {
|
||||||
display: grid;
|
display: flex;
|
||||||
place-items: center;
|
flex-wrap: wrap;
|
||||||
padding: 1.5rem;
|
align-items: center;
|
||||||
background: rgba(3, 8, 14, 0.52);
|
gap: 0.85rem;
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-header-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(1.1rem, 2vw, 1.45rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
background: linear-gradient(135deg, var(--page-text) 40%, var(--accent-color) 100%);
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Back link ───────────────────────────────────────────────────────────── */
|
||||||
.back-link {
|
.back-link {
|
||||||
color: var(--link-color);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--page-text-muted);
|
||||||
|
background: var(--badge-background);
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 160ms ease, background 160ms ease, border-color 160ms ease, transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.back-link:hover,
|
||||||
|
.back-link:focus-visible {
|
||||||
|
color: var(--accent-color);
|
||||||
|
background: var(--accent-color-soft);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status indicators ───────────────────────────────────────────────────── */
|
||||||
.status-indicators {
|
.status-indicators {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.9rem;
|
gap: 0.5rem;
|
||||||
|
margin-left: auto;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-indicator {
|
.status-indicator {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.42rem;
|
||||||
font-size: 0.9rem;
|
padding: 0.32rem 0.75rem;
|
||||||
color: var(--page-text-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-action {
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-action:not(:disabled) {
|
|
||||||
color: var(--page-text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-action:not(:disabled):hover,
|
|
||||||
.status-indicator-action:not(:disabled):focus-visible {
|
|
||||||
color: var(--accent-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator-action:disabled {
|
|
||||||
cursor: default;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-led {
|
|
||||||
width: 0.8rem;
|
|
||||||
height: 0.8rem;
|
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
box-shadow: 0 0 0 1px var(--input-border);
|
font-size: 0.72rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--page-text-soft);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-led-ok {
|
.expand-action-icon {
|
||||||
background: #59d66f;
|
font-size: 1.25rem;
|
||||||
}
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
.status-led-connecting {
|
|
||||||
background: #f3ad3d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-led-offline {
|
|
||||||
background: #eb5d64;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Layout ──────────────────────────────────────────────────────────────── */
|
||||||
.chat-layout {
|
.chat-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr);
|
flex: 1 1 auto;
|
||||||
gap:1.25rem;
|
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-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-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.75rem;
|
|
||||||
max-height: calc(100dvh - 17rem);
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.8rem 0.85rem 0.8rem 1rem;
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
border-radius: 1rem;
|
|
||||||
color: inherit;
|
|
||||||
background: var(--surface-background);
|
|
||||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile-main {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
color: inherit;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile-delete {
|
|
||||||
width: 2.2rem;
|
|
||||||
height: 2.2rem;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: transparent;
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile-delete:hover,
|
|
||||||
.peer-tile-delete:focus-visible {
|
|
||||||
background: var(--badge-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile:hover,
|
|
||||||
.peer-tile:focus-visible,
|
|
||||||
.peer-tile-active {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile-title {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.45rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-typing-dots {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.2rem;
|
|
||||||
min-height: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-typing-dots span {
|
|
||||||
width: 0.38rem;
|
|
||||||
height: 0.38rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--page-text);
|
|
||||||
opacity: 0.28;
|
|
||||||
animation: peer-typing-pulse 900ms infinite ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-typing-dots span:nth-child(2) {
|
|
||||||
animation-delay: 120ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-typing-dots span:nth-child(3) {
|
|
||||||
animation-delay: 240ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.peer-tile-status {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-main {
|
.chat-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Conversation scroll area ────────────────────────────────────────────── */
|
||||||
.conversation {
|
.conversation {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.85rem;
|
gap: 0.75rem;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
min-height: 24rem;
|
min-height: 0;
|
||||||
max-height: calc(100dvh - 20rem);
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 0.5rem 0;
|
padding: 0.75rem 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bubble ──────────────────────────────────────────────────────────────── */
|
||||||
.bubble {
|
.bubble {
|
||||||
position: relative;
|
position: relative;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
max-width: min(75%, 34rem);
|
max-width: min(78%, 36rem);
|
||||||
padding: 0.9rem 3.4rem 0.9rem 1rem;
|
padding: 0.85rem 3.25rem 0.85rem 1rem;
|
||||||
border-radius: 1.2rem;
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22);
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-actions {
|
.bubble-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.45rem;
|
top: 0.45rem;
|
||||||
right: 0.55rem;
|
right: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 0.35rem;
|
gap: 0.28rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble:hover .bubble-actions {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-action {
|
.bubble-action {
|
||||||
width: 1.5rem;
|
width: 1.45rem;
|
||||||
height: 1.5rem;
|
height: 1.45rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #fff;
|
color: var(--page-text-muted);
|
||||||
background: var(--badge-background);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-size: 0.9rem;
|
font-size: 0.8rem;
|
||||||
|
transition: background 120ms ease, color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-action:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
color: var(--page-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-delete {
|
.bubble-delete {
|
||||||
background: var(--danger-background);
|
background: rgba(239, 68, 68, 0.2);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-delete:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.35);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-forward-menu {
|
.bubble-forward-menu {
|
||||||
@@ -271,9 +194,10 @@
|
|||||||
min-width: 12rem;
|
min-width: 12rem;
|
||||||
padding: 0.45rem;
|
padding: 0.45rem;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 0.85rem;
|
border-radius: 0.9rem;
|
||||||
background: var(--surface-background);
|
background: var(--panel-background);
|
||||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.18);
|
backdrop-filter: blur(16px);
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-forward-select {
|
.bubble-forward-select {
|
||||||
@@ -282,194 +206,15 @@
|
|||||||
border-radius: 0.65rem;
|
border-radius: 0.65rem;
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
background: var(--input-background);
|
background: var(--input-background);
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-incoming {
|
.emoji-only-text {
|
||||||
justify-self: start;
|
font-size: clamp(2.1rem, 5vw, 3.4rem);
|
||||||
color: var(--incoming-bubble-text);
|
line-height: 1.15;
|
||||||
background: var(--incoming-bubble-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-outgoing {
|
|
||||||
justify-self: end;
|
|
||||||
color: var(--outgoing-bubble-text);
|
|
||||||
background: var(--outgoing-bubble-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-system {
|
|
||||||
justify-self: center;
|
|
||||||
max-width: 90%;
|
|
||||||
color: var(--page-text-soft);
|
|
||||||
background: var(--badge-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-meta {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.12rem;
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-time {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-system-status {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-spinner {
|
|
||||||
width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border: 0.15rem solid currentColor;
|
|
||||||
border-right-color: transparent;
|
|
||||||
border-radius: 999px;
|
|
||||||
opacity: 0.8;
|
|
||||||
animation: bubble-spin 700ms linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.85rem;
|
|
||||||
padding-top: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
border-top: 1px solid var(--surface-border-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-toolbar {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.6rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-emoji-picker-shell {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-file-input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-camera,
|
|
||||||
.composer-call,
|
|
||||||
.composer-dictation,
|
|
||||||
.composer-hangup,
|
|
||||||
.composer-voice,
|
|
||||||
.composer-image-generate,
|
|
||||||
.composer-emoji-trigger,
|
|
||||||
.composer-plus,
|
|
||||||
.send-emoji {
|
|
||||||
width: 3.25rem;
|
|
||||||
height: 3.25rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 1.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-textarea,
|
|
||||||
.composer-textarea:focus {
|
|
||||||
color: var(--page-text);
|
|
||||||
background-color: var(--input-background);
|
|
||||||
border-color: var(--input-border);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-textarea::placeholder {
|
|
||||||
color: var(--placeholder-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-textarea {
|
|
||||||
min-height: 7rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-call {
|
|
||||||
color: var(--page-text);
|
|
||||||
background: linear-gradient(135deg, #bfe9ff, #96c3ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-camera,
|
|
||||||
.composer-emoji-trigger,
|
|
||||||
.composer-plus {
|
|
||||||
color: var(--page-text);
|
|
||||||
background: var(--badge-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-dictation {
|
|
||||||
color: var(--page-text);
|
|
||||||
background: linear-gradient(135deg, #f6d8ff, #ffcadb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-dictation-active,
|
|
||||||
.composer-hangup,
|
|
||||||
.composer-voice-recording {
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(135deg, #ff7d63, #dc3e5d);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-voice {
|
|
||||||
color: var(--page-text);
|
|
||||||
background: linear-gradient(135deg, #ffd8bf, #ff9b8a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-voice-recording {
|
|
||||||
box-shadow: 0 0 0 0.2rem rgba(220, 62, 93, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-image-generate {
|
|
||||||
color: var(--page-text);
|
|
||||||
background: linear-gradient(135deg, #ffe6b0, #ffc8a8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.send-emoji {
|
|
||||||
background: linear-gradient(135deg, #def7dd, #9bd5ff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-emoji-picker {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
bottom: calc(100% + 0.65rem);
|
|
||||||
z-index: 3;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
||||||
gap: 0.35rem;
|
|
||||||
width: min(14rem, 70vw);
|
|
||||||
max-height: 10.35rem;
|
|
||||||
overflow-y: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
padding: 0.65rem;
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: var(--panel-background);
|
|
||||||
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-emoji-option {
|
|
||||||
width: 2.1rem;
|
|
||||||
height: 2.1rem;
|
|
||||||
padding: 0;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: var(--surface-background);
|
|
||||||
font-size: 1.2rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-emoji-option:hover,
|
|
||||||
.composer-emoji-option:focus-visible {
|
|
||||||
background: var(--surface-hover-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble-author,
|
|
||||||
.bubble-download,
|
|
||||||
.voice-bubble-label {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bubble: image / video / download ───────────────────────────────────── */
|
||||||
.bubble-image,
|
.bubble-image,
|
||||||
.bubble-video {
|
.bubble-video {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
@@ -477,19 +222,55 @@
|
|||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-video {
|
.bubble-video { background: #000; }
|
||||||
background: #000;
|
|
||||||
}
|
|
||||||
.bubble-download { color: inherit; }
|
|
||||||
|
|
||||||
.voice-bubble {
|
.bubble-download {
|
||||||
display: grid;
|
color: var(--accent-color);
|
||||||
gap: 0.65rem;
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voice-bubble-label { font-size: 0.88rem; }
|
.bubble-download:hover { color: var(--link-color); }
|
||||||
|
|
||||||
|
.bubble-preview { display: grid; gap: 0.45rem; }
|
||||||
|
|
||||||
|
.bubble-preview-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-preview-image {
|
||||||
|
display: block;
|
||||||
|
width: min(240px, 100%);
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Voice bubble ────────────────────────────────────────────────────────── */
|
||||||
|
.voice-bubble { display: grid; gap: 0.6rem; }
|
||||||
|
|
||||||
|
.voice-bubble-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
.voice-player {
|
.voice-player {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -500,59 +281,292 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bubble metadata ─────────────────────────────────────────────────────── */
|
||||||
|
.bubble-author { font-weight: 600; }
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────────── */
|
||||||
.empty-chat {
|
.empty-chat {
|
||||||
padding: 1.25rem;
|
padding: 1.5rem;
|
||||||
border: 1px dashed var(--input-border);
|
border: 1px dashed var(--surface-border);
|
||||||
border-radius: 1rem;
|
border-radius: 1.25rem;
|
||||||
color: var(--page-text-muted);
|
color: var(--page-text-muted);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-peers {
|
.empty-peers { min-height: 10rem; }
|
||||||
min-height: 10rem;
|
|
||||||
|
/* ── Composer ────────────────────────────────────────────────────────────── */
|
||||||
|
.composer {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 1rem 0 0;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--surface-border-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.h3,
|
.composer-textarea,
|
||||||
.small {
|
.composer-textarea:focus {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
|
background-color: var(--input-background);
|
||||||
|
border-color: var(--input-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes peer-typing-pulse {
|
.composer-textarea:focus {
|
||||||
0%,
|
border-color: color-mix(in srgb, var(--accent-color) 50%, transparent);
|
||||||
80%,
|
box-shadow: 0 0 0 3px var(--accent-color-soft);
|
||||||
100% {
|
|
||||||
opacity: 0.28;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
40% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes bubble-spin {
|
.composer-textarea::placeholder { color: var(--placeholder-color); }
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
.composer-textarea {
|
||||||
}
|
min-height: calc(2 * 1.5rem + 1.25rem);
|
||||||
|
max-height: calc(6 * 1.5rem + 1.25rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
resize: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Composer toolbar ────────────────────────────────────────────────────── */
|
||||||
|
.composer-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All composer icon buttons share base sizing */
|
||||||
|
.composer-camera,
|
||||||
|
.composer-call,
|
||||||
|
.composer-dictation,
|
||||||
|
.composer-hangup,
|
||||||
|
.composer-voice,
|
||||||
|
.composer-image-generate,
|
||||||
|
.composer-emoji-trigger,
|
||||||
|
.composer-plus,
|
||||||
|
.send-emoji {
|
||||||
|
width: 2.85rem;
|
||||||
|
height: 2.85rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: transform 160ms ease, opacity 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-camera:hover,
|
||||||
|
.composer-call:hover,
|
||||||
|
.composer-dictation:hover,
|
||||||
|
.composer-voice:hover,
|
||||||
|
.composer-image-generate:hover,
|
||||||
|
.composer-emoji-trigger:hover,
|
||||||
|
.composer-plus:hover,
|
||||||
|
.send-emoji:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(
|
||||||
|
.composer-camera,
|
||||||
|
.composer-call,
|
||||||
|
.composer-dictation,
|
||||||
|
.composer-hangup,
|
||||||
|
.composer-voice,
|
||||||
|
.composer-image-generate,
|
||||||
|
.composer-emoji-trigger,
|
||||||
|
.composer-plus,
|
||||||
|
.send-emoji
|
||||||
|
):disabled {
|
||||||
|
opacity: 0.38;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-file-input { display: none; }
|
||||||
|
|
||||||
|
/* Individual button colors */
|
||||||
|
.composer-call {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #059669, #10b981);
|
||||||
|
box-shadow: 0 4px 16px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-hangup {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #dc2626, #ef4444);
|
||||||
|
box-shadow: 0 4px 16px rgba(220, 38, 38, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-voice {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #d97706, #f59e0b);
|
||||||
|
box-shadow: 0 4px 16px rgba(245, 158, 11, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-voice-recording {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #db2777, #ec4899) !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.2), 0 4px 16px rgba(219, 39, 119, 0.35) !important;
|
||||||
|
animation: voice-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes voice-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.2); }
|
||||||
|
50% { box-shadow: 0 0 0 6px rgba(236, 72, 153, 0.08); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-dictation {
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: linear-gradient(135deg, #fbbf24, #fde68a);
|
||||||
|
box-shadow: 0 4px 16px rgba(251, 191, 36, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-dictation-active {
|
||||||
|
color: #fff !important;
|
||||||
|
background: linear-gradient(135deg, #db2777, #ec4899) !important;
|
||||||
|
box-shadow: 0 4px 16px rgba(219, 39, 119, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-plus {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #059669, #34d399);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-image-generate {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #7c3aed, #a78bfa);
|
||||||
|
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-trigger {
|
||||||
|
color: #fff;
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #818cf8);
|
||||||
|
box-shadow: 0 4px 16px rgba(79, 70, 229, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-emoji {
|
||||||
|
color: #fff;
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
box-shadow: 0 4px 20px rgba(13, 148, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-emoji:not(:disabled):hover {
|
||||||
|
background: var(--accent-gradient-hover);
|
||||||
|
box-shadow: 0 6px 24px rgba(13, 148, 136, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Receive speed ───────────────────────────────────────────────────────── */
|
||||||
|
.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.68rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-receive-speed-value {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Emoji picker ────────────────────────────────────────────────────────── */
|
||||||
|
.composer-emoji-picker-shell { position: relative; }
|
||||||
|
|
||||||
|
.composer-emoji-picker {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 0.65rem);
|
||||||
|
z-index: 3;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 0.35rem;
|
||||||
|
width: min(15rem, 72vw);
|
||||||
|
max-height: 10.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--panel-background);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-option {
|
||||||
|
width: 2.15rem;
|
||||||
|
height: 2.15rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--surface-background);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 120ms ease, transform 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-emoji-option:hover,
|
||||||
|
.composer-emoji-option:focus-visible {
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc helpers ────────────────────────────────────────────────────────── */
|
||||||
|
.h3, .small { color: var(--page-text); }
|
||||||
|
|
||||||
|
/* ── Mobile ──────────────────────────────────────────────────────────────── */
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.chat-layout {
|
.chat-layout { grid-template-columns: 1fr; }
|
||||||
grid-template-columns: 1fr;
|
|
||||||
|
.status-indicators {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-list {
|
.bubble { max-width: 90%; }
|
||||||
max-height: 16rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bubble {
|
.composer-toolbar { justify-content: flex-start; }
|
||||||
max-width: 88%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer-toolbar {
|
.composer-call,
|
||||||
justify-content: flex-start;
|
.composer-hangup,
|
||||||
|
.composer-voice,
|
||||||
|
.composer-dictation,
|
||||||
|
.composer-image-generate,
|
||||||
|
.composer-emoji-trigger,
|
||||||
|
.composer-plus,
|
||||||
|
.send-emoji {
|
||||||
|
width: 2.6rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,43 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, ViewChild } from '@angular/core';
|
import { Component, computed, effect, ElementRef, inject, NgZone, OnDestroy, signal, untracked, ViewChild } from '@angular/core';
|
||||||
import { toSignal } from '@angular/core/rxjs-interop';
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { PeerVideoModalComponent } from './peer-video-modal.component';
|
import { BrowserSpeechTranscriberService } from './browser-speech-transcriber.service';
|
||||||
|
import { PeerCallModalComponent } from './peer-call-modal.component';
|
||||||
import { ChatSessionService } from './chat-session.service';
|
import { ChatSessionService } from './chat-session.service';
|
||||||
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
||||||
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
|
import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
|
||||||
|
|
||||||
|
type KnownPeerSummary = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DropdownPeerSummary = PeerSummary & { knownOnly: boolean };
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-chat-page',
|
selector: 'app-chat-page',
|
||||||
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
JsonFileViewerComponent,
|
||||||
|
PeerCallModalComponent,
|
||||||
|
],
|
||||||
templateUrl: './chat-page.component.html',
|
templateUrl: './chat-page.component.html',
|
||||||
styleUrl: './chat-page.component.scss',
|
styleUrl: './chat-page.component.scss',
|
||||||
})
|
})
|
||||||
export class ChatPageComponent implements OnDestroy {
|
export class ChatPageComponent implements OnDestroy {
|
||||||
|
private static readonly knownPeersStoragePrefix = 'privatechat.knownPeers';
|
||||||
|
private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||||
|
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||||
|
: null;
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly ngZone = inject(NgZone);
|
private readonly ngZone = inject(NgZone);
|
||||||
|
private readonly speechTranscriber = inject(BrowserSpeechTranscriberService);
|
||||||
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
private readonly routeParamMap = toSignal(this.route.paramMap, {
|
||||||
initialValue: this.route.snapshot.paramMap,
|
initialValue: this.route.snapshot.paramMap,
|
||||||
});
|
});
|
||||||
@@ -37,49 +56,161 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
private dictationCompletionPromise: Promise<void> | null = null;
|
private dictationCompletionPromise: Promise<void> | null = null;
|
||||||
private resolveDictationCompletion: (() => void) | null = null;
|
private resolveDictationCompletion: (() => void) | null = null;
|
||||||
private dictationApplyToken = 0;
|
private dictationApplyToken = 0;
|
||||||
|
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
|
||||||
|
private lastAutoConnectedPeerId: string | null = null;
|
||||||
@ViewChild('callAudioElement')
|
@ViewChild('callAudioElement')
|
||||||
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
||||||
this.callAudioElement = value;
|
this.callAudioElement = value;
|
||||||
this.syncCallAudioSource();
|
this.syncCallAudioSource();
|
||||||
}
|
}
|
||||||
private callAudioElement?: ElementRef<HTMLAudioElement>;
|
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 = '';
|
messageText = '';
|
||||||
readonly forwardingEntryId = signal<string | null>(null);
|
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 emojiPickerOpen = signal(false);
|
||||||
readonly isRecordingVoice = signal(false);
|
readonly isRecordingVoice = signal(false);
|
||||||
readonly isDictating = signal(false);
|
readonly isDictating = signal(false);
|
||||||
readonly isTranscribingDictation = signal(false);
|
readonly isTranscribingDictation = signal(false);
|
||||||
|
readonly knownPeers = signal<KnownPeerSummary[]>([]);
|
||||||
readonly emojiOptions = [
|
readonly emojiOptions = [
|
||||||
'😀', '😁', '😂', '🤣', '😊',
|
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||||
'😉', '😍', '😘', '😎', '🤔',
|
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
|
||||||
'😅', '😭', '😡', '😴', '🙃',
|
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
|
||||||
'👍', '👎', '👏', '🙏', '🤝',
|
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
|
||||||
'🎉', '🔥', '❤️', '💡', '✅',
|
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🫠', '🤑', '😲',
|
||||||
'🚀', '👀', '📹', '📎', '💬',
|
'☹️', '🙁', '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧',
|
||||||
'🌍', '⚡', '⭐', '🎵', '📷',
|
'😨', '😩', '🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪',
|
||||||
'🗑️', '⏩', '🛑', '🙌', '👌',
|
'😵', '🥴', '😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮',
|
||||||
|
'🤧', '😇', '🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐',
|
||||||
|
'🤓', '😈', '👿', '👹', '👺', '💀', '☠️', '👻', '👽', '🤖',
|
||||||
|
'💩', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾',
|
||||||
|
'🙈', '🙉', '🙊', '💋', '💌', '💘', '💝', '💖', '💗', '💓',
|
||||||
|
'💞', '💕', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||||
|
'🤎', '💔', '❤️🔥', '❤️🩹', '❣️', '💯', '💢', '💥', '💫', '💦',
|
||||||
|
'💨', '🕳️', '💬', '🗨️', '🗯️', '💭', '💤', '👋', '🤚', '🖐️',
|
||||||
|
'✋', '🖖', '🫱', '🫲', '🫳', '🫴', '👌', '🤌', '🤏', '✌️',
|
||||||
|
'🤞', '🫰', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👇', '☝️',
|
||||||
|
'👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '🫶', '👐',
|
||||||
|
'🤲', '🙏', '✍️', '💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶',
|
||||||
|
'👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️',
|
||||||
|
'👅', '👄', '🫦', '🌍', '🌎', '🌏', '🌕', '⭐', '🌟', '✨',
|
||||||
|
'⚡', '🔥', '💧', '🌈', '☀️', '🌤️', '⛅', '🌧️', '⛈️', '🌩️',
|
||||||
|
'❄️', '☃️', '☔', '🍎', '🍊', '🍋', '🍉', '🍇', '🍓', '🍒',
|
||||||
|
'🍑', '🍍', '🥥', '🥑', '🍔', '🍕', '🌮', '🍣', '🍪', '🎂',
|
||||||
|
'☕', '🍵', '🍹', '🎉', '🎈', '🎁', '🏆', '🚀', '📷', '🎵',
|
||||||
];
|
];
|
||||||
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
||||||
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
||||||
|
readonly dropdownPeers = computed<DropdownPeerSummary[]>(() => {
|
||||||
|
const connectedPeers = this.session.peers();
|
||||||
|
const connectedPeerIds = new Set(connectedPeers.map((peer) => peer.id));
|
||||||
|
const knownOnlyPeers = this.knownPeers()
|
||||||
|
.filter((peer) => !connectedPeerIds.has(peer.id))
|
||||||
|
.sort((left, right) => left.displayName.localeCompare(right.displayName))
|
||||||
|
.map<DropdownPeerSummary>((peer) => ({
|
||||||
|
id: peer.id,
|
||||||
|
username: peer.id,
|
||||||
|
displayName: peer.displayName,
|
||||||
|
connectionState: 'disconnected',
|
||||||
|
channelState: 'closed',
|
||||||
|
knownOnly: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...connectedPeers.map<DropdownPeerSummary>((peer) => ({ ...peer, knownOnly: false })),
|
||||||
|
...knownOnlyPeers,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
readonly displayedPeer = computed<DropdownPeerSummary | null>(() => {
|
||||||
|
const selectedPeerId = this.peerId();
|
||||||
|
const peers = this.dropdownPeers();
|
||||||
|
|
||||||
|
return (selectedPeerId ? peers.find((peer) => peer.id === selectedPeerId) ?? null : null) ?? peers[0] ?? null;
|
||||||
|
});
|
||||||
readonly currentUser = computed(() => this.session.currentUser());
|
readonly currentUser = computed(() => this.session.currentUser());
|
||||||
readonly incomingVoiceCallPeer = computed(() => {
|
readonly callModalPeerId = computed(() =>
|
||||||
const peerId = this.session.incomingVoiceCallPeerId();
|
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;
|
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
||||||
});
|
});
|
||||||
|
readonly callChoicePeer = computed(() => {
|
||||||
|
const peerId = this.callChoicePeerId();
|
||||||
|
|
||||||
|
return peerId ? this.dropdownPeers().find((peer) => peer.id === peerId) ?? null : null;
|
||||||
|
});
|
||||||
readonly conversation = computed(() =>
|
readonly conversation = computed(() =>
|
||||||
this.session
|
this.session
|
||||||
.messages()
|
.messages()
|
||||||
.filter((entry) => entry.peerId === this.peerId()),
|
.filter((entry) => entry.peerId === this.peerId()),
|
||||||
);
|
);
|
||||||
readonly remoteVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.peerId()));
|
readonly lastIncomingReceiveMetric = computed(() => {
|
||||||
|
const metric = this.session.lastIncomingReceiveMetric();
|
||||||
|
|
||||||
|
return metric?.peerId === this.peerId() ? metric : null;
|
||||||
|
});
|
||||||
readonly remoteCallAudioStream = computed(() =>
|
readonly remoteCallAudioStream = computed(() =>
|
||||||
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''),
|
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
|
||||||
);
|
|
||||||
readonly remoteVideoModalVisible = computed(
|
|
||||||
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
|
|
||||||
);
|
);
|
||||||
|
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'>(() => {
|
readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => {
|
||||||
const peerId = this.peerId();
|
const peerId = this.peerId();
|
||||||
|
|
||||||
@@ -145,14 +276,68 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
void this.router.navigateByUrl('/');
|
void this.router.navigateByUrl('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
void this.speechTranscriber.preload().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const currentUserId = this.currentUser()?.id ?? null;
|
||||||
|
this.knownPeers.set(this.readKnownPeers(currentUserId));
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const connectedPeers = this.session.peers();
|
||||||
|
|
||||||
|
if (connectedPeers.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mergeKnownPeers(
|
||||||
|
connectedPeers.map((peer) => ({ id: peer.id, displayName: peer.displayName })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const currentUserId = this.currentUser()?.id ?? null;
|
||||||
|
const knownPeersFromMessages = this.session.messages()
|
||||||
|
.filter((entry) => entry.direction !== 'system' && entry.kind !== 'system')
|
||||||
|
.map((entry) => ({
|
||||||
|
id: entry.peerId,
|
||||||
|
displayName: entry.direction === 'incoming' && entry.authorLabel !== 'You'
|
||||||
|
? entry.authorLabel
|
||||||
|
: this.findKnownPeerDisplayName(entry.peerId) ?? entry.peerId,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!currentUserId || knownPeersFromMessages.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.mergeKnownPeers(knownPeersFromMessages);
|
||||||
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const peerId = this.peerId();
|
const peerId = this.peerId();
|
||||||
|
const hasLivePeer = !!this.peer();
|
||||||
|
|
||||||
if (!peerId) {
|
if (!peerId) {
|
||||||
|
this.lastAutoConnectedPeerId = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.session.selectPeer(peerId);
|
this.session.selectPeer(peerId);
|
||||||
|
|
||||||
|
if (!hasLivePeer) {
|
||||||
|
if (this.lastAutoConnectedPeerId === peerId) {
|
||||||
|
this.lastAutoConnectedPeerId = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.lastAutoConnectedPeerId === peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastAutoConnectedPeerId = peerId;
|
||||||
void this.session.connectToPeer(peerId);
|
void this.session.connectToPeer(peerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,6 +345,32 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.remoteCallAudioStream();
|
this.remoteCallAudioStream();
|
||||||
this.syncCallAudioSource();
|
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 {
|
ngOnDestroy(): void {
|
||||||
@@ -171,12 +382,12 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
async ensureConnection(): Promise<void> {
|
async ensureConnection(): Promise<void> {
|
||||||
const peerId = this.peerId();
|
const peerId = this.peerId();
|
||||||
|
|
||||||
if (!peerId) {
|
if (!peerId || !this.peer()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.session.selectPeer(peerId);
|
this.session.selectPeer(peerId);
|
||||||
await this.session.connectToPeer(peerId);
|
await this.session.reconnectToPeer(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(): Promise<void> {
|
async sendMessage(): Promise<void> {
|
||||||
@@ -265,6 +476,29 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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> {
|
async sendFile(peerId: string, input: HTMLInputElement): Promise<void> {
|
||||||
const file = input.files?.item(0);
|
const file = input.files?.item(0);
|
||||||
|
|
||||||
@@ -436,9 +670,21 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
await this.session.deleteMessage(entry);
|
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> {
|
async deleteConversation(peerId: string, event?: Event): Promise<void> {
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
await this.session.deleteConversation(peerId);
|
await this.session.deleteConversation(peerId);
|
||||||
|
this.removeKnownPeer(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleForwardMenu(entry: ChatEntry, event?: Event): void {
|
toggleForwardMenu(entry: ChatEntry, event?: Event): void {
|
||||||
@@ -464,6 +710,33 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
|
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
togglePeerDropdown(): void {
|
||||||
|
if (this.dropdownPeers().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> {
|
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
|
||||||
if (!targetPeerId) {
|
if (!targetPeerId) {
|
||||||
return;
|
return;
|
||||||
@@ -474,17 +747,14 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.forwardingEntryId.set(null);
|
this.forwardingEntryId.set(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleCameraStream(peerId: string): Promise<void> {
|
async sendGeneratedImage(entry: ChatEntry): Promise<void> {
|
||||||
if (this.session.isStreamingCameraToPeer(peerId)) {
|
const peerId = this.peerId();
|
||||||
await this.session.stopCameraStream(peerId);
|
|
||||||
|
if (!peerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.session.startCameraStream(peerId);
|
await this.session.sendGeneratedImageToPeer(entry, peerId);
|
||||||
}
|
|
||||||
|
|
||||||
async startVoiceCall(peerId: string): Promise<void> {
|
|
||||||
await this.session.startVoiceCall(peerId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async endVoiceCall(peerId: string): Promise<void> {
|
async endVoiceCall(peerId: string): Promise<void> {
|
||||||
@@ -496,10 +766,6 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (peerId !== this.peerId()) {
|
|
||||||
await this.router.navigate(['/chat', peerId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.session.acceptVoiceCall(peerId);
|
await this.session.acceptVoiceCall(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,7 +778,19 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isImageEntry(entry: ChatEntry): boolean {
|
isImageEntry(entry: ChatEntry): boolean {
|
||||||
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
return entry.kind === 'file' && !!this.imageDisplayUrl(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
isGeneratedImageEntry(entry: ChatEntry): boolean {
|
||||||
|
return this.isImageEntry(entry) && entry.generatedByAi === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageDisplayUrl(entry: ChatEntry): string | null {
|
||||||
|
if (entry.kind !== 'file' || !(entry.fileMimeType?.startsWith('image/') ?? false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.previewDownloadUrl ?? entry.downloadUrl ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
isVideoEntry(entry: ChatEntry): boolean {
|
isVideoEntry(entry: ChatEntry): boolean {
|
||||||
@@ -537,6 +815,23 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasDocumentPreviewImage(entry: ChatEntry): boolean {
|
||||||
|
return (
|
||||||
|
entry.kind === 'file' &&
|
||||||
|
!(entry.fileMimeType?.startsWith('image/') ?? false) &&
|
||||||
|
!!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 {
|
isPeerTyping(peerId: string): boolean {
|
||||||
return this.session.typingPeerIds().includes(peerId);
|
return this.session.typingPeerIds().includes(peerId);
|
||||||
}
|
}
|
||||||
@@ -545,6 +840,10 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
return this.session.unreadPeerIds().includes(peerId);
|
return this.session.unreadPeerIds().includes(peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPendingOutgoingEntry(entry: ChatEntry): boolean {
|
||||||
|
return entry.direction === 'outgoing' && entry.deliveryState === 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
|
indicatorTone(state: ConnectionState): 'ok' | 'connecting' | 'offline' {
|
||||||
if (state === 'connected') {
|
if (state === 'connected') {
|
||||||
return 'ok';
|
return 'ok';
|
||||||
@@ -558,23 +857,7 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canReconnectWebRtc(): boolean {
|
canReconnectWebRtc(): boolean {
|
||||||
return this.indicatorTone(this.webRtcState()) === 'offline';
|
return !!this.peerId() && !!this.peer() && this.indicatorTone(this.webRtcState()) !== 'ok';
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchPeer(peerId: string): Promise<void> {
|
async switchPeer(peerId: string): Promise<void> {
|
||||||
@@ -585,11 +868,163 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
await this.stopDictation(true);
|
await this.stopDictation(true);
|
||||||
this.stopVoiceRecording(true);
|
this.stopVoiceRecording(true);
|
||||||
this.forwardingEntryId.set(null);
|
this.forwardingEntryId.set(null);
|
||||||
|
this.callChoicePeerId.set(null);
|
||||||
|
this.conversationModalOpen.set(false);
|
||||||
|
this.peerDropdownOpen.set(false);
|
||||||
this.emojiPickerOpen.set(false);
|
this.emojiPickerOpen.set(false);
|
||||||
this.session.selectPeer(peerId);
|
|
||||||
await this.router.navigate(['/chat', peerId]);
|
await this.router.navigate(['/chat', peerId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mergeKnownPeers(peers: Array<{ id: string; displayName?: string }>): void {
|
||||||
|
const currentUserId = this.currentUser()?.id;
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKnownPeers = untracked(this.knownPeers);
|
||||||
|
const nextPeers = this.normalizeKnownPeers([
|
||||||
|
...currentKnownPeers,
|
||||||
|
...peers,
|
||||||
|
], currentUserId);
|
||||||
|
|
||||||
|
if (this.areKnownPeersEqual(nextPeers, currentKnownPeers)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.knownPeers.set(nextPeers);
|
||||||
|
this.writeKnownPeers(currentUserId, nextPeers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeKnownPeer(peerId: string): void {
|
||||||
|
const currentUserId = this.currentUser()?.id;
|
||||||
|
|
||||||
|
if (!currentUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentKnownPeers = untracked(this.knownPeers);
|
||||||
|
const nextPeers = currentKnownPeers.filter((knownPeer) => knownPeer.id !== peerId);
|
||||||
|
|
||||||
|
if (nextPeers.length === currentKnownPeers.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.knownPeers.set(nextPeers);
|
||||||
|
this.writeKnownPeers(currentUserId, nextPeers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readKnownPeers(currentUserId: string | null): KnownPeerSummary[] {
|
||||||
|
if (!currentUserId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedValue = this.readStorage(this.knownPeersStorageKey(currentUserId));
|
||||||
|
|
||||||
|
if (!storedValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedValue = JSON.parse(storedValue);
|
||||||
|
|
||||||
|
if (!Array.isArray(parsedValue)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalizeKnownPeers(parsedValue, currentUserId);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeKnownPeers(currentUserId: string, peers: KnownPeerSummary[]): void {
|
||||||
|
const storageKey = this.knownPeersStorageKey(currentUserId);
|
||||||
|
|
||||||
|
if (peers.length === 0) {
|
||||||
|
this.removeStorage(storageKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.writeStorage(storageKey, JSON.stringify(peers));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeKnownPeers(peers: unknown[], currentUserId: string): KnownPeerSummary[] {
|
||||||
|
const peerMap = new Map<string, KnownPeerSummary>();
|
||||||
|
|
||||||
|
for (const peer of peers) {
|
||||||
|
if (typeof peer === 'string') {
|
||||||
|
const id = peer.trim();
|
||||||
|
|
||||||
|
if (!id || id === currentUserId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
peerMap.set(id, { id, displayName: id });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!peer || typeof peer !== 'object') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = peer as Partial<KnownPeerSummary>;
|
||||||
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
||||||
|
|
||||||
|
if (!id || id === currentUserId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = typeof candidate.displayName === 'string' && candidate.displayName.trim()
|
||||||
|
? candidate.displayName.trim()
|
||||||
|
: peerMap.get(id)?.displayName ?? id;
|
||||||
|
|
||||||
|
peerMap.set(id, { id, displayName });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(peerMap.values())
|
||||||
|
.sort((left, right) => left.displayName.localeCompare(right.displayName));
|
||||||
|
}
|
||||||
|
|
||||||
|
private areKnownPeersEqual(left: KnownPeerSummary[], right: KnownPeerSummary[]): boolean {
|
||||||
|
return left.length === right.length
|
||||||
|
&& left.every((peer, index) =>
|
||||||
|
peer.id === right[index]?.id && peer.displayName === right[index]?.displayName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findKnownPeerDisplayName(peerId: string): string | null {
|
||||||
|
return untracked(this.knownPeers).find((peer) => peer.id === peerId)?.displayName ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private knownPeersStorageKey(currentUserId: string): string {
|
||||||
|
return `${ChatPageComponent.knownPeersStoragePrefix}.${currentUserId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readStorage(key: string): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeStorage(key: string, value: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors in private browsing modes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeStorage(key: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors in private browsing modes.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private stopVoiceRecording(discard: boolean): void {
|
private stopVoiceRecording(discard: boolean): void {
|
||||||
const recorder = this.voiceRecorder;
|
const recorder = this.voiceRecorder;
|
||||||
|
|
||||||
@@ -686,16 +1121,16 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
|
|
||||||
private async transcribeDictation(blob: Blob, textarea: HTMLTextAreaElement, applyToken: number): Promise<void> {
|
private async transcribeDictation(blob: Blob, textarea: HTMLTextAreaElement, applyToken: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const transcript = await this.session.requestSpeechTranscription(blob);
|
const transcript = await this.speechTranscriber.transcribe(blob);
|
||||||
|
|
||||||
if (applyToken !== this.dictationApplyToken) {
|
if (applyToken !== this.dictationApplyToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.applyDictatedText(this.mergeDictatedText(this.dictationBaseText, transcript), textarea);
|
this.applyDictatedText(this.mergeDictatedText(this.dictationBaseText, transcript), textarea);
|
||||||
} catch {
|
} catch (error) {
|
||||||
if (applyToken === this.dictationApplyToken) {
|
if (applyToken === this.dictationApplyToken) {
|
||||||
this.session.error.set('Dictation transcription failed.');
|
this.session.error.set(error instanceof Error ? error.message : 'Dictation transcription failed.');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (applyToken === this.dictationApplyToken) {
|
if (applyToken === this.dictationApplyToken) {
|
||||||
@@ -764,4 +1199,36 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
audio.pause();
|
audio.pause();
|
||||||
audio.srcObject = null;
|
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
@@ -53,22 +53,11 @@
|
|||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h3 mb-1">Connect to the signaling backend</h2>
|
<h2 class="h3 mb-1">Connect to the signaling backend</h2>
|
||||||
<p class="text-secondary mb-0">Use the Fastify server for authentication and peer discovery.</p>
|
<p class="text-secondary mb-0">Use the current browser host for authentication and peer discovery.</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge rounded-pill text-bg-dark">Angular + Bootstrap</span>
|
<span class="badge rounded-pill text-bg-dark">Angular + Bootstrap</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label" for="serverUrl">Backend URL</label>
|
|
||||||
<input
|
|
||||||
id="serverUrl"
|
|
||||||
name="serverUrl"
|
|
||||||
class="form-control form-control-lg"
|
|
||||||
[(ngModel)]="serverUrl"
|
|
||||||
placeholder="http://localhost:3000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group mb-4 w-100" role="group" aria-label="Authentication mode">
|
<div class="btn-group mb-4 w-100" role="group" aria-label="Authentication mode">
|
||||||
<button
|
<button
|
||||||
class="btn"
|
class="btn"
|
||||||
@@ -177,49 +166,123 @@
|
|||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<section class="row g-4 align-items-stretch">
|
<section class="row g-4 align-items-stretch">
|
||||||
<div class="col-lg-5">
|
<div class="col-12">
|
||||||
<div class="panel p-4 h-100">
|
<div class="panel p-4 h-100">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="h3 mb-1">Connection settings</h2>
|
<h2 class="h3 mb-1">Account tools</h2>
|
||||||
<p class="text-secondary mb-0">Manage the backend endpoint used for auth and signaling.</p>
|
<p class="text-secondary mb-0">This session uses the current browser host for auth and signaling.</p>
|
||||||
</div>
|
</div>
|
||||||
@if (session.isApprovalAdmin()) {
|
@if (session.isApprovalAdmin()) {
|
||||||
<a class="btn btn-sm btn-outline-light" routerLink="/approvals">Approvals</a>
|
<a class="btn btn-sm btn-outline-light" routerLink="/approvals">Approvals</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!embeddedMode) {
|
<div class="small status-pill mb-3">{{ session.status() }}</div>
|
||||||
<label class="form-label" for="connectedServerUrl">Backend URL</label>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input
|
|
||||||
id="connectedServerUrl"
|
|
||||||
class="form-control"
|
|
||||||
[(ngModel)]="serverUrl"
|
|
||||||
(blur)="applyServerUrl()"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="applyServerUrl()">Apply</button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div class="empty-state p-4 text-center text-secondary">
|
|
||||||
Backend settings are managed by the native app in embedded mode.
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="small status-pill mt-3">{{ session.status() }}</div>
|
|
||||||
|
|
||||||
@if (session.error()) {
|
@if (session.error()) {
|
||||||
<div class="alert alert-danger mt-4 mb-0">{{ session.error() }}</div>
|
<div class="alert alert-danger mb-3">{{ session.error() }}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (session.notice()) {
|
@if (session.notice()) {
|
||||||
<div class="alert alert-success mt-4 mb-0">{{ session.notice() }}</div>
|
<div class="alert alert-success mb-4">{{ session.notice() }}</div>
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-lg-7">
|
<section class="access-key-panel mb-4">
|
||||||
<div class="panel p-4 h-100">
|
<div class="dictation-language-panel">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">Dictation language</h3>
|
||||||
|
<p class="small text-secondary mb-0">
|
||||||
|
Speech input and text output use the same selected language.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dictation-language-select-shell mt-3">
|
||||||
|
<label class="form-label small mb-2" for="dictationLanguage">Language</label>
|
||||||
|
<select
|
||||||
|
id="dictationLanguage"
|
||||||
|
class="form-select"
|
||||||
|
[ngModel]="session.dictationLanguage()"
|
||||||
|
(ngModelChange)="setDictationLanguage($event)"
|
||||||
|
>
|
||||||
|
@for (option of dictationLanguageOptions; track option.value) {
|
||||||
|
<option [ngValue]="option.value">{{ option.label }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="access-key-panel mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">Notifications</h3>
|
||||||
|
<p class="small text-secondary mb-0">Play a sound when any incoming message or file arrives.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch m-0">
|
||||||
|
<input
|
||||||
|
id="incomingMessageSoundEnabled"
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
[ngModel]="session.incomingMessageSoundEnabled()"
|
||||||
|
(ngModelChange)="setIncomingMessageSound($event)"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label small" for="incomingMessageSoundEnabled">
|
||||||
|
{{ session.incomingMessageSoundEnabled() ? 'On' : 'Off' }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="access-key-panel user-search-panel mb-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 class="h5 mb-1">Find people</h3>
|
||||||
|
<p class="small text-secondary mb-0">Search approved accounts and add them to the chat peer list.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-light"
|
||||||
|
type="button"
|
||||||
|
[disabled]="loadingKnownUsers()"
|
||||||
|
(click)="reloadKnownUsers()"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="user-search-shell">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="userSearch"
|
||||||
|
placeholder="Search by display name or username"
|
||||||
|
/>
|
||||||
|
|
||||||
|
@if (knownUsersError()) {
|
||||||
|
<div class="alert alert-danger mt-3 mb-0">{{ knownUsersError() }}</div>
|
||||||
|
} @else if (loadingKnownUsers()) {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary mt-3">Loading users...</div>
|
||||||
|
} @else if (filteredKnownUsers().length === 0) {
|
||||||
|
<div class="empty-state p-3 text-center text-secondary mt-3">No matching users found.</div>
|
||||||
|
} @else {
|
||||||
|
<div class="user-search-results mt-3">
|
||||||
|
@for (user of filteredKnownUsers(); track user.id) {
|
||||||
|
<button class="user-search-result" type="button" (click)="addKnownPeer(user)">
|
||||||
|
<span class="user-search-result-copy">
|
||||||
|
<span class="fw-semibold">{{ user.displayName }}</span>
|
||||||
|
<span class="small text-secondary">@{{ user.username }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="user-search-result-action">Add</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (knownUsersNotice()) {
|
||||||
|
<div class="small text-secondary mt-3">{{ knownUsersNotice() }}</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="access-key-panel">
|
<section class="access-key-panel">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -8,144 +8,130 @@
|
|||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Panel primitives ───────────────────────────────────────────────────── */
|
||||||
.hero-panel,
|
.hero-panel,
|
||||||
.panel,
|
.panel,
|
||||||
.session-card,
|
.session-card,
|
||||||
.empty-state {
|
.empty-state {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
background: var(--panel-background);
|
background: var(--panel-background);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(20px);
|
||||||
box-shadow: 0 20px 60px var(--shadow-color);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
box-shadow:
|
||||||
|
0 24px 64px var(--shadow-color),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-panel {
|
.hero-panel { border-radius: 2rem; }
|
||||||
border-radius: 2rem;
|
.panel { border-radius: 1.75rem; }
|
||||||
}
|
.session-card { border-radius: 1.5rem; }
|
||||||
|
|
||||||
.panel,
|
|
||||||
.session-card {
|
|
||||||
border-radius: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-muted {
|
.panel-muted {
|
||||||
background: var(--panel-alt-background);
|
background: var(--panel-alt-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-copy {
|
/* ── Hero header ────────────────────────────────────────────────────────── */
|
||||||
max-width: 52rem;
|
.hero-copy { max-width: 52rem; }
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.45rem 0.85rem;
|
padding: 0.4rem 0.9rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 0.75rem;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.18em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-size: 0.72rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
font-family: var(--font-mono);
|
||||||
color: var(--accent-color);
|
color: var(--accent-color);
|
||||||
background: var(--accent-color-soft);
|
background: var(--accent-color-soft);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--accent-color) 25%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.eyebrow::before {
|
||||||
|
content: '';
|
||||||
|
width: 0.45rem;
|
||||||
|
height: 0.45rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-color);
|
||||||
|
animation: glow-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Theme toggle ───────────────────────────────────────────────────────── */
|
||||||
.theme-toggle {
|
.theme-toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.5rem;
|
||||||
min-width: 7.5rem;
|
min-width: 7.5rem;
|
||||||
height: 3rem;
|
height: 2.75rem;
|
||||||
padding: 0 0.95rem;
|
padding: 0 1rem;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
background: var(--panel-soft-background);
|
background: rgba(255, 255, 255, 0.04);
|
||||||
font-size: 0.95rem;
|
font-size: 0.88rem;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
text-transform: capitalize;
|
font-family: var(--font-mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease;
|
transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle-icon {
|
.theme-toggle-icon { font-size: 1.15rem; }
|
||||||
font-size: 1.25rem;
|
.theme-toggle-label { letter-spacing: 0.06em; }
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle-label {
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle:hover,
|
.theme-toggle:hover,
|
||||||
.theme-toggle:focus-visible {
|
.theme-toggle:focus-visible {
|
||||||
border-color: color-mix(in srgb, var(--accent-color) 35%, var(--surface-border));
|
border-color: color-mix(in srgb, var(--accent-color) 40%, transparent);
|
||||||
background: var(--surface-hover-background);
|
background: var(--surface-hover-background);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-color-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Session card ───────────────────────────────────────────────────────── */
|
||||||
.session-card { min-width: min(100%, 18rem); }
|
.session-card { min-width: min(100%, 18rem); }
|
||||||
|
|
||||||
.status-pill {
|
.status-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 0.45rem 0.8rem;
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.38rem 0.85rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--badge-background);
|
background: var(--badge-background);
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
|
||||||
background: var(--accent-gradient-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-key-panel {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: var(--panel-soft-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-key-card {
|
|
||||||
border-radius: 0.9rem;
|
|
||||||
border: 1px solid var(--surface-border-soft);
|
border: 1px solid var(--surface-border-soft);
|
||||||
background: var(--surface-background);
|
font-family: var(--font-mono);
|
||||||
}
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
.empty-state {
|
text-transform: uppercase;
|
||||||
border-radius: 1.25rem;
|
color: var(--page-text-soft);
|
||||||
}
|
|
||||||
|
|
||||||
.info-rail article {
|
|
||||||
padding: 1rem 1.1rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
background: var(--panel-soft-background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Auth form card ─────────────────────────────────────────────────────── */
|
||||||
.form-control,
|
.form-control,
|
||||||
.form-control:focus {
|
.form-control:focus,
|
||||||
|
.form-select,
|
||||||
|
.form-select:focus {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
background-color: var(--input-background);
|
background-color: var(--input-background);
|
||||||
border-color: var(--input-border);
|
border-color: var(--input-border);
|
||||||
|
border-radius: 0.85rem;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control::placeholder {
|
.form-control:focus,
|
||||||
color: var(--placeholder-color);
|
.form-select:focus {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 55%, transparent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-color-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-control::placeholder { color: var(--placeholder-color); }
|
||||||
|
|
||||||
.form-label,
|
.form-label,
|
||||||
.h3,
|
.h3, .h4, .h5,
|
||||||
.h4,
|
|
||||||
.display-5,
|
|
||||||
.fw-semibold,
|
.fw-semibold,
|
||||||
.fw-bold {
|
.fw-bold {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
@@ -156,3 +142,134 @@
|
|||||||
.small {
|
.small {
|
||||||
color: var(--page-text-muted) !important;
|
color: var(--page-text-muted) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Auth tab toggle */
|
||||||
|
.btn-group .btn {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn-primary {
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 12px rgba(13, 148, 136, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn-outline-primary {
|
||||||
|
color: var(--page-text-muted);
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group .btn-outline-primary:hover {
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
color: var(--page-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Access key / settings panels ──────────────────────────────────────── */
|
||||||
|
.access-key-panel {
|
||||||
|
padding: 1.1rem 1.25rem;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
transition: border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-key-panel:hover {
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-key-card {
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
background: var(--surface-background);
|
||||||
|
transition: border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-key-card:hover {
|
||||||
|
border-color: var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── User search ────────────────────────────────────────────────────────── */
|
||||||
|
.user-search-panel,
|
||||||
|
.user-search-shell,
|
||||||
|
.dictation-language-panel,
|
||||||
|
.dictation-language-select-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--surface-background);
|
||||||
|
text-align: left;
|
||||||
|
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-result:hover,
|
||||||
|
.user-search-result:focus-visible {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 38%, transparent);
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-result-copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.08rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-search-result-action {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── How-it-works info rail ─────────────────────────────────────────────── */
|
||||||
|
.info-rail article {
|
||||||
|
padding: 1rem 1.15rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-soft-background);
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-rail article::before {
|
||||||
|
content: attr(data-step);
|
||||||
|
position: absolute;
|
||||||
|
top: 0.65rem;
|
||||||
|
right: 0.9rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent-color);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ────────────────────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
border: 1px dashed var(--surface-border);
|
||||||
|
background: var(--panel-soft-background) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Component, effect, inject, signal } from '@angular/core';
|
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { ChatSessionService } from './chat-session.service';
|
import { ChatSessionService } from './chat-session.service';
|
||||||
import type { AdminUserSummary } from './models';
|
import type { AdminUserSummary, DictationLanguage, UserProfile } from './models';
|
||||||
import { ThemeService } from './theme.service';
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -19,19 +19,41 @@ export class HomePageComponent {
|
|||||||
authMode: 'login' | 'register' = 'login';
|
authMode: 'login' | 'register' = 'login';
|
||||||
readonly embeddedMode =
|
readonly embeddedMode =
|
||||||
typeof window !== 'undefined' && window.localStorage.getItem('privatechat.embeddedMode') === '1';
|
typeof window !== 'undefined' && window.localStorage.getItem('privatechat.embeddedMode') === '1';
|
||||||
serverUrl = '';
|
|
||||||
displayName = '';
|
displayName = '';
|
||||||
username = '';
|
username = '';
|
||||||
password = '';
|
password = '';
|
||||||
accessKeyLabel = '';
|
accessKeyLabel = '';
|
||||||
|
userSearch = '';
|
||||||
readonly adminUsers = signal<AdminUserSummary[]>([]);
|
readonly adminUsers = signal<AdminUserSummary[]>([]);
|
||||||
|
readonly knownUsers = signal<UserProfile[]>([]);
|
||||||
|
readonly loadingKnownUsers = signal(false);
|
||||||
|
readonly knownUsersError = signal<string | null>(null);
|
||||||
|
readonly knownUsersNotice = signal<string | null>(null);
|
||||||
readonly loadingAdminUsers = signal(false);
|
readonly loadingAdminUsers = signal(false);
|
||||||
readonly deletingUserId = signal<string | null>(null);
|
readonly deletingUserId = signal<string | null>(null);
|
||||||
readonly adminUsersError = signal<string | null>(null);
|
readonly adminUsersError = signal<string | null>(null);
|
||||||
|
readonly dictationLanguageOptions: Array<{ value: DictationLanguage; label: string }> = [
|
||||||
|
{ value: 'en', label: 'English' },
|
||||||
|
{ value: 'fr', label: 'French' },
|
||||||
|
{ value: 'es', label: 'Spanish' },
|
||||||
|
];
|
||||||
|
readonly filteredKnownUsers = computed(() => {
|
||||||
|
const query = this.userSearch.trim().toLowerCase();
|
||||||
|
const users = this.knownUsers();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return users.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
return users
|
||||||
|
.filter((user) =>
|
||||||
|
user.displayName.toLowerCase().includes(query)
|
||||||
|
|| user.username.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
|
.slice(0, 8);
|
||||||
|
});
|
||||||
|
|
||||||
constructor(readonly session: ChatSessionService) {
|
constructor(readonly session: ChatSessionService) {
|
||||||
this.serverUrl = session.serverUrl();
|
|
||||||
|
|
||||||
if (this.embeddedMode) {
|
if (this.embeddedMode) {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const currentUser = this.session.currentUser();
|
const currentUser = this.session.currentUser();
|
||||||
@@ -45,6 +67,21 @@ export class HomePageComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
const currentUser = this.session.currentUser();
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
this.knownUsers.set([]);
|
||||||
|
this.loadingKnownUsers.set(false);
|
||||||
|
this.knownUsersError.set(null);
|
||||||
|
this.knownUsersNotice.set(null);
|
||||||
|
this.userSearch = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.reloadKnownUsers();
|
||||||
|
});
|
||||||
|
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const currentUser = this.session.currentUser();
|
const currentUser = this.session.currentUser();
|
||||||
|
|
||||||
@@ -60,8 +97,6 @@ export class HomePageComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async submitAuth(): Promise<void> {
|
async submitAuth(): Promise<void> {
|
||||||
this.applyServerUrl();
|
|
||||||
|
|
||||||
if (this.authMode === 'register') {
|
if (this.authMode === 'register') {
|
||||||
const authenticated = await this.session.register(this.username, this.password, this.displayName);
|
const authenticated = await this.session.register(this.username, this.password, this.displayName);
|
||||||
this.password = '';
|
this.password = '';
|
||||||
@@ -76,10 +111,6 @@ export class HomePageComponent {
|
|||||||
await this.session.login(this.username, this.password);
|
await this.session.login(this.username, this.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
applyServerUrl(): void {
|
|
||||||
this.session.setServerUrl(this.serverUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout(): Promise<void> {
|
async logout(): Promise<void> {
|
||||||
await this.session.logout();
|
await this.session.logout();
|
||||||
this.authMode = 'login';
|
this.authMode = 'login';
|
||||||
@@ -88,7 +119,6 @@ export class HomePageComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginWithAccessKey(): Promise<void> {
|
async loginWithAccessKey(): Promise<void> {
|
||||||
this.applyServerUrl();
|
|
||||||
await this.session.loginWithAccessKey(this.username);
|
await this.session.loginWithAccessKey(this.username);
|
||||||
this.password = '';
|
this.password = '';
|
||||||
}
|
}
|
||||||
@@ -98,6 +128,27 @@ export class HomePageComponent {
|
|||||||
this.accessKeyLabel = '';
|
this.accessKeyLabel = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reloadKnownUsers(): Promise<void> {
|
||||||
|
this.loadingKnownUsers.set(true);
|
||||||
|
this.knownUsersError.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.knownUsers.set(await this.session.loadKnownUsers());
|
||||||
|
} catch (error) {
|
||||||
|
this.knownUsersError.set(
|
||||||
|
error instanceof Error ? error.message : 'Could not load users.',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
this.loadingKnownUsers.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addKnownPeer(user: UserProfile): void {
|
||||||
|
this.session.rememberKnownPeer(user);
|
||||||
|
this.knownUsersNotice.set(`${user.displayName} was added to your chat peer list.`);
|
||||||
|
this.userSearch = user.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
async reloadAdminUsers(): Promise<void> {
|
async reloadAdminUsers(): Promise<void> {
|
||||||
this.loadingAdminUsers.set(true);
|
this.loadingAdminUsers.set(true);
|
||||||
this.adminUsersError.set(null);
|
this.adminUsersError.set(null);
|
||||||
@@ -152,4 +203,12 @@ export class HomePageComponent {
|
|||||||
cycleTheme(): void {
|
cycleTheme(): void {
|
||||||
this.theme.cycleMode();
|
this.theme.cycleMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIncomingMessageSound(enabled: boolean): void {
|
||||||
|
this.session.setIncomingMessageSoundEnabled(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDictationLanguage(language: string): void {
|
||||||
|
this.session.setDictationLanguage(language as DictationLanguage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ export interface AccessKeySummary {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DeliveryState = 'pending' | 'sent';
|
||||||
|
|
||||||
export interface RegistrationOptionsResponse {
|
export interface RegistrationOptionsResponse {
|
||||||
rp: PublicKeyCredentialRpEntity;
|
rp: PublicKeyCredentialRpEntity;
|
||||||
user: {
|
user: {
|
||||||
@@ -97,6 +99,8 @@ export interface ChatEntry {
|
|||||||
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
authorLabel: string;
|
authorLabel: string;
|
||||||
|
deliveryState?: DeliveryState;
|
||||||
|
generatedByAi?: boolean;
|
||||||
showSpinner?: boolean;
|
showSpinner?: boolean;
|
||||||
text?: string;
|
text?: string;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
@@ -104,8 +108,13 @@ export interface ChatEntry {
|
|||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
fileMimeType?: string;
|
fileMimeType?: string;
|
||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
|
previewMimeType?: string;
|
||||||
|
previewDownloadUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CallMode = 'audio' | 'video';
|
||||||
|
export type DictationLanguage = 'en' | 'fr' | 'es';
|
||||||
|
|
||||||
export type SignalPayload =
|
export type SignalPayload =
|
||||||
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
||||||
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
||||||
@@ -130,16 +139,6 @@ export type ServerEvent =
|
|||||||
peerId: string;
|
peerId: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: 'speech-transcribed';
|
|
||||||
requestId: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'speech-transcription-error';
|
|
||||||
requestId: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
@@ -179,12 +178,9 @@ export type DataEnvelope =
|
|||||||
type: 'typing';
|
type: 'typing';
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: 'camera-state';
|
|
||||||
active: boolean;
|
|
||||||
}
|
|
||||||
| {
|
| {
|
||||||
type: 'voice-call-offer';
|
type: 'voice-call-offer';
|
||||||
|
mode: CallMode;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'voice-call-response';
|
type: 'voice-call-response';
|
||||||
|
|||||||
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,7 +5,7 @@
|
|||||||
<title>PrivateChat</title>
|
<title>PrivateChat</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="theme-color" content="#08111d">
|
<meta name="theme-color" content="#030712">
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-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-status-bar-style" content="black-translucent">
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
<script src="env.js"></script>
|
<script src="env.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,108 +1,104 @@
|
|||||||
@use 'bootstrap/scss/bootstrap';
|
@use 'bootstrap/scss/bootstrap';
|
||||||
|
|
||||||
|
/* ── Design Tokens ───────────────────────────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
--page-text: #142236;
|
/* Text */
|
||||||
--page-text-muted: rgba(39, 63, 91, 0.72);
|
--page-text: #e2e8f4;
|
||||||
--page-text-soft: rgba(39, 63, 91, 0.82);
|
--page-text-muted: rgba(180, 196, 224, 0.68);
|
||||||
--page-background:
|
--page-text-soft: rgba(180, 196, 224, 0.82);
|
||||||
radial-gradient(circle at top left, rgba(81, 168, 255, 0.2), transparent 30%),
|
|
||||||
radial-gradient(circle at top right, rgba(129, 244, 215, 0.22), transparent 24%),
|
|
||||||
linear-gradient(180deg, #f6fbff 0%, #e8f1fb 100%);
|
|
||||||
--panel-background: rgba(255, 255, 255, 0.82);
|
|
||||||
--panel-alt-background: rgba(241, 247, 255, 0.9);
|
|
||||||
--panel-soft-background: rgba(20, 34, 54, 0.04);
|
|
||||||
--surface-background: rgba(255, 255, 255, 0.82);
|
|
||||||
--surface-hover-background: rgba(235, 244, 255, 0.98);
|
|
||||||
--surface-border: rgba(33, 62, 94, 0.12);
|
|
||||||
--surface-border-soft: rgba(33, 62, 94, 0.08);
|
|
||||||
--input-background: rgba(255, 255, 255, 0.92);
|
|
||||||
--input-border: rgba(77, 114, 154, 0.26);
|
|
||||||
--placeholder-color: rgba(55, 83, 118, 0.52);
|
|
||||||
--accent-color: #138a7b;
|
|
||||||
--accent-color-soft: rgba(19, 138, 123, 0.1);
|
|
||||||
--accent-gradient: linear-gradient(135deg, #8df0df, #6cb6ff);
|
|
||||||
--accent-gradient-hover: linear-gradient(135deg, #a6f5e8, #86c4ff);
|
|
||||||
--link-color: #2f7cd6;
|
|
||||||
--badge-background: rgba(20, 34, 54, 0.08);
|
|
||||||
--incoming-bubble-background: #d9ebff;
|
|
||||||
--incoming-bubble-text: #183759;
|
|
||||||
--outgoing-bubble-background: #d9f5df;
|
|
||||||
--outgoing-bubble-text: #1e4d2f;
|
|
||||||
--danger-background: #d94b53;
|
|
||||||
--shadow-color: rgba(41, 73, 110, 0.14);
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
/* Page background: deep obsidian with teal/blue aurora */
|
||||||
:root:not([data-theme]) {
|
|
||||||
--page-text: #eff3ff;
|
|
||||||
--page-text-muted: rgba(231, 238, 249, 0.72);
|
|
||||||
--page-text-soft: rgba(231, 238, 249, 0.84);
|
|
||||||
--page-background:
|
|
||||||
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
|
|
||||||
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
|
|
||||||
linear-gradient(180deg, #08111d 0%, #101d31 100%);
|
|
||||||
--panel-background: rgba(9, 16, 28, 0.78);
|
|
||||||
--panel-alt-background: rgba(15, 27, 44, 0.78);
|
|
||||||
--panel-soft-background: rgba(255, 255, 255, 0.04);
|
|
||||||
--surface-background: rgba(8, 14, 23, 0.7);
|
|
||||||
--surface-hover-background: rgba(16, 30, 49, 0.92);
|
|
||||||
--surface-border: rgba(255, 255, 255, 0.12);
|
|
||||||
--surface-border-soft: rgba(255, 255, 255, 0.08);
|
|
||||||
--input-background: rgba(255, 255, 255, 0.06);
|
|
||||||
--input-border: rgba(255, 255, 255, 0.16);
|
|
||||||
--placeholder-color: rgba(239, 243, 255, 0.5);
|
|
||||||
--accent-color: #81f4d7;
|
|
||||||
--accent-color-soft: rgba(129, 244, 215, 0.1);
|
|
||||||
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
|
|
||||||
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
|
|
||||||
--link-color: #9bd5ff;
|
|
||||||
--badge-background: rgba(255, 255, 255, 0.08);
|
|
||||||
--incoming-bubble-background: #dcefff;
|
|
||||||
--incoming-bubble-text: #0f2540;
|
|
||||||
--outgoing-bubble-background: #def7dd;
|
|
||||||
--outgoing-bubble-text: #153420;
|
|
||||||
--danger-background: #d94b53;
|
|
||||||
--shadow-color: rgba(0, 0, 0, 0.28);
|
|
||||||
color-scheme: dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
|
||||||
--page-text: #eff3ff;
|
|
||||||
--page-text-muted: rgba(231, 238, 249, 0.72);
|
|
||||||
--page-text-soft: rgba(231, 238, 249, 0.84);
|
|
||||||
--page-background:
|
--page-background:
|
||||||
radial-gradient(circle at top left, rgba(129, 244, 215, 0.18), transparent 28%),
|
radial-gradient(circle at 12% 8%, rgba(13, 148, 136, 0.22), transparent 28%),
|
||||||
radial-gradient(circle at top right, rgba(85, 168, 255, 0.18), transparent 24%),
|
radial-gradient(circle at 90% 5%, rgba(59, 130, 246, 0.18), transparent 24%),
|
||||||
linear-gradient(180deg, #08111d 0%, #101d31 100%);
|
radial-gradient(circle at 50% 95%, rgba(99, 102, 241, 0.12), transparent 30%),
|
||||||
--panel-background: rgba(9, 16, 28, 0.78);
|
linear-gradient(180deg, #030712 0%, #080f1e 100%);
|
||||||
--panel-alt-background: rgba(15, 27, 44, 0.78);
|
|
||||||
--panel-soft-background: rgba(255, 255, 255, 0.04);
|
/* Panels */
|
||||||
--surface-background: rgba(8, 14, 23, 0.7);
|
--panel-background: rgba(11, 17, 32, 0.72);
|
||||||
--surface-hover-background: rgba(16, 30, 49, 0.92);
|
--panel-alt-background: rgba(15, 23, 42, 0.78);
|
||||||
--surface-border: rgba(255, 255, 255, 0.12);
|
--panel-soft-background: rgba(255, 255, 255, 0.04);
|
||||||
--surface-border-soft: rgba(255, 255, 255, 0.08);
|
|
||||||
--input-background: rgba(255, 255, 255, 0.06);
|
/* Surfaces */
|
||||||
--input-border: rgba(255, 255, 255, 0.16);
|
--surface-background: rgba(8, 14, 24, 0.65);
|
||||||
--placeholder-color: rgba(239, 243, 255, 0.5);
|
--surface-hover-background: rgba(17, 28, 50, 0.92);
|
||||||
--accent-color: #81f4d7;
|
--surface-border: rgba(255, 255, 255, 0.08);
|
||||||
--accent-color-soft: rgba(129, 244, 215, 0.1);
|
--surface-border-soft: rgba(255, 255, 255, 0.05);
|
||||||
--accent-gradient: linear-gradient(135deg, #81f4d7, #55a8ff);
|
|
||||||
--accent-gradient-hover: linear-gradient(135deg, #9bf7e0, #7abaff);
|
/* Inputs */
|
||||||
--link-color: #9bd5ff;
|
--input-background: rgba(0, 0, 0, 0.35);
|
||||||
--badge-background: rgba(255, 255, 255, 0.08);
|
--input-border: rgba(255, 255, 255, 0.12);
|
||||||
--incoming-bubble-background: #dcefff;
|
--placeholder-color: rgba(148, 168, 210, 0.45);
|
||||||
--incoming-bubble-text: #0f2540;
|
|
||||||
--outgoing-bubble-background: #def7dd;
|
/* Accent — teal-to-blue gradient */
|
||||||
--outgoing-bubble-text: #153420;
|
--accent-color: #2dd4bf;
|
||||||
--danger-background: #d94b53;
|
--accent-color-soft: rgba(45, 212, 191, 0.12);
|
||||||
--shadow-color: rgba(0, 0, 0, 0.28);
|
--accent-gradient: linear-gradient(135deg, #0d9488, #3b82f6);
|
||||||
|
--accent-gradient-hover: linear-gradient(135deg, #14b8a6, #60a5fa);
|
||||||
|
|
||||||
|
/* Link */
|
||||||
|
--link-color: #60a5fa;
|
||||||
|
|
||||||
|
/* Badges / Pills */
|
||||||
|
--badge-background: rgba(255, 255, 255, 0.07);
|
||||||
|
|
||||||
|
/* Chat bubbles */
|
||||||
|
--incoming-bubble-background: rgba(30, 58, 138, 0.22);
|
||||||
|
--incoming-bubble-border: rgba(59, 130, 246, 0.28);
|
||||||
|
--incoming-bubble-text: #dbeafe;
|
||||||
|
--outgoing-bubble-background: rgba(6, 78, 59, 0.25);
|
||||||
|
--outgoing-bubble-border: rgba(45, 212, 191, 0.30);
|
||||||
|
--outgoing-bubble-text: #d1fae5;
|
||||||
|
|
||||||
|
/* Status LEDs */
|
||||||
|
--led-ok: #10b981;
|
||||||
|
--led-connecting: #f59e0b;
|
||||||
|
--led-offline: #ef4444;
|
||||||
|
|
||||||
|
/* Shadow */
|
||||||
|
--shadow-color: rgba(0, 0, 0, 0.55);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
/* Light theme override */
|
||||||
body {
|
:root[data-theme='light'] {
|
||||||
|
--page-text: #0f172a;
|
||||||
|
--page-text-muted: rgba(30, 50, 80, 0.66);
|
||||||
|
--page-text-soft: rgba(30, 50, 80, 0.82);
|
||||||
|
--page-background:
|
||||||
|
radial-gradient(circle at 12% 8%, rgba(13, 148, 136, 0.15), transparent 28%),
|
||||||
|
radial-gradient(circle at 90% 5%, rgba(59, 130, 246, 0.12), transparent 24%),
|
||||||
|
linear-gradient(180deg, #f0fafa 0%, #e8f1fb 100%);
|
||||||
|
--panel-background: rgba(255, 255, 255, 0.82);
|
||||||
|
--panel-alt-background: rgba(241, 248, 255, 0.88);
|
||||||
|
--panel-soft-background: rgba(15, 23, 42, 0.04);
|
||||||
|
--surface-background: rgba(255, 255, 255, 0.75);
|
||||||
|
--surface-hover-background: rgba(236, 248, 255, 0.98);
|
||||||
|
--surface-border: rgba(30, 60, 100, 0.12);
|
||||||
|
--surface-border-soft: rgba(30, 60, 100, 0.07);
|
||||||
|
--input-background: rgba(255, 255, 255, 0.9);
|
||||||
|
--input-border: rgba(100, 130, 180, 0.28);
|
||||||
|
--placeholder-color: rgba(60, 90, 130, 0.48);
|
||||||
|
--accent-color: #0d9488;
|
||||||
|
--accent-color-soft: rgba(13, 148, 136, 0.1);
|
||||||
|
--incoming-bubble-background: #dbeafe;
|
||||||
|
--incoming-bubble-border: rgba(59, 130, 246, 0.28);
|
||||||
|
--incoming-bubble-text: #1e3a5f;
|
||||||
|
--outgoing-bubble-background: #d1fae5;
|
||||||
|
--outgoing-bubble-border: rgba(13, 148, 136, 0.28);
|
||||||
|
--outgoing-bubble-text: #064e3b;
|
||||||
|
--shadow-color: rgba(30, 60, 100, 0.14);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Base ────────────────────────────────────────────────────────────────── */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||||
|
|
||||||
|
html, body {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,18 +109,28 @@ body {
|
|||||||
background: var(--page-background);
|
background: var(--page-background);
|
||||||
background-attachment: fixed;
|
background-attachment: fixed;
|
||||||
transition:
|
transition:
|
||||||
background 180ms ease,
|
background 220ms ease,
|
||||||
color 180ms ease,
|
color 220ms ease,
|
||||||
border-color 180ms ease,
|
border-color 220ms ease,
|
||||||
box-shadow 180ms ease;
|
box-shadow 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button, input, textarea, select {
|
||||||
input,
|
|
||||||
textarea {
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(45, 212, 191, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bootstrap overrides ────────────────────────────────────────────────── */
|
||||||
.text-secondary {
|
.text-secondary {
|
||||||
color: var(--page-text-muted) !important;
|
color: var(--page-text-muted) !important;
|
||||||
}
|
}
|
||||||
@@ -176,3 +182,455 @@ textarea {
|
|||||||
.alert-warning {
|
.alert-warning {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Modals (shared across pages) ───────────────────────────────────────── */
|
||||||
|
.call-choice-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1240;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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(0, 0, 0, 0.65);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,
|
||||||
|
.call-choice-eyebrow,
|
||||||
|
.bubble-delivery-state {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-eyebrow {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
transition: background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-body {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-eyebrow {
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.call-choice-button:hover,
|
||||||
|
.call-choice-button:focus-visible {
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 40%, transparent);
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-action {
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-action:not(:disabled) {
|
||||||
|
color: var(--page-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-action:not(:disabled):hover,
|
||||||
|
.status-indicator-action:not(:disabled):focus-visible {
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-indicator-action:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status LEDs ─────────────────────────────────────────────────────────── */
|
||||||
|
.status-led,
|
||||||
|
.peer-tile-delete,
|
||||||
|
.bubble-spinner {
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led {
|
||||||
|
width: 0.55rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 0 6px currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-ok {
|
||||||
|
color: var(--led-ok);
|
||||||
|
background: var(--led-ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-connecting {
|
||||||
|
color: var(--led-connecting);
|
||||||
|
background: var(--led-connecting);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-offline {
|
||||||
|
color: var(--led-offline);
|
||||||
|
background: var(--led-offline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Peer dropdown ───────────────────────────────────────────────────────── */
|
||||||
|
.peer-dropdown {
|
||||||
|
position: relative;
|
||||||
|
min-width: min(18rem, 42vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-trigger {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 0.52rem;
|
||||||
|
padding-bottom: 0.52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.65rem);
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
width: 100%;
|
||||||
|
max-height: calc(3 * 4.55rem + 1.5rem);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: var(--panel-background);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 0.85rem 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--surface-background);
|
||||||
|
font-size: 1em;
|
||||||
|
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-main {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-indicators,
|
||||||
|
.bubble-system-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-indicators {
|
||||||
|
gap: 0.38rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-caret {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-caret-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-delete {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-delete:hover,
|
||||||
|
.peer-tile-delete:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile:hover,
|
||||||
|
.peer-tile:focus-visible,
|
||||||
|
.peer-tile-active {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: color-mix(in srgb, var(--accent-color) 38%, transparent);
|
||||||
|
background: var(--surface-hover-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-unread {
|
||||||
|
border-color: #ef4444;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.4), 0 0 12px rgba(239, 68, 68, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.53rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-tile-title {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.32rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing indicator */
|
||||||
|
.peer-typing-dots {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-height: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-typing-dots span {
|
||||||
|
width: 0.27rem;
|
||||||
|
height: 0.27rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
opacity: 0.35;
|
||||||
|
animation: peer-typing-pulse 900ms infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-typing-dots span:nth-child(2) { animation-delay: 120ms; }
|
||||||
|
.peer-typing-dots span:nth-child(3) { animation-delay: 240ms; }
|
||||||
|
|
||||||
|
.peer-tile-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chat Bubbles ────────────────────────────────────────────────────────── */
|
||||||
|
.bubble-incoming {
|
||||||
|
justify-self: start;
|
||||||
|
color: var(--incoming-bubble-text);
|
||||||
|
background: var(--incoming-bubble-background);
|
||||||
|
border: 1px solid var(--incoming-bubble-border);
|
||||||
|
border-radius: 1.25rem 1.25rem 1.25rem 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-outgoing {
|
||||||
|
justify-self: end;
|
||||||
|
color: var(--outgoing-bubble-text);
|
||||||
|
background: var(--outgoing-bubble-background);
|
||||||
|
border: 1px solid var(--outgoing-bubble-border);
|
||||||
|
border-radius: 1.25rem 1.25rem 0.35rem 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-pending {
|
||||||
|
opacity: 0.52;
|
||||||
|
filter: grayscale(0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-system {
|
||||||
|
justify-self: center;
|
||||||
|
max-width: 90%;
|
||||||
|
color: var(--page-text-soft);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--surface-border-soft);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-emoji-only {
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.45rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.65;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-time { display: block; }
|
||||||
|
|
||||||
|
.bubble-delivery-state {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-system-status {
|
||||||
|
gap: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-spinner {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 0.15rem solid currentColor;
|
||||||
|
border-right-color: transparent;
|
||||||
|
opacity: 0.8;
|
||||||
|
animation: bubble-spin 700ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Keyframes ───────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes peer-typing-pulse {
|
||||||
|
0%, 80%, 100% { opacity: 0.35; transform: translateY(0); }
|
||||||
|
40% { opacity: 1; transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bubble-spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% { opacity: 0.5; box-shadow: 0 0 6px currentColor; }
|
||||||
|
50% { opacity: 1; box-shadow: 0 0 14px currentColor; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-led-ok { animation: glow-pulse 3s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* ── Global accent button (used across pages) ───────────────────────────── */
|
||||||
|
.btn-accent,
|
||||||
|
.btn-accent:hover,
|
||||||
|
.btn-accent:focus-visible {
|
||||||
|
color: #fff;
|
||||||
|
border: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
background: var(--accent-gradient);
|
||||||
|
box-shadow: 0 4px 20px rgba(13, 148, 136, 0.3);
|
||||||
|
transition: opacity 160ms ease, transform 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover,
|
||||||
|
.btn-accent:focus-visible {
|
||||||
|
background: var(--accent-gradient-hover);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 28px rgba(13, 148, 136, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile ──────────────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.peer-dropdown { min-width: min(100%, 18rem); }
|
||||||
|
|
||||||
|
.conversation-modal-backdrop { padding: 0; }
|
||||||
|
|
||||||
|
.conversation-modal {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
1023
package-lock.json
generated
1023
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,11 @@
|
|||||||
"dev:server": "npm run dev --prefix server",
|
"dev:server": "npm run dev --prefix server",
|
||||||
"dev:client": "npm run start --prefix client",
|
"dev:client": "npm run start --prefix client",
|
||||||
"build": "npm run build --prefix server && npm run build --prefix client",
|
"build": "npm run build --prefix server && npm run build --prefix client",
|
||||||
"start": "npm run build && npm run start --prefix server"
|
"start": "npm run build && npm run start --prefix server",
|
||||||
|
"restart": "npm run build && sudo systemctl restart privatechat.service"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.1",
|
||||||
|
"puppeteer": "^24.41.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
175
server/dist/index.js
vendored
175
server/dist/index.js
vendored
@@ -1,19 +1,21 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import jwt from '@fastify/jwt';
|
import jwt from '@fastify/jwt';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import libreOffice from 'libreoffice-convert';
|
||||||
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { SpeechTranscriber } from './speech-transcriber.js';
|
|
||||||
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
||||||
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
||||||
const registerSchema = z.object({
|
const registerSchema = z.object({
|
||||||
@@ -47,6 +49,11 @@ const adminDeleteUserParamsSchema = z.object({
|
|||||||
const webBundleFileParamsSchema = z.object({
|
const webBundleFileParamsSchema = z.object({
|
||||||
'*': z.string().min(1),
|
'*': 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({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -82,12 +89,6 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
|||||||
z.object({
|
z.object({
|
||||||
type: z.literal('ping'),
|
type: z.literal('ping'),
|
||||||
}),
|
}),
|
||||||
z.object({
|
|
||||||
type: z.literal('speech-transcription'),
|
|
||||||
requestId: z.string().uuid(),
|
|
||||||
mimeType: z.string().trim().min(1).max(128),
|
|
||||||
audioBase64: z.string().min(1).max(32_000_000),
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
const app = Fastify({ logger: true, trustProxy: true });
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
const approvalAdminUsername = 'ladparis';
|
const approvalAdminUsername = 'ladparis';
|
||||||
@@ -98,9 +99,6 @@ const frontendDistPath = resolveProjectPath(process.env.PRIVATECHAT_WEB_DIST_DIR
|
|||||||
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
||||||
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
||||||
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
||||||
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080';
|
|
||||||
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
|
|
||||||
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
|
|
||||||
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
||||||
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||||
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||||
@@ -111,11 +109,8 @@ const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
|||||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
||||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
const speechTranscriber = new SpeechTranscriber({
|
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||||
serviceUrl: speechTranscriptionServiceUrl,
|
const execFileAsync = promisify(execFile);
|
||||||
language: speechTranscriptionLanguage,
|
|
||||||
requestTimeoutMs: speechTranscriptionTimeoutMs,
|
|
||||||
}, app.log);
|
|
||||||
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
||||||
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
|
const encryptionKey = deriveEncryptionKey(loadOrCreateMasterKey(masterKeyPath));
|
||||||
@@ -462,6 +457,44 @@ app.get('/api/auth/session', async (request, reply) => {
|
|||||||
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
app.get('/api/users', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
users: listDiscoverableUsers(authContext.user.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
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) => {
|
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
if (!authContext) {
|
if (!authContext) {
|
||||||
@@ -734,25 +767,6 @@ async function handleSocketMessage(userId, sessionId, socket, rawMessage) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsed.type === 'speech-transcription') {
|
|
||||||
try {
|
|
||||||
const text = await transcribeAudioPayload(parsed.requestId, parsed.audioBase64, parsed.mimeType);
|
|
||||||
send(socket, {
|
|
||||||
type: 'speech-transcribed',
|
|
||||||
requestId: parsed.requestId,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
app.log.warn({ err: error, userId }, 'Speech transcription failed');
|
|
||||||
send(socket, {
|
|
||||||
type: 'speech-transcription-error',
|
|
||||||
requestId: parsed.requestId,
|
|
||||||
message: error instanceof Error ? error.message : 'Speech transcription failed.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||||
if (recipientSockets) {
|
if (recipientSockets) {
|
||||||
@@ -835,6 +849,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) {
|
function createUser(input) {
|
||||||
const createdAt = new Date().toISOString();
|
const createdAt = new Date().toISOString();
|
||||||
const user = {
|
const user = {
|
||||||
@@ -873,6 +956,17 @@ function listAdminUsers() {
|
|||||||
approvedAt: row.approved_at,
|
approvedAt: row.approved_at,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
function listDiscoverableUsers(currentUserId) {
|
||||||
|
const rows = selectAllUsersStatement.all();
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.is_active === 1 && row.id !== currentUserId)
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
}))
|
||||||
|
.sort((left, right) => left.displayName.localeCompare(right.displayName) || left.username.localeCompare(right.username));
|
||||||
|
}
|
||||||
function approveUser(userId) {
|
function approveUser(userId) {
|
||||||
const approvedAt = new Date().toISOString();
|
const approvedAt = new Date().toISOString();
|
||||||
const result = approveUserStatement.run(approvedAt, userId);
|
const result = approveUserStatement.run(approvedAt, userId);
|
||||||
@@ -1129,23 +1223,12 @@ function parseClientMessage(rawMessage) {
|
|||||||
prompt: parsed.data.prompt,
|
prompt: parsed.data.prompt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (parsed.data.type === 'speech-transcription') {
|
|
||||||
return {
|
|
||||||
type: 'speech-transcription',
|
|
||||||
requestId: parsed.data.requestId,
|
|
||||||
mimeType: parsed.data.mimeType,
|
|
||||||
audioBase64: parsed.data.audioBase64,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
type: 'signal',
|
type: 'signal',
|
||||||
to: parsed.data.to,
|
to: parsed.data.to,
|
||||||
signal: normalizeSignal(parsed.data.signal),
|
signal: normalizeSignal(parsed.data.signal),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async function transcribeAudioPayload(requestId, audioBase64, mimeType) {
|
|
||||||
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
|
|
||||||
}
|
|
||||||
async function generateImageFromPrompt(prompt) {
|
async function generateImageFromPrompt(prompt) {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||||
|
|||||||
124
server/dist/speech-transcriber.js
vendored
124
server/dist/speech-transcriber.js
vendored
@@ -1,124 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
357
server/package-lock.json
generated
357
server/package-lock.json
generated
@@ -10,26 +10,27 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.1.0",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@simplewebauthn/server": "^13.2.3",
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.4",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.1",
|
||||||
"ws": "^8.19.0",
|
"libreoffice-convert": "^1.8.1",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.5",
|
"@types/node": "^25.6.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -44,9 +45,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -61,9 +62,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -78,9 +79,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -95,9 +96,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -112,9 +113,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -129,9 +130,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -146,9 +147,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -163,9 +164,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
|
||||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -180,9 +181,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -197,9 +198,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -214,9 +215,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
|
||||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -231,9 +232,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
|
||||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -248,9 +249,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
|
||||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -265,9 +266,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
|
||||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -282,9 +283,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
|
||||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -299,9 +300,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -316,9 +317,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -333,9 +334,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -350,9 +351,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -367,9 +368,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -384,9 +385,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -401,9 +402,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -418,9 +419,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
|
||||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -435,9 +436,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
|
||||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -452,9 +453,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
|
||||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -662,9 +663,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fastify/static": {
|
"node_modules/@fastify/static": {
|
||||||
"version": "9.0.0",
|
"version": "9.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@fastify/static/-/static-9.1.0.tgz",
|
||||||
"integrity": "sha512-r64H8Woe/vfilg5RTy7lwWlE8ZZcTrc3kebYFMEUBrMqlydhQyoiExQXdYAy2REVpST/G35+stAM8WYp1WGmMA==",
|
"integrity": "sha512-EPRNQYqEYEYTK8yyGbcM0iHpyJaupb94bey5O6iCQfLTADr02kaZU+qeHSdd9H9TiMwTBVkrMa59V8CMbn3avQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -899,9 +900,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@simplewebauthn/server": {
|
"node_modules/@simplewebauthn/server": {
|
||||||
"version": "13.2.3",
|
"version": "13.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.0.tgz",
|
||||||
"integrity": "sha512-ZhcVBOw63birYx9jVfbhK6rTehckVes8PeWV324zpmdxr0BUfylospwMzcrxrdMcOi48MHWj2LCA+S528LnGvg==",
|
"integrity": "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hexagon/base64": "^1.1.27",
|
"@hexagon/base64": "^1.1.27",
|
||||||
@@ -918,13 +919,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.3.5",
|
"version": "25.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
"integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==",
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
@@ -1002,6 +1003,12 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
@@ -1047,9 +1054,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -1068,9 +1075,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/content-disposition": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||||
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
|
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -1138,9 +1145,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.3.1",
|
"version": "17.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@@ -1180,9 +1187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.7",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
|
||||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1193,32 +1200,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.27.3",
|
"@esbuild/aix-ppc64": "0.27.7",
|
||||||
"@esbuild/android-arm": "0.27.3",
|
"@esbuild/android-arm": "0.27.7",
|
||||||
"@esbuild/android-arm64": "0.27.3",
|
"@esbuild/android-arm64": "0.27.7",
|
||||||
"@esbuild/android-x64": "0.27.3",
|
"@esbuild/android-x64": "0.27.7",
|
||||||
"@esbuild/darwin-arm64": "0.27.3",
|
"@esbuild/darwin-arm64": "0.27.7",
|
||||||
"@esbuild/darwin-x64": "0.27.3",
|
"@esbuild/darwin-x64": "0.27.7",
|
||||||
"@esbuild/freebsd-arm64": "0.27.3",
|
"@esbuild/freebsd-arm64": "0.27.7",
|
||||||
"@esbuild/freebsd-x64": "0.27.3",
|
"@esbuild/freebsd-x64": "0.27.7",
|
||||||
"@esbuild/linux-arm": "0.27.3",
|
"@esbuild/linux-arm": "0.27.7",
|
||||||
"@esbuild/linux-arm64": "0.27.3",
|
"@esbuild/linux-arm64": "0.27.7",
|
||||||
"@esbuild/linux-ia32": "0.27.3",
|
"@esbuild/linux-ia32": "0.27.7",
|
||||||
"@esbuild/linux-loong64": "0.27.3",
|
"@esbuild/linux-loong64": "0.27.7",
|
||||||
"@esbuild/linux-mips64el": "0.27.3",
|
"@esbuild/linux-mips64el": "0.27.7",
|
||||||
"@esbuild/linux-ppc64": "0.27.3",
|
"@esbuild/linux-ppc64": "0.27.7",
|
||||||
"@esbuild/linux-riscv64": "0.27.3",
|
"@esbuild/linux-riscv64": "0.27.7",
|
||||||
"@esbuild/linux-s390x": "0.27.3",
|
"@esbuild/linux-s390x": "0.27.7",
|
||||||
"@esbuild/linux-x64": "0.27.3",
|
"@esbuild/linux-x64": "0.27.7",
|
||||||
"@esbuild/netbsd-arm64": "0.27.3",
|
"@esbuild/netbsd-arm64": "0.27.7",
|
||||||
"@esbuild/netbsd-x64": "0.27.3",
|
"@esbuild/netbsd-x64": "0.27.7",
|
||||||
"@esbuild/openbsd-arm64": "0.27.3",
|
"@esbuild/openbsd-arm64": "0.27.7",
|
||||||
"@esbuild/openbsd-x64": "0.27.3",
|
"@esbuild/openbsd-x64": "0.27.7",
|
||||||
"@esbuild/openharmony-arm64": "0.27.3",
|
"@esbuild/openharmony-arm64": "0.27.7",
|
||||||
"@esbuild/sunos-x64": "0.27.3",
|
"@esbuild/sunos-x64": "0.27.7",
|
||||||
"@esbuild/win32-arm64": "0.27.3",
|
"@esbuild/win32-arm64": "0.27.7",
|
||||||
"@esbuild/win32-ia32": "0.27.3",
|
"@esbuild/win32-ia32": "0.27.7",
|
||||||
"@esbuild/win32-x64": "0.27.3"
|
"@esbuild/win32-x64": "0.27.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
@@ -1264,15 +1271,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-jwt": {
|
"node_modules/fast-jwt": {
|
||||||
"version": "6.1.0",
|
"version": "6.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.2.2.tgz",
|
||||||
"integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==",
|
"integrity": "sha512-lzy+8JVyBOvwxjydFRBKLFVe1elRArL37pHRX1zHPt4T7FP7kNIpqauE1lOjZlD79DBzzRzQmp+28wbsY13weA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@lukeed/ms": "^2.0.2",
|
"@lukeed/ms": "^2.0.2",
|
||||||
"asn1.js": "^5.4.1",
|
"asn1.js": "^5.4.1",
|
||||||
"ecdsa-sig-formatter": "^1.0.11",
|
"ecdsa-sig-formatter": "^1.0.11",
|
||||||
"mnemonist": "^0.40.0"
|
"mnemonist": "^0.40.0",
|
||||||
|
"safe-regex2": "^5.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -1316,9 +1324,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fastify": {
|
"node_modules/fastify": {
|
||||||
"version": "5.8.2",
|
"version": "5.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
|
||||||
"integrity": "sha512-lZmt3navvZG915IE+f7/TIVamxIwmBd+OMB+O9WBzcpIwOo6F0LTh0sluoMFk5VkrKTvvrwIaoJPkir4Z+jtAg==",
|
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1423,9 +1431,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/get-tsconfig": {
|
"node_modules/get-tsconfig": {
|
||||||
"version": "4.13.6",
|
"version": "4.13.7",
|
||||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1479,9 +1487,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ioredis": {
|
"node_modules/ioredis": {
|
||||||
"version": "5.10.0",
|
"version": "5.10.1",
|
||||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz",
|
||||||
"integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==",
|
"integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ioredis/commands": "1.5.1",
|
"@ioredis/commands": "1.5.1",
|
||||||
@@ -1536,6 +1544,19 @@
|
|||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||||
@@ -1586,9 +1607,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "11.2.6",
|
"version": "11.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz",
|
||||||
"integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
|
"integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
@@ -1613,12 +1634,12 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.4",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.2"
|
"brace-expansion": "^5.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
@@ -1883,9 +1904,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/safe-regex2": {
|
"node_modules/safe-regex2": {
|
||||||
"version": "5.0.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.0.tgz",
|
||||||
"integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==",
|
"integrity": "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -1899,6 +1920,9 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ret": "~0.5.0"
|
"ret": "~0.5.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"safe-regex2": "bin/safe-regex2.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-stable-stringify": {
|
"node_modules/safe-stable-stringify": {
|
||||||
@@ -2029,6 +2053,15 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/toad-cache": {
|
||||||
"version": "3.7.0",
|
"version": "3.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||||
@@ -2106,9 +2139,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2125,9 +2158,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
@@ -11,17 +11,18 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.1.0",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"@simplewebauthn/server": "^13.2.3",
|
"@simplewebauthn/server": "^13.3.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.4",
|
||||||
"ioredis": "^5.10.0",
|
"ioredis": "^5.10.1",
|
||||||
"ws": "^8.19.0",
|
"libreoffice-convert": "^1.8.1",
|
||||||
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.3.5",
|
"@types/node": "^25.6.0",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { TextEncoder } from 'node:util';
|
import { promisify, TextEncoder } from 'node:util';
|
||||||
import { DatabaseSync } from 'node:sqlite';
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
|
|
||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
@@ -10,6 +12,7 @@ import jwt from '@fastify/jwt';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import libreOffice from 'libreoffice-convert';
|
||||||
import {
|
import {
|
||||||
generateAuthenticationOptions,
|
generateAuthenticationOptions,
|
||||||
generateRegistrationOptions,
|
generateRegistrationOptions,
|
||||||
@@ -23,8 +26,6 @@ import { Redis } from 'ioredis';
|
|||||||
import type WebSocket from 'ws';
|
import type WebSocket from 'ws';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { SpeechTranscriber } from './speech-transcriber.js';
|
|
||||||
|
|
||||||
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
dotenv.config({ path: fileURLToPath(new URL('../../.env', import.meta.url)) });
|
||||||
|
|
||||||
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
const projectRootPath = fileURLToPath(new URL('../../', import.meta.url));
|
||||||
@@ -122,12 +123,6 @@ type ClientMessage =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'ping';
|
type: 'ping';
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'speech-transcription';
|
|
||||||
requestId: string;
|
|
||||||
mimeType: string;
|
|
||||||
audioBase64: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ServerMessage =
|
type ServerMessage =
|
||||||
@@ -150,16 +145,6 @@ type ServerMessage =
|
|||||||
peerId: string;
|
peerId: string;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: 'speech-transcribed';
|
|
||||||
requestId: string;
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'speech-transcription-error';
|
|
||||||
requestId: string;
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
| { type: 'pong' }
|
| { type: 'pong' }
|
||||||
| { type: 'error'; message: string };
|
| { type: 'error'; message: string };
|
||||||
|
|
||||||
@@ -271,6 +256,12 @@ const webBundleFileParamsSchema = z.object({
|
|||||||
'*': z.string().min(1),
|
'*': 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({
|
const wsQuerySchema = z.object({
|
||||||
token: z.string().min(1),
|
token: z.string().min(1),
|
||||||
});
|
});
|
||||||
@@ -307,12 +298,6 @@ const signalMessageSchema = z.discriminatedUnion('type', [
|
|||||||
z.object({
|
z.object({
|
||||||
type: z.literal('ping'),
|
type: z.literal('ping'),
|
||||||
}),
|
}),
|
||||||
z.object({
|
|
||||||
type: z.literal('speech-transcription'),
|
|
||||||
requestId: z.string().uuid(),
|
|
||||||
mimeType: z.string().trim().min(1).max(128),
|
|
||||||
audioBase64: z.string().min(1).max(32_000_000),
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const app = Fastify({ logger: true, trustProxy: true });
|
const app = Fastify({ logger: true, trustProxy: true });
|
||||||
@@ -331,9 +316,6 @@ const frontendDistPath = resolveProjectPath(
|
|||||||
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
const ollamaServerUrl = (process.env.PRIVATECHAT_OLLAMA_URL ?? 'http://192.168.1.19:11434').replace(/\/+$/, '');
|
||||||
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
const ollamaImageModel = process.env.PRIVATECHAT_OLLAMA_IMAGE_MODEL ?? 'x/z-image-turbo:latest';
|
||||||
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
const ollamaImageSize = process.env.PRIVATECHAT_OLLAMA_IMAGE_SIZE ?? '1024x1024';
|
||||||
const speechTranscriptionServiceUrl = process.env.PRIVATECHAT_TRANSCRIPTION_WS_URL ?? 'ws://192.168.1.19:8080';
|
|
||||||
const speechTranscriptionLanguage = process.env.PRIVATECHAT_TRANSCRIPTION_LANGUAGE ?? 'auto';
|
|
||||||
const speechTranscriptionTimeoutMs = Number(process.env.PRIVATECHAT_TRANSCRIPTION_TIMEOUT_MS ?? 120_000);
|
|
||||||
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
const sessionTtlSeconds = Number(process.env.SESSION_TTL_SECONDS ?? 60 * 60 * 12);
|
||||||
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
const webAuthnChallengeTtlSeconds = Number(process.env.WEBAUTHN_CHALLENGE_TTL_SECONDS ?? 5 * 60);
|
||||||
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
const allowedCorsOrigins = parseAllowedOrigins(process.env.CORS_ORIGIN);
|
||||||
@@ -346,15 +328,8 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
|||||||
);
|
);
|
||||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||||
|
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||||
const speechTranscriber = new SpeechTranscriber(
|
const execFileAsync = promisify(execFile);
|
||||||
{
|
|
||||||
serviceUrl: speechTranscriptionServiceUrl,
|
|
||||||
language: speechTranscriptionLanguage,
|
|
||||||
requestTimeoutMs: speechTranscriptionTimeoutMs,
|
|
||||||
},
|
|
||||||
app.log,
|
|
||||||
);
|
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
fs.mkdirSync(path.dirname(sqlitePath), { recursive: true });
|
||||||
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
fs.mkdirSync(path.dirname(masterKeyPath), { recursive: true });
|
||||||
@@ -795,6 +770,57 @@ app.get('/api/auth/session', async (request, reply) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/users', async (request, reply) => {
|
||||||
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
|
if (!authContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: listDiscoverableUsers(authContext.user.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||||
const authContext = await authenticateRequest(request, reply);
|
const authContext = await authenticateRequest(request, reply);
|
||||||
|
|
||||||
@@ -1163,27 +1189,6 @@ async function handleSocketMessage(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === 'speech-transcription') {
|
|
||||||
try {
|
|
||||||
const text = await transcribeAudioPayload(parsed.requestId, parsed.audioBase64, parsed.mimeType);
|
|
||||||
|
|
||||||
send(socket, {
|
|
||||||
type: 'speech-transcribed',
|
|
||||||
requestId: parsed.requestId,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
app.log.warn({ err: error, userId }, 'Speech transcription failed');
|
|
||||||
send(socket, {
|
|
||||||
type: 'speech-transcription-error',
|
|
||||||
requestId: parsed.requestId,
|
|
||||||
message: error instanceof Error ? error.message : 'Speech transcription failed.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let delivered = 0;
|
let delivered = 0;
|
||||||
const recipientSockets = socketsByUserId.get(parsed.to);
|
const recipientSockets = socketsByUserId.get(parsed.to);
|
||||||
|
|
||||||
@@ -1294,6 +1299,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: {
|
function createUser(input: {
|
||||||
username: string;
|
username: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -1352,6 +1452,21 @@ function listAdminUsers(): AdminUserSummary[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listDiscoverableUsers(currentUserId: string): PublicUser[] {
|
||||||
|
const rows = selectAllUsersStatement.all() as DatabaseUserRow[];
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.is_active === 1 && row.id !== currentUserId)
|
||||||
|
.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
displayName: row.display_name,
|
||||||
|
}))
|
||||||
|
.sort((left, right) =>
|
||||||
|
left.displayName.localeCompare(right.displayName) || left.username.localeCompare(right.username),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function approveUser(userId: string): UserRecord | null {
|
function approveUser(userId: string): UserRecord | null {
|
||||||
const approvedAt = new Date().toISOString();
|
const approvedAt = new Date().toISOString();
|
||||||
const result = approveUserStatement.run(approvedAt, userId);
|
const result = approveUserStatement.run(approvedAt, userId);
|
||||||
@@ -1725,15 +1840,6 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.data.type === 'speech-transcription') {
|
|
||||||
return {
|
|
||||||
type: 'speech-transcription',
|
|
||||||
requestId: parsed.data.requestId,
|
|
||||||
mimeType: parsed.data.mimeType,
|
|
||||||
audioBase64: parsed.data.audioBase64,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'signal',
|
type: 'signal',
|
||||||
to: parsed.data.to,
|
to: parsed.data.to,
|
||||||
@@ -1741,10 +1847,6 @@ function parseClientMessage(rawMessage: string): ClientMessage | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function transcribeAudioPayload(requestId: string, audioBase64: string, mimeType: string): Promise<string> {
|
|
||||||
return await speechTranscriber.transcribe(requestId, audioBase64, mimeType);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateImageFromPrompt(prompt: string): Promise<{ imageBase64: string; mimeType: string }> {
|
async function generateImageFromPrompt(prompt: string): Promise<{ imageBase64: string; mimeType: string }> {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
const timeoutId = setTimeout(() => abortController.abort(), 120_000);
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
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