Fix private calls
This commit is contained in:
@@ -13,10 +13,25 @@
|
||||
[showStatusBadge]="true"
|
||||
size="md"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-base font-semibold text-foreground">{{ peerName() }}</h1>
|
||||
<p class="text-xs text-muted-foreground">Direct Message</p>
|
||||
<p class="text-xs text-muted-foreground">{{ isGroupConversation() ? 'Group Chat' : 'Direct Message' }}</p>
|
||||
</div>
|
||||
@if (showCallButton() && conversation()) {
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-9 w-9 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
[disabled]="!canCallConversation()"
|
||||
[attr.aria-label]="'Call ' + peerName()"
|
||||
[title]="'Call ' + peerName()"
|
||||
(click)="callConversation()"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="peerCallIcon()"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (conversation()) {
|
||||
@@ -58,6 +73,15 @@
|
||||
appThemeNode="chatComposerBar"
|
||||
class="chat-bottom-bar absolute bottom-0 left-0 right-2 z-10 bg-background/85 backdrop-blur-md"
|
||||
>
|
||||
@if (typingUsers().length > 0) {
|
||||
<div
|
||||
data-testid="dm-typing-indicator"
|
||||
class="px-4 pb-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ typingUsers().join(', ') }} {{ typingUsers().length === 1 ? 'is' : 'are' }} typing...
|
||||
</div>
|
||||
}
|
||||
|
||||
<app-chat-message-composer
|
||||
[replyTo]="replyTo()"
|
||||
[showKlipyGifPicker]="showGifPicker()"
|
||||
@@ -65,6 +89,7 @@
|
||||
[klipySignalSource]="null"
|
||||
[textareaTestId]="'dm-input'"
|
||||
(messageSubmitted)="handleMessageSubmitted($event)"
|
||||
(typingStarted)="handleTypingStarted()"
|
||||
(replyCleared)="clearReply()"
|
||||
(heightChanged)="composerBottomPadding.set($event + 20)"
|
||||
(klipyGifPickerToggleRequested)="toggleGifPicker()"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
effect,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
@@ -15,10 +16,13 @@ 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 { DirectCallService } from '../../../direct-call';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucidePhone, lucidePhoneCall } from '@ng-icons/lucide';
|
||||
import {
|
||||
ChatMessageComposerSubmitEvent,
|
||||
ChatMessageComposerComponent,
|
||||
@@ -57,9 +61,11 @@ interface DmStatusLabel {
|
||||
ChatMessageListComponent,
|
||||
ChatMessageOverlaysComponent,
|
||||
KlipyGifPickerComponent,
|
||||
NgIcon,
|
||||
ThemeNodeDirective,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall })],
|
||||
templateUrl: './dm-chat.component.html',
|
||||
host: {
|
||||
class: 'block h-full'
|
||||
@@ -74,10 +80,15 @@ export class DmChatComponent {
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly linkMetadata = inject(LinkMetadataService);
|
||||
private readonly metadataRequestKeys = new Set<string>();
|
||||
private openedConversationId: string | null = null;
|
||||
readonly directCalls = inject(DirectCallService);
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
readonly allUsers = this.store.selectSignal(selectAllUsers);
|
||||
readonly showGifPicker = signal(false);
|
||||
readonly conversationId = input<string | null>(null);
|
||||
readonly showCallButton = input(true);
|
||||
readonly composerBottomPadding = signal(140);
|
||||
readonly gifPickerAnchorRight = signal(16);
|
||||
readonly linkMetadataByMessageId = signal<Record<string, LinkMetadata[]>>({});
|
||||
@@ -87,15 +98,26 @@ export class DmChatComponent {
|
||||
readonly routeConversationId = toSignal(this.route.paramMap.pipe(map((params) => params.get('conversationId'))), {
|
||||
initialValue: this.route.snapshot.paramMap.get('conversationId')
|
||||
});
|
||||
readonly effectiveConversationId = computed(() => this.conversationId() ?? this.routeConversationId());
|
||||
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 typingUsers = computed(() => {
|
||||
void this.directMessages.typingEntries();
|
||||
|
||||
return this.directMessages.typingUsers(this.conversation()?.id);
|
||||
});
|
||||
readonly peerUser = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
|
||||
return conversation ? this.peerUserFor(conversation) : null;
|
||||
});
|
||||
readonly isGroupConversation = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
|
||||
return !!conversation && (conversation.kind === 'group' || conversation.participants.length > 2);
|
||||
});
|
||||
readonly participantUsers = computed<User[]>(() => {
|
||||
const conversation = this.conversation();
|
||||
const knownUsers = this.allUsers();
|
||||
@@ -173,22 +195,57 @@ export class DmChatComponent {
|
||||
readonly peerName = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
const currentUserId = this.currentUserId();
|
||||
|
||||
if (conversation && this.isGroupConversation()) {
|
||||
return conversation.title || this.groupConversationTitle(conversation);
|
||||
}
|
||||
|
||||
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
return peerId ? conversation?.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
|
||||
});
|
||||
readonly peerCallIcon = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (conversation && this.isGroupConversation()) {
|
||||
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
}
|
||||
|
||||
const peer = this.peerUser();
|
||||
|
||||
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
});
|
||||
readonly canCallConversation = computed(() => {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (!conversation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.isGroupConversation()) {
|
||||
return conversation.participants.some((participantId) => participantId !== this.currentUserId());
|
||||
}
|
||||
|
||||
return !!this.peerUser();
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const conversationId = this.routeConversationId();
|
||||
const conversationId = this.effectiveConversationId();
|
||||
|
||||
if (conversationId) {
|
||||
if (!conversationId) {
|
||||
this.openedConversationId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (conversationId !== this.openedConversationId) {
|
||||
this.openedConversationId = conversationId;
|
||||
void this.directMessages.openConversation(conversationId);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
void this.routeConversationId();
|
||||
void this.effectiveConversationId();
|
||||
void this.klipy.refreshAvailability(null);
|
||||
});
|
||||
|
||||
@@ -226,11 +283,28 @@ export class DmChatComponent {
|
||||
this.replyTo.set(null);
|
||||
|
||||
if (event.pendingFiles.length > 0) {
|
||||
this.attachments.rememberMessageRoom(message.id, `direct-message:${conversation.id}`);
|
||||
this.attachments.publishAttachments(message.id, event.pendingFiles, this.currentUserId() || undefined);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleTypingStarted(): void {
|
||||
const conversationId = this.conversation()?.id;
|
||||
|
||||
if (conversationId) {
|
||||
this.directMessages.sendTyping(conversationId, true);
|
||||
}
|
||||
}
|
||||
|
||||
async callConversation(): Promise<void> {
|
||||
const conversation = this.conversation();
|
||||
|
||||
if (conversation && this.canCallConversation()) {
|
||||
await this.directCalls.startConversationCall(conversation);
|
||||
}
|
||||
}
|
||||
|
||||
setReplyTo(message: ChatMessageReplyEvent): void {
|
||||
this.replyTo.set(message);
|
||||
}
|
||||
@@ -325,6 +399,20 @@ export class DmChatComponent {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi) {
|
||||
const diskPath = this.getAttachmentDiskPath(attachment);
|
||||
|
||||
if (diskPath && electronApi.saveExistingFileAs) {
|
||||
try {
|
||||
const result = await electronApi.saveExistingFileAs(diskPath, attachment.filename);
|
||||
|
||||
if (result.saved || result.cancelled) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall back to blob/browser download */
|
||||
}
|
||||
}
|
||||
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
@@ -391,12 +479,16 @@ export class DmChatComponent {
|
||||
continue;
|
||||
}
|
||||
|
||||
const urls = this.linkMetadata.extractUrls(message.content).filter((url) => !hasDedicatedChatEmbed(url));
|
||||
const urls = this.linkMetadata.extractUrls(message.content)
|
||||
.filter((url) => !hasDedicatedChatEmbed(url))
|
||||
.filter((url) => !this.metadataRequestKeys.has(this.metadataRequestKey(message.id, url)));
|
||||
|
||||
if (urls.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
urls.forEach((url) => this.metadataRequestKeys.add(this.metadataRequestKey(message.id, url)));
|
||||
|
||||
const metadata = (await this.linkMetadata.fetchAllMetadata(urls)).filter((entry) => !entry.failed);
|
||||
|
||||
if (metadata.length === 0) {
|
||||
@@ -410,11 +502,19 @@ export class DmChatComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private metadataRequestKey(messageId: string, url: string): string {
|
||||
return `${messageId}:${url}`;
|
||||
}
|
||||
|
||||
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
|
||||
if (!attachment.objectUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (attachment.objectUrl.startsWith('file:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
@@ -424,6 +524,10 @@ export class DmChatComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private getAttachmentDiskPath(attachment: Attachment): string | null {
|
||||
return attachment.savedPath || attachment.filePath || null;
|
||||
}
|
||||
|
||||
private blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
@@ -445,6 +549,10 @@ export class DmChatComponent {
|
||||
}
|
||||
|
||||
private peerUserFor(conversation: NonNullable<ReturnType<typeof this.conversation>>): User | null {
|
||||
if (conversation.kind === 'group' || conversation.participants.length > 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentUserId = this.currentUserId();
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
@@ -454,4 +562,16 @@ export class DmChatComponent {
|
||||
|
||||
return this.participantUsers().find((user) => user.id === peerId || user.oderId === peerId) ?? null;
|
||||
}
|
||||
|
||||
private groupConversationTitle(conversation: NonNullable<ReturnType<typeof this.conversation>>): string {
|
||||
const names = conversation.participants
|
||||
.filter((participantId) => participantId !== this.currentUserId())
|
||||
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
|
||||
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,11 @@
|
||||
[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'"
|
||||
[attr.data-testid]="'dm-rail-item-' + item.id"
|
||||
[title]="item.label"
|
||||
[attr.aria-current]="isSelectedItem(item) ? 'page' : null"
|
||||
(click)="openItem(item)"
|
||||
(contextmenu)="openContextMenu($event, item)"
|
||||
>
|
||||
<div class="h-full w-full overflow-hidden rounded-[inherit]">
|
||||
@if (item.avatarUrl) {
|
||||
@@ -58,7 +60,7 @@
|
||||
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"
|
||||
[name]="iconFor(item)"
|
||||
class="h-2.5 w-2.5"
|
||||
/>
|
||||
</span>
|
||||
@@ -72,3 +74,24 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (contextMenu(); as menu) {
|
||||
<app-context-menu
|
||||
[x]="menu.x"
|
||||
[y]="menu.y"
|
||||
width="w-44"
|
||||
(closed)="closeContextMenu()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="context-menu-item-icon-danger"
|
||||
(click)="forgetContextItem()"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="forgetContextIcon(menu.item)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
{{ forgetContextLabel(menu.item) }}
|
||||
</button>
|
||||
</app-context-menu>
|
||||
}
|
||||
|
||||
@@ -12,8 +12,15 @@ 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 {
|
||||
lucideLogOut,
|
||||
lucideMessageCircle,
|
||||
lucideTrash2,
|
||||
lucideUser,
|
||||
lucideUsers
|
||||
} from '@ng-icons/lucide';
|
||||
import { filter, map } from 'rxjs';
|
||||
import { ContextMenuComponent } from '../../../../shared';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { FriendService } from '../../application/services/friend.service';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
@@ -30,13 +37,23 @@ interface DmRailItem {
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
interface DmRailContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
item: DmRailItem;
|
||||
}
|
||||
|
||||
const EXIT_ANIMATION_MS = 160;
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-rail',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucideUser })],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ContextMenuComponent,
|
||||
NgIcon
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideLogOut, lucideMessageCircle, lucideTrash2, lucideUser, lucideUsers })],
|
||||
templateUrl: './dm-rail.component.html',
|
||||
styleUrl: './dm-rail.component.scss'
|
||||
})
|
||||
@@ -60,11 +77,29 @@ export class DmRailComponent implements OnDestroy {
|
||||
this.friends.isFriend(user.oderId || user.id) && (user.oderId || user.id) !== this.currentUserId()
|
||||
));
|
||||
readonly railItems = signal<DmRailItem[]>([]);
|
||||
readonly contextMenu = signal<DmRailContextMenuState | null>(null);
|
||||
readonly unreadRailItems = computed<DmRailItem[]>(() => {
|
||||
const currentUserId = this.currentUserId();
|
||||
const items = new Map<string, DmRailItem>();
|
||||
|
||||
for (const conversation of this.directMessages.conversations()) {
|
||||
if (conversation.unreadCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
items.set(conversation.id, {
|
||||
id: conversation.id,
|
||||
label: this.titleFor(conversation),
|
||||
conversation,
|
||||
isExiting: false,
|
||||
user: null,
|
||||
unreadCount: conversation.unreadCount
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
if (!peerId) {
|
||||
@@ -103,7 +138,7 @@ export class DmRailComponent implements OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
return Array.from(items.values()).filter((item) => item.unreadCount > 0);
|
||||
return Array.from(items.values()).filter((item) => item.conversation && item.unreadCount > 0);
|
||||
});
|
||||
readonly isOnDirectMessages = toSignal(
|
||||
this.router.events.pipe(
|
||||
@@ -140,6 +175,8 @@ export class DmRailComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
async openItem(item: DmRailItem): Promise<void> {
|
||||
this.closeContextMenu();
|
||||
|
||||
if (item.conversation) {
|
||||
await this.openConversation(item.conversation);
|
||||
return;
|
||||
@@ -155,6 +192,10 @@ export class DmRailComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
titleFor(conversation: DirectMessageConversation): string {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return conversation.title || this.groupConversationTitle(conversation);
|
||||
}
|
||||
|
||||
const peerId = conversation.participants.find((participantId) => participantId !== this.currentUserId());
|
||||
|
||||
return peerId ? conversation.participantProfiles[peerId]?.displayName || peerId : 'DM';
|
||||
@@ -184,6 +225,51 @@ export class DmRailComponent implements OnDestroy {
|
||||
return !!item.conversation && this.isSelectedConversation(item.conversation);
|
||||
}
|
||||
|
||||
iconFor(item: DmRailItem): string {
|
||||
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideUsers' : 'lucideUser';
|
||||
}
|
||||
|
||||
openContextMenu(event: MouseEvent, item: DmRailItem): void {
|
||||
if (!item.conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.contextMenu.set({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
item
|
||||
});
|
||||
}
|
||||
|
||||
closeContextMenu(): void {
|
||||
this.contextMenu.set(null);
|
||||
}
|
||||
|
||||
async forgetContextItem(): Promise<void> {
|
||||
const item = this.contextMenu()?.item;
|
||||
|
||||
if (!item?.conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.directMessages.forgetConversation(item.conversation.id);
|
||||
this.closeContextMenu();
|
||||
|
||||
if (this.isSelectedConversation(item.conversation)) {
|
||||
await this.router.navigate(['/dm']);
|
||||
}
|
||||
}
|
||||
|
||||
forgetContextLabel(item: DmRailItem): string {
|
||||
return item.conversation && this.isGroupConversation(item.conversation) ? 'Leave chat' : 'Forget chat';
|
||||
}
|
||||
|
||||
forgetContextIcon(item: DmRailItem): string {
|
||||
return item.conversation && this.isGroupConversation(item.conversation) ? 'lucideLogOut' : 'lucideTrash2';
|
||||
}
|
||||
|
||||
formatUnreadCount(count: number): string {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
@@ -227,4 +313,20 @@ export class DmRailComponent implements OnDestroy {
|
||||
|
||||
this.railItems.set(nextItems);
|
||||
}
|
||||
|
||||
private isGroupConversation(conversation: DirectMessageConversation): boolean {
|
||||
return conversation.kind === 'group' || conversation.participants.length > 2;
|
||||
}
|
||||
|
||||
private groupConversationTitle(conversation: DirectMessageConversation): string {
|
||||
const names = conversation.participants
|
||||
.filter((participantId) => participantId !== this.currentUserId())
|
||||
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
|
||||
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<main
|
||||
appThemeNode="dmChatPanel"
|
||||
class="relative min-h-0 min-w-0 overflow-hidden bg-background"
|
||||
[ngStyle]="chatPanelStyles()"
|
||||
>
|
||||
<app-dm-chat />
|
||||
</main>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../theme';
|
||||
import { DmChatComponent } from '../dm-chat/dm-chat.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-chat-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ThemeNodeDirective,
|
||||
DmChatComponent
|
||||
],
|
||||
host: { class: 'contents' },
|
||||
templateUrl: './dm-chat-panel.component.html'
|
||||
})
|
||||
export class DmChatPanelComponent {
|
||||
private readonly theme = inject(ThemeService);
|
||||
|
||||
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<div
|
||||
appThemeNode="dmConversationItem"
|
||||
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]="isSelected()"
|
||||
[class.text-foreground]="isSelected()"
|
||||
[attr.aria-current]="isSelected() ? 'page' : null"
|
||||
(click)="openConversation()"
|
||||
(keydown.enter)="openConversation()"
|
||||
(keydown.space)="openConversation()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="peerName()"
|
||||
[avatarUrl]="peerAvatarUrl()"
|
||||
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() }}</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() }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="invisible grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition hover:bg-emerald-500/10 hover:text-emerald-600 focus:visible focus:opacity-100 group-focus-within:visible group-focus-within:opacity-100 group-hover:visible group-hover:opacity-100 disabled:group-focus-within:opacity-30 disabled:group-hover:opacity-30"
|
||||
[disabled]="!canCall()"
|
||||
[attr.aria-label]="'Call ' + peerName()"
|
||||
[title]="'Call ' + peerName()"
|
||||
(click)="callConversationPeer($event)"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="callIcon()"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
<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()"
|
||||
[title]="'Forget ' + peerName()"
|
||||
(click)="forgetConversation($event)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideTrash2"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,216 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input
|
||||
} 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 {
|
||||
lucidePhone,
|
||||
lucidePhoneCall,
|
||||
lucideTrash2
|
||||
} from '@ng-icons/lucide';
|
||||
import { map } from 'rxjs';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
import { AttachmentFacade } from '../../../attachment';
|
||||
import { DirectCallService } from '../../../direct-call';
|
||||
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';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-conversation-item',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ThemeNodeDirective
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePhone, lucidePhoneCall, lucideTrash2 })],
|
||||
host: { class: 'block' },
|
||||
templateUrl: './dm-conversation-item.component.html'
|
||||
})
|
||||
export class DmConversationItemComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
private readonly directMessages = inject(DirectMessageService);
|
||||
private readonly directCalls = inject(DirectCallService);
|
||||
readonly conversation = input.required<DirectMessageConversation>();
|
||||
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 isSelected = computed(() => this.routeConversationId() === this.conversation().id);
|
||||
readonly peerName = computed(() => this.resolvePeerName(this.conversation()));
|
||||
readonly peerAvatarUrl = computed(() => this.resolvePeerAvatarUrl(this.conversation()));
|
||||
readonly lastMessagePreview = computed(() => this.resolveLastMessagePreview(this.conversation()));
|
||||
readonly canCall = computed(() => this.canCallConversation(this.conversation()));
|
||||
readonly callIcon = computed(() => this.conversationCallIcon(this.conversation()));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const conversation = this.conversation();
|
||||
const peer = this.peerUser(conversation, this.users());
|
||||
|
||||
if (!peer?.avatarUrl) {
|
||||
this.directMessages.requestPeerAvatarSync(conversation.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
openConversation(): void {
|
||||
void this.router.navigate(['/dm', this.conversation().id]);
|
||||
}
|
||||
|
||||
async forgetConversation(event: Event): Promise<void> {
|
||||
event.stopPropagation();
|
||||
const conversation = this.conversation();
|
||||
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']);
|
||||
}
|
||||
}
|
||||
|
||||
async callConversationPeer(event: Event): Promise<void> {
|
||||
event.stopPropagation();
|
||||
await this.directCalls.startConversationCall(this.conversation());
|
||||
}
|
||||
|
||||
formatUnreadCount(count: number): string {
|
||||
return count > 99 ? '99+' : String(count);
|
||||
}
|
||||
|
||||
private resolvePeerName(conversation: DirectMessageConversation): string {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return conversation.title || this.groupConversationTitle(conversation);
|
||||
}
|
||||
|
||||
const peerId = this.peerId(conversation);
|
||||
const knownUser = this.peerUser(conversation);
|
||||
|
||||
return peerId ? knownUser?.displayName || conversation.participantProfiles[peerId]?.displayName || peerId : 'Direct Message';
|
||||
}
|
||||
|
||||
private resolvePeerAvatarUrl(conversation: DirectMessageConversation): string | undefined {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const peerId = this.peerId(conversation);
|
||||
const knownUser = this.peerUser(conversation);
|
||||
|
||||
return peerId ? knownUser?.avatarUrl || conversation.participantProfiles[peerId]?.avatarUrl : undefined;
|
||||
}
|
||||
|
||||
private resolveLastMessagePreview(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';
|
||||
}
|
||||
|
||||
private conversationCallIcon(conversation: DirectMessageConversation): string {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return this.directCalls.isCallingConversation(conversation.id) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
}
|
||||
|
||||
const peer = this.peerUser(conversation);
|
||||
|
||||
return peer && this.directCalls.isCallingUser(peer) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
}
|
||||
|
||||
private canCallConversation(conversation: DirectMessageConversation): boolean {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return conversation.participants.some((participantId) => participantId !== this.directMessages.currentUserId());
|
||||
}
|
||||
|
||||
return !!this.peerUser(conversation);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (this.isGroupConversation(conversation)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const peerId = this.peerId(conversation);
|
||||
|
||||
return peerId ? users.find((user) => user.id === peerId || user.oderId === peerId) : undefined;
|
||||
}
|
||||
|
||||
private isGroupConversation(conversation: DirectMessageConversation): boolean {
|
||||
return conversation.kind === 'group' || conversation.participants.length > 2;
|
||||
}
|
||||
|
||||
private groupConversationTitle(conversation: DirectMessageConversation): string {
|
||||
const currentUserId = this.directMessages.currentUserId();
|
||||
const names = conversation.participants
|
||||
.filter((participantId) => participantId !== currentUserId)
|
||||
.map((participantId) => conversation.participantProfiles[participantId]?.displayName || participantId);
|
||||
|
||||
if (names.length <= 3) {
|
||||
return names.join(', ');
|
||||
}
|
||||
|
||||
return `${names.slice(0, 3).join(', ')} +${names.length - 3}`;
|
||||
}
|
||||
|
||||
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,46 @@
|
||||
<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
|
||||
appThemeNode="dmConversationsHeader"
|
||||
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
|
||||
appThemeNode="dmConversationList"
|
||||
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">
|
||||
<app-dm-conversation-item
|
||||
*ngFor="let conversation of directMessages.conversations(); trackBy: trackConversationId"
|
||||
[conversation]="conversation"
|
||||
></app-dm-conversation-item>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
appThemeNode="dmVoiceControlsArea"
|
||||
class="border-t border-border px-2 py-3"
|
||||
>
|
||||
<app-voice-controls />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
@@ -0,0 +1,38 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideMessageCircle } from '@ng-icons/lucide';
|
||||
import { ThemeNodeDirective, ThemeService } from '../../../theme';
|
||||
import { VoiceControlsComponent } from '../../../voice-session';
|
||||
import type { DirectMessageConversation } from '../../domain/models/direct-message.model';
|
||||
import { DirectMessageService } from '../../application/services/direct-message.service';
|
||||
import { DmConversationItemComponent } from './dm-conversation-item.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-conversations-panel',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
DmConversationItemComponent,
|
||||
NgIcon,
|
||||
ThemeNodeDirective,
|
||||
VoiceControlsComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle })],
|
||||
host: { class: 'contents' },
|
||||
templateUrl: './dm-conversations-panel.component.html'
|
||||
})
|
||||
export class DmConversationsPanelComponent {
|
||||
private readonly theme = inject(ThemeService);
|
||||
readonly directMessages = inject(DirectMessageService);
|
||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||
|
||||
trackConversationId(index: number, conversation: DirectMessageConversation): string {
|
||||
return conversation.id;
|
||||
}
|
||||
}
|
||||
@@ -2,97 +2,6 @@
|
||||
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
|
||||
appThemeNode="dmConversationsHeader"
|
||||
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
|
||||
appThemeNode="dmConversationList"
|
||||
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
|
||||
appThemeNode="dmConversationItem"
|
||||
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
|
||||
appThemeNode="dmVoiceControlsArea"
|
||||
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>
|
||||
<app-dm-conversations-panel />
|
||||
<app-dm-chat-panel />
|
||||
</div>
|
||||
|
||||
@@ -9,50 +9,31 @@ import {
|
||||
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 { ThemeService } from '../../../theme';
|
||||
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';
|
||||
import { DmChatPanelComponent } from './dm-chat-panel.component';
|
||||
import { DmConversationsPanelComponent } from './dm-conversations-panel.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dm-workspace',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
UserAvatarComponent,
|
||||
ThemeNodeDirective,
|
||||
DmChatComponent,
|
||||
VoiceControlsComponent
|
||||
DmChatPanelComponent,
|
||||
DmConversationsPanelComponent
|
||||
],
|
||||
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('dmLayout'));
|
||||
readonly listPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmConversationsPanel'));
|
||||
readonly chatPanelStyles = computed(() => this.theme.getLayoutItemStyles('dmChatPanel'));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
@@ -69,116 +50,9 @@ export class DmWorkspaceComponent implements OnDestroy {
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,19 @@
|
||||
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]="'call-friend-' + userKey(user)"
|
||||
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
|
||||
[attr.aria-label]="'Call ' + user.displayName"
|
||||
[title]="'Call ' + user.displayName"
|
||||
(click)="callUser(user)"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="callIcon(user)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-testid]="'message-friend-' + userKey(user)"
|
||||
@@ -98,6 +111,19 @@
|
||||
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]="'call-user-' + userKey(user)"
|
||||
class="grid h-8 w-8 place-items-center rounded-md bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
|
||||
[attr.aria-label]="'Call ' + user.displayName"
|
||||
[title]="'Call ' + user.displayName"
|
||||
(click)="callUser(user)"
|
||||
>
|
||||
<ng-icon
|
||||
[name]="callIcon(user)"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.data-testid]="'message-user-' + userKey(user)"
|
||||
|
||||
@@ -9,11 +9,17 @@ 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 {
|
||||
lucideMessageCircle,
|
||||
lucidePhone,
|
||||
lucidePhoneCall,
|
||||
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 { DirectCallService } from '../../../direct-call';
|
||||
import { FriendService } from '../../application/services/friend.service';
|
||||
import { FriendButtonComponent } from '../friend-button/friend-button.component';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
@@ -27,13 +33,14 @@ import type { User } from '../../../../shared-kernel';
|
||||
UserAvatarComponent,
|
||||
FriendButtonComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucideSearch })],
|
||||
viewProviders: [provideIcons({ lucideMessageCircle, lucidePhone, lucidePhoneCall, 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 directCalls = inject(DirectCallService);
|
||||
readonly friends = inject(FriendService);
|
||||
readonly searchQuery = input('');
|
||||
readonly users = this.store.selectSignal(selectAllUsers);
|
||||
@@ -93,6 +100,14 @@ export class UserSearchListComponent {
|
||||
await this.router.navigate(['/dm', conversation.id]);
|
||||
}
|
||||
|
||||
async callUser(user: User): Promise<void> {
|
||||
await this.directCalls.startCall(user);
|
||||
}
|
||||
|
||||
callIcon(user: User): string {
|
||||
return this.directCalls.isCallingUser(user) ? 'lucidePhoneCall' : 'lucidePhone';
|
||||
}
|
||||
|
||||
userKey(user: User): string {
|
||||
return user.oderId || user.id;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user