13 Commits

Author SHA1 Message Date
Myx
8b6578da3c fix: Notification audio
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 11m55s
Queue Release Build / build-linux (push) Successful in 30m56s
Queue Release Build / build-windows (push) Successful in 27m50s
Queue Release Build / finalize (push) Successful in 2m0s
2026-03-30 21:14:26 +02:00
Myx
851d6ae759 fix: Prefer cached channels before loaded 2026-03-30 20:37:24 +02:00
1e833ec7f2 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
2026-03-30 02:56:34 +00:00
Myx
64e34ad586 feat: basic selected server indicator 2026-03-30 04:54:02 +02:00
Myx
e3b23247a9 feat: Close to tray 2026-03-30 04:48:34 +02:00
Myx
42ac712571 feat: Add notifications 2026-03-30 04:41:58 +02:00
Myx
b7d4bf20e3 feat: Add webcam basic support 2026-03-30 03:10:44 +02:00
Myx
727059fb52 Add seperation of voice channels, creation of new ones, and move around users 2026-03-30 02:11:39 +02:00
Myx
83694570e3 feat: Allow admin to create new text channels 2026-03-30 01:25:56 +02:00
Myx
109402cdd6 perf: Health snapshot changes 2026-03-30 00:28:45 +02:00
Myx
eb23fd71ec perf: Optimizing the image loading
Does no longer load all klipy images through image proxy from signal server. Improves loading performance.
2026-03-30 00:26:28 +02:00
Myx
11917f3412 fix: Make attachments unique when downloaded
Fixes the issue with attachments replacing each other locally so files with same filename appears as the same file
2026-03-30 00:08:53 +02:00
Myx
8162e0444a Move toju-app into own its folder 2026-03-29 23:55:24 +02:00
323 changed files with 4812 additions and 564 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

3
.gitignore vendored
View File

@@ -53,3 +53,6 @@ Thumbs.db
.certs/
/server/data/variables.json
dist-server/*
AGENTS.md
doc/**

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

@@ -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,78 @@ 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();
const suppressSystemNotification = mainWindow?.isVisible() === true
&& !mainWindow.isMinimized()
&& mainWindow.isMaximized();
if (!title) {
return false;
}
if (!suppressSystemNotification && 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 +414,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",

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,23 +0,0 @@
const ROOM_NAME_SANITIZER = /[^\w.-]+/g;
export function sanitizeAttachmentRoomName(roomName: string): string {
const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_');
return sanitizedRoomName || 'room';
}
export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' {
if (mime.startsWith('video/')) {
return 'video';
}
if (mime.startsWith('audio/')) {
return 'audio';
}
if (mime.startsWith('image/')) {
return 'image';
}
return 'files';
}

View File

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

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

@@ -17,6 +17,7 @@ import { Store } from '@ngrx/store';
import { DatabaseService } from './infrastructure/persistence';
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
import { ServerDirectoryFacade } from './domains/server-directory';
import { NotificationsFacade } from './domains/notifications';
import { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/platform';
@@ -61,6 +62,7 @@ export class App implements OnInit, OnDestroy {
private databaseService = inject(DatabaseService);
private router = inject(Router);
private servers = inject(ServerDirectoryFacade);
private notifications = inject(NotificationsFacade);
private settingsModal = inject(SettingsModalService);
private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionFacade);
@@ -84,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());

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

@@ -57,6 +57,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
@@ -83,6 +89,7 @@ export interface DesktopUpdateState {
export interface DesktopSettingsSnapshot {
autoUpdateMode: AutoUpdateMode;
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
@@ -93,12 +100,24 @@ export interface DesktopSettingsSnapshot {
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;
@@ -126,7 +145,12 @@ export interface ElectronApi {
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>;

View File

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

@@ -10,17 +10,13 @@ import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server
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 ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface ServerHealthSnapshot {
endpointId: string;
manifestUrl: string | null;
@@ -29,7 +25,6 @@ interface ServerHealthSnapshot {
}
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
function createInitialState(): DesktopUpdateState {
return {
@@ -292,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 {
@@ -327,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();

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

@@ -11,11 +11,25 @@ infrastructure adapters and UI.
| **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 | `VoiceConnectionFacade` |
| **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:

View File

@@ -129,10 +129,10 @@ The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachment
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
```
{appDataPath}/{serverId}/{roomName}/{bucket}/{filename}
{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.
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.

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

@@ -1,7 +1,11 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../domain/attachment.models';
import { resolveAttachmentStorageBucket, sanitizeAttachmentRoomName } from './attachment-storage.helpers';
import {
resolveAttachmentStorageBucket,
resolveAttachmentStoredFilename,
sanitizeAttachmentRoomName
} from './attachment-storage.helpers';
@Injectable({ providedIn: 'root' })
export class AttachmentStorageService {
@@ -38,7 +42,7 @@ export class AttachmentStorageService {
}
async saveBlob(
attachment: Pick<Attachment, 'filename' | 'mime'>,
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
blob: Blob,
roomName: string
): Promise<string | null> {
@@ -55,7 +59,7 @@ export class AttachmentStorageService {
await electronApi.ensureDir(directoryPath);
const arrayBuffer = await blob.arrayBuffer();
const diskPath = `${directoryPath}/${attachment.filename}`;
const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));

View File

@@ -90,6 +90,12 @@ sequenceDiagram
User->>DC: broadcastMessage(delete-message)
```
## Text channel scoping
`ChatMessagesComponent` renders only the active text channel selected in `store/rooms`. Legacy messages without an explicit `channelId` are treated as `general` for backward compatibility, while new sends and typing events attach the active `channelId` so one text channel does not leak state into the rest of the server. 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.
@@ -109,7 +115,7 @@ sequenceDiagram
## GIF integration
`KlipyService` checks availability on the active server, then proxies search requests through the server API. Images are rendered via an image proxy endpoint to avoid mixed-content issues.
`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
@@ -140,4 +146,4 @@ graph LR
## Typing indicator
`TypingIndicatorComponent` listens for typing events from peers. Each event resets a 3-second TTL timer. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".
`TypingIndicatorComponent` listens for typing events from peers scoped to the current server and active text channel. Each event resets a 3-second TTL timer for that channel. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".

View File

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

View File

@@ -108,8 +108,18 @@ export class ChatMessagesComponent {
}
handleTypingStarted(): void {
const roomId = this.currentRoom()?.id;
if (!roomId) {
return;
}
try {
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
this.webrtc.sendRawMessage({
type: 'typing',
serverId: roomId,
channelId: this.activeChannelId() ?? 'general'
});
} catch {
/* ignore */
}

View File

@@ -206,7 +206,7 @@
<div class="group flex max-w-sm items-center gap-3 rounded-xl border border-border bg-secondary/60 px-2.5 py-2">
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
<img
[src]="getPendingKlipyGifPreviewUrl()"
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
class="h-full w-full object-cover"
loading="lazy"

View File

@@ -23,6 +23,7 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/klipy.service';
import { Message } from '../../../../../../shared-kernel';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
@@ -40,6 +41,7 @@ const DEFAULT_TEXTAREA_HEIGHT = 62;
CommonModule,
FormsModule,
NgIcon,
ChatImageProxyFallbackDirective,
TypingIndicatorComponent
],
viewProviders: [
@@ -231,12 +233,6 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
getPendingKlipyGifPreviewUrl(): string {
const gif = this.pendingKlipyGif();
return gif ? this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url) : '';
}
formatBytes(bytes: number): string {
const units = [
'B',

View File

@@ -98,7 +98,7 @@
>
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<img
[src]="getMarkdownImageSource(node.url)"
[appChatImageProxyFallback]="node.url"
[alt]="node.alt || 'Shared image'"
class="block max-h-80 max-w-full w-auto"
loading="lazy"

View File

@@ -41,6 +41,7 @@ import {
ChatVideoPlayerComponent,
UserAvatarComponent
} from '../../../../../../shared';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
@@ -102,6 +103,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
ChatVideoPlayerComponent,
RemarkModule,
MermaidComponent,
ChatImageProxyFallbackDirective,
UserAvatarComponent
],
viewProviders: [
@@ -318,10 +320,6 @@ export class ChatMessageItemComponent {
);
}
getMarkdownImageSource(url?: string): string {
return url ? this.klipy.buildRenderableImageUrl(url) : '';
}
getMermaidCode(code?: string): string {
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
}

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