Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84745eb104 | |||
| ae59d3deac | |||
| 687bd56e42 | |||
| 03d3b75fb4 | |||
| 32084a66d1 | |||
| 64e03964e9 |
@@ -40,12 +40,129 @@
|
|||||||
</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">{{ peer()?.displayName ?? 'Conversation' }}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="conversation-modal-close"
|
||||||
|
type="button"
|
||||||
|
(click)="closeConversationModal()"
|
||||||
|
aria-label="Close fullscreen conversation"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div #fullscreenConversationContainer class="conversation conversation-modal-body">
|
||||||
|
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="chat-header mb-4">
|
||||||
|
@if (currentUser(); as connectedUser) {
|
||||||
|
<div class="chat-header-main">
|
||||||
|
<a class="back-link" routerLink="/" aria-label="Back to dashboard">←</a>
|
||||||
|
<h1 class="chat-header-title mb-0">{{ connectedUser.displayName }}</h1>
|
||||||
|
@if (displayedPeer(); as selectedPeer) {
|
||||||
|
<div class="peer-dropdown" (click)="$event.stopPropagation()">
|
||||||
|
<button
|
||||||
|
class="peer-dropdown-trigger peer-tile"
|
||||||
|
type="button"
|
||||||
|
[class.peer-tile-active]="true"
|
||||||
|
[class.peer-tile-unread]="isPeerUnread(selectedPeer.id)"
|
||||||
|
(click)="togglePeerDropdown()"
|
||||||
|
[attr.aria-expanded]="peerDropdownOpen()"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-label="Choose peer"
|
||||||
|
>
|
||||||
|
<span class="peer-tile-main text-start">
|
||||||
|
<span class="peer-tile-row">
|
||||||
|
<span class="peer-tile-title">
|
||||||
|
<span class="fw-semibold">{{ selectedPeer.displayName }}</span>
|
||||||
|
@if (isPeerTyping(selectedPeer.id)) {
|
||||||
|
<span class="peer-typing-dots" aria-label="Typing">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<span class="peer-tile-indicators">
|
||||||
|
<span
|
||||||
|
class="status-led peer-tile-status"
|
||||||
|
[class.status-led-ok]="selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'"
|
||||||
|
[class.status-led-offline]="selectedPeer.channelState !== 'open' && selectedPeer.connectionState !== 'connected'"
|
||||||
|
[attr.aria-label]="
|
||||||
|
selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'
|
||||||
|
? 'Connected'
|
||||||
|
: 'Disconnected'
|
||||||
|
"
|
||||||
|
></span>
|
||||||
|
<span class="peer-dropdown-caret" [class.peer-dropdown-caret-open]="peerDropdownOpen()">▾</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (peerDropdownOpen()) {
|
||||||
|
<div class="peer-dropdown-menu" role="listbox">
|
||||||
|
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
||||||
|
<article
|
||||||
|
class="peer-tile"
|
||||||
|
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
||||||
|
[class.peer-tile-unread]="isPeerUnread(connectedPeer.id)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="peer-tile-main text-start"
|
||||||
|
type="button"
|
||||||
|
(click)="selectPeerFromDropdown(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>
|
||||||
|
}
|
||||||
|
</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>
|
||||||
@@ -61,225 +178,37 @@
|
|||||||
<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 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 #conversationContainer class="conversation">
|
<div #conversationContainer class="conversation">
|
||||||
@if (conversation().length === 0) {
|
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||||
<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">
|
|
||||||
@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>
|
|
||||||
}
|
|
||||||
<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>
|
|
||||||
}
|
|
||||||
|
|
||||||
@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>
|
|
||||||
}
|
|
||||||
</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)"
|
||||||
@@ -290,139 +219,297 @@
|
|||||||
placeholder="Write a text message to your peer"
|
placeholder="Write a text message to your peer"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<div class="composer-toolbar">
|
<div class="composer-toolbar">
|
||||||
@if (peer(); as selectedPeer) {
|
<div class="composer-actions">
|
||||||
<button
|
@if (peer(); as selectedPeer) {
|
||||||
class="composer-call"
|
<button
|
||||||
type="button"
|
class="composer-call"
|
||||||
[disabled]="!canStartSelectedVoiceCall()"
|
type="button"
|
||||||
(click)="openCallChoice(selectedPeer.id)"
|
[disabled]="!canStartSelectedVoiceCall()"
|
||||||
title="Start call"
|
(click)="openCallChoice(selectedPeer.id)"
|
||||||
aria-label="Start call"
|
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(selectedPeer.id)"
|
||||||
title="End call"
|
title="End call"
|
||||||
aria-label="End call"
|
aria-label="End call"
|
||||||
>
|
>
|
||||||
🛑
|
🛑
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="composer-voice"
|
class="composer-voice"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
|
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
|
||||||
(click)="toggleVoiceRecording()"
|
(click)="toggleVoiceRecording()"
|
||||||
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
[title]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||||
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
[attr.aria-label]="isRecordingVoice() ? 'Stop and send voice message' : 'Record voice message'"
|
||||||
[class.composer-voice-recording]="isRecordingVoice()"
|
[class.composer-voice-recording]="isRecordingVoice()"
|
||||||
>
|
>
|
||||||
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
|
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="composer-dictation"
|
class="composer-dictation"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
|
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
|
||||||
(click)="toggleDictation(composerTextarea)"
|
(click)="toggleDictation(composerTextarea)"
|
||||||
[title]="
|
[title]="
|
||||||
isDictating()
|
isDictating()
|
||||||
? 'Stop dictation and transcribe'
|
? 'Stop dictation and transcribe'
|
||||||
: isTranscribingDictation()
|
: isTranscribingDictation()
|
||||||
? 'Transcribing dictated audio'
|
? 'Transcribing dictated audio'
|
||||||
: 'Start dictation'
|
: 'Start dictation'
|
||||||
"
|
"
|
||||||
[attr.aria-label]="
|
[attr.aria-label]="
|
||||||
isDictating()
|
isDictating()
|
||||||
? 'Stop dictation and transcribe'
|
? 'Stop dictation and transcribe'
|
||||||
: isTranscribingDictation()
|
: isTranscribingDictation()
|
||||||
? 'Transcribing dictated audio'
|
? 'Transcribing dictated audio'
|
||||||
: 'Start dictation'
|
: 'Start dictation'
|
||||||
"
|
"
|
||||||
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
|
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
|
||||||
>
|
>
|
||||||
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
|
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
#fileInput
|
#fileInput
|
||||||
class="composer-file-input"
|
class="composer-file-input"
|
||||||
type="file"
|
type="file"
|
||||||
[disabled]="selectedPeer.channelState !== 'open'"
|
[disabled]="selectedPeer.channelState !== 'open'"
|
||||||
(change)="sendFile(selectedPeer.id, fileInput)"
|
(change)="sendFile(selectedPeer.id, fileInput)"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="composer-plus"
|
class="composer-plus"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="selectedPeer.channelState !== 'open'"
|
[disabled]="selectedPeer.channelState !== 'open'"
|
||||||
(click)="fileInput.click()"
|
(click)="fileInput.click()"
|
||||||
title="Send file"
|
title="Send file"
|
||||||
aria-label="Send file"
|
aria-label="Send file"
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="composer-image-generate"
|
class="composer-image-generate"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
|
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
|
||||||
(click)="requestGeneratedImage()"
|
(click)="requestGeneratedImage()"
|
||||||
title="Generate image from prompt"
|
title="Generate image from prompt"
|
||||||
aria-label="Generate image from prompt"
|
aria-label="Generate image from prompt"
|
||||||
>
|
>
|
||||||
🖼️
|
🖼️
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="composer-emoji-picker-shell">
|
<div class="composer-emoji-picker-shell">
|
||||||
@if (emojiPickerOpen()) {
|
@if (emojiPickerOpen()) {
|
||||||
<div class="composer-emoji-picker">
|
<div class="composer-emoji-picker">
|
||||||
@for (emoji of emojiOptions; track emoji) {
|
@for (emoji of emojiOptions; track emoji) {
|
||||||
<button
|
<button
|
||||||
class="composer-emoji-option"
|
class="composer-emoji-option"
|
||||||
type="button"
|
type="button"
|
||||||
(click)="insertEmoji(emoji, composerTextarea)"
|
(click)="insertEmoji(emoji, composerTextarea)"
|
||||||
[attr.aria-label]="'Insert ' + emoji"
|
[attr.aria-label]="'Insert ' + emoji"
|
||||||
[title]="'Insert ' + emoji"
|
[title]="'Insert ' + emoji"
|
||||||
>
|
>
|
||||||
{{ emoji }}
|
{{ emoji }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<button
|
<button
|
||||||
class="composer-emoji-trigger"
|
class="composer-emoji-trigger"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
[disabled]="!session.isSelectedPeerReady()"
|
||||||
(click)="toggleEmojiPicker($event)"
|
(click)="toggleEmojiPicker($event)"
|
||||||
title="Insert emoji"
|
title="Insert emoji"
|
||||||
aria-label="Insert emoji"
|
aria-label="Insert emoji"
|
||||||
>
|
>
|
||||||
😀
|
😀
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="send-emoji"
|
class="send-emoji"
|
||||||
type="button"
|
type="button"
|
||||||
[disabled]="!session.isSelectedPeerReady()"
|
[disabled]="!session.isSelectedPeerReady()"
|
||||||
(click)="sendMessage()"
|
(click)="sendMessage()"
|
||||||
title="Send message"
|
title="Send message"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
>
|
>
|
||||||
✅
|
✅
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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 {
|
.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;
|
margin-inline: auto;
|
||||||
overflow-x: hidden;
|
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 {
|
.call-modal-backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -48,6 +69,59 @@
|
|||||||
width: min(100%, 25rem);
|
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 {
|
.call-choice-eyebrow {
|
||||||
margin-bottom: 0.45rem;
|
margin-bottom: 0.45rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -91,14 +165,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-link {
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
color: var(--link-color);
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-indicators {
|
.status-indicators {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-indicator {
|
.status-indicator {
|
||||||
@@ -130,6 +212,11 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.expand-action-icon {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.status-led {
|
.status-led {
|
||||||
width: 0.8rem;
|
width: 0.8rem;
|
||||||
height: 0.8rem;
|
height: 0.8rem;
|
||||||
@@ -151,32 +238,36 @@
|
|||||||
|
|
||||||
.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 {
|
.peer-dropdown {
|
||||||
padding:1rem;
|
position: relative;
|
||||||
border-radius: 1.3rem;
|
min-width: min(18rem, 42vw);
|
||||||
border: 1px solid var(--surface-border-soft);
|
|
||||||
background: var(--panel-soft-background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-count {
|
.peer-dropdown-trigger {
|
||||||
display: inline-flex;
|
width: 100%;
|
||||||
min-width: 2rem;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.35rem 0.65rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
background: var(--badge-background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-list {
|
.peer-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.65rem);
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 4;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
max-height: calc(100dvh - 17rem);
|
max-height: calc(3 * 4.55rem + 1.5rem);
|
||||||
overflow: auto;
|
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 {
|
.peer-tile {
|
||||||
@@ -190,10 +281,12 @@
|
|||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: var(--surface-background);
|
background: var(--surface-background);
|
||||||
|
font-size: 1.05em;
|
||||||
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-tile-main {
|
.peer-tile-main {
|
||||||
|
display: block;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -201,14 +294,31 @@
|
|||||||
background: transparent;
|
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 {
|
.peer-tile-delete {
|
||||||
width: 2.2rem;
|
width: 1.54rem;
|
||||||
height: 2.2rem;
|
height: 1.54rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
font-size: 1rem;
|
font-size: 0.7rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,13 +344,13 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.53rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-tile-title {
|
.peer-tile-title {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.45rem;
|
gap: 0.32rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,8 +362,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.peer-typing-dots span {
|
.peer-typing-dots span {
|
||||||
width: 0.38rem;
|
width: 0.27rem;
|
||||||
height: 0.38rem;
|
height: 0.27rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--page-text);
|
background: var(--page-text);
|
||||||
opacity: 0.28;
|
opacity: 0.28;
|
||||||
@@ -273,15 +383,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-main {
|
.chat-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: minmax(0, 1fr) auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation {
|
.conversation {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
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.5rem 0;
|
||||||
}
|
}
|
||||||
@@ -360,6 +472,14 @@
|
|||||||
background: var(--badge-background);
|
background: var(--badge-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bubble-emoji-only {
|
||||||
|
max-width: none;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.bubble-meta {
|
.bubble-meta {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.12rem;
|
gap: 0.12rem;
|
||||||
@@ -372,6 +492,11 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.emoji-only-text {
|
||||||
|
font-size: clamp(2.1rem, 5vw, 3.4rem);
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
.bubble-system-status {
|
.bubble-system-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -392,6 +517,7 @@
|
|||||||
.composer {
|
.composer {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.85rem;
|
gap: 0.85rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
border-top: 1px solid var(--surface-border-soft);
|
border-top: 1px solid var(--surface-border-soft);
|
||||||
@@ -400,10 +526,41 @@
|
|||||||
.composer-toolbar {
|
.composer-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
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;
|
gap: 0.6rem;
|
||||||
align-items: center;
|
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 {
|
.composer-emoji-picker-shell {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -441,7 +598,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.composer-textarea {
|
.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 {
|
.composer-call {
|
||||||
@@ -456,6 +616,11 @@
|
|||||||
background: var(--badge-background);
|
background: var(--badge-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.composer-plus {
|
||||||
|
font-size: 1.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.composer-dictation {
|
.composer-dictation {
|
||||||
color: var(--page-text);
|
color: var(--page-text);
|
||||||
background: linear-gradient(135deg, #f6d8ff, #ffcadb);
|
background: linear-gradient(135deg, #f6d8ff, #ffcadb);
|
||||||
@@ -622,8 +787,23 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-list {
|
.peer-dropdown {
|
||||||
max-height: 16rem;
|
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 {
|
.bubble {
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
|
|||||||
styleUrl: './chat-page.component.scss',
|
styleUrl: './chat-page.component.scss',
|
||||||
})
|
})
|
||||||
export class ChatPageComponent implements OnDestroy {
|
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 route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly ngZone = inject(NgZone);
|
private readonly ngZone = inject(NgZone);
|
||||||
@@ -55,26 +58,51 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.conversationContainer = value;
|
this.conversationContainer = value;
|
||||||
}
|
}
|
||||||
private conversationContainer?: ElementRef<HTMLDivElement>;
|
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 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 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 displayedPeer = computed(() => this.peer() ?? this.session.peers()[0] ?? null);
|
||||||
readonly currentUser = computed(() => this.session.currentUser());
|
readonly currentUser = computed(() => this.session.currentUser());
|
||||||
readonly callModalPeerId = computed(() =>
|
readonly callModalPeerId = computed(() =>
|
||||||
this.session.activeVoiceCallPeerId()
|
this.session.activeVoiceCallPeerId()
|
||||||
@@ -97,6 +125,11 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
.messages()
|
.messages()
|
||||||
.filter((entry) => entry.peerId === this.peerId()),
|
.filter((entry) => entry.peerId === 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.callModalPeerId() ?? ''),
|
this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''),
|
||||||
);
|
);
|
||||||
@@ -263,7 +296,7 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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> {
|
||||||
@@ -546,6 +579,17 @@ 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);
|
||||||
@@ -574,6 +618,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.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> {
|
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
|
||||||
if (!targetPeerId) {
|
if (!targetPeerId) {
|
||||||
return;
|
return;
|
||||||
@@ -681,7 +752,7 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
canReconnectWebRtc(): boolean {
|
canReconnectWebRtc(): boolean {
|
||||||
return this.indicatorTone(this.webRtcState()) === 'offline';
|
return !!this.peerId() && this.indicatorTone(this.webRtcState()) !== 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
async switchPeer(peerId: string): Promise<void> {
|
async switchPeer(peerId: string): Promise<void> {
|
||||||
@@ -693,6 +764,8 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.stopVoiceRecording(true);
|
this.stopVoiceRecording(true);
|
||||||
this.forwardingEntryId.set(null);
|
this.forwardingEntryId.set(null);
|
||||||
this.callChoicePeerId.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);
|
this.session.selectPeer(peerId);
|
||||||
await this.router.navigate(['/chat', peerId]);
|
await this.router.navigate(['/chat', peerId]);
|
||||||
@@ -874,16 +947,34 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private scrollConversationToBottom(): void {
|
private scrollConversationToBottom(): void {
|
||||||
const container = this.conversationContainer?.nativeElement;
|
|
||||||
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
container.scrollTop = container.scrollHeight;
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ type IncomingFileTransfer = {
|
|||||||
authorName: string;
|
authorName: string;
|
||||||
chunks: ArrayBuffer[];
|
chunks: ArrayBuffer[];
|
||||||
receivedBytes: number;
|
receivedBytes: number;
|
||||||
|
controlBytes: number;
|
||||||
|
startedAtMs: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PersistedBinary = string | ArrayBuffer;
|
type PersistedBinary = string | ArrayBuffer;
|
||||||
@@ -146,6 +148,7 @@ export class ChatSessionService {
|
|||||||
readonly status = signal('Disconnected from signaling server.');
|
readonly status = signal('Disconnected from signaling server.');
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly notice = signal<string | null>(null);
|
readonly notice = signal<string | null>(null);
|
||||||
|
readonly lastIncomingReceiveMetric = signal<{ peerId: string; mbps: number } | null>(null);
|
||||||
readonly webAuthnSupported = signal(
|
readonly webAuthnSupported = signal(
|
||||||
typeof window !== 'undefined' &&
|
typeof window !== 'undefined' &&
|
||||||
typeof window.PublicKeyCredential !== 'undefined' &&
|
typeof window.PublicKeyCredential !== 'undefined' &&
|
||||||
@@ -382,6 +385,15 @@ export class ChatSessionService {
|
|||||||
await this.negotiatePeer(peerId, bundle);
|
await this.negotiatePeer(peerId, bundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reconnectToPeer(peerId: string): Promise<void> {
|
||||||
|
if (!peerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.releasePeerBundle(peerId, true);
|
||||||
|
await this.connectToPeer(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
localCallStreamForPeer(peerId: string): MediaStream | null {
|
localCallStreamForPeer(peerId: string): MediaStream | null {
|
||||||
return this.localCallStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
|
return this.localCallStreams().find((entry) => entry.peerId === peerId)?.stream ?? null;
|
||||||
}
|
}
|
||||||
@@ -1493,8 +1505,15 @@ export class ChatSessionService {
|
|||||||
};
|
};
|
||||||
|
|
||||||
channel.onmessage = (event) => {
|
channel.onmessage = (event) => {
|
||||||
|
const receivedAtMs = this.nowMs();
|
||||||
|
|
||||||
if (typeof event.data === 'string') {
|
if (typeof event.data === 'string') {
|
||||||
this.handleChannelEnvelope(peerId, JSON.parse(event.data) as DataEnvelope);
|
this.handleChannelEnvelope(
|
||||||
|
peerId,
|
||||||
|
JSON.parse(event.data) as DataEnvelope,
|
||||||
|
receivedAtMs,
|
||||||
|
this.measureStringBytes(event.data),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1502,7 +1521,7 @@ export class ChatSessionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleChannelEnvelope(peerId: string, envelope: DataEnvelope): void {
|
private handleChannelEnvelope(peerId: string, envelope: DataEnvelope, receivedAtMs: number, rawSizeBytes: number): void {
|
||||||
switch (envelope.type) {
|
switch (envelope.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
this.pushMessage({
|
this.pushMessage({
|
||||||
@@ -1514,6 +1533,7 @@ export class ChatSessionService {
|
|||||||
authorLabel: envelope.authorName,
|
authorLabel: envelope.authorName,
|
||||||
text: envelope.body,
|
text: envelope.body,
|
||||||
});
|
});
|
||||||
|
this.recordIncomingReceiveMetric(peerId, rawSizeBytes, receivedAtMs, receivedAtMs);
|
||||||
break;
|
break;
|
||||||
case 'json':
|
case 'json':
|
||||||
this.pushMessage({
|
this.pushMessage({
|
||||||
@@ -1525,6 +1545,7 @@ export class ChatSessionService {
|
|||||||
authorLabel: envelope.authorName,
|
authorLabel: envelope.authorName,
|
||||||
payload: envelope.body,
|
payload: envelope.body,
|
||||||
});
|
});
|
||||||
|
this.recordIncomingReceiveMetric(peerId, rawSizeBytes, receivedAtMs, receivedAtMs);
|
||||||
break;
|
break;
|
||||||
case 'file-meta':
|
case 'file-meta':
|
||||||
this.incomingFiles.set(peerId, {
|
this.incomingFiles.set(peerId, {
|
||||||
@@ -1537,11 +1558,13 @@ export class ChatSessionService {
|
|||||||
authorName: envelope.authorName,
|
authorName: envelope.authorName,
|
||||||
chunks: [],
|
chunks: [],
|
||||||
receivedBytes: 0,
|
receivedBytes: 0,
|
||||||
|
controlBytes: rawSizeBytes,
|
||||||
|
startedAtMs: receivedAtMs,
|
||||||
});
|
});
|
||||||
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
|
this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`);
|
||||||
break;
|
break;
|
||||||
case 'file-complete':
|
case 'file-complete':
|
||||||
void this.finalizeIncomingFile(peerId, envelope.id);
|
void this.finalizeIncomingFile(peerId, envelope.id, rawSizeBytes, receivedAtMs);
|
||||||
break;
|
break;
|
||||||
case 'typing':
|
case 'typing':
|
||||||
this.setPeerTyping(peerId, envelope.active);
|
this.setPeerTyping(peerId, envelope.active);
|
||||||
@@ -1571,7 +1594,12 @@ export class ChatSessionService {
|
|||||||
transfer.receivedBytes += arrayBuffer.byteLength;
|
transfer.receivedBytes += arrayBuffer.byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async finalizeIncomingFile(peerId: string, transferId: string): Promise<void> {
|
private async finalizeIncomingFile(
|
||||||
|
peerId: string,
|
||||||
|
transferId: string,
|
||||||
|
completionEnvelopeBytes: number,
|
||||||
|
completedAtMs: number,
|
||||||
|
): Promise<void> {
|
||||||
const transfer = this.incomingFiles.get(peerId);
|
const transfer = this.incomingFiles.get(peerId);
|
||||||
|
|
||||||
if (!transfer || transfer.id !== transferId) {
|
if (!transfer || transfer.id !== transferId) {
|
||||||
@@ -1610,6 +1638,12 @@ export class ChatSessionService {
|
|||||||
previewMimeType,
|
previewMimeType,
|
||||||
previewDownloadUrl,
|
previewDownloadUrl,
|
||||||
}, blob, previewBlob);
|
}, blob, previewBlob);
|
||||||
|
this.recordIncomingReceiveMetric(
|
||||||
|
peerId,
|
||||||
|
transfer.controlBytes + transfer.receivedBytes + completionEnvelopeBytes,
|
||||||
|
transfer.startedAtMs,
|
||||||
|
completedAtMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
|
private async flushPendingCandidates(bundle: PeerBundle): Promise<void> {
|
||||||
@@ -3123,6 +3157,30 @@ export class ChatSessionService {
|
|||||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private measureStringBytes(value: string): number {
|
||||||
|
return new TextEncoder().encode(value).byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nowMs(): number {
|
||||||
|
return typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordIncomingReceiveMetric(peerId: string, totalBytes: number, startedAtMs: number, endedAtMs: number): void {
|
||||||
|
if (!peerId || totalBytes <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const durationSeconds = Math.max((endedAtMs - startedAtMs) / 1000, 0.001);
|
||||||
|
const mbps = (totalBytes * 8) / durationSeconds / 1_000_000;
|
||||||
|
|
||||||
|
this.lastIncomingReceiveMetric.set({
|
||||||
|
peerId,
|
||||||
|
mbps: Number.isFinite(mbps) ? mbps : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private readUserStorage(): UserProfile | null {
|
private readUserStorage(): UserProfile | null {
|
||||||
const value = this.readStorage('privatechat.user');
|
const value = this.readStorage('privatechat.user');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user