Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

View File

@@ -0,0 +1,143 @@
# Chat Domain
Text messaging, reactions, GIF search, typing indicators, and the user list. All UI is under `feature/`; application services handle GIF integration; domain rules govern message editing, deletion, and sync.
## Module map
```
chat/
├── application/
│ └── klipy.service.ts GIF search via the KLIPY API (proxied through the server)
├── domain/
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
│ └── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
├── feature/
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
│ │ ├── chat-messages.component.ts Root component: replies, GIF picker, reactions, drag-drop
│ │ ├── components/
│ │ │ ├── message-composer/ Markdown toolbar, file drag-drop, send
│ │ │ ├── message-item/ Single message bubble with edit/delete/react
│ │ │ ├── message-list/ Paginated list (50 msgs/page), auto-scroll, Prism highlighting
│ │ │ └── message-overlays/ Context menus, reaction picker, reply preview
│ │ ├── models/ View models for messages
│ │ └── services/
│ │ └── chat-markdown.service.ts Markdown-to-HTML rendering
│ │
│ ├── klipy-gif-picker/ GIF search/browse picker panel
│ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names)
│ └── user-list/ Online user sidebar
└── index.ts Barrel exports
```
## Component composition
`ChatMessagesComponent` is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting a GIF.
```mermaid
graph TD
Chat[ChatMessagesComponent]
List[MessageListComponent]
Composer[MessageComposerComponent]
Overlays[MessageOverlays]
Item[MessageItemComponent]
GIF[KlipyGifPickerComponent]
Typing[TypingIndicatorComponent]
Users[UserListComponent]
Chat --> List
Chat --> Composer
Chat --> Overlays
Chat --> GIF
List --> Item
Item --> Overlays
click Chat "feature/chat-messages/chat-messages.component.ts" "Root chat view" _blank
click List "feature/chat-messages/components/message-list/" "Paginated message list" _blank
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send" _blank
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker" _blank
click Item "feature/chat-messages/components/message-item/" "Single message bubble" _blank
click GIF "feature/klipy-gif-picker/" "GIF search panel" _blank
click Typing "feature/typing-indicator/" "Typing indicator" _blank
click Users "feature/user-list/" "Online user sidebar" _blank
```
## Message lifecycle
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Editing and deletion are sender-only operations.
```mermaid
sequenceDiagram
participant User
participant Composer as MessageComposer
participant Store as NgRx Store
participant DC as Data Channel
participant Peer as Remote Peer
User->>Composer: Type + send
Composer->>Store: dispatch addMessage
Composer->>DC: broadcastMessage(chat-message)
DC->>Peer: chat-message event
Note over User: Edit
User->>Store: dispatch editMessage
User->>DC: broadcastMessage(edit-message)
Note over User: Delete
User->>Store: dispatch deleteMessage (normaliseDeletedMessage)
User->>DC: broadcastMessage(delete-message)
```
## Message sync
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
```mermaid
sequenceDiagram
participant A as Peer A
participant B as Peer B
A->>B: inventory (up to 1000 msg IDs + timestamps)
B->>B: findMissingIds(remote, local)
B->>A: request missing message IDs
A->>B: message payloads (chunked, 200/batch)
```
`findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested.
## GIF integration
`KlipyService` checks availability on the active server, then proxies search requests through the server API. Images are rendered via an image proxy endpoint to avoid mixed-content issues.
```mermaid
graph LR
Picker[KlipyGifPickerComponent]
Klipy[KlipyService]
SD[ServerDirectoryFacade]
API[Server API]
Picker --> Klipy
Klipy --> SD
Klipy --> API
click Picker "feature/klipy-gif-picker/" "GIF search panel" _blank
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API" _blank
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank
```
## Domain rules
| Function | Purpose |
|---|---|
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
## Typing indicator
`TypingIndicatorComponent` listens for typing events from peers. Each event resets a 3-second TTL timer. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".

View File

@@ -0,0 +1,200 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import {
Observable,
firstValueFrom,
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerDirectoryFacade } from '../../server-directory';
export interface KlipyGif {
id: string;
slug: string;
title?: string;
url: string;
previewUrl: string;
width: number;
height: number;
}
interface KlipyAvailabilityResponse {
enabled: boolean;
}
export interface KlipyGifSearchResponse {
enabled: boolean;
results: KlipyGif[];
hasNext: boolean;
}
const DEFAULT_PAGE_SIZE = 24;
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
@Injectable({ providedIn: 'root' })
export class KlipyService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly availabilityState = signal({
enabled: false,
loading: true
});
private lastAvailabilityKey = '';
readonly isEnabled = computed(() => this.availabilityState().enabled);
readonly isLoading = computed(() => this.availabilityState().loading);
constructor() {
effect(() => {
const activeServer = this.serverDirectory.activeServer();
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
if (nextKey === this.lastAvailabilityKey)
return;
this.lastAvailabilityKey = nextKey;
void this.refreshAvailability();
});
}
async refreshAvailability(): Promise<void> {
this.availabilityState.set({ enabled: false,
loading: true });
try {
const response = await firstValueFrom(
this.http.get<KlipyAvailabilityResponse>(
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
)
);
this.availabilityState.set({
enabled: response.enabled === true,
loading: false
});
} catch {
this.availabilityState.set({ enabled: false,
loading: false });
}
}
searchGifs(
query: string,
page = 1,
perPage = DEFAULT_PAGE_SIZE
): Observable<KlipyGifSearchResponse> {
let params = new HttpParams()
.set('page', String(Math.max(1, Math.floor(page))))
.set('per_page', String(Math.max(1, Math.floor(perPage))))
.set('customer_id', this.getOrCreateCustomerId());
const trimmedQuery = query.trim();
if (trimmedQuery) {
params = params.set('q', trimmedQuery);
}
const locale = this.getPreferredLocale();
if (locale) {
params = params.set('locale', locale);
}
return this.http
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
.pipe(
map((response) => ({
enabled: response.enabled !== false,
results: Array.isArray(response.results) ? response.results : [],
hasNext: response.hasNext === true
})),
catchError((error) =>
throwError(() => new Error(this.extractErrorMessage(error)))
)
);
}
normalizeMediaUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed)
return '';
if (trimmed.startsWith('//'))
return `https:${trimmed}`;
return trimmed;
}
buildRenderableImageUrl(url: string): string {
const trimmed = this.normalizeMediaUrl(url);
if (!trimmed)
return '';
if (!/^https?:\/\//i.test(trimmed))
return trimmed;
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
}
private getPreferredLocale(): string | null {
if (typeof navigator === 'undefined' || !navigator.language)
return null;
const locale = navigator.language.trim();
return locale || null;
}
private getOrCreateCustomerId(): string {
if (typeof window === 'undefined') {
return 'server';
}
try {
const existing = window.localStorage.getItem(KLIPY_CUSTOMER_ID_STORAGE_KEY);
if (existing?.trim())
return existing;
const created = window.crypto?.randomUUID?.()
?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 10)}`;
window.localStorage.setItem(KLIPY_CUSTOMER_ID_STORAGE_KEY, created);
return created;
} catch {
return `klipy-${Date.now().toString(36)}`;
}
}
private extractErrorMessage(error: unknown): string {
const httpError = error as {
error?: {
error?: unknown;
message?: unknown;
};
message?: unknown;
};
if (typeof httpError?.error?.error === 'string')
return httpError.error.error;
if (typeof httpError?.error?.message === 'string')
return httpError.error.message;
if (typeof httpError?.message === 'string')
return httpError.message;
return 'Failed to load GIFs from KLIPY.';
}
}

View File

@@ -0,0 +1,59 @@
/** Maximum number of recent messages to include in sync inventories. */
export const INVENTORY_LIMIT = 1000;
/** Number of messages per chunk for inventory / batch transfers. */
export const CHUNK_SIZE = 200;
/** Aggressive sync poll interval (10 seconds). */
export const SYNC_POLL_FAST_MS = 10_000;
/** Idle sync poll interval after a clean (no-new-messages) cycle (15 minutes). */
export const SYNC_POLL_SLOW_MS = 900_000;
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
export const SYNC_TIMEOUT_MS = 5_000;
/** Large limit used for legacy full-sync operations. */
export const FULL_SYNC_LIMIT = 10_000;
/** Inventory item representing a message's sync state. */
export interface InventoryItem {
id: string;
ts: number;
rc: number;
ac?: number;
}
/** Splits an array into chunks of the given size. */
export function chunkArray<T>(items: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
export function findMissingIds(
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
): string[] {
const missing: string[] = [];
for (const item of remoteItems) {
const local = localMap.get(item.id);
if (
!local ||
item.ts > local.ts ||
(item.rc !== undefined && item.rc !== local.rc) ||
(item.ac !== undefined && item.ac !== local.ac)
) {
missing.push(item.id);
}
}
return missing;
}

View File

@@ -0,0 +1,31 @@
import { DELETED_MESSAGE_CONTENT, type Message } from '../../../shared-kernel';
/** Extracts the effective timestamp from a message (editedAt takes priority). */
export function getMessageTimestamp(msg: Message): number {
return msg.editedAt || msg.timestamp || 0;
}
/** Computes the most recent timestamp across a batch of messages. */
export function getLatestTimestamp(messages: Message[]): number {
return messages.reduce(
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
0
);
}
/** Strips sensitive content from a deleted message. */
export function normaliseDeletedMessage(message: Message): Message {
if (!message.isDeleted)
return message;
return {
...message,
content: DELETED_MESSAGE_CONTENT,
reactions: []
};
}
/** Whether the given user is allowed to edit this message. */
export function canEditMessage(message: Message, userId: string): boolean {
return message.senderId === userId;
}

View File

@@ -0,0 +1,68 @@
<div class="chat-layout relative h-full">
<app-chat-message-list
[allMessages]="allMessages()"
[channelMessages]="channelMessages()"
[loading]="loading()"
[syncing]="syncing()"
[currentUserId]="currentUser()?.id ?? null"
[isAdmin]="isAdmin()"
[bottomPadding]="composerBottomPadding()"
[conversationKey]="conversationKey()"
(replyRequested)="setReplyTo($event)"
(deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)"
(reactionAdded)="handleReactionAdded($event)"
(reactionToggled)="handleReactionToggled($event)"
(downloadRequested)="downloadAttachment($event)"
(imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
/>
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()"
(messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()"
(heightChanged)="handleComposerHeightChanged($event)"
(klipyGifPickerToggleRequested)="toggleKlipyGifPicker()"
/>
</div>
@if (showKlipyGifPicker()) {
<div
class="fixed inset-0 z-[89]"
(click)="closeKlipyGifPicker()"
(keydown.enter)="closeKlipyGifPicker()"
(keydown.space)="closeKlipyGifPicker()"
tabindex="0"
role="button"
aria-label="Close GIF picker"
style="-webkit-app-region: no-drag"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
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()"
>
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
</div>
</div>
}
<app-chat-message-overlays
[lightboxAttachment]="lightboxAttachment()"
[imageContextMenu]="imageContextMenu()"
(lightboxClosed)="closeLightbox()"
(contextMenuClosed)="closeImageContextMenu()"
(downloadRequested)="downloadAttachment($event)"
(copyRequested)="copyImageToClipboard($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
/>
</div>

View File

@@ -0,0 +1,12 @@
.chat-layout {
display: flex;
flex-direction: column;
}
.chat-bottom-bar {
pointer-events: auto;
right: 8px;
background: hsl(var(--background) / 0.85);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}

View File

@@ -0,0 +1,404 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
HostListener,
ViewChild,
computed,
inject,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif } from '../../application/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import {
selectAllMessages,
selectMessagesLoading,
selectMessagesSyncing
} from '../../../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { Message } from '../../../../shared-kernel';
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';
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
import {
ChatMessageComposerSubmitEvent,
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from './models/chat-messages.models';
@Component({
selector: 'app-chat-messages',
standalone: true,
imports: [
ChatMessageComposerComponent,
KlipyGifPickerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent
],
templateUrl: './chat-messages.component.html',
styleUrl: './chat-messages.component.scss'
})
export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade);
readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly loading = this.store.selectSignal(selectMessagesLoading);
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
readonly channelMessages = computed(() => {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id;
return this.allMessages().filter(
(message) =>
message.roomId === roomId &&
(message.channelId || 'general') === channelId
);
});
readonly conversationKey = computed(
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
);
readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null);
readonly showKlipyGifPicker = signal(false);
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
@HostListener('window:resize')
onWindowResize(): void {
if (this.showKlipyGifPicker()) {
this.syncKlipyGifPickerAnchor();
}
}
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
this.store.dispatch(
MessagesActions.sendMessage({
content: event.content,
replyToId: this.replyTo()?.id,
channelId: this.activeChannelId()
})
);
this.clearReply();
if (event.pendingFiles.length > 0) {
setTimeout(() => this.attachFilesToLastOwnMessage(event.content, event.pendingFiles), 100);
}
}
handleTypingStarted(): void {
try {
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
} catch {
/* ignore */
}
}
setReplyTo(message: ChatMessageReplyEvent): void {
this.replyTo.set(message);
}
clearReply(): void {
this.replyTo.set(null);
}
handleEditSaved(event: ChatMessageEditEvent): void {
this.store.dispatch(
MessagesActions.editMessage({
messageId: event.messageId,
content: event.content
})
);
}
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
if (this.isOwnMessage(message)) {
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
} else if (this.isAdmin()) {
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId: message.id }));
}
}
handleReactionAdded(event: ChatMessageReactionEvent): void {
this.store.dispatch(
MessagesActions.addReaction({
messageId: event.messageId,
emoji: event.emoji
})
);
}
handleReactionToggled(event: ChatMessageReactionEvent): void {
const message = this.channelMessages().find((entry) => entry.id === event.messageId);
const currentUserId = this.currentUser()?.id;
if (!message || !currentUserId)
return;
const hasReacted = message.reactions.some(
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
);
if (hasReacted) {
this.store.dispatch(
MessagesActions.removeReaction({
messageId: event.messageId,
emoji: event.emoji
})
);
} else {
this.store.dispatch(
MessagesActions.addReaction({
messageId: event.messageId,
emoji: event.emoji
})
);
}
}
handleComposerHeightChanged(height: number): void {
this.composerBottomPadding.set(height + 20);
}
toggleKlipyGifPicker(): void {
const nextState = !this.showKlipyGifPicker();
this.showKlipyGifPicker.set(nextState);
if (nextState) {
requestAnimationFrame(() => this.syncKlipyGifPickerAnchor());
}
}
closeKlipyGifPicker(): void {
this.showKlipyGifPicker.set(false);
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.closeKlipyGifPicker();
this.composer?.handleKlipyGifSelected(gif);
}
private syncKlipyGifPickerAnchor(): void {
const triggerRect = this.composer?.getKlipyTriggerRect();
if (!triggerRect) {
this.klipyGifPickerAnchorRight.set(16);
return;
}
const viewportWidth = window.innerWidth;
const popupWidth = this.getKlipyGifPickerWidth(viewportWidth);
const preferredRight = viewportWidth - triggerRect.right;
const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.klipyGifPickerAnchorRight.set(
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
);
}
private getKlipyGifPickerWidth(viewportWidth: number): number {
if (viewportWidth >= 1280)
return 52 * 16;
if (viewportWidth >= 768)
return 42 * 16;
if (viewportWidth >= 640)
return 34 * 16;
return Math.max(0, viewportWidth - 32);
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.lightboxAttachment.set(attachment);
}
}
closeLightbox(): void {
this.lightboxAttachment.set(null);
}
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {
this.imageContextMenu.set(event);
}
closeImageContextMenu(): void {
this.imageContextMenu.set(null);
}
async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl)
return;
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(
attachment.filename,
await this.blobToBase64(blob)
);
if (result.saved || result.cancelled)
return;
} catch {
/* fall back to browser download */
}
}
}
const link = document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async copyImageToClipboard(attachment: Attachment): Promise<void> {
this.closeImageContextMenu();
if (!attachment.objectUrl)
return;
try {
const response = await fetch(attachment.objectUrl);
const blob = await response.blob();
const pngBlob = await this.convertToPng(blob);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
} catch {
/* ignore */
}
}
private isOwnMessage(message: Message): boolean {
return message.senderId === this.currentUser()?.id;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl)
return null;
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
private convertToPng(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
if (blob.type === 'image/png') {
resolve(blob);
return;
}
const image = new Image();
const url = URL.createObjectURL(blob);
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext('2d');
if (!context) {
reject(new Error('Canvas not supported'));
return;
}
context.drawImage(image, 0, 0);
canvas.toBlob((pngBlob) => {
URL.revokeObjectURL(url);
if (pngBlob)
resolve(pngBlob);
else
reject(new Error('PNG conversion failed'));
}, 'image/png');
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Image load failed'));
};
image.src = url;
});
}
private attachFilesToLastOwnMessage(content: string, pendingFiles: File[]): void {
const currentUserId = this.currentUser()?.id;
if (!currentUserId)
return;
const message = [...this.channelMessages()]
.reverse()
.find(
(entry) =>
entry.senderId === currentUserId &&
entry.content === content &&
!entry.isDeleted
);
if (!message) {
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
return;
}
this.attachmentsSvc.publishAttachments(message.id, pendingFiles, currentUserId || undefined);
}
}

View File

@@ -0,0 +1,255 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/prefer-ngsrc -->
<div #composerRoot>
@if (replyTo()) {
<div 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"
/>
<span class="flex-1 text-sm text-muted-foreground">
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
</span>
<button
(click)="clearReply()"
class="rounded p-1 hover:bg-secondary"
>
<ng-icon
name="lucideX"
class="h-4 w-4 text-muted-foreground"
/>
</button>
</div>
}
<app-typing-indicator />
@if (toolbarVisible()) {
<div
class="pointer-events-auto"
(mousedown)="$event.preventDefault()"
(mouseenter)="onToolbarMouseEnter()"
(mouseleave)="onToolbarMouseLeave()"
>
<div
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
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline('**')"
>
<b>B</b>
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline('*')"
>
<i>I</i>
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline('~~')"
>
<s>S</s>
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline(inlineCodeToken)"
>
&#96;
</button>
<span class="mx-1 text-muted-foreground">|</span>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHeading(1)"
>
H1
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHeading(2)"
>
H2
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHeading(3)"
>
H3
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('> ')"
>
Quote
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('- ')"
>
• List
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyOrderedList()"
>
1. List
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyCodeBlock()"
>
Code
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyLink()"
>
Link
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyImage()"
>
Image
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHorizontalRule()"
>
HR
</button>
</div>
</div>
}
<div class="border-border p-4">
<div
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
>
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
@if (klipy.isEnabled()) {
<button
#klipyTrigger
type="button"
(click)="toggleKlipyGifPicker()"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.border-primary]="showKlipyGifPicker()"
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
[class.text-primary]="showKlipyGifPicker()"
aria-label="Search KLIPY GIFs"
title="Search KLIPY GIFs"
>
<ng-icon
name="lucideImage"
class="h-4 w-4"
/>
<span class="hidden sm:inline">GIF</span>
</button>
}
<button
type="button"
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
aria-label="Send message"
title="Send message"
>
<ng-icon
name="lucideSend"
class="h-5 w-5"
/>
</button>
</div>
<textarea
#messageInputRef
rows="1"
[(ngModel)]="messageContent"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
(keydown.enter)="onEnter($event)"
(input)="onInputChange(); autoResizeTextarea()"
(paste)="onPaste($event)"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
class="chat-textarea w-full rounded-[1.35rem] border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-dashed]="dragActive()"
[class.border-primary]="dragActive()"
[class.chat-textarea-expanded]="textareaExpanded()"
[class.ctrl-resize]="ctrlHeld()"
[class.pr-16]="!klipy.isEnabled()"
[class.pr-40]="klipy.isEnabled()"
></textarea>
@if (dragActive()) {
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/5"
>
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</div>
}
@if (pendingKlipyGif()) {
<div class="mt-2 flex">
<div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2">
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
<img
[src]="getPendingKlipyGifPreviewUrl()"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
class="h-full w-full object-cover"
loading="lazy"
/>
<span
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
>
KLIPY
</span>
</div>
<div class="min-w-0">
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
</div>
</div>
<button
type="button"
(click)="removePendingKlipyGif()"
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
>
Remove
</button>
</div>
</div>
}
@if (pendingFiles.length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (file of pendingFiles; track file.name) {
<div class="group flex items-center gap-2 rounded border border-border bg-secondary/60 px-2 py-1">
<div class="max-w-[14rem] truncate text-xs font-medium">{{ file.name }}</div>
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
<button
(click)="removePendingFile(file)"
class="rounded bg-destructive/20 px-1 py-0.5 text-[10px] text-destructive opacity-70 group-hover:opacity-100"
>
Remove
</button>
</div>
}
</div>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,41 @@
.chat-textarea {
--textarea-bg: hsl(40deg 3.7% 15.9% / 25%);
--textarea-collapsed-padding-y: 18px;
--textarea-expanded-padding-y: 8px;
background: var(--textarea-bg);
height: 62px;
min-height: 62px;
max-height: 520px;
overflow-y: hidden;
padding-top: var(--textarea-collapsed-padding-y);
padding-bottom: var(--textarea-collapsed-padding-y);
resize: none;
transition:
height 0.12s ease,
padding 0.12s ease;
&.chat-textarea-expanded {
padding-top: var(--textarea-expanded-padding-y);
padding-bottom: var(--textarea-expanded-padding-y);
}
&.ctrl-resize {
resize: vertical;
}
}
.send-btn {
opacity: 0;
pointer-events: none;
transform: scale(0.85);
transition:
opacity 0.2s ease,
transform 0.2s ease;
&.visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}

View File

@@ -0,0 +1,642 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
ViewChild,
inject,
input,
output,
signal
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideImage,
lucideReply,
lucideSend,
lucideX
} from '@ng-icons/lucide';
import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/klipy.service';
import { Message } from '../../../../../../shared-kernel';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
type LocalFileWithPath = File & {
path?: string;
};
const DEFAULT_TEXTAREA_HEIGHT = 62;
@Component({
selector: 'app-chat-message-composer',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
TypingIndicatorComponent
],
viewProviders: [
provideIcons({
lucideImage,
lucideReply,
lucideSend,
lucideX
})
],
templateUrl: './chat-message-composer.component.html',
styleUrl: './chat-message-composer.component.scss',
host: {
'(document:keydown)': 'onDocKeydown($event)',
'(document:keyup)': 'onDocKeyup($event)'
}
})
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
readonly replyTo = input<Message | null>(null);
readonly showKlipyGifPicker = input(false);
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
readonly typingStarted = output();
readonly replyCleared = output();
readonly heightChanged = output<number>();
readonly klipyGifPickerToggleRequested = output();
readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly toolbarVisible = signal(false);
readonly dragActive = signal(false);
readonly inputHovered = signal(false);
readonly ctrlHeld = signal(false);
readonly textareaExpanded = signal(false);
messageContent = '';
pendingFiles: File[] = [];
inlineCodeToken = '`';
private toolbarHovering = false;
private dragDepth = 0;
private lastTypingSentAt = 0;
private resizeObserver: ResizeObserver | null = null;
ngAfterViewInit(): void {
this.autoResizeTextarea();
this.observeHeight();
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
}
sendMessage(): void {
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
return;
const content = this.buildOutgoingMessageContent(raw);
this.messageSubmitted.emit({
content,
pendingFiles: [...this.pendingFiles]
});
this.messageContent = '';
this.pendingFiles = [];
this.pendingKlipyGif.set(null);
this.replyCleared.emit();
requestAnimationFrame(() => {
this.autoResizeTextarea();
this.messageInputRef?.nativeElement.focus();
});
}
onInputChange(): void {
const now = Date.now();
if (now - this.lastTypingSentAt > 1000) {
this.typingStarted.emit();
this.lastTypingSentAt = now;
}
}
clearReply(): void {
this.replyCleared.emit();
}
onEnter(event: Event): void {
const keyEvent = event as KeyboardEvent;
if (keyEvent.shiftKey)
return;
keyEvent.preventDefault();
this.sendMessage();
}
applyInline(token: string): void {
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyPrefix(prefix: string): void {
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyHeading(level: number): void {
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyOrderedList(): void {
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyCodeBlock(): void {
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyLink(): void {
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyImage(): void {
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyHorizontalRule(): void {
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
toggleKlipyGifPicker(): void {
if (!this.klipy.isEnabled())
return;
this.klipyGifPickerToggleRequested.emit();
}
getKlipyTriggerRect(): DOMRect | null {
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.pendingKlipyGif.set(gif);
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
this.sendMessage();
return;
}
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
removePendingKlipyGif(): void {
this.pendingKlipyGif.set(null);
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
getPendingKlipyGifPreviewUrl(): string {
const gif = this.pendingKlipyGif();
return gif ? this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url) : '';
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
removePendingFile(file: File): void {
const index = this.pendingFiles.findIndex((pendingFile) => pendingFile === file);
if (index >= 0) {
this.pendingFiles.splice(index, 1);
this.emitHeight();
}
}
onDragEnter(event: DragEvent): void {
if (!this.hasPotentialFilePayload(event.dataTransfer))
return;
event.preventDefault();
event.stopPropagation();
this.dragDepth++;
this.dragActive.set(true);
}
onDragOver(event: DragEvent): void {
if (!this.hasPotentialFilePayload(event.dataTransfer))
return;
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
this.dragActive.set(true);
}
onDragLeave(event: DragEvent): void {
if (!this.dragActive())
return;
event.preventDefault();
event.stopPropagation();
this.dragDepth = Math.max(0, this.dragDepth - 1);
if (this.dragDepth === 0) {
this.dragActive.set(false);
}
}
onDrop(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragDepth = 0;
const droppedFiles = this.extractFilesFromTransfer(event.dataTransfer);
if (droppedFiles.length === 0) {
this.dragActive.set(false);
return;
}
this.addPendingFiles(droppedFiles);
this.dragActive.set(false);
}
async onPaste(event: ClipboardEvent): Promise<void> {
if (!this.hasPotentialFilePayload(event.clipboardData, false))
return;
event.preventDefault();
event.stopPropagation();
const pastedFiles = await this.extractPastedFiles(event);
if (pastedFiles.length === 0)
return;
this.addPendingFiles(pastedFiles);
}
autoResizeTextarea(): void {
const element = this.messageInputRef?.nativeElement;
if (!element)
return;
element.style.height = 'auto';
element.style.height = Math.min(element.scrollHeight, 520) + 'px';
element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden';
this.syncTextareaExpandedState();
}
onInputFocus(): void {
this.toolbarVisible.set(true);
}
onInputBlur(): void {
setTimeout(() => {
if (!this.toolbarHovering) {
this.toolbarVisible.set(false);
}
}, 150);
}
onToolbarMouseEnter(): void {
this.toolbarHovering = true;
}
onToolbarMouseLeave(): void {
this.toolbarHovering = false;
if (document.activeElement !== this.messageInputRef?.nativeElement) {
this.toolbarVisible.set(false);
}
}
onDocKeydown(event: KeyboardEvent): void {
if (event.key === 'Control') {
this.ctrlHeld.set(true);
}
}
onDocKeyup(event: KeyboardEvent): void {
if (event.key === 'Control') {
this.ctrlHeld.set(false);
}
}
private getSelection(): { start: number; end: number } {
const element = this.messageInputRef?.nativeElement;
return {
start: element?.selectionStart ?? this.messageContent.length,
end: element?.selectionEnd ?? this.messageContent.length
};
}
private setSelection(start: number, end: number): void {
const element = this.messageInputRef?.nativeElement;
if (element) {
element.selectionStart = start;
element.selectionEnd = end;
element.focus();
}
}
private addPendingFiles(files: File[]): void {
if (files.length === 0)
return;
const mergedFiles = this.mergeUniqueFiles(this.pendingFiles, files);
if (mergedFiles.length === this.pendingFiles.length)
return;
this.pendingFiles = mergedFiles;
this.toolbarVisible.set(true);
this.emitHeight();
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
private hasPotentialFilePayload(
dataTransfer: DataTransfer | null,
treatMissingTypesAsPotentialFile = true
): boolean {
if (!dataTransfer)
return false;
if (dataTransfer.files?.length)
return true;
const items = dataTransfer.items;
if (items?.length) {
for (const item of items) {
if (item.kind === 'file') {
return true;
}
}
}
const types = dataTransfer.types;
if (!types || types.length === 0)
return treatMissingTypesAsPotentialFile;
for (const type of types) {
if (
type === 'Files' ||
type === 'application/x-moz-file' ||
type === 'public.file-url' ||
type === 'text/uri-list' ||
type === 'x-special/gnome-copied-files'
) {
return true;
}
}
return false;
}
private extractFilesFromTransfer(dataTransfer: DataTransfer | null): File[] {
const extractedFiles: File[] = [];
const items = dataTransfer?.items ?? null;
if (items && items.length) {
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
this.pushUniqueFile(extractedFiles, file);
}
}
}
}
const files = dataTransfer?.files;
if (!files?.length)
return extractedFiles;
for (const file of files) {
this.pushUniqueFile(extractedFiles, file);
}
return extractedFiles;
}
private mergeUniqueFiles(existingFiles: File[], incomingFiles: File[]): File[] {
const mergedFiles = [...existingFiles];
for (const file of incomingFiles) {
this.pushUniqueFile(mergedFiles, file);
}
return mergedFiles;
}
private pushUniqueFile(target: File[], candidate: File): void {
const exists = target.some((file) => this.areFilesEquivalent(file, candidate));
if (!exists) {
target.push(candidate);
}
}
private areFilesEquivalent(left: File, right: File): boolean {
const leftPath = this.getLocalFilePath(left);
const rightPath = this.getLocalFilePath(right);
if (leftPath && rightPath) {
return leftPath === rightPath;
}
if (left.name !== right.name || left.size !== right.size) {
return false;
}
const leftType = left.type.trim();
const rightType = right.type.trim();
if (leftType && rightType && leftType !== rightType) {
return false;
}
const leftLastModified = Number.isFinite(left.lastModified) ? left.lastModified : 0;
const rightLastModified = Number.isFinite(right.lastModified) ? right.lastModified : 0;
if (!leftLastModified || !rightLastModified) {
return true;
}
return Math.abs(leftLastModified - rightLastModified) <= 1000;
}
private getLocalFilePath(file: File): string {
return ((file as LocalFileWithPath).path || '').trim();
}
private async extractPastedFiles(event: ClipboardEvent): Promise<File[]> {
const directFiles = this.extractFilesFromTransfer(event.clipboardData);
if (directFiles.length > 0)
return directFiles;
return await this.readFilesFromElectronClipboard();
}
private async readFilesFromElectronClipboard(): Promise<File[]> {
const electronApi = this.electronBridge.getApi();
if (!electronApi)
return [];
try {
const clipboardFiles = await electronApi.readClipboardFiles();
return clipboardFiles.map((clipboardFile) => this.createFileFromClipboardPayload(clipboardFile));
} catch {
return [];
}
}
private createFileFromClipboardPayload(payload: ClipboardFilePayload): File {
const file = new File([this.base64ToArrayBuffer(payload.data)], payload.name, {
lastModified: payload.lastModified,
type: payload.mime
});
if (payload.path) {
try {
Object.defineProperty(file, 'path', {
configurable: true,
value: payload.path
});
} catch {
(file as LocalFileWithPath).path = payload.path;
}
}
return file;
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let index = 0; index < binaryString.length; index++) {
bytes[index] = binaryString.charCodeAt(index);
}
return bytes.buffer;
}
private buildOutgoingMessageContent(raw: string): string {
const withEmbeddedImages = this.markdown.appendImageMarkdown(raw);
const gif = this.pendingKlipyGif();
if (!gif)
return withEmbeddedImages;
const gifMarkdown = this.buildKlipyGifMarkdown(gif);
return withEmbeddedImages ? `${withEmbeddedImages}\n${gifMarkdown}` : gifMarkdown;
}
private buildKlipyGifMarkdown(gif: KlipyGif): string {
return `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
}
private observeHeight(): void {
const root = this.composerRoot?.nativeElement;
if (!root)
return;
this.syncTextareaExpandedState();
this.emitHeight();
if (typeof ResizeObserver === 'undefined')
return;
this.resizeObserver = new ResizeObserver(() => {
this.syncTextareaExpandedState();
this.emitHeight();
});
this.resizeObserver.observe(root);
}
private syncTextareaExpandedState(): void {
const textarea = this.messageInputRef?.nativeElement;
this.textareaExpanded.set(Boolean(textarea && textarea.offsetHeight > DEFAULT_TEXTAREA_HEIGHT));
}
private emitHeight(): void {
const root = this.composerRoot?.nativeElement;
if (root) {
this.heightChanged.emit(root.offsetHeight);
}
}
}

View File

@@ -0,0 +1,424 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
@let msg = message();
@let attachmentsList = attachmentViewModels();
<div
[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"
>
<app-user-avatar
[name]="msg.senderName"
size="md"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
@if (msg.replyToId) {
@let reply = repliedMessage();
<div
class="mb-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
(click)="requestReferenceScroll(msg.replyToId)"
>
<div class="h-3 w-4 rounded-tl-md border-l-2 border-t-2 border-muted-foreground/50"></div>
<ng-icon
name="lucideReply"
class="h-3 w-3"
/>
@if (reply) {
<span class="font-medium">{{ reply.senderName }}</span>
<span class="max-w-[200px] truncate">{{ reply.isDeleted ? deletedMessageContent : reply.content }}</span>
} @else {
<span class="italic">Original message not found</span>
}
</div>
}
<div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ msg.senderName }}</span>
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
@if (msg.editedAt && !msg.isDeleted) {
<span class="text-xs text-muted-foreground">(edited)</span>
}
</div>
@if (isEditing()) {
<div class="mt-1 flex items-start gap-2">
<textarea
#editTextareaRef
rows="1"
[(ngModel)]="editContent"
(keydown.enter)="onEditEnter($event)"
(keydown.escape)="cancelEdit()"
(input)="autoResizeEditTextarea()"
class="edit-textarea flex-1 rounded border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
></textarea>
<div class="flex flex-col gap-2">
<button
(click)="saveEdit()"
class="rounded p-1 text-primary hover:bg-primary/10"
>
<ng-icon
name="lucideCheck"
class="h-4 w-4"
/>
</button>
<button
(click)="cancelEdit()"
class="rounded p-1 text-muted-foreground hover:bg-secondary"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
</div>
} @else {
@if (msg.isDeleted) {
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
} @else {
<div class="chat-markdown mt-1 break-words">
<remark
[markdown]="msg.content"
[processor]="$any(remarkProcessor)"
>
<ng-template
[remarkTemplate]="'code'"
let-node
>
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
<remark-mermaid [code]="getMermaidCode(node.value)" />
} @else {
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
}
</ng-template>
<ng-template
[remarkTemplate]="'image'"
let-node
>
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<img
[src]="getMarkdownImageSource(node.url)"
[alt]="node.alt || 'Shared image'"
class="block max-h-80 max-w-full w-auto"
loading="lazy"
/>
@if (isKlipyMediaUrl(node.url)) {
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
}
</div>
</ng-template>
</remark>
</div>
@if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2">
@for (att of attachmentsList; track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<div
class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)"
>
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="max-h-80 w-auto cursor-pointer rounded-md"
(click)="openLightbox(att)"
/>
<div class="pointer-events-none absolute inset-0 rounded-md bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover/img:opacity-100">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="View full size"
>
<ng-icon
name="lucideExpand"
class="h-4 w-4"
/>
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div 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
name="lucideImage"
class="h-5 w-5 text-primary"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
</div>
} @else {
<div 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
name="lucideImage"
class="h-5 w-5 text-muted-foreground"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-0.5 text-xs"
[class.italic]="!att.requestError"
[class.opacity-70]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source…' }}
</div>
</div>
</div>
<button
(click)="retryImageRequest(att)"
class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
Retry
</button>
</div>
}
} @else if (att.isVideo || att.isAudio) {
@if (att.available && att.objectUrl) {
@if (att.isVideo) {
<app-chat-video-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
} @else {
<app-chat-audio-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div 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>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>{{ formatSpeed(att.speedBps) }}</span>
}
</div>
</div>
} @else {
<div 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>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-1 text-xs leading-relaxed"
[class.opacity-80]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.mediaStatusText }}
</div>
</div>
<button
(click)="requestAttachment(att)"
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
{{ att.mediaActionLabel }}
</button>
</div>
</div>
}
} @else {
<div 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>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
</div>
<div class="flex items-center gap-2">
@if (!att.isUploader) {
@if (!att.available) {
<div class="h-1.5 w-24 rounded bg-muted">
<div
class="h-1.5 rounded bg-primary"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else {
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
}
} @else {
<button
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)"
>
Download
</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
}
</div>
</div>
@if (!att.available && att.requestError) {
<div
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
>
{{ att.requestError }}
</div>
}
</div>
}
}
</div>
}
}
}
@if (!msg.isDeleted && msg.reactions.length > 0) {
<div class="mt-2 flex flex-wrap gap-1">
@for (reaction of getGroupedReactions(); track reaction.emoji) {
<button
(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"
[class.ring-primary]="reaction.hasCurrentUser"
>
<span>{{ reaction.emoji }}</span>
<span class="text-muted-foreground">{{ reaction.count }}</span>
</button>
}
</div>
}
</div>
@if (!msg.isDeleted) {
<div
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">
<button
(click)="toggleEmojiPicker()"
class="rounded-l-lg p-1.5 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideSmile"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (showEmojiPicker()) {
<div class="absolute bottom-full right-0 z-10 mb-2 flex gap-1 rounded-lg border border-border bg-card p-2 shadow-lg">
@for (emoji of commonEmojis; track emoji) {
<button
(click)="addReaction(emoji)"
class="rounded p-1 text-lg transition-colors hover:bg-secondary"
>
{{ emoji }}
</button>
}
</div>
}
</div>
<button
(click)="requestReply()"
class="p-1.5 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideReply"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (isOwnMessage()) {
<button
(click)="startEdit()"
class="p-1.5 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideEdit"
class="h-4 w-4 text-muted-foreground"
/>
</button>
}
@if (isOwnMessage() || isAdmin()) {
<button
(click)="requestDelete()"
class="rounded-r-lg p-1.5 transition-colors hover:bg-destructive/10"
>
<ng-icon
name="lucideTrash2"
class="h-4 w-4 text-destructive"
/>
</button>
}
</div>
}
</div>

View File

@@ -0,0 +1,189 @@
.chat-markdown {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 0.9375rem;
line-height: 1.5;
color: hsl(var(--foreground));
::ng-deep {
remark {
display: contents;
}
p {
margin: 0.25em 0;
}
strong {
font-weight: 700;
color: hsl(var(--foreground));
}
em {
font-style: italic;
}
del {
text-decoration: line-through;
opacity: 0.7;
}
a {
color: hsl(var(--primary));
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
h1, h2, h3, h4, h5, h6 {
margin: 0.5em 0 0.25em;
color: hsl(var(--foreground));
font-weight: 700;
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.3em; }
h3 { font-size: 1.15em; }
ul, ol {
margin: 0.25em 0;
padding-left: 1.5em;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
margin: 0.125em 0;
}
blockquote {
margin: 0.5em 0;
border-left: 3px solid hsl(var(--primary) / 0.5);
border-radius: 0 var(--radius) var(--radius) 0;
background: hsl(var(--secondary) / 0.3);
padding: 0.25em 0.75em;
color: hsl(var(--muted-foreground));
}
code:not([class*='language-']) {
white-space: pre-wrap;
word-break: break-word;
border-radius: 4px;
background: hsl(var(--secondary));
padding: 0.15em 0.35em;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
}
pre {
margin: 0.5em 0;
max-width: 100%;
overflow-x: auto;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
background: hsl(var(--secondary));
padding: 0.75em 1em;
code:not([class*='language-']) {
border-radius: 0;
background: transparent;
padding: 0;
white-space: pre;
word-break: normal;
}
}
pre[class*='language-'],
code[class*='language-'] {
text-shadow: none;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
}
pre[class*='language-'] {
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
padding: 0.875em 1rem;
}
pre[class*='language-'] > code[class*='language-'] {
display: block;
border-radius: 0;
background: transparent;
padding: 0;
white-space: pre;
word-break: normal;
}
hr {
margin: 0.75em 0;
border: none;
border-top: 1px solid hsl(var(--border));
}
table {
display: block;
margin: 0.5em 0;
width: auto;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
font-size: 0.875em;
}
th, td {
border: 1px solid hsl(var(--border));
padding: 0.35em 0.75em;
text-align: left;
}
th {
background: hsl(var(--secondary));
font-weight: 600;
}
img {
display: block;
max-height: 320px;
max-width: 100%;
height: auto;
border-radius: var(--radius);
}
p + p {
margin-top: 0.25em;
}
remark-mermaid {
display: block;
max-width: 100%;
overflow-x: auto;
svg {
pointer-events: none;
max-width: 100%;
height: auto;
}
}
}
}
.edit-textarea {
min-height: 42px;
max-height: 520px;
overflow-y: hidden;
resize: none;
transition: height 0.12s ease;
}

View File

@@ -0,0 +1,516 @@
/* eslint-disable @typescript-eslint/member-ordering, */
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
Component,
computed,
ElementRef,
effect,
inject,
input,
output,
signal,
ViewChild
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCheck,
lucideDownload,
lucideEdit,
lucideExpand,
lucideImage,
lucideReply,
lucideSmile,
lucideTrash2,
lucideX
} from '@ng-icons/lucide';
import { MermaidComponent, RemarkModule } from 'ngx-remark';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import {
Attachment,
AttachmentFacade,
MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment';
import { KlipyService } from '../../../../application/klipy.service';
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel';
import {
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
UserAvatarComponent
} from '../../../../../../shared';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from '../../models/chat-messages.models';
const COMMON_EMOJIS = [
'👍',
'❤️',
'😂',
'😮',
'😢',
'🎉',
'🔥',
'👀'
];
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp',
html: 'markup',
js: 'javascript',
md: 'markdown',
plain: 'none',
plaintext: 'none',
py: 'python',
sh: 'bash',
shell: 'bash',
svg: 'markup',
text: 'none',
ts: 'typescript',
xml: 'markup',
yml: 'yaml',
zsh: 'bash'
};
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
const REMARK_PROCESSOR = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks);
interface ChatMessageAttachmentViewModel extends Attachment {
isAudio: boolean;
isUploader: boolean;
isVideo: boolean;
mediaActionLabel: string;
mediaStatusText: string;
progressPercent: number;
}
@Component({
selector: 'app-chat-message-item',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
RemarkModule,
MermaidComponent,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideCheck,
lucideDownload,
lucideEdit,
lucideExpand,
lucideImage,
lucideReply,
lucideSmile,
lucideTrash2,
lucideX
})
],
templateUrl: './chat-message-item.component.html',
styleUrl: './chat-message-item.component.scss',
host: {
style: 'display: contents;'
}
})
export class ChatMessageItemComponent {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
readonly message = input.required<Message>();
readonly repliedMessage = input<Message | undefined>();
readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false);
readonly remarkProcessor = REMARK_PROCESSOR;
readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>();
readonly editSaved = output<ChatMessageEditEvent>();
readonly reactionAdded = output<ChatMessageReactionEvent>();
readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly referenceRequested = output<string>();
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly commonEmojis = COMMON_EMOJIS;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false);
editContent = '';
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
void this.attachmentVersion();
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) =>
this.buildAttachmentViewModel(attachment)
);
});
private readonly syncAttachmentVersion = effect(() => {
const version = this.attachmentsSvc.updated();
queueMicrotask(() => {
if (this.attachmentVersion() !== version) {
this.attachmentVersion.set(version);
}
});
});
startEdit(): void {
this.editContent = this.message().content;
this.isEditing.set(true);
requestAnimationFrame(() => {
this.autoResizeEditTextarea();
const element = this.editTextareaRef?.nativeElement;
if (!element)
return;
element.focus();
element.setSelectionRange(element.value.length, element.value.length);
});
}
onEditEnter(event: Event): void {
const keyEvent = event as KeyboardEvent;
if (keyEvent.shiftKey)
return;
keyEvent.preventDefault();
this.saveEdit();
}
saveEdit(): void {
if (!this.editContent.trim())
return;
this.editSaved.emit({
messageId: this.message().id,
content: this.editContent.trim()
});
this.cancelEdit();
}
cancelEdit(): void {
this.isEditing.set(false);
this.editContent = '';
}
autoResizeEditTextarea(): void {
const element = this.editTextareaRef?.nativeElement;
if (!element)
return;
element.style.height = 'auto';
element.style.height = Math.min(element.scrollHeight, 520) + 'px';
element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden';
}
toggleEmojiPicker(): void {
this.showEmojiPicker.update((current) => !current);
}
addReaction(emoji: string): void {
this.reactionAdded.emit({
messageId: this.message().id,
emoji
});
this.showEmojiPicker.set(false);
}
toggleReaction(emoji: string): void {
this.reactionToggled.emit({
messageId: this.message().id,
emoji
});
}
requestReply(): void {
this.replyRequested.emit(this.message());
}
requestDelete(): void {
this.deleteRequested.emit(this.message());
}
requestReferenceScroll(messageId: string): void {
this.referenceRequested.emit(messageId);
}
isOwnMessage(): boolean {
return this.message().senderId === this.currentUserId();
}
getGroupedReactions(): { emoji: string; count: number; hasCurrentUser: boolean }[] {
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
const currentUserId = this.currentUserId();
this.message().reactions.forEach((reaction) => {
const existing = groups.get(reaction.emoji) || {
count: 0,
hasCurrentUser: false
};
groups.set(reaction.emoji, {
count: existing.count + 1,
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId
});
});
return Array.from(groups.entries()).map(([emoji, data]) => ({
emoji,
...data
}));
}
formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const time = date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
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)
return time;
if (dayDiff === 1)
return 'Yesterday ' + time;
if (dayDiff < 7) {
return (
date.toLocaleDateString([], { weekday: 'short' }) +
' ' +
time
);
}
return (
date.toLocaleDateString([], {
month: 'short',
day: 'numeric'
}) +
' ' +
time
);
}
getMarkdownImageSource(url?: string): string {
return url ? this.klipy.buildRenderableImageUrl(url) : '';
}
getMermaidCode(code?: string): string {
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
}
isKlipyMediaUrl(url?: string): boolean {
if (!url)
return false;
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
}
isMermaidCodeBlock(lang?: string): boolean {
return this.normalizeCodeLanguage(lang) === 'mermaid';
}
getCodeBlockClass(lang?: string): string {
return `language-${this.normalizeCodeLanguage(lang)}`;
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
formatSpeed(bytesPerSecond?: number): string {
if (!bytesPerSecond || bytesPerSecond <= 0)
return '0 B/s';
const units = [
'B/s',
'KB/s',
'MB/s',
'GB/s'
];
let speed = bytesPerSecond;
let unitIndex = 0;
while (speed >= 1024 && unitIndex < units.length - 1) {
speed /= 1024;
unitIndex++;
}
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[unitIndex]}`;
}
isVideoAttachment(attachment: Attachment): boolean {
return attachment.mime.startsWith('video/');
}
isAudioAttachment(attachment: Attachment): boolean {
return attachment.mime.startsWith('audio/');
}
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
return (
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
);
}
getMediaAttachmentStatusText(attachment: Attachment): string {
if (attachment.requestError)
return attachment.requestError;
if (this.requiresMediaDownloadAcceptance(attachment)) {
return this.isVideoAttachment(attachment)
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.';
}
return this.isVideoAttachment(attachment)
? 'Waiting for video source…'
: 'Waiting for audio source…';
}
getMediaAttachmentActionLabel(attachment: Attachment): string {
if (this.requiresMediaDownloadAcceptance(attachment)) {
return attachment.requestError ? 'Retry download' : 'Accept download';
}
return attachment.requestError ? 'Retry' : 'Request';
}
isUploader(attachment: Attachment): boolean {
const currentUserId = this.currentUserId();
return !!attachment.uploaderPeerId && !!currentUserId && attachment.uploaderPeerId === currentUserId;
}
requestAttachment(attachment: Attachment): void {
const liveAttachment = this.getLiveAttachment(attachment.id);
if (liveAttachment) {
this.attachmentsSvc.requestFile(this.message().id, liveAttachment);
}
}
cancelAttachment(attachment: Attachment): void {
const liveAttachment = this.getLiveAttachment(attachment.id);
if (liveAttachment) {
this.attachmentsSvc.cancelRequest(this.message().id, liveAttachment);
}
}
retryImageRequest(attachment: Attachment): void {
const liveAttachment = this.getLiveAttachment(attachment.id);
if (liveAttachment) {
this.attachmentsSvc.requestImageFromAnyPeer(this.message().id, liveAttachment);
}
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.imageOpened.emit(attachment);
}
}
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenuRequested.emit({
positionX: event.clientX,
positionY: event.clientY,
attachment
});
}
downloadAttachment(attachment: Attachment): void {
this.downloadRequested.emit(attachment);
}
private normalizeCodeLanguage(lang?: string): string {
const normalized = (lang || '').trim().toLowerCase();
if (!normalized)
return 'none';
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
}
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;
return {
...attachment,
isAudio,
isUploader: this.isUploader(attachment),
isVideo,
mediaActionLabel: requiresMediaDownloadAcceptance
? attachment.requestError ? 'Retry download' : 'Accept download'
: attachment.requestError ? 'Retry' : 'Request',
mediaStatusText: attachment.requestError
? attachment.requestError
: requiresMediaDownloadAcceptance
? isVideo
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.'
: isVideo
? 'Waiting for video source…'
: 'Waiting for audio source…',
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);
}
}

View File

@@ -0,0 +1,73 @@
<div
#messagesContainer
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
[style.padding-bottom.px]="bottomPadding()"
(scroll)="onScroll()"
>
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="h-3 w-3 animate-spin rounded-full border-b-2 border-primary"></div>
<span>Syncing messages…</span>
</div>
}
@if (loading()) {
<div class="flex items-center justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if (messages().length === 0) {
<div class="flex h-full flex-col items-center justify-center text-muted-foreground">
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to say something!</p>
</div>
} @else {
@if (hasMoreMessages()) {
<div class="flex items-center justify-center py-3">
@if (loadingMore()) {
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
} @else {
<button
type="button"
(click)="loadMore()"
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
Load older messages
</button>
}
</div>
}
@for (message of messages(); track message.id) {
<app-chat-message-item
[message]="message"
[repliedMessage]="findRepliedMessage(message.replyToId)"
[currentUserId]="currentUserId()"
[isAdmin]="isAdmin()"
(replyRequested)="handleReplyRequested($event)"
(deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)"
(reactionAdded)="handleReactionAdded($event)"
(reactionToggled)="handleReactionToggled($event)"
(referenceRequested)="handleReferenceRequested($event)"
(downloadRequested)="handleDownloadRequested($event)"
(imageOpened)="handleImageOpened($event)"
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
/>
}
}
@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">
<span class="text-sm text-muted-foreground">New messages</span>
<button
type="button"
(click)="readLatest()"
class="rounded bg-primary px-2 py-1 text-sm text-primary-foreground hover:bg-primary/90"
>
Read latest
</button>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,412 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import {
AfterViewChecked,
Component,
ElementRef,
OnDestroy,
ViewChild,
computed,
effect,
input,
output,
signal
} from '@angular/core';
import { Attachment } from '../../../../../attachment';
import { Message } from '../../../../../../shared-kernel';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from '../../models/chat-messages.models';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
interface PrismGlobal {
highlightElement(element: Element): void;
}
declare global {
interface Window {
Prism?: PrismGlobal;
}
}
@Component({
selector: 'app-chat-message-list',
standalone: true,
imports: [CommonModule, ChatMessageItemComponent],
templateUrl: './chat-message-list.component.html',
host: {
style: 'display: contents;'
}
})
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
readonly allMessages = input.required<Message[]>();
readonly channelMessages = input.required<Message[]>();
readonly loading = input(false);
readonly syncing = input(false);
readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false);
readonly bottomPadding = input(120);
readonly conversationKey = input.required<string>();
readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>();
readonly editSaved = output<ChatMessageEditEvent>();
readonly reactionAdded = output<ChatMessageReactionEvent>();
readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
private readonly PAGE_SIZE = 50;
readonly displayLimit = signal(this.PAGE_SIZE);
readonly loadingMore = signal(false);
readonly showNewMessagesBar = signal(false);
readonly messages = computed(() => {
const all = this.channelMessages();
const limit = this.displayLimit();
if (all.length <= limit)
return all;
return all.slice(all.length - limit);
});
readonly hasMoreMessages = computed(
() => this.channelMessages().length > this.displayLimit()
);
private initialScrollObserver: MutationObserver | null = null;
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
private boundOnImageLoad: (() => void) | null = null;
private isAutoScrolling = false;
private lastMessageCount = 0;
private initialScrollPending = true;
private prismHighlightScheduled = false;
private readonly onConversationChanged = effect(() => {
void this.conversationKey();
this.resetScrollingState();
});
private readonly onMessagesChanged = effect(() => {
const currentCount = this.channelMessages().length;
const element = this.messagesContainer?.nativeElement;
if (!element) {
this.lastMessageCount = currentCount;
return;
}
if (this.initialScrollPending) {
this.lastMessageCount = currentCount;
return;
}
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount;
if (newMessages) {
if (distanceFromBottom <= 300) {
this.scheduleScrollToBottomSmooth();
this.showNewMessagesBar.set(false);
} else {
queueMicrotask(() => this.showNewMessagesBar.set(true));
}
}
this.lastMessageCount = currentCount;
});
ngAfterViewChecked(): void {
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
if (this.initialScrollPending) {
if (this.messages().length > 0) {
this.initialScrollPending = false;
this.isAutoScrolling = true;
element.scrollTop = element.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
this.startInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
this.scheduleCodeHighlight();
} else if (!this.loading()) {
this.initialScrollPending = false;
this.lastMessageCount = 0;
}
return;
}
this.scheduleCodeHighlight();
}
ngOnDestroy(): void {
this.stopInitialScrollWatch();
}
findRepliedMessage(messageId?: string | null): Message | undefined {
if (!messageId)
return undefined;
return this.allMessages().find((message) => message.id === messageId);
}
onScroll(): void {
const element = this.messagesContainer?.nativeElement;
if (!element || this.isAutoScrolling)
return;
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const shouldStickToBottom = distanceFromBottom <= 300;
if (shouldStickToBottom) {
this.showNewMessagesBar.set(false);
}
if (this.initialScrollObserver) {
this.stopInitialScrollWatch();
}
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore();
}
}
loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages())
return;
this.loadingMore.set(true);
const element = this.messagesContainer?.nativeElement;
const previousScrollHeight = element?.scrollHeight ?? 0;
this.displayLimit.update((limit) => limit + this.PAGE_SIZE);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (element) {
const newScrollHeight = element.scrollHeight;
element.scrollTop += newScrollHeight - previousScrollHeight;
}
this.loadingMore.set(false);
});
});
}
readLatest(): void {
this.scrollToBottomSmooth();
this.showNewMessagesBar.set(false);
}
scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement;
if (!container)
return;
const element = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null;
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
element.classList.add('bg-primary/10');
setTimeout(() => element.classList.remove('bg-primary/10'), 2000);
}
}
handleReplyRequested(message: ChatMessageReplyEvent): void {
this.replyRequested.emit(message);
}
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
this.deleteRequested.emit(message);
}
handleEditSaved(event: ChatMessageEditEvent): void {
this.editSaved.emit(event);
}
handleReactionAdded(event: ChatMessageReactionEvent): void {
this.reactionAdded.emit(event);
}
handleReactionToggled(event: ChatMessageReactionEvent): void {
this.reactionToggled.emit(event);
}
handleReferenceRequested(messageId: string): void {
this.scrollToMessage(messageId);
}
handleDownloadRequested(attachment: Attachment): void {
this.downloadRequested.emit(attachment);
}
handleImageOpened(attachment: Attachment): void {
this.imageOpened.emit(attachment);
}
handleImageContextMenuRequested(event: ChatMessageImageContextMenuEvent): void {
this.imageContextMenuRequested.emit(event);
}
private resetScrollingState(): void {
this.initialScrollPending = true;
this.stopInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE);
}
private startInitialScrollWatch(): void {
this.stopInitialScrollWatch();
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
const snapToBottom = () => {
const container = this.messagesContainer?.nativeElement;
if (!container)
return;
this.isAutoScrolling = true;
container.scrollTop = container.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
};
this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snapToBottom);
});
this.initialScrollObserver.observe(element, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
this.boundOnImageLoad = () => requestAnimationFrame(snapToBottom);
element.addEventListener('load', this.boundOnImageLoad, true);
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
}
private stopInitialScrollWatch(): void {
if (this.initialScrollObserver) {
this.initialScrollObserver.disconnect();
this.initialScrollObserver = null;
}
if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener(
'load',
this.boundOnImageLoad,
true
);
this.boundOnImageLoad = null;
}
if (this.initialScrollTimer) {
clearTimeout(this.initialScrollTimer);
this.initialScrollTimer = null;
}
}
private scrollToBottomSmooth(): void {
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
try {
element.scrollTo({
top: element.scrollHeight,
behavior: 'smooth'
});
} catch {
element.scrollTop = element.scrollHeight;
}
}
private scheduleScrollToBottomSmooth(): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => this.scrollToBottomSmooth());
});
}
private scheduleCodeHighlight(): void {
if (this.prismHighlightScheduled)
return;
this.prismHighlightScheduled = true;
requestAnimationFrame(() => {
this.prismHighlightScheduled = false;
this.highlightRenderedCodeBlocks();
});
}
private highlightRenderedCodeBlocks(): void {
const container = this.messagesContainer?.nativeElement;
const prism = window.Prism;
if (!container || !prism?.highlightElement)
return;
const blocks = container.querySelectorAll<HTMLElement>('pre > code[class*="language-"]');
for (const block of blocks) {
const signature = this.getCodeBlockSignature(block);
if (block.dataset['prismSignature'] === signature)
continue;
try {
prism.highlightElement(block);
} finally {
block.dataset['prismSignature'] = signature;
}
}
}
private getCodeBlockSignature(block: HTMLElement): string {
const value = `${block.className}:${block.textContent ?? ''}`;
let hash = 0;
for (let index = 0; index < value.length; index++) {
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
}
return String(hash);
}
}

View File

@@ -0,0 +1,79 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc -->
@if (lightboxAttachment()) {
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
(click)="closeLightbox()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()"
tabindex="0"
>
<div
class="relative max-h-[90vh] max-w-[90vw]"
(click)="$event.stopPropagation()"
>
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
/>
<div class="absolute right-3 top-3 flex gap-2">
<button
(click)="downloadAttachment(lightboxAttachment()!)"
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-5 w-5"
/>
</button>
<button
(click)="closeLightbox()"
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Close"
>
<ng-icon
name="lucideX"
class="h-5 w-5"
/>
</button>
</div>
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
<div class="rounded-lg bg-black/60 px-3 py-1.5 backdrop-blur-sm">
<span class="text-sm text-white">{{ lightboxAttachment()!.filename }}</span>
<span class="ml-2 text-xs text-white/60">{{ formatBytes(lightboxAttachment()!.size) }}</span>
</div>
</div>
</div>
</div>
}
@if (imageContextMenu()) {
<app-context-menu
[x]="imageContextMenu()!.positionX"
[y]="imageContextMenu()!.positionY"
(closed)="closeImageContextMenu()"
>
<button
(click)="copyImageToClipboard(imageContextMenu()!.attachment); closeImageContextMenu()"
class="context-menu-item-icon"
>
<ng-icon
name="lucideCopy"
class="h-4 w-4 text-muted-foreground"
/>
Copy Image
</button>
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
class="context-menu-item-icon"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4 text-muted-foreground"
/>
Save Image
</button>
</app-context-menu>
}

View File

@@ -0,0 +1,91 @@
import { CommonModule } from '@angular/common';
import {
Component,
input,
output
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCopy,
lucideDownload,
lucideX
} from '@ng-icons/lucide';
import { Attachment } from '../../../../../attachment';
import { ContextMenuComponent } from '../../../../../../shared';
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models';
@Component({
selector: 'app-chat-message-overlays',
standalone: true,
imports: [
CommonModule,
NgIcon,
ContextMenuComponent
],
viewProviders: [
provideIcons({
lucideCopy,
lucideDownload,
lucideX
})
],
templateUrl: './chat-message-overlays.component.html',
host: {
style: 'display: contents;'
}
})
export class ChatMessageOverlaysComponent {
readonly lightboxAttachment = input<Attachment | null>(null);
readonly imageContextMenu = input<ChatMessageImageContextMenuEvent | null>(null);
readonly lightboxClosed = output();
readonly contextMenuClosed = output();
readonly downloadRequested = output<Attachment>();
readonly copyRequested = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
closeLightbox(): void {
this.lightboxClosed.emit();
}
closeImageContextMenu(): void {
this.contextMenuClosed.emit();
}
downloadAttachment(attachment: Attachment): void {
this.downloadRequested.emit(attachment);
}
copyImageToClipboard(attachment: Attachment): void {
this.copyRequested.emit(attachment);
}
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenuRequested.emit({
positionX: event.clientX,
positionY: event.clientY,
attachment
});
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
}

View File

@@ -0,0 +1,31 @@
import { Attachment } from '../../../../attachment';
import { Message } from '../../../../../shared-kernel';
export interface ChatMessageComposerSubmitEvent {
content: string;
pendingFiles: File[];
}
export interface ChatMessageEditEvent {
messageId: string;
content: string;
}
export interface ChatMessageReactionEvent {
messageId: string;
emoji: string;
}
export interface ChatMessageAttachmentEvent {
messageId: string;
attachment: Attachment;
}
export interface ChatMessageImageContextMenuEvent {
positionX: number;
positionY: number;
attachment: Attachment;
}
export type ChatMessageReplyEvent = Message;
export type ChatMessageDeleteEvent = Message;

View File

@@ -0,0 +1,163 @@
import { Injectable } from '@angular/core';
export interface SelectionRange {
start: number;
end: number;
}
export interface ComposeResult {
text: string;
selectionStart: number;
selectionEnd: number;
}
@Injectable({ providedIn: 'root' })
export class ChatMarkdownService {
applyInline(content: string, selection: SelectionRange, token: string): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'text';
const after = content.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
const cursor = before.length + token.length + selected.length + token.length;
return { text: newText,
selectionStart: cursor,
selectionEnd: cursor };
}
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'text';
const after = content.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'Heading';
const after = content.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const text = `${before}${block}${after}`;
const cursor = before.length + block.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'item\nitem';
const after = content.slice(end);
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'code';
const after = content.slice(end);
const fenced = `\`\`\`\n${selected}\n\`\`\`\n\n`;
const text = `${before}${fenced}${after}`;
const cursor = before.length + fenced.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyLink(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'link';
const after = content.slice(end);
const link = `[${selected}](https://)`;
const text = `${before}${link}${after}`;
const cursorStart = before.length + link.length - 1;
// Position inside the URL placeholder
return { text,
selectionStart: cursorStart - 8,
selectionEnd: cursorStart - 1 };
}
applyImage(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'alt';
const after = content.slice(end);
const img = `![${selected}](https://)`;
const text = `${before}${img}${after}`;
const cursorStart = before.length + img.length - 1;
return { text,
selectionStart: cursorStart - 8,
selectionEnd: cursorStart - 1 };
}
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const after = content.slice(end);
const hr = '\n\n---\n\n';
const text = `${before}${hr}${after}`;
const cursor = before.length + hr.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0)
return content;
let append = '';
for (const url of urls) {
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${url})`;
}
}
return append ? content + append : content;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
}
}

View File

@@ -0,0 +1,133 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
<div
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
role="dialog"
aria-label="KLIPY GIF picker"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
>
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</p>
</div>
<button
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
aria-label="Close GIF picker"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
<label class="relative block">
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
[ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY"
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4">
@if (errorMessage()) {
<div
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive backdrop-blur-sm"
>
<span>{{ errorMessage() }}</span>
<button
type="button"
(click)="retry()"
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Retry
</button>
</div>
}
@if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY…</p>
</div>
} @else if (results().length === 0) {
<div
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/80 bg-secondary/10 px-6 text-center text-muted-foreground"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ng-icon
name="lucideImage"
class="h-5 w-5"
/>
</div>
<div>
<p class="text-sm font-medium text-foreground">No GIFs found</p>
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
</div>
</div>
} @else {
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
@for (gif of results(); track gif.id) {
<button
type="button"
(click)="selectGif(gif)"
class="group overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
>
<div
class="relative overflow-hidden bg-secondary/30"
[style.aspect-ratio]="gifAspectRatio(gif)"
>
<img
[src]="gifPreviewUrl(gif)"
[alt]="gif.title || 'KLIPY GIF'"
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
</div>
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
</button>
}
</div>
}
</div>
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
@if (hasNext()) {
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading…' : 'Load more' }}
</button>
}
</div>
</div>

View File

@@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
AfterViewInit,
Component,
ElementRef,
HostListener,
OnDestroy,
OnInit,
ViewChild,
inject,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideImage,
lucideSearch,
lucideX
} from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../application/klipy.service';
@Component({
selector: 'app-klipy-gif-picker',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideImage,
lucideSearch,
lucideX
})
],
templateUrl: './klipy-gif-picker.component.html'
})
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
readonly gifSelected = output<KlipyGif>();
readonly closed = output<undefined>();
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private readonly klipy = inject(KlipyService);
private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0;
searchQuery = '';
results = signal<KlipyGif[]>([]);
loading = signal(false);
errorMessage = signal('');
hasNext = signal(false);
ngOnInit(): void {
void this.loadResults(true);
}
ngAfterViewInit(): void {
requestAnimationFrame(() => {
this.searchInput?.nativeElement.focus();
this.searchInput?.nativeElement.select();
});
}
ngOnDestroy(): void {
this.clearSearchTimer();
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.close();
}
onSearchQueryChanged(query: string): void {
this.searchQuery = query;
this.clearSearchTimer();
this.searchTimer = setTimeout(() => {
void this.loadResults(true);
}, 250);
}
retry(): void {
void this.loadResults(true);
}
async loadMore(): Promise<void> {
if (this.loading() || !this.hasNext())
return;
this.currentPage += 1;
await this.loadResults(false);
}
selectGif(gif: KlipyGif): void {
this.gifSelected.emit(gif);
}
close(): void {
this.closed.emit(undefined);
}
gifAspectRatio(gif: KlipyGif): string {
if (gif.width > 0 && gif.height > 0) {
return `${gif.width} / ${gif.height}`;
}
return '1 / 1';
}
gifPreviewUrl(gif: KlipyGif): string {
return this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url);
}
private async loadResults(reset: boolean): Promise<void> {
if (reset) {
this.currentPage = 1;
}
const requestId = ++this.requestId;
this.loading.set(true);
this.errorMessage.set('');
try {
const response = await firstValueFrom(
this.klipy.searchGifs(this.searchQuery, this.currentPage)
);
if (requestId !== this.requestId)
return;
this.results.set(
reset
? response.results
: this.mergeResults(this.results(), response.results)
);
this.hasNext.set(response.hasNext);
} catch (error) {
if (requestId !== this.requestId)
return;
this.errorMessage.set(
error instanceof Error
? error.message
: 'Failed to load GIFs from KLIPY.'
);
if (reset) {
this.results.set([]);
}
this.hasNext.set(false);
} finally {
if (requestId === this.requestId) {
this.loading.set(false);
}
}
}
private mergeResults(existing: KlipyGif[], incoming: KlipyGif[]): KlipyGif[] {
const seen = new Set(existing.map((gif) => gif.id));
const merged = [...existing];
for (const gif of incoming) {
if (seen.has(gif.id))
continue;
merged.push(gif);
seen.add(gif.id);
}
return merged;
}
private clearSearchTimer(): void {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
this.searchTimer = null;
}
}
}

View File

@@ -0,0 +1,12 @@
@if (typingDisplay().length > 0) {
<div class="px-4 py-2 backdrop-blur-sm bg-background/60">
<span class="inline-block px-3 py-1 rounded-full text-sm text-muted-foreground">
{{ typingDisplay().join(', ') }}
@if (typingOthersCount() > 0) {
and {{ typingOthersCount() }} others are typing...
} @else {
{{ typingDisplay().length === 1 ? 'is' : 'are' }} typing...
}
</span>
</div>
}

View File

@@ -0,0 +1,111 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */
import {
Component,
inject,
signal,
DestroyRef,
effect
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import {
merge,
interval,
filter,
map,
tap
} from 'rxjs';
const TYPING_TTL = 3_000;
const PURGE_INTERVAL = 1_000;
const MAX_SHOWN = 4;
interface TypingSignalingMessage {
type: string;
displayName: string;
oderId: string;
serverId: string;
}
@Component({
selector: 'app-typing-indicator',
standalone: true,
templateUrl: './typing-indicator.component.html',
host: {
'class': 'block',
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
}
})
export class TypingIndicatorComponent {
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
private readonly store = inject(Store);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private lastRoomId: string | null = null;
typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0);
constructor() {
const webrtc = inject(RealtimeSessionFacade);
const destroyRef = inject(DestroyRef);
const typing$ = webrtc.onSignalingMessage.pipe(
filter((msg): msg is TypingSignalingMessage =>
msg?.type === 'user_typing' &&
typeof msg.displayName === 'string' &&
typeof msg.oderId === 'string' &&
typeof msg.serverId === 'string'
),
filter((msg) => msg.serverId === this.currentRoom()?.id),
tap((msg) => {
const now = Date.now();
this.typingMap.set(msg.oderId, {
name: msg.displayName,
expiresAt: now + TYPING_TTL
});
})
);
const purge$ = interval(PURGE_INTERVAL).pipe(
map(() => Date.now()),
filter((now) => {
let changed = false;
for (const [key, entry] of this.typingMap) {
if (entry.expiresAt <= now) {
this.typingMap.delete(key);
changed = true;
}
}
return changed;
})
);
merge(typing$, purge$)
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => this.recomputeDisplay());
effect(() => {
const roomId = this.currentRoom()?.id ?? null;
if (roomId === this.lastRoomId)
return;
this.lastRoomId = roomId;
this.typingMap.clear();
this.recomputeDisplay();
});
}
private recomputeDisplay(): void {
const now = Date.now();
const names = Array.from(this.typingMap.values())
.filter((e) => e.expiresAt > now)
.map((e) => e.name);
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
}
}

View File

@@ -0,0 +1,202 @@
<!-- Header -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
@if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) {
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
{{ v.displayName }}
</span>
}
</div>
}
</div>
<!-- User List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
@for (user of onlineUsers(); track user.id) {
<div
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
(click)="toggleUserMenu(user.id)"
(keydown.enter)="toggleUserMenu(user.id)"
(keydown.space)="toggleUserMenu(user.id)"
(keyup.enter)="toggleUserMenu(user.id)"
(keyup.space)="toggleUserMenu(user.id)"
role="button"
tabindex="0"
>
<!-- Avatar with online indicator -->
<div class="relative">
<app-user-avatar
[name]="user.displayName"
size="sm"
/>
<span
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
></span>
</div>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1">
<span class="font-medium text-sm text-foreground truncate">
{{ user.displayName }}
</span>
@if (user.isAdmin) {
<ng-icon
name="lucideShield"
class="w-3 h-3 text-primary"
/>
}
@if (user.isRoomOwner) {
<ng-icon
name="lucideCrown"
class="w-3 h-3 text-yellow-500"
/>
}
</div>
</div>
<!-- Voice/Screen Status -->
<div class="flex items-center gap-1">
@if (user.voiceState?.isSpeaking) {
<ng-icon
name="lucideMic"
class="w-4 h-4 text-green-500 animate-pulse"
/>
} @else if (user.voiceState?.isMuted) {
<ng-icon
name="lucideMicOff"
class="w-4 h-4 text-muted-foreground"
/>
} @else if (user.voiceState?.isConnected) {
<ng-icon
name="lucideMic"
class="w-4 h-4 text-muted-foreground"
/>
}
@if (user.screenShareState?.isSharing) {
<ng-icon
name="lucideMonitor"
class="w-4 h-4 text-primary"
/>
}
</div>
<!-- User Menu -->
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
<div
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
role="menu"
tabindex="0"
>
@if (user.voiceState?.isConnected) {
<button
type="button"
(click)="muteUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
>
@if (user.voiceState?.isMutedByAdmin) {
<ng-icon
name="lucideVolume2"
class="w-4 h-4"
/>
<span>Unmute</span>
} @else {
<ng-icon
name="lucideVolumeX"
class="w-4 h-4"
/>
<span>Mute</span>
}
</button>
}
<button
type="button"
(click)="kickUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
<span>Kick</span>
</button>
<button
type="button"
(click)="banUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
<span>Ban</span>
</button>
</div>
}
</div>
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">No users online</div>
}
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<app-confirm-dialog
title="Ban User"
confirmLabel="Ban User"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span
>?
</p>
<div class="mb-4">
<label
for="ban-reason-input"
class="block text-sm font-medium text-foreground mb-1"
>Reason (optional)</label
>
<input
type="text"
[(ngModel)]="banReason"
placeholder="Enter ban reason..."
id="ban-reason-input"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
for="ban-duration-select"
class="block text-sm font-medium text-foreground mb-1"
>Duration</label
>
<select
[(ngModel)]="banDuration"
id="ban-duration-select"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="3600000">1 hour</option>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
</select>
</div>
</app-confirm-dialog>
}

View File

@@ -0,0 +1,139 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
signal,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX
} from '@ng-icons/lucide';
import { UsersActions } from '../../../../store/users/users.actions';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin
} from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX
})
],
templateUrl: './user-list.component.html'
})
/**
* Displays the list of online users with voice state indicators and admin actions.
*/
export class UserListComponent {
private store = inject(Store);
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
showUserMenu = signal<string | null>(null);
showBanDialog = signal(false);
userToBan = signal<User | null>(null);
banReason = '';
banDuration = '86400000'; // Default 1 day
/** Toggle the context menu for a specific user. */
toggleUserMenu(userId: string): void {
this.showUserMenu.update((current) => (current === userId ? null : userId));
}
/** Check whether the given user is the currently authenticated user. */
isCurrentUser(user: User): boolean {
return user.id === this.currentUser()?.id;
}
/** Toggle server-side mute on a user (admin action). */
muteUser(user: User): void {
if (user.voiceState?.isMutedByAdmin) {
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
} else {
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
}
this.showUserMenu.set(null);
}
/** Kick a user from the server (admin action). */
kickUser(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.showUserMenu.set(null);
}
/** Open the ban confirmation dialog for a user (admin action). */
banUser(user: User): void {
this.userToBan.set(user);
this.showBanDialog.set(true);
this.showUserMenu.set(null);
}
/** Close the ban dialog and reset its form fields. */
closeBanDialog(): void {
this.showBanDialog.set(false);
this.userToBan.set(null);
this.banReason = '';
this.banDuration = '86400000';
}
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
confirmBan(): void {
const user = this.userToBan();
if (!user)
return;
const duration = parseInt(this.banDuration, 10);
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
this.store.dispatch(
UsersActions.banUser({
userId: user.id,
reason: this.banReason || undefined,
expiresAt
})
);
this.closeBanDialog();
}
}

View File

@@ -0,0 +1,7 @@
export * from './application/klipy.service';
export * from './domain/message.rules';
export * from './domain/message-sync.rules';
export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component';
export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component';
export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component';
export { UserListComponent } from './feature/user-list/user-list.component';