fix: Improve plugin ui entry points, Fix chat scroll, fix notifications, fix user rights

This commit is contained in:
2026-05-17 16:09:16 +02:00
parent 8e3ccf4157
commit 8631290c01
35 changed files with 1560 additions and 619 deletions

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.
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`.
## Chat View

View File

@@ -2,6 +2,8 @@ import {
advanceDirectMessageStatus,
createDirectConversation,
createGroupConversation,
directMessageEventIncludesUser,
directMessageSyncIncludesUser,
getDirectConversationId,
isGroupDirectConversation,
updateMessageStatusInConversation,
@@ -92,6 +94,30 @@ describe('DirectMessageService domain flow', () => {
expect(advanceDirectMessageStatus('DELIVERED', 'SENT')).toBe('DELIVERED');
expect(advanceDirectMessageStatus('DELIVERED', 'ACKNOWLEDGED')).toBe('ACKNOWLEDGED');
});
it('recognises only declared direct-message recipients and participants', () => {
const payload = {
message: createMessage('message-1', 'SENT', 'dm-group-test', ['bob']),
participants: [alice, bob],
sender: alice
};
expect(directMessageEventIncludesUser(payload, 'bob')).toBe(true);
expect(directMessageEventIncludesUser(payload, 'charlie')).toBe(false);
});
it('recognises only declared sync participants', () => {
const payload = {
conversationId: 'dm-group-test',
messages: [],
participants: [alice, bob],
sender: alice,
syncedAt: 30
};
expect(directMessageSyncIncludesUser(payload, 'alice')).toBe(true);
expect(directMessageSyncIncludesUser(payload, 'charlie')).toBe(false);
});
});
function createMessage(

View File

@@ -17,6 +17,9 @@ import {
advanceDirectMessageStatus,
createDirectConversation,
createGroupConversation,
directMessageConversationIncludesUser,
directMessageEventIncludesUser,
directMessageSyncIncludesUser,
getDirectConversationId,
isGroupDirectConversation,
updateMessageStatusInConversation,
@@ -464,6 +467,11 @@ export class DirectMessageService {
private async handleIncomingMessage(payload: DirectMessageEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
if (!directMessageEventIncludesUser(payload, ownerId) || payload.sender.userId === ownerId || payload.message.senderId === ownerId) {
return;
}
const currentParticipant = toDirectMessageParticipant(currentUser);
const sender = payload.sender;
const conversationId = payload.message.conversationId
@@ -528,7 +536,11 @@ export class DirectMessageService {
private async handleIncomingMutation(payload: DirectMessageMutationEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const conversation = await this.requireConversation(ownerId, payload.conversationId);
const conversation = await this.findConversation(ownerId, payload.conversationId);
if (!conversation || !directMessageConversationIncludesUser(conversation, ownerId)) {
return;
}
await this.persistConversation(ownerId, this.applyMutation(conversation, payload));
}
@@ -540,6 +552,14 @@ export class DirectMessageService {
return;
}
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId);
if (!conversation
|| !directMessageConversationIncludesUser(conversation, currentUserId)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return;
}
if (!payload.isTyping) {
this.typingEntriesSignal.update((entries) => entries.filter((entry) =>
!(entry.conversationId === payload.conversationId && entry.userId === payload.sender.userId)
@@ -566,10 +586,12 @@ export class DirectMessageService {
private async handleIncomingSyncRequest(payload: DirectMessageSyncRequestEventPayload): Promise<void> {
const ownerId = this.getCurrentUserIdOrThrow();
const currentUser = this.requireCurrentUser();
const conversation = this.conversationsSignal().find((entry) => entry.id === payload.conversationId)
?? await this.repository.getConversation(ownerId, payload.conversationId);
const conversation = await this.findConversation(ownerId, payload.conversationId);
if (!conversation || payload.sender.userId === ownerId) {
if (!conversation
|| payload.sender.userId === ownerId
|| !directMessageConversationIncludesUser(conversation, ownerId)
|| !directMessageConversationIncludesUser(conversation, payload.sender.userId)) {
return;
}
@@ -596,6 +618,10 @@ export class DirectMessageService {
return;
}
if (!directMessageSyncIncludesUser(payload, ownerId) || !directMessageSyncIncludesUser(payload, payload.sender.userId)) {
return;
}
const existingConversation = this.conversationsSignal().find((conversation) => conversation.id === payload.conversationId)
?? await this.repository.getConversation(ownerId, payload.conversationId)
?? (payload.conversationKind === 'group' || payload.participants.length > 2
@@ -863,10 +889,7 @@ export class DirectMessageService {
}
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);
const conversation = await this.findConversation(ownerId, conversationId);
if (!conversation) {
throw new Error('Direct message conversation not found.');
@@ -875,6 +898,13 @@ export class DirectMessageService {
return conversation;
}
private async findConversation(ownerId: string, conversationId: string): Promise<DirectMessageConversation | null> {
await this.loadForOwner(ownerId);
return this.conversationsSignal().find((entry) => entry.id === conversationId)
?? await this.repository.getConversation(ownerId, conversationId);
}
private requireCurrentUser(): User {
const currentUser = this.currentUser();

View File

@@ -1,7 +1,9 @@
import type {
DirectMessage,
DirectMessageConversation,
DirectMessageEventPayload,
DirectMessageParticipant,
DirectMessageSyncEventPayload,
DirectMessageStatus
} from '../models/direct-message.model';
@@ -74,6 +76,27 @@ export function isGroupDirectConversation(conversation: DirectMessageConversatio
return conversation.kind === 'group' || conversation.participants.length > 2;
}
export function directMessageConversationIncludesUser(
conversation: Pick<DirectMessageConversation, 'participantProfiles' | 'participants'>,
userId: string
): boolean {
return conversation.participants.includes(userId) || !!conversation.participantProfiles[userId];
}
export function directMessageEventIncludesUser(
payload: DirectMessageEventPayload,
userId: string
): boolean {
return collectDirectMessageEventParticipantIds(payload).has(userId);
}
export function directMessageSyncIncludesUser(
payload: DirectMessageSyncEventPayload,
userId: string
): boolean {
return payload.participants.some((participant) => participant.userId === userId);
}
export function upsertDirectMessage(
conversation: DirectMessageConversation,
message: DirectMessage,
@@ -129,6 +152,30 @@ function uniqueDirectMessageParticipants(participants: DirectMessageParticipant[
});
}
function collectDirectMessageEventParticipantIds(payload: DirectMessageEventPayload): Set<string> {
const participantIds = new Set<string>();
if (payload.message.senderId) {
participantIds.add(payload.message.senderId);
}
if (payload.message.recipientId) {
participantIds.add(payload.message.recipientId);
}
for (const recipientId of payload.message.recipientIds ?? []) {
participantIds.add(recipientId);
}
for (const participant of payload.participants ?? []) {
if (participant.userId) {
participantIds.add(participant.userId);
}
}
return participantIds;
}
function buildGroupConversationTitle(participants: DirectMessageParticipant[]): string {
const names = participants.map((participant) => participant.displayName || participant.username || participant.userId);