full screen chat for iphone
This commit is contained in:
@@ -40,6 +40,31 @@
|
|||||||
</div>
|
</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">
|
<div class="chat-header mb-4">
|
||||||
@if (currentUser(); as connectedUser) {
|
@if (currentUser(); as connectedUser) {
|
||||||
<div class="chat-header-main">
|
<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 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>
|
<span>WebRTC</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -72,31 +107,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-layout">
|
<div class="chat-layout">
|
||||||
<aside class="peer-sidebar">
|
<aside class="peer-sidebar" (click)="$event.stopPropagation()">
|
||||||
|
@if (displayedPeer(); as selectedPeer) {
|
||||||
|
<div class="peer-dropdown">
|
||||||
<div class="peer-list">
|
<button
|
||||||
@if (session.peers().length === 0) {
|
class="peer-dropdown-trigger peer-tile"
|
||||||
<div class="empty-chat empty-peers">
|
type="button"
|
||||||
No peers are currently connected.
|
[class.peer-tile-active]="true"
|
||||||
</div>
|
[class.peer-tile-unread]="isPeerUnread(selectedPeer.id)"
|
||||||
}
|
(click)="togglePeerDropdown()"
|
||||||
|
[attr.aria-expanded]="peerDropdownOpen()"
|
||||||
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
aria-haspopup="listbox"
|
||||||
<article
|
aria-label="Choose peer"
|
||||||
class="peer-tile"
|
|
||||||
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
|
||||||
[class.peer-tile-unread]="isPeerUnread(connectedPeer.id)"
|
|
||||||
>
|
>
|
||||||
<button
|
<span class="peer-tile-main text-start">
|
||||||
class="peer-tile-main text-start"
|
<span class="peer-tile-row">
|
||||||
type="button"
|
|
||||||
(click)="switchPeer(connectedPeer.id)"
|
|
||||||
>
|
|
||||||
<div class="peer-tile-row">
|
|
||||||
<span class="peer-tile-title">
|
<span class="peer-tile-title">
|
||||||
<span class="fw-semibold">{{ connectedPeer.displayName }}</span>
|
<span class="fw-semibold">{{ selectedPeer.displayName }}</span>
|
||||||
@if (isPeerTyping(connectedPeer.id)) {
|
@if (isPeerTyping(selectedPeer.id)) {
|
||||||
<span class="peer-typing-dots" aria-label="Typing">
|
<span class="peer-typing-dots" aria-label="Typing">
|
||||||
<span></span>
|
<span></span>
|
||||||
<span></span>
|
<span></span>
|
||||||
@@ -104,177 +132,83 @@
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span class="peer-tile-indicators">
|
||||||
class="status-led peer-tile-status"
|
<span
|
||||||
[class.status-led-ok]="connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'"
|
class="status-led peer-tile-status"
|
||||||
[class.status-led-offline]="connectedPeer.channelState !== 'open' && connectedPeer.connectionState !== 'connected'"
|
[class.status-led-ok]="selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'"
|
||||||
[attr.aria-label]="
|
[class.status-led-offline]="selectedPeer.channelState !== 'open' && selectedPeer.connectionState !== 'connected'"
|
||||||
connectedPeer.channelState === 'open' || connectedPeer.connectionState === 'connected'
|
[attr.aria-label]="
|
||||||
? 'Connected'
|
selectedPeer.channelState === 'open' || selectedPeer.connectionState === 'connected'
|
||||||
: 'Disconnected'
|
? 'Connected'
|
||||||
"
|
: 'Disconnected'
|
||||||
></span>
|
"
|
||||||
</div>
|
></span>
|
||||||
</button>
|
<span class="peer-dropdown-caret" [class.peer-dropdown-caret-open]="peerDropdownOpen()">▾</span>
|
||||||
<button
|
</span>
|
||||||
class="peer-tile-delete"
|
</span>
|
||||||
type="button"
|
</span>
|
||||||
title="Delete conversation"
|
</button>
|
||||||
aria-label="Delete conversation"
|
|
||||||
(click)="deleteConversation(connectedPeer.id, $event)"
|
@if (peerDropdownOpen()) {
|
||||||
>
|
<div class="peer-dropdown-menu" role="listbox">
|
||||||
🗑️
|
@for (connectedPeer of session.peers(); track connectedPeer.id) {
|
||||||
</button>
|
<article
|
||||||
</article>
|
class="peer-tile"
|
||||||
}
|
[class.peer-tile-active]="connectedPeer.id === peerId()"
|
||||||
</div>
|
[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>
|
</aside>
|
||||||
|
|
||||||
<div class="chat-main">
|
<div class="chat-main" (click)="closePeerDropdown()">
|
||||||
<div #conversationContainer class="conversation">
|
<div #conversationContainer class="conversation">
|
||||||
@if (conversation().length === 0) {
|
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
|
||||||
<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>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="composer">
|
<div class="composer">
|
||||||
@@ -428,3 +362,149 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
||||||
|
|||||||
@@ -69,6 +69,59 @@
|
|||||||
width: min(100%, 25rem);
|
width: min(100%, 25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1230;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: rgba(3, 8, 14, 0.56);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
width: min(100%, 96rem);
|
||||||
|
height: min(100dvh - 1.5rem, 100%);
|
||||||
|
max-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--surface-border-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-eyebrow {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--page-text-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-close {
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--page-text);
|
||||||
|
background: var(--badge-background);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal-body {
|
||||||
|
min-height: 0;
|
||||||
|
max-height: none;
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.call-choice-eyebrow {
|
.call-choice-eyebrow {
|
||||||
margin-bottom: 0.45rem;
|
margin-bottom: 0.45rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
@@ -159,6 +212,11 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.expand-action-icon {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.status-led {
|
.status-led {
|
||||||
width: 0.8rem;
|
width: 0.8rem;
|
||||||
height: 0.8rem;
|
height: 0.8rem;
|
||||||
@@ -187,32 +245,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.peer-sidebar {
|
.peer-sidebar {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: 0;
|
|
||||||
padding:1rem;
|
padding:1rem;
|
||||||
border-radius: 1.3rem;
|
border-radius: 1.3rem;
|
||||||
border: 1px solid var(--surface-border-soft);
|
border: 1px solid var(--surface-border-soft);
|
||||||
background: var(--panel-soft-background);
|
background: var(--panel-soft-background);
|
||||||
|
align-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-count {
|
.peer-dropdown {
|
||||||
display: inline-flex;
|
position: relative;
|
||||||
min-width: 2rem;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.35rem 0.65rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
background: var(--badge-background);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-list {
|
.peer-dropdown-trigger {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 0.65rem);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 4;
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-height: 0;
|
max-height: calc(3 * 4.55rem + 1.5rem);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: var(--panel-background);
|
||||||
|
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.peer-tile {
|
.peer-tile {
|
||||||
@@ -230,6 +292,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.peer-tile-main {
|
.peer-tile-main {
|
||||||
|
display: block;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -237,6 +300,23 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.peer-tile-indicators {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-caret {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.peer-dropdown-caret-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
.peer-tile-delete {
|
.peer-tile-delete {
|
||||||
width: 2.2rem;
|
width: 2.2rem;
|
||||||
height: 2.2rem;
|
height: 2.2rem;
|
||||||
@@ -673,6 +753,16 @@
|
|||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation-modal-backdrop {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-modal {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.bubble {
|
.bubble {
|
||||||
max-width: 88%;
|
max-width: 88%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,10 +55,17 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.conversationContainer = value;
|
this.conversationContainer = value;
|
||||||
}
|
}
|
||||||
private conversationContainer?: ElementRef<HTMLDivElement>;
|
private conversationContainer?: ElementRef<HTMLDivElement>;
|
||||||
|
@ViewChild('fullscreenConversationContainer')
|
||||||
|
set fullscreenConversationContainerRef(value: ElementRef<HTMLDivElement> | undefined) {
|
||||||
|
this.fullscreenConversationContainer = value;
|
||||||
|
}
|
||||||
|
private fullscreenConversationContainer?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
messageText = '';
|
messageText = '';
|
||||||
readonly forwardingEntryId = signal<string | null>(null);
|
readonly forwardingEntryId = signal<string | null>(null);
|
||||||
readonly callChoicePeerId = signal<string | null>(null);
|
readonly callChoicePeerId = signal<string | null>(null);
|
||||||
|
readonly conversationModalOpen = signal(false);
|
||||||
|
readonly peerDropdownOpen = signal(false);
|
||||||
readonly emojiPickerOpen = signal(false);
|
readonly emojiPickerOpen = signal(false);
|
||||||
readonly isRecordingVoice = signal(false);
|
readonly isRecordingVoice = signal(false);
|
||||||
readonly isDictating = signal(false);
|
readonly isDictating = signal(false);
|
||||||
@@ -75,6 +82,7 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
];
|
];
|
||||||
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
|
||||||
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
readonly peer = computed(() => this.session.peers().find((item) => item.id === this.peerId()) ?? null);
|
||||||
|
readonly displayedPeer = computed(() => this.peer() ?? this.session.peers()[0] ?? null);
|
||||||
readonly currentUser = computed(() => this.session.currentUser());
|
readonly currentUser = computed(() => this.session.currentUser());
|
||||||
readonly callModalPeerId = computed(() =>
|
readonly callModalPeerId = computed(() =>
|
||||||
this.session.activeVoiceCallPeerId()
|
this.session.activeVoiceCallPeerId()
|
||||||
@@ -574,6 +582,33 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
|
return this.session.peers().filter((peer) => peer.id !== entry.peerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
togglePeerDropdown(): void {
|
||||||
|
if (this.session.peers().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.peerDropdownOpen.update((open) => !open);
|
||||||
|
}
|
||||||
|
|
||||||
|
closePeerDropdown(): void {
|
||||||
|
this.peerDropdownOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
openConversationModal(): void {
|
||||||
|
this.closePeerDropdown();
|
||||||
|
this.conversationModalOpen.set(true);
|
||||||
|
this.scrollConversationToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConversationModal(): void {
|
||||||
|
this.conversationModalOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectPeerFromDropdown(peerId: string): Promise<void> {
|
||||||
|
this.closePeerDropdown();
|
||||||
|
await this.switchPeer(peerId);
|
||||||
|
}
|
||||||
|
|
||||||
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
|
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise<void> {
|
||||||
if (!targetPeerId) {
|
if (!targetPeerId) {
|
||||||
return;
|
return;
|
||||||
@@ -693,6 +728,8 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
this.stopVoiceRecording(true);
|
this.stopVoiceRecording(true);
|
||||||
this.forwardingEntryId.set(null);
|
this.forwardingEntryId.set(null);
|
||||||
this.callChoicePeerId.set(null);
|
this.callChoicePeerId.set(null);
|
||||||
|
this.conversationModalOpen.set(false);
|
||||||
|
this.peerDropdownOpen.set(false);
|
||||||
this.emojiPickerOpen.set(false);
|
this.emojiPickerOpen.set(false);
|
||||||
this.session.selectPeer(peerId);
|
this.session.selectPeer(peerId);
|
||||||
await this.router.navigate(['/chat', peerId]);
|
await this.router.navigate(['/chat', peerId]);
|
||||||
@@ -874,15 +911,18 @@ export class ChatPageComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private scrollConversationToBottom(): void {
|
private scrollConversationToBottom(): void {
|
||||||
const container = this.conversationContainer?.nativeElement;
|
|
||||||
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
container.scrollTop = container.scrollHeight;
|
for (const container of [
|
||||||
|
this.conversationContainer?.nativeElement,
|
||||||
|
this.fullscreenConversationContainer?.nativeElement,
|
||||||
|
]) {
|
||||||
|
if (!container) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user