Revert the automated member-ordering pass that broke Angular field init (TS2729) and disable that rule until a safe reorder strategy exists. Fix modal/confirm dialog i18n defaults via template fallbacks, search all active endpoints (including offline), register foreign rooms with actor owner IDs, sync profile display names from avatar summaries, and guard dm-chat when a private call converts to a group conversation. Co-authored-by: Cursor <cursoragent@cursor.com>
728 lines
23 KiB
TypeScript
728 lines
23 KiB
TypeScript
/* eslint-disable @typescript-eslint/member-ordering */
|
|
import {
|
|
Component,
|
|
effect,
|
|
inject,
|
|
Injector,
|
|
Input,
|
|
OnInit,
|
|
signal
|
|
} from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { Router } from '@angular/router';
|
|
import { Store } from '@ngrx/store';
|
|
import {
|
|
debounceTime,
|
|
distinctUntilChanged,
|
|
firstValueFrom,
|
|
Subject
|
|
} from 'rxjs';
|
|
import { NgIcon, provideIcons } from '@ng-icons/core';
|
|
import {
|
|
lucideExternalLink,
|
|
lucideFileText,
|
|
lucideSearch,
|
|
lucideUsers,
|
|
lucideLock,
|
|
lucideGlobe,
|
|
lucideChevronDown
|
|
} from '@ng-icons/lucide';
|
|
|
|
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
|
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
|
|
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
|
import { SignalServerAuthorizeService } from '../../../authentication/application/services/signal-server-authorize.service';
|
|
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
|
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
|
import {
|
|
selectSearchResults,
|
|
selectIsSearching,
|
|
selectRoomsError,
|
|
selectSavedRooms
|
|
} from '../../../../store/rooms/rooms.selectors';
|
|
import {
|
|
Room,
|
|
User,
|
|
type PluginRequirementSummary
|
|
} from '../../../../shared-kernel';
|
|
import { ExternalLinkService } from '../../../../core/platform';
|
|
import { DatabaseService } from '../../../../infrastructure/persistence';
|
|
import { type ServerInfo } from '../../domain/models/server-directory.model';
|
|
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|
import {
|
|
ConfirmDialogComponent,
|
|
LeaveServerDialogComponent,
|
|
ModalBackdropComponent,
|
|
type LeaveServerDialogResult
|
|
} from '../../../../shared';
|
|
import { ChatMessageMarkdownComponent } from '../../../chat';
|
|
import { hasRoomBanForUser } from '../../../access-control';
|
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
|
import {
|
|
PluginRequirementService,
|
|
PluginStoreService,
|
|
type PluginStoreReadme
|
|
} from '../../../plugins';
|
|
|
|
interface JoinPluginConsentDialog {
|
|
optional: PluginRequirementSummary[];
|
|
password?: string;
|
|
required: PluginRequirementSummary[];
|
|
server: ServerInfo;
|
|
}
|
|
|
|
/** A named group of servers rendered when the browser is not in active search mode. */
|
|
export interface ServerDiscoverySection {
|
|
id: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
servers: ServerInfo[];
|
|
}
|
|
|
|
/**
|
|
* Reusable server discovery + join surface. Owns the full join flow (password prompt,
|
|
* plugin-consent, banned, plugin readme) and the leave-server dialog, and renders both
|
|
* live search results and any caller-supplied discovery sections with the same card UI.
|
|
*/
|
|
@Component({
|
|
selector: 'app-server-browser',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
NgIcon,
|
|
ChatMessageMarkdownComponent,
|
|
ConfirmDialogComponent,
|
|
LeaveServerDialogComponent,
|
|
ModalBackdropComponent,
|
|
AutoFocusDirective,
|
|
SelectOnFocusDirective,
|
|
...APP_TRANSLATE_IMPORTS
|
|
],
|
|
viewProviders: [
|
|
provideIcons({
|
|
lucideExternalLink,
|
|
lucideFileText,
|
|
lucideSearch,
|
|
lucideUsers,
|
|
lucideLock,
|
|
lucideGlobe,
|
|
lucideChevronDown
|
|
})
|
|
],
|
|
templateUrl: './server-browser.component.html'
|
|
})
|
|
export class ServerBrowserComponent implements OnInit {
|
|
private store = inject(Store);
|
|
private router = inject(Router);
|
|
private db = inject(DatabaseService);
|
|
private externalLinks = inject(ExternalLinkService);
|
|
private serverDirectory = inject(ServerDirectoryFacade);
|
|
private webrtc = inject(RealtimeSessionFacade);
|
|
private pluginRequirements = inject(PluginRequirementService);
|
|
private pluginStore = inject(PluginStoreService);
|
|
private injector = inject(Injector);
|
|
private readonly i18n = inject(AppI18nService);
|
|
private readonly signalServerAuthorize = inject(SignalServerAuthorizeService);
|
|
private searchSubject = new Subject<string>();
|
|
private banLookupRequestVersion = 0;
|
|
|
|
/** Discovery sections shown when the search query is empty. */
|
|
@Input() discoverySections: ServerDiscoverySection[] = [];
|
|
/** Title for the onboarding empty state when there is nothing to show. */
|
|
@Input() emptyStateTitle?: string;
|
|
/** Supporting copy for the onboarding empty state. */
|
|
@Input() emptyStateMessage?: string;
|
|
/** Placeholder for the search input. */
|
|
@Input() searchPlaceholder?: string;
|
|
/** Whether the My Servers quick bar is shown. */
|
|
@Input() showMyServers = true;
|
|
|
|
get resolvedEmptyStateTitle(): string {
|
|
return this.emptyStateTitle ?? this.i18n.instant('servers.browser.empty.title');
|
|
}
|
|
|
|
get resolvedEmptyStateMessage(): string {
|
|
return this.emptyStateMessage ?? this.i18n.instant('servers.browser.empty.message');
|
|
}
|
|
|
|
get resolvedSearchPlaceholder(): string {
|
|
return this.searchPlaceholder ?? this.i18n.instant('servers.browser.search.placeholder');
|
|
}
|
|
|
|
serverCardTitle(server: ServerInfo): string {
|
|
return this.isJoinedServer(server)
|
|
? this.i18n.instant('servers.browser.card.doubleClickOpen', { name: server.name })
|
|
: this.i18n.instant('servers.browser.card.doubleClickJoin', { name: server.name });
|
|
}
|
|
|
|
serverActionsLabel(server: ServerInfo): string {
|
|
return this.i18n.instant('servers.browser.card.serverActions', { name: server.name });
|
|
}
|
|
|
|
joinServerLabel(server: ServerInfo): string {
|
|
return this.i18n.instant('servers.browser.card.joinServer', { name: server.name });
|
|
}
|
|
|
|
ownerLabel(server: ServerInfo): string {
|
|
return this.i18n.instant('servers.browser.card.owner', { name: this.getServerOwnerLabel(server) });
|
|
}
|
|
|
|
bannedDialogMessage(): string {
|
|
return this.i18n.instant('servers.browser.bannedDialog.message', {
|
|
name: this.bannedServerName() || this.i18n.instant('servers.browser.bannedDialog.thisServer')
|
|
});
|
|
}
|
|
|
|
passwordDialogMessage(server: ServerInfo): string {
|
|
return this.i18n.instant('servers.browser.passwordDialog.message', { name: server.name });
|
|
}
|
|
|
|
pluginUsesPluginsLabel(serverName: string): string {
|
|
return this.i18n.instant('servers.plugins.usesPlugins', { name: serverName });
|
|
}
|
|
|
|
pluginConsentConfirmLabel(requiredCount: number): string {
|
|
if (this.pluginConsentBusy()) {
|
|
return this.i18n.instant('servers.plugins.downloading');
|
|
}
|
|
|
|
return requiredCount > 0
|
|
? this.i18n.instant('servers.plugins.acceptAndJoin')
|
|
: this.i18n.instant('servers.plugins.join');
|
|
}
|
|
|
|
pluginReadmeButtonLabel(pluginId: string): string {
|
|
return this.pluginConsentReadmeLoadingId() === pluginId
|
|
? this.i18n.instant('common.labels.loading')
|
|
: this.i18n.instant('servers.plugins.readme');
|
|
}
|
|
|
|
searchQuery = '';
|
|
searchResults = this.store.selectSignal(selectSearchResults);
|
|
isSearching = this.store.selectSignal(selectIsSearching);
|
|
error = this.store.selectSignal(selectRoomsError);
|
|
savedRooms = this.store.selectSignal(selectSavedRooms);
|
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
|
activeEndpoints = this.serverDirectory.activeServers;
|
|
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);
|
|
joinedServerMenuId = signal<string | null>(null);
|
|
leaveDialogRoom = signal<Room | null>(null);
|
|
pluginConsentDialog = signal<JoinPluginConsentDialog | null>(null);
|
|
selectedOptionalPluginIds = signal<Set<string>>(new Set());
|
|
pluginConsentBusy = signal(false);
|
|
pluginConsentError = signal<string | null>(null);
|
|
pluginConsentReadme = signal<PluginStoreReadme | null>(null);
|
|
pluginConsentReadmeLoadingId = signal<string | null>(null);
|
|
pluginConsentReadmeError = signal<string | null>(null);
|
|
|
|
// The reactive effect is created in ngOnInit with an explicit injector so the
|
|
// component can be instantiated outside a change-detection context (e.g. unit tests).
|
|
ngOnInit(): void {
|
|
effect(
|
|
() => {
|
|
const servers = this.searchResults();
|
|
const currentUser = this.currentUser();
|
|
|
|
void this.refreshBannedLookup(servers, currentUser ?? null);
|
|
void this.requestMissingServerIcons(servers, currentUser ?? null);
|
|
},
|
|
{ injector: this.injector }
|
|
);
|
|
|
|
this.store.dispatch(RoomsActions.searchServers({ query: '' }));
|
|
this.store.dispatch(RoomsActions.loadRooms());
|
|
|
|
this.searchSubject.pipe(debounceTime(120), distinctUntilChanged()).subscribe((query) => {
|
|
this.store.dispatch(RoomsActions.searchServers({ query }));
|
|
});
|
|
}
|
|
|
|
/** True while the user is actively searching (non-empty query). */
|
|
get isSearchMode(): boolean {
|
|
return this.searchQuery.trim().length > 0;
|
|
}
|
|
|
|
/** Discovery sections that actually contain servers. */
|
|
get visibleSections(): ServerDiscoverySection[] {
|
|
return this.discoverySections.filter((section) => section.servers.length > 0);
|
|
}
|
|
|
|
/** True when there is nothing to render outside of search mode. */
|
|
get showEmptyState(): boolean {
|
|
return !this.isSearchMode && this.visibleSections.length === 0;
|
|
}
|
|
|
|
onSearchChange(query: string): void {
|
|
this.searchSubject.next(query);
|
|
}
|
|
|
|
async joinServer(server: ServerInfo): Promise<void> {
|
|
const currentUser = this.currentUser();
|
|
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
|
|
|
|
if (!currentUserId) {
|
|
this.router.navigate(['/login'], {
|
|
queryParams: buildLoginReturnQueryParams(this.router.url)
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
setStoredCurrentUserId(currentUserId);
|
|
|
|
if (await this.isServerBanned(server)) {
|
|
this.bannedServerName.set(server.name);
|
|
this.showBannedDialog.set(true);
|
|
return;
|
|
}
|
|
|
|
await this.attemptJoinServer(server);
|
|
}
|
|
|
|
joinSavedRoom(room: Room): void {
|
|
this.openJoinedRoom(room);
|
|
}
|
|
|
|
openServerCard(server: ServerInfo): void {
|
|
const joinedRoom = this.joinedRoomForServer(server);
|
|
|
|
if (joinedRoom) {
|
|
this.openJoinedRoom(joinedRoom);
|
|
return;
|
|
}
|
|
|
|
void this.joinServer(server);
|
|
}
|
|
|
|
joinedRoomForServer(server: ServerInfo): Room | null {
|
|
return this.savedRooms().find((room) => room.id === server.id) ?? null;
|
|
}
|
|
|
|
isJoinedServer(server: ServerInfo): boolean {
|
|
return !!this.joinedRoomForServer(server);
|
|
}
|
|
|
|
toggleJoinedServerMenu(event: Event, server: ServerInfo): void {
|
|
event.stopPropagation();
|
|
this.joinedServerMenuId.update((currentId) => (currentId === server.id ? null : server.id));
|
|
}
|
|
|
|
closeJoinedServerMenu(): void {
|
|
this.joinedServerMenuId.set(null);
|
|
}
|
|
|
|
openLeaveDialog(event: Event, server: ServerInfo): void {
|
|
event.stopPropagation();
|
|
const room = this.joinedRoomForServer(server);
|
|
|
|
if (!room) {
|
|
return;
|
|
}
|
|
|
|
this.joinedServerMenuId.set(null);
|
|
this.leaveDialogRoom.set(room);
|
|
}
|
|
|
|
closeLeaveDialog(): void {
|
|
this.leaveDialogRoom.set(null);
|
|
}
|
|
|
|
confirmLeaveServer(result: LeaveServerDialogResult): void {
|
|
const room = this.leaveDialogRoom();
|
|
|
|
if (!room) {
|
|
return;
|
|
}
|
|
|
|
this.store.dispatch(
|
|
RoomsActions.forgetRoom({
|
|
roomId: room.id,
|
|
nextOwnerKey: result.nextOwnerKey
|
|
})
|
|
);
|
|
|
|
this.leaveDialogRoom.set(null);
|
|
}
|
|
|
|
closeBannedDialog(): void {
|
|
this.showBannedDialog.set(false);
|
|
this.bannedServerName.set('');
|
|
}
|
|
|
|
closePasswordDialog(): void {
|
|
this.showPasswordDialog.set(false);
|
|
this.passwordPromptServer.set(null);
|
|
this.joinPassword.set('');
|
|
this.joinPasswordError.set(null);
|
|
}
|
|
|
|
closePluginConsentDialog(): void {
|
|
if (this.pluginConsentBusy()) {
|
|
return;
|
|
}
|
|
|
|
this.pluginConsentDialog.set(null);
|
|
this.selectedOptionalPluginIds.set(new Set());
|
|
this.pluginConsentError.set(null);
|
|
this.closePluginConsentReadme();
|
|
}
|
|
|
|
toggleOptionalPluginInstall(pluginId: string, checked: boolean): void {
|
|
this.selectedOptionalPluginIds.update((selectedIds) => {
|
|
const nextIds = new Set(selectedIds);
|
|
|
|
if (checked) {
|
|
nextIds.add(pluginId);
|
|
} else {
|
|
nextIds.delete(pluginId);
|
|
}
|
|
|
|
return nextIds;
|
|
});
|
|
}
|
|
|
|
async confirmPluginConsent(): Promise<void> {
|
|
const dialog = this.pluginConsentDialog();
|
|
|
|
if (!dialog) {
|
|
return;
|
|
}
|
|
|
|
const selectedOptionalIds = this.selectedOptionalPluginIds();
|
|
const acceptedRequirements = dialog.required.concat(
|
|
dialog.optional.filter((requirement) => selectedOptionalIds.has(requirement.pluginId))
|
|
);
|
|
|
|
this.pluginConsentBusy.set(true);
|
|
this.pluginConsentError.set(null);
|
|
|
|
try {
|
|
await this.attemptJoinServer(dialog.server, dialog.password, {
|
|
acceptedRequirements,
|
|
skipPluginConsent: true
|
|
});
|
|
|
|
this.pluginConsentDialog.set(null);
|
|
this.selectedOptionalPluginIds.set(new Set());
|
|
this.closePluginConsentReadme();
|
|
} catch (error) {
|
|
this.pluginConsentError.set(error instanceof Error ? error.message : this.i18n.instant('servers.errors.installPluginsFailed'));
|
|
} finally {
|
|
this.pluginConsentBusy.set(false);
|
|
}
|
|
}
|
|
|
|
async openPluginConsentReadme(requirement: PluginRequirementSummary): Promise<void> {
|
|
this.pluginConsentReadmeError.set(null);
|
|
this.pluginConsentReadmeLoadingId.set(requirement.pluginId);
|
|
|
|
try {
|
|
const readme = await this.pluginStore.loadRequirementReadme(requirement);
|
|
|
|
this.pluginConsentReadme.set(readme);
|
|
} catch (error) {
|
|
this.pluginConsentReadmeError.set(error instanceof Error ? error.message : this.i18n.instant('servers.errors.loadPluginReadmeFailed'));
|
|
} finally {
|
|
this.pluginConsentReadmeLoadingId.set(null);
|
|
}
|
|
}
|
|
|
|
closePluginConsentReadme(): void {
|
|
this.pluginConsentReadme.set(null);
|
|
this.pluginConsentReadmeError.set(null);
|
|
this.pluginConsentReadmeLoadingId.set(null);
|
|
}
|
|
|
|
openPluginSource(requirement: PluginRequirementSummary): void {
|
|
const sourceUrl = this.getPluginSourceUrl(requirement);
|
|
|
|
if (sourceUrl) {
|
|
this.externalLinks.open(sourceUrl);
|
|
}
|
|
}
|
|
|
|
getPluginSourceUrl(requirement: PluginRequirementSummary): string | null {
|
|
const candidate = requirement.manifest?.homepage ?? requirement.sourceUrl ?? requirement.installUrl ?? requirement.manifest?.bugs ?? null;
|
|
|
|
return candidate?.startsWith('http://') || candidate?.startsWith('https://') ? candidate : null;
|
|
}
|
|
|
|
hasPluginReadme(requirement: PluginRequirementSummary): boolean {
|
|
return !!requirement.manifest?.readme;
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
getServerUserCount(server: ServerInfo): number {
|
|
const candidate = server as ServerInfo & { currentUsers?: number };
|
|
|
|
if (typeof server.userCount === 'number')
|
|
return server.userCount;
|
|
|
|
return typeof candidate.currentUsers === 'number' ? candidate.currentUsers : 0;
|
|
}
|
|
|
|
getServerCapacityLabel(server: ServerInfo): string {
|
|
return server.maxUsers > 0 ? String(server.maxUsers) : '∞';
|
|
}
|
|
|
|
getServerOwnerLabel(server: ServerInfo): string {
|
|
const joinedRoom = this.joinedRoomForServer(server);
|
|
const ownerKey = server.ownerId || joinedRoom?.hostId || '';
|
|
const ownerMember = joinedRoom?.members?.find((member) => member.id === ownerKey || member.oderId === ownerKey);
|
|
|
|
return server.ownerName || ownerMember?.displayName || server.ownerId || joinedRoom?.hostId || this.i18n.instant('servers.browser.ownerUnknown');
|
|
}
|
|
|
|
private openJoinedRoom(room: Room): void {
|
|
this.joinedServerMenuId.set(null);
|
|
this.store.dispatch(RoomsActions.viewServer({ room }));
|
|
}
|
|
|
|
private async attemptJoinServer(
|
|
server: ServerInfo,
|
|
password?: string,
|
|
options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {}
|
|
): Promise<void> {
|
|
const currentUser = this.currentUser();
|
|
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
|
|
|
|
if (!currentUserId) {
|
|
this.router.navigate(['/login'], {
|
|
queryParams: buildLoginReturnQueryParams(this.router.url)
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
setStoredCurrentUserId(currentUserId);
|
|
|
|
this.joinErrorMessage.set(null);
|
|
this.joinPasswordError.set(null);
|
|
|
|
try {
|
|
if (options.skipPluginConsent !== true) {
|
|
const consentDialog = await this.buildPluginConsentDialog(server, password);
|
|
|
|
if (consentDialog) {
|
|
this.pluginConsentDialog.set(consentDialog);
|
|
this.selectedOptionalPluginIds.set(new Set(consentDialog.optional.map((requirement) => requirement.pluginId)));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (server.sourceUrl) {
|
|
const hasCredential = await this.signalServerAuthorize.ensureCredentialForServerUrl(server.sourceUrl);
|
|
|
|
if (!hasCredential) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
const response = await firstValueFrom(
|
|
this.serverDirectory.requestJoin(
|
|
{
|
|
roomId: server.id,
|
|
userId: currentUserId,
|
|
userPublicKey: currentUser?.oderId || currentUserId,
|
|
displayName: currentUser?.displayName || this.i18n.instant('common.labels.anonymous'),
|
|
password: password?.trim() || undefined
|
|
},
|
|
{
|
|
sourceId: server.sourceId,
|
|
sourceUrl: server.sourceUrl
|
|
}
|
|
)
|
|
);
|
|
const resolvedSource = this.serverDirectory.normaliseRoomSignalSource(
|
|
{
|
|
sourceId: response.server.sourceId ?? server.sourceId,
|
|
sourceName: response.server.sourceName ?? server.sourceName,
|
|
sourceUrl: response.server.sourceUrl ?? server.sourceUrl,
|
|
signalingUrl: response.signalingUrl,
|
|
fallbackName: response.server.sourceName ?? server.sourceName ?? server.name
|
|
},
|
|
{
|
|
ensureEndpoint: true
|
|
}
|
|
);
|
|
const resolvedServer = {
|
|
...server,
|
|
...response.server,
|
|
channels: Array.isArray(response.server.channels) && response.server.channels.length > 0 ? response.server.channels : server.channels,
|
|
...resolvedSource,
|
|
signalingUrl: response.signalingUrl
|
|
};
|
|
|
|
this.closePasswordDialog();
|
|
|
|
if (options.acceptedRequirements?.length) {
|
|
await this.pluginStore.installServerRequirementsLocally(resolvedServer.id, options.acceptedRequirements, { activate: true });
|
|
}
|
|
|
|
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 || this.i18n.instant('servers.errors.joinFailed');
|
|
|
|
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);
|
|
|
|
if (options.skipPluginConsent) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async buildPluginConsentDialog(server: ServerInfo, password?: string): Promise<JoinPluginConsentDialog | null> {
|
|
const apiBaseUrl = this.serverDirectory.getApiBaseUrl({
|
|
sourceId: server.sourceId,
|
|
sourceUrl: server.sourceUrl
|
|
});
|
|
const snapshot = await firstValueFrom(this.pluginRequirements.getSnapshot(apiBaseUrl, server.id));
|
|
const installedPluginIds = await this.pluginStore.getLocalServerInstalledPluginIds(server.id);
|
|
const installableRequirements = snapshot.requirements
|
|
.filter((requirement) => !installedPluginIds.has(requirement.pluginId))
|
|
.filter((requirement) => !!requirement.manifest || !!requirement.installUrl);
|
|
const required = installableRequirements.filter((requirement) => requirement.status === 'required');
|
|
const optional = installableRequirements.filter(
|
|
(requirement) => requirement.status === 'optional' || requirement.status === 'recommended'
|
|
);
|
|
|
|
if (required.length === 0 && optional.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
optional,
|
|
password,
|
|
required,
|
|
server
|
|
};
|
|
}
|
|
|
|
private async requestMissingServerIcons(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
|
if (!currentUser) {
|
|
return;
|
|
}
|
|
|
|
for (const server of servers) {
|
|
if (server.icon) {
|
|
continue;
|
|
}
|
|
|
|
const selector = this.serverDirectory.buildRoomSignalSelector(
|
|
{
|
|
sourceId: server.sourceId,
|
|
sourceName: server.sourceName,
|
|
sourceUrl: server.sourceUrl,
|
|
fallbackName: server.sourceName ?? server.name
|
|
},
|
|
{
|
|
ensureEndpoint: !!server.sourceUrl
|
|
}
|
|
);
|
|
|
|
if (!selector) {
|
|
continue;
|
|
}
|
|
|
|
const wsUrl = this.serverDirectory.getWebSocketUrl(selector);
|
|
|
|
try {
|
|
await firstValueFrom(this.webrtc.connectToSignalingServer(wsUrl));
|
|
this.webrtc.identify(currentUser.oderId || currentUser.id, currentUser.displayName || this.i18n.instant('common.labels.user'), wsUrl, {
|
|
description: currentUser.description,
|
|
profileUpdatedAt: currentUser.profileUpdatedAt,
|
|
homeSignalServerUrl: currentUser.homeSignalServerUrl
|
|
});
|
|
|
|
this.webrtc.sendRawMessageToSignalUrl(wsUrl, {
|
|
type: 'server_icon_sync_request',
|
|
serverId: server.id,
|
|
iconUpdatedAt: 0
|
|
});
|
|
} catch {
|
|
/* discovery icons are best-effort */
|
|
}
|
|
}
|
|
}
|
|
|
|
private async refreshBannedLookup(servers: ServerInfo[], currentUser: User | null): Promise<void> {
|
|
const requestVersion = ++this.banLookupRequestVersion;
|
|
|
|
if (!currentUser || servers.length === 0) {
|
|
this.bannedServerLookup.set({});
|
|
return;
|
|
}
|
|
|
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
|
const entries = await Promise.all(
|
|
servers.map(async (server) => {
|
|
const bans = await this.db.getBansForRoom(server.id);
|
|
const isBanned = hasRoomBanForUser(bans, currentUser, currentUserId);
|
|
|
|
return [server.id, isBanned] as const;
|
|
})
|
|
);
|
|
|
|
if (requestVersion !== this.banLookupRequestVersion)
|
|
return;
|
|
|
|
this.bannedServerLookup.set(Object.fromEntries(entries));
|
|
}
|
|
|
|
private async isServerBanned(server: ServerInfo): Promise<boolean> {
|
|
const currentUser = this.currentUser();
|
|
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
|
|
|
if (!currentUser && !currentUserId)
|
|
return false;
|
|
|
|
const bans = await this.db.getBansForRoom(server.id);
|
|
|
|
return hasRoomBanForUser(bans, currentUser, currentUserId);
|
|
}
|
|
}
|