diff --git a/ipc/chat.handler.ts b/ipc/chat.handler.ts index 2df93fe..55468a0 100644 --- a/ipc/chat.handler.ts +++ b/ipc/chat.handler.ts @@ -3,6 +3,7 @@ import type { IpcMainInvokeEvent } from 'electron'; import { chatService, type ChatPayload, + type UpdateChatAvatarPayload, type UpdateChatTitlePayload, } from '../services/chat.service.js'; import { withIpcErrorHandling } from './ipc-handler.js'; @@ -32,4 +33,11 @@ export function registerChatHandlers(): void { chatService.deleteChat(chatId), ), ); + + ipcMain.handle( + 'db:updateChatAvatar', + withIpcErrorHandling(async (_event: IpcMainInvokeEvent, payload: UpdateChatAvatarPayload) => + chatService.updateChatAvatar(payload), + ), + ); } diff --git a/services/chat.service.ts b/services/chat.service.ts index 2cf6740..8ab716b 100644 --- a/services/chat.service.ts +++ b/services/chat.service.ts @@ -18,10 +18,15 @@ interface ChatRow { updated_at: string; } +interface AvatarPayload { + type: 'image' | 'text'; + value: string; +} + export interface ChatPayload { name: string; status: string; - avatar: string; + avatar: AvatarPayload; subtitle?: string | null; timeLabel?: string | null; unreadCount?: number | null; @@ -35,6 +40,11 @@ export interface UpdateChatTitlePayload { name: string; } +export interface UpdateChatAvatarPayload { + chatId: Uuid; + avatar: AvatarPayload; +} + export class ChatService { constructor(private readonly db: DbService) {} @@ -76,7 +86,7 @@ export class ChatService { ORDER BY id DESC `); - return rows.map(this.mapChatRow); + return rows.map((row) => this.mapChatRow(row)); } async createChat(chat: ChatPayload) { @@ -104,7 +114,7 @@ export class ChatService { chatId, chat.name, chat.status, - chat.avatar, + JSON.stringify(chat.avatar), chat.subtitle ?? null, chat.timeLabel ?? null, chat.unreadCount ?? 0, @@ -144,6 +154,64 @@ export class ChatService { return this.mapChatRow(row); } + public parseAvatarColumn(value: string | null, rowId: Uuid) { + const parsedValue = this.db.parseJsonColumn(value, 'avatar', rowId); + if (parsedValue === undefined) { + return value; + } + + if ( + parsedValue && + typeof parsedValue === 'object' && + typeof parsedValue.type === 'string' && + typeof parsedValue.value === 'string' + ) { + return parsedValue; + } + + console.warn(`Unexpected avatar payload for chat ${rowId}.`, parsedValue); + return value; + } + + async updateChatAvatar({ chatId, avatar }: UpdateChatAvatarPayload) { + const now = new Date().toISOString(); + await this.db.run( + ` + UPDATE chats + SET avatar = ?, + updated_at = ? + WHERE id = ? + `, + [JSON.stringify(avatar), now, chatId], + ); + + const row = await this.db.get( + ` + SELECT + id, + name, + status, + avatar, + subtitle, + time_label, + unread_count, + highlight_time, + avatar_ring, + tip_label, + created_at, + updated_at + FROM chats + WHERE id = ? + `, + [chatId], + ); + if (!row) { + throw new Error(`Updated avatar for chat ${chatId} could not be loaded.`); + } + + return this.mapChatRow(row); + } + async updateChatTitle({ chatId, name }: UpdateChatTitlePayload) { const now = new Date().toISOString(); await this.db.run( @@ -196,7 +264,7 @@ export class ChatService { id: row.id, name: row.name, status: row.status, - avatar: row.avatar, + avatar: this.parseAvatarColumn(row.avatar, row.id), subtitle: row.subtitle ?? undefined, timeLabel: row.time_label ?? undefined, unreadCount: row.unread_count ?? undefined, diff --git a/services/db.service.ts b/services/db.service.ts index 385a9bb..cc33074 100644 --- a/services/db.service.ts +++ b/services/db.service.ts @@ -6,6 +6,7 @@ export type SqlParameter = string | number | null; export type RunResult = { lastID: number; changes: number }; const sqlite = sqlite3.verbose(); +type Uuid = string; export class DbService { private database?: sqlite3.Database; @@ -15,6 +16,19 @@ export class DbService { this.database = undefined; } + public parseJsonColumn(value: string | null, fieldName: string, rowId: Uuid) { + if (!value) { + return undefined; + } + + try { + return JSON.parse(value); + } catch (error) { + console.warn(`Failed to parse ${fieldName} for message ${rowId}.`, error); + return undefined; + } + } + run(sql: string, params: SqlParameter[] = []): Promise { return new Promise((resolve, reject) => { this.getDatabase().run( diff --git a/services/message.service.ts b/services/message.service.ts index 38edfda..493a0b4 100644 --- a/services/message.service.ts +++ b/services/message.service.ts @@ -274,30 +274,19 @@ export class MessageService { deletable: Boolean(row.deletable), attachment: this.parseAttachmentColumn(row.attachment, 'attachment', row.id), possibleAnswers: this.parseStringArrayColumn(row.possible_answers, 'possible_answers', row.id), - validatorSpec: this.parseJsonColumn(row.validator_spec, 'validator_spec', row.id), + validatorSpec: this.db.parseJsonColumn(row.validator_spec, 'validator_spec', row.id), validationErrorMessage: row.validation_error_message ?? undefined, }; } - private parseJsonColumn(value: string | null, fieldName: string, rowId: Uuid): unknown { - if (!value) { - return undefined; - } - - try { - return JSON.parse(value); - } catch (error) { - console.warn(`Failed to parse ${fieldName} for message ${rowId}.`, error); - return undefined; - } - } + private parseStringArrayColumn( value: string | null, fieldName: string, rowId: Uuid, ): string[] | undefined { - const parsedValue = this.parseJsonColumn(value, fieldName, rowId); + const parsedValue = this.db.parseJsonColumn(value, fieldName, rowId); if (parsedValue === undefined) { return undefined; } @@ -315,7 +304,7 @@ export class MessageService { fieldName: string, rowId: Uuid, ): AttachmentPayload | undefined { - const parsedValue = this.parseJsonColumn(value, fieldName, rowId); + const parsedValue = this.db.parseJsonColumn(value, fieldName, rowId); if (parsedValue === undefined) { return undefined; } diff --git a/src/agents/AiAgent/AiAgent.ts b/src/agents/AiAgent/AiAgent.ts index 72bef67..2dbc0ee 100644 --- a/src/agents/AiAgent/AiAgent.ts +++ b/src/agents/AiAgent/AiAgent.ts @@ -29,6 +29,10 @@ export class AiAgent extends Agent { const lastMessage = this.chat.messages.at(-1) as Message; this.aiService.sendMessage(lastMessage.value as string).subscribe((response) => { void this.chatService.setChatTitle(this.chat, response.model); + void this.chatService.updateChatAvatar(this.chat, { + type: 'text', + value: response.model.slice(0, 2).toUpperCase(), + }); const aiMessage = response.choices[0].message.content; this.supporter.sendMessage(aiMessage); }); diff --git a/src/app/chat-list-component/chat-list-component.html b/src/app/chat-list-component/chat-list-component.html index 0410560..2f5d9cc 100644 --- a/src/app/chat-list-component/chat-list-component.html +++ b/src/app/chat-list-component/chat-list-component.html @@ -21,12 +21,15 @@ [class.chat-list-item--active]="selectedChat?.id === chat.id" (click)="onOpenChat(chat)" > -
-
{{ avatarFor(chat) }}
+
+
+ @if (chat.avatar.type === 'image') { + avatar + } @else { + {{ chat.avatar.value }} + }
+
{{ chat.name }} diff --git a/src/app/chat-list-component/chat-list-component.scss b/src/app/chat-list-component/chat-list-component.scss index 9c7ed0f..f4ba2e6 100644 --- a/src/app/chat-list-component/chat-list-component.scss +++ b/src/app/chat-list-component/chat-list-component.scss @@ -147,6 +147,44 @@ color: #21b15d !important; } +.chat-list-item__avatar { + width: 42px; + height: 42px; + border-radius: 50%; + overflow: hidden; + + display: flex; + align-items: center; + justify-content: center; +} + +.chat-list-item__avatar-img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.chat-delete-button { + width: 1.7rem; + height: 1.7rem; + border: 0; + border-radius: 50%; + background: transparent; + color: #7a8a96; + font-size: 0.8rem; + cursor: pointer; +} + +.chat-delete-button:hover:not(:disabled) { + background: #eceff1; + color: #d93025; +} + +.chat-delete-button:disabled { + opacity: 0.45; + cursor: default; +} + .chat-list-item__bottom { margin-top: 0.28rem; } diff --git a/src/app/chat-list-component/chat-list-component.ts b/src/app/chat-list-component/chat-list-component.ts index eeb7235..1f46848 100644 --- a/src/app/chat-list-component/chat-list-component.ts +++ b/src/app/chat-list-component/chat-list-component.ts @@ -34,26 +34,20 @@ export class ChatListComponent { }); return chats.sort( - (a, b) => (b.messages.at(-1)?.time?.getTime() ?? 0) - (a.messages.at(-1)?.time?.getTime() ?? 0), + (a, b) => + (b.messages.at(-1)?.time?.getTime() ?? 0) - (a.messages.at(-1)?.time?.getTime() ?? 0), ); } - avatarFor(chat: Chat): string { - return chat.avatar; - } - lastMessageText(chat: Chat): string { const lastMessage = chat.messages.at(-1); if (!lastMessage) { return chat.subtitle || 'start the conversation'; } - return DOMPurify.sanitize( - lastMessage.value || - lastMessage.attachment?.name || - '', - { ALLOWED_TAGS: [] } - ); + return DOMPurify.sanitize(lastMessage.value || lastMessage.attachment?.name || '', { + ALLOWED_TAGS: [], + }); } lastMessageTime(chat: Chat): string { diff --git a/src/app/chat-navbar-component/chat-navbar-component.html b/src/app/chat-navbar-component/chat-navbar-component.html index 593e0c9..6631cf9 100644 --- a/src/app/chat-navbar-component/chat-navbar-component.html +++ b/src/app/chat-navbar-component/chat-navbar-component.html @@ -96,7 +96,13 @@ } - +

{{ chat.name }}

{{ chat.status }}

diff --git a/src/app/chat-navbar-component/chat-navbar-component.scss b/src/app/chat-navbar-component/chat-navbar-component.scss index 4409bcc..b257215 100644 --- a/src/app/chat-navbar-component/chat-navbar-component.scss +++ b/src/app/chat-navbar-component/chat-navbar-component.scss @@ -200,6 +200,14 @@ color: var(--wa-green); font-weight: 700; flex: 0 0 auto; + overflow: hidden; + line-height: 1; +} + +.avatar__image { + width: 100%; + height: 100%; + object-fit: cover; } .contact-meta { diff --git a/src/classes/Chat.ts b/src/classes/Chat.ts index a233dab..af97044 100644 --- a/src/classes/Chat.ts +++ b/src/classes/Chat.ts @@ -4,11 +4,16 @@ import { Supporter } from './Supporter'; import { Client } from './Client'; import { Uuid } from '../interfaces/db/Uuid'; +export type Avatar = { + type: 'image' | 'text'; + value: string; +}; + export class Chat { id: Uuid; name: string; status: string; - avatar: string; + avatar: Avatar; subtitle?: string; timeLabel?: string; unreadCount: number; @@ -27,7 +32,7 @@ export class Chat { id: Uuid, name: string, status: string, - avatar: string, + avatar: Avatar, supporter: Supporter, options: { subtitle?: string; @@ -60,6 +65,9 @@ export class Chat { processFileUrl(file: File): string | Promise { return this._processFileUrlDriver(file); } + updateAvatar(avatar: Avatar) { + this.avatar = avatar; + } setFileUrlProcessor(processor: typeof this._processFileUrlDriver) { this._processFileUrlDriver = processor; } diff --git a/src/interfaces/db/ChatRecord.ts b/src/interfaces/db/ChatRecord.ts index 058fa12..b9e18bd 100644 --- a/src/interfaces/db/ChatRecord.ts +++ b/src/interfaces/db/ChatRecord.ts @@ -1,10 +1,11 @@ +import { Avatar } from '../../classes/Chat'; import type { Uuid } from './Uuid'; export interface ChatRecord { id: Uuid; name: string; status: string; - avatar: string; + avatar: Avatar; subtitle?: string; timeLabel?: string; unreadCount?: number; diff --git a/src/interfaces/db/CreateChatRecordInput.ts b/src/interfaces/db/CreateChatRecordInput.ts index 42761b3..925e4ef 100644 --- a/src/interfaces/db/CreateChatRecordInput.ts +++ b/src/interfaces/db/CreateChatRecordInput.ts @@ -1,7 +1,9 @@ +import { Avatar } from '../../classes/Chat'; + export interface CreateChatRecordInput { name: string; status: string; - avatar: string; + avatar: Avatar; subtitle?: string; timeLabel?: string; unreadCount?: number; diff --git a/src/interfaces/db/UpdateChatAvatarInput.ts b/src/interfaces/db/UpdateChatAvatarInput.ts new file mode 100644 index 0000000..c71a8cd --- /dev/null +++ b/src/interfaces/db/UpdateChatAvatarInput.ts @@ -0,0 +1,6 @@ +import { Avatar } from '../../classes/Chat'; + +export interface UpdateChatAvatarInput { + chatId: string; + avatar: Avatar; +} diff --git a/src/services/chat.service.ts b/src/services/chat.service.ts index 41656bc..e36632f 100644 --- a/src/services/chat.service.ts +++ b/src/services/chat.service.ts @@ -4,7 +4,7 @@ import { Message } from '../classes/Message'; import { coerceValidatorSpec } from '../classes/MessageValidator'; import { Question, getPersistableValidationErrorMessage } from '../classes/Question'; import { Supporter } from '../classes/Supporter'; -import { Chat } from '../classes/Chat'; +import { Avatar, Chat } from '../classes/Chat'; import { Agent } from '../classes/Agent'; import { AgentsService } from './agents.service'; import { ChatRecord } from '../interfaces/db/ChatRecord'; @@ -38,7 +38,7 @@ export class ChatService { const record = await this.dbService.createChat({ name, status, - avatar: name.slice(0, 2).toUpperCase(), + avatar: { type: 'text', value: name.slice(0, 2).toUpperCase() }, subtitle: options.subtitle, timeLabel: options.timeLabel, unreadCount: options.unreadCount, @@ -104,6 +104,19 @@ export class ChatService { chat.name = record.name; } + async updateChatAvatar(chat: Chat, avatar: Avatar): Promise { + if (chat.avatar.type === avatar.type && chat.avatar.value === avatar.value) { + return; + } + + const record = await this.dbService.updateChatAvatar({ + chatId: chat.id, + avatar, + }); + + chat.updateAvatar(record.avatar); + } + hydrateChat( record: ChatRecord, initialAgent: Agent, diff --git a/src/services/db.service.ts b/src/services/db.service.ts index 0969bcc..84f4ca6 100644 --- a/src/services/db.service.ts +++ b/src/services/db.service.ts @@ -6,6 +6,7 @@ import { CreateMessageRecordInput } from '../interfaces/db/CreateMessageRecordIn import { CreateSupporterRecordInput } from '../interfaces/db/CreateSupporterRecordInput'; import { MessageRecord } from '../interfaces/db/MessageRecord'; import { SupporterRecord } from '../interfaces/db/SupporterRecord'; +import { UpdateChatAvatarInput } from '../interfaces/db/UpdateChatAvatarInput'; import { UpdateChatTitleInput } from '../interfaces/db/UpdateChatTitleInput'; import { UpdateMessageInput } from '../interfaces/db/UpdateMessageInput'; import { UpdateSupporterAgentInput } from '../interfaces/db/UpdateSupporterAgentInput'; @@ -66,6 +67,10 @@ export class DbService { return this.electronService.invoke('db:updateChatTitle', input); } + async updateChatAvatar(input: UpdateChatAvatarInput): Promise { + return this.electronService.invoke('db:updateChatAvatar', input); + } + async updateSupporterAgent(input: UpdateSupporterAgentInput): Promise { return this.electronService.invoke('db:updateSupporterAgent', input); } diff --git a/tsconfig.app.json b/tsconfig.app.json index 264f459..e4dd97c 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -4,6 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/app", + "rootDir": "./src", "types": [] }, "include": [