fix: multiple bug fixes
isolated users, db backup, weird disconnect issues for long voice sessions,
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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('/')) {
|
||||
|
||||
@@ -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('/')) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()"
|
||||
/>
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user