fix: Improve plugin ui entry points, Fix chat scroll, fix notifications, fix user rights
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user