diff --git a/client/src/app/chat-page.component.html b/client/src/app/chat-page.component.html index 7d986cb..1fa31ac 100644 --- a/client/src/app/chat-page.component.html +++ b/client/src/app/chat-page.component.html @@ -219,136 +219,145 @@ placeholder="Write a text message to your peer" > -
- @if (peer(); as selectedPeer) { - +
+
+ @if (peer(); as selectedPeer) { + - @if (canEndSelectedVoiceCall()) { - - } + @if (canEndSelectedVoiceCall()) { + + } - + - + - - - } + + + } - + -
- @if (emojiPickerOpen()) { -
- @for (emoji of emojiOptions; track emoji) { - - } -
- } - -
+
+ @if (emojiPickerOpen()) { +
+ @for (emoji of emojiOptions; track emoji) { + + } +
+ } + +
- -
+ +
+ + @if (lastIncomingReceiveMetric(); as receiveMetric) { +
+ Rx + {{ receiveMetric.mbps | number: '1.2-2' }} Mbit/s +
+ } +
diff --git a/client/src/app/chat-page.component.scss b/client/src/app/chat-page.component.scss index 81f0aff..46372bf 100644 --- a/client/src/app/chat-page.component.scss +++ b/client/src/app/chat-page.component.scss @@ -526,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; } @@ -585,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); diff --git a/client/src/app/chat-page.component.ts b/client/src/app/chat-page.component.ts index 662ca1f..7aaaa52 100644 --- a/client/src/app/chat-page.component.ts +++ b/client/src/app/chat-page.component.ts @@ -125,6 +125,11 @@ export class ChatPageComponent implements OnDestroy { .messages() .filter((entry) => entry.peerId === this.peerId()), ); + readonly lastIncomingReceiveMetric = computed(() => { + const metric = this.session.lastIncomingReceiveMetric(); + + return metric?.peerId === this.peerId() ? metric : null; + }); readonly remoteCallAudioStream = computed(() => this.session.remoteAudioStreamForPeer(this.callModalPeerId() ?? ''), ); diff --git a/client/src/app/chat-session.service.ts b/client/src/app/chat-session.service.ts index 544bcc2..4cef67b 100644 --- a/client/src/app/chat-session.service.ts +++ b/client/src/app/chat-session.service.ts @@ -43,6 +43,8 @@ type IncomingFileTransfer = { authorName: string; chunks: ArrayBuffer[]; receivedBytes: number; + controlBytes: number; + startedAtMs: number; }; type PersistedBinary = string | ArrayBuffer; @@ -146,6 +148,7 @@ export class ChatSessionService { readonly status = signal('Disconnected from signaling server.'); readonly error = signal(null); readonly notice = signal(null); + readonly lastIncomingReceiveMetric = signal<{ peerId: string; mbps: number } | null>(null); readonly webAuthnSupported = signal( typeof window !== 'undefined' && typeof window.PublicKeyCredential !== 'undefined' && @@ -1502,8 +1505,15 @@ export class ChatSessionService { }; channel.onmessage = (event) => { + const receivedAtMs = this.nowMs(); + 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; } @@ -1511,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) { case 'text': this.pushMessage({ @@ -1523,6 +1533,7 @@ export class ChatSessionService { authorLabel: envelope.authorName, text: envelope.body, }); + this.recordIncomingReceiveMetric(peerId, rawSizeBytes, receivedAtMs, receivedAtMs); break; case 'json': this.pushMessage({ @@ -1534,6 +1545,7 @@ export class ChatSessionService { authorLabel: envelope.authorName, payload: envelope.body, }); + this.recordIncomingReceiveMetric(peerId, rawSizeBytes, receivedAtMs, receivedAtMs); break; case 'file-meta': this.incomingFiles.set(peerId, { @@ -1546,11 +1558,13 @@ export class ChatSessionService { authorName: envelope.authorName, chunks: [], receivedBytes: 0, + controlBytes: rawSizeBytes, + startedAtMs: receivedAtMs, }); this.addSystemMessage(peerId, `Receiving file ${envelope.name}.`); break; case 'file-complete': - void this.finalizeIncomingFile(peerId, envelope.id); + void this.finalizeIncomingFile(peerId, envelope.id, rawSizeBytes, receivedAtMs); break; case 'typing': this.setPeerTyping(peerId, envelope.active); @@ -1580,7 +1594,12 @@ export class ChatSessionService { transfer.receivedBytes += arrayBuffer.byteLength; } - private async finalizeIncomingFile(peerId: string, transferId: string): Promise { + private async finalizeIncomingFile( + peerId: string, + transferId: string, + completionEnvelopeBytes: number, + completedAtMs: number, + ): Promise { const transfer = this.incomingFiles.get(peerId); if (!transfer || transfer.id !== transferId) { @@ -1619,6 +1638,12 @@ export class ChatSessionService { previewMimeType, previewDownloadUrl, }, blob, previewBlob); + this.recordIncomingReceiveMetric( + peerId, + transfer.controlBytes + transfer.receivedBytes + completionEnvelopeBytes, + transfer.startedAtMs, + completedAtMs, + ); } private async flushPendingCandidates(bundle: PeerBundle): Promise { @@ -3132,6 +3157,30 @@ export class ChatSessionService { 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 { const value = this.readStorage('privatechat.user');