feat: Allow admin to create new text channels

This commit is contained in:
2026-03-30 01:25:56 +02:00
parent 109402cdd6
commit 83694570e3
24 changed files with 563 additions and 64 deletions

View File

@@ -90,6 +90,12 @@ sequenceDiagram
User->>DC: broadcastMessage(delete-message)
```
## Text channel scoping
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server.
If a room has no text channels, the room shell in `features/room/chat-room/` renders an empty state instead of mounting the chat view. The chat domain only mounts once a valid text channel exists.
## Message sync
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
@@ -140,4 +146,4 @@ graph LR
## Typing indicator
`TypingIndicatorComponent` listens for typing events from peers. Each event resets a 3-second TTL timer. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each event resets a 3-second TTL timer for that channel. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".

View File

@@ -108,8 +108,18 @@ export class ChatMessagesComponent {
}
handleTypingStarted(): void {
const roomId = this.currentRoom()?.id;
if (!roomId) {
return;
}
try {
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
this.webrtc.sendRawMessage({
type: 'typing',
serverId: roomId,
channelId: this.activeChannelId() ?? 'general'
});
} catch {
/* ignore */
}

View File

@@ -9,7 +9,10 @@ import {
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import {
selectActiveChannelId,
selectCurrentRoom
} from '../../../../store/rooms/rooms.selectors';
import {
merge,
interval,
@@ -27,6 +30,7 @@ interface TypingSignalingMessage {
displayName: string;
oderId: string;
serverId: string;
channelId?: string;
}
@Component({
@@ -39,10 +43,12 @@ interface TypingSignalingMessage {
}
})
export class TypingIndicatorComponent {
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
private readonly typingMap = new Map<string, { name: string; channelId: string; expiresAt: number }>();
private readonly store = inject(Store);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private lastRoomId: string | null = null;
private lastConversationKey: string | null = null;
typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0);
@@ -60,9 +66,13 @@ export class TypingIndicatorComponent {
filter((msg) => msg.serverId === this.currentRoom()?.id),
tap((msg) => {
const now = Date.now();
const channelId = typeof msg.channelId === 'string' && msg.channelId.trim()
? msg.channelId.trim()
: 'general';
this.typingMap.set(msg.oderId, {
this.typingMap.set(`${channelId}:${msg.oderId}`, {
name: msg.displayName,
channelId,
expiresAt: now + TYPING_TTL
});
})
@@ -89,20 +99,27 @@ export class TypingIndicatorComponent {
effect(() => {
const roomId = this.currentRoom()?.id ?? null;
const activeChannelId = this.activeChannelId() ?? 'general';
const conversationKey = roomId ? `${roomId}:${activeChannelId}` : null;
if (roomId === this.lastRoomId)
if (roomId !== this.lastRoomId) {
this.lastRoomId = roomId;
this.typingMap.clear();
}
if (conversationKey === this.lastConversationKey)
return;
this.lastRoomId = roomId;
this.typingMap.clear();
this.lastConversationKey = conversationKey;
this.recomputeDisplay();
});
}
private recomputeDisplay(): void {
const now = Date.now();
const activeChannelId = this.activeChannelId() ?? 'general';
const names = Array.from(this.typingMap.values())
.filter((e) => e.expiresAt > now)
.filter((entry) => entry.expiresAt > now && entry.channelId === activeChannelId)
.map((e) => e.name);
this.typingDisplay.set(names.slice(0, MAX_SHOWN));