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

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