full screen chat for iphone
This commit is contained in:
@@ -40,6 +40,31 @@
|
||||
</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">{{ 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>
|
||||
}
|
||||
|
||||
<div class="chat-header mb-4">
|
||||
@if (currentUser(); as connectedUser) {
|
||||
<div class="chat-header-main">
|
||||
@@ -61,6 +86,16 @@
|
||||
<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 {
|
||||
@@ -72,31 +107,24 @@
|
||||
</div>
|
||||
|
||||
<div class="chat-layout">
|
||||
<aside class="peer-sidebar">
|
||||
|
||||
|
||||
<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) {
|
||||
<article
|
||||
class="peer-tile"
|
||||
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
||||
[class.peer-tile-unread]="isPeerUnread(connectedPeer.id)"
|
||||
<aside class="peer-sidebar" (click)="$event.stopPropagation()">
|
||||
@if (displayedPeer(); as selectedPeer) {
|
||||
<div class="peer-dropdown">
|
||||
<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"
|
||||
>
|
||||
<button
|
||||
class="peer-tile-main text-start"
|
||||
type="button"
|
||||
(click)="switchPeer(connectedPeer.id)"
|
||||
>
|
||||
<div class="peer-tile-row">
|
||||
<span class="peer-tile-main text-start">
|
||||
<span class="peer-tile-row">
|
||||
<span class="peer-tile-title">
|
||||
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
|
||||
@if (isPeerTyping(connectedPeer.id)) {
|
||||
<span class="fw-semibold">{{ selectedPeer.displayName }}</span>
|
||||
@if (isPeerTyping(selectedPeer.id)) {
|
||||
<span class="peer-typing-dots" aria-label="Typing">
|
||||
<span></span>
|
||||
<span></span>
|
||||
@@ -104,177 +132,83 @@
|
||||
</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>
|
||||
<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>
|
||||
} @else {
|
||||
<div class="empty-chat empty-peers">
|
||||
No peers are currently connected.
|
||||
</div>
|
||||
}
|
||||
</aside>
|
||||
|
||||
<div class="chat-main">
|
||||
<div class="chat-main" (click)="closePeerDropdown()">
|
||||
<div #conversationContainer 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'"
|
||||
>
|
||||
@if (entry.direction !== 'system') {
|
||||
<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>
|
||||
}
|
||||
<div class="bubble-meta">
|
||||
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
||||
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||
</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'"
|
||||
/>
|
||||
}
|
||||
|
||||
@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-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
@@ -428,3 +362,149 @@
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<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'"
|
||||
>
|
||||
@if (entry.direction !== 'system') {
|
||||
<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>
|
||||
}
|
||||
<div class="bubble-meta">
|
||||
<span class="bubble-author">{{ entry.authorLabel }}</span>
|
||||
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
|
||||
</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'"
|
||||
/>
|
||||
}
|
||||
|
||||
@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>
|
||||
|
||||
Reference in New Issue
Block a user