feat: Add pm

This commit is contained in:
2026-04-27 00:45:16 +02:00
parent bc2fa7de22
commit 11c2588e45
65 changed files with 3653 additions and 214 deletions

View 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.

View File

@@ -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
};
}

View File

@@ -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;
}
}

View File

@@ -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()
});
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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()
});
}

View File

@@ -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();
};
}
}

View File

@@ -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 };
}

View File

@@ -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
};
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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';
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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';
}
}

View File

@@ -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>

View File

@@ -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());
}
}

View File

@@ -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>

View File

@@ -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));
}
}

View 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';

View File

@@ -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}`;
}
}

View File

@@ -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}`;
}
}

View File

@@ -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}`;
}
}