522 lines
22 KiB
HTML
522 lines
22 KiB
HTML
<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>
|