feat: Add pm
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
<section class="chat-layout relative h-full bg-background">
|
||||
<header class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4">
|
||||
<app-user-avatar
|
||||
[name]="peerName()"
|
||||
[avatarUrl]="peerUser()?.avatarUrl"
|
||||
[status]="peerUser()?.status"
|
||||
[showStatusBadge]="true"
|
||||
size="md"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
||||
<p class="text-xs text-muted-foreground">Direct Message</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (conversation()) {
|
||||
<div class="absolute inset-x-0 bottom-0 top-14">
|
||||
<app-chat-message-list
|
||||
[allMessages]="chatMessages()"
|
||||
[channelMessages]="chatMessages()"
|
||||
[loading]="false"
|
||||
[syncing]="false"
|
||||
[currentUserId]="currentUserId()"
|
||||
[isAdmin]="false"
|
||||
[bottomPadding]="composerBottomPadding()"
|
||||
[conversationKey]="conversationKey()"
|
||||
[userLookupOverrides]="participantUsers()"
|
||||
(replyRequested)="setReplyTo($event)"
|
||||
(deleteRequested)="handleDeleteRequested($event)"
|
||||
(editSaved)="handleEditSaved($event)"
|
||||
(reactionAdded)="handleReactionAdded($event)"
|
||||
(reactionToggled)="handleReactionToggled($event)"
|
||||
(downloadRequested)="downloadAttachment($event)"
|
||||
(imageOpened)="openLightbox($event)"
|
||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||
(embedRemoved)="handleEmbedRemoved($event)"
|
||||
/>
|
||||
|
||||
@for (messageStatus of messageStatuses(); track messageStatus.id) {
|
||||
<span
|
||||
data-testid="message-status"
|
||||
class="sr-only"
|
||||
>{{ messageStatus.status }}</span
|
||||
>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md">
|
||||
<app-chat-message-composer
|
||||
[replyTo]="replyTo()"
|
||||
[showKlipyGifPicker]="showGifPicker()"
|
||||
[klipyEnabled]="klipyEnabled()"
|
||||
[klipySignalSource]="null"
|
||||
[textareaTestId]="'dm-input'"
|
||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||
(replyCleared)="clearReply()"
|
||||
(heightChanged)="composerBottomPadding.set($event + 20)"
|
||||
(klipyGifPickerToggleRequested)="toggleGifPicker()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@if (showGifPicker()) {
|
||||
<div
|
||||
class="fixed inset-0 z-[89]"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label="Close GIF picker"
|
||||
(click)="closeGifPicker()"
|
||||
(keydown.enter)="closeGifPicker()"
|
||||
(keydown.space)="closeGifPicker()"
|
||||
></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]="gifPickerAnchorRight()"
|
||||
>
|
||||
<app-klipy-gif-picker
|
||||
(gifSelected)="handleGifSelected($event)"
|
||||
(closed)="closeGifPicker()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-chat-message-overlays
|
||||
[lightboxAttachment]="lightboxAttachment()"
|
||||
[imageContextMenu]="imageContextMenu()"
|
||||
(lightboxClosed)="closeLightbox()"
|
||||
(contextMenuClosed)="closeImageContextMenu()"
|
||||
(downloadRequested)="downloadAttachment($event)"
|
||||
(copyRequested)="copyImageToClipboard($event)"
|
||||
(imageContextMenuRequested)="openImageContextMenu($event)"
|
||||
/>
|
||||
} @else {
|
||||
<div class="flex flex-1 items-center justify-center px-6 text-sm text-muted-foreground">Select a direct message from the rail.</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,455 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
HostListener,
|
||||
inject,
|
||||
signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { map } from 'rxjs';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import {
|
||||
ChatMessageComposerSubmitEvent,
|
||||
ChatMessageComposerComponent,
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
ChatMessageImageContextMenuEvent,
|
||||
ChatMessageListComponent,
|
||||
ChatMessageOverlaysComponent,
|
||||
ChatMessageReactionEvent,
|
||||
ChatMessageReplyEvent,
|
||||
hasDedicatedChatEmbed,
|
||||
KlipyGif,
|
||||
KlipyGifPickerComponent,
|
||||
KlipyService,
|
||||
LinkMetadataService,
|
||||
type ChatMessageEmbedRemoveEvent
|
||||
} from '../../../chat';
|
||||
import type {
|
||||
DirectMessageStatus,
|
||||
LinkMetadata,
|
||||
Message,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
|
||||
interface DmStatusLabel {
|
||||
id: string;
|
||||
status: DirectMessageStatus;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-chat',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ChatMessageComposerComponent,
|
||||
ChatMessageListComponent,
|
||||
ChatMessageOverlaysComponent,
|
||||
KlipyGifPickerComponent,
|
||||
UserAvatarComponent
|
||||
],
|
||||
templateUrl: './dm-chat.component.html',
|
||||
host: {
|
||||
class: 'block h-full'
|
||||
}
|
||||
})
|
||||
export class DmChatComponent {
|
||||
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly store = inject(Store);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||
readonly showGifPicker = signal(false);
|
||||
readonly composerBottomPadding = signal(140);
|
||||
readonly gifPickerAnchorRight = signal(16);
|
||||
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
|
||||
readonly replyTo = signal<Message | null>(null);
|
||||
readonly lightboxAttachment = signal<Attachment | null>(null);
|
||||
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
|
||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||
});
|
||||
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
|
||||
readonly conversation = this.directMessages.selectedConversation;
|
||||
readonly klipyEnabled = computed(() => this.klipy.isEnabled(null));
|
||||
readonly conversationKey = computed(() => this.conversation()?.id ?? 'dm:none');
|
||||
readonly peerUser = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
|
||||
return conversation ? this.peerUserFor(conversation) : null;
|
||||
});
|
||||
readonly participantUsers = computed<User[]>(() => {
|
||||
const conversation = this.conversation();
|
||||
const knownUsers = this.allUsers();
|
||||
|
||||
if (!conversation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return conversation.participants.map((participantId) => {
|
||||
const knownUser = knownUsers.find((user) => user.id === participantId || user.oderId === participantId);
|
||||
const participant = conversation.participantProfiles[participantId];
|
||||
|
||||
return knownUser ?? {
|
||||
id: participantId,
|
||||
oderId: participantId,
|
||||
username: participant?.username || participant?.displayName || participantId,
|
||||
displayName: participant?.displayName || participant?.username || participantId,
|
||||
description: participant?.description,
|
||||
profileUpdatedAt: participant?.profileUpdatedAt,
|
||||
avatarUrl: participant?.avatarUrl,
|
||||
avatarHash: participant?.avatarHash,
|
||||
avatarMime: participant?.avatarMime,
|
||||
avatarUpdatedAt: participant?.avatarUpdatedAt,
|
||||
status: 'disconnected',
|
||||
role: 'member',
|
||||
joinedAt: 0
|
||||
};
|
||||
});
|
||||
});
|
||||
readonly messageStatuses = computed<DmStatusLabel[]>(() => {
|
||||
const conversation = this.conversation();
|
||||
const currentUserId = this.currentUserId();
|
||||
|
||||
if (!conversation || !currentUserId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return conversation.messages
|
||||
.filter((message) => message.senderId === currentUserId)
|
||||
.map((message) => ({
|
||||
id: message.id,
|
||||
status: message.status
|
||||
}));
|
||||
});
|
||||
readonly chatMessages = computed<Message[]>(() => {
|
||||
const conversation = this.conversation();
|
||||
const metadataByMessageId = this.linkMetadataByMessageId();
|
||||
|
||||
if (!conversation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return conversation.messages.map((message) => {
|
||||
const participant = conversation.participantProfiles[message.senderId];
|
||||
const knownUser = this.participantUsers().find((user) => user.id === message.senderId || user.oderId === message.senderId);
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
roomId: conversation.id,
|
||||
channelId: 'direct-message',
|
||||
senderId: message.senderId,
|
||||
senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? 'You' : message.senderId),
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
editedAt: message.editedAt,
|
||||
reactions: message.reactions ?? [],
|
||||
isDeleted: !!message.isDeleted,
|
||||
replyToId: message.replyToId,
|
||||
linkMetadata: metadataByMessageId[message.id]
|
||||
};
|
||||
});
|
||||
});
|
||||
readonly peerName = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
const currentUserId = this.currentUserId();
|
||||
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const conversationId = this.routeConversationId();
|
||||
|
||||
if (conversationId) {
|
||||
void this.directMessages.openConversation(conversationId);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
void this.routeConversationId();
|
||||
void this.klipy.refreshAvailability(null);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
void this.refreshLinkMetadata(this.chatMessages());
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const conversation = this.conversation();
|
||||
const peerUser = this.peerUser();
|
||||
|
||||
if (conversation && !peerUser?.avatarUrl) {
|
||||
this.directMessages.requestPeerAvatarSync(conversation.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onWindowResize(): void {
|
||||
if (this.showGifPicker()) {
|
||||
this.syncGifPickerAnchor();
|
||||
}
|
||||
}
|
||||
|
||||
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (!conversation || (!event.content.trim() && event.pendingFiles.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = event.content.trim() || event.pendingFiles.map((file) => file.name).join('\n');
|
||||
|
||||
void this.directMessages.sendMessage(conversation.id, content, this.replyTo()?.id)
|
||||
.then((message) => {
|
||||
this.replyTo.set(null);
|
||||
|
||||
if (event.pendingFiles.length > 0) {
|
||||
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setReplyTo(message: ChatMessageReplyEvent): void {
|
||||
this.replyTo.set(message);
|
||||
}
|
||||
|
||||
clearReply(): void {
|
||||
this.replyTo.set(null);
|
||||
}
|
||||
|
||||
handleEditSaved(event: ChatMessageEditEvent): void {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (conversation) {
|
||||
void this.directMessages.editMessage(conversation.id, event.messageId, event.content);
|
||||
}
|
||||
}
|
||||
|
||||
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (conversation && message.senderId === this.currentUserId()) {
|
||||
void this.directMessages.deleteMessage(conversation.id, message.id);
|
||||
}
|
||||
}
|
||||
|
||||
handleReactionAdded(event: ChatMessageReactionEvent): void {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (conversation) {
|
||||
void this.directMessages.addReaction(conversation.id, event.messageId, event.emoji);
|
||||
}
|
||||
}
|
||||
|
||||
handleReactionToggled(event: ChatMessageReactionEvent): void {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (conversation) {
|
||||
void this.directMessages.toggleReaction(conversation.id, event.messageId, event.emoji);
|
||||
}
|
||||
}
|
||||
|
||||
toggleGifPicker(): void {
|
||||
if (!this.klipyEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showGifPicker.update((visible) => !visible);
|
||||
|
||||
if (this.showGifPicker()) {
|
||||
requestAnimationFrame(() => this.syncGifPickerAnchor());
|
||||
}
|
||||
}
|
||||
|
||||
closeGifPicker(): void {
|
||||
this.showGifPicker.set(false);
|
||||
}
|
||||
|
||||
handleGifSelected(gif: KlipyGif): void {
|
||||
this.closeGifPicker();
|
||||
this.composer?.handleKlipyGifSelected(gif);
|
||||
}
|
||||
|
||||
handleEmbedRemoved(event: ChatMessageEmbedRemoveEvent): void {
|
||||
this.linkMetadataByMessageId.update((metadataByMessageId) => ({
|
||||
...metadataByMessageId,
|
||||
[event.messageId]: (metadataByMessageId[event.messageId] ?? []).filter((metadata) => metadata.url !== event.url)
|
||||
}));
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
await navigator.clipboard.write([new ClipboardItem({ [blob.type || 'image/png']: blob })]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
private syncGifPickerAnchor(): void {
|
||||
const triggerRect = this.composer?.getKlipyTriggerRect();
|
||||
|
||||
if (!triggerRect) {
|
||||
this.gifPickerAnchorRight.set(16);
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const popupWidth = viewportWidth >= 1280 ? 52 * 16 : viewportWidth >= 768 ? 42 * 16 : 34 * 16;
|
||||
const preferredRight = viewportWidth - triggerRect.right;
|
||||
const minRight = 16;
|
||||
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
|
||||
|
||||
this.gifPickerAnchorRight.set(Math.min(Math.max(Math.round(preferredRight), minRight), maxRight));
|
||||
}
|
||||
|
||||
private async refreshLinkMetadata(messages: Message[]): Promise<void> {
|
||||
const metadataByMessageId = this.linkMetadataByMessageId();
|
||||
|
||||
for (const message of messages) {
|
||||
if (metadataByMessageId[message.id]?.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const urls = this.linkMetadata.extractUrls(message.content)
|
||||
.filter((url) => !hasDedicatedChatEmbed(url));
|
||||
|
||||
if (urls.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed);
|
||||
|
||||
if (metadata.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.linkMetadataByMessageId.update((currentMetadata) => ({
|
||||
...currentMetadata,
|
||||
[message.id]: metadata
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
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 peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
|
||||
const currentUserId = this.currentUserId();
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
if (!peerId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<div
|
||||
class="group flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
|
||||
[class.flex-row-reverse]="isOutgoing()"
|
||||
>
|
||||
<div class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-secondary text-xs font-semibold text-foreground">
|
||||
{{ isOutgoing() ? 'You'[0] : message().senderId[0] || '?' }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 max-w-3xl flex-1"
|
||||
[class.text-right]="isOutgoing()"
|
||||
>
|
||||
<div
|
||||
class="mb-0.5 flex items-baseline gap-2"
|
||||
[class.justify-end]="isOutgoing()"
|
||||
>
|
||||
<span class="text-sm font-semibold text-foreground">{{ isOutgoing() ? 'You' : message().senderId }}</span>
|
||||
<span class="text-xs text-muted-foreground">{{ message().timestamp | date: 'shortTime' }}</span>
|
||||
</div>
|
||||
|
||||
@if (requiresRichMarkdown(message().content)) {
|
||||
<div class="mt-1 inline-block max-w-full rounded-lg bg-card px-3 py-2 text-left text-sm text-foreground">
|
||||
<app-chat-message-markdown [content]="message().content" />
|
||||
</div>
|
||||
} @else {
|
||||
<p
|
||||
class="mt-1 inline-block max-w-full whitespace-pre-wrap break-words rounded-lg bg-card px-3 py-2 text-left text-sm leading-5 text-foreground"
|
||||
>
|
||||
{{ message().content }}
|
||||
</p>
|
||||
}
|
||||
|
||||
@if (isOutgoing()) {
|
||||
<span
|
||||
data-testid="message-status"
|
||||
class="mt-1 inline-flex items-center gap-1 text-[10px] font-semibold uppercase text-muted-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="statusIcon(message().status)"
|
||||
class="h-3 w-3"
|
||||
[class.fill-current]="message().status === 'ACKNOWLEDGED'"
|
||||
/>
|
||||
{{ message().status }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideCheck,
|
||||
lucideCheckCheck,
|
||||
lucideClock3
|
||||
} from '@ng-icons/lucide';
|
||||
import { ChatMessageMarkdownComponent } from '../../../chat';
|
||||
import type { DirectMessage } from '../../domain/models/direct-message.model';
|
||||
|
||||
const RICH_MARKDOWN_PATTERNS = [
|
||||
|
||||
/!\[[^\]]*\]\([^\s)]+\)/,
|
||||
|
||||
/https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg)(?:\?[^\s)]*)?/i,
|
||||
|
||||
/\[[^\]]+\]\([^\s)]+\)/
|
||||
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-message',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
ChatMessageMarkdownComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideCheck, lucideCheckCheck, lucideClock3 })],
|
||||
templateUrl: './dm-message.component.html'
|
||||
})
|
||||
export class DmMessageComponent {
|
||||
readonly message = input.required<DirectMessage>();
|
||||
readonly currentUserId = input.required<string>();
|
||||
readonly isOutgoing = computed(() => this.message().senderId === this.currentUserId());
|
||||
|
||||
requiresRichMarkdown(content: string): boolean {
|
||||
return RICH_MARKDOWN_PATTERNS.some((pattern) => pattern.test(content));
|
||||
}
|
||||
|
||||
statusIcon(status: DirectMessage['status']): string {
|
||||
if (status === 'QUEUED') {
|
||||
return 'lucideClock3';
|
||||
}
|
||||
|
||||
if (status === 'SENT') {
|
||||
return 'lucideCheck';
|
||||
}
|
||||
|
||||
return 'lucideCheckCheck';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
|
||||
<div class="mt-2 flex w-full flex-col items-center gap-2 border-b border-border/70 pb-2">
|
||||
<div class="group/server relative flex w-full justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent text-muted-foreground transition-[border-radius,box-shadow,background-color,color] duration-100 hover:rounded-lg hover:bg-card hover:text-foreground"
|
||||
title="Direct Messages"
|
||||
aria-label="Direct Messages"
|
||||
[ngClass]="isOnDirectMessages() ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10 text-foreground' : 'rounded-xl bg-card'"
|
||||
[attr.aria-current]="isOnDirectMessages() ? 'page' : null"
|
||||
(click)="openDirectMessages()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMessageCircle"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
@if (directMessages.totalUnreadCount() > 0) {
|
||||
<span class="dm-rail-slide-in absolute -right-1 -top-1 h-3 w-3 rounded-full bg-amber-400 ring-2 ring-card"></span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@for (item of railItems(); track item.id) {
|
||||
<div class="group/server relative flex w-full justify-center">
|
||||
<button
|
||||
type="button"
|
||||
class="relative z-10 flex h-10 w-10 cursor-pointer flex-shrink-0 items-center justify-center border border-transparent transition-[border-radius,box-shadow,background-color] duration-100 hover:rounded-lg hover:bg-card"
|
||||
[class.dm-rail-slide-in]="!item.isExiting"
|
||||
[class.dm-rail-slide-out]="item.isExiting"
|
||||
[class.pointer-events-none]="item.isExiting"
|
||||
[ngClass]="isSelectedItem(item) ? 'rounded-lg ring-2 ring-primary/40 bg-primary/10' : 'rounded-xl bg-card'"
|
||||
[title]="item.label"
|
||||
[attr.aria-current]="isSelectedItem(item) ? 'page' : null"
|
||||
(click)="openItem(item)"
|
||||
>
|
||||
<div class="h-full w-full overflow-hidden rounded-[inherit]">
|
||||
@if (item.avatarUrl) {
|
||||
<img
|
||||
[src]="item.avatarUrl"
|
||||
[alt]="item.label"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
} @else {
|
||||
<div
|
||||
class="flex h-full w-full items-center justify-center bg-secondary transition-colors"
|
||||
[class.bg-primary/15]="isSelectedItem(item)"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-semibold text-muted-foreground transition-colors"
|
||||
[class.text-foreground]="isSelectedItem(item)"
|
||||
>{{ initial(item.label) }}</span
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<span
|
||||
class="absolute -bottom-1 -right-1 grid h-4 w-4 place-items-center rounded-full bg-secondary text-muted-foreground shadow-sm ring-2 ring-card"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideUser"
|
||||
class="h-2.5 w-2.5"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@if (!item.isExiting && item.unreadCount > 0) {
|
||||
<span class="absolute -right-1 -top-1 min-w-5 rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black shadow-sm">
|
||||
{{ formatUnreadCount(item.unreadCount) }}
|
||||
</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
@keyframes dm-rail-slide-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.5rem) scale(0.94);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dm-rail-slide-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-0.5rem) scale(0.94);
|
||||
}
|
||||
}
|
||||
|
||||
.dm-rail-slide-in {
|
||||
animation: dm-rail-slide-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
|
||||
}
|
||||
|
||||
.dm-rail-slide-out {
|
||||
animation: dm-rail-slide-out 140ms cubic-bezier(0.4, 0, 1, 1) both;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnDestroy,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageCircle, lucideUser } from '@ng-icons/lucide';
|
||||
import { filter, map } from 'rxjs';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { FriendService } from '../../application/services/friend.service';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
interface DmRailItem {
|
||||
id: string;
|
||||
label: string;
|
||||
avatarUrl?: string;
|
||||
conversation: DirectMessageConversation | null;
|
||||
isExiting: boolean;
|
||||
user: User | null;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
const EXIT_ANIMATION_MS = 160;
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-rail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucideUser })],
|
||||
templateUrl: './dm-rail.component.html',
|
||||
styleUrl: './dm-rail.component.scss'
|
||||
})
|
||||
export class DmRailComponent implements OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly exitTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly friends = inject(FriendService);
|
||||
readonly users = this.store.selectSignal(selectAllUsers);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly currentUserId = computed(() => this.currentUser()?.oderId || this.currentUser()?.id || '');
|
||||
readonly activeConversationId = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
map((navigationEvent) => this.getConversationIdFromUrl(navigationEvent.urlAfterRedirects))
|
||||
),
|
||||
{ initialValue: this.getConversationIdFromUrl(this.router.url) }
|
||||
);
|
||||
readonly friendUsers = computed(() => this.users().filter((user) =>
|
||||
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
|
||||
));
|
||||
readonly railItems = signal<DmRailItem[]>([]);
|
||||
readonly unreadRailItems = computed<DmRailItem[]>(() => {
|
||||
const currentUserId = this.currentUserId();
|
||||
const items = new Map<string, DmRailItem>();
|
||||
|
||||
for (const conversation of this.directMessages.conversations()) {
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
if (!peerId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const knownUser = this.users().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
|
||||
const profile = conversation.participantProfiles[peerId];
|
||||
|
||||
items.set(peerId, {
|
||||
id: peerId,
|
||||
label: knownUser?.displayName || profile?.displayName || peerId,
|
||||
avatarUrl: knownUser?.avatarUrl || profile?.avatarUrl,
|
||||
conversation,
|
||||
isExiting: false,
|
||||
user: knownUser,
|
||||
unreadCount: conversation.unreadCount
|
||||
});
|
||||
}
|
||||
|
||||
for (const user of this.friendUsers()) {
|
||||
const userId = user.oderId || user.id;
|
||||
|
||||
if (items.has(userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.set(userId, {
|
||||
id: userId,
|
||||
label: user.displayName || user.username,
|
||||
avatarUrl: user.avatarUrl,
|
||||
conversation: null,
|
||||
isExiting: false,
|
||||
user,
|
||||
unreadCount: 0
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(items.values()).filter((item) => item.unreadCount > 0);
|
||||
});
|
||||
readonly isOnDirectMessages = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/dm'))
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/dm') }
|
||||
);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const unreadItems = this.unreadRailItems();
|
||||
|
||||
queueMicrotask(() => this.syncRailItems(unreadItems));
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
for (const timer of this.exitTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
this.exitTimers.clear();
|
||||
}
|
||||
|
||||
async openConversation(conversation: DirectMessageConversation): Promise<void> {
|
||||
await this.router.navigate(['/dm', conversation.id]);
|
||||
}
|
||||
|
||||
async openFriend(user: User): Promise<void> {
|
||||
const conversation = await this.directMessages.createConversation(user);
|
||||
|
||||
await this.router.navigate(['/dm', conversation.id]);
|
||||
}
|
||||
|
||||
async openItem(item: DmRailItem): Promise<void> {
|
||||
if (item.conversation) {
|
||||
await this.openConversation(item.conversation);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.user) {
|
||||
await this.openFriend(item.user);
|
||||
}
|
||||
}
|
||||
|
||||
openDirectMessages(): void {
|
||||
void this.router.navigate(['/dm']);
|
||||
}
|
||||
|
||||
titleFor(conversation: DirectMessageConversation): string {
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId());
|
||||
|
||||
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM';
|
||||
}
|
||||
|
||||
initial(label: string): string {
|
||||
return label.trim()[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
conversationForFriend(user: User): DirectMessageConversation | null {
|
||||
const userId = user.oderId || user.id;
|
||||
|
||||
return this.directMessages.conversations().find((conversation) => conversation.participants.includes(userId)) ?? null;
|
||||
}
|
||||
|
||||
isSelectedConversation(conversation: DirectMessageConversation): boolean {
|
||||
return this.activeConversationId() === conversation.id;
|
||||
}
|
||||
|
||||
isSelectedFriend(user: User): boolean {
|
||||
const conversation = this.conversationForFriend(user);
|
||||
|
||||
return !!conversation && this.isSelectedConversation(conversation);
|
||||
}
|
||||
|
||||
isSelectedItem(item: DmRailItem): boolean {
|
||||
return !!item.conversation && this.isSelectedConversation(item.conversation);
|
||||
}
|
||||
|
||||
formatUnreadCount(count: number): string {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
private getConversationIdFromUrl(url: string): string | null {
|
||||
const match = /^\/dm\/([^/?#]+)/.exec(url);
|
||||
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
private syncRailItems(unreadItems: DmRailItem[]): void {
|
||||
const unreadById = new Map(unreadItems.map((item) => [item.id, item]));
|
||||
const currentItems = this.railItems();
|
||||
const nextItems: DmRailItem[] = [];
|
||||
|
||||
for (const item of unreadItems) {
|
||||
const timer = this.exitTimers.get(item.id);
|
||||
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.exitTimers.delete(item.id);
|
||||
}
|
||||
|
||||
nextItems.push({ ...item, isExiting: false });
|
||||
}
|
||||
|
||||
for (const item of currentItems) {
|
||||
if (unreadById.has(item.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextItems.push({ ...item, isExiting: true });
|
||||
|
||||
if (!this.exitTimers.has(item.id)) {
|
||||
this.exitTimers.set(item.id, setTimeout(() => {
|
||||
this.exitTimers.delete(item.id);
|
||||
this.railItems.update((items) => items.filter((entry) => entry.id !== item.id));
|
||||
}, EXIT_ANIMATION_MS));
|
||||
}
|
||||
}
|
||||
|
||||
this.railItems.set(nextItems);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<div
|
||||
class="grid h-full min-h-0 overflow-hidden bg-background"
|
||||
[ngStyle]="layoutStyles()"
|
||||
>
|
||||
<aside
|
||||
appThemeNode="dmConversationsPanel"
|
||||
class="flex min-h-0 overflow-hidden border-r border-border bg-card"
|
||||
[ngStyle]="listPanelStyles()"
|
||||
>
|
||||
<section class="flex h-full w-full min-w-0 flex-col">
|
||||
<header class="flex h-14 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<div class="grid h-8 w-8 place-items-center rounded-lg bg-secondary text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideMessageCircle"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="truncate text-sm font-semibold text-foreground">Direct Messages</h1>
|
||||
<p class="text-xs text-muted-foreground">{{ directMessages.conversations().length }} chats</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
@if (directMessages.conversations().length === 0) {
|
||||
<div class="flex h-full items-center justify-center px-4 text-center text-sm text-muted-foreground">No direct messages yet.</div>
|
||||
} @else {
|
||||
<div class="space-y-1">
|
||||
@for (conversation of directMessages.conversations(); track conversation.id) {
|
||||
<div
|
||||
class="group flex w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-secondary/60"
|
||||
[class.bg-primary/10]="isSelectedConversation(conversation)"
|
||||
[class.text-foreground]="isSelectedConversation(conversation)"
|
||||
[attr.aria-current]="isSelectedConversation(conversation) ? 'page' : null"
|
||||
(click)="openConversation(conversation)"
|
||||
(keydown.enter)="openConversation(conversation)"
|
||||
(keydown.space)="openConversation(conversation)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="peerName(conversation)"
|
||||
[avatarUrl]="peerAvatarUrl(conversation)"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="truncate text-sm font-medium text-foreground">{{ peerName(conversation) }}</p>
|
||||
@if (conversation.unreadCount > 0) {
|
||||
<span class="rounded-full bg-amber-400 px-1.5 py-0.5 text-[10px] font-semibold text-black">
|
||||
{{ formatUnreadCount(conversation.unreadCount) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ lastMessagePreview(conversation) }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-destructive/10 hover:text-destructive focus:opacity-100 group-hover:opacity-100"
|
||||
[attr.aria-label]="'Forget ' + peerName(conversation)"
|
||||
[title]="'Forget ' + peerName(conversation)"
|
||||
(click)="forgetConversation($event, conversation)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border px-2 py-3">
|
||||
<app-voice-controls />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main
|
||||
appThemeNode="dmChatPanel"
|
||||
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
[ngStyle]="chatPanelStyles()"
|
||||
>
|
||||
<app-dm-chat />
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,184 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageCircle, lucideTrash2 } from '@ng-icons/lucide';
|
||||
import { map } from 'rxjs';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../theme';
|
||||
import { AttachmentFacade } from '../../../attachment';
|
||||
import { VoiceControlsComponent } from '../../../voice-session';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { DmChatComponent } from '../dm-chat/dm-chat.component';
|
||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
|
||||
import type { Attachment } from '../../../attachment';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-workspace',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ThemeNodeDirective,
|
||||
DmChatComponent,
|
||||
VoiceControlsComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucideTrash2 })],
|
||||
templateUrl: './dm-workspace.component.html'
|
||||
})
|
||||
export class DmWorkspaceComponent implements OnDestroy {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly users = this.store.selectSignal(selectAllUsers);
|
||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||
});
|
||||
readonly layoutStyles = computed(() => this.theme.getLayoutContainerStyles('roomLayout'));
|
||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const conversationId = this.routeConversationId();
|
||||
|
||||
if (conversationId) {
|
||||
void this.directMessages.openConversation(conversationId);
|
||||
return;
|
||||
}
|
||||
|
||||
const firstConversation = this.directMessages.conversations()[0];
|
||||
|
||||
if (firstConversation) {
|
||||
void this.router.navigate(['/dm', firstConversation.id], { replaceUrl: true });
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const users = this.users();
|
||||
|
||||
for (const conversation of this.directMessages.conversations()) {
|
||||
const peer = this.peerUser(conversation, users);
|
||||
|
||||
if (!peer?.avatarUrl) {
|
||||
this.directMessages.requestPeerAvatarSync(conversation.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openConversation(conversation: DirectMessageConversation): void {
|
||||
void this.router.navigate(['/dm', conversation.id]);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.directMessages.closeConversationView(this.routeConversationId());
|
||||
}
|
||||
|
||||
isSelectedConversation(conversation: DirectMessageConversation): boolean {
|
||||
return this.routeConversationId() === conversation.id;
|
||||
}
|
||||
|
||||
peerName(conversation: DirectMessageConversation): string {
|
||||
const peerId = this.peerId(conversation);
|
||||
const knownUser = this.peerUser(conversation);
|
||||
|
||||
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
|
||||
}
|
||||
|
||||
peerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
|
||||
const peerId = this.peerId(conversation);
|
||||
const knownUser = this.peerUser(conversation);
|
||||
|
||||
return peerId ? knownUser?.avatarUrl || conversation.participantProfiles[peerId]?.avatarUrl : undefined;
|
||||
}
|
||||
|
||||
lastMessagePreview(conversation: DirectMessageConversation): string {
|
||||
const lastMessage = conversation.messages.at(-1);
|
||||
|
||||
if (!lastMessage) {
|
||||
return 'No messages yet';
|
||||
}
|
||||
|
||||
if (lastMessage.isDeleted) {
|
||||
return 'Message deleted';
|
||||
}
|
||||
|
||||
if (this.isKlipyGif(lastMessage.content)) {
|
||||
return 'Sent a GIF';
|
||||
}
|
||||
|
||||
this.attachments.updated();
|
||||
const attachments = this.attachments.getForMessage(lastMessage.id);
|
||||
|
||||
if (attachments.length > 0) {
|
||||
return this.attachmentPreview(attachments);
|
||||
}
|
||||
|
||||
return lastMessage.content || 'Attachment';
|
||||
}
|
||||
|
||||
async forgetConversation(event: Event, conversation: DirectMessageConversation): Promise<void> {
|
||||
event.stopPropagation();
|
||||
const conversations = this.directMessages.conversations();
|
||||
const nextConversation = conversations.find((entry) => entry.id !== conversation.id) ?? null;
|
||||
|
||||
await this.directMessages.forgetConversation(conversation.id);
|
||||
|
||||
if (this.routeConversationId() === conversation.id) {
|
||||
await this.router.navigate(nextConversation ? ['/dm', nextConversation.id] : ['/dm']);
|
||||
}
|
||||
}
|
||||
|
||||
formatUnreadCount(count: number): string {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
private peerId(conversation: DirectMessageConversation): string | undefined {
|
||||
const currentUserId = this.directMessages.currentUserId();
|
||||
|
||||
return conversation.participants.find((participantId) => participantId !== currentUserId);
|
||||
}
|
||||
|
||||
private peerUser(conversation: DirectMessageConversation, users = this.users()): User | undefined {
|
||||
const peerId = this.peerId(conversation);
|
||||
|
||||
return peerId ? users.find((user) => user.id === peerId || user.oderId === peerId) : undefined;
|
||||
}
|
||||
|
||||
private isKlipyGif(content: string): boolean {
|
||||
return /!\[KLIPY GIF\]\([^)]*static\.klipy\.com[^)]*\)/i.test(content.trim());
|
||||
}
|
||||
|
||||
private attachmentPreview(attachments: Attachment[]): string {
|
||||
if (attachments.some((attachment) => attachment.mime.startsWith('image/'))) {
|
||||
return 'Sent an image';
|
||||
}
|
||||
|
||||
if (attachments.some((attachment) => attachment.mime.startsWith('video/'))) {
|
||||
return 'Sent a video';
|
||||
}
|
||||
|
||||
if (attachments.some((attachment) => attachment.mime.startsWith('audio/'))) {
|
||||
return 'Sent audio';
|
||||
}
|
||||
|
||||
return attachments.length === 1 ? 'Sent an attachment' : 'Sent attachments';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-testid]="'friend-button-' + userId()"
|
||||
class="grid h-8 w-8 place-items-center rounded-md border border-border bg-secondary text-foreground transition-colors hover:bg-secondary/80"
|
||||
[attr.aria-pressed]="isFriend()"
|
||||
[attr.aria-label]="isFriend() ? 'Remove friend' : 'Add friend'"
|
||||
[title]="isFriend() ? 'Remove friend' : 'Add friend'"
|
||||
(click)="toggle($event)"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="isFriend() ? 'lucideUserCheck' : 'lucideUserPlus'"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUserCheck, lucideUserPlus } from '@ng-icons/lucide';
|
||||
import { FriendService } from '../../application/services/friend.service';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-friend-button',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideUserCheck, lucideUserPlus })],
|
||||
templateUrl: './friend-button.component.html'
|
||||
})
|
||||
export class FriendButtonComponent {
|
||||
private readonly friends = inject(FriendService);
|
||||
|
||||
readonly user = input.required<User>();
|
||||
readonly userId = computed(() => this.user().oderId || this.user().id);
|
||||
readonly isFriend = computed(() => this.friends.isFriend(this.userId()));
|
||||
|
||||
toggle(event: Event): void {
|
||||
event.stopPropagation();
|
||||
void this.friends.toggleFriend(this.userId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<section class="min-h-full p-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold text-foreground">People</h3>
|
||||
<span class="text-xs text-muted-foreground">{{ matchingUsers().length }}</span>
|
||||
</div>
|
||||
|
||||
@if (friendResults().length > 0) {
|
||||
<div class="mb-3">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Friends</h4>
|
||||
<span class="text-xs text-muted-foreground">{{ friendResults().length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
@for (user of friendResults(); track user.id) {
|
||||
<div
|
||||
class="group flex items-center gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-2"
|
||||
[attr.data-testid]="'friend-card-' + userKey(user)"
|
||||
>
|
||||
<app-user-avatar
|
||||
[avatarUrl]="user.avatarUrl"
|
||||
[name]="user.displayName"
|
||||
[showStatusBadge]="true"
|
||||
[status]="user.status"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ user.displayName }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ user.username }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<app-friend-button [user]="user" />
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-testid]="'message-friend-' + userKey(user)"
|
||||
class="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
[attr.aria-label]="'Message ' + user.displayName"
|
||||
[title]="'Message ' + user.displayName"
|
||||
(click)="messageUser(user)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMessageCircle"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (friendResults().length > 0) {
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Others</h4>
|
||||
<span class="text-xs text-muted-foreground">{{ results().length }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (matchingUsers().length === 0) {
|
||||
<div class="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-3 text-sm text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
No users found
|
||||
</div>
|
||||
} @else {
|
||||
<div class="space-y-1.5">
|
||||
@for (user of results(); track user.id) {
|
||||
<div
|
||||
class="group flex items-center gap-2 rounded-lg border border-border bg-card p-2 transition-colors hover:bg-card/80"
|
||||
[attr.data-testid]="'user-card-' + userKey(user)"
|
||||
>
|
||||
<app-user-avatar
|
||||
[avatarUrl]="user.avatarUrl"
|
||||
[name]="user.displayName"
|
||||
[showStatusBadge]="true"
|
||||
[status]="user.status"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="truncate text-sm font-semibold text-foreground">{{ user.displayName }}</p>
|
||||
</div>
|
||||
<p class="truncate text-xs text-muted-foreground">{{ user.username }}</p>
|
||||
@if (user.description) {
|
||||
<p class="line-clamp-1 text-xs text-muted-foreground">{{ user.description }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pointer-events-none flex scale-95 shrink-0 items-center gap-2 opacity-0 transition-[opacity,transform] duration-75 ease-out group-hover:pointer-events-auto group-hover:scale-100 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:scale-100 group-focus-within:opacity-100"
|
||||
>
|
||||
<app-friend-button [user]="user" />
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-testid]="'message-user-' + userKey(user)"
|
||||
class="grid h-8 w-8 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
[attr.aria-label]="'Message ' + user.displayName"
|
||||
[title]="'Message ' + user.displayName"
|
||||
(click)="messageUser(user)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMessageCircle"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageCircle, lucideSearch } from '@ng-icons/lucide';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { FriendService } from '../../application/services/friend.service';
|
||||
import { FriendButtonComponent } from '../friend-button/friend-button.component';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-search-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
FriendButtonComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucideSearch })],
|
||||
templateUrl: './user-search-list.component.html'
|
||||
})
|
||||
export class UserSearchListComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly router = inject(Router);
|
||||
private readonly directMessages = inject(DirectMessageService);
|
||||
readonly friends = inject(FriendService);
|
||||
readonly searchQuery = input('');
|
||||
readonly users = this.store.selectSignal(selectAllUsers);
|
||||
readonly savedRooms = this.store.selectSignal(selectSavedRooms);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly discoveredUsers = computed(() => {
|
||||
const usersById = new Map<string, User>();
|
||||
|
||||
for (const user of this.users()) {
|
||||
usersById.set(user.oderId || user.id, user);
|
||||
}
|
||||
|
||||
for (const room of this.savedRooms()) {
|
||||
for (const member of room.members ?? []) {
|
||||
const userId = member.oderId || member.id;
|
||||
|
||||
if (usersById.has(userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
usersById.set(userId, {
|
||||
id: member.id,
|
||||
oderId: userId,
|
||||
username: member.username,
|
||||
displayName: member.displayName,
|
||||
description: member.description,
|
||||
avatarUrl: member.avatarUrl,
|
||||
profileUpdatedAt: member.profileUpdatedAt,
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt,
|
||||
status: 'disconnected'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(usersById.values());
|
||||
});
|
||||
readonly matchingUsers = computed(() => {
|
||||
const query = this.normalizedSearchQuery();
|
||||
const currentUserId = this.currentUserKey();
|
||||
|
||||
return this.discoveredUsers()
|
||||
.filter((user) => this.userKey(user) !== currentUserId)
|
||||
.filter((user) => this.matchesQuery(user, query))
|
||||
.slice(0, 24);
|
||||
});
|
||||
readonly friendResults = computed(() => this.matchingUsers().filter((user) => this.friends.isFriend(this.userKey(user))));
|
||||
readonly results = computed(() => {
|
||||
const friendIds = this.friends.friendIds();
|
||||
|
||||
return this.matchingUsers().filter((user) => !friendIds.has(this.userKey(user)));
|
||||
});
|
||||
|
||||
async messageUser(user: User): Promise<void> {
|
||||
const conversation = await this.directMessages.createConversation(user);
|
||||
|
||||
await this.router.navigate(['/dm', conversation.id]);
|
||||
}
|
||||
|
||||
userKey(user: User): string {
|
||||
return user.oderId || user.id;
|
||||
}
|
||||
|
||||
initial(label: string): string {
|
||||
return label.trim()[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
private currentUserKey(): string {
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
return currentUser ? this.userKey(currentUser) : '';
|
||||
}
|
||||
|
||||
private normalizedSearchQuery(): string {
|
||||
return this.searchQuery().trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
private matchesQuery(user: User, query: string): boolean {
|
||||
if (!query) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [
|
||||
user.displayName,
|
||||
user.username,
|
||||
user.description
|
||||
]
|
||||
.filter((value): value is string => !!value)
|
||||
.some((value) => value.toLowerCase().includes(query));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user