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

522 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<main class="chat-shell py-4">
<div class="container-lg">
<section class="chat-page panel p-3 p-lg-4">
<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>
<audio #callAudioElement hidden autoplay playsinline></audio>
@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>
</button>
<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
</button>
</div>
</section>
</div>
}
@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">{{ displayedPeer()?.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 (dropdownPeer of dropdownPeers(); track dropdownPeer.id) {
<article
class="peer-tile"
[class.peer-tile-active]="dropdownPeer.id === peerId()"
[class.peer-tile-unread]="isPeerUnread(dropdownPeer.id)"
>
<button
class="peer-tile-main text-start"
type="button"
(click)="selectPeerFromDropdown(dropdownPeer.id)"
>
<div class="peer-tile-row">
<span class="peer-tile-title">
<span class="fw-semibold">{{ dropdownPeer.displayName }}</span>
@if (isPeerTyping(dropdownPeer.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]="dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'"
[class.status-led-offline]="dropdownPeer.channelState !== 'open' && dropdownPeer.connectionState !== 'connected'"
[attr.aria-label]="
dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'
? 'Connected'
: 'Disconnected'
"
></span>
</div>
</button>
<button
class="peer-tile-delete"
type="button"
title="Delete conversation"
aria-label="Delete conversation"
(click)="deleteConversation(dropdownPeer.id, $event)"
>
🗑️
</button>
</article>
}
</div>
}
</div>
}
<div class="status-indicators">
<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>
<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()"
>
<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
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 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>
}
</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">
<textarea
#composerTextarea
class="form-control composer-textarea"
rows="2"
[(ngModel)]="messageText"
(ngModelChange)="handleMessageTextChange($event)"
(keydown.enter)="handleComposerEnter($event)"
(click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)"
[disabled]="!peerId()"
placeholder="Write a text message to your peer, even if they are offline"
></textarea>
<div class="composer-toolbar">
<div class="composer-actions">
@if (peerId(); as selectedPeerId) {
@if (peer(); as livePeer) {
<button
class="composer-call"
type="button"
[disabled]="!canStartSelectedVoiceCall()"
(click)="openCallChoice(livePeer.id)"
title="Start call"
aria-label="Start call"
>
📞
</button>
@if (canEndSelectedVoiceCall()) {
<button
class="composer-hangup"
type="button"
(click)="endVoiceCall(livePeer.id)"
title="End call"
aria-label="End call"
>
🛑
</button>
}
<button
class="composer-voice"
type="button"
[disabled]="livePeer.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>
<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>
}
<input
#fileInput
class="composer-file-input"
type="file"
[disabled]="!selectedPeerId"
(change)="sendFile(selectedPeerId, fileInput)"
/>
<button
class="composer-plus"
type="button"
[disabled]="!selectedPeerId"
(click)="fileInput.click()"
title="Send file"
aria-label="Send file"
>
+
</button>
}
<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]="!peerId()"
(click)="toggleEmojiPicker($event)"
title="Insert emoji"
aria-label="Insert emoji"
>
😀
</button>
</div>
<button
class="send-emoji"
type="button"
[disabled]="!peerId()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
>
</button>
</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>
</section>
</div>
</main>
<ng-template #conversationBubbles>
@if (conversation().length === 0) {
<div class="empty-chat">
No text messages yet. Messages and files can be queued here and will send when the peer reconnects.
</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-pending]="isPendingOutgoingEntry(entry)"
[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>
@if (isPendingOutgoingEntry(entry)) {
<span class="bubble-delivery-state">Queued</span>
}
</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>