fix: multiple bug fixes

isolated users, db backup, weird disconnect issues for long voice sessions,
This commit is contained in:
2026-04-24 22:19:57 +02:00
parent 44588e8789
commit bc2fa7de22
56 changed files with 1861 additions and 133 deletions

View File

@@ -24,7 +24,7 @@ authentication/
## Service overview
`AuthenticationService` 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.
`AuthenticationService` 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 dispatches `UsersActions.authenticateUser`, and the users effects prepare the local persistence boundary before exposing the new user in the NgRx store.
```mermaid
graph TD
@@ -58,6 +58,7 @@ sequenceDiagram
participant SD as ServerDirectoryFacade
participant API as Server API
participant Store as NgRx Store
participant Effects as UsersEffects
User->>Login: Submit credentials
Login->>Auth: login(username, password)
@@ -66,13 +67,15 @@ sequenceDiagram
Auth->>API: POST /api/auth/login
API-->>Auth: { userId, displayName }
Auth-->>Login: success
Login->>Store: UsersActions.setCurrentUser
Login->>Login: localStorage.setItem(currentUserId)
Login->>Store: UsersActions.authenticateUser
Store->>Effects: prepare persisted user scope
Effects->>Store: reset stale room/user/message state
Effects->>Store: UsersActions.setCurrentUser
```
## 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.
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 authenticated-user transition runs, switching the browser persistence layer to that user's local scope before the app reloads rooms and user state.
## User bar

View File

@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
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',
@@ -70,9 +69,7 @@ export class LoginComponent {
joinedAt: Date.now()
};
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.store.dispatch(UsersActions.authenticateUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {

View File

@@ -15,7 +15,6 @@ import { AuthenticationService } from '../../application/services/authentication
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',
@@ -72,9 +71,7 @@ export class RegisterComponent {
joinedAt: Date.now()
};
try { localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, resp.id); } catch {}
this.store.dispatch(UsersActions.setCurrentUser({ user }));
this.store.dispatch(UsersActions.authenticateUser({ user }));
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
if (returnUrl?.startsWith('/')) {

View File

@@ -1,8 +1,5 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Injectable,
computed,
effect,
inject,
signal
} from '@angular/core';
@@ -13,7 +10,11 @@ import {
throwError
} from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { ServerDirectoryFacade } from '../../../server-directory';
import {
ServerDirectoryFacade,
type RoomSignalSourceInput,
type ServerSourceSelector
} from '../../../server-directory';
export interface KlipyGif {
id: string;
@@ -37,51 +38,47 @@ export interface KlipyGifSearchResponse {
const DEFAULT_PAGE_SIZE = 24;
const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id';
const DEFAULT_AVAILABILITY_KEY = 'default';
interface KlipyAvailabilityState {
enabled: boolean;
loading: boolean;
}
@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 = '';
private readonly availabilityByKey = signal<Record<string, KlipyAvailabilityState>>({});
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();
});
isEnabled(source?: RoomSignalSourceInput | null): boolean {
return this.getAvailabilityState(source).enabled;
}
async refreshAvailability(): Promise<void> {
this.availabilityState.set({ enabled: false,
isLoading(source?: RoomSignalSourceInput | null): boolean {
return this.getAvailabilityState(source).loading;
}
async refreshAvailability(source?: RoomSignalSourceInput | null): Promise<void> {
const selector = this.getSourceSelector(source);
const key = this.getAvailabilityKey(selector);
this.setAvailabilityState(key, { enabled: false,
loading: true });
try {
const response = await firstValueFrom(
this.http.get<KlipyAvailabilityResponse>(
`${this.serverDirectory.getApiBaseUrl()}/klipy/config`
`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/config`
)
);
this.availabilityState.set({
this.setAvailabilityState(key, {
enabled: response.enabled === true,
loading: false
});
} catch {
this.availabilityState.set({ enabled: false,
this.setAvailabilityState(key, { enabled: false,
loading: false });
}
}
@@ -89,8 +86,11 @@ export class KlipyService {
searchGifs(
query: string,
page = 1,
perPage = DEFAULT_PAGE_SIZE
perPage = DEFAULT_PAGE_SIZE,
source?: RoomSignalSourceInput | null
): Observable<KlipyGifSearchResponse> {
const selector = this.getSourceSelector(source);
let params = new HttpParams()
.set('page', String(Math.max(1, Math.floor(page))))
.set('per_page', String(Math.max(1, Math.floor(perPage))))
@@ -109,7 +109,7 @@ export class KlipyService {
}
return this.http
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl()}/klipy/gifs`, { params })
.get<KlipyGifSearchResponse>(`${this.serverDirectory.getApiBaseUrl(selector)}/klipy/gifs`, { params })
.pipe(
map((response) => ({
enabled: response.enabled !== false,
@@ -138,7 +138,7 @@ export class KlipyService {
return this.normalizeMediaUrl(url);
}
buildImageProxyUrl(url: string): string {
buildImageProxyUrl(url: string, source?: RoomSignalSourceInput | null): string {
const trimmed = this.normalizeMediaUrl(url);
if (!trimmed)
@@ -147,7 +147,36 @@ export class KlipyService {
if (!/^https?:\/\//i.test(trimmed))
return trimmed;
return `${this.serverDirectory.getApiBaseUrl()}/image-proxy?url=${encodeURIComponent(trimmed)}`;
return `${this.serverDirectory.getApiBaseUrl(this.getSourceSelector(source))}/image-proxy?url=${encodeURIComponent(trimmed)}`;
}
private getAvailabilityState(source?: RoomSignalSourceInput | null): KlipyAvailabilityState {
return this.availabilityByKey()[this.getAvailabilityKey(this.getSourceSelector(source))]
?? { enabled: false,
loading: true };
}
private setAvailabilityState(key: string, state: KlipyAvailabilityState): void {
this.availabilityByKey.update((availabilityByKey) => ({
...availabilityByKey,
[key]: state
}));
}
private getSourceSelector(source?: RoomSignalSourceInput | null): ServerSourceSelector | undefined {
return this.serverDirectory.buildRoomSignalSelector(source ?? undefined);
}
private getAvailabilityKey(selector?: ServerSourceSelector): string {
if (selector?.sourceId) {
return `id:${selector.sourceId}`;
}
if (selector?.sourceUrl) {
return `url:${selector.sourceUrl}`;
}
return DEFAULT_AVAILABILITY_KEY;
}
private getPreferredLocale(): string | null {

View File

@@ -8,6 +8,7 @@ import {
signal
} from '@angular/core';
import { KlipyService } from '../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../server-directory';
@Directive({
selector: 'img[appChatImageProxyFallback]',
@@ -15,6 +16,7 @@ import { KlipyService } from '../application/services/klipy.service';
})
export class ChatImageProxyFallbackDirective {
readonly sourceUrl = input('', { alias: 'appChatImageProxyFallback' });
readonly signalSource = input<RoomSignalSourceInput | null>(null);
private readonly klipy = inject(KlipyService);
private readonly renderedSource = signal('');
@@ -38,7 +40,7 @@ export class ChatImageProxyFallbackDirective {
return;
}
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl());
const proxyUrl = this.klipy.buildImageProxyUrl(this.sourceUrl(), this.signalSource());
if (!proxyUrl || proxyUrl === this.renderedSource()) {
return;

View File

@@ -23,6 +23,8 @@
<app-chat-message-composer
[replyTo]="replyTo()"
[showKlipyGifPicker]="showKlipyGifPicker()"
[klipyEnabled]="klipyEnabled()"
[klipySignalSource]="currentRoom()"
(messageSubmitted)="handleMessageSubmitted($event)"
(typingStarted)="handleTypingStarted()"
(replyCleared)="clearReply()"
@@ -50,6 +52,7 @@
[style.right.px]="klipyGifPickerAnchorRight()"
>
<app-klipy-gif-picker
[signalSource]="currentRoom()"
(gifSelected)="handleKlipyGifSelected($event)"
(closed)="closeKlipyGifPicker()"
/>

View File

@@ -4,6 +4,7 @@ import {
HostListener,
ViewChild,
computed,
effect,
inject,
signal
} from '@angular/core';
@@ -11,7 +12,7 @@ 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/services/klipy.service';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import {
selectAllMessages,
@@ -54,10 +55,11 @@ export class ChatMessagesComponent {
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly attachmentsSvc = inject(AttachmentFacade);
private readonly klipy = inject(KlipyService);
readonly allMessages = this.store.selectSignal(selectAllMessages);
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
readonly loading = this.store.selectSignal(selectMessagesLoading);
readonly syncing = this.store.selectSignal(selectMessagesSyncing);
@@ -78,6 +80,7 @@ export class ChatMessagesComponent {
readonly conversationKey = computed(
() => `${this.currentRoom()?.id ?? 'no-room'}:${this.activeChannelId() ?? 'general'}`
);
readonly klipyEnabled = computed(() => this.klipy.isEnabled(this.currentRoom()));
readonly composerBottomPadding = signal(140);
readonly klipyGifPickerAnchorRight = signal(16);
readonly replyTo = signal<Message | null>(null);
@@ -85,6 +88,12 @@ export class ChatMessagesComponent {
readonly lightboxAttachment = signal<Attachment | null>(null);
readonly imageContextMenu = signal<ChatMessageImageContextMenuEvent | null>(null);
constructor() {
effect(() => {
void this.klipy.refreshAvailability(this.currentRoom());
});
}
@HostListener('window:resize')
onWindowResize(): void {
if (this.showKlipyGifPicker()) {

View File

@@ -133,7 +133,7 @@
(drop)="onDrop($event)"
>
<div class="absolute bottom-3 right-3 z-10 flex items-center gap-2 m-0.5">
@if (klipy.isEnabled()) {
@if (klipyEnabled()) {
<button
#klipyTrigger
type="button"
@@ -189,8 +189,8 @@
[class.border-primary]="dragActive()"
[class.chat-textarea-expanded]="textareaExpanded()"
[class.ctrl-resize]="ctrlHeld()"
[class.pr-16]="!klipy.isEnabled()"
[class.pr-40]="klipy.isEnabled()"
[class.pr-16]="!klipyEnabled()"
[class.pr-40]="klipyEnabled()"
></textarea>
@if (dragActive()) {
@@ -207,6 +207,7 @@
<div class="relative h-12 w-12 overflow-hidden rounded-lg bg-secondary/80">
<img
[appChatImageProxyFallback]="pendingKlipyGif()!.previewUrl || pendingKlipyGif()!.url"
[signalSource]="klipySignalSource()"
[alt]="pendingKlipyGif()!.title || 'KLIPY GIF'"
class="h-full w-full object-cover"
loading="lazy"

View File

@@ -23,6 +23,7 @@ import type { ClipboardFilePayload } from '../../../../../../core/platform/elect
import { ElectronBridgeService } from '../../../../../../core/platform/electron/electron-bridge.service';
import { KlipyGif, KlipyService } from '../../../../application/services/klipy.service';
import { Message } from '../../../../../../shared-kernel';
import type { RoomSignalSourceInput } from '../../../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../../../chat-image-proxy-fallback.directive';
import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component';
import { ChatMarkdownService } from '../../services/chat-markdown.service';
@@ -66,6 +67,8 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
readonly replyTo = input<Message | null>(null);
readonly showKlipyGifPicker = input(false);
readonly klipyEnabled = input(false);
readonly klipySignalSource = input<RoomSignalSourceInput | null>(null);
readonly messageSubmitted = output<ChatMessageComposerSubmitEvent>();
readonly typingStarted = output();
@@ -73,7 +76,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
readonly heightChanged = output<number>();
readonly klipyGifPickerToggleRequested = output();
readonly klipy = inject(KlipyService);
private readonly klipy = inject(KlipyService);
private readonly markdown = inject(ChatMarkdownService);
private readonly electronBridge = inject(ElectronBridgeService);
@@ -207,7 +210,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy {
}
toggleKlipyGifPicker(): void {
if (!this.klipy.isEnabled())
if (!this.klipyEnabled())
return;
this.klipyGifPickerToggleRequested.emit();

View File

@@ -93,6 +93,7 @@
>
<img
[appChatImageProxyFallback]="gif.previewUrl || gif.url"
[signalSource]="signalSource()"
[alt]="gif.title || 'KLIPY GIF'"
class="h-full w-full object-contain p-1.5 transition-transform duration-200 group-hover:scale-[1.03]"
loading="lazy"

View File

@@ -8,6 +8,7 @@ import {
OnInit,
ViewChild,
inject,
input,
output,
signal
} from '@angular/core';
@@ -21,6 +22,7 @@ import {
lucideX
} from '@ng-icons/lucide';
import { KlipyGif, KlipyService } from '../../application/services/klipy.service';
import type { RoomSignalSourceInput } from '../../../server-directory';
import { ChatImageProxyFallbackDirective } from '../chat-image-proxy-fallback.directive';
const KLIPY_CARD_MIN_WIDTH = 140;
@@ -48,6 +50,8 @@ const KLIPY_CARD_FALLBACK_SIZE = 160;
templateUrl: './klipy-gif-picker.component.html'
})
export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy {
readonly signalSource = input<RoomSignalSourceInput | null>(null);
readonly gifSelected = output<KlipyGif>();
readonly closed = output<undefined>();
@@ -128,7 +132,7 @@ export class KlipyGifPickerComponent implements OnInit, AfterViewInit, OnDestroy
try {
const response = await firstValueFrom(
this.klipy.searchGifs(this.searchQuery, this.currentPage)
this.klipy.searchGifs(this.searchQuery, this.currentPage, undefined, this.signalSource())
);
if (requestId !== this.requestId)

View File

@@ -90,7 +90,8 @@ All effects in this domain are `dispatch: false`. The effect layer never owns no
| `hydrateUnreadCounts$` | `loadRoomsSuccess` and `loadCurrentUserSuccess` | Rebuilds unread counts from persisted messages once both rooms and user identity exist |
| `markVisibleChannelRead$` | Room activation and channel selection | Clears unread for the current visible channel |
| `handleIncomingMessage$` | `MessagesActions.receiveMessage` | Updates unread counts and triggers desktop delivery for live messages |
| `refreshCurrentRoomUnread$` | `loadMessagesSuccess` and `syncMessages` | Recomputes unread counts for the active room from the latest message snapshot |
| `refreshViewedRoomUnread$` | `loadMessagesSuccess` | Recomputes unread counts for the active room from the latest viewed-room snapshot |
| `refreshSyncedRoomUnread$` | `syncMessages` | Recomputes unread counts for every room represented in a sync batch, including background servers |
## Incoming message flow

View File

@@ -0,0 +1,34 @@
import { type Message } from '../../../../shared-kernel';
import { groupMessagesByRoom } from './notifications.effects';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'message-1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'User 1',
content: 'hello',
timestamp: 1,
reactions: [],
isDeleted: false,
...overrides
};
}
describe('groupMessagesByRoom', () => {
it('groups sync batches by room id', () => {
const grouped = groupMessagesByRoom([
createMessage({ id: 'a1', roomId: 'room-a' }),
createMessage({ id: 'b1', roomId: 'room-b' }),
createMessage({ id: 'a2', roomId: 'room-a' })
]);
expect(Array.from(grouped.keys())).toEqual(['room-a', 'room-b']);
expect(grouped.get('room-a')?.map((message) => message.id)).toEqual(['a1', 'a2']);
expect(grouped.get('room-b')?.map((message) => message.id)).toEqual(['b1']);
});
it('returns empty map for empty sync batch', () => {
expect(groupMessagesByRoom([]).size).toBe(0);
});
});

View File

@@ -11,6 +11,7 @@ import {
tap,
withLatestFrom
} from 'rxjs/operators';
import type { Message } from '../../../../shared-kernel';
import { MessagesActions } from '../../../../store/messages/messages.actions';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
@@ -18,6 +19,23 @@ import { UsersActions } from '../../../../store/users/users.actions';
import { selectCurrentUser } from '../../../../store/users/users.selectors';
import { NotificationsFacade } from '../facades/notifications.facade';
export function groupMessagesByRoom(messages: Message[]): Map<string, Message[]> {
const messagesByRoom = new Map<string, Message[]>();
for (const message of messages) {
const roomMessages = messagesByRoom.get(message.roomId);
if (roomMessages) {
roomMessages.push(message);
continue;
}
messagesByRoom.set(message.roomId, [message]);
}
return messagesByRoom;
}
@Injectable()
export class NotificationsEffects {
private readonly actions$ = inject(Actions);
@@ -92,10 +110,10 @@ export class NotificationsEffects {
{ dispatch: false }
);
refreshCurrentRoomUnread$ = createEffect(
refreshViewedRoomUnread$ = createEffect(
() =>
this.actions$.pipe(
ofType(MessagesActions.loadMessagesSuccess, MessagesActions.syncMessages),
ofType(MessagesActions.loadMessagesSuccess),
withLatestFrom(this.store.select(selectCurrentRoom)),
tap(([{ messages }, room]) => {
if (room) {
@@ -105,4 +123,17 @@ export class NotificationsEffects {
),
{ dispatch: false }
);
refreshSyncedRoomUnread$ = createEffect(
() =>
this.actions$.pipe(
ofType(MessagesActions.syncMessages),
tap(({ messages }) => {
for (const [roomId, roomMessages] of groupMessagesByRoom(messages)) {
this.notifications.refreshRoomUnreadFromMessages(roomId, roomMessages);
}
})
)
, { dispatch: false }
);
}

View File

@@ -79,7 +79,7 @@ graph TD
## 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.
On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active. Configured default endpoints are treated as active by default unless the user explicitly disabled or removed them.
```mermaid
stateDiagram-v2
@@ -167,6 +167,7 @@ Default servers are configured in the environment file. The state service builds
- 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)
- Default endpoints stay active by default unless the user explicitly disabled them (tracked separately from the endpoint payload)
- `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

View File

@@ -0,0 +1,177 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import type { ServerEndpoint } from '../../domain/models/server-directory.model';
import * as serverDirectoryStorageKeys from '../../infrastructure/constants/server-directory.infrastructure.constants';
import { ServerEndpointStorageService } from '../../infrastructure/services/server-endpoint-storage.service';
import { ServerEndpointStateService } from './server-endpoint-state.service';
function createLocalStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length(): number {
return store.size;
},
clear(): void {
store.clear();
},
getItem(key: string): string | null {
return store.get(key) ?? null;
},
key(index: number): string | null {
return [...store.keys()][index] ?? null;
},
removeItem(key: string): void {
store.delete(key);
},
setItem(key: string, value: string): void {
store.set(key, value);
}
};
}
Object.defineProperty(globalThis, 'localStorage', {
value: createLocalStorageMock(),
configurable: true
});
function getConfiguredDefaultServer(key: string): { key?: string; name?: string; url?: string } {
const defaultServer = environment.defaultServers.find((server) => server.key === key);
if (!defaultServer) {
throw new Error(`Missing configured default server for key: ${key}`);
}
return defaultServer;
}
function seedStoredEndpoints(endpoints: ServerEndpoint[]): void {
localStorage.setItem(serverDirectoryStorageKeys.SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
}
function createService(): ServerEndpointStateService {
const injector = Injector.create({
providers: [
{
provide: ServerEndpointStorageService,
useClass: ServerEndpointStorageService,
deps: []
}
]
});
return runInInjectionContext(injector, () => new ServerEndpointStateService());
}
function getRequiredDefaultEndpoint(service: ServerEndpointStateService, defaultKey: string | undefined): ServerEndpoint {
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultKey);
if (!endpoint) {
throw new Error(`Expected default endpoint for key: ${defaultKey ?? 'unknown'}`);
}
return endpoint;
}
describe('ServerEndpointStateService', () => {
beforeEach(() => {
localStorage.clear();
});
it('reactivates configured default endpoints unless the user disabled them', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
seedStoredEndpoints([
{
id: 'default-server',
name: 'Stored Default',
url: defaultServer.url ?? '',
isActive: false,
isDefault: true,
defaultKey: defaultServer.key,
status: 'unknown'
}
]);
const service = createService();
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
expect(endpoint?.isActive).toBe(true);
});
it('keeps a configured default endpoint inactive after the user turned it off', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
seedStoredEndpoints([
{
id: 'default-server',
name: 'Stored Default',
url: defaultServer.url ?? '',
isActive: true,
isDefault: true,
defaultKey: defaultServer.key,
status: 'unknown'
}
]);
localStorage.setItem(
serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
JSON.stringify([defaultServer.key])
);
const service = createService();
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
expect(endpoint?.isActive).toBe(false);
});
it('keeps configured default endpoints active even when stored as incompatible unless the user disabled them', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
seedStoredEndpoints([
{
id: 'default-server',
name: 'Stored Default',
url: defaultServer.url ?? '',
isActive: false,
isDefault: true,
defaultKey: defaultServer.key,
status: 'incompatible'
}
]);
const service = createService();
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
expect(endpoint?.isActive).toBe(true);
expect(endpoint?.status).toBe('incompatible');
});
it('does not deactivate configured default endpoints when compatibility checks fail', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
const service = createService();
const endpoint = getRequiredDefaultEndpoint(service, defaultServer.key);
service.updateServerStatus(endpoint.id, 'incompatible');
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true);
});
it('persists turning a configured default endpoint off and back on', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
const service = createService();
const endpoint = getRequiredDefaultEndpoint(service, defaultServer.key);
service.deactivateServer(endpoint.id);
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(false);
expect(JSON.parse(
localStorage.getItem(serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY) ?? '[]'
)).toContain(defaultServer.key);
service.setActiveServer(endpoint.id);
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true);
expect(localStorage.getItem(serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -145,6 +145,7 @@ export class ServerEndpointStateService {
if (target.isDefault) {
this.markDefaultEndpointRemoved(target);
this.clearDefaultEndpointDisabled(target);
}
const updatedEndpoints = ensureAnyActiveEndpoint(
@@ -171,6 +172,7 @@ export class ServerEndpointStateService {
this._servers.update((endpoints) => ensureAnyActiveEndpoint([...endpoints, ...restoredEndpoints]));
this.storage.clearRemovedDefaultEndpointKeys();
this.clearDisabledDefaultEndpointKeys(restoredEndpoints);
this.saveEndpoints();
return restoredEndpoints;
}
@@ -190,6 +192,12 @@ export class ServerEndpointStateService {
);
});
const target = this._servers().find((endpoint) => endpoint.id === endpointId);
if (target?.isDefault) {
this.clearDefaultEndpointDisabled(target);
}
this.saveEndpoints();
}
@@ -206,6 +214,12 @@ export class ServerEndpointStateService {
)
);
const target = this._servers().find((endpoint) => endpoint.id === endpointId);
if (target?.isDefault) {
this.markDefaultEndpointDisabled(target);
}
this.saveEndpoints();
}
@@ -225,7 +239,7 @@ export class ServerEndpointStateService {
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
status,
latency,
isActive: status === 'incompatible' ? false : endpoint.isActive,
isActive: status === 'incompatible' && !endpoint.isDefault ? false : endpoint.isActive,
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
};
@@ -258,6 +272,7 @@ export class ServerEndpointStateService {
private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] {
const reconciled: ServerEndpoint[] = [];
const claimedDefaultKeys = new Set<string>();
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
for (const endpoint of storedEndpoints) {
@@ -279,6 +294,7 @@ export class ServerEndpointStateService {
...endpoint,
name: matchedDefault.name,
url: matchedDefault.url,
isActive: this.isDefaultEndpointActive(matchedDefault.defaultKey, disabledDefaultKeys),
isDefault: true,
defaultKey: matchedDefault.defaultKey,
status: endpoint.status ?? 'unknown'
@@ -303,7 +319,7 @@ export class ServerEndpointStateService {
reconciled.push({
...defaultEndpoint,
id: uuidv4(),
isActive: defaultEndpoint.isActive
isActive: this.isDefaultEndpointActive(defaultEndpoint.defaultKey, disabledDefaultKeys)
});
}
}
@@ -324,6 +340,64 @@ export class ServerEndpointStateService {
this.storage.saveRemovedDefaultEndpointKeys(removedDefaultKeys);
}
private markDefaultEndpointDisabled(endpoint: ServerEndpoint): void {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
return;
}
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
disabledDefaultKeys.add(defaultKey);
this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys);
}
private clearDefaultEndpointDisabled(endpoint: ServerEndpoint): void {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
return;
}
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
if (!disabledDefaultKeys.delete(defaultKey)) {
return;
}
this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys);
}
private clearDisabledDefaultEndpointKeys(endpoints: ServerEndpoint[]): void {
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
let didChange = false;
for (const endpoint of endpoints) {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
continue;
}
didChange = disabledDefaultKeys.delete(defaultKey) || didChange;
}
if (!didChange) {
return;
}
this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys);
}
private isDefaultEndpointActive(
defaultKey: string,
disabledDefaultKeys: Set<string>
): boolean {
return !disabledDefaultKeys.has(defaultKey);
}
private saveEndpoints(): void {
this.storage.saveEndpoints(this._servers());
}

View File

@@ -1,3 +1,4 @@
export const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
export const REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
export const DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_disabled_default_server_keys';
export const SERVER_HEALTH_CHECK_TIMEOUT_MS = 5000;

View File

@@ -1,5 +1,9 @@
import { Injectable } from '@angular/core';
import { REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, SERVER_ENDPOINTS_STORAGE_KEY } from '../constants/server-directory.infrastructure.constants';
import {
DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
SERVER_ENDPOINTS_STORAGE_KEY
} from '../constants/server-directory.infrastructure.constants';
import type { ServerEndpoint } from '../../domain/models/server-directory.model';
@Injectable({ providedIn: 'root' })
@@ -26,8 +30,32 @@ export class ServerEndpointStorageService {
localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
}
loadDisabledDefaultEndpointKeys(): Set<string> {
return this.loadStringSet(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
saveDisabledDefaultEndpointKeys(keys: Set<string>): void {
this.saveStringSet(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY, keys);
}
clearDisabledDefaultEndpointKeys(): void {
localStorage.removeItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
loadRemovedDefaultEndpointKeys(): Set<string> {
const stored = localStorage.getItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
return this.loadStringSet(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
saveRemovedDefaultEndpointKeys(keys: Set<string>): void {
this.saveStringSet(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, keys);
}
clearRemovedDefaultEndpointKeys(): void {
localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
private loadStringSet(storageKey: string): Set<string> {
const stored = localStorage.getItem(storageKey);
if (!stored) {
return new Set<string>();
@@ -46,16 +74,12 @@ export class ServerEndpointStorageService {
}
}
saveRemovedDefaultEndpointKeys(keys: Set<string>): void {
private saveStringSet(storageKey: string, keys: Set<string>): void {
if (keys.size === 0) {
this.clearRemovedDefaultEndpointKeys();
localStorage.removeItem(storageKey);
return;
}
localStorage.setItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([...keys]));
}
clearRemovedDefaultEndpointKeys(): void {
localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
localStorage.setItem(storageKey, JSON.stringify([...keys]));
}
}