peer list at top, emoji w/o bubbles

This commit is contained in:
2026-03-11 17:17:54 +01:00
parent 03d3b75fb4
commit 687bd56e42
3 changed files with 153 additions and 122 deletions

View File

@@ -70,6 +70,98 @@
<div class="chat-header-main"> <div class="chat-header-main">
<a class="back-link" routerLink="/" aria-label="Back to dashboard"></a> <a class="back-link" routerLink="/" aria-label="Back to dashboard"></a>
<h1 class="chat-header-title mb-0">{{ connectedUser.displayName }}</h1> <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 (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>
}
<div class="status-indicators"> <div class="status-indicators">
<div class="status-indicator"> <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 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>
@@ -107,105 +199,6 @@
</div> </div>
<div class="chat-layout"> <div class="chat-layout">
<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"
>
<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 (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" (click)="closePeerDropdown()"> <div class="chat-main" (click)="closePeerDropdown()">
<div #conversationContainer class="conversation"> <div #conversationContainer class="conversation">
<ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container> <ng-container [ngTemplateOutlet]="conversationBubbles"></ng-container>
@@ -376,8 +369,9 @@
[class.bubble-incoming]="entry.direction === 'incoming'" [class.bubble-incoming]="entry.direction === 'incoming'"
[class.bubble-outgoing]="entry.direction === 'outgoing'" [class.bubble-outgoing]="entry.direction === 'outgoing'"
[class.bubble-system]="entry.direction === 'system'" [class.bubble-system]="entry.direction === 'system'"
[class.bubble-emoji-only]="isEmojiOnlyEntry(entry)"
> >
@if (entry.direction !== 'system') { @if (entry.direction !== 'system' && !isEmojiOnlyEntry(entry)) {
<div class="bubble-actions"> <div class="bubble-actions">
@if (isGeneratedImageEntry(entry)) { @if (isGeneratedImageEntry(entry)) {
<button <button
@@ -420,14 +414,16 @@
} }
</div> </div>
} }
<div class="bubble-meta"> @if (!isEmojiOnlyEntry(entry)) {
<span class="bubble-author">{{ entry.authorLabel }}</span> <div class="bubble-meta">
<time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time> <span class="bubble-author">{{ entry.authorLabel }}</span>
</div> <time class="bubble-time">{{ entry.createdAt | date: 'shortTime' }}</time>
</div>
}
@switch (entry.kind) { @switch (entry.kind) {
@case ('text') { @case ('text') {
<p class="mb-0">{{ entry.text }}</p> <p class="mb-0" [class.emoji-only-text]="isEmojiOnlyEntry(entry)">{{ entry.text }}</p>
} }
@case ('json') { @case ('json') {
<pre class="bubble-json mb-0">{{ entry.payload | json }}</pre> <pre class="bubble-json mb-0">{{ entry.payload | json }}</pre>

View File

@@ -19,10 +19,10 @@
.chat-page { .chat-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: min(100%, 800px); width: min(95vw, 95%);
height: min(calc(100dvh - 2rem), 1024px); height: min(calc(100dvh - 2rem), 1024px);
max-height: 1024px; max-height: 1024px;
margin-inline: auto; margin-inline: 0 auto;
overflow-x: hidden; overflow-x: hidden;
} }
@@ -239,21 +239,14 @@
.chat-layout { .chat-layout {
display: grid; display: grid;
flex: 1 1 auto; flex: 1 1 auto;
grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap:1.25rem; gap: 0;
min-height: 0; min-height: 0;
} }
.peer-sidebar {
padding:1rem;
border-radius: 1.3rem;
border: 1px solid var(--surface-border-soft);
background: var(--panel-soft-background);
align-self: start;
}
.peer-dropdown { .peer-dropdown {
position: relative; position: relative;
min-width: min(18rem, 42vw);
} }
.peer-dropdown-trigger { .peer-dropdown-trigger {
@@ -264,7 +257,7 @@
position: absolute; position: absolute;
top: calc(100% + 0.65rem); top: calc(100% + 0.65rem);
left: 0; left: 0;
right: 0; width: 100%;
z-index: 4; z-index: 4;
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -288,7 +281,7 @@
border-radius: 1rem; border-radius: 1rem;
color: inherit; color: inherit;
background: var(--surface-background); background: var(--surface-background);
font-size: 0.7em; font-size: 1.05em;
transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease;
} }
@@ -309,7 +302,7 @@
} }
.peer-dropdown-caret { .peer-dropdown-caret {
font-size: 1.34rem; font-size: 4.02rem;
line-height: 1; line-height: 1;
transition: transform 160ms ease; transition: transform 160ms ease;
} }
@@ -479,6 +472,14 @@
background: var(--badge-background); background: var(--badge-background);
} }
.bubble-emoji-only {
max-width: none;
padding: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.bubble-meta { .bubble-meta {
display: grid; display: grid;
gap: 0.12rem; gap: 0.12rem;
@@ -491,6 +492,11 @@
display: block; display: block;
} }
.emoji-only-text {
font-size: clamp(2.1rem, 5vw, 3.4rem);
line-height: 1.15;
}
.bubble-system-status { .bubble-system-status {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -745,8 +751,8 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.peer-list { .peer-dropdown {
max-height: 16rem; min-width: min(100%, 18rem);
} }
.status-indicators { .status-indicators {

View File

@@ -22,6 +22,9 @@ import type { CallMode, ChatEntry, ConnectionState, PeerSummary } from './models
styleUrl: './chat-page.component.scss', styleUrl: './chat-page.component.scss',
}) })
export class ChatPageComponent implements OnDestroy { export class ChatPageComponent implements OnDestroy {
private readonly graphemeSegmenter = typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null;
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly ngZone = inject(NgZone); private readonly ngZone = inject(NgZone);
@@ -554,6 +557,17 @@ export class ChatPageComponent implements OnDestroy {
await this.session.deleteMessage(entry); await this.session.deleteMessage(entry);
} }
isEmojiOnlyEntry(entry: ChatEntry): boolean {
if (entry.kind !== 'text' || entry.direction === 'system' || !entry.text?.trim()) {
return false;
}
return entry.text
.trim()
.split(/\s+/u)
.every((token) => this.isEmojiToken(token));
}
async deleteConversation(peerId: string, event?: Event): Promise<void> { async deleteConversation(peerId: string, event?: Event): Promise<void> {
event?.stopPropagation(); event?.stopPropagation();
await this.session.deleteConversation(peerId); await this.session.deleteConversation(peerId);
@@ -926,4 +940,19 @@ export class ChatPageComponent implements OnDestroy {
}); });
}); });
} }
private isEmojiToken(token: string): boolean {
if (!token) {
return false;
}
const graphemes = this.graphemeSegmenter
? Array.from(this.graphemeSegmenter.segment(token), ({ segment }) => segment)
: [token];
return graphemes.every((grapheme) =>
/[\p{Emoji}\p{Extended_Pictographic}\u20E3]/u.test(grapheme)
&& /^[\p{Emoji}\p{Emoji_Component}\p{Extended_Pictographic}\u200D\uFE0F\u20E3]+$/u.test(grapheme),
);
}
} }