feat: Add pm
This commit is contained in:
@@ -22,7 +22,7 @@ export class ServerSearchPage {
|
||||
readonly dialogCancelButton: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.searchInput = page.getByPlaceholder('Search servers...');
|
||||
this.searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
this.railCreateServerButton = page.locator('button[title="Create Server"]');
|
||||
this.searchCreateServerButton = page.getByRole('button', { name: 'Create New Server' });
|
||||
this.createServerButton = this.searchCreateServerButton;
|
||||
|
||||
117
e2e/tests/chat/dm-flow.spec.ts
Normal file
117
e2e/tests/chat/dm-flow.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
import {
|
||||
test,
|
||||
expect,
|
||||
type Client
|
||||
} from '../../fixtures/multi-client';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
import { ServerSearchPage } from '../../pages/server-search.page';
|
||||
import { ChatMessagesPage } from '../../pages/chat-messages.page';
|
||||
|
||||
test.describe('Direct message flow', () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test('opens a DM from a user card and queues messages while offline', async ({ createClient }) => {
|
||||
const scenario = await createDmScenario(createClient);
|
||||
const offlineMessage = `Offline DM ${uniqueName('msg')}`;
|
||||
|
||||
await test.step('Alice opens Bob from the room user list', async () => {
|
||||
const bobUserCard = scenario.alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||
|
||||
await expect(bobUserCard).toBeVisible({ timeout: 20_000 });
|
||||
await bobUserCard.getByRole('button', { name: 'Message Bob' }).click();
|
||||
await expect(scenario.alice.page).toHaveURL(/\/dm\//, { timeout: 15_000 });
|
||||
await expect(scenario.alice.page.getByRole('heading', { name: 'Bob' })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
await test.step('Offline send persists locally as queued', async () => {
|
||||
await scenario.alice.page.evaluate(() => window.simulateOffline?.());
|
||||
await scenario.alice.page.getByTestId('dm-input').fill(offlineMessage);
|
||||
await scenario.alice.page.getByTestId('dm-input').press('Enter');
|
||||
|
||||
await expect(scenario.alice.page.locator('app-dm-chat').getByText(offlineMessage)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(scenario.alice.page.getByTestId('message-status').last()).toContainText('QUEUED');
|
||||
});
|
||||
});
|
||||
|
||||
test('shows friend and message actions on the search people list', async ({ createClient }) => {
|
||||
const scenario = await createDmScenario(createClient);
|
||||
|
||||
await scenario.alice.page.goto('/search', { waitUntil: 'domcontentloaded' });
|
||||
await expect(scenario.alice.page).toHaveURL(/\/search/, { timeout: 20_000 });
|
||||
await expect(scenario.alice.page.locator('app-server-search')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.alice.page.locator('app-user-search-list')).toBeVisible({ timeout: 20_000 });
|
||||
const bobPeopleCard = scenario.alice.page.locator(`app-user-search-list [data-testid="user-card-${scenario.bobUserId}"]`);
|
||||
|
||||
await expect(bobPeopleCard).toBeVisible({ timeout: 15_000 });
|
||||
const friendButton = bobPeopleCard.locator(`[data-testid="friend-button-${scenario.bobUserId}"]`);
|
||||
const messageButton = bobPeopleCard.locator(`[data-testid="message-user-${scenario.bobUserId}"]`);
|
||||
|
||||
await expect(friendButton).toBeVisible({ timeout: 15_000 });
|
||||
await expect(messageButton).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
interface DmScenario {
|
||||
alice: Client;
|
||||
bob: Client;
|
||||
bobUserId: string;
|
||||
aliceSearch: ServerSearchPage;
|
||||
}
|
||||
|
||||
async function createDmScenario(createClient: () => Promise<Client>): Promise<DmScenario> {
|
||||
const suffix = uniqueName('dm');
|
||||
const serverName = `DM Server ${suffix}`;
|
||||
const alice = await createClient();
|
||||
const bob = await createClient();
|
||||
|
||||
await registerUser(alice.page, `alice_${suffix}`, 'Alice');
|
||||
await registerUser(bob.page, `bob_${suffix}`, 'Bob');
|
||||
|
||||
const aliceSearch = new ServerSearchPage(alice.page);
|
||||
|
||||
await aliceSearch.createServer(serverName, { description: 'E2E direct message discovery server' });
|
||||
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await new ChatMessagesPage(alice.page).waitForReady();
|
||||
|
||||
const bobSearch = new ServerSearchPage(bob.page);
|
||||
|
||||
await bobSearch.searchInput.fill(serverName);
|
||||
|
||||
await bob.page.locator('button', { hasText: serverName })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await new ChatMessagesPage(bob.page).waitForReady();
|
||||
const bobRoomCard = alice.page.locator('[data-testid^="room-user-card-"]', { hasText: 'Bob' }).first();
|
||||
|
||||
await expect(bobRoomCard).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const bobUserCardTestId = await bobRoomCard.getAttribute('data-testid');
|
||||
const bobUserId = bobUserCardTestId?.replace('room-user-card-', '');
|
||||
|
||||
if (!bobUserId) {
|
||||
throw new Error('Expected Bob room user card to expose a stable test id.');
|
||||
}
|
||||
|
||||
return {
|
||||
alice,
|
||||
bob,
|
||||
bobUserId,
|
||||
aliceSearch
|
||||
};
|
||||
}
|
||||
|
||||
async function registerUser(page: Page, username: string, displayName: string): Promise<void> {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(username, displayName, 'TestPass123!');
|
||||
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
@@ -88,7 +88,7 @@ test.describe('Connectivity warning', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Register Bob', async () => {
|
||||
@@ -96,7 +96,7 @@ test.describe('Connectivity warning', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Register Charlie', async () => {
|
||||
@@ -104,7 +104,7 @@ test.describe('Connectivity warning', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!');
|
||||
await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(charlie.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
// ── Create server and have everyone join ──
|
||||
|
||||
@@ -9,7 +9,7 @@ test.describe('ICE server settings', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!');
|
||||
await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTitle('Settings').click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole('button', { name: 'Network' }).click();
|
||||
|
||||
@@ -89,7 +89,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!');
|
||||
await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(alice.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Register Bob', async () => {
|
||||
@@ -97,7 +97,7 @@ test.describe('STUN/TURN fallback behaviour', () => {
|
||||
|
||||
await register.goto();
|
||||
await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!');
|
||||
await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(bob.page.getByPlaceholder('Search servers and users...')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
await test.step('Alice creates a server', async () => {
|
||||
|
||||
@@ -556,7 +556,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function openSearchView(page: Page): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
return;
|
||||
@@ -567,7 +567,7 @@ async function openSearchView(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
|
||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||
await searchInput.fill(roomName);
|
||||
|
||||
@@ -319,7 +319,7 @@ async function installDeterministicVoiceSettings(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function openSearchView(page: Page): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
|
||||
if (await searchInput.isVisible().catch(() => false)) {
|
||||
return;
|
||||
@@ -330,7 +330,7 @@ async function openSearchView(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function joinRoomFromSearch(page: Page, roomName: string): Promise<void> {
|
||||
const searchInput = page.getByPlaceholder('Search servers...');
|
||||
const searchInput = page.getByPlaceholder('Search servers and users...');
|
||||
|
||||
await expect(searchInput).toBeVisible({ timeout: 20_000 });
|
||||
await searchInput.fill(roomName);
|
||||
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
ServerAccessError,
|
||||
kickServerUser,
|
||||
ensureServerMembership,
|
||||
unbanServerUser
|
||||
unbanServerUser,
|
||||
countServerMemberships
|
||||
} from '../services/server-access.service';
|
||||
import {
|
||||
buildAppInviteUrl,
|
||||
@@ -78,6 +79,7 @@ function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
|
||||
|
||||
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||
const owner = await getUserById(server.ownerId);
|
||||
const userCount = await countServerMemberships(server.id);
|
||||
const { passwordHash, ...publicServer } = server;
|
||||
|
||||
return {
|
||||
@@ -85,7 +87,8 @@ async function enrichServer(server: ServerPayload, sourceUrl?: string) {
|
||||
hasPassword: server.hasPassword ?? !!passwordHash,
|
||||
ownerName: owner?.displayName,
|
||||
sourceUrl,
|
||||
userCount: server.currentUsers
|
||||
currentUsers: userCount,
|
||||
userCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -130,6 +130,10 @@ export async function findServerMembership(serverId: string, userId: string): Pr
|
||||
return await getMembershipRepository().findOne({ where: { serverId, userId } });
|
||||
}
|
||||
|
||||
export async function countServerMemberships(serverId: string): Promise<number> {
|
||||
return await getMembershipRepository().count({ where: { serverId } });
|
||||
}
|
||||
|
||||
export async function ensureServerMembership(serverId: string, userId: string): Promise<ServerMembershipEntity> {
|
||||
const repo = getMembershipRepository();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2.2MB",
|
||||
"maximumError": "2.35MB"
|
||||
"maximumError": "2.36MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
|
||||
@@ -145,7 +145,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} @if (!isThemeStudioFullscreen()) {
|
||||
} @if (!isThemeStudioFullscreen() && !isDirectMessageRoute()) {
|
||||
<app-floating-voice-controls />
|
||||
}
|
||||
<app-settings-modal />
|
||||
|
||||
@@ -34,6 +34,16 @@ export const routes: Routes = [
|
||||
loadComponent: () =>
|
||||
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
|
||||
},
|
||||
{
|
||||
path: 'dm',
|
||||
loadComponent: () =>
|
||||
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
||||
},
|
||||
{
|
||||
path: 'dm/:conversationId',
|
||||
loadComponent: () =>
|
||||
import('./domains/direct-message/feature/dm-workspace/dm-workspace.component').then((module) => module.DmWorkspaceComponent)
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
loadComponent: () =>
|
||||
|
||||
@@ -45,10 +45,7 @@ import { UsersActions } from './store/users/users.actions';
|
||||
import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
import { ROOM_URL_PATTERN } from './core/constants';
|
||||
import {
|
||||
clearStoredCurrentUserId,
|
||||
getStoredCurrentUserId
|
||||
} from './core/storage/current-user-storage';
|
||||
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
||||
import {
|
||||
ThemeNodeDirective,
|
||||
ThemePickerOverlayComponent,
|
||||
@@ -102,6 +99,7 @@ export class App implements OnInit, OnDestroy {
|
||||
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
|
||||
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
|
||||
readonly isDraggingThemeStudioControls = signal(false);
|
||||
readonly currentRouteUrl = signal(this.getCurrentRouteUrl());
|
||||
|
||||
readonly appShellLayoutStyles = computed(() => this.theme.getLayoutContainerStyles('appShell'));
|
||||
readonly serversRailLayoutStyles = computed(() => this.theme.getLayoutItemStyles('serversRail'));
|
||||
@@ -115,6 +113,7 @@ export class App implements OnInit, OnDestroy {
|
||||
return this.settingsModal.activePage() === 'theme'
|
||||
&& this.settingsModal.themeStudioMinimized();
|
||||
});
|
||||
readonly isDirectMessageRoute = computed(() => this.getRoutePath(this.currentRouteUrl()).startsWith('/dm'));
|
||||
readonly desktopUpdateNoticeKey = computed(() => {
|
||||
const updateState = this.desktopUpdateState();
|
||||
|
||||
@@ -276,6 +275,8 @@ export class App implements OnInit, OnDestroy {
|
||||
this.router.events.subscribe((evt) => {
|
||||
if (evt instanceof NavigationEnd) {
|
||||
const url = evt.urlAfterRedirects || evt.url;
|
||||
|
||||
this.currentRouteUrl.set(url);
|
||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ infrastructure adapters and UI.
|
||||
| **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
|
||||
| **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
|
||||
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
|
||||
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
@@ -28,6 +29,7 @@ The larger domains also keep longer design notes in their own folders:
|
||||
- [access-control/README.md](access-control/README.md)
|
||||
- [authentication/README.md](authentication/README.md)
|
||||
- [chat/README.md](chat/README.md)
|
||||
- [direct-message/README.md](direct-message/README.md)
|
||||
- [notifications/README.md](notifications/README.md)
|
||||
- [profile-avatar/README.md](profile-avatar/README.md)
|
||||
- [screen-share/README.md](screen-share/README.md)
|
||||
|
||||
@@ -172,6 +172,7 @@
|
||||
|
||||
<textarea
|
||||
#messageInputRef
|
||||
[attr.data-testid]="textareaTestId()"
|
||||
rows="1"
|
||||
[(ngModel)]="messageContent"
|
||||
(focus)="onInputFocus()"
|
||||
|
||||
@@ -69,6 +69,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
readonly showKlipyGifPicker = input(false);
|
||||
readonly klipyEnabled = input(false);
|
||||
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
|
||||
readonly textareaTestId = input<string | null>(null);
|
||||
|
||||
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
|
||||
readonly typingStarted = output();
|
||||
|
||||
@@ -66,6 +66,7 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
readonly isAdmin = input(false);
|
||||
readonly bottomPadding = input(120);
|
||||
readonly conversationKey = input.required<string>();
|
||||
readonly userLookupOverrides = input<User[]>([]);
|
||||
|
||||
readonly replyRequested = output<ChatMessageReplyEvent>();
|
||||
readonly deleteRequested = output<ChatMessageDeleteEvent>();
|
||||
@@ -126,6 +127,14 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
for (const user of this.userLookupOverrides()) {
|
||||
lookup.set(user.id, user);
|
||||
|
||||
if (user.oderId && user.oderId !== user.id) {
|
||||
lookup.set(user.oderId, user);
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
});
|
||||
|
||||
|
||||
@@ -19,11 +19,10 @@
|
||||
@for (user of onlineUsers(); track user.id) {
|
||||
<div
|
||||
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
|
||||
(click)="toggleUserMenu(user.id)"
|
||||
(keydown.enter)="toggleUserMenu(user.id)"
|
||||
(keydown.space)="toggleUserMenu(user.id)"
|
||||
(keyup.enter)="toggleUserMenu(user.id)"
|
||||
(keyup.space)="toggleUserMenu(user.id)"
|
||||
[attr.data-testid]="'user-card-' + (user.oderId || user.id)"
|
||||
(click)="openDirectMessage(user)"
|
||||
(keydown.enter)="openDirectMessage(user)"
|
||||
(keydown.space)="openDirectMessage(user)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -70,6 +69,19 @@
|
||||
|
||||
<!-- Voice/Screen Status -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-card hover:text-foreground"
|
||||
[class.hidden]="isCurrentUser(user)"
|
||||
title="Message"
|
||||
(click)="$event.stopPropagation(); openDirectMessage(user)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMessageCircle"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (user.voiceState?.isSpeaking) {
|
||||
<ng-icon
|
||||
name="lucideMic"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Router } from '@angular/router';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
lucideMic,
|
||||
@@ -19,7 +20,8 @@ import {
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
lucideVolumeX,
|
||||
lucideMessageCircle
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
} from '../../../../store/users/users.selectors';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
|
||||
import { DirectMessageService } from '../../../direct-message';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
@@ -52,7 +55,8 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared'
|
||||
lucideBan,
|
||||
lucideUserX,
|
||||
lucideVolume2,
|
||||
lucideVolumeX
|
||||
lucideVolumeX,
|
||||
lucideMessageCircle
|
||||
})
|
||||
],
|
||||
templateUrl: './user-list.component.html'
|
||||
@@ -62,6 +66,8 @@ import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared'
|
||||
*/
|
||||
export class UserListComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private directMessages = inject(DirectMessageService);
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
|
||||
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
|
||||
@@ -84,6 +90,16 @@ export class UserListComponent {
|
||||
return user.id === this.currentUser()?.id;
|
||||
}
|
||||
|
||||
async openDirectMessage(user: User): Promise<void> {
|
||||
if (this.isCurrentUser(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = await this.directMessages.createConversation(user);
|
||||
|
||||
await this.router.navigate(['/dm', conversation.id]);
|
||||
}
|
||||
|
||||
/** Toggle server-side mute on a user (admin action). */
|
||||
muteUser(user: User): void {
|
||||
if (user.voiceState?.isMutedByAdmin) {
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
export * from './application/services/klipy.service';
|
||||
export * from './application/services/link-metadata.service';
|
||||
export * from './domain/rules/link-embed.rules';
|
||||
export * from './domain/rules/message.rules';
|
||||
export * from './domain/rules/message-sync.rules';
|
||||
export { ChatMarkdownService } from './feature/chat-messages/services/chat-markdown.service';
|
||||
export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component';
|
||||
export type { ChatMessageEmbedRemoveEvent } from './feature/chat-messages/models/chat-messages.model';
|
||||
export type {
|
||||
ChatMessageComposerSubmitEvent,
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
ChatMessageImageContextMenuEvent,
|
||||
ChatMessageReactionEvent,
|
||||
ChatMessageReplyEvent
|
||||
} from './feature/chat-messages/models/chat-messages.model';
|
||||
export { ChatMessageComposerComponent } from './feature/chat-messages/components/message-composer/chat-message-composer.component';
|
||||
export { ChatMessageListComponent } from './feature/chat-messages/components/message-list/chat-message-list.component';
|
||||
export { ChatMessageOverlaysComponent } from './feature/chat-messages/components/message-overlays/chat-message-overlays.component';
|
||||
export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component';
|
||||
export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component';
|
||||
export { ChatMessageMarkdownComponent } from './feature/chat-messages/components/message-item/chat-message-markdown/chat-message-markdown.component';
|
||||
export { UserListComponent } from './feature/user-list/user-list.component';
|
||||
|
||||
41
toju-app/src/app/domains/direct-message/README.md
Normal file
41
toju-app/src/app/domains/direct-message/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Direct Message Domain
|
||||
|
||||
Direct messages provide local, offline-safe one-to-one messaging over the existing WebRTC data channel.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
direct-message/
|
||||
├── application/services/ DirectMessageService, OfflineMessageQueueService, FriendService, PeerDeliveryService
|
||||
├── domain/ Direct message models and status-transition rules
|
||||
├── infrastructure/ User-scoped local repositories
|
||||
└── feature/ DM rail, chat view, message rows, user search, friend button
|
||||
```
|
||||
|
||||
## Flow
|
||||
|
||||
1. `DirectMessageService.sendMessage()` stores the message locally with `QUEUED`.
|
||||
2. `PeerDeliveryService` tries to send a `direct-message` P2P event to the recipient's current peer id.
|
||||
3. If the peer is connected, the sender advances to `SENT`; otherwise the message id remains in `OfflineMessageQueueService`.
|
||||
4. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
|
||||
5. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
|
||||
|
||||
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
|
||||
|
||||
## Chat View
|
||||
|
||||
The DM view reuses the chat domain's shared message list, composer, overlays, markdown renderer, link embeds, media players, and attachment controls. Direct-message records are mapped into the shared `Message` shape at the feature boundary so PMs keep the same date separators, replies, editing, deletion, reactions, image lightbox, audio playback, and video playback as server text channels.
|
||||
|
||||
Message edits, deletions, and reaction changes are stored locally and mirrored to the peer with `direct-message-mutation` events. Delivery state remains direct-message-owned and is exposed separately from the visible shared chat row UI.
|
||||
|
||||
## GIFs
|
||||
|
||||
The DM composer reuses the chat domain's KLIPY integration. Availability and GIF search go through the configured signal server API, and selected GIFs are sent as markdown image messages so the same proxy-fallback image rendering path is used in DMs and server chat.
|
||||
|
||||
## Avatars
|
||||
|
||||
Conversation participants keep avatar/profile metadata captured from user cards or room membership. When a PM is opened and the peer avatar is missing, the view asks the peer for the existing profile-avatar sync payload so downloaded user icons can be filled in without adding a DM-specific avatar transport.
|
||||
|
||||
## Persistence
|
||||
|
||||
Repositories are user-scoped and stored locally under `metoyou_direct_message_*` keys. The storage is intentionally domain-owned so browser and Electron runtimes share the same renderer API without changing the existing chat-message database tables.
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
advanceDirectMessageStatus,
|
||||
createDirectConversation,
|
||||
getDirectConversationId,
|
||||
updateMessageStatusInConversation,
|
||||
upsertDirectMessage
|
||||
} from '../../domain/logic/direct-message.logic';
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageParticipant
|
||||
} from '../../domain/models/direct-message.model';
|
||||
|
||||
const alice: DirectMessageParticipant = {
|
||||
userId: 'alice',
|
||||
username: 'alice',
|
||||
displayName: 'Alice'
|
||||
};
|
||||
|
||||
const bob: DirectMessageParticipant = {
|
||||
userId: 'bob',
|
||||
username: 'bob',
|
||||
displayName: 'Bob'
|
||||
};
|
||||
|
||||
describe('DirectMessageService domain flow', () => {
|
||||
it('should create conversation', () => {
|
||||
const conversation = createDirectConversation(alice, bob, 10);
|
||||
|
||||
expect(conversation.id).toBe(getDirectConversationId('alice', 'bob'));
|
||||
expect(conversation.participants).toEqual(['alice', 'bob']);
|
||||
expect(conversation.unreadCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should send message', () => {
|
||||
const conversation = createDirectConversation(alice, bob, 10);
|
||||
const queuedMessage = createMessage('message-1', 'QUEUED');
|
||||
const withQueuedMessage = upsertDirectMessage(conversation, queuedMessage, false);
|
||||
const withSentMessage = updateMessageStatusInConversation(withQueuedMessage, queuedMessage.id, 'SENT');
|
||||
|
||||
expect(withSentMessage.messages[0].status).toBe('SENT');
|
||||
});
|
||||
|
||||
it('should queue message when offline', () => {
|
||||
const conversation = createDirectConversation(alice, bob, 10);
|
||||
const queuedMessage = createMessage('message-1', 'QUEUED');
|
||||
const updatedConversation = upsertDirectMessage(conversation, queuedMessage, false);
|
||||
|
||||
expect(updatedConversation.messages[0].status).toBe('QUEUED');
|
||||
});
|
||||
|
||||
it('should update status correctly', () => {
|
||||
expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT');
|
||||
expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED');
|
||||
expect(advanceDirectMessageStatus('DELIVERED', 'SENT')).toBe('DELIVERED');
|
||||
expect(advanceDirectMessageStatus('DELIVERED', 'ACKNOWLEDGED')).toBe('ACKNOWLEDGED');
|
||||
});
|
||||
});
|
||||
|
||||
function createMessage(id: string, status: DirectMessage['status']): DirectMessage {
|
||||
return {
|
||||
id,
|
||||
conversationId: getDirectConversationId('alice', 'bob'),
|
||||
senderId: 'alice',
|
||||
recipientId: 'bob',
|
||||
content: 'Hello',
|
||||
timestamp: 20,
|
||||
status
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { DirectMessageRepository } from '../../infrastructure/direct-message.repository';
|
||||
import { OfflineMessageQueueService } from './offline-message-queue.service';
|
||||
import { PeerDeliveryService } from './peer-delivery.service';
|
||||
import {
|
||||
advanceDirectMessageStatus,
|
||||
createDirectConversation,
|
||||
getDirectConversationId,
|
||||
updateMessageStatusInConversation,
|
||||
upsertDirectMessage
|
||||
} from '../../domain/logic/direct-message.logic';
|
||||
import {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
DirectMessageEventPayload,
|
||||
DirectMessageMutationEventPayload,
|
||||
DirectMessageStatus,
|
||||
DirectMessageStatusEventPayload,
|
||||
toDirectMessageParticipant
|
||||
} from '../../domain/models/direct-message.model';
|
||||
import type {
|
||||
ChatEvent,
|
||||
Reaction,
|
||||
User
|
||||
} from '../../../../shared-kernel';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DirectMessageService {
|
||||
private readonly repository = inject(DirectMessageRepository);
|
||||
private readonly offlineQueue = inject(OfflineMessageQueueService);
|
||||
private readonly delivery = inject(PeerDeliveryService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly router = inject(Router);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly conversationsSignal = signal<DirectMessageConversation[]>([]);
|
||||
private readonly selectedConversationIdSignal = signal<string | null>(null);
|
||||
private loadedOwnerId: string | null = null;
|
||||
|
||||
readonly conversations = computed(() => [...this.conversationsSignal()].sort(
|
||||
(firstConversation, secondConversation) => secondConversation.lastMessageAt - firstConversation.lastMessageAt
|
||||
));
|
||||
readonly selectedConversationId = this.selectedConversationIdSignal.asReadonly();
|
||||
readonly selectedConversation = computed(() => {
|
||||
const selectedId = this.selectedConversationIdSignal();
|
||||
|
||||
return selectedId
|
||||
? this.conversationsSignal().find((conversation) => conversation.id === selectedId) ?? null
|
||||
: null;
|
||||
});
|
||||
readonly totalUnreadCount = computed(() => this.conversationsSignal().reduce(
|
||||
(total, conversation) => total + conversation.unreadCount,
|
||||
0
|
||||
));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const ownerId = this.getCurrentUserId();
|
||||
|
||||
void this.loadForOwner(ownerId);
|
||||
});
|
||||
|
||||
this.delivery.directMessageEvents$.subscribe((event) => {
|
||||
void this.handlePeerEvent(event);
|
||||
});
|
||||
|
||||
this.delivery.peerConnected$.subscribe(() => {
|
||||
void this.retryPending();
|
||||
});
|
||||
|
||||
this.delivery.networkRestored$.subscribe(() => {
|
||||
void this.retryPending();
|
||||
});
|
||||
}
|
||||
|
||||
async createConversation(user: User): Promise<DirectMessageConversation> {
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const peerParticipant = toDirectMessageParticipant(user);
|
||||
const conversationId = getDirectConversationId(currentParticipant.userId, peerParticipant.userId);
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId);
|
||||
|
||||
if (existingConversation) {
|
||||
this.selectedConversationIdSignal.set(existingConversation.id);
|
||||
return existingConversation;
|
||||
}
|
||||
|
||||
const conversation = createDirectConversation(currentParticipant, peerParticipant, Date.now());
|
||||
|
||||
await this.persistConversation(ownerId, conversation);
|
||||
this.selectedConversationIdSignal.set(conversation.id);
|
||||
return conversation;
|
||||
}
|
||||
|
||||
async openConversation(conversationId: string): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
this.selectedConversationIdSignal.set(conversationId);
|
||||
await this.markRead(conversationId);
|
||||
}
|
||||
|
||||
closeConversationView(conversationId?: string | null): void {
|
||||
if (!conversationId || this.selectedConversationIdSignal() === conversationId) {
|
||||
this.selectedConversationIdSignal.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async forgetConversation(conversationId: string): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.repository.getConversation(ownerId, conversationId);
|
||||
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.repository.deleteConversation(ownerId, conversationId);
|
||||
|
||||
for (const message of conversation.messages) {
|
||||
await this.offlineQueue.markDelivered(ownerId, message.id);
|
||||
}
|
||||
|
||||
this.conversationsSignal.update((conversations) => conversations.filter((entry) => entry.id !== conversationId));
|
||||
|
||||
if (this.selectedConversationIdSignal() === conversationId) {
|
||||
this.selectedConversationIdSignal.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(conversationId: string, content: string, replyToId?: string): Promise<DirectMessage> {
|
||||
const normalizedContent = content.trim();
|
||||
|
||||
if (!normalizedContent) {
|
||||
throw new Error('Cannot send an empty direct message.');
|
||||
}
|
||||
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.requireConversation(ownerId, conversationId);
|
||||
const senderId = currentUser.oderId || currentUser.id;
|
||||
const recipientId = conversation.participants.find((participantId) => participantId !== senderId);
|
||||
|
||||
if (!recipientId) {
|
||||
throw new Error('Direct message conversation has no recipient.');
|
||||
}
|
||||
|
||||
const message: DirectMessage = {
|
||||
id: uuidv4(),
|
||||
conversationId,
|
||||
senderId,
|
||||
recipientId,
|
||||
content: normalizedContent,
|
||||
timestamp: Date.now(),
|
||||
status: 'QUEUED',
|
||||
reactions: [],
|
||||
isDeleted: false,
|
||||
replyToId
|
||||
};
|
||||
|
||||
await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
|
||||
await this.attemptDelivery(ownerId, message);
|
||||
return message;
|
||||
}
|
||||
|
||||
async editMessage(conversationId: string, messageId: string, content: string): Promise<void> {
|
||||
const normalizedContent = content.trim();
|
||||
|
||||
if (!normalizedContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.applyAndSendMutation(conversationId, {
|
||||
conversationId,
|
||||
messageId,
|
||||
type: 'edit',
|
||||
content: normalizedContent,
|
||||
editedAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMessage(conversationId: string, messageId: string): Promise<void> {
|
||||
await this.applyAndSendMutation(conversationId, {
|
||||
conversationId,
|
||||
messageId,
|
||||
type: 'delete',
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
async addReaction(conversationId: string, messageId: string, emoji: string): Promise<void> {
|
||||
const userId = this.getCurrentUserIdOrThrow();
|
||||
const reaction: Reaction = {
|
||||
id: uuidv4(),
|
||||
messageId,
|
||||
oderId: userId,
|
||||
userId,
|
||||
emoji,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await this.applyAndSendMutation(conversationId, {
|
||||
conversationId,
|
||||
messageId,
|
||||
type: 'reaction-add',
|
||||
reaction,
|
||||
updatedAt: reaction.timestamp
|
||||
});
|
||||
}
|
||||
|
||||
async toggleReaction(conversationId: string, messageId: string, emoji: string): Promise<void> {
|
||||
const userId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.requireConversation(userId, conversationId);
|
||||
const message = conversation.messages.find((entry) => entry.id === messageId);
|
||||
const existingReaction = message?.reactions?.find((reaction) =>
|
||||
reaction.emoji === emoji && (reaction.userId === userId || reaction.oderId === userId)
|
||||
);
|
||||
|
||||
if (existingReaction) {
|
||||
await this.applyAndSendMutation(conversationId, {
|
||||
conversationId,
|
||||
messageId,
|
||||
type: 'reaction-remove',
|
||||
oderId: userId,
|
||||
emoji,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.addReaction(conversationId, messageId, emoji);
|
||||
}
|
||||
|
||||
requestPeerAvatarSync(conversationId: string): void {
|
||||
const currentUserId = this.getCurrentUserId();
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId);
|
||||
const peerId = conversation?.participants.find((participantId) => participantId !== currentUserId);
|
||||
|
||||
if (peerId) {
|
||||
this.delivery.requestUserAvatar(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
currentUserId(): string | null {
|
||||
return this.getCurrentUserId();
|
||||
}
|
||||
|
||||
async updateStatus(messageId: string, status: DirectMessageStatus): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.messages.some((message) => message.id === messageId));
|
||||
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.persistConversation(ownerId, updateMessageStatusInConversation(conversation, messageId, status));
|
||||
}
|
||||
|
||||
async receiveMessage(message: DirectMessage, sender: User): Promise<void> {
|
||||
await this.handleIncomingMessage({
|
||||
message,
|
||||
sender: toDirectMessageParticipant(sender)
|
||||
});
|
||||
}
|
||||
|
||||
async markRead(conversationId: string): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUserId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.requireConversation(ownerId, conversationId);
|
||||
const updatedConversation = { ...conversation, unreadCount: 0 };
|
||||
|
||||
await this.persistConversation(ownerId, updatedConversation);
|
||||
await this.repository.markRead(ownerId, conversationId);
|
||||
|
||||
for (const message of updatedConversation.messages) {
|
||||
if (message.recipientId !== currentUserId || message.status === 'ACKNOWLEDGED') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextStatus = advanceDirectMessageStatus(message.status, 'ACKNOWLEDGED');
|
||||
|
||||
if (nextStatus !== message.status) {
|
||||
await this.persistConversation(ownerId, updateMessageStatusInConversation(updatedConversation, message.id, nextStatus));
|
||||
}
|
||||
|
||||
this.sendStatusUpdate(message.senderId, {
|
||||
conversationId,
|
||||
messageId: message.id,
|
||||
status: 'ACKNOWLEDGED',
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async retryPending(): Promise<void> {
|
||||
const ownerId = this.getCurrentUserId();
|
||||
|
||||
if (!ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const pendingMessageIds = await this.offlineQueue.retryPending(ownerId);
|
||||
const messages = this.conversationsSignal().flatMap((conversation) => conversation.messages);
|
||||
|
||||
for (const messageId of pendingMessageIds) {
|
||||
const message = messages.find((entry) => entry.id === messageId);
|
||||
|
||||
if (message) {
|
||||
await this.attemptDelivery(ownerId, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePeerEvent(event: ChatEvent): Promise<void> {
|
||||
if (event.type === 'direct-message' && event.directMessage) {
|
||||
await this.handleIncomingMessage(event.directMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'direct-message-status' && event.directMessageStatus) {
|
||||
await this.handleIncomingStatus(event.directMessageStatus);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'direct-message-mutation' && event.directMessageMutation) {
|
||||
await this.handleIncomingMutation(event.directMessageMutation);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const currentParticipant = toDirectMessageParticipant(currentUser);
|
||||
const sender = payload.sender;
|
||||
const conversationId = payload.message.conversationId
|
||||
|| getDirectConversationId(currentParticipant.userId, sender.userId);
|
||||
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
|
||||
?? createDirectConversation(currentParticipant, sender, payload.message.timestamp);
|
||||
const incomingMessage: DirectMessage = {
|
||||
...payload.message,
|
||||
conversationId,
|
||||
status: advanceDirectMessageStatus(payload.message.status, 'DELIVERED')
|
||||
};
|
||||
const shouldIncrementUnread = !this.isConversationVisible(conversationId);
|
||||
|
||||
await this.persistConversation(ownerId, upsertDirectMessage(existingConversation, incomingMessage, shouldIncrementUnread));
|
||||
this.sendStatusUpdate(incomingMessage.senderId, {
|
||||
conversationId,
|
||||
messageId: incomingMessage.id,
|
||||
status: 'DELIVERED',
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
if (!shouldIncrementUnread) {
|
||||
await this.markRead(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingStatus(payload: DirectMessageStatusEventPayload): Promise<void> {
|
||||
await this.updateStatus(payload.messageId, payload.status);
|
||||
|
||||
if (payload.status === 'DELIVERED' || payload.status === 'ACKNOWLEDGED') {
|
||||
await this.offlineQueue.markDelivered(this.getCurrentUserIdOrThrow(), payload.messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private isConversationVisible(conversationId: string): boolean {
|
||||
const currentUrl = this.router.url.split(/[?#]/, 1)[0];
|
||||
|
||||
if (!currentUrl.startsWith('/dm/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(currentUrl.slice('/dm/'.length)) === conversationId;
|
||||
} catch {
|
||||
return currentUrl.slice('/dm/'.length) === conversationId;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.requireConversation(ownerId, payload.conversationId);
|
||||
|
||||
await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
|
||||
}
|
||||
|
||||
private async applyAndSendMutation(
|
||||
conversationId: string,
|
||||
payload: DirectMessageMutationEventPayload
|
||||
): Promise<void> {
|
||||
const ownerId = this.getCurrentUserIdOrThrow();
|
||||
const conversation = await this.requireConversation(ownerId, conversationId);
|
||||
const updatedConversation = this.applyMutation(conversation, payload);
|
||||
const recipientId = conversation.participants.find((participantId) => participantId !== ownerId);
|
||||
|
||||
await this.persistConversation(ownerId, updatedConversation);
|
||||
|
||||
if (recipientId) {
|
||||
this.delivery.sendViaWebRTC(recipientId, {
|
||||
type: 'direct-message-mutation',
|
||||
directMessageMutation: payload
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private applyMutation(
|
||||
conversation: DirectMessageConversation,
|
||||
payload: DirectMessageMutationEventPayload
|
||||
): DirectMessageConversation {
|
||||
const messages = conversation.messages.map((message) => {
|
||||
if (message.id !== payload.messageId) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (payload.type === 'edit' && payload.content) {
|
||||
return {
|
||||
...message,
|
||||
content: payload.content,
|
||||
editedAt: payload.editedAt ?? payload.updatedAt,
|
||||
isDeleted: false
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === 'delete') {
|
||||
return {
|
||||
...message,
|
||||
content: '',
|
||||
isDeleted: true,
|
||||
editedAt: payload.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === 'reaction-add' && payload.reaction) {
|
||||
const reactions = (message.reactions ?? []).filter((reaction) =>
|
||||
!(reaction.emoji === payload.reaction?.emoji && reaction.userId === payload.reaction.userId)
|
||||
);
|
||||
|
||||
return {
|
||||
...message,
|
||||
reactions: [...reactions, payload.reaction]
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === 'reaction-remove' && payload.oderId && payload.emoji) {
|
||||
return {
|
||||
...message,
|
||||
reactions: (message.reactions ?? []).filter((reaction) =>
|
||||
!(reaction.emoji === payload.emoji && (reaction.userId === payload.oderId || reaction.oderId === payload.oderId))
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return message;
|
||||
});
|
||||
|
||||
return { ...conversation, messages };
|
||||
}
|
||||
|
||||
private async attemptDelivery(ownerId: string, message: DirectMessage): Promise<void> {
|
||||
const currentUser = this.requireCurrentUser();
|
||||
const sent = this.delivery.sendViaWebRTC(message.recipientId, {
|
||||
type: 'direct-message',
|
||||
directMessage: {
|
||||
message,
|
||||
sender: toDirectMessageParticipant(currentUser)
|
||||
}
|
||||
});
|
||||
|
||||
if (!sent) {
|
||||
await this.offlineQueue.enqueue(ownerId, message.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.offlineQueue.markDelivered(ownerId, message.id);
|
||||
await this.updateStatus(message.id, 'SENT');
|
||||
}
|
||||
|
||||
private sendStatusUpdate(recipientId: string, payload: DirectMessageStatusEventPayload): void {
|
||||
this.delivery.handleAck(recipientId, {
|
||||
type: 'direct-message-status',
|
||||
directMessageStatus: payload
|
||||
});
|
||||
}
|
||||
|
||||
private async loadForOwner(ownerId: string | null): Promise<void> {
|
||||
if (!ownerId) {
|
||||
this.loadedOwnerId = null;
|
||||
this.conversationsSignal.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.loadedOwnerId === ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadedOwnerId = ownerId;
|
||||
this.conversationsSignal.set(await this.repository.loadConversations(ownerId));
|
||||
}
|
||||
|
||||
private async persistConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
|
||||
await this.repository.saveConversation(ownerId, conversation);
|
||||
this.conversationsSignal.update((conversations) => {
|
||||
const nextConversations = conversations.filter((entry) => entry.id !== conversation.id);
|
||||
|
||||
nextConversations.push(conversation);
|
||||
return nextConversations;
|
||||
});
|
||||
}
|
||||
|
||||
private async requireConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation> {
|
||||
await this.loadForOwner(ownerId);
|
||||
|
||||
const conversation = this.conversationsSignal().find((entry) => entry.id === conversationId)
|
||||
?? await this.repository.getConversation(ownerId, conversationId);
|
||||
|
||||
if (!conversation) {
|
||||
throw new Error('Direct message conversation not found.');
|
||||
}
|
||||
|
||||
return conversation;
|
||||
}
|
||||
|
||||
private requireCurrentUser(): User {
|
||||
const currentUser = this.currentUser();
|
||||
|
||||
if (!currentUser) {
|
||||
throw new Error('Cannot use direct messages without a current user.');
|
||||
}
|
||||
|
||||
return currentUser;
|
||||
}
|
||||
|
||||
private getCurrentUserId(): string | null {
|
||||
const user = this.currentUser();
|
||||
|
||||
return user?.oderId || user?.id || null;
|
||||
}
|
||||
|
||||
private getCurrentUserIdOrThrow(): string {
|
||||
const ownerId = this.getCurrentUserId();
|
||||
|
||||
if (!ownerId) {
|
||||
throw new Error('Cannot use direct messages without a current user.');
|
||||
}
|
||||
|
||||
return ownerId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FriendRepository } from '../../infrastructure/friend.repository';
|
||||
|
||||
describe('FriendService storage contract', () => {
|
||||
let repository: FriendRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
installLocalStorageMock();
|
||||
repository = new FriendRepository();
|
||||
});
|
||||
|
||||
it('should add friend', async () => {
|
||||
await repository.addFriend('alice', { userId: 'bob', addedAt: 10 });
|
||||
|
||||
expect(await repository.loadFriends('alice')).toEqual([{ userId: 'bob', addedAt: 10 }]);
|
||||
});
|
||||
|
||||
it('should remove friend', async () => {
|
||||
await repository.addFriend('alice', { userId: 'bob', addedAt: 10 });
|
||||
await repository.removeFriend('alice', 'bob');
|
||||
|
||||
expect(await repository.loadFriends('alice')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should persist friends', async () => {
|
||||
await repository.addFriend('alice', { userId: 'bob', addedAt: 10 });
|
||||
const reloadedRepository = new FriendRepository();
|
||||
|
||||
expect(await reloadedRepository.loadFriends('alice')).toEqual([{ userId: 'bob', addedAt: 10 }]);
|
||||
});
|
||||
});
|
||||
|
||||
function installLocalStorageMock(): void {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
clear: () => values.clear()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { FriendRepository } from '../../infrastructure/friend.repository';
|
||||
import type { Friend } from '../../domain/models/direct-message.model';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FriendService {
|
||||
private readonly repository = inject(FriendRepository);
|
||||
private readonly store = inject(Store);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly friendsSignal = signal<Friend[]>([]);
|
||||
private loadedOwnerId: string | null = null;
|
||||
|
||||
readonly friends = this.friendsSignal.asReadonly();
|
||||
readonly friendIds = computed(() => new Set(this.friendsSignal().map((friend) => friend.userId)));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const ownerId = this.currentUser()?.oderId || this.currentUser()?.id || null;
|
||||
|
||||
void this.loadForOwner(ownerId);
|
||||
});
|
||||
}
|
||||
|
||||
async addFriend(userId: string): Promise<void> {
|
||||
const ownerId = await this.requireOwnerId();
|
||||
const friend: Friend = { userId, addedAt: Date.now() };
|
||||
|
||||
await this.repository.addFriend(ownerId, friend);
|
||||
await this.loadForOwner(ownerId, true);
|
||||
}
|
||||
|
||||
async removeFriend(userId: string): Promise<void> {
|
||||
const ownerId = await this.requireOwnerId();
|
||||
|
||||
await this.repository.removeFriend(ownerId, userId);
|
||||
await this.loadForOwner(ownerId, true);
|
||||
}
|
||||
|
||||
isFriend(userId: string): boolean {
|
||||
return this.friendIds().has(userId);
|
||||
}
|
||||
|
||||
async toggleFriend(userId: string): Promise<void> {
|
||||
if (this.isFriend(userId)) {
|
||||
await this.removeFriend(userId);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.addFriend(userId);
|
||||
}
|
||||
|
||||
private async loadForOwner(ownerId: string | null, force = false): Promise<void> {
|
||||
if (!ownerId) {
|
||||
this.loadedOwnerId = null;
|
||||
this.friendsSignal.set([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!force && this.loadedOwnerId === ownerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadedOwnerId = ownerId;
|
||||
this.friendsSignal.set(await this.repository.loadFriends(ownerId));
|
||||
}
|
||||
|
||||
private async requireOwnerId(): Promise<string> {
|
||||
const ownerId = this.currentUser()?.oderId || this.currentUser()?.id;
|
||||
|
||||
if (!ownerId) {
|
||||
throw new Error('Cannot manage friends without a current user.');
|
||||
}
|
||||
|
||||
await this.loadForOwner(ownerId);
|
||||
return ownerId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { OfflineQueueRepository } from '../../infrastructure/offline-queue.repository';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OfflineMessageQueueService {
|
||||
private readonly repository = inject(OfflineQueueRepository);
|
||||
|
||||
enqueue(ownerId: string, messageId: string): Promise<void> {
|
||||
return this.repository.enqueue(ownerId, messageId);
|
||||
}
|
||||
|
||||
retryPending(ownerId: string): Promise<string[]> {
|
||||
return this.repository.load(ownerId);
|
||||
}
|
||||
|
||||
markDelivered(ownerId: string, messageId: string): Promise<void> {
|
||||
return this.repository.remove(ownerId, messageId);
|
||||
}
|
||||
|
||||
clear(ownerId: string): Promise<void> {
|
||||
return this.repository.clear(ownerId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { OfflineQueueRepository } from '../../infrastructure/offline-queue.repository';
|
||||
|
||||
describe('OfflineMessageQueueService storage contract', () => {
|
||||
let repository: OfflineQueueRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
installLocalStorageMock();
|
||||
repository = new OfflineQueueRepository();
|
||||
});
|
||||
|
||||
it('should enqueue messages', async () => {
|
||||
await repository.enqueue('alice', 'message-1');
|
||||
await repository.enqueue('alice', 'message-1');
|
||||
|
||||
expect(await repository.load('alice')).toEqual(['message-1']);
|
||||
});
|
||||
|
||||
it('should retry on reconnect', async () => {
|
||||
await repository.enqueue('alice', 'message-1');
|
||||
await repository.enqueue('alice', 'message-2');
|
||||
|
||||
expect(await repository.load('alice')).toEqual(['message-1', 'message-2']);
|
||||
});
|
||||
|
||||
it('should clear delivered messages', async () => {
|
||||
await repository.enqueue('alice', 'message-1');
|
||||
await repository.remove('alice', 'message-1');
|
||||
|
||||
expect(await repository.load('alice')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function installLocalStorageMock(): void {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
clear: () => values.clear()
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/* eslint-disable @typescript-eslint/member-ordering */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import {
|
||||
Subject,
|
||||
filter,
|
||||
type Observable
|
||||
} from 'rxjs';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||
import type { ChatEvent, User } from '../../../../shared-kernel';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PeerDeliveryService {
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly store = inject(Store);
|
||||
private readonly users = this.store.selectSignal(selectAllUsers);
|
||||
private readonly networkRestoredSubject = new Subject<void>();
|
||||
|
||||
readonly directMessageEvents$: Observable<ChatEvent> = this.webrtc.onMessageReceived.pipe(
|
||||
filter((event) => event.type === 'direct-message' || event.type === 'direct-message-status' || event.type === 'direct-message-mutation')
|
||||
);
|
||||
|
||||
readonly peerConnected$ = this.webrtc.onPeerConnected;
|
||||
readonly networkRestored$ = this.networkRestoredSubject.asObservable();
|
||||
|
||||
constructor() {
|
||||
this.installNetworkTestHooks();
|
||||
}
|
||||
|
||||
sendViaWebRTC(recipientId: string, event: ChatEvent): boolean {
|
||||
if (this.isOfflineOverrideEnabled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const peerId = this.resolvePeerId(recipientId);
|
||||
|
||||
if (!peerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.webrtc.sendToPeer(peerId, event);
|
||||
return true;
|
||||
}
|
||||
|
||||
handleAck(recipientId: string, event: ChatEvent): boolean {
|
||||
return this.sendViaWebRTC(recipientId, event);
|
||||
}
|
||||
|
||||
requestUserAvatar(recipientId: string): boolean {
|
||||
return this.sendViaWebRTC(recipientId, {
|
||||
type: 'user-avatar-request',
|
||||
oderId: recipientId
|
||||
});
|
||||
}
|
||||
|
||||
syncOnReconnect(onReconnect: () => void): void {
|
||||
this.peerConnected$.subscribe(() => onReconnect());
|
||||
}
|
||||
|
||||
private resolvePeerId(recipientId: string): string | null {
|
||||
const connectedPeerIds = new Set(this.webrtc.getConnectedPeers());
|
||||
|
||||
if (connectedPeerIds.has(recipientId)) {
|
||||
return recipientId;
|
||||
}
|
||||
|
||||
const user = this.users().find((candidate: User) =>
|
||||
candidate.id === recipientId || candidate.oderId === recipientId || candidate.peerId === recipientId
|
||||
);
|
||||
const candidates = [
|
||||
user?.oderId,
|
||||
user?.peerId,
|
||||
user?.id
|
||||
].filter((candidate): candidate is string => !!candidate);
|
||||
|
||||
return candidates.find((candidate) => connectedPeerIds.has(candidate)) ?? null;
|
||||
}
|
||||
|
||||
private isOfflineOverrideEnabled(): boolean {
|
||||
return typeof window !== 'undefined'
|
||||
&& !!(window as Window & { metoyouDmNetworkOffline?: boolean }).metoyouDmNetworkOffline;
|
||||
}
|
||||
|
||||
private installNetworkTestHooks(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const testWindow = window as Window & {
|
||||
simulateOffline?: () => void;
|
||||
simulateOnline?: () => void;
|
||||
metoyouDmNetworkOffline?: boolean;
|
||||
};
|
||||
|
||||
testWindow.simulateOffline = () => {
|
||||
testWindow.metoyouDmNetworkOffline = true;
|
||||
};
|
||||
|
||||
testWindow.simulateOnline = () => {
|
||||
testWindow.metoyouDmNetworkOffline = false;
|
||||
this.networkRestoredSubject.next();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type {
|
||||
DirectMessage,
|
||||
DirectMessageConversation,
|
||||
DirectMessageParticipant,
|
||||
DirectMessageStatus
|
||||
} from '../models/direct-message.model';
|
||||
|
||||
const STATUS_ORDER: Record<DirectMessageStatus, number> = {
|
||||
QUEUED: 0,
|
||||
SENT: 1,
|
||||
DELIVERED: 2,
|
||||
ACKNOWLEDGED: 3
|
||||
};
|
||||
|
||||
export function getDirectConversationId(firstUserId: string, secondUserId: string): string {
|
||||
return `dm-${[firstUserId, secondUserId]
|
||||
.map((userId) => encodeURIComponent(userId.trim()))
|
||||
.sort()
|
||||
.join('--')}`;
|
||||
}
|
||||
|
||||
export function advanceDirectMessageStatus(
|
||||
currentStatus: DirectMessageStatus,
|
||||
incomingStatus: DirectMessageStatus
|
||||
): DirectMessageStatus {
|
||||
return STATUS_ORDER[incomingStatus] > STATUS_ORDER[currentStatus]
|
||||
? incomingStatus
|
||||
: currentStatus;
|
||||
}
|
||||
|
||||
export function createDirectConversation(
|
||||
currentUser: DirectMessageParticipant,
|
||||
peer: DirectMessageParticipant,
|
||||
now: number
|
||||
): DirectMessageConversation {
|
||||
const participants = [currentUser.userId, peer.userId].sort();
|
||||
|
||||
return {
|
||||
id: getDirectConversationId(currentUser.userId, peer.userId),
|
||||
participants,
|
||||
participantProfiles: {
|
||||
[currentUser.userId]: currentUser,
|
||||
[peer.userId]: peer
|
||||
},
|
||||
messages: [],
|
||||
lastMessageAt: now,
|
||||
unreadCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertDirectMessage(
|
||||
conversation: DirectMessageConversation,
|
||||
message: DirectMessage,
|
||||
incrementUnread: boolean
|
||||
): DirectMessageConversation {
|
||||
const existingIndex = conversation.messages.findIndex((entry) => entry.id === message.id);
|
||||
const messages = [...conversation.messages];
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const existing = messages[existingIndex];
|
||||
|
||||
messages[existingIndex] = {
|
||||
...existing,
|
||||
...message,
|
||||
status: advanceDirectMessageStatus(existing.status, message.status)
|
||||
};
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
|
||||
messages.sort((firstMessage, secondMessage) => firstMessage.timestamp - secondMessage.timestamp);
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
messages,
|
||||
lastMessageAt: Math.max(conversation.lastMessageAt, message.timestamp),
|
||||
unreadCount: incrementUnread ? conversation.unreadCount + 1 : conversation.unreadCount
|
||||
};
|
||||
}
|
||||
|
||||
export function updateMessageStatusInConversation(
|
||||
conversation: DirectMessageConversation,
|
||||
messageId: string,
|
||||
status: DirectMessageStatus
|
||||
): DirectMessageConversation {
|
||||
const messages = conversation.messages.map((message) => message.id === messageId
|
||||
? { ...message, status: advanceDirectMessageStatus(message.status, status) }
|
||||
: message);
|
||||
|
||||
return { ...conversation, messages };
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import type { DirectMessage, DirectMessageParticipant } from '../../../../shared-kernel';
|
||||
|
||||
export type {
|
||||
DirectMessage,
|
||||
DirectMessageEventPayload,
|
||||
DirectMessageMutationEventPayload,
|
||||
DirectMessageParticipant,
|
||||
DirectMessageStatus,
|
||||
DirectMessageStatusEventPayload
|
||||
} from '../../../../shared-kernel';
|
||||
|
||||
export interface DirectMessageConversation {
|
||||
id: string;
|
||||
participants: string[];
|
||||
participantProfiles: Record<string, DirectMessageParticipant>;
|
||||
messages: DirectMessage[];
|
||||
lastMessageAt: number;
|
||||
unreadCount: number;
|
||||
}
|
||||
|
||||
export interface Friend {
|
||||
userId: string;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export function toDirectMessageParticipant(user: User): DirectMessageParticipant {
|
||||
return {
|
||||
userId: user.oderId || user.id,
|
||||
username: user.username,
|
||||
displayName: user.displayName || user.username,
|
||||
description: user.description,
|
||||
avatarUrl: user.avatarUrl,
|
||||
avatarHash: user.avatarHash,
|
||||
avatarMime: user.avatarMime,
|
||||
avatarUpdatedAt: user.avatarUpdatedAt,
|
||||
profileUpdatedAt: user.profileUpdatedAt
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
6
toju-app/src/app/domains/direct-message/index.ts
Normal file
6
toju-app/src/app/domains/direct-message/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './application/services/direct-message.service';
|
||||
export * from './application/services/friend.service';
|
||||
export * from './application/services/offline-message-queue.service';
|
||||
export * from './application/services/peer-delivery.service';
|
||||
export * from './domain/models/direct-message.model';
|
||||
export * from './domain/logic/direct-message.logic';
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type { DirectMessageConversation } from '../domain/models/direct-message.model';
|
||||
|
||||
const STORAGE_PREFIX = 'metoyou_direct_message_conversations';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DirectMessageRepository {
|
||||
async loadConversations(ownerId: string): Promise<DirectMessageConversation[]> {
|
||||
return this.read(ownerId);
|
||||
}
|
||||
|
||||
async saveConversation(ownerId: string, conversation: DirectMessageConversation): Promise<void> {
|
||||
const conversations = this.read(ownerId).filter((entry) => entry.id !== conversation.id);
|
||||
|
||||
conversations.push(conversation);
|
||||
this.write(ownerId, conversations);
|
||||
}
|
||||
|
||||
async getConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation | null> {
|
||||
return this.read(ownerId).find((conversation) => conversation.id === conversationId) ?? null;
|
||||
}
|
||||
|
||||
async deleteConversation(ownerId: string, conversationId: string): Promise<void> {
|
||||
this.write(ownerId, this.read(ownerId).filter((conversation) => conversation.id !== conversationId));
|
||||
}
|
||||
|
||||
async markRead(ownerId: string, conversationId: string): Promise<void> {
|
||||
const conversations = this.read(ownerId).map((conversation) =>
|
||||
conversation.id === conversationId ? { ...conversation, unreadCount: 0 } : conversation
|
||||
);
|
||||
|
||||
this.write(ownerId, conversations);
|
||||
}
|
||||
|
||||
private read(ownerId: string): DirectMessageConversation[] {
|
||||
const rawValue = localStorage.getItem(this.key(ownerId));
|
||||
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as DirectMessageConversation[];
|
||||
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private write(ownerId: string, conversations: DirectMessageConversation[]): void {
|
||||
localStorage.setItem(this.key(ownerId), JSON.stringify(conversations));
|
||||
}
|
||||
|
||||
private key(ownerId: string): string {
|
||||
return `${STORAGE_PREFIX}:${ownerId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type { Friend } from '../domain/models/direct-message.model';
|
||||
|
||||
const STORAGE_PREFIX = 'metoyou_friends';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FriendRepository {
|
||||
async loadFriends(ownerId: string): Promise<Friend[]> {
|
||||
return this.read(ownerId);
|
||||
}
|
||||
|
||||
async addFriend(ownerId: string, friend: Friend): Promise<void> {
|
||||
const friends = this.read(ownerId).filter((entry) => entry.userId !== friend.userId);
|
||||
|
||||
friends.push(friend);
|
||||
this.write(ownerId, friends);
|
||||
}
|
||||
|
||||
async removeFriend(ownerId: string, userId: string): Promise<void> {
|
||||
this.write(
|
||||
ownerId,
|
||||
this.read(ownerId).filter((entry) => entry.userId !== userId)
|
||||
);
|
||||
}
|
||||
|
||||
private read(ownerId: string): Friend[] {
|
||||
const rawValue = localStorage.getItem(this.key(ownerId));
|
||||
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as Friend[];
|
||||
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private write(ownerId: string, friends: Friend[]): void {
|
||||
localStorage.setItem(this.key(ownerId), JSON.stringify(friends));
|
||||
}
|
||||
|
||||
private key(ownerId: string): string {
|
||||
return `${STORAGE_PREFIX}:${ownerId}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const STORAGE_PREFIX = 'metoyou_direct_message_queue';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OfflineQueueRepository {
|
||||
async load(ownerId: string): Promise<string[]> {
|
||||
return this.read(ownerId);
|
||||
}
|
||||
|
||||
async enqueue(ownerId: string, messageId: string): Promise<void> {
|
||||
this.write(ownerId, Array.from(new Set([...this.read(ownerId), messageId])));
|
||||
}
|
||||
|
||||
async remove(ownerId: string, messageId: string): Promise<void> {
|
||||
this.write(
|
||||
ownerId,
|
||||
this.read(ownerId).filter((entry) => entry !== messageId)
|
||||
);
|
||||
}
|
||||
|
||||
async clear(ownerId: string): Promise<void> {
|
||||
this.write(ownerId, []);
|
||||
}
|
||||
|
||||
private read(ownerId: string): string[] {
|
||||
const rawValue = localStorage.getItem(this.key(ownerId));
|
||||
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue) as string[];
|
||||
|
||||
return Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private write(ownerId: string, messageIds: string[]): void {
|
||||
localStorage.setItem(this.key(ownerId), JSON.stringify(messageIds));
|
||||
}
|
||||
|
||||
private key(ownerId: string): string {
|
||||
return `${STORAGE_PREFIX}:${ownerId}`;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,57 @@
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- My Servers -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<h3 class="font-semibold text-foreground mb-2">My Servers</h3>
|
||||
@if (savedRooms().length === 0) {
|
||||
<p class="text-sm text-muted-foreground">No joined servers yet</p>
|
||||
} @else {
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="border-b border-border px-3 py-3">
|
||||
<div class="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<div class="relative min-w-0 flex-1">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Search people and servers"
|
||||
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Search servers and users..."
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Create New Server"
|
||||
class="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
(click)="openCreateDialog()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-10 w-10 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80"
|
||||
title="Settings"
|
||||
(click)="openSettings()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="h-5 w-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (savedRooms().length > 0) {
|
||||
<div class="mt-2 flex items-center gap-2 overflow-x-auto pb-1">
|
||||
<span class="shrink-0 text-xs font-medium text-muted-foreground">My Servers</span>
|
||||
@for (room of savedRooms(); track room.id) {
|
||||
<button
|
||||
(click)="joinSavedRoom(room)"
|
||||
type="button"
|
||||
class="px-3 py-1.5 text-xs rounded-full bg-secondary hover:bg-secondary/80 border border-border text-foreground"
|
||||
class="shrink-0 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-secondary"
|
||||
(click)="joinSavedRoom(room)"
|
||||
>
|
||||
{{ room.name }}
|
||||
</button>
|
||||
@@ -18,161 +59,170 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Search Header -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground w-4 h-4"
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 overflow-hidden lg:grid-cols-[minmax(300px,380px)_1fr]">
|
||||
<app-user-search-list
|
||||
class="min-h-0 overflow-y-auto border-b border-border lg:border-b-0 lg:border-r"
|
||||
[searchQuery]="searchQuery"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
placeholder="Search servers..."
|
||||
class="w-full pl-10 pr-4 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
(click)="openSettings()"
|
||||
type="button"
|
||||
class="grid h-9 w-9 place-items-center rounded-lg border border-border bg-secondary transition-colors hover:bg-secondary/80"
|
||||
title="Settings"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideSettings"
|
||||
class="w-5 h-5 text-muted-foreground"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<section class="min-h-0 overflow-y-auto">
|
||||
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">Servers</h3>
|
||||
<p class="text-xs text-muted-foreground">{{ searchResults().length }} found</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Server Button -->
|
||||
<div class="p-4 border-b border-border">
|
||||
<button
|
||||
(click)="openCreateDialog()"
|
||||
type="button"
|
||||
class="w-full flex items-center justify-center gap-2 px-4 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucidePlus"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
Create New Server
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@if (isSearching()) {
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
</div>
|
||||
} @else if (searchResults().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-muted-foreground">
|
||||
<ng-icon
|
||||
name="lucideSearch"
|
||||
class="w-12 h-12 mb-4 opacity-50"
|
||||
class="mb-3 h-10 w-10 opacity-50"
|
||||
/>
|
||||
<p class="text-lg">No servers found</p>
|
||||
<p class="text-sm">Try a different search or create your own</p>
|
||||
<p class="text-sm font-medium">No servers found</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="p-4 space-y-3">
|
||||
<div class="space-y-2 p-3">
|
||||
@for (server of searchResults(); track server.id) {
|
||||
<button
|
||||
(click)="joinServer(server)"
|
||||
type="button"
|
||||
class="w-full p-4 bg-card rounded-lg border transition-all text-left group"
|
||||
<div
|
||||
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
|
||||
[class.border-border]="!isServerMarkedBanned(server)"
|
||||
[class.hover:border-primary/50]="!isServerMarkedBanned(server)"
|
||||
[class.hover:bg-card/80]="!isServerMarkedBanned(server)"
|
||||
[class.border-destructive/40]="isServerMarkedBanned(server)"
|
||||
[class.bg-destructive/5]="isServerMarkedBanned(server)"
|
||||
[class.hover:border-destructive/60]="isServerMarkedBanned(server)"
|
||||
[attr.aria-label]="isServerMarkedBanned(server) ? 'Banned server' : 'Join server'"
|
||||
[title]="isJoinedServer(server) ? 'Double-click to open ' + server.name : 'Double-click to join ' + server.name"
|
||||
(dblclick)="openServerCard(server)"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex min-w-0 items-start gap-3">
|
||||
<div class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-secondary text-sm font-semibold text-foreground">
|
||||
{{ server.name[0] || '?' }}
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<h3
|
||||
class="font-semibold transition-colors"
|
||||
class="truncate text-sm font-semibold transition-colors"
|
||||
[class.text-foreground]="!isServerMarkedBanned(server)"
|
||||
[class.group-hover:text-primary]="!isServerMarkedBanned(server)"
|
||||
[class.text-destructive]="isServerMarkedBanned(server)"
|
||||
>
|
||||
{{ server.name }}
|
||||
</h3>
|
||||
|
||||
@if (isServerMarkedBanned(server)) {
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-destructive"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
<span class="inline-flex items-center rounded-full bg-destructive/10 px-2 py-0.5 text-[11px] font-medium text-destructive"
|
||||
>Banned</span
|
||||
>
|
||||
Banned
|
||||
</span>
|
||||
} @else if (server.isPrivate) {
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>Private</span
|
||||
>
|
||||
Private
|
||||
</span>
|
||||
} @else if (server.hasPassword) {
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideLock"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
class="h-3 w-3"
|
||||
/>
|
||||
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
|
||||
>Password</span
|
||||
>
|
||||
Password
|
||||
</span>
|
||||
} @else {
|
||||
<ng-icon
|
||||
name="lucideGlobe"
|
||||
class="w-4 h-4 text-muted-foreground"
|
||||
class="h-4 w-4 text-muted-foreground"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (server.description) {
|
||||
<p class="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{{ server.description }}
|
||||
</p>
|
||||
<p class="mt-1 line-clamp-1 text-xs text-muted-foreground">{{ server.description }}</p>
|
||||
}
|
||||
@if (server.topic) {
|
||||
<span class="inline-block mt-2 px-2 py-0.5 text-xs bg-secondary rounded-full text-muted-foreground">
|
||||
{{ server.topic }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-muted-foreground text-sm ml-4">
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ng-icon
|
||||
name="lucideUsers"
|
||||
class="w-4 h-4"
|
||||
class="h-3.5 w-3.5"
|
||||
/>
|
||||
<span>{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 text-xs">
|
||||
<div class="text-muted-foreground">
|
||||
Users: <span class="text-foreground/80">{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Listed by: <span class="text-foreground/80">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
|
||||
</div>
|
||||
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
|
||||
<div class="text-muted-foreground">Access: <span class="text-foreground/80">Password required</span></div>
|
||||
{{ getServerUserCount(server) }}/{{ getServerCapacityLabel(server) }}
|
||||
</span>
|
||||
@if (server.topic) {
|
||||
<span class="truncate">{{ server.topic }}</span>
|
||||
}
|
||||
<span class="truncate">Owner: {{ getServerOwnerLabel(server) }}</span>
|
||||
<span class="truncate">{{ server.sourceName || server.hostName || 'Unknown' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
@if (isJoinedServer(server)) {
|
||||
<div
|
||||
class="flex items-center overflow-hidden rounded-md border border-emerald-500/30 bg-emerald-500/10 text-xs font-semibold text-emerald-500"
|
||||
>
|
||||
<span class="px-2.5 py-1.5">Joined</span>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-8 w-8 place-items-center border-l border-emerald-500/20 transition-colors hover:bg-emerald-500/15"
|
||||
[attr.aria-label]="'Server actions for ' + server.name"
|
||||
(click)="toggleJoinedServerMenu($event, server)"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideChevronDown"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (joinedServerMenuId() === server.id) {
|
||||
<div class="absolute right-0 top-full z-20 mt-1 w-36 rounded-md border border-border bg-card py-1 shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-2 text-left text-xs font-medium text-destructive transition-colors hover:bg-destructive/10"
|
||||
(click)="openLeaveDialog($event, server)"
|
||||
>
|
||||
Leave
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="pointer-events-none scale-95 rounded-md bg-primary px-2.5 py-1.5 text-xs font-semibold text-primary-foreground opacity-0 transition-[opacity,transform] duration-75 ease-out hover:scale-100 hover:opacity-100 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"
|
||||
[attr.aria-label]="'Join ' + server.name"
|
||||
(click)="joinServer(server)"
|
||||
>
|
||||
<span class="sr-only">{{ server.name }}</span>
|
||||
Join
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@if (joinErrorMessage() || error()) {
|
||||
<div class="p-4 bg-destructive/10 border-t border-destructive">
|
||||
@@ -181,6 +231,15 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (leaveDialogRoom()) {
|
||||
<app-leave-server-dialog
|
||||
[room]="leaveDialogRoom()!"
|
||||
[currentUser]="currentUser() ?? null"
|
||||
(confirmed)="confirmLeaveServer($event)"
|
||||
(cancelled)="closeLeaveDialog()"
|
||||
/>
|
||||
}
|
||||
|
||||
@if (showBannedDialog()) {
|
||||
<app-confirm-dialog
|
||||
title="Banned"
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings
|
||||
lucideSettings,
|
||||
lucideChevronDown
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
@@ -39,8 +40,13 @@ import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import {
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
type LeaveServerDialogResult
|
||||
} from '../../../../shared';
|
||||
import { hasRoomBanForUser } from '../../../access-control';
|
||||
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-search',
|
||||
@@ -49,7 +55,9 @@ import { hasRoomBanForUser } from '../../../access-control';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
UserSearchListComponent
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
@@ -58,7 +66,8 @@ import { hasRoomBanForUser } from '../../../access-control';
|
||||
lucideLock,
|
||||
lucideGlobe,
|
||||
lucidePlus,
|
||||
lucideSettings
|
||||
lucideSettings,
|
||||
lucideChevronDown
|
||||
})
|
||||
],
|
||||
templateUrl: './server-search.component.html'
|
||||
@@ -91,6 +100,8 @@ export class ServerSearchComponent implements OnInit {
|
||||
joinPassword = signal('');
|
||||
joinPasswordError = signal<string | null>(null);
|
||||
joinErrorMessage = signal<string | null>(null);
|
||||
joinedServerMenuId = signal<string | null>(null);
|
||||
leaveDialogRoom = signal<Room | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
showCreateDialog = signal(false);
|
||||
@@ -117,7 +128,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
this.store.dispatch(RoomsActions.loadRooms());
|
||||
|
||||
// Setup debounced search
|
||||
this.searchSubject.pipe(debounceTime(300), distinctUntilChanged()).subscribe((query) => {
|
||||
this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => {
|
||||
this.store.dispatch(RoomsActions.searchServers({ query }));
|
||||
});
|
||||
}
|
||||
@@ -190,7 +201,66 @@ export class ServerSearchComponent implements OnInit {
|
||||
|
||||
/** Join a previously saved room by converting it to a ServerInfo payload. */
|
||||
joinSavedRoom(room: Room): void {
|
||||
void this.joinServer(this.toServerInfo(room));
|
||||
this.openJoinedRoom(room);
|
||||
}
|
||||
|
||||
openServerCard(server: ServerInfo): void {
|
||||
const joinedRoom = this.joinedRoomForServer(server);
|
||||
|
||||
if (joinedRoom) {
|
||||
this.openJoinedRoom(joinedRoom);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.joinServer(server);
|
||||
}
|
||||
|
||||
joinedRoomForServer(server: ServerInfo): Room | null {
|
||||
return this.savedRooms().find((room) => room.id === server.id) ?? null;
|
||||
}
|
||||
|
||||
isJoinedServer(server: ServerInfo): boolean {
|
||||
return !!this.joinedRoomForServer(server);
|
||||
}
|
||||
|
||||
toggleJoinedServerMenu(event: Event, server: ServerInfo): void {
|
||||
event.stopPropagation();
|
||||
this.joinedServerMenuId.update((currentId) => currentId === server.id ? null : server.id);
|
||||
}
|
||||
|
||||
closeJoinedServerMenu(): void {
|
||||
this.joinedServerMenuId.set(null);
|
||||
}
|
||||
|
||||
openLeaveDialog(event: Event, server: ServerInfo): void {
|
||||
event.stopPropagation();
|
||||
const room = this.joinedRoomForServer(server);
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.joinedServerMenuId.set(null);
|
||||
this.leaveDialogRoom.set(room);
|
||||
}
|
||||
|
||||
closeLeaveDialog(): void {
|
||||
this.leaveDialogRoom.set(null);
|
||||
}
|
||||
|
||||
confirmLeaveServer(result: LeaveServerDialogResult): void {
|
||||
const room = this.leaveDialogRoom();
|
||||
|
||||
if (!room) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.dispatch(RoomsActions.forgetRoom({
|
||||
roomId: room.id,
|
||||
nextOwnerKey: result.nextOwnerKey
|
||||
}));
|
||||
|
||||
this.leaveDialogRoom.set(null);
|
||||
}
|
||||
|
||||
closeBannedDialog(): void {
|
||||
@@ -231,6 +301,21 @@ export class ServerSearchComponent implements OnInit {
|
||||
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
|
||||
}
|
||||
|
||||
getServerOwnerLabel(server: ServerInfo): string {
|
||||
const joinedRoom = this.joinedRoomForServer(server);
|
||||
const ownerKey = server.ownerId || joinedRoom?.hostId || '';
|
||||
const ownerMember = joinedRoom?.members?.find((member) =>
|
||||
member.id === ownerKey || member.oderId === ownerKey
|
||||
);
|
||||
|
||||
return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || 'Unknown owner';
|
||||
}
|
||||
|
||||
private openJoinedRoom(room: Room): void {
|
||||
this.joinedServerMenuId.set(null);
|
||||
this.store.dispatch(RoomsActions.viewServer({ room }));
|
||||
}
|
||||
|
||||
private toServerInfo(room: Room): ServerInfo {
|
||||
return {
|
||||
id: room.id,
|
||||
|
||||
@@ -4,10 +4,11 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import {
|
||||
Observable,
|
||||
forkJoin,
|
||||
merge,
|
||||
of,
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { catchError, map, scan } from 'rxjs/operators';
|
||||
import {
|
||||
ChannelPermissionOverride,
|
||||
type Channel,
|
||||
@@ -299,9 +300,8 @@ export class ServerDirectoryApiService {
|
||||
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
||||
}
|
||||
|
||||
return forkJoin(onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe(
|
||||
map((resultArrays) => resultArrays.flat()),
|
||||
map((servers) => this.deduplicateById(servers))
|
||||
return merge(...onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))).pipe(
|
||||
scan((servers, endpointServers) => this.deduplicateById([...servers, ...endpointServers]), [] as ServerInfo[])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,30 @@ function createDefaultLayout(): Record<string, ThemeLayoutEntry> {
|
||||
w: 4,
|
||||
h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'dmConversationsPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 0,
|
||||
y: 0,
|
||||
w: 4,
|
||||
h: 12 }
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (key === 'dmChatPanel') {
|
||||
layoutEntries[key] = {
|
||||
container: 'roomLayout',
|
||||
grid: { x: 4,
|
||||
y: 0,
|
||||
w: 16,
|
||||
h: 12 }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,6 +187,14 @@ function createDarkDefaultElements(): Record<string, ThemeElementStyles> {
|
||||
backdropFilter: 'var(--theme-effect-glass-blur)'
|
||||
};
|
||||
|
||||
elements['dmConversationsPanel'] = {
|
||||
...elements['chatRoomChannelsPanel']
|
||||
};
|
||||
|
||||
elements['dmChatPanel'] = {
|
||||
...elements['chatRoomMainPanel']
|
||||
};
|
||||
|
||||
elements['chatRoomEmptyState'] = {
|
||||
backgroundColor: 'hsl(var(--panel-background-alt) / 0.88)',
|
||||
color: 'hsl(var(--muted-foreground))',
|
||||
|
||||
@@ -102,6 +102,30 @@ export const THEME_REGISTRY: readonly ThemeRegistryEntry[] = [
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'dmConversationsPanel',
|
||||
label: 'DM Conversations Panel',
|
||||
description: 'The direct-message sidebar showing private chat conversations.',
|
||||
category: 'room',
|
||||
container: 'roomLayout',
|
||||
layoutEditable: true,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'dmChatPanel',
|
||||
label: 'DM Chat Panel',
|
||||
description: 'The main direct-message panel that hosts private chat messages.',
|
||||
category: 'room',
|
||||
container: 'roomLayout',
|
||||
layoutEditable: true,
|
||||
pickerVisible: true,
|
||||
supportsTextOverride: false,
|
||||
supportsLink: false,
|
||||
supportsIcon: false
|
||||
},
|
||||
{
|
||||
key: 'chatRoomEmptyState',
|
||||
label: 'Room Empty State',
|
||||
|
||||
@@ -301,12 +301,14 @@
|
||||
@for (user of onlineRoomUsers(); track user.id) {
|
||||
<div
|
||||
class="group/user flex items-center gap-2 rounded-md px-3 py-2 transition-colors hover:bg-secondary/50 cursor-pointer"
|
||||
[attr.data-testid]="'room-user-card-' + (user.oderId || user.id)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(contextmenu)="openUserContextMenu($event, user)"
|
||||
(click)="openProfileCard($event, user, false); $event.stopPropagation()"
|
||||
(keydown.enter)="openProfileCard($event, user, false); $event.stopPropagation()"
|
||||
(keydown.space)="openProfileCard($event, user, false); $event.preventDefault(); $event.stopPropagation()"
|
||||
(click)="openUserCard($event, user)"
|
||||
(dblclick)="openDirectMessage($event, user)"
|
||||
(keydown.enter)="openUserCard($event, user)"
|
||||
(keydown.space)="openUserCard($event, user); $event.preventDefault()"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="user.displayName"
|
||||
@@ -350,6 +352,19 @@
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-card hover:text-foreground group-hover/user:opacity-100 focus:opacity-100"
|
||||
title="Message"
|
||||
[attr.aria-label]="'Message ' + user.displayName"
|
||||
(click)="openDirectMessage($event, user)"
|
||||
(dblclick)="$event.stopPropagation()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMessageSquare"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -363,12 +378,14 @@
|
||||
<div class="space-y-1">
|
||||
@for (member of offlineRoomMembers(); track member.oderId || member.id) {
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
|
||||
class="group/user flex items-center gap-2 rounded-md px-3 py-2 opacity-80 hover:bg-secondary/30 transition-colors cursor-pointer"
|
||||
[attr.data-testid]="'room-user-card-' + (member.oderId || member.id)"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
||||
(keydown.enter)="openProfileCardForMember($event, member); $event.stopPropagation()"
|
||||
(keydown.space)="openProfileCardForMember($event, member); $event.preventDefault(); $event.stopPropagation()"
|
||||
(click)="openMemberCard($event, member)"
|
||||
(dblclick)="openDirectMessageForMember($event, member)"
|
||||
(keydown.enter)="openMemberCard($event, member)"
|
||||
(keydown.space)="openMemberCard($event, member); $event.preventDefault()"
|
||||
>
|
||||
<app-user-avatar
|
||||
[name]="member.displayName"
|
||||
@@ -390,6 +407,19 @@
|
||||
</div>
|
||||
<p class="text-[10px] text-muted-foreground">Offline</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground opacity-0 transition-colors hover:bg-card hover:text-foreground group-hover/user:opacity-100 focus:opacity-100"
|
||||
title="Message"
|
||||
[attr.aria-label]="'Message ' + member.displayName"
|
||||
(click)="openDirectMessageForMember($event, member)"
|
||||
(dblclick)="$event.stopPropagation()"
|
||||
>
|
||||
<ng-icon
|
||||
name="lucideMessageSquare"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
VoiceConnectivityHealthService
|
||||
} from '../../../domains/voice-connection';
|
||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import { DirectMessageService } from '../../../domains/direct-message';
|
||||
import { VoicePlaybackService } from '../../../domains/voice-connection';
|
||||
import { VoiceControlsComponent } from '../../../domains/voice-session/feature/voice-controls/voice-controls.component';
|
||||
import { isChannelNameTaken, normalizeChannelName } from '../../../store/rooms/room-channels.rules';
|
||||
@@ -101,6 +103,7 @@ type PanelMode = 'channels' | 'users';
|
||||
})
|
||||
export class RoomsSidePanelComponent {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private realtime = inject(RealtimeSessionFacade);
|
||||
private voiceConnection = inject(VoiceConnectionFacade);
|
||||
private screenShare = inject(ScreenShareFacade);
|
||||
@@ -109,8 +112,10 @@ export class RoomsSidePanelComponent {
|
||||
private voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private profileCard = inject(ProfileCardService);
|
||||
private directMessages = inject(DirectMessageService);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly voiceConnectivity = inject(VoiceConnectivityHealthService);
|
||||
private profileCardOpenTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
readonly panelMode = input<PanelMode>('channels');
|
||||
readonly showVoiceControls = input(true);
|
||||
@@ -201,8 +206,41 @@ export class RoomsSidePanelComponent {
|
||||
this.profileCard.open(el, user, { placement: 'left', editable });
|
||||
}
|
||||
|
||||
openUserCard(event: Event, user: User): void {
|
||||
event.stopPropagation();
|
||||
this.queueProfileCardOpen(event.currentTarget as HTMLElement, user, false);
|
||||
}
|
||||
|
||||
openProfileCardForMember(event: Event, member: RoomMember): void {
|
||||
const user: User = {
|
||||
const user = this.roomMemberToUser(member);
|
||||
|
||||
this.openProfileCard(event, user, false);
|
||||
}
|
||||
|
||||
openMemberCard(event: Event, member: RoomMember): void {
|
||||
event.stopPropagation();
|
||||
this.queueProfileCardOpen(event.currentTarget as HTMLElement, this.roomMemberToUser(member), false);
|
||||
}
|
||||
|
||||
async openDirectMessage(event: Event, user: User): Promise<void> {
|
||||
event.stopPropagation();
|
||||
this.cancelQueuedProfileCardOpen();
|
||||
|
||||
if (this.isCurrentUserIdentity(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversation = await this.directMessages.createConversation(user);
|
||||
|
||||
await this.router.navigate(['/dm', conversation.id]);
|
||||
}
|
||||
|
||||
async openDirectMessageForMember(event: Event, member: RoomMember): Promise<void> {
|
||||
await this.openDirectMessage(event, this.roomMemberToUser(member));
|
||||
}
|
||||
|
||||
private roomMemberToUser(member: RoomMember): User {
|
||||
return {
|
||||
id: member.id,
|
||||
oderId: member.oderId || member.id,
|
||||
username: member.username,
|
||||
@@ -210,12 +248,13 @@ export class RoomsSidePanelComponent {
|
||||
description: member.description,
|
||||
profileUpdatedAt: member.profileUpdatedAt,
|
||||
avatarUrl: member.avatarUrl,
|
||||
avatarHash: member.avatarHash,
|
||||
avatarMime: member.avatarMime,
|
||||
avatarUpdatedAt: member.avatarUpdatedAt,
|
||||
status: 'disconnected',
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt
|
||||
};
|
||||
|
||||
this.openProfileCard(event, user, false);
|
||||
}
|
||||
|
||||
private roomMemberKey(member: RoomMember): string {
|
||||
@@ -256,6 +295,23 @@ export class RoomsSidePanelComponent {
|
||||
);
|
||||
}
|
||||
|
||||
private queueProfileCardOpen(anchor: HTMLElement, user: User, editable: boolean): void {
|
||||
this.cancelQueuedProfileCardOpen();
|
||||
this.profileCardOpenTimer = setTimeout(() => {
|
||||
this.profileCardOpenTimer = null;
|
||||
this.profileCard.open(anchor, user, { placement: 'left', editable });
|
||||
}, 180);
|
||||
}
|
||||
|
||||
private cancelQueuedProfileCardOpen(): void {
|
||||
if (!this.profileCardOpenTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.profileCardOpenTimer);
|
||||
this.profileCardOpenTimer = null;
|
||||
}
|
||||
|
||||
hasConnectivityIssue(user: User): boolean {
|
||||
return this.voiceConnectivity.hasPeerDesync(user.oderId || user.id);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
/>
|
||||
</button>
|
||||
|
||||
@if (dmRailComponent()) {
|
||||
<ng-container *ngComponentOutlet="dmRailComponent()" />
|
||||
}
|
||||
|
||||
<!-- Saved servers icons -->
|
||||
<div class="no-scrollbar mt-2 flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto pt-0.5">
|
||||
@for (room of visibleSavedRooms(); track room.id) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
Type,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
@@ -72,6 +73,7 @@ export class ServersRailComponent {
|
||||
currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
|
||||
showMenu = signal(false);
|
||||
dmRailComponent = signal<Type<unknown> | null>(null);
|
||||
menuX = signal(72);
|
||||
menuY = signal(100);
|
||||
contextRoom = signal<Room | null>(null);
|
||||
@@ -86,6 +88,13 @@ export class ServersRailComponent {
|
||||
),
|
||||
{ initialValue: this.router.url.startsWith('/search') }
|
||||
);
|
||||
isOnDirectMessage = 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/') }
|
||||
);
|
||||
bannedServerName = signal('');
|
||||
showBannedDialog = signal(false);
|
||||
showPasswordDialog = signal(false);
|
||||
@@ -135,6 +144,10 @@ export class ServersRailComponent {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void import('../../../domains/direct-message/feature/dm-rail/dm-rail.component').then((module) => {
|
||||
this.dmRailComponent.set(module.DmRailComponent);
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const rooms = this.savedRooms();
|
||||
const currentUser = this.currentUser();
|
||||
@@ -296,6 +309,10 @@ export class ServersRailComponent {
|
||||
}
|
||||
|
||||
isSelectedRoom(room: Room): boolean {
|
||||
if (this.isOnDirectMessage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.currentRoom()?.id === room.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
|
||||
<div
|
||||
appThemeNode="titleBar"
|
||||
class="relative z-50 flex h-10 w-full items-center justify-between border-b border-border bg-card px-4 select-none"
|
||||
@@ -55,7 +56,7 @@
|
||||
{{ roomContextMeta() }}
|
||||
</span>
|
||||
}
|
||||
} @else {
|
||||
} @else if (!isInDirectMessage()) {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
data-theme-slot="text"
|
||||
|
||||
@@ -18,7 +18,9 @@ import {
|
||||
lucideMenu,
|
||||
lucideRefreshCw
|
||||
} from '@ng-icons/lucide';
|
||||
import { Router } from '@angular/router';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { filter, map } from 'rxjs';
|
||||
import {
|
||||
selectCurrentRoom,
|
||||
selectActiveChannelId,
|
||||
@@ -93,7 +95,14 @@ export class TitleBarComponent {
|
||||
isVoiceWorkspaceExpanded = this.voiceWorkspace.isExpanded;
|
||||
isSignalServerReconnecting = this.store.selectSignal(selectIsSignalServerReconnecting);
|
||||
signalServerCompatibilityError = this.store.selectSignal(selectSignalServerCompatibilityError);
|
||||
inRoom = computed(() => !!this.currentRoom());
|
||||
isInDirectMessage = 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/') }
|
||||
);
|
||||
inRoom = computed(() => !!this.currentRoom() && !this.isInDirectMessage());
|
||||
roomName = computed(() => this.currentRoom()?.name || '');
|
||||
activeTextChannelName = computed(() => {
|
||||
const textChannels = this.textChannels();
|
||||
|
||||
@@ -14,6 +14,7 @@ require coordination.
|
||||
| `moderation.models.ts` | `BanEntry` |
|
||||
| `voice-state.models.ts` | `VoiceState`, `ScreenShareState` |
|
||||
| `chat-events.ts` | `ChatEventType`, `ChatEvent`, `ChatInventoryItem` |
|
||||
| `direct-message-contracts.ts` | `DirectMessage`, delivery status, P2P DM event payloads |
|
||||
| `media-preferences.ts` | `LatencyProfile`, `ScreenShareQuality`, quality presets |
|
||||
| `signaling-contracts.ts` | `SignalingMessage`, `SignalingMessageType` |
|
||||
| `attachment-contracts.ts` | `ChatAttachmentAnnouncement`, `ChatAttachmentMeta` |
|
||||
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
import type { VoiceState } from './voice-state.models';
|
||||
import type { BanEntry } from './moderation.models';
|
||||
import type { ChatAttachmentAnnouncement, ChatAttachmentMeta } from './attachment-contracts';
|
||||
import type {
|
||||
DirectMessageEventPayload,
|
||||
DirectMessageMutationEventPayload,
|
||||
DirectMessageStatusEventPayload
|
||||
} from './direct-message-contracts';
|
||||
|
||||
export interface ChatInventoryItem {
|
||||
id: string;
|
||||
@@ -77,6 +82,9 @@ export interface ChatEventBase {
|
||||
bans?: BanEntry[];
|
||||
banOderId?: string;
|
||||
expiresAt?: number;
|
||||
directMessage?: DirectMessageEventPayload;
|
||||
directMessageStatus?: DirectMessageStatusEventPayload;
|
||||
directMessageMutation?: DirectMessageMutationEventPayload;
|
||||
}
|
||||
|
||||
export interface ChatMessageEvent extends ChatEventBase {
|
||||
@@ -360,6 +368,21 @@ export interface ChannelsUpdateEvent extends ChatEventBase {
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
export interface DirectMessagePeerEvent extends ChatEventBase {
|
||||
type: 'direct-message';
|
||||
directMessage: DirectMessageEventPayload;
|
||||
}
|
||||
|
||||
export interface DirectMessageStatusPeerEvent extends ChatEventBase {
|
||||
type: 'direct-message-status';
|
||||
directMessageStatus: DirectMessageStatusEventPayload;
|
||||
}
|
||||
|
||||
export interface DirectMessageMutationPeerEvent extends ChatEventBase {
|
||||
type: 'direct-message-mutation';
|
||||
directMessageMutation: DirectMessageMutationEventPayload;
|
||||
}
|
||||
|
||||
/** Discriminated union of all P2P chat events. Narrow via `event.type`. */
|
||||
export type ChatEvent =
|
||||
| ChatMessageEvent
|
||||
@@ -408,7 +431,10 @@ export type ChatEvent =
|
||||
| KickEvent
|
||||
| BanEvent
|
||||
| UnbanEvent
|
||||
| ChannelsUpdateEvent;
|
||||
| ChannelsUpdateEvent
|
||||
| DirectMessagePeerEvent
|
||||
| DirectMessageStatusPeerEvent
|
||||
| DirectMessageMutationPeerEvent;
|
||||
|
||||
/** All possible `type` values, derived from the union. */
|
||||
export type ChatEventType = ChatEvent['type'];
|
||||
|
||||
54
toju-app/src/app/shared-kernel/direct-message-contracts.ts
Normal file
54
toju-app/src/app/shared-kernel/direct-message-contracts.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Reaction } from './message.models';
|
||||
|
||||
export type DirectMessageStatus = 'QUEUED' | 'SENT' | 'DELIVERED' | 'ACKNOWLEDGED';
|
||||
export type DirectMessageMutationType = 'edit' | 'delete' | 'reaction-add' | 'reaction-remove';
|
||||
|
||||
export interface DirectMessageParticipant {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
description?: string;
|
||||
avatarUrl?: string;
|
||||
avatarHash?: string;
|
||||
avatarMime?: string;
|
||||
avatarUpdatedAt?: number;
|
||||
profileUpdatedAt?: number;
|
||||
}
|
||||
|
||||
export interface DirectMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
recipientId: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
status: DirectMessageStatus;
|
||||
editedAt?: number;
|
||||
isDeleted?: boolean;
|
||||
reactions?: Reaction[];
|
||||
replyToId?: string;
|
||||
}
|
||||
|
||||
export interface DirectMessageEventPayload {
|
||||
message: DirectMessage;
|
||||
sender: DirectMessageParticipant;
|
||||
}
|
||||
|
||||
export interface DirectMessageStatusEventPayload {
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
status: DirectMessageStatus;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface DirectMessageMutationEventPayload {
|
||||
conversationId: string;
|
||||
messageId: string;
|
||||
type: DirectMessageMutationType;
|
||||
content?: string;
|
||||
editedAt?: number;
|
||||
reaction?: Reaction;
|
||||
oderId?: string;
|
||||
emoji?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from './access-control.models';
|
||||
export * from './message.models';
|
||||
export * from './moderation.models';
|
||||
export * from './voice-state.models';
|
||||
export * from './direct-message-contracts';
|
||||
export * from './chat-events';
|
||||
export * from './media-preferences';
|
||||
export * from './signaling-contracts';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
class="w-72 rounded-lg border border-border bg-card shadow-xl"
|
||||
style="animation: profile-card-in 120ms cubic-bezier(0.2, 0, 0, 1) both"
|
||||
>
|
||||
@let profileUser = user();
|
||||
@let profileUser = displayedUser();
|
||||
@let isEditable = editable();
|
||||
@let activeField = editingField();
|
||||
@let statusColor = currentStatusColor();
|
||||
@@ -125,13 +125,22 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-secondary"
|
||||
[class.bg-secondary]="isStatusOptionSelected(opt.value)"
|
||||
[class.text-foreground]="isStatusOptionSelected(opt.value)"
|
||||
[class.text-muted-foreground]="!isStatusOptionSelected(opt.value)"
|
||||
(click)="setStatus(opt.value)"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
[class]="opt.color"
|
||||
></span>
|
||||
<span>{{ opt.label }}</span>
|
||||
<span class="flex-1">{{ opt.label }}</span>
|
||||
@if (isStatusOptionSelected(opt.value)) {
|
||||
<ng-icon
|
||||
name="lucideCheck"
|
||||
class="h-3.5 w-3.5 text-primary"
|
||||
/>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
signal
|
||||
@@ -7,7 +8,7 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideChevronDown } from '@ng-icons/lucide';
|
||||
import { lucideCheck, lucideChevronDown } from '@ng-icons/lucide';
|
||||
import { UserAvatarComponent } from '../user-avatar/user-avatar.component';
|
||||
import { UserStatusService } from '../../../core/services/user-status.service';
|
||||
import { User, UserStatus } from '../../../shared-kernel';
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
ProcessedProfileAvatar
|
||||
} from '../../../domains/profile-avatar';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { selectUsersEntities } from '../../../store/users/users.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile-card',
|
||||
@@ -28,11 +30,20 @@ import { UsersActions } from '../../../store/users/users.actions';
|
||||
NgIcon,
|
||||
UserAvatarComponent
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideChevronDown })],
|
||||
viewProviders: [provideIcons({ lucideCheck, lucideChevronDown })],
|
||||
templateUrl: './profile-card.component.html'
|
||||
})
|
||||
export class ProfileCardComponent {
|
||||
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
|
||||
private readonly store = inject(Store);
|
||||
private readonly users = this.store.selectSignal(selectUsersEntities);
|
||||
readonly displayedUser = computed(() => {
|
||||
const snapshot = this.user();
|
||||
const entities = this.users();
|
||||
const liveUser = entities[snapshot.id] ?? entities[snapshot.oderId];
|
||||
|
||||
return liveUser ? { ...snapshot, ...liveUser } : snapshot;
|
||||
});
|
||||
readonly editable = signal(false);
|
||||
readonly showStatusMenu = signal(false);
|
||||
readonly avatarAccept = PROFILE_AVATAR_ACCEPT_ATTRIBUTE;
|
||||
@@ -50,11 +61,10 @@ export class ProfileCardComponent {
|
||||
];
|
||||
|
||||
private readonly userStatus = inject(UserStatusService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly profileAvatar = inject(ProfileAvatarFacade);
|
||||
private readonly profileAvatarEditor = inject(ProfileAvatarEditorService);
|
||||
private readonly syncProfileDrafts = effect(() => {
|
||||
const user = this.user();
|
||||
const user = this.displayedUser();
|
||||
const editingField = this.editingField();
|
||||
|
||||
if (editingField !== 'displayName') {
|
||||
@@ -68,7 +78,7 @@ export class ProfileCardComponent {
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
currentStatusColor(): string {
|
||||
switch (this.user().status) {
|
||||
switch (this.displayedUser().status) {
|
||||
case 'online': return 'bg-green-500';
|
||||
case 'away': return 'bg-yellow-500';
|
||||
case 'busy': return 'bg-red-500';
|
||||
@@ -79,7 +89,7 @@ export class ProfileCardComponent {
|
||||
}
|
||||
|
||||
currentStatusLabel(): string {
|
||||
switch (this.user().status) {
|
||||
switch (this.displayedUser().status) {
|
||||
case 'online': return 'Online';
|
||||
case 'away': return 'Away';
|
||||
case 'busy': return 'Do Not Disturb';
|
||||
@@ -98,6 +108,14 @@ export class ProfileCardComponent {
|
||||
this.showStatusMenu.set(false);
|
||||
}
|
||||
|
||||
isStatusOptionSelected(status: UserStatus | null): boolean {
|
||||
const currentStatus = this.displayedUser().status;
|
||||
|
||||
return status === null
|
||||
? currentStatus === 'online'
|
||||
: currentStatus === status;
|
||||
}
|
||||
|
||||
onDisplayNameInput(event: Event): void {
|
||||
this.displayNameDraft.set((event.target as HTMLInputElement).value);
|
||||
}
|
||||
@@ -168,7 +186,7 @@ export class ProfileCardComponent {
|
||||
}
|
||||
|
||||
async applyAvatar(avatar: ProcessedProfileAvatar): Promise<void> {
|
||||
const currentUser = this.user();
|
||||
const currentUser = this.displayedUser();
|
||||
|
||||
this.avatarSaving.set(true);
|
||||
this.avatarError.set(null);
|
||||
@@ -202,7 +220,7 @@ export class ProfileCardComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = this.user();
|
||||
const user = this.displayedUser();
|
||||
const description = this.normalizeDescription(this.descriptionDraft());
|
||||
|
||||
if (
|
||||
|
||||
@@ -5,6 +5,7 @@ export { ContextMenuComponent } from './components/context-menu/context-menu.com
|
||||
export { UserAvatarComponent } from './components/user-avatar/user-avatar.component';
|
||||
export { ConfirmDialogComponent } from './components/confirm-dialog/confirm-dialog.component';
|
||||
export { LeaveServerDialogComponent } from './components/leave-server-dialog/leave-server-dialog.component';
|
||||
export type { LeaveServerDialogResult } from './components/leave-server-dialog/leave-server-dialog.component';
|
||||
export { ChatAudioPlayerComponent } from './components/chat-audio-player/chat-audio-player.component';
|
||||
export { ChatVideoPlayerComponent } from './components/chat-video-player/chat-video-player.component';
|
||||
export { DebugConsoleComponent } from './components/debug-console/debug-console.component';
|
||||
|
||||
Reference in New Issue
Block a user