fix: solve small pm chat ui issues

unwrap the pill
fix the fetching images in pm not auto download
This commit is contained in:
2026-05-25 17:17:32 +02:00
parent 1259645706
commit 161f57f52e
28 changed files with 697 additions and 82 deletions

View File

@@ -15,7 +15,7 @@ infrastructure adapters and UI.
| **direct-message** | One-to-one WebRTC messages, offline queueing, delivery state, and friends | `DirectMessageService`, `FriendService` |
| **direct-call** | Direct and small-group private calls initiated from people cards and direct messages | `DirectCallService` |
| **experimental-media** | Optional media playback experiments kept isolated from the default attachment path | `ExperimentalMediaSettingsService` |
| **game-activity** | Foreground-window-first game detection with confidence scoring (`MIN_GAME_CONFIDENCE`), server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **game-activity** | Foreground-window-first game detection with confidence scoring (`MIN_GAME_CONFIDENCE`), focused-window scan suppression in Electron, server metadata matching, P2P now-playing sync, and elapsed playtime formatting | `GameActivityService`, `formatGameActivityElapsed()` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **plugins** | Client-only plugin manifests, load ordering, registry state, and signal-server support metadata | `PluginHostService`, `PluginRegistryService` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |

View File

@@ -133,6 +133,8 @@ When the user navigates to a room, the manager watches the route and decides whi
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
Direct-message routes (`/dm/:conversationId` and `/pm/:conversationId`) are treated as watched attachment containers named `direct-message:<conversationId>`, so image/video metadata announced for the visible conversation is eligible for the same automatic request path as server-room media.
Browser chat views render audio/video larger than 50 MB with the same generic file interface as other downloads, even after the bytes are available. Attachments with audio/video MIME types that Chromium reports as unsupported also use the generic file interface instead of a broken native player.
An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads `/vlcjs/metoyou-vlc-player.js`. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path.

View File

@@ -6,8 +6,11 @@ import {
import { NavigationEnd, Router } from '@angular/router';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
import { ROOM_URL_PATTERN } from '../../../../core/constants';
import { shouldAutoRequestWhenWatched } from '../../domain/logic/attachment.logic';
import {
getWatchedAttachmentRoomIdFromUrl,
isDirectMessageAttachmentRoomId,
shouldAutoRequestWhenWatched
} from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import type {
FileAnnouncePayload,
@@ -182,6 +185,11 @@ export class AttachmentManagerService {
return;
}
if (isDirectMessageAttachmentRoomId(roomId)) {
await this.requestAutoDownloadsForRuntimeRoom(roomId);
return;
}
if (this.database.isReady()) {
const messages = await this.database.getMessages(roomId, 500, 0);
@@ -193,6 +201,10 @@ export class AttachmentManagerService {
return;
}
await this.requestAutoDownloadsForRuntimeRoom(roomId);
}
private async requestAutoDownloadsForRuntimeRoom(roomId: string): Promise<void> {
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
@@ -235,9 +247,7 @@ export class AttachmentManagerService {
}
private extractWatchedRoomId(url: string): string | null {
const roomMatch = url.match(ROOM_URL_PATTERN);
return roomMatch ? roomMatch[1] : null;
return getWatchedAttachmentRoomIdFromUrl(url);
}
private isRoomWatched(roomId: string | null | undefined): boolean {

View File

@@ -0,0 +1,24 @@
import { getWatchedAttachmentRoomIdFromUrl, isDirectMessageAttachmentRoomId } from './attachment.logic';
describe('attachment logic', () => {
it('extracts watched server room ids from room URLs', () => {
expect(getWatchedAttachmentRoomIdFromUrl('/room/general')).toBe('general');
expect(getWatchedAttachmentRoomIdFromUrl('/room/general/chat')).toBe('general');
});
it('extracts watched direct-message storage ids from DM URLs', () => {
expect(getWatchedAttachmentRoomIdFromUrl('/dm/alice%3Abob')).toBe('direct-message:alice:bob');
expect(getWatchedAttachmentRoomIdFromUrl('/pm/dm-group-1?tab=chat')).toBe('direct-message:dm-group-1');
});
it('ignores non-message URLs', () => {
expect(getWatchedAttachmentRoomIdFromUrl('/settings')).toBeNull();
expect(getWatchedAttachmentRoomIdFromUrl('/dm')).toBeNull();
});
it('identifies direct-message attachment storage ids', () => {
expect(isDirectMessageAttachmentRoomId('direct-message:alice:bob')).toBe(true);
expect(isDirectMessageAttachmentRoomId('room-1')).toBe(false);
expect(isDirectMessageAttachmentRoomId(null)).toBe(false);
});
});

View File

@@ -1,6 +1,10 @@
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../constants/attachment.constants';
import type { Attachment } from '../models/attachment.model';
const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
const DIRECT_MESSAGE_URL_PATTERN = /^\/(?:dm|pm)\/([^/]+)/;
const DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX = 'direct-message:';
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('image/') ||
attachment.mime.startsWith('video/') ||
@@ -17,3 +21,28 @@ export function shouldPersistDownloadedAttachment(attachment: Pick<Attachment, '
attachment.mime.startsWith('video/') ||
attachment.mime.startsWith('audio/');
}
export function getWatchedAttachmentRoomIdFromUrl(url: string): string | null {
const path = url.split(/[?#]/, 1)[0];
const directMessageMatch = path.match(DIRECT_MESSAGE_URL_PATTERN);
if (directMessageMatch) {
return `${DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX}${decodeUrlSegment(directMessageMatch[1])}`;
}
const roomMatch = path.match(ROOM_URL_PATTERN);
return roomMatch ? decodeUrlSegment(roomMatch[1]) : null;
}
export function isDirectMessageAttachmentRoomId(roomId: string | null | undefined): boolean {
return !!roomId && roomId.startsWith(DIRECT_MESSAGE_ATTACHMENT_STORAGE_PREFIX);
}
function decodeUrlSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}

View File

@@ -1,6 +1,21 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
@let msg = message();
@let attachmentsList = attachmentViewModels();
@if (isSystemMessage()) {
<div
class="flex justify-center py-1"
[class.opacity-50]="msg.isDeleted"
>
<div
data-testid="chat-system-message"
[attr.data-message-id]="msg.id"
class="flex max-w-full items-center gap-2 rounded-full border border-border bg-secondary/45 px-3 py-1 text-center text-xs text-muted-foreground"
>
<span class="truncate">{{ msg.content }}</span>
<span class="shrink-0 opacity-70">{{ formatTimestamp(msg.timestamp) }}</span>
</div>
</div>
} @else {
<div
appThemeNode="chatMessageBubble"
[attr.data-message-id]="msg.id"
@@ -618,3 +633,4 @@
</app-bottom-sheet>
</ng-template>
</div>
}

View File

@@ -203,6 +203,7 @@ export class ChatMessageItemComponent implements OnDestroy {
readonly pluginEmbedToken = computed(() => parsePluginEmbedToken(this.message().content));
readonly pluginEmbeds = computed(() => this.findPluginEmbeds(this.pluginEmbedToken()));
readonly missingPluginEmbed = computed(() => this.resolveMissingPluginEmbed());
readonly isSystemMessage = computed(() => this.message().kind === 'system');
readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false);
readonly senderUser = computed<User>(() => {

View File

@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
const template = readFileSync(new URL('./chat-message-item.component.html', import.meta.url), 'utf8');
describe('ChatMessageItemComponent template', () => {
it('keeps system messages in a centered pill without full-row card styling', () => {
const systemMessageIndex = template.indexOf('data-testid="chat-system-message"');
const systemWrapperBlock = template.slice(Math.max(0, systemMessageIndex - 220), systemMessageIndex);
const messageRowBlock = template.match(/appThemeNode="chatMessageBubble"[\s\S]*?class="([^"]+)"/);
const systemMessageBlock = template.match(/data-testid="chat-system-message"[\s\S]*?class="([^"]+)"/);
expect(systemMessageIndex).toBeGreaterThan(-1);
expect(systemWrapperBlock).not.toContain('appThemeNode="chatMessageBubble"');
expect(systemWrapperBlock).not.toContain('[attr.data-message-id]');
expect(systemWrapperBlock).toContain('justify-center');
expect(messageRowBlock?.[1]).toBeDefined();
expect(messageRowBlock?.[1]).toMatch(/\brounded-lg\b/);
expect(messageRowBlock?.[1]).toMatch(/\bhover:bg-secondary\/30\b/);
expect(systemMessageBlock?.[1]).toBeDefined();
expect(systemMessageBlock?.[1]).toMatch(/\brounded-full\b/);
expect(systemMessageBlock?.[1]).toMatch(/\bborder\b/);
expect(systemMessageBlock?.[1]).toMatch(/\bbg-secondary\/45\b/);
});
});

View File

@@ -6,12 +6,13 @@ Direct calls coordinate private voice sessions started from people cards, direct
1. `DirectCallService.startCall()` creates or reuses the direct-message conversation for a peer, while `startConversationCall()` starts from an existing one-to-one or group conversation. Both paths reuse a live call for the same peer or group before creating a new session.
2. The caller joins a call-scoped voice session and sends a `direct-call` ring event through `PeerDeliveryService`. Joining a direct call first leaves any other joined call or server voice channel.
3. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. The ring stops when the recipient joins, declines, leaves, or the call ends.
4. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel.
5. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat.
6. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant.
7. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
8. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view.
3. The caller and recipient both record a direct-message `call-started` system entry for the call's conversation, so the chat history shows who started the call without creating a normal text message.
4. The recipient stores the incoming session, loops `assets/audio/call.wav`, shows an in-app answer/decline modal, and shows a desktop notification when permission allows. If the recipient is set to Do Not Disturb (`status: "busy"`), the session is stored silently without call audio, the in-app modal, or a desktop notification. Ring events received before the current user identity is hydrated are queued and replayed once identity is available. The ring stops when the recipient joins, declines, leaves, or the call ends; stale duplicate ring events for a locally ended call are ignored.
5. Opening `/call/:callId` shows the private call surface with portraits, voice indicators, media controls, screen/camera tiles, add-user control, and a narrow DM chat panel.
6. If a third participant is invited, the call creates a fresh empty group conversation and switches the call chat panel to it. Existing one-to-one messages stay in the original PM and are not copied into the group chat.
7. Starting a call from a group chat uses the group conversation id as the call id and rings every other participant.
8. Joining, leaving, ending, participant additions, and call chat conversion updates are mirrored as `direct-call` events over the same P2P/signaling fallback path used by direct messages.
9. The server rail shows call icons only while at least one participant is joined. If a user is viewing a private call after the session ends, the route returns to the call's chat view.
Incoming `direct-call` events are ignored unless the current user is declared in the event's `participantIds` or participant profiles, so only invited PM/group-call participants can receive call audio, the in-app incoming-call modal, or a desktop ring notification.

View File

@@ -9,6 +9,7 @@ import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { Subject } from 'rxjs';
import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service';
import { ViewportService } from '../../../../core/platform';
import {
VoiceActivityService,
VoiceConnectionFacade,
@@ -136,6 +137,87 @@ describe('DirectCallService', () => {
expect(context.service.incomingCall()).toBeNull();
});
it('does not start ringing after declining while incoming ring setup is still pending', async () => {
let releaseCallLog: (() => void) | null = null;
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directMessages.recordCallStarted.mockImplementationOnce(async () => new Promise<void>((resolve) => {
releaseCallLog = resolve;
}));
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
await vi.waitFor(() => expect(context.directMessages.recordCallStarted).toHaveBeenCalled());
context.service.declineIncomingCall('dm-alice-bob');
releaseCallLog?.();
await Promise.resolve();
await Promise.resolve();
expect(context.service.incomingCall()).toBeNull();
expect(context.audio.playLoop).not.toHaveBeenCalled();
expect(context.audio.stop).toHaveBeenCalledWith(AppSound.Call);
});
it('logs a system call-started entry when starting a direct PM call', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
await context.service.startCall(bob);
expect(context.directMessages.recordCallStarted).toHaveBeenCalledWith(
'dm-alice-bob',
expect.objectContaining({ userId: 'alice' }),
expect.arrayContaining([expect.objectContaining({ userId: 'alice' }), expect.objectContaining({ userId: 'bob' })]),
expect.any(Number)
);
});
it('logs a system call-started entry when receiving a PM ring', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
await vi.waitFor(() => expect(context.directMessages.recordCallStarted).toHaveBeenCalledWith(
'dm-alice-bob',
expect.objectContaining({ userId: 'alice' }),
expect.arrayContaining([expect.objectContaining({ userId: 'alice' }), expect.objectContaining({ userId: 'bob' })]),
10
));
});
it('does not restart the call sound when a stale ring arrives after declining', async () => {
const context = createServiceContext({ currentUser: bob, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
context.service.declineIncomingCall('dm-alice-bob');
context.audio.playLoop.mockClear();
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await vi.waitFor(() => expect(context.service.sessionById('dm-alice-bob')?.status).toBe('ended'));
expect(context.service.incomingCall()).toBeNull();
expect(context.audio.playLoop).not.toHaveBeenCalled();
});
it('shows a pending incoming call once the current user hydrates', async () => {
const context = createServiceContext({ currentUser: null, allUsers: [alice, bob] });
context.directCallEvents.next(createCallEvent('ring', alice, ['alice', 'bob']));
await Promise.resolve();
expect(context.service.sessionById('dm-alice-bob')).toBeNull();
context.currentUser.set(bob);
context.effectScheduler.flush();
await vi.waitFor(() => expect(context.service.incomingCall()?.callId).toBe('dm-alice-bob'));
await vi.waitFor(() => expect(context.audio.playLoop).toHaveBeenCalledWith(AppSound.Call));
});
it('rejoins an existing direct call instead of ringing a duplicate after leaving locally', async () => {
const context = createServiceContext({ currentUser: alice, allUsers: [alice, bob] });
const session = createSession('connected', true);
@@ -298,7 +380,7 @@ describe('DirectCallService', () => {
interface ServiceContextOptions {
allUsers: User[];
currentUser: User;
currentUser: User | null;
}
interface ServiceContext {
@@ -306,6 +388,7 @@ interface ServiceContext {
playLoop: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
};
currentUser: ReturnType<typeof signal<User | null>>;
delivery: {
sendCallEvent: ReturnType<typeof vi.fn>;
};
@@ -314,6 +397,10 @@ interface ServiceContext {
createConversation: ReturnType<typeof vi.fn>;
createGroupConversation: ReturnType<typeof vi.fn>;
openConversation: ReturnType<typeof vi.fn>;
recordCallStarted: ReturnType<typeof vi.fn>;
};
effectScheduler: {
flush: ReturnType<typeof vi.fn>;
};
router: {
navigate: ReturnType<typeof vi.fn>;
@@ -351,12 +438,13 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
})
};
const directMessages = {
createConversation: vi.fn(async (user: User) => createDirectConversation(options.currentUser, user)),
createConversation: vi.fn(async (user: User) => createDirectConversation(currentUser() ?? bob, user)),
createGroupConversation: vi.fn(async (participants: DirectMessageParticipant[], title?: string, conversationId = 'dm-group-test') => ({
...createGroupConversation(conversationId, participants.map(participantToUser)),
title
})),
openConversation: vi.fn(async () => undefined)
openConversation: vi.fn(async () => undefined),
recordCallStarted: vi.fn(async () => undefined)
};
const delivery = {
directCallEvents$: directCallEvents.asObservable(),
@@ -366,6 +454,25 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
playLoop: vi.fn(),
stop: vi.fn()
};
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
const effectScheduler = {
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
}),
flush: vi.fn(() => {
for (const scheduledEffect of scheduledEffects) {
if (scheduledEffect.dirty) {
scheduledEffect.run();
}
}
}),
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.delete(scheduledEffect);
}),
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
scheduledEffects.add(scheduledEffect);
})
};
const voice = {
broadcastMessage: vi.fn(),
disableVoice: vi.fn(),
@@ -391,12 +498,7 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
},
{
provide: EffectScheduler,
useValue: {
add: vi.fn(),
flush: vi.fn(),
remove: vi.fn(),
schedule: vi.fn()
}
useValue: effectScheduler
},
{
provide: DirectMessageService,
@@ -439,15 +541,23 @@ function createServiceContext(options: ServiceContextOptions): ServiceContext {
playPendingStreams: vi.fn(),
teardownAll: vi.fn()
}
},
{
provide: ViewportService,
useValue: {
isMobile: vi.fn(() => false)
}
}
]
});
return {
audio,
currentUser,
delivery,
directCallEvents,
directMessages,
effectScheduler,
router,
service: runInInjectionContext(injector, () => new DirectCallService()),
voice,

View File

@@ -44,6 +44,7 @@ export class DirectCallService {
private readonly users = this.store.selectSignal(selectAllUsers);
private readonly sessionsSignal = signal<DirectCallSession[]>([]);
private readonly mobileOverlayCallId = signal<string | null>(null);
private readonly pendingIncomingCallEvents: DirectCallEventPayload[] = [];
readonly sessions = computed(() => this.sessionsSignal());
readonly activeSessions = computed(() => this.sessions().filter((session) => session.status !== 'ended'));
@@ -85,6 +86,18 @@ export class DirectCallService {
}
});
effect(() => {
if (!this.currentUserId() || this.pendingIncomingCallEvents.length === 0) {
return;
}
const pendingEvents = this.pendingIncomingCallEvents.splice(0);
for (const payload of pendingEvents) {
void this.handleIncomingCallEvent(payload);
}
});
effect(() => {
const session = this.currentSession();
@@ -159,7 +172,7 @@ export class DirectCallService {
}
const existing = this.sessionById(conversation.id);
const session = existing ?? this.createSession({
const session = existing && existing.status !== 'ended' ? existing : this.createSession({
callId: conversation.id,
conversationId: conversation.id,
createdAt: Date.now(),
@@ -171,6 +184,14 @@ export class DirectCallService {
this.upsertSession(session);
this.currentSession.set(session);
await this.directMessages.recordCallStarted(
session.conversationId,
meParticipant,
Object.values(session.participants).map((participant) => participant.profile),
session.createdAt
);
await this.joinCall(session.callId, false);
this.sendCallEvent(peerParticipant.userId, 'ring', session);
await this.openCallView(session.callId);
@@ -236,6 +257,8 @@ export class DirectCallService {
}
declineIncomingCall(callId: string): void {
this.audio.stop(AppSound.Call);
const session = this.sessionById(callId);
if (!session || session.status === 'ended') {
@@ -253,8 +276,6 @@ export class DirectCallService {
status: 'ended' as const
};
this.audio.stop(AppSound.Call);
if (meId) {
this.broadcastCallEvent('leave', session);
}
@@ -385,18 +406,29 @@ export class DirectCallService {
}
private async handleIncomingCallEvent(payload: DirectCallEventPayload): Promise<void> {
const currentUserIds = this.currentUserIds();
const meId = this.currentUserId();
if (!meId || payload.sender.userId === meId) {
if (!meId) {
this.pendingIncomingCallEvents.push(payload);
return;
}
if (!this.callPayloadIncludesParticipant(payload, meId)) {
if (currentUserIds.has(payload.sender.userId)) {
return;
}
if (!this.callPayloadIncludesAnyParticipant(payload, currentUserIds)) {
return;
}
const participants = this.callParticipantsFromPayload(payload);
const existing = this.sessionById(payload.callId);
if (this.isStaleRingForEndedSession(payload, existing)) {
return;
}
const incomingSession = this.createSession({
callId: payload.callId,
conversationId: payload.conversationId,
@@ -424,14 +456,22 @@ export class DirectCallService {
if (payload.action === 'ring') {
await this.ensureCallConversation(session);
await this.directMessages.recordCallStarted(
session.conversationId,
payload.sender,
Object.values(session.participants).map((participant) => participant.profile),
session.createdAt
);
if (this.shouldAlertIncomingCall(session)) {
const latestSession = this.sessionById(session.callId);
if (latestSession && this.shouldPlayIncomingCallAlert(latestSession)) {
this.audio.playLoop(AppSound.Call);
} else {
this.audio.stop(AppSound.Call);
}
if (this.shouldAlertIncomingCall(session)) {
if (latestSession && this.shouldPlayIncomingCallAlert(latestSession)) {
await this.showIncomingNotification(payload.sender.displayName, payload.callId);
}
@@ -475,6 +515,14 @@ export class DirectCallService {
this.upsertSession(session);
this.currentSession.set(session);
await this.directMessages.recordCallStarted(
session.conversationId,
meParticipant,
Object.values(session.participants).map((participant) => participant.profile),
session.createdAt
);
await this.joinCall(session.callId, false);
this.broadcastCallEvent('ring', this.sessionById(session.callId) ?? session);
await this.router.navigate(['/call', session.callId]);
@@ -711,9 +759,15 @@ export class DirectCallService {
]);
}
private callPayloadIncludesParticipant(payload: DirectCallEventPayload, participantId: string): boolean {
return payload.participantIds.includes(participantId)
|| (payload.participants ?? []).some((participant) => participant.userId === participantId);
private callPayloadIncludesAnyParticipant(payload: DirectCallEventPayload, participantIds: Set<string>): boolean {
return payload.participantIds.some((participantId) => participantIds.has(participantId))
|| (payload.participants ?? []).some((participant) => participantIds.has(participant.userId));
}
private isStaleRingForEndedSession(payload: DirectCallEventPayload, existing: DirectCallSession | null): boolean {
return payload.action === 'ring'
&& existing?.status === 'ended'
&& payload.createdAt <= existing.createdAt;
}
private groupConversationTitle(session: DirectCallSession): string {
@@ -867,6 +921,10 @@ export class DirectCallService {
return session.status !== 'connected' && !this.isDoNotDisturb();
}
private shouldPlayIncomingCallAlert(session: DirectCallSession): boolean {
return this.shouldAlertIncomingCall(session) && this.incomingCall()?.callId === session.callId;
}
private isDoNotDisturb(): boolean {
return this.currentUser()?.status === 'busy';
}
@@ -923,6 +981,25 @@ export class DirectCallService {
return user ? this.userKey(user) : null;
}
private currentUserIds(): Set<string> {
const ids = new Set<string>();
const user = this.currentUser();
if (user?.id) {
ids.add(user.id);
}
if (user?.oderId) {
ids.add(user.oderId);
}
if (user?.peerId) {
ids.add(user.peerId);
}
return ids;
}
private requireCurrentUser(): User {
const user = this.currentUser();

View File

@@ -23,6 +23,8 @@ direct-message/
5. The recipient persists the message as `DELIVERED` and sends a `direct-message-status` event back.
6. Opening the conversation marks incoming messages as `ACKNOWLEDGED` and emits a status event.
Unread counts are idempotent by message id: re-receiving or syncing a message that already exists can update status/content metadata but must not increment the conversation unread count again.
Incoming PM and group-chat events are ignored unless the current user is declared in the message recipients, participant profiles, or existing local conversation. Sync requests are only answered for conversation participants, so a stray peer route cannot create unread state or expose private history.
Status transitions are monotonic, so a stale `SENT` event cannot overwrite `DELIVERED` or `ACKNOWLEDGED`.
@@ -37,6 +39,8 @@ When a private call grows beyond two participants, the direct-call domain create
The DM header and conversation list can start calls from both one-to-one and group conversations. Group calls reuse the group conversation id as the call id and send the same ring notification to every other participant.
Starting or receiving a PM/group call records a local `system` direct-message event with `systemEvent: "call-started"`. These entries are stored with deterministic ids based on the conversation and call timestamp, do not increment unread counts, and are rendered by the shared chat list as compact timeline rows instead of editable/reactable text messages.
Typing state is DM-owned as well. The composer emits `direct-message-typing` events, and the chat view renders the active peer names with a short TTL so the embedded private-call chat has the same typing feedback as a standalone PM.
When a conversation opens, a peer reconnects, or network service is restored, the selected conversation requests a bounded `direct-message-sync` snapshot from the peer. Incoming snapshots merge the newest messages by id instead of replacing local history, which lets clients backfill older PMs when their local stores drift.

View File

@@ -4,6 +4,7 @@ import {
createGroupConversation,
directMessageEventIncludesUser,
directMessageSyncIncludesUser,
createDirectCallStartedMessage,
getDirectConversationId,
isGroupDirectConversation,
updateMessageStatusInConversation,
@@ -88,6 +89,36 @@ describe('DirectMessageService domain flow', () => {
expect(updatedConversation.messages[0].recipientIds).toEqual(recipientIds);
});
it('does not increment unread when an existing message is upserted again', () => {
const conversation = createDirectConversation(alice, bob, 10);
const message = createMessage('message-1', 'SENT');
const withUnreadMessage = upsertDirectMessage(conversation, message, true);
const withDuplicateMessage = upsertDirectMessage(withUnreadMessage, { ...message, status: 'DELIVERED' }, true);
expect(withDuplicateMessage.messages).toHaveLength(1);
expect(withDuplicateMessage.unreadCount).toBe(1);
expect(withDuplicateMessage.messages[0].status).toBe('DELIVERED');
});
it('does not increment unread for call-started system messages', () => {
const conversation = createDirectConversation(alice, bob, 0);
const message = createDirectCallStartedMessage(conversation.id, alice, ['bob'], 123);
const withSystemMessage = upsertDirectMessage(conversation, message, true);
expect(withSystemMessage.unreadCount).toBe(0);
expect(withSystemMessage.messages).toHaveLength(1);
});
it('creates call-started system messages that do not read like normal text', () => {
const message = createDirectCallStartedMessage(getDirectConversationId('alice', 'bob'), alice, ['bob'], 123);
expect(message.id).toBe(`dm-call-started-${getDirectConversationId('alice', 'bob')}-123`);
expect(message.kind).toBe('system');
expect(message.systemEvent).toBe('call-started');
expect(message.content).toBe('Alice started a call');
expect(message.status).toBe('DELIVERED');
});
it('should update status correctly', () => {
expect(advanceDirectMessageStatus('QUEUED', 'SENT')).toBe('SENT');
expect(advanceDirectMessageStatus('SENT', 'DELIVERED')).toBe('DELIVERED');

View File

@@ -17,6 +17,7 @@ import {
advanceDirectMessageStatus,
createDirectConversation,
createGroupConversation,
createDirectCallStartedMessage,
directMessageConversationIncludesUser,
directMessageEventIncludesUser,
directMessageSyncIncludesUser,
@@ -244,6 +245,37 @@ export class DirectMessageService {
return message;
}
async recordCallStarted(
conversationId: string,
caller: DirectMessageParticipant,
participants: DirectMessageParticipant[],
timestamp = Date.now()
): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const currentParticipant = toDirectMessageParticipant(currentUser);
const allParticipants = this.uniqueParticipants([
currentParticipant,
caller,
...participants
]);
await this.loadForOwner(ownerId);
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === conversationId)
?? await this.repository.getConversation(ownerId, conversationId)
?? this.createConversationForSystemEvent(conversationId, currentParticipant, caller, allParticipants, timestamp);
const conversation = this.mergeConversationParticipants(existingConversation, allParticipants);
const message = createDirectCallStartedMessage(
conversation.id,
caller,
conversation.participants.filter((participantId) => participantId !== caller.userId),
timestamp
);
await this.persistConversation(ownerId, upsertDirectMessage(conversation, message, false));
}
async editMessage(conversationId: string, messageId: string, content: string): Promise<void> {
const normalizedContent = content.trim();
@@ -863,6 +895,25 @@ export class DirectMessageService {
};
}
private createConversationForSystemEvent(
conversationId: string,
currentParticipant: DirectMessageParticipant,
caller: DirectMessageParticipant,
participants: DirectMessageParticipant[],
timestamp: number
): DirectMessageConversation {
if (participants.length > 2) {
return createGroupConversation(conversationId, participants, timestamp);
}
const peer = participants.find((participant) => participant.userId !== currentParticipant.userId) ?? caller;
return {
...createDirectConversation(currentParticipant, peer, timestamp),
id: conversationId
};
}
private recipientIdsFor(conversation: DirectMessageConversation | null | undefined, currentUserId: string | null | undefined): string[] {
if (!conversation || !currentUserId) {
return [];

View File

@@ -72,6 +72,28 @@ export function createGroupConversation(
};
}
export function createDirectCallStartedMessage(
conversationId: string,
caller: DirectMessageParticipant,
recipientIds: string[],
timestamp: number
): DirectMessage {
return {
id: `dm-call-started-${conversationId}-${timestamp}`,
conversationId,
senderId: caller.userId,
recipientId: recipientIds[0] ?? caller.userId,
recipientIds,
content: `${caller.displayName || caller.username || caller.userId} started a call`,
timestamp,
status: 'DELIVERED',
kind: 'system',
systemEvent: 'call-started',
reactions: [],
isDeleted: false
};
}
export function isGroupDirectConversation(conversation: DirectMessageConversation): boolean {
return conversation.kind === 'group' || conversation.participants.length > 2;
}
@@ -104,6 +126,7 @@ export function upsertDirectMessage(
): DirectMessageConversation {
const existingIndex = conversation.messages.findIndex((entry) => entry.id === message.id);
const messages = [...conversation.messages];
const isNewMessage = existingIndex < 0;
if (existingIndex >= 0) {
const existing = messages[existingIndex];
@@ -119,11 +142,13 @@ export function upsertDirectMessage(
messages.sort((firstMessage, secondMessage) => firstMessage.timestamp - secondMessage.timestamp);
const shouldIncrementUnread = incrementUnread && isNewMessage && message.kind !== 'system';
return {
...conversation,
messages,
lastMessageAt: Math.max(conversation.lastMessageAt, message.timestamp),
unreadCount: incrementUnread ? conversation.unreadCount + 1 : conversation.unreadCount
unreadCount: shouldIncrementUnread ? conversation.unreadCount + 1 : conversation.unreadCount
};
}

View File

@@ -193,6 +193,8 @@ export class DmChatComponent {
senderName: knownUser?.displayName || participant?.displayName || (message.senderId === this.currentUserId() ? 'You' : message.senderId),
content: message.content,
timestamp: message.timestamp,
kind: message.kind,
systemEvent: message.systemEvent,
editedAt: message.editedAt,
reactions: message.reactions ?? [],
isDeleted: !!message.isDeleted,

View File

@@ -8,6 +8,7 @@ import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Subject, of } from 'rxjs';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import type { ActiveGameCandidateResult } from '../../../core/platform/electron/electron-api.models';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../server-directory';
import { UsersActions } from '../../../store/users/users.actions';
@@ -117,12 +118,38 @@ describe('GameActivityService sync', () => {
})
}));
});
it('does not ask Electron for running games while the app window is focused', () => {
const getActiveGameCandidate = vi.fn<() => Promise<ActiveGameCandidateResult>>(async () => ({
candidate: null,
fallbackProcessNames: ['Hades.exe']
}));
const getRunningProcessNames = vi.fn(async () => ['Hades.exe']);
const context = createServiceContext({
currentUser: alice,
allUsers: [alice],
documentHasFocus: true,
electronApi: {
getActiveGameCandidate,
getRunningProcessNames
}
});
context.service.start();
expect(getActiveGameCandidate).not.toHaveBeenCalled();
expect(getRunningProcessNames).not.toHaveBeenCalled();
});
});
interface ServiceContextOptions {
currentUser: User;
allUsers: User[];
electronApi?: { getRunningProcessNames: () => Promise<string[]> } | null;
electronApi?: {
getActiveGameCandidate?: () => Promise<ActiveGameCandidateResult>;
getRunningProcessNames?: () => Promise<string[]>;
} | null;
documentHasFocus?: boolean;
processNames?: string[];
gameMatchResponse?: GameMatchResponse;
}
@@ -141,6 +168,8 @@ interface ServiceContext {
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
vi.stubGlobal('document', { hasFocus: () => options.documentHasFocus ?? false });
const currentUser = signal<User | null>(options.currentUser);
const allUsers = signal<User[]>(options.allUsers);
const incomingMessages = new Subject<ChatEvent>();

View File

@@ -160,6 +160,13 @@ export class GameActivityService implements OnDestroy {
return;
}
if (this.isAppWindowFocused()) {
this.lastProcessHash = '';
this.ngZone.run(() => this.applyMatchedGame(null));
return;
}
this.scanInFlight = true;
try {
@@ -596,6 +603,12 @@ export class GameActivityService implements OnDestroy {
.join('|');
}
private isAppWindowFocused(): boolean {
return typeof document !== 'undefined'
&& typeof document.hasFocus === 'function'
&& document.hasFocus();
}
private getScanIntervalMs(): number {
const storedValue = Number.parseInt(localStorage.getItem(SCAN_INTERVAL_STORAGE_KEY) ?? '', 10);
const interval = Number.isFinite(storedValue) ? storedValue : DEFAULT_SCAN_INTERVAL_MS;

View File

@@ -149,6 +149,7 @@ The domain must avoid marking an entire historical backlog as unread the first t
- When `syncRoomCatalog()` sees a room for the first time, its baseline is set to `Date.now()`. Old stored messages stay treated as historical backlog.
- When a live message arrives before the room has been catalogued, `ensureRoomTracking()` uses `message.timestamp - 1` so that the current live message still counts as unread.
- When startup room metadata has not loaded channels yet, existing channel read markers are preserved until the real text-channel catalog arrives, preventing previously read messages from reappearing as unread after reload.
### Channel scope

View File

@@ -0,0 +1,156 @@
import {
Injector,
runInInjectionContext,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import type {
Message,
Room,
User
} from '../../../../shared-kernel';
import { NotificationAudioService } from '../../../../core/services/notification-audio.service';
import { DatabaseService } from '../../../../infrastructure/persistence';
import {
selectActiveChannelId,
selectCurrentRoom,
selectSavedRooms
} from '../../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { DesktopNotificationService } from '../../infrastructure/services/desktop-notification.service';
import { NotificationSettingsStorageService } from '../../infrastructure/services/notification-settings-storage.service';
import { createDefaultNotificationSettings, type NotificationsSettings } from '../../domain/models/notification.model';
import { NotificationsService } from './notifications.service';
const alice: User = {
id: 'alice',
oderId: 'alice',
username: 'alice',
displayName: 'Alice',
status: 'online',
role: 'member',
joinedAt: 1
};
describe('NotificationsService', () => {
it('keeps channel read markers when startup room metadata has no channels yet', async () => {
const roomWithoutChannels = createRoom({ channels: [] });
const context = createServiceContext({
currentUser: alice,
savedRooms: [roomWithoutChannels],
settings: {
...createDefaultNotificationSettings(),
roomBaselines: { 'room-1': 10 },
lastReadByChannel: {
'room-1': {
'channel-1': 100
}
}
}
});
await context.service.initialize();
expect(context.service.settings().lastReadByChannel['room-1']?.['channel-1']).toBe(100);
});
});
interface ServiceContextOptions {
currentUser: User | null;
savedRooms: Room[];
settings: NotificationsSettings;
}
interface ServiceContext {
service: NotificationsService;
}
function createServiceContext(options: ServiceContextOptions): ServiceContext {
const currentUser = signal<User | null>(options.currentUser);
const savedRooms = signal<Room[]>(options.savedRooms);
const currentRoom = signal<Room | null>(null);
const activeChannelId = signal<string | null>(null);
let storedSettings = options.settings;
const injector = Injector.create({
providers: [
{
provide: DatabaseService,
useValue: {
getMessagesSince: vi.fn(async (): Promise<Message[]> => [])
}
},
{
provide: DesktopNotificationService,
useValue: {
clearAttention: vi.fn(),
onWindowStateChanged: vi.fn(() => () => undefined),
requestAttention: vi.fn(),
showNotification: vi.fn(async () => undefined)
}
},
{
provide: NotificationAudioService,
useValue: {
play: vi.fn()
}
},
{
provide: NotificationSettingsStorageService,
useValue: {
load: vi.fn(() => storedSettings),
save: vi.fn((settings: NotificationsSettings) => {
storedSettings = settings;
})
}
},
{
provide: Store,
useValue: {
selectSignal: vi.fn((selector: unknown) => {
if (selector === selectCurrentRoom) {
return currentRoom;
}
if (selector === selectActiveChannelId) {
return activeChannelId;
}
if (selector === selectSavedRooms) {
return savedRooms;
}
if (selector === selectCurrentUser) {
return currentUser;
}
throw new Error('Unexpected selector requested by NotificationsService test.');
})
}
}
]
});
return {
service: runInInjectionContext(injector, () => new NotificationsService())
};
}
function createRoom(overrides: Partial<Room> = {}): Room {
return {
id: 'room-1',
name: 'Room 1',
description: '',
channels: [
{
id: 'channel-1',
name: 'general',
type: 'text'
}
],
members: [],
roles: [],
...overrides
} as Room;
}

View File

@@ -102,19 +102,24 @@ export class NotificationsService {
nextSettings.mutedRooms[room.id] = currentSettings.mutedRooms[room.id] === true;
nextSettings.roomBaselines[room.id] = currentSettings.roomBaselines[room.id] ?? now;
const hasChannelCatalog = (room.channels?.length ?? 0) > 0;
const textChannelIds = new Set(getRoomTextChannelIds(room));
const mutedChannels = currentSettings.mutedChannels[room.id] ?? {};
const lastReadByChannel = currentSettings.lastReadByChannel[room.id] ?? {};
nextSettings.mutedChannels[room.id] = Object.fromEntries(
Object.entries(mutedChannels)
.filter(([channelId, muted]) => textChannelIds.has(channelId) && muted === true)
);
nextSettings.mutedChannels[room.id] = hasChannelCatalog
? Object.fromEntries(
Object.entries(mutedChannels)
.filter(([channelId, muted]) => textChannelIds.has(channelId) && muted === true)
)
: { ...mutedChannels };
nextSettings.lastReadByChannel[room.id] = Object.fromEntries(
Object.entries(lastReadByChannel)
.filter((entry): entry is [string, number] => textChannelIds.has(entry[0]) && typeof entry[1] === 'number')
);
nextSettings.lastReadByChannel[room.id] = hasChannelCatalog
? Object.fromEntries(
Object.entries(lastReadByChannel)
.filter((entry): entry is [string, number] => textChannelIds.has(entry[0]) && typeof entry[1] === 'number')
)
: { ...lastReadByChannel };
}
this.setSettings(nextSettings);

View File

@@ -153,10 +153,10 @@
/>
<section
class="flex min-h-0 flex-col"
class="min-h-0 overflow-y-auto"
[class.hidden]="isMobile() && mobileTab() !== 'servers'"
>
<div class="z-10 flex shrink-0 items-center justify-between border-b border-border bg-background/95 px-3 py-2 backdrop-blur">
<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>
@@ -164,11 +164,11 @@
</div>
@if (isSearching()) {
<div class="flex flex-1 items-center justify-center py-8">
<div class="flex items-center justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if (searchResults().length === 0) {
<div class="flex flex-1 flex-col items-center justify-center px-4 py-10 text-muted-foreground">
<div class="flex flex-col items-center justify-center px-4 py-10 text-muted-foreground">
<ng-icon
name="lucideSearch"
class="mb-3 h-10 w-10 opacity-50"
@@ -176,20 +176,10 @@
<p class="text-sm font-medium">No servers found</p>
</div>
} @else {
<app-virtual-list
class="block min-h-0 flex-1 p-3"
[items]="searchResults()"
[estimateSize]="140"
[overscan]="4"
[trackBy]="trackServerById"
>
<ng-template
#item
let-server
>
<div class="pb-2">
<div
class="group w-full cursor-pointer rounded-lg border bg-card p-3 text-left transition-colors"
<div class="space-y-2 p-3">
@for (server of searchResults(); track server.id) {
<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)"
@@ -325,9 +315,8 @@
</div>
</div>
</div>
</div>
</ng-template>
</app-virtual-list>
}
</div>
}
</section>
</div>

View File

@@ -58,7 +58,6 @@ import {
import { ChatMessageMarkdownComponent } from '../../../chat';
import { hasRoomBanForUser } from '../../../access-control';
import { UserSearchListComponent } from '../../../direct-message/feature/user-search-list/user-search-list.component';
import { VirtualListComponent } from '../../../../shared/components/virtual-list';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import {
PluginRequirementService,
@@ -83,8 +82,7 @@ interface JoinPluginConsentDialog {
ChatMessageMarkdownComponent,
ConfirmDialogComponent,
LeaveServerDialogComponent,
UserSearchListComponent,
VirtualListComponent
UserSearchListComponent
],
viewProviders: [
provideIcons({
@@ -189,9 +187,6 @@ export class ServerSearchComponent implements OnInit {
this.searchSubject.next(query);
}
/** Stable trackBy reference for the virtualized server results list. */
readonly trackServerById = (_index: number, server: ServerInfo): string => server.id;
/** Join a server from the search results. Redirects to login if unauthenticated. */
async joinServer(server: ServerInfo): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');

View File

@@ -1,7 +1,9 @@
import type { Reaction } from './message.models';
export type DirectMessageStatus = 'QUEUED' | 'SENT' | 'DELIVERED' | 'ACKNOWLEDGED';
export type DirectMessageKind = 'user' | 'system';
export type DirectMessageMutationType = 'edit' | 'delete' | 'reaction-add' | 'reaction-remove';
export type DirectMessageSystemEvent = 'call-started';
export interface DirectMessageParticipant {
userId: string;
@@ -24,6 +26,8 @@ export interface DirectMessage {
content: string;
timestamp: number;
status: DirectMessageStatus;
kind?: DirectMessageKind;
systemEvent?: DirectMessageSystemEvent;
editedAt?: number;
isDeleted?: boolean;
reactions?: Reaction[];

View File

@@ -17,6 +17,8 @@ export interface Message {
senderName: string;
content: string;
timestamp: number;
kind?: 'user' | 'system';
systemEvent?: 'call-started';
editedAt?: number;
reactions: Reaction[];
isDeleted: boolean;

View File

@@ -1,14 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
DestroyRef,
computed,
effect,
inject,
OnDestroy,
output,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
@@ -33,7 +32,6 @@ import { FriendService } from '../../../domains/direct-message/application/servi
import { DirectCallService } from '../../../domains/direct-call/application/services/direct-call.service';
import { formatGameActivityElapsed } from '../../../domains/game-activity';
import { ExternalLinkService } from '../../../core/platform/external-link.service';
import { visibilityAwareInterval$ } from '../../rxjs';
import { UserStatusService } from '../../../core/services/user-status.service';
import {
EditableProfileAvatarSource,
@@ -66,7 +64,7 @@ import {
],
templateUrl: './profile-card-mobile.component.html'
})
export class ProfileCardMobileComponent {
export class ProfileCardMobileComponent implements OnDestroy {
readonly user = signal<User>({ id: '', oderId: '', username: '', displayName: '', status: 'offline', role: 'member', joinedAt: 0 });
readonly editable = signal(false);
readonly closed = output<undefined>();
@@ -120,10 +118,7 @@ export class ProfileCardMobileComponent {
readonly activityNow = signal(Date.now());
readonly busy = signal(false);
private readonly destroyRef = inject(DestroyRef);
private readonly activityTimerSub = visibilityAwareInterval$(1_000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.activityNow.set(Date.now()));
private readonly activityTimer = setInterval(() => this.activityNow.set(Date.now()), 1_000);
private readonly syncProfileDrafts = effect(
() => {
const user = this.displayedUser();
@@ -140,6 +135,10 @@ export class ProfileCardMobileComponent {
{ allowSignalWrites: true }
);
ngOnDestroy(): void {
clearInterval(this.activityTimer);
}
currentStatusColor(): string {
switch (this.displayedUser().status) {
case 'online':

View File

@@ -20,7 +20,8 @@ function createMessage(overrides: Partial<Message> = {}): Message {
function createContext(overrides: Record<string, unknown> = {}) {
return {
db: {
getMessages: vi.fn()
getMessages: vi.fn(),
getRoomMessageStats: vi.fn(async () => ({ count: 0, lastUpdated: 0 }))
},
webrtc: {
sendToPeer: vi.fn()
@@ -36,12 +37,12 @@ function createContext(overrides: Record<string, unknown> = {}) {
describe('dispatchIncomingMessage room-scoped sync', () => {
it('requests sync for event room even when another room is viewed', async () => {
const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b'
? [createMessage({ roomId: 'room-b', timestamp: 10, editedAt: 10 })]
: [createMessage({ roomId: 'room-a', timestamp: 100, editedAt: 100 })]);
const getRoomMessageStats = vi.fn(async (roomId: string) => roomId === 'room-b'
? { count: 1, lastUpdated: 10 }
: { count: 1, lastUpdated: 100 });
const sendToPeer = vi.fn();
const context = createContext({
db: { getMessages },
db: { getMessages: vi.fn(), getRoomMessageStats },
webrtc: { sendToPeer },
currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-b' }]
@@ -60,7 +61,7 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
).pipe(defaultIfEmpty(null))
);
expect(getMessages).toHaveBeenCalledWith('room-b', expect.any(Number), 0);
expect(getRoomMessageStats).toHaveBeenCalledWith('room-b');
expect(sendToPeer).toHaveBeenCalledWith('peer-1', {
type: 'chat-sync-request',
roomId: 'room-b'