Private servers with password and invite links (Experimental)

This commit is contained in:
2026-03-18 20:42:40 +01:00
parent f8fd78d21a
commit eb987ac672
54 changed files with 2910 additions and 286 deletions

View File

@@ -17,6 +17,11 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
},
{
path: 'invite/:inviteId',
loadComponent: () =>
import('./features/invite/invite.component').then((module) => module.InviteComponent)
},
{
path: 'search',
loadComponent: () =>

View File

@@ -2,6 +2,7 @@
import {
Component,
OnInit,
OnDestroy,
inject,
HostListener
} from '@angular/core';
@@ -35,6 +36,15 @@ import {
STORAGE_KEY_LAST_VISITED_ROUTE
} from './core/constants';
interface DeepLinkElectronApi {
consumePendingDeepLink?: () => Promise<string | null>;
onDeepLinkReceived?: (listener: (url: string) => void) => () => void;
}
type DeepLinkWindow = Window & {
electronAPI?: DeepLinkElectronApi;
};
@Component({
selector: 'app-root',
imports: [
@@ -50,7 +60,7 @@ import {
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App implements OnInit {
export class App implements OnInit, OnDestroy {
store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom);
desktopUpdates = inject(DesktopAppUpdateService);
@@ -63,6 +73,7 @@ export class App implements OnInit {
private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionService);
private externalLinks = inject(ExternalLinkService);
private deepLinkCleanup: (() => void) | null = null;
@HostListener('document:click', ['$event'])
onGlobalLinkClick(evt: MouseEvent): void {
@@ -80,6 +91,8 @@ export class App implements OnInit {
await this.timeSync.syncWithEndpoint(apiBase);
} catch {}
await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser());
this.store.dispatch(RoomsActions.loadRooms());
@@ -87,8 +100,12 @@ export class App implements OnInit {
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) {
if (this.router.url !== '/login' && this.router.url !== '/register') {
this.router.navigate(['/login']).catch(() => {});
if (!this.isPublicRoute(this.router.url)) {
this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
}
}).catch(() => {});
}
} else {
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
@@ -116,6 +133,11 @@ export class App implements OnInit {
});
}
ngOnDestroy(): void {
this.deepLinkCleanup?.();
this.deepLinkCleanup = null;
}
openNetworkSettings(): void {
this.settingsModal.open('network');
}
@@ -131,4 +153,78 @@ export class App implements OnInit {
async restartToApplyUpdate(): Promise<void> {
await this.desktopUpdates.restartToApplyUpdate();
}
private async setupDesktopDeepLinks(): Promise<void> {
const electronApi = this.getDeepLinkElectronApi();
if (!electronApi) {
return;
}
this.deepLinkCleanup = electronApi.onDeepLinkReceived?.((url) => {
void this.handleDesktopDeepLink(url);
}) || null;
const pendingDeepLink = await electronApi.consumePendingDeepLink?.();
if (pendingDeepLink) {
await this.handleDesktopDeepLink(pendingDeepLink);
}
}
private async handleDesktopDeepLink(url: string): Promise<void> {
const invite = this.parseDesktopInviteUrl(url);
if (!invite) {
return;
}
await this.router.navigate(['/invite', invite.inviteId], {
queryParams: {
server: invite.sourceUrl
}
});
}
private getDeepLinkElectronApi(): DeepLinkElectronApi | null {
return typeof window !== 'undefined'
? (window as DeepLinkWindow).electronAPI ?? null
: null;
}
private isPublicRoute(url: string): boolean {
return url === '/login' ||
url === '/register' ||
url.startsWith('/invite/');
}
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'toju:') {
return null;
}
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
.map((segment) => decodeURIComponent(segment));
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
return null;
}
const sourceUrl = parsedUrl.searchParams.get('server')?.trim();
if (!sourceUrl) {
return null;
}
return {
inviteId: pathSegments[1],
sourceUrl
};
} catch {
return null;
}
}
}

View File

@@ -71,6 +71,7 @@ export interface Room {
topic?: string;
hostId: string;
password?: string;
hasPassword?: boolean;
isPrivate: boolean;
createdAt: number;
userCount: number;
@@ -80,6 +81,9 @@ export interface Room {
permissions?: RoomPermissions;
channels?: Channel[];
members?: RoomMember[];
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
}
export interface RoomSettings {
@@ -88,6 +92,7 @@ export interface RoomSettings {
topic?: string;
isPrivate: boolean;
password?: string;
hasPassword?: boolean;
maxUsers?: number;
rules?: string[];
}
@@ -265,14 +270,14 @@ export interface ChatEvent {
displayName?: string;
emoji?: string;
reason?: string;
settings?: RoomSettings;
settings?: Partial<RoomSettings>;
permissions?: Partial<RoomPermissions>;
voiceState?: Partial<VoiceState>;
isScreenSharing?: boolean;
icon?: string;
iconUpdatedAt?: number;
role?: UserRole;
room?: Room;
room?: Partial<Room>;
channels?: Channel[];
members?: RoomMember[];
ban?: BanEntry;
@@ -292,11 +297,13 @@ export interface ServerInfo {
ownerPublicKey?: string;
userCount: number;
maxUsers: number;
hasPassword?: boolean;
isPrivate: boolean;
tags?: string[];
createdAt: number;
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
}
export interface JoinRequest {

View File

@@ -12,11 +12,7 @@ import {
forkJoin
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import {
ServerInfo,
JoinRequest,
User
} from '../models/index';
import { ServerInfo, User } from '../models/index';
import { v4 as uuidv4 } from 'uuid';
import { environment } from '../../../environments/environment';
@@ -40,6 +36,69 @@ export interface ServerEndpoint {
latency?: number;
}
export interface ServerSourceSelector {
sourceId?: string;
sourceUrl?: string;
}
export interface ServerJoinAccessRequest {
roomId: string;
userId: string;
userPublicKey: string;
displayName: string;
password?: string;
inviteId?: string;
}
export interface ServerJoinAccessResponse {
success: boolean;
signalingUrl: string;
joinedBefore: boolean;
via: 'membership' | 'password' | 'invite' | 'public';
server: ServerInfo;
}
export interface CreateServerInviteRequest {
requesterUserId: string;
requesterDisplayName?: string;
requesterRole?: string;
}
export interface ServerInviteInfo {
id: string;
serverId: string;
createdAt: number;
expiresAt: number;
inviteUrl: string;
browserUrl: string;
appUrl: string;
sourceUrl: string;
createdBy?: string;
createdByDisplayName?: string;
isExpired: boolean;
server: ServerInfo;
}
export interface KickServerMemberRequest {
actorUserId: string;
actorRole?: string;
targetUserId: string;
}
export interface BanServerMemberRequest extends KickServerMemberRequest {
banId?: string;
displayName?: string;
reason?: string;
expiresAt?: number;
}
export interface UnbanServerMemberRequest {
actorUserId: string;
actorRole?: string;
banId?: string;
targetUserId?: string;
}
/** localStorage key that persists the user's configured endpoints. */
const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
/** Timeout (ms) for server health-check and alternative-endpoint pings. */
@@ -131,7 +190,7 @@ export class ServerDirectoryService {
*
* @param server - Name and URL of the endpoint to add.
*/
addServer(server: { name: string; url: string }): void {
addServer(server: { name: string; url: string }): ServerEndpoint {
const sanitisedUrl = this.sanitiseUrl(server.url);
const newEndpoint: ServerEndpoint = {
id: uuidv4(),
@@ -144,6 +203,40 @@ export class ServerDirectoryService {
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
this.saveEndpoints();
return newEndpoint;
}
/** Ensure an endpoint exists for a given URL, optionally activating it. */
ensureServerEndpoint(
server: { name: string; url: string },
options?: { setActive?: boolean }
): ServerEndpoint {
const sanitisedUrl = this.sanitiseUrl(server.url);
const existing = this.findServerByUrl(sanitisedUrl);
if (existing) {
if (options?.setActive) {
this.setActiveServer(existing.id);
}
return existing;
}
const created = this.addServer({ name: server.name,
url: sanitisedUrl });
if (options?.setActive) {
this.setActiveServer(created.id);
}
return created;
}
/** Find a configured endpoint by URL. */
findServerByUrl(url: string): ServerEndpoint | undefined {
const sanitisedUrl = this.sanitiseUrl(url);
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
}
/**
@@ -265,18 +358,13 @@ export class ServerDirectoryService {
}
/** Expose the API base URL for external consumers. */
getApiBaseUrl(): string {
return this.buildApiBaseUrl();
getApiBaseUrl(selector?: ServerSourceSelector): string {
return this.buildApiBaseUrl(selector);
}
/** Get the WebSocket URL derived from the active endpoint. */
getWebSocketUrl(): string {
const active = this.activeServer();
if (!active)
return buildDefaultServerUrl().replace(/^http/, 'ws');
return active.url.replace(/^http/, 'ws');
getWebSocketUrl(selector?: ServerSourceSelector): string {
return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws');
}
/**
@@ -310,11 +398,11 @@ export class ServerDirectoryService {
}
/** Fetch details for a single server. */
getServer(serverId: string): Observable<ServerInfo | null> {
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.http
.get<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
.get<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`)
.pipe(
map((server) => this.normalizeServerInfo(server, this.activeServer())),
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
catchError((error) => {
console.error('Failed to get server:', error);
return of(null);
@@ -324,10 +412,11 @@ export class ServerDirectoryService {
/** Register a new server listing in the directory. */
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string }
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.http
.post<ServerInfo>(`${this.buildApiBaseUrl()}/servers`, server)
.post<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers`, server)
.pipe(
catchError((error) => {
console.error('Failed to register server:', error);
@@ -339,10 +428,15 @@ export class ServerDirectoryService {
/** Update an existing server listing. */
updateServer(
serverId: string,
updates: Partial<ServerInfo> & { currentOwnerId: string }
updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.http
.put<ServerInfo>(`${this.buildApiBaseUrl()}/servers/${serverId}`, updates)
.put<ServerInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`, updates)
.pipe(
catchError((error) => {
console.error('Failed to update server:', error);
@@ -352,9 +446,9 @@ export class ServerDirectoryService {
}
/** Remove a server listing from the directory. */
unregisterServer(serverId: string): Observable<void> {
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
return this.http
.delete<void>(`${this.buildApiBaseUrl()}/servers/${serverId}`)
.delete<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`)
.pipe(
catchError((error) => {
console.error('Failed to unregister server:', error);
@@ -364,9 +458,9 @@ export class ServerDirectoryService {
}
/** Retrieve users currently connected to a server. */
getServerUsers(serverId: string): Observable<User[]> {
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
return this.http
.get<User[]>(`${this.buildApiBaseUrl()}/servers/${serverId}/users`)
.get<User[]>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/users`)
.pipe(
catchError((error) => {
console.error('Failed to get server users:', error);
@@ -377,11 +471,12 @@ export class ServerDirectoryService {
/** Send a join request for a server and receive the signaling URL. */
requestJoin(
request: JoinRequest
): Observable<{ success: boolean; signalingUrl?: string }> {
request: ServerJoinAccessRequest,
selector?: ServerSourceSelector
): Observable<ServerJoinAccessResponse> {
return this.http
.post<{ success: boolean; signalingUrl?: string }>(
`${this.buildApiBaseUrl()}/servers/${request.roomId}/join`,
.post<ServerJoinAccessResponse>(
`${this.buildApiBaseUrl(selector)}/servers/${request.roomId}/join`,
request
)
.pipe(
@@ -392,6 +487,82 @@ export class ServerDirectoryService {
);
}
/** Create an expiring invite link for a server. */
createInvite(
serverId: string,
request: CreateServerInviteRequest,
selector?: ServerSourceSelector
): Observable<ServerInviteInfo> {
return this.http
.post<ServerInviteInfo>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
.pipe(
catchError((error) => {
console.error('Failed to create invite:', error);
return throwError(() => error);
})
);
}
/** Retrieve public invite metadata. */
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
return this.http
.get<ServerInviteInfo>(`${this.buildApiBaseUrl(selector)}/invites/${inviteId}`)
.pipe(
catchError((error) => {
console.error('Failed to get invite:', error);
return throwError(() => error);
})
);
}
/** Remove a member's stored join access for a server. */
kickServerMember(
serverId: string,
request: KickServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
.pipe(
catchError((error) => {
console.error('Failed to kick server member:', error);
return throwError(() => error);
})
);
}
/** Ban a member from a server invite/password access list. */
banServerMember(
serverId: string,
request: BanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
.pipe(
catchError((error) => {
console.error('Failed to ban server member:', error);
return throwError(() => error);
})
);
}
/** Remove a stored server ban. */
unbanServerMember(
serverId: string,
request: UnbanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.http
.post<void>(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
.pipe(
catchError((error) => {
console.error('Failed to unban server member:', error);
return throwError(() => error);
})
);
}
/** Notify the directory that a user has left a server. */
notifyLeave(serverId: string, userId: string): Observable<void> {
return this.http
@@ -432,17 +603,8 @@ export class ServerDirectoryService {
* Build the active endpoint's API base URL, stripping trailing
* slashes and accidental `/api` suffixes.
*/
private buildApiBaseUrl(): string {
const active = this.activeServer();
const rawUrl = active ? active.url : buildDefaultServerUrl();
let base = rawUrl.replace(/\/+$/, '');
if (base.toLowerCase().endsWith('/api')) {
base = base.slice(0, -4);
}
return `${base}/api`;
private buildApiBaseUrl(selector?: ServerSourceSelector): string {
return `${this.resolveBaseServerUrl(selector)}/api`;
}
/** Strip trailing slashes and `/api` suffix from a URL. */
@@ -456,6 +618,26 @@ export class ServerDirectoryService {
return cleaned;
}
private resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null {
if (selector?.sourceId) {
return this._servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null;
}
if (selector?.sourceUrl) {
return this.findServerByUrl(selector.sourceUrl) ?? null;
}
return this.activeServer() ?? this._servers()[0] ?? null;
}
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
if (selector?.sourceUrl) {
return this.sanitiseUrl(selector.sourceUrl);
}
return this.resolveEndpoint(selector)?.url ?? buildDefaultServerUrl();
}
/**
* Handle both `{ servers: [...] }` and direct `ServerInfo[]`
* response shapes from the directory API.
@@ -560,45 +742,44 @@ export class ServerDirectoryService {
source?: ServerEndpoint | null
): ServerInfo {
const candidate = server as Record<string, unknown>;
const userCount = typeof candidate['userCount'] === 'number'
? candidate['userCount']
: (typeof candidate['currentUsers'] === 'number' ? candidate['currentUsers'] : 0);
const maxUsers = typeof candidate['maxUsers'] === 'number' ? candidate['maxUsers'] : 0;
const isPrivate = typeof candidate['isPrivate'] === 'boolean'
? candidate['isPrivate']
: candidate['isPrivate'] === 1;
const sourceName = this.getStringValue(candidate['sourceName']);
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
return {
id: typeof candidate['id'] === 'string' ? candidate['id'] : '',
name: typeof candidate['name'] === 'string' ? candidate['name'] : 'Unnamed server',
description: typeof candidate['description'] === 'string' ? candidate['description'] : undefined,
topic: typeof candidate['topic'] === 'string' ? candidate['topic'] : undefined,
hostName:
typeof candidate['hostName'] === 'string'
? candidate['hostName']
: (typeof candidate['sourceName'] === 'string'
? candidate['sourceName']
: (source?.name ?? 'Unknown API')),
ownerId: typeof candidate['ownerId'] === 'string' ? candidate['ownerId'] : undefined,
ownerName: typeof candidate['ownerName'] === 'string' ? candidate['ownerName'] : undefined,
ownerPublicKey:
typeof candidate['ownerPublicKey'] === 'string' ? candidate['ownerPublicKey'] : undefined,
userCount,
maxUsers,
isPrivate,
id: this.getStringValue(candidate['id']) ?? '',
name: this.getStringValue(candidate['name']) ?? 'Unnamed server',
description: this.getStringValue(candidate['description']),
topic: this.getStringValue(candidate['topic']),
hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API',
ownerId: this.getStringValue(candidate['ownerId']),
ownerName: this.getStringValue(candidate['ownerName']),
ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']),
userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])),
maxUsers: this.getNumberValue(candidate['maxUsers']),
hasPassword: this.getBooleanValue(candidate['hasPassword']),
isPrivate: this.getBooleanValue(candidate['isPrivate']),
tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [],
createdAt: typeof candidate['createdAt'] === 'number' ? candidate['createdAt'] : Date.now(),
sourceId:
typeof candidate['sourceId'] === 'string'
? candidate['sourceId']
: source?.id,
sourceName:
typeof candidate['sourceName'] === 'string'
? candidate['sourceName']
: source?.name
createdAt: this.getNumberValue(candidate['createdAt'], Date.now()),
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
sourceName: sourceName ?? source?.name,
sourceUrl: sourceUrl
? this.sanitiseUrl(sourceUrl)
: (source ? this.sanitiseUrl(source.url) : undefined)
};
}
private getBooleanValue(value: unknown): boolean {
return typeof value === 'boolean' ? value : value === 1;
}
private getNumberValue(value: unknown, fallback = 0): number {
return typeof value === 'number' ? value : fallback;
}
private getStringValue(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
/** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */
private loadEndpoints(): void {
const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY);

View File

@@ -521,6 +521,11 @@ export class WebRTCService implements OnDestroy {
return this.activeServerId;
}
/** The last signaling URL used by the client, if any. */
getCurrentSignalingUrl(): string | null {
return this.signalingManager.getLastUrl();
}
/**
* Send an identify message to the signaling server.
*

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide';
@@ -42,6 +42,7 @@ export class LoginComponent {
private auth = inject(AuthService);
private store = inject(Store);
private route = inject(ActivatedRoute);
private router = inject(Router);
/** TrackBy function for server list rendering. */
@@ -72,6 +73,14 @@ export class LoginComponent {
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {
this.router.navigateByUrl(returnUrl);
return;
}
this.router.navigate(['/search']);
},
error: (err) => {
@@ -82,6 +91,10 @@ export class LoginComponent {
/** Navigate to the registration page. */
goRegister() {
this.router.navigate(['/register']);
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
this.router.navigate(['/register'], {
queryParams: returnUrl ? { returnUrl } : undefined
});
}
}

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserPlus } from '@ng-icons/lucide';
@@ -43,6 +43,7 @@ export class RegisterComponent {
private auth = inject(AuthService);
private store = inject(Store);
private route = inject(ActivatedRoute);
private router = inject(Router);
/** TrackBy function for server list rendering. */
@@ -74,6 +75,14 @@ export class RegisterComponent {
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {
this.router.navigateByUrl(returnUrl);
return;
}
this.router.navigate(['/search']);
},
error: (err) => {
@@ -84,6 +93,10 @@ export class RegisterComponent {
/** Navigate to the login page. */
goLogin() {
this.router.navigate(['/login']);
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
this.router.navigate(['/login'], {
queryParams: returnUrl ? { returnUrl } : undefined
});
}
}

View File

@@ -0,0 +1,85 @@
<div class="min-h-full bg-background px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto flex min-h-[calc(100vh-8rem)] max-w-4xl items-center justify-center">
<div class="w-full overflow-hidden rounded-3xl border border-border bg-card/90 shadow-2xl backdrop-blur">
<div class="border-b border-border bg-gradient-to-br from-primary/20 via-transparent to-blue-500/10 px-6 py-8 sm:px-10">
<div class="inline-flex items-center rounded-full border border-border bg-secondary/70 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.25em] text-muted-foreground">
Invite link
</div>
<h1 class="mt-4 text-3xl font-semibold tracking-tight text-foreground sm:text-4xl">
@if (invite()) {
Join {{ invite()!.server.name }}
} @else {
Toju server invite
}
</h1>
<p class="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base">
@switch (status()) {
@case ('redirecting') {
Sign in to continue with this invite.
}
@case ('joining') {
We are connecting you to the invited server.
}
@case ('error') {
This invite could not be completed automatically.
}
@default {
Loading invite details and preparing the correct signal server.
}
}
</p>
</div>
<div class="grid gap-6 px-6 py-8 sm:px-10 lg:grid-cols-[1.2fr,0.8fr]">
<section class="space-y-4">
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Status</h2>
<p class="mt-3 text-lg font-medium text-foreground">{{ message() }}</p>
</div>
@if (invite()) {
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">Server</h2>
<p class="mt-3 text-xl font-semibold text-foreground">{{ invite()!.server.name }}</p>
@if (invite()!.server.description) {
<p class="mt-2 text-sm leading-6 text-muted-foreground">{{ invite()!.server.description }}</p>
}
<div class="mt-4 flex flex-wrap gap-2 text-xs">
@if (invite()!.server.isPrivate) {
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Private</span>
}
@if (invite()!.server.hasPassword) {
<span class="rounded-full bg-secondary px-2.5 py-1 text-muted-foreground">Password bypassed by invite</span>
}
<span class="rounded-full bg-primary/10 px-2.5 py-1 text-primary">
Expires {{ invite()!.expiresAt | date:'medium' }}
</span>
</div>
</div>
}
</section>
<aside class="space-y-4">
<div class="rounded-2xl border border-border bg-secondary/20 p-5">
<h2 class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">What happens next</h2>
<ul class="mt-4 space-y-3 text-sm leading-6 text-muted-foreground">
<li>• The linked signal server is added to your configured server list if needed.</li>
<li>• Invite links bypass private and password restrictions.</li>
<li>• Banned users still cannot join through invites.</li>
</ul>
</div>
@if (status() === 'error') {
<button
type="button"
(click)="goToSearch()"
class="inline-flex w-full items-center justify-center rounded-2xl bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
Back to server search
</button>
}
</aside>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,192 @@
import {
Component,
OnInit,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { Store } from '@ngrx/store';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { UsersActions } from '../../store/users/users.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ServerDirectoryService, ServerInviteInfo } from '../../core/services/server-directory.service';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { DatabaseService } from '../../core/services/database.service';
import { User } from '../../core/models/index';
@Component({
selector: 'app-invite',
standalone: true,
imports: [CommonModule],
templateUrl: './invite.component.html'
})
export class InviteComponent implements OnInit {
readonly currentUser = inject(Store).selectSignal(selectCurrentUser);
readonly invite = signal<ServerInviteInfo | null>(null);
readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading');
readonly message = signal('Loading invite…');
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly serverDirectory = inject(ServerDirectoryService);
private readonly databaseService = inject(DatabaseService);
async ngOnInit(): Promise<void> {
const inviteContext = this.resolveInviteContext();
if (!inviteContext) {
return;
}
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) {
await this.redirectToLogin();
return;
}
try {
await this.joinInvite(inviteContext, currentUserId);
} catch (error: unknown) {
this.applyInviteError(error);
}
}
goToSearch(): void {
this.router.navigate(['/search']).catch(() => {});
}
private buildEndpointName(sourceUrl: string): string {
try {
const url = new URL(sourceUrl);
return url.hostname;
} catch {
return 'Signal Server';
}
}
private applyInviteError(error: unknown): void {
const inviteError = error as {
error?: { error?: string; errorCode?: string };
};
const errorCode = inviteError?.error?.errorCode;
const fallbackMessage = inviteError?.error?.error || 'Unable to accept this invite.';
this.status.set('error');
if (errorCode === 'BANNED') {
this.message.set('You are banned from this server and cannot accept this invite.');
return;
}
if (errorCode === 'INVITE_EXPIRED') {
this.message.set('This invite has expired. Ask for a fresh invite link.');
return;
}
this.message.set(fallbackMessage);
}
private async hydrateCurrentUser(): Promise<User | null> {
const currentUser = this.currentUser();
if (currentUser) {
return currentUser;
}
const storedUser = await this.databaseService.getCurrentUser();
if (storedUser) {
this.store.dispatch(UsersActions.setCurrentUser({ user: storedUser }));
}
return storedUser;
}
private async joinInvite(
context: { endpoint: { id: string; name: string }; inviteId: string; sourceUrl: string },
currentUserId: string
): Promise<void> {
const invite = await firstValueFrom(this.serverDirectory.getInvite(context.inviteId, {
sourceId: context.endpoint.id,
sourceUrl: context.sourceUrl
}));
this.invite.set(invite);
this.status.set('joining');
this.message.set(`Joining ${invite.server.name}`);
const currentUser = await this.hydrateCurrentUser();
const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: invite.server.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
inviteId: context.inviteId
}, {
sourceId: context.endpoint.id,
sourceUrl: context.sourceUrl
}));
this.store.dispatch(
RoomsActions.joinRoom({
roomId: joinResponse.server.id,
serverInfo: {
...joinResponse.server,
sourceId: context.endpoint.id,
sourceName: context.endpoint.name,
sourceUrl: context.sourceUrl
}
})
);
}
private async redirectToLogin(): Promise<void> {
this.status.set('redirecting');
this.message.set('Redirecting to login…');
await this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
}
});
}
private resolveInviteContext(): {
endpoint: { id: string; name: string };
inviteId: string;
sourceUrl: string;
} | null {
const inviteId = this.route.snapshot.paramMap.get('inviteId')?.trim() || '';
const sourceUrl = this.route.snapshot.queryParamMap.get('server')?.trim() || '';
if (!inviteId || !sourceUrl) {
this.status.set('error');
this.message.set('This invite link is missing required server information.');
return null;
}
const endpoint = this.serverDirectory.ensureServerEndpoint({
name: this.buildEndpointName(sourceUrl),
url: sourceUrl
}, {
setActive: !localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID)
});
return {
endpoint: {
id: endpoint.id,
name: endpoint.name
},
inviteId,
sourceUrl
};
}
}

View File

@@ -117,6 +117,17 @@
name="lucideLock"
class="w-4 h-4 text-muted-foreground"
/>
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>Private</span
>
} @else if (server.hasPassword) {
<ng-icon
name="lucideLock"
class="w-4 h-4 text-muted-foreground"
/>
<span class="inline-flex items-center rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
>Password</span
>
} @else {
<ng-icon
name="lucideGlobe"
@@ -153,6 +164,11 @@
<div class="text-muted-foreground">
Owner: <span class="text-foreground/80">{{ server.ownerName || server.ownerId || 'Unknown' }}</span>
</div>
@if (server.hasPassword && !server.isPrivate && !isServerMarkedBanned(server)) {
<div class="text-muted-foreground">
Access: <span class="text-foreground/80">Password required</span>
</div>
}
</div>
</button>
}
@@ -160,9 +176,9 @@
}
</div>
@if (error()) {
@if (joinErrorMessage() || error()) {
<div class="p-4 bg-destructive/10 border-t border-destructive">
<p class="text-sm text-destructive">{{ error() }}</p>
<p class="text-sm text-destructive">{{ joinErrorMessage() || error() }}</p>
</div>
}
</div>
@@ -181,6 +197,41 @@
</app-confirm-dialog>
}
@if (showPasswordDialog() && passwordPromptServer()) {
<app-confirm-dialog
title="Password required"
confirmLabel="Join server"
cancelLabel="Cancel"
[widthClass]="'w-[420px] max-w-[92vw]'"
(confirmed)="confirmPasswordJoin()"
(cancelled)="closePasswordDialog()"
>
<div class="space-y-3">
<p>Enter the password to join {{ passwordPromptServer()!.name }}.</p>
<div>
<label
for="join-server-password"
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Server password
</label>
<input
id="join-server-password"
type="password"
[(ngModel)]="joinPassword"
placeholder="Enter password"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@if (joinPasswordError()) {
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
}
</div>
</app-confirm-dialog>
}
<!-- Create Server Dialog -->
@if (showCreateDialog()) {
<div
@@ -263,22 +314,23 @@
>
</div>
@if (newServerPrivate()) {
<div>
<label
for="create-server-password"
class="block text-sm font-medium text-foreground mb-1"
>Password</label
>
<input
type="password"
[(ngModel)]="newServerPassword"
placeholder="Enter password"
id="create-server-password"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
}
<div>
<label
for="create-server-password"
class="block text-sm font-medium text-foreground mb-1"
>Password (optional)</label
>
<input
type="password"
[(ngModel)]="newServerPassword"
placeholder="Leave blank to allow joining without a password"
id="create-server-password"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
<p class="mt-1 text-xs text-muted-foreground">
Users who already joined keep access even if you change the password later.
</p>
</div>
</div>
<div class="flex gap-3 mt-6">

View File

@@ -13,6 +13,7 @@ import { Store } from '@ngrx/store';
import {
debounceTime,
distinctUntilChanged,
firstValueFrom,
Subject
} from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
@@ -39,6 +40,7 @@ import {
} from '../../core/models/index';
import { SettingsModalService } from '../../core/services/settings-modal.service';
import { DatabaseService } from '../../core/services/database.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { selectCurrentUser } from '../../store/users/users.selectors';
import { ConfirmDialogComponent } from '../../shared';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
@@ -73,6 +75,7 @@ export class ServerSearchComponent implements OnInit {
private router = inject(Router);
private settingsModal = inject(SettingsModalService);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryService);
private searchSubject = new Subject<string>();
private banLookupRequestVersion = 0;
@@ -85,6 +88,11 @@ export class ServerSearchComponent implements OnInit {
bannedServerLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
passwordPromptServer = signal<ServerInfo | null>(null);
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
joinErrorMessage = signal<string | null>(null);
// Create dialog state
showCreateDialog = signal(false);
@@ -135,16 +143,7 @@ export class ServerSearchComponent implements OnInit {
return;
}
this.store.dispatch(
RoomsActions.joinRoom({
roomId: server.id,
serverInfo: {
name: server.name,
description: server.description,
hostName: server.sourceName || server.hostName
}
})
);
await this.attemptJoinServer(server);
}
/** Open the create-server dialog. */
@@ -176,7 +175,7 @@ export class ServerSearchComponent implements OnInit {
description: this.newServerDescription() || undefined,
topic: this.newServerTopic() || undefined,
isPrivate: this.newServerPrivate(),
password: this.newServerPrivate() ? this.newServerPassword() : undefined
password: this.newServerPassword().trim() || undefined
})
);
@@ -198,6 +197,22 @@ export class ServerSearchComponent implements OnInit {
this.bannedServerName.set('');
}
closePasswordDialog(): void {
this.showPasswordDialog.set(false);
this.passwordPromptServer.set(null);
this.joinPassword.set('');
this.joinPasswordError.set(null);
}
async confirmPasswordJoin(): Promise<void> {
const server = this.passwordPromptServer();
if (!server)
return;
await this.attemptJoinServer(server, this.joinPassword());
}
isServerMarkedBanned(server: ServerInfo): boolean {
return !!this.bannedServerLookup()[server.id];
}
@@ -223,12 +238,72 @@ export class ServerSearchComponent implements OnInit {
hostName: room.hostId || 'Unknown',
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? 50,
isPrivate: !!room.password,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: room.isPrivate,
createdAt: room.createdAt,
ownerId: room.hostId
ownerId: room.hostId,
sourceId: room.sourceId,
sourceName: room.sourceName,
sourceUrl: room.sourceUrl
};
}
private async attemptJoinServer(server: ServerInfo, password?: string): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
if (!currentUserId) {
this.router.navigate(['/login']);
return;
}
this.joinErrorMessage.set(null);
this.joinPasswordError.set(null);
try {
const response = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: server.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined
}, {
sourceId: server.sourceId,
sourceUrl: server.sourceUrl
}));
const resolvedServer = response.server ?? server;
this.closePasswordDialog();
this.store.dispatch(
RoomsActions.joinRoom({
roomId: resolvedServer.id,
serverInfo: resolvedServer
})
);
} catch (error: unknown) {
const serverError = error as {
error?: { error?: string; errorCode?: string };
};
const errorCode = serverError?.error?.errorCode;
const message = serverError?.error?.error || 'Failed to join server';
if (errorCode === 'PASSWORD_REQUIRED') {
this.passwordPromptServer.set(server);
this.showPasswordDialog.set(true);
this.joinPasswordError.set(message);
return;
}
if (errorCode === 'BANNED') {
this.bannedServerName.set(server.name);
this.showBannedDialog.set(true);
return;
}
this.joinErrorMessage.set(message);
}
}
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
const requestVersion = ++this.banLookupRequestVersion;

View File

@@ -70,6 +70,41 @@
</app-confirm-dialog>
}
@if (showPasswordDialog() && passwordPromptRoom()) {
<app-confirm-dialog
title="Password required"
confirmLabel="Join server"
cancelLabel="Cancel"
[widthClass]="'w-[420px] max-w-[92vw]'"
(confirmed)="confirmPasswordJoin()"
(cancelled)="closePasswordDialog()"
>
<div class="space-y-3 text-left">
<p>Enter the password to rejoin {{ passwordPromptRoom()!.name }}.</p>
<div>
<label
for="rail-join-password"
class="mb-1 block text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Server password
</label>
<input
id="rail-join-password"
type="password"
[(ngModel)]="joinPassword"
placeholder="Enter password"
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
@if (joinPasswordError()) {
<p class="text-sm text-destructive">{{ joinPasswordError() }}</p>
}
</div>
</app-confirm-dialog>
}
@if (showLeaveConfirm() && contextRoom()) {
<app-leave-server-dialog
[room]="contextRoom()!"

View File

@@ -7,10 +7,12 @@ import {
signal
} from '@angular/core';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';
import { firstValueFrom } from 'rxjs';
import { Room, User } from '../../core/models/index';
import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors';
@@ -19,6 +21,7 @@ import { VoiceSessionService } from '../../core/services/voice-session.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import { RoomsActions } from '../../store/rooms/rooms.actions';
import { DatabaseService } from '../../core/services/database.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers';
import {
ConfirmDialogComponent,
@@ -31,6 +34,7 @@ import {
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ConfirmDialogComponent,
ContextMenuComponent,
@@ -46,6 +50,7 @@ export class ServersRailComponent {
private voiceSession = inject(VoiceSessionService);
private webrtc = inject(WebRTCService);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryService);
private banLookupRequestVersion = 0;
savedRooms = this.store.selectSignal(selectSavedRooms);
currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -59,6 +64,10 @@ export class ServersRailComponent {
bannedRoomLookup = signal<Record<string, boolean>>({});
bannedServerName = signal('');
showBannedDialog = signal(false);
showPasswordDialog = signal(false);
passwordPromptRoom = signal<Room | null>(null);
joinPassword = signal('');
joinPasswordError = signal<string | null>(null);
visibleSavedRooms = computed(() => this.savedRooms().filter((room) => !this.isRoomMarkedBanned(room)));
constructor() {
@@ -105,27 +114,18 @@ export class ServersRailComponent {
return;
}
const voiceServerId = this.voiceSession.getVoiceServerId();
const roomWsUrl = this.serverDirectory.getWebSocketUrl({
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
});
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
if (voiceServerId && voiceServerId !== room.id) {
this.voiceSession.setViewingVoiceServer(false);
} else if (voiceServerId === room.id) {
this.voiceSession.setViewingVoiceServer(true);
}
this.prepareVoiceContext(room);
if (this.webrtc.hasJoinedServer(room.id)) {
if (this.webrtc.hasJoinedServer(room.id) && roomWsUrl === currentWsUrl) {
this.store.dispatch(RoomsActions.viewServer({ room }));
} else {
this.store.dispatch(
RoomsActions.joinRoom({
roomId: room.id,
serverInfo: {
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown'
}
})
);
await this.attemptJoinRoom(room);
}
}
@@ -134,6 +134,22 @@ export class ServersRailComponent {
this.bannedServerName.set('');
}
closePasswordDialog(): void {
this.showPasswordDialog.set(false);
this.passwordPromptRoom.set(null);
this.joinPassword.set('');
this.joinPasswordError.set(null);
}
async confirmPasswordJoin(): Promise<void> {
const room = this.passwordPromptRoom();
if (!room)
return;
await this.attemptJoinRoom(room, this.joinPassword());
}
isRoomMarkedBanned(room: Room): boolean {
return !!this.bannedRoomLookup()[room.id];
}
@@ -226,4 +242,84 @@ export class ServersRailComponent {
return hasRoomBanForUser(bans, currentUser, persistedUserId);
}
private prepareVoiceContext(room: Room): void {
const voiceServerId = this.voiceSession.getVoiceServerId();
if (voiceServerId && voiceServerId !== room.id) {
this.voiceSession.setViewingVoiceServer(false);
} else if (voiceServerId === room.id) {
this.voiceSession.setViewingVoiceServer(true);
}
}
private async attemptJoinRoom(room: Room, password?: string): Promise<void> {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
if (!currentUserId)
return;
this.joinPasswordError.set(null);
try {
const response = await firstValueFrom(this.serverDirectory.requestJoin({
roomId: room.id,
userId: currentUserId,
userPublicKey: currentUser?.oderId || currentUserId,
displayName: currentUser?.displayName || 'Anonymous',
password: password?.trim() || undefined
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}));
this.closePasswordDialog();
this.store.dispatch(
RoomsActions.joinRoom({
roomId: room.id,
serverInfo: {
...this.toServerInfo(room),
...response.server
}
})
);
} catch (error: unknown) {
const serverError = error as {
error?: { error?: string; errorCode?: string };
};
const errorCode = serverError?.error?.errorCode;
const message = serverError?.error?.error || 'Failed to join server';
if (errorCode === 'PASSWORD_REQUIRED') {
this.passwordPromptRoom.set(room);
this.showPasswordDialog.set(true);
this.joinPasswordError.set(message);
return;
}
if (errorCode === 'BANNED') {
this.bannedServerName.set(room.name);
this.showBannedDialog.set(true);
}
}
}
private toServerInfo(room: Room) {
return {
id: room.id,
name: room.name,
description: room.description,
hostName: room.hostId || 'Unknown',
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? 50,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
isPrivate: room.isPrivate,
createdAt: room.createdAt,
ownerId: room.hostId,
sourceId: room.sourceId,
sourceName: room.sourceName,
sourceUrl: room.sourceUrl
};
}
}

View File

@@ -95,6 +95,84 @@
[class.cursor-not-allowed]="!isAdmin()"
/>
</div>
@if (isAdmin()) {
<div class="rounded-lg border border-border bg-secondary/40 p-4 space-y-3">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Server Password</p>
<p class="text-xs text-muted-foreground">
@if (hasPassword() && passwordAction() !== 'remove') {
Joined members stay whitelisted until they are kicked or banned.
} @else {
Add an optional password so new members need it to join.
}
</p>
</div>
@if (hasPassword() && passwordAction() !== 'remove') {
<button
type="button"
(click)="markPasswordForRemoval()"
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
>
Remove Password
</button>
} @else if (hasPassword() && passwordAction() === 'remove') {
<button
type="button"
(click)="keepCurrentPassword()"
class="rounded-lg border border-border px-3 py-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-secondary"
>
Keep Password
</button>
}
</div>
<div class="text-xs text-muted-foreground">
@if (hasPassword() && passwordAction() !== 'remove') {
Password protection is currently enabled.
} @else if (hasPassword() && passwordAction() === 'remove') {
Password protection will be removed when you save.
} @else {
Password protection is currently disabled.
}
</div>
<div>
<label
for="room-password"
class="block text-xs font-medium text-muted-foreground mb-1"
>
{{ hasPassword() ? 'Set New Password' : 'Set Password' }}
</label>
<input
type="password"
id="room-password"
[ngModel]="roomPassword"
(ngModelChange)="onPasswordInput($event)"
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
[placeholder]="hasPassword() ? 'Leave blank to keep the current password' : 'Optional password required for new joins'"
/>
@if (passwordAction() === 'update') {
<p class="mt-2 text-xs text-muted-foreground">The new password will replace the current one when you save.</p>
}
@if (passwordError()) {
<p class="mt-2 text-xs text-destructive">{{ passwordError() }}</p>
}
</div>
</div>
} @else {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Server Password</p>
<p class="text-xs text-muted-foreground">Invite links bypass the password, but bans still apply.</p>
</div>
<span class="text-sm text-muted-foreground">{{ hasPassword() ? 'Enabled' : 'Disabled' }}</span>
</div>
}
</div>
</section>

View File

@@ -53,6 +53,10 @@ export class ServerSettingsComponent {
roomName = '';
roomDescription = '';
isPrivate = signal(false);
hasPassword = signal(false);
passwordAction = signal<'keep' | 'update' | 'remove'>('keep');
passwordError = signal<string | null>(null);
roomPassword = '';
maxUsers = 0;
showDeleteConfirm = signal(false);
@@ -72,6 +76,10 @@ export class ServerSettingsComponent {
this.roomName = room.name;
this.roomDescription = room.description || '';
this.isPrivate.set(room.isPrivate);
this.hasPassword.set(typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password);
this.passwordAction.set('keep');
this.passwordError.set(null);
this.roomPassword = '';
this.maxUsers = room.maxUsers || 0;
});
}
@@ -86,21 +94,67 @@ export class ServerSettingsComponent {
if (!room)
return;
const normalizedPassword = this.roomPassword.trim();
const settings: {
description: string;
hasPassword?: boolean;
isPrivate: boolean;
maxUsers: number;
name: string;
password?: string;
} = {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers
};
if (this.passwordAction() === 'remove') {
settings.password = '';
settings.hasPassword = false;
} else if (normalizedPassword) {
settings.password = normalizedPassword;
settings.hasPassword = true;
}
this.store.dispatch(
RoomsActions.updateRoomSettings({
roomId: room.id,
settings: {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),
maxUsers: this.maxUsers
}
settings
})
);
this.hasPassword.set(settings.hasPassword ?? this.hasPassword());
this.passwordAction.set('keep');
this.passwordError.set(null);
this.roomPassword = '';
this.showSaveSuccess('server');
}
markPasswordForRemoval(): void {
this.passwordAction.set('remove');
this.passwordError.set(null);
this.roomPassword = '';
}
keepCurrentPassword(): void {
this.passwordAction.set('keep');
this.passwordError.set(null);
this.roomPassword = '';
}
onPasswordInput(value: string): void {
this.roomPassword = value;
this.passwordError.set(null);
if (value.trim().length > 0) {
this.passwordAction.set('update');
return;
}
this.passwordAction.set('keep');
}
confirmDeleteRoom(): void {
this.showDeleteConfirm.set(true);
}

View File

@@ -55,8 +55,20 @@
</button>
<!-- Anchored dropdown under the menu button -->
@if (showMenu()) {
<div class="absolute right-0 top-full mt-1 z-50 bg-card border border-border rounded-lg shadow-lg w-48">
<div class="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border border-border bg-card shadow-lg">
@if (inRoom()) {
<button
type="button"
(click)="createInviteLink()"
[disabled]="creatingInvite()"
class="w-full text-left px-3 py-2 text-sm hover:bg-secondary transition-colors text-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
@if (creatingInvite()) {
Creating Invite Link…
} @else {
Create Invite Link
}
</button>
<button
type="button"
(click)="leaveServer()"
@@ -65,6 +77,11 @@
Leave Server
</button>
}
@if (inviteStatus()) {
<div class="border-t border-border px-3 py-2 text-xs leading-5 text-muted-foreground">
{{ inviteStatus() }}
</div>
}
<div class="border-t border-border"></div>
<button
type="button"

View File

@@ -6,6 +6,7 @@ import {
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { firstValueFrom } from 'rxjs';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
@@ -25,6 +26,7 @@ import { WebRTCService } from '../../core/services/webrtc.service';
import { PlatformService } from '../../core/services/platform.service';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants';
import { LeaveServerDialogComponent } from '../../shared';
import { Room } from '../../core/models/index';
interface WindowControlsAPI {
minimizeWindow?: () => void;
@@ -84,6 +86,8 @@ export class TitleBarComponent {
private _showMenu = signal(false);
showMenu = computed(() => this._showMenu());
showLeaveConfirm = signal(false);
inviteStatus = signal<string | null>(null);
creatingInvite = signal(false);
/** Minimize the Electron window. */
minimize() {
@@ -122,9 +126,44 @@ export class TitleBarComponent {
/** Toggle the server dropdown menu. */
toggleMenu() {
this.inviteStatus.set(null);
this._showMenu.set(!this._showMenu());
}
/** Create a new invite link for the active room and copy it to the clipboard. */
async createInviteLink(): Promise<void> {
const room = this.currentRoom();
const user = this.currentUser();
if (!room || !user || this.creatingInvite()) {
return;
}
this.creatingInvite.set(true);
this.inviteStatus.set('Creating invite link…');
try {
const invite = await firstValueFrom(this.serverDirectory.createInvite(
room.id,
{
requesterUserId: user.id,
requesterDisplayName: user.displayName,
requesterRole: user.role
},
this.toSourceSelector(room)
));
await this.copyInviteLink(invite.inviteUrl);
this.inviteStatus.set('Invite link copied to clipboard.');
} catch (error: unknown) {
const inviteError = error as { error?: { error?: string } };
this.inviteStatus.set(inviteError?.error?.error || 'Unable to create invite link.');
} finally {
this.creatingInvite.set(false);
}
}
/** Leave the current server and navigate to the servers list. */
leaveServer() {
this.openLeaveConfirm();
@@ -170,4 +209,44 @@ export class TitleBarComponent {
this.router.navigate(['/login']);
}
private async copyInviteLink(inviteUrl: string): Promise<void> {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(inviteUrl);
return;
} catch {}
}
const textarea = document.createElement('textarea');
textarea.value = inviteUrl;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.select();
try {
const copied = document.execCommand('copy');
if (copied) {
return;
}
} catch {
/* fall through to prompt fallback */
} finally {
document.body.removeChild(textarea);
}
window.prompt('Copy this invite link', inviteUrl);
}
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
return {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
};
}
}

View File

@@ -29,7 +29,7 @@ export const RoomsActions = createActionGroup({
'Create Room Success': props<{ room: Room }>(),
'Create Room Failure': props<{ error: string }>(),
'Join Room': props<{ roomId: string; password?: string; serverInfo?: { name: string; description?: string; hostName?: string } }>(),
'Join Room': props<{ roomId: string; password?: string; serverInfo?: Partial<ServerInfo> & { name: string } }>(),
'Join Room Success': props<{ room: Room }>(),
'Join Room Failure': props<{ error: string }>(),

View File

@@ -139,7 +139,6 @@ export class RoomsEffects {
searchServers$ = createEffect(() =>
this.actions$.pipe(
ofType(RoomsActions.searchServers),
debounceTime(300),
switchMap(({ query }) =>
this.serverDirectory.searchServers(query).pipe(
map((servers) => RoomsActions.searchServersSuccess({ servers })),
@@ -159,17 +158,23 @@ export class RoomsEffects {
return of(RoomsActions.createRoomFailure({ error: 'Not logged in' }));
}
const activeEndpoint = this.serverDirectory.activeServer();
const normalizedPassword = typeof password === 'string' ? password.trim() : '';
const room: Room = {
id: uuidv4(),
name,
description,
topic,
hostId: currentUser.id,
password: normalizedPassword || undefined,
hasPassword: normalizedPassword.length > 0,
isPrivate: isPrivate ?? false,
password,
createdAt: Date.now(),
userCount: 1,
maxUsers: 50
maxUsers: 50,
sourceId: activeEndpoint?.id,
sourceName: activeEndpoint?.name,
sourceUrl: activeEndpoint?.url
};
// Save to local DB
@@ -184,6 +189,8 @@ export class RoomsEffects {
ownerId: currentUser.id,
ownerPublicKey: currentUser.oderId,
hostName: currentUser.displayName,
password: normalizedPassword || null,
hasPassword: normalizedPassword.length > 0,
isPrivate: room.isPrivate,
userCount: 1,
maxUsers: room.maxUsers || 50,
@@ -216,8 +223,35 @@ export class RoomsEffects {
// First check local DB
return from(this.db.getRoom(roomId)).pipe(
switchMap((room) => {
const sourceSelector = serverInfo
? {
sourceId: serverInfo.sourceId,
sourceUrl: serverInfo.sourceUrl
}
: undefined;
if (room) {
return of(RoomsActions.joinRoomSuccess({ room }));
const resolvedRoom: Room = {
...room,
isPrivate: typeof serverInfo?.isPrivate === 'boolean' ? serverInfo.isPrivate : room.isPrivate,
sourceId: serverInfo?.sourceId ?? room.sourceId,
sourceName: serverInfo?.sourceName ?? room.sourceName,
sourceUrl: serverInfo?.sourceUrl ?? room.sourceUrl,
hasPassword:
typeof serverInfo?.hasPassword === 'boolean'
? serverInfo.hasPassword
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password)
};
this.db.updateRoom(room.id, {
sourceId: resolvedRoom.sourceId,
sourceName: resolvedRoom.sourceName,
sourceUrl: resolvedRoom.sourceUrl,
hasPassword: resolvedRoom.hasPassword,
isPrivate: resolvedRoom.isPrivate
});
return of(RoomsActions.joinRoomSuccess({ room: resolvedRoom }));
}
// If not in local DB but we have server info from search, create a room entry
@@ -227,11 +261,14 @@ export class RoomsEffects {
name: serverInfo.name,
description: serverInfo.description,
hostId: '', // Unknown, will be determined via signaling
isPrivate: !!password,
password,
hasPassword: !!serverInfo.hasPassword,
isPrivate: !!serverInfo.isPrivate,
createdAt: Date.now(),
userCount: 1,
maxUsers: 50
maxUsers: 50,
sourceId: serverInfo.sourceId,
sourceName: serverInfo.sourceName,
sourceUrl: serverInfo.sourceUrl
};
// Save to local DB for future reference
@@ -240,7 +277,7 @@ export class RoomsEffects {
}
// Try to get room info from server
return this.serverDirectory.getServer(roomId).pipe(
return this.serverDirectory.getServer(roomId, sourceSelector).pipe(
switchMap((serverData) => {
if (serverData) {
const newRoom: Room = {
@@ -248,11 +285,14 @@ export class RoomsEffects {
name: serverData.name,
description: serverData.description,
hostId: serverData.ownerId || '',
hasPassword: !!serverData.hasPassword,
isPrivate: serverData.isPrivate,
password,
createdAt: serverData.createdAt || Date.now(),
userCount: serverData.userCount,
maxUsers: serverData.maxUsers
maxUsers: serverData.maxUsers,
sourceId: serverData.sourceId,
sourceName: serverData.sourceName,
sourceUrl: serverData.sourceUrl
};
this.db.saveRoom(newRoom);
@@ -280,26 +320,7 @@ export class RoomsEffects {
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
withLatestFrom(this.store.select(selectCurrentUser)),
tap(([{ room }, user]) => {
const wsUrl = this.serverDirectory.getWebSocketUrl();
const oderId = user?.oderId || this.webrtc.peerId();
const displayName = user?.displayName || 'Anonymous';
// Check if already connected to signaling server
if (this.webrtc.isConnected()) {
this.webrtc.setCurrentServer(room.id);
this.webrtc.switchServer(room.id, oderId);
} else {
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
next: (connected) => {
if (connected) {
this.webrtc.setCurrentServer(room.id);
this.webrtc.identify(oderId, displayName);
this.webrtc.joinRoom(room.id, oderId);
}
},
error: () => {}
});
}
this.connectToRoomSignaling(room, user ?? null);
this.router.navigate(['/room', room.id]);
})
@@ -325,10 +346,7 @@ export class RoomsEffects {
const oderId = user.oderId || this.webrtc.peerId();
if (this.webrtc.isConnected()) {
this.webrtc.setCurrentServer(room.id);
this.webrtc.switchServer(room.id, oderId);
}
this.connectToRoomSignaling(room, user, oderId);
this.router.navigate(['/room', room.id]);
return of(RoomsActions.viewServerSuccess({ room }));
@@ -432,8 +450,12 @@ export class RoomsEffects {
this.serverDirectory.updateServer(roomId, {
currentOwnerId: currentUser.id,
actingRole: 'host',
ownerId: nextHostId,
ownerPublicKey: nextHostOderId
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
@@ -485,10 +507,11 @@ export class RoomsEffects {
if (!room)
return of(RoomsActions.updateRoomSettingsFailure({ error: 'Room not found' }));
const isOwner = room.hostId === currentUser.id || room.hostId === currentUser.oderId;
const canManageCurrentRoom = currentRoom?.id === room.id && (currentUser.role === 'host' || currentUser.role === 'admin');
const currentUserRole = this.getUserRoleForRoom(room, currentUser, currentRoom);
const isOwner = currentUserRole === 'host';
const canManageRoom = currentUserRole === 'host' || currentUserRole === 'admin';
if (!isOwner && !canManageCurrentRoom) {
if (!canManageRoom) {
return of(
RoomsActions.updateRoomSettingsFailure({
error: 'Permission denied'
@@ -496,30 +519,58 @@ export class RoomsEffects {
);
}
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(settings, 'password');
const normalizedPassword = typeof settings.password === 'string' ? settings.password.trim() : undefined;
const nextHasPassword = typeof settings.hasPassword === 'boolean'
? settings.hasPassword
: (hasPasswordUpdate
? !!normalizedPassword
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password));
const updatedSettings: RoomSettings = {
name: settings.name ?? room.name,
description: settings.description ?? room.description,
topic: settings.topic ?? room.topic,
isPrivate: settings.isPrivate ?? room.isPrivate,
password: settings.password ?? room.password,
password: hasPasswordUpdate ? (normalizedPassword || '') : room.password,
hasPassword: nextHasPassword,
maxUsers: settings.maxUsers ?? room.maxUsers
};
this.db.updateRoom(room.id, updatedSettings);
const localRoomUpdates: Partial<Room> = {
...updatedSettings,
password: hasPasswordUpdate ? (normalizedPassword || undefined) : room.password,
hasPassword: nextHasPassword
};
const sharedSettings: RoomSettings = {
name: updatedSettings.name,
description: updatedSettings.description,
topic: updatedSettings.topic,
isPrivate: updatedSettings.isPrivate,
hasPassword: nextHasPassword,
maxUsers: updatedSettings.maxUsers,
password: hasPasswordUpdate ? (normalizedPassword || '') : undefined
};
this.db.updateRoom(room.id, localRoomUpdates);
this.webrtc.broadcastMessage({
type: 'room-settings-update',
roomId: room.id,
settings: updatedSettings
settings: sharedSettings
});
if (isOwner) {
if (canManageRoom) {
this.serverDirectory.updateServer(room.id, {
currentOwnerId: currentUser.id,
actingRole: currentUserRole ?? undefined,
name: updatedSettings.name,
description: updatedSettings.description,
isPrivate: updatedSettings.isPrivate,
maxUsers: updatedSettings.maxUsers
maxUsers: updatedSettings.maxUsers,
password: hasPasswordUpdate ? (normalizedPassword || null) : undefined
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
}).subscribe({
error: () => {}
});
@@ -945,14 +996,20 @@ export class RoomsEffects {
description: typeof room.description === 'string' ? room.description : undefined,
topic: typeof room.topic === 'string' ? room.topic : undefined,
hostId: typeof room.hostId === 'string' ? room.hostId : undefined,
password: typeof room.password === 'string' ? room.password : undefined,
hasPassword:
typeof room.hasPassword === 'boolean'
? room.hasPassword
: (typeof room.password === 'string' ? room.password.trim().length > 0 : undefined),
isPrivate: typeof room.isPrivate === 'boolean' ? room.isPrivate : undefined,
maxUsers: typeof room.maxUsers === 'number' ? room.maxUsers : undefined,
icon: typeof room.icon === 'string' ? room.icon : undefined,
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
permissions: room.permissions ? { ...room.permissions } : undefined,
channels: Array.isArray(room.channels) ? room.channels : undefined,
members: Array.isArray(room.members) ? room.members : undefined
members: Array.isArray(room.members) ? room.members : undefined,
sourceId: typeof room.sourceId === 'string' ? room.sourceId : undefined,
sourceName: typeof room.sourceName === 'string' ? room.sourceName : undefined,
sourceUrl: typeof room.sourceUrl === 'string' ? room.sourceUrl : undefined
};
}
@@ -1005,7 +1062,7 @@ export class RoomsEffects {
this.webrtc.sendToPeer(fromPeerId, {
type: 'server-state-full',
roomId: room.id,
room,
room: this.sanitizeRoomSnapshot(room),
bans
});
}),
@@ -1075,7 +1132,11 @@ export class RoomsEffects {
description: settings.description ?? room.description,
topic: settings.topic ?? room.topic,
isPrivate: settings.isPrivate ?? room.isPrivate,
password: settings.password ?? room.password,
password: settings.password === '' ? undefined : room.password,
hasPassword:
typeof settings.hasPassword === 'boolean'
? settings.hasPassword
: (typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password),
maxUsers: settings.maxUsers ?? room.maxUsers
}
})
@@ -1196,6 +1257,62 @@ export class RoomsEffects {
{ dispatch: false }
);
private connectToRoomSignaling(
room: Room,
user: User | null,
resolvedOderId?: string
): void {
const wsUrl = this.serverDirectory.getWebSocketUrl({
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
});
const currentWsUrl = this.webrtc.getCurrentSignalingUrl();
const oderId = resolvedOderId || user?.oderId || this.webrtc.peerId();
const displayName = user?.displayName || 'Anonymous';
const sameSignalServer = currentWsUrl === wsUrl;
if (this.webrtc.isConnected() && sameSignalServer) {
this.webrtc.setCurrentServer(room.id);
if (this.webrtc.hasJoinedServer(room.id)) {
this.webrtc.switchServer(room.id, oderId);
} else {
this.webrtc.identify(oderId, displayName);
this.webrtc.joinRoom(room.id, oderId);
}
return;
}
if (currentWsUrl && currentWsUrl !== wsUrl) {
this.webrtc.disconnectAll();
}
this.webrtc.connectToSignalingServer(wsUrl).subscribe({
next: (connected) => {
if (!connected)
return;
this.webrtc.setCurrentServer(room.id);
this.webrtc.identify(oderId, displayName);
this.webrtc.joinRoom(room.id, oderId);
},
error: () => {}
});
}
private getUserRoleForRoom(room: Room, currentUser: User, currentRoom: Room | null): User['role'] | null {
if (room.hostId === currentUser.id || room.hostId === currentUser.oderId)
return 'host';
if (currentRoom?.id === room.id && currentUser.role)
return currentUser.role;
return findRoomMember(room.members ?? [], currentUser.id)?.role
|| findRoomMember(room.members ?? [], currentUser.oderId)?.role
|| null;
}
private getPersistedCurrentUserId(): string | null {
return localStorage.getItem('metoyou_currentUserId');
}

View File

@@ -45,6 +45,7 @@ function deduplicateRooms(rooms: Room[]): Room[] {
function enrichRoom(room: Room): Room {
return {
...room,
hasPassword: typeof room.hasPassword === 'boolean' ? room.hasPassword : !!room.password,
channels: room.channels || defaultChannels(),
members: pruneRoomMembers(room.members || [])
};
@@ -252,7 +253,13 @@ export const roomsReducer = createReducer(
description: settings.description,
topic: settings.topic,
isPrivate: settings.isPrivate,
password: settings.password,
password: settings.password === '' ? undefined : (settings.password ?? baseRoom.password),
hasPassword:
typeof settings.hasPassword === 'boolean'
? settings.hasPassword
: (typeof settings.password === 'string'
? settings.password.trim().length > 0
: baseRoom.hasPassword),
maxUsers: settings.maxUsers
});

View File

@@ -33,6 +33,7 @@ import {
} from './users.selectors';
import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors';
import { DatabaseService } from '../../core/services/database.service';
import { ServerDirectoryService } from '../../core/services/server-directory.service';
import { WebRTCService } from '../../core/services/webrtc.service';
import {
BanEntry,
@@ -56,6 +57,7 @@ export class UsersEffects {
private actions$ = inject(Actions);
private store = inject(Store);
private db = inject(DatabaseService);
private serverDirectory = inject(ServerDirectoryService);
private webrtc = inject(WebRTCService);
// Load current user from storage
@@ -162,24 +164,40 @@ export class UsersEffects {
const nextMembers = removeRoomMember(room.members ?? [], userId, userId);
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: userId,
roomId: room.id,
kickedBy: currentUser.id
});
return this.serverDirectory.kickServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to revoke server membership on kick:', error);
return of(void 0);
}),
mergeMap(() => {
this.webrtc.broadcastMessage({
type: 'kick',
targetUserId: userId,
roomId: room.id,
kickedBy: currentUser.id
});
return currentRoom?.id === room.id
? [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } }),
UsersActions.kickUserSuccess({ userId,
roomId: room.id })
]
: of(
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
);
return currentRoom?.id === room.id
? [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } }),
UsersActions.kickUserSuccess({ userId,
roomId: room.id })
]
: of(
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
);
})
);
})
)
);
@@ -228,32 +246,52 @@ export class UsersEffects {
timestamp: Date.now()
};
return from(this.db.saveBan(ban)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'ban',
targetUserId: userId,
roomId: room.id,
bannedBy: currentUser.id,
ban
});
return this.serverDirectory.banServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
targetUserId: userId,
banId: ban.oderId,
displayName: ban.displayName,
reason,
expiresAt
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to persist server ban:', error);
return of(void 0);
}),
mergeMap(() => {
const actions: (ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
];
switchMap(() =>
from(this.db.saveBan(ban)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'ban',
targetUserId: userId,
roomId: room.id,
bannedBy: currentUser.id,
ban
});
}),
mergeMap(() => {
const actions: (ReturnType<typeof RoomsActions.updateRoom>
| ReturnType<typeof UsersActions.banUserSuccess>)[] = [
RoomsActions.updateRoom({ roomId: room.id,
changes: { members: nextMembers } })
];
if (currentRoom?.id === room.id) {
actions.push(UsersActions.banUserSuccess({ userId,
roomId: room.id,
ban }));
}
if (currentRoom?.id === room.id) {
actions.push(UsersActions.banUserSuccess({ userId,
roomId: room.id,
ban }));
}
return actions;
}),
catchError(() => EMPTY)
return actions;
}),
catchError(() => EMPTY)
)
)
);
})
)
@@ -279,16 +317,32 @@ export class UsersEffects {
if (!currentUser || !room || !this.canModerateRoom(room, currentUser, currentRoom))
return EMPTY;
return from(this.db.removeBan(oderId)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'unban',
roomId: room.id,
banOderId: oderId
});
return this.serverDirectory.unbanServerMember(
room.id,
{
actorUserId: currentUser.id,
actorRole: currentUser.role,
banId: oderId
},
this.toSourceSelector(room)
).pipe(
catchError((error) => {
console.error('Failed to remove server ban:', error);
return of(void 0);
}),
map(() => UsersActions.unbanUserSuccess({ oderId })),
catchError(() => EMPTY)
switchMap(() =>
from(this.db.removeBan(oderId)).pipe(
tap(() => {
this.webrtc.broadcastMessage({
type: 'unban',
roomId: room.id,
banOderId: oderId
});
}),
map(() => UsersActions.unbanUserSuccess({ oderId })),
catchError(() => EMPTY)
)
)
);
})
)
@@ -394,6 +448,13 @@ export class UsersEffects {
return savedRooms.find((room) => room.id === roomId) ?? null;
}
private toSourceSelector(room: Room): { sourceId?: string; sourceUrl?: string } {
return {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl
};
}
private canModerateRoom(room: Room, currentUser: User, currentRoom: Room | null): boolean {
const role = this.getCurrentUserRoleForRoom(room, currentUser, currentRoom);