diff --git a/client/src/app/chat-page.component.html b/client/src/app/chat-page.component.html index cd8e542..b229755 100644 --- a/client/src/app/chat-page.component.html +++ b/client/src/app/chat-page.component.html @@ -190,7 +190,14 @@ } @default { -

{{ entry.text }}

+ @if (entry.showSpinner) { +
+ +

{{ entry.text }}

+
+ } @else { +

{{ entry.text }}

+ } } } diff --git a/client/src/app/chat-page.component.scss b/client/src/app/chat-page.component.scss index ed8f0dd..70800ec 100644 --- a/client/src/app/chat-page.component.scss +++ b/client/src/app/chat-page.component.scss @@ -16,6 +16,11 @@ box-shadow: 0 20px 60px var(--shadow-color); } +.chat-page { + width: min(100%, 95vw); + margin-inline: auto; +} + .back-link { color: var(--link-color); text-decoration: none; @@ -297,6 +302,23 @@ display: block; } +.bubble-system-status { + display: inline-flex; + align-items: center; + gap: 0.7rem; +} + +.bubble-spinner { + width: 1rem; + height: 1rem; + flex: 0 0 auto; + border: 0.15rem solid currentColor; + border-right-color: transparent; + border-radius: 999px; + opacity: 0.8; + animation: bubble-spin 700ms linear infinite; +} + .composer { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; @@ -467,6 +489,12 @@ } } +@keyframes bubble-spin { + to { + transform: rotate(360deg); + } +} + @media (max-width: 767.98px) { .chat-layout { grid-template-columns: 1fr; diff --git a/client/src/app/chat-page.component.ts b/client/src/app/chat-page.component.ts index 4051c42..8a9ccbb 100644 --- a/client/src/app/chat-page.component.ts +++ b/client/src/app/chat-page.component.ts @@ -116,7 +116,17 @@ export class ChatPageComponent { return; } - await this.session.requestGeneratedImage(peerId, this.messageText); + const requested = await this.session.requestGeneratedImage(peerId, this.messageText); + + if (!requested) { + return; + } + + this.messageText = ''; + this.handleMessageTextChange(''); + this.emojiPickerOpen.set(false); + this.composerSelectionStart = 0; + this.composerSelectionEnd = 0; } handleComposerEnter(event: Event): void { diff --git a/client/src/app/chat-session.service.ts b/client/src/app/chat-session.service.ts index 65cd7e5..72acdca 100644 --- a/client/src/app/chat-session.service.ts +++ b/client/src/app/chat-session.service.ts @@ -157,7 +157,10 @@ export class ChatSessionService { private readonly outgoingTypingIdleTimeouts = new Map(); private readonly outgoingTypingStates = new Map(); private readonly messageStoreOperations = new Map>(); - private readonly pendingImageGenerationRequests = new Map(); + private readonly pendingImageGenerationRequests = new Map< + string, + { peerId: string; prompt: string; waitMessageId: string } + >(); private readonly remoteVideoStreams = signal>([]); private readonly activeCameraPeerId = signal(null); private sessionKeepaliveIntervalId: number | null = null; @@ -772,33 +775,39 @@ export class ChatSessionService { } } - async requestGeneratedImage(peerId: string, prompt: string): Promise { + async requestGeneratedImage(peerId: string, prompt: string): Promise { const trimmedPrompt = prompt.trim(); if (!trimmedPrompt) { this.error.set('Enter a text prompt before requesting an image.'); - return; + return false; } if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { this.error.set('You must be connected to signaling before requesting an image.'); - return; + return false; } const requestId = crypto.randomUUID(); + const waitMessageId = this.addSystemMessage(peerId, 'Generating image from prompt.', { + persistent: true, + showSpinner: true, + }); this.pendingImageGenerationRequests.set(requestId, { peerId, prompt: trimmedPrompt, + waitMessageId, }); this.error.set(null); - this.addSystemMessage(peerId, 'Generating image from prompt.'); this.websocket.send(JSON.stringify({ type: 'image-generation', requestId, peerId, prompt: trimmedPrompt, })); + + return true; } private async loadAccessKeys(): Promise { @@ -972,6 +981,10 @@ export class ChatSessionService { fileMimeType: event.mimeType, downloadUrl: URL.createObjectURL(imageBlob), }, imageBlob); + + if (pendingRequest) { + this.removeMessageById(pendingRequest.waitMessageId); + } } private handleGeneratedImageError(event: Extract): void { @@ -979,6 +992,7 @@ export class ChatSessionService { if (pendingRequest) { this.pendingImageGenerationRequests.delete(event.requestId); + this.removeMessageById(pendingRequest.waitMessageId); this.addSystemMessage(pendingRequest.peerId, 'Image generation failed.'); } @@ -1628,7 +1642,11 @@ export class ChatSessionService { } } - private addSystemMessage(peerId: string, text: string): void { + private addSystemMessage( + peerId: string, + text: string, + options?: { persistent?: boolean; showSpinner?: boolean }, + ): string { const id = crypto.randomUUID(); this.pushMessage({ @@ -1638,13 +1656,18 @@ export class ChatSessionService { kind: 'system', createdAt: Date.now(), authorLabel: 'System', + showSpinner: options?.showSpinner, text, }); - const timeoutId = window.setTimeout(() => { - this.removeMessageById(id); - }, ChatSessionService.systemMessageLifetimeMs); - this.systemMessageTimeouts.set(id, timeoutId); + if (!options?.persistent) { + const timeoutId = window.setTimeout(() => { + this.removeMessageById(id); + }, ChatSessionService.systemMessageLifetimeMs); + this.systemMessageTimeouts.set(id, timeoutId); + } + + return id; } private isPolitePeer(peerId: string): boolean { diff --git a/client/src/app/models.ts b/client/src/app/models.ts index b549d87..b9be44d 100644 --- a/client/src/app/models.ts +++ b/client/src/app/models.ts @@ -97,6 +97,7 @@ export interface ChatEntry { kind: 'text' | 'json' | 'file' | 'system'; createdAt: number; authorLabel: string; + showSpinner?: boolean; text?: string; payload?: unknown; fileName?: string;