6 Commits
3.5 ... 3.6

Author SHA1 Message Date
84745eb104 bandwidth meter 2026-03-11 18:12:08 +01:00
ae59d3deac cosmetic fixes 2026-03-11 17:59:14 +01:00
687bd56e42 peer list at top, emoji w/o bubbles 2026-03-11 17:17:54 +01:00
03d3b75fb4 peer list touch up 2026-03-11 16:55:34 +01:00
32084a66d1 full screen chat for iphone 2026-03-11 16:48:39 +01:00
64e03964e9 fixed scrolling 2026-03-11 16:22:17 +01:00
4 changed files with 804 additions and 388 deletions

View File

@@ -40,46 +40,79 @@
</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 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> <div>
<a class="back-link" routerLink="/">← Back to dashboard</a> <p class="conversation-modal-eyebrow mb-1">Fullscreen conversation</p>
@if (currentUser(); as connectedUser) { <h2 class="h5 mb-0">{{ peer()?.displayName ?? 'Conversation' }}</h2>
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
<div class="status-indicators mt-2">
<div class="status-indicator">
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
<span>Signaling</span>
</div> </div>
<button <button
class="status-indicator status-indicator-action" class="conversation-modal-close"
type="button" type="button"
[disabled]="!canReconnectWebRtc()" (click)="closeConversationModal()"
[attr.aria-label]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'" aria-label="Close fullscreen conversation"
[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>
</div> </header>
} @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>
<div class="chat-layout"> <div #fullscreenConversationContainer class="conversation conversation-modal-body">
<aside class="peer-sidebar"> <ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
</div>
</section>
<div class="peer-list">
@if (session.peers().length === 0) {
<div class="empty-chat empty-peers">
No peers are currently connected.
</div> </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) { @for (connectedPeer of session.peers(); track connectedPeer.id) {
<article <article
class="peer-tile" class="peer-tile"
@@ -89,7 +122,7 @@
<button <button
class="peer-tile-main text-start" class="peer-tile-main text-start"
type="button" type="button"
(click)="switchPeer(connectedPeer.id)" (click)="selectPeerFromDropdown(connectedPeer.id)"
> >
<div class="peer-tile-row"> <div class="peer-tile-row">
<span class="peer-tile-title"> <span class="peer-tile-title">
@@ -126,160 +159,56 @@
</article> </article>
} }
</div> </div>
</aside> }
<div class="chat-main">
<div #conversationContainer 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> </div>
} }
<div class="status-indicators">
@for (entry of conversation(); track entry.id) { <div class="status-indicator">
<article <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>
class="bubble" <span>Signaling</span>
[class.bubble-incoming]="entry.direction === 'incoming'" </div>
[class.bubble-outgoing]="entry.direction === 'outgoing'"
[class.bubble-system]="entry.direction === 'system'"
>
@if (entry.direction !== 'system') {
<div class="bubble-actions">
@if (isGeneratedImageEntry(entry)) {
<button <button
class="bubble-action" class="status-indicator status-indicator-action"
type="button" type="button"
(click)="sendGeneratedImage(entry)" [disabled]="!canReconnectWebRtc()"
title="Send image to peer" [attr.aria-label]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
aria-label="Send image to peer" [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>
</button> <span>WebRTC</span>
}
<button
class="bubble-action"
type="button"
(click)="toggleForwardMenu(entry, $event)"
title="Forward message"
aria-label="Forward message"
>
</button> </button>
<button <button
class="bubble-action bubble-delete" class="status-indicator status-indicator-action"
type="button" type="button"
(click)="deleteMessage(entry)" [disabled]="conversation().length === 0"
title="Delete message" aria-label="Open fullscreen conversation"
aria-label="Delete message" title="Open fullscreen conversation"
(click)="openConversationModal()"
> >
× <span class="expand-action-icon" aria-hidden="true"></span>
</button> </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>
}
<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> </div>
} @else { } @else {
<p class="mb-0">{{ entry.text }}</p> <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>
</article>
} }
</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"> <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)"
@@ -291,6 +220,7 @@
></textarea> ></textarea>
<div class="composer-toolbar"> <div class="composer-toolbar">
<div class="composer-actions">
@if (peer(); as selectedPeer) { @if (peer(); as selectedPeer) {
<button <button
class="composer-call" class="composer-call"
@@ -420,9 +350,166 @@
</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>

View File

@@ -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 {

View File

@@ -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(() => {
for (const container of [
this.conversationContainer?.nativeElement,
this.fullscreenConversationContainer?.nativeElement,
]) {
if (!container) {
continue;
}
container.scrollTop = container.scrollHeight; 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),
);
}
} }

View File

@@ -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');