Compare commits
10 Commits
v1.0.74
...
0467a7b612
| Author | SHA1 | Date | |
|---|---|---|---|
| 0467a7b612 | |||
| 971a5afb8b | |||
| fe9c1dd1c0 | |||
| 429bb9d8ff | |||
| b5d676fb78 | |||
| aa595c45d8 | |||
| 1c7e535057 | |||
| 8f960be1e9 | |||
| 9a173792a4 | |||
| cb2c0495b9 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,7 +6,9 @@
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
*.sqlite
|
||||
*/architecture.md
|
||||
/docs
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
|
||||
67
README.md
67
README.md
@@ -1,10 +1,14 @@
|
||||
<img src="./images/icon.png" width="100" height="100">
|
||||
|
||||
|
||||
# Toju / Zoracord
|
||||
|
||||
Desktop chat app with three parts:
|
||||
Desktop chat app with four parts:
|
||||
|
||||
- `src/` Angular client
|
||||
- `electron/` desktop shell, IPC, and local database
|
||||
- `server/` directory server, join request API, and websocket events
|
||||
- `website/` Toju website served at toju.app
|
||||
|
||||
## Install
|
||||
|
||||
@@ -52,3 +56,64 @@ Inside `server/`:
|
||||
- `npm run dev` starts the server with reload
|
||||
- `npm run build` compiles to `dist/`
|
||||
- `npm run start` runs the compiled server
|
||||
|
||||
# Images
|
||||
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
|
||||
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
|
||||
|
||||
## Main Toju app Structure
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `src/app/` | Main application root |
|
||||
| `src/app/core/` | Core utilities, services, models |
|
||||
| `src/app/domains/` | Domain-driven modules |
|
||||
| `src/app/features/` | UI feature modules |
|
||||
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
|
||||
| `src/app/shared/` | Shared UI components |
|
||||
| `src/app/shared-kernel/` | Shared domain contracts & models |
|
||||
| `src/app/store/` | Global state management |
|
||||
| `src/assets/` | Static assets |
|
||||
| `src/environments/` | Environment configs |
|
||||
|
||||
---
|
||||
|
||||
### Domains
|
||||
|
||||
| Path | Link |
|
||||
|------|------|
|
||||
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
|
||||
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
|
||||
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
|
||||
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
|
||||
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
|
||||
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
|
||||
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
|
||||
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
|
||||
|
||||
---
|
||||
|
||||
### Infrastructure
|
||||
|
||||
| Path | Link |
|
||||
|------|------|
|
||||
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
|
||||
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
|
||||
|
||||
---
|
||||
|
||||
### Shared Kernel
|
||||
|
||||
| Path | Link |
|
||||
|------|------|
|
||||
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
|
||||
|
||||
---
|
||||
|
||||
### Entry Points
|
||||
|
||||
| File | Link |
|
||||
|------|------|
|
||||
| Main | [main.ts](src/main.ts) |
|
||||
| Index HTML | [index.html](src/index.html) |
|
||||
| App Root | [app/app.ts](src/app/app.ts) |
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { app } from 'electron';
|
||||
import AutoLaunch from 'auto-launch';
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
let autoLauncher: AutoLaunch | null = null;
|
||||
let autoLaunchPath = '';
|
||||
|
||||
const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U'];
|
||||
|
||||
function resolveLaunchPath(): string {
|
||||
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
|
||||
@@ -13,15 +18,77 @@ function resolveLaunchPath(): string {
|
||||
return appImagePath || process.execPath;
|
||||
}
|
||||
|
||||
function escapeDesktopEntryExecArgument(argument: string): string {
|
||||
const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1');
|
||||
|
||||
return /[\s"]/u.test(argument)
|
||||
? `"${escapedArgument}"`
|
||||
: escapedArgument;
|
||||
}
|
||||
|
||||
function getLinuxAutoStartDesktopEntryPath(launchPath: string): string {
|
||||
return path.join(app.getPath('home'), '.config', 'autostart', `${path.basename(launchPath)}.desktop`);
|
||||
}
|
||||
|
||||
function buildLinuxAutoStartExecLine(launchPath: string): string {
|
||||
return `Exec=${[escapeDesktopEntryExecArgument(launchPath), ...LINUX_AUTO_START_ARGUMENTS].join(' ')}`;
|
||||
}
|
||||
|
||||
function buildLinuxAutoStartDesktopEntry(launchPath: string): string {
|
||||
const appName = path.basename(launchPath);
|
||||
|
||||
return [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Version=1.0',
|
||||
`Name=${appName}`,
|
||||
`Comment=${appName}startup script`,
|
||||
buildLinuxAutoStartExecLine(launchPath),
|
||||
'StartupNotify=false',
|
||||
'Terminal=false'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function synchronizeLinuxAutoStartDesktopEntry(launchPath: string): Promise<void> {
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
const desktopEntryPath = getLinuxAutoStartDesktopEntryPath(launchPath);
|
||||
const execLine = buildLinuxAutoStartExecLine(launchPath);
|
||||
|
||||
let currentDesktopEntry = '';
|
||||
|
||||
try {
|
||||
currentDesktopEntry = await fsp.readFile(desktopEntryPath, 'utf8');
|
||||
} catch {
|
||||
// Create the desktop entry if auto-launch did not leave one behind.
|
||||
}
|
||||
|
||||
const nextDesktopEntry = currentDesktopEntry
|
||||
? /^Exec=.*$/m.test(currentDesktopEntry)
|
||||
? currentDesktopEntry.replace(/^Exec=.*$/m, execLine)
|
||||
: `${currentDesktopEntry.trimEnd()}\n${execLine}\n`
|
||||
: buildLinuxAutoStartDesktopEntry(launchPath);
|
||||
|
||||
if (nextDesktopEntry === currentDesktopEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fsp.mkdir(path.dirname(desktopEntryPath), { recursive: true });
|
||||
await fsp.writeFile(desktopEntryPath, nextDesktopEntry, 'utf8');
|
||||
}
|
||||
|
||||
function getAutoLauncher(): AutoLaunch | null {
|
||||
if (!app.isPackaged) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!autoLauncher) {
|
||||
autoLaunchPath = resolveLaunchPath();
|
||||
autoLauncher = new AutoLaunch({
|
||||
name: app.getName(),
|
||||
path: resolveLaunchPath()
|
||||
path: autoLaunchPath
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,12 +104,16 @@ async function setAutoStartEnabled(enabled: boolean): Promise<void> {
|
||||
|
||||
const currentlyEnabled = await launcher.isEnabled();
|
||||
|
||||
if (currentlyEnabled === enabled) {
|
||||
if (!enabled && currentlyEnabled === enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
await launcher.enable();
|
||||
if (!currentlyEnabled) {
|
||||
await launcher.enable();
|
||||
}
|
||||
|
||||
await synchronizeLinuxAutoStartDesktopEntry(autoLaunchPath);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -8,18 +8,24 @@ interface WsMessage {
|
||||
type: string;
|
||||
}
|
||||
|
||||
function normalizeDisplayName(value: unknown, fallback = 'User'): string {
|
||||
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = Array.from(connectedUsers.values())
|
||||
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId)
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: cu.displayName ?? 'Anonymous' }));
|
||||
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
|
||||
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
|
||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
user.oderId = String(message['oderId'] || connectionId);
|
||||
user.displayName = String(message['displayName'] || 'Anonymous');
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
}
|
||||
@@ -47,7 +53,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
user.serverIds.add(sid);
|
||||
user.viewedServerId = sid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`);
|
||||
|
||||
sendServerUsers(user, sid);
|
||||
|
||||
@@ -55,7 +61,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
broadcastToServer(sid, {
|
||||
type: 'user_joined',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName ?? 'Anonymous',
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
serverId: sid
|
||||
}, user.oderId);
|
||||
}
|
||||
@@ -66,7 +72,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
|
||||
|
||||
user.viewedServerId = viewSid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${user.displayName ?? 'Anonymous'} (${user.oderId}) viewing server ${viewSid}`);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||
|
||||
sendServerUsers(user, viewSid);
|
||||
}
|
||||
@@ -87,7 +93,7 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
broadcastToServer(leaveSid, {
|
||||
type: 'user_left',
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName ?? 'Anonymous',
|
||||
displayName: normalizeDisplayName(user.displayName),
|
||||
serverId: leaveSid,
|
||||
serverIds: Array.from(user.serverIds)
|
||||
}, user.oderId);
|
||||
|
||||
@@ -10,22 +10,22 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'login',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/login/login.component').then((module) => module.LoginComponent)
|
||||
import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent)
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
loadComponent: () =>
|
||||
import('./features/auth/register/register.component').then((module) => module.RegisterComponent)
|
||||
import('./domains/auth/feature/register/register.component').then((module) => module.RegisterComponent)
|
||||
},
|
||||
{
|
||||
path: 'invite/:inviteId',
|
||||
loadComponent: () =>
|
||||
import('./features/invite/invite.component').then((module) => module.InviteComponent)
|
||||
import('./domains/server-directory/feature/invite/invite.component').then((module) => module.InviteComponent)
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
loadComponent: () =>
|
||||
import('./features/server-search/server-search.component').then(
|
||||
import('./domains/server-directory/feature/server-search/server-search.component').then(
|
||||
(module) => module.ServerSearchComponent
|
||||
)
|
||||
},
|
||||
|
||||
@@ -14,16 +14,17 @@ import {
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Store } from '@ngrx/store';
|
||||
|
||||
import { DatabaseService } from './core/services/database.service';
|
||||
import { DatabaseService } from './infrastructure/persistence';
|
||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||
import { ServerDirectoryService } from './core/services/server-directory.service';
|
||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||
import { TimeSyncService } from './core/services/time-sync.service';
|
||||
import { VoiceSessionService } from './core/services/voice-session.service';
|
||||
import { ExternalLinkService } from './core/services/external-link.service';
|
||||
import { VoiceSessionFacade } from './domains/voice-session';
|
||||
import { ExternalLinkService } from './core/platform';
|
||||
import { SettingsModalService } from './core/services/settings-modal.service';
|
||||
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
|
||||
import { ServersRailComponent } from './features/servers/servers-rail.component';
|
||||
import { TitleBarComponent } from './features/shell/title-bar.component';
|
||||
import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component';
|
||||
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
|
||||
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
|
||||
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
|
||||
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
|
||||
@@ -36,15 +37,6 @@ 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: [
|
||||
@@ -68,11 +60,12 @@ export class App implements OnInit, OnDestroy {
|
||||
|
||||
private databaseService = inject(DatabaseService);
|
||||
private router = inject(Router);
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private servers = inject(ServerDirectoryFacade);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private timeSync = inject(TimeSyncService);
|
||||
private voiceSession = inject(VoiceSessionService);
|
||||
private voiceSession = inject(VoiceSessionFacade);
|
||||
private externalLinks = inject(ExternalLinkService);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private deepLinkCleanup: (() => void) | null = null;
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
@@ -155,7 +148,7 @@ export class App implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async setupDesktopDeepLinks(): Promise<void> {
|
||||
const electronApi = this.getDeepLinkElectronApi();
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return;
|
||||
@@ -186,12 +179,6 @@ export class App implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private getDeepLinkElectronApi(): DeepLinkElectronApi | null {
|
||||
return typeof window !== 'undefined'
|
||||
? (window as DeepLinkWindow).electronAPI ?? null
|
||||
: null;
|
||||
}
|
||||
|
||||
private isPublicRoute(url: string): boolean {
|
||||
return url === '/login' ||
|
||||
url === '/register' ||
|
||||
|
||||
@@ -1,320 +1,52 @@
|
||||
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
|
||||
/**
|
||||
* Transitional compatibility barrel.
|
||||
*
|
||||
* All business types now live in `src/app/shared-kernel/` (organised by concept)
|
||||
* or in their owning domain. This file re-exports everything so existing
|
||||
* `import { X } from 'core/models'` lines keep working while the codebase
|
||||
* migrates to direct shared-kernel imports.
|
||||
*
|
||||
* NEW CODE should import from `@shared-kernel` or the owning domain barrel
|
||||
* instead of this file.
|
||||
*/
|
||||
|
||||
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
|
||||
export type {
|
||||
User,
|
||||
UserStatus,
|
||||
UserRole,
|
||||
RoomMember
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export type ChannelType = 'text' | 'voice';
|
||||
export type {
|
||||
Room,
|
||||
RoomSettings,
|
||||
RoomPermissions,
|
||||
Channel,
|
||||
ChannelType
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||
export type { Message, Reaction } from '../../shared-kernel';
|
||||
export { DELETED_MESSAGE_CONTENT } from '../../shared-kernel';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
oderId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
status: UserStatus;
|
||||
role: UserRole;
|
||||
joinedAt: number;
|
||||
peerId?: string;
|
||||
isOnline?: boolean;
|
||||
isAdmin?: boolean;
|
||||
isRoomOwner?: boolean;
|
||||
voiceState?: VoiceState;
|
||||
screenShareState?: ScreenShareState;
|
||||
}
|
||||
export type { BanEntry } from '../../shared-kernel';
|
||||
|
||||
export interface RoomMember {
|
||||
id: string;
|
||||
oderId?: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: UserRole;
|
||||
joinedAt: number;
|
||||
lastSeenAt: number;
|
||||
}
|
||||
export type { VoiceState, ScreenShareState } from '../../shared-kernel';
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
position: number;
|
||||
}
|
||||
export type {
|
||||
ChatEventBase,
|
||||
ChatEventType,
|
||||
ChatEvent,
|
||||
ChatInventoryItem
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
roomId: string;
|
||||
channelId?: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
editedAt?: number;
|
||||
reactions: Reaction[];
|
||||
isDeleted: boolean;
|
||||
replyToId?: string;
|
||||
}
|
||||
export type {
|
||||
SignalingMessage,
|
||||
SignalingMessageType
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
messageId: string;
|
||||
oderId: string;
|
||||
userId: string;
|
||||
emoji: string;
|
||||
timestamp: number;
|
||||
}
|
||||
export type {
|
||||
ChatAttachmentAnnouncement,
|
||||
ChatAttachmentMeta
|
||||
} from '../../shared-kernel';
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
hostId: string;
|
||||
password?: string;
|
||||
hasPassword?: boolean;
|
||||
isPrivate: boolean;
|
||||
createdAt: number;
|
||||
userCount: number;
|
||||
maxUsers?: number;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
permissions?: RoomPermissions;
|
||||
channels?: Channel[];
|
||||
members?: RoomMember[];
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface RoomSettings {
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
isPrivate: boolean;
|
||||
password?: string;
|
||||
hasPassword?: boolean;
|
||||
maxUsers?: number;
|
||||
rules?: string[];
|
||||
}
|
||||
|
||||
export interface RoomPermissions {
|
||||
adminsManageRooms?: boolean;
|
||||
moderatorsManageRooms?: boolean;
|
||||
adminsManageIcon?: boolean;
|
||||
moderatorsManageIcon?: boolean;
|
||||
allowVoice?: boolean;
|
||||
allowScreenShare?: boolean;
|
||||
allowFileUploads?: boolean;
|
||||
slowModeInterval?: number;
|
||||
}
|
||||
|
||||
export interface BanEntry {
|
||||
oderId: string;
|
||||
userId: string;
|
||||
roomId: string;
|
||||
bannedBy: string;
|
||||
displayName?: string;
|
||||
reason?: string;
|
||||
expiresAt?: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PeerConnection {
|
||||
peerId: string;
|
||||
userId: string;
|
||||
status: 'connecting' | 'connected' | 'disconnected' | 'failed';
|
||||
dataChannel?: RTCDataChannel;
|
||||
connection?: RTCPeerConnection;
|
||||
}
|
||||
|
||||
export interface VoiceState {
|
||||
isConnected: boolean;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isSpeaking: boolean;
|
||||
isMutedByAdmin?: boolean;
|
||||
volume?: number;
|
||||
roomId?: string;
|
||||
serverId?: string;
|
||||
}
|
||||
|
||||
export interface ScreenShareState {
|
||||
isSharing: boolean;
|
||||
streamId?: string;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
}
|
||||
|
||||
export type SignalingMessageType =
|
||||
| 'offer'
|
||||
| 'answer'
|
||||
| 'ice-candidate'
|
||||
| 'join'
|
||||
| 'leave'
|
||||
| 'chat'
|
||||
| 'state-sync'
|
||||
| 'kick'
|
||||
| 'ban'
|
||||
| 'host-change'
|
||||
| 'room-update';
|
||||
|
||||
export interface SignalingMessage {
|
||||
type: SignalingMessageType;
|
||||
from: string;
|
||||
to?: string;
|
||||
payload: unknown;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export type ChatEventType =
|
||||
| 'message'
|
||||
| 'chat-message'
|
||||
| 'edit'
|
||||
| 'message-edited'
|
||||
| 'delete'
|
||||
| 'message-deleted'
|
||||
| 'reaction'
|
||||
| 'reaction-added'
|
||||
| 'reaction-removed'
|
||||
| 'kick'
|
||||
| 'ban'
|
||||
| 'room-deleted'
|
||||
| 'host-change'
|
||||
| 'room-settings-update'
|
||||
| 'voice-state'
|
||||
| 'chat-inventory-request'
|
||||
| 'chat-inventory'
|
||||
| 'chat-sync-request-ids'
|
||||
| 'chat-sync-batch'
|
||||
| 'chat-sync-summary'
|
||||
| 'chat-sync-request'
|
||||
| 'chat-sync-full'
|
||||
| 'file-announce'
|
||||
| 'file-chunk'
|
||||
| 'file-request'
|
||||
| 'file-cancel'
|
||||
| 'file-not-found'
|
||||
| 'member-roster-request'
|
||||
| 'member-roster'
|
||||
| 'member-leave'
|
||||
| 'voice-state-request'
|
||||
| 'state-request'
|
||||
| 'screen-state'
|
||||
| 'screen-share-request'
|
||||
| 'screen-share-stop'
|
||||
| 'role-change'
|
||||
| 'room-permissions-update'
|
||||
| 'server-icon-summary'
|
||||
| 'server-icon-request'
|
||||
| 'server-icon-full'
|
||||
| 'server-icon-update'
|
||||
| 'server-state-request'
|
||||
| 'server-state-full'
|
||||
| 'unban'
|
||||
| 'channels-update';
|
||||
|
||||
export interface ChatInventoryItem {
|
||||
id: string;
|
||||
ts: number;
|
||||
rc: number;
|
||||
ac?: number;
|
||||
}
|
||||
|
||||
export interface ChatAttachmentAnnouncement {
|
||||
id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
isImage: boolean;
|
||||
uploaderPeerId?: string;
|
||||
}
|
||||
|
||||
export interface ChatAttachmentMeta extends ChatAttachmentAnnouncement {
|
||||
messageId: string;
|
||||
filePath?: string;
|
||||
savedPath?: string;
|
||||
}
|
||||
|
||||
/** Optional fields depend on `type`. */
|
||||
export interface ChatEvent {
|
||||
type: ChatEventType;
|
||||
fromPeerId?: string;
|
||||
messageId?: string;
|
||||
message?: Message;
|
||||
reaction?: Reaction;
|
||||
data?: string | Partial<Message>;
|
||||
timestamp?: number;
|
||||
targetUserId?: string;
|
||||
roomId?: string;
|
||||
items?: ChatInventoryItem[];
|
||||
ids?: string[];
|
||||
messages?: Message[];
|
||||
attachments?: Record<string, ChatAttachmentMeta[]>;
|
||||
total?: number;
|
||||
index?: number;
|
||||
count?: number;
|
||||
lastUpdated?: number;
|
||||
file?: ChatAttachmentAnnouncement;
|
||||
fileId?: string;
|
||||
hostId?: string;
|
||||
hostOderId?: string;
|
||||
previousHostId?: string;
|
||||
previousHostOderId?: string;
|
||||
kickedBy?: string;
|
||||
bannedBy?: string;
|
||||
content?: string;
|
||||
editedAt?: number;
|
||||
deletedAt?: number;
|
||||
deletedBy?: string;
|
||||
oderId?: string;
|
||||
displayName?: string;
|
||||
emoji?: string;
|
||||
reason?: string;
|
||||
settings?: Partial<RoomSettings>;
|
||||
permissions?: Partial<RoomPermissions>;
|
||||
voiceState?: Partial<VoiceState>;
|
||||
isScreenSharing?: boolean;
|
||||
icon?: string;
|
||||
iconUpdatedAt?: number;
|
||||
role?: UserRole;
|
||||
room?: Partial<Room>;
|
||||
channels?: Channel[];
|
||||
members?: RoomMember[];
|
||||
ban?: BanEntry;
|
||||
bans?: BanEntry[];
|
||||
banOderId?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
hostName: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
ownerPublicKey?: string;
|
||||
userCount: number;
|
||||
maxUsers: number;
|
||||
hasPassword?: boolean;
|
||||
isPrivate: boolean;
|
||||
tags?: string[];
|
||||
createdAt: number;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface JoinRequest {
|
||||
roomId: string;
|
||||
userId: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
currentUser: User | null;
|
||||
currentRoom: Room | null;
|
||||
isConnecting: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
export type { ServerInfo } from '../../domains/server-directory';
|
||||
|
||||
150
src/app/core/platform/electron/electron-api.models.ts
Normal file
150
src/app/core/platform/electron/electron-api.models.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
active: boolean;
|
||||
monitorCaptureSupported: boolean;
|
||||
screenShareSinkName: string;
|
||||
screenShareMonitorSourceName: string;
|
||||
voiceSinkName: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface LinuxScreenShareMonitorCaptureInfo {
|
||||
bitsPerSample: number;
|
||||
captureId: string;
|
||||
channelCount: number;
|
||||
sampleRate: number;
|
||||
sourceName: string;
|
||||
}
|
||||
|
||||
export interface LinuxScreenShareMonitorAudioChunkPayload {
|
||||
captureId: string;
|
||||
chunk: Uint8Array;
|
||||
}
|
||||
|
||||
export interface LinuxScreenShareMonitorAudioEndedPayload {
|
||||
captureId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ClipboardFilePayload {
|
||||
data: string;
|
||||
lastModified: number;
|
||||
mime: string;
|
||||
name: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
|
||||
export type DesktopUpdateStatus =
|
||||
| 'idle'
|
||||
| 'disabled'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'up-to-date'
|
||||
| 'restart-required'
|
||||
| 'unsupported'
|
||||
| 'no-manifest'
|
||||
| 'target-unavailable'
|
||||
| 'target-older-than-installed'
|
||||
| 'error';
|
||||
|
||||
export type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
|
||||
|
||||
export interface DesktopUpdateServerContext {
|
||||
manifestUrls: string[];
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
export interface DesktopUpdateState {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
availableVersions: string[];
|
||||
configuredManifestUrls: string[];
|
||||
currentVersion: string;
|
||||
defaultManifestUrls: string[];
|
||||
isSupported: boolean;
|
||||
lastCheckedAt: number | null;
|
||||
latestVersion: string | null;
|
||||
manifestUrl: string | null;
|
||||
manifestUrls: string[];
|
||||
minimumServerVersion: string | null;
|
||||
preferredVersion: string | null;
|
||||
restartRequired: boolean;
|
||||
serverBlocked: boolean;
|
||||
serverBlockMessage: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
status: DesktopUpdateStatus;
|
||||
statusMessage: string | null;
|
||||
targetVersion: string | null;
|
||||
}
|
||||
|
||||
export interface DesktopSettingsSnapshot {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
autoStart: boolean;
|
||||
hardwareAcceleration: boolean;
|
||||
manifestUrls: string[];
|
||||
preferredVersion: string | null;
|
||||
runtimeHardwareAcceleration: boolean;
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopSettingsPatch {
|
||||
autoUpdateMode?: AutoUpdateMode;
|
||||
autoStart?: boolean;
|
||||
hardwareAcceleration?: boolean;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
vaapiVideoEncode?: boolean;
|
||||
}
|
||||
|
||||
export interface ElectronCommand {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface ElectronQuery {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
closeWindow: () => void;
|
||||
openExternal: (url: string) => Promise<boolean>;
|
||||
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
|
||||
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
|
||||
startLinuxScreenShareMonitorCapture: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
|
||||
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
consumePendingDeepLink: () => Promise<string | null>;
|
||||
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
|
||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||
restartToApplyUpdate: () => Promise<boolean>;
|
||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||
setDesktopSettings: (patch: DesktopSettingsPatch) => Promise<DesktopSettingsSnapshot>;
|
||||
relaunchApp: () => Promise<boolean>;
|
||||
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||
readFile: (filePath: string) => Promise<string>;
|
||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
fileExists: (filePath: string) => Promise<boolean>;
|
||||
deleteFile: (filePath: string) => Promise<boolean>;
|
||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
|
||||
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
|
||||
}
|
||||
|
||||
export type ElectronWindow = Window & {
|
||||
electronAPI?: ElectronApi;
|
||||
};
|
||||
24
src/app/core/platform/electron/electron-bridge.service.ts
Normal file
24
src/app/core/platform/electron/electron-bridge.service.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import type { ElectronApi } from './electron-api.models';
|
||||
import { getElectronApi } from './get-electron-api';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ElectronBridgeService {
|
||||
get isAvailable(): boolean {
|
||||
return this.getApi() !== null;
|
||||
}
|
||||
|
||||
getApi(): ElectronApi | null {
|
||||
return getElectronApi();
|
||||
}
|
||||
|
||||
requireApi(): ElectronApi {
|
||||
const api = this.getApi();
|
||||
|
||||
if (!api) {
|
||||
throw new Error('Electron API is not available in this runtime.');
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
}
|
||||
7
src/app/core/platform/electron/get-electron-api.ts
Normal file
7
src/app/core/platform/electron/get-electron-api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ElectronApi, ElectronWindow } from './electron-api.models';
|
||||
|
||||
export function getElectronApi(): ElectronApi | null {
|
||||
return typeof window !== 'undefined'
|
||||
? (window as ElectronWindow).electronAPI ?? null
|
||||
: null;
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { PlatformService } from './platform.service';
|
||||
|
||||
interface ExternalLinkElectronApi {
|
||||
openExternal?: (url: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
type ExternalLinkWindow = Window & {
|
||||
electronAPI?: ExternalLinkElectronApi;
|
||||
};
|
||||
import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||
|
||||
/**
|
||||
* Opens URLs in the system default browser (Electron) or a new tab (browser).
|
||||
@@ -17,18 +9,21 @@ type ExternalLinkWindow = Window & {
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExternalLinkService {
|
||||
private platform = inject(PlatformService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
/** Open a URL externally. Only http/https URLs are allowed. */
|
||||
open(url: string): void {
|
||||
if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
|
||||
return;
|
||||
|
||||
if (this.platform.isElectron) {
|
||||
(window as ExternalLinkWindow).electronAPI?.openExternal?.(url);
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi) {
|
||||
void electronApi.openExternal(url);
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,22 +36,19 @@ export class ExternalLinkService {
|
||||
if (!target)
|
||||
return false;
|
||||
|
||||
const href = target.href; // resolved full URL
|
||||
const href = target.href;
|
||||
|
||||
if (!href)
|
||||
return false;
|
||||
|
||||
// Skip non-navigable URLs
|
||||
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
|
||||
return false;
|
||||
|
||||
// Skip same-page anchors
|
||||
const rawAttr = target.getAttribute('href');
|
||||
|
||||
if (rawAttr?.startsWith('#'))
|
||||
return false;
|
||||
|
||||
// Skip Angular router links
|
||||
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
|
||||
return false;
|
||||
|
||||
2
src/app/core/platform/index.ts
Normal file
2
src/app/core/platform/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './platform.service';
|
||||
export * from './external-link.service';
|
||||
15
src/app/core/platform/platform.service.ts
Normal file
15
src/app/core/platform/platform.service.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from './electron/electron-bridge.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlatformService {
|
||||
readonly isElectron: boolean;
|
||||
readonly isBrowser: boolean;
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
constructor() {
|
||||
this.isElectron = this.electronBridge.isAvailable;
|
||||
|
||||
this.isBrowser = !this.isElectron;
|
||||
}
|
||||
}
|
||||
8
src/app/core/realtime/index.ts
Normal file
8
src/app/core/realtime/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Transitional application-facing boundary over the shared realtime runtime.
|
||||
* Keep business domains depending on this technical API rather than reaching
|
||||
* into low-level infrastructure implementations directly.
|
||||
*/
|
||||
export { WebRTCService as RealtimeSessionFacade } from '../../infrastructure/realtime/realtime-session.service';
|
||||
export * from '../../infrastructure/realtime/realtime.constants';
|
||||
export * from '../../infrastructure/realtime/realtime.types';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable complexity, padding-line-between-statements */
|
||||
import { getDebugNetworkMetricSnapshot } from '../debug-network-metrics.service';
|
||||
import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||
import type { Room, User } from '../../models/index';
|
||||
import {
|
||||
LOCAL_NETWORK_NODE_ID,
|
||||
|
||||
@@ -5,65 +5,16 @@ import {
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { PlatformService } from './platform.service';
|
||||
import { ServerDirectoryService, type ServerEndpoint } from './server-directory.service';
|
||||
|
||||
type AutoUpdateMode = 'auto' | 'off' | 'version';
|
||||
type DesktopUpdateStatus =
|
||||
| 'idle'
|
||||
| 'disabled'
|
||||
| 'checking'
|
||||
| 'downloading'
|
||||
| 'up-to-date'
|
||||
| 'restart-required'
|
||||
| 'unsupported'
|
||||
| 'no-manifest'
|
||||
| 'target-unavailable'
|
||||
| 'target-older-than-installed'
|
||||
| 'error';
|
||||
type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
|
||||
|
||||
interface DesktopUpdateState {
|
||||
autoUpdateMode: AutoUpdateMode;
|
||||
availableVersions: string[];
|
||||
configuredManifestUrls: string[];
|
||||
currentVersion: string;
|
||||
defaultManifestUrls: string[];
|
||||
isSupported: boolean;
|
||||
lastCheckedAt: number | null;
|
||||
latestVersion: string | null;
|
||||
manifestUrl: string | null;
|
||||
manifestUrls: string[];
|
||||
minimumServerVersion: string | null;
|
||||
preferredVersion: string | null;
|
||||
restartRequired: boolean;
|
||||
serverBlocked: boolean;
|
||||
serverBlockMessage: string | null;
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
status: DesktopUpdateStatus;
|
||||
statusMessage: string | null;
|
||||
targetVersion: string | null;
|
||||
}
|
||||
|
||||
interface DesktopUpdateServerContext {
|
||||
manifestUrls: string[];
|
||||
serverVersion: string | null;
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
interface DesktopUpdateElectronApi {
|
||||
checkForAppUpdates?: () => Promise<DesktopUpdateState>;
|
||||
configureAutoUpdateContext?: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||
getAutoUpdateState?: () => Promise<DesktopUpdateState>;
|
||||
onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||
restartToApplyUpdate?: () => Promise<boolean>;
|
||||
setDesktopSettings?: (patch: {
|
||||
autoUpdateMode?: AutoUpdateMode;
|
||||
manifestUrls?: string[];
|
||||
preferredVersion?: string | null;
|
||||
}) => Promise<unknown>;
|
||||
}
|
||||
import { PlatformService } from '../platform';
|
||||
import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory';
|
||||
import {
|
||||
type AutoUpdateMode,
|
||||
type DesktopUpdateServerContext,
|
||||
type DesktopUpdateServerVersionStatus,
|
||||
type DesktopUpdateState,
|
||||
type ElectronApi
|
||||
} from '../platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
|
||||
|
||||
interface ServerHealthResponse {
|
||||
releaseManifestUrl?: string;
|
||||
@@ -77,10 +28,6 @@ interface ServerHealthSnapshot {
|
||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||
}
|
||||
|
||||
type DesktopUpdateWindow = Window & {
|
||||
electronAPI?: DesktopUpdateElectronApi;
|
||||
};
|
||||
|
||||
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
|
||||
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
|
||||
|
||||
@@ -153,7 +100,8 @@ export class DesktopAppUpdateService {
|
||||
readonly state = signal<DesktopUpdateState>(createInitialState());
|
||||
|
||||
private injector = inject(Injector);
|
||||
private servers = inject(ServerDirectoryService);
|
||||
private servers = inject(ServerDirectoryFacade);
|
||||
private electronBridge = inject(ElectronBridgeService);
|
||||
private initialized = false;
|
||||
private refreshTimerId: number | null = null;
|
||||
private removeStateListener: (() => void) | null = null;
|
||||
@@ -393,9 +341,7 @@ export class DesktopAppUpdateService {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
private getElectronApi(): DesktopUpdateElectronApi | null {
|
||||
return typeof window !== 'undefined'
|
||||
? (window as DesktopUpdateWindow).electronAPI ?? null
|
||||
: null;
|
||||
private getElectronApi(): ElectronApi | null {
|
||||
return this.electronBridge.getApi();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
export * from './notification-audio.service';
|
||||
export * from './platform.service';
|
||||
export * from './browser-database.service';
|
||||
export * from './electron-database.service';
|
||||
export * from './database.service';
|
||||
export * from '../models/debugging.models';
|
||||
export * from './debugging/debugging.service';
|
||||
export * from './webrtc.service';
|
||||
export * from './server-directory.service';
|
||||
export * from './klipy.service';
|
||||
export * from './voice-session.service';
|
||||
export * from './voice-activity.service';
|
||||
export * from './external-link.service';
|
||||
export * from './settings-modal.service';
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
type ElectronPlatformWindow = Window & {
|
||||
electronAPI?: unknown;
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlatformService {
|
||||
readonly isElectron: boolean;
|
||||
readonly isBrowser: boolean;
|
||||
|
||||
constructor() {
|
||||
this.isElectron =
|
||||
typeof window !== 'undefined' && !!(window as ElectronPlatformWindow).electronAPI;
|
||||
|
||||
this.isBrowser = !this.isElectron;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Barrel export for the WebRTC sub-module.
|
||||
*
|
||||
* Other modules should import from here:
|
||||
* import { ... } from './webrtc';
|
||||
*/
|
||||
export * from './webrtc.constants';
|
||||
export * from './webrtc.types';
|
||||
export * from './webrtc-logger';
|
||||
export * from './signaling.manager';
|
||||
export * from './peer-connection.manager';
|
||||
export * from './media.manager';
|
||||
export * from './screen-share.manager';
|
||||
export * from './screen-share.config';
|
||||
export * from './noise-reduction.manager';
|
||||
@@ -1,80 +0,0 @@
|
||||
export interface DesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
export interface ElectronDesktopSourceSelection {
|
||||
includeSystemAudio: boolean;
|
||||
source: DesktopSource;
|
||||
}
|
||||
|
||||
export interface ElectronDesktopCaptureResult {
|
||||
includeSystemAudio: boolean;
|
||||
stream: MediaStream;
|
||||
}
|
||||
|
||||
export interface LinuxScreenShareAudioRoutingInfo {
|
||||
available: boolean;
|
||||
active: boolean;
|
||||
monitorCaptureSupported: boolean;
|
||||
screenShareSinkName: string;
|
||||
screenShareMonitorSourceName: string;
|
||||
voiceSinkName: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface LinuxScreenShareMonitorCaptureInfo {
|
||||
bitsPerSample: number;
|
||||
captureId: string;
|
||||
channelCount: number;
|
||||
sampleRate: number;
|
||||
sourceName: string;
|
||||
}
|
||||
|
||||
export interface LinuxScreenShareMonitorAudioChunkPayload {
|
||||
captureId: string;
|
||||
chunk: Uint8Array;
|
||||
}
|
||||
|
||||
export interface LinuxScreenShareMonitorAudioEndedPayload {
|
||||
captureId: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ScreenShareElectronApi {
|
||||
getSources?: () => Promise<DesktopSource[]>;
|
||||
prepareLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
activateLinuxScreenShareAudioRouting?: () => Promise<LinuxScreenShareAudioRoutingInfo>;
|
||||
deactivateLinuxScreenShareAudioRouting?: () => Promise<boolean>;
|
||||
startLinuxScreenShareMonitorCapture?: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
|
||||
stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise<boolean>;
|
||||
onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
}
|
||||
|
||||
export type ElectronDesktopVideoConstraint = MediaTrackConstraints & {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop';
|
||||
chromeMediaSourceId: string;
|
||||
maxWidth: number;
|
||||
maxHeight: number;
|
||||
maxFrameRate: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type ElectronDesktopAudioConstraint = MediaTrackConstraints & {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'desktop';
|
||||
chromeMediaSourceId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints {
|
||||
video: ElectronDesktopVideoConstraint;
|
||||
audio?: false | ElectronDesktopAudioConstraint;
|
||||
}
|
||||
|
||||
export type ScreenShareWindow = Window & {
|
||||
electronAPI?: ScreenShareElectronApi;
|
||||
};
|
||||
62
src/app/domains/README.md
Normal file
62
src/app/domains/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Domains
|
||||
|
||||
Each folder below is a **bounded context** — a self-contained slice of
|
||||
business logic with its own models, application services, and (optionally)
|
||||
infrastructure adapters and UI.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Domain | Purpose | Public entry point |
|
||||
|---|---|---|
|
||||
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
|
||||
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
|
||||
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
|
||||
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
|
||||
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
|
||||
| **voice-connection** | Voice activity detection, bitrate profiles | `VoiceConnectionFacade` |
|
||||
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
|
||||
|
||||
## Folder convention
|
||||
|
||||
Every domain follows the same internal layout:
|
||||
|
||||
```
|
||||
domains/<name>/
|
||||
├── index.ts # Barrel — the ONLY file outsiders import
|
||||
├── domain/ # Pure types, interfaces, business rules
|
||||
│ ├── <name>.models.ts
|
||||
│ └── <name>.logic.ts # Pure functions (no Angular, no side effects)
|
||||
├── application/ # Angular services that orchestrate domain logic
|
||||
│ └── <name>.facade.ts # Public entry point for the domain
|
||||
├── infrastructure/ # Technical adapters (HTTP, storage, WebSocket)
|
||||
└── feature/ # Optional: domain-owned UI components / routes
|
||||
└── settings/ # e.g. settings subpanel owned by this domain
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Import from the barrel.** Outside a domain, always import from
|
||||
`domains/<name>` (the `index.ts`), never from internal paths.
|
||||
|
||||
2. **No cross-domain imports.** Domain A must never import from Domain B's
|
||||
internals. Shared types live in `shared-kernel/`.
|
||||
|
||||
3. **Features compose domains.** Top-level `features/` components inject
|
||||
domain facades and compose their outputs — they never contain business
|
||||
logic.
|
||||
|
||||
4. **Store slices are application-level.** `store/messages`, `store/rooms`,
|
||||
`store/users` are global state managed by NgRx. They import from
|
||||
`shared-kernel` for types and from domain facades for side-effects.
|
||||
|
||||
## Where do I put new code?
|
||||
|
||||
| I want to… | Put it in… |
|
||||
|---|---|
|
||||
| Add a new business concept | New folder under `domains/` following the convention above |
|
||||
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
|
||||
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
|
||||
| Add a settings subpanel | `domains/<name>/feature/settings/` |
|
||||
| Add a top-level page or shell component | `features/` |
|
||||
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
|
||||
| Add realtime/WebRTC logic | `infrastructure/realtime/` |
|
||||
148
src/app/domains/attachment/README.md
Normal file
148
src/app/domains/attachment/README.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# Attachment Domain
|
||||
|
||||
Handles file sharing between peers over WebRTC data channels. Files are announced, chunked into 64 KB pieces, streamed peer-to-peer as base64, and optionally persisted to disk (Electron) or kept in memory (browser).
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
attachment/
|
||||
├── application/
|
||||
│ ├── attachment.facade.ts Thin entry point, delegates to manager
|
||||
│ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners
|
||||
│ ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel)
|
||||
│ ├── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming
|
||||
│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage
|
||||
│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending)
|
||||
│
|
||||
├── domain/
|
||||
│ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state
|
||||
│ ├── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
|
||||
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
|
||||
│ ├── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...)
|
||||
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
|
||||
│
|
||||
├── infrastructure/
|
||||
│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete)
|
||||
│ └── attachment-storage.helpers.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Service composition
|
||||
|
||||
The facade is a thin pass-through. All real work happens inside the manager, which coordinates the transfer service (protocol), persistence service (DB/disk), and runtime store (signals).
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Facade[AttachmentFacade]
|
||||
Manager[AttachmentManagerService]
|
||||
Transfer[AttachmentTransferService]
|
||||
Transport[AttachmentTransferTransportService]
|
||||
Persistence[AttachmentPersistenceService]
|
||||
Store[AttachmentRuntimeStore]
|
||||
Storage[AttachmentStorageService]
|
||||
Logic[attachment.logic]
|
||||
|
||||
Facade --> Manager
|
||||
Manager --> Transfer
|
||||
Manager --> Persistence
|
||||
Manager --> Store
|
||||
Manager --> Logic
|
||||
Transfer --> Transport
|
||||
Transfer --> Store
|
||||
Persistence --> Storage
|
||||
Persistence --> Store
|
||||
Storage --> Helpers[attachment-storage.helpers]
|
||||
|
||||
click Facade "application/attachment.facade.ts" "Thin entry point" _blank
|
||||
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
|
||||
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
|
||||
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
|
||||
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
|
||||
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state" _blank
|
||||
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank
|
||||
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers" _blank
|
||||
click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank
|
||||
```
|
||||
|
||||
## File transfer protocol
|
||||
|
||||
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as Sender
|
||||
participant R as Receiver
|
||||
|
||||
S->>R: file-announce (id, name, size, mimeType)
|
||||
Note over R: Store metadata in runtime store
|
||||
Note over R: shouldAutoRequestWhenWatched?
|
||||
|
||||
R->>S: file-request (attachmentId)
|
||||
Note over S: Look up file in runtime store or on disk
|
||||
|
||||
loop Every 64 KB chunk
|
||||
S->>R: file-chunk (attachmentId, index, data, progress, speed)
|
||||
Note over R: Append to chunk buffer
|
||||
Note over R: Update progress + EWMA speed
|
||||
end
|
||||
|
||||
Note over R: All chunks received
|
||||
Note over R: Reassemble blob
|
||||
Note over R: shouldPersistDownloadedAttachment? Save to disk
|
||||
```
|
||||
|
||||
### Failure handling
|
||||
|
||||
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant R as Receiver
|
||||
participant P1 as Peer A
|
||||
participant P2 as Peer B
|
||||
|
||||
R->>P1: file-request
|
||||
P1->>R: file-not-found
|
||||
Note over R: Try next peer
|
||||
R->>P2: file-request
|
||||
P2->>R: file-chunk (1/N)
|
||||
P2->>R: file-chunk (2/N)
|
||||
P2->>R: file-chunk (N/N)
|
||||
Note over R: Transfer complete
|
||||
```
|
||||
|
||||
## Auto-download rules
|
||||
|
||||
When the user navigates to a room, the manager watches the route and decides which attachments to request automatically based on domain logic:
|
||||
|
||||
| Condition | Auto-download? |
|
||||
|---|---|
|
||||
| Image or video, size <= 10 MB | Yes |
|
||||
| Image or video, size > 10 MB | No |
|
||||
| Non-media file | No |
|
||||
|
||||
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
|
||||
|
||||
## Persistence
|
||||
|
||||
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
|
||||
|
||||
```
|
||||
{appDataPath}/{serverId}/{roomName}/{bucket}/{filename}
|
||||
```
|
||||
|
||||
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type.
|
||||
|
||||
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only.
|
||||
|
||||
## Runtime store
|
||||
|
||||
`AttachmentRuntimeStore` is a signal-based in-memory store using `Map` instances for:
|
||||
|
||||
- **attachments**: all known attachments keyed by ID
|
||||
- **chunks**: incoming chunk buffers during active transfers
|
||||
- **pendingRequests**: outbound requests waiting for a response
|
||||
- **cancellations**: IDs of transfers the user cancelled
|
||||
|
||||
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.
|
||||
@@ -0,0 +1,224 @@
|
||||
import {
|
||||
Injectable,
|
||||
effect,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||
import { ROOM_URL_PATTERN } from '../../../core/constants';
|
||||
import { shouldAutoRequestWhenWatched } from '../domain/attachment.logic';
|
||||
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
|
||||
import type {
|
||||
FileAnnouncePayload,
|
||||
FileCancelPayload,
|
||||
FileChunkPayload,
|
||||
FileNotFoundPayload,
|
||||
FileRequestPayload
|
||||
} from '../domain/attachment-transfer.models';
|
||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferService } from './attachment-transfer.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentManagerService {
|
||||
get updated() {
|
||||
return this.runtimeStore.updated;
|
||||
}
|
||||
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly router = inject(Router);
|
||||
private readonly database = inject(DatabaseService);
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
private readonly transfer = inject(AttachmentTransferService);
|
||||
|
||||
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
|
||||
private isDatabaseInitialised = false;
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.database.isReady() && !this.isDatabaseInitialised) {
|
||||
this.isDatabaseInitialised = true;
|
||||
void this.persistence.initFromDatabase();
|
||||
}
|
||||
});
|
||||
|
||||
this.router.events.subscribe((event) => {
|
||||
if (!(event instanceof NavigationEnd)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
|
||||
|
||||
if (this.watchedRoomId) {
|
||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||
}
|
||||
});
|
||||
|
||||
this.webrtc.onPeerConnected.subscribe(() => {
|
||||
if (this.watchedRoomId) {
|
||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getForMessage(messageId: string): Attachment[] {
|
||||
return this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
}
|
||||
|
||||
rememberMessageRoom(messageId: string, roomId: string): void {
|
||||
if (!messageId || !roomId)
|
||||
return;
|
||||
|
||||
this.runtimeStore.rememberMessageRoom(messageId, roomId);
|
||||
}
|
||||
|
||||
queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void {
|
||||
void this.requestAutoDownloadsForMessage(messageId, attachmentId);
|
||||
}
|
||||
|
||||
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
|
||||
if (!roomId || !this.isRoomWatched(roomId))
|
||||
return;
|
||||
|
||||
if (this.database.isReady()) {
|
||||
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||
|
||||
for (const message of messages) {
|
||||
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
||||
await this.requestAutoDownloadsForMessage(message.id);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
||||
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||
|
||||
if (attachmentRoomId === roomId) {
|
||||
await this.requestAutoDownloadsForMessage(messageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteForMessage(messageId: string): Promise<void> {
|
||||
await this.persistence.deleteForMessage(messageId);
|
||||
}
|
||||
|
||||
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||
return this.transfer.getAttachmentMetasForMessages(messageIds);
|
||||
}
|
||||
|
||||
registerSyncedAttachments(
|
||||
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||
messageRoomIds?: Record<string, string>
|
||||
): void {
|
||||
this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
|
||||
|
||||
for (const [messageId, attachments] of Object.entries(attachmentMap)) {
|
||||
for (const attachment of attachments) {
|
||||
this.queueAutoDownloadsForMessage(messageId, attachment.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
handleFileNotFound(payload: FileNotFoundPayload): void {
|
||||
this.transfer.handleFileNotFound(payload);
|
||||
}
|
||||
|
||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.requestImageFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
requestFile(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.requestFile(messageId, attachment);
|
||||
}
|
||||
|
||||
async publishAttachments(
|
||||
messageId: string,
|
||||
files: File[],
|
||||
uploaderPeerId?: string
|
||||
): Promise<void> {
|
||||
await this.transfer.publishAttachments(messageId, files, uploaderPeerId);
|
||||
}
|
||||
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||
this.transfer.handleFileAnnounce(payload);
|
||||
|
||||
if (payload.messageId && payload.file?.id) {
|
||||
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
|
||||
}
|
||||
}
|
||||
|
||||
handleFileChunk(payload: FileChunkPayload): void {
|
||||
this.transfer.handleFileChunk(payload);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
await this.transfer.handleFileRequest(payload);
|
||||
}
|
||||
|
||||
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.cancelRequest(messageId, attachment);
|
||||
}
|
||||
|
||||
handleFileCancel(payload: FileCancelPayload): void {
|
||||
this.transfer.handleFileCancel(payload);
|
||||
}
|
||||
|
||||
async fulfillRequestWithFile(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
targetPeerId: string,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
|
||||
}
|
||||
|
||||
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
|
||||
if (!messageId)
|
||||
return;
|
||||
|
||||
const roomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||
|
||||
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachmentId && attachment.id !== attachmentId)
|
||||
continue;
|
||||
|
||||
if (!shouldAutoRequestWhenWatched(attachment))
|
||||
continue;
|
||||
|
||||
if (attachment.available)
|
||||
continue;
|
||||
|
||||
if ((attachment.receivedBytes ?? 0) > 0)
|
||||
continue;
|
||||
|
||||
if (this.transfer.hasPendingRequest(messageId, attachment.id))
|
||||
continue;
|
||||
|
||||
this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
}
|
||||
|
||||
private extractWatchedRoomId(url: string): string | null {
|
||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||
|
||||
return roomMatch ? roomMatch[1] : null;
|
||||
}
|
||||
|
||||
private isRoomWatched(roomId: string | null | undefined): boolean {
|
||||
return !!roomId && roomId === this.watchedRoomId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { take } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectCurrentRoomName } from '../../../store/rooms/rooms.selectors';
|
||||
import { DatabaseService } from '../../../infrastructure/persistence';
|
||||
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
|
||||
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
|
||||
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../domain/attachment-transfer.constants';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentPersistenceService {
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly ngrxStore = inject(Store);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly database = inject(DatabaseService);
|
||||
|
||||
async deleteForMessage(messageId: string): Promise<void> {
|
||||
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const hadCachedAttachments = attachments.length > 0 || this.runtimeStore.hasAttachmentsForMessage(messageId);
|
||||
const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId);
|
||||
const savedPathsToDelete = new Set<string>();
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.objectUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(attachment.objectUrl);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) {
|
||||
savedPathsToDelete.add(attachment.savedPath);
|
||||
}
|
||||
}
|
||||
|
||||
this.runtimeStore.deleteAttachmentsForMessage(messageId);
|
||||
this.runtimeStore.deleteMessageRoom(messageId);
|
||||
this.runtimeStore.clearMessageScopedState(messageId);
|
||||
|
||||
if (hadCachedAttachments) {
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
|
||||
if (this.database.isReady()) {
|
||||
await this.database.deleteAttachmentsForMessage(messageId);
|
||||
}
|
||||
|
||||
for (const diskPath of savedPathsToDelete) {
|
||||
await this.attachmentStorage.deleteFile(diskPath);
|
||||
}
|
||||
}
|
||||
|
||||
async persistAttachmentMeta(attachment: Attachment): Promise<void> {
|
||||
if (!this.database.isReady())
|
||||
return;
|
||||
|
||||
try {
|
||||
await this.database.saveAttachment({
|
||||
id: attachment.id,
|
||||
messageId: attachment.messageId,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId,
|
||||
filePath: attachment.filePath,
|
||||
savedPath: attachment.savedPath
|
||||
});
|
||||
} catch { /* persistence is best-effort */ }
|
||||
}
|
||||
|
||||
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
|
||||
try {
|
||||
const roomName = await this.resolveCurrentRoomName();
|
||||
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName);
|
||||
|
||||
if (!diskPath)
|
||||
return;
|
||||
|
||||
attachment.savedPath = diskPath;
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
} catch { /* disk save is best-effort */ }
|
||||
}
|
||||
|
||||
async initFromDatabase(): Promise<void> {
|
||||
await this.loadFromDatabase();
|
||||
await this.migrateFromLocalStorage();
|
||||
await this.tryLoadSavedFiles();
|
||||
}
|
||||
|
||||
async resolveMessageRoomId(messageId: string): Promise<string | null> {
|
||||
const cachedRoomId = this.runtimeStore.getMessageRoomId(messageId);
|
||||
|
||||
if (cachedRoomId)
|
||||
return cachedRoomId;
|
||||
|
||||
if (!this.database.isReady())
|
||||
return null;
|
||||
|
||||
try {
|
||||
const message = await this.database.getMessageById(messageId);
|
||||
|
||||
if (!message?.roomId)
|
||||
return null;
|
||||
|
||||
this.runtimeStore.rememberMessageRoom(messageId, message.roomId);
|
||||
return message.roomId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveCurrentRoomName(): Promise<string> {
|
||||
return new Promise<string>((resolve) => {
|
||||
this.ngrxStore
|
||||
.select(selectCurrentRoomName)
|
||||
.pipe(take(1))
|
||||
.subscribe((name) => resolve(name || ''));
|
||||
});
|
||||
}
|
||||
|
||||
private async loadFromDatabase(): Promise<void> {
|
||||
try {
|
||||
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
|
||||
const grouped = new Map<string, Attachment[]>();
|
||||
|
||||
for (const record of allRecords) {
|
||||
const attachment: Attachment = { ...record,
|
||||
available: false };
|
||||
const bucket = grouped.get(record.messageId) ?? [];
|
||||
|
||||
bucket.push(attachment);
|
||||
grouped.set(record.messageId, bucket);
|
||||
}
|
||||
|
||||
this.runtimeStore.replaceAttachments(grouped);
|
||||
this.runtimeStore.touch();
|
||||
} catch { /* load is best-effort */ }
|
||||
}
|
||||
|
||||
private async migrateFromLocalStorage(): Promise<void> {
|
||||
try {
|
||||
const raw = localStorage.getItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
|
||||
|
||||
if (!raw)
|
||||
return;
|
||||
|
||||
const legacyRecords: AttachmentMeta[] = JSON.parse(raw);
|
||||
|
||||
for (const meta of legacyRecords) {
|
||||
const existing = [...this.runtimeStore.getAttachmentsForMessage(meta.messageId)];
|
||||
|
||||
if (!existing.find((entry) => entry.id === meta.id)) {
|
||||
const attachment: Attachment = { ...meta,
|
||||
available: false };
|
||||
|
||||
existing.push(attachment);
|
||||
this.runtimeStore.setAttachmentsForMessage(meta.messageId, existing);
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.removeItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
|
||||
this.runtimeStore.touch();
|
||||
} catch { /* migration is best-effort */ }
|
||||
}
|
||||
|
||||
private async tryLoadSavedFiles(): Promise<void> {
|
||||
try {
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.available)
|
||||
continue;
|
||||
|
||||
if (attachment.savedPath) {
|
||||
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
|
||||
|
||||
if (savedBase64) {
|
||||
this.restoreAttachmentFromDisk(attachment, savedBase64);
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.filePath) {
|
||||
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
|
||||
|
||||
if (originalBase64) {
|
||||
this.restoreAttachmentFromDisk(attachment, originalBase64);
|
||||
hasChanges = true;
|
||||
|
||||
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES && attachment.objectUrl) {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
void this.saveFileToDisk(attachment, await response.blob());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges)
|
||||
this.runtimeStore.touch();
|
||||
} catch { /* startup load is best-effort */ }
|
||||
}
|
||||
|
||||
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
|
||||
const bytes = this.base64ToUint8Array(base64);
|
||||
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
|
||||
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
attachment.available = true;
|
||||
|
||||
this.runtimeStore.setOriginalFile(
|
||||
`${attachment.messageId}:${attachment.id}`,
|
||||
new File([blob], attachment.filename, { type: attachment.mime })
|
||||
);
|
||||
}
|
||||
|
||||
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
|
||||
const retainedSavedPaths = new Set<string>();
|
||||
|
||||
for (const [existingMessageId, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||
if (existingMessageId === messageId)
|
||||
continue;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.savedPath) {
|
||||
retainedSavedPaths.add(attachment.savedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.database.isReady()) {
|
||||
return retainedSavedPaths;
|
||||
}
|
||||
|
||||
const persistedAttachments = await this.database.getAllAttachments();
|
||||
|
||||
for (const attachment of persistedAttachments) {
|
||||
if (attachment.messageId !== messageId && attachment.savedPath) {
|
||||
retainedSavedPaths.add(attachment.savedPath);
|
||||
}
|
||||
}
|
||||
|
||||
return retainedSavedPaths;
|
||||
}
|
||||
|
||||
private base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import type { Attachment } from '../domain/attachment.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentRuntimeStore {
|
||||
readonly updated = signal<number>(0);
|
||||
|
||||
private attachmentsByMessage = new Map<string, Attachment[]>();
|
||||
private messageRoomIds = new Map<string, string>();
|
||||
private originalFiles = new Map<string, File>();
|
||||
private cancelledTransfers = new Set<string>();
|
||||
private pendingRequests = new Map<string, Set<string>>();
|
||||
private chunkBuffers = new Map<string, ArrayBuffer[]>();
|
||||
private chunkCounts = new Map<string, number>();
|
||||
|
||||
touch(): void {
|
||||
this.updated.set(this.updated() + 1);
|
||||
}
|
||||
|
||||
getAttachmentsForMessage(messageId: string): Attachment[] {
|
||||
return this.attachmentsByMessage.get(messageId) ?? [];
|
||||
}
|
||||
|
||||
setAttachmentsForMessage(messageId: string, attachments: Attachment[]): void {
|
||||
if (attachments.length === 0) {
|
||||
this.attachmentsByMessage.delete(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.attachmentsByMessage.set(messageId, attachments);
|
||||
}
|
||||
|
||||
hasAttachmentsForMessage(messageId: string): boolean {
|
||||
return this.attachmentsByMessage.has(messageId);
|
||||
}
|
||||
|
||||
deleteAttachmentsForMessage(messageId: string): void {
|
||||
this.attachmentsByMessage.delete(messageId);
|
||||
}
|
||||
|
||||
replaceAttachments(nextAttachments: Map<string, Attachment[]>): void {
|
||||
this.attachmentsByMessage = nextAttachments;
|
||||
}
|
||||
|
||||
getAttachmentEntries(): IterableIterator<[string, Attachment[]]> {
|
||||
return this.attachmentsByMessage.entries();
|
||||
}
|
||||
|
||||
rememberMessageRoom(messageId: string, roomId: string): void {
|
||||
this.messageRoomIds.set(messageId, roomId);
|
||||
}
|
||||
|
||||
getMessageRoomId(messageId: string): string | undefined {
|
||||
return this.messageRoomIds.get(messageId);
|
||||
}
|
||||
|
||||
deleteMessageRoom(messageId: string): void {
|
||||
this.messageRoomIds.delete(messageId);
|
||||
}
|
||||
|
||||
setOriginalFile(key: string, file: File): void {
|
||||
this.originalFiles.set(key, file);
|
||||
}
|
||||
|
||||
getOriginalFile(key: string): File | undefined {
|
||||
return this.originalFiles.get(key);
|
||||
}
|
||||
|
||||
findOriginalFileByFileId(fileId: string): File | null {
|
||||
for (const [key, file] of this.originalFiles) {
|
||||
if (key.endsWith(`:${fileId}`)) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
addCancelledTransfer(key: string): void {
|
||||
this.cancelledTransfers.add(key);
|
||||
}
|
||||
|
||||
hasCancelledTransfer(key: string): boolean {
|
||||
return this.cancelledTransfers.has(key);
|
||||
}
|
||||
|
||||
setPendingRequestPeers(key: string, peers: Set<string>): void {
|
||||
this.pendingRequests.set(key, peers);
|
||||
}
|
||||
|
||||
getPendingRequestPeers(key: string): Set<string> | undefined {
|
||||
return this.pendingRequests.get(key);
|
||||
}
|
||||
|
||||
hasPendingRequest(key: string): boolean {
|
||||
return this.pendingRequests.has(key);
|
||||
}
|
||||
|
||||
deletePendingRequest(key: string): void {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
|
||||
setChunkBuffer(key: string, buffer: ArrayBuffer[]): void {
|
||||
this.chunkBuffers.set(key, buffer);
|
||||
}
|
||||
|
||||
getChunkBuffer(key: string): ArrayBuffer[] | undefined {
|
||||
return this.chunkBuffers.get(key);
|
||||
}
|
||||
|
||||
deleteChunkBuffer(key: string): void {
|
||||
this.chunkBuffers.delete(key);
|
||||
}
|
||||
|
||||
setChunkCount(key: string, count: number): void {
|
||||
this.chunkCounts.set(key, count);
|
||||
}
|
||||
|
||||
getChunkCount(key: string): number | undefined {
|
||||
return this.chunkCounts.get(key);
|
||||
}
|
||||
|
||||
deleteChunkCount(key: string): void {
|
||||
this.chunkCounts.delete(key);
|
||||
}
|
||||
|
||||
clearMessageScopedState(messageId: string): void {
|
||||
const scopedPrefix = `${messageId}:`;
|
||||
|
||||
for (const key of Array.from(this.originalFiles.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.originalFiles.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.pendingRequests.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.pendingRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.chunkBuffers.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.chunkBuffers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.chunkCounts.keys())) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.chunkCounts.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of Array.from(this.cancelledTransfers)) {
|
||||
if (key.startsWith(scopedPrefix)) {
|
||||
this.cancelledTransfers.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
|
||||
import { FILE_CHUNK_SIZE_BYTES } from '../domain/attachment-transfer.constants';
|
||||
import { FileChunkEvent } from '../domain/attachment-transfer.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferTransportService {
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
|
||||
decodeBase64(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async streamFileToPeer(
|
||||
targetPeerId: string,
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
file: File,
|
||||
isCancelled: () => boolean
|
||||
): Promise<void> {
|
||||
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
let offset = 0;
|
||||
let chunkIndex = 0;
|
||||
|
||||
while (offset < file.size) {
|
||||
if (isCancelled())
|
||||
break;
|
||||
|
||||
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
|
||||
const arrayBuffer = await slice.arrayBuffer();
|
||||
const base64 = this.arrayBufferToBase64(arrayBuffer);
|
||||
const fileChunkEvent: FileChunkEvent = {
|
||||
type: 'file-chunk',
|
||||
messageId,
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
total: totalChunks,
|
||||
data: base64
|
||||
};
|
||||
|
||||
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
|
||||
|
||||
offset += FILE_CHUNK_SIZE_BYTES;
|
||||
chunkIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
async streamFileFromDiskToPeer(
|
||||
targetPeerId: string,
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
diskPath: string,
|
||||
isCancelled: () => boolean
|
||||
): Promise<void> {
|
||||
const base64Full = await this.attachmentStorage.readFile(diskPath);
|
||||
|
||||
if (!base64Full)
|
||||
return;
|
||||
|
||||
const fileBytes = this.decodeBase64(base64Full);
|
||||
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
if (isCancelled())
|
||||
break;
|
||||
|
||||
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
|
||||
const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES);
|
||||
const slice = fileBytes.subarray(start, end);
|
||||
const sliceBuffer = (slice.buffer as ArrayBuffer).slice(
|
||||
slice.byteOffset,
|
||||
slice.byteOffset + slice.byteLength
|
||||
);
|
||||
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
|
||||
const fileChunkEvent: FileChunkEvent = {
|
||||
type: 'file-chunk',
|
||||
messageId,
|
||||
fileId,
|
||||
index: chunkIndex,
|
||||
total: totalChunks,
|
||||
data: base64Chunk
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
|
||||
}
|
||||
}
|
||||
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
for (let index = 0; index < bytes.byteLength; index++) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { recordDebugNetworkFileChunk } from '../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
|
||||
import { shouldPersistDownloadedAttachment } from '../domain/attachment.logic';
|
||||
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
|
||||
import {
|
||||
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
|
||||
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
|
||||
DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||
FILE_NOT_FOUND_REQUEST_ERROR,
|
||||
NO_CONNECTED_PEERS_REQUEST_ERROR
|
||||
} from '../domain/attachment-transfer.constants';
|
||||
import {
|
||||
type FileAnnounceEvent,
|
||||
type FileAnnouncePayload,
|
||||
type FileCancelEvent,
|
||||
type FileCancelPayload,
|
||||
type FileChunkPayload,
|
||||
type FileNotFoundEvent,
|
||||
type FileNotFoundPayload,
|
||||
type FileRequestEvent,
|
||||
type FileRequestPayload,
|
||||
type LocalFileWithPath
|
||||
} from '../domain/attachment-transfer.models';
|
||||
import { AttachmentPersistenceService } from './attachment-persistence.service';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferService {
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
private readonly persistence = inject(AttachmentPersistenceService);
|
||||
private readonly transport = inject(AttachmentTransferTransportService);
|
||||
|
||||
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
|
||||
const result: Record<string, AttachmentMeta[]> = {};
|
||||
|
||||
for (const messageId of messageIds) {
|
||||
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
|
||||
if (attachments.length > 0) {
|
||||
result[messageId] = attachments.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
messageId: attachment.messageId,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId,
|
||||
filePath: undefined,
|
||||
savedPath: undefined
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
registerSyncedAttachments(
|
||||
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||
messageRoomIds?: Record<string, string>
|
||||
): void {
|
||||
if (messageRoomIds) {
|
||||
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
|
||||
this.runtimeStore.rememberMessageRoom(messageId, roomId);
|
||||
}
|
||||
}
|
||||
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
for (const [messageId, metas] of Object.entries(attachmentMap)) {
|
||||
const existing = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
|
||||
|
||||
for (const meta of metas) {
|
||||
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
|
||||
|
||||
if (!alreadyKnown) {
|
||||
const attachment: Attachment = { ...meta,
|
||||
available: false,
|
||||
receivedBytes: 0 };
|
||||
|
||||
existing.push(attachment);
|
||||
newAttachments.push(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
this.runtimeStore.setAttachmentsForMessage(messageId, existing);
|
||||
}
|
||||
|
||||
if (newAttachments.length > 0) {
|
||||
this.runtimeStore.touch();
|
||||
|
||||
for (const attachment of newAttachments) {
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
const clearedRequestError = this.clearAttachmentRequestError(attachment);
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
|
||||
if (connectedPeers.length === 0) {
|
||||
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
|
||||
this.runtimeStore.touch();
|
||||
console.warn('[Attachments] No connected peers to request file from');
|
||||
return;
|
||||
}
|
||||
|
||||
if (clearedRequestError)
|
||||
this.runtimeStore.touch();
|
||||
|
||||
this.runtimeStore.setPendingRequestPeers(
|
||||
this.buildRequestKey(messageId, attachment.id),
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
|
||||
}
|
||||
|
||||
handleFileNotFound(payload: FileNotFoundPayload): void {
|
||||
const { messageId, fileId } = payload;
|
||||
|
||||
if (!messageId || !fileId)
|
||||
return;
|
||||
|
||||
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = attachments.find((entry) => entry.id === fileId);
|
||||
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
|
||||
|
||||
if (!didSendRequest && attachment) {
|
||||
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
}
|
||||
|
||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
this.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
requestFile(messageId: string, attachment: Attachment): void {
|
||||
this.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
hasPendingRequest(messageId: string, fileId: string): boolean {
|
||||
return this.runtimeStore.hasPendingRequest(this.buildRequestKey(messageId, fileId));
|
||||
}
|
||||
|
||||
async publishAttachments(
|
||||
messageId: string,
|
||||
files: File[],
|
||||
uploaderPeerId?: string
|
||||
): Promise<void> {
|
||||
const attachments: Attachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const fileId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
|
||||
const attachment: Attachment = {
|
||||
id: fileId,
|
||||
messageId,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||
isImage: file.type.startsWith('image/'),
|
||||
uploaderPeerId,
|
||||
filePath: (file as LocalFileWithPath).path,
|
||||
available: false
|
||||
};
|
||||
|
||||
attachments.push(attachment);
|
||||
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
|
||||
|
||||
try {
|
||||
attachment.objectUrl = URL.createObjectURL(file);
|
||||
attachment.available = true;
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
void this.persistence.saveFileToDisk(attachment, file);
|
||||
}
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
type: 'file-announce',
|
||||
messageId,
|
||||
file: {
|
||||
id: fileId,
|
||||
filename: attachment.filename,
|
||||
size: attachment.size,
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId
|
||||
}
|
||||
};
|
||||
|
||||
this.webrtc.broadcastMessage(fileAnnounceEvent);
|
||||
}
|
||||
|
||||
const existingList = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
|
||||
this.runtimeStore.setAttachmentsForMessage(messageId, [...existingList, ...attachments]);
|
||||
this.runtimeStore.touch();
|
||||
|
||||
for (const attachment of attachments) {
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
handleFileAnnounce(payload: FileAnnouncePayload): void {
|
||||
const { messageId, file } = payload;
|
||||
|
||||
if (!messageId || !file)
|
||||
return;
|
||||
|
||||
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
|
||||
const alreadyKnown = list.find((entry) => entry.id === file.id);
|
||||
|
||||
if (alreadyKnown)
|
||||
return;
|
||||
|
||||
const attachment: Attachment = {
|
||||
id: file.id,
|
||||
messageId,
|
||||
filename: file.filename,
|
||||
size: file.size,
|
||||
mime: file.mime,
|
||||
isImage: !!file.isImage,
|
||||
uploaderPeerId: file.uploaderPeerId,
|
||||
available: false,
|
||||
receivedBytes: 0
|
||||
};
|
||||
|
||||
list.push(attachment);
|
||||
this.runtimeStore.setAttachmentsForMessage(messageId, list);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
|
||||
handleFileChunk(payload: FileChunkPayload): void {
|
||||
const { messageId, fileId, fromPeerId, index, total, data } = payload;
|
||||
|
||||
if (
|
||||
!messageId || !fileId ||
|
||||
typeof index !== 'number' ||
|
||||
typeof total !== 'number' ||
|
||||
typeof data !== 'string'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = list.find((entry) => entry.id === fileId);
|
||||
|
||||
if (!attachment)
|
||||
return;
|
||||
|
||||
const decodedBytes = this.transport.decodeBase64(data);
|
||||
const assemblyKey = `${messageId}:${fileId}`;
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
this.clearAttachmentRequestError(attachment);
|
||||
|
||||
const chunkBuffer = this.getOrCreateChunkBuffer(assemblyKey, total);
|
||||
|
||||
if (!chunkBuffer[index]) {
|
||||
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
|
||||
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
|
||||
}
|
||||
|
||||
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
|
||||
|
||||
this.runtimeStore.touch();
|
||||
this.finalizeTransferIfComplete(attachment, assemblyKey, total);
|
||||
}
|
||||
|
||||
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
|
||||
const { messageId, fileId, fromPeerId } = payload;
|
||||
|
||||
if (!messageId || !fileId || !fromPeerId)
|
||||
return;
|
||||
|
||||
const exactKey = `${messageId}:${fileId}`;
|
||||
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
|
||||
?? this.runtimeStore.findOriginalFileByFileId(fileId);
|
||||
|
||||
if (originalFile) {
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
originalFile,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
|
||||
const attachment = list.find((entry) => entry.id === fileId);
|
||||
const diskPath = attachment
|
||||
? await this.attachmentStorage.resolveExistingPath(attachment)
|
||||
: null;
|
||||
|
||||
if (diskPath) {
|
||||
await this.transport.streamFileFromDiskToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
diskPath,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachment?.isImage) {
|
||||
const roomName = await this.persistence.resolveCurrentRoomName();
|
||||
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
|
||||
attachment.filename,
|
||||
roomName
|
||||
);
|
||||
|
||||
if (legacyDiskPath) {
|
||||
await this.transport.streamFileFromDiskToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
legacyDiskPath,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment?.available && attachment.objectUrl) {
|
||||
try {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], attachment.filename, { type: attachment.mime });
|
||||
|
||||
await this.transport.streamFileToPeer(
|
||||
fromPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
file,
|
||||
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
|
||||
);
|
||||
|
||||
return;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
|
||||
const fileNotFoundEvent: FileNotFoundEvent = {
|
||||
type: 'file-not-found',
|
||||
messageId,
|
||||
fileId
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent);
|
||||
}
|
||||
|
||||
cancelRequest(messageId: string, attachment: Attachment): void {
|
||||
const targetPeerId = attachment.uploaderPeerId;
|
||||
|
||||
if (!targetPeerId)
|
||||
return;
|
||||
|
||||
try {
|
||||
const assemblyKey = `${messageId}:${attachment.id}`;
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
|
||||
attachment.receivedBytes = 0;
|
||||
attachment.speedBps = 0;
|
||||
attachment.startedAtMs = undefined;
|
||||
attachment.lastUpdateMs = undefined;
|
||||
|
||||
if (attachment.objectUrl) {
|
||||
try {
|
||||
URL.revokeObjectURL(attachment.objectUrl);
|
||||
} catch { /* ignore */ }
|
||||
|
||||
attachment.objectUrl = undefined;
|
||||
}
|
||||
|
||||
attachment.available = false;
|
||||
this.runtimeStore.touch();
|
||||
|
||||
const fileCancelEvent: FileCancelEvent = {
|
||||
type: 'file-cancel',
|
||||
messageId,
|
||||
fileId: attachment.id
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
|
||||
} catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
handleFileCancel(payload: FileCancelPayload): void {
|
||||
const { messageId, fileId, fromPeerId } = payload;
|
||||
|
||||
if (!messageId || !fileId || !fromPeerId)
|
||||
return;
|
||||
|
||||
this.runtimeStore.addCancelledTransfer(
|
||||
this.buildTransferKey(messageId, fileId, fromPeerId)
|
||||
);
|
||||
}
|
||||
|
||||
async fulfillRequestWithFile(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
targetPeerId: string,
|
||||
file: File
|
||||
): Promise<void> {
|
||||
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
|
||||
await this.transport.streamFileToPeer(
|
||||
targetPeerId,
|
||||
messageId,
|
||||
fileId,
|
||||
file,
|
||||
() => this.isTransferCancelled(targetPeerId, messageId, fileId)
|
||||
);
|
||||
}
|
||||
|
||||
private buildTransferKey(messageId: string, fileId: string, peerId: string): string {
|
||||
return `${messageId}:${fileId}:${peerId}`;
|
||||
}
|
||||
|
||||
private buildRequestKey(messageId: string, fileId: string): string {
|
||||
return `${messageId}:${fileId}`;
|
||||
}
|
||||
|
||||
private clearAttachmentRequestError(attachment: Attachment): boolean {
|
||||
if (!attachment.requestError)
|
||||
return false;
|
||||
|
||||
attachment.requestError = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
|
||||
return this.runtimeStore.hasCancelledTransfer(
|
||||
this.buildTransferKey(messageId, fileId, targetPeerId)
|
||||
);
|
||||
}
|
||||
|
||||
private sendFileRequestToNextPeer(
|
||||
messageId: string,
|
||||
fileId: string,
|
||||
preferredPeerId?: string
|
||||
): boolean {
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
const requestKey = this.buildRequestKey(messageId, fileId);
|
||||
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
|
||||
|
||||
let targetPeerId: string | undefined;
|
||||
|
||||
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
|
||||
targetPeerId = preferredPeerId;
|
||||
} else {
|
||||
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId));
|
||||
}
|
||||
|
||||
if (!targetPeerId) {
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
triedPeers.add(targetPeerId);
|
||||
this.runtimeStore.setPendingRequestPeers(requestKey, triedPeers);
|
||||
|
||||
const fileRequestEvent: FileRequestEvent = {
|
||||
type: 'file-request',
|
||||
messageId,
|
||||
fileId
|
||||
};
|
||||
|
||||
this.webrtc.sendToPeer(targetPeerId, fileRequestEvent);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getOrCreateChunkBuffer(assemblyKey: string, total: number): ArrayBuffer[] {
|
||||
const existingChunkBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
|
||||
|
||||
if (existingChunkBuffer) {
|
||||
return existingChunkBuffer;
|
||||
}
|
||||
|
||||
const createdChunkBuffer = new Array(total);
|
||||
|
||||
this.runtimeStore.setChunkBuffer(assemblyKey, createdChunkBuffer);
|
||||
this.runtimeStore.setChunkCount(assemblyKey, 0);
|
||||
|
||||
return createdChunkBuffer;
|
||||
}
|
||||
|
||||
private updateTransferProgress(
|
||||
attachment: Attachment,
|
||||
decodedBytes: Uint8Array,
|
||||
fromPeerId?: string
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const previousReceived = attachment.receivedBytes ?? 0;
|
||||
|
||||
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
|
||||
|
||||
if (fromPeerId) {
|
||||
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
|
||||
}
|
||||
|
||||
if (!attachment.startedAtMs)
|
||||
attachment.startedAtMs = now;
|
||||
|
||||
if (!attachment.lastUpdateMs)
|
||||
attachment.lastUpdateMs = now;
|
||||
|
||||
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
|
||||
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
|
||||
const previousSpeed = attachment.speedBps ?? instantaneousBps;
|
||||
|
||||
attachment.speedBps =
|
||||
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT * previousSpeed +
|
||||
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT * instantaneousBps;
|
||||
|
||||
attachment.lastUpdateMs = now;
|
||||
}
|
||||
|
||||
private finalizeTransferIfComplete(
|
||||
attachment: Attachment,
|
||||
assemblyKey: string,
|
||||
total: number
|
||||
): void {
|
||||
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
|
||||
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
|
||||
|
||||
if (
|
||||
!completeBuffer
|
||||
|| (receivedChunkCount !== total && (attachment.receivedBytes ?? 0) < attachment.size)
|
||||
|| !completeBuffer.every((part) => part instanceof ArrayBuffer)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob(completeBuffer, { type: attachment.mime });
|
||||
|
||||
attachment.available = true;
|
||||
attachment.objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (shouldPersistDownloadedAttachment(attachment)) {
|
||||
void this.persistence.saveFileToDisk(attachment, blob);
|
||||
}
|
||||
|
||||
this.runtimeStore.deleteChunkBuffer(assemblyKey);
|
||||
this.runtimeStore.deleteChunkCount(assemblyKey);
|
||||
this.runtimeStore.touch();
|
||||
void this.persistence.persistAttachmentMeta(attachment);
|
||||
}
|
||||
}
|
||||
119
src/app/domains/attachment/application/attachment.facade.ts
Normal file
119
src/app/domains/attachment/application/attachment.facade.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { AttachmentManagerService } from './attachment-manager.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentFacade {
|
||||
get updated() {
|
||||
return this.manager.updated;
|
||||
}
|
||||
|
||||
private readonly manager = inject(AttachmentManagerService);
|
||||
|
||||
getForMessage(
|
||||
...args: Parameters<AttachmentManagerService['getForMessage']>
|
||||
): ReturnType<AttachmentManagerService['getForMessage']> {
|
||||
return this.manager.getForMessage(...args);
|
||||
}
|
||||
|
||||
rememberMessageRoom(
|
||||
...args: Parameters<AttachmentManagerService['rememberMessageRoom']>
|
||||
): ReturnType<AttachmentManagerService['rememberMessageRoom']> {
|
||||
return this.manager.rememberMessageRoom(...args);
|
||||
}
|
||||
|
||||
queueAutoDownloadsForMessage(
|
||||
...args: Parameters<AttachmentManagerService['queueAutoDownloadsForMessage']>
|
||||
): ReturnType<AttachmentManagerService['queueAutoDownloadsForMessage']> {
|
||||
return this.manager.queueAutoDownloadsForMessage(...args);
|
||||
}
|
||||
|
||||
requestAutoDownloadsForRoom(
|
||||
...args: Parameters<AttachmentManagerService['requestAutoDownloadsForRoom']>
|
||||
): ReturnType<AttachmentManagerService['requestAutoDownloadsForRoom']> {
|
||||
return this.manager.requestAutoDownloadsForRoom(...args);
|
||||
}
|
||||
|
||||
deleteForMessage(
|
||||
...args: Parameters<AttachmentManagerService['deleteForMessage']>
|
||||
): ReturnType<AttachmentManagerService['deleteForMessage']> {
|
||||
return this.manager.deleteForMessage(...args);
|
||||
}
|
||||
|
||||
getAttachmentMetasForMessages(
|
||||
...args: Parameters<AttachmentManagerService['getAttachmentMetasForMessages']>
|
||||
): ReturnType<AttachmentManagerService['getAttachmentMetasForMessages']> {
|
||||
return this.manager.getAttachmentMetasForMessages(...args);
|
||||
}
|
||||
|
||||
registerSyncedAttachments(
|
||||
...args: Parameters<AttachmentManagerService['registerSyncedAttachments']>
|
||||
): ReturnType<AttachmentManagerService['registerSyncedAttachments']> {
|
||||
return this.manager.registerSyncedAttachments(...args);
|
||||
}
|
||||
|
||||
requestFromAnyPeer(
|
||||
...args: Parameters<AttachmentManagerService['requestFromAnyPeer']>
|
||||
): ReturnType<AttachmentManagerService['requestFromAnyPeer']> {
|
||||
return this.manager.requestFromAnyPeer(...args);
|
||||
}
|
||||
|
||||
handleFileNotFound(
|
||||
...args: Parameters<AttachmentManagerService['handleFileNotFound']>
|
||||
): ReturnType<AttachmentManagerService['handleFileNotFound']> {
|
||||
return this.manager.handleFileNotFound(...args);
|
||||
}
|
||||
|
||||
requestImageFromAnyPeer(
|
||||
...args: Parameters<AttachmentManagerService['requestImageFromAnyPeer']>
|
||||
): ReturnType<AttachmentManagerService['requestImageFromAnyPeer']> {
|
||||
return this.manager.requestImageFromAnyPeer(...args);
|
||||
}
|
||||
|
||||
requestFile(
|
||||
...args: Parameters<AttachmentManagerService['requestFile']>
|
||||
): ReturnType<AttachmentManagerService['requestFile']> {
|
||||
return this.manager.requestFile(...args);
|
||||
}
|
||||
|
||||
publishAttachments(
|
||||
...args: Parameters<AttachmentManagerService['publishAttachments']>
|
||||
): ReturnType<AttachmentManagerService['publishAttachments']> {
|
||||
return this.manager.publishAttachments(...args);
|
||||
}
|
||||
|
||||
handleFileAnnounce(
|
||||
...args: Parameters<AttachmentManagerService['handleFileAnnounce']>
|
||||
): ReturnType<AttachmentManagerService['handleFileAnnounce']> {
|
||||
return this.manager.handleFileAnnounce(...args);
|
||||
}
|
||||
|
||||
handleFileChunk(
|
||||
...args: Parameters<AttachmentManagerService['handleFileChunk']>
|
||||
): ReturnType<AttachmentManagerService['handleFileChunk']> {
|
||||
return this.manager.handleFileChunk(...args);
|
||||
}
|
||||
|
||||
handleFileRequest(
|
||||
...args: Parameters<AttachmentManagerService['handleFileRequest']>
|
||||
): ReturnType<AttachmentManagerService['handleFileRequest']> {
|
||||
return this.manager.handleFileRequest(...args);
|
||||
}
|
||||
|
||||
cancelRequest(
|
||||
...args: Parameters<AttachmentManagerService['cancelRequest']>
|
||||
): ReturnType<AttachmentManagerService['cancelRequest']> {
|
||||
return this.manager.cancelRequest(...args);
|
||||
}
|
||||
|
||||
handleFileCancel(
|
||||
...args: Parameters<AttachmentManagerService['handleFileCancel']>
|
||||
): ReturnType<AttachmentManagerService['handleFileCancel']> {
|
||||
return this.manager.handleFileCancel(...args);
|
||||
}
|
||||
|
||||
fulfillRequestWithFile(
|
||||
...args: Parameters<AttachmentManagerService['fulfillRequestWithFile']>
|
||||
): ReturnType<AttachmentManagerService['fulfillRequestWithFile']> {
|
||||
return this.manager.fulfillRequestWithFile(...args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
|
||||
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
|
||||
|
||||
/**
|
||||
* EWMA smoothing weight for the previous speed estimate.
|
||||
* The complementary weight is applied to the latest sample.
|
||||
*/
|
||||
export const ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT = 0.7;
|
||||
export const ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT = 1 - ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT;
|
||||
|
||||
/** Fallback MIME type when none is provided by the sender. */
|
||||
export const DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream';
|
||||
|
||||
/** localStorage key used by the legacy attachment store during migration. */
|
||||
export const LEGACY_ATTACHMENTS_STORAGE_KEY = 'metoyou_attachments';
|
||||
|
||||
/** User-facing error when no peers are available for a request. */
|
||||
export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.';
|
||||
|
||||
/** User-facing error when connected peers cannot provide a requested file. */
|
||||
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ChatEvent } from '../../../shared-kernel';
|
||||
import type { ChatAttachmentAnnouncement } from '../../../shared-kernel';
|
||||
|
||||
export type FileAnnounceEvent = ChatEvent & {
|
||||
type: 'file-announce';
|
||||
messageId: string;
|
||||
file: ChatAttachmentAnnouncement;
|
||||
};
|
||||
|
||||
export type FileChunkEvent = ChatEvent & {
|
||||
type: 'file-chunk';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
index: number;
|
||||
total: number;
|
||||
data: string;
|
||||
fromPeerId?: string;
|
||||
};
|
||||
|
||||
export type FileRequestEvent = ChatEvent & {
|
||||
type: 'file-request';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
fromPeerId?: string;
|
||||
};
|
||||
|
||||
export type FileCancelEvent = ChatEvent & {
|
||||
type: 'file-cancel';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
fromPeerId?: string;
|
||||
};
|
||||
|
||||
export type FileNotFoundEvent = ChatEvent & {
|
||||
type: 'file-not-found';
|
||||
messageId: string;
|
||||
fileId: string;
|
||||
};
|
||||
|
||||
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
|
||||
|
||||
export interface FileChunkPayload {
|
||||
messageId?: string;
|
||||
fileId?: string;
|
||||
fromPeerId?: string;
|
||||
index?: number;
|
||||
total?: number;
|
||||
data?: ChatEvent['data'];
|
||||
}
|
||||
|
||||
export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||
export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
|
||||
export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
|
||||
|
||||
export type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
|
||||
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
19
src/app/domains/attachment/domain/attachment.logic.ts
Normal file
19
src/app/domains/attachment/domain/attachment.logic.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from './attachment.constants';
|
||||
import type { Attachment } from './attachment.models';
|
||||
|
||||
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
|
||||
return attachment.mime.startsWith('image/') ||
|
||||
attachment.mime.startsWith('video/') ||
|
||||
attachment.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
|
||||
return attachment.isImage ||
|
||||
(isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
|
||||
}
|
||||
|
||||
export function shouldPersistDownloadedAttachment(attachment: Pick<Attachment, 'size' | 'mime'>): boolean {
|
||||
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
|
||||
attachment.mime.startsWith('video/') ||
|
||||
attachment.mime.startsWith('audio/');
|
||||
}
|
||||
13
src/app/domains/attachment/domain/attachment.models.ts
Normal file
13
src/app/domains/attachment/domain/attachment.models.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ChatAttachmentMeta } from '../../../shared-kernel';
|
||||
|
||||
export type AttachmentMeta = ChatAttachmentMeta;
|
||||
|
||||
export interface Attachment extends AttachmentMeta {
|
||||
available: boolean;
|
||||
objectUrl?: string;
|
||||
receivedBytes?: number;
|
||||
speedBps?: number;
|
||||
startedAtMs?: number;
|
||||
lastUpdateMs?: number;
|
||||
requestError?: string;
|
||||
}
|
||||
3
src/app/domains/attachment/index.ts
Normal file
3
src/app/domains/attachment/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './application/attachment.facade';
|
||||
export * from './domain/attachment.constants';
|
||||
export * from './domain/attachment.models';
|
||||
@@ -0,0 +1,23 @@
|
||||
const ROOM_NAME_SANITIZER = /[^\w.-]+/g;
|
||||
|
||||
export function sanitizeAttachmentRoomName(roomName: string): string {
|
||||
const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_');
|
||||
|
||||
return sanitizedRoomName || 'room';
|
||||
}
|
||||
|
||||
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
|
||||
if (mime.startsWith('video/')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (mime.startsWith('audio/')) {
|
||||
return 'audio';
|
||||
}
|
||||
|
||||
if (mime.startsWith('image/')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
return 'files';
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
|
||||
import type { Attachment } from '../domain/attachment.models';
|
||||
import { resolveAttachmentStorageBucket, sanitizeAttachmentRoomName } from './attachment-storage.helpers';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentStorageService {
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
async resolveExistingPath(
|
||||
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
|
||||
): Promise<string | null> {
|
||||
return this.findExistingPath([attachment.filePath, attachment.savedPath]);
|
||||
}
|
||||
|
||||
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {
|
||||
const appDataPath = await this.resolveAppDataPath();
|
||||
|
||||
if (!appDataPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]);
|
||||
}
|
||||
|
||||
async readFile(filePath: string): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi || !filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.readFile(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveBlob(
|
||||
attachment: Pick<Attachment, 'filename' | 'mime'>,
|
||||
blob: Blob,
|
||||
roomName: string
|
||||
): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
const appDataPath = await this.resolveAppDataPath();
|
||||
|
||||
if (!electronApi || !appDataPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`;
|
||||
|
||||
await electronApi.ensureDir(directoryPath);
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const diskPath = `${directoryPath}/${attachment.filename}`;
|
||||
|
||||
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));
|
||||
|
||||
return diskPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi || !filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await electronApi.deleteFile(filePath);
|
||||
} catch { /* best-effort cleanup */ }
|
||||
}
|
||||
|
||||
private async resolveAppDataPath(): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.getAppDataPath();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const candidatePath of candidates) {
|
||||
if (!candidatePath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (await electronApi.fileExists(candidatePath)) {
|
||||
return candidatePath;
|
||||
}
|
||||
} catch { /* keep trying remaining candidates */ }
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
let binary = '';
|
||||
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
for (let index = 0; index < bytes.byteLength; index++) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
}
|
||||
}
|
||||
74
src/app/domains/auth/README.md
Normal file
74
src/app/domains/auth/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Auth Domain
|
||||
|
||||
Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
auth/
|
||||
├── application/
|
||||
│ └── auth.service.ts HTTP login/register against the active server endpoint
|
||||
│
|
||||
├── feature/
|
||||
│ ├── login/ Login form component
|
||||
│ ├── register/ Registration form component
|
||||
│ └── user-bar/ Displays current user or login/register links
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Service overview
|
||||
|
||||
`AuthService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Login[LoginComponent]
|
||||
Register[RegisterComponent]
|
||||
UserBar[UserBarComponent]
|
||||
Auth[AuthService]
|
||||
SD[ServerDirectoryFacade]
|
||||
Store[NgRx Store]
|
||||
|
||||
Login --> Auth
|
||||
Register --> Auth
|
||||
UserBar --> Store
|
||||
Auth --> SD
|
||||
Login --> Store
|
||||
|
||||
click Auth "application/auth.service.ts" "HTTP login/register" _blank
|
||||
click Login "feature/login/" "Login form" _blank
|
||||
click Register "feature/register/" "Registration form" _blank
|
||||
click UserBar "feature/user-bar/" "Current user display" _blank
|
||||
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL" _blank
|
||||
```
|
||||
|
||||
## Login flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Login as LoginComponent
|
||||
participant Auth as AuthService
|
||||
participant SD as ServerDirectoryFacade
|
||||
participant API as Server API
|
||||
participant Store as NgRx Store
|
||||
|
||||
User->>Login: Submit credentials
|
||||
Login->>Auth: login(username, password)
|
||||
Auth->>SD: getApiBaseUrl()
|
||||
SD-->>Auth: https://server/api
|
||||
Auth->>API: POST /api/auth/login
|
||||
API-->>Auth: { userId, displayName }
|
||||
Auth-->>Login: success
|
||||
Login->>Store: UsersActions.setCurrentUser
|
||||
Login->>Login: localStorage.setItem(currentUserId)
|
||||
```
|
||||
|
||||
## Registration flow
|
||||
|
||||
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens.
|
||||
|
||||
## User bar
|
||||
|
||||
`UserBarComponent` reads the current user from the NgRx store. When logged in it shows the user's display name; when not logged in it shows links to the login and register views.
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { ServerDirectoryService, ServerEndpoint } from './server-directory.service';
|
||||
import { type ServerEndpoint, ServerDirectoryFacade } from '../../server-directory';
|
||||
|
||||
/**
|
||||
* Response returned by the authentication endpoints (login / register).
|
||||
@@ -20,14 +20,14 @@ export interface LoginResponse {
|
||||
* Handles user authentication (login and registration) against a
|
||||
* configurable back-end server.
|
||||
*
|
||||
* The target server is resolved via {@link ServerDirectoryService}: the
|
||||
* The target server is resolved via {@link ServerDirectoryFacade}: the
|
||||
* caller may pass an explicit `serverId`, otherwise the currently active
|
||||
* server endpoint is used.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
|
||||
/**
|
||||
* Resolve the API base URL for the given server.
|
||||
@@ -11,11 +11,11 @@ import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
import { AuthService } from '../../application/auth.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -32,7 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
* Login form allowing existing users to authenticate against a selected server.
|
||||
*/
|
||||
export class LoginComponent {
|
||||
serversSvc = inject(ServerDirectoryService);
|
||||
serversSvc = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
@@ -11,11 +11,11 @@ import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideUserPlus } from '@ng-icons/lucide';
|
||||
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { ServerDirectoryService } from '../../../core/services/server-directory.service';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
import { AuthService } from '../../application/auth.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
@@ -32,7 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
|
||||
* Registration form allowing new users to create an account on a selected server.
|
||||
*/
|
||||
export class RegisterComponent {
|
||||
serversSvc = inject(ServerDirectoryService);
|
||||
serversSvc = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
username = '';
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
lucideLogIn,
|
||||
lucideUserPlus
|
||||
} from '@ng-icons/lucide';
|
||||
import { selectCurrentUser } from '../../../store/users/users.selectors';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-bar',
|
||||
1
src/app/domains/auth/index.ts
Normal file
1
src/app/domains/auth/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './application/auth.service';
|
||||
143
src/app/domains/chat/README.md
Normal file
143
src/app/domains/chat/README.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Chat Domain
|
||||
|
||||
Text messaging, reactions, GIF search, typing indicators, and the user list. All UI is under `feature/`; application services handle GIF integration; domain rules govern message editing, deletion, and sync.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
chat/
|
||||
├── application/
|
||||
│ └── klipy.service.ts GIF search via the KLIPY API (proxied through the server)
|
||||
│
|
||||
├── domain/
|
||||
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
|
||||
│ └── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
|
||||
│
|
||||
├── feature/
|
||||
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
|
||||
│ │ ├── chat-messages.component.ts Root component: replies, GIF picker, reactions, drag-drop
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── message-composer/ Markdown toolbar, file drag-drop, send
|
||||
│ │ │ ├── message-item/ Single message bubble with edit/delete/react
|
||||
│ │ │ ├── message-list/ Paginated list (50 msgs/page), auto-scroll, Prism highlighting
|
||||
│ │ │ └── message-overlays/ Context menus, reaction picker, reply preview
|
||||
│ │ ├── models/ View models for messages
|
||||
│ │ └── services/
|
||||
│ │ └── chat-markdown.service.ts Markdown-to-HTML rendering
|
||||
│ │
|
||||
│ ├── klipy-gif-picker/ GIF search/browse picker panel
|
||||
│ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names)
|
||||
│ └── user-list/ Online user sidebar
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Component composition
|
||||
|
||||
`ChatMessagesComponent` is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting a GIF.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Chat[ChatMessagesComponent]
|
||||
List[MessageListComponent]
|
||||
Composer[MessageComposerComponent]
|
||||
Overlays[MessageOverlays]
|
||||
Item[MessageItemComponent]
|
||||
GIF[KlipyGifPickerComponent]
|
||||
Typing[TypingIndicatorComponent]
|
||||
Users[UserListComponent]
|
||||
|
||||
Chat --> List
|
||||
Chat --> Composer
|
||||
Chat --> Overlays
|
||||
Chat --> GIF
|
||||
List --> Item
|
||||
Item --> Overlays
|
||||
|
||||
click Chat "feature/chat-messages/chat-messages.component.ts" "Root chat view" _blank
|
||||
click List "feature/chat-messages/components/message-list/" "Paginated message list" _blank
|
||||
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send" _blank
|
||||
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker" _blank
|
||||
click Item "feature/chat-messages/components/message-item/" "Single message bubble" _blank
|
||||
click GIF "feature/klipy-gif-picker/" "GIF search panel" _blank
|
||||
click Typing "feature/typing-indicator/" "Typing indicator" _blank
|
||||
click Users "feature/user-list/" "Online user sidebar" _blank
|
||||
```
|
||||
|
||||
## Message lifecycle
|
||||
|
||||
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Editing and deletion are sender-only operations.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Composer as MessageComposer
|
||||
participant Store as NgRx Store
|
||||
participant DC as Data Channel
|
||||
participant Peer as Remote Peer
|
||||
|
||||
User->>Composer: Type + send
|
||||
Composer->>Store: dispatch addMessage
|
||||
Composer->>DC: broadcastMessage(chat-message)
|
||||
DC->>Peer: chat-message event
|
||||
|
||||
Note over User: Edit
|
||||
User->>Store: dispatch editMessage
|
||||
User->>DC: broadcastMessage(edit-message)
|
||||
|
||||
Note over User: Delete
|
||||
User->>Store: dispatch deleteMessage (normaliseDeletedMessage)
|
||||
User->>DC: broadcastMessage(delete-message)
|
||||
```
|
||||
|
||||
## Message sync
|
||||
|
||||
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Peer A
|
||||
participant B as Peer B
|
||||
|
||||
A->>B: inventory (up to 1000 msg IDs + timestamps)
|
||||
B->>B: findMissingIds(remote, local)
|
||||
B->>A: request missing message IDs
|
||||
A->>B: message payloads (chunked, 200/batch)
|
||||
```
|
||||
|
||||
`findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested.
|
||||
|
||||
## GIF integration
|
||||
|
||||
`KlipyService` checks availability on the active server, then proxies search requests through the server API. Images are rendered via an image proxy endpoint to avoid mixed-content issues.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Picker[KlipyGifPickerComponent]
|
||||
Klipy[KlipyService]
|
||||
SD[ServerDirectoryFacade]
|
||||
API[Server API]
|
||||
|
||||
Picker --> Klipy
|
||||
Klipy --> SD
|
||||
Klipy --> API
|
||||
|
||||
click Picker "feature/klipy-gif-picker/" "GIF search panel" _blank
|
||||
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API" _blank
|
||||
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank
|
||||
```
|
||||
|
||||
## Domain rules
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
|
||||
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
|
||||
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
|
||||
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
|
||||
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
|
||||
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
|
||||
|
||||
## Typing indicator
|
||||
|
||||
`TypingIndicatorComponent` listens for typing events from peers. Each event resets a 3-second TTL timer. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { ServerDirectoryService } from './server-directory.service';
|
||||
import { ServerDirectoryFacade } from '../../server-directory';
|
||||
|
||||
export interface KlipyGif {
|
||||
id: string;
|
||||
@@ -41,7 +41,7 @@ const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class KlipyService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly availabilityState = signal({
|
||||
enabled: false,
|
||||
loading: true
|
||||
59
src/app/domains/chat/domain/message-sync.rules.ts
Normal file
59
src/app/domains/chat/domain/message-sync.rules.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** Maximum number of recent messages to include in sync inventories. */
|
||||
export const INVENTORY_LIMIT = 1000;
|
||||
|
||||
/** Number of messages per chunk for inventory / batch transfers. */
|
||||
export const CHUNK_SIZE = 200;
|
||||
|
||||
/** Aggressive sync poll interval (10 seconds). */
|
||||
export const SYNC_POLL_FAST_MS = 10_000;
|
||||
|
||||
/** Idle sync poll interval after a clean (no-new-messages) cycle (15 minutes). */
|
||||
export const SYNC_POLL_SLOW_MS = 900_000;
|
||||
|
||||
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
|
||||
export const SYNC_TIMEOUT_MS = 5_000;
|
||||
|
||||
/** Large limit used for legacy full-sync operations. */
|
||||
export const FULL_SYNC_LIMIT = 10_000;
|
||||
|
||||
/** Inventory item representing a message's sync state. */
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
ts: number;
|
||||
rc: number;
|
||||
ac?: number;
|
||||
}
|
||||
|
||||
/** Splits an array into chunks of the given size. */
|
||||
export function chunkArray<T>(items: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
chunks.push(items.slice(index, index + size));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
|
||||
export function findMissingIds(
|
||||
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
|
||||
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
|
||||
): string[] {
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const item of remoteItems) {
|
||||
const local = localMap.get(item.id);
|
||||
|
||||
if (
|
||||
!local ||
|
||||
item.ts > local.ts ||
|
||||
(item.rc !== undefined && item.rc !== local.rc) ||
|
||||
(item.ac !== undefined && item.ac !== local.ac)
|
||||
) {
|
||||
missing.push(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
31
src/app/domains/chat/domain/message.rules.ts
Normal file
31
src/app/domains/chat/domain/message.rules.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { DELETED_MESSAGE_CONTENT, type Message } from '../../../shared-kernel';
|
||||
|
||||
/** Extracts the effective timestamp from a message (editedAt takes priority). */
|
||||
export function getMessageTimestamp(msg: Message): number {
|
||||
return msg.editedAt || msg.timestamp || 0;
|
||||
}
|
||||
|
||||
/** Computes the most recent timestamp across a batch of messages. */
|
||||
export function getLatestTimestamp(messages: Message[]): number {
|
||||
return messages.reduce(
|
||||
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** Strips sensitive content from a deleted message. */
|
||||
export function normaliseDeletedMessage(message: Message): Message {
|
||||
if (!message.isDeleted)
|
||||
return message;
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: DELETED_MESSAGE_CONTENT,
|
||||
reactions: []
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the given user is allowed to edit this message. */
|
||||
export function canEditMessage(message: Message, userId: string): boolean {
|
||||
return message.senderId === userId;
|
||||
}
|
||||
@@ -8,18 +8,19 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Attachment, AttachmentService } from '../../../core/services/attachment.service';
|
||||
import { KlipyGif } from '../../../core/services/klipy.service';
|
||||
import { MessagesActions } from '../../../store/messages/messages.actions';
|
||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { Attachment, AttachmentFacade } from '../../../attachment';
|
||||
import { KlipyGif } from '../../application/klipy.service';
|
||||
import { MessagesActions } from '../../../../store/messages/messages.actions';
|
||||
import {
|
||||
selectAllMessages,
|
||||
selectMessagesLoading,
|
||||
selectMessagesSyncing
|
||||
} from '../../../store/messages/messages.selectors';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors';
|
||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../core/models';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
} from '../../../../store/messages/messages.selectors';
|
||||
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
|
||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { Message } from '../../../../shared-kernel';
|
||||
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
|
||||
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
|
||||
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
|
||||
@@ -48,9 +49,10 @@ import {
|
||||
export class ChatMessagesComponent {
|
||||
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
|
||||
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(WebRTCService);
|
||||
private readonly attachmentsSvc = inject(AttachmentService);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
|
||||
readonly allMessages = this.store.selectSignal(selectAllMessages);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
@@ -252,17 +254,9 @@ export class ChatMessagesComponent {
|
||||
if (!attachment.available || !attachment.objectUrl)
|
||||
return;
|
||||
|
||||
const electronWindow = window as Window & {
|
||||
electronAPI?: {
|
||||
saveFileAs?: (
|
||||
defaultFileName: string,
|
||||
data: string
|
||||
) => Promise<{ saved: boolean; cancelled: boolean }>;
|
||||
};
|
||||
};
|
||||
const electronApi = electronWindow.electronAPI;
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (electronApi?.saveFileAs) {
|
||||
if (electronApi) {
|
||||
const blob = await this.getAttachmentBlob(attachment);
|
||||
|
||||
if (blob) {
|
||||
@@ -132,7 +132,7 @@
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
>
|
||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2">
|
||||
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
|
||||
@if (klipy.isEnabled()) {
|
||||
<button
|
||||
#klipyTrigger
|
||||
@@ -184,9 +184,10 @@
|
||||
(dragleave)="onDragLeave($event)"
|
||||
(drop)="onDrop($event)"
|
||||
placeholder="Type a message..."
|
||||
class="chat-textarea w-full rounded-[1.35rem] border border-border py-2 pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
class="chat-textarea w-full rounded-[1.35rem] border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.border-dashed]="dragActive()"
|
||||
[class.border-primary]="dragActive()"
|
||||
[class.chat-textarea-expanded]="textareaExpanded()"
|
||||
[class.ctrl-resize]="ctrlHeld()"
|
||||
[class.pr-16]="!klipy.isEnabled()"
|
||||
[class.pr-40]="klipy.isEnabled()"
|
||||
@@ -0,0 +1,41 @@
|
||||
.chat-textarea {
|
||||
--textarea-bg: hsl(40deg 3.7% 15.9% / 25%);
|
||||
--textarea-collapsed-padding-y: 18px;
|
||||
--textarea-expanded-padding-y: 8px;
|
||||
|
||||
background: var(--textarea-bg);
|
||||
height: 62px;
|
||||
min-height: 62px;
|
||||
max-height: 520px;
|
||||
overflow-y: hidden;
|
||||
padding-top: var(--textarea-collapsed-padding-y);
|
||||
padding-bottom: var(--textarea-collapsed-padding-y);
|
||||
resize: none;
|
||||
transition:
|
||||
height 0.12s ease,
|
||||
padding 0.12s ease;
|
||||
|
||||
&.chat-textarea-expanded {
|
||||
padding-top: var(--textarea-expanded-padding-y);
|
||||
padding-bottom: var(--textarea-expanded-padding-y);
|
||||
}
|
||||
|
||||
&.ctrl-resize {
|
||||
resize: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: scale(0.85);
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -19,32 +19,20 @@ import {
|
||||
lucideSend,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { KlipyGif, KlipyService } from '../../../../../core/services/klipy.service';
|
||||
import { Message } from '../../../../../core/models';
|
||||
import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models';
|
||||
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
|
||||
import { KlipyGif, KlipyService } from '../../../../application/klipy.service';
|
||||
import { Message } from '../../../../../../shared-kernel';
|
||||
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
|
||||
import { ChatMarkdownService } from '../../services/chat-markdown.service';
|
||||
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
|
||||
|
||||
interface ClipboardFilePayload {
|
||||
data: string;
|
||||
lastModified: number;
|
||||
mime: string;
|
||||
name: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface ClipboardElectronApi {
|
||||
readClipboardFiles?: () => Promise<ClipboardFilePayload[]>;
|
||||
}
|
||||
|
||||
type ClipboardWindow = Window & {
|
||||
electronAPI?: ClipboardElectronApi;
|
||||
};
|
||||
|
||||
type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_TEXTAREA_HEIGHT = 62;
|
||||
|
||||
@Component({
|
||||
selector: 'app-chat-message-composer',
|
||||
standalone: true,
|
||||
@@ -85,12 +73,14 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
|
||||
readonly klipy = inject(KlipyService);
|
||||
private readonly markdown = inject(ChatMarkdownService);
|
||||
private readonly electronBridge = inject(ElectronBridgeService);
|
||||
|
||||
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
|
||||
readonly toolbarVisible = signal(false);
|
||||
readonly dragActive = signal(false);
|
||||
readonly inputHovered = signal(false);
|
||||
readonly ctrlHeld = signal(false);
|
||||
readonly textareaExpanded = signal(false);
|
||||
|
||||
messageContent = '';
|
||||
pendingFiles: File[] = [];
|
||||
@@ -351,6 +341,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
element.style.height = 'auto';
|
||||
element.style.height = Math.min(element.scrollHeight, 520) + 'px';
|
||||
element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden';
|
||||
this.syncTextareaExpandedState();
|
||||
}
|
||||
|
||||
onInputFocus(): void {
|
||||
@@ -554,9 +545,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async readFilesFromElectronClipboard(): Promise<File[]> {
|
||||
const electronApi = (window as ClipboardWindow).electronAPI;
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi?.readClipboardFiles)
|
||||
if (!electronApi)
|
||||
return [];
|
||||
|
||||
try {
|
||||
@@ -621,15 +612,26 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
|
||||
if (!root)
|
||||
return;
|
||||
|
||||
this.syncTextareaExpandedState();
|
||||
this.emitHeight();
|
||||
|
||||
if (typeof ResizeObserver === 'undefined')
|
||||
return;
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => this.emitHeight());
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.syncTextareaExpandedState();
|
||||
this.emitHeight();
|
||||
});
|
||||
|
||||
this.resizeObserver.observe(root);
|
||||
}
|
||||
|
||||
private syncTextareaExpandedState(): void {
|
||||
const textarea = this.messageInputRef?.nativeElement;
|
||||
|
||||
this.textareaExpanded.set(Boolean(textarea && textarea.offsetHeight > DEFAULT_TEXTAREA_HEIGHT));
|
||||
}
|
||||
|
||||
private emitHeight(): void {
|
||||
const root = this.composerRoot?.nativeElement;
|
||||
|
||||
@@ -31,16 +31,16 @@ import remarkParse from 'remark-parse';
|
||||
import { unified } from 'unified';
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentService,
|
||||
AttachmentFacade,
|
||||
MAX_AUTO_SAVE_SIZE_BYTES
|
||||
} from '../../../../../core/services/attachment.service';
|
||||
import { KlipyService } from '../../../../../core/services/klipy.service';
|
||||
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models';
|
||||
} from '../../../../../attachment';
|
||||
import { KlipyService } from '../../../../application/klipy.service';
|
||||
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
ChatAudioPlayerComponent,
|
||||
ChatVideoPlayerComponent,
|
||||
UserAvatarComponent
|
||||
} from '../../../../../shared';
|
||||
} from '../../../../../../shared';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
@@ -126,7 +126,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
export class ChatMessageItemComponent {
|
||||
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
|
||||
|
||||
private readonly attachmentsSvc = inject(AttachmentService);
|
||||
private readonly attachmentsSvc = inject(AttachmentFacade);
|
||||
private readonly klipy = inject(KlipyService);
|
||||
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
output,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Attachment } from '../../../../../core/services/attachment.service';
|
||||
import { Message } from '../../../../../core/models';
|
||||
import { Attachment } from '../../../../../attachment';
|
||||
import { Message } from '../../../../../../shared-kernel';
|
||||
import {
|
||||
ChatMessageDeleteEvent,
|
||||
ChatMessageEditEvent,
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
lucideDownload,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { Attachment } from '../../../../../core/services/attachment.service';
|
||||
import { ContextMenuComponent } from '../../../../../shared';
|
||||
import { Attachment } from '../../../../../attachment';
|
||||
import { ContextMenuComponent } from '../../../../../../shared';
|
||||
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models';
|
||||
|
||||
@Component({
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Attachment } from '../../../../core/services/attachment.service';
|
||||
import { Message } from '../../../../core/models';
|
||||
import { Attachment } from '../../../../attachment';
|
||||
import { Message } from '../../../../../shared-kernel';
|
||||
|
||||
export interface ChatMessageComposerSubmitEvent {
|
||||
content: string;
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
lucideSearch,
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { KlipyGif, KlipyService } from '../../../core/services/klipy.service';
|
||||
import { KlipyGif, KlipyService } from '../../application/klipy.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-klipy-gif-picker',
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
merge,
|
||||
interval,
|
||||
@@ -48,7 +48,7 @@ export class TypingIndicatorComponent {
|
||||
typingOthersCount = signal<number>(0);
|
||||
|
||||
constructor() {
|
||||
const webrtc = inject(WebRTCService);
|
||||
const webrtc = inject(RealtimeSessionFacade);
|
||||
const destroyRef = inject(DestroyRef);
|
||||
const typing$ = webrtc.onSignalingMessage.pipe(
|
||||
filter((msg): msg is TypingSignalingMessage =>
|
||||
@@ -22,14 +22,14 @@ import {
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import {
|
||||
selectOnlineUsers,
|
||||
selectCurrentUser,
|
||||
selectIsCurrentUserAdmin
|
||||
} from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared';
|
||||
} from '../../../../store/users/users.selectors';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
|
||||
|
||||
@Component({
|
||||
selector: 'app-user-list',
|
||||
7
src/app/domains/chat/index.ts
Normal file
7
src/app/domains/chat/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './application/klipy.service';
|
||||
export * from './domain/message.rules';
|
||||
export * from './domain/message-sync.rules';
|
||||
export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component';
|
||||
export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component';
|
||||
export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component';
|
||||
export { UserListComponent } from './feature/user-list/user-list.component';
|
||||
137
src/app/domains/screen-share/README.md
Normal file
137
src/app/domains/screen-share/README.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Screen Share Domain
|
||||
|
||||
Manages screen sharing sessions, source selection (Electron), quality presets, and the viewer/workspace UI. Like `voice-connection`, the actual WebRTC track distribution lives in `infrastructure/realtime`; this domain provides the application-facing API and UI components.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
screen-share/
|
||||
├── application/
|
||||
│ ├── screen-share.facade.ts Proxy to RealtimeSessionFacade for screen share signals and methods
|
||||
│ └── screen-share-source-picker.service.ts Electron desktop source picker (Promise-based open/confirm/cancel)
|
||||
│
|
||||
├── domain/
|
||||
│ └── screen-share.config.ts Quality presets and types (re-exported from shared-kernel)
|
||||
│
|
||||
├── feature/
|
||||
│ ├── screen-share-viewer/ Single-stream video player with fullscreen + volume
|
||||
│ └── screen-share-workspace/ Multi-stream grid workspace
|
||||
│ ├── screen-share-workspace.component.ts Grid layout, featured/thumbnail streams, mini-window mode
|
||||
│ ├── screen-share-stream-tile.component.ts Individual stream tile with fullscreen/volume controls
|
||||
│ ├── screen-share-playback.service.ts Per-user mute/volume state for screen share audio
|
||||
│ └── screen-share-workspace.models.ts ScreenShareWorkspaceStreamItem
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Service relationships
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
SSF[ScreenShareFacade]
|
||||
Picker[ScreenShareSourcePickerService]
|
||||
RSF[RealtimeSessionFacade]
|
||||
Config[screen-share.config]
|
||||
Viewer[ScreenShareViewerComponent]
|
||||
Workspace[ScreenShareWorkspaceComponent]
|
||||
Tile[ScreenShareStreamTileComponent]
|
||||
Playback[ScreenSharePlaybackService]
|
||||
|
||||
SSF --> RSF
|
||||
Viewer --> SSF
|
||||
Workspace --> SSF
|
||||
Workspace --> Playback
|
||||
Workspace --> Tile
|
||||
Picker --> Config
|
||||
|
||||
click SSF "application/screen-share.facade.ts" "Proxy to RealtimeSessionFacade" _blank
|
||||
click Picker "application/screen-share-source-picker.service.ts" "Electron source picker" _blank
|
||||
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root" _blank
|
||||
click Viewer "feature/screen-share-viewer/screen-share-viewer.component.ts" "Single-stream player" _blank
|
||||
click Workspace "feature/screen-share-workspace/screen-share-workspace.component.ts" "Multi-stream workspace" _blank
|
||||
click Tile "feature/screen-share-workspace/screen-share-stream-tile.component.ts" "Stream tile" _blank
|
||||
click Playback "feature/screen-share-workspace/screen-share-playback.service.ts" "Per-user volume state" _blank
|
||||
click Config "domain/screen-share.config.ts" "Quality presets" _blank
|
||||
```
|
||||
|
||||
## Starting a screen share
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Controls as VoiceControls
|
||||
participant Facade as ScreenShareFacade
|
||||
participant Realtime as RealtimeSessionFacade
|
||||
participant Picker as SourcePickerService
|
||||
|
||||
User->>Controls: Click "Share Screen"
|
||||
|
||||
alt Electron
|
||||
Controls->>Picker: open(sources)
|
||||
Picker-->>Controls: selected source + includeSystemAudio
|
||||
end
|
||||
|
||||
Controls->>Facade: startScreenShare(options)
|
||||
Facade->>Realtime: startScreenShare(options)
|
||||
Note over Realtime: Captures screen via platform strategy
|
||||
Note over Realtime: Waits for SCREEN_SHARE_REQUEST from viewers
|
||||
Realtime-->>Facade: MediaStream
|
||||
|
||||
User->>Controls: Click "Stop"
|
||||
Controls->>Facade: stopScreenShare()
|
||||
Facade->>Realtime: stopScreenShare()
|
||||
```
|
||||
|
||||
## Source picker (Electron)
|
||||
|
||||
`ScreenShareSourcePickerService` manages a Promise-based flow for Electron desktop capture. `open()` sets a signal with the available sources, and the UI renders a picker dialog. When the user selects a source, `confirm(sourceId, includeSystemAudio)` resolves the Promise. `cancel()` rejects with an `AbortError`.
|
||||
|
||||
Sources are classified as either `screen` or `window` based on the source ID prefix or name. The `includeSystemAudio` preference is persisted to voice settings storage.
|
||||
|
||||
## Quality presets
|
||||
|
||||
Screen share quality is configured through presets defined in the shared kernel:
|
||||
|
||||
| Preset | Resolution | Framerate |
|
||||
|---|---|---|
|
||||
| `low` | Reduced | Lower FPS |
|
||||
| `balanced` | Medium | Medium FPS |
|
||||
| `high` | Full | High FPS |
|
||||
|
||||
The quality dialog can be shown before each share (`askScreenShareQuality` setting) or skipped to use the last chosen preset.
|
||||
|
||||
## Viewer component
|
||||
|
||||
`ScreenShareViewerComponent` is a single-stream video player. It supports:
|
||||
|
||||
- Fullscreen toggle (browser Fullscreen API with CSS fallback)
|
||||
- Volume control for remote streams (delegated to `VoicePlaybackService`)
|
||||
- Local shares are always muted to avoid feedback
|
||||
- Focus events from other components via a `viewer:focus` custom DOM event
|
||||
- Auto-stop when the watched user stops sharing or the stream's video tracks end
|
||||
|
||||
## Workspace component
|
||||
|
||||
`ScreenShareWorkspaceComponent` is the multi-stream grid view inside the voice workspace panel. It handles:
|
||||
|
||||
- Listing all active screen shares (local + remote) sorted with remote first
|
||||
- Featured/widescreen mode for a single focused stream with thumbnail sidebar
|
||||
- Mini-window mode (draggable, position-clamped to viewport)
|
||||
- Auto-hide header chrome in widescreen mode (2.2 s timeout, revealed on pointer move)
|
||||
- On-demand remote stream requests via `syncRemoteScreenShareRequests`
|
||||
- Per-stream volume and mute via `ScreenSharePlaybackService`
|
||||
- Voice controls (mute, deafen, disconnect, share toggle) integrated into the workspace header
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Hidden
|
||||
Hidden --> Expanded: open()
|
||||
Expanded --> GridView: multiple shares, no focus
|
||||
Expanded --> WidescreenView: single share or focused stream
|
||||
WidescreenView --> GridView: showAllStreams()
|
||||
GridView --> WidescreenView: focusShare(peerKey)
|
||||
Expanded --> Minimized: minimize()
|
||||
Minimized --> Expanded: restore()
|
||||
Expanded --> Hidden: close()
|
||||
Minimized --> Hidden: close()
|
||||
```
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
computed,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from './voice-settings.storage';
|
||||
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from './webrtc/webrtc.constants';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../voice-session';
|
||||
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../domain/screen-share.config';
|
||||
|
||||
export type ScreenShareSourceKind = 'screen' | 'window';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { ScreenShareStartOptions } from '../domain/screen-share.config';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ScreenShareFacade {
|
||||
readonly isScreenSharing = inject(RealtimeSessionFacade).isScreenSharing;
|
||||
readonly screenStream = inject(RealtimeSessionFacade).screenStream;
|
||||
readonly isScreenShareRemotePlaybackSuppressed = inject(RealtimeSessionFacade).isScreenShareRemotePlaybackSuppressed;
|
||||
readonly forceDefaultRemotePlaybackOutput = inject(RealtimeSessionFacade).forceDefaultRemotePlaybackOutput;
|
||||
readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream;
|
||||
readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected;
|
||||
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
|
||||
getRemoteScreenShareStream(peerId: string): MediaStream | null {
|
||||
return this.realtime.getRemoteScreenShareStream(peerId);
|
||||
}
|
||||
|
||||
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
|
||||
return await this.realtime.startScreenShare(options);
|
||||
}
|
||||
|
||||
stopScreenShare(): void {
|
||||
this.realtime.stopScreenShare();
|
||||
}
|
||||
|
||||
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
|
||||
this.realtime.syncRemoteScreenShareRequests(peerIds, enabled);
|
||||
}
|
||||
}
|
||||
21
src/app/domains/screen-share/domain/screen-share.config.ts
Normal file
21
src/app/domains/screen-share/domain/screen-share.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
DEFAULT_SCREEN_SHARE_QUALITY,
|
||||
DEFAULT_SCREEN_SHARE_START_OPTIONS,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
|
||||
SCREEN_SHARE_QUALITY_OPTIONS,
|
||||
SCREEN_SHARE_QUALITY_PRESETS,
|
||||
type ScreenShareQualityPreset,
|
||||
type ScreenShareStartOptions,
|
||||
type ScreenShareQuality
|
||||
} from '../../../shared-kernel';
|
||||
|
||||
export {
|
||||
DEFAULT_SCREEN_SHARE_QUALITY,
|
||||
DEFAULT_SCREEN_SHARE_START_OPTIONS,
|
||||
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
|
||||
SCREEN_SHARE_QUALITY_OPTIONS,
|
||||
SCREEN_SHARE_QUALITY_PRESETS,
|
||||
type ScreenShareQualityPreset,
|
||||
type ScreenShareStartOptions,
|
||||
type ScreenShareQuality
|
||||
};
|
||||
@@ -19,11 +19,11 @@ import {
|
||||
lucideMonitor
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||
import { User } from '../../../core/models/index';
|
||||
import { DEFAULT_VOLUME } from '../../../core/constants';
|
||||
import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service';
|
||||
import { ScreenShareFacade } from '../../application/screen-share.facade';
|
||||
import { selectOnlineUsers } from '../../../../store/users/users.selectors';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { DEFAULT_VOLUME } from '../../../../core/constants';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-screen-share-viewer',
|
||||
@@ -46,9 +46,9 @@ import { VoicePlaybackService } from '../voice-controls/services/voice-playback.
|
||||
export class ScreenShareViewerComponent implements OnDestroy {
|
||||
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
|
||||
|
||||
private webrtcService = inject(WebRTCService);
|
||||
private voicePlayback = inject(VoicePlaybackService);
|
||||
private store = inject(Store);
|
||||
private readonly screenShareService = inject(ScreenShareFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly store = inject(Store);
|
||||
private remoteStreamSub: Subscription | null = null;
|
||||
|
||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||
@@ -69,7 +69,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
if (!userId)
|
||||
return;
|
||||
|
||||
const stream = this.webrtcService.getRemoteScreenShareStream(userId);
|
||||
const stream = this.screenShareService.getRemoteScreenShareStream(userId);
|
||||
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
|
||||
|
||||
if (stream && stream.getVideoTracks().length > 0) {
|
||||
@@ -94,7 +94,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
constructor() {
|
||||
// React to screen share stream changes
|
||||
effect(() => {
|
||||
const screenStream = this.webrtcService.screenStream();
|
||||
const screenStream = this.screenShareService.screenStream();
|
||||
|
||||
if (screenStream && this.videoRef) {
|
||||
// Local share: always mute to avoid audio feedback
|
||||
@@ -129,7 +129,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
// Also check if the stream's video tracks are still available
|
||||
const stream = this.webrtcService.getRemoteScreenShareStream(watchingId);
|
||||
const stream = this.screenShareService.getRemoteScreenShareStream(watchingId);
|
||||
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
|
||||
|
||||
if (!hasActiveVideo) {
|
||||
@@ -141,12 +141,12 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
// Subscribe to remote streams with video (screen shares)
|
||||
// NOTE: We no longer auto-display remote streams. Users must click "Live" to view.
|
||||
// This subscription is kept for potential future use (e.g., tracking available streams)
|
||||
this.remoteStreamSub = this.webrtcService.onRemoteStream.subscribe(({ peerId }) => {
|
||||
this.remoteStreamSub = this.screenShareService.onRemoteStream.subscribe(({ peerId }) => {
|
||||
if (peerId !== this.watchingUserId() || this.isLocalShare()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = this.webrtcService.getRemoteScreenShareStream(peerId);
|
||||
const stream = this.screenShareService.getRemoteScreenShareStream(peerId);
|
||||
const hasActiveVideo = stream?.getVideoTracks().some((track) => track.readyState === 'live') ?? false;
|
||||
|
||||
if (!hasActiveVideo) {
|
||||
@@ -202,7 +202,7 @@ export class ScreenShareViewerComponent implements OnDestroy {
|
||||
|
||||
/** Stop the local screen share and reset viewer state. */
|
||||
stopSharing(): void {
|
||||
this.webrtcService.stopScreenShare();
|
||||
this.screenShareService.stopScreenShare();
|
||||
this.activeScreenSharer.set(null);
|
||||
this.hasStream.set(false);
|
||||
this.isLocalShare.set(false);
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
lucideVolumeX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { UserAvatarComponent } from '../../../shared';
|
||||
import { UserAvatarComponent } from '../../../../shared';
|
||||
import { ScreenSharePlaybackService } from './screen-share-playback.service';
|
||||
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';
|
||||
|
||||
@@ -29,17 +29,22 @@ import {
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { User } from '../../../core/models';
|
||||
import { VoiceSessionService } from '../../../core/services/voice-session.service';
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage';
|
||||
import { VoiceWorkspacePosition, VoiceWorkspaceService } from '../../../core/services/voice-workspace.service';
|
||||
import { ScreenShareQuality, ScreenShareStartOptions } from '../../../core/services/webrtc';
|
||||
import { WebRTCService } from '../../../core/services/webrtc.service';
|
||||
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../store/users/users.actions';
|
||||
import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors';
|
||||
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../shared';
|
||||
import { VoicePlaybackService } from '../voice-controls/services/voice-playback.service';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import {
|
||||
loadVoiceSettingsFromStorage,
|
||||
saveVoiceSettingsToStorage,
|
||||
VoiceSessionFacade,
|
||||
VoiceWorkspacePosition,
|
||||
VoiceWorkspaceService
|
||||
} from '../../../../domains/voice-session';
|
||||
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
|
||||
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
|
||||
import { ScreenShareFacade } from '../../application/screen-share.facade';
|
||||
import { ScreenShareQuality, ScreenShareStartOptions } from '../../domain/screen-share.config';
|
||||
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
|
||||
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../../shared';
|
||||
import { ScreenSharePlaybackService } from './screen-share-playback.service';
|
||||
import { ScreenShareStreamTileComponent } from './screen-share-stream-tile.component';
|
||||
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';
|
||||
@@ -79,10 +84,11 @@ export class ScreenShareWorkspaceComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(WebRTCService);
|
||||
private readonly webrtc = inject(VoiceConnectionFacade);
|
||||
private readonly screenShare = inject(ScreenShareFacade);
|
||||
private readonly voicePlayback = inject(VoicePlaybackService);
|
||||
private readonly screenSharePlayback = inject(ScreenSharePlaybackService);
|
||||
private readonly voiceSession = inject(VoiceSessionService);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
|
||||
|
||||
private readonly remoteStreamRevision = signal(0);
|
||||
@@ -116,7 +122,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
|
||||
readonly isMuted = computed(() => this.webrtc.isMuted());
|
||||
readonly isDeafened = computed(() => this.webrtc.isDeafened());
|
||||
readonly isScreenSharing = computed(() => this.webrtc.isScreenSharing());
|
||||
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
|
||||
|
||||
readonly includeSystemAudio = signal(false);
|
||||
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
|
||||
@@ -167,7 +173,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
}
|
||||
|
||||
const shares: ScreenShareWorkspaceStreamItem[] = [];
|
||||
const localStream = this.webrtc.screenStream();
|
||||
const localStream = this.screenShare.screenStream();
|
||||
const localPeerKey = this.getUserPeerKey(me);
|
||||
|
||||
if (localStream && localPeerKey) {
|
||||
@@ -306,18 +312,18 @@ export class ScreenShareWorkspaceComponent {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.clearHeaderHideTimeout();
|
||||
this.cleanupObservedRemoteStreams();
|
||||
this.webrtc.syncRemoteScreenShareRequests([], false);
|
||||
this.screenShare.syncRemoteScreenShareRequests([], false);
|
||||
this.screenSharePlayback.teardownAll();
|
||||
});
|
||||
|
||||
this.webrtc.onRemoteStream
|
||||
this.screenShare.onRemoteStream
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(({ peerId }) => {
|
||||
this.observeRemoteStream(peerId);
|
||||
this.bumpRemoteStreamRevision();
|
||||
});
|
||||
|
||||
this.webrtc.onPeerDisconnected
|
||||
this.screenShare.onPeerDisconnected
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => this.bumpRemoteStreamRevision());
|
||||
|
||||
@@ -363,7 +369,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
.filter((peerKey): peerKey is string => !!peerKey && peerKey !== currentUserPeerKey)
|
||||
));
|
||||
|
||||
this.webrtc.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
|
||||
this.screenShare.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
|
||||
|
||||
if (!shouldConnectRemoteShares) {
|
||||
this.screenSharePlayback.teardownAll();
|
||||
@@ -614,7 +620,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
|
||||
async toggleScreenShare(): Promise<void> {
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtc.stopScreenShare();
|
||||
this.screenShare.stopScreenShare();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -656,7 +662,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
});
|
||||
|
||||
if (this.isScreenSharing()) {
|
||||
this.webrtc.stopScreenShare();
|
||||
this.screenShare.stopScreenShare();
|
||||
}
|
||||
|
||||
this.webrtc.disableVoice();
|
||||
@@ -773,7 +779,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
};
|
||||
|
||||
try {
|
||||
await this.webrtc.startScreenShare(options);
|
||||
await this.screenShare.startScreenShare(options);
|
||||
|
||||
this.voiceWorkspace.open(null);
|
||||
} catch {
|
||||
@@ -791,7 +797,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
);
|
||||
|
||||
for (const peerKey of peerKeys) {
|
||||
const stream = this.webrtc.getRemoteScreenShareStream(peerKey);
|
||||
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
|
||||
|
||||
if (stream && this.hasActiveVideo(stream)) {
|
||||
return { peerKey, stream };
|
||||
@@ -848,7 +854,7 @@ export class ScreenShareWorkspaceComponent {
|
||||
}
|
||||
|
||||
private observeRemoteStream(peerKey: string): void {
|
||||
const stream = this.webrtc.getRemoteScreenShareStream(peerKey);
|
||||
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
|
||||
const existing = this.observedRemoteStreams.get(peerKey);
|
||||
|
||||
if (!stream) {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User } from '../../../core/models';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
|
||||
export interface ScreenShareWorkspaceStreamItem {
|
||||
id: string;
|
||||
8
src/app/domains/screen-share/index.ts
Normal file
8
src/app/domains/screen-share/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './application/screen-share.facade';
|
||||
export * from './application/screen-share-source-picker.service';
|
||||
export * from './domain/screen-share.config';
|
||||
|
||||
// Feature components
|
||||
export { ScreenShareViewerComponent } from './feature/screen-share-viewer/screen-share-viewer.component';
|
||||
export { ScreenShareWorkspaceComponent } from './feature/screen-share-workspace/screen-share-workspace.component';
|
||||
export { ScreenShareStreamTileComponent } from './feature/screen-share-workspace/screen-share-stream-tile.component';
|
||||
176
src/app/domains/server-directory/README.md
Normal file
176
src/app/domains/server-directory/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Server Directory Domain
|
||||
|
||||
Manages the list of server endpoints the client can connect to, health-checking them, resolving API URLs, and providing server CRUD, search, invites, and moderation. This is the central domain that other domains (auth, chat, attachment) depend on for knowing where the backend is.
|
||||
|
||||
## Module map
|
||||
|
||||
```
|
||||
server-directory/
|
||||
├── application/
|
||||
│ ├── server-directory.facade.ts High-level API: server CRUD, search, health, invites, moderation
|
||||
│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence
|
||||
│
|
||||
├── domain/
|
||||
│ ├── server-directory.models.ts ServerEndpoint, ServerInfo, ServerJoinAccessResponse, invite/ban/kick types
|
||||
│ ├── server-directory.constants.ts CLIENT_UPDATE_REQUIRED_MESSAGE
|
||||
│ └── server-endpoint-defaults.ts Default endpoint templates, URL sanitisation, reconciliation helpers
|
||||
│
|
||||
├── infrastructure/
|
||||
│ ├── server-directory-api.service.ts HTTP client for all server API calls
|
||||
│ ├── server-endpoint-health.service.ts Health probe (GET /api/health with 5 s timeout, fallback to /api/servers)
|
||||
│ ├── server-endpoint-compatibility.service.ts Semantic version comparison for client/server compatibility
|
||||
│ └── server-endpoint-storage.service.ts localStorage read/write for endpoint list and removed-default tracking
|
||||
│
|
||||
├── feature/
|
||||
│ ├── invite/ Invite creation and resolution UI
|
||||
│ ├── server-search/ Server search/browse panel
|
||||
│ └── settings/ Server endpoint management settings
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
```
|
||||
|
||||
## Layer composition
|
||||
|
||||
The facade delegates HTTP work to the API service and endpoint state to the state service. Health probing combines the health service and compatibility service. Storage is accessed only through the state service.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Facade[ServerDirectoryFacade]
|
||||
State[ServerEndpointStateService]
|
||||
API[ServerDirectoryApiService]
|
||||
Health[ServerEndpointHealthService]
|
||||
Compat[ServerEndpointCompatibilityService]
|
||||
Storage[ServerEndpointStorageService]
|
||||
Defaults[server-endpoint-defaults]
|
||||
Models[server-directory.models]
|
||||
|
||||
Facade --> API
|
||||
Facade --> State
|
||||
Facade --> Health
|
||||
Facade --> Compat
|
||||
API --> State
|
||||
State --> Storage
|
||||
State --> Defaults
|
||||
Health --> Compat
|
||||
|
||||
click Facade "application/server-directory.facade.ts" "High-level API" _blank
|
||||
click State "application/server-endpoint-state.service.ts" "Signal-based endpoint state" _blank
|
||||
click API "infrastructure/server-directory-api.service.ts" "HTTP client for server API" _blank
|
||||
click Health "infrastructure/server-endpoint-health.service.ts" "Health probe" _blank
|
||||
click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility" _blank
|
||||
click Storage "infrastructure/server-endpoint-storage.service.ts" "localStorage persistence" _blank
|
||||
click Defaults "domain/server-endpoint-defaults.ts" "Default endpoint templates" _blank
|
||||
click Models "domain/server-directory.models.ts" "Domain types" _blank
|
||||
```
|
||||
|
||||
## Endpoint lifecycle
|
||||
|
||||
On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Load: constructor
|
||||
Load --> HasStored: localStorage has endpoints
|
||||
Load --> InitDefaults: no stored endpoints
|
||||
InitDefaults --> Ready: save default endpoints
|
||||
HasStored --> Reconcile: compare stored vs defaults
|
||||
Reconcile --> Ready: merge, ensure active
|
||||
Ready --> HealthCheck: facade.testAllServers()
|
||||
|
||||
state HealthCheck {
|
||||
[*] --> Probing
|
||||
Probing --> Online: /api/health 200 OK
|
||||
Probing --> Incompatible: version mismatch
|
||||
Probing --> Offline: request failed
|
||||
}
|
||||
```
|
||||
|
||||
## Health probing
|
||||
|
||||
The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate to `ServerEndpointHealthService.probeEndpoint()`, which:
|
||||
|
||||
1. Sends `GET /api/health` with a 5-second timeout
|
||||
2. On success, checks the response's `serverVersion` against the client version via `ServerEndpointCompatibilityService`
|
||||
3. If versions are incompatible, the endpoint is marked `incompatible` and deactivated
|
||||
4. If `/api/health` fails, falls back to `GET /api/servers` as a basic liveness check
|
||||
5. Updates the endpoint's status, latency, and version info in the state service
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Facade
|
||||
participant Health as HealthService
|
||||
participant Compat as CompatibilityService
|
||||
participant API as Server
|
||||
|
||||
Facade->>Health: probeEndpoint(endpoint, clientVersion)
|
||||
Health->>API: GET /api/health (5s timeout)
|
||||
|
||||
alt 200 OK
|
||||
API-->>Health: { serverVersion }
|
||||
Health->>Compat: evaluateServerVersion(serverVersion, clientVersion)
|
||||
Compat-->>Health: { isCompatible, serverVersion }
|
||||
Health-->>Facade: online / incompatible + latency + versions
|
||||
else Request failed
|
||||
Health->>API: GET /api/servers (fallback)
|
||||
alt 200 OK
|
||||
API-->>Health: servers list
|
||||
Health-->>Facade: online + latency
|
||||
else Also failed
|
||||
Health-->>Facade: offline
|
||||
end
|
||||
end
|
||||
|
||||
Facade->>Facade: updateServerStatus(id, status, latency, versions)
|
||||
```
|
||||
|
||||
## Server search
|
||||
|
||||
The facade's `searchServers(query)` method supports two modes controlled by a `searchAllServers` flag:
|
||||
|
||||
- **Single endpoint**: searches only the active server's API
|
||||
- **All endpoints**: fans out the query to every online active endpoint via `forkJoin`, then deduplicates results by server ID
|
||||
|
||||
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
|
||||
|
||||
## Default endpoint management
|
||||
|
||||
Default servers are configured in the environment file. The state service builds `DefaultEndpointTemplate` objects from the configuration and uses them during reconciliation:
|
||||
|
||||
- Stored endpoints are matched to defaults by `defaultKey` or URL
|
||||
- Missing defaults are added unless the user explicitly removed them (tracked in a separate localStorage key)
|
||||
- `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking
|
||||
- The primary default URL is used as a fallback when no endpoint is resolved
|
||||
|
||||
URL sanitisation strips trailing slashes and `/api` suffixes. Protocol-less URLs get `http` or `https` based on the current page protocol.
|
||||
|
||||
## Server administration
|
||||
|
||||
The facade provides methods for server registration, updates, and unregistration. These map directly to the API service's HTTP calls:
|
||||
|
||||
| Method | HTTP | Endpoint |
|
||||
|---|---|---|
|
||||
| `registerServer` | POST | `/api/servers` |
|
||||
| `updateServer` | PUT | `/api/servers/:id` |
|
||||
| `unregisterServer` | DELETE | `/api/servers/:id` |
|
||||
|
||||
## Invites and moderation
|
||||
|
||||
| Method | Purpose |
|
||||
|---|---|
|
||||
| `createInvite(serverId, request)` | Creates a time-limited invite link |
|
||||
| `getInvite(inviteId)` | Resolves invite metadata |
|
||||
| `requestServerAccess(request)` | Joins a server (via membership, password, invite, or public access) |
|
||||
| `kickServerMember(serverId, request)` | Removes a user from the server |
|
||||
| `banServerMember(serverId, request)` | Bans a user with optional reason and expiry |
|
||||
| `unbanServerMember(serverId, request)` | Lifts a ban |
|
||||
|
||||
## Persistence
|
||||
|
||||
All endpoint state is persisted to localStorage under two keys:
|
||||
|
||||
| Key | Contents |
|
||||
|---|---|
|
||||
| `metoyou_server_endpoints` | Full `ServerEndpoint[]` array |
|
||||
| `metoyou_removed_default_server_keys` | Set of default endpoint keys the user explicitly removed |
|
||||
|
||||
The storage service handles JSON serialisation and defensive parsing. Invalid data falls back to empty state rather than throwing.
|
||||
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
Injectable,
|
||||
inject,
|
||||
type Signal
|
||||
} from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
|
||||
import { User } from '../../../shared-kernel';
|
||||
import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants';
|
||||
import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service';
|
||||
import type {
|
||||
BanServerMemberRequest,
|
||||
CreateServerInviteRequest,
|
||||
KickServerMemberRequest,
|
||||
ServerEndpoint,
|
||||
ServerEndpointVersions,
|
||||
ServerInfo,
|
||||
ServerInviteInfo,
|
||||
ServerJoinAccessRequest,
|
||||
ServerJoinAccessResponse,
|
||||
ServerSourceSelector,
|
||||
UnbanServerMemberRequest
|
||||
} from '../domain/server-directory.models';
|
||||
import { ServerEndpointCompatibilityService } from '../infrastructure/server-endpoint-compatibility.service';
|
||||
import { ServerEndpointHealthService } from '../infrastructure/server-endpoint-health.service';
|
||||
import { ServerEndpointStateService } from './server-endpoint-state.service';
|
||||
|
||||
export { CLIENT_UPDATE_REQUIRED_MESSAGE };
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerDirectoryFacade {
|
||||
readonly servers: Signal<ServerEndpoint[]>;
|
||||
readonly activeServers: Signal<ServerEndpoint[]>;
|
||||
readonly hasMissingDefaultServers: Signal<boolean>;
|
||||
readonly activeServer: Signal<ServerEndpoint | null>;
|
||||
|
||||
private readonly endpointState = inject(ServerEndpointStateService);
|
||||
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
|
||||
private readonly endpointHealth = inject(ServerEndpointHealthService);
|
||||
private readonly api = inject(ServerDirectoryApiService);
|
||||
private shouldSearchAllServers = true;
|
||||
|
||||
constructor() {
|
||||
this.servers = this.endpointState.servers;
|
||||
this.activeServers = this.endpointState.activeServers;
|
||||
this.hasMissingDefaultServers = this.endpointState.hasMissingDefaultServers;
|
||||
this.activeServer = this.endpointState.activeServer;
|
||||
|
||||
this.loadConnectionSettings();
|
||||
void this.testAllServers();
|
||||
}
|
||||
|
||||
addServer(server: { name: string; url: string }): ServerEndpoint {
|
||||
return this.endpointState.addServer(server);
|
||||
}
|
||||
|
||||
ensureServerEndpoint(
|
||||
server: { name: string; url: string },
|
||||
options?: { setActive?: boolean }
|
||||
): ServerEndpoint {
|
||||
return this.endpointState.ensureServerEndpoint(server, options);
|
||||
}
|
||||
|
||||
findServerByUrl(url: string): ServerEndpoint | undefined {
|
||||
return this.endpointState.findServerByUrl(url);
|
||||
}
|
||||
|
||||
removeServer(endpointId: string): void {
|
||||
this.endpointState.removeServer(endpointId);
|
||||
}
|
||||
|
||||
restoreDefaultServers(): ServerEndpoint[] {
|
||||
return this.endpointState.restoreDefaultServers();
|
||||
}
|
||||
|
||||
setActiveServer(endpointId: string): void {
|
||||
this.endpointState.setActiveServer(endpointId);
|
||||
}
|
||||
|
||||
deactivateServer(endpointId: string): void {
|
||||
this.endpointState.deactivateServer(endpointId);
|
||||
}
|
||||
|
||||
updateServerStatus(
|
||||
endpointId: string,
|
||||
status: ServerEndpoint['status'],
|
||||
latency?: number,
|
||||
versions?: ServerEndpointVersions
|
||||
): void {
|
||||
this.endpointState.updateServerStatus(endpointId, status, latency, versions);
|
||||
}
|
||||
|
||||
async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise<boolean> {
|
||||
const endpoint = this.api.resolveEndpoint(selector);
|
||||
|
||||
if (!endpoint || endpoint.status === 'incompatible') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const clientVersion = await this.endpointCompatibility.getClientVersion();
|
||||
|
||||
if (!clientVersion) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.testServer(endpoint.id);
|
||||
|
||||
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
|
||||
|
||||
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
|
||||
}
|
||||
|
||||
setSearchAllServers(enabled: boolean): void {
|
||||
this.shouldSearchAllServers = enabled;
|
||||
}
|
||||
|
||||
async testServer(endpointId: string): Promise<boolean> {
|
||||
const endpoint = this.servers().find((entry) => entry.id === endpointId);
|
||||
|
||||
if (!endpoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.updateServerStatus(endpointId, 'checking');
|
||||
const clientVersion = await this.endpointCompatibility.getClientVersion();
|
||||
const healthResult = await this.endpointHealth.probeEndpoint(endpoint, clientVersion);
|
||||
|
||||
this.updateServerStatus(
|
||||
endpointId,
|
||||
healthResult.status,
|
||||
healthResult.latency,
|
||||
healthResult.versions
|
||||
);
|
||||
|
||||
return healthResult.status === 'online';
|
||||
}
|
||||
|
||||
async testAllServers(): Promise<void> {
|
||||
await Promise.all(this.servers().map((endpoint) => this.testServer(endpoint.id)));
|
||||
}
|
||||
|
||||
getApiBaseUrl(selector?: ServerSourceSelector): string {
|
||||
return this.api.getApiBaseUrl(selector);
|
||||
}
|
||||
|
||||
getWebSocketUrl(selector?: ServerSourceSelector): string {
|
||||
return this.api.getWebSocketUrl(selector);
|
||||
}
|
||||
|
||||
searchServers(query: string): Observable<ServerInfo[]> {
|
||||
return this.api.searchServers(query, this.shouldSearchAllServers);
|
||||
}
|
||||
|
||||
getServers(): Observable<ServerInfo[]> {
|
||||
return this.api.getServers(this.shouldSearchAllServers);
|
||||
}
|
||||
|
||||
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
|
||||
return this.api.getServer(serverId, selector);
|
||||
}
|
||||
|
||||
registerServer(
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.api.registerServer(server, selector);
|
||||
}
|
||||
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo> & {
|
||||
currentOwnerId: string;
|
||||
actingRole?: string;
|
||||
password?: string | null;
|
||||
},
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.api.updateServer(serverId, updates, selector);
|
||||
}
|
||||
|
||||
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.api.unregisterServer(serverId, selector);
|
||||
}
|
||||
|
||||
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
|
||||
return this.api.getServerUsers(serverId, selector);
|
||||
}
|
||||
|
||||
requestJoin(
|
||||
request: ServerJoinAccessRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerJoinAccessResponse> {
|
||||
return this.api.requestJoin(request, selector);
|
||||
}
|
||||
|
||||
createInvite(
|
||||
serverId: string,
|
||||
request: CreateServerInviteRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInviteInfo> {
|
||||
return this.api.createInvite(serverId, request, selector);
|
||||
}
|
||||
|
||||
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
|
||||
return this.api.getInvite(inviteId, selector);
|
||||
}
|
||||
|
||||
kickServerMember(
|
||||
serverId: string,
|
||||
request: KickServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.api.kickServerMember(serverId, request, selector);
|
||||
}
|
||||
|
||||
banServerMember(
|
||||
serverId: string,
|
||||
request: BanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.api.banServerMember(serverId, request, selector);
|
||||
}
|
||||
|
||||
unbanServerMember(
|
||||
serverId: string,
|
||||
request: UnbanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.api.unbanServerMember(serverId, request, selector);
|
||||
}
|
||||
|
||||
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.api.notifyLeave(serverId, userId, selector);
|
||||
}
|
||||
|
||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||
return this.api.updateUserCount(serverId, count);
|
||||
}
|
||||
|
||||
sendHeartbeat(serverId: string): Observable<void> {
|
||||
return this.api.sendHeartbeat(serverId);
|
||||
}
|
||||
|
||||
private loadConnectionSettings(): void {
|
||||
const stored = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
|
||||
|
||||
if (!stored) {
|
||||
this.shouldSearchAllServers = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as { searchAllServers?: boolean };
|
||||
|
||||
this.shouldSearchAllServers = parsed.searchAllServers ?? true;
|
||||
} catch {
|
||||
this.shouldSearchAllServers = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import {
|
||||
Injectable,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
type Signal
|
||||
} from '@angular/core';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { environment } from '../../../../environments/environment';
|
||||
import {
|
||||
buildDefaultEndpointTemplates,
|
||||
buildDefaultServerDefinitions,
|
||||
ensureAnyActiveEndpoint,
|
||||
ensureCompatibleActiveEndpoint,
|
||||
findDefaultEndpointKeyByUrl,
|
||||
hasEndpointForDefault,
|
||||
matchDefaultEndpointTemplate,
|
||||
sanitiseServerBaseUrl
|
||||
} from '../domain/server-endpoint-defaults';
|
||||
import { ServerEndpointStorageService } from '../infrastructure/server-endpoint-storage.service';
|
||||
import type {
|
||||
ConfiguredDefaultServerDefinition,
|
||||
DefaultEndpointTemplate,
|
||||
ServerEndpoint,
|
||||
ServerEndpointVersions
|
||||
} from '../domain/server-directory.models';
|
||||
|
||||
function resolveDefaultHttpProtocol(): 'http' | 'https' {
|
||||
return typeof window !== 'undefined' && window.location?.protocol === 'https:'
|
||||
? 'https'
|
||||
: 'http';
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerEndpointStateService {
|
||||
readonly servers: Signal<ServerEndpoint[]>;
|
||||
readonly activeServers: Signal<ServerEndpoint[]>;
|
||||
readonly hasMissingDefaultServers: Signal<boolean>;
|
||||
readonly activeServer: Signal<ServerEndpoint | null>;
|
||||
|
||||
private readonly storage = inject(ServerEndpointStorageService);
|
||||
private readonly _servers = signal<ServerEndpoint[]>([]);
|
||||
private readonly defaultEndpoints: DefaultEndpointTemplate[];
|
||||
private readonly primaryDefaultServerUrl: string;
|
||||
|
||||
constructor() {
|
||||
const defaultServerDefinitions = buildDefaultServerDefinitions(
|
||||
Array.isArray(environment.defaultServers)
|
||||
? environment.defaultServers as ConfiguredDefaultServerDefinition[]
|
||||
: [],
|
||||
environment.defaultServerUrl,
|
||||
resolveDefaultHttpProtocol()
|
||||
);
|
||||
|
||||
this.defaultEndpoints = buildDefaultEndpointTemplates(defaultServerDefinitions);
|
||||
this.primaryDefaultServerUrl = this.defaultEndpoints[0]?.url ?? 'http://localhost:3001';
|
||||
|
||||
this.servers = computed(() => this._servers());
|
||||
this.activeServers = computed(() =>
|
||||
this._servers().filter((endpoint) => endpoint.isActive && endpoint.status !== 'incompatible')
|
||||
);
|
||||
|
||||
this.hasMissingDefaultServers = computed(() =>
|
||||
this.defaultEndpoints.some((endpoint) => !hasEndpointForDefault(this._servers(), endpoint))
|
||||
);
|
||||
|
||||
this.activeServer = computed(() => this.activeServers()[0] ?? null);
|
||||
|
||||
this.loadEndpoints();
|
||||
}
|
||||
|
||||
getPrimaryDefaultServerUrl(): string {
|
||||
return this.primaryDefaultServerUrl;
|
||||
}
|
||||
|
||||
sanitiseUrl(rawUrl: string): string {
|
||||
return sanitiseServerBaseUrl(rawUrl);
|
||||
}
|
||||
|
||||
addServer(server: { name: string; url: string }): ServerEndpoint {
|
||||
const newEndpoint: ServerEndpoint = {
|
||||
id: uuidv4(),
|
||||
name: server.name,
|
||||
url: this.sanitiseUrl(server.url),
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
status: 'unknown'
|
||||
};
|
||||
|
||||
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
|
||||
this.saveEndpoints();
|
||||
return newEndpoint;
|
||||
}
|
||||
|
||||
ensureServerEndpoint(
|
||||
server: { name: string; url: string },
|
||||
options?: { setActive?: boolean }
|
||||
): ServerEndpoint {
|
||||
const existing = this.findServerByUrl(server.url);
|
||||
|
||||
if (existing) {
|
||||
if (options?.setActive) {
|
||||
this.setActiveServer(existing.id);
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = this.addServer(server);
|
||||
|
||||
if (options?.setActive) {
|
||||
this.setActiveServer(created.id);
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
findServerByUrl(url: string): ServerEndpoint | undefined {
|
||||
const sanitisedUrl = this.sanitiseUrl(url);
|
||||
|
||||
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
|
||||
}
|
||||
|
||||
removeServer(endpointId: string): void {
|
||||
const endpoints = this._servers();
|
||||
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
|
||||
|
||||
if (!target || endpoints.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.isDefault) {
|
||||
this.markDefaultEndpointRemoved(target);
|
||||
}
|
||||
|
||||
const updatedEndpoints = ensureAnyActiveEndpoint(
|
||||
endpoints.filter((endpoint) => endpoint.id !== endpointId)
|
||||
);
|
||||
|
||||
this._servers.set(updatedEndpoints);
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
restoreDefaultServers(): ServerEndpoint[] {
|
||||
const restoredEndpoints = this.defaultEndpoints
|
||||
.filter((defaultEndpoint) => !hasEndpointForDefault(this._servers(), defaultEndpoint))
|
||||
.map((defaultEndpoint) => ({
|
||||
...defaultEndpoint,
|
||||
id: uuidv4(),
|
||||
isActive: true
|
||||
}));
|
||||
|
||||
if (restoredEndpoints.length === 0) {
|
||||
this.storage.clearRemovedDefaultEndpointKeys();
|
||||
return [];
|
||||
}
|
||||
|
||||
this._servers.update((endpoints) => ensureAnyActiveEndpoint([...endpoints, ...restoredEndpoints]));
|
||||
this.storage.clearRemovedDefaultEndpointKeys();
|
||||
this.saveEndpoints();
|
||||
return restoredEndpoints;
|
||||
}
|
||||
|
||||
setActiveServer(endpointId: string): void {
|
||||
this._servers.update((endpoints) => {
|
||||
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
|
||||
|
||||
if (!target || target.status === 'incompatible') {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
return endpoints.map((endpoint) =>
|
||||
endpoint.id === endpointId
|
||||
? { ...endpoint, isActive: true }
|
||||
: endpoint
|
||||
);
|
||||
});
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
deactivateServer(endpointId: string): void {
|
||||
if (this.activeServers().length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._servers.update((endpoints) =>
|
||||
endpoints.map((endpoint) =>
|
||||
endpoint.id === endpointId
|
||||
? { ...endpoint, isActive: false }
|
||||
: endpoint
|
||||
)
|
||||
);
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
updateServerStatus(
|
||||
endpointId: string,
|
||||
status: ServerEndpoint['status'],
|
||||
latency?: number,
|
||||
versions?: ServerEndpointVersions
|
||||
): void {
|
||||
this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => {
|
||||
if (endpoint.id !== endpointId) {
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
return {
|
||||
...endpoint,
|
||||
status,
|
||||
latency,
|
||||
isActive: status === 'incompatible' ? false : endpoint.isActive,
|
||||
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
|
||||
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
|
||||
};
|
||||
})));
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
private loadEndpoints(): void {
|
||||
const storedEndpoints = this.storage.loadEndpoints();
|
||||
|
||||
if (!storedEndpoints) {
|
||||
this.initialiseDefaultEndpoints();
|
||||
return;
|
||||
}
|
||||
|
||||
this._servers.set(this.reconcileStoredEndpoints(storedEndpoints));
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
private initialiseDefaultEndpoints(): void {
|
||||
this._servers.set(this.defaultEndpoints.map((endpoint) => ({
|
||||
...endpoint,
|
||||
id: uuidv4()
|
||||
})));
|
||||
|
||||
this.saveEndpoints();
|
||||
}
|
||||
|
||||
private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] {
|
||||
const reconciled: ServerEndpoint[] = [];
|
||||
const claimedDefaultKeys = new Set<string>();
|
||||
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
|
||||
|
||||
for (const endpoint of storedEndpoints) {
|
||||
if (!endpoint || typeof endpoint.id !== 'string' || typeof endpoint.url !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sanitisedUrl = this.sanitiseUrl(endpoint.url);
|
||||
const matchedDefault = matchDefaultEndpointTemplate(
|
||||
this.defaultEndpoints,
|
||||
endpoint,
|
||||
sanitisedUrl,
|
||||
claimedDefaultKeys
|
||||
);
|
||||
|
||||
if (matchedDefault) {
|
||||
claimedDefaultKeys.add(matchedDefault.defaultKey);
|
||||
reconciled.push({
|
||||
...endpoint,
|
||||
name: matchedDefault.name,
|
||||
url: matchedDefault.url,
|
||||
isDefault: true,
|
||||
defaultKey: matchedDefault.defaultKey,
|
||||
status: endpoint.status ?? 'unknown'
|
||||
});
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
reconciled.push({
|
||||
...endpoint,
|
||||
url: sanitisedUrl,
|
||||
status: endpoint.status ?? 'unknown'
|
||||
});
|
||||
}
|
||||
|
||||
for (const defaultEndpoint of this.defaultEndpoints) {
|
||||
if (
|
||||
!claimedDefaultKeys.has(defaultEndpoint.defaultKey)
|
||||
&& !removedDefaultKeys.has(defaultEndpoint.defaultKey)
|
||||
&& !hasEndpointForDefault(reconciled, defaultEndpoint)
|
||||
) {
|
||||
reconciled.push({
|
||||
...defaultEndpoint,
|
||||
id: uuidv4(),
|
||||
isActive: defaultEndpoint.isActive
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ensureAnyActiveEndpoint(reconciled);
|
||||
}
|
||||
|
||||
private markDefaultEndpointRemoved(endpoint: ServerEndpoint): void {
|
||||
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
|
||||
|
||||
if (!defaultKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
|
||||
|
||||
removedDefaultKeys.add(defaultKey);
|
||||
this.storage.saveRemovedDefaultEndpointKeys(removedDefaultKeys);
|
||||
}
|
||||
|
||||
private saveEndpoints(): void {
|
||||
this.storage.saveEndpoints(this._servers());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users';
|
||||
@@ -0,0 +1,133 @@
|
||||
export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible';
|
||||
|
||||
export interface ServerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
topic?: string;
|
||||
hostName: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
ownerPublicKey?: string;
|
||||
userCount: number;
|
||||
maxUsers: number;
|
||||
hasPassword?: boolean;
|
||||
isPrivate: boolean;
|
||||
tags?: string[];
|
||||
createdAt: number;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
sourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface ConfiguredDefaultServerDefinition {
|
||||
key?: string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface DefaultServerDefinition {
|
||||
key: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ServerEndpointVersions {
|
||||
serverVersion?: string | null;
|
||||
clientVersion?: string | null;
|
||||
}
|
||||
|
||||
export interface ServerEndpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
defaultKey?: string;
|
||||
status: ServerEndpointStatus;
|
||||
latency?: number;
|
||||
serverVersion?: string;
|
||||
clientVersion?: string;
|
||||
}
|
||||
|
||||
export type DefaultEndpointTemplate = Omit<ServerEndpoint, 'id' | 'defaultKey'> & {
|
||||
defaultKey: string;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ServerVersionCompatibilityResult {
|
||||
isCompatible: boolean;
|
||||
serverVersion: string | null;
|
||||
}
|
||||
|
||||
export interface ServerHealthCheckPayload {
|
||||
serverVersion?: unknown;
|
||||
}
|
||||
|
||||
export interface ServerEndpointHealthResult {
|
||||
status: ServerEndpointStatus;
|
||||
latency?: number;
|
||||
versions?: ServerEndpointVersions;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
ConfiguredDefaultServerDefinition,
|
||||
DefaultEndpointTemplate,
|
||||
DefaultServerDefinition,
|
||||
ServerEndpoint
|
||||
} from './server-directory.models';
|
||||
|
||||
export function sanitiseServerBaseUrl(rawUrl: string): string {
|
||||
let cleaned = rawUrl.trim().replace(/\/+$/, '');
|
||||
|
||||
if (cleaned.toLowerCase().endsWith('/api')) {
|
||||
cleaned = cleaned.slice(0, -4);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function normaliseConfiguredServerUrl(
|
||||
rawUrl: string,
|
||||
defaultProtocol: 'http' | 'https'
|
||||
): string {
|
||||
let cleaned = rawUrl.trim();
|
||||
|
||||
if (!cleaned) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (cleaned.toLowerCase().startsWith('ws://')) {
|
||||
cleaned = `http://${cleaned.slice(5)}`;
|
||||
} else if (cleaned.toLowerCase().startsWith('wss://')) {
|
||||
cleaned = `https://${cleaned.slice(6)}`;
|
||||
} else if (cleaned.startsWith('//')) {
|
||||
cleaned = `${defaultProtocol}:${cleaned}`;
|
||||
} else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) {
|
||||
cleaned = `${defaultProtocol}://${cleaned}`;
|
||||
}
|
||||
|
||||
return sanitiseServerBaseUrl(cleaned);
|
||||
}
|
||||
|
||||
export function buildFallbackDefaultServerUrl(
|
||||
configuredUrl: string | undefined,
|
||||
defaultProtocol: 'http' | 'https'
|
||||
): string {
|
||||
if (configuredUrl?.trim()) {
|
||||
return normaliseConfiguredServerUrl(configuredUrl, defaultProtocol);
|
||||
}
|
||||
|
||||
return `${defaultProtocol}://localhost:3001`;
|
||||
}
|
||||
|
||||
export function buildDefaultServerDefinitions(
|
||||
configuredDefaults: ConfiguredDefaultServerDefinition[] | undefined,
|
||||
configuredUrl: string | undefined,
|
||||
defaultProtocol: 'http' | 'https'
|
||||
): DefaultServerDefinition[] {
|
||||
const seenKeys = new Set<string>();
|
||||
const seenUrls = new Set<string>();
|
||||
const definitions = (configuredDefaults ?? [])
|
||||
.map((server, index) => {
|
||||
const key = server.key?.trim() || `default-${index + 1}`;
|
||||
const url = normaliseConfiguredServerUrl(server.url ?? '', defaultProtocol);
|
||||
|
||||
if (!key || !url || seenKeys.has(key) || seenUrls.has(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
seenKeys.add(key);
|
||||
seenUrls.add(url);
|
||||
|
||||
return {
|
||||
key,
|
||||
name: server.name?.trim() || (index === 0 ? 'Default Server' : `Default Server ${index + 1}`),
|
||||
url
|
||||
} satisfies DefaultServerDefinition;
|
||||
})
|
||||
.filter((definition): definition is DefaultServerDefinition => definition !== null);
|
||||
|
||||
if (definitions.length > 0) {
|
||||
return definitions;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'default',
|
||||
name: 'Default Server',
|
||||
url: buildFallbackDefaultServerUrl(configuredUrl, defaultProtocol)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export function buildDefaultEndpointTemplates(
|
||||
definitions: DefaultServerDefinition[]
|
||||
): DefaultEndpointTemplate[] {
|
||||
return definitions.map((definition) => ({
|
||||
name: definition.name,
|
||||
url: definition.url,
|
||||
isActive: true,
|
||||
isDefault: true,
|
||||
defaultKey: definition.key,
|
||||
status: 'unknown'
|
||||
}));
|
||||
}
|
||||
|
||||
export function hasEndpointForDefault(
|
||||
endpoints: ServerEndpoint[],
|
||||
defaultEndpoint: DefaultEndpointTemplate
|
||||
): boolean {
|
||||
return endpoints.some((endpoint) =>
|
||||
endpoint.defaultKey === defaultEndpoint.defaultKey
|
||||
|| sanitiseServerBaseUrl(endpoint.url) === defaultEndpoint.url
|
||||
);
|
||||
}
|
||||
|
||||
export function matchDefaultEndpointTemplate(
|
||||
defaultEndpoints: DefaultEndpointTemplate[],
|
||||
endpoint: ServerEndpoint,
|
||||
sanitisedUrl: string,
|
||||
claimedDefaultKeys: Set<string>
|
||||
): DefaultEndpointTemplate | null {
|
||||
if (endpoint.defaultKey) {
|
||||
return defaultEndpoints.find(
|
||||
(candidate) => candidate.defaultKey === endpoint.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey)
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
if (!endpoint.isDefault) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const matchingCurrentDefault = defaultEndpoints.find(
|
||||
(candidate) => candidate.url === sanitisedUrl && !claimedDefaultKeys.has(candidate.defaultKey)
|
||||
);
|
||||
|
||||
if (matchingCurrentDefault) {
|
||||
return matchingCurrentDefault;
|
||||
}
|
||||
|
||||
return defaultEndpoints.find(
|
||||
(candidate) => !claimedDefaultKeys.has(candidate.defaultKey)
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function findDefaultEndpointKeyByUrl(
|
||||
defaultEndpoints: DefaultEndpointTemplate[],
|
||||
url: string
|
||||
): string | null {
|
||||
const sanitisedUrl = sanitiseServerBaseUrl(url);
|
||||
|
||||
return defaultEndpoints.find((endpoint) => endpoint.url === sanitisedUrl)?.defaultKey ?? null;
|
||||
}
|
||||
|
||||
export function ensureAnyActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] {
|
||||
if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
const nextEndpoints = [...endpoints];
|
||||
|
||||
nextEndpoints[0] = {
|
||||
...nextEndpoints[0],
|
||||
isActive: true
|
||||
};
|
||||
|
||||
return nextEndpoints;
|
||||
}
|
||||
|
||||
export function ensureCompatibleActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] {
|
||||
if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
const fallbackIndex = endpoints.findIndex((endpoint) => endpoint.status !== 'incompatible');
|
||||
|
||||
if (fallbackIndex < 0) {
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
const nextEndpoints = [...endpoints];
|
||||
|
||||
nextEndpoints[fallbackIndex] = {
|
||||
...nextEndpoints[fallbackIndex],
|
||||
isActive: true
|
||||
};
|
||||
|
||||
return nextEndpoints;
|
||||
}
|
||||
@@ -8,13 +8,14 @@ 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';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import type { ServerInviteInfo } from '../../domain/server-directory.models';
|
||||
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../application/server-directory.facade';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite',
|
||||
@@ -31,7 +32,7 @@ export class InviteComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly serverDirectory = inject(ServerDirectoryService);
|
||||
private readonly serverDirectory = inject(ServerDirectoryFacade);
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
@@ -26,24 +26,21 @@ import {
|
||||
lucideSettings
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { RoomsActions } from '../../store/rooms/rooms.actions';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import {
|
||||
selectSearchResults,
|
||||
selectIsSearching,
|
||||
selectRoomsError,
|
||||
selectSavedRooms
|
||||
} from '../../store/rooms/rooms.selectors';
|
||||
import {
|
||||
Room,
|
||||
ServerInfo,
|
||||
User
|
||||
} 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';
|
||||
} from '../../../../store/rooms/rooms.selectors';
|
||||
import { Room, User } from '../../../../shared-kernel';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { type ServerInfo } from '../../domain/server-directory.models';
|
||||
import { ServerDirectoryFacade } from '../../application/server-directory.facade';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { hasRoomBanForUser } from '../../../../core/helpers/room-ban.helpers';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-search',
|
||||
@@ -75,7 +72,7 @@ export class ServerSearchComponent implements OnInit {
|
||||
private router = inject(Router);
|
||||
private settingsModal = inject(SettingsModalService);
|
||||
private db = inject(DatabaseService);
|
||||
private serverDirectory = inject(ServerDirectoryService);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private searchSubject = new Subject<string>();
|
||||
private banLookupRequestVersion = 0;
|
||||
|
||||
3
src/app/domains/server-directory/index.ts
Normal file
3
src/app/domains/server-directory/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './application/server-directory.facade';
|
||||
export * from './domain/server-directory.constants';
|
||||
export * from './domain/server-directory.models';
|
||||
@@ -0,0 +1,405 @@
|
||||
/* eslint-disable @typescript-eslint/no-invalid-void-type */
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import {
|
||||
Observable,
|
||||
forkJoin,
|
||||
of,
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { catchError, map } from 'rxjs/operators';
|
||||
import { User } from '../../../shared-kernel';
|
||||
import { ServerEndpointStateService } from '../application/server-endpoint-state.service';
|
||||
import type {
|
||||
BanServerMemberRequest,
|
||||
CreateServerInviteRequest,
|
||||
KickServerMemberRequest,
|
||||
ServerEndpoint,
|
||||
ServerInfo,
|
||||
ServerInviteInfo,
|
||||
ServerJoinAccessRequest,
|
||||
ServerJoinAccessResponse,
|
||||
ServerSourceSelector,
|
||||
UnbanServerMemberRequest
|
||||
} from '../domain/server-directory.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ServerDirectoryApiService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly endpointState = inject(ServerEndpointStateService);
|
||||
|
||||
getApiBaseUrl(selector?: ServerSourceSelector): string {
|
||||
return `${this.resolveBaseServerUrl(selector)}/api`;
|
||||
}
|
||||
|
||||
getWebSocketUrl(selector?: ServerSourceSelector): string {
|
||||
return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null {
|
||||
if (selector?.sourceId) {
|
||||
return this.endpointState.servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null;
|
||||
}
|
||||
|
||||
if (selector?.sourceUrl) {
|
||||
return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null;
|
||||
}
|
||||
|
||||
return this.endpointState.activeServer()
|
||||
?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible')
|
||||
?? this.endpointState.servers()[0]
|
||||
?? null;
|
||||
}
|
||||
|
||||
searchServers(query: string, shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
|
||||
if (shouldSearchAllServers) {
|
||||
return this.searchAllEndpoints(query);
|
||||
}
|
||||
|
||||
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
||||
}
|
||||
|
||||
getServers(shouldSearchAllServers: boolean): Observable<ServerInfo[]> {
|
||||
if (shouldSearchAllServers) {
|
||||
return this.getAllServersFromAllEndpoints();
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
|
||||
return this.http
|
||||
.get<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))),
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
registerServer(
|
||||
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.post<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers`, server)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to register server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateServer(
|
||||
serverId: string,
|
||||
updates: Partial<ServerInfo> & {
|
||||
currentOwnerId: string;
|
||||
actingRole?: string;
|
||||
password?: string | null;
|
||||
},
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInfo> {
|
||||
return this.http
|
||||
.put<ServerInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http
|
||||
.delete<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unregister server:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
|
||||
return this.http
|
||||
.get<User[]>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get server users:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
requestJoin(
|
||||
request: ServerJoinAccessRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerJoinAccessResponse> {
|
||||
return this.http
|
||||
.post<ServerJoinAccessResponse>(
|
||||
`${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`,
|
||||
request
|
||||
)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send join request:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
createInvite(
|
||||
serverId: string,
|
||||
request: CreateServerInviteRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<ServerInviteInfo> {
|
||||
return this.http
|
||||
.post<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to create invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
|
||||
return this.http
|
||||
.get<ServerInviteInfo>(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to get invite:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
kickServerMember(
|
||||
serverId: string,
|
||||
request: KickServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to kick server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
banServerMember(
|
||||
serverId: string,
|
||||
request: BanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to ban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
unbanServerMember(
|
||||
serverId: string,
|
||||
request: UnbanServerMemberRequest,
|
||||
selector?: ServerSourceSelector
|
||||
): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to unban server member:', error);
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to notify leave:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateUserCount(serverId: string, count: number): Observable<void> {
|
||||
return this.http
|
||||
.patch<void>(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count })
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to update user count:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
sendHeartbeat(serverId: string): Observable<void> {
|
||||
return this.http
|
||||
.post<void>(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {})
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
return of(undefined);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private resolveBaseServerUrl(selector?: ServerSourceSelector): string {
|
||||
if (selector?.sourceUrl) {
|
||||
return this.endpointState.sanitiseUrl(selector.sourceUrl);
|
||||
}
|
||||
|
||||
return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl();
|
||||
}
|
||||
|
||||
private unwrapServersResponse(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[]
|
||||
): ServerInfo[] {
|
||||
return Array.isArray(response)
|
||||
? response
|
||||
: (response.servers ?? []);
|
||||
}
|
||||
|
||||
private searchSingleEndpoint(
|
||||
query: string,
|
||||
apiBaseUrl: string,
|
||||
source?: ServerEndpoint | null
|
||||
): Observable<ServerInfo[]> {
|
||||
const params = new HttpParams().set('q', query);
|
||||
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params })
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, source)),
|
||||
catchError((error) => {
|
||||
console.error('Failed to search servers:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private searchAllEndpoints(query: string): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer());
|
||||
}
|
||||
|
||||
return forkJoin(
|
||||
onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint))
|
||||
).pipe(
|
||||
map((resultArrays) => resultArrays.flat()),
|
||||
map((servers) => this.deduplicateById(servers))
|
||||
);
|
||||
}
|
||||
|
||||
private getAllServersFromAllEndpoints(): Observable<ServerInfo[]> {
|
||||
const onlineEndpoints = this.endpointState.activeServers().filter(
|
||||
(endpoint) => endpoint.status !== 'offline'
|
||||
);
|
||||
|
||||
if (onlineEndpoints.length === 0) {
|
||||
return this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
||||
catchError(() => of([]))
|
||||
);
|
||||
}
|
||||
|
||||
return forkJoin(
|
||||
onlineEndpoints.map((endpoint) =>
|
||||
this.http
|
||||
.get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`)
|
||||
.pipe(
|
||||
map((response) => this.normalizeServerList(response, endpoint)),
|
||||
catchError(() => of([] as ServerInfo[]))
|
||||
)
|
||||
)
|
||||
).pipe(map((resultArrays) => resultArrays.flat()));
|
||||
}
|
||||
|
||||
private deduplicateById<T extends { id: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeServerList(
|
||||
response: { servers: ServerInfo[]; total: number } | ServerInfo[],
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo[] {
|
||||
return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source));
|
||||
}
|
||||
|
||||
private normalizeServerInfo(
|
||||
server: ServerInfo | Record<string, unknown>,
|
||||
source?: ServerEndpoint | null
|
||||
): ServerInfo {
|
||||
const candidate = server as Record<string, unknown>;
|
||||
const sourceName = this.getStringValue(candidate['sourceName']);
|
||||
const sourceUrl = this.getStringValue(candidate['sourceUrl']);
|
||||
|
||||
return {
|
||||
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: this.getNumberValue(candidate['createdAt'], Date.now()),
|
||||
sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id,
|
||||
sourceName: sourceName ?? source?.name,
|
||||
sourceUrl: sourceUrl
|
||||
? this.endpointState.sanitiseUrl(sourceUrl)
|
||||
: (source ? this.endpointState.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;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user