Compare commits

..

13 Commits

Author SHA1 Message Date
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
Myx
0467a7b612 documentation improvement 2026-03-23 01:34:18 +01:00
Myx
971a5afb8b ddd test 2 2026-03-23 00:42:08 +01:00
326 changed files with 4927 additions and 870 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,16 +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
## Architecture
- Renderer architecture and refactor conventions live in `docs/architecture.md`
- Electron renderer integrations should go through `src/app/core/platform/electron/`
- Pure shared logic belongs in `src/app/core/helpers/` and reusable contracts belong in `src/app/core/models/`
- `website/` Toju website served at toju.app
## Install
@@ -58,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,166 +0,0 @@
# Frontend Architecture
This document defines the target structure for the Angular renderer and the boundaries between web-safe code, Electron adapters, reusable logic, and feature UI.
## Goals
- Keep feature code easy to navigate by grouping it around user-facing domains.
- Push platform-specific behavior behind explicit adapters.
- Move pure logic into helpers and dedicated model files instead of embedding it in components and large services.
- Split large services into smaller units with one clear responsibility each.
## Target Structure
```text
src/app/
app.ts
domains/
<domain>/
application/ # facades and use-case orchestration
domain/ # models and pure domain logic
infrastructure/ # adapters, api clients, persistence
feature/ # smart domain UI containers
ui/ # dumb/presentational domain UI
infrastructure/
persistence/ # shared storage adapters and persistence facades
realtime/ # shared signaling, peer transport, media runtime state
shared-kernel/ # intentionally shared cross-domain contracts and enums
core/
constants.ts # cross-domain technical constants only
helpers/ # transitional pure helpers still being moved out
models/ # transitional shared contracts pending extraction
platform/
platform.service.ts # runtime/environment detection adapters
external-link.service.ts # browser/electron navigation adapters
electron/ # renderer-side adapters for preload / Electron APIs
realtime/ # compatibility/public import boundary for realtime
services/ # technical cross-domain services only
features/ # transitional feature area while slices move into domains/*/feature
shared/
ui/ # shared presentational primitives
utils/ # shared pure utilities
store/ # ngrx reducers, effects, selectors, actions
```
## Layering Rules
Dependency direction must stay one-way:
1. `domains/*/feature`, `domains/*/ui`, and transitional `features/` may depend on `domains/`, `infrastructure/`, `core/`, `shared/`, and `store/`.
2. `domains/*/application` may depend on its own `domain/`, `infrastructure/`, top-level `infrastructure/`, `core/`, and `store/`.
3. `domains/*/domain` should stay framework-light and hold domain models plus pure logic.
4. `domains/*/infrastructure` may depend on `core/platform/`, browser APIs, HTTP, persistence, and external adapters.
5. Top-level `infrastructure/` should hold shared technical runtime implementations; `core/` should hold cross-domain utilities, compatibility entry points, and platform adapters rather than full runtime subsystems.
6. `shared-kernel/` should hold the intentionally shared cross-domain contracts that more than one domain must agree on.
7. `core/models/` is transitional and should shrink over time. Domain-specific models belong inside their domain folder, while stable cross-domain contracts should move into `shared-kernel/`.
## Responsibility Split
Use these roles consistently when a domain starts to absorb too many concerns:
- `application`: Angular-facing facades and use-case orchestration.
- `domain`: contracts, policies, calculations, mapping rules, and other pure domain logic.
- `infrastructure`: platform adapters, storage, transport, HTTP, IPC, and persistence boundaries.
- `feature`: smart components that wire store, routing, and facades.
- `ui`: presentational components with minimal business knowledge.
## Platform Boundary
Renderer code should not access `window.electronAPI` directly except inside the Electron adapter layer.
Current convention:
- Use `core/platform/electron/electron-api.models.ts` for Angular-side Electron typings.
- Use `core/platform/electron/electron-bridge.service.ts` to reach preload APIs from Angular code.
- Keep runtime detection and browser/Electron wrappers such as `core/platform/platform.service.ts` and `core/platform/external-link.service.ts` inside `core/platform/` rather than `core/services/`.
- Keep Electron-only persistence and file-system logic inside dedicated adapter services such as `domains/attachment/infrastructure/attachment-storage.service.ts`.
This keeps feature and domain code platform-agnostic and makes the browser runtime easier to reason about.
## Models And Pure Logic
- Attachment runtime types now live in `domains/attachment/domain/attachment.models.ts`.
- Attachment download-policy rules now live in `domains/attachment/domain/attachment.logic.ts`.
- Attachment file-path sanitizing and storage-bucket selection live in `domains/attachment/infrastructure/attachment-storage.helpers.ts`.
- New domain types should be placed in a dedicated model file inside their domain folder when they are not shared across domains.
- Only transitional shared contracts should stay in `core/models/`.
- Stable cross-domain enums and contracts should be extracted into `shared-kernel/` once ownership is clear.
- Shared signaling and attachment transport contracts should live in `shared-kernel/` rather than inside `core/models/`.
## Incremental Refactor Path
The repo is large enough that refactoring must stay incremental. Preferred order:
1. Extract platform adapters from direct renderer global access.
2. Split persistence and cache behavior out of large orchestration services.
3. Move reusable types and helper logic into dedicated files.
4. Break remaining multi-responsibility facades into managers or coordinators.
## Current Baseline
The current refactor establishes these patterns:
- Shared Electron bridge for Angular services and root app wiring.
- Attachment now lives under `domains/attachment/` with `application/attachment.facade.ts` as the public Angular-facing boundary.
- Attachment application orchestration is now split across `domains/attachment/application/attachment-manager.service.ts`, `attachment-transfer.service.ts`, `attachment-persistence.service.ts`, and `attachment-runtime.store.ts`.
- Attachment runtime types and pure transfer-policy logic live in `domains/attachment/domain/`.
- Attachment disk persistence and storage helpers live in `domains/attachment/infrastructure/`.
- Shared browser/Electron persistence adapters now live in `infrastructure/persistence/`, with `DatabaseService` as the renderer-facing facade over IndexedDB and Electron CQRS-backed SQLite.
- Auth HTTP/login orchestration now lives under `domains/auth/application/auth.service.ts`.
- Chat GIF search and KLIPY integration now live under `domains/chat/application/klipy.service.ts`.
- Voice-session now lives under `domains/voice-session/` with `application/voice-session.facade.ts` as the public Angular-facing boundary.
- Voice-session models and pure route/room mapping logic live in `domains/voice-session/domain/`.
- Voice workspace UI state now lives in `domains/voice-session/application/voice-workspace.service.ts`, and persisted voice/screen-share preferences now live in `domains/voice-session/infrastructure/voice-settings.storage.ts`.
- Voice activity tracking now lives in `domains/voice-connection/application/voice-activity.service.ts`.
- Server-directory now lives under `domains/server-directory/` with `application/server-directory.facade.ts` as the public Angular-facing boundary.
- Server-directory endpoint state now lives in `domains/server-directory/application/server-endpoint-state.service.ts`.
- Server-directory contracts and user-facing compatibility messaging live in `domains/server-directory/domain/`.
- Endpoint default URL normalization and built-in endpoint templates live in `domains/server-directory/domain/server-endpoint-defaults.ts`.
- Endpoint localStorage persistence, HTTP fan-out, compatibility checks, and health probes live in `domains/server-directory/infrastructure/`.
- `infrastructure/realtime/realtime-session.service.ts` now holds the shared realtime runtime service.
- `core/realtime/index.ts` is the compatibility/public import surface and re-exports that runtime service as `RealtimeSessionFacade` for technical cross-domain consumers.
- `domains/voice-connection/` now exposes `application/voice-connection.facade.ts` as the voice-specific Angular-facing boundary.
- `domains/screen-share/` now exposes `application/screen-share.facade.ts` plus `application/screen-share-source-picker.service.ts`, and `domain/screen-share.config.ts` is the real source for screen-share presets/options plus `ELECTRON_ENTIRE_SCREEN_SOURCE_NAME`.
- Shared transport contracts and the low-level signaling and peer-connection stack now live under `infrastructure/realtime/`.
- Shared media preference contracts now live under `shared-kernel/media-preferences.ts`, so `voice-session` settings no longer import `voice-connection` or `screen-share` domain internals.
- Shared signaling and attachment contracts now live under `shared-kernel/signaling-contracts.ts` and `shared-kernel/attachment-contracts.ts`, while `core/models/` keeps compatibility re-exports during the migration.
- Realtime debug network metric collection now lives in `infrastructure/realtime/logging/debug-network-metrics.ts`; the debug console reads that infrastructure state rather than keeping the metric store inside `core/services/`.
- Shared media handling, voice orchestration helpers, noise reduction, and screen-share capture adapters now live in `infrastructure/realtime/media/`.
- The old `domains/webrtc/*` and `core/services/webrtc.service.ts` compatibility shims have been removed after all in-repo callers moved to `core/realtime`, `domains/voice-connection/`, and `domains/screen-share/`.
- Multi-server signaling topology and signaling-manager lifecycle extracted from `WebRTCService` into `ServerSignalingCoordinator`.
- Angular-facing signal and connection-state bookkeeping extracted from `WebRTCService` into `WebRtcStateController`.
- Peer/media event streams, peer messaging, remote stream access, and screen-share entry points extracted from `WebRTCService` into `PeerMediaFacade`.
- Lower-level signaling connect/send/identify helpers extracted from `WebRTCService` into `SignalingTransportHandler`.
- Incoming signaling message semantics extracted from `WebRTCService` into `IncomingSignalingMessageHandler`.
- Outbound server-membership signaling commands extracted from `WebRTCService` into `ServerMembershipSignalingHandler`.
- Remote screen-share request state and control-plane handling extracted from `WebRTCService` into `RemoteScreenShareRequestController`.
- Voice session and heartbeat orchestration extracted from `WebRTCService` into `VoiceSessionController`.
- Desktop client-version lookup and endpoint compatibility checks for server-directory live in `domains/server-directory/infrastructure/server-endpoint-compatibility.service.ts`.
- Endpoint health probing and fallback transport for server-directory live in `domains/server-directory/infrastructure/server-endpoint-health.service.ts`.
- Server-directory HTTP fan-out, endpoint resolution, and API response normalization live in `domains/server-directory/infrastructure/server-directory-api.service.ts`.
## Shared Kernel Organisation
`shared-kernel/` is organised by business concept so newcomers can find types
by meaning rather than by historical accident:
| File | Contents |
|---|---|
| `user.models.ts` | `User`, `UserStatus`, `UserRole`, `RoomMember` |
| `room.models.ts` | `Room`, `RoomSettings`, `RoomPermissions`, `Channel`, `ChannelType` |
| `message.models.ts` | `Message`, `Reaction`, `DELETED_MESSAGE_CONTENT` |
| `moderation.models.ts` | `BanEntry` |
| `voice-state.models.ts` | `VoiceState`, `ScreenShareState` |
| `chat-events.ts` | `ChatEventType`, `ChatEvent`, `ChatInventoryItem` |
| `media-preferences.ts` | `LatencyProfile`, `ScreenShareQuality`, quality presets |
| `signaling-contracts.ts` | `SignalingMessage`, `SignalingMessageType` |
| `attachment-contracts.ts` | `ChatAttachmentAnnouncement`, `ChatAttachmentMeta` |
`core/models/index.ts` is now a **pure re-export barrel** that re-exports all
of the above (plus domain re-exports like `ServerInfo`) for backward
compatibility. New code should import from `shared-kernel/` directly.
## Next Candidates
- Migrate remaining voice and room UI from transitional `features/` into domain-local `feature/` and `ui/` folders.
- Migrate remaining WebRTC-facing UI from transitional `features/` and `shared/` locations into domain-local `feature/` and `ui/` folders where it improves ownership.

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

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,10 +0,0 @@
export {
DEFAULT_SCREEN_SHARE_QUALITY,
DEFAULT_SCREEN_SHARE_START_OPTIONS,
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
SCREEN_SHARE_QUALITY_OPTIONS,
SCREEN_SHARE_QUALITY_PRESETS,
type ScreenShareQuality,
type ScreenShareQualityPreset,
type ScreenShareStartOptions
} from '../../shared-kernel';

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

@@ -10,7 +10,6 @@
* instead of this file.
*/
// ── shared-kernel re-exports ────────────────────────────────────────
export type {
User,
UserStatus,
@@ -50,5 +49,4 @@ export type {
ChatAttachmentMeta
} from '../../shared-kernel';
// ── domain re-exports ───────────────────────────────────────────────
export type { ServerInfo } from '../../domains/server-directory';

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

@@ -54,15 +54,15 @@ graph TD
Persistence --> Store
Storage --> Helpers[attachment-storage.helpers]
click Facade "application/attachment.facade.ts" "Thin entry point"
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle"
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol"
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming"
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence"
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state"
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access"
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers"
click Logic "domain/attachment.logic.ts" "Pure decision functions"
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
@@ -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

@@ -36,11 +36,11 @@ graph TD
Auth --> SD
Login --> Store
click Auth "application/auth.service.ts" "HTTP login/register"
click Login "feature/login/" "Login form"
click Register "feature/register/" "Registration form"
click UserBar "feature/user-bar/" "Current user display"
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL"
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

View File

@@ -54,14 +54,14 @@ graph TD
List --> Item
Item --> Overlays
click Chat "feature/chat-messages/chat-messages.component.ts" "Root chat view"
click List "feature/chat-messages/components/message-list/" "Paginated message list"
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send"
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker"
click Item "feature/chat-messages/components/message-item/" "Single message bubble"
click GIF "feature/klipy-gif-picker/" "GIF search panel"
click Typing "feature/typing-indicator/" "Typing indicator"
click Users "feature/user-list/" "Online user sidebar"
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
@@ -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
@@ -122,9 +128,9 @@ graph LR
Klipy --> SD
Klipy --> API
click Picker "feature/klipy-gif-picker/" "GIF search panel"
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API"
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL"
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
@@ -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"

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