Merge pull request 'Restructure' (#9) from maybe-ddd into main
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 16m15s
Queue Release Build / build-linux (push) Successful in 37m23s
Queue Release Build / build-windows (push) Successful in 28m39s
Queue Release Build / finalize (push) Successful in 2m7s

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-03-30 02:56:34 +00:00
334 changed files with 13374 additions and 5891 deletions

View File

@@ -67,8 +67,10 @@ jobs:
- name: Build application
run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
cd toju-app
npx ng build --configuration production --base-href='./'
cd ..
npx --package typescript tsc -p tsconfig.electron.json
cd server
node ../tools/sync-server-build-version.js
@@ -120,8 +122,10 @@ jobs:
- name: Build application
run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
Push-Location "toju-app"
npx ng build --configuration production --base-href='./'
Pop-Location
npx --package typescript tsc -p tsconfig.electron.json
Push-Location server
node ../tools/sync-server-build-version.js

7
.gitignore vendored
View File

@@ -6,7 +6,9 @@
/tmp
/out-tsc
/bazel-out
*.sqlite
*/architecture.md
/docs
# Node
/node_modules
npm-debug.log
@@ -51,3 +53,6 @@ Thumbs.db
.certs/
/server/data/variables.json
dist-server/*
AGENTS.md
doc/**

View File

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

4
dev.sh
View File

@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
"$DIR/generate-cert.sh"
fi
NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key"
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
WAIT_URL="https://localhost:4200"
HEALTH_URL="https://localhost:3001/api/health"
export NODE_TLS_REJECT_UNAUTHORIZED=0
else
NG_SERVE="ng serve --host=0.0.0.0"
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
WAIT_URL="http://localhost:4200"
HEALTH_URL="http://localhost:3001/api/health"
fi

View File

@@ -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;
}

View File

@@ -7,7 +7,13 @@ import {
destroyDatabase,
getDataSource
} from '../db/database';
import { createWindow, getDockIconPath } from '../window/create-window';
import {
createWindow,
getDockIconPath,
getMainWindow,
prepareWindowForAppQuit,
showMainWindow
} from '../window/create-window';
import {
setupCqrsHandlers,
setupSystemHandlers,
@@ -30,8 +36,13 @@ export function registerAppLifecycle(): void {
await createWindow();
app.on('activate', () => {
if (getMainWindow()) {
void showMainWindow();
return;
}
if (BrowserWindow.getAllWindows().length === 0)
createWindow();
void createWindow();
});
});
@@ -41,6 +52,8 @@ export function registerAppLifecycle(): void {
});
app.on('before-quit', async (event) => {
prepareWindowForAppQuit();
if (getDataSource()?.isInitialized) {
event.preventDefault();
shutdownDesktopUpdater();

View File

@@ -0,0 +1,18 @@
import { DataSource, MoreThan } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesSinceQuery } from '../../types';
import { rowToMessage } from '../../mappers';
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, sinceTimestamp } = query.payload;
const rows = await repo.find({
where: {
roomId,
timestamp: MoreThan(sinceTimestamp)
},
order: { timestamp: 'ASC' }
});
return rows.map(rowToMessage);
}

View File

@@ -4,6 +4,7 @@ import {
QueryTypeKey,
Query,
GetMessagesQuery,
GetMessagesSinceQuery,
GetMessageByIdQuery,
GetReactionsForMessageQuery,
GetUserQuery,
@@ -13,6 +14,7 @@ import {
GetAttachmentsForMessageQuery
} from '../types';
import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessagesSince } from './handlers/getMessagesSince';
import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser';
@@ -27,6 +29,7 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),

View File

@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
export const QueryType = {
GetMessages: 'get-messages',
GetMessagesSince: 'get-messages-since',
GetMessageById: 'get-message-by-id',
GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user',
@@ -160,6 +161,7 @@ export type Command =
| ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
@@ -174,6 +176,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
export type Query =
| GetMessagesQuery
| GetMessagesSinceQuery
| GetMessageByIdQuery
| GetReactionsForMessageQuery
| GetUserQuery

View File

@@ -7,6 +7,7 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
export interface DesktopSettings {
autoUpdateMode: AutoUpdateMode;
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
@@ -21,6 +22,7 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoUpdateMode: 'auto',
autoStart: true,
closeToTray: true,
hardwareAcceleration: true,
manifestUrls: [],
preferredVersion: null,
@@ -86,6 +88,9 @@ export function readDesktopSettings(): DesktopSettings {
autoStart: typeof parsed.autoStart === 'boolean'
? parsed.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof parsed.closeToTray === 'boolean'
? parsed.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
? parsed.vaapiVideoEncode
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
@@ -110,6 +115,9 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
autoStart: typeof mergedSettings.autoStart === 'boolean'
? mergedSettings.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
? mergedSettings.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,

View File

@@ -4,6 +4,7 @@ import {
desktopCapturer,
dialog,
ipcMain,
Notification,
shell
} from 'electron';
import * as fs from 'fs';
@@ -28,10 +29,16 @@ import {
getDesktopUpdateState,
handleDesktopSettingsChanged,
restartToApplyUpdate,
readDesktopUpdateServerHealth,
type DesktopUpdateServerContext
} from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start';
import {
getMainWindow,
getWindowIconPath,
updateCloseToTraySetting
} from '../window/create-window';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [
@@ -85,6 +92,12 @@ interface ClipboardFilePayload {
path?: string;
}
interface DesktopNotificationPayload {
body: string;
requestAttention?: boolean;
title: string;
}
function resolveLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
@@ -315,8 +328,75 @@ export function setupSystemHandlers(): void {
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
const body = typeof payload?.body === 'string' ? payload.body : '';
const mainWindow = getMainWindow();
if (!title) {
return false;
}
if (Notification.isSupported()) {
try {
const notification = new Notification({
title,
body,
icon: getWindowIconPath(),
silent: true
});
notification.on('click', () => {
if (!mainWindow) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
});
notification.show();
} catch {
// Ignore notification center failures and still attempt taskbar attention.
}
}
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
mainWindow.flashFrame(true);
}
return true;
});
ipcMain.handle('request-window-attention', () => {
const mainWindow = getMainWindow();
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
return false;
}
mainWindow.flashFrame(true);
return true;
});
ipcMain.handle('clear-window-attention', () => {
getMainWindow()?.flashFrame(false);
return true;
});
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {
return await readDesktopUpdateServerHealth(serverUrl);
});
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
return await configureDesktopUpdaterContext(context);
});
@@ -331,6 +411,7 @@ export function setupSystemHandlers(): void {
const snapshot = updateDesktopSettings(patch);
await synchronizeAutoStartSetting(snapshot.autoStart);
updateCloseToTraySetting(snapshot.closeToTray);
await handleDesktopSettingsChanged();
return snapshot;
});

View File

@@ -5,6 +5,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monit
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
@@ -50,6 +51,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: 'auto' | 'off' | 'version';
availableVersions: string[];
@@ -84,6 +91,17 @@ export interface DesktopUpdateState {
targetVersion: string | null;
}
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: boolean;
}
function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
@@ -120,13 +138,19 @@ export interface ElectronAPI {
getDesktopSettings: () => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>;
@@ -134,6 +158,7 @@ export interface ElectronAPI {
setDesktopSettings: (patch: {
autoUpdateMode?: 'auto' | 'off' | 'version';
autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean;
manifestUrls?: string[];
preferredVersion?: string | null;
@@ -141,6 +166,7 @@ export interface ElectronAPI {
}) => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
@@ -206,7 +232,22 @@ const electronAPI: ElectronAPI = {
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'),
clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'),
onWindowStateChanged: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => {
listener(state);
};
ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
};
},
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),

View File

@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
version: string;
}
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface UpdateVersionInfo {
version: string;
}
@@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
@@ -78,6 +89,8 @@ export interface DesktopUpdateState {
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const SERVER_HEALTH_TIMEOUT_MS = 5_000;
let currentCheckPromise: Promise<void> | null = null;
let currentContext: DesktopUpdateServerContext = {
manifestUrls: [],
@@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
return parseReleaseManifest(payload);
}
function createUnavailableServerHealthSnapshot(): DesktopUpdateServerHealthSnapshot {
return {
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
async function loadServerHealth(serverUrl: string): Promise<DesktopUpdateServerHealthSnapshot> {
const sanitizedServerUrl = sanitizeHttpUrl(serverUrl);
if (!sanitizedServerUrl) {
return createUnavailableServerHealthSnapshot();
}
try {
const response = await net.fetch(`${sanitizedServerUrl}/api/health`, {
method: 'GET',
headers: {
accept: 'application/json'
},
signal: AbortSignal.timeout(SERVER_HEALTH_TIMEOUT_MS)
});
if (!response.ok) {
return createUnavailableServerHealthSnapshot();
}
const payload = await response.json() as ServerHealthResponse;
const serverVersion = normalizeSemanticVersion(payload.serverVersion);
return {
manifestUrl: sanitizeHttpUrl(payload.releaseManifestUrl),
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
};
} catch {
return createUnavailableServerHealthSnapshot();
}
}
function formatManifestLoadErrors(errors: string[]): string {
if (errors.length === 0) {
return 'No valid release manifest could be loaded.';
@@ -724,6 +778,12 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
return desktopUpdateState;
}
export async function readDesktopUpdateServerHealth(
serverUrl: string
): Promise<DesktopUpdateServerHealthSnapshot> {
return await loadServerHealth(serverUrl);
}
export function restartToApplyUpdate(): boolean {
if (!desktopUpdateState.restartRequired) {
return false;

View File

@@ -2,13 +2,21 @@ import {
app,
BrowserWindow,
desktopCapturer,
Menu,
session,
shell
shell,
Tray
} from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { readDesktopSettings } from '../desktop-settings';
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let closeToTrayEnabled = true;
let appQuitting = false;
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
function getAssetPath(...segments: string[]): string {
const basePath = app.isPackaged
@@ -38,13 +46,124 @@ export function getDockIconPath(): string | undefined {
return getExistingAssetPath('macos', '1024x1024.png');
}
function getTrayIconPath(): string | undefined {
if (process.platform === 'win32')
return getExistingAssetPath('windows', 'icon.ico');
return getExistingAssetPath('icon.png');
}
export { getWindowIconPath };
export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
function destroyTray(): void {
if (!tray) {
return;
}
tray.destroy();
tray = null;
}
function requestAppQuit(): void {
prepareWindowForAppQuit();
app.quit();
}
function ensureTray(): void {
if (tray) {
return;
}
const trayIconPath = getTrayIconPath();
if (!trayIconPath) {
return;
}
tray = new Tray(trayIconPath);
tray.setToolTip('MetoYou');
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: 'Open MetoYou',
click: () => {
void showMainWindow();
}
},
{
type: 'separator'
},
{
label: 'Close MetoYou',
click: () => {
requestAppQuit();
}
}
])
);
tray.on('click', () => {
void showMainWindow();
});
}
function hideWindowToTray(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.hide();
emitWindowState();
}
export function updateCloseToTraySetting(enabled: boolean): void {
closeToTrayEnabled = enabled;
}
export function prepareWindowForAppQuit(): void {
appQuitting = true;
destroyTray();
}
export async function showMainWindow(): Promise<void> {
if (!mainWindow || mainWindow.isDestroyed()) {
await createWindow();
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
emitWindowState();
}
function emitWindowState(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
isFocused: mainWindow.isFocused(),
isMinimized: mainWindow.isMinimized()
});
}
export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath();
closeToTrayEnabled = readDesktopSettings().closeToTray;
ensureTray();
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
@@ -105,10 +224,46 @@ export async function createWindow(): Promise<void> {
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
}
mainWindow.on('close', (event) => {
if (appQuitting || !closeToTrayEnabled) {
return;
}
event.preventDefault();
hideWindowToTray();
});
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.on('focus', () => {
mainWindow?.flashFrame(false);
emitWindowState();
});
mainWindow.on('blur', () => {
emitWindowState();
});
mainWindow.on('minimize', () => {
emitWindowState();
});
mainWindow.on('restore', () => {
emitWindowState();
});
mainWindow.on('show', () => {
emitWindowState();
});
mainWindow.on('hide', () => {
emitWindowState();
});
emitWindowState();
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };

View File

@@ -199,7 +199,7 @@ module.exports = tseslint.config(
},
// HTML template formatting rules (external Angular templates only)
{
files: ['src/app/**/*.html'],
files: ['toju-app/src/app/**/*.html'],
plugins: { 'no-dashes': noDashPlugin },
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: {

View File

@@ -7,22 +7,22 @@
"homepage": "https://git.azaaxin.com/myxelium/Toju",
"main": "dist/electron/main.js",
"scripts": {
"ng": "ng",
"ng": "cd \"toju-app\" && ng",
"prebuild": "npm run bundle:rnnoise",
"prestart": "npm run bundle:rnnoise",
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
"start": "ng serve",
"build": "ng build",
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
"start": "cd \"toju-app\" && ng serve",
"build": "cd \"toju-app\" && ng build",
"build:electron": "tsc -p tsconfig.electron.json",
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
"build:prod": "ng build --configuration production --base-href='./'",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
"test": "cd \"toju-app\" && ng test",
"server:build": "cd server && npm run build",
"server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev",
"electron": "ng build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"npm run start\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"electron:full": "./dev.sh",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
@@ -40,8 +40,8 @@
"dev:app": "npm run electron:dev",
"lint": "eslint .",
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
"format": "prettier --write \"src/app/**/*.html\"",
"format:check": "prettier --check \"src/app/**/*.html\"",
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
"format:check": "prettier --check \"toju-app/src/app/**/*.html\"",
"release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux",
"release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
"release:manifest": "node tools/generate-release-manifest.js",

Binary file not shown.

View File

@@ -16,6 +16,7 @@ export async function handleUpsertServer(command: UpsertServerCommand, dataSourc
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
tags: JSON.stringify(server.tags),
channels: JSON.stringify(server.channels ?? []),
createdAt: server.createdAt,
lastSeen: server.lastSeen
});

View File

@@ -3,10 +3,67 @@ import { ServerEntity } from '../entities/ServerEntity';
import { JoinRequestEntity } from '../entities/JoinRequestEntity';
import {
AuthUserPayload,
ServerChannelPayload,
ServerPayload,
JoinRequestPayload
} from './types';
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`;
}
function parseStringArray(raw: string | null | undefined): string[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === 'string')
: [];
} catch {
return [];
}
}
function parseServerChannels(raw: string | null | undefined): ServerChannelPayload[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies ServerChannelPayload;
})
.filter((channel): channel is ServerChannelPayload => !!channel);
} catch {
return [];
}
}
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return {
id: row.id,
@@ -29,7 +86,8 @@ export function rowToServer(row: ServerEntity): ServerPayload {
isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers,
currentUsers: row.currentUsers,
tags: JSON.parse(row.tags || '[]'),
tags: parseStringArray(row.tags),
channels: parseServerChannels(row.channels),
createdAt: row.createdAt,
lastSeen: row.lastSeen
};

View File

@@ -28,6 +28,15 @@ export interface AuthUserPayload {
createdAt: number;
}
export type ServerChannelType = 'text' | 'voice';
export interface ServerChannelPayload {
id: string;
name: string;
type: ServerChannelType;
position: number;
}
export interface ServerPayload {
id: string;
name: string;
@@ -40,6 +49,7 @@ export interface ServerPayload {
maxUsers: number;
currentUsers: number;
tags: string[];
channels: ServerChannelPayload[];
createdAt: number;
lastSeen: number;
}

View File

@@ -36,6 +36,9 @@ export class ServerEntity {
@Column('text', { default: '[]' })
tags!: string;
@Column('text', { default: '[]' })
channels!: string;
@Column('integer')
createdAt!: number;

View File

@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
"maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0,
"tags" TEXT NOT NULL DEFAULT '[]',
"channels" TEXT NOT NULL DEFAULT '[]',
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL
)

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerChannels1000000000002 implements MigrationInterface {
name = 'ServerChannels1000000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
}
}

View File

@@ -0,0 +1,119 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
interface LegacyServerRow {
id: string;
channels: string | null;
}
interface LegacyServerChannel {
id: string;
name: string;
type: 'text' | 'voice';
position: number;
}
function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies LegacyServerChannel;
})
.filter((channel): channel is LegacyServerChannel => !!channel);
} catch {
return [];
}
}
function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean {
const hasTextGeneral = channels.some(
(channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general')
);
const hasVoiceAfk = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk')
);
const hasVoiceGeneral = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general')
);
return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral;
}
function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] {
if (!shouldRestoreLegacyVoiceGeneral(channels)) {
return channels;
}
const textChannels = channels.filter((channel) => channel.type === 'text');
const voiceChannels = channels.filter((channel) => channel.type === 'voice');
const repairedVoiceChannels = [
{
id: 'vc-general',
name: 'General',
type: 'voice' as const,
position: 0
},
...voiceChannels
].map((channel, index) => ({
...channel,
position: index
}));
return [
...textChannels,
...repairedVoiceChannels
];
}
export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface {
name = 'RepairLegacyVoiceChannels1000000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[];
for (const row of rows) {
const channels = normalizeLegacyChannels(row.channels);
const repaired = repairLegacyVoiceChannels(channels);
if (JSON.stringify(repaired) === JSON.stringify(channels)) {
continue;
}
await queryRunner.query(
`UPDATE "servers" SET "channels" = ? WHERE "id" = ?`,
[JSON.stringify(repaired), row.id]
);
}
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Forward-only data repair migration.
}
}

View File

@@ -1,7 +1,11 @@
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
export const serverMigrations = [
InitialSchema1000000000000,
ServerAccessControl1000000000001
ServerAccessControl1000000000001,
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003
];

View File

@@ -1,6 +1,9 @@
import { Response, Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { ServerPayload } from '../cqrs/types';
import {
ServerChannelPayload,
ServerPayload
} from '../cqrs/types';
import {
getAllPublicServers,
getServerById,
@@ -34,10 +37,51 @@ function normalizeRole(role: unknown): string | null {
return typeof role === 'string' ? role.trim().toLowerCase() : null;
}
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${name.toLocaleLowerCase()}`;
}
function isAllowedRole(role: string | null, allowedRoles: string[]): boolean {
return !!role && allowedRoles.includes(role);
}
function normalizeServerChannels(value: unknown): ServerChannelPayload[] {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set<string>();
const seenNames = new Set<string>();
const channels: ServerChannelPayload[] = [];
for (const [index, channel] of value.entries()) {
if (!channel || typeof channel !== 'object') {
continue;
}
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seen.has(id) || seenNames.has(nameKey)) {
continue;
}
seen.add(id);
seenNames.add(nameKey);
channels.push({
id,
name,
type,
position
});
}
return channels;
}
async function enrichServer(server: ServerPayload, sourceUrl?: string) {
const owner = await getUserById(server.ownerId);
const { passwordHash, ...publicServer } = server;
@@ -124,7 +168,8 @@ router.post('/', async (req, res) => {
isPrivate,
maxUsers,
password,
tags
tags,
channels
} = req.body;
if (!name || !ownerId || !ownerPublicKey)
@@ -143,6 +188,7 @@ router.post('/', async (req, res) => {
maxUsers: maxUsers ?? 0,
currentUsers: 0,
tags: tags ?? [],
channels: normalizeServerChannels(channels),
createdAt: Date.now(),
lastSeen: Date.now()
};
@@ -161,6 +207,7 @@ router.put('/:id', async (req, res) => {
password,
hasPassword: _ignoredHasPassword,
passwordHash: _ignoredPasswordHash,
channels,
...updates
} = req.body;
const existing = await getServerById(id);
@@ -178,10 +225,12 @@ router.put('/:id', async (req, res) => {
}
const hasPasswordUpdate = Object.prototype.hasOwnProperty.call(req.body, 'password');
const hasChannelsUpdate = Object.prototype.hasOwnProperty.call(req.body, 'channels');
const nextPasswordHash = hasPasswordUpdate ? passwordHashForInput(password) : (existing.passwordHash ?? null);
const server: ServerPayload = {
...existing,
...updates,
channels: hasChannelsUpdate ? normalizeServerChannels(channels) : existing.channels,
hasPassword: !!nextPasswordHash,
passwordHash: nextPasswordHash,
lastSeen: Date.now()

View File

@@ -134,11 +134,15 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
function handleTyping(user: ConnectedUser, message: WsMessage): void {
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim()
? message['channelId'].trim()
: 'general';
if (typingSid && user.serverIds.has(typingSid)) {
broadcastToServer(typingSid, {
type: 'user_typing',
serverId: typingSid,
channelId,
oderId: user.oderId,
displayName: user.displayName
}, user.oderId);

View File

@@ -1,320 +0,0 @@
export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
export type UserRole = 'host' | 'admin' | 'moderator' | 'member';
export type ChannelType = 'text' | 'voice';
export const DELETED_MESSAGE_CONTENT = '[Message deleted]';
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 interface RoomMember {
id: string;
oderId?: string;
username: string;
displayName: string;
avatarUrl?: string;
role: UserRole;
joinedAt: number;
lastSeenAt: number;
}
export interface Channel {
id: string;
name: string;
type: ChannelType;
position: number;
}
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 interface Reaction {
id: string;
messageId: string;
oderId: string;
userId: string;
emoji: string;
timestamp: number;
}
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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +0,0 @@
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';

View File

@@ -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

View File

@@ -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';

View File

@@ -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;
};

View File

@@ -1,29 +0,0 @@
.chat-textarea {
--textarea-bg: hsl(40deg 3.7% 15.9% / 87%);
background: var(--textarea-bg);
height: 62px;
min-height: 62px;
max-height: 520px;
overflow-y: hidden;
resize: none;
transition: height 0.12s ease;
&.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);
}
}

View File

@@ -1,47 +0,0 @@
<div class="space-y-6 max-w-xl">
<section>
<div class="flex items-center gap-2 mb-3">
<ng-icon
name="lucidePower"
class="w-5 h-5 text-muted-foreground"
/>
<h4 class="text-sm font-semibold text-foreground">Application</h4>
</div>
<div
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
[class.opacity-60]="!isElectron"
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-foreground">Launch on system startup</p>
@if (isElectron) {
<p class="text-xs text-muted-foreground">Automatically start MetoYou when you sign in</p>
} @else {
<p class="text-xs text-muted-foreground">This setting is only available in the desktop app.</p>
}
</div>
<label
class="relative inline-flex items-center"
[class.cursor-pointer]="isElectron && !savingAutoStart()"
[class.cursor-not-allowed]="!isElectron || savingAutoStart()"
>
<input
type="checkbox"
[checked]="autoStart()"
[disabled]="!isElectron || savingAutoStart()"
(change)="onAutoStartChange($event)"
id="general-auto-start-toggle"
aria-label="Toggle launch on startup"
class="sr-only peer"
/>
<div
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-disabled:bg-muted/80 peer-disabled:after:bg-muted-foreground/40 peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
></div>
</label>
</div>
</div>
</section>
</div>

View File

@@ -1,9 +0,0 @@
import { User } from '../../../core/models';
export interface ScreenShareWorkspaceStreamItem {
id: string;
peerKey: string;
user: User;
stream: MediaStream;
isLocal: boolean;
}

View File

@@ -1,5 +1,5 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"$schema": "../node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
@@ -62,27 +62,28 @@
],
"styles": [
"src/styles.scss",
"node_modules/prismjs/themes/prism-okaidia.css"
"../node_modules/prismjs/themes/prism-okaidia.css"
],
"scripts": [
"node_modules/prismjs/prism.js",
"node_modules/prismjs/components/prism-markup.min.js",
"node_modules/prismjs/components/prism-clike.min.js",
"node_modules/prismjs/components/prism-javascript.min.js",
"node_modules/prismjs/components/prism-typescript.min.js",
"node_modules/prismjs/components/prism-css.min.js",
"node_modules/prismjs/components/prism-scss.min.js",
"node_modules/prismjs/components/prism-json.min.js",
"node_modules/prismjs/components/prism-bash.min.js",
"node_modules/prismjs/components/prism-markdown.min.js",
"node_modules/prismjs/components/prism-yaml.min.js",
"node_modules/prismjs/components/prism-python.min.js",
"node_modules/prismjs/components/prism-csharp.min.js"
"../node_modules/prismjs/prism.js",
"../node_modules/prismjs/components/prism-markup.min.js",
"../node_modules/prismjs/components/prism-clike.min.js",
"../node_modules/prismjs/components/prism-javascript.min.js",
"../node_modules/prismjs/components/prism-typescript.min.js",
"../node_modules/prismjs/components/prism-css.min.js",
"../node_modules/prismjs/components/prism-scss.min.js",
"../node_modules/prismjs/components/prism-json.min.js",
"../node_modules/prismjs/components/prism-bash.min.js",
"../node_modules/prismjs/components/prism-markdown.min.js",
"../node_modules/prismjs/components/prism-yaml.min.js",
"../node_modules/prismjs/components/prism-python.min.js",
"../node_modules/prismjs/components/prism-csharp.min.js"
],
"allowedCommonJsDependencies": [
"simple-peer",
"uuid"
]
],
"outputPath": "../dist/client"
},
"configurations": {
"production": {
@@ -96,7 +97,7 @@
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
"maximumError": "2.1MB"
},
{
"type": "anyComponentStyle",

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -13,6 +13,7 @@ import { routes } from './app.routes';
import { messagesReducer } from './store/messages/messages.reducer';
import { usersReducer } from './store/users/users.reducer';
import { roomsReducer } from './store/rooms/rooms.reducer';
import { NotificationsEffects } from './domains/notifications';
import { MessagesEffects } from './store/messages/messages.effects';
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UsersEffects } from './store/users/users.effects';
@@ -32,6 +33,7 @@ export const appConfig: ApplicationConfig = {
rooms: roomsReducer
}),
provideEffects([
NotificationsEffects,
MessagesEffects,
MessagesSyncEffects,
UsersEffects,

View File

@@ -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
)
},

View File

@@ -14,16 +14,18 @@ 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 { NotificationsFacade } from './domains/notifications';
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 +38,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 +61,13 @@ export class App implements OnInit, OnDestroy {
private databaseService = inject(DatabaseService);
private router = inject(Router);
private servers = inject(ServerDirectoryService);
private servers = inject(ServerDirectoryFacade);
private notifications = inject(NotificationsFacade);
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'])
@@ -91,6 +86,8 @@ export class App implements OnInit, OnDestroy {
await this.timeSync.syncWithEndpoint(apiBase);
} catch {}
await this.notifications.initialize();
await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser());
@@ -155,7 +152,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 +183,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' ||

View File

@@ -1,6 +1,7 @@
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';

View File

@@ -0,0 +1,52 @@
/**
* 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 {
User,
UserStatus,
UserRole,
RoomMember
} from '../../shared-kernel';
export type {
Room,
RoomSettings,
RoomPermissions,
Channel,
ChannelType
} from '../../shared-kernel';
export type { Message, Reaction } from '../../shared-kernel';
export { DELETED_MESSAGE_CONTENT } from '../../shared-kernel';
export type { BanEntry } from '../../shared-kernel';
export type { VoiceState, ScreenShareState } from '../../shared-kernel';
export type {
ChatEventBase,
ChatEventType,
ChatEvent,
ChatInventoryItem
} from '../../shared-kernel';
export type {
SignalingMessage,
SignalingMessageType
} from '../../shared-kernel';
export type {
ChatAttachmentAnnouncement,
ChatAttachmentMeta
} from '../../shared-kernel';
export type { ServerInfo } from '../../domains/server-directory';

View File

@@ -0,0 +1,174 @@
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 DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
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;
closeToTray: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}
export interface DesktopSettingsPatch {
autoUpdateMode?: AutoUpdateMode;
autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean;
manifestUrls?: string[];
preferredVersion?: string | null;
vaapiVideoEncode?: boolean;
}
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: 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>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
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;
};

View 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;
}
}

View 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;
}

View File

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

View File

@@ -0,0 +1,2 @@
export * from './platform.service';
export * from './external-link.service';

View 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;
}
}

View 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';

View File

@@ -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,
@@ -433,7 +433,7 @@ class DebugNetworkSnapshotBuilder {
}
}
if (type === 'screen-state') {
if (type === 'screen-state' || type === 'camera-state') {
const subjectNode = direction === 'outbound'
? this.ensureLocalNetworkNode(
state,
@@ -442,12 +442,14 @@ class DebugNetworkSnapshotBuilder {
this.getPayloadString(payload, 'displayName')
)
: peerNode;
const isScreenSharing = this.getPayloadBoolean(payload, 'isScreenSharing');
const isStreaming = type === 'screen-state'
? this.getPayloadBoolean(payload, 'isScreenSharing')
: this.getPayloadBoolean(payload, 'isCameraEnabled');
if (isScreenSharing !== null) {
subjectNode.isStreaming = isScreenSharing;
if (isStreaming !== null) {
subjectNode.isStreaming = isStreaming;
if (!isScreenSharing)
if (!isStreaming)
subjectNode.streams.video = 0;
}
}

View File

@@ -5,70 +5,17 @@ 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>;
}
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
import { PlatformService } from '../platform';
import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory';
import {
type AutoUpdateMode,
type DesktopUpdateServerContext,
type DesktopUpdateServerHealthSnapshot,
type DesktopUpdateServerVersionStatus,
type DesktopUpdateState,
type ElectronApi
} from '../platform/electron/electron-api.models';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
interface ServerHealthSnapshot {
endpointId: string;
@@ -77,12 +24,7 @@ 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;
function createInitialState(): DesktopUpdateState {
return {
@@ -153,7 +95,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;
@@ -344,30 +287,23 @@ export class DesktopAppUpdateService {
private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> {
const sanitizedServerUrl = endpoint.url.replace(/\/+$/, '');
const api = this.getElectronApi();
if (!api?.getAutoUpdateServerHealth) {
return {
endpointId: endpoint.id,
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
try {
const response = await fetch(`${sanitizedServerUrl}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(SERVER_CONTEXT_TIMEOUT_MS)
});
if (!response.ok) {
return {
endpointId: endpoint.id,
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
const payload = await response.json() as ServerHealthResponse;
const serverVersion = normalizeOptionalString(payload.serverVersion);
const payload = await api.getAutoUpdateServerHealth(sanitizedServerUrl);
return {
endpointId: endpoint.id,
manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl),
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
...this.normalizeHealthSnapshot(payload)
};
} catch {
return {
@@ -379,6 +315,22 @@ export class DesktopAppUpdateService {
}
}
private normalizeHealthSnapshot(
snapshot: DesktopUpdateServerHealthSnapshot
): Omit<ServerHealthSnapshot, 'endpointId'> {
const serverVersion = normalizeOptionalString(snapshot.serverVersion);
return {
manifestUrl: normalizeOptionalHttpUrl(snapshot.manifestUrl),
serverVersion,
serverVersionStatus: serverVersion
? snapshot.serverVersionStatus
: snapshot.serverVersionStatus === 'reported'
? 'missing'
: snapshot.serverVersionStatus
};
}
private async pushContext(context: Partial<DesktopUpdateServerContext>): Promise<void> {
const api = this.getElectronApi();
@@ -393,9 +345,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();
}
}

View File

@@ -0,0 +1,4 @@
export * from './notification-audio.service';
export * from '../models/debugging.models';
export * from './debugging/debugging.service';
export * from './settings-modal.service';

View File

@@ -13,7 +13,7 @@ export enum AppSound {
}
/** Path prefix for audio assets (served from the `assets/audio/` folder). */
const AUDIO_BASE = '/assets/audio';
const AUDIO_BASE = 'assets/audio';
/** File extension used for all sound-effect assets. */
const AUDIO_EXT = 'wav';
/** localStorage key for persisting notification volume. */
@@ -36,6 +36,8 @@ export class NotificationAudioService {
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
private readonly cache = new Map<AppSound, HTMLAudioElement>();
private readonly sources = new Map<AppSound, string>();
/** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume());
@@ -46,13 +48,22 @@ export class NotificationAudioService {
/** Eagerly create (and start loading) an {@link HTMLAudioElement} for every known sound. */
private preload(): void {
for (const sound of Object.values(AppSound)) {
const audio = new Audio(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`);
const src = this.resolveAudioUrl(sound);
const audio = new Audio();
audio.preload = 'auto';
audio.src = src;
audio.load();
this.sources.set(sound, src);
this.cache.set(sound, audio);
}
}
private resolveAudioUrl(sound: AppSound): string {
return new URL(`${AUDIO_BASE}/${sound}.${AUDIO_EXT}`, document.baseURI).toString();
}
/** Read persisted volume from localStorage, falling back to the default. */
private loadVolume(): number {
try {
@@ -96,8 +107,9 @@ export class NotificationAudioService {
*/
play(sound: AppSound, volumeOverride?: number): void {
const cached = this.cache.get(sound);
const src = this.sources.get(sound);
if (!cached)
if (!cached || !src)
return;
const vol = volumeOverride ?? this.notificationVolume();
@@ -105,12 +117,23 @@ export class NotificationAudioService {
if (vol === 0)
return; // skip playback when muted
if (cached.readyState === HTMLMediaElement.HAVE_NOTHING) {
cached.load();
}
// Clone so overlapping plays don't cut each other off.
const clone = cached.cloneNode(true) as HTMLAudioElement;
clone.preload = 'auto';
clone.volume = Math.max(0, Math.min(1, vol));
clone.play().catch(() => {
/* swallow autoplay errors */
const fallback = new Audio(src);
fallback.preload = 'auto';
fallback.volume = clone.volume;
fallback.play().catch(() => {
/* swallow autoplay errors */
});
});
}
}

View File

@@ -1,5 +1,16 @@
import { Injectable, signal } from '@angular/core';
export type SettingsPage = 'general' | 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
export type SettingsPage =
| 'general'
| 'network'
| 'notifications'
| 'voice'
| 'updates'
| 'debugging'
| 'server'
| 'members'
| 'bans'
| 'permissions';
@Injectable({ providedIn: 'root' })
export class SettingsModalService {

View File

@@ -0,0 +1,76 @@
# 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` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **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, in-channel camera transport | `VoiceConnectionFacade` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
## Detailed docs
The larger domains also keep longer design notes in their own folders:
- [attachment/README.md](attachment/README.md)
- [auth/README.md](auth/README.md)
- [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md)
- [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md)
- [voice-connection/README.md](voice-connection/README.md)
- [voice-session/README.md](voice-session/README.md)
## 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/` |

View 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}/{attachmentId}.{ext?}
```
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type. The original filename is kept in attachment metadata for display and downloads, but the stored file uses the attachment ID plus the original extension so two uploads with the same visible name do not overwrite each other.
`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.

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

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

View File

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

View File

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

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

View File

@@ -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.';

View File

@@ -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;
};

View File

@@ -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

View 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/');
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export * from './application/attachment.facade';
export * from './domain/attachment.constants';
export * from './domain/attachment.models';

View File

@@ -0,0 +1,43 @@
const ROOM_NAME_SANITIZER = /[^\w.-]+/g;
const STORED_FILENAME_SANITIZER = /[^\w.-]+/g;
export function sanitizeAttachmentRoomName(roomName: string): string {
const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_');
return sanitizedRoomName || 'room';
}
export function resolveAttachmentStoredFilename(attachmentId: string, filename: string): string {
const sanitizedAttachmentId = attachmentId.trim().replace(STORED_FILENAME_SANITIZER, '_') || 'attachment';
const basename = filename.trim().split(/[\\/]/)
.pop() ?? '';
const extensionIndex = basename.lastIndexOf('.');
if (extensionIndex <= 0 || extensionIndex === basename.length - 1) {
return sanitizedAttachmentId;
}
const sanitizedExtension = basename.slice(extensionIndex)
.replace(STORED_FILENAME_SANITIZER, '_')
.toLowerCase();
return sanitizedExtension === '.'
? sanitizedAttachmentId
: `${sanitizedAttachmentId}${sanitizedExtension}`;
}
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';
}

View File

@@ -0,0 +1,131 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../domain/attachment.models';
import {
resolveAttachmentStorageBucket,
resolveAttachmentStoredFilename,
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, 'id' | '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}/${resolveAttachmentStoredFilename(attachment.id, 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);
}
}

View 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.

View File

@@ -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.

View File

@@ -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 = '';

View File

@@ -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 = '';

View File

@@ -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',

View File

@@ -0,0 +1 @@
export * from './application/auth.service';

View File

@@ -0,0 +1,149 @@
# 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)
```
## Text channel scoping
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server. Voice channels live in the same server-owned channel list, but they do not participate in chat-message routing.
If a room has no text channels, the room shell in `features/room/chat-room/` renders an empty state instead of mounting the chat view. The chat domain only mounts once a valid text channel exists.
## Message sync
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
```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. Rendered remote images now attempt a direct load first and only fall back to the image proxy after the browser reports a load failure, which is the practical approximation of a CORS or mixed-content fallback path in the renderer.
```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 scoped to the current server and active text channel. Each event resets a 3-second TTL timer for that channel. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".

View File

@@ -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
@@ -135,6 +135,10 @@ export class KlipyService {
}
buildRenderableImageUrl(url: string): string {
return this.normalizeMediaUrl(url);
}
buildImageProxyUrl(url: string): string {
const trimmed = this.normalizeMediaUrl(url);
if (!trimmed)

View 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;
}

View 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;
}

View File

@@ -0,0 +1,50 @@
import {
Directive,
HostBinding,
HostListener,
effect,
inject,
input,
signal
} from '@angular/core';
import { KlipyService } from '../application/klipy.service';
@Directive({
selector: 'img[appChatImageProxyFallback]',
standalone: true
})
export class ChatImageProxyFallbackDirective {
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
private readonly klipy = inject(KlipyService);
private readonly renderedSource = signal('');
private hasAppliedProxyFallback = false;
constructor() {
effect(() => {
this.hasAppliedProxyFallback = false;
this.renderedSource.set(this.klipy.buildRenderableImageUrl(this.sourceUrl()));
});
}
@HostBinding('src')
get src(): string {
return this.renderedSource();
}
@HostListener('error')
handleError(): void {
if (this.hasAppliedProxyFallback) {
return;
}
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl());
if (!proxyUrl || proxyUrl === this.renderedSource()) {
return;
}
this.hasAppliedProxyFallback = true;
this.renderedSource.set(proxyUrl);
}
}

Some files were not shown because too many files have changed in this diff Show More