Move toju-app into own its folder

This commit is contained in:
2026-03-29 23:30:37 +02:00
parent 0467a7b612
commit 8162e0444a
287 changed files with 42 additions and 34 deletions

142
toju-app/angular.json Normal file
View File

@@ -0,0 +1,142 @@
{
"$schema": "../node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm",
"analytics": false,
"schematicCollections": [
"angular-eslint"
]
},
"newProjectRoot": "projects",
"projects": {
"client": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"skipTests": true
},
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
},
{
"glob": "**/*",
"input": "src/assets",
"output": "assets"
}
],
"styles": [
"src/styles.scss",
"../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"
],
"allowedCommonJsDependencies": [
"simple-peer",
"uuid"
],
"outputPath": "../dist/client"
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
],
"budgets": [
{
"type": "initial",
"maximumWarning": "1MB",
"maximumError": "2MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "client:build:production"
},
"development": {
"buildTarget": "client:build:development"
}
},
"defaultConfiguration": "development"
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": [
"src/**/*.ts",
"src/**/*.html"
]
}
}
}
}
}
}

BIN
toju-app/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<staticContent>
<remove fileExtension=".wasm" />
<remove fileExtension=".webmanifest" />
<mimeMap fileExtension=".wasm" mimeType="application/wasm" />
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
</staticContent>
<rewrite>
<rules>
<rule name="Angular Routes" stopProcessing="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Rewrite" url="/index.html" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>

View File

@@ -0,0 +1,48 @@
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
isDevMode
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideStore } from '@ngrx/store';
import { provideEffects } from '@ngrx/effects';
import { provideStoreDevtools } from '@ngrx/store-devtools';
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 { MessagesEffects } from './store/messages/messages.effects';
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UsersEffects } from './store/users/users.effects';
import { RoomsEffects } from './store/rooms/rooms.effects';
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
import { STORE_DEVTOOLS_MAX_AGE } from './core/constants';
/** Root application configuration providing routing, HTTP, NgRx store, and devtools. */
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideHttpClient(),
provideStore({
messages: messagesReducer,
users: usersReducer,
rooms: roomsReducer
}),
provideEffects([
MessagesEffects,
MessagesSyncEffects,
UsersEffects,
RoomsEffects,
RoomMembersSyncEffects
]),
provideStoreDevtools({
maxAge: STORE_DEVTOOLS_MAX_AGE,
logOnly: !isDevMode(),
autoPause: true,
trace: false
})
]
};

60
toju-app/src/app/app.html Normal file
View File

@@ -0,0 +1,60 @@
<div class="h-screen bg-background text-foreground flex">
<!-- Global left servers rail always visible -->
<aside class="w-16 flex-shrink-0 border-r border-border bg-card">
<app-servers-rail class="h-full" />
</aside>
<main class="flex-1 min-w-0 relative overflow-hidden">
<!-- Custom draggable title bar -->
<app-title-bar />
@if (desktopUpdateState().restartRequired) {
<div class="absolute inset-x-0 top-10 z-20 px-4 pt-4 pointer-events-none">
<div class="pointer-events-auto mx-auto max-w-4xl rounded-xl border border-primary/30 bg-primary/10 p-4 shadow-2xl backdrop-blur-sm">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold text-foreground">Update ready to install</p>
<p class="mt-1 text-sm text-muted-foreground">
MetoYou {{ desktopUpdateState().targetVersion || 'update' }} has been downloaded. Restart the app to finish applying it.
</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
(click)="openUpdatesSettings()"
class="inline-flex items-center rounded-lg border border-border bg-secondary px-4 py-2 text-sm font-medium text-foreground transition-colors hover:bg-secondary/80"
>
Update settings
</button>
<button
type="button"
(click)="restartToApplyUpdate()"
class="inline-flex items-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Restart now
</button>
</div>
</div>
</div>
</div>
}
<!-- Content area fills below the title bar without global scroll -->
<div class="absolute inset-x-0 top-10 bottom-0 overflow-auto">
<router-outlet />
</div>
</main>
<!-- Floating voice controls - shown when connected to voice and navigated away from server -->
<app-floating-voice-controls />
</div>
<!-- Unified Settings Modal -->
<app-settings-modal />
<!-- Shared Screen Share Source Picker -->
<app-screen-share-source-picker />
<!-- Shared Debug Console -->
<app-debug-console [showLauncher]="false" />

View File

@@ -0,0 +1,42 @@
import { Routes } from '@angular/router';
/** Application route configuration with lazy-loaded feature components. */
export const routes: Routes = [
{
path: '',
redirectTo: 'search',
pathMatch: 'full'
},
{
path: 'login',
loadComponent: () =>
import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent)
},
{
path: 'register',
loadComponent: () =>
import('./domains/auth/feature/register/register.component').then((module) => module.RegisterComponent)
},
{
path: 'invite/:inviteId',
loadComponent: () =>
import('./domains/server-directory/feature/invite/invite.component').then((module) => module.InviteComponent)
},
{
path: 'search',
loadComponent: () =>
import('./domains/server-directory/feature/server-search/server-search.component').then(
(module) => module.ServerSearchComponent
)
},
{
path: 'room/:roomId',
loadComponent: () =>
import('./features/room/chat-room/chat-room.component').then((module) => module.ChatRoomComponent)
},
{
path: 'settings',
loadComponent: () =>
import('./features/settings/settings.component').then((module) => module.SettingsComponent)
}
];

View File

217
toju-app/src/app/app.ts Normal file
View File

@@ -0,0 +1,217 @@
/* eslint-disable @angular-eslint/component-class-suffix */
import {
Component,
OnInit,
OnDestroy,
inject,
HostListener
} from '@angular/core';
import {
Router,
RouterOutlet,
NavigationEnd
} from '@angular/router';
import { CommonModule } from '@angular/common';
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 { TimeSyncService } from './core/services/time-sync.service';
import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { ServersRailComponent } from './features/servers/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
import {
ROOM_URL_PATTERN,
STORAGE_KEY_CURRENT_USER_ID,
STORAGE_KEY_LAST_VISITED_ROUTE
} from './core/constants';
@Component({
selector: 'app-root',
imports: [
CommonModule,
RouterOutlet,
ServersRailComponent,
TitleBarComponent,
FloatingVoiceControlsComponent,
SettingsModalComponent,
DebugConsoleComponent,
ScreenShareSourcePickerComponent
],
templateUrl: './app.html',
styleUrl: './app.scss'
})
export class App implements OnInit, OnDestroy {
store = inject(Store);
currentRoom = this.store.selectSignal(selectCurrentRoom);
desktopUpdates = inject(DesktopAppUpdateService);
desktopUpdateState = this.desktopUpdates.state;
private databaseService = inject(DatabaseService);
private router = inject(Router);
private servers = inject(ServerDirectoryFacade);
private settingsModal = inject(SettingsModalService);
private timeSync = inject(TimeSyncService);
private voiceSession = inject(VoiceSessionFacade);
private externalLinks = inject(ExternalLinkService);
private electronBridge = inject(ElectronBridgeService);
private deepLinkCleanup: (() => void) | null = null;
@HostListener('document:click', ['$event'])
onGlobalLinkClick(evt: MouseEvent): void {
this.externalLinks.handleClick(evt);
}
async ngOnInit(): Promise<void> {
void this.desktopUpdates.initialize();
await this.databaseService.initialize();
try {
const apiBase = this.servers.getApiBaseUrl();
await this.timeSync.syncWithEndpoint(apiBase);
} catch {}
await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser());
this.store.dispatch(RoomsActions.loadRooms());
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
if (!currentUserId) {
if (!this.isPublicRoute(this.router.url)) {
this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
}
}).catch(() => {});
}
} else {
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
if (last && typeof last === 'string') {
const current = this.router.url;
if (current === '/' || current === '/search') {
this.router.navigate([last], { replaceUrl: true }).catch(() => {});
}
}
}
this.router.events.subscribe((evt) => {
if (evt instanceof NavigationEnd) {
const url = evt.urlAfterRedirects || evt.url;
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
const roomMatch = url.match(ROOM_URL_PATTERN);
const currentRoomId = roomMatch ? roomMatch[1] : null;
this.voiceSession.checkCurrentRoute(currentRoomId);
}
});
}
ngOnDestroy(): void {
this.deepLinkCleanup?.();
this.deepLinkCleanup = null;
}
openNetworkSettings(): void {
this.settingsModal.open('network');
}
openUpdatesSettings(): void {
this.settingsModal.open('updates');
}
async refreshDesktopUpdateContext(): Promise<void> {
await this.desktopUpdates.refreshServerContext();
}
async restartToApplyUpdate(): Promise<void> {
await this.desktopUpdates.restartToApplyUpdate();
}
private async setupDesktopDeepLinks(): Promise<void> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return;
}
this.deepLinkCleanup = electronApi.onDeepLinkReceived?.((url) => {
void this.handleDesktopDeepLink(url);
}) || null;
const pendingDeepLink = await electronApi.consumePendingDeepLink?.();
if (pendingDeepLink) {
await this.handleDesktopDeepLink(pendingDeepLink);
}
}
private async handleDesktopDeepLink(url: string): Promise<void> {
const invite = this.parseDesktopInviteUrl(url);
if (!invite) {
return;
}
await this.router.navigate(['/invite', invite.inviteId], {
queryParams: {
server: invite.sourceUrl
}
});
}
private isPublicRoute(url: string): boolean {
return url === '/login' ||
url === '/register' ||
url.startsWith('/invite/');
}
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== 'toju:') {
return null;
}
const pathSegments = [parsedUrl.hostname, ...parsedUrl.pathname.split('/').filter(Boolean)]
.map((segment) => decodeURIComponent(segment));
if (pathSegments[0] !== 'invite' || !pathSegments[1]) {
return null;
}
const sourceUrl = parsedUrl.searchParams.get('server')?.trim();
if (!sourceUrl) {
return null;
}
return {
inviteId: pathSegments[1],
sourceUrl
};
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,13 @@
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_VOICE_SETTINGS = 'metoyou_voice_settings';
export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
export const STORE_DEVTOOLS_MAX_AGE = 25;
export const DEBUG_LOG_MAX_ENTRIES = 500;
export const DEFAULT_MAX_USERS = 50;
export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
export const DEFAULT_VOLUME = 100;
export const SEARCH_DEBOUNCE_MS = 300;

View File

@@ -0,0 +1,26 @@
import type { DebuggingService } from '../services/debugging.service';
export function reportDebuggingError(
debugging: DebuggingService,
source: string,
message: string,
payload: Record<string, unknown>,
error: unknown
): void {
debugging.error(source, message, {
...payload,
error
});
}
export function trackDebuggingTaskFailure(
task: Promise<unknown> | unknown,
debugging: DebuggingService,
source: string,
message: string,
payload: Record<string, unknown>
): void {
Promise.resolve(task).catch((error) => {
reportDebuggingError(debugging, source, message, payload, error);
});
}

View File

@@ -0,0 +1,47 @@
import { BanEntry, User } from '../models/index';
type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined;
/** Build the set of user identifiers that may appear in room ban entries. */
export function getRoomBanCandidateIds(user: BanAwareUser, persistedUserId?: string | null): string[] {
const candidates = [
user?.id,
user?.oderId,
persistedUserId
].filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
return Array.from(new Set(candidates));
}
/** Resolve the user identifier stored by a ban entry, with legacy fallback support. */
export function getRoomBanTargetId(ban: Pick<BanEntry, 'userId' | 'oderId'>): string {
if (typeof ban.userId === 'string' && ban.userId.trim().length > 0) {
return ban.userId;
}
return ban.oderId;
}
/** Return true when the given ban targets the provided user. */
export function isRoomBanMatch(
ban: Pick<BanEntry, 'userId' | 'oderId'>,
user: BanAwareUser,
persistedUserId?: string | null
): boolean {
const candidateIds = getRoomBanCandidateIds(user, persistedUserId);
if (candidateIds.length === 0) {
return false;
}
return candidateIds.includes(getRoomBanTargetId(ban));
}
/** Return true when any active ban entry targets the provided user. */
export function hasRoomBanForUser(
bans: Pick<BanEntry, 'userId' | 'oderId'>[],
user: BanAwareUser,
persistedUserId?: string | null
): boolean {
return bans.some((ban) => isRoomBanMatch(ban, user, persistedUserId));
}

View File

@@ -0,0 +1 @@
export * from './models/index';

View File

@@ -0,0 +1,193 @@
import type { Room, User } from './index';
export type DebugLogLevel = 'event' | 'info' | 'warn' | 'error' | 'debug';
export type DebugNetworkNodeKind = 'local-client' | 'remote-client' | 'signaling-server' | 'app-server';
export type DebugNetworkEdgeKind = 'peer' | 'signaling' | 'membership';
export type DebugNetworkMessageDirection = 'inbound' | 'outbound';
export type DebugNetworkMessageScope = 'data-channel' | 'signaling';
export interface DebugLogEntry {
id: number;
timestamp: number;
timeLabel: string;
dateTimeLabel: string;
level: DebugLogLevel;
source: string;
message: string;
payload: unknown | null;
payloadText: string | null;
count: number;
}
export interface DebugNetworkMessageGroup {
id: string;
scope: DebugNetworkMessageScope;
direction: DebugNetworkMessageDirection;
type: string;
count: number;
lastSeen: number;
}
export interface DebugNetworkHandshakeStats {
answersReceived: number;
answersSent: number;
iceReceived: number;
iceSent: number;
offersReceived: number;
offersSent: number;
}
export interface DebugNetworkTextStats {
received: number;
sent: number;
}
export interface DebugNetworkStreamStats {
audio: number;
video: number;
}
export interface DebugNetworkDownloadStats {
audioMbps: number | null;
fileMbps: number | null;
videoMbps: number | null;
}
export interface DebugNetworkNode {
id: string;
identity: string | null;
userId: string | null;
kind: DebugNetworkNodeKind;
label: string;
secondaryLabel: string;
title: string;
statuses: string[];
isActive: boolean;
isVoiceConnected: boolean;
isTyping: boolean;
isSpeaking: boolean;
isMuted: boolean;
isDeafened: boolean;
isStreaming: boolean;
connectionDrops: number;
downloads: DebugNetworkDownloadStats;
handshake: DebugNetworkHandshakeStats;
pingMs: number | null;
streams: DebugNetworkStreamStats;
textMessages: DebugNetworkTextStats;
lastSeen: number;
}
export interface DebugNetworkEdge {
id: string;
kind: DebugNetworkEdgeKind;
sourceId: string;
targetId: string;
sourceLabel: string;
targetLabel: string;
label: string;
stateLabel: string;
isActive: boolean;
pingMs: number | null;
lastSeen: number;
messageTotal: number;
messageGroups: DebugNetworkMessageGroup[];
}
export interface DebugNetworkSummary {
clientCount: number;
serverCount: number;
signalingServerCount: number;
peerConnectionCount: number;
membershipCount: number;
messageCount: number;
typingCount: number;
speakingCount: number;
streamingCount: number;
}
export interface DebugNetworkSnapshot {
nodes: DebugNetworkNode[];
edges: DebugNetworkEdge[];
summary: DebugNetworkSummary;
generatedAt: number;
}
export interface DebuggingSettingsState {
enabled?: boolean;
}
export interface PendingDebugEntry {
level: DebugLogLevel;
source: string;
message: string;
payload?: unknown;
payloadText?: string | null;
timestamp?: number;
}
export type ConsoleMethodName = 'debug' | 'log' | 'info' | 'warn' | 'error';
export type ConsoleMethod = (...args: unknown[]) => void;
export interface MutableDebugNetworkMessageGroup {
id: string;
scope: DebugNetworkMessageScope;
direction: DebugNetworkMessageDirection;
type: string;
count: number;
lastSeen: number;
}
export interface MutableDebugNetworkNode {
id: string;
identity: string | null;
userId: string | null;
kind: DebugNetworkNodeKind;
label: string;
secondaryLabel: string;
title: string;
lastSeen: number;
isActive: boolean;
isVoiceConnected: boolean;
typingExpiresAt: number | null;
isSpeaking: boolean;
isMuted: boolean;
isDeafened: boolean;
isStreaming: boolean;
connectionDrops: number;
downloads: DebugNetworkDownloadStats;
downloadsUpdatedAt: number | null;
fileTransferSamples: DebugNetworkTransferSample[];
handshake: DebugNetworkHandshakeStats;
pingMs: number | null;
streams: DebugNetworkStreamStats;
textMessages: DebugNetworkTextStats;
}
export interface DebugNetworkTransferSample {
bytes: number;
timestamp: number;
}
export interface MutableDebugNetworkEdge {
id: string;
kind: DebugNetworkEdgeKind;
sourceId: string;
targetId: string;
stateLabel: string;
isActive: boolean;
pingMs: number | null;
lastSeen: number;
messageTotal: number;
messageGroups: Map<string, MutableDebugNetworkMessageGroup>;
}
export interface DebugNetworkBuildState {
currentRoom: Room | null;
currentUser: User | null;
edges: Map<string, MutableDebugNetworkEdge>;
localIds: Set<string>;
nodes: Map<string, MutableDebugNetworkNode>;
users: readonly User[];
userLookup: Map<string, User>;
}

View File

@@ -0,0 +1,52 @@
/**
* Transitional compatibility barrel.
*
* All business types now live in `src/app/shared-kernel/` (organised by concept)
* or in their owning domain. This file re-exports everything so existing
* `import { X } from 'core/models'` lines keep working while the codebase
* migrates to direct shared-kernel imports.
*
* NEW CODE should import from `@shared-kernel` or the owning domain barrel
* instead of this file.
*/
export type {
User,
UserStatus,
UserRole,
RoomMember
} from '../../shared-kernel';
export type {
Room,
RoomSettings,
RoomPermissions,
Channel,
ChannelType
} from '../../shared-kernel';
export type { Message, Reaction } from '../../shared-kernel';
export { DELETED_MESSAGE_CONTENT } from '../../shared-kernel';
export type { BanEntry } from '../../shared-kernel';
export type { VoiceState, ScreenShareState } from '../../shared-kernel';
export type {
ChatEventBase,
ChatEventType,
ChatEvent,
ChatInventoryItem
} from '../../shared-kernel';
export type {
SignalingMessage,
SignalingMessageType
} from '../../shared-kernel';
export type {
ChatAttachmentAnnouncement,
ChatAttachmentMeta
} from '../../shared-kernel';
export type { ServerInfo } from '../../domains/server-directory';

View File

@@ -0,0 +1,150 @@
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
active: boolean;
monitorCaptureSupported: boolean;
screenShareSinkName: string;
screenShareMonitorSourceName: string;
voiceSinkName: string;
reason?: string;
}
export interface LinuxScreenShareMonitorCaptureInfo {
bitsPerSample: number;
captureId: string;
channelCount: number;
sampleRate: number;
sourceName: string;
}
export interface LinuxScreenShareMonitorAudioChunkPayload {
captureId: string;
chunk: Uint8Array;
}
export interface LinuxScreenShareMonitorAudioEndedPayload {
captureId: string;
reason?: string;
}
export interface ClipboardFilePayload {
data: string;
lastModified: number;
mime: string;
name: string;
path?: string;
}
export type AutoUpdateMode = 'auto' | 'off' | 'version';
export type DesktopUpdateStatus =
| 'idle'
| 'disabled'
| 'checking'
| 'downloading'
| 'up-to-date'
| 'restart-required'
| 'unsupported'
| 'no-manifest'
| 'target-unavailable'
| 'target-older-than-installed'
| 'error';
export type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable';
export interface DesktopUpdateServerContext {
manifestUrls: string[];
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
configuredManifestUrls: string[];
currentVersion: string;
defaultManifestUrls: string[];
isSupported: boolean;
lastCheckedAt: number | null;
latestVersion: string | null;
manifestUrl: string | null;
manifestUrls: string[];
minimumServerVersion: string | null;
preferredVersion: string | null;
restartRequired: boolean;
serverBlocked: boolean;
serverBlockMessage: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
status: DesktopUpdateStatus;
statusMessage: string | null;
targetVersion: string | null;
}
export interface DesktopSettingsSnapshot {
autoUpdateMode: AutoUpdateMode;
autoStart: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}
export interface DesktopSettingsPatch {
autoUpdateMode?: AutoUpdateMode;
autoStart?: boolean;
hardwareAcceleration?: boolean;
manifestUrls?: string[];
preferredVersion?: string | null;
vaapiVideoEncode?: boolean;
}
export interface ElectronCommand {
type: string;
payload: unknown;
}
export interface ElectronQuery {
type: string;
payload: unknown;
}
export interface ElectronApi {
linuxDisplayServer: string;
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
openExternal: (url: string) => Promise<boolean>;
getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>;
prepareLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
activateLinuxScreenShareAudioRouting: () => Promise<LinuxScreenShareAudioRoutingInfo>;
deactivateLinuxScreenShareAudioRouting: () => Promise<boolean>;
startLinuxScreenShareMonitorCapture: () => Promise<LinuxScreenShareMonitorCaptureInfo>;
stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise<boolean>;
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
consumePendingDeepLink: () => Promise<string | null>;
getDesktopSettings: () => Promise<DesktopSettingsSnapshot>;
getAutoUpdateState: () => Promise<DesktopUpdateState>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>;
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
setDesktopSettings: (patch: DesktopSettingsPatch) => Promise<DesktopSettingsSnapshot>;
relaunchApp: () => Promise<boolean>;
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>;
fileExists: (filePath: string) => Promise<boolean>;
deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>;
command: <T = unknown>(command: ElectronCommand) => Promise<T>;
query: <T = unknown>(query: ElectronQuery) => Promise<T>;
}
export type ElectronWindow = Window & {
electronAPI?: ElectronApi;
};

View File

@@ -0,0 +1,24 @@
import { Injectable } from '@angular/core';
import type { ElectronApi } from './electron-api.models';
import { getElectronApi } from './get-electron-api';
@Injectable({ providedIn: 'root' })
export class ElectronBridgeService {
get isAvailable(): boolean {
return this.getApi() !== null;
}
getApi(): ElectronApi | null {
return getElectronApi();
}
requireApi(): ElectronApi {
const api = this.getApi();
if (!api) {
throw new Error('Electron API is not available in this runtime.');
}
return api;
}
}

View File

@@ -0,0 +1,7 @@
import type { ElectronApi, ElectronWindow } from './electron-api.models';
export function getElectronApi(): ElectronApi | null {
return typeof window !== 'undefined'
? (window as ElectronWindow).electronAPI ?? null
: null;
}

View File

@@ -0,0 +1,60 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from './electron/electron-bridge.service';
/**
* Opens URLs in the system default browser (Electron) or a new tab (browser).
*
* Usage:
* inject(ExternalLinkService).open('https://example.com');
*/
@Injectable({ providedIn: 'root' })
export class ExternalLinkService {
private readonly electronBridge = inject(ElectronBridgeService);
/** Open a URL externally. Only http/https URLs are allowed. */
open(url: string): void {
if (!url || !(url.startsWith('http://') || url.startsWith('https://')))
return;
const electronApi = this.electronBridge.getApi();
if (electronApi) {
void electronApi.openExternal(url);
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}
/**
* Click handler for anchor elements. Call from a (click) binding or HostListener.
* Returns true if the click was handled (link opened externally), false otherwise.
*/
handleClick(evt: MouseEvent): boolean {
const target = (evt.target as HTMLElement)?.closest('a') as HTMLAnchorElement | null;
if (!target)
return false;
const href = target.href;
if (!href)
return false;
if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:'))
return false;
const rawAttr = target.getAttribute('href');
if (rawAttr?.startsWith('#'))
return false;
if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link'))
return false;
evt.preventDefault();
evt.stopPropagation();
this.open(href);
return true;
}
}

View File

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

View File

@@ -0,0 +1,15 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from './electron/electron-bridge.service';
@Injectable({ providedIn: 'root' })
export class PlatformService {
readonly isElectron: boolean;
readonly isBrowser: boolean;
private readonly electronBridge = inject(ElectronBridgeService);
constructor() {
this.isElectron = this.electronBridge.isAvailable;
this.isBrowser = !this.isElectron;
}
}

View File

@@ -0,0 +1,8 @@
/**
* Transitional application-facing boundary over the shared realtime runtime.
* Keep business domains depending on this technical API rather than reaching
* into low-level infrastructure implementations directly.
*/
export { WebRTCService as RealtimeSessionFacade } from '../../infrastructure/realtime/realtime-session.service';
export * from '../../infrastructure/realtime/realtime.constants';
export * from '../../infrastructure/realtime/realtime.types';

View File

@@ -0,0 +1,2 @@
export * from '../models/debugging.models';
export * from './debugging/debugging.service';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
import type {
DebugNetworkDownloadStats,
DebugNetworkHandshakeStats,
DebugNetworkTextStats,
DebugNetworkStreamStats
} from '../../models/debugging.models';
export const LOCAL_NETWORK_NODE_ID = 'client:local';
export const NETWORK_TYPING_TTL_MS = 4_500;
export const NETWORK_RECENT_EDGE_TTL_MS = 30_000;
export const NETWORK_MESSAGE_GROUP_LIMIT = 12;
export const NETWORK_FILE_TRANSFER_RATE_WINDOW_MS = 6_000;
export const NETWORK_DOWNLOAD_STAT_TTL_MS = 8_000;
export const NETWORK_IGNORED_MESSAGE_TYPES = new Set([
'ping',
'pong',
'state-request',
'voice-state-request'
]);
export function createEmptyHandshakeStats(): DebugNetworkHandshakeStats {
return {
answersReceived: 0,
answersSent: 0,
iceReceived: 0,
iceSent: 0,
offersReceived: 0,
offersSent: 0
};
}
export function createEmptyTextStats(): DebugNetworkTextStats {
return {
received: 0,
sent: 0
};
}
export function createEmptyStreamStats(): DebugNetworkStreamStats {
return {
audio: 0,
video: 0
};
}
export function createEmptyDownloadStats(): DebugNetworkDownloadStats {
return {
audioMbps: null,
fileMbps: null,
videoMbps: null
};
}

View File

@@ -0,0 +1,811 @@
import {
Injectable,
computed,
inject,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import {
Event as RouterNavigationEvent,
NavigationCancel,
NavigationEnd,
NavigationError,
NavigationStart,
Router
} from '@angular/router';
import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectAllUsers, selectCurrentUser } from '../../../store/users/users.selectors';
import { DEBUG_LOG_MAX_ENTRIES, STORAGE_KEY_DEBUGGING_SETTINGS } from '../../constants';
import { buildDebugNetworkSnapshot } from './debugging-network-snapshot.builder';
import type {
ConsoleMethod,
ConsoleMethodName,
DebugLogEntry,
DebugLogLevel,
DebugNetworkSnapshot,
DebuggingSettingsState,
PendingDebugEntry
} from '../../models/debugging.models';
@Injectable({ providedIn: 'root' })
export class DebuggingService {
readonly enabled = signal(false);
readonly entries = signal<DebugLogEntry[]>([]);
readonly isConsoleOpen = signal(false);
readonly networkSnapshot = computed<DebugNetworkSnapshot>(() =>
buildDebugNetworkSnapshot(
this.entries(),
this.currentUser() ?? null,
this.allUsers() ?? [],
this.currentRoom() ?? null
)
);
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private readonly allUsers = this.store.selectSignal(selectAllUsers);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private readonly timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
hour12: false
});
private readonly dateTimeFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
hour12: false
});
private readonly originalConsoleMethods: Record<ConsoleMethodName, ConsoleMethod> = {
debug: console.debug.bind(console),
log: console.log.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console)
};
private initialized = false;
private nextEntryId = 1;
private pendingEntries: PendingDebugEntry[] = [];
private flushQueued = false;
constructor() {
this.loadSettings();
this.init();
}
init(): void {
if (this.initialized)
return;
this.initialized = true;
this.patchConsole();
this.trackRouterEvents();
this.trackGlobalEvents();
}
setEnabled(enabled: boolean): void {
if (enabled === this.enabled())
return;
if (enabled) {
this.enabled.set(true);
this.persistSettings();
this.info('debugging', 'Debugging service enabled');
return;
}
this.info('debugging', 'Debugging service disabled');
this.enabled.set(false);
this.isConsoleOpen.set(false);
this.persistSettings();
}
toggleConsole(): void {
if (!this.enabled())
return;
this.isConsoleOpen.update((open) => !open);
}
openConsole(): void {
if (!this.enabled())
return;
this.isConsoleOpen.set(true);
}
closeConsole(): void {
this.isConsoleOpen.set(false);
}
clear(): void {
this.pendingEntries = [];
this.entries.set([]);
}
recordEvent(source: string, message: string, payload?: unknown): void {
if (!this.enabled())
return;
this.pushEntry({ level: 'event',
source,
message,
payload });
}
info(source: string, message: string, payload?: unknown): void {
if (!this.enabled())
return;
this.pushEntry({ level: 'info',
source,
message,
payload });
}
warn(source: string, message: string, payload?: unknown): void {
if (!this.enabled())
return;
this.pushEntry({ level: 'warn',
source,
message,
payload });
}
error(source: string, message: string, payload?: unknown): void {
if (!this.enabled())
return;
this.pushEntry({ level: 'error',
source,
message,
payload });
}
private loadSettings(): void {
try {
const raw = localStorage.getItem(STORAGE_KEY_DEBUGGING_SETTINGS);
if (!raw)
return;
const parsed = JSON.parse(raw) as DebuggingSettingsState;
this.enabled.set(parsed.enabled === true);
} catch {}
}
private persistSettings(): void {
try {
localStorage.setItem(
STORAGE_KEY_DEBUGGING_SETTINGS,
JSON.stringify({ enabled: this.enabled() })
);
} catch {}
}
private patchConsole(): void {
this.patchConsoleMethod('debug', 'debug');
this.patchConsoleMethod('log', 'info');
this.patchConsoleMethod('info', 'info');
this.patchConsoleMethod('warn', 'warn');
this.patchConsoleMethod('error', 'error');
}
private patchConsoleMethod(methodName: ConsoleMethodName, level: DebugLogLevel): void {
const originalMethod = this.originalConsoleMethods[methodName];
const consoleRef = console as unknown as Record<ConsoleMethodName, ConsoleMethod>;
consoleRef[methodName] = (...args: unknown[]) => {
originalMethod(...args);
this.captureConsoleMessage(level, args);
};
}
private captureConsoleMessage(level: DebugLogLevel, args: readonly unknown[]): void {
if (!this.enabled())
return;
const rawMessage = args.map((arg) => this.stringifyPreview(arg)).join(' ')
.trim() || '(empty console call)';
// Use only string args for label/message extraction so that
// stringified object payloads don't pollute the parsed message.
// Object payloads are captured separately via extractConsolePayload.
const metadataSource = args
.filter((arg): arg is string => typeof arg === 'string')
.join(' ')
.trim() || rawMessage;
const consoleMetadata = this.extractConsoleMetadata(metadataSource);
const payload = this.extractConsolePayload(args);
const payloadText = payload === undefined
? null
: this.stringifyPayload(payload);
this.pushEntry({ level,
source: consoleMetadata.source,
message: consoleMetadata.message,
payload,
payloadText });
}
private extractConsoleMetadata(rawMessage: string): { source: string; message: string } {
const labels: string[] = [];
let remainder = rawMessage.trim();
while (remainder.startsWith('[')) {
const closingIndex = remainder.indexOf(']');
if (closingIndex <= 1)
break;
labels.push(remainder.slice(1, closingIndex));
remainder = remainder.slice(closingIndex + 1).trim();
}
if (labels.length === 0) {
return {
source: 'console',
message: rawMessage
};
}
const normalizedLabels = labels.map((label) => this.normalizeConsoleLabel(label));
if (normalizedLabels[0] === 'webrtc') {
return {
source: normalizedLabels[1] ? `webrtc:${normalizedLabels[1]}` : 'webrtc',
message: remainder || rawMessage
};
}
return {
source: normalizedLabels[0] || 'console',
message: remainder || rawMessage
};
}
private normalizeConsoleLabel(label: string): string {
return label.trim().toLowerCase()
.replace(/\s+/g, '-');
}
private extractConsolePayload(args: readonly unknown[]): unknown {
const structuredArgs = args.filter((arg) => typeof arg !== 'string');
if (structuredArgs.length === 0)
return undefined;
if (structuredArgs.length === 1)
return structuredArgs[0];
return structuredArgs;
}
private trackRouterEvents(): void {
this.router.events.subscribe((event) => {
if (!this.enabled())
return;
this.captureRouterEvent(event);
});
}
private captureRouterEvent(event: RouterNavigationEvent): void {
if (event instanceof NavigationStart) {
this.pushEntry({ level: 'event',
source: 'router',
message: `Navigation started: ${event.url}`,
payload: {
id: event.id,
url: event.url,
navigationTrigger: event.navigationTrigger,
restoredState: event.restoredState ?? null
}
});
return;
}
if (event instanceof NavigationEnd) {
this.pushEntry({ level: 'event',
source: 'router',
message: `Navigation completed: ${event.urlAfterRedirects}`,
payload: {
id: event.id,
url: event.url,
urlAfterRedirects: event.urlAfterRedirects
}
});
return;
}
if (event instanceof NavigationCancel) {
this.pushEntry({ level: 'warn',
source: 'router',
message: `Navigation cancelled: ${event.url}`,
payload: {
id: event.id,
url: event.url,
reason: event.reason,
code: event.code
}
});
return;
}
if (event instanceof NavigationError) {
this.pushEntry({ level: 'error',
source: 'router',
message: `Navigation failed: ${event.url}`,
payload: {
id: event.id,
url: event.url,
error: event.error
}
});
}
}
private trackGlobalEvents(): void {
window.addEventListener('error', (event) => {
if (!this.enabled())
return;
this.pushEntry({ level: 'error',
source: 'window',
message: event.message || 'Unhandled runtime error',
payload: {
filename: event.filename || null,
line: event.lineno || null,
column: event.colno || null,
error: event.error
}
});
});
window.addEventListener('unhandledrejection', (event) => {
if (!this.enabled())
return;
this.pushEntry({ level: 'error',
source: 'window',
message: 'Unhandled promise rejection',
payload: event.reason
});
});
window.addEventListener('online', () => {
this.recordEvent('window', 'Browser connection restored');
});
window.addEventListener('offline', () => {
this.warn('window', 'Browser went offline');
});
document.addEventListener('visibilitychange', () => {
this.recordEvent('document', `Visibility changed: ${document.visibilityState}`, {
visibilityState: document.visibilityState
});
});
document.addEventListener('click', (event) => this.captureDocumentEvent(event), true);
document.addEventListener('change', (event) => this.captureDocumentEvent(event), true);
document.addEventListener('submit', (event) => this.captureDocumentEvent(event), true);
document.addEventListener('keydown', (event) => this.captureDocumentEvent(event), true);
}
private captureDocumentEvent(event: Event): void {
if (!this.enabled())
return;
if (this.isIgnoredTarget(event.target))
return;
if (event instanceof KeyboardEvent && !this.shouldTrackKeyboardEvent(event))
return;
const payload: Record<string, unknown> = {
type: event.type,
target: this.describeElement(event.target)
};
const controlMetadata = this.describeControl(event.target);
if (controlMetadata)
payload['control'] = controlMetadata;
if (event instanceof KeyboardEvent) {
payload['key'] = event.key;
payload['code'] = event.code;
payload['altKey'] = event.altKey;
payload['ctrlKey'] = event.ctrlKey;
payload['metaKey'] = event.metaKey;
payload['shiftKey'] = event.shiftKey;
}
this.pushEntry({ level: 'event',
source: 'ui',
message: this.describeInteraction(event),
payload });
}
private shouldTrackKeyboardEvent(event: KeyboardEvent): boolean {
if (event.ctrlKey || event.metaKey || event.altKey)
return true;
return event.key === 'Enter' || event.key === 'Escape' || event.key === 'Tab';
}
private isIgnoredTarget(target: EventTarget | null): boolean {
const element = this.asElement(target);
if (!element)
return false;
return !!element.closest('[data-debug-console-root="true"]');
}
private describeInteraction(event: Event): string {
const element = this.asElement(event.target);
const targetLabel = element ? this.buildElementLabel(element) : 'unknown target';
if (event instanceof KeyboardEvent)
return `keydown (${event.key}) on ${targetLabel}`;
return `${event.type} on ${targetLabel}`;
}
private buildElementLabel(element: Element): string {
const tagName = element.tagName.toLowerCase();
const parts = [tagName];
const id = element.id.trim();
const type = element.getAttribute('type');
const name = element.getAttribute('name');
const ariaLabel = element.getAttribute('aria-label');
const placeholder = element.getAttribute('placeholder');
const interactiveText = this.getInteractiveText(element);
if (id)
parts.push(`#${id}`);
if (type)
parts.push(`type=${type}`);
if (name) {
parts.push(`name=${name}`);
} else if (ariaLabel) {
parts.push(`label=${ariaLabel}`);
} else if (placeholder) {
parts.push(`placeholder=${placeholder}`);
} else if (interactiveText) {
parts.push(`text=${interactiveText}`);
}
return parts.join(' ');
}
private getInteractiveText(element: Element): string | null {
const tagName = element.tagName.toLowerCase();
if (tagName !== 'button' && tagName !== 'a')
return null;
const text = element.textContent?.trim().replace(/\s+/g, ' ') || '';
if (!text)
return null;
return text.slice(0, 60);
}
private describeElement(target: EventTarget | null): Record<string, unknown> | null {
const element = this.asElement(target);
if (!element)
return null;
const payload: Record<string, unknown> = {
tag: element.tagName.toLowerCase()
};
const id = element.id.trim();
const type = element.getAttribute('type');
const role = element.getAttribute('role');
const name = element.getAttribute('name');
const ariaLabel = element.getAttribute('aria-label');
const placeholder = element.getAttribute('placeholder');
const interactiveText = this.getInteractiveText(element);
if (id)
payload['id'] = id;
if (type)
payload['type'] = type;
if (role)
payload['role'] = role;
if (name)
payload['name'] = name;
if (ariaLabel)
payload['ariaLabel'] = ariaLabel;
if (placeholder)
payload['placeholder'] = placeholder;
if (interactiveText)
payload['text'] = interactiveText;
return payload;
}
private describeControl(target: EventTarget | null): Record<string, unknown> | null {
if (target instanceof HTMLInputElement) {
if (target.type === 'checkbox' || target.type === 'radio') {
return {
type: target.type,
checked: target.checked
};
}
if (target.type === 'file') {
return {
type: target.type,
filesCount: target.files?.length ?? 0
};
}
return {
type: target.type || 'text',
hasValue: target.value.length > 0,
valueLength: target.value.length,
masked: target.type === 'password'
};
}
if (target instanceof HTMLTextAreaElement) {
return {
type: 'textarea',
hasValue: target.value.length > 0,
valueLength: target.value.length
};
}
if (target instanceof HTMLSelectElement) {
return {
type: 'select',
hasValue: target.value.length > 0,
selectedIndex: target.selectedIndex
};
}
return null;
}
private asElement(target: EventTarget | null): Element | null {
if (target instanceof Element)
return target;
if (target instanceof Node)
return target.parentElement;
return null;
}
private pushEntry(entry: PendingDebugEntry): void {
this.pendingEntries.push(entry);
this.schedulePendingEntryFlush();
}
private schedulePendingEntryFlush(): void {
if (this.flushQueued)
return;
this.flushQueued = true;
Promise.resolve().then(() => {
this.flushQueued = false;
this.flushPendingEntries();
if (this.pendingEntries.length > 0)
this.schedulePendingEntryFlush();
});
}
private flushPendingEntries(): void {
if (this.pendingEntries.length === 0)
return;
const pendingEntries = this.pendingEntries;
this.pendingEntries = [];
let nextEntries = this.entries();
for (const entry of pendingEntries) {
const nextEntry = this.createEntry(entry);
const lastEntry = nextEntries[nextEntries.length - 1];
if (lastEntry && this.canCollapseEntries(lastEntry, nextEntry)) {
nextEntries = [
...nextEntries.slice(0, -1),
{
...lastEntry,
count: lastEntry.count + 1,
timestamp: nextEntry.timestamp,
timeLabel: nextEntry.timeLabel,
dateTimeLabel: nextEntry.dateTimeLabel
}
];
continue;
}
nextEntries = [...nextEntries, nextEntry].slice(-DEBUG_LOG_MAX_ENTRIES);
}
this.entries.set(nextEntries);
}
private createEntry(entry: PendingDebugEntry): DebugLogEntry {
const timestamp = entry.timestamp ?? Date.now();
const hasPayload = entry.payload !== undefined;
const normalizedPayload = hasPayload
? this.normalizePayload(entry.payload)
: null;
const payloadText = entry.payloadText === undefined
? (hasPayload ? this.stringifyNormalizedPayload(normalizedPayload) : null)
: entry.payloadText;
const nextEntry: DebugLogEntry = {
id: this.nextEntryId++,
timestamp,
timeLabel: this.timeFormatter.format(timestamp),
dateTimeLabel: this.dateTimeFormatter.format(timestamp),
level: entry.level,
source: entry.source,
message: entry.message,
payload: normalizedPayload,
payloadText,
count: 1
};
return nextEntry;
}
private canCollapseEntries(previousEntry: DebugLogEntry, nextEntry: DebugLogEntry): boolean {
const withinWindow = nextEntry.timestamp - previousEntry.timestamp <= 1000;
return withinWindow
&& previousEntry.level === nextEntry.level
&& previousEntry.source === nextEntry.source
&& previousEntry.message === nextEntry.message
&& previousEntry.payloadText === nextEntry.payloadText;
}
private stringifyPreview(value: unknown): string {
if (typeof value === 'string')
return value;
if (value instanceof Error)
return `${value.name}: ${value.message}`;
const payloadText = this.stringifyPayload(value);
if (!payloadText)
return String(value);
return payloadText.replace(/\s+/g, ' ').slice(0, 200);
}
private stringifyPayload(value: unknown): string | null {
if (value === undefined)
return null;
return this.stringifyNormalizedPayload(this.normalizePayload(value));
}
private normalizePayload(value: unknown): unknown {
try {
return this.normalizeValue(value, 0, new WeakSet<object>());
} catch {
try {
return String(value);
} catch {
return '[unserializable value]';
}
}
}
private stringifyNormalizedPayload(value: unknown): string | null {
try {
const json = JSON.stringify(value, null, 2);
if (typeof json === 'string')
return json;
return value === undefined ? null : String(value);
} catch {
try {
return String(value);
} catch {
return '[unserializable value]';
}
}
}
private normalizeValue(value: unknown, depth: number, seen: WeakSet<object>): unknown {
if (
value === null
|| typeof value === 'string'
|| typeof value === 'number'
|| typeof value === 'boolean'
) {
return value;
}
if (typeof value === 'bigint')
return value.toString();
if (typeof value === 'function')
return `[Function ${value.name || 'anonymous'}]`;
if (value instanceof Date)
return value.toISOString();
if (value instanceof Error) {
return {
name: value.name,
message: value.message,
stack: value.stack
};
}
if (value instanceof Event) {
return {
type: value.type,
target: this.describeElement(value.target),
currentTarget: this.describeElement(value.currentTarget),
timeStamp: value.timeStamp
};
}
if (value instanceof HTMLElement)
return this.describeElement(value);
if (typeof value !== 'object')
return String(value);
if (seen.has(value))
return '[Circular]';
seen.add(value);
if (depth >= 3)
return `[${value.constructor?.name || 'Object'}]`;
if (Array.isArray(value)) {
return value.slice(0, 20).map((item) => this.normalizeValue(item, depth + 1, seen));
}
const normalizedObject: Record<string, unknown> = {};
const objectValue = value as Record<string, unknown>;
for (const key of Object.keys(objectValue).slice(0, 20)) {
normalizedObject[key] = this.normalizeValue(objectValue[key], depth + 1, seen);
}
return normalizedObject;
}
}

View File

@@ -0,0 +1,2 @@
export * from '../../models/debugging.models';
export * from './debugging.service';

View File

@@ -0,0 +1,347 @@
import {
Injectable,
Injector,
effect,
inject,
signal
} from '@angular/core';
import { PlatformService } from '../platform';
import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory';
import {
type AutoUpdateMode,
type DesktopUpdateServerContext,
type DesktopUpdateServerVersionStatus,
type DesktopUpdateState,
type ElectronApi
} from '../platform/electron/electron-api.models';
import { ElectronBridgeService } from '../platform/electron/electron-bridge.service';
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface ServerHealthSnapshot {
endpointId: string;
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000;
const SERVER_CONTEXT_TIMEOUT_MS = 5_000;
function createInitialState(): DesktopUpdateState {
return {
autoUpdateMode: 'auto',
availableVersions: [],
configuredManifestUrls: [],
currentVersion: '0.0.0',
defaultManifestUrls: [],
isSupported: false,
lastCheckedAt: null,
latestVersion: null,
manifestUrl: null,
manifestUrls: [],
minimumServerVersion: null,
preferredVersion: null,
restartRequired: false,
serverBlocked: false,
serverBlockMessage: null,
serverVersion: null,
serverVersionStatus: 'unknown',
status: 'idle',
statusMessage: null,
targetVersion: null
};
}
function normalizeOptionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeOptionalHttpUrl(value: unknown): string | null {
const nextValue = normalizeOptionalString(value);
if (!nextValue) {
return null;
}
try {
const parsedUrl = new URL(nextValue);
return parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:'
? parsedUrl.toString().replace(/\/+$/, '')
: null;
} catch {
return null;
}
}
function normalizeUrlList(values: readonly unknown[]): string[] {
const manifestUrls: string[] = [];
for (const entry of values) {
const manifestUrl = normalizeOptionalHttpUrl(entry);
if (!manifestUrl || manifestUrls.includes(manifestUrl)) {
continue;
}
manifestUrls.push(manifestUrl);
}
return manifestUrls;
}
@Injectable({ providedIn: 'root' })
export class DesktopAppUpdateService {
readonly isElectron = inject(PlatformService).isElectron;
readonly state = signal<DesktopUpdateState>(createInitialState());
private injector = inject(Injector);
private servers = inject(ServerDirectoryFacade);
private electronBridge = inject(ElectronBridgeService);
private initialized = false;
private refreshTimerId: number | null = null;
private removeStateListener: (() => void) | null = null;
async initialize(): Promise<void> {
if (!this.isElectron || this.initialized) {
return;
}
this.initialized = true;
this.setupServerWatcher();
this.startRefreshTimer();
const api = this.getElectronApi();
if (!api) {
return;
}
try {
const currentState = await api.getAutoUpdateState?.();
if (currentState) {
this.state.set(currentState);
}
} catch {}
if (api.onAutoUpdateStateChanged) {
this.removeStateListener?.();
this.removeStateListener = api.onAutoUpdateStateChanged((nextState) => {
this.state.set(nextState);
});
}
await this.refreshServerContext();
}
async refreshServerContext(): Promise<void> {
if (!this.isElectron) {
return;
}
await this.syncServerHealth();
}
async checkForUpdates(): Promise<void> {
const api = this.getElectronApi();
if (!api?.checkForAppUpdates) {
return;
}
try {
const nextState = await api.checkForAppUpdates();
this.state.set(nextState);
} catch {}
}
async saveUpdatePreferences(mode: AutoUpdateMode, preferredVersion: string | null): Promise<void> {
const api = this.getElectronApi();
if (!api?.setDesktopSettings) {
return;
}
try {
await api.setDesktopSettings({
autoUpdateMode: mode,
preferredVersion: normalizeOptionalString(preferredVersion)
});
if (api.getAutoUpdateState) {
const nextState = await api.getAutoUpdateState();
this.state.set(nextState);
}
} catch {}
}
async saveManifestUrls(manifestUrls: string[]): Promise<void> {
const api = this.getElectronApi();
if (!api?.setDesktopSettings) {
return;
}
try {
await api.setDesktopSettings({
manifestUrls: normalizeUrlList(manifestUrls)
});
if (api.getAutoUpdateState) {
const nextState = await api.getAutoUpdateState();
this.state.set(nextState);
}
} catch {}
}
async restartToApplyUpdate(): Promise<void> {
const api = this.getElectronApi();
if (!api?.restartToApplyUpdate) {
return;
}
try {
await api.restartToApplyUpdate();
} catch {}
}
private setupServerWatcher(): void {
effect(() => {
this.servers.servers();
if (!this.initialized) {
return;
}
void this.syncServerHealth();
}, { injector: this.injector });
}
private startRefreshTimer(): void {
if (this.refreshTimerId !== null || typeof window === 'undefined') {
return;
}
this.refreshTimerId = window.setInterval(() => {
void this.refreshServerContext();
}, SERVER_CONTEXT_REFRESH_INTERVAL_MS);
}
private async syncServerHealth(): Promise<void> {
const api = this.getElectronApi();
if (!api?.configureAutoUpdateContext) {
return;
}
const endpoints = this.getPrioritizedServers();
if (endpoints.length === 0) {
await this.pushContext({
manifestUrls: [],
serverVersion: null,
serverVersionStatus: 'unknown'
});
return;
}
const healthSnapshots = await Promise.all(
endpoints.map((endpoint) => this.readServerHealth(endpoint))
);
const activeEndpoint = this.servers.activeServer() ?? endpoints[0] ?? null;
const activeSnapshot = activeEndpoint
? healthSnapshots.find((snapshot) => snapshot.endpointId === activeEndpoint.id) ?? null
: null;
await this.pushContext({
manifestUrls: normalizeUrlList(
healthSnapshots.map((snapshot) => snapshot.manifestUrl)
),
serverVersion: activeSnapshot?.serverVersion ?? null,
serverVersionStatus: activeSnapshot?.serverVersionStatus ?? 'unknown'
});
}
private getPrioritizedServers(): ServerEndpoint[] {
const endpoints = [...this.servers.servers()];
const activeServerId = this.servers.activeServer()?.id ?? null;
return endpoints.sort((left, right) => {
if (left.id === activeServerId) {
return -1;
}
if (right.id === activeServerId) {
return 1;
}
return 0;
});
}
private async readServerHealth(endpoint: ServerEndpoint): Promise<ServerHealthSnapshot> {
const sanitizedServerUrl = endpoint.url.replace(/\/+$/, '');
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);
return {
endpointId: endpoint.id,
manifestUrl: normalizeOptionalHttpUrl(payload.releaseManifestUrl),
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
};
} catch {
return {
endpointId: endpoint.id,
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
}
private async pushContext(context: Partial<DesktopUpdateServerContext>): Promise<void> {
const api = this.getElectronApi();
if (!api?.configureAutoUpdateContext) {
return;
}
try {
const nextState = await api.configureAutoUpdateContext(context);
this.state.set(nextState);
} catch {}
}
private getElectronApi(): ElectronApi | null {
return this.electronBridge.getApi();
}
}

View File

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

View File

@@ -0,0 +1,116 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, signal } from '@angular/core';
/**
* All known sound effects shipped with the application.
*
* Each key maps to a file in `src/assets/audio/`.
*/
export enum AppSound {
Joining = 'joining',
Leave = 'leave',
Notification = 'notification'
}
/** Path prefix for audio assets (served from the `assets/audio/` folder). */
const AUDIO_BASE = '/assets/audio';
/** File extension used for all sound-effect assets. */
const AUDIO_EXT = 'wav';
/** localStorage key for persisting notification volume. */
const STORAGE_KEY_NOTIFICATION_VOLUME = 'metoyou_notification_volume';
/** Default notification volume (0 - 1). */
const DEFAULT_VOLUME = 0.2;
/**
* A lightweight audio playback service that pre-loads the shipped
* sound-effect files and lets any component / service trigger them
* by name.
*
* Usage:
* ```ts
* audioService.play(AppSound.Joining);
* ```
*/
@Injectable({ providedIn: 'root' })
export class NotificationAudioService {
/** Pre-loaded audio buffers keyed by {@link AppSound}. */
private readonly cache = new Map<AppSound, HTMLAudioElement>();
/** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume());
constructor() {
this.preload();
}
/** 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}`);
audio.preload = 'auto';
this.cache.set(sound, audio);
}
}
/** Read persisted volume from localStorage, falling back to the default. */
private loadVolume(): number {
try {
const raw = localStorage.getItem(STORAGE_KEY_NOTIFICATION_VOLUME);
if (raw !== null) {
const parsed = parseFloat(raw);
if (!isNaN(parsed))
return Math.max(0, Math.min(1, parsed));
}
} catch {}
return DEFAULT_VOLUME;
}
/**
* Update the notification volume and persist it.
*
* @param volume - A value between 0 (silent) and 1 (full).
*/
setNotificationVolume(volume: number): void {
const clamped = Math.max(0, Math.min(1, volume));
this.notificationVolume.set(clamped);
try {
localStorage.setItem(STORAGE_KEY_NOTIFICATION_VOLUME, String(clamped));
} catch {}
}
/**
* Play a sound effect at the current notification volume.
*
* If playback fails (e.g. browser autoplay policy) the error is
* silently swallowed - sound effects are non-critical.
*
* @param sound - The {@link AppSound} to play.
* @param volumeOverride - Optional explicit volume (0 - 1). When omitted
* the persisted {@link notificationVolume} is used.
*/
play(sound: AppSound, volumeOverride?: number): void {
const cached = this.cache.get(sound);
if (!cached)
return;
const vol = volumeOverride ?? this.notificationVolume();
if (vol === 0)
return; // skip playback when muted
// Clone so overlapping plays don't cut each other off.
const clone = cached.cloneNode(true) as HTMLAudioElement;
clone.volume = Math.max(0, Math.min(1, vol));
clone.play().catch(() => {
/* swallow autoplay errors */
});
}
}

View File

@@ -0,0 +1,23 @@
import { Injectable, signal } from '@angular/core';
export type SettingsPage = 'general' | 'network' | 'voice' | 'updates' | 'debugging' | 'server' | 'members' | 'bans' | 'permissions';
@Injectable({ providedIn: 'root' })
export class SettingsModalService {
readonly isOpen = signal(false);
readonly activePage = signal<SettingsPage>('general');
readonly targetServerId = signal<string | null>(null);
open(page: SettingsPage = 'general', serverId?: string): void {
this.activePage.set(page);
this.targetServerId.set(serverId ?? null);
this.isOpen.set(true);
}
close(): void {
this.isOpen.set(false);
}
navigate(page: SettingsPage): void {
this.activePage.set(page);
}
}

View File

@@ -0,0 +1,100 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
signal,
computed
} from '@angular/core';
/** Default timeout (ms) for the NTP-style HTTP sync request. */
const DEFAULT_SYNC_TIMEOUT_MS = 5000;
/**
* Maintains a clock-offset between the local system time and the
* remote signaling server.
*
* The offset is estimated using a simple NTP-style round-trip
* measurement and is stored as a reactive Angular signal so that
* any dependent computed value auto-updates when a new sync occurs.
*/
@Injectable({ providedIn: 'root' })
export class TimeSyncService {
/**
* Internal offset signal:
* `serverTime = Date.now() + offset`.
*/
private readonly _offset = signal<number>(0);
/** Epoch timestamp of the most recent successful sync. */
private lastSyncTimestamp = 0;
/** Reactive read-only offset (milliseconds). */
readonly offset = computed(() => this._offset());
/**
* Return a server-adjusted "now" timestamp.
*
* @returns Epoch milliseconds aligned to the server clock.
*/
now(): number {
return Date.now() + this._offset();
}
/**
* Set the offset from a known server timestamp.
*
* @param serverTime - Epoch timestamp reported by the server.
* @param receiveTimestamp - Local epoch timestamp when the server time was
* observed. Defaults to `Date.now()` if omitted.
*/
setFromServerTime(serverTime: number, receiveTimestamp?: number): void {
const observedAt = receiveTimestamp ?? Date.now();
this._offset.set(serverTime - observedAt);
this.lastSyncTimestamp = Date.now();
}
/**
* Perform an HTTP-based clock synchronisation using a simple
* NTP-style round-trip.
*
* 1. Record `clientSendTime` (`t0`).
* 2. Fetch `GET {baseApiUrl}/time`.
* 3. Record `clientReceiveTime` (`t2`).
* 4. Estimate one-way latency as `(t2 t0) / 2`.
* 5. Compute offset: `serverNow midpoint(t0, t2)`.
*
* Any network or parsing error is silently ignored so that the
* last known offset (or zero) is retained.
*
* @param baseApiUrl - API base URL (e.g. `http://host:3001/api`).
* @param timeoutMs - Maximum time to wait for the response.
*/
async syncWithEndpoint(
baseApiUrl: string,
timeoutMs: number = DEFAULT_SYNC_TIMEOUT_MS
): Promise<void> {
try {
const controller = new AbortController();
const clientSendTime = Date.now();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(`${baseApiUrl}/time`, {
signal: controller.signal
});
const clientReceiveTime = Date.now();
clearTimeout(timer);
if (!response.ok)
return;
const data = await response.json();
const serverNow = Number(data?.now) || Date.now();
const midpoint = (clientSendTime + clientReceiveTime) / 2;
this._offset.set(serverNow - midpoint);
this.lastSyncTimestamp = Date.now();
} catch {
// Sync failure is non-fatal; retain the previous offset.
}
}
}

View File

@@ -0,0 +1,62 @@
# Domains
Each folder below is a **bounded context** — a self-contained slice of
business logic with its own models, application services, and (optionally)
infrastructure adapters and UI.
## Quick reference
| Domain | Purpose | Public entry point |
|---|---|---|
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **voice-connection** | Voice activity detection, bitrate profiles | `VoiceConnectionFacade` |
| **voice-session** | Join/leave orchestration, voice settings persistence | `VoiceSessionFacade` |
## Folder convention
Every domain follows the same internal layout:
```
domains/<name>/
├── index.ts # Barrel — the ONLY file outsiders import
├── domain/ # Pure types, interfaces, business rules
│ ├── <name>.models.ts
│ └── <name>.logic.ts # Pure functions (no Angular, no side effects)
├── application/ # Angular services that orchestrate domain logic
│ └── <name>.facade.ts # Public entry point for the domain
├── infrastructure/ # Technical adapters (HTTP, storage, WebSocket)
└── feature/ # Optional: domain-owned UI components / routes
└── settings/ # e.g. settings subpanel owned by this domain
```
## Rules
1. **Import from the barrel.** Outside a domain, always import from
`domains/<name>` (the `index.ts`), never from internal paths.
2. **No cross-domain imports.** Domain A must never import from Domain B's
internals. Shared types live in `shared-kernel/`.
3. **Features compose domains.** Top-level `features/` components inject
domain facades and compose their outputs — they never contain business
logic.
4. **Store slices are application-level.** `store/messages`, `store/rooms`,
`store/users` are global state managed by NgRx. They import from
`shared-kernel` for types and from domain facades for side-effects.
## Where do I put new code?
| I want to… | Put it in… |
|---|---|
| Add a new business concept | New folder under `domains/` following the convention above |
| Add a type used by multiple domains | `shared-kernel/` with a descriptive file name |
| Add a UI component for a domain feature | `domains/<name>/feature/` or `domains/<name>/ui/` |
| Add a settings subpanel | `domains/<name>/feature/settings/` |
| Add a top-level page or shell component | `features/` |
| Add persistence logic | `infrastructure/persistence/` or `domains/<name>/infrastructure/` |
| Add realtime/WebRTC logic | `infrastructure/realtime/` |

View File

@@ -0,0 +1,148 @@
# Attachment Domain
Handles file sharing between peers over WebRTC data channels. Files are announced, chunked into 64 KB pieces, streamed peer-to-peer as base64, and optionally persisted to disk (Electron) or kept in memory (browser).
## Module map
```
attachment/
├── application/
│ ├── attachment.facade.ts Thin entry point, delegates to manager
│ ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners
│ ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel)
│ ├── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming
│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage
│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending)
├── domain/
│ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state
│ ├── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
│ ├── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...)
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
├── infrastructure/
│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete)
│ └── attachment-storage.helpers.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
└── index.ts Barrel exports
```
## Service composition
The facade is a thin pass-through. All real work happens inside the manager, which coordinates the transfer service (protocol), persistence service (DB/disk), and runtime store (signals).
```mermaid
graph TD
Facade[AttachmentFacade]
Manager[AttachmentManagerService]
Transfer[AttachmentTransferService]
Transport[AttachmentTransferTransportService]
Persistence[AttachmentPersistenceService]
Store[AttachmentRuntimeStore]
Storage[AttachmentStorageService]
Logic[attachment.logic]
Facade --> Manager
Manager --> Transfer
Manager --> Persistence
Manager --> Store
Manager --> Logic
Transfer --> Transport
Transfer --> Store
Persistence --> Storage
Persistence --> Store
Storage --> Helpers[attachment-storage.helpers]
click Facade "application/attachment.facade.ts" "Thin entry point" _blank
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state" _blank
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers" _blank
click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank
```
## File transfer protocol
Files move between peers using a request/response pattern over the WebRTC data channel. The sender announces a file, the receiver requests it, and chunks flow back one by one.
```mermaid
sequenceDiagram
participant S as Sender
participant R as Receiver
S->>R: file-announce (id, name, size, mimeType)
Note over R: Store metadata in runtime store
Note over R: shouldAutoRequestWhenWatched?
R->>S: file-request (attachmentId)
Note over S: Look up file in runtime store or on disk
loop Every 64 KB chunk
S->>R: file-chunk (attachmentId, index, data, progress, speed)
Note over R: Append to chunk buffer
Note over R: Update progress + EWMA speed
end
Note over R: All chunks received
Note over R: Reassemble blob
Note over R: shouldPersistDownloadedAttachment? Save to disk
```
### Failure handling
If the sender cannot find the file, it replies with `file-not-found`. The transfer service then tries the next connected peer that has announced the same attachment. Either side can send `file-cancel` to abort a transfer in progress.
```mermaid
sequenceDiagram
participant R as Receiver
participant P1 as Peer A
participant P2 as Peer B
R->>P1: file-request
P1->>R: file-not-found
Note over R: Try next peer
R->>P2: file-request
P2->>R: file-chunk (1/N)
P2->>R: file-chunk (2/N)
P2->>R: file-chunk (N/N)
Note over R: Transfer complete
```
## Auto-download rules
When the user navigates to a room, the manager watches the route and decides which attachments to request automatically based on domain logic:
| Condition | Auto-download? |
|---|---|
| Image or video, size <= 10 MB | Yes |
| Image or video, size > 10 MB | No |
| Non-media file | No |
The decision lives in `shouldAutoRequestWhenWatched()` which calls `isAttachmentMedia()` and checks against `MAX_AUTO_SAVE_SIZE_BYTES`.
## Persistence
On Electron, completed downloads are written to the app-data directory. The storage path is resolved per room and bucket:
```
{appDataPath}/{serverId}/{roomName}/{bucket}/{filename}
```
Room names are sanitised to remove filesystem-unsafe characters. The bucket is either `attachments` or `media` depending on the attachment type.
`AttachmentPersistenceService` handles startup migration from an older localStorage-based format into the database, and restores attachment metadata from the DB on init. On browser builds, files stay in memory only.
## Runtime store
`AttachmentRuntimeStore` is a signal-based in-memory store using `Map` instances for:
- **attachments**: all known attachments keyed by ID
- **chunks**: incoming chunk buffers during active transfers
- **pendingRequests**: outbound requests waiting for a response
- **cancellations**: IDs of transfers the user cancelled
Components read attachment state reactively through the store's signals. The store has no persistence of its own; that responsibility belongs to the persistence service.

View File

@@ -0,0 +1,224 @@
import {
Injectable,
effect,
inject
} from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { DatabaseService } from '../../../infrastructure/persistence';
import { ROOM_URL_PATTERN } from '../../../core/constants';
import { shouldAutoRequestWhenWatched } from '../domain/attachment.logic';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
import type {
FileAnnouncePayload,
FileCancelPayload,
FileChunkPayload,
FileNotFoundPayload,
FileRequestPayload
} from '../domain/attachment-transfer.models';
import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferService } from './attachment-transfer.service';
@Injectable({ providedIn: 'root' })
export class AttachmentManagerService {
get updated() {
return this.runtimeStore.updated;
}
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly router = inject(Router);
private readonly database = inject(DatabaseService);
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly persistence = inject(AttachmentPersistenceService);
private readonly transfer = inject(AttachmentTransferService);
private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url);
private isDatabaseInitialised = false;
constructor() {
effect(() => {
if (this.database.isReady() && !this.isDatabaseInitialised) {
this.isDatabaseInitialised = true;
void this.persistence.initFromDatabase();
}
});
this.router.events.subscribe((event) => {
if (!(event instanceof NavigationEnd)) {
return;
}
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
if (this.watchedRoomId) {
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
this.webrtc.onPeerConnected.subscribe(() => {
if (this.watchedRoomId) {
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
}
});
}
getForMessage(messageId: string): Attachment[] {
return this.runtimeStore.getAttachmentsForMessage(messageId);
}
rememberMessageRoom(messageId: string, roomId: string): void {
if (!messageId || !roomId)
return;
this.runtimeStore.rememberMessageRoom(messageId, roomId);
}
queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void {
void this.requestAutoDownloadsForMessage(messageId, attachmentId);
}
async requestAutoDownloadsForRoom(roomId: string): Promise<void> {
if (!roomId || !this.isRoomWatched(roomId))
return;
if (this.database.isReady()) {
const messages = await this.database.getMessages(roomId, 500, 0);
for (const message of messages) {
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
await this.requestAutoDownloadsForMessage(message.id);
}
return;
}
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
if (attachmentRoomId === roomId) {
await this.requestAutoDownloadsForMessage(messageId);
}
}
}
async deleteForMessage(messageId: string): Promise<void> {
await this.persistence.deleteForMessage(messageId);
}
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
return this.transfer.getAttachmentMetasForMessages(messageIds);
}
registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
for (const [messageId, attachments] of Object.entries(attachmentMap)) {
for (const attachment of attachments) {
this.queueAutoDownloadsForMessage(messageId, attachment.id);
}
}
}
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
this.transfer.requestFromAnyPeer(messageId, attachment);
}
handleFileNotFound(payload: FileNotFoundPayload): void {
this.transfer.handleFileNotFound(payload);
}
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
this.transfer.requestImageFromAnyPeer(messageId, attachment);
}
requestFile(messageId: string, attachment: Attachment): void {
this.transfer.requestFile(messageId, attachment);
}
async publishAttachments(
messageId: string,
files: File[],
uploaderPeerId?: string
): Promise<void> {
await this.transfer.publishAttachments(messageId, files, uploaderPeerId);
}
handleFileAnnounce(payload: FileAnnouncePayload): void {
this.transfer.handleFileAnnounce(payload);
if (payload.messageId && payload.file?.id) {
this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id);
}
}
handleFileChunk(payload: FileChunkPayload): void {
this.transfer.handleFileChunk(payload);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
await this.transfer.handleFileRequest(payload);
}
cancelRequest(messageId: string, attachment: Attachment): void {
this.transfer.cancelRequest(messageId, attachment);
}
handleFileCancel(payload: FileCancelPayload): void {
this.transfer.handleFileCancel(payload);
}
async fulfillRequestWithFile(
messageId: string,
fileId: string,
targetPeerId: string,
file: File
): Promise<void> {
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
}
private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise<void> {
if (!messageId)
return;
const roomId = await this.persistence.resolveMessageRoomId(messageId);
if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) {
return;
}
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
for (const attachment of attachments) {
if (attachmentId && attachment.id !== attachmentId)
continue;
if (!shouldAutoRequestWhenWatched(attachment))
continue;
if (attachment.available)
continue;
if ((attachment.receivedBytes ?? 0) > 0)
continue;
if (this.transfer.hasPendingRequest(messageId, attachment.id))
continue;
this.transfer.requestFromAnyPeer(messageId, attachment);
}
}
private extractWatchedRoomId(url: string): string | null {
const roomMatch = url.match(ROOM_URL_PATTERN);
return roomMatch ? roomMatch[1] : null;
}
private isRoomWatched(roomId: string | null | undefined): boolean {
return !!roomId && roomId === this.watchedRoomId;
}
}

View File

@@ -0,0 +1,264 @@
import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs';
import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../../store/rooms/rooms.selectors';
import { DatabaseService } from '../../../infrastructure/persistence';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../domain/attachment-transfer.constants';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
@Injectable({ providedIn: 'root' })
export class AttachmentPersistenceService {
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly ngrxStore = inject(Store);
private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly database = inject(DatabaseService);
async deleteForMessage(messageId: string): Promise<void> {
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
const hadCachedAttachments = attachments.length > 0 || this.runtimeStore.hasAttachmentsForMessage(messageId);
const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId);
const savedPathsToDelete = new Set<string>();
for (const attachment of attachments) {
if (attachment.objectUrl) {
try {
URL.revokeObjectURL(attachment.objectUrl);
} catch { /* ignore */ }
}
if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) {
savedPathsToDelete.add(attachment.savedPath);
}
}
this.runtimeStore.deleteAttachmentsForMessage(messageId);
this.runtimeStore.deleteMessageRoom(messageId);
this.runtimeStore.clearMessageScopedState(messageId);
if (hadCachedAttachments) {
this.runtimeStore.touch();
}
if (this.database.isReady()) {
await this.database.deleteAttachmentsForMessage(messageId);
}
for (const diskPath of savedPathsToDelete) {
await this.attachmentStorage.deleteFile(diskPath);
}
}
async persistAttachmentMeta(attachment: Attachment): Promise<void> {
if (!this.database.isReady())
return;
try {
await this.database.saveAttachment({
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId,
filePath: attachment.filePath,
savedPath: attachment.savedPath
});
} catch { /* persistence is best-effort */ }
}
async saveFileToDisk(attachment: Attachment, blob: Blob): Promise<void> {
try {
const roomName = await this.resolveCurrentRoomName();
const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName);
if (!diskPath)
return;
attachment.savedPath = diskPath;
void this.persistAttachmentMeta(attachment);
} catch { /* disk save is best-effort */ }
}
async initFromDatabase(): Promise<void> {
await this.loadFromDatabase();
await this.migrateFromLocalStorage();
await this.tryLoadSavedFiles();
}
async resolveMessageRoomId(messageId: string): Promise<string | null> {
const cachedRoomId = this.runtimeStore.getMessageRoomId(messageId);
if (cachedRoomId)
return cachedRoomId;
if (!this.database.isReady())
return null;
try {
const message = await this.database.getMessageById(messageId);
if (!message?.roomId)
return null;
this.runtimeStore.rememberMessageRoom(messageId, message.roomId);
return message.roomId;
} catch {
return null;
}
}
async resolveCurrentRoomName(): Promise<string> {
return new Promise<string>((resolve) => {
this.ngrxStore
.select(selectCurrentRoomName)
.pipe(take(1))
.subscribe((name) => resolve(name || ''));
});
}
private async loadFromDatabase(): Promise<void> {
try {
const allRecords: AttachmentMeta[] = await this.database.getAllAttachments();
const grouped = new Map<string, Attachment[]>();
for (const record of allRecords) {
const attachment: Attachment = { ...record,
available: false };
const bucket = grouped.get(record.messageId) ?? [];
bucket.push(attachment);
grouped.set(record.messageId, bucket);
}
this.runtimeStore.replaceAttachments(grouped);
this.runtimeStore.touch();
} catch { /* load is best-effort */ }
}
private async migrateFromLocalStorage(): Promise<void> {
try {
const raw = localStorage.getItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
if (!raw)
return;
const legacyRecords: AttachmentMeta[] = JSON.parse(raw);
for (const meta of legacyRecords) {
const existing = [...this.runtimeStore.getAttachmentsForMessage(meta.messageId)];
if (!existing.find((entry) => entry.id === meta.id)) {
const attachment: Attachment = { ...meta,
available: false };
existing.push(attachment);
this.runtimeStore.setAttachmentsForMessage(meta.messageId, existing);
void this.persistAttachmentMeta(attachment);
}
}
localStorage.removeItem(LEGACY_ATTACHMENTS_STORAGE_KEY);
this.runtimeStore.touch();
} catch { /* migration is best-effort */ }
}
private async tryLoadSavedFiles(): Promise<void> {
try {
let hasChanges = false;
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
for (const attachment of attachments) {
if (attachment.available)
continue;
if (attachment.savedPath) {
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
if (savedBase64) {
this.restoreAttachmentFromDisk(attachment, savedBase64);
hasChanges = true;
continue;
}
}
if (attachment.filePath) {
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
if (originalBase64) {
this.restoreAttachmentFromDisk(attachment, originalBase64);
hasChanges = true;
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES && attachment.objectUrl) {
const response = await fetch(attachment.objectUrl);
void this.saveFileToDisk(attachment, await response.blob());
}
continue;
}
}
}
}
if (hasChanges)
this.runtimeStore.touch();
} catch { /* startup load is best-effort */ }
}
private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void {
const bytes = this.base64ToUint8Array(base64);
const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime });
attachment.objectUrl = URL.createObjectURL(blob);
attachment.available = true;
this.runtimeStore.setOriginalFile(
`${attachment.messageId}:${attachment.id}`,
new File([blob], attachment.filename, { type: attachment.mime })
);
}
private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise<Set<string>> {
const retainedSavedPaths = new Set<string>();
for (const [existingMessageId, attachments] of this.runtimeStore.getAttachmentEntries()) {
if (existingMessageId === messageId)
continue;
for (const attachment of attachments) {
if (attachment.savedPath) {
retainedSavedPaths.add(attachment.savedPath);
}
}
}
if (!this.database.isReady()) {
return retainedSavedPaths;
}
const persistedAttachments = await this.database.getAllAttachments();
for (const attachment of persistedAttachments) {
if (attachment.messageId !== messageId && attachment.savedPath) {
retainedSavedPaths.add(attachment.savedPath);
}
}
return retainedSavedPaths;
}
private base64ToUint8Array(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
}

View File

@@ -0,0 +1,160 @@
import { Injectable, signal } from '@angular/core';
import type { Attachment } from '../domain/attachment.models';
@Injectable({ providedIn: 'root' })
export class AttachmentRuntimeStore {
readonly updated = signal<number>(0);
private attachmentsByMessage = new Map<string, Attachment[]>();
private messageRoomIds = new Map<string, string>();
private originalFiles = new Map<string, File>();
private cancelledTransfers = new Set<string>();
private pendingRequests = new Map<string, Set<string>>();
private chunkBuffers = new Map<string, ArrayBuffer[]>();
private chunkCounts = new Map<string, number>();
touch(): void {
this.updated.set(this.updated() + 1);
}
getAttachmentsForMessage(messageId: string): Attachment[] {
return this.attachmentsByMessage.get(messageId) ?? [];
}
setAttachmentsForMessage(messageId: string, attachments: Attachment[]): void {
if (attachments.length === 0) {
this.attachmentsByMessage.delete(messageId);
return;
}
this.attachmentsByMessage.set(messageId, attachments);
}
hasAttachmentsForMessage(messageId: string): boolean {
return this.attachmentsByMessage.has(messageId);
}
deleteAttachmentsForMessage(messageId: string): void {
this.attachmentsByMessage.delete(messageId);
}
replaceAttachments(nextAttachments: Map<string, Attachment[]>): void {
this.attachmentsByMessage = nextAttachments;
}
getAttachmentEntries(): IterableIterator<[string, Attachment[]]> {
return this.attachmentsByMessage.entries();
}
rememberMessageRoom(messageId: string, roomId: string): void {
this.messageRoomIds.set(messageId, roomId);
}
getMessageRoomId(messageId: string): string | undefined {
return this.messageRoomIds.get(messageId);
}
deleteMessageRoom(messageId: string): void {
this.messageRoomIds.delete(messageId);
}
setOriginalFile(key: string, file: File): void {
this.originalFiles.set(key, file);
}
getOriginalFile(key: string): File | undefined {
return this.originalFiles.get(key);
}
findOriginalFileByFileId(fileId: string): File | null {
for (const [key, file] of this.originalFiles) {
if (key.endsWith(`:${fileId}`)) {
return file;
}
}
return null;
}
addCancelledTransfer(key: string): void {
this.cancelledTransfers.add(key);
}
hasCancelledTransfer(key: string): boolean {
return this.cancelledTransfers.has(key);
}
setPendingRequestPeers(key: string, peers: Set<string>): void {
this.pendingRequests.set(key, peers);
}
getPendingRequestPeers(key: string): Set<string> | undefined {
return this.pendingRequests.get(key);
}
hasPendingRequest(key: string): boolean {
return this.pendingRequests.has(key);
}
deletePendingRequest(key: string): void {
this.pendingRequests.delete(key);
}
setChunkBuffer(key: string, buffer: ArrayBuffer[]): void {
this.chunkBuffers.set(key, buffer);
}
getChunkBuffer(key: string): ArrayBuffer[] | undefined {
return this.chunkBuffers.get(key);
}
deleteChunkBuffer(key: string): void {
this.chunkBuffers.delete(key);
}
setChunkCount(key: string, count: number): void {
this.chunkCounts.set(key, count);
}
getChunkCount(key: string): number | undefined {
return this.chunkCounts.get(key);
}
deleteChunkCount(key: string): void {
this.chunkCounts.delete(key);
}
clearMessageScopedState(messageId: string): void {
const scopedPrefix = `${messageId}:`;
for (const key of Array.from(this.originalFiles.keys())) {
if (key.startsWith(scopedPrefix)) {
this.originalFiles.delete(key);
}
}
for (const key of Array.from(this.pendingRequests.keys())) {
if (key.startsWith(scopedPrefix)) {
this.pendingRequests.delete(key);
}
}
for (const key of Array.from(this.chunkBuffers.keys())) {
if (key.startsWith(scopedPrefix)) {
this.chunkBuffers.delete(key);
}
}
for (const key of Array.from(this.chunkCounts.keys())) {
if (key.startsWith(scopedPrefix)) {
this.chunkCounts.delete(key);
}
}
for (const key of Array.from(this.cancelledTransfers)) {
if (key.startsWith(scopedPrefix)) {
this.cancelledTransfers.delete(key);
}
}
}
}

View File

@@ -0,0 +1,109 @@
import { Injectable, inject } from '@angular/core';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
import { FILE_CHUNK_SIZE_BYTES } from '../domain/attachment-transfer.constants';
import { FileChunkEvent } from '../domain/attachment-transfer.models';
@Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentStorage = inject(AttachmentStorageService);
decodeBase64(base64: string): Uint8Array {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
async streamFileToPeer(
targetPeerId: string,
messageId: string,
fileId: string,
file: File,
isCancelled: () => boolean
): Promise<void> {
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES);
let offset = 0;
let chunkIndex = 0;
while (offset < file.size) {
if (isCancelled())
break;
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
const arrayBuffer = await slice.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer);
const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk',
messageId,
fileId,
index: chunkIndex,
total: totalChunks,
data: base64
};
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
offset += FILE_CHUNK_SIZE_BYTES;
chunkIndex++;
}
}
async streamFileFromDiskToPeer(
targetPeerId: string,
messageId: string,
fileId: string,
diskPath: string,
isCancelled: () => boolean
): Promise<void> {
const base64Full = await this.attachmentStorage.readFile(diskPath);
if (!base64Full)
return;
const fileBytes = this.decodeBase64(base64Full);
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
if (isCancelled())
break;
const start = chunkIndex * FILE_CHUNK_SIZE_BYTES;
const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES);
const slice = fileBytes.subarray(start, end);
const sliceBuffer = (slice.buffer as ArrayBuffer).slice(
slice.byteOffset,
slice.byteOffset + slice.byteLength
);
const base64Chunk = this.arrayBufferToBase64(sliceBuffer);
const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk',
messageId,
fileId,
index: chunkIndex,
total: totalChunks,
data: base64Chunk
};
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent);
}
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]);
}
return btoa(binary);
}
}

View File

@@ -0,0 +1,566 @@
import { Injectable, inject } from '@angular/core';
import { recordDebugNetworkFileChunk } from '../../../infrastructure/realtime/logging/debug-network-metrics';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants';
import { shouldPersistDownloadedAttachment } from '../domain/attachment.logic';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models';
import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE,
FILE_NOT_FOUND_REQUEST_ERROR,
NO_CONNECTED_PEERS_REQUEST_ERROR
} from '../domain/attachment-transfer.constants';
import {
type FileAnnounceEvent,
type FileAnnouncePayload,
type FileCancelEvent,
type FileCancelPayload,
type FileChunkPayload,
type FileNotFoundEvent,
type FileNotFoundPayload,
type FileRequestEvent,
type FileRequestPayload,
type LocalFileWithPath
} from '../domain/attachment-transfer.models';
import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';
@Injectable({ providedIn: 'root' })
export class AttachmentTransferService {
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly runtimeStore = inject(AttachmentRuntimeStore);
private readonly attachmentStorage = inject(AttachmentStorageService);
private readonly persistence = inject(AttachmentPersistenceService);
private readonly transport = inject(AttachmentTransferTransportService);
getAttachmentMetasForMessages(messageIds: string[]): Record<string, AttachmentMeta[]> {
const result: Record<string, AttachmentMeta[]> = {};
for (const messageId of messageIds) {
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
if (attachments.length > 0) {
result[messageId] = attachments.map((attachment) => ({
id: attachment.id,
messageId: attachment.messageId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId: attachment.uploaderPeerId,
filePath: undefined,
savedPath: undefined
}));
}
}
return result;
}
registerSyncedAttachments(
attachmentMap: Record<string, AttachmentMeta[]>,
messageRoomIds?: Record<string, string>
): void {
if (messageRoomIds) {
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
this.runtimeStore.rememberMessageRoom(messageId, roomId);
}
}
const newAttachments: Attachment[] = [];
for (const [messageId, metas] of Object.entries(attachmentMap)) {
const existing = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
for (const meta of metas) {
const alreadyKnown = existing.find((entry) => entry.id === meta.id);
if (!alreadyKnown) {
const attachment: Attachment = { ...meta,
available: false,
receivedBytes: 0 };
existing.push(attachment);
newAttachments.push(attachment);
}
}
this.runtimeStore.setAttachmentsForMessage(messageId, existing);
}
if (newAttachments.length > 0) {
this.runtimeStore.touch();
for (const attachment of newAttachments) {
void this.persistence.persistAttachmentMeta(attachment);
}
}
}
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
const clearedRequestError = this.clearAttachmentRequestError(attachment);
const connectedPeers = this.webrtc.getConnectedPeers();
if (connectedPeers.length === 0) {
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
this.runtimeStore.touch();
console.warn('[Attachments] No connected peers to request file from');
return;
}
if (clearedRequestError)
this.runtimeStore.touch();
this.runtimeStore.setPendingRequestPeers(
this.buildRequestKey(messageId, attachment.id),
new Set<string>()
);
this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId);
}
handleFileNotFound(payload: FileNotFoundPayload): void {
const { messageId, fileId } = payload;
if (!messageId || !fileId)
return;
const attachments = this.runtimeStore.getAttachmentsForMessage(messageId);
const attachment = attachments.find((entry) => entry.id === fileId);
const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId);
if (!didSendRequest && attachment) {
attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR;
this.runtimeStore.touch();
}
}
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
this.requestFromAnyPeer(messageId, attachment);
}
requestFile(messageId: string, attachment: Attachment): void {
this.requestFromAnyPeer(messageId, attachment);
}
hasPendingRequest(messageId: string, fileId: string): boolean {
return this.runtimeStore.hasPendingRequest(this.buildRequestKey(messageId, fileId));
}
async publishAttachments(
messageId: string,
files: File[],
uploaderPeerId?: string
): Promise<void> {
const attachments: Attachment[] = [];
for (const file of files) {
const fileId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`;
const attachment: Attachment = {
id: fileId,
messageId,
filename: file.name,
size: file.size,
mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE,
isImage: file.type.startsWith('image/'),
uploaderPeerId,
filePath: (file as LocalFileWithPath).path,
available: false
};
attachments.push(attachment);
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
try {
attachment.objectUrl = URL.createObjectURL(file);
attachment.available = true;
} catch { /* non-critical */ }
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
void this.persistence.saveFileToDisk(attachment, file);
}
const fileAnnounceEvent: FileAnnounceEvent = {
type: 'file-announce',
messageId,
file: {
id: fileId,
filename: attachment.filename,
size: attachment.size,
mime: attachment.mime,
isImage: attachment.isImage,
uploaderPeerId
}
};
this.webrtc.broadcastMessage(fileAnnounceEvent);
}
const existingList = this.runtimeStore.getAttachmentsForMessage(messageId);
this.runtimeStore.setAttachmentsForMessage(messageId, [...existingList, ...attachments]);
this.runtimeStore.touch();
for (const attachment of attachments) {
void this.persistence.persistAttachmentMeta(attachment);
}
}
handleFileAnnounce(payload: FileAnnouncePayload): void {
const { messageId, file } = payload;
if (!messageId || !file)
return;
const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)];
const alreadyKnown = list.find((entry) => entry.id === file.id);
if (alreadyKnown)
return;
const attachment: Attachment = {
id: file.id,
messageId,
filename: file.filename,
size: file.size,
mime: file.mime,
isImage: !!file.isImage,
uploaderPeerId: file.uploaderPeerId,
available: false,
receivedBytes: 0
};
list.push(attachment);
this.runtimeStore.setAttachmentsForMessage(messageId, list);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
}
handleFileChunk(payload: FileChunkPayload): void {
const { messageId, fileId, fromPeerId, index, total, data } = payload;
if (
!messageId || !fileId ||
typeof index !== 'number' ||
typeof total !== 'number' ||
typeof data !== 'string'
) {
return;
}
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
const attachment = list.find((entry) => entry.id === fileId);
if (!attachment)
return;
const decodedBytes = this.transport.decodeBase64(data);
const assemblyKey = `${messageId}:${fileId}`;
const requestKey = this.buildRequestKey(messageId, fileId);
this.runtimeStore.deletePendingRequest(requestKey);
this.clearAttachmentRequestError(attachment);
const chunkBuffer = this.getOrCreateChunkBuffer(assemblyKey, total);
if (!chunkBuffer[index]) {
chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer;
this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1);
}
this.updateTransferProgress(attachment, decodedBytes, fromPeerId);
this.runtimeStore.touch();
this.finalizeTransferIfComplete(attachment, assemblyKey, total);
}
async handleFileRequest(payload: FileRequestPayload): Promise<void> {
const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId)
return;
const exactKey = `${messageId}:${fileId}`;
const originalFile = this.runtimeStore.getOriginalFile(exactKey)
?? this.runtimeStore.findOriginalFileByFileId(fileId);
if (originalFile) {
await this.transport.streamFileToPeer(
fromPeerId,
messageId,
fileId,
originalFile,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
const list = this.runtimeStore.getAttachmentsForMessage(messageId);
const attachment = list.find((entry) => entry.id === fileId);
const diskPath = attachment
? await this.attachmentStorage.resolveExistingPath(attachment)
: null;
if (diskPath) {
await this.transport.streamFileFromDiskToPeer(
fromPeerId,
messageId,
fileId,
diskPath,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
if (attachment?.isImage) {
const roomName = await this.persistence.resolveCurrentRoomName();
const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath(
attachment.filename,
roomName
);
if (legacyDiskPath) {
await this.transport.streamFileFromDiskToPeer(
fromPeerId,
messageId,
fileId,
legacyDiskPath,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
}
}
if (attachment?.available && attachment.objectUrl) {
try {
const response = await fetch(attachment.objectUrl);
const blob = await response.blob();
const file = new File([blob], attachment.filename, { type: attachment.mime });
await this.transport.streamFileToPeer(
fromPeerId,
messageId,
fileId,
file,
() => this.isTransferCancelled(fromPeerId, messageId, fileId)
);
return;
} catch { /* fall through */ }
}
const fileNotFoundEvent: FileNotFoundEvent = {
type: 'file-not-found',
messageId,
fileId
};
this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent);
}
cancelRequest(messageId: string, attachment: Attachment): void {
const targetPeerId = attachment.uploaderPeerId;
if (!targetPeerId)
return;
try {
const assemblyKey = `${messageId}:${attachment.id}`;
this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey);
attachment.receivedBytes = 0;
attachment.speedBps = 0;
attachment.startedAtMs = undefined;
attachment.lastUpdateMs = undefined;
if (attachment.objectUrl) {
try {
URL.revokeObjectURL(attachment.objectUrl);
} catch { /* ignore */ }
attachment.objectUrl = undefined;
}
attachment.available = false;
this.runtimeStore.touch();
const fileCancelEvent: FileCancelEvent = {
type: 'file-cancel',
messageId,
fileId: attachment.id
};
this.webrtc.sendToPeer(targetPeerId, fileCancelEvent);
} catch { /* best-effort */ }
}
handleFileCancel(payload: FileCancelPayload): void {
const { messageId, fileId, fromPeerId } = payload;
if (!messageId || !fileId || !fromPeerId)
return;
this.runtimeStore.addCancelledTransfer(
this.buildTransferKey(messageId, fileId, fromPeerId)
);
}
async fulfillRequestWithFile(
messageId: string,
fileId: string,
targetPeerId: string,
file: File
): Promise<void> {
this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file);
await this.transport.streamFileToPeer(
targetPeerId,
messageId,
fileId,
file,
() => this.isTransferCancelled(targetPeerId, messageId, fileId)
);
}
private buildTransferKey(messageId: string, fileId: string, peerId: string): string {
return `${messageId}:${fileId}:${peerId}`;
}
private buildRequestKey(messageId: string, fileId: string): string {
return `${messageId}:${fileId}`;
}
private clearAttachmentRequestError(attachment: Attachment): boolean {
if (!attachment.requestError)
return false;
attachment.requestError = undefined;
return true;
}
private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean {
return this.runtimeStore.hasCancelledTransfer(
this.buildTransferKey(messageId, fileId, targetPeerId)
);
}
private sendFileRequestToNextPeer(
messageId: string,
fileId: string,
preferredPeerId?: string
): boolean {
const connectedPeers = this.webrtc.getConnectedPeers();
const requestKey = this.buildRequestKey(messageId, fileId);
const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set<string>();
let targetPeerId: string | undefined;
if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) {
targetPeerId = preferredPeerId;
} else {
targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId));
}
if (!targetPeerId) {
this.runtimeStore.deletePendingRequest(requestKey);
return false;
}
triedPeers.add(targetPeerId);
this.runtimeStore.setPendingRequestPeers(requestKey, triedPeers);
const fileRequestEvent: FileRequestEvent = {
type: 'file-request',
messageId,
fileId
};
this.webrtc.sendToPeer(targetPeerId, fileRequestEvent);
return true;
}
private getOrCreateChunkBuffer(assemblyKey: string, total: number): ArrayBuffer[] {
const existingChunkBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
if (existingChunkBuffer) {
return existingChunkBuffer;
}
const createdChunkBuffer = new Array(total);
this.runtimeStore.setChunkBuffer(assemblyKey, createdChunkBuffer);
this.runtimeStore.setChunkCount(assemblyKey, 0);
return createdChunkBuffer;
}
private updateTransferProgress(
attachment: Attachment,
decodedBytes: Uint8Array,
fromPeerId?: string
): void {
const now = Date.now();
const previousReceived = attachment.receivedBytes ?? 0;
attachment.receivedBytes = previousReceived + decodedBytes.byteLength;
if (fromPeerId) {
recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now);
}
if (!attachment.startedAtMs)
attachment.startedAtMs = now;
if (!attachment.lastUpdateMs)
attachment.lastUpdateMs = now;
const elapsedMs = Math.max(1, now - attachment.lastUpdateMs);
const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000;
const previousSpeed = attachment.speedBps ?? instantaneousBps;
attachment.speedBps =
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT * previousSpeed +
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT * instantaneousBps;
attachment.lastUpdateMs = now;
}
private finalizeTransferIfComplete(
attachment: Attachment,
assemblyKey: string,
total: number
): void {
const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0;
const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey);
if (
!completeBuffer
|| (receivedChunkCount !== total && (attachment.receivedBytes ?? 0) < attachment.size)
|| !completeBuffer.every((part) => part instanceof ArrayBuffer)
) {
return;
}
const blob = new Blob(completeBuffer, { type: attachment.mime });
attachment.available = true;
attachment.objectUrl = URL.createObjectURL(blob);
if (shouldPersistDownloadedAttachment(attachment)) {
void this.persistence.saveFileToDisk(attachment, blob);
}
this.runtimeStore.deleteChunkBuffer(assemblyKey);
this.runtimeStore.deleteChunkCount(assemblyKey);
this.runtimeStore.touch();
void this.persistence.persistAttachmentMeta(attachment);
}
}

View File

@@ -0,0 +1,119 @@
import { Injectable, inject } from '@angular/core';
import { AttachmentManagerService } from './attachment-manager.service';
@Injectable({ providedIn: 'root' })
export class AttachmentFacade {
get updated() {
return this.manager.updated;
}
private readonly manager = inject(AttachmentManagerService);
getForMessage(
...args: Parameters<AttachmentManagerService['getForMessage']>
): ReturnType<AttachmentManagerService['getForMessage']> {
return this.manager.getForMessage(...args);
}
rememberMessageRoom(
...args: Parameters<AttachmentManagerService['rememberMessageRoom']>
): ReturnType<AttachmentManagerService['rememberMessageRoom']> {
return this.manager.rememberMessageRoom(...args);
}
queueAutoDownloadsForMessage(
...args: Parameters<AttachmentManagerService['queueAutoDownloadsForMessage']>
): ReturnType<AttachmentManagerService['queueAutoDownloadsForMessage']> {
return this.manager.queueAutoDownloadsForMessage(...args);
}
requestAutoDownloadsForRoom(
...args: Parameters<AttachmentManagerService['requestAutoDownloadsForRoom']>
): ReturnType<AttachmentManagerService['requestAutoDownloadsForRoom']> {
return this.manager.requestAutoDownloadsForRoom(...args);
}
deleteForMessage(
...args: Parameters<AttachmentManagerService['deleteForMessage']>
): ReturnType<AttachmentManagerService['deleteForMessage']> {
return this.manager.deleteForMessage(...args);
}
getAttachmentMetasForMessages(
...args: Parameters<AttachmentManagerService['getAttachmentMetasForMessages']>
): ReturnType<AttachmentManagerService['getAttachmentMetasForMessages']> {
return this.manager.getAttachmentMetasForMessages(...args);
}
registerSyncedAttachments(
...args: Parameters<AttachmentManagerService['registerSyncedAttachments']>
): ReturnType<AttachmentManagerService['registerSyncedAttachments']> {
return this.manager.registerSyncedAttachments(...args);
}
requestFromAnyPeer(
...args: Parameters<AttachmentManagerService['requestFromAnyPeer']>
): ReturnType<AttachmentManagerService['requestFromAnyPeer']> {
return this.manager.requestFromAnyPeer(...args);
}
handleFileNotFound(
...args: Parameters<AttachmentManagerService['handleFileNotFound']>
): ReturnType<AttachmentManagerService['handleFileNotFound']> {
return this.manager.handleFileNotFound(...args);
}
requestImageFromAnyPeer(
...args: Parameters<AttachmentManagerService['requestImageFromAnyPeer']>
): ReturnType<AttachmentManagerService['requestImageFromAnyPeer']> {
return this.manager.requestImageFromAnyPeer(...args);
}
requestFile(
...args: Parameters<AttachmentManagerService['requestFile']>
): ReturnType<AttachmentManagerService['requestFile']> {
return this.manager.requestFile(...args);
}
publishAttachments(
...args: Parameters<AttachmentManagerService['publishAttachments']>
): ReturnType<AttachmentManagerService['publishAttachments']> {
return this.manager.publishAttachments(...args);
}
handleFileAnnounce(
...args: Parameters<AttachmentManagerService['handleFileAnnounce']>
): ReturnType<AttachmentManagerService['handleFileAnnounce']> {
return this.manager.handleFileAnnounce(...args);
}
handleFileChunk(
...args: Parameters<AttachmentManagerService['handleFileChunk']>
): ReturnType<AttachmentManagerService['handleFileChunk']> {
return this.manager.handleFileChunk(...args);
}
handleFileRequest(
...args: Parameters<AttachmentManagerService['handleFileRequest']>
): ReturnType<AttachmentManagerService['handleFileRequest']> {
return this.manager.handleFileRequest(...args);
}
cancelRequest(
...args: Parameters<AttachmentManagerService['cancelRequest']>
): ReturnType<AttachmentManagerService['cancelRequest']> {
return this.manager.cancelRequest(...args);
}
handleFileCancel(
...args: Parameters<AttachmentManagerService['handleFileCancel']>
): ReturnType<AttachmentManagerService['handleFileCancel']> {
return this.manager.handleFileCancel(...args);
}
fulfillRequestWithFile(
...args: Parameters<AttachmentManagerService['fulfillRequestWithFile']>
): ReturnType<AttachmentManagerService['fulfillRequestWithFile']> {
return this.manager.fulfillRequestWithFile(...args);
}
}

View File

@@ -0,0 +1,21 @@
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
/**
* EWMA smoothing weight for the previous speed estimate.
* The complementary weight is applied to the latest sample.
*/
export const ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT = 0.7;
export const ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT = 1 - ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT;
/** Fallback MIME type when none is provided by the sender. */
export const DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream';
/** localStorage key used by the legacy attachment store during migration. */
export const LEGACY_ATTACHMENTS_STORAGE_KEY = 'metoyou_attachments';
/** User-facing error when no peers are available for a request. */
export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.';
/** User-facing error when connected peers cannot provide a requested file. */
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';

View File

@@ -0,0 +1,57 @@
import type { ChatEvent } from '../../../shared-kernel';
import type { ChatAttachmentAnnouncement } from '../../../shared-kernel';
export type FileAnnounceEvent = ChatEvent & {
type: 'file-announce';
messageId: string;
file: ChatAttachmentAnnouncement;
};
export type FileChunkEvent = ChatEvent & {
type: 'file-chunk';
messageId: string;
fileId: string;
index: number;
total: number;
data: string;
fromPeerId?: string;
};
export type FileRequestEvent = ChatEvent & {
type: 'file-request';
messageId: string;
fileId: string;
fromPeerId?: string;
};
export type FileCancelEvent = ChatEvent & {
type: 'file-cancel';
messageId: string;
fileId: string;
fromPeerId?: string;
};
export type FileNotFoundEvent = ChatEvent & {
type: 'file-not-found';
messageId: string;
fileId: string;
};
export type FileAnnouncePayload = Pick<ChatEvent, 'messageId' | 'file'>;
export interface FileChunkPayload {
messageId?: string;
fileId?: string;
fromPeerId?: string;
index?: number;
total?: number;
data?: ChatEvent['data'];
}
export type FileRequestPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileCancelPayload = Pick<ChatEvent, 'messageId' | 'fileId' | 'fromPeerId'>;
export type FileNotFoundPayload = Pick<ChatEvent, 'messageId' | 'fileId'>;
export type LocalFileWithPath = File & {
path?: string;
};

View File

@@ -0,0 +1,2 @@
/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */
export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB

View File

@@ -0,0 +1,19 @@
import { MAX_AUTO_SAVE_SIZE_BYTES } from './attachment.constants';
import type { Attachment } from './attachment.models';
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('image/') ||
attachment.mime.startsWith('video/') ||
attachment.mime.startsWith('audio/');
}
export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean {
return attachment.isImage ||
(isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES);
}
export function shouldPersistDownloadedAttachment(attachment: Pick<Attachment, 'size' | 'mime'>): boolean {
return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES ||
attachment.mime.startsWith('video/') ||
attachment.mime.startsWith('audio/');
}

View File

@@ -0,0 +1,13 @@
import type { ChatAttachmentMeta } from '../../../shared-kernel';
export type AttachmentMeta = ChatAttachmentMeta;
export interface Attachment extends AttachmentMeta {
available: boolean;
objectUrl?: string;
receivedBytes?: number;
speedBps?: number;
startedAtMs?: number;
lastUpdateMs?: number;
requestError?: string;
}

View File

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

View File

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

View File

@@ -0,0 +1,127 @@
import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../domain/attachment.models';
import { resolveAttachmentStorageBucket, sanitizeAttachmentRoomName } from './attachment-storage.helpers';
@Injectable({ providedIn: 'root' })
export class AttachmentStorageService {
private readonly electronBridge = inject(ElectronBridgeService);
async resolveExistingPath(
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
): Promise<string | null> {
return this.findExistingPath([attachment.filePath, attachment.savedPath]);
}
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {
const appDataPath = await this.resolveAppDataPath();
if (!appDataPath) {
return null;
}
return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]);
}
async readFile(filePath: string): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return null;
}
try {
return await electronApi.readFile(filePath);
} catch {
return null;
}
}
async saveBlob(
attachment: Pick<Attachment, 'filename' | 'mime'>,
blob: Blob,
roomName: string
): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
const appDataPath = await this.resolveAppDataPath();
if (!electronApi || !appDataPath) {
return null;
}
try {
const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`;
await electronApi.ensureDir(directoryPath);
const arrayBuffer = await blob.arrayBuffer();
const diskPath = `${directoryPath}/${attachment.filename}`;
await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer));
return diskPath;
} catch {
return null;
}
}
async deleteFile(filePath: string): Promise<void> {
const electronApi = this.electronBridge.getApi();
if (!electronApi || !filePath) {
return;
}
try {
await electronApi.deleteFile(filePath);
} catch { /* best-effort cleanup */ }
}
private async resolveAppDataPath(): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
try {
return await electronApi.getAppDataPath();
} catch {
return null;
}
}
private async findExistingPath(candidates: (string | null | undefined)[]): Promise<string | null> {
const electronApi = this.electronBridge.getApi();
if (!electronApi) {
return null;
}
for (const candidatePath of candidates) {
if (!candidatePath) {
continue;
}
try {
if (await electronApi.fileExists(candidatePath)) {
return candidatePath;
}
} catch { /* keep trying remaining candidates */ }
}
return null;
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let index = 0; index < bytes.byteLength; index++) {
binary += String.fromCharCode(bytes[index]);
}
return btoa(binary);
}
}

View File

@@ -0,0 +1,74 @@
# Auth Domain
Handles user authentication (login and registration) against the configured server endpoint. Provides the login, register, and user-bar UI components.
## Module map
```
auth/
├── application/
│ └── auth.service.ts HTTP login/register against the active server endpoint
├── feature/
│ ├── login/ Login form component
│ ├── register/ Registration form component
│ └── user-bar/ Displays current user or login/register links
└── index.ts Barrel exports
```
## Service overview
`AuthService` resolves the API base URL from `ServerDirectoryFacade`, then makes POST requests for login and registration. It does not hold session state itself; after a successful login the calling component stores `currentUserId` in localStorage and dispatches `UsersActions.setCurrentUser` into the NgRx store.
```mermaid
graph TD
Login[LoginComponent]
Register[RegisterComponent]
UserBar[UserBarComponent]
Auth[AuthService]
SD[ServerDirectoryFacade]
Store[NgRx Store]
Login --> Auth
Register --> Auth
UserBar --> Store
Auth --> SD
Login --> Store
click Auth "application/auth.service.ts" "HTTP login/register" _blank
click Login "feature/login/" "Login form" _blank
click Register "feature/register/" "Registration form" _blank
click UserBar "feature/user-bar/" "Current user display" _blank
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API URL" _blank
```
## Login flow
```mermaid
sequenceDiagram
participant User
participant Login as LoginComponent
participant Auth as AuthService
participant SD as ServerDirectoryFacade
participant API as Server API
participant Store as NgRx Store
User->>Login: Submit credentials
Login->>Auth: login(username, password)
Auth->>SD: getApiBaseUrl()
SD-->>Auth: https://server/api
Auth->>API: POST /api/auth/login
API-->>Auth: { userId, displayName }
Auth-->>Login: success
Login->>Store: UsersActions.setCurrentUser
Login->>Login: localStorage.setItem(currentUserId)
```
## Registration flow
Registration follows the same pattern but posts to `/api/auth/register` with an additional `displayName` field. On success the user is treated as logged in and the same store dispatch happens.
## User bar
`UserBarComponent` reads the current user from the NgRx store. When logged in it shows the user's display name; when not logged in it shows links to the login and register views.

View File

@@ -0,0 +1,101 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { type ServerEndpoint, ServerDirectoryFacade } from '../../server-directory';
/**
* Response returned by the authentication endpoints (login / register).
*/
export interface LoginResponse {
/** Unique user identifier assigned by the server. */
id: string;
/** Login username. */
username: string;
/** Human-readable display name. */
displayName: string;
}
/**
* Handles user authentication (login and registration) against a
* configurable back-end server.
*
* The target server is resolved via {@link ServerDirectoryFacade}: the
* caller may pass an explicit `serverId`, otherwise the currently active
* server endpoint is used.
*/
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
/**
* Resolve the API base URL for the given server.
*
* @param serverId - Optional server ID to look up. When omitted the
* currently active endpoint is used.
* @returns Fully-qualified API base URL (e.g. `http://host:3001/api`).
*/
private endpointFor(serverId?: string): string {
let endpoint: ServerEndpoint | undefined;
if (serverId) {
endpoint = this.serverDirectory.servers().find(
(server) => server.id === serverId
);
}
const activeEndpoint = endpoint ?? this.serverDirectory.activeServer();
return activeEndpoint
? `${activeEndpoint.url}/api`
: this.serverDirectory.getApiBaseUrl();
}
/**
* Register a new user account on the target server.
*
* @param params - Registration parameters.
* @param params.username - Desired login username.
* @param params.password - Account password.
* @param params.displayName - Optional display name (defaults to username on the server).
* @param params.serverId - Optional server ID to register against.
* @returns Observable emitting the {@link LoginResponse} on success.
*/
register(params: {
username: string;
password: string;
displayName?: string;
serverId?: string;
}): Observable<LoginResponse> {
const url = `${this.endpointFor(params.serverId)}/users/register`;
return this.http.post<LoginResponse>(url, {
username: params.username,
password: params.password,
displayName: params.displayName
});
}
/**
* Log in to an existing user account on the target server.
*
* @param params - Login parameters.
* @param params.username - Login username.
* @param params.password - Account password.
* @param params.serverId - Optional server ID to authenticate against.
* @returns Observable emitting the {@link LoginResponse} on success.
*/
login(params: {
username: string;
password: string;
serverId?: string;
}): Observable<LoginResponse> {
const url = `${this.endpointFor(params.serverId)}/users/login`;
return this.http.post<LoginResponse>(url, {
username: params.username,
password: params.password
});
}
}

View File

@@ -0,0 +1,76 @@
<div class="h-full grid place-items-center bg-background">
<div class="w-[360px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<ng-icon
name="lucideLogIn"
class="w-5 h-5 text-primary"
/>
<h1 class="text-lg font-semibold text-foreground">Login</h1>
</div>
<div class="space-y-3">
<div>
<label
for="login-username"
class="block text-xs text-muted-foreground mb-1"
>Username</label
>
<input
[(ngModel)]="username"
type="text"
id="login-username"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label
for="login-password"
class="block text-xs text-muted-foreground mb-1"
>Password</label
>
<input
[(ngModel)]="password"
type="password"
id="login-password"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label
for="login-server"
class="block text-xs text-muted-foreground mb-1"
>Server App</label
>
<select
[(ngModel)]="serverId"
id="login-server"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
>
@for (s of servers(); track s.id) {
<option [value]="s.id">{{ s.name }}</option>
}
</select>
</div>
@if (error()) {
<p class="text-xs text-destructive">{{ error() }}</p>
}
<button
(click)="submit()"
type="button"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
Login
</button>
<div class="text-xs text-muted-foreground text-center mt-2">
No account?
<button
type="button"
(click)="goRegister()"
class="text-primary hover:underline"
>
Register
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,100 @@
/* eslint-disable max-statements-per-line */
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide';
import { AuthService } from '../../application/auth.service';
import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
@Component({
selector: 'app-login',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [provideIcons({ lucideLogIn })],
templateUrl: './login.component.html'
})
/**
* Login form allowing existing users to authenticate against a selected server.
*/
export class LoginComponent {
serversSvc = inject(ServerDirectoryFacade);
servers = this.serversSvc.servers;
username = '';
password = '';
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
private auth = inject(AuthService);
private store = inject(Store);
private route = inject(ActivatedRoute);
private router = inject(Router);
/** TrackBy function for server list rendering. */
trackById(_index: number, item: { id: string }) { return item.id; }
/** Validate and submit the login form, then navigate to search on success. */
submit() {
this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id;
this.auth.login({ username: this.username.trim(),
password: this.password,
serverId: sid }).subscribe({
next: (resp) => {
if (sid)
this.serversSvc.setActiveServer(sid);
const user: User = {
id: resp.id,
oderId: resp.id,
username: resp.username,
displayName: resp.displayName,
status: 'online',
role: 'member',
joinedAt: Date.now()
};
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {
this.router.navigateByUrl(returnUrl);
return;
}
this.router.navigate(['/search']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Login failed');
}
});
}
/** Navigate to the registration page. */
goRegister() {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
this.router.navigate(['/register'], {
queryParams: returnUrl ? { returnUrl } : undefined
});
}
}

View File

@@ -0,0 +1,89 @@
<div class="h-full grid place-items-center bg-background">
<div class="w-[380px] bg-card border border-border rounded-xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<ng-icon
name="lucideUserPlus"
class="w-5 h-5 text-primary"
/>
<h1 class="text-lg font-semibold text-foreground">Register</h1>
</div>
<div class="space-y-3">
<div>
<label
for="register-username"
class="block text-xs text-muted-foreground mb-1"
>Username</label
>
<input
[(ngModel)]="username"
type="text"
id="register-username"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label
for="register-display-name"
class="block text-xs text-muted-foreground mb-1"
>Display Name</label
>
<input
[(ngModel)]="displayName"
type="text"
id="register-display-name"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label
for="register-password"
class="block text-xs text-muted-foreground mb-1"
>Password</label
>
<input
[(ngModel)]="password"
type="password"
id="register-password"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
</div>
<div>
<label
for="register-server"
class="block text-xs text-muted-foreground mb-1"
>Server App</label
>
<select
[(ngModel)]="serverId"
id="register-server"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
>
@for (s of servers(); track s.id) {
<option [value]="s.id">{{ s.name }}</option>
}
</select>
</div>
@if (error()) {
<p class="text-xs text-destructive">{{ error() }}</p>
}
<button
(click)="submit()"
type="button"
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
>
Create Account
</button>
<div class="text-xs text-muted-foreground text-center mt-2">
Have an account?
<button
type="button"
(click)="goLogin()"
class="text-primary hover:underline"
>
Login
</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,102 @@
/* eslint-disable max-statements-per-line */
import {
Component,
inject,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideUserPlus } from '@ng-icons/lucide';
import { AuthService } from '../../application/auth.service';
import { ServerDirectoryFacade } from '../../../server-directory';
import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
@Component({
selector: 'app-register',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [provideIcons({ lucideUserPlus })],
templateUrl: './register.component.html'
})
/**
* Registration form allowing new users to create an account on a selected server.
*/
export class RegisterComponent {
serversSvc = inject(ServerDirectoryFacade);
servers = this.serversSvc.servers;
username = '';
displayName = '';
password = '';
serverId: string | undefined = this.serversSvc.activeServer()?.id;
error = signal<string | null>(null);
private auth = inject(AuthService);
private store = inject(Store);
private route = inject(ActivatedRoute);
private router = inject(Router);
/** TrackBy function for server list rendering. */
trackById(_index: number, item: { id: string }) { return item.id; }
/** Validate and submit the registration form, then navigate to search on success. */
submit() {
this.error.set(null);
const sid = this.serverId || this.serversSvc.activeServer()?.id;
this.auth.register({ username: this.username.trim(),
password: this.password,
displayName: this.displayName.trim(),
serverId: sid }).subscribe({
next: (resp) => {
if (sid)
this.serversSvc.setActiveServer(sid);
const user: User = {
id: resp.id,
oderId: resp.id,
username: resp.username,
displayName: resp.displayName,
status: 'online',
role: 'member',
joinedAt: Date.now()
};
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {
this.router.navigateByUrl(returnUrl);
return;
}
this.router.navigate(['/search']);
},
error: (err) => {
this.error.set(err?.error?.error || 'Registration failed');
}
});
}
/** Navigate to the login page. */
goLogin() {
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
this.router.navigate(['/login'], {
queryParams: returnUrl ? { returnUrl } : undefined
});
}
}

View File

@@ -0,0 +1,35 @@
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
<div class="flex-1"></div>
@if (user()) {
<div class="flex items-center gap-2 text-sm">
<ng-icon
name="lucideUser"
class="w-4 h-4 text-muted-foreground"
/>
<span class="text-foreground">{{ user()?.displayName }}</span>
</div>
} @else {
<button
type="button"
(click)="goto('login')"
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"
>
<ng-icon
name="lucideLogIn"
class="w-4 h-4"
/>
Login
</button>
<button
type="button"
(click)="goto('register')"
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"
>
<ng-icon
name="lucideUserPlus"
class="w-4 h-4"
/>
Register
</button>
}
</div>

View File

@@ -0,0 +1,37 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideUser,
lucideLogIn,
lucideUserPlus
} from '@ng-icons/lucide';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
@Component({
selector: 'app-user-bar',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({ lucideUser,
lucideLogIn,
lucideUserPlus })
],
templateUrl: './user-bar.component.html'
})
/**
* Compact user status bar showing the current user with login/register navigation links.
*/
export class UserBarComponent {
store = inject(Store);
user = this.store.selectSignal(selectCurrentUser);
private router = inject(Router);
/** Navigate to the specified authentication page. */
goto(path: 'login' | 'register') {
this.router.navigate([`/${path}`]);
}
}

View File

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

View File

@@ -0,0 +1,143 @@
# Chat Domain
Text messaging, reactions, GIF search, typing indicators, and the user list. All UI is under `feature/`; application services handle GIF integration; domain rules govern message editing, deletion, and sync.
## Module map
```
chat/
├── application/
│ └── klipy.service.ts GIF search via the KLIPY API (proxied through the server)
├── domain/
│ ├── message.rules.ts canEditMessage, normaliseDeletedMessage, getMessageTimestamp
│ └── message-sync.rules.ts Inventory-based sync: chunkArray, findMissingIds, limits
├── feature/
│ ├── chat-messages/ Main chat view (orchestrates composer, list, overlays)
│ │ ├── chat-messages.component.ts Root component: replies, GIF picker, reactions, drag-drop
│ │ ├── components/
│ │ │ ├── message-composer/ Markdown toolbar, file drag-drop, send
│ │ │ ├── message-item/ Single message bubble with edit/delete/react
│ │ │ ├── message-list/ Paginated list (50 msgs/page), auto-scroll, Prism highlighting
│ │ │ └── message-overlays/ Context menus, reaction picker, reply preview
│ │ ├── models/ View models for messages
│ │ └── services/
│ │ └── chat-markdown.service.ts Markdown-to-HTML rendering
│ │
│ ├── klipy-gif-picker/ GIF search/browse picker panel
│ ├── typing-indicator/ "X is typing..." display (3 s TTL, max 4 names)
│ └── user-list/ Online user sidebar
└── index.ts Barrel exports
```
## Component composition
`ChatMessagesComponent` is the root of the chat view. It renders the message list, composer, and overlays as child components and coordinates cross-cutting interactions like replying to a message or inserting a GIF.
```mermaid
graph TD
Chat[ChatMessagesComponent]
List[MessageListComponent]
Composer[MessageComposerComponent]
Overlays[MessageOverlays]
Item[MessageItemComponent]
GIF[KlipyGifPickerComponent]
Typing[TypingIndicatorComponent]
Users[UserListComponent]
Chat --> List
Chat --> Composer
Chat --> Overlays
Chat --> GIF
List --> Item
Item --> Overlays
click Chat "feature/chat-messages/chat-messages.component.ts" "Root chat view" _blank
click List "feature/chat-messages/components/message-list/" "Paginated message list" _blank
click Composer "feature/chat-messages/components/message-composer/" "Markdown toolbar + send" _blank
click Overlays "feature/chat-messages/components/message-overlays/" "Context menus, reaction picker" _blank
click Item "feature/chat-messages/components/message-item/" "Single message bubble" _blank
click GIF "feature/klipy-gif-picker/" "GIF search panel" _blank
click Typing "feature/typing-indicator/" "Typing indicator" _blank
click Users "feature/user-list/" "Online user sidebar" _blank
```
## Message lifecycle
Messages are created in the composer, broadcast to peers over the data channel, and rendered in the list. Editing and deletion are sender-only operations.
```mermaid
sequenceDiagram
participant User
participant Composer as MessageComposer
participant Store as NgRx Store
participant DC as Data Channel
participant Peer as Remote Peer
User->>Composer: Type + send
Composer->>Store: dispatch addMessage
Composer->>DC: broadcastMessage(chat-message)
DC->>Peer: chat-message event
Note over User: Edit
User->>Store: dispatch editMessage
User->>DC: broadcastMessage(edit-message)
Note over User: Delete
User->>Store: dispatch deleteMessage (normaliseDeletedMessage)
User->>DC: broadcastMessage(delete-message)
```
## Message sync
When a peer connects (or reconnects), both sides exchange an inventory of their recent messages so each can request anything it missed. The inventory is capped at 1 000 messages and sent in chunks of 200.
```mermaid
sequenceDiagram
participant A as Peer A
participant B as Peer B
A->>B: inventory (up to 1000 msg IDs + timestamps)
B->>B: findMissingIds(remote, local)
B->>A: request missing message IDs
A->>B: message payloads (chunked, 200/batch)
```
`findMissingIds` compares each remote item's timestamp and reaction/attachment counts against the local map. Any item that is missing, newer, or has different counts is requested.
## GIF integration
`KlipyService` checks availability on the active server, then proxies search requests through the server API. Images are rendered via an image proxy endpoint to avoid mixed-content issues.
```mermaid
graph LR
Picker[KlipyGifPickerComponent]
Klipy[KlipyService]
SD[ServerDirectoryFacade]
API[Server API]
Picker --> Klipy
Klipy --> SD
Klipy --> API
click Picker "feature/klipy-gif-picker/" "GIF search panel" _blank
click Klipy "application/klipy.service.ts" "GIF search via KLIPY API" _blank
click SD "../server-directory/application/server-directory.facade.ts" "Resolves API base URL" _blank
```
## Domain rules
| Function | Purpose |
|---|---|
| `canEditMessage(msg, userId)` | Only the sender can edit their own message |
| `normaliseDeletedMessage(msg)` | Strips content and reactions from deleted messages |
| `getMessageTimestamp(msg)` | Returns `editedAt` if present, otherwise `timestamp` |
| `getLatestTimestamp(msgs)` | Max timestamp across a batch, used for sync ordering |
| `chunkArray(items, size)` | Splits arrays into fixed-size chunks for batched transfer |
| `findMissingIds(remote, local)` | Compares inventories and returns IDs to request |
## Typing indicator
`TypingIndicatorComponent` listens for typing events from peers. Each event resets a 3-second TTL timer. If no new event arrives within 3 seconds, the user is removed from the typing list. At most 4 names are shown; beyond that it displays "N users are typing".

View File

@@ -0,0 +1,200 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import {
Observable,
firstValueFrom,
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerDirectoryFacade } from '../../server-directory';
export interface KlipyGif {
id: string;
slug: string;
title?: string;
url: string;
previewUrl: string;
width: number;
height: number;
}
interface KlipyAvailabilityResponse {
enabled: boolean;
}
export interface KlipyGifSearchResponse {
enabled: boolean;
results: KlipyGif[];
hasNext: boolean;
}
const DEFAULT_PAGE_SIZE = 24;
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
@Injectable({ providedIn: 'root' })
export class KlipyService {
private readonly http = inject(HttpClient);
private readonly serverDirectory = inject(ServerDirectoryFacade);
private readonly availabilityState = signal({
enabled: false,
loading: true
});
private lastAvailabilityKey = '';
readonly isEnabled = computed(() => this.availabilityState().enabled);
readonly isLoading = computed(() => this.availabilityState().loading);
constructor() {
effect(() => {
const activeServer = this.serverDirectory.activeServer();
const apiBaseUrl = this.serverDirectory.getApiBaseUrl();
const nextKey = `${activeServer?.id ?? 'default'}:${apiBaseUrl}`;
if (nextKey === this.lastAvailabilityKey)
return;
this.lastAvailabilityKey = nextKey;
void this.refreshAvailability();
});
}
async refreshAvailability(): Promise<void> {
this.availabilityState.set({ enabled: false,
loading: true });
try {
const response = await firstValueFrom(
this.http.get<KlipyAvailabilityResponse>(
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
)
);
this.availabilityState.set({
enabled: response.enabled === true,
loading: false
});
} catch {
this.availabilityState.set({ enabled: false,
loading: false });
}
}
searchGifs(
query: string,
page = 1,
perPage = DEFAULT_PAGE_SIZE
): Observable<KlipyGifSearchResponse> {
let params = new HttpParams()
.set('page', String(Math.max(1, Math.floor(page))))
.set('per_page', String(Math.max(1, Math.floor(perPage))))
.set('customer_id', this.getOrCreateCustomerId());
const trimmedQuery = query.trim();
if (trimmedQuery) {
params = params.set('q', trimmedQuery);
}
const locale = this.getPreferredLocale();
if (locale) {
params = params.set('locale', locale);
}
return this.http
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
.pipe(
map((response) => ({
enabled: response.enabled !== false,
results: Array.isArray(response.results) ? response.results : [],
hasNext: response.hasNext === true
})),
catchError((error) =>
throwError(() => new Error(this.extractErrorMessage(error)))
)
);
}
normalizeMediaUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed)
return '';
if (trimmed.startsWith('//'))
return `https:${trimmed}`;
return trimmed;
}
buildRenderableImageUrl(url: string): string {
const trimmed = this.normalizeMediaUrl(url);
if (!trimmed)
return '';
if (!/^https?:\/\//i.test(trimmed))
return trimmed;
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
}
private getPreferredLocale(): string | null {
if (typeof navigator === 'undefined' || !navigator.language)
return null;
const locale = navigator.language.trim();
return locale || null;
}
private getOrCreateCustomerId(): string {
if (typeof window === 'undefined') {
return 'server';
}
try {
const existing = window.localStorage.getItem(KLIPY_CUSTOMER_ID_STORAGE_KEY);
if (existing?.trim())
return existing;
const created = window.crypto?.randomUUID?.()
?? `klipy-${Date.now().toString(36)}-${Math.random().toString(36)
.slice(2, 10)}`;
window.localStorage.setItem(KLIPY_CUSTOMER_ID_STORAGE_KEY, created);
return created;
} catch {
return `klipy-${Date.now().toString(36)}`;
}
}
private extractErrorMessage(error: unknown): string {
const httpError = error as {
error?: {
error?: unknown;
message?: unknown;
};
message?: unknown;
};
if (typeof httpError?.error?.error === 'string')
return httpError.error.error;
if (typeof httpError?.error?.message === 'string')
return httpError.error.message;
if (typeof httpError?.message === 'string')
return httpError.message;
return 'Failed to load GIFs from KLIPY.';
}
}

View File

@@ -0,0 +1,59 @@
/** Maximum number of recent messages to include in sync inventories. */
export const INVENTORY_LIMIT = 1000;
/** Number of messages per chunk for inventory / batch transfers. */
export const CHUNK_SIZE = 200;
/** Aggressive sync poll interval (10 seconds). */
export const SYNC_POLL_FAST_MS = 10_000;
/** Idle sync poll interval after a clean (no-new-messages) cycle (15 minutes). */
export const SYNC_POLL_SLOW_MS = 900_000;
/** Sync timeout duration before auto-completing a cycle (5 seconds). */
export const SYNC_TIMEOUT_MS = 5_000;
/** Large limit used for legacy full-sync operations. */
export const FULL_SYNC_LIMIT = 10_000;
/** Inventory item representing a message's sync state. */
export interface InventoryItem {
id: string;
ts: number;
rc: number;
ac?: number;
}
/** Splits an array into chunks of the given size. */
export function chunkArray<T>(items: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
/** Identifies missing or stale message IDs by comparing remote items against a local map. */
export function findMissingIds(
remoteItems: readonly { id: string; ts: number; rc?: number; ac?: number }[],
localMap: ReadonlyMap<string, { ts: number; rc: number; ac: number }>
): string[] {
const missing: string[] = [];
for (const item of remoteItems) {
const local = localMap.get(item.id);
if (
!local ||
item.ts > local.ts ||
(item.rc !== undefined && item.rc !== local.rc) ||
(item.ac !== undefined && item.ac !== local.ac)
) {
missing.push(item.id);
}
}
return missing;
}

View File

@@ -0,0 +1,31 @@
import { DELETED_MESSAGE_CONTENT, type Message } from '../../../shared-kernel';
/** Extracts the effective timestamp from a message (editedAt takes priority). */
export function getMessageTimestamp(msg: Message): number {
return msg.editedAt || msg.timestamp || 0;
}
/** Computes the most recent timestamp across a batch of messages. */
export function getLatestTimestamp(messages: Message[]): number {
return messages.reduce(
(max, msg) => Math.max(max, getMessageTimestamp(msg)),
0
);
}
/** Strips sensitive content from a deleted message. */
export function normaliseDeletedMessage(message: Message): Message {
if (!message.isDeleted)
return message;
return {
...message,
content: DELETED_MESSAGE_CONTENT,
reactions: []
};
}
/** Whether the given user is allowed to edit this message. */
export function canEditMessage(message: Message, userId: string): boolean {
return message.senderId === userId;
}

View File

@@ -0,0 +1,68 @@
<div class="chat-layout relative h-full">
<app-chat-message-list
[allMessages]="allMessages()"
[channelMessages]="channelMessages()"
[loading]="loading()"
[syncing]="syncing()"
[currentUserId]="currentUser()?.id ?? null"
[isAdmin]="isAdmin()"
[bottomPadding]="composerBottomPadding()"
[conversationKey]="conversationKey()"
(replyRequested)="setReplyTo($event)"
(deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)"
(reactionAdded)="handleReactionAdded($event)"
(reactionToggled)="handleReactionToggled($event)"
(downloadRequested)="downloadAttachment($event)"
(imageOpened)="openLightbox($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
/>
<div class="chat-bottom-bar absolute bottom-0 left-0 right-0 z-10">
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()"
(messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()"
(heightChanged)="handleComposerHeightChanged($event)"
(klipyGifPickerToggleRequested)="toggleKlipyGifPicker()"
/>
</div>
@if (showKlipyGifPicker()) {
<div
class="fixed inset-0 z-[89]"
(click)="closeKlipyGifPicker()"
(keydown.enter)="closeKlipyGifPicker()"
(keydown.space)="closeKlipyGifPicker()"
tabindex="0"
role="button"
aria-label="Close GIF picker"
style="-webkit-app-region: no-drag"
></div>
<div class="pointer-events-none fixed inset-0 z-[90]">
<div
class="pointer-events-auto absolute w-[calc(100vw-2rem)] max-w-5xl sm:w-[34rem] md:w-[42rem] xl:w-[52rem]"
[style.bottom.px]="composerBottomPadding() + 8"
[style.right.px]="klipyGifPickerAnchorRight()"
>
<app-klipy-gif-picker
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>
</div>
</div>
}
<app-chat-message-overlays
[lightboxAttachment]="lightboxAttachment()"
[imageContextMenu]="imageContextMenu()"
(lightboxClosed)="closeLightbox()"
(contextMenuClosed)="closeImageContextMenu()"
(downloadRequested)="downloadAttachment($event)"
(copyRequested)="copyImageToClipboard($event)"
(imageContextMenuRequested)="openImageContextMenu($event)"
/>
</div>

View File

@@ -0,0 +1,12 @@
.chat-layout {
display: flex;
flex-direction: column;
}
.chat-bottom-bar {
pointer-events: auto;
right: 8px;
background: hsl(var(--background) / 0.85);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}

View File

@@ -0,0 +1,404 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
HostListener,
ViewChild,
computed,
inject,
signal
} from '@angular/core';
import { Store } from '@ngrx/store';
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { Attachment, AttachmentFacade } from '../../../attachment';
import { KlipyGif } from '../../application/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import {
selectAllMessages,
selectMessagesLoading,
selectMessagesSyncing
} from '../../../../store/messages/messages.selectors';
import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../../store/users/users.selectors';
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { Message } from '../../../../shared-kernel';
import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component';
import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component';
import { ChatMessageListComponent } from './components/message-list/chat-message-list.component';
import { ChatMessageOverlaysComponent } from './components/message-overlays/chat-message-overlays.component';
import {
ChatMessageComposerSubmitEvent,
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from './models/chat-messages.models';
@Component({
selector: 'app-chat-messages',
standalone: true,
imports: [
ChatMessageComposerComponent,
KlipyGifPickerComponent,
ChatMessageListComponent,
ChatMessageOverlaysComponent
],
templateUrl: './chat-messages.component.html',
styleUrl: './chat-messages.component.scss'
})
export class ChatMessagesComponent {
@ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent;
private readonly electronBridge = inject(ElectronBridgeService);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade);
readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly loading = this.store.selectSignal(selectMessagesLoading);
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
readonly channelMessages = computed(() => {
const channelId = this.activeChannelId();
const roomId = this.currentRoom()?.id;
return this.allMessages().filter(
(message) =>
message.roomId === roomId &&
(message.channelId || 'general') === channelId
);
});
readonly conversationKey = computed(
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
);
readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null);
readonly showKlipyGifPicker = signal(false);
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
@HostListener('window:resize')
onWindowResize(): void {
if (this.showKlipyGifPicker()) {
this.syncKlipyGifPickerAnchor();
}
}
handleMessageSubmitted(event: ChatMessageComposerSubmitEvent): void {
this.store.dispatch(
MessagesActions.sendMessage({
content: event.content,
replyToId: this.replyTo()?.id,
channelId: this.activeChannelId()
})
);
this.clearReply();
if (event.pendingFiles.length > 0) {
setTimeout(() => this.attachFilesToLastOwnMessage(event.content, event.pendingFiles), 100);
}
}
handleTypingStarted(): void {
try {
this.webrtc.sendRawMessage({ type: 'typing', serverId: this.webrtc.currentServerId });
} catch {
/* ignore */
}
}
setReplyTo(message: ChatMessageReplyEvent): void {
this.replyTo.set(message);
}
clearReply(): void {
this.replyTo.set(null);
}
handleEditSaved(event: ChatMessageEditEvent): void {
this.store.dispatch(
MessagesActions.editMessage({
messageId: event.messageId,
content: event.content
})
);
}
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
if (this.isOwnMessage(message)) {
this.store.dispatch(MessagesActions.deleteMessage({ messageId: message.id }));
} else if (this.isAdmin()) {
this.store.dispatch(MessagesActions.adminDeleteMessage({ messageId: message.id }));
}
}
handleReactionAdded(event: ChatMessageReactionEvent): void {
this.store.dispatch(
MessagesActions.addReaction({
messageId: event.messageId,
emoji: event.emoji
})
);
}
handleReactionToggled(event: ChatMessageReactionEvent): void {
const message = this.channelMessages().find((entry) => entry.id === event.messageId);
const currentUserId = this.currentUser()?.id;
if (!message || !currentUserId)
return;
const hasReacted = message.reactions.some(
(reaction) => reaction.emoji === event.emoji && reaction.userId === currentUserId
);
if (hasReacted) {
this.store.dispatch(
MessagesActions.removeReaction({
messageId: event.messageId,
emoji: event.emoji
})
);
} else {
this.store.dispatch(
MessagesActions.addReaction({
messageId: event.messageId,
emoji: event.emoji
})
);
}
}
handleComposerHeightChanged(height: number): void {
this.composerBottomPadding.set(height + 20);
}
toggleKlipyGifPicker(): void {
const nextState = !this.showKlipyGifPicker();
this.showKlipyGifPicker.set(nextState);
if (nextState) {
requestAnimationFrame(() => this.syncKlipyGifPickerAnchor());
}
}
closeKlipyGifPicker(): void {
this.showKlipyGifPicker.set(false);
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.closeKlipyGifPicker();
this.composer?.handleKlipyGifSelected(gif);
}
private syncKlipyGifPickerAnchor(): void {
const triggerRect = this.composer?.getKlipyTriggerRect();
if (!triggerRect) {
this.klipyGifPickerAnchorRight.set(16);
return;
}
const viewportWidth = window.innerWidth;
const popupWidth = this.getKlipyGifPickerWidth(viewportWidth);
const preferredRight = viewportWidth - triggerRect.right;
const minRight = 16;
const maxRight = Math.max(minRight, viewportWidth - popupWidth - 16);
this.klipyGifPickerAnchorRight.set(
Math.min(Math.max(Math.round(preferredRight), minRight), maxRight)
);
}
private getKlipyGifPickerWidth(viewportWidth: number): number {
if (viewportWidth >= 1280)
return 52 * 16;
if (viewportWidth >= 768)
return 42 * 16;
if (viewportWidth >= 640)
return 34 * 16;
return Math.max(0, viewportWidth - 32);
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.lightboxAttachment.set(attachment);
}
}
closeLightbox(): void {
this.lightboxAttachment.set(null);
}
openImageContextMenu(event: ChatMessageImageContextMenuEvent): void {
this.imageContextMenu.set(event);
}
closeImageContextMenu(): void {
this.imageContextMenu.set(null);
}
async downloadAttachment(attachment: Attachment): Promise<void> {
if (!attachment.available || !attachment.objectUrl)
return;
const electronApi = this.electronBridge.getApi();
if (electronApi) {
const blob = await this.getAttachmentBlob(attachment);
if (blob) {
try {
const result = await electronApi.saveFileAs(
attachment.filename,
await this.blobToBase64(blob)
);
if (result.saved || result.cancelled)
return;
} catch {
/* fall back to browser download */
}
}
}
const link = document.createElement('a');
link.href = attachment.objectUrl;
link.download = attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async copyImageToClipboard(attachment: Attachment): Promise<void> {
this.closeImageContextMenu();
if (!attachment.objectUrl)
return;
try {
const response = await fetch(attachment.objectUrl);
const blob = await response.blob();
const pngBlob = await this.convertToPng(blob);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]);
} catch {
/* ignore */
}
}
private isOwnMessage(message: Message): boolean {
return message.senderId === this.currentUser()?.id;
}
private async getAttachmentBlob(attachment: Attachment): Promise<Blob | null> {
if (!attachment.objectUrl)
return null;
try {
const response = await fetch(attachment.objectUrl);
return await response.blob();
} catch {
return null;
}
}
private blobToBase64(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('Failed to encode attachment'));
return;
}
const [, base64 = ''] = reader.result.split(',', 2);
resolve(base64);
};
reader.onerror = () => reject(reader.error ?? new Error('Failed to read attachment'));
reader.readAsDataURL(blob);
});
}
private convertToPng(blob: Blob): Promise<Blob> {
return new Promise((resolve, reject) => {
if (blob.type === 'image/png') {
resolve(blob);
return;
}
const image = new Image();
const url = URL.createObjectURL(blob);
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext('2d');
if (!context) {
reject(new Error('Canvas not supported'));
return;
}
context.drawImage(image, 0, 0);
canvas.toBlob((pngBlob) => {
URL.revokeObjectURL(url);
if (pngBlob)
resolve(pngBlob);
else
reject(new Error('PNG conversion failed'));
}, 'image/png');
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Image load failed'));
};
image.src = url;
});
}
private attachFilesToLastOwnMessage(content: string, pendingFiles: File[]): void {
const currentUserId = this.currentUser()?.id;
if (!currentUserId)
return;
const message = [...this.channelMessages()]
.reverse()
.find(
(entry) =>
entry.senderId === currentUserId &&
entry.content === content &&
!entry.isDeleted
);
if (!message) {
setTimeout(() => this.attachFilesToLastOwnMessage(content, pendingFiles), 150);
return;
}
this.attachmentsSvc.publishAttachments(message.id, pendingFiles, currentUserId || undefined);
}
}

View File

@@ -0,0 +1,255 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/prefer-ngsrc -->
<div #composerRoot>
@if (replyTo()) {
<div class="pointer-events-auto flex items-center gap-2 bg-secondary/50 px-4 py-2">
<ng-icon
name="lucideReply"
class="h-4 w-4 text-muted-foreground"
/>
<span class="flex-1 text-sm text-muted-foreground">
Replying to <span class="font-semibold">{{ replyTo()?.senderName }}</span>
</span>
<button
(click)="clearReply()"
class="rounded p-1 hover:bg-secondary"
>
<ng-icon
name="lucideX"
class="h-4 w-4 text-muted-foreground"
/>
</button>
</div>
}
<app-typing-indicator />
@if (toolbarVisible()) {
<div
class="pointer-events-auto"
(mousedown)="$event.preventDefault()"
(mouseenter)="onToolbarMouseEnter()"
(mouseleave)="onToolbarMouseLeave()"
>
<div
class="mx-4 -mb-2 flex flex-wrap items-center justify-start gap-2 rounded-lg border border-border bg-card/70 px-2 py-1 shadow-sm backdrop-blur"
>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline('**')"
>
<b>B</b>
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline('*')"
>
<i>I</i>
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline('~~')"
>
<s>S</s>
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyInline(inlineCodeToken)"
>
&#96;
</button>
<span class="mx-1 text-muted-foreground">|</span>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHeading(1)"
>
H1
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHeading(2)"
>
H2
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHeading(3)"
>
H3
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('> ')"
>
Quote
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyPrefix('- ')"
>
• List
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyOrderedList()"
>
1. List
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyCodeBlock()"
>
Code
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyLink()"
>
Link
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyImage()"
>
Image
</button>
<button
class="rounded px-2 py-1 text-xs hover:bg-secondary"
(click)="applyHorizontalRule()"
>
HR
</button>
</div>
</div>
}
<div class="border-border p-4">
<div
class="chat-input-wrapper relative"
(mouseenter)="inputHovered.set(true)"
(mouseleave)="inputHovered.set(false)"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
>
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
@if (klipy.isEnabled()) {
<button
#klipyTrigger
type="button"
(click)="toggleKlipyGifPicker()"
class="inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-2xl border border-border/70 bg-secondary/55 px-3 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground shadow-sm backdrop-blur-md transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/35 hover:bg-secondary/90 hover:text-foreground"
[class.border-primary]="showKlipyGifPicker()"
[class.opacity-100]="inputHovered() || showKlipyGifPicker()"
[class.opacity-70]="!inputHovered() && !showKlipyGifPicker()"
[class.shadow-none]="!inputHovered() && !showKlipyGifPicker()"
[class.text-primary]="showKlipyGifPicker()"
aria-label="Search KLIPY GIFs"
title="Search KLIPY GIFs"
>
<ng-icon
name="lucideImage"
class="h-4 w-4"
/>
<span class="hidden sm:inline">GIF</span>
</button>
}
<button
type="button"
(click)="sendMessage()"
[disabled]="!messageContent.trim() && pendingFiles.length === 0 && !pendingKlipyGif()"
class="send-btn visible inline-flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-1 ring-primary/20 transition-all duration-200 hover:-translate-y-0.5 hover:bg-primary/90 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground disabled:shadow-none disabled:ring-0"
aria-label="Send message"
title="Send message"
>
<ng-icon
name="lucideSend"
class="h-5 w-5"
/>
</button>
</div>
<textarea
#messageInputRef
rows="1"
[(ngModel)]="messageContent"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
(keydown.enter)="onEnter($event)"
(input)="onInputChange(); autoResizeTextarea()"
(paste)="onPaste($event)"
(dragenter)="onDragEnter($event)"
(dragover)="onDragOver($event)"
(dragleave)="onDragLeave($event)"
(drop)="onDrop($event)"
placeholder="Type a message..."
class="chat-textarea w-full rounded-[1.35rem] border border-border pl-4 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-dashed]="dragActive()"
[class.border-primary]="dragActive()"
[class.chat-textarea-expanded]="textareaExpanded()"
[class.ctrl-resize]="ctrlHeld()"
[class.pr-16]="!klipy.isEnabled()"
[class.pr-40]="klipy.isEnabled()"
></textarea>
@if (dragActive()) {
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary bg-primary/5"
>
<div class="text-sm text-muted-foreground">Drop files to attach</div>
</div>
}
@if (pendingKlipyGif()) {
<div class="mt-2 flex">
<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()"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
class="h-full w-full object-cover"
loading="lazy"
/>
<span
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-[0.18em] text-white/90"
>
KLIPY
</span>
</div>
<div class="min-w-0">
<div class="text-xs font-medium text-foreground">GIF ready to send</div>
<div class="max-w-[12rem] truncate text-[10px] text-muted-foreground">
{{ pendingKlipyGif()!.title || 'KLIPY GIF' }}
</div>
</div>
<button
type="button"
(click)="removePendingKlipyGif()"
class="rounded px-2 py-1 text-[10px] text-destructive transition-colors hover:bg-destructive/10"
>
Remove
</button>
</div>
</div>
}
@if (pendingFiles.length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (file of pendingFiles; track file.name) {
<div class="group flex items-center gap-2 rounded border border-border bg-secondary/60 px-2 py-1">
<div class="max-w-[14rem] truncate text-xs font-medium">{{ file.name }}</div>
<div class="text-[10px] text-muted-foreground">{{ formatBytes(file.size) }}</div>
<button
(click)="removePendingFile(file)"
class="rounded bg-destructive/20 px-1 py-0.5 text-[10px] text-destructive opacity-70 group-hover:opacity-100"
>
Remove
</button>
</div>
}
</div>
}
</div>
</div>
</div>

View File

@@ -0,0 +1,41 @@
.chat-textarea {
--textarea-bg: hsl(40deg 3.7% 15.9% / 25%);
--textarea-collapsed-padding-y: 18px;
--textarea-expanded-padding-y: 8px;
background: var(--textarea-bg);
height: 62px;
min-height: 62px;
max-height: 520px;
overflow-y: hidden;
padding-top: var(--textarea-collapsed-padding-y);
padding-bottom: var(--textarea-collapsed-padding-y);
resize: none;
transition:
height 0.12s ease,
padding 0.12s ease;
&.chat-textarea-expanded {
padding-top: var(--textarea-expanded-padding-y);
padding-bottom: var(--textarea-expanded-padding-y);
}
&.ctrl-resize {
resize: vertical;
}
}
.send-btn {
opacity: 0;
pointer-events: none;
transform: scale(0.85);
transition:
opacity 0.2s ease,
transform 0.2s ease;
&.visible {
opacity: 1;
pointer-events: auto;
transform: scale(1);
}
}

View File

@@ -0,0 +1,642 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
AfterViewInit,
Component,
ElementRef,
OnDestroy,
ViewChild,
inject,
input,
output,
signal
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideImage,
lucideReply,
lucideSend,
lucideX
} from '@ng-icons/lucide';
import type { ClipboardFilePayload } from '../../../../../../core/platform/electron/electron-api.models';
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/klipy.service';
import { Message } from '../../../../../../shared-kernel';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service';
import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models';
type LocalFileWithPath = File & {
path?: string;
};
const DEFAULT_TEXTAREA_HEIGHT = 62;
@Component({
selector: 'app-chat-message-composer',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
TypingIndicatorComponent
],
viewProviders: [
provideIcons({
lucideImage,
lucideReply,
lucideSend,
lucideX
})
],
templateUrl: './chat-message-composer.component.html',
styleUrl: './chat-message-composer.component.scss',
host: {
'(document:keydown)': 'onDocKeydown($event)',
'(document:keyup)': 'onDocKeyup($event)'
}
})
export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
@ViewChild('messageInputRef') messageInputRef?: ElementRef<HTMLTextAreaElement>;
@ViewChild('composerRoot') composerRoot?: ElementRef<HTMLDivElement>;
@ViewChild('klipyTrigger') klipyTrigger?: ElementRef<HTMLButtonElement>;
readonly replyTo = input<Message | null>(null);
readonly showKlipyGifPicker = input(false);
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
readonly typingStarted = output();
readonly replyCleared = output();
readonly heightChanged = output<number>();
readonly klipyGifPickerToggleRequested = output();
readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService);
readonly pendingKlipyGif = signal<KlipyGif | null>(null);
readonly toolbarVisible = signal(false);
readonly dragActive = signal(false);
readonly inputHovered = signal(false);
readonly ctrlHeld = signal(false);
readonly textareaExpanded = signal(false);
messageContent = '';
pendingFiles: File[] = [];
inlineCodeToken = '`';
private toolbarHovering = false;
private dragDepth = 0;
private lastTypingSentAt = 0;
private resizeObserver: ResizeObserver | null = null;
ngAfterViewInit(): void {
this.autoResizeTextarea();
this.observeHeight();
}
ngOnDestroy(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
}
sendMessage(): void {
const raw = this.messageContent.trim();
if (!raw && this.pendingFiles.length === 0 && !this.pendingKlipyGif())
return;
const content = this.buildOutgoingMessageContent(raw);
this.messageSubmitted.emit({
content,
pendingFiles: [...this.pendingFiles]
});
this.messageContent = '';
this.pendingFiles = [];
this.pendingKlipyGif.set(null);
this.replyCleared.emit();
requestAnimationFrame(() => {
this.autoResizeTextarea();
this.messageInputRef?.nativeElement.focus();
});
}
onInputChange(): void {
const now = Date.now();
if (now - this.lastTypingSentAt > 1000) {
this.typingStarted.emit();
this.lastTypingSentAt = now;
}
}
clearReply(): void {
this.replyCleared.emit();
}
onEnter(event: Event): void {
const keyEvent = event as KeyboardEvent;
if (keyEvent.shiftKey)
return;
keyEvent.preventDefault();
this.sendMessage();
}
applyInline(token: string): void {
const result = this.markdown.applyInline(this.messageContent, this.getSelection(), token);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyPrefix(prefix: string): void {
const result = this.markdown.applyPrefix(this.messageContent, this.getSelection(), prefix);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyHeading(level: number): void {
const result = this.markdown.applyHeading(this.messageContent, this.getSelection(), level);
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyOrderedList(): void {
const result = this.markdown.applyOrderedList(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyCodeBlock(): void {
const result = this.markdown.applyCodeBlock(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyLink(): void {
const result = this.markdown.applyLink(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyImage(): void {
const result = this.markdown.applyImage(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
applyHorizontalRule(): void {
const result = this.markdown.applyHorizontalRule(this.messageContent, this.getSelection());
this.messageContent = result.text;
this.setSelection(result.selectionStart, result.selectionEnd);
}
toggleKlipyGifPicker(): void {
if (!this.klipy.isEnabled())
return;
this.klipyGifPickerToggleRequested.emit();
}
getKlipyTriggerRect(): DOMRect | null {
return this.klipyTrigger?.nativeElement.getBoundingClientRect() ?? null;
}
handleKlipyGifSelected(gif: KlipyGif): void {
this.pendingKlipyGif.set(gif);
if (!this.messageContent.trim() && this.pendingFiles.length === 0) {
this.sendMessage();
return;
}
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
removePendingKlipyGif(): void {
this.pendingKlipyGif.set(null);
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',
'KB',
'MB',
'GB'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
removePendingFile(file: File): void {
const index = this.pendingFiles.findIndex((pendingFile) => pendingFile === file);
if (index >= 0) {
this.pendingFiles.splice(index, 1);
this.emitHeight();
}
}
onDragEnter(event: DragEvent): void {
if (!this.hasPotentialFilePayload(event.dataTransfer))
return;
event.preventDefault();
event.stopPropagation();
this.dragDepth++;
this.dragActive.set(true);
}
onDragOver(event: DragEvent): void {
if (!this.hasPotentialFilePayload(event.dataTransfer))
return;
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
this.dragActive.set(true);
}
onDragLeave(event: DragEvent): void {
if (!this.dragActive())
return;
event.preventDefault();
event.stopPropagation();
this.dragDepth = Math.max(0, this.dragDepth - 1);
if (this.dragDepth === 0) {
this.dragActive.set(false);
}
}
onDrop(event: DragEvent): void {
event.preventDefault();
event.stopPropagation();
this.dragDepth = 0;
const droppedFiles = this.extractFilesFromTransfer(event.dataTransfer);
if (droppedFiles.length === 0) {
this.dragActive.set(false);
return;
}
this.addPendingFiles(droppedFiles);
this.dragActive.set(false);
}
async onPaste(event: ClipboardEvent): Promise<void> {
if (!this.hasPotentialFilePayload(event.clipboardData, false))
return;
event.preventDefault();
event.stopPropagation();
const pastedFiles = await this.extractPastedFiles(event);
if (pastedFiles.length === 0)
return;
this.addPendingFiles(pastedFiles);
}
autoResizeTextarea(): void {
const element = this.messageInputRef?.nativeElement;
if (!element)
return;
element.style.height = 'auto';
element.style.height = Math.min(element.scrollHeight, 520) + 'px';
element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden';
this.syncTextareaExpandedState();
}
onInputFocus(): void {
this.toolbarVisible.set(true);
}
onInputBlur(): void {
setTimeout(() => {
if (!this.toolbarHovering) {
this.toolbarVisible.set(false);
}
}, 150);
}
onToolbarMouseEnter(): void {
this.toolbarHovering = true;
}
onToolbarMouseLeave(): void {
this.toolbarHovering = false;
if (document.activeElement !== this.messageInputRef?.nativeElement) {
this.toolbarVisible.set(false);
}
}
onDocKeydown(event: KeyboardEvent): void {
if (event.key === 'Control') {
this.ctrlHeld.set(true);
}
}
onDocKeyup(event: KeyboardEvent): void {
if (event.key === 'Control') {
this.ctrlHeld.set(false);
}
}
private getSelection(): { start: number; end: number } {
const element = this.messageInputRef?.nativeElement;
return {
start: element?.selectionStart ?? this.messageContent.length,
end: element?.selectionEnd ?? this.messageContent.length
};
}
private setSelection(start: number, end: number): void {
const element = this.messageInputRef?.nativeElement;
if (element) {
element.selectionStart = start;
element.selectionEnd = end;
element.focus();
}
}
private addPendingFiles(files: File[]): void {
if (files.length === 0)
return;
const mergedFiles = this.mergeUniqueFiles(this.pendingFiles, files);
if (mergedFiles.length === this.pendingFiles.length)
return;
this.pendingFiles = mergedFiles;
this.toolbarVisible.set(true);
this.emitHeight();
requestAnimationFrame(() => this.messageInputRef?.nativeElement.focus());
}
private hasPotentialFilePayload(
dataTransfer: DataTransfer | null,
treatMissingTypesAsPotentialFile = true
): boolean {
if (!dataTransfer)
return false;
if (dataTransfer.files?.length)
return true;
const items = dataTransfer.items;
if (items?.length) {
for (const item of items) {
if (item.kind === 'file') {
return true;
}
}
}
const types = dataTransfer.types;
if (!types || types.length === 0)
return treatMissingTypesAsPotentialFile;
for (const type of types) {
if (
type === 'Files' ||
type === 'application/x-moz-file' ||
type === 'public.file-url' ||
type === 'text/uri-list' ||
type === 'x-special/gnome-copied-files'
) {
return true;
}
}
return false;
}
private extractFilesFromTransfer(dataTransfer: DataTransfer | null): File[] {
const extractedFiles: File[] = [];
const items = dataTransfer?.items ?? null;
if (items && items.length) {
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
this.pushUniqueFile(extractedFiles, file);
}
}
}
}
const files = dataTransfer?.files;
if (!files?.length)
return extractedFiles;
for (const file of files) {
this.pushUniqueFile(extractedFiles, file);
}
return extractedFiles;
}
private mergeUniqueFiles(existingFiles: File[], incomingFiles: File[]): File[] {
const mergedFiles = [...existingFiles];
for (const file of incomingFiles) {
this.pushUniqueFile(mergedFiles, file);
}
return mergedFiles;
}
private pushUniqueFile(target: File[], candidate: File): void {
const exists = target.some((file) => this.areFilesEquivalent(file, candidate));
if (!exists) {
target.push(candidate);
}
}
private areFilesEquivalent(left: File, right: File): boolean {
const leftPath = this.getLocalFilePath(left);
const rightPath = this.getLocalFilePath(right);
if (leftPath && rightPath) {
return leftPath === rightPath;
}
if (left.name !== right.name || left.size !== right.size) {
return false;
}
const leftType = left.type.trim();
const rightType = right.type.trim();
if (leftType && rightType && leftType !== rightType) {
return false;
}
const leftLastModified = Number.isFinite(left.lastModified) ? left.lastModified : 0;
const rightLastModified = Number.isFinite(right.lastModified) ? right.lastModified : 0;
if (!leftLastModified || !rightLastModified) {
return true;
}
return Math.abs(leftLastModified - rightLastModified) <= 1000;
}
private getLocalFilePath(file: File): string {
return ((file as LocalFileWithPath).path || '').trim();
}
private async extractPastedFiles(event: ClipboardEvent): Promise<File[]> {
const directFiles = this.extractFilesFromTransfer(event.clipboardData);
if (directFiles.length > 0)
return directFiles;
return await this.readFilesFromElectronClipboard();
}
private async readFilesFromElectronClipboard(): Promise<File[]> {
const electronApi = this.electronBridge.getApi();
if (!electronApi)
return [];
try {
const clipboardFiles = await electronApi.readClipboardFiles();
return clipboardFiles.map((clipboardFile) => this.createFileFromClipboardPayload(clipboardFile));
} catch {
return [];
}
}
private createFileFromClipboardPayload(payload: ClipboardFilePayload): File {
const file = new File([this.base64ToArrayBuffer(payload.data)], payload.name, {
lastModified: payload.lastModified,
type: payload.mime
});
if (payload.path) {
try {
Object.defineProperty(file, 'path', {
configurable: true,
value: payload.path
});
} catch {
(file as LocalFileWithPath).path = payload.path;
}
}
return file;
}
private base64ToArrayBuffer(base64: string): ArrayBuffer {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let index = 0; index < binaryString.length; index++) {
bytes[index] = binaryString.charCodeAt(index);
}
return bytes.buffer;
}
private buildOutgoingMessageContent(raw: string): string {
const withEmbeddedImages = this.markdown.appendImageMarkdown(raw);
const gif = this.pendingKlipyGif();
if (!gif)
return withEmbeddedImages;
const gifMarkdown = this.buildKlipyGifMarkdown(gif);
return withEmbeddedImages ? `${withEmbeddedImages}\n${gifMarkdown}` : gifMarkdown;
}
private buildKlipyGifMarkdown(gif: KlipyGif): string {
return `![KLIPY GIF](${this.klipy.normalizeMediaUrl(gif.url)})`;
}
private observeHeight(): void {
const root = this.composerRoot?.nativeElement;
if (!root)
return;
this.syncTextareaExpandedState();
this.emitHeight();
if (typeof ResizeObserver === 'undefined')
return;
this.resizeObserver = new ResizeObserver(() => {
this.syncTextareaExpandedState();
this.emitHeight();
});
this.resizeObserver.observe(root);
}
private syncTextareaExpandedState(): void {
const textarea = this.messageInputRef?.nativeElement;
this.textareaExpanded.set(Boolean(textarea && textarea.offsetHeight > DEFAULT_TEXTAREA_HEIGHT));
}
private emitHeight(): void {
const root = this.composerRoot?.nativeElement;
if (root) {
this.heightChanged.emit(root.offsetHeight);
}
}
}

View File

@@ -0,0 +1,424 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/cyclomatic-complexity, @angular-eslint/template/prefer-ngsrc -->
@let msg = message();
@let attachmentsList = attachmentViewModels();
<div
[attr.data-message-id]="msg.id"
class="group relative flex gap-3 rounded-lg p-2 transition-colors hover:bg-secondary/30"
[class.opacity-50]="msg.isDeleted"
>
<app-user-avatar
[name]="msg.senderName"
size="md"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
@if (msg.replyToId) {
@let reply = repliedMessage();
<div
class="mb-1 flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
(click)="requestReferenceScroll(msg.replyToId)"
>
<div class="h-3 w-4 rounded-tl-md border-l-2 border-t-2 border-muted-foreground/50"></div>
<ng-icon
name="lucideReply"
class="h-3 w-3"
/>
@if (reply) {
<span class="font-medium">{{ reply.senderName }}</span>
<span class="max-w-[200px] truncate">{{ reply.isDeleted ? deletedMessageContent : reply.content }}</span>
} @else {
<span class="italic">Original message not found</span>
}
</div>
}
<div class="flex items-baseline gap-2">
<span class="font-semibold text-foreground">{{ msg.senderName }}</span>
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
@if (msg.editedAt && !msg.isDeleted) {
<span class="text-xs text-muted-foreground">(edited)</span>
}
</div>
@if (isEditing()) {
<div class="mt-1 flex items-start gap-2">
<textarea
#editTextareaRef
rows="1"
[(ngModel)]="editContent"
(keydown.enter)="onEditEnter($event)"
(keydown.escape)="cancelEdit()"
(input)="autoResizeEditTextarea()"
class="edit-textarea flex-1 rounded border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
></textarea>
<div class="flex flex-col gap-2">
<button
(click)="saveEdit()"
class="rounded p-1 text-primary hover:bg-primary/10"
>
<ng-icon
name="lucideCheck"
class="h-4 w-4"
/>
</button>
<button
(click)="cancelEdit()"
class="rounded p-1 text-muted-foreground hover:bg-secondary"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
</div>
} @else {
@if (msg.isDeleted) {
<div class="mt-1 text-sm italic text-muted-foreground">{{ deletedMessageContent }}</div>
} @else {
<div class="chat-markdown mt-1 break-words">
<remark
[markdown]="msg.content"
[processor]="$any(remarkProcessor)"
>
<ng-template
[remarkTemplate]="'code'"
let-node
>
@if (isMermaidCodeBlock(node.lang) && getMermaidCode(node.value)) {
<remark-mermaid [code]="getMermaidCode(node.value)" />
} @else {
<pre [class]="getCodeBlockClass(node.lang)"><code [class]="getCodeBlockClass(node.lang)">{{ node.value }}</code></pre>
}
</ng-template>
<ng-template
[remarkTemplate]="'image'"
let-node
>
<div class="relative mt-2 inline-block overflow-hidden rounded-md border border-border/60 bg-secondary/20">
<img
[src]="getMarkdownImageSource(node.url)"
[alt]="node.alt || 'Shared image'"
class="block max-h-80 max-w-full w-auto"
loading="lazy"
/>
@if (isKlipyMediaUrl(node.url)) {
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
}
</div>
</ng-template>
</remark>
</div>
@if (attachmentsList.length > 0) {
<div class="mt-2 space-y-2">
@for (att of attachmentsList; track att.id) {
@if (att.isImage) {
@if (att.available && att.objectUrl) {
<div
class="group/img relative inline-block"
(contextmenu)="openImageContextMenu($event, att)"
>
<img
[src]="att.objectUrl"
[alt]="att.filename"
class="max-h-80 w-auto cursor-pointer rounded-md"
(click)="openLightbox(att)"
/>
<div class="pointer-events-none absolute inset-0 rounded-md bg-black/0 transition-colors group-hover/img:bg-black/20"></div>
<div class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover/img:opacity-100">
<button
(click)="openLightbox(att); $event.stopPropagation()"
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="View full size"
>
<ng-icon
name="lucideExpand"
class="h-4 w-4"
/>
</button>
<button
(click)="downloadAttachment(att); $event.stopPropagation()"
class="rounded-md bg-black/60 p-1.5 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4"
/>
</button>
</div>
</div>
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xs rounded-md border border-border bg-secondary/40 p-3">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-primary/10">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-primary"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<div class="text-xs font-medium text-primary">{{ ((att.receivedBytes || 0) * 100) / att.size | number: '1.0-0' }}%</div>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="((att.receivedBytes || 0) * 100) / att.size"
></div>
</div>
</div>
} @else {
<div class="max-w-xs rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-md bg-muted">
<ng-icon
name="lucideImage"
class="h-5 w-5 text-muted-foreground"
/>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-0.5 text-xs"
[class.italic]="!att.requestError"
[class.opacity-70]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.requestError || 'Waiting for image source…' }}
</div>
</div>
</div>
<button
(click)="retryImageRequest(att)"
class="mt-2 w-full rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
Retry
</button>
</div>
}
} @else if (att.isVideo || att.isAudio) {
@if (att.available && att.objectUrl) {
@if (att.isVideo) {
<app-chat-video-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
} @else {
<app-chat-audio-player
[src]="att.objectUrl"
[filename]="att.filename"
[sizeLabel]="formatBytes(att.size)"
(downloadRequested)="downloadAttachment(att)"
/>
}
} @else if ((att.receivedBytes || 0) > 0) {
<div class="max-w-xl rounded-md border border-border bg-secondary/40 p-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.receivedBytes || 0) }} / {{ formatBytes(att.size) }}</div>
</div>
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
</div>
<div class="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full bg-primary transition-all duration-300"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="mt-2 flex items-center justify-between text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>{{ formatSpeed(att.speedBps) }}</span>
}
</div>
</div>
} @else {
<div class="max-w-xl rounded-md border border-dashed border-border bg-secondary/20 p-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-foreground">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
<div
class="mt-1 text-xs leading-relaxed"
[class.opacity-80]="!att.requestError"
[class.text-destructive]="!!att.requestError"
[class.text-muted-foreground]="!att.requestError"
>
{{ att.mediaStatusText }}
</div>
</div>
<button
(click)="requestAttachment(att)"
class="shrink-0 rounded-md bg-secondary px-3 py-1.5 text-xs text-foreground transition-colors hover:bg-secondary/80"
>
{{ att.mediaActionLabel }}
</button>
</div>
</div>
}
} @else {
<div class="rounded-md border border-border bg-secondary/40 p-2">
<div class="flex items-center justify-between">
<div class="min-w-0">
<div class="truncate text-sm font-medium">{{ att.filename }}</div>
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
</div>
<div class="flex items-center gap-2">
@if (!att.isUploader) {
@if (!att.available) {
<div class="h-1.5 w-24 rounded bg-muted">
<div
class="h-1.5 rounded bg-primary"
[style.width.%]="att.progressPercent"
></div>
</div>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<span>{{ att.progressPercent | number: '1.0-0' }}%</span>
@if (att.speedBps) {
<span>• {{ formatSpeed(att.speedBps) }}</span>
}
</div>
@if (!(att.receivedBytes || 0)) {
<button
class="rounded bg-secondary px-2 py-1 text-xs text-foreground"
(click)="requestAttachment(att)"
>
{{ att.requestError ? 'Retry' : 'Request' }}
</button>
} @else {
<button
class="rounded bg-destructive px-2 py-1 text-xs text-destructive-foreground"
(click)="cancelAttachment(att)"
>
Cancel
</button>
}
} @else {
<button
class="rounded bg-primary px-2 py-1 text-xs text-primary-foreground"
(click)="downloadAttachment(att)"
>
Download
</button>
}
} @else {
<div class="text-xs text-muted-foreground">Shared from your device</div>
}
</div>
</div>
@if (!att.available && att.requestError) {
<div
class="mt-2 w-full rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 text-xs leading-relaxed text-destructive"
>
{{ att.requestError }}
</div>
}
</div>
}
}
</div>
}
}
}
@if (!msg.isDeleted && msg.reactions.length > 0) {
<div class="mt-2 flex flex-wrap gap-1">
@for (reaction of getGroupedReactions(); track reaction.emoji) {
<button
(click)="toggleReaction(reaction.emoji)"
class="flex items-center gap-1 rounded-full bg-secondary px-2 py-0.5 text-xs transition-colors hover:bg-secondary/80"
[class.ring-1]="reaction.hasCurrentUser"
[class.ring-primary]="reaction.hasCurrentUser"
>
<span>{{ reaction.emoji }}</span>
<span class="text-muted-foreground">{{ reaction.count }}</span>
</button>
}
</div>
}
</div>
@if (!msg.isDeleted) {
<div
class="absolute right-2 top-2 flex items-center gap-1 rounded-lg border border-border bg-card shadow-lg opacity-0 transition-opacity group-hover:opacity-100"
>
<div class="relative">
<button
(click)="toggleEmojiPicker()"
class="rounded-l-lg p-1.5 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideSmile"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (showEmojiPicker()) {
<div class="absolute bottom-full right-0 z-10 mb-2 flex gap-1 rounded-lg border border-border bg-card p-2 shadow-lg">
@for (emoji of commonEmojis; track emoji) {
<button
(click)="addReaction(emoji)"
class="rounded p-1 text-lg transition-colors hover:bg-secondary"
>
{{ emoji }}
</button>
}
</div>
}
</div>
<button
(click)="requestReply()"
class="p-1.5 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideReply"
class="h-4 w-4 text-muted-foreground"
/>
</button>
@if (isOwnMessage()) {
<button
(click)="startEdit()"
class="p-1.5 transition-colors hover:bg-secondary"
>
<ng-icon
name="lucideEdit"
class="h-4 w-4 text-muted-foreground"
/>
</button>
}
@if (isOwnMessage() || isAdmin()) {
<button
(click)="requestDelete()"
class="rounded-r-lg p-1.5 transition-colors hover:bg-destructive/10"
>
<ng-icon
name="lucideTrash2"
class="h-4 w-4 text-destructive"
/>
</button>
}
</div>
}
</div>

View File

@@ -0,0 +1,189 @@
.chat-markdown {
max-width: 100%;
word-wrap: break-word;
overflow-wrap: break-word;
font-size: 0.9375rem;
line-height: 1.5;
color: hsl(var(--foreground));
::ng-deep {
remark {
display: contents;
}
p {
margin: 0.25em 0;
}
strong {
font-weight: 700;
color: hsl(var(--foreground));
}
em {
font-style: italic;
}
del {
text-decoration: line-through;
opacity: 0.7;
}
a {
color: hsl(var(--primary));
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
h1, h2, h3, h4, h5, h6 {
margin: 0.5em 0 0.25em;
color: hsl(var(--foreground));
font-weight: 700;
}
h1 { font-size: 1.5em; }
h2 { font-size: 1.3em; }
h3 { font-size: 1.15em; }
ul, ol {
margin: 0.25em 0;
padding-left: 1.5em;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
}
li {
margin: 0.125em 0;
}
blockquote {
margin: 0.5em 0;
border-left: 3px solid hsl(var(--primary) / 0.5);
border-radius: 0 var(--radius) var(--radius) 0;
background: hsl(var(--secondary) / 0.3);
padding: 0.25em 0.75em;
color: hsl(var(--muted-foreground));
}
code:not([class*='language-']) {
white-space: pre-wrap;
word-break: break-word;
border-radius: 4px;
background: hsl(var(--secondary));
padding: 0.15em 0.35em;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
}
pre {
margin: 0.5em 0;
max-width: 100%;
overflow-x: auto;
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
background: hsl(var(--secondary));
padding: 0.75em 1em;
code:not([class*='language-']) {
border-radius: 0;
background: transparent;
padding: 0;
white-space: pre;
word-break: normal;
}
}
pre[class*='language-'],
code[class*='language-'] {
text-shadow: none;
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
font-size: 0.875em;
}
pre[class*='language-'] {
border: 1px solid hsl(var(--border));
border-radius: var(--radius);
padding: 0.875em 1rem;
}
pre[class*='language-'] > code[class*='language-'] {
display: block;
border-radius: 0;
background: transparent;
padding: 0;
white-space: pre;
word-break: normal;
}
hr {
margin: 0.75em 0;
border: none;
border-top: 1px solid hsl(var(--border));
}
table {
display: block;
margin: 0.5em 0;
width: auto;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
font-size: 0.875em;
}
th, td {
border: 1px solid hsl(var(--border));
padding: 0.35em 0.75em;
text-align: left;
}
th {
background: hsl(var(--secondary));
font-weight: 600;
}
img {
display: block;
max-height: 320px;
max-width: 100%;
height: auto;
border-radius: var(--radius);
}
p + p {
margin-top: 0.25em;
}
remark-mermaid {
display: block;
max-width: 100%;
overflow-x: auto;
svg {
pointer-events: none;
max-width: 100%;
height: auto;
}
}
}
}
.edit-textarea {
min-height: 42px;
max-height: 520px;
overflow-y: hidden;
resize: none;
transition: height 0.12s ease;
}

View File

@@ -0,0 +1,516 @@
/* eslint-disable @typescript-eslint/member-ordering, */
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import {
Component,
computed,
ElementRef,
effect,
inject,
input,
output,
signal,
ViewChild
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCheck,
lucideDownload,
lucideEdit,
lucideExpand,
lucideImage,
lucideReply,
lucideSmile,
lucideTrash2,
lucideX
} from '@ng-icons/lucide';
import { MermaidComponent, RemarkModule } from 'ngx-remark';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkParse from 'remark-parse';
import { unified } from 'unified';
import {
Attachment,
AttachmentFacade,
MAX_AUTO_SAVE_SIZE_BYTES
} from '../../../../../attachment';
import { KlipyService } from '../../../../application/klipy.service';
import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../../shared-kernel';
import {
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
UserAvatarComponent
} from '../../../../../../shared';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from '../../models/chat-messages.models';
const COMMON_EMOJIS = [
'👍',
'❤️',
'😂',
'😮',
'😢',
'🎉',
'🔥',
'👀'
];
const PRISM_LANGUAGE_ALIASES: Record<string, string> = {
cs: 'csharp',
html: 'markup',
js: 'javascript',
md: 'markdown',
plain: 'none',
plaintext: 'none',
py: 'python',
sh: 'bash',
shell: 'bash',
svg: 'markup',
text: 'none',
ts: 'typescript',
xml: 'markup',
yml: 'yaml',
zsh: 'bash'
};
const MERMAID_LINE_BREAK_PATTERN = /\r\n?/g;
const REMARK_PROCESSOR = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkBreaks);
interface ChatMessageAttachmentViewModel extends Attachment {
isAudio: boolean;
isUploader: boolean;
isVideo: boolean;
mediaActionLabel: string;
mediaStatusText: string;
progressPercent: number;
}
@Component({
selector: 'app-chat-message-item',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
ChatAudioPlayerComponent,
ChatVideoPlayerComponent,
RemarkModule,
MermaidComponent,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideCheck,
lucideDownload,
lucideEdit,
lucideExpand,
lucideImage,
lucideReply,
lucideSmile,
lucideTrash2,
lucideX
})
],
templateUrl: './chat-message-item.component.html',
styleUrl: './chat-message-item.component.scss',
host: {
style: 'display: contents;'
}
})
export class ChatMessageItemComponent {
@ViewChild('editTextareaRef') editTextareaRef?: ElementRef<HTMLTextAreaElement>;
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
private readonly attachmentVersion = signal(this.attachmentsSvc.updated());
readonly message = input.required<Message>();
readonly repliedMessage = input<Message | undefined>();
readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false);
readonly remarkProcessor = REMARK_PROCESSOR;
readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>();
readonly editSaved = output<ChatMessageEditEvent>();
readonly reactionAdded = output<ChatMessageReactionEvent>();
readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly referenceRequested = output<string>();
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
readonly commonEmojis = COMMON_EMOJIS;
readonly deletedMessageContent = DELETED_MESSAGE_CONTENT;
readonly isEditing = signal(false);
readonly showEmojiPicker = signal(false);
editContent = '';
readonly attachmentViewModels = computed<ChatMessageAttachmentViewModel[]>(() => {
void this.attachmentVersion();
return this.attachmentsSvc.getForMessage(this.message().id).map((attachment) =>
this.buildAttachmentViewModel(attachment)
);
});
private readonly syncAttachmentVersion = effect(() => {
const version = this.attachmentsSvc.updated();
queueMicrotask(() => {
if (this.attachmentVersion() !== version) {
this.attachmentVersion.set(version);
}
});
});
startEdit(): void {
this.editContent = this.message().content;
this.isEditing.set(true);
requestAnimationFrame(() => {
this.autoResizeEditTextarea();
const element = this.editTextareaRef?.nativeElement;
if (!element)
return;
element.focus();
element.setSelectionRange(element.value.length, element.value.length);
});
}
onEditEnter(event: Event): void {
const keyEvent = event as KeyboardEvent;
if (keyEvent.shiftKey)
return;
keyEvent.preventDefault();
this.saveEdit();
}
saveEdit(): void {
if (!this.editContent.trim())
return;
this.editSaved.emit({
messageId: this.message().id,
content: this.editContent.trim()
});
this.cancelEdit();
}
cancelEdit(): void {
this.isEditing.set(false);
this.editContent = '';
}
autoResizeEditTextarea(): void {
const element = this.editTextareaRef?.nativeElement;
if (!element)
return;
element.style.height = 'auto';
element.style.height = Math.min(element.scrollHeight, 520) + 'px';
element.style.overflowY = element.scrollHeight > 520 ? 'auto' : 'hidden';
}
toggleEmojiPicker(): void {
this.showEmojiPicker.update((current) => !current);
}
addReaction(emoji: string): void {
this.reactionAdded.emit({
messageId: this.message().id,
emoji
});
this.showEmojiPicker.set(false);
}
toggleReaction(emoji: string): void {
this.reactionToggled.emit({
messageId: this.message().id,
emoji
});
}
requestReply(): void {
this.replyRequested.emit(this.message());
}
requestDelete(): void {
this.deleteRequested.emit(this.message());
}
requestReferenceScroll(messageId: string): void {
this.referenceRequested.emit(messageId);
}
isOwnMessage(): boolean {
return this.message().senderId === this.currentUserId();
}
getGroupedReactions(): { emoji: string; count: number; hasCurrentUser: boolean }[] {
const groups = new Map<string, { count: number; hasCurrentUser: boolean }>();
const currentUserId = this.currentUserId();
this.message().reactions.forEach((reaction) => {
const existing = groups.get(reaction.emoji) || {
count: 0,
hasCurrentUser: false
};
groups.set(reaction.emoji, {
count: existing.count + 1,
hasCurrentUser: existing.hasCurrentUser || reaction.userId === currentUserId
});
});
return Array.from(groups.entries()).map(([emoji, data]) => ({
emoji,
...data
}));
}
formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const time = date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
});
const toDay = (value: Date) =>
new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
const dayDiff = Math.round((toDay(now) - toDay(date)) / (1000 * 60 * 60 * 24));
if (dayDiff === 0)
return time;
if (dayDiff === 1)
return 'Yesterday ' + time;
if (dayDiff < 7) {
return (
date.toLocaleDateString([], { weekday: 'short' }) +
' ' +
time
);
}
return (
date.toLocaleDateString([], {
month: 'short',
day: 'numeric'
}) +
' ' +
time
);
}
getMarkdownImageSource(url?: string): string {
return url ? this.klipy.buildRenderableImageUrl(url) : '';
}
getMermaidCode(code?: string): string {
return (code ?? '').replace(MERMAID_LINE_BREAK_PATTERN, '\n').trim();
}
isKlipyMediaUrl(url?: string): boolean {
if (!url)
return false;
return /^(?:https?:)?\/\/(?:[^/]+\.)?klipy\.com/i.test(url);
}
isMermaidCodeBlock(lang?: string): boolean {
return this.normalizeCodeLanguage(lang) === 'mermaid';
}
getCodeBlockClass(lang?: string): string {
return `language-${this.normalizeCodeLanguage(lang)}`;
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
formatSpeed(bytesPerSecond?: number): string {
if (!bytesPerSecond || bytesPerSecond <= 0)
return '0 B/s';
const units = [
'B/s',
'KB/s',
'MB/s',
'GB/s'
];
let speed = bytesPerSecond;
let unitIndex = 0;
while (speed >= 1024 && unitIndex < units.length - 1) {
speed /= 1024;
unitIndex++;
}
return `${speed.toFixed(speed < 100 ? 2 : 1)} ${units[unitIndex]}`;
}
isVideoAttachment(attachment: Attachment): boolean {
return attachment.mime.startsWith('video/');
}
isAudioAttachment(attachment: Attachment): boolean {
return attachment.mime.startsWith('audio/');
}
requiresMediaDownloadAcceptance(attachment: Attachment): boolean {
return (
(this.isVideoAttachment(attachment) || this.isAudioAttachment(attachment)) &&
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES
);
}
getMediaAttachmentStatusText(attachment: Attachment): string {
if (attachment.requestError)
return attachment.requestError;
if (this.requiresMediaDownloadAcceptance(attachment)) {
return this.isVideoAttachment(attachment)
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.';
}
return this.isVideoAttachment(attachment)
? 'Waiting for video source…'
: 'Waiting for audio source…';
}
getMediaAttachmentActionLabel(attachment: Attachment): string {
if (this.requiresMediaDownloadAcceptance(attachment)) {
return attachment.requestError ? 'Retry download' : 'Accept download';
}
return attachment.requestError ? 'Retry' : 'Request';
}
isUploader(attachment: Attachment): boolean {
const currentUserId = this.currentUserId();
return !!attachment.uploaderPeerId && !!currentUserId && attachment.uploaderPeerId === currentUserId;
}
requestAttachment(attachment: Attachment): void {
const liveAttachment = this.getLiveAttachment(attachment.id);
if (liveAttachment) {
this.attachmentsSvc.requestFile(this.message().id, liveAttachment);
}
}
cancelAttachment(attachment: Attachment): void {
const liveAttachment = this.getLiveAttachment(attachment.id);
if (liveAttachment) {
this.attachmentsSvc.cancelRequest(this.message().id, liveAttachment);
}
}
retryImageRequest(attachment: Attachment): void {
const liveAttachment = this.getLiveAttachment(attachment.id);
if (liveAttachment) {
this.attachmentsSvc.requestImageFromAnyPeer(this.message().id, liveAttachment);
}
}
openLightbox(attachment: Attachment): void {
if (attachment.available && attachment.objectUrl) {
this.imageOpened.emit(attachment);
}
}
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenuRequested.emit({
positionX: event.clientX,
positionY: event.clientY,
attachment
});
}
downloadAttachment(attachment: Attachment): void {
this.downloadRequested.emit(attachment);
}
private normalizeCodeLanguage(lang?: string): string {
const normalized = (lang || '').trim().toLowerCase();
if (!normalized)
return 'none';
return PRISM_LANGUAGE_ALIASES[normalized] ?? normalized;
}
private buildAttachmentViewModel(attachment: Attachment): ChatMessageAttachmentViewModel {
const isVideo = this.isVideoAttachment(attachment);
const isAudio = this.isAudioAttachment(attachment);
const requiresMediaDownloadAcceptance =
(isVideo || isAudio) && attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
return {
...attachment,
isAudio,
isUploader: this.isUploader(attachment),
isVideo,
mediaActionLabel: requiresMediaDownloadAcceptance
? attachment.requestError ? 'Retry download' : 'Accept download'
: attachment.requestError ? 'Retry' : 'Request',
mediaStatusText: attachment.requestError
? attachment.requestError
: requiresMediaDownloadAcceptance
? isVideo
? 'Large video. Accept the download to watch it in chat.'
: 'Large audio file. Accept the download to play it in chat.'
: isVideo
? 'Waiting for video source…'
: 'Waiting for audio source…',
progressPercent: attachment.size > 0
? ((attachment.receivedBytes || 0) * 100) / attachment.size
: 0
};
}
private getLiveAttachment(attachmentId: string): Attachment | undefined {
return this.attachmentsSvc
.getForMessage(this.message().id)
.find((attachment) => attachment.id === attachmentId);
}
}

View File

@@ -0,0 +1,73 @@
<div
#messagesContainer
class="absolute inset-0 space-y-4 overflow-y-auto p-4"
[style.padding-bottom.px]="bottomPadding()"
(scroll)="onScroll()"
>
@if (syncing() && !loading()) {
<div class="flex items-center justify-center gap-2 py-1.5 text-xs text-muted-foreground">
<div class="h-3 w-3 animate-spin rounded-full border-b-2 border-primary"></div>
<span>Syncing messages…</span>
</div>
}
@if (loading()) {
<div class="flex items-center justify-center py-8">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
</div>
} @else if (messages().length === 0) {
<div class="flex h-full flex-col items-center justify-center text-muted-foreground">
<p class="text-lg">No messages yet</p>
<p class="text-sm">Be the first to say something!</p>
</div>
} @else {
@if (hasMoreMessages()) {
<div class="flex items-center justify-center py-3">
@if (loadingMore()) {
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div>
} @else {
<button
type="button"
(click)="loadMore()"
class="rounded-md px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
Load older messages
</button>
}
</div>
}
@for (message of messages(); track message.id) {
<app-chat-message-item
[message]="message"
[repliedMessage]="findRepliedMessage(message.replyToId)"
[currentUserId]="currentUserId()"
[isAdmin]="isAdmin()"
(replyRequested)="handleReplyRequested($event)"
(deleteRequested)="handleDeleteRequested($event)"
(editSaved)="handleEditSaved($event)"
(reactionAdded)="handleReactionAdded($event)"
(reactionToggled)="handleReactionToggled($event)"
(referenceRequested)="handleReferenceRequested($event)"
(downloadRequested)="handleDownloadRequested($event)"
(imageOpened)="handleImageOpened($event)"
(imageContextMenuRequested)="handleImageContextMenuRequested($event)"
/>
}
}
@if (showNewMessagesBar()) {
<div class="pointer-events-none sticky bottom-4 flex justify-center">
<div class="pointer-events-auto flex items-center gap-3 rounded-lg border border-border bg-card px-3 py-2 shadow">
<span class="text-sm text-muted-foreground">New messages</span>
<button
type="button"
(click)="readLatest()"
class="rounded bg-primary px-2 py-1 text-sm text-primary-foreground hover:bg-primary/90"
>
Read latest
</button>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,412 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import {
AfterViewChecked,
Component,
ElementRef,
OnDestroy,
ViewChild,
computed,
effect,
input,
output,
signal
} from '@angular/core';
import { Attachment } from '../../../../../attachment';
import { Message } from '../../../../../../shared-kernel';
import {
ChatMessageDeleteEvent,
ChatMessageEditEvent,
ChatMessageImageContextMenuEvent,
ChatMessageReactionEvent,
ChatMessageReplyEvent
} from '../../models/chat-messages.models';
import { ChatMessageItemComponent } from '../message-item/chat-message-item.component';
interface PrismGlobal {
highlightElement(element: Element): void;
}
declare global {
interface Window {
Prism?: PrismGlobal;
}
}
@Component({
selector: 'app-chat-message-list',
standalone: true,
imports: [CommonModule, ChatMessageItemComponent],
templateUrl: './chat-message-list.component.html',
host: {
style: 'display: contents;'
}
})
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
readonly allMessages = input.required<Message[]>();
readonly channelMessages = input.required<Message[]>();
readonly loading = input(false);
readonly syncing = input(false);
readonly currentUserId = input<string | null>(null);
readonly isAdmin = input(false);
readonly bottomPadding = input(120);
readonly conversationKey = input.required<string>();
readonly replyRequested = output<ChatMessageReplyEvent>();
readonly deleteRequested = output<ChatMessageDeleteEvent>();
readonly editSaved = output<ChatMessageEditEvent>();
readonly reactionAdded = output<ChatMessageReactionEvent>();
readonly reactionToggled = output<ChatMessageReactionEvent>();
readonly downloadRequested = output<Attachment>();
readonly imageOpened = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
private readonly PAGE_SIZE = 50;
readonly displayLimit = signal(this.PAGE_SIZE);
readonly loadingMore = signal(false);
readonly showNewMessagesBar = signal(false);
readonly messages = computed(() => {
const all = this.channelMessages();
const limit = this.displayLimit();
if (all.length <= limit)
return all;
return all.slice(all.length - limit);
});
readonly hasMoreMessages = computed(
() => this.channelMessages().length > this.displayLimit()
);
private initialScrollObserver: MutationObserver | null = null;
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
private boundOnImageLoad: (() => void) | null = null;
private isAutoScrolling = false;
private lastMessageCount = 0;
private initialScrollPending = true;
private prismHighlightScheduled = false;
private readonly onConversationChanged = effect(() => {
void this.conversationKey();
this.resetScrollingState();
});
private readonly onMessagesChanged = effect(() => {
const currentCount = this.channelMessages().length;
const element = this.messagesContainer?.nativeElement;
if (!element) {
this.lastMessageCount = currentCount;
return;
}
if (this.initialScrollPending) {
this.lastMessageCount = currentCount;
return;
}
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const newMessages = currentCount > this.lastMessageCount;
if (newMessages) {
if (distanceFromBottom <= 300) {
this.scheduleScrollToBottomSmooth();
this.showNewMessagesBar.set(false);
} else {
queueMicrotask(() => this.showNewMessagesBar.set(true));
}
}
this.lastMessageCount = currentCount;
});
ngAfterViewChecked(): void {
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
if (this.initialScrollPending) {
if (this.messages().length > 0) {
this.initialScrollPending = false;
this.isAutoScrolling = true;
element.scrollTop = element.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
this.startInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = this.messages().length;
this.scheduleCodeHighlight();
} else if (!this.loading()) {
this.initialScrollPending = false;
this.lastMessageCount = 0;
}
return;
}
this.scheduleCodeHighlight();
}
ngOnDestroy(): void {
this.stopInitialScrollWatch();
}
findRepliedMessage(messageId?: string | null): Message | undefined {
if (!messageId)
return undefined;
return this.allMessages().find((message) => message.id === messageId);
}
onScroll(): void {
const element = this.messagesContainer?.nativeElement;
if (!element || this.isAutoScrolling)
return;
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const shouldStickToBottom = distanceFromBottom <= 300;
if (shouldStickToBottom) {
this.showNewMessagesBar.set(false);
}
if (this.initialScrollObserver) {
this.stopInitialScrollWatch();
}
if (element.scrollTop < 150 && this.hasMoreMessages() && !this.loadingMore()) {
this.loadMore();
}
}
loadMore(): void {
if (this.loadingMore() || !this.hasMoreMessages())
return;
this.loadingMore.set(true);
const element = this.messagesContainer?.nativeElement;
const previousScrollHeight = element?.scrollHeight ?? 0;
this.displayLimit.update((limit) => limit + this.PAGE_SIZE);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (element) {
const newScrollHeight = element.scrollHeight;
element.scrollTop += newScrollHeight - previousScrollHeight;
}
this.loadingMore.set(false);
});
});
}
readLatest(): void {
this.scrollToBottomSmooth();
this.showNewMessagesBar.set(false);
}
scrollToMessage(messageId: string): void {
const container = this.messagesContainer?.nativeElement;
if (!container)
return;
const element = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement | null;
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
element.classList.add('bg-primary/10');
setTimeout(() => element.classList.remove('bg-primary/10'), 2000);
}
}
handleReplyRequested(message: ChatMessageReplyEvent): void {
this.replyRequested.emit(message);
}
handleDeleteRequested(message: ChatMessageDeleteEvent): void {
this.deleteRequested.emit(message);
}
handleEditSaved(event: ChatMessageEditEvent): void {
this.editSaved.emit(event);
}
handleReactionAdded(event: ChatMessageReactionEvent): void {
this.reactionAdded.emit(event);
}
handleReactionToggled(event: ChatMessageReactionEvent): void {
this.reactionToggled.emit(event);
}
handleReferenceRequested(messageId: string): void {
this.scrollToMessage(messageId);
}
handleDownloadRequested(attachment: Attachment): void {
this.downloadRequested.emit(attachment);
}
handleImageOpened(attachment: Attachment): void {
this.imageOpened.emit(attachment);
}
handleImageContextMenuRequested(event: ChatMessageImageContextMenuEvent): void {
this.imageContextMenuRequested.emit(event);
}
private resetScrollingState(): void {
this.initialScrollPending = true;
this.stopInitialScrollWatch();
this.showNewMessagesBar.set(false);
this.lastMessageCount = 0;
this.displayLimit.set(this.PAGE_SIZE);
}
private startInitialScrollWatch(): void {
this.stopInitialScrollWatch();
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
const snapToBottom = () => {
const container = this.messagesContainer?.nativeElement;
if (!container)
return;
this.isAutoScrolling = true;
container.scrollTop = container.scrollHeight;
requestAnimationFrame(() => {
this.isAutoScrolling = false;
});
};
this.initialScrollObserver = new MutationObserver(() => {
requestAnimationFrame(snapToBottom);
});
this.initialScrollObserver.observe(element, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src']
});
this.boundOnImageLoad = () => requestAnimationFrame(snapToBottom);
element.addEventListener('load', this.boundOnImageLoad, true);
this.initialScrollTimer = setTimeout(() => this.stopInitialScrollWatch(), 5000);
}
private stopInitialScrollWatch(): void {
if (this.initialScrollObserver) {
this.initialScrollObserver.disconnect();
this.initialScrollObserver = null;
}
if (this.boundOnImageLoad && this.messagesContainer) {
this.messagesContainer.nativeElement.removeEventListener(
'load',
this.boundOnImageLoad,
true
);
this.boundOnImageLoad = null;
}
if (this.initialScrollTimer) {
clearTimeout(this.initialScrollTimer);
this.initialScrollTimer = null;
}
}
private scrollToBottomSmooth(): void {
const element = this.messagesContainer?.nativeElement;
if (!element)
return;
try {
element.scrollTo({
top: element.scrollHeight,
behavior: 'smooth'
});
} catch {
element.scrollTop = element.scrollHeight;
}
}
private scheduleScrollToBottomSmooth(): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => this.scrollToBottomSmooth());
});
}
private scheduleCodeHighlight(): void {
if (this.prismHighlightScheduled)
return;
this.prismHighlightScheduled = true;
requestAnimationFrame(() => {
this.prismHighlightScheduled = false;
this.highlightRenderedCodeBlocks();
});
}
private highlightRenderedCodeBlocks(): void {
const container = this.messagesContainer?.nativeElement;
const prism = window.Prism;
if (!container || !prism?.highlightElement)
return;
const blocks = container.querySelectorAll<HTMLElement>('pre > code[class*="language-"]');
for (const block of blocks) {
const signature = this.getCodeBlockSignature(block);
if (block.dataset['prismSignature'] === signature)
continue;
try {
prism.highlightElement(block);
} finally {
block.dataset['prismSignature'] = signature;
}
}
}
private getCodeBlockSignature(block: HTMLElement): string {
const value = `${block.className}:${block.textContent ?? ''}`;
let hash = 0;
for (let index = 0; index < value.length; index++) {
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
}
return String(hash);
}
}

View File

@@ -0,0 +1,79 @@
<!-- eslint-disable @angular-eslint/template/button-has-type, @angular-eslint/template/click-events-have-key-events, @angular-eslint/template/interactive-supports-focus, @angular-eslint/template/prefer-ngsrc -->
@if (lightboxAttachment()) {
<div
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-sm"
(click)="closeLightbox()"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!)"
(keydown.escape)="closeLightbox()"
tabindex="0"
>
<div
class="relative max-h-[90vh] max-w-[90vw]"
(click)="$event.stopPropagation()"
>
<img
[src]="lightboxAttachment()!.objectUrl"
[alt]="lightboxAttachment()!.filename"
class="max-h-[90vh] max-w-[90vw] rounded-lg object-contain shadow-2xl"
(contextmenu)="openImageContextMenu($event, lightboxAttachment()!); $event.stopPropagation()"
/>
<div class="absolute right-3 top-3 flex gap-2">
<button
(click)="downloadAttachment(lightboxAttachment()!)"
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Download"
>
<ng-icon
name="lucideDownload"
class="h-5 w-5"
/>
</button>
<button
(click)="closeLightbox()"
class="rounded-lg bg-black/60 p-2 text-white backdrop-blur-sm transition-colors hover:bg-black/80"
title="Close"
>
<ng-icon
name="lucideX"
class="h-5 w-5"
/>
</button>
</div>
<div class="absolute bottom-3 left-3 right-3 flex items-center justify-between">
<div class="rounded-lg bg-black/60 px-3 py-1.5 backdrop-blur-sm">
<span class="text-sm text-white">{{ lightboxAttachment()!.filename }}</span>
<span class="ml-2 text-xs text-white/60">{{ formatBytes(lightboxAttachment()!.size) }}</span>
</div>
</div>
</div>
</div>
}
@if (imageContextMenu()) {
<app-context-menu
[x]="imageContextMenu()!.positionX"
[y]="imageContextMenu()!.positionY"
(closed)="closeImageContextMenu()"
>
<button
(click)="copyImageToClipboard(imageContextMenu()!.attachment); closeImageContextMenu()"
class="context-menu-item-icon"
>
<ng-icon
name="lucideCopy"
class="h-4 w-4 text-muted-foreground"
/>
Copy Image
</button>
<button
(click)="downloadAttachment(imageContextMenu()!.attachment); closeImageContextMenu()"
class="context-menu-item-icon"
>
<ng-icon
name="lucideDownload"
class="h-4 w-4 text-muted-foreground"
/>
Save Image
</button>
</app-context-menu>
}

View File

@@ -0,0 +1,91 @@
import { CommonModule } from '@angular/common';
import {
Component,
input,
output
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideCopy,
lucideDownload,
lucideX
} from '@ng-icons/lucide';
import { Attachment } from '../../../../../attachment';
import { ContextMenuComponent } from '../../../../../../shared';
import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models';
@Component({
selector: 'app-chat-message-overlays',
standalone: true,
imports: [
CommonModule,
NgIcon,
ContextMenuComponent
],
viewProviders: [
provideIcons({
lucideCopy,
lucideDownload,
lucideX
})
],
templateUrl: './chat-message-overlays.component.html',
host: {
style: 'display: contents;'
}
})
export class ChatMessageOverlaysComponent {
readonly lightboxAttachment = input<Attachment | null>(null);
readonly imageContextMenu = input<ChatMessageImageContextMenuEvent | null>(null);
readonly lightboxClosed = output();
readonly contextMenuClosed = output();
readonly downloadRequested = output<Attachment>();
readonly copyRequested = output<Attachment>();
readonly imageContextMenuRequested = output<ChatMessageImageContextMenuEvent>();
closeLightbox(): void {
this.lightboxClosed.emit();
}
closeImageContextMenu(): void {
this.contextMenuClosed.emit();
}
downloadAttachment(attachment: Attachment): void {
this.downloadRequested.emit(attachment);
}
copyImageToClipboard(attachment: Attachment): void {
this.copyRequested.emit(attachment);
}
openImageContextMenu(event: MouseEvent, attachment: Attachment): void {
event.preventDefault();
event.stopPropagation();
this.imageContextMenuRequested.emit({
positionX: event.clientX,
positionY: event.clientY,
attachment
});
}
formatBytes(bytes: number): string {
const units = [
'B',
'KB',
'MB',
'GB'
];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
}

View File

@@ -0,0 +1,31 @@
import { Attachment } from '../../../../attachment';
import { Message } from '../../../../../shared-kernel';
export interface ChatMessageComposerSubmitEvent {
content: string;
pendingFiles: File[];
}
export interface ChatMessageEditEvent {
messageId: string;
content: string;
}
export interface ChatMessageReactionEvent {
messageId: string;
emoji: string;
}
export interface ChatMessageAttachmentEvent {
messageId: string;
attachment: Attachment;
}
export interface ChatMessageImageContextMenuEvent {
positionX: number;
positionY: number;
attachment: Attachment;
}
export type ChatMessageReplyEvent = Message;
export type ChatMessageDeleteEvent = Message;

View File

@@ -0,0 +1,163 @@
import { Injectable } from '@angular/core';
export interface SelectionRange {
start: number;
end: number;
}
export interface ComposeResult {
text: string;
selectionStart: number;
selectionEnd: number;
}
@Injectable({ providedIn: 'root' })
export class ChatMarkdownService {
applyInline(content: string, selection: SelectionRange, token: string): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'text';
const after = content.slice(end);
const newText = `${before}${token}${selected}${token}${after}`;
const cursor = before.length + token.length + selected.length + token.length;
return { text: newText,
selectionStart: cursor,
selectionEnd: cursor };
}
applyPrefix(content: string, selection: SelectionRange, prefix: string): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'text';
const after = content.slice(end);
const lines = selected.split('\n').map(line => `${prefix}${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyHeading(content: string, selection: SelectionRange, level: number): ComposeResult {
const hashes = '#'.repeat(Math.max(1, Math.min(6, level)));
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'Heading';
const after = content.slice(end);
const needsLeadingNewline = before.length > 0 && !before.endsWith('\n');
const needsTrailingNewline = after.length > 0 && !after.startsWith('\n');
const block = `${needsLeadingNewline ? '\n' : ''}${hashes} ${selected}${needsTrailingNewline ? '\n' : ''}`;
const text = `${before}${block}${after}`;
const cursor = before.length + block.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyOrderedList(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'item\nitem';
const after = content.slice(end);
const lines = selected.split('\n').map((line, index) => `${index + 1}. ${line}`);
const newSelected = lines.join('\n');
const text = `${before}${newSelected}${after}`;
const cursor = before.length + newSelected.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyCodeBlock(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'code';
const after = content.slice(end);
const fenced = `\`\`\`\n${selected}\n\`\`\`\n\n`;
const text = `${before}${fenced}${after}`;
const cursor = before.length + fenced.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
applyLink(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'link';
const after = content.slice(end);
const link = `[${selected}](https://)`;
const text = `${before}${link}${after}`;
const cursorStart = before.length + link.length - 1;
// Position inside the URL placeholder
return { text,
selectionStart: cursorStart - 8,
selectionEnd: cursorStart - 1 };
}
applyImage(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const selected = content.slice(start, end) || 'alt';
const after = content.slice(end);
const img = `![${selected}](https://)`;
const text = `${before}${img}${after}`;
const cursorStart = before.length + img.length - 1;
return { text,
selectionStart: cursorStart - 8,
selectionEnd: cursorStart - 1 };
}
applyHorizontalRule(content: string, selection: SelectionRange): ComposeResult {
const { start, end } = selection;
const before = content.slice(0, start);
const after = content.slice(end);
const hr = '\n\n---\n\n';
const text = `${before}${hr}${after}`;
const cursor = before.length + hr.length;
return { text,
selectionStart: cursor,
selectionEnd: cursor };
}
appendImageMarkdown(content: string): string {
const imageUrlRegex = /(https?:\/\/[^\s)]+?\.(?:png|jpe?g|gif|webp|svg|bmp|tiff)(?:\?[^\s)]*)?)/ig;
const urls = new Set<string>();
let match: RegExpExecArray | null;
const text = content;
while ((match = imageUrlRegex.exec(text)) !== null) {
urls.add(match[1]);
}
if (urls.size === 0)
return content;
let append = '';
for (const url of urls) {
const alreadyEmbedded = new RegExp(`!\\[[^\\]]*\\]\\(\\s*${this.escapeRegex(url)}\\s*\\)`, 'i').test(text);
if (!alreadyEmbedded) {
append += `\n![](${url})`;
}
}
return append ? content + append : content;
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&');
}
}

View File

@@ -0,0 +1,133 @@
<!-- eslint-disable @angular-eslint/template/prefer-ngsrc -->
<div
class="flex h-[min(70vh,42rem)] w-full flex-col overflow-hidden rounded-[1.65rem] border border-border/80 shadow-2xl ring-1 ring-white/5"
role="dialog"
aria-label="KLIPY GIF picker"
style="background: hsl(var(--background) / 0.85); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px)"
>
<div class="flex items-start justify-between gap-4 border-b border-border/70 bg-secondary/15 px-5 py-4">
<div>
<div class="text-[11px] font-semibold uppercase tracking-[0.28em] text-primary">KLIPY</div>
<h3 class="mt-1 text-lg font-semibold text-foreground">Choose a GIF</h3>
<p class="mt-1 text-sm text-muted-foreground">
{{ searchQuery.trim() ? 'Search results from KLIPY.' : 'Trending GIFs from KLIPY.' }}
</p>
</div>
<button
type="button"
(click)="close()"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-border/70 bg-secondary/30 text-muted-foreground transition-colors hover:bg-secondary/80 hover:text-foreground"
aria-label="Close GIF picker"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<div class="border-b border-border/70 bg-secondary/10 px-5 py-4">
<label class="relative block">
<ng-icon
name="lucideSearch"
class="pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-muted-foreground"
/>
<input
#searchInput
type="text"
[ngModel]="searchQuery"
(ngModelChange)="onSearchQueryChanged($event)"
placeholder="Search KLIPY"
class="relative z-0 w-full rounded-xl border border-border/80 bg-background/70 px-10 py-3 text-sm text-foreground placeholder:text-muted-foreground shadow-sm backdrop-blur-sm focus:outline-none focus:ring-2 focus:ring-primary"
/>
</label>
</div>
<div class="flex-1 overflow-y-auto px-5 py-4">
@if (errorMessage()) {
<div
class="mb-4 flex items-center justify-between gap-3 rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm text-destructive backdrop-blur-sm"
>
<span>{{ errorMessage() }}</span>
<button
type="button"
(click)="retry()"
class="rounded-lg bg-destructive px-3 py-1.5 text-xs font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
Retry
</button>
</div>
}
@if (loading() && results().length === 0) {
<div class="flex h-full min-h-56 flex-col items-center justify-center gap-3 text-muted-foreground">
<span class="h-6 w-6 animate-spin rounded-full border-2 border-primary/20 border-t-primary"></span>
<p class="text-sm">Loading GIFs from KLIPY…</p>
</div>
} @else if (results().length === 0) {
<div
class="flex h-full min-h-56 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/80 bg-secondary/10 px-6 text-center text-muted-foreground"
>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<ng-icon
name="lucideImage"
class="h-5 w-5"
/>
</div>
<div>
<p class="text-sm font-medium text-foreground">No GIFs found</p>
<p class="mt-1 text-sm">Try another search term or clear the search to browse trending GIFs.</p>
</div>
</div>
} @else {
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-4">
@for (gif of results(); track gif.id) {
<button
type="button"
(click)="selectGif(gif)"
class="group overflow-hidden rounded-2xl border border-border/80 bg-secondary/10 text-left shadow-sm transition-transform duration-200 hover:-translate-y-0.5 hover:border-primary/50 hover:bg-secondary/30"
>
<div
class="relative overflow-hidden bg-secondary/30"
[style.aspect-ratio]="gifAspectRatio(gif)"
>
<img
[src]="gifPreviewUrl(gif)"
[alt]="gif.title || 'KLIPY GIF'"
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"
/>
<span
class="pointer-events-none absolute bottom-2 left-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.24em] text-white/90 backdrop-blur-sm"
>
KLIPY
</span>
</div>
<div class="px-3 py-2">
<p class="truncate text-xs font-medium text-foreground">
{{ gif.title || 'KLIPY GIF' }}
</p>
<p class="mt-1 text-[10px] uppercase tracking-[0.22em] text-muted-foreground">Click to select</p>
</div>
</button>
}
</div>
}
</div>
<div class="flex items-center justify-between gap-4 border-t border-border/70 bg-secondary/10 px-5 py-4">
<p class="text-xs text-muted-foreground">Click a GIF to select it. Powered by KLIPY.</p>
@if (hasNext()) {
<button
type="button"
(click)="loadMore()"
[disabled]="loading()"
class="rounded-full border border-border/80 bg-background/60 px-4 py-2 text-xs font-medium text-foreground transition-colors hover:bg-secondary disabled:cursor-not-allowed disabled:opacity-60"
>
{{ loading() ? 'Loading…' : 'Load more' }}
</button>
}
</div>
</div>

View File

@@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
AfterViewInit,
Component,
ElementRef,
HostListener,
OnDestroy,
OnInit,
ViewChild,
inject,
output,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideImage,
lucideSearch,
lucideX
} from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../application/klipy.service';
@Component({
selector: 'app-klipy-gif-picker',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon
],
viewProviders: [
provideIcons({
lucideImage,
lucideSearch,
lucideX
})
],
templateUrl: './klipy-gif-picker.component.html'
})
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
readonly gifSelected = output<KlipyGif>();
readonly closed = output<undefined>();
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
private readonly klipy = inject(KlipyService);
private currentPage = 1;
private searchTimer: ReturnType<typeof setTimeout> | null = null;
private requestId = 0;
searchQuery = '';
results = signal<KlipyGif[]>([]);
loading = signal(false);
errorMessage = signal('');
hasNext = signal(false);
ngOnInit(): void {
void this.loadResults(true);
}
ngAfterViewInit(): void {
requestAnimationFrame(() => {
this.searchInput?.nativeElement.focus();
this.searchInput?.nativeElement.select();
});
}
ngOnDestroy(): void {
this.clearSearchTimer();
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.close();
}
onSearchQueryChanged(query: string): void {
this.searchQuery = query;
this.clearSearchTimer();
this.searchTimer = setTimeout(() => {
void this.loadResults(true);
}, 250);
}
retry(): void {
void this.loadResults(true);
}
async loadMore(): Promise<void> {
if (this.loading() || !this.hasNext())
return;
this.currentPage += 1;
await this.loadResults(false);
}
selectGif(gif: KlipyGif): void {
this.gifSelected.emit(gif);
}
close(): void {
this.closed.emit(undefined);
}
gifAspectRatio(gif: KlipyGif): string {
if (gif.width > 0 && gif.height > 0) {
return `${gif.width} / ${gif.height}`;
}
return '1 / 1';
}
gifPreviewUrl(gif: KlipyGif): string {
return this.klipy.buildRenderableImageUrl(gif.previewUrl || gif.url);
}
private async loadResults(reset: boolean): Promise<void> {
if (reset) {
this.currentPage = 1;
}
const requestId = ++this.requestId;
this.loading.set(true);
this.errorMessage.set('');
try {
const response = await firstValueFrom(
this.klipy.searchGifs(this.searchQuery, this.currentPage)
);
if (requestId !== this.requestId)
return;
this.results.set(
reset
? response.results
: this.mergeResults(this.results(), response.results)
);
this.hasNext.set(response.hasNext);
} catch (error) {
if (requestId !== this.requestId)
return;
this.errorMessage.set(
error instanceof Error
? error.message
: 'Failed to load GIFs from KLIPY.'
);
if (reset) {
this.results.set([]);
}
this.hasNext.set(false);
} finally {
if (requestId === this.requestId) {
this.loading.set(false);
}
}
}
private mergeResults(existing: KlipyGif[], incoming: KlipyGif[]): KlipyGif[] {
const seen = new Set(existing.map((gif) => gif.id));
const merged = [...existing];
for (const gif of incoming) {
if (seen.has(gif.id))
continue;
merged.push(gif);
seen.add(gif.id);
}
return merged;
}
private clearSearchTimer(): void {
if (this.searchTimer) {
clearTimeout(this.searchTimer);
this.searchTimer = null;
}
}
}

View File

@@ -0,0 +1,12 @@
@if (typingDisplay().length > 0) {
<div class="px-4 py-2 backdrop-blur-sm bg-background/60">
<span class="inline-block px-3 py-1 rounded-full text-sm text-muted-foreground">
{{ typingDisplay().join(', ') }}
@if (typingOthersCount() > 0) {
and {{ typingOthersCount() }} others are typing...
} @else {
{{ typingDisplay().length === 1 ? 'is' : 'are' }} typing...
}
</span>
</div>
}

View File

@@ -0,0 +1,111 @@
/* eslint-disable @typescript-eslint/member-ordering, id-length, id-denylist, */
import {
Component,
inject,
signal,
DestroyRef,
effect
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import {
merge,
interval,
filter,
map,
tap
} from 'rxjs';
const TYPING_TTL = 3_000;
const PURGE_INTERVAL = 1_000;
const MAX_SHOWN = 4;
interface TypingSignalingMessage {
type: string;
displayName: string;
oderId: string;
serverId: string;
}
@Component({
selector: 'app-typing-indicator',
standalone: true,
templateUrl: './typing-indicator.component.html',
host: {
'class': 'block',
'style': 'background: linear-gradient(to bottom, transparent, hsl(var(--background)));'
}
})
export class TypingIndicatorComponent {
private readonly typingMap = new Map<string, { name: string; expiresAt: number }>();
private readonly store = inject(Store);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
private lastRoomId: string | null = null;
typingDisplay = signal<string[]>([]);
typingOthersCount = signal<number>(0);
constructor() {
const webrtc = inject(RealtimeSessionFacade);
const destroyRef = inject(DestroyRef);
const typing$ = webrtc.onSignalingMessage.pipe(
filter((msg): msg is TypingSignalingMessage =>
msg?.type === 'user_typing' &&
typeof msg.displayName === 'string' &&
typeof msg.oderId === 'string' &&
typeof msg.serverId === 'string'
),
filter((msg) => msg.serverId === this.currentRoom()?.id),
tap((msg) => {
const now = Date.now();
this.typingMap.set(msg.oderId, {
name: msg.displayName,
expiresAt: now + TYPING_TTL
});
})
);
const purge$ = interval(PURGE_INTERVAL).pipe(
map(() => Date.now()),
filter((now) => {
let changed = false;
for (const [key, entry] of this.typingMap) {
if (entry.expiresAt <= now) {
this.typingMap.delete(key);
changed = true;
}
}
return changed;
})
);
merge(typing$, purge$)
.pipe(takeUntilDestroyed(destroyRef))
.subscribe(() => this.recomputeDisplay());
effect(() => {
const roomId = this.currentRoom()?.id ?? null;
if (roomId === this.lastRoomId)
return;
this.lastRoomId = roomId;
this.typingMap.clear();
this.recomputeDisplay();
});
}
private recomputeDisplay(): void {
const now = Date.now();
const names = Array.from(this.typingMap.values())
.filter((e) => e.expiresAt > now)
.map((e) => e.name);
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
}
}

View File

@@ -0,0 +1,202 @@
<!-- Header -->
<div class="p-4 border-b border-border">
<h3 class="font-semibold text-foreground">Members</h3>
<p class="text-xs text-muted-foreground">{{ onlineUsers().length }} online · {{ voiceUsers().length }} in voice</p>
@if (voiceUsers().length > 0) {
<div class="mt-2 flex flex-wrap gap-2">
@for (v of voiceUsers(); track v.id) {
<span class="px-2 py-1 text-xs rounded bg-secondary text-foreground flex items-center gap-1">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-green-500"></span>
{{ v.displayName }}
</span>
}
</div>
}
</div>
<!-- User List -->
<div class="flex-1 overflow-y-auto p-2 space-y-1">
@for (user of onlineUsers(); track user.id) {
<div
class="group relative flex items-center gap-3 p-2 rounded-lg hover:bg-secondary/50 transition-colors cursor-pointer"
(click)="toggleUserMenu(user.id)"
(keydown.enter)="toggleUserMenu(user.id)"
(keydown.space)="toggleUserMenu(user.id)"
(keyup.enter)="toggleUserMenu(user.id)"
(keyup.space)="toggleUserMenu(user.id)"
role="button"
tabindex="0"
>
<!-- Avatar with online indicator -->
<div class="relative">
<app-user-avatar
[name]="user.displayName"
size="sm"
/>
<span
class="absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-card"
[class.bg-green-500]="user.isOnline !== false && user.status !== 'offline'"
[class.bg-gray-500]="user.isOnline === false || user.status === 'offline'"
></span>
</div>
<!-- User Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1">
<span class="font-medium text-sm text-foreground truncate">
{{ user.displayName }}
</span>
@if (user.isAdmin) {
<ng-icon
name="lucideShield"
class="w-3 h-3 text-primary"
/>
}
@if (user.isRoomOwner) {
<ng-icon
name="lucideCrown"
class="w-3 h-3 text-yellow-500"
/>
}
</div>
</div>
<!-- Voice/Screen Status -->
<div class="flex items-center gap-1">
@if (user.voiceState?.isSpeaking) {
<ng-icon
name="lucideMic"
class="w-4 h-4 text-green-500 animate-pulse"
/>
} @else if (user.voiceState?.isMuted) {
<ng-icon
name="lucideMicOff"
class="w-4 h-4 text-muted-foreground"
/>
} @else if (user.voiceState?.isConnected) {
<ng-icon
name="lucideMic"
class="w-4 h-4 text-muted-foreground"
/>
}
@if (user.screenShareState?.isSharing) {
<ng-icon
name="lucideMonitor"
class="w-4 h-4 text-primary"
/>
}
</div>
<!-- User Menu -->
@if (showUserMenu() === user.id && isAdmin() && !isCurrentUser(user)) {
<div
class="absolute right-0 top-full mt-1 z-10 w-48 bg-card border border-border rounded-lg shadow-lg py-1"
(click)="$event.stopPropagation()"
(keydown)="$event.stopPropagation()"
role="menu"
tabindex="0"
>
@if (user.voiceState?.isConnected) {
<button
type="button"
(click)="muteUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2"
>
@if (user.voiceState?.isMutedByAdmin) {
<ng-icon
name="lucideVolume2"
class="w-4 h-4"
/>
<span>Unmute</span>
} @else {
<ng-icon
name="lucideVolumeX"
class="w-4 h-4"
/>
<span>Mute</span>
}
</button>
}
<button
type="button"
(click)="kickUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-secondary flex items-center gap-2 text-yellow-500"
>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
<span>Kick</span>
</button>
<button
type="button"
(click)="banUser(user)"
class="w-full px-4 py-2 text-left text-sm hover:bg-destructive/10 flex items-center gap-2 text-destructive"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
<span>Ban</span>
</button>
</div>
}
</div>
}
@if (onlineUsers().length === 0) {
<div class="text-center py-8 text-muted-foreground text-sm">No users online</div>
}
</div>
<!-- Ban Dialog -->
@if (showBanDialog()) {
<app-confirm-dialog
title="Ban User"
confirmLabel="Ban User"
variant="danger"
[widthClass]="'w-96 max-w-[90vw]'"
(confirmed)="confirmBan()"
(cancelled)="closeBanDialog()"
>
<p class="mb-4">
Are you sure you want to ban <span class="font-semibold text-foreground">{{ userToBan()?.displayName }}</span
>?
</p>
<div class="mb-4">
<label
for="ban-reason-input"
class="block text-sm font-medium text-foreground mb-1"
>Reason (optional)</label
>
<input
type="text"
[(ngModel)]="banReason"
placeholder="Enter ban reason..."
id="ban-reason-input"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
/>
</div>
<div>
<label
for="ban-duration-select"
class="block text-sm font-medium text-foreground mb-1"
>Duration</label
>
<select
[(ngModel)]="banDuration"
id="ban-duration-select"
class="w-full px-3 py-2 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="3600000">1 hour</option>
<option value="86400000">1 day</option>
<option value="604800000">1 week</option>
<option value="2592000000">30 days</option>
<option value="0">Permanent</option>
</select>
</div>
</app-confirm-dialog>
}

View File

@@ -0,0 +1,139 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
inject,
signal,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX
} from '@ng-icons/lucide';
import { UsersActions } from '../../../../store/users/users.actions';
import {
selectOnlineUsers,
selectCurrentUser,
selectIsCurrentUserAdmin
} from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
import { UserAvatarComponent, ConfirmDialogComponent } from '../../../../shared';
@Component({
selector: 'app-user-list',
standalone: true,
imports: [
CommonModule,
FormsModule,
NgIcon,
UserAvatarComponent,
ConfirmDialogComponent
],
viewProviders: [
provideIcons({
lucideMic,
lucideMicOff,
lucideMonitor,
lucideShield,
lucideCrown,
lucideMoreVertical,
lucideBan,
lucideUserX,
lucideVolume2,
lucideVolumeX
})
],
templateUrl: './user-list.component.html'
})
/**
* Displays the list of online users with voice state indicators and admin actions.
*/
export class UserListComponent {
private store = inject(Store);
onlineUsers = this.store.selectSignal(selectOnlineUsers) as import('@angular/core').Signal<User[]>;
voiceUsers = computed(() => this.onlineUsers().filter((user: User) => !!user.voiceState?.isConnected));
currentUser = this.store.selectSignal(selectCurrentUser) as import('@angular/core').Signal<User | undefined | null>;
isAdmin = this.store.selectSignal(selectIsCurrentUserAdmin);
showUserMenu = signal<string | null>(null);
showBanDialog = signal(false);
userToBan = signal<User | null>(null);
banReason = '';
banDuration = '86400000'; // Default 1 day
/** Toggle the context menu for a specific user. */
toggleUserMenu(userId: string): void {
this.showUserMenu.update((current) => (current === userId ? null : userId));
}
/** Check whether the given user is the currently authenticated user. */
isCurrentUser(user: User): boolean {
return user.id === this.currentUser()?.id;
}
/** Toggle server-side mute on a user (admin action). */
muteUser(user: User): void {
if (user.voiceState?.isMutedByAdmin) {
this.store.dispatch(UsersActions.adminUnmuteUser({ userId: user.id }));
} else {
this.store.dispatch(UsersActions.adminMuteUser({ userId: user.id }));
}
this.showUserMenu.set(null);
}
/** Kick a user from the server (admin action). */
kickUser(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.showUserMenu.set(null);
}
/** Open the ban confirmation dialog for a user (admin action). */
banUser(user: User): void {
this.userToBan.set(user);
this.showBanDialog.set(true);
this.showUserMenu.set(null);
}
/** Close the ban dialog and reset its form fields. */
closeBanDialog(): void {
this.showBanDialog.set(false);
this.userToBan.set(null);
this.banReason = '';
this.banDuration = '86400000';
}
/** Confirm the ban, dispatch the action with duration, and close the dialog. */
confirmBan(): void {
const user = this.userToBan();
if (!user)
return;
const duration = parseInt(this.banDuration, 10);
const expiresAt = duration === 0 ? undefined : Date.now() + duration;
this.store.dispatch(
UsersActions.banUser({
userId: user.id,
reason: this.banReason || undefined,
expiresAt
})
);
this.closeBanDialog();
}
}

View File

@@ -0,0 +1,7 @@
export * from './application/klipy.service';
export * from './domain/message.rules';
export * from './domain/message-sync.rules';
export { ChatMessagesComponent } from './feature/chat-messages/chat-messages.component';
export { TypingIndicatorComponent } from './feature/typing-indicator/typing-indicator.component';
export { KlipyGifPickerComponent } from './feature/klipy-gif-picker/klipy-gif-picker.component';
export { UserListComponent } from './feature/user-list/user-list.component';

View File

@@ -0,0 +1,137 @@
# Screen Share Domain
Manages screen sharing sessions, source selection (Electron), quality presets, and the viewer/workspace UI. Like `voice-connection`, the actual WebRTC track distribution lives in `infrastructure/realtime`; this domain provides the application-facing API and UI components.
## Module map
```
screen-share/
├── application/
│ ├── screen-share.facade.ts Proxy to RealtimeSessionFacade for screen share signals and methods
│ └── screen-share-source-picker.service.ts Electron desktop source picker (Promise-based open/confirm/cancel)
├── domain/
│ └── screen-share.config.ts Quality presets and types (re-exported from shared-kernel)
├── feature/
│ ├── screen-share-viewer/ Single-stream video player with fullscreen + volume
│ └── screen-share-workspace/ Multi-stream grid workspace
│ ├── screen-share-workspace.component.ts Grid layout, featured/thumbnail streams, mini-window mode
│ ├── screen-share-stream-tile.component.ts Individual stream tile with fullscreen/volume controls
│ ├── screen-share-playback.service.ts Per-user mute/volume state for screen share audio
│ └── screen-share-workspace.models.ts ScreenShareWorkspaceStreamItem
└── index.ts Barrel exports
```
## Service relationships
```mermaid
graph TD
SSF[ScreenShareFacade]
Picker[ScreenShareSourcePickerService]
RSF[RealtimeSessionFacade]
Config[screen-share.config]
Viewer[ScreenShareViewerComponent]
Workspace[ScreenShareWorkspaceComponent]
Tile[ScreenShareStreamTileComponent]
Playback[ScreenSharePlaybackService]
SSF --> RSF
Viewer --> SSF
Workspace --> SSF
Workspace --> Playback
Workspace --> Tile
Picker --> Config
click SSF "application/screen-share.facade.ts" "Proxy to RealtimeSessionFacade" _blank
click Picker "application/screen-share-source-picker.service.ts" "Electron source picker" _blank
click RSF "../../infrastructure/realtime/realtime-session.service.ts" "Low-level WebRTC composition root" _blank
click Viewer "feature/screen-share-viewer/screen-share-viewer.component.ts" "Single-stream player" _blank
click Workspace "feature/screen-share-workspace/screen-share-workspace.component.ts" "Multi-stream workspace" _blank
click Tile "feature/screen-share-workspace/screen-share-stream-tile.component.ts" "Stream tile" _blank
click Playback "feature/screen-share-workspace/screen-share-playback.service.ts" "Per-user volume state" _blank
click Config "domain/screen-share.config.ts" "Quality presets" _blank
```
## Starting a screen share
```mermaid
sequenceDiagram
participant User
participant Controls as VoiceControls
participant Facade as ScreenShareFacade
participant Realtime as RealtimeSessionFacade
participant Picker as SourcePickerService
User->>Controls: Click "Share Screen"
alt Electron
Controls->>Picker: open(sources)
Picker-->>Controls: selected source + includeSystemAudio
end
Controls->>Facade: startScreenShare(options)
Facade->>Realtime: startScreenShare(options)
Note over Realtime: Captures screen via platform strategy
Note over Realtime: Waits for SCREEN_SHARE_REQUEST from viewers
Realtime-->>Facade: MediaStream
User->>Controls: Click "Stop"
Controls->>Facade: stopScreenShare()
Facade->>Realtime: stopScreenShare()
```
## Source picker (Electron)
`ScreenShareSourcePickerService` manages a Promise-based flow for Electron desktop capture. `open()` sets a signal with the available sources, and the UI renders a picker dialog. When the user selects a source, `confirm(sourceId, includeSystemAudio)` resolves the Promise. `cancel()` rejects with an `AbortError`.
Sources are classified as either `screen` or `window` based on the source ID prefix or name. The `includeSystemAudio` preference is persisted to voice settings storage.
## Quality presets
Screen share quality is configured through presets defined in the shared kernel:
| Preset | Resolution | Framerate |
|---|---|---|
| `low` | Reduced | Lower FPS |
| `balanced` | Medium | Medium FPS |
| `high` | Full | High FPS |
The quality dialog can be shown before each share (`askScreenShareQuality` setting) or skipped to use the last chosen preset.
## Viewer component
`ScreenShareViewerComponent` is a single-stream video player. It supports:
- Fullscreen toggle (browser Fullscreen API with CSS fallback)
- Volume control for remote streams (delegated to `VoicePlaybackService`)
- Local shares are always muted to avoid feedback
- Focus events from other components via a `viewer:focus` custom DOM event
- Auto-stop when the watched user stops sharing or the stream's video tracks end
## Workspace component
`ScreenShareWorkspaceComponent` is the multi-stream grid view inside the voice workspace panel. It handles:
- Listing all active screen shares (local + remote) sorted with remote first
- Featured/widescreen mode for a single focused stream with thumbnail sidebar
- Mini-window mode (draggable, position-clamped to viewport)
- Auto-hide header chrome in widescreen mode (2.2 s timeout, revealed on pointer move)
- On-demand remote stream requests via `syncRemoteScreenShareRequests`
- Per-stream volume and mute via `ScreenSharePlaybackService`
- Voice controls (mute, deafen, disconnect, share toggle) integrated into the workspace header
```mermaid
stateDiagram-v2
[*] --> Hidden
Hidden --> Expanded: open()
Expanded --> GridView: multiple shares, no focus
Expanded --> WidescreenView: single share or focused stream
WidescreenView --> GridView: showAllStreams()
GridView --> WidescreenView: focusShare(peerKey)
Expanded --> Minimized: minimize()
Minimized --> Expanded: restore()
Expanded --> Hidden: close()
Minimized --> Hidden: close()
```

View File

@@ -0,0 +1,134 @@
import {
Injectable,
computed,
signal
} from '@angular/core';
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../voice-session';
import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../domain/screen-share.config';
export type ScreenShareSourceKind = 'screen' | 'window';
export interface ScreenShareSourceOption {
id: string;
kind: ScreenShareSourceKind;
name: string;
thumbnail: string;
}
export interface ScreenShareSourceSelection {
includeSystemAudio: boolean;
source: ScreenShareSourceOption;
}
interface ScreenShareSourcePickerRequest {
includeSystemAudio: boolean;
sources: readonly ScreenShareSourceOption[];
}
@Injectable({ providedIn: 'root' })
export class ScreenShareSourcePickerService {
readonly request = computed(() => this._request());
private readonly _request = signal<ScreenShareSourcePickerRequest | null>(null);
private pendingResolve: ((selection: ScreenShareSourceSelection) => void) | null = null;
private pendingReject: ((reason?: unknown) => void) | null = null;
open(
sources: readonly Pick<ScreenShareSourceOption, 'id' | 'name' | 'thumbnail'>[],
initialIncludeSystemAudio = loadVoiceSettingsFromStorage().includeSystemAudio
): Promise<ScreenShareSourceSelection> {
if (sources.length === 0) {
throw new Error('No desktop capture sources were available.');
}
this.cancelPendingRequest();
const normalizedSources = sources.map((source) => {
const kind = this.getSourceKind(source);
return {
...source,
kind,
name: this.getSourceDisplayName(source.name, kind)
};
});
this._request.set({
includeSystemAudio: initialIncludeSystemAudio,
sources: normalizedSources
});
return new Promise<ScreenShareSourceSelection>((resolve, reject) => {
this.pendingResolve = resolve;
this.pendingReject = reject;
});
}
confirm(sourceId: string, includeSystemAudio: boolean): void {
const activeRequest = this._request();
const source = activeRequest?.sources.find((entry) => entry.id === sourceId);
const resolve = this.pendingResolve;
if (!source || !resolve) {
return;
}
this.clearPendingRequest();
saveVoiceSettingsToStorage({ includeSystemAudio });
resolve({
includeSystemAudio,
source
});
}
cancel(): void {
this.cancelPendingRequest();
}
private cancelPendingRequest(): void {
const reject = this.pendingReject;
this.clearPendingRequest();
if (reject) {
reject(this.createAbortError());
}
}
private clearPendingRequest(): void {
this._request.set(null);
this.pendingResolve = null;
this.pendingReject = null;
}
private getSourceKind(
source: Pick<ScreenShareSourceOption, 'id' | 'name'>
): ScreenShareSourceKind {
return source.id.startsWith('screen') || source.name === ELECTRON_ENTIRE_SCREEN_SOURCE_NAME
? 'screen'
: 'window';
}
private getSourceDisplayName(name: string, kind: ScreenShareSourceKind): string {
const trimmedName = name.trim();
if (trimmedName) {
return trimmedName;
}
return kind === 'screen' ? 'Entire screen' : 'Window';
}
private createAbortError(): Error {
if (typeof DOMException !== 'undefined') {
return new DOMException('The user aborted a request.', 'AbortError');
}
const error = new Error('The user aborted a request.');
error.name = 'AbortError';
return error;
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable, inject } from '@angular/core';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ScreenShareStartOptions } from '../domain/screen-share.config';
@Injectable({ providedIn: 'root' })
export class ScreenShareFacade {
readonly isScreenSharing = inject(RealtimeSessionFacade).isScreenSharing;
readonly screenStream = inject(RealtimeSessionFacade).screenStream;
readonly isScreenShareRemotePlaybackSuppressed = inject(RealtimeSessionFacade).isScreenShareRemotePlaybackSuppressed;
readonly forceDefaultRemotePlaybackOutput = inject(RealtimeSessionFacade).forceDefaultRemotePlaybackOutput;
readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream;
readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected;
private readonly realtime = inject(RealtimeSessionFacade);
getRemoteScreenShareStream(peerId: string): MediaStream | null {
return this.realtime.getRemoteScreenShareStream(peerId);
}
async startScreenShare(options: ScreenShareStartOptions): Promise<MediaStream> {
return await this.realtime.startScreenShare(options);
}
stopScreenShare(): void {
this.realtime.stopScreenShare();
}
syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void {
this.realtime.syncRemoteScreenShareRequests(peerIds, enabled);
}
}

View File

@@ -0,0 +1,21 @@
import {
DEFAULT_SCREEN_SHARE_QUALITY,
DEFAULT_SCREEN_SHARE_START_OPTIONS,
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
SCREEN_SHARE_QUALITY_OPTIONS,
SCREEN_SHARE_QUALITY_PRESETS,
type ScreenShareQualityPreset,
type ScreenShareStartOptions,
type ScreenShareQuality
} from '../../../shared-kernel';
export {
DEFAULT_SCREEN_SHARE_QUALITY,
DEFAULT_SCREEN_SHARE_START_OPTIONS,
ELECTRON_ENTIRE_SCREEN_SOURCE_NAME,
SCREEN_SHARE_QUALITY_OPTIONS,
SCREEN_SHARE_QUALITY_PRESETS,
type ScreenShareQualityPreset,
type ScreenShareStartOptions,
type ScreenShareQuality
};

View File

@@ -0,0 +1,102 @@
<div
class="relative bg-black rounded-lg overflow-hidden"
[class.fixed]="isFullscreen()"
[class.inset-0]="isFullscreen()"
[class.z-50]="isFullscreen()"
[class.hidden]="!hasStream()"
>
<!-- Video Element -->
<video
#screenVideo
autoplay
playsinline
class="w-full h-full object-contain"
[class.max-h-[400px]]="!isFullscreen()"
></video>
<!-- Overlay Controls -->
<div class="absolute top-0 left-0 right-0 p-4 bg-gradient-to-b from-black/70 to-transparent opacity-0 hover:opacity-100 transition-opacity">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-white">
<ng-icon
name="lucideMonitor"
class="w-4 h-4"
/>
@if (activeScreenSharer()) {
<span class="text-sm font-medium"> {{ activeScreenSharer()?.displayName }} is sharing their screen </span>
} @else {
<span class="text-sm font-medium">Someone is sharing their screen</span>
}
</div>
<div class="flex items-center gap-3">
<!-- Viewer volume -->
<div class="flex items-center gap-2 text-white">
<span class="text-xs opacity-80">Volume: {{ screenVolume() }}%</span>
<input
type="range"
min="0"
max="200"
[value]="screenVolume()"
(input)="onScreenVolumeChange($event)"
class="w-32 accent-white"
/>
</div>
<button
(click)="toggleFullscreen()"
type="button"
class="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-colors"
>
@if (isFullscreen()) {
<ng-icon
name="lucideMinimize"
class="w-4 h-4 text-white"
/>
} @else {
<ng-icon
name="lucideMaximize"
class="w-4 h-4 text-white"
/>
}
</button>
@if (isLocalShare()) {
<button
(click)="stopSharing()"
type="button"
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop sharing"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-white"
/>
</button>
} @else {
<button
(click)="stopWatching()"
type="button"
class="p-2 bg-destructive hover:bg-destructive/90 rounded-lg transition-colors"
title="Stop watching"
>
<ng-icon
name="lucideX"
class="w-4 h-4 text-white"
/>
</button>
}
</div>
</div>
</div>
<!-- No Stream Placeholder -->
@if (!hasStream()) {
<div class="absolute inset-0 flex items-center justify-center bg-secondary">
<div class="text-center text-muted-foreground">
<ng-icon
name="lucideMonitor"
class="w-12 h-12 mx-auto mb-2 opacity-50"
/>
<p>Waiting for screen share...</p>
</div>
</div>
}
</div>

View File

@@ -0,0 +1,279 @@
/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-unused-vars */
import {
Component,
inject,
signal,
ElementRef,
ViewChild,
OnDestroy,
effect
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Subscription } from 'rxjs';
import {
lucideMaximize,
lucideMinimize,
lucideX,
lucideMonitor
} from '@ng-icons/lucide';
import { ScreenShareFacade } from '../../application/screen-share.facade';
import { selectOnlineUsers } from '../../../../store/users/users.selectors';
import { User } from '../../../../shared-kernel';
import { DEFAULT_VOLUME } from '../../../../core/constants';
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
@Component({
selector: 'app-screen-share-viewer',
standalone: true,
imports: [CommonModule, NgIcon],
viewProviders: [
provideIcons({
lucideMaximize,
lucideMinimize,
lucideX,
lucideMonitor
})
],
templateUrl: './screen-share-viewer.component.html'
})
/**
* Displays a local or remote screen-share stream in a video player.
* Supports fullscreen toggling, volume control, and viewer focus events.
*/
export class ScreenShareViewerComponent implements OnDestroy {
@ViewChild('screenVideo') videoRef!: ElementRef<HTMLVideoElement>;
private readonly screenShareService = inject(ScreenShareFacade);
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly store = inject(Store);
private remoteStreamSub: Subscription | null = null;
onlineUsers = this.store.selectSignal(selectOnlineUsers);
activeScreenSharer = signal<User | null>(null);
// Track the userId we're currently watching (for detecting when they stop sharing)
private watchingUserId = signal<string | null>(null);
isFullscreen = signal(false);
hasStream = signal(false);
isLocalShare = signal(false);
screenVolume = signal(DEFAULT_VOLUME);
private streamSubscription: (() => void) | null = null;
private viewerFocusHandler = (evt: CustomEvent<{ userId: string }>) => {
try {
const userId = evt.detail?.userId;
if (!userId)
return;
const stream = this.screenShareService.getRemoteScreenShareStream(userId);
const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null;
if (stream && stream.getVideoTracks().length > 0) {
if (user) {
this.setRemoteStream(stream, user);
} else if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
this.videoRef.nativeElement.volume = 0;
this.videoRef.nativeElement.muted = true;
this.hasStream.set(true);
this.activeScreenSharer.set(null);
this.watchingUserId.set(userId);
this.screenVolume.set(this.voicePlayback.getUserVolume(userId));
this.isLocalShare.set(false);
}
}
} catch (_error) {
// Failed to focus viewer on user stream
}
};
constructor() {
// React to screen share stream changes
effect(() => {
const screenStream = this.screenShareService.screenStream();
if (screenStream && this.videoRef) {
// Local share: always mute to avoid audio feedback
this.videoRef.nativeElement.srcObject = screenStream;
this.videoRef.nativeElement.volume = 0;
this.videoRef.nativeElement.muted = true;
this.isLocalShare.set(true);
this.hasStream.set(true);
} else if (this.videoRef) {
this.videoRef.nativeElement.srcObject = null;
this.isLocalShare.set(false);
this.hasStream.set(false);
}
});
// Watch for when the user we're watching stops sharing
effect(() => {
const watchingId = this.watchingUserId();
const isWatchingRemote = this.hasStream() && !this.isLocalShare();
// Only check if we're actually watching a remote stream
if (!watchingId || !isWatchingRemote)
return;
const users = this.onlineUsers();
const watchedUser = users.find(user => user.id === watchingId || user.oderId === watchingId);
// If the user is no longer sharing (screenShareState.isSharing is false), stop watching
if (watchedUser && watchedUser.screenShareState?.isSharing === false) {
this.stopWatching();
return;
}
// Also check if the stream's video tracks are still available
const stream = this.screenShareService.getRemoteScreenShareStream(watchingId);
const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live');
if (!hasActiveVideo) {
// Stream or video tracks are gone - stop watching
this.stopWatching();
}
});
// Subscribe to remote streams with video (screen shares)
// NOTE: We no longer auto-display remote streams. Users must click "Live" to view.
// This subscription is kept for potential future use (e.g., tracking available streams)
this.remoteStreamSub = this.screenShareService.onRemoteStream.subscribe(({ peerId }) => {
if (peerId !== this.watchingUserId() || this.isLocalShare()) {
return;
}
const stream = this.screenShareService.getRemoteScreenShareStream(peerId);
const hasActiveVideo = stream?.getVideoTracks().some((track) => track.readyState === 'live') ?? false;
if (!hasActiveVideo) {
this.stopWatching();
}
});
// Listen for focus events dispatched by other components
window.addEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
}
ngOnDestroy(): void {
if (this.isFullscreen()) {
this.exitFullscreen();
}
// Cleanup subscription
this.remoteStreamSub?.unsubscribe();
// Remove event listener
window.removeEventListener('viewer:focus', this.viewerFocusHandler as EventListener);
}
/** Toggle between fullscreen and windowed display. */
toggleFullscreen(): void {
if (this.isFullscreen()) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
}
/** Enter fullscreen mode, requesting browser fullscreen if available. */
enterFullscreen(): void {
this.isFullscreen.set(true);
// Request browser fullscreen if available
if (this.videoRef?.nativeElement.requestFullscreen) {
this.videoRef.nativeElement.requestFullscreen().catch(() => {
// Fallback to CSS fullscreen
});
}
}
/** Exit fullscreen mode. */
exitFullscreen(): void {
this.isFullscreen.set(false);
if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
/** Stop the local screen share and reset viewer state. */
stopSharing(): void {
this.screenShareService.stopScreenShare();
this.activeScreenSharer.set(null);
this.hasStream.set(false);
this.isLocalShare.set(false);
}
/** Stop watching a remote stream and reset the viewer. */
// Stop watching a remote stream (for viewers)
stopWatching(): void {
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = null;
}
this.activeScreenSharer.set(null);
this.watchingUserId.set(null);
this.hasStream.set(false);
this.isLocalShare.set(false);
if (this.isFullscreen()) {
this.exitFullscreen();
}
}
/** Attach and play a remote peer's screen-share stream. */
// Called by parent when a remote peer starts sharing
setRemoteStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
this.watchingUserId.set(user.id || user.oderId || null);
this.isLocalShare.set(false);
this.screenVolume.set(this.voicePlayback.getUserVolume(user.id || user.oderId || ''));
if (this.videoRef) {
const el = this.videoRef.nativeElement;
el.srcObject = stream;
// Keep the viewer muted so screen-share audio only plays once via VoicePlaybackService.
el.muted = true;
el.volume = 0;
el.play().catch(() => {});
this.hasStream.set(true);
}
}
/** Attach and play the local user's screen-share stream (always muted). */
// Called when local user starts sharing
setLocalStream(stream: MediaStream, user: User): void {
this.activeScreenSharer.set(user);
this.isLocalShare.set(true);
if (this.videoRef) {
this.videoRef.nativeElement.srcObject = stream;
// Always mute local share playback
this.videoRef.nativeElement.volume = 0;
this.videoRef.nativeElement.muted = true;
this.hasStream.set(true);
}
}
/** Handle volume slider changes, applying only to remote streams. */
onScreenVolumeChange(event: Event): void {
const input = event.target as HTMLInputElement;
const val = Math.max(0, Math.min(200, parseInt(input.value, 10)));
this.screenVolume.set(val);
if (!this.isLocalShare()) {
const userId = this.watchingUserId();
if (userId) {
this.voicePlayback.setUserVolume(userId, val);
}
}
}
}

View File

@@ -0,0 +1,77 @@
import { Injectable, signal } from '@angular/core';
interface ScreenSharePlaybackSettings {
muted: boolean;
volume: number;
}
const DEFAULT_SETTINGS: ScreenSharePlaybackSettings = {
muted: false,
volume: 100
};
@Injectable({ providedIn: 'root' })
export class ScreenSharePlaybackService {
private readonly _settings = signal<ReadonlyMap<string, ScreenSharePlaybackSettings>>(new Map());
settings(): ReadonlyMap<string, ScreenSharePlaybackSettings> {
return this._settings();
}
getUserVolume(peerId: string): number {
return this._settings().get(peerId)?.volume ?? DEFAULT_SETTINGS.volume;
}
isUserMuted(peerId: string): boolean {
return this._settings().get(peerId)?.muted ?? DEFAULT_SETTINGS.muted;
}
setUserVolume(peerId: string, volume: number): void {
const nextVolume = Math.max(0, Math.min(100, volume));
const current = this._settings().get(peerId) ?? DEFAULT_SETTINGS;
this._settings.update((settings) => {
const next = new Map(settings);
next.set(peerId, {
...current,
muted: nextVolume === 0 ? current.muted : false,
volume: nextVolume
});
return next;
});
}
setUserMuted(peerId: string, muted: boolean): void {
const current = this._settings().get(peerId) ?? DEFAULT_SETTINGS;
this._settings.update((settings) => {
const next = new Map(settings);
next.set(peerId, {
...current,
muted
});
return next;
});
}
resetUser(peerId: string): void {
this._settings.update((settings) => {
if (!settings.has(peerId)) {
return settings;
}
const next = new Map(settings);
next.delete(peerId);
return next;
});
}
teardownAll(): void {
// Screen-share audio is played directly by the video element.
}
}

View File

@@ -0,0 +1,220 @@
<div
#tileRoot
class="group relative flex h-full min-h-0 flex-col overflow-hidden bg-black/85 transition duration-200"
tabindex="0"
role="button"
[attr.aria-label]="mini() ? 'Focus ' + displayName() + ' stream' : 'Open ' + displayName() + ' stream in widescreen mode'"
[attr.title]="canToggleFullscreen() ? (isFullscreen() ? 'Double-click to exit fullscreen' : 'Double-click for fullscreen') : null"
[ngClass]="{
'ring-2 ring-primary/70': focused() && !immersive() && !mini() && !isFullscreen(),
'min-h-[24rem] rounded-[1.75rem] border border-white/10 shadow-2xl': featured() && !compact() && !immersive() && !mini() && !isFullscreen(),
'rounded-[1.75rem] border border-white/10 shadow-2xl': !featured() && !compact() && !immersive() && !mini() && !isFullscreen(),
'rounded-2xl border border-white/10 shadow-2xl': compact() && !immersive() && !mini() && !isFullscreen(),
'rounded-2xl border border-white/10 shadow-xl': mini() && !isFullscreen(),
'shadow-none': immersive() || isFullscreen()
}"
(click)="requestFocus()"
(dblclick)="onTileDoubleClick($event)"
(mousemove)="onTilePointerMove()"
(keydown.enter)="requestFocus()"
(keydown.space)="requestFocus(); $event.preventDefault()"
>
<video
#streamVideo
autoplay
playsinline
class="absolute inset-0 h-full w-full bg-black object-contain"
></video>
<div class="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/70 via-black/10 to-black/80"></div>
@if (isFullscreen()) {
<div
class="pointer-events-none absolute inset-x-3 top-3 z-20 transition-all duration-300 sm:inset-x-4 sm:top-4"
[class.opacity-0]="!showFullscreenHeader()"
[class.translate-y-[-12px]]="!showFullscreenHeader()"
>
<div
class="pointer-events-auto flex items-center gap-3 rounded-2xl border border-white/10 bg-black/45 px-4 py-3 backdrop-blur-lg"
[class.pointer-events-none]="!showFullscreenHeader()"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<app-user-avatar
[name]="displayName()"
[avatarUrl]="item().user.avatarUrl"
size="xs"
/>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<p class="truncate text-sm font-semibold text-white sm:text-base">{{ displayName() }}</p>
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary"> Live </span>
</div>
<p class="mt-1 text-xs text-white/60">
{{ item().isLocal ? 'Local preview in fullscreen' : 'Fullscreen stream view' }}
</p>
</div>
</div>
@if (!item().isLocal) {
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
(click)="toggleMuted(); $event.stopPropagation()"
>
<ng-icon
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="h-4 w-4"
/>
</button>
}
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
title="Exit fullscreen"
(click)="exitFullscreen($event)"
>
<ng-icon
name="lucideMinimize"
class="h-4 w-4"
/>
</button>
</div>
</div>
}
@if (mini()) {
<div class="absolute inset-x-0 bottom-0 p-2">
<div class="rounded-xl border border-white/10 bg-black/55 px-2.5 py-2 backdrop-blur-md">
<div class="flex items-center gap-2">
<app-user-avatar
[name]="displayName()"
[avatarUrl]="item().user.avatarUrl"
size="xs"
/>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-semibold text-white">{{ displayName() }}</p>
<p class="text-[10px] uppercase tracking-[0.16em] text-white/60">Live stream</p>
</div>
</div>
</div>
</div>
} @else if (!immersive()) {
<div
class="absolute left-4 top-4 flex items-center gap-3 bg-black/50 backdrop-blur-md"
[ngClass]="compact() ? 'max-w-[calc(100%-5rem)] rounded-xl px-2.5 py-2' : 'max-w-[calc(100%-8rem)] rounded-full px-3 py-2'"
>
<app-user-avatar
[name]="displayName()"
[avatarUrl]="item().user.avatarUrl"
size="xs"
/>
<div class="min-w-0">
<p
class="truncate font-semibold text-white"
[class.text-xs]="compact()"
[class.text-sm]="!compact()"
>
{{ displayName() }}
</p>
<p
class="flex items-center gap-1 uppercase text-white/65"
[class.text-[10px]]="compact()"
[class.text-[11px]]="!compact()"
[class.tracking-[0.18em]]="compact()"
[class.tracking-[0.24em]]="!compact()"
>
<ng-icon
name="lucideMonitor"
class="h-3 w-3"
/>
Live
</p>
</div>
</div>
<div class="absolute right-4 top-4 flex items-center gap-2 opacity-100 transition md:opacity-0 md:group-hover:opacity-100">
<button
type="button"
class="inline-flex items-center justify-center rounded-full border border-white/15 bg-black/45 text-white/90 backdrop-blur-md transition hover:bg-black/65"
[class.h-8]="compact()"
[class.w-8]="compact()"
[class.h-10]="!compact()"
[class.w-10]="!compact()"
[title]="focused() ? 'Viewing in widescreen' : 'View in widescreen'"
(click)="requestFocus(); $event.stopPropagation()"
>
<ng-icon
name="lucideMaximize"
[class.h-3.5]="compact()"
[class.w-3.5]="compact()"
[class.h-4]="!compact()"
[class.w-4]="!compact()"
/>
</button>
@if (!item().isLocal) {
<button
type="button"
class="inline-flex items-center justify-center rounded-full border border-white/15 bg-black/45 text-white/90 backdrop-blur-md transition hover:bg-black/65"
[class.h-8]="compact()"
[class.w-8]="compact()"
[class.h-10]="!compact()"
[class.w-10]="!compact()"
[title]="muted() ? 'Unmute stream audio' : 'Mute stream audio'"
(click)="toggleMuted(); $event.stopPropagation()"
>
<ng-icon
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
[class.h-3.5]="compact()"
[class.w-3.5]="compact()"
[class.h-4]="!compact()"
[class.w-4]="!compact()"
/>
</button>
}
</div>
<div class="absolute inset-x-0 bottom-0 p-4">
@if (item().isLocal) {
@if (!compact()) {
<div class="rounded-2xl bg-black/50 px-4 py-3 text-xs text-white/75 backdrop-blur-md">
Your preview stays muted locally to avoid audio feedback.
</div>
}
} @else {
@if (compact()) {
<div class="rounded-xl bg-black/50 px-3 py-2 text-[11px] text-white/80 backdrop-blur-md">
{{ muted() ? 'Muted' : volume() + '% audio' }}
</div>
} @else {
<div class="rounded-2xl bg-black/50 px-4 py-3 backdrop-blur-md">
<div class="mb-2 flex items-center justify-between text-xs text-white/80">
<span class="flex items-center gap-2 font-medium">
<ng-icon
[name]="muted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="h-3.5 w-3.5"
/>
Stream audio
</span>
<span>{{ muted() ? 'Muted' : volume() + '%' }}</span>
</div>
<input
type="range"
min="0"
max="100"
[value]="volume()"
class="w-full accent-primary"
(click)="$event.stopPropagation()"
(input)="updateVolume($event)"
/>
</div>
}
}
</div>
}
</div>

View File

@@ -0,0 +1,263 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { CommonModule } from '@angular/common';
import {
Component,
ElementRef,
effect,
HostListener,
inject,
input,
OnDestroy,
output,
signal,
viewChild
} from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideMaximize,
lucideMinimize,
lucideMonitor,
lucideVolume2,
lucideVolumeX
} from '@ng-icons/lucide';
import { UserAvatarComponent } from '../../../../shared';
import { ScreenSharePlaybackService } from './screen-share-playback.service';
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';
@Component({
selector: 'app-screen-share-stream-tile',
standalone: true,
imports: [
CommonModule,
NgIcon,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideMaximize,
lucideMinimize,
lucideMonitor,
lucideVolume2,
lucideVolumeX
})
],
templateUrl: './screen-share-stream-tile.component.html',
host: {
class: 'block h-full'
}
})
export class ScreenShareStreamTileComponent implements OnDestroy {
private readonly screenSharePlayback = inject(ScreenSharePlaybackService);
private fullscreenHeaderHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
readonly item = input.required<ScreenShareWorkspaceStreamItem>();
readonly focused = input(false);
readonly featured = input(false);
readonly compact = input(false);
readonly mini = input(false);
readonly immersive = input(false);
readonly focusRequested = output<string>();
readonly tileRef = viewChild<ElementRef<HTMLElement>>('tileRoot');
readonly videoRef = viewChild<ElementRef<HTMLVideoElement>>('streamVideo');
readonly isFullscreen = signal(false);
readonly showFullscreenHeader = signal(true);
readonly volume = signal(100);
readonly muted = signal(false);
constructor() {
effect(() => {
const ref = this.videoRef();
const item = this.item();
if (!ref) {
return;
}
const video = ref.nativeElement;
if (video.srcObject !== item.stream) {
video.srcObject = item.stream;
}
void video.play().catch(() => {});
});
effect(
() => {
this.screenSharePlayback.settings();
const item = this.item();
if (item.isLocal) {
this.volume.set(0);
this.muted.set(false);
return;
}
this.volume.set(this.screenSharePlayback.getUserVolume(item.peerKey));
this.muted.set(this.screenSharePlayback.isUserMuted(item.peerKey));
},
{ allowSignalWrites: true }
);
effect(() => {
const ref = this.videoRef();
const item = this.item();
const muted = this.muted();
const volume = this.volume();
if (!ref) {
return;
}
const video = ref.nativeElement;
if (item.isLocal) {
video.muted = true;
video.volume = 0;
return;
}
video.muted = muted;
video.volume = Math.max(0, Math.min(1, volume / 100));
void video.play().catch(() => {});
});
}
@HostListener('document:fullscreenchange')
onFullscreenChange(): void {
const tile = this.tileRef()?.nativeElement;
const isFullscreen = !!tile && document.fullscreenElement === tile;
this.isFullscreen.set(isFullscreen);
if (isFullscreen) {
this.revealFullscreenHeader();
return;
}
this.clearFullscreenHeaderHideTimeout();
this.showFullscreenHeader.set(true);
}
ngOnDestroy(): void {
this.clearFullscreenHeaderHideTimeout();
const tile = this.tileRef()?.nativeElement;
if (tile && document.fullscreenElement === tile) {
void document.exitFullscreen().catch(() => {});
}
}
canToggleFullscreen(): boolean {
return !this.mini() && !this.compact() && (this.immersive() || this.focused());
}
onTilePointerMove(): void {
if (!this.isFullscreen()) {
return;
}
this.revealFullscreenHeader();
}
async onTileDoubleClick(event: MouseEvent): Promise<void> {
event.preventDefault();
event.stopPropagation();
if (!this.canToggleFullscreen()) {
return;
}
const tile = this.tileRef()?.nativeElement;
if (!tile || !tile.requestFullscreen) {
return;
}
if (document.fullscreenElement === tile) {
await document.exitFullscreen().catch(() => {});
return;
}
await tile.requestFullscreen().catch(() => {});
}
async exitFullscreen(event?: Event): Promise<void> {
event?.preventDefault();
event?.stopPropagation();
if (!this.isFullscreen()) {
return;
}
await document.exitFullscreen().catch(() => {});
}
requestFocus(): void {
this.focusRequested.emit(this.item().peerKey);
}
toggleMuted(): void {
const item = this.item();
if (item.isLocal) {
return;
}
const nextMuted = !this.muted();
this.muted.set(nextMuted);
this.screenSharePlayback.setUserMuted(item.peerKey, nextMuted);
}
updateVolume(event: Event): void {
const item = this.item();
if (item.isLocal) {
return;
}
const input = event.target as HTMLInputElement;
const nextVolume = Math.max(0, Math.min(100, parseInt(input.value, 10) || 0));
this.volume.set(nextVolume);
this.screenSharePlayback.setUserVolume(item.peerKey, nextVolume);
if (nextVolume > 0 && this.muted()) {
this.muted.set(false);
this.screenSharePlayback.setUserMuted(item.peerKey, false);
}
}
displayName(): string {
return this.item().isLocal ? 'You' : this.item().user.displayName;
}
private scheduleFullscreenHeaderHide(): void {
this.clearFullscreenHeaderHideTimeout();
this.fullscreenHeaderHideTimeoutId = setTimeout(() => {
this.showFullscreenHeader.set(false);
this.fullscreenHeaderHideTimeoutId = null;
}, 2200);
}
private revealFullscreenHeader(): void {
this.showFullscreenHeader.set(true);
this.scheduleFullscreenHeaderHide();
}
private clearFullscreenHeaderHideTimeout(): void {
if (this.fullscreenHeaderHideTimeoutId === null) {
return;
}
clearTimeout(this.fullscreenHeaderHideTimeoutId);
this.fullscreenHeaderHideTimeoutId = null;
}
}

View File

@@ -0,0 +1,365 @@
<!-- eslint-disable @angular-eslint/template/cyclomatic-complexity -->
<div class="absolute inset-0">
@if (showExpanded()) {
<section
class="pointer-events-auto absolute inset-0 bg-background/95 backdrop-blur-xl"
(mouseenter)="onWorkspacePointerMove()"
(mousemove)="onWorkspacePointerMove()"
>
<div class="flex h-full min-h-0 flex-col">
<div class="relative flex-1 min-h-0 overflow-hidden">
<div
class="pointer-events-none absolute inset-x-3 top-3 z-10 transition-all duration-300 sm:inset-x-4 sm:top-4"
[class.opacity-0]="!showWorkspaceHeader()"
[class.translate-y-[-12px]]="!showWorkspaceHeader()"
>
<div
class="pointer-events-auto flex flex-wrap items-center gap-3 rounded-2xl border border-white/10 bg-black/45 px-4 py-3 backdrop-blur-lg"
[class.pointer-events-none]="!showWorkspaceHeader()"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<ng-icon
name="lucideMonitor"
class="h-5 w-5"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h2 class="truncate text-sm font-semibold text-white sm:text-base">{{ connectedVoiceChannelName() }}</h2>
<span class="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-primary">
Streams
</span>
</div>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-white/65">
<span>{{ serverName() }}</span>
<span class="h-1 w-1 rounded-full bg-white/25"></span>
<span>{{ connectedVoiceUsers().length }} in voice</span>
<span class="h-1 w-1 rounded-full bg-white/25"></span>
<span>{{ liveShareCount() }} live {{ liveShareCount() === 1 ? 'stream' : 'streams' }}</span>
</div>
</div>
</div>
@if (connectedVoiceUsers().length > 0) {
<div class="hidden items-center gap-2 lg:flex">
@for (participant of connectedVoiceUsers().slice(0, 4); track trackUser($index, participant)) {
<app-user-avatar
[name]="participant.displayName"
[avatarUrl]="participant.avatarUrl"
size="xs"
[ringClass]="'ring-2 ring-white/10'"
/>
}
@if (connectedVoiceUsers().length > 4) {
<div class="rounded-full bg-white/10 px-2.5 py-1 text-[11px] font-medium text-white/70">
+{{ connectedVoiceUsers().length - 4 }}
</div>
}
</div>
}
@if (isWidescreenMode() && widescreenShare()) {
<div class="flex min-w-0 items-center gap-2 rounded-2xl border border-white/10 bg-black/35 px-2.5 py-2 text-white/85">
<app-user-avatar
[name]="focusedShareTitle()"
[avatarUrl]="widescreenShare()!.user.avatarUrl"
size="xs"
/>
<div class="min-w-0">
<p class="truncate text-xs font-semibold text-white">{{ focusedShareTitle() }}</p>
<p class="text-[10px] uppercase tracking-[0.18em] text-white/55">
{{ widescreenShare()!.isLocal ? 'Local preview' : 'Focused stream' }}
</p>
</div>
@if (focusedAudioShare()) {
<div class="mx-1 hidden h-6 w-px bg-white/10 sm:block"></div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/10 bg-black/45 text-white/75 transition hover:bg-black/60 hover:text-white"
[title]="focusedShareMuted() ? 'Unmute stream audio' : 'Mute stream audio'"
(click)="toggleFocusedShareMuted()"
>
<ng-icon
[name]="focusedShareMuted() ? 'lucideVolumeX' : 'lucideVolume2'"
class="h-3.5 w-3.5"
/>
</button>
<input
type="range"
min="0"
max="100"
[value]="focusedShareVolume()"
class="h-1.5 w-20 accent-primary sm:w-24"
(input)="updateFocusedShareVolume($event)"
/>
<span class="w-10 text-right text-[11px] text-white/65">
{{ focusedShareMuted() ? 'Muted' : focusedShareVolume() + '%' }}
</span>
</div>
}
</div>
}
<div class="ml-auto flex items-center gap-2">
@if (isWidescreenMode() && hasMultipleShares()) {
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/35 px-3 py-2 text-xs font-medium text-white/80 transition hover:bg-black/55 hover:text-white"
title="Show all streams"
(click)="showAllStreams()"
>
<ng-icon
name="lucideUsers"
class="h-3.5 w-3.5"
/>
All streams
</button>
}
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white/70 transition hover:bg-black/55 hover:text-white"
title="Minimize stream workspace"
(click)="minimizeWorkspace()"
>
<ng-icon
name="lucideMinimize"
class="h-4 w-4"
/>
</button>
<button
type="button"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/10 bg-black/35 text-white/70 transition hover:bg-black/55 hover:text-white"
title="Return to chat"
(click)="closeWorkspace()"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
</div>
</div>
@if (isWidescreenMode() && thumbnailShares().length > 0) {
<div
class="pointer-events-none absolute inset-x-3 bottom-3 z-10 transition-all duration-300 sm:inset-x-4 sm:bottom-4"
[class.opacity-0]="!showWorkspaceHeader()"
[class.translate-y-[12px]]="!showWorkspaceHeader()"
>
<div
class="pointer-events-auto rounded-2xl border border-white/10 bg-black/45 p-2.5 backdrop-blur-lg"
[class.pointer-events-none]="!showWorkspaceHeader()"
>
<div class="mb-2 flex items-center justify-between px-1">
<span class="text-[10px] font-semibold uppercase tracking-[0.18em] text-white/55">Other live streams</span>
<span class="text-[10px] text-white/40">{{ thumbnailShares().length }}</span>
</div>
<div class="flex gap-2 overflow-x-auto pb-1">
@for (share of thumbnailShares(); track trackShare($index, share)) {
<div class="h-[5.25rem] w-[9.5rem] shrink-0 sm:h-[5.5rem] sm:w-[10rem]">
<app-screen-share-stream-tile
[item]="share"
[mini]="true"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
}
</div>
</div>
</div>
}
<div
class="h-full min-h-0"
[ngClass]="isWidescreenMode() ? 'p-0' : 'p-3 pt-20 sm:p-4 sm:pt-24'"
>
@if (activeShares().length > 0) {
@if (isWidescreenMode() && widescreenShare()) {
<div class="h-full min-h-0">
<app-screen-share-stream-tile
[item]="widescreenShare()!"
[featured]="true"
[focused]="true"
[immersive]="true"
(focusRequested)="focusShare($event)"
/>
</div>
} @else {
<div
class="grid h-full min-h-0 auto-rows-[minmax(15rem,1fr)] grid-cols-1 gap-3 overflow-auto sm:grid-cols-2 sm:gap-4"
[ngClass]="{ '2xl:grid-cols-3': activeShares().length > 2 }"
>
@for (share of activeShares(); track trackShare($index, share)) {
<div class="min-h-[15rem]">
<app-screen-share-stream-tile
[item]="share"
[focused]="false"
(focusRequested)="focusShare($event)"
/>
</div>
}
</div>
}
} @else {
<div class="flex h-full items-center justify-center">
<div class="w-full max-w-3xl rounded-[2rem] border border-dashed border-white/10 bg-card/60 p-8 text-center shadow-2xl sm:p-10">
<div class="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-3xl bg-primary/10 text-primary">
<ng-icon
name="lucideMonitor"
class="h-8 w-8"
/>
</div>
<h2 class="text-2xl font-semibold text-foreground">No live screen shares yet</h2>
<p class="mx-auto mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
Click Screen Share below to start streaming, or wait for someone in {{ connectedVoiceChannelName() }} to go live.
</p>
@if (connectedVoiceUsers().length > 0) {
<div class="mt-6 flex flex-wrap items-center justify-center gap-3">
@for (participant of connectedVoiceUsers().slice(0, 4); track trackUser($index, participant)) {
<div class="flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-2">
<app-user-avatar
[name]="participant.displayName"
[avatarUrl]="participant.avatarUrl"
size="xs"
/>
<span class="text-sm text-foreground">{{ participant.displayName }}</span>
</div>
}
</div>
}
<div class="mt-8 flex flex-wrap items-center justify-center gap-3 text-sm text-muted-foreground">
<span class="inline-flex items-center gap-2 rounded-full bg-secondary/70 px-4 py-2">
<ng-icon
name="lucideUsers"
class="h-4 w-4"
/>
{{ connectedVoiceUsers().length }} participants ready
</span>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full bg-primary px-5 py-2.5 font-medium text-primary-foreground transition hover:bg-primary/90"
(click)="toggleScreenShare()"
>
<ng-icon
name="lucideMonitor"
class="h-4 w-4"
/>
Start screen sharing
</button>
</div>
</div>
</div>
}
</div>
</div>
</div>
</section>
}
@if (showMiniWindow()) {
<div
class="pointer-events-auto absolute z-20 w-[20rem] select-none overflow-hidden rounded-[1.75rem] border border-white/10 bg-card/95 shadow-2xl backdrop-blur-xl"
[style.left.px]="miniPosition().left"
[style.top.px]="miniPosition().top"
(dblclick)="restoreWorkspace()"
>
<div
class="flex cursor-move items-center gap-3 border-b border-white/10 bg-black/25 px-4 py-3"
(mousedown)="startMiniWindowDrag($event)"
>
<div class="flex h-9 w-9 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<ng-icon
name="lucideMonitor"
class="h-4 w-4"
/>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-foreground">{{ connectedVoiceChannelName() }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ liveShareCount() }} live {{ liveShareCount() === 1 ? 'stream' : 'streams' }} · double-click to expand
</p>
</div>
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition hover:bg-black/30 hover:text-foreground"
title="Expand"
(click)="restoreWorkspace()"
>
<ng-icon
name="lucideMaximize"
class="h-4 w-4"
/>
</button>
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground transition hover:bg-black/30 hover:text-foreground"
title="Close"
(click)="closeWorkspace()"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
</div>
<div class="relative aspect-video bg-black">
@if (miniPreviewShare()) {
<video
#miniPreview
autoplay
playsinline
class="h-full w-full bg-black object-cover"
></video>
} @else {
<div class="flex h-full items-center justify-center text-muted-foreground">
<div class="text-center">
<ng-icon
name="lucideMonitor"
class="mx-auto h-8 w-8 opacity-50"
/>
<p class="mt-2 text-sm">Waiting for a live stream</p>
</div>
</div>
}
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/50 to-transparent px-4 py-3 text-white">
<p class="truncate text-sm font-semibold">
{{ miniPreviewTitle() }}
</p>
<p class="truncate text-xs text-white/75">Connected to {{ serverName() }}</p>
</div>
</div>
</div>
}
@if (showScreenShareQualityDialog()) {
<app-screen-share-quality-dialog
[selectedQuality]="screenShareQuality()"
[includeSystemAudio]="includeSystemAudio()"
(cancelled)="onScreenShareQualityCancelled()"
(confirmed)="onScreenShareQualityConfirmed($event)"
/>
}
</div>

View File

@@ -0,0 +1,963 @@
/* eslint-disable @typescript-eslint/member-ordering, complexity */
import { CommonModule } from '@angular/common';
import {
Component,
DestroyRef,
ElementRef,
HostListener,
computed,
effect,
inject,
signal,
viewChild
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import {
lucideHeadphones,
lucideMaximize,
lucideMic,
lucideMicOff,
lucideMinimize,
lucideMonitor,
lucideMonitorOff,
lucidePhoneOff,
lucideUsers,
lucideVolume2,
lucideVolumeX,
lucideX
} from '@ng-icons/lucide';
import { User } from '../../../../shared-kernel';
import {
loadVoiceSettingsFromStorage,
saveVoiceSettingsToStorage,
VoiceSessionFacade,
VoiceWorkspacePosition,
VoiceWorkspaceService
} from '../../../../domains/voice-session';
import { VoiceConnectionFacade } from '../../../../domains/voice-connection';
import { VoicePlaybackService } from '../../../../domains/voice-connection/application/voice-playback.service';
import { ScreenShareFacade } from '../../application/screen-share.facade';
import { ScreenShareQuality, ScreenShareStartOptions } from '../../domain/screen-share.config';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
import { ScreenShareQualityDialogComponent, UserAvatarComponent } from '../../../../shared';
import { ScreenSharePlaybackService } from './screen-share-playback.service';
import { ScreenShareStreamTileComponent } from './screen-share-stream-tile.component';
import { ScreenShareWorkspaceStreamItem } from './screen-share-workspace.models';
@Component({
selector: 'app-screen-share-workspace',
standalone: true,
imports: [
CommonModule,
NgIcon,
ScreenShareQualityDialogComponent,
ScreenShareStreamTileComponent,
UserAvatarComponent
],
viewProviders: [
provideIcons({
lucideHeadphones,
lucideMaximize,
lucideMic,
lucideMicOff,
lucideMinimize,
lucideMonitor,
lucideMonitorOff,
lucidePhoneOff,
lucideUsers,
lucideVolume2,
lucideVolumeX,
lucideX
})
],
templateUrl: './screen-share-workspace.component.html',
host: {
class: 'pointer-events-none absolute inset-0 z-20 block'
}
})
export class ScreenShareWorkspaceComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly store = inject(Store);
private readonly webrtc = inject(VoiceConnectionFacade);
private readonly screenShare = inject(ScreenShareFacade);
private readonly voicePlayback = inject(VoicePlaybackService);
private readonly screenSharePlayback = inject(ScreenSharePlaybackService);
private readonly voiceSession = inject(VoiceSessionFacade);
private readonly voiceWorkspace = inject(VoiceWorkspaceService);
private readonly remoteStreamRevision = signal(0);
private readonly miniWindowWidth = 320;
private readonly miniWindowHeight = 228;
private miniWindowDragging = false;
private miniDragOffsetX = 0;
private miniDragOffsetY = 0;
private wasExpanded = false;
private wasAutoHideChrome = false;
private headerHideTimeoutId: ReturnType<typeof setTimeout> | null = null;
private readonly observedRemoteStreams = new Map<string, {
stream: MediaStream;
cleanup: () => void;
}>();
readonly miniPreviewRef = viewChild<ElementRef<HTMLVideoElement>>('miniPreview');
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly currentUser = this.store.selectSignal(selectCurrentUser);
readonly onlineUsers = this.store.selectSignal(selectOnlineUsers);
readonly voiceSessionInfo = this.voiceSession.voiceSession;
readonly showExpanded = this.voiceWorkspace.isExpanded;
readonly showMiniWindow = this.voiceWorkspace.isMinimized;
readonly shouldConnectRemoteShares = this.voiceWorkspace.shouldConnectRemoteShares;
readonly miniPosition = this.voiceWorkspace.miniWindowPosition;
readonly showWorkspaceHeader = signal(true);
readonly isConnected = computed(() => this.webrtc.isVoiceConnected());
readonly isMuted = computed(() => this.webrtc.isMuted());
readonly isDeafened = computed(() => this.webrtc.isDeafened());
readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing());
readonly includeSystemAudio = signal(false);
readonly screenShareQuality = signal<ScreenShareQuality>('balanced');
readonly askScreenShareQuality = signal(true);
readonly showScreenShareQualityDialog = signal(false);
readonly connectedVoiceUsers = computed(() => {
const room = this.currentRoom();
const me = this.currentUser();
const roomId = me?.voiceState?.roomId;
const serverId = me?.voiceState?.serverId;
if (!room || !roomId || !serverId || serverId !== room.id) {
return [] as User[];
}
const voiceUsers = this.onlineUsers().filter(
(user) =>
!!user.voiceState?.isConnected
&& user.voiceState.roomId === roomId
&& user.voiceState.serverId === room.id
);
if (!me?.voiceState?.isConnected) {
return voiceUsers;
}
const currentKeys = new Set(voiceUsers.map((user) => user.oderId || user.id));
const meKey = me.oderId || me.id;
if (meKey && !currentKeys.has(meKey)) {
return [me, ...voiceUsers];
}
return voiceUsers;
});
readonly activeShares = computed<ScreenShareWorkspaceStreamItem[]>(() => {
this.remoteStreamRevision();
const room = this.currentRoom();
const me = this.currentUser();
const connectedRoomId = me?.voiceState?.roomId;
const connectedServerId = me?.voiceState?.serverId;
if (!room || !me || !connectedRoomId || connectedServerId !== room.id) {
return [];
}
const shares: ScreenShareWorkspaceStreamItem[] = [];
const localStream = this.screenShare.screenStream();
const localPeerKey = this.getUserPeerKey(me);
if (localStream && localPeerKey) {
shares.push({
id: localPeerKey,
peerKey: localPeerKey,
user: me,
stream: localStream,
isLocal: true
});
}
for (const user of this.onlineUsers()) {
const peerKey = this.getUserPeerKey(user);
if (!peerKey || peerKey === localPeerKey) {
continue;
}
if (
!user.voiceState?.isConnected
|| user.voiceState.roomId !== connectedRoomId
|| user.voiceState.serverId !== room.id
) {
continue;
}
if (user.screenShareState?.isSharing === false) {
continue;
}
const remoteShare = this.getRemoteShareStream(user);
if (!remoteShare) {
continue;
}
shares.push({
id: remoteShare.peerKey,
peerKey: remoteShare.peerKey,
user,
stream: remoteShare.stream,
isLocal: false
});
}
return shares.sort((shareA, shareB) => {
if (shareA.isLocal !== shareB.isLocal) {
return shareA.isLocal ? 1 : -1;
}
return shareA.user.displayName.localeCompare(shareB.user.displayName);
});
});
readonly widescreenShareId = computed(() => {
const requested = this.voiceWorkspace.focusedStreamId();
const activeShares = this.activeShares();
if (requested && activeShares.some((share) => share.peerKey === requested)) {
return requested;
}
if (activeShares.length === 1) {
return activeShares[0].peerKey;
}
return null;
});
readonly isWidescreenMode = computed(() => this.widescreenShareId() !== null);
readonly shouldAutoHideChrome = computed(
() => this.showExpanded() && this.isWidescreenMode() && this.activeShares().length > 0
);
readonly hasMultipleShares = computed(() => this.activeShares().length > 1);
readonly widescreenShare = computed(
() => this.activeShares().find((share) => share.peerKey === this.widescreenShareId()) ?? null
);
readonly focusedAudioShare = computed(() => {
const share = this.widescreenShare();
return share && !share.isLocal ? share : null;
});
readonly focusedShareTitle = computed(() => {
const share = this.widescreenShare();
if (!share) {
return 'Focused stream';
}
return share.isLocal ? 'Your stream' : share.user.displayName;
});
readonly thumbnailShares = computed(() => {
const widescreenShareId = this.widescreenShareId();
if (!widescreenShareId) {
return [] as ScreenShareWorkspaceStreamItem[];
}
return this.activeShares().filter((share) => share.peerKey !== widescreenShareId);
});
readonly miniPreviewShare = computed(
() => this.widescreenShare() ?? this.activeShares()[0] ?? null
);
readonly miniPreviewTitle = computed(() => {
const previewShare = this.miniPreviewShare();
if (!previewShare) {
return 'Voice workspace';
}
return previewShare.isLocal ? 'Your stream' : previewShare.user.displayName;
});
readonly liveShareCount = computed(() => this.activeShares().length);
readonly connectedVoiceChannelName = computed(() => {
const me = this.currentUser();
const room = this.currentRoom();
const channelId = me?.voiceState?.roomId ?? this.voiceSessionInfo()?.roomId;
const channel = room?.channels?.find(
(candidate) => candidate.id === channelId && candidate.type === 'voice'
);
if (channel) {
return channel.name;
}
const sessionRoomName = this.voiceSessionInfo()?.roomName?.replace(/^🔊\s*/, '');
return sessionRoomName || 'Voice Lounge';
});
readonly serverName = computed(
() => this.currentRoom()?.name || this.voiceSessionInfo()?.serverName || 'Voice server'
);
constructor() {
this.destroyRef.onDestroy(() => {
this.clearHeaderHideTimeout();
this.cleanupObservedRemoteStreams();
this.screenShare.syncRemoteScreenShareRequests([], false);
this.screenSharePlayback.teardownAll();
});
this.screenShare.onRemoteStream
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ peerId }) => {
this.observeRemoteStream(peerId);
this.bumpRemoteStreamRevision();
});
this.screenShare.onPeerDisconnected
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(() => this.bumpRemoteStreamRevision());
effect(() => {
const ref = this.miniPreviewRef();
const previewShare = this.miniPreviewShare();
const showMiniWindow = this.showMiniWindow();
if (!ref) {
return;
}
const video = ref.nativeElement;
if (!showMiniWindow || !previewShare) {
video.srcObject = null;
return;
}
if (video.srcObject !== previewShare.stream) {
video.srcObject = previewShare.stream;
}
video.muted = true;
video.volume = 0;
void video.play().catch(() => {});
});
effect(() => {
if (!this.showMiniWindow()) {
return;
}
requestAnimationFrame(() => this.ensureMiniWindowPosition());
});
effect(() => {
const shouldConnectRemoteShares = this.shouldConnectRemoteShares();
const currentUserPeerKey = this.getUserPeerKey(this.currentUser());
const peerKeys = Array.from(new Set(
this.connectedVoiceUsers()
.map((user) => this.getUserPeerKey(user))
.filter((peerKey): peerKey is string => !!peerKey && peerKey !== currentUserPeerKey)
));
this.screenShare.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares);
if (!shouldConnectRemoteShares) {
this.screenSharePlayback.teardownAll();
}
});
effect(() => {
this.remoteStreamRevision();
const room = this.currentRoom();
const currentUser = this.currentUser();
const connectedRoomId = currentUser?.voiceState?.roomId;
const connectedServerId = currentUser?.voiceState?.serverId;
const peerKeys = new Set<string>();
if (room && connectedRoomId && connectedServerId === room.id) {
for (const user of this.onlineUsers()) {
if (
!user.voiceState?.isConnected
|| user.voiceState.roomId !== connectedRoomId
|| user.voiceState.serverId !== room.id
) {
continue;
}
for (const peerKey of [user.oderId, user.id]) {
if (!peerKey || peerKey === this.getUserPeerKey(currentUser)) {
continue;
}
peerKeys.add(peerKey);
this.observeRemoteStream(peerKey);
}
}
}
this.pruneObservedRemoteStreams(peerKeys);
});
effect(
() => {
const isExpanded = this.showExpanded();
const shouldAutoHideChrome = this.shouldAutoHideChrome();
if (!isExpanded) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = false;
this.wasAutoHideChrome = false;
return;
}
if (!shouldAutoHideChrome) {
this.clearHeaderHideTimeout();
this.showWorkspaceHeader.set(true);
this.wasExpanded = true;
this.wasAutoHideChrome = false;
return;
}
const shouldRevealChrome = !this.wasExpanded || !this.wasAutoHideChrome;
this.wasExpanded = true;
this.wasAutoHideChrome = true;
if (shouldRevealChrome) {
this.revealWorkspaceChrome();
}
},
{ allowSignalWrites: true }
);
}
onWorkspacePointerMove(): void {
if (!this.shouldAutoHideChrome()) {
return;
}
this.revealWorkspaceChrome();
}
@HostListener('window:mousemove', ['$event'])
onWindowMouseMove(event: MouseEvent): void {
if (!this.miniWindowDragging) {
return;
}
event.preventDefault();
const bounds = this.getWorkspaceBounds();
const nextPosition = this.clampMiniWindowPosition({
left: event.clientX - bounds.left - this.miniDragOffsetX,
top: event.clientY - bounds.top - this.miniDragOffsetY
});
this.voiceWorkspace.setMiniWindowPosition(nextPosition);
}
@HostListener('window:mouseup')
onWindowMouseUp(): void {
this.miniWindowDragging = false;
}
@HostListener('window:resize')
onWindowResize(): void {
if (!this.showMiniWindow()) {
return;
}
this.ensureMiniWindowPosition();
}
trackUser(index: number, user: User): string {
return this.getUserPeerKey(user) || `${index}`;
}
trackShare(index: number, share: ScreenShareWorkspaceStreamItem): string {
return share.id || `${index}`;
}
focusShare(peerKey: string): void {
if (this.widescreenShareId() === peerKey) {
return;
}
this.voiceWorkspace.focusStream(peerKey);
}
showAllStreams(): void {
this.voiceWorkspace.clearFocusedStream();
}
minimizeWorkspace(): void {
this.voiceWorkspace.minimize();
this.ensureMiniWindowPosition();
}
restoreWorkspace(): void {
this.voiceWorkspace.restore();
}
closeWorkspace(): void {
this.voiceWorkspace.clearFocusedStream();
this.voiceWorkspace.close();
}
focusedShareVolume(): number {
const share = this.focusedAudioShare();
if (!share) {
return 100;
}
return this.screenSharePlayback.getUserVolume(share.peerKey);
}
focusedShareMuted(): boolean {
const share = this.focusedAudioShare();
if (!share) {
return false;
}
return this.screenSharePlayback.isUserMuted(share.peerKey);
}
toggleFocusedShareMuted(): void {
const share = this.focusedAudioShare();
if (!share) {
return;
}
this.screenSharePlayback.setUserMuted(
share.peerKey,
!this.screenSharePlayback.isUserMuted(share.peerKey)
);
}
updateFocusedShareVolume(event: Event): void {
const share = this.focusedAudioShare();
if (!share) {
return;
}
const input = event.target as HTMLInputElement;
const nextVolume = Math.max(0, Math.min(100, parseInt(input.value, 10) || 0));
this.screenSharePlayback.setUserVolume(share.peerKey, nextVolume);
if (nextVolume > 0 && this.screenSharePlayback.isUserMuted(share.peerKey)) {
this.screenSharePlayback.setUserMuted(share.peerKey, false);
}
}
startMiniWindowDrag(event: MouseEvent): void {
const target = event.target as HTMLElement | null;
if (target?.closest('button, input')) {
return;
}
event.preventDefault();
const bounds = this.getWorkspaceBounds();
const currentPosition = this.voiceWorkspace.miniWindowPosition();
this.miniWindowDragging = true;
this.miniDragOffsetX = event.clientX - bounds.left - currentPosition.left;
this.miniDragOffsetY = event.clientY - bounds.top - currentPosition.top;
}
toggleMute(): void {
const nextMuted = !this.isMuted();
this.webrtc.toggleMute(nextMuted);
this.syncVoiceState({
isConnected: this.isConnected(),
isMuted: nextMuted,
isDeafened: this.isDeafened()
});
this.broadcastVoiceState(nextMuted, this.isDeafened());
}
toggleDeafen(): void {
const nextDeafened = !this.isDeafened();
let nextMuted = this.isMuted();
this.webrtc.toggleDeafen(nextDeafened);
this.voicePlayback.updateDeafened(nextDeafened);
if (nextDeafened && !nextMuted) {
nextMuted = true;
this.webrtc.toggleMute(true);
}
this.syncVoiceState({
isConnected: this.isConnected(),
isMuted: nextMuted,
isDeafened: nextDeafened
});
this.broadcastVoiceState(nextMuted, nextDeafened);
}
async toggleScreenShare(): Promise<void> {
if (this.isScreenSharing()) {
this.screenShare.stopScreenShare();
return;
}
this.syncScreenShareSettings();
if (this.askScreenShareQuality()) {
this.showScreenShareQualityDialog.set(true);
return;
}
await this.startScreenShareWithOptions(this.screenShareQuality());
}
onScreenShareQualityCancelled(): void {
this.showScreenShareQualityDialog.set(false);
}
async onScreenShareQualityConfirmed(quality: ScreenShareQuality): Promise<void> {
this.showScreenShareQualityDialog.set(false);
this.screenShareQuality.set(quality);
saveVoiceSettingsToStorage({ screenShareQuality: quality });
await this.startScreenShareWithOptions(quality);
}
disconnect(): void {
this.webrtc.stopVoiceHeartbeat();
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
});
if (this.isScreenSharing()) {
this.screenShare.stopScreenShare();
}
this.webrtc.disableVoice();
this.voicePlayback.teardownAll();
this.voicePlayback.updateDeafened(false);
const user = this.currentUser();
if (user?.id) {
this.store.dispatch(
UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
isConnected: false,
isMuted: false,
isDeafened: false,
roomId: undefined,
serverId: undefined
}
})
);
}
this.voiceSession.endSession();
this.voiceWorkspace.reset();
}
getControlButtonClass(
isActive: boolean,
accent: 'default' | 'primary' | 'danger' = 'default'
): string {
const base = 'inline-flex min-w-[5.5rem] flex-col items-center gap-2 rounded-2xl px-4 py-3 text-sm font-medium transition-colors';
if (accent === 'danger') {
return `${base} bg-destructive text-destructive-foreground hover:bg-destructive/90`;
}
if (accent === 'primary' || isActive) {
return `${base} bg-primary/15 text-primary hover:bg-primary/25`;
}
return `${base} bg-secondary/80 text-foreground hover:bg-secondary`;
}
private bumpRemoteStreamRevision(): void {
this.remoteStreamRevision.update((value) => value + 1);
}
private syncVoiceState(voiceState: {
isConnected: boolean;
isMuted: boolean;
isDeafened: boolean;
}): void {
const user = this.currentUser();
const identifiers = this.getCurrentVoiceIdentifiers();
if (!user?.id) {
return;
}
this.store.dispatch(
UsersActions.updateVoiceState({
userId: user.id,
voiceState: {
...voiceState,
roomId: identifiers.roomId,
serverId: identifiers.serverId
}
})
);
}
private broadcastVoiceState(isMuted: boolean, isDeafened: boolean): void {
const identifiers = this.getCurrentVoiceIdentifiers();
this.webrtc.broadcastMessage({
type: 'voice-state',
oderId: this.currentUser()?.oderId || this.currentUser()?.id,
displayName: this.currentUser()?.displayName || 'User',
voiceState: {
isConnected: this.isConnected(),
isMuted,
isDeafened,
roomId: identifiers.roomId,
serverId: identifiers.serverId
}
});
}
private getCurrentVoiceIdentifiers(): {
roomId: string | undefined;
serverId: string | undefined;
} {
const me = this.currentUser();
return {
roomId: me?.voiceState?.roomId ?? this.voiceSessionInfo()?.roomId,
serverId: me?.voiceState?.serverId ?? this.currentRoom()?.id ?? this.voiceSessionInfo()?.serverId
};
}
private syncScreenShareSettings(): void {
const settings = loadVoiceSettingsFromStorage();
this.includeSystemAudio.set(settings.includeSystemAudio);
this.screenShareQuality.set(settings.screenShareQuality);
this.askScreenShareQuality.set(settings.askScreenShareQuality);
}
private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise<void> {
const options: ScreenShareStartOptions = {
includeSystemAudio: this.includeSystemAudio(),
quality
};
try {
await this.screenShare.startScreenShare(options);
this.voiceWorkspace.open(null);
} catch {
// Screen-share prompt was dismissed or failed.
}
}
private getUserPeerKey(user: User | null | undefined): string | null {
return user?.oderId || user?.id || null;
}
private getRemoteShareStream(user: User): { peerKey: string; stream: MediaStream } | null {
const peerKeys = [user.oderId, user.id].filter(
(candidate): candidate is string => !!candidate
);
for (const peerKey of peerKeys) {
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
if (stream && this.hasActiveVideo(stream)) {
return { peerKey, stream };
}
}
return null;
}
private hasActiveVideo(stream: MediaStream): boolean {
return stream.getVideoTracks().some((track) => track.readyState === 'live');
}
private ensureMiniWindowPosition(): void {
const bounds = this.getWorkspaceBounds();
if (bounds.width === 0 || bounds.height === 0) {
return;
}
if (!this.voiceWorkspace.hasCustomMiniWindowPosition()) {
this.voiceWorkspace.setMiniWindowPosition(
this.clampMiniWindowPosition({
left: bounds.width - this.miniWindowWidth - 20,
top: bounds.height - this.miniWindowHeight - 20
}),
false
);
return;
}
this.voiceWorkspace.setMiniWindowPosition(
this.clampMiniWindowPosition(this.voiceWorkspace.miniWindowPosition()),
true
);
}
private clampMiniWindowPosition(position: VoiceWorkspacePosition): VoiceWorkspacePosition {
const bounds = this.getWorkspaceBounds();
const minLeft = 8;
const minTop = 8;
const maxLeft = Math.max(minLeft, bounds.width - this.miniWindowWidth - 8);
const maxTop = Math.max(minTop, bounds.height - this.miniWindowHeight - 8);
return {
left: this.clamp(position.left, minLeft, maxLeft),
top: this.clamp(position.top, minTop, maxTop)
};
}
private getWorkspaceBounds(): DOMRect {
return this.elementRef.nativeElement.getBoundingClientRect();
}
private observeRemoteStream(peerKey: string): void {
const stream = this.screenShare.getRemoteScreenShareStream(peerKey);
const existing = this.observedRemoteStreams.get(peerKey);
if (!stream) {
if (existing) {
existing.cleanup();
this.observedRemoteStreams.delete(peerKey);
}
return;
}
if (existing?.stream === stream) {
return;
}
existing?.cleanup();
const onChanged = () => this.bumpRemoteStreamRevision();
const trackCleanups: (() => void)[] = [];
const bindTrack = (track: MediaStreamTrack) => {
if (track.kind !== 'video') {
return;
}
const onTrackChanged = () => onChanged();
track.addEventListener('ended', onTrackChanged);
track.addEventListener('mute', onTrackChanged);
track.addEventListener('unmute', onTrackChanged);
trackCleanups.push(() => {
track.removeEventListener('ended', onTrackChanged);
track.removeEventListener('mute', onTrackChanged);
track.removeEventListener('unmute', onTrackChanged);
});
};
stream.getVideoTracks().forEach((track) => bindTrack(track));
const onAddTrack = (event: MediaStreamTrackEvent) => {
bindTrack(event.track);
onChanged();
};
const onRemoveTrack = () => onChanged();
stream.addEventListener('addtrack', onAddTrack);
stream.addEventListener('removetrack', onRemoveTrack);
this.observedRemoteStreams.set(peerKey, {
stream,
cleanup: () => {
stream.removeEventListener('addtrack', onAddTrack);
stream.removeEventListener('removetrack', onRemoveTrack);
trackCleanups.forEach((cleanup) => cleanup());
}
});
onChanged();
}
private pruneObservedRemoteStreams(activePeerKeys: Set<string>): void {
for (const [peerKey, observed] of this.observedRemoteStreams.entries()) {
if (activePeerKeys.has(peerKey)) {
continue;
}
observed.cleanup();
this.observedRemoteStreams.delete(peerKey);
}
}
private cleanupObservedRemoteStreams(): void {
for (const observed of this.observedRemoteStreams.values()) {
observed.cleanup();
}
this.observedRemoteStreams.clear();
}
private scheduleHeaderHide(): void {
this.clearHeaderHideTimeout();
this.headerHideTimeoutId = setTimeout(() => {
this.showWorkspaceHeader.set(false);
this.headerHideTimeoutId = null;
}, 2200);
}
private revealWorkspaceChrome(): void {
this.showWorkspaceHeader.set(true);
this.scheduleHeaderHide();
}
private clearHeaderHideTimeout(): void {
if (this.headerHideTimeoutId === null) {
return;
}
clearTimeout(this.headerHideTimeoutId);
this.headerHideTimeoutId = null;
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
}

View File

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

View File

@@ -0,0 +1,8 @@
export * from './application/screen-share.facade';
export * from './application/screen-share-source-picker.service';
export * from './domain/screen-share.config';
// Feature components
export { ScreenShareViewerComponent } from './feature/screen-share-viewer/screen-share-viewer.component';
export { ScreenShareWorkspaceComponent } from './feature/screen-share-workspace/screen-share-workspace.component';
export { ScreenShareStreamTileComponent } from './feature/screen-share-workspace/screen-share-stream-tile.component';

View File

@@ -0,0 +1,176 @@
# Server Directory Domain
Manages the list of server endpoints the client can connect to, health-checking them, resolving API URLs, and providing server CRUD, search, invites, and moderation. This is the central domain that other domains (auth, chat, attachment) depend on for knowing where the backend is.
## Module map
```
server-directory/
├── application/
│ ├── server-directory.facade.ts High-level API: server CRUD, search, health, invites, moderation
│ └── server-endpoint-state.service.ts Signal-based endpoint list, reconciliation with defaults, localStorage persistence
├── domain/
│ ├── server-directory.models.ts ServerEndpoint, ServerInfo, ServerJoinAccessResponse, invite/ban/kick types
│ ├── server-directory.constants.ts CLIENT_UPDATE_REQUIRED_MESSAGE
│ └── server-endpoint-defaults.ts Default endpoint templates, URL sanitisation, reconciliation helpers
├── infrastructure/
│ ├── server-directory-api.service.ts HTTP client for all server API calls
│ ├── server-endpoint-health.service.ts Health probe (GET /api/health with 5 s timeout, fallback to /api/servers)
│ ├── server-endpoint-compatibility.service.ts Semantic version comparison for client/server compatibility
│ └── server-endpoint-storage.service.ts localStorage read/write for endpoint list and removed-default tracking
├── feature/
│ ├── invite/ Invite creation and resolution UI
│ ├── server-search/ Server search/browse panel
│ └── settings/ Server endpoint management settings
└── index.ts Barrel exports
```
## Layer composition
The facade delegates HTTP work to the API service and endpoint state to the state service. Health probing combines the health service and compatibility service. Storage is accessed only through the state service.
```mermaid
graph TD
Facade[ServerDirectoryFacade]
State[ServerEndpointStateService]
API[ServerDirectoryApiService]
Health[ServerEndpointHealthService]
Compat[ServerEndpointCompatibilityService]
Storage[ServerEndpointStorageService]
Defaults[server-endpoint-defaults]
Models[server-directory.models]
Facade --> API
Facade --> State
Facade --> Health
Facade --> Compat
API --> State
State --> Storage
State --> Defaults
Health --> Compat
click Facade "application/server-directory.facade.ts" "High-level API" _blank
click State "application/server-endpoint-state.service.ts" "Signal-based endpoint state" _blank
click API "infrastructure/server-directory-api.service.ts" "HTTP client for server API" _blank
click Health "infrastructure/server-endpoint-health.service.ts" "Health probe" _blank
click Compat "infrastructure/server-endpoint-compatibility.service.ts" "Version compatibility" _blank
click Storage "infrastructure/server-endpoint-storage.service.ts" "localStorage persistence" _blank
click Defaults "domain/server-endpoint-defaults.ts" "Default endpoint templates" _blank
click Models "domain/server-directory.models.ts" "Domain types" _blank
```
## Endpoint lifecycle
On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active.
```mermaid
stateDiagram-v2
[*] --> Load: constructor
Load --> HasStored: localStorage has endpoints
Load --> InitDefaults: no stored endpoints
InitDefaults --> Ready: save default endpoints
HasStored --> Reconcile: compare stored vs defaults
Reconcile --> Ready: merge, ensure active
Ready --> HealthCheck: facade.testAllServers()
state HealthCheck {
[*] --> Probing
Probing --> Online: /api/health 200 OK
Probing --> Incompatible: version mismatch
Probing --> Offline: request failed
}
```
## Health probing
The facade exposes `testServer(endpointId)` and `testAllServers()`. Both delegate to `ServerEndpointHealthService.probeEndpoint()`, which:
1. Sends `GET /api/health` with a 5-second timeout
2. On success, checks the response's `serverVersion` against the client version via `ServerEndpointCompatibilityService`
3. If versions are incompatible, the endpoint is marked `incompatible` and deactivated
4. If `/api/health` fails, falls back to `GET /api/servers` as a basic liveness check
5. Updates the endpoint's status, latency, and version info in the state service
```mermaid
sequenceDiagram
participant Facade
participant Health as HealthService
participant Compat as CompatibilityService
participant API as Server
Facade->>Health: probeEndpoint(endpoint, clientVersion)
Health->>API: GET /api/health (5s timeout)
alt 200 OK
API-->>Health: { serverVersion }
Health->>Compat: evaluateServerVersion(serverVersion, clientVersion)
Compat-->>Health: { isCompatible, serverVersion }
Health-->>Facade: online / incompatible + latency + versions
else Request failed
Health->>API: GET /api/servers (fallback)
alt 200 OK
API-->>Health: servers list
Health-->>Facade: online + latency
else Also failed
Health-->>Facade: offline
end
end
Facade->>Facade: updateServerStatus(id, status, latency, versions)
```
## Server search
The facade's `searchServers(query)` method supports two modes controlled by a `searchAllServers` flag:
- **Single endpoint**: searches only the active server's API
- **All endpoints**: fans out the query to every online active endpoint via `forkJoin`, then deduplicates results by server ID
The API service normalises every `ServerInfo` response, filling in `sourceId`, `sourceName`, and `sourceUrl` so the UI knows which endpoint each server came from.
## Default endpoint management
Default servers are configured in the environment file. The state service builds `DefaultEndpointTemplate` objects from the configuration and uses them during reconciliation:
- Stored endpoints are matched to defaults by `defaultKey` or URL
- Missing defaults are added unless the user explicitly removed them (tracked in a separate localStorage key)
- `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking
- The primary default URL is used as a fallback when no endpoint is resolved
URL sanitisation strips trailing slashes and `/api` suffixes. Protocol-less URLs get `http` or `https` based on the current page protocol.
## Server administration
The facade provides methods for server registration, updates, and unregistration. These map directly to the API service's HTTP calls:
| Method | HTTP | Endpoint |
|---|---|---|
| `registerServer` | POST | `/api/servers` |
| `updateServer` | PUT | `/api/servers/:id` |
| `unregisterServer` | DELETE | `/api/servers/:id` |
## Invites and moderation
| Method | Purpose |
|---|---|
| `createInvite(serverId, request)` | Creates a time-limited invite link |
| `getInvite(inviteId)` | Resolves invite metadata |
| `requestServerAccess(request)` | Joins a server (via membership, password, invite, or public access) |
| `kickServerMember(serverId, request)` | Removes a user from the server |
| `banServerMember(serverId, request)` | Bans a user with optional reason and expiry |
| `unbanServerMember(serverId, request)` | Lifts a ban |
## Persistence
All endpoint state is persisted to localStorage under two keys:
| Key | Contents |
|---|---|
| `metoyou_server_endpoints` | Full `ServerEndpoint[]` array |
| `metoyou_removed_default_server_keys` | Set of default endpoint keys the user explicitly removed |
The storage service handles JSON serialisation and defensive parsing. Invalid data falls back to empty state rather than throwing.

View File

@@ -0,0 +1,260 @@
import {
Injectable,
inject,
type Signal
} from '@angular/core';
import { Observable } from 'rxjs';
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants';
import { User } from '../../../shared-kernel';
import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants';
import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service';
import type {
BanServerMemberRequest,
CreateServerInviteRequest,
KickServerMemberRequest,
ServerEndpoint,
ServerEndpointVersions,
ServerInfo,
ServerInviteInfo,
ServerJoinAccessRequest,
ServerJoinAccessResponse,
ServerSourceSelector,
UnbanServerMemberRequest
} from '../domain/server-directory.models';
import { ServerEndpointCompatibilityService } from '../infrastructure/server-endpoint-compatibility.service';
import { ServerEndpointHealthService } from '../infrastructure/server-endpoint-health.service';
import { ServerEndpointStateService } from './server-endpoint-state.service';
export { CLIENT_UPDATE_REQUIRED_MESSAGE };
@Injectable({ providedIn: 'root' })
export class ServerDirectoryFacade {
readonly servers: Signal<ServerEndpoint[]>;
readonly activeServers: Signal<ServerEndpoint[]>;
readonly hasMissingDefaultServers: Signal<boolean>;
readonly activeServer: Signal<ServerEndpoint | null>;
private readonly endpointState = inject(ServerEndpointStateService);
private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService);
private readonly endpointHealth = inject(ServerEndpointHealthService);
private readonly api = inject(ServerDirectoryApiService);
private shouldSearchAllServers = true;
constructor() {
this.servers = this.endpointState.servers;
this.activeServers = this.endpointState.activeServers;
this.hasMissingDefaultServers = this.endpointState.hasMissingDefaultServers;
this.activeServer = this.endpointState.activeServer;
this.loadConnectionSettings();
void this.testAllServers();
}
addServer(server: { name: string; url: string }): ServerEndpoint {
return this.endpointState.addServer(server);
}
ensureServerEndpoint(
server: { name: string; url: string },
options?: { setActive?: boolean }
): ServerEndpoint {
return this.endpointState.ensureServerEndpoint(server, options);
}
findServerByUrl(url: string): ServerEndpoint | undefined {
return this.endpointState.findServerByUrl(url);
}
removeServer(endpointId: string): void {
this.endpointState.removeServer(endpointId);
}
restoreDefaultServers(): ServerEndpoint[] {
return this.endpointState.restoreDefaultServers();
}
setActiveServer(endpointId: string): void {
this.endpointState.setActiveServer(endpointId);
}
deactivateServer(endpointId: string): void {
this.endpointState.deactivateServer(endpointId);
}
updateServerStatus(
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
versions?: ServerEndpointVersions
): void {
this.endpointState.updateServerStatus(endpointId, status, latency, versions);
}
async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise<boolean> {
const endpoint = this.api.resolveEndpoint(selector);
if (!endpoint || endpoint.status === 'incompatible') {
return false;
}
const clientVersion = await this.endpointCompatibility.getClientVersion();
if (!clientVersion) {
return true;
}
await this.testServer(endpoint.id);
const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id);
return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible';
}
setSearchAllServers(enabled: boolean): void {
this.shouldSearchAllServers = enabled;
}
async testServer(endpointId: string): Promise<boolean> {
const endpoint = this.servers().find((entry) => entry.id === endpointId);
if (!endpoint) {
return false;
}
this.updateServerStatus(endpointId, 'checking');
const clientVersion = await this.endpointCompatibility.getClientVersion();
const healthResult = await this.endpointHealth.probeEndpoint(endpoint, clientVersion);
this.updateServerStatus(
endpointId,
healthResult.status,
healthResult.latency,
healthResult.versions
);
return healthResult.status === 'online';
}
async testAllServers(): Promise<void> {
await Promise.all(this.servers().map((endpoint) => this.testServer(endpoint.id)));
}
getApiBaseUrl(selector?: ServerSourceSelector): string {
return this.api.getApiBaseUrl(selector);
}
getWebSocketUrl(selector?: ServerSourceSelector): string {
return this.api.getWebSocketUrl(selector);
}
searchServers(query: string): Observable<ServerInfo[]> {
return this.api.searchServers(query, this.shouldSearchAllServers);
}
getServers(): Observable<ServerInfo[]> {
return this.api.getServers(this.shouldSearchAllServers);
}
getServer(serverId: string, selector?: ServerSourceSelector): Observable<ServerInfo | null> {
return this.api.getServer(serverId, selector);
}
registerServer(
server: Omit<ServerInfo, 'createdAt'> & { id?: string; password?: string | null },
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.api.registerServer(server, selector);
}
updateServer(
serverId: string,
updates: Partial<ServerInfo> & {
currentOwnerId: string;
actingRole?: string;
password?: string | null;
},
selector?: ServerSourceSelector
): Observable<ServerInfo> {
return this.api.updateServer(serverId, updates, selector);
}
unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable<void> {
return this.api.unregisterServer(serverId, selector);
}
getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable<User[]> {
return this.api.getServerUsers(serverId, selector);
}
requestJoin(
request: ServerJoinAccessRequest,
selector?: ServerSourceSelector
): Observable<ServerJoinAccessResponse> {
return this.api.requestJoin(request, selector);
}
createInvite(
serverId: string,
request: CreateServerInviteRequest,
selector?: ServerSourceSelector
): Observable<ServerInviteInfo> {
return this.api.createInvite(serverId, request, selector);
}
getInvite(inviteId: string, selector?: ServerSourceSelector): Observable<ServerInviteInfo> {
return this.api.getInvite(inviteId, selector);
}
kickServerMember(
serverId: string,
request: KickServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.api.kickServerMember(serverId, request, selector);
}
banServerMember(
serverId: string,
request: BanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.api.banServerMember(serverId, request, selector);
}
unbanServerMember(
serverId: string,
request: UnbanServerMemberRequest,
selector?: ServerSourceSelector
): Observable<void> {
return this.api.unbanServerMember(serverId, request, selector);
}
notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable<void> {
return this.api.notifyLeave(serverId, userId, selector);
}
updateUserCount(serverId: string, count: number): Observable<void> {
return this.api.updateUserCount(serverId, count);
}
sendHeartbeat(serverId: string): Observable<void> {
return this.api.sendHeartbeat(serverId);
}
private loadConnectionSettings(): void {
const stored = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS);
if (!stored) {
this.shouldSearchAllServers = true;
return;
}
try {
const parsed = JSON.parse(stored) as { searchAllServers?: boolean };
this.shouldSearchAllServers = parsed.searchAllServers ?? true;
} catch {
this.shouldSearchAllServers = true;
}
}
}

View File

@@ -0,0 +1,315 @@
import {
Injectable,
computed,
inject,
signal,
type Signal
} from '@angular/core';
import { v4 as uuidv4 } from 'uuid';
import { environment } from '../../../../environments/environment';
import {
buildDefaultEndpointTemplates,
buildDefaultServerDefinitions,
ensureAnyActiveEndpoint,
ensureCompatibleActiveEndpoint,
findDefaultEndpointKeyByUrl,
hasEndpointForDefault,
matchDefaultEndpointTemplate,
sanitiseServerBaseUrl
} from '../domain/server-endpoint-defaults';
import { ServerEndpointStorageService } from '../infrastructure/server-endpoint-storage.service';
import type {
ConfiguredDefaultServerDefinition,
DefaultEndpointTemplate,
ServerEndpoint,
ServerEndpointVersions
} from '../domain/server-directory.models';
function resolveDefaultHttpProtocol(): 'http' | 'https' {
return typeof window !== 'undefined' && window.location?.protocol === 'https:'
? 'https'
: 'http';
}
@Injectable({ providedIn: 'root' })
export class ServerEndpointStateService {
readonly servers: Signal<ServerEndpoint[]>;
readonly activeServers: Signal<ServerEndpoint[]>;
readonly hasMissingDefaultServers: Signal<boolean>;
readonly activeServer: Signal<ServerEndpoint | null>;
private readonly storage = inject(ServerEndpointStorageService);
private readonly _servers = signal<ServerEndpoint[]>([]);
private readonly defaultEndpoints: DefaultEndpointTemplate[];
private readonly primaryDefaultServerUrl: string;
constructor() {
const defaultServerDefinitions = buildDefaultServerDefinitions(
Array.isArray(environment.defaultServers)
? environment.defaultServers as ConfiguredDefaultServerDefinition[]
: [],
environment.defaultServerUrl,
resolveDefaultHttpProtocol()
);
this.defaultEndpoints = buildDefaultEndpointTemplates(defaultServerDefinitions);
this.primaryDefaultServerUrl = this.defaultEndpoints[0]?.url ?? 'http://localhost:3001';
this.servers = computed(() => this._servers());
this.activeServers = computed(() =>
this._servers().filter((endpoint) => endpoint.isActive && endpoint.status !== 'incompatible')
);
this.hasMissingDefaultServers = computed(() =>
this.defaultEndpoints.some((endpoint) => !hasEndpointForDefault(this._servers(), endpoint))
);
this.activeServer = computed(() => this.activeServers()[0] ?? null);
this.loadEndpoints();
}
getPrimaryDefaultServerUrl(): string {
return this.primaryDefaultServerUrl;
}
sanitiseUrl(rawUrl: string): string {
return sanitiseServerBaseUrl(rawUrl);
}
addServer(server: { name: string; url: string }): ServerEndpoint {
const newEndpoint: ServerEndpoint = {
id: uuidv4(),
name: server.name,
url: this.sanitiseUrl(server.url),
isActive: true,
isDefault: false,
status: 'unknown'
};
this._servers.update((endpoints) => [...endpoints, newEndpoint]);
this.saveEndpoints();
return newEndpoint;
}
ensureServerEndpoint(
server: { name: string; url: string },
options?: { setActive?: boolean }
): ServerEndpoint {
const existing = this.findServerByUrl(server.url);
if (existing) {
if (options?.setActive) {
this.setActiveServer(existing.id);
}
return existing;
}
const created = this.addServer(server);
if (options?.setActive) {
this.setActiveServer(created.id);
}
return created;
}
findServerByUrl(url: string): ServerEndpoint | undefined {
const sanitisedUrl = this.sanitiseUrl(url);
return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl);
}
removeServer(endpointId: string): void {
const endpoints = this._servers();
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
if (!target || endpoints.length <= 1) {
return;
}
if (target.isDefault) {
this.markDefaultEndpointRemoved(target);
}
const updatedEndpoints = ensureAnyActiveEndpoint(
endpoints.filter((endpoint) => endpoint.id !== endpointId)
);
this._servers.set(updatedEndpoints);
this.saveEndpoints();
}
restoreDefaultServers(): ServerEndpoint[] {
const restoredEndpoints = this.defaultEndpoints
.filter((defaultEndpoint) => !hasEndpointForDefault(this._servers(), defaultEndpoint))
.map((defaultEndpoint) => ({
...defaultEndpoint,
id: uuidv4(),
isActive: true
}));
if (restoredEndpoints.length === 0) {
this.storage.clearRemovedDefaultEndpointKeys();
return [];
}
this._servers.update((endpoints) => ensureAnyActiveEndpoint([...endpoints, ...restoredEndpoints]));
this.storage.clearRemovedDefaultEndpointKeys();
this.saveEndpoints();
return restoredEndpoints;
}
setActiveServer(endpointId: string): void {
this._servers.update((endpoints) => {
const target = endpoints.find((endpoint) => endpoint.id === endpointId);
if (!target || target.status === 'incompatible') {
return endpoints;
}
return endpoints.map((endpoint) =>
endpoint.id === endpointId
? { ...endpoint, isActive: true }
: endpoint
);
});
this.saveEndpoints();
}
deactivateServer(endpointId: string): void {
if (this.activeServers().length <= 1) {
return;
}
this._servers.update((endpoints) =>
endpoints.map((endpoint) =>
endpoint.id === endpointId
? { ...endpoint, isActive: false }
: endpoint
)
);
this.saveEndpoints();
}
updateServerStatus(
endpointId: string,
status: ServerEndpoint['status'],
latency?: number,
versions?: ServerEndpointVersions
): void {
this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => {
if (endpoint.id !== endpointId) {
return endpoint;
}
return {
...endpoint,
status,
latency,
isActive: status === 'incompatible' ? false : endpoint.isActive,
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
};
})));
this.saveEndpoints();
}
private loadEndpoints(): void {
const storedEndpoints = this.storage.loadEndpoints();
if (!storedEndpoints) {
this.initialiseDefaultEndpoints();
return;
}
this._servers.set(this.reconcileStoredEndpoints(storedEndpoints));
this.saveEndpoints();
}
private initialiseDefaultEndpoints(): void {
this._servers.set(this.defaultEndpoints.map((endpoint) => ({
...endpoint,
id: uuidv4()
})));
this.saveEndpoints();
}
private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] {
const reconciled: ServerEndpoint[] = [];
const claimedDefaultKeys = new Set<string>();
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
for (const endpoint of storedEndpoints) {
if (!endpoint || typeof endpoint.id !== 'string' || typeof endpoint.url !== 'string') {
continue;
}
const sanitisedUrl = this.sanitiseUrl(endpoint.url);
const matchedDefault = matchDefaultEndpointTemplate(
this.defaultEndpoints,
endpoint,
sanitisedUrl,
claimedDefaultKeys
);
if (matchedDefault) {
claimedDefaultKeys.add(matchedDefault.defaultKey);
reconciled.push({
...endpoint,
name: matchedDefault.name,
url: matchedDefault.url,
isDefault: true,
defaultKey: matchedDefault.defaultKey,
status: endpoint.status ?? 'unknown'
});
continue;
}
reconciled.push({
...endpoint,
url: sanitisedUrl,
status: endpoint.status ?? 'unknown'
});
}
for (const defaultEndpoint of this.defaultEndpoints) {
if (
!claimedDefaultKeys.has(defaultEndpoint.defaultKey)
&& !removedDefaultKeys.has(defaultEndpoint.defaultKey)
&& !hasEndpointForDefault(reconciled, defaultEndpoint)
) {
reconciled.push({
...defaultEndpoint,
id: uuidv4(),
isActive: defaultEndpoint.isActive
});
}
}
return ensureAnyActiveEndpoint(reconciled);
}
private markDefaultEndpointRemoved(endpoint: ServerEndpoint): void {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
return;
}
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
removedDefaultKeys.add(defaultKey);
this.storage.saveRemovedDefaultEndpointKeys(removedDefaultKeys);
}
private saveEndpoints(): void {
this.storage.saveEndpoints(this._servers());
}
}

View File

@@ -0,0 +1 @@
export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users';

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