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

View File

@@ -132,6 +132,12 @@ The facade's `searchServers(query)` method supports two modes controlled by a `s
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
## Server-owned room metadata
`ServerInfo` also carries the server-owned `channels` list for each room. Register and update calls persist this channel metadata on the server, and search or hydration responses return the normalised channel list so text and voice channel topology survives reloads, reconnects, and fresh joins.
The renderer may cache room data locally, but channel creation, rename, and removal must round-trip through the server-directory API instead of being treated as client-only state. Server-side normalisation also deduplicates channel names before persistence.
## Default endpoint management
Default servers are configured in the environment file. The state service builds `DefaultEndpointTemplate` objects from the configuration and uses them during reconciliation:

View File

@@ -1,3 +1,5 @@
import type { Channel } from '../../../shared-kernel';
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
export interface ServerInfo {
@@ -14,6 +16,7 @@ export interface ServerInfo {
hasPassword?: boolean;
isPrivate: boolean;
tags?: string[];
channels?: Channel[];
createdAt: number;
sourceId?: string;
sourceName?: string;

View File

@@ -8,7 +8,10 @@ import {
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { User } from '../../../shared-kernel';
import {
type Channel,
User
} from '../../../shared-kernel';
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
import type {
BanServerMemberRequest,
@@ -382,6 +385,7 @@ export class ServerDirectoryApiService {
hasPassword: this.getBooleanValue(candidate['hasPassword']),
isPrivate: this.getBooleanValue(candidate['isPrivate']),
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
channels: this.getChannelsValue(candidate['channels']),
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
sourceName: sourceName ?? source?.name,
@@ -399,6 +403,37 @@ export class ServerDirectoryApiService {
return typeof value === 'number' ? value : fallback;
}
private getChannelsValue(value: unknown): Channel[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
return value
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = this.getStringValue(channel['id']);
const name = this.getStringValue(channel['name']);
const type = this.getChannelTypeValue(channel['type']);
const position = this.getNumberValue(channel['position'], index);
if (!id || !name || !type) {
return null;
}
return {
id,
name,
type,
position
} satisfies Channel;
})
.filter((channel): channel is Channel => !!channel);
}
private getChannelTypeValue(value: unknown): Channel['type'] | undefined {
return value === 'text' || value === 'voice' ? value : undefined;
}
private getStringValue(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}