- @if (conversation().length === 0) {
-
- No text messages yet. The chat page is ready as soon as the peer channel opens.
-
- }
-
- @for (entry of conversation(); track entry.id) {
-
- @if (entry.direction !== 'system') {
-
- @if (isGeneratedImageEntry(entry)) {
-
- }
-
-
- @if (isForwardMenuOpen(entry.id)) {
-
- }
-
- }
-
- {{ entry.authorLabel }}
-
-
-
- @switch (entry.kind) {
- @case ('text') {
- {{ entry.text }}
- }
- @case ('json') {
- {{ entry.payload | json }}
- }
- @case ('file') {
-
- @if (isImageEntry(entry)) {
-
![]()
- }
-
- @if (isVideoEntry(entry)) {
-
- }
-
- @if (isIncomingJsonFileEntry(entry)) {
-
- }
-
-
-
{{ entry.fileName }}
- @if (entry.fileSize) {
-
{{ entry.fileSize | number }} bytes
- }
-
-
- @if (entry.downloadUrl) {
-
Download
- }
-
- @if (hasDocumentPreviewImage(entry)) {
-
-
Preview
-
![]()
-
- }
-
- }
- @case ('voice') {
-
-
Voice message
- @if (entry.downloadUrl) {
-
- }
-
- }
- @default {
- @if (entry.showSpinner) {
-
- } @else {
- {{ entry.text }}
- }
- }
- }
-
- }
+
@@ -428,3 +362,149 @@
+
+
+ @if (conversation().length === 0) {
+
+ No text messages yet. The chat page is ready as soon as the peer channel opens.
+
+ }
+
+ @for (entry of conversation(); track entry.id) {
+
+ @if (entry.direction !== 'system') {
+
+ @if (isGeneratedImageEntry(entry)) {
+
+ }
+
+
+ @if (isForwardMenuOpen(entry.id)) {
+
+ }
+
+ }
+
+ {{ entry.authorLabel }}
+
+
+
+ @switch (entry.kind) {
+ @case ('text') {
+ {{ entry.text }}
+ }
+ @case ('json') {
+ {{ entry.payload | json }}
+ }
+ @case ('file') {
+
+ @if (isImageEntry(entry)) {
+
![]()
+ }
+
+ @if (isVideoEntry(entry)) {
+
+ }
+
+ @if (isIncomingJsonFileEntry(entry)) {
+
+ }
+
+
+
{{ entry.fileName }}
+ @if (entry.fileSize) {
+
{{ entry.fileSize | number }} bytes
+ }
+
+
+ @if (entry.downloadUrl) {
+
Download
+ }
+
+ @if (hasDocumentPreviewImage(entry)) {
+
+
Preview
+
![]()
+
+ }
+
+ }
+ @case ('voice') {
+
+
Voice message
+ @if (entry.downloadUrl) {
+
+ }
+
+ }
+ @default {
+ @if (entry.showSpinner) {
+
+ } @else {
+ {{ entry.text }}
+ }
+ }
+ }
+
+ }
+
diff --git a/client/src/app/chat-page.component.scss b/client/src/app/chat-page.component.scss
index d75aae6..831201a 100644
--- a/client/src/app/chat-page.component.scss
+++ b/client/src/app/chat-page.component.scss
@@ -69,6 +69,59 @@
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 {
margin-bottom: 0.45rem;
font-size: 0.78rem;
@@ -159,6 +212,11 @@
opacity: 1;
}
+.expand-action-icon {
+ font-size: 1.9rem;
+ line-height: 1;
+}
+
.status-led {
width: 0.8rem;
height: 0.8rem;
@@ -187,32 +245,36 @@
}
.peer-sidebar {
- display: flex;
- flex-direction: column;
- min-height: 0;
padding:1rem;
border-radius: 1.3rem;
border: 1px solid var(--surface-border-soft);
background: var(--panel-soft-background);
+ align-self: start;
}
-.peer-count {
- display: inline-flex;
- min-width: 2rem;
- justify-content: center;
- padding: 0.35rem 0.65rem;
- border-radius: 999px;
- font-size: 0.85rem;
- background: var(--badge-background);
+.peer-dropdown {
+ position: relative;
}
-.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;
- align-content: start;
- flex: 1 1 auto;
gap: 0.75rem;
- min-height: 0;
+ max-height: calc(3 * 4.55rem + 1.5rem);
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 {
@@ -230,6 +292,7 @@
}
.peer-tile-main {
+ display: block;
min-width: 0;
padding: 0;
border: 0;
@@ -237,6 +300,23 @@
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 {
width: 2.2rem;
height: 2.2rem;
@@ -673,6 +753,16 @@
margin-left: 0;
}
+ .conversation-modal-backdrop {
+ padding: 0;
+ }
+
+ .conversation-modal {
+ width: 100vw;
+ height: 100dvh;
+ border-radius: 0;
+ }
+
.bubble {
max-width: 88%;
}
diff --git a/client/src/app/chat-page.component.ts b/client/src/app/chat-page.component.ts
index 2c87342..9afbcbe 100644
--- a/client/src/app/chat-page.component.ts
+++ b/client/src/app/chat-page.component.ts
@@ -55,10 +55,17 @@ export class ChatPageComponent implements OnDestroy {
this.conversationContainer = value;
}
private conversationContainer?: ElementRef
;
+ @ViewChild('fullscreenConversationContainer')
+ set fullscreenConversationContainerRef(value: ElementRef | undefined) {
+ this.fullscreenConversationContainer = value;
+ }
+ private fullscreenConversationContainer?: ElementRef;
messageText = '';
readonly forwardingEntryId = signal(null);
readonly callChoicePeerId = signal(null);
+ readonly conversationModalOpen = signal(false);
+ readonly peerDropdownOpen = signal(false);
readonly emojiPickerOpen = signal(false);
readonly isRecordingVoice = signal(false);
readonly isDictating = signal(false);
@@ -75,6 +82,7 @@ export class ChatPageComponent implements OnDestroy {
];
readonly peerId = computed(() => this.routeParamMap().get('peerId') ?? '');
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 callModalPeerId = computed(() =>
this.session.activeVoiceCallPeerId()
@@ -574,6 +582,33 @@ export class ChatPageComponent implements OnDestroy {
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 {
+ this.closePeerDropdown();
+ await this.switchPeer(peerId);
+ }
+
async forwardEntry(entry: ChatEntry, targetPeerId: string, select: HTMLSelectElement): Promise {
if (!targetPeerId) {
return;
@@ -693,6 +728,8 @@ export class ChatPageComponent implements OnDestroy {
this.stopVoiceRecording(true);
this.forwardingEntryId.set(null);
this.callChoicePeerId.set(null);
+ this.conversationModalOpen.set(false);
+ this.peerDropdownOpen.set(false);
this.emojiPickerOpen.set(false);
this.session.selectPeer(peerId);
await this.router.navigate(['/chat', peerId]);
@@ -874,15 +911,18 @@ export class ChatPageComponent implements OnDestroy {
}
private scrollConversationToBottom(): void {
- const container = this.conversationContainer?.nativeElement;
-
- if (!container) {
- return;
- }
-
queueMicrotask(() => {
requestAnimationFrame(() => {
- container.scrollTop = container.scrollHeight;
+ for (const container of [
+ this.conversationContainer?.nativeElement,
+ this.fullscreenConversationContainer?.nativeElement,
+ ]) {
+ if (!container) {
+ continue;
+ }
+
+ container.scrollTop = container.scrollHeight;
+ }
});
});
}