offline users and messages

This commit is contained in:
2026-03-25 20:09:36 +01:00
parent fd888c9ed1
commit f13c04e809
10 changed files with 792 additions and 154 deletions

View File

@@ -46,7 +46,7 @@
<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>
<h2 class="h5 mb-0">{{ displayedPeer()?.displayName ?? 'Conversation' }}</h2>
</div>
<button
class="conversation-modal-close"
@@ -113,21 +113,21 @@
@if (peerDropdownOpen()) {
<div class="peer-dropdown-menu" role="listbox">
@for (connectedPeer of session.peers(); track connectedPeer.id) {
@for (dropdownPeer of dropdownPeers(); track dropdownPeer.id) {
<article
class="peer-tile"
[class.peer-tile-active]="connectedPeer.id === peerId()"
[class.peer-tile-unread]="isPeerUnread(connectedPeer.id)"
[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(connectedPeer.id)"
(click)="selectPeerFromDropdown(dropdownPeer.id)"
>
<div class="peer-tile-row">
<span class="peer-tile-title">
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
@if (isPeerTyping(connectedPeer.id)) {
<span class="fw-semibold">{{ dropdownPeer.displayName }}</span>
@if (isPeerTyping(dropdownPeer.id)) {
<span class="peer-typing-dots" aria-label="Typing">
<span></span>
<span></span>
@@ -137,10 +137,10 @@
</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'"
[class.status-led-ok]="dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'"
[class.status-led-offline]="dropdownPeer.channelState !== 'open' && dropdownPeer.connectionState !== 'connected'"
[attr.aria-label]="
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
dropdownPeer.channelState === 'open' || dropdownPeer.connectionState === 'connected'
? 'Connected'
: 'Disconnected'
"
@@ -152,7 +152,7 @@
type="button"
title="Delete conversation"
aria-label="Delete conversation"
(click)="deleteConversation(connectedPeer.id, $event)"
(click)="deleteConversation(dropdownPeer.id, $event)"
>
🗑️
</button>
@@ -215,83 +215,85 @@
(click)="trackComposerSelection(composerTextarea)"
(keyup)="trackComposerSelection(composerTextarea)"
(select)="trackComposerSelection(composerTextarea)"
[disabled]="!session.isSelectedPeerReady()"
placeholder="Write a text message to your peer"
[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 (peer(); as selectedPeer) {
<button
class="composer-call"
type="button"
[disabled]="!canStartSelectedVoiceCall()"
(click)="openCallChoice(selectedPeer.id)"
title="Start call"
aria-label="Start call"
>
📞
</button>
@if (canEndSelectedVoiceCall()) {
@if (peerId(); as selectedPeerId) {
@if (peer(); as livePeer) {
<button
class="composer-hangup"
class="composer-call"
type="button"
(click)="endVoiceCall(selectedPeer.id)"
title="End call"
aria-label="End call"
[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>
}
<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>
<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]="selectedPeer.channelState !== 'open'"
(change)="sendFile(selectedPeer.id, fileInput)"
[disabled]="!selectedPeerId"
(change)="sendFile(selectedPeerId, fileInput)"
/>
<button
class="composer-plus"
type="button"
[disabled]="selectedPeer.channelState !== 'open'"
[disabled]="!selectedPeerId"
(click)="fileInput.click()"
title="Send file"
aria-label="Send file"
@@ -330,7 +332,7 @@
<button
class="composer-emoji-trigger"
type="button"
[disabled]="!session.isSelectedPeerReady()"
[disabled]="!peerId()"
(click)="toggleEmojiPicker($event)"
title="Insert emoji"
aria-label="Insert emoji"
@@ -342,7 +344,7 @@
<button
class="send-emoji"
type="button"
[disabled]="!session.isSelectedPeerReady()"
[disabled]="!peerId()"
(click)="sendMessage()"
title="Send message"
aria-label="Send message"
@@ -368,7 +370,7 @@
<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.
No text messages yet. Messages and files can be queued here and will send when the peer reconnects.
</div>
}
@@ -377,6 +379,7 @@
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)"
>
@@ -427,6 +430,9 @@
<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>
}