feat: Theme studio v2

This commit is contained in:
2026-04-27 03:02:13 +02:00
parent 11c2588e45
commit 1b91eacb5b
52 changed files with 2792 additions and 844 deletions

View File

@@ -1,4 +1,7 @@
<div class="chat-layout relative h-full">
<div
appThemeNode="chatSurface"
class="chat-layout relative h-full"
>
<app-chat-message-list
[allMessages]="allMessages()"
[channelMessages]="channelMessages()"
@@ -19,7 +22,10 @@
(embedRemoved)="handleEmbedRemoved($event)"
/>
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<div
appThemeNode="chatComposerBar"
class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10"
>
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()"
@@ -47,6 +53,7 @@
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
appThemeNode="chatGifPickerSurface"
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()"

View File

@@ -22,6 +22,7 @@ import {
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { Message } from '../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../theme';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
@@ -43,7 +44,8 @@ import {
ChatMessageComposerComponent,
KlipyGifPickerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent
ChatMessageOverlaysComponent,
ThemeNodeDirective
],
templateUrl: './chat-messages.component.html',
styleUrl: './chat-messages.component.scss'
@@ -70,16 +72,10 @@ export class ChatMessagesComponent {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id;
return this.allMessages().filter(
(message) =>
message.roomId === roomId &&
(message.channelId || 'general') === channelId
);
return this.allMessages().filter((message) => message.roomId === roomId && (message.channelId || 'general') === channelId);
});
readonly conversationKey = computed(
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
);
readonly conversationKey = computed(() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`);
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16);
@@ -176,9 +172,7 @@ export class ChatMessagesComponent {
if (!message || !currentUserId)
return;
const hasReacted = message.reactions.some(
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
);
const hasReacted = message.reactions.some((reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId);
if (hasReacted) {
this.store.dispatch(
@@ -243,9 +237,7 @@ export class ChatMessagesComponent {
const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.klipyGifPickerAnchorRight.set(
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
);
this.klipyGifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
}
private getKlipyGifPickerWidth(viewportWidth: number): number {
@@ -290,10 +282,7 @@ export class ChatMessagesComponent {
if (blob) {
try {
const result = await electronApi.saveFileAs(
attachment.filename,
await this.blobToBase64(blob)
);
const result = await electronApi.saveFileAs(attachment.filename, await this.blobToBase64(blob));
if (result.saved || result.cancelled)
return;
@@ -416,12 +405,7 @@ export class ChatMessagesComponent {
const message = [...this.channelMessages()]
.reverse()
.find(
(entry) =>
entry.senderId === currentUserId &&
entry.content === content &&
!entry.isDeleted
);
.find((entry) => entry.senderId === currentUserId && entry.content === content && !entry.isDeleted);
if (!message) {
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);

View File

@@ -1,7 +1,13 @@
<!-- eslint-disable @angular-eslint/template/button-has-type -->
<div #composerRoot>
<div
#composerRoot
appThemeNode="chatComposerBar"
>
@if (replyTo()) {
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
<div
appThemeNode="chatComposerReplyBar"
class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2"
>
<ng-icon
name="lucideReply"
class="h-4 w-4 text-muted-foreground"
@@ -31,6 +37,7 @@
(mouseleave)="onToolbarMouseLeave()"
>
<div
appThemeNode="chatComposerToolbar"
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
>
<button
@@ -124,6 +131,7 @@
<div class="border-border p-4">
<div
appThemeNode="chatComposerInput"
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
@@ -156,6 +164,7 @@
}
<button
appThemeNode="chatComposerSendButton"
type="button"
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"

View File

@@ -23,6 +23,7 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
import { Message } from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import type { RoomSignalSourceInput } from '../../../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
@@ -43,7 +44,8 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
FormsModule,
NgIcon,
ChatImageProxyFallbackDirective,
TypingIndicatorComponent
TypingIndicatorComponent,
ThemeNodeDirective
],
viewProviders: [
provideIcons({
@@ -415,11 +417,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
private hasPotentialFilePayload(
dataTransfer: DataTransfer | null,
treatMissingTypesAsPotentialFile = true
): boolean {
private hasPotentialFilePayload(dataTransfer: DataTransfer | null, treatMissingTypesAsPotentialFile = true): boolean {
if (!dataTransfer)
return false;

View File

@@ -2,11 +2,13 @@
@let msg = message();
@let attachmentsList = attachmentViewModels();
<div
appThemeNode="chatMessageBubble"
[attr.data-message-id]="msg.id"
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted"
>
<div
appThemeNode="chatMessageAvatar"
class="flex-shrink-0 cursor-pointer"
(click)="openSenderProfileCard($event); $event.stopPropagation()"
>
@@ -17,7 +19,10 @@
/>
</div>
<div class="min-w-0 flex-1">
<div
appThemeNode="chatMessageContent"
class="min-w-0 flex-1"
>
@if (msg.replyToId) {
@let reply = repliedMessage();
<div
@@ -150,7 +155,10 @@
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
<ng-icon
@@ -172,7 +180,10 @@
</div>
</div>
} @else {
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div
appThemeNode="chatAttachmentCard"
class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
<ng-icon
@@ -220,7 +231,10 @@
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-border bg-secondary/40 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
@@ -247,7 +261,10 @@
</div>
</div>
} @else {
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div
appThemeNode="chatAttachmentCard"
class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
@@ -271,7 +288,10 @@
</div>
}
} @else {
<div class="rounded-md border border-border bg-secondary/40 p-2">
<div
appThemeNode="chatAttachmentCard"
class="rounded-md border border-border bg-secondary/40 p-2"
>
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
@@ -339,6 +359,7 @@
<div class="mt-2 flex flex-wrap gap-1">
@for (reaction of getGroupedReactions(); track reaction.emoji) {
<button
appThemeNode="chatReactionPill"
(click)="toggleReaction(reaction.emoji)"
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
[class.ring-1]="reaction.hasCurrentUser"
@@ -354,6 +375,7 @@
@if (!msg.isDeleted) {
<div
appThemeNode="chatMessageActions"
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
>
<div class="relative">

View File

@@ -37,6 +37,7 @@ import {
Message,
User
} from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme';
import {
ChatAudioPlayerComponent,
@@ -96,7 +97,8 @@ interface ChatMessageAttachmentViewModel extends Attachment {
ChatVideoPlayerComponent,
ChatMessageMarkdownComponent,
ChatLinkEmbedComponent,
UserAvatarComponent
UserAvatarComponent,
ThemeNodeDirective
],
viewProviders: [
provideIcons({
@@ -150,15 +152,17 @@ export class ChatMessageItemComponent {
const msg = this.message();
const found = this.userLookup().get(msg.senderId);
return found ?? {
id: msg.senderId,
oderId: msg.senderId,
username: msg.senderName,
displayName: msg.senderName,
status: 'disconnected',
role: 'member',
joinedAt: 0
};
return (
found ?? {
id: msg.senderId,
oderId: msg.senderId,
username: msg.senderName,
displayName: msg.senderName,
status: 'disconnected',
role: 'member',
joinedAt: 0
}
);
});
editContent = '';
@@ -175,9 +179,7 @@ export class ChatMessageItemComponent {
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
void this.attachmentVersion();
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) =>
this.buildAttachmentViewModel(attachment)
);
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) => this.buildAttachmentViewModel(attachment));
});
private readonly syncAttachmentVersion = effect(() => {
const version = this.attachmentsSvc.updated();
@@ -320,8 +322,7 @@ export class ChatMessageItemComponent {
hour: '2-digit',
minute: '2-digit'
});
const toDay = (value: Date) =>
new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
const toDay = (value: Date) => new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
if (dayDiff === 0)
@@ -331,11 +332,7 @@ export class ChatMessageItemComponent {
return 'Yesterday ' + time;
if (dayDiff < 7) {
return (
date.toLocaleDateString([], { weekday: 'short' }) +
' ' +
time
);
return date.toLocaleDateString([], { weekday: 'short' }) + ' ' + time;
}
return (
@@ -402,10 +399,7 @@ export class ChatMessageItemComponent {
}
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
return (
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
);
return (this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
}
getMediaAttachmentStatusText(attachment: Attachment): string {
@@ -418,9 +412,7 @@ export class ChatMessageItemComponent {
: 'Large audio file. Accept the download to play it in chat.';
}
return this.isVideoAttachment(attachment)
? 'Waiting for video source...'
: 'Waiting for audio source...';
return this.isVideoAttachment(attachment) ? 'Waiting for video source...' : 'Waiting for audio source...';
}
getMediaAttachmentActionLabel(attachment: Attachment): string {
@@ -484,8 +476,7 @@ export class ChatMessageItemComponent {
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isVideo = this.isVideoAttachment(attachment);
const isAudio = this.isAudioAttachment(attachment);
const requiresMediaDownloadAcceptance =
(isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
const requiresMediaDownloadAcceptance = (isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
return {
...attachment,
@@ -493,8 +484,12 @@ export class ChatMessageItemComponent {
isUploader: this.isUploader(attachment),
isVideo,
mediaActionLabel: requiresMediaDownloadAcceptance
? attachment.requestError ? 'Retry download' : 'Accept download'
: attachment.requestError ? 'Retry' : 'Request',
? attachment.requestError
? 'Retry download'
: 'Accept download'
: attachment.requestError
? 'Retry'
: 'Request',
mediaStatusText: attachment.requestError
? attachment.requestError
: requiresMediaDownloadAcceptance
@@ -504,15 +499,11 @@ export class ChatMessageItemComponent {
: isVideo
? 'Waiting for video source...'
: 'Waiting for audio source...',
progressPercent: attachment.size > 0
? ((attachment.receivedBytes || 0) * 100) / attachment.size
: 0
progressPercent: attachment.size > 0 ? ((attachment.receivedBytes || 0) * 100) / attachment.size : 0
};
}
private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc
.getForMessage(this.message().id)
.find((attachment) => attachment.id === attachmentId);
return this.attachmentsSvc.getForMessage(this.message().id).find((attachment) => attachment.id === attachmentId);
}
}

View File

@@ -1,5 +1,6 @@
<div
#messagesContainer
appThemeNode="chatMessageList"
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
[style.padding-bottom.px]="bottomPadding()"
(scroll)="onScroll()"
@@ -39,7 +40,10 @@
@for (message of messages(); track message.id; let index = $index) {
@if (dateSeparatorLabels().get(index); as separatorLabel) {
<div class="flex items-center gap-3 py-1">
<div
appThemeNode="chatDateSeparator"
class="flex items-center gap-3 py-1"
>
<div class="h-px flex-1 bg-border"></div>
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
{{ separatorLabel }}
@@ -70,7 +74,10 @@
@if (showNewMessagesBar()) {
<div class="pointer-events-none sticky bottom-4 flex justify-center">
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow">
<div
appThemeNode="chatNewMessagesBar"
class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow"
>
<span class="text-sm text-muted-foreground">New messages</span>
<button
type="button"

View File

@@ -26,6 +26,7 @@ import {
ChatMessageReplyEvent
} from '../../models/chat-messages.model';
import { selectAllUsers } from '../../../../../../store/users/users.selectors';
import { ThemeNodeDirective } from '../../../../../theme';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
interface PrismGlobal {
@@ -41,7 +42,11 @@ declare global {
@Component({
selector: 'app-chat-message-list',
standalone: true,
imports: [CommonModule, ChatMessageItemComponent],
imports: [
CommonModule,
ChatMessageItemComponent,
ThemeNodeDirective
],
templateUrl: './chat-message-list.component.html',
host: {
style: 'display: contents;'
@@ -94,9 +99,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return all.slice(all.length - limit);
});
readonly hasMoreMessages = computed(
() => this.channelMessages().length > this.displayLimit()
);
readonly hasMoreMessages = computed(() => this.channelMessages().length > this.displayLimit());
readonly dateSeparatorLabels = computed(() => {
const labels = new Map<number, string>();
@@ -165,8 +168,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
return;
}
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount;
if (newMessages) {
@@ -228,8 +230,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
if (!element || this.isAutoScrolling)
return;
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
const shouldStickToBottom = distanceFromBottom <= 300;
if (shouldStickToBottom) {
@@ -386,11 +387,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
}
if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener(
'load',
this.boundOnImageLoad,
true
);
this.messagesContainer.nativeElement.removeEventListener('load', this.boundOnImageLoad, true);
this.boundOnImageLoad = null;
}