Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84745eb104 | |||
| ae59d3deac | |||
| 687bd56e42 | |||
| 03d3b75fb4 | |||
| 32084a66d1 | |||
| 64e03964e9 | |||
| 11cc5350c8 | |||
| 0e4c79b735 | |||
| ffdea4fe62 |
14
client/package-lock.json
generated
14
client/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -5960,6 +5961,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ngx-extended-pdf-viewer": {
|
||||
"version": "25.6.4",
|
||||
"resolved": "https://registry.npmjs.org/ngx-extended-pdf-viewer/-/ngx-extended-pdf-viewer-25.6.4.tgz",
|
||||
"integrity": "sha512-eYIiWzatcupB7HKDtcOOZN7gcLFjqAkeIAlZOMIO6XyUJnTe+PUZLZGit/19mtO/8fAaH41lMyyh8MAcU8NAhA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=17.0.0 <22.0.0",
|
||||
"@angular/core": ">=17.0.0 <22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"ngx-extended-pdf-viewer": "^25.6.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
|
||||
@@ -1,83 +1,118 @@
|
||||
<main class="chat-shell py-4">
|
||||
<div class="container-lg">
|
||||
<section class="chat-page panel p-3 p-lg-4">
|
||||
<app-peer-video-modal
|
||||
[visible]="remoteVideoModalVisible()"
|
||||
[stream]="remoteVideoStream()"
|
||||
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
|
||||
(closeRequested)="closeRemoteVideoModal()"
|
||||
></app-peer-video-modal>
|
||||
<app-peer-call-modal
|
||||
[visible]="callModalVisible()"
|
||||
[peerName]="callModalPeer()?.displayName ?? 'Peer'"
|
||||
[callState]="callModalState()"
|
||||
[callMode]="callModalMode()"
|
||||
[statusText]="callModalStatusText()"
|
||||
[localStream]="localCallStream()"
|
||||
[remoteStream]="remoteCallVideoStream()"
|
||||
(acceptRequested)="callModalPeer() && acceptIncomingVoiceCall(callModalPeer()!.id)"
|
||||
(rejectRequested)="callModalPeer() && rejectIncomingVoiceCall(callModalPeer()!.id)"
|
||||
(hangupRequested)="callModalPeer() && endVoiceCall(callModalPeer()!.id)"
|
||||
></app-peer-call-modal>
|
||||
<audio #callAudioElement hidden autoplay playsinline></audio>
|
||||
|
||||
@if (incomingVoiceCallPeer(); as callingPeer) {
|
||||
<div class="call-modal-backdrop">
|
||||
<section class="panel p-4" style="width:min(100%,24rem)" (click)="$event.stopPropagation()">
|
||||
<div class="mb-3">
|
||||
<div>
|
||||
<h2 class="h5 mb-1">Incoming voice call</h2>
|
||||
<p class="small mb-0">{{ callingPeer.displayName }} is calling you.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 justify-content-end">
|
||||
<button
|
||||
class="btn btn-success"
|
||||
type="button"
|
||||
(click)="acceptIncomingVoiceCall(callingPeer.id)"
|
||||
>
|
||||
Accept
|
||||
@if (callChoicePeer(); as selectedCallPeer) {
|
||||
<div class="call-choice-backdrop" (click)="closeCallChoice()">
|
||||
<section class="call-choice-card panel p-4" (click)="$event.stopPropagation()">
|
||||
<p class="call-choice-eyebrow">Start a call</p>
|
||||
<h2 class="h5 mb-2">{{ selectedCallPeer.displayName }}</h2>
|
||||
<p class="small mb-3">Choose whether to place a full video call or audio only.</p>
|
||||
<div class="call-choice-actions">
|
||||
<button class="call-choice-button" type="button" (click)="startSelectedCall('video')">
|
||||
<span class="call-choice-icon">📹</span>
|
||||
<span>Video call</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
(click)="rejectIncomingVoiceCall(callingPeer.id)"
|
||||
>
|
||||
Reject
|
||||
<button class="call-choice-button" type="button" (click)="startSelectedCall('audio')">
|
||||
<span class="call-choice-icon">🎙️</span>
|
||||
<span>Audio only</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="closeCallChoice()">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="chat-header d-flex flex-column flex-lg-row justify-content-between align-items-start align-items-lg-center gap-3 mb-4">
|
||||
@if (conversationModalOpen()) {
|
||||
<div class="conversation-modal-backdrop" (click)="closeConversationModal()">
|
||||
<section class="conversation-modal panel p-3 p-lg-4" (click)="$event.stopPropagation()">
|
||||
<header class="conversation-modal-header">
|
||||
<div>
|
||||
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
||||
@if (currentUser(); as connectedUser) {
|
||||
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
|
||||
<div class="status-indicators mt-2">
|
||||
<div class="status-indicator">
|
||||
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
|
||||
<span>Signaling</span>
|
||||
<p class="conversation-modal-eyebrow mb-1">Fullscreen conversation</p>
|
||||
<h2 class="h5 mb-0">{{ peer()?.displayName ?? 'Conversation' }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="status-indicator status-indicator-action"
|
||||
class="conversation-modal-close"
|
||||
type="button"
|
||||
[disabled]="!canReconnectWebRtc()"
|
||||
[attr.aria-label]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
||||
[title]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
||||
(click)="ensureConnection()"
|
||||
(click)="closeConversationModal()"
|
||||
aria-label="Close fullscreen conversation"
|
||||
>
|
||||
<span class="status-led" [class.status-led-ok]="indicatorTone(webRtcState()) === 'ok'" [class.status-led-connecting]="indicatorTone(webRtcState()) === 'connecting'" [class.status-led-offline]="indicatorTone(webRtcState()) === 'offline'"></span>
|
||||
<span>WebRTC</span>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<h1 class="h3 mb-1 mt-2">Not signed in</h1>
|
||||
<p class="small text-secondary mb-0">Return to the dashboard and sign in again.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="chat-layout">
|
||||
<aside class="peer-sidebar">
|
||||
|
||||
|
||||
<div class="peer-list">
|
||||
@if (session.peers().length === 0) {
|
||||
<div class="empty-chat empty-peers">
|
||||
No peers are currently connected.
|
||||
<div #fullscreenConversationContainer class="conversation conversation-modal-body">
|
||||
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="chat-header mb-4">
|
||||
@if (currentUser(); as connectedUser) {
|
||||
<div class="chat-header-main">
|
||||
<a class="back-link" routerLink="/" aria-label="Back to dashboard">←</a>
|
||||
<h1 class="chat-header-title mb-0">{{ connectedUser.displayName }}</h1>
|
||||
@if (displayedPeer(); as selectedPeer) {
|
||||
<div class="peer-dropdown" (click)="$event.stopPropagation()">
|
||||
<button
|
||||
class="peer-dropdown-trigger peer-tile"
|
||||
type="button"
|
||||
[class.peer-tile-active]="true"
|
||||
[class.peer-tile-unread]="isPeerUnread(selectedPeer.id)"
|
||||
(click)="togglePeerDropdown()"
|
||||
[attr.aria-expanded]="peerDropdownOpen()"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Choose peer"
|
||||
>
|
||||
<span class="peer-tile-main text-start">
|
||||
<span class="peer-tile-row">
|
||||
<span class="peer-tile-title">
|
||||
<span class="fw-semibold">{{ selectedPeer.displayName }}</span>
|
||||
@if (isPeerTyping(selectedPeer.id)) {
|
||||
<span class="peer-typing-dots" aria-label="Typing">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="peer-tile-indicators">
|
||||
<span
|
||||
class="status-led peer-tile-status"
|
||||
[class.status-led-ok]="selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'"
|
||||
[class.status-led-offline]="selectedPeer.channelState !== 'open' && selectedPeer.connectionState !== 'connected'"
|
||||
[attr.aria-label]="
|
||||
selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'
|
||||
? 'Connected'
|
||||
: 'Disconnected'
|
||||
"
|
||||
></span>
|
||||
<span class="peer-dropdown-caret" [class.peer-dropdown-caret-open]="peerDropdownOpen()">▾</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@if (peerDropdownOpen()) {
|
||||
<div class="peer-dropdown-menu" role="listbox">
|
||||
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
||||
<article
|
||||
class="peer-tile"
|
||||
@@ -87,7 +122,7 @@
|
||||
<button
|
||||
class="peer-tile-main text-start"
|
||||
type="button"
|
||||
(click)="switchPeer(connectedPeer.id)"
|
||||
(click)="selectPeerFromDropdown(connectedPeer.id)"
|
||||
>
|
||||
<div class="peer-tile-row">
|
||||
<span class="peer-tile-title">
|
||||
@@ -124,138 +159,56 @@
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="chat-main">
|
||||
<div class="conversation">
|
||||
@if (conversation().length === 0) {
|
||||
<div class="empty-chat">
|
||||
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (entry of conversation(); track entry.id) {
|
||||
<article
|
||||
class="bubble"
|
||||
[class.bubble-incoming]="entry.direction === 'incoming'"
|
||||
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
||||
[class.bubble-system]="entry.direction === 'system'"
|
||||
>
|
||||
@if (entry.direction !== 'system') {
|
||||
<div class="bubble-actions">
|
||||
<div class="status-indicators">
|
||||
<div class="status-indicator">
|
||||
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
|
||||
<span>Signaling</span>
|
||||
</div>
|
||||
<button
|
||||
class="bubble-action"
|
||||
class="status-indicator status-indicator-action"
|
||||
type="button"
|
||||
(click)="toggleForwardMenu(entry, $event)"
|
||||
title="Forward message"
|
||||
aria-label="Forward message"
|
||||
[disabled]="!canReconnectWebRtc()"
|
||||
[attr.aria-label]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
||||
[title]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
||||
(click)="ensureConnection()"
|
||||
>
|
||||
⏩
|
||||
<span class="status-led" [class.status-led-ok]="indicatorTone(webRtcState()) === 'ok'" [class.status-led-connecting]="indicatorTone(webRtcState()) === 'connecting'" [class.status-led-offline]="indicatorTone(webRtcState()) === 'offline'"></span>
|
||||
<span>WebRTC</span>
|
||||
</button>
|
||||
<button
|
||||
class="bubble-action bubble-delete"
|
||||
class="status-indicator status-indicator-action"
|
||||
type="button"
|
||||
(click)="deleteMessage(entry)"
|
||||
title="Delete message"
|
||||
aria-label="Delete message"
|
||||
[disabled]="conversation().length === 0"
|
||||
aria-label="Open fullscreen conversation"
|
||||
title="Open fullscreen conversation"
|
||||
(click)="openConversationModal()"
|
||||
>
|
||||
×
|
||||
<span class="expand-action-icon" aria-hidden="true">⤢</span>
|
||||
</button>
|
||||
@if (isForwardMenuOpen(entry.id)) {
|
||||
<div class="bubble-forward-menu">
|
||||
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
|
||||
<option value="">Forward to…</option>
|
||||
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
|
||||
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<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 class="chat-header-main">
|
||||
<a class="back-link" routerLink="/" aria-label="Back to dashboard">←</a>
|
||||
<h1 class="chat-header-title mb-0">Not signed in</h1>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="chat-layout">
|
||||
<div class="chat-main" (click)="closePeerDropdown()">
|
||||
<div #conversationContainer class="conversation">
|
||||
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<textarea
|
||||
#composerTextarea
|
||||
class="form-control composer-textarea"
|
||||
rows="3"
|
||||
rows="2"
|
||||
[(ngModel)]="messageText"
|
||||
(ngModelChange)="handleMessageTextChange($event)"
|
||||
(keydown.enter)="handleComposerEnter($event)"
|
||||
@@ -267,14 +220,15 @@
|
||||
></textarea>
|
||||
|
||||
<div class="composer-toolbar">
|
||||
<div class="composer-actions">
|
||||
@if (peer(); as selectedPeer) {
|
||||
<button
|
||||
class="composer-call"
|
||||
type="button"
|
||||
[disabled]="!canStartSelectedVoiceCall()"
|
||||
(click)="startVoiceCall(selectedPeer.id)"
|
||||
title="Start voice call"
|
||||
aria-label="Start voice call"
|
||||
(click)="openCallChoice(selectedPeer.id)"
|
||||
title="Start call"
|
||||
aria-label="Start call"
|
||||
>
|
||||
📞
|
||||
</button>
|
||||
@@ -284,24 +238,13 @@
|
||||
class="composer-hangup"
|
||||
type="button"
|
||||
(click)="endVoiceCall(selectedPeer.id)"
|
||||
title="End voice call"
|
||||
aria-label="End voice call"
|
||||
title="End call"
|
||||
aria-label="End call"
|
||||
>
|
||||
🛑
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
class="composer-camera"
|
||||
type="button"
|
||||
[disabled]="selectedPeer.channelState !== 'open' && !isStreamingCameraToSelectedPeer()"
|
||||
(click)="toggleCameraStream(selectedPeer.id)"
|
||||
[title]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
||||
[attr.aria-label]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
||||
>
|
||||
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="composer-voice"
|
||||
type="button"
|
||||
@@ -407,9 +350,166 @@
|
||||
✅
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (lastIncomingReceiveMetric(); as receiveMetric) {
|
||||
<div class="composer-receive-speed" title="Receive speed of the last completed incoming WebRTC message">
|
||||
<span class="composer-receive-speed-label">Rx</span>
|
||||
<span class="composer-receive-speed-value">{{ receiveMetric.mbps | number: '1.2-2' }} Mbit/s</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ng-template #conversationBubbles>
|
||||
@if (conversation().length === 0) {
|
||||
<div class="empty-chat">
|
||||
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (entry of conversation(); track entry.id) {
|
||||
<article
|
||||
class="bubble"
|
||||
[class.bubble-incoming]="entry.direction === 'incoming'"
|
||||
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
||||
[class.bubble-system]="entry.direction === 'system'"
|
||||
[class.bubble-emoji-only]="isEmojiOnlyEntry(entry)"
|
||||
>
|
||||
@if (entry.direction !== 'system' && !isEmojiOnlyEntry(entry)) {
|
||||
<div class="bubble-actions">
|
||||
@if (isGeneratedImageEntry(entry)) {
|
||||
<button
|
||||
class="bubble-action"
|
||||
type="button"
|
||||
(click)="sendGeneratedImage(entry)"
|
||||
title="Send image to peer"
|
||||
aria-label="Send image to peer"
|
||||
>
|
||||
📤
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
class="bubble-action"
|
||||
type="button"
|
||||
(click)="toggleForwardMenu(entry, $event)"
|
||||
title="Forward message"
|
||||
aria-label="Forward message"
|
||||
>
|
||||
⏩
|
||||
</button>
|
||||
<button
|
||||
class="bubble-action bubble-delete"
|
||||
type="button"
|
||||
(click)="deleteMessage(entry)"
|
||||
title="Delete message"
|
||||
aria-label="Delete message"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@if (isForwardMenuOpen(entry.id)) {
|
||||
<div class="bubble-forward-menu">
|
||||
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
|
||||
<option value="">Forward to…</option>
|
||||
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
|
||||
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (!isEmojiOnlyEntry(entry)) {
|
||||
<div class="bubble-meta">
|
||||
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
||||
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||
</div>
|
||||
}
|
||||
|
||||
@switch (entry.kind) {
|
||||
@case ('text') {
|
||||
<p class="mb-0" [class.emoji-only-text]="isEmojiOnlyEntry(entry)">{{ entry.text }}</p>
|
||||
}
|
||||
@case ('json') {
|
||||
<pre class="bubble-json mb-0">{{ entry.payload | json }}</pre>
|
||||
}
|
||||
@case ('file') {
|
||||
<div class="d-grid gap-3">
|
||||
@if (isImageEntry(entry)) {
|
||||
<img
|
||||
class="bubble-image"
|
||||
[src]="entry.downloadUrl"
|
||||
[alt]="entry.fileName || 'Shared image'"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (isVideoEntry(entry)) {
|
||||
<video
|
||||
class="bubble-video"
|
||||
[src]="entry.downloadUrl"
|
||||
controls
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
preload="metadata"
|
||||
></video>
|
||||
}
|
||||
|
||||
@if (isIncomingJsonFileEntry(entry)) {
|
||||
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
||||
}
|
||||
|
||||
<div>
|
||||
<div class="fw-semibold">{{ entry.fileName }}</div>
|
||||
@if (entry.fileSize) {
|
||||
<div class="small text-secondary-emphasis">{{ entry.fileSize | number }} bytes</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (entry.downloadUrl) {
|
||||
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
||||
}
|
||||
|
||||
@if (hasDocumentPreviewImage(entry)) {
|
||||
<div class="bubble-preview">
|
||||
<div class="bubble-preview-label">Preview</div>
|
||||
<img
|
||||
class="bubble-preview-image"
|
||||
[src]="documentPreviewImageUrl(entry)"
|
||||
[alt]="entry.fileName || 'Document preview'"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('voice') {
|
||||
<div class="voice-bubble">
|
||||
<div class="voice-bubble-label">Voice message</div>
|
||||
@if (entry.downloadUrl) {
|
||||
<audio
|
||||
class="voice-player"
|
||||
[src]="entry.downloadUrl"
|
||||
controls
|
||||
preload="metadata"
|
||||
></audio>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
@if (entry.showSpinner) {
|
||||
<div class="bubble-system-status">
|
||||
<span class="bubble-spinner" aria-hidden="true"></span>
|
||||
<p class="mb-0">{{ entry.text }}</p>
|
||||
</div>
|
||||
} @else {
|
||||
<p class="mb-0">{{ entry.text }}</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@@ -17,11 +17,32 @@
|
||||
}
|
||||
|
||||
.chat-page {
|
||||
width: min(100%, 800px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(95vw, 95%);
|
||||
height: min(calc(100dvh - 2rem), 1024px);
|
||||
max-height: 1024px;
|
||||
margin-inline: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chat-header-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.chat-header-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2vw, 1.75rem);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.call-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -33,15 +54,133 @@
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.call-choice-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1240;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1rem;
|
||||
background: rgba(3, 8, 14, 0.46);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.call-choice-card {
|
||||
width: min(100%, 25rem);
|
||||
}
|
||||
|
||||
.conversation-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1230;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0.75rem;
|
||||
background: rgba(3, 8, 14, 0.56);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.conversation-modal {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(100%, 96rem);
|
||||
height: min(100dvh - 1.5rem, 100%);
|
||||
max-height: 100dvh;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.conversation-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.conversation-modal-eyebrow {
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--page-text-soft);
|
||||
}
|
||||
|
||||
.conversation-modal-close {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: var(--page-text);
|
||||
background: var(--badge-background);
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.conversation-modal-body {
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.call-choice-eyebrow {
|
||||
margin-bottom: 0.45rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--page-text-soft);
|
||||
}
|
||||
|
||||
.call-choice-actions {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.call-choice-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
width: 100%;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
color: var(--page-text);
|
||||
background: var(--surface-background);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.call-choice-button:hover,
|
||||
.call-choice-button:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--accent-color) 35%, transparent);
|
||||
background: var(--surface-hover-background);
|
||||
}
|
||||
|
||||
.call-choice-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--badge-background);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@@ -73,6 +212,11 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.expand-action-icon {
|
||||
font-size: 1.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.status-led {
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
@@ -94,32 +238,36 @@
|
||||
|
||||
.chat-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr);
|
||||
gap:1.25rem;
|
||||
flex: 1 1 auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.peer-sidebar {
|
||||
padding:1rem;
|
||||
border-radius: 1.3rem;
|
||||
border: 1px solid var(--surface-border-soft);
|
||||
background: var(--panel-soft-background);
|
||||
.peer-dropdown {
|
||||
position: relative;
|
||||
min-width: min(18rem, 42vw);
|
||||
}
|
||||
|
||||
.peer-count {
|
||||
display: inline-flex;
|
||||
min-width: 2rem;
|
||||
justify-content: center;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
background: var(--badge-background);
|
||||
.peer-dropdown-trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.peer-list {
|
||||
.peer-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.65rem);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: 4;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
max-height: calc(100dvh - 17rem);
|
||||
max-height: calc(3 * 4.55rem + 1.5rem);
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
background: var(--panel-background);
|
||||
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.peer-tile {
|
||||
@@ -133,10 +281,12 @@
|
||||
border-radius: 1rem;
|
||||
color: inherit;
|
||||
background: var(--surface-background);
|
||||
font-size: 1.05em;
|
||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.peer-tile-main {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -144,14 +294,31 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.peer-tile-indicators {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.peer-dropdown-caret {
|
||||
font-size: 4.02rem;
|
||||
line-height: 1;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.peer-dropdown-caret-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.peer-tile-delete {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
width: 1.54rem;
|
||||
height: 1.54rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 1rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -177,13 +344,13 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
gap: 0.53rem;
|
||||
}
|
||||
|
||||
.peer-tile-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
gap: 0.32rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -195,8 +362,8 @@
|
||||
}
|
||||
|
||||
.peer-typing-dots span {
|
||||
width: 0.38rem;
|
||||
height: 0.38rem;
|
||||
width: 0.27rem;
|
||||
height: 0.27rem;
|
||||
border-radius: 999px;
|
||||
background: var(--page-text);
|
||||
opacity: 0.28;
|
||||
@@ -216,15 +383,17 @@
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.conversation {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
align-content: start;
|
||||
min-height: 24rem;
|
||||
max-height: calc(100dvh - 20rem);
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
@@ -303,6 +472,14 @@
|
||||
background: var(--badge-background);
|
||||
}
|
||||
|
||||
.bubble-emoji-only {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.bubble-meta {
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
@@ -315,6 +492,11 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.emoji-only-text {
|
||||
font-size: clamp(2.1rem, 5vw, 3.4rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.bubble-system-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -335,6 +517,7 @@
|
||||
.composer {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
flex: 0 0 auto;
|
||||
padding-top: 1rem;
|
||||
margin-top: 1rem;
|
||||
border-top: 1px solid var(--surface-border-soft);
|
||||
@@ -343,10 +526,41 @@
|
||||
.composer-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.composer-receive-speed {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
color: var(--page-text-soft);
|
||||
}
|
||||
|
||||
.composer-receive-speed-label {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.composer-receive-speed-value {
|
||||
font-size: 0.92rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.composer-emoji-picker-shell {
|
||||
position: relative;
|
||||
}
|
||||
@@ -384,7 +598,10 @@
|
||||
}
|
||||
|
||||
.composer-textarea {
|
||||
min-height: 7rem;
|
||||
min-height: calc(2 * 1.5rem + 1.25rem);
|
||||
max-height: calc(6 * 1.5rem + 1.25rem);
|
||||
overflow-y: auto;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.composer-call {
|
||||
@@ -399,6 +616,11 @@
|
||||
background: var(--badge-background);
|
||||
}
|
||||
|
||||
.composer-plus {
|
||||
font-size: 1.76rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.composer-dictation {
|
||||
color: var(--page-text);
|
||||
background: linear-gradient(135deg, #f6d8ff, #ffcadb);
|
||||
@@ -470,6 +692,27 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.bubble-preview {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.bubble-preview-label {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.bubble-preview-image {
|
||||
display: block;
|
||||
width: min(240px, 100%);
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bubble-image,
|
||||
.bubble-video {
|
||||
width: 200px;
|
||||
@@ -544,8 +787,23 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.peer-list {
|
||||
max-height: 16rem;
|
||||
.peer-dropdown {
|
||||
min-width: min(100%, 18rem);
|
||||
}
|
||||
|
||||
.status-indicators {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.conversation-modal-backdrop {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.conversation-modal {
|
||||
width: 100vw;
|
||||
height: 100dvh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
|
||||
@@ -4,18 +4,27 @@ import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { PeerVideoModalComponent } from './peer-video-modal.component';
|
||||
import { PeerCallModalComponent } from './peer-call-modal.component';
|
||||
import { ChatSessionService } from './chat-session.service';
|
||||
import { JsonFileViewerComponent } from './json-file-viewer.component';
|
||||
import type { ChatEntry, ConnectionState, PeerSummary } from './models';
|
||||
import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-page',
|
||||
imports: [CommonModule, FormsModule, RouterLink, JsonFileViewerComponent, PeerVideoModalComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
JsonFileViewerComponent,
|
||||
PeerCallModalComponent,
|
||||
],
|
||||
templateUrl: './chat-page.component.html',
|
||||
styleUrl: './chat-page.component.scss',
|
||||
})
|
||||
export class ChatPageComponent implements OnDestroy {
|
||||
private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
: null;
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
@@ -37,34 +46,77 @@ export class ChatPageComponent implements OnDestroy {
|
||||
private dictationCompletionPromise: Promise<void> | null = null;
|
||||
private resolveDictationCompletion: (() => void) | null = null;
|
||||
private dictationApplyToken = 0;
|
||||
private lastConversationSnapshot: { peerId: string; length: number; lastEntryId: string | null } | null = null;
|
||||
@ViewChild('callAudioElement')
|
||||
set callAudioElementRef(value: ElementRef<HTMLAudioElement> | undefined) {
|
||||
this.callAudioElement = value;
|
||||
this.syncCallAudioSource();
|
||||
}
|
||||
private callAudioElement?: ElementRef<HTMLAudioElement>;
|
||||
@ViewChild('conversationContainer')
|
||||
set conversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
||||
this.conversationContainer = value;
|
||||
}
|
||||
private conversationContainer?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('fullscreenConversationContainer')
|
||||
set fullscreenConversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
||||
this.fullscreenConversationContainer = value;
|
||||
}
|
||||
private fullscreenConversationContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
messageText = '';
|
||||
readonly forwardingEntryId = signal<string | null>(null);
|
||||
readonly callChoicePeerId = signal<string | null>(null);
|
||||
readonly conversationModalOpen = signal(false);
|
||||
readonly peerDropdownOpen = signal(false);
|
||||
readonly emojiPickerOpen = signal(false);
|
||||
readonly isRecordingVoice = signal(false);
|
||||
readonly isDictating = signal(false);
|
||||
readonly isTranscribingDictation = signal(false);
|
||||
readonly emojiOptions = [
|
||||
'😀', '😁', '😂', '🤣', '😊',
|
||||
'😉', '😍', '😘', '😎', '🤔',
|
||||
'😅', '😭', '😡', '😴', '🙃',
|
||||
'👍', '👎', '👏', '🙏', '🤝',
|
||||
'🎉', '🔥', '❤️', '💡', '✅',
|
||||
'🚀', '👀', '📹', '📎', '💬',
|
||||
'🌍', '⚡', '⭐', '🎵', '📷',
|
||||
'🗑️', '⏩', '🛑', '🙌', '👌',
|
||||
'😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊',
|
||||
'😋', '😎', '😍', '😘', '🥰', '😗', '😙', '😚', '🙂', '🤗',
|
||||
'🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥',
|
||||
'😮', '🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜',
|
||||
'😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🫠', '🤑', '😲',
|
||||
'☹️', '🙁', '😖', '😞', '😟', '😤', '😢', '😭', '😦', '😧',
|
||||
'😨', '😩', '🤯', '😬', '😰', '😱', '🥵', '🥶', '😳', '🤪',
|
||||
'😵', '🥴', '😠', '😡', '🤬', '😷', '🤒', '🤕', '🤢', '🤮',
|
||||
'🤧', '😇', '🥳', '🥺', '🤠', '🤡', '🤥', '🤫', '🤭', '🧐',
|
||||
'🤓', '😈', '👿', '👹', '👺', '💀', '☠️', '👻', '👽', '🤖',
|
||||
'💩', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾',
|
||||
'🙈', '🙉', '🙊', '💋', '💌', '💘', '💝', '💖', '💗', '💓',
|
||||
'💞', '💕', '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||
'🤎', '💔', '❤️🔥', '❤️🩹', '❣️', '💯', '💢', '💥', '💫', '💦',
|
||||
'💨', '🕳️', '💬', '🗨️', '🗯️', '💭', '💤', '👋', '🤚', '🖐️',
|
||||
'✋', '🖖', '🫱', '🫲', '🫳', '🫴', '👌', '🤌', '🤏', '✌️',
|
||||
'🤞', '🫰', '🤟', '🤘', '🤙', '👈', '👉', '👆', '👇', '☝️',
|
||||
'👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌', '🫶', '👐',
|
||||
'🤲', '🙏', '✍️', '💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶',
|
||||
'👂', '🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀', '👁️',
|
||||
'👅', '👄', '🫦', '🌍', '🌎', '🌏', '🌕', '⭐', '🌟', '✨',
|
||||
'⚡', '🔥', '💧', '🌈', '☀️', '🌤️', '⛅', '🌧️', '⛈️', '🌩️',
|
||||
'❄️', '☃️', '☔', '🍎', '🍊', '🍋', '🍉', '🍇', '🍓', '🍒',
|
||||
'🍑', '🍍', '🥥', '🥑', '🍔', '🍕', '🌮', '🍣', '🍪', '🎂',
|
||||
'☕', '🍵', '🍹', '🎉', '🎈', '🎁', '🏆', '🚀', '📷', '🎵',
|
||||
];
|
||||
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
||||
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
||||
readonly displayedPeer = computed(() => this.peer() ?? this.session.peers()[0] ?? null);
|
||||
readonly currentUser = computed(() => this.session.currentUser());
|
||||
readonly incomingVoiceCallPeer = computed(() => {
|
||||
const peerId = this.session.incomingVoiceCallPeerId();
|
||||
readonly callModalPeerId = computed(() =>
|
||||
this.session.activeVoiceCallPeerId()
|
||||
?? this.session.incomingVoiceCallPeerId()
|
||||
?? this.session.outgoingVoiceCallPeerId()
|
||||
?? null,
|
||||
);
|
||||
readonly callModalPeer = computed(() => {
|
||||
const peerId = this.callModalPeerId();
|
||||
|
||||
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
||||
});
|
||||
readonly callChoicePeer = computed(() => {
|
||||
const peerId = this.callChoicePeerId();
|
||||
|
||||
return peerId ? this.session.peers().find((peer) => peer.id === peerId) ?? null : null;
|
||||
});
|
||||
@@ -73,13 +125,55 @@ export class ChatPageComponent implements OnDestroy {
|
||||
.messages()
|
||||
.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(() =>
|
||||
this.session.remoteAudioStreamForPeer(this.session.activeVoiceCallPeerId() ?? ''),
|
||||
);
|
||||
readonly remoteVideoModalVisible = computed(
|
||||
() => this.session.remoteVideoModalPeerId() === this.peerId() && !!this.remoteVideoStream(),
|
||||
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
|
||||
);
|
||||
readonly callModalMode = computed<CallMode>(() => this.session.callModeForPeer(this.callModalPeerId() ?? '') ?? 'video');
|
||||
readonly localCallStream = computed(() => this.session.localCallStreamForPeer(this.callModalPeerId() ?? ''));
|
||||
readonly remoteCallVideoStream = computed(() => this.session.remoteVideoStreamForPeer(this.callModalPeerId() ?? ''));
|
||||
readonly callModalVisible = computed(() => !!this.callModalPeer());
|
||||
readonly callModalState = computed<'incoming' | 'outgoing' | 'active'>(() => {
|
||||
const peerId = this.callModalPeerId();
|
||||
|
||||
if (!peerId) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if (this.session.incomingVoiceCallPeerId() === peerId) {
|
||||
return 'incoming';
|
||||
}
|
||||
|
||||
if (this.session.outgoingVoiceCallPeerId() === peerId) {
|
||||
return 'outgoing';
|
||||
}
|
||||
|
||||
return 'active';
|
||||
});
|
||||
readonly callModalStatusText = computed(() => {
|
||||
const peer = this.callModalPeer();
|
||||
|
||||
if (!peer) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (this.callModalState()) {
|
||||
case 'incoming':
|
||||
return `${peer.displayName} is calling you${this.callModalMode() === 'audio' ? ' with audio only.' : '.'}`;
|
||||
case 'outgoing':
|
||||
return this.callModalMode() === 'audio'
|
||||
? 'Calling… your microphone is ready.'
|
||||
: 'Calling… your camera and microphone are ready.';
|
||||
default:
|
||||
return this.callModalMode() === 'audio'
|
||||
? 'Connected with live audio.'
|
||||
: 'Connected with live video and audio.';
|
||||
}
|
||||
});
|
||||
readonly selectedPeerVoiceCallState = computed<'idle' | 'incoming' | 'outgoing' | 'active'>(() => {
|
||||
const peerId = this.peerId();
|
||||
|
||||
@@ -160,6 +254,32 @@ export class ChatPageComponent implements OnDestroy {
|
||||
this.remoteCallAudioStream();
|
||||
this.syncCallAudioSource();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const peerId = this.peerId();
|
||||
const entries = this.conversation();
|
||||
const snapshot = {
|
||||
peerId,
|
||||
length: entries.length,
|
||||
lastEntryId: entries.at(-1)?.id ?? null,
|
||||
};
|
||||
const previousSnapshot = this.lastConversationSnapshot;
|
||||
|
||||
this.lastConversationSnapshot = snapshot;
|
||||
|
||||
if (!peerId || !previousSnapshot || previousSnapshot.peerId !== peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasNewTailEntry = snapshot.length > previousSnapshot.length
|
||||
|| (snapshot.length > 0 && snapshot.lastEntryId !== previousSnapshot.lastEntryId);
|
||||
|
||||
if (!hasNewTailEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollConversationToBottom();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -176,7 +296,7 @@ export class ChatPageComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
this.session.selectPeer(peerId);
|
||||
await this.session.connectToPeer(peerId);
|
||||
await this.session.reconnectToPeer(peerId);
|
||||
}
|
||||
|
||||
async sendMessage(): Promise<void> {
|
||||
@@ -265,6 +385,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> {
|
||||
const file = input.files?.item(0);
|
||||
|
||||
@@ -436,6 +579,17 @@ export class ChatPageComponent implements OnDestroy {
|
||||
await this.session.deleteMessage(entry);
|
||||
}
|
||||
|
||||
isEmojiOnlyEntry(entry: ChatEntry): boolean {
|
||||
if (entry.kind !== 'text' || entry.direction === 'system' || !entry.text?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entry.text
|
||||
.trim()
|
||||
.split(/\s+/u)
|
||||
.every((token) => this.isEmojiToken(token));
|
||||
}
|
||||
|
||||
async deleteConversation(peerId: string, event?: Event): Promise<void> {
|
||||
event?.stopPropagation();
|
||||
await this.session.deleteConversation(peerId);
|
||||
@@ -464,6 +618,33 @@ export class ChatPageComponent implements OnDestroy {
|
||||
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
|
||||
}
|
||||
|
||||
togglePeerDropdown(): void {
|
||||
if (this.session.peers().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.peerDropdownOpen.update((open) => !open);
|
||||
}
|
||||
|
||||
closePeerDropdown(): void {
|
||||
this.peerDropdownOpen.set(false);
|
||||
}
|
||||
|
||||
openConversationModal(): void {
|
||||
this.closePeerDropdown();
|
||||
this.conversationModalOpen.set(true);
|
||||
this.scrollConversationToBottom();
|
||||
}
|
||||
|
||||
closeConversationModal(): void {
|
||||
this.conversationModalOpen.set(false);
|
||||
}
|
||||
|
||||
async selectPeerFromDropdown(peerId: string): Promise<void> {
|
||||
this.closePeerDropdown();
|
||||
await this.switchPeer(peerId);
|
||||
}
|
||||
|
||||
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
|
||||
if (!targetPeerId) {
|
||||
return;
|
||||
@@ -474,17 +655,14 @@ export class ChatPageComponent implements OnDestroy {
|
||||
this.forwardingEntryId.set(null);
|
||||
}
|
||||
|
||||
async toggleCameraStream(peerId: string): Promise<void> {
|
||||
if (this.session.isStreamingCameraToPeer(peerId)) {
|
||||
await this.session.stopCameraStream(peerId);
|
||||
async sendGeneratedImage(entry: ChatEntry): Promise<void> {
|
||||
const peerId = this.peerId();
|
||||
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.session.startCameraStream(peerId);
|
||||
}
|
||||
|
||||
async startVoiceCall(peerId: string): Promise<void> {
|
||||
await this.session.startVoiceCall(peerId);
|
||||
await this.session.sendGeneratedImageToPeer(entry, peerId);
|
||||
}
|
||||
|
||||
async endVoiceCall(peerId: string): Promise<void> {
|
||||
@@ -496,10 +674,6 @@ export class ChatPageComponent implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
if (peerId !== this.peerId()) {
|
||||
await this.router.navigate(['/chat', peerId]);
|
||||
}
|
||||
|
||||
await this.session.acceptVoiceCall(peerId);
|
||||
}
|
||||
|
||||
@@ -515,6 +689,10 @@ export class ChatPageComponent implements OnDestroy {
|
||||
return entry.kind === 'file' && !!entry.downloadUrl && (entry.fileMimeType?.startsWith('image/') ?? false);
|
||||
}
|
||||
|
||||
isGeneratedImageEntry(entry: ChatEntry): boolean {
|
||||
return this.isImageEntry(entry) && entry.generatedByAi === true;
|
||||
}
|
||||
|
||||
isVideoEntry(entry: ChatEntry): boolean {
|
||||
if (entry.kind !== 'file' || !entry.downloadUrl) {
|
||||
return false;
|
||||
@@ -537,6 +715,22 @@ export class ChatPageComponent implements OnDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
hasDocumentPreviewImage(entry: ChatEntry): boolean {
|
||||
return (
|
||||
entry.kind === 'file' &&
|
||||
!!entry.previewDownloadUrl &&
|
||||
(entry.previewMimeType?.startsWith('image/') ?? false)
|
||||
);
|
||||
}
|
||||
|
||||
documentPreviewImageUrl(entry: ChatEntry): string | null {
|
||||
if (!this.hasDocumentPreviewImage(entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.previewDownloadUrl ?? null;
|
||||
}
|
||||
|
||||
isPeerTyping(peerId: string): boolean {
|
||||
return this.session.typingPeerIds().includes(peerId);
|
||||
}
|
||||
@@ -558,23 +752,7 @@ export class ChatPageComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
canReconnectWebRtc(): boolean {
|
||||
return this.indicatorTone(this.webRtcState()) === 'offline';
|
||||
}
|
||||
|
||||
isStreamingCameraToSelectedPeer(): boolean {
|
||||
const peerId = this.peerId();
|
||||
|
||||
return !!peerId && this.session.isStreamingCameraToPeer(peerId);
|
||||
}
|
||||
|
||||
closeRemoteVideoModal(): void {
|
||||
const peerId = this.peerId();
|
||||
|
||||
if (!peerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.session.dismissRemoteVideoModal(peerId);
|
||||
return !!this.peerId() && this.indicatorTone(this.webRtcState()) !== 'ok';
|
||||
}
|
||||
|
||||
async switchPeer(peerId: string): Promise<void> {
|
||||
@@ -585,6 +763,9 @@ export class ChatPageComponent implements OnDestroy {
|
||||
await this.stopDictation(true);
|
||||
this.stopVoiceRecording(true);
|
||||
this.forwardingEntryId.set(null);
|
||||
this.callChoicePeerId.set(null);
|
||||
this.conversationModalOpen.set(false);
|
||||
this.peerDropdownOpen.set(false);
|
||||
this.emojiPickerOpen.set(false);
|
||||
this.session.selectPeer(peerId);
|
||||
await this.router.navigate(['/chat', peerId]);
|
||||
@@ -764,4 +945,36 @@ export class ChatPageComponent implements OnDestroy {
|
||||
audio.pause();
|
||||
audio.srcObject = null;
|
||||
}
|
||||
|
||||
private scrollConversationToBottom(): void {
|
||||
queueMicrotask(() => {
|
||||
requestAnimationFrame(() => {
|
||||
for (const container of [
|
||||
this.conversationContainer?.nativeElement,
|
||||
this.fullscreenConversationContainer?.nativeElement,
|
||||
]) {
|
||||
if (!container) {
|
||||
continue;
|
||||
}
|
||||
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private isEmojiToken(token: string): boolean {
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const graphemes = this.graphemeSegmenter
|
||||
? Array.from(this.graphemeSegmenter.segment(token), ({ segment }) => segment)
|
||||
: [token];
|
||||
|
||||
return graphemes.every((grapheme) =>
|
||||
/[\p{Emoji}\p{Extended_Pictographic}\u20E3]/u.test(grapheme)
|
||||
&& /^[\p{Emoji}\p{Emoji_Component}\p{Extended_Pictographic}\u200D\uFE0F\u20E3]+$/u.test(grapheme),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,7 @@ export interface ChatEntry {
|
||||
kind: 'text' | 'json' | 'file' | 'voice' | 'system';
|
||||
createdAt: number;
|
||||
authorLabel: string;
|
||||
generatedByAi?: boolean;
|
||||
showSpinner?: boolean;
|
||||
text?: string;
|
||||
payload?: unknown;
|
||||
@@ -104,8 +105,12 @@ export interface ChatEntry {
|
||||
fileSize?: number;
|
||||
fileMimeType?: string;
|
||||
downloadUrl?: string;
|
||||
previewMimeType?: string;
|
||||
previewDownloadUrl?: string;
|
||||
}
|
||||
|
||||
export type CallMode = 'audio' | 'video';
|
||||
|
||||
export type SignalPayload =
|
||||
| { type: 'sdp'; description: RTCSessionDescriptionInit }
|
||||
| { type: 'ice-candidate'; candidate: RTCIceCandidateInit };
|
||||
@@ -179,12 +184,9 @@ export type DataEnvelope =
|
||||
type: 'typing';
|
||||
active: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'camera-state';
|
||||
active: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'voice-call-offer';
|
||||
mode: CallMode;
|
||||
}
|
||||
| {
|
||||
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;
|
||||
}
|
||||
}
|
||||
110
server/dist/index.js
vendored
110
server/dist/index.js
vendored
@@ -1,14 +1,17 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { TextEncoder } from 'node:util';
|
||||
import { promisify, TextEncoder } from 'node:util';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import websocket from '@fastify/websocket';
|
||||
import dotenv from 'dotenv';
|
||||
import libreOffice from 'libreoffice-convert';
|
||||
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse, } from '@simplewebauthn/server';
|
||||
import Fastify from 'fastify';
|
||||
import { Redis } from 'ioredis';
|
||||
@@ -47,6 +50,11 @@ const adminDeleteUserParamsSchema = z.object({
|
||||
const webBundleFileParamsSchema = z.object({
|
||||
'*': z.string().min(1),
|
||||
});
|
||||
const documentPreviewSchema = z.object({
|
||||
fileName: z.string().trim().min(1).max(256),
|
||||
mimeType: z.string().trim().min(1).max(256),
|
||||
fileBase64: z.string().min(1).max(96_000_000),
|
||||
});
|
||||
const wsQuerySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
@@ -111,6 +119,8 @@ const webAuthnRpName = process.env.WEBAUTHN_RP_NAME ?? 'PrivateChat';
|
||||
const webAuthnUserVerification = resolveWebAuthnUserVerification(process.env.WEBAUTHN_USER_VERIFICATION);
|
||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const speechTranscriber = new SpeechTranscriber({
|
||||
serviceUrl: speechTranscriptionServiceUrl,
|
||||
language: speechTranscriptionLanguage,
|
||||
@@ -462,6 +472,35 @@ app.get('/api/auth/session', async (request, reply) => {
|
||||
messageEncryptionKey: authContext.user.messageEncryptionKey,
|
||||
};
|
||||
});
|
||||
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
if (!authContext) {
|
||||
return;
|
||||
}
|
||||
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({
|
||||
message: 'Invalid document preview payload.',
|
||||
issues: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||
}
|
||||
try {
|
||||
const previewImageBuffer = await createDocumentPreviewImage(parsed.data.fileName, parsed.data.mimeType, parsed.data.fileBase64);
|
||||
return {
|
||||
mimeType: 'image/png',
|
||||
imageBase64: previewImageBuffer.toString('base64'),
|
||||
};
|
||||
}
|
||||
catch (error) {
|
||||
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
|
||||
return reply.code(422).send({
|
||||
message: describeDocumentPreviewFailure(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
if (!authContext) {
|
||||
@@ -835,6 +874,75 @@ async function authenticateTokenFromSession(userId, sessionId, decoded) {
|
||||
},
|
||||
};
|
||||
}
|
||||
async function convertOfficeDocumentToPdf(fileName, fileBase64) {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error('The uploaded office document is empty.');
|
||||
}
|
||||
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||
}
|
||||
async function createDocumentPreviewImage(fileName, mimeType, fileBase64) {
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
const pdfBuffer = normalizedMimeType === 'application/pdf'
|
||||
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
|
||||
: await convertOfficeDocumentToPdf(fileName, fileBase64);
|
||||
return renderPdfFirstPageToPng(pdfBuffer);
|
||||
}
|
||||
async function renderPdfFirstPageToPng(pdfBuffer) {
|
||||
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
|
||||
const pdfPath = path.join(tempDirectory, 'source.pdf');
|
||||
const outputBasePath = path.join(tempDirectory, 'page-preview');
|
||||
const imagePath = `${outputBasePath}.png`;
|
||||
try {
|
||||
await fs.promises.writeFile(pdfPath, pdfBuffer);
|
||||
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
|
||||
return await fs.promises.readFile(imagePath);
|
||||
}
|
||||
finally {
|
||||
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
function decodeBase64File(fileBase64, emptyMessage) {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error(emptyMessage);
|
||||
}
|
||||
return inputBuffer;
|
||||
}
|
||||
function isSupportedPreviewDocument(fileName, mimeType) {
|
||||
if (isPdfFile(fileName, mimeType)) {
|
||||
return true;
|
||||
}
|
||||
return isSupportedOfficeDocument(fileName, mimeType);
|
||||
}
|
||||
function isSupportedOfficeDocument(fileName, mimeType) {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
if (normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation') {
|
||||
return true;
|
||||
}
|
||||
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||
}
|
||||
function isPdfFile(fileName, mimeType) {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
|
||||
}
|
||||
function normalizeOfficeDocumentFileName(fileName) {
|
||||
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||
}
|
||||
function describeDocumentPreviewFailure(error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return 'Document preview generation failed because a required conversion tool is missing on the server.';
|
||||
}
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return `Document preview generation failed: ${error.message}`;
|
||||
}
|
||||
return 'Document preview generation failed.';
|
||||
}
|
||||
function createUser(input) {
|
||||
const createdAt = new Date().toISOString();
|
||||
const user = {
|
||||
|
||||
29
server/package-lock.json
generated
29
server/package-lock.json
generated
@@ -16,6 +16,7 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.8.2",
|
||||
"ioredis": "^5.10.0",
|
||||
"libreoffice-convert": "^1.8.1",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -1002,6 +1003,12 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -1536,6 +1543,19 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libreoffice-convert": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/libreoffice-convert/-/libreoffice-convert-1.8.1.tgz",
|
||||
"integrity": "sha512-iZ1DD/EMTlPvol8G++QQ/0w4pVecSwRuhMLXRm7nRim/gcaSscSXuTO9Tgbkieyw5UdJg7UXD+lkFT8SCi51Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"async": "^3.2.3",
|
||||
"tmp": "^0.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request": {
|
||||
"version": "6.6.0",
|
||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
||||
@@ -2029,6 +2049,15 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/toad-cache": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"fastify": "^5.8.2",
|
||||
"ioredis": "^5.10.0",
|
||||
"libreoffice-convert": "^1.8.1",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { TextEncoder } from 'node:util';
|
||||
import { promisify, TextEncoder } from 'node:util';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
import cors from '@fastify/cors';
|
||||
@@ -10,6 +12,7 @@ import jwt from '@fastify/jwt';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import websocket from '@fastify/websocket';
|
||||
import dotenv from 'dotenv';
|
||||
import libreOffice from 'libreoffice-convert';
|
||||
import {
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
@@ -271,6 +274,12 @@ const webBundleFileParamsSchema = z.object({
|
||||
'*': z.string().min(1),
|
||||
});
|
||||
|
||||
const documentPreviewSchema = z.object({
|
||||
fileName: z.string().trim().min(1).max(256),
|
||||
mimeType: z.string().trim().min(1).max(256),
|
||||
fileBase64: z.string().min(1).max(96_000_000),
|
||||
});
|
||||
|
||||
const wsQuerySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
@@ -346,6 +355,8 @@ const webAuthnUserVerification = resolveWebAuthnUserVerification(
|
||||
);
|
||||
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
|
||||
const hasFrontendBuild = fs.existsSync(frontendIndexPath);
|
||||
const convertOfficeDocument = promisify(libreOffice.convertWithOptions);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const speechTranscriber = new SpeechTranscriber(
|
||||
{
|
||||
@@ -795,6 +806,45 @@ app.get('/api/auth/session', async (request, reply) => {
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/files/document-preview-image', { bodyLimit: 64 * 1024 * 1024 }, async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
|
||||
if (!authContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = documentPreviewSchema.safeParse(request.body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({
|
||||
message: 'Invalid document preview payload.',
|
||||
issues: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSupportedPreviewDocument(parsed.data.fileName, parsed.data.mimeType)) {
|
||||
return reply.code(400).send({ message: 'Only PDF, DOCX, XLSX, and PPTX files can be previewed.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const previewImageBuffer = await createDocumentPreviewImage(
|
||||
parsed.data.fileName,
|
||||
parsed.data.mimeType,
|
||||
parsed.data.fileBase64,
|
||||
);
|
||||
|
||||
return {
|
||||
mimeType: 'image/png',
|
||||
imageBase64: previewImageBuffer.toString('base64'),
|
||||
};
|
||||
} catch (error) {
|
||||
app.log.warn({ err: error, userId: authContext.user.id }, 'Document preview generation failed');
|
||||
return reply.code(422).send({
|
||||
message: describeDocumentPreviewFailure(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/admin/pending-users', async (request, reply) => {
|
||||
const authContext = await authenticateRequest(request, reply);
|
||||
|
||||
@@ -1294,6 +1344,101 @@ async function authenticateTokenFromSession(
|
||||
};
|
||||
}
|
||||
|
||||
async function convertOfficeDocumentToPdf(fileName: string, fileBase64: string): Promise<Buffer> {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error('The uploaded office document is empty.');
|
||||
}
|
||||
|
||||
const normalizedFileName = normalizeOfficeDocumentFileName(fileName);
|
||||
return convertOfficeDocument(inputBuffer, '.pdf', undefined, { fileName: normalizedFileName });
|
||||
}
|
||||
|
||||
async function createDocumentPreviewImage(
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
fileBase64: string,
|
||||
): Promise<Buffer> {
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
const pdfBuffer = normalizedMimeType === 'application/pdf'
|
||||
? decodeBase64File(fileBase64, 'The uploaded PDF is empty.')
|
||||
: await convertOfficeDocumentToPdf(fileName, fileBase64);
|
||||
|
||||
return renderPdfFirstPageToPng(pdfBuffer);
|
||||
}
|
||||
|
||||
async function renderPdfFirstPageToPng(pdfBuffer: Buffer): Promise<Buffer> {
|
||||
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'privatechat-preview-'));
|
||||
const pdfPath = path.join(tempDirectory, 'source.pdf');
|
||||
const outputBasePath = path.join(tempDirectory, 'page-preview');
|
||||
const imagePath = `${outputBasePath}.png`;
|
||||
|
||||
try {
|
||||
await fs.promises.writeFile(pdfPath, pdfBuffer);
|
||||
await execFileAsync('pdftoppm', ['-png', '-f', '1', '-singlefile', pdfPath, outputBasePath]);
|
||||
return await fs.promises.readFile(imagePath);
|
||||
} finally {
|
||||
await fs.promises.rm(tempDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64File(fileBase64: string, emptyMessage: string): Buffer {
|
||||
const inputBuffer = Buffer.from(fileBase64, 'base64');
|
||||
|
||||
if (inputBuffer.byteLength === 0) {
|
||||
throw new Error(emptyMessage);
|
||||
}
|
||||
|
||||
return inputBuffer;
|
||||
}
|
||||
|
||||
function isSupportedPreviewDocument(fileName: string, mimeType: string): boolean {
|
||||
if (isPdfFile(fileName, mimeType)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isSupportedOfficeDocument(fileName, mimeType);
|
||||
}
|
||||
|
||||
function isSupportedOfficeDocument(fileName: string, mimeType: string): boolean {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
|
||||
if (
|
||||
normalizedMimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
|| normalizedMimeType === 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /\.(docx|xlsx|xslx|pptx)$/i.test(normalizedFileName);
|
||||
}
|
||||
|
||||
function isPdfFile(fileName: string, mimeType: string): boolean {
|
||||
const normalizedFileName = fileName.trim().toLowerCase();
|
||||
const normalizedMimeType = mimeType.trim().toLowerCase();
|
||||
|
||||
return normalizedMimeType === 'application/pdf' || normalizedFileName.endsWith('.pdf');
|
||||
}
|
||||
|
||||
function normalizeOfficeDocumentFileName(fileName: string): string {
|
||||
return fileName.trim().replace(/\.xslx$/i, '.xlsx');
|
||||
}
|
||||
|
||||
function describeDocumentPreviewFailure(error: unknown): string {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
return 'Document preview generation failed because a required conversion tool is missing on the server.';
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return `Document preview generation failed: ${error.message}`;
|
||||
}
|
||||
|
||||
return 'Document preview generation failed.';
|
||||
}
|
||||
|
||||
function createUser(input: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
|
||||
Reference in New Issue
Block a user