2026-03-09 19:35:08 +01:00
|
|
|
|
<main class="chat-shell py-4">
|
|
|
|
|
|
<div class="container-lg">
|
|
|
|
|
|
<section class="chat-page panel p-3 p-lg-4">
|
2026-03-10 02:49:27 +01:00
|
|
|
|
<app-peer-video-modal
|
|
|
|
|
|
[visible]="remoteVideoModalVisible()"
|
|
|
|
|
|
[stream]="remoteVideoStream()"
|
|
|
|
|
|
[title]="(peer()?.displayName ?? 'Peer') + ' webcam'"
|
|
|
|
|
|
(closeRequested)="closeRemoteVideoModal()"
|
|
|
|
|
|
></app-peer-video-modal>
|
|
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
|
<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">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<a class="back-link" routerLink="/">← Back to dashboard</a>
|
|
|
|
|
|
@if (currentUser(); as connectedUser) {
|
|
|
|
|
|
<h1 class="h3 mb-1 mt-2">{{ connectedUser.displayName }}</h1>
|
|
|
|
|
|
<div class="status-indicators mt-2">
|
|
|
|
|
|
<div class="status-indicator">
|
|
|
|
|
|
<span class="status-led" [class.status-led-ok]="indicatorTone(session.signalingState()) === 'ok'" [class.status-led-connecting]="indicatorTone(session.signalingState()) === 'connecting'" [class.status-led-offline]="indicatorTone(session.signalingState()) === 'offline'"></span>
|
|
|
|
|
|
<span>Signaling</span>
|
|
|
|
|
|
</div>
|
2026-03-09 20:09:46 +01:00
|
|
|
|
<button
|
|
|
|
|
|
class="status-indicator status-indicator-action"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
[disabled]="!canReconnectWebRtc()"
|
|
|
|
|
|
[attr.aria-label]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
|
|
|
|
|
[title]="canReconnectWebRtc() ? 'Open WebRTC channel' : 'WebRTC channel status'"
|
|
|
|
|
|
(click)="ensureConnection()"
|
|
|
|
|
|
>
|
2026-03-09 19:35:08 +01:00
|
|
|
|
<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>
|
2026-03-09 20:09:46 +01:00
|
|
|
|
</button>
|
2026-03-09 19:35:08 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
} @else {
|
|
|
|
|
|
<h1 class="h3 mb-1 mt-2">Not signed in</h1>
|
|
|
|
|
|
<p class="small text-secondary mb-0">Return to the dashboard and sign in again.</p>
|
|
|
|
|
|
}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="chat-layout">
|
|
|
|
|
|
<aside class="peer-sidebar">
|
2026-03-09 20:09:46 +01:00
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
|
|
|
|
|
|
|
<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) {
|
2026-03-09 20:40:21 +01:00
|
|
|
|
<article class="peer-tile" [class.peer-tile-active]="connectedPeer.id === peerId()">
|
|
|
|
|
|
<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>
|
2026-03-09 19:35:08 +01:00
|
|
|
|
}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="chat-main">
|
|
|
|
|
|
<div class="conversation">
|
|
|
|
|
|
@if (conversation().length === 0) {
|
|
|
|
|
|
<div class="empty-chat">
|
|
|
|
|
|
No text messages yet. The chat page is ready as soon as the peer channel opens.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@for (entry of conversation(); track entry.id) {
|
|
|
|
|
|
<article
|
|
|
|
|
|
class="bubble"
|
|
|
|
|
|
[class.bubble-incoming]="entry.direction === 'incoming'"
|
|
|
|
|
|
[class.bubble-outgoing]="entry.direction === 'outgoing'"
|
|
|
|
|
|
[class.bubble-system]="entry.direction === 'system'"
|
|
|
|
|
|
>
|
2026-03-10 02:49:27 +01:00
|
|
|
|
@if (entry.direction !== 'system') {
|
|
|
|
|
|
<div class="bubble-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="bubble-action"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
(click)="toggleForwardMenu(entry, $event)"
|
|
|
|
|
|
title="Forward message"
|
|
|
|
|
|
aria-label="Forward message"
|
|
|
|
|
|
>
|
|
|
|
|
|
⏩
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="bubble-action bubble-delete"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
(click)="deleteMessage(entry)"
|
|
|
|
|
|
title="Delete message"
|
|
|
|
|
|
aria-label="Delete message"
|
|
|
|
|
|
>
|
|
|
|
|
|
×
|
|
|
|
|
|
</button>
|
|
|
|
|
|
@if (isForwardMenuOpen(entry.id)) {
|
|
|
|
|
|
<div class="bubble-forward-menu">
|
|
|
|
|
|
<select #forwardSelect class="bubble-forward-select" (change)="forwardEntry(entry, forwardSelect.value, forwardSelect)">
|
|
|
|
|
|
<option value="">Forward to…</option>
|
|
|
|
|
|
@for (targetPeer of forwardTargets(entry); track targetPeer.id) {
|
|
|
|
|
|
<option [value]="targetPeer.id">{{ targetPeer.displayName }}</option>
|
|
|
|
|
|
}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
2026-03-09 19:35:08 +01:00
|
|
|
|
<div class="bubble-meta">
|
2026-03-10 02:49:27 +01:00
|
|
|
|
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
|
|
|
|
|
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
2026-03-09 19:35:08 +01:00
|
|
|
|
</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'"
|
|
|
|
|
|
/>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 02:49:27 +01:00
|
|
|
|
@if (isVideoEntry(entry)) {
|
|
|
|
|
|
<video
|
|
|
|
|
|
class="bubble-video"
|
|
|
|
|
|
[src]="entry.downloadUrl"
|
|
|
|
|
|
controls
|
|
|
|
|
|
autoplay
|
|
|
|
|
|
muted
|
|
|
|
|
|
playsinline
|
|
|
|
|
|
preload="metadata"
|
|
|
|
|
|
></video>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 20:40:21 +01:00
|
|
|
|
@if (isIncomingJsonFileEntry(entry)) {
|
|
|
|
|
|
<app-json-file-viewer [entry]="entry"></app-json-file-viewer>
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
|
<div>
|
|
|
|
|
|
<div class="fw-semibold">{{ entry.fileName }}</div>
|
|
|
|
|
|
@if (entry.fileSize) {
|
|
|
|
|
|
<div class="small text-secondary-emphasis">{{ entry.fileSize | number }} bytes</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
@if (entry.downloadUrl) {
|
|
|
|
|
|
<a class="bubble-download" [href]="entry.downloadUrl" [download]="entry.fileName">Download</a>
|
|
|
|
|
|
}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
@default {
|
|
|
|
|
|
<p class="mb-0">{{ entry.text }}</p>
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</article>
|
|
|
|
|
|
}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="composer">
|
|
|
|
|
|
@if (peer(); as selectedPeer) {
|
2026-03-10 02:49:27 +01:00
|
|
|
|
<div class="composer-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="composer-camera"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
[disabled]="selectedPeer.channelState !== 'open' && !isStreamingCameraToSelectedPeer()"
|
|
|
|
|
|
(click)="toggleCameraStream(selectedPeer.id)"
|
|
|
|
|
|
[title]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
|
|
|
|
|
[attr.aria-label]="isStreamingCameraToSelectedPeer() ? 'Stop webcam' : 'Start webcam'"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ isStreamingCameraToSelectedPeer() ? '🛑' : '📹' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
2026-03-09 19:35:08 +01:00
|
|
|
|
<input
|
|
|
|
|
|
#fileInput
|
|
|
|
|
|
class="composer-file-input"
|
|
|
|
|
|
type="file"
|
|
|
|
|
|
[disabled]="selectedPeer.channelState !== 'open'"
|
|
|
|
|
|
(change)="sendFile(selectedPeer.id, fileInput)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="composer-plus"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
[disabled]="selectedPeer.channelState !== 'open'"
|
|
|
|
|
|
(click)="fileInput.click()"
|
|
|
|
|
|
title="Send file"
|
|
|
|
|
|
aria-label="Send file"
|
|
|
|
|
|
>
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
2026-03-10 02:49:27 +01:00
|
|
|
|
</div>
|
2026-03-09 19:35:08 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
<textarea
|
2026-03-10 02:49:27 +01:00
|
|
|
|
#composerTextarea
|
2026-03-09 19:35:08 +01:00
|
|
|
|
class="form-control composer-textarea"
|
|
|
|
|
|
rows="3"
|
|
|
|
|
|
[(ngModel)]="messageText"
|
|
|
|
|
|
(ngModelChange)="handleMessageTextChange($event)"
|
|
|
|
|
|
(keydown.enter)="handleComposerEnter($event)"
|
2026-03-10 02:49:27 +01:00
|
|
|
|
(click)="trackComposerSelection(composerTextarea)"
|
|
|
|
|
|
(keyup)="trackComposerSelection(composerTextarea)"
|
|
|
|
|
|
(select)="trackComposerSelection(composerTextarea)"
|
2026-03-09 19:35:08 +01:00
|
|
|
|
[disabled]="!session.isSelectedPeerReady()"
|
|
|
|
|
|
placeholder="Write a text message to your peer"
|
|
|
|
|
|
></textarea>
|
2026-03-10 02:49:27 +01:00
|
|
|
|
|
|
|
|
|
|
<div class="composer-send">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="composer-image-generate"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
[disabled]="!peer() || session.signalingState() !== 'connected' || !messageText.trim()"
|
|
|
|
|
|
(click)="requestGeneratedImage()"
|
|
|
|
|
|
title="Generate image from prompt"
|
|
|
|
|
|
aria-label="Generate image from prompt"
|
|
|
|
|
|
>
|
|
|
|
|
|
🖼️
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="composer-emoji-picker-shell">
|
|
|
|
|
|
@if (emojiPickerOpen()) {
|
|
|
|
|
|
<div class="composer-emoji-picker">
|
|
|
|
|
|
@for (emoji of emojiOptions; track emoji) {
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="composer-emoji-option"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
(click)="insertEmoji(emoji, composerTextarea)"
|
|
|
|
|
|
[attr.aria-label]="'Insert ' + emoji"
|
|
|
|
|
|
[title]="'Insert ' + emoji"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ emoji }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="composer-emoji-trigger"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
[disabled]="!session.isSelectedPeerReady()"
|
|
|
|
|
|
(click)="toggleEmojiPicker($event)"
|
|
|
|
|
|
title="Insert emoji"
|
|
|
|
|
|
aria-label="Insert emoji"
|
|
|
|
|
|
>
|
|
|
|
|
|
😀
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="send-emoji"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
[disabled]="!session.isSelectedPeerReady()"
|
|
|
|
|
|
(click)="sendMessage()"
|
|
|
|
|
|
title="Send message"
|
|
|
|
|
|
aria-label="Send message"
|
|
|
|
|
|
>
|
|
|
|
|
|
✅
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-03-09 19:35:08 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</main>
|