Files
PrivateChat/client/src/app/chat-page.component.html

507 lines
21 KiB
HTML
Raw Normal View History

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-11 08:05:54 +01:00
<app-peer-call-modal
[visible]="callModalVisible()"
[peerName]="callModalPeer()?.displayName ?? 'Peer'"
[callState]="callModalState()"
[callMode]="callModalMode()"
[statusText]="callModalStatusText()"
[localStream]="localCallStream()"
[remoteStream]="remoteCallVideoStream()"
(acceptRequested)="callModalPeer() && acceptIncomingVoiceCall(callModalPeer()!.id)"
(rejectRequested)="callModalPeer() && rejectIncomingVoiceCall(callModalPeer()!.id)"
(hangupRequested)="callModalPeer() && endVoiceCall(callModalPeer()!.id)"
></app-peer-call-modal>
2026-03-10 22:36:21 +01:00
<audio #callAudioElement hidden autoplay playsinline></audio>
2026-03-11 08:05:54 +01:00
@if (callChoicePeer(); as selectedCallPeer) {
<div class="call-choice-backdrop" (click)="closeCallChoice()">
<section class="call-choice-card panel p-4" (click)="$event.stopPropagation()">
<p class="call-choice-eyebrow">Start a call</p>
<h2 class="h5 mb-2">{{ selectedCallPeer.displayName }}</h2>
<p class="small mb-3">Choose whether to place a full video call or audio only.</p>
<div class="call-choice-actions">
<button class="call-choice-button" type="button" (click)="startSelectedCall('video')">
<span class="call-choice-icon">📹</span>
<span>Video call</span>
2026-03-10 22:36:21 +01:00
</button>
2026-03-11 08:05:54 +01:00
<button class="call-choice-button" type="button" (click)="startSelectedCall('audio')">
<span class="call-choice-icon">🎙️</span>
<span>Audio only</span>
</button>
</div>
<div class="d-flex justify-content-end mt-3">
<button class="btn btn-outline-secondary" type="button" (click)="closeCallChoice()">
Cancel
2026-03-10 22:36:21 +01:00
</button>
</div>
</section>
</div>
}
2026-03-10 02:49:27 +01:00
2026-03-11 16:48:39 +01:00
@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>
<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>
}
2026-03-11 16:22:17 +01:00
<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>
2026-03-11 17:17:54 +01:00
@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>
}
2026-03-11 16:22:17 +01:00
<div class="status-indicators">
2026-03-09 19:35:08 +01:00
<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-11 16:48:39 +01:00
<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>
2026-03-09 19:35:08 +01:00
</div>
2026-03-11 16:22:17 +01:00
</div>
} @else {
<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>
}
2026-03-09 19:35:08 +01:00
</div>
<div class="chat-layout">
2026-03-11 16:48:39 +01:00
<div class="chat-main" (click)="closePeerDropdown()">
2026-03-11 09:09:15 +01:00
<div #conversationContainer class="conversation">
2026-03-11 16:48:39 +01:00
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
2026-03-09 19:35:08 +01:00
</div>
<div class="composer">
<textarea
2026-03-10 02:49:27 +01:00
#composerTextarea
2026-03-09 19:35:08 +01:00
class="form-control composer-textarea"
2026-03-11 16:22:17 +01:00
rows="2"
2026-03-09 19:35:08 +01:00
[(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
2026-03-10 22:36:21 +01:00
<div class="composer-toolbar">
@if (peer(); as selectedPeer) {
<button
class="composer-call"
type="button"
[disabled]="!canStartSelectedVoiceCall()"
2026-03-11 08:05:54 +01:00
(click)="openCallChoice(selectedPeer.id)"
title="Start call"
aria-label="Start call"
2026-03-10 22:36:21 +01:00
>
📞
</button>
@if (canEndSelectedVoiceCall()) {
<button
class="composer-hangup"
type="button"
(click)="endVoiceCall(selectedPeer.id)"
2026-03-11 08:05:54 +01:00
title="End call"
aria-label="End call"
2026-03-10 22:36:21 +01:00
>
🛑
</button>
}
<button
class="composer-voice"
type="button"
[disabled]="selectedPeer.channelState !== 'open' && !isRecordingVoice()"
(click)="toggleVoiceRecording()"
[title]="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()"
>
{{ isRecordingVoice() ? '⏹️' : '🎙️' }}
</button>
2026-03-11 00:26:49 +01:00
<button
class="composer-dictation"
type="button"
[disabled]="!session.isSelectedPeerReady() || session.signalingState() !== 'connected' || isTranscribingDictation()"
(click)="toggleDictation(composerTextarea)"
[title]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[attr.aria-label]="
isDictating()
? 'Stop dictation and transcribe'
: isTranscribingDictation()
? 'Transcribing dictated audio'
: 'Start dictation'
"
[class.composer-dictation-active]="isDictating() || isTranscribingDictation()"
>
{{ isDictating() ? '🛑' : isTranscribingDictation() ? '⏳' : '🗣️' }}
</button>
2026-03-10 22:36:21 +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
<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>
2026-03-11 16:48:39 +01:00
<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'"
2026-03-11 17:17:54 +01:00
[class.bubble-emoji-only]="isEmojiOnlyEntry(entry)"
2026-03-11 16:48:39 +01:00
>
2026-03-11 17:17:54 +01:00
@if (entry.direction !== 'system' && !isEmojiOnlyEntry(entry)) {
2026-03-11 16:48:39 +01:00
<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>
}
2026-03-11 17:17:54 +01:00
@if (!isEmojiOnlyEntry(entry)) {
<div class="bubble-meta">
<span class="bubble-author">{{ entry.authorLabel }}</span>
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
</div>
}
2026-03-11 16:48:39 +01:00
@switch (entry.kind) {
@case ('text') {
2026-03-11 17:17:54 +01:00
<p class="mb-0" [class.emoji-only-text]="isEmojiOnlyEntry(entry)">{{ entry.text }}</p>
2026-03-11 16:48:39 +01:00
}
@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>