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

@@ -29,6 +29,7 @@ function createContext(overrides: Record<string, unknown> = {}) {
debugging: {},
currentUser: null,
currentRoom: null,
savedRooms: [],
...overrides
} as const;
}
@@ -42,7 +43,8 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
const context = createContext({
db: { getMessages },
webrtc: { sendToPeer },
currentRoom: { id: 'room-a' }
currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-b' }]
});
await firstValueFrom(
@@ -76,7 +78,8 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
const context = createContext({
db: { getMessages },
webrtc: { sendToPeer },
currentRoom: { id: 'room-a' }
currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-b' }]
});
await firstValueFrom(
@@ -97,4 +100,29 @@ describe('dispatchIncomingMessage room-scoped sync', () => {
messages: roomBMessages
});
});
it('ignores chat messages for rooms that are not saved or currently viewed', async () => {
const saveMessage = vi.fn(async () => undefined);
const rememberMessageRoom = vi.fn();
const context = createContext({
db: { saveMessage },
attachments: { rememberMessageRoom },
currentRoom: { id: 'room-a' },
savedRooms: [{ id: 'room-a' }]
});
const action = await firstValueFrom(
dispatchIncomingMessage(
{
type: 'chat-message',
message: createMessage({ roomId: 'room-b' })
} as never,
context as never
).pipe(defaultIfEmpty(null))
);
expect(action).toBeNull();
expect(saveMessage).not.toHaveBeenCalled();
expect(rememberMessageRoom).not.toHaveBeenCalled();
});
});

View File

@@ -96,6 +96,7 @@ export interface IncomingMessageContext {
debugging: DebuggingService;
currentUser: User | null;
currentRoom: Room | null;
savedRooms?: Room[];
}
/** Signature for an incoming-message handler function. */
@@ -110,11 +111,12 @@ type MessageHandler = (
*/
function handleInventoryRequest(
event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext
ctx: IncomingMessageContext
): Observable<Action> {
const { db, webrtc, attachments } = ctx;
const { roomId, fromPeerId } = event;
if (!roomId || !fromPeerId)
if (!roomId || !fromPeerId || !isKnownRoomId(roomId, ctx))
return EMPTY;
return from(
@@ -155,11 +157,12 @@ function handleInventoryRequest(
*/
function handleInventory(
event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext
ctx: IncomingMessageContext
): Observable<Action> {
const { db, webrtc, attachments } = ctx;
const { roomId, fromPeerId, items } = event;
if (!roomId || !Array.isArray(items) || !fromPeerId)
if (!roomId || !Array.isArray(items) || !fromPeerId || !isKnownRoomId(roomId, ctx))
return EMPTY;
return from(
@@ -197,11 +200,12 @@ function handleInventory(
*/
function handleSyncRequestIds(
event: IncomingMessageEvent,
{ db, webrtc, attachments }: IncomingMessageContext
ctx: IncomingMessageContext
): Observable<Action> {
const { db, webrtc, attachments } = ctx;
const { roomId, ids, fromPeerId } = event;
if (!Array.isArray(ids) || !fromPeerId)
if (!roomId || !Array.isArray(ids) || !fromPeerId || !isKnownRoomId(roomId, ctx))
return EMPTY;
return from(
@@ -210,7 +214,7 @@ function handleSyncRequestIds(
(ids as string[]).map((id) => db.getMessageById(id))
);
const messages = maybeMessages.filter(
(msg): msg is Message => !!msg
(msg): msg is Message => !!msg && msg.roomId === roomId
);
const hydrated = await Promise.all(
messages.map((msg) => hydrateMessage(msg, db))
@@ -250,19 +254,26 @@ function handleSyncRequestIds(
*/
function handleSyncBatch(
event: IncomingMessageEvent,
{ db, attachments }: IncomingMessageContext
ctx: IncomingMessageContext
): Observable<Action> {
if (!hasMessageBatch(event))
return EMPTY;
if (hasAttachmentMetaMap(event.attachments)) {
const scopedEvent = scopeMessageBatchToKnownRooms(event, ctx);
if (!scopedEvent)
return EMPTY;
const { db, attachments } = ctx;
if (hasAttachmentMetaMap(scopedEvent.attachments)) {
attachments.registerSyncedAttachments(
event.attachments,
Object.fromEntries(event.messages.map((message) => [message.id, message.roomId]))
scopedEvent.attachments,
Object.fromEntries(scopedEvent.messages.map((message) => [message.id, message.roomId]))
);
}
return from(processSyncBatch(event, db, attachments)).pipe(
return from(processSyncBatch(scopedEvent, db, attachments)).pipe(
mergeMap((toUpsert) =>
toUpsert.length > 0
? of(MessagesActions.syncMessages({ messages: toUpsert }))
@@ -316,18 +327,22 @@ function queueWatchedAttachmentDownloads(
/** Saves an incoming chat message to DB and dispatches receiveMessage. */
function handleChatMessage(
event: IncomingMessageEvent,
{
ctx: IncomingMessageContext
): Observable<Action> {
const {
db,
debugging,
attachments,
currentUser
}: IncomingMessageContext
): Observable<Action> {
} = ctx;
const msg = event.message;
if (!msg)
return EMPTY;
if (!isKnownRoomId(msg.roomId, ctx))
return EMPTY;
// Skip our own messages (reflected via server relay)
const isOwnMessage =
msg.senderId === currentUser?.id ||
@@ -536,11 +551,12 @@ function handleFileNotFound(
*/
function handleSyncSummary(
event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext
ctx: IncomingMessageContext
): Observable<Action> {
const { db, webrtc, currentRoom } = ctx;
const targetRoomId = event.roomId || currentRoom?.id;
if (!targetRoomId)
if (!targetRoomId || !isKnownRoomId(targetRoomId, ctx))
return EMPTY;
return from(
@@ -575,12 +591,13 @@ function handleSyncSummary(
/** Responds to a peer's full sync request by sending all local messages. */
function handleSyncRequest(
event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext
ctx: IncomingMessageContext
): Observable<Action> {
const { db, webrtc, currentRoom } = ctx;
const targetRoomId = event.roomId || currentRoom?.id;
const fromPeerId = event.fromPeerId;
if (!targetRoomId || !fromPeerId)
if (!targetRoomId || !fromPeerId || !isKnownRoomId(targetRoomId, ctx))
return EMPTY;
return from(
@@ -600,12 +617,17 @@ function handleSyncRequest(
/** Merges a full message dump from a peer into the local DB and store. */
function handleSyncFull(
event: IncomingMessageEvent,
{ db, attachments }: IncomingMessageContext
ctx: IncomingMessageContext
): Observable<Action> {
if (!hasMessageBatch(event))
return EMPTY;
return from(processSyncBatch(event, db, attachments)).pipe(
const scopedEvent = scopeMessageBatchToKnownRooms(event, ctx);
if (!scopedEvent)
return EMPTY;
return from(processSyncBatch(scopedEvent, ctx.db, ctx.attachments)).pipe(
mergeMap((toUpsert) =>
toUpsert.length > 0
? of(MessagesActions.syncMessages({ messages: toUpsert }))
@@ -657,6 +679,49 @@ export function dispatchIncomingMessage(
return handler ? handler(event, ctx) : EMPTY;
}
function isKnownRoomId(roomId: string | undefined, ctx: IncomingMessageContext): boolean {
if (!roomId) {
return false;
}
return ctx.currentRoom?.id === roomId || (ctx.savedRooms ?? []).some((room) => room.id === roomId);
}
function scopeMessageBatchToKnownRooms(
event: SyncBatchEvent,
ctx: IncomingMessageContext
): SyncBatchEvent | null {
if (event.roomId && !isKnownRoomId(event.roomId, ctx)) {
return null;
}
const messages = event.messages.filter((message) => isKnownRoomId(message.roomId, ctx));
if (messages.length === 0) {
return null;
}
return {
...event,
attachments: filterAttachmentMapToMessages(event.attachments, messages),
messages
};
}
function filterAttachmentMapToMessages(
attachmentMap: IncomingMessageEvent['attachments'],
messages: Message[]
): AttachmentMetaMap | undefined {
if (!hasAttachmentMetaMap(attachmentMap)) {
return undefined;
}
const messageIds = new Set(messages.map((message) => message.id));
const filteredEntries = Object.entries(attachmentMap).filter(([messageId]) => messageIds.has(messageId));
return filteredEntries.length > 0 ? Object.fromEntries(filteredEntries) : undefined;
}
function trackBackgroundOperation(
task: Promise<unknown> | unknown,
debugging: DebuggingService,

View File

@@ -30,7 +30,7 @@ import {
import { v4 as uuidv4 } from 'uuid';
import { MessagesActions } from './messages.actions';
import { selectCurrentUser } from '../users/users.selectors';
import { selectCurrentRoom } from '../rooms/rooms.selectors';
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { selectMessagesEntities } from './messages.selectors';
import { RealtimeSessionFacade } from '../../core/realtime';
import { DatabaseService } from '../../infrastructure/persistence';
@@ -457,12 +457,14 @@ export class MessagesEffects {
this.webrtc.onMessageReceived.pipe(
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
event,
currentUser,
currentRoom
currentRoom,
savedRooms
]) => {
const ctx: IncomingMessageContext = {
db: this.db,
@@ -470,7 +472,8 @@ export class MessagesEffects {
attachments: this.attachments,
debugging: this.debugging,
currentUser: currentUser ?? null,
currentRoom
currentRoom,
savedRooms
};
return dispatchIncomingMessage(event, ctx).pipe(
@@ -502,12 +505,14 @@ export class MessagesEffects {
this.webrtc.onSignalingMessage.pipe(
withLatestFrom(
this.store.select(selectCurrentUser),
this.store.select(selectCurrentRoom)
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms)
),
mergeMap(([
event,
currentUser,
currentRoom
currentRoom,
savedRooms
]) => {
if (event.type !== 'chat_message') {
return EMPTY;
@@ -519,7 +524,8 @@ export class MessagesEffects {
attachments: this.attachments,
debugging: this.debugging,
currentUser: currentUser ?? null,
currentRoom
currentRoom,
savedRooms
};
return dispatchIncomingMessage({

View File

@@ -20,9 +20,10 @@ import type {
} from '../../shared-kernel';
import { RealtimeSessionFacade } from '../../core/realtime';
import { UsersActions } from '../users/users.actions';
import { selectCurrentUser } from '../users/users.selectors';
import { selectAllUsers, selectCurrentUser } from '../users/users.selectors';
import { RoomsActions } from './rooms.actions';
import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors';
import { normalizeRoomAccessControl, resolveLegacyRole } from '../../domains/access-control';
import {
areRoomMembersEqual,
findRoomMember,
@@ -113,6 +114,40 @@ export class RoomMembersSyncEffects {
)
);
/** Keep active-room user roles derived from room access-control assignments. */
syncAccessControlRolesIntoUsers$ = createEffect(() =>
this.actions$.pipe(
ofType(
RoomsActions.createRoomSuccess,
RoomsActions.joinRoomSuccess,
RoomsActions.viewServerSuccess,
RoomsActions.updateRoom
),
withLatestFrom(
this.store.select(selectCurrentRoom),
this.store.select(selectSavedRooms),
this.store.select(selectAllUsers),
this.store.select(selectCurrentUser)
),
mergeMap(([
action,
currentRoom,
savedRooms,
allUsers,
currentUser
]) => {
const room = this.resolveRoleSyncRoom(action, currentRoom, savedRooms);
if (!room)
return EMPTY;
const actions = this.createUserRoleSyncActions(room, allUsers, currentUser ?? null);
return actions.length > 0 ? actions : EMPTY;
})
)
);
/** Update persisted room rosters when signaling presence changes arrive. */
signalingPresenceIntoRoomMembers$ = createEffect(() =>
this.webrtc.onSignalingMessage.pipe(
@@ -342,6 +377,88 @@ export class RoomMembersSyncEffects {
: [RoomsActions.updateRoom({ roomId: room.id, changes: { members } })];
}
private resolveRoleSyncRoom(
action:
| ReturnType<typeof RoomsActions.createRoomSuccess>
| ReturnType<typeof RoomsActions.joinRoomSuccess>
| ReturnType<typeof RoomsActions.viewServerSuccess>
| ReturnType<typeof RoomsActions.updateRoom>,
currentRoom: Room | null,
savedRooms: Room[]
): Room | null {
if ('room' in action) {
return normalizeRoomAccessControl(action.room);
}
if (currentRoom?.id !== action.roomId || !this.hasRoleRelevantRoomChanges(action.changes)) {
return null;
}
const room = this.resolveRoom(action.roomId, currentRoom, savedRooms);
return room ? normalizeRoomAccessControl({ ...room, ...action.changes }) : null;
}
private hasRoleRelevantRoomChanges(changes: Partial<Room>): boolean {
return (
Object.prototype.hasOwnProperty.call(changes, 'hostId') ||
Object.prototype.hasOwnProperty.call(changes, 'members') ||
Object.prototype.hasOwnProperty.call(changes, 'permissions') ||
Object.prototype.hasOwnProperty.call(changes, 'roles') ||
Object.prototype.hasOwnProperty.call(changes, 'roleAssignments') ||
Object.prototype.hasOwnProperty.call(changes, 'channelPermissions') ||
Object.prototype.hasOwnProperty.call(changes, 'slowModeInterval')
);
}
private createUserRoleSyncActions(room: Room, allUsers: User[], currentUser: User | null): Action[] {
const usersById = new Map<string, User>();
for (const user of allUsers) {
if (this.shouldSyncUserRoleForRoom(room, user, currentUser)) {
usersById.set(user.id, user);
}
}
if (currentUser) {
usersById.set(currentUser.id, currentUser);
}
return Array.from(usersById.values())
.map((user) => ({
user,
role: resolveLegacyRole(room, user)
}))
.filter(({ user, role }) => user.role !== role)
.map(({ user, role }) => UsersActions.updateUserRole({ userId: user.id,
role }));
}
private shouldSyncUserRoleForRoom(room: Room, user: User, currentUser: User | null): boolean {
if (currentUser && user.id === currentUser.id) {
return true;
}
if (room.hostId === user.id || room.hostId === user.oderId) {
return true;
}
if (Array.isArray(user.presenceServerIds) && user.presenceServerIds.includes(room.id)) {
return true;
}
if (findRoomMember(room.members ?? [], user.oderId || user.id)) {
return true;
}
return (room.roleAssignments ?? []).some((assignment) => (
assignment.userId === user.id ||
assignment.userId === user.oderId ||
assignment.oderId === user.id ||
assignment.oderId === user.oderId
));
}
private handleMemberRosterRequest(
event: ChatEvent,
currentRoom: Room | null,