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

@@ -44,7 +44,11 @@ import { NativeContextMenuComponent } from './features/shell/native-context-menu
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 } from './core/constants';
import { ROOM_URL_PATTERN } from './core/constants';
import {
clearStoredCurrentUserId,
getStoredCurrentUserId
} from './core/storage/current-user-storage';
import {
ThemeNodeDirective,
ThemePickerOverlayComponent,
@@ -219,8 +223,19 @@ export class App implements OnInit, OnDestroy {
void this.desktopUpdates.initialize();
let currentUserId = getStoredCurrentUserId();
await this.databaseService.initialize();
if (currentUserId) {
const persistedUserId = await this.databaseService.getCurrentUserId();
if (persistedUserId !== currentUserId) {
clearStoredCurrentUserId();
currentUserId = null;
}
}
try {
const apiBase = this.servers.getApiBaseUrl();
@@ -231,31 +246,28 @@ export class App implements OnInit, OnDestroy {
await this.setupDesktopDeepLinks();
this.store.dispatch(UsersActions.loadCurrentUser());
this.userStatus.start();
this.store.dispatch(RoomsActions.loadRooms());
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);
const currentUrl = this.getCurrentRouteUrl();
if (!currentUserId) {
if (!this.isPublicRoute(this.router.url)) {
if (!this.isPublicRoute(currentUrl)) {
this.router.navigate(['/login'], {
queryParams: {
returnUrl: this.router.url
returnUrl: currentUrl
}
}).catch(() => {});
}
} else {
const current = this.router.url;
this.store.dispatch(UsersActions.loadCurrentUser());
this.store.dispatch(RoomsActions.loadRooms());
const generalSettings = loadGeneralSettingsFromStorage();
const lastViewedChat = loadLastViewedChatFromStorage(currentUserId);
if (
generalSettings.reopenLastViewedChat
&& lastViewedChat
&& (current === '/' || current === '/search')
&& (currentUrl === '/' || currentUrl === '/search')
) {
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
}
@@ -388,9 +400,31 @@ export class App implements OnInit, OnDestroy {
}
private isPublicRoute(url: string): boolean {
return url === '/login' ||
url === '/register' ||
url.startsWith('/invite/');
const path = this.getRoutePath(url);
return path === '/login' ||
path === '/register' ||
path.startsWith('/invite/');
}
private getCurrentRouteUrl(): string {
if (typeof window === 'undefined') {
return this.router.url;
}
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
return currentUrl || this.router.url;
}
private getRoutePath(url: string): string {
if (!url) {
return '/';
}
const [path] = url.split(/[?#]/, 1);
return path || '/';
}
private parseDesktopInviteUrl(url: string): { inviteId: string; sourceUrl: string } | null {

View File

@@ -0,0 +1,59 @@
import { STORAGE_KEY_CURRENT_USER_ID } from '../constants';
const METOYOU_STORAGE_PREFIX = 'metoyou_';
function normaliseStorageUserId(userId?: string | null): string | null {
const trimmedUserId = userId?.trim();
return trimmedUserId || null;
}
export function getStoredCurrentUserId(): string | null {
try {
const raw = normaliseStorageUserId(localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID));
return raw || null;
} catch {
return null;
}
}
export function getUserScopedStorageKey(baseKey: string, userId?: string | null): string {
const scopedUserId = userId === undefined
? getStoredCurrentUserId()
: normaliseStorageUserId(userId);
return scopedUserId
? `${baseKey}__${encodeURIComponent(scopedUserId)}`
: baseKey;
}
export function setStoredCurrentUserId(userId: string): void {
try {
localStorage.setItem(STORAGE_KEY_CURRENT_USER_ID, userId);
} catch {}
}
export function clearStoredCurrentUserId(): void {
try {
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
} catch {}
}
export function clearStoredLocalAppData(): void {
try {
const keysToRemove: string[] = [];
for (let index = 0; index < localStorage.length; index += 1) {
const key = localStorage.key(index);
if (key?.startsWith(METOYOU_STORAGE_PREFIX)) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
} catch {}
}

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]));
}
}

View File

@@ -27,13 +27,15 @@ import {
selectIsSignalServerReconnecting,
selectSignalServerCompatibilityError
} from '../../../store/rooms/rooms.selectors';
import { MessagesActions } from '../../../store/messages/messages.actions';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../store/users/users.actions';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { ServerDirectoryFacade } from '../../../domains/server-directory';
import { PlatformService } from '../../../core/platform';
import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants';
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
import { LeaveServerDialogComponent } from '../../../shared';
import { Room } from '../../../shared-kernel';
import { VoiceWorkspaceService } from '../../../domains/voice-session';
@@ -256,9 +258,10 @@ export class TitleBarComponent {
// servers the user was a member of, so other users see them go offline.
this.webrtc.disconnect();
try {
localStorage.removeItem(STORAGE_KEY_CURRENT_USER_ID);
} catch {}
clearStoredCurrentUserId();
this.store.dispatch(MessagesActions.clearMessages());
this.store.dispatch(RoomsActions.resetRoomsState());
this.store.dispatch(UsersActions.resetUsersState());
this.router.navigate(['/login']);
}

View File

@@ -2,6 +2,8 @@
Offline-first storage layer that keeps messages, users, rooms, reactions, bans, and attachments on the client. The rest of the app only ever talks to `DatabaseService`, which picks the right backend for the current platform at runtime.
Persisted data is treated as belonging to the authenticated user that created it. In the browser runtime, IndexedDB is user-scoped: the renderer opens a per-user database for the active account and switches scopes during authentication so one account never boots into another account's stored rooms, messages, or settings.
## Files
```
@@ -13,7 +15,7 @@ persistence/
└── electron-database.service.ts IPC/SQLite backend (desktop)
```
`app-resume.storage.ts` is the one exception to the `DatabaseService` facade. It stores lightweight UI-level launch preferences and the last viewed room/channel snapshot in `localStorage`, which would be unnecessary overhead to route through IndexedDB or SQLite.
`app-resume.storage.ts` is the one exception to the `DatabaseService` facade. It stores lightweight UI-level launch preferences and the last viewed room/channel snapshot in `localStorage`, which would be unnecessary overhead to route through IndexedDB or SQLite. Those values use user-scoped storage keys so each account restores its own resume state instead of overwriting another user's snapshot.
## Platform routing
@@ -55,7 +57,7 @@ The persisted `rooms` store is a local cache of room metadata. Channel topology
### Browser (IndexedDB)
All operations run inside IndexedDB transactions in the renderer thread. Queries like `getMessages` pull all messages for a room via the `roomId` index, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string).
All operations run inside IndexedDB transactions in the renderer thread. The browser backend resolves the active database name from the logged-in user, reusing a legacy shared database only when it already belongs to that same account. Queries like `getMessages` pull all messages for a room via the `roomId` index, sort them by timestamp in JS, then apply limit/offset. Deleted messages are normalised on read (content replaced with a sentinel string).
```mermaid
sequenceDiagram

View File

@@ -1,4 +1,5 @@
import { STORAGE_KEY_GENERAL_SETTINGS, STORAGE_KEY_LAST_VIEWED_CHAT } from '../../core/constants';
import { getUserScopedStorageKey } from '../../core/storage/current-user-storage';
export interface GeneralSettings {
reopenLastViewedChat: boolean;
@@ -16,7 +17,8 @@ export interface LastViewedChatSnapshot {
export function loadGeneralSettingsFromStorage(): GeneralSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS);
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS))
?? localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS);
if (!raw) {
return { ...DEFAULT_GENERAL_SETTINGS };
@@ -35,7 +37,7 @@ export function saveGeneralSettingsToStorage(patch: Partial<GeneralSettings>): G
});
try {
localStorage.setItem(STORAGE_KEY_GENERAL_SETTINGS, JSON.stringify(nextSettings));
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_GENERAL_SETTINGS), JSON.stringify(nextSettings));
} catch {}
return nextSettings;
@@ -43,7 +45,8 @@ export function saveGeneralSettingsToStorage(patch: Partial<GeneralSettings>): G
export function loadLastViewedChatFromStorage(userId?: string | null): LastViewedChatSnapshot | null {
try {
const raw = localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT);
const raw = localStorage.getItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId))
?? localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT);
if (!raw) {
return null;
@@ -73,12 +76,13 @@ export function saveLastViewedChatToStorage(snapshot: LastViewedChatSnapshot): v
}
try {
localStorage.setItem(STORAGE_KEY_LAST_VIEWED_CHAT, JSON.stringify(normalised));
localStorage.setItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, normalised.userId), JSON.stringify(normalised));
} catch {}
}
export function clearLastViewedChatFromStorage(): void {
export function clearLastViewedChatFromStorage(userId?: string | null): void {
try {
localStorage.removeItem(getUserScopedStorageKey(STORAGE_KEY_LAST_VIEWED_CHAT, userId));
localStorage.removeItem(STORAGE_KEY_LAST_VIEWED_CHAT);
} catch {}
}

View File

@@ -9,9 +9,11 @@ import {
BanEntry
} from '../../shared-kernel';
import type { ChatAttachmentMeta } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
/** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou';
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
/** IndexedDB schema version - bump when adding/changing object stores. */
const DATABASE_VERSION = 2;
/** Names of every object store used by the application. */
@@ -44,13 +46,18 @@ const ALL_STORE_NAMES: string[] = [
export class BrowserDatabaseService {
/** Handle to the opened IndexedDB database, or `null` before {@link initialize}. */
private database: IDBDatabase | null = null;
private activeDatabaseName: string | null = null;
/** Open (or create) the IndexedDB database. Safe to call multiple times. */
async initialize(): Promise<void> {
if (this.database)
const databaseName = await this.resolveDatabaseName();
if (this.database && this.activeDatabaseName === databaseName)
return;
this.database = await this.openDatabase();
this.closeDatabase();
this.database = await this.openDatabase(databaseName);
this.activeDatabaseName = databaseName;
}
/** Persist a single message. */
@@ -180,6 +187,15 @@ export class BrowserDatabaseService {
return this.getUser(meta.value);
}
/** Retrieve the persisted current user ID without loading the full user. */
async getCurrentUserId(): Promise<string | null> {
const meta = await this.get<{ id: string; value: string }>(
STORE_META, 'currentUserId'
);
return meta?.value?.trim() || null;
}
/** Store which user ID is considered "current" (logged-in). */
async setCurrentUserId(userId: string): Promise<void> {
await this.put(STORE_META, { id: 'currentUserId',
@@ -313,9 +329,66 @@ export class BrowserDatabaseService {
await this.awaitTransaction(transaction);
}
private openDatabase(): Promise<IDBDatabase> {
private async resolveDatabaseName(): Promise<string> {
const currentUserId = getStoredCurrentUserId();
const scopedDatabaseName = this.createScopedDatabaseName(currentUserId);
if (!currentUserId) {
return scopedDatabaseName;
}
if (await this.databaseExists(scopedDatabaseName)) {
return scopedDatabaseName;
}
const legacyCurrentUserId = await this.readCurrentUserIdFromDatabase(DATABASE_NAME);
return legacyCurrentUserId === currentUserId
? DATABASE_NAME
: scopedDatabaseName;
}
private createScopedDatabaseName(userId: string | null): string {
return `${DATABASE_NAME}::${encodeURIComponent(userId || ANONYMOUS_DATABASE_SCOPE)}`;
}
private async databaseExists(name: string): Promise<boolean> {
const hasDatabasesApi = typeof indexedDB.databases === 'function';
if (!hasDatabasesApi) {
return false;
}
const databases = await indexedDB.databases();
return databases.some((database) => database.name === name);
}
private async readCurrentUserIdFromDatabase(databaseName: string): Promise<string | null> {
if (!await this.databaseExists(databaseName)) {
return null;
}
const database = await this.openDatabase(databaseName);
try {
const transaction = database.transaction(STORE_META, 'readonly');
const request = transaction.objectStore(STORE_META).get('currentUserId');
return await new Promise<string | null>((resolve, reject) => {
request.onsuccess = () => resolve((request.result as { value?: string } | undefined)?.value?.trim() || null);
request.onerror = () => reject(request.error);
});
} catch {
return null;
} finally {
database.close();
}
}
private openDatabase(databaseName: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
const request = indexedDB.open(databaseName, DATABASE_VERSION);
request.onerror = () => reject(request.error);
request.onupgradeneeded = () => this.setupSchema(request.result);
@@ -323,6 +396,12 @@ export class BrowserDatabaseService {
});
}
private closeDatabase(): void {
this.database?.close();
this.database = null;
this.activeDatabaseName = null;
}
private setupSchema(database: IDBDatabase): void {
const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' });

View File

@@ -85,6 +85,9 @@ export class DatabaseService {
/** Retrieve the current (logged-in) user. */
getCurrentUser() { return this.backend.getCurrentUser(); }
/** Retrieve the persisted current user ID without loading the full user. */
getCurrentUserId() { return this.backend.getCurrentUserId(); }
/** Store the current user ID. */
setCurrentUserId(userId: string) { return this.backend.setCurrentUserId(userId); }

View File

@@ -101,6 +101,11 @@ export class ElectronDatabaseService {
return this.api.query<User | null>({ type: 'get-current-user', payload: {} });
}
/** Retrieve the persisted current user ID without loading the full user. */
getCurrentUserId(): Promise<string | null> {
return this.api.query<string | null>({ type: 'get-current-user-id', payload: {} });
}
/** Store which user ID is considered "current" (logged-in). */
setCurrentUserId(userId: string): Promise<void> {
return this.api.command({ type: 'set-current-user-id', payload: { userId } });

View File

@@ -121,6 +121,8 @@ Each signaling URL gets its own `SignalingManager` (one WebSocket each). `Signal
Room affinity is authoritative at this layer as well. The renderer repairs each room's saved `sourceId` / `sourceUrl` from server-directory responses and routes `join_server`, `view_server`, and room-scoped signaling traffic to that room's signaling URL first. If that route fails, alternate endpoints can be tried temporarily, but server-scoped raw messages are no longer broadcast to every connected signaling manager when the route is unknown.
In UI/debug conversations, a **chat-server** means one of the saved rooms navigated from the server rail. Each chat-server has its own assigned signal server via `sourceId` / `sourceUrl`, and room-scoped feature/config checks must prefer that signal server before considering any global active endpoint. For example, KLIPY GIF picker visibility is resolved against the currently viewed chat-server's signal server so an unrelated offline chat-server does not hide the button everywhere.
Cold-start routing now waits for the initial server-directory health probes so same-backend aliases can collapse to one canonical signaling endpoint before any saved rooms reconnect. When a room is reconnected on a chosen socket, its background rooms are re-joined on that same socket as well so stale per-signal memberships do not keep orphan managers alive, and reconnect replay only sends `view_server` for rooms that manager still has joined.
This is still a non-federated model. Different signaling servers do not share peer registries or relay WebRTC offers for each other, so users in the same room must converge on the same signaling endpoint to discover one another reliably.
@@ -152,6 +154,8 @@ sequenceDiagram
When the WebSocket drops, `SignalingManager` schedules reconnection with exponential backoff (1s, 2s, 4s, ... up to 30s). On reconnect it replays the cached `identify` and `join_server` messages so presence is restored without the UI doing anything.
The browser also sends a lightweight `keepalive` message on the signaling socket during long-lived sessions. The server treats both WebSocket pong frames and any inbound client message as liveness, so users who are still active in voice or chat are not removed from server presence just because control-frame pong delivery stalls behind a proxy or runtime quirk.
### Server-side connection hygiene
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). The server's `handleIdentify` now closes any existing connection that shares the same `oderId` but a different `connectionId`. This guarantees `findUserByOderId` always routes offers and presence events to the freshest socket, eliminating a class of bugs where signaling messages landed on a dead tab's socket and were silently lost.

View File

@@ -29,6 +29,8 @@ export const PEER_DISCONNECT_GRACE_MS = 10_000;
/** Interval (ms) for broadcasting state heartbeats */
export const STATE_HEARTBEAT_INTERVAL_MS = 5_000;
/** Interval (ms) for application-level signaling keepalive messages */
export const SIGNALING_KEEPALIVE_INTERVAL_MS = 25_000;
/** Interval (ms) for broadcasting voice presence */
export const VOICE_HEARTBEAT_INTERVAL_MS = 5_000;
@@ -85,6 +87,7 @@ export const SIGNALING_TYPE_SERVER_USERS = 'server_users';
export const SIGNALING_TYPE_USER_JOINED = 'user_joined';
export const SIGNALING_TYPE_USER_LEFT = 'user_left';
export const SIGNALING_TYPE_ACCESS_DENIED = 'access_denied';
export const SIGNALING_TYPE_KEEPALIVE = 'keepalive';
export const P2P_TYPE_STATE_REQUEST = 'state-request';
export const P2P_TYPE_VOICE_STATE_REQUEST = 'voice-state-request';

View File

@@ -17,8 +17,10 @@ import {
SIGNALING_RECONNECT_MAX_DELAY_MS,
SIGNALING_CONNECT_TIMEOUT_MS,
STATE_HEARTBEAT_INTERVAL_MS,
SIGNALING_KEEPALIVE_INTERVAL_MS,
SIGNALING_TYPE_IDENTIFY,
SIGNALING_TYPE_JOIN_SERVER,
SIGNALING_TYPE_KEEPALIVE,
SIGNALING_TYPE_VIEW_SERVER
} from '../realtime.constants';
@@ -39,6 +41,7 @@ export class SignalingManager {
private signalingReconnectAttempts = 0;
private signalingReconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateHeartbeatTimer: ReturnType<typeof setInterval> | null = null;
private lastKeepaliveSentAt = 0;
/** Fires every heartbeat tick - the main service hooks this to broadcast state. */
readonly heartbeatTick$ = new Subject<void>();
@@ -391,7 +394,11 @@ export class SignalingManager {
/** Start the heartbeat interval that drives periodic state broadcasts. */
private startHeartbeat(): void {
this.stopHeartbeat();
this.stateHeartbeatTimer = setInterval(() => this.heartbeatTick$.next(), STATE_HEARTBEAT_INTERVAL_MS);
this.lastKeepaliveSentAt = Date.now();
this.stateHeartbeatTimer = setInterval(() => {
this.heartbeatTick$.next();
this.sendKeepaliveIfDue();
}, STATE_HEARTBEAT_INTERVAL_MS);
}
/** Stop the heartbeat interval. */
@@ -400,6 +407,28 @@ export class SignalingManager {
clearInterval(this.stateHeartbeatTimer);
this.stateHeartbeatTimer = null;
}
this.lastKeepaliveSentAt = 0;
}
private sendKeepaliveIfDue(): void {
const now = Date.now();
if (now - this.lastKeepaliveSentAt < SIGNALING_KEEPALIVE_INTERVAL_MS) {
return;
}
this.lastKeepaliveSentAt = now;
try {
this.sendRawMessage({ type: SIGNALING_TYPE_KEEPALIVE });
} catch (error) {
this.logger.warn('[signaling] Failed to send signaling keepalive', {
error,
readyState: this.getSocketReadyStateLabel(),
url: this.lastSignalingUrl
});
}
}
/** Clean up all resources. */

View File

@@ -0,0 +1,104 @@
import {
defaultIfEmpty,
firstValueFrom
} from 'rxjs';
import { type Message } from '../../shared-kernel';
import { dispatchIncomingMessage } from './messages-incoming.handlers';
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
};
}
function createContext(overrides: Record<string, unknown> = {}) {
return {
db: {
getMessages: vi.fn()
},
webrtc: {
sendToPeer: vi.fn()
},
attachments: {},
debugging: {},
currentUser: null,
currentRoom: null,
...overrides
} as const;
}
describe('dispatchIncomingMessage room-scoped sync', () => {
it('requests sync for event room even when another room is viewed', async () => {
const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b'
? [createMessage({ roomId: 'room-b', timestamp: 10, editedAt: 10 })]
: [createMessage({ roomId: 'room-a', timestamp: 100, editedAt: 100 })]);
const sendToPeer = vi.fn();
const context = createContext({
db: { getMessages },
webrtc: { sendToPeer },
currentRoom: { id: 'room-a' }
});
await firstValueFrom(
dispatchIncomingMessage(
{
type: 'chat-sync-summary',
roomId: 'room-b',
fromPeerId: 'peer-1',
count: 2,
lastUpdated: 20
} as never,
context as never
).pipe(defaultIfEmpty(null))
);
expect(getMessages).toHaveBeenCalledWith('room-b', expect.any(Number), 0);
expect(sendToPeer).toHaveBeenCalledWith('peer-1', {
type: 'chat-sync-request',
roomId: 'room-b'
});
});
it('sends full sync for requested room even when another room is viewed', async () => {
const roomBMessages = [
createMessage({ id: 'message-b1', roomId: 'room-b', timestamp: 5 }),
createMessage({ id: 'message-b2', roomId: 'room-b', timestamp: 15 })
];
const getMessages = vi.fn(async (roomId: string) => roomId === 'room-b'
? roomBMessages
: [createMessage({ id: 'message-a1', roomId: 'room-a', timestamp: 200 })]);
const sendToPeer = vi.fn();
const context = createContext({
db: { getMessages },
webrtc: { sendToPeer },
currentRoom: { id: 'room-a' }
});
await firstValueFrom(
dispatchIncomingMessage(
{
type: 'chat-sync-request',
roomId: 'room-b',
fromPeerId: 'peer-2'
} as never,
context as never
).pipe(defaultIfEmpty(null))
);
expect(getMessages).toHaveBeenCalledWith('room-b', expect.any(Number), 0);
expect(sendToPeer).toHaveBeenCalledWith('peer-2', {
type: 'chat-sync-full',
roomId: 'room-b',
messages: roomBMessages
});
});
});

View File

@@ -538,12 +538,14 @@ function handleSyncSummary(
event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext
): Observable<Action> {
if (!currentRoom)
const targetRoomId = event.roomId || currentRoom?.id;
if (!targetRoomId)
return EMPTY;
return from(
(async () => {
const local = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
const local = await db.getMessages(targetRoomId, FULL_SYNC_LIMIT, 0);
const localCount = local.length;
const localLastUpdated = local.reduce(
(maxTimestamp, message) => Math.max(maxTimestamp, message.editedAt || message.timestamp || 0),
@@ -561,7 +563,7 @@ function handleSyncSummary(
if (!identical && needsSync && fromPeerId) {
const syncRequestEvent: ChatEvent = {
type: 'chat-sync-request',
roomId: currentRoom.id
roomId: targetRoomId
};
webrtc.sendToPeer(fromPeerId, syncRequestEvent);
@@ -575,17 +577,18 @@ function handleSyncRequest(
event: IncomingMessageEvent,
{ db, webrtc, currentRoom }: IncomingMessageContext
): Observable<Action> {
const targetRoomId = event.roomId || currentRoom?.id;
const fromPeerId = event.fromPeerId;
if (!currentRoom || !fromPeerId)
if (!targetRoomId || !fromPeerId)
return EMPTY;
return from(
(async () => {
const all = await db.getMessages(currentRoom.id, FULL_SYNC_LIMIT, 0);
const all = await db.getMessages(targetRoomId, FULL_SYNC_LIMIT, 0);
const syncFullEvent: ChatEvent = {
type: 'chat-sync-full',
roomId: currentRoom.id,
roomId: targetRoomId,
messages: all
};

View File

@@ -48,6 +48,7 @@ import {
buildKnownUserExtras,
isWrongServer,
resolveRoom,
reconcileRoomSnapshotChannels,
sanitizeRoomSnapshot,
normalizeIncomingBans,
getPersistedCurrentUserId
@@ -122,7 +123,8 @@ export class RoomStateSyncEffects {
const actions: Action[] = [
UsersActions.syncServerPresence({
roomId: signalingMessage.serverId,
users: syncedUsers
users: syncedUsers,
connectedPeerIds: this.webrtc.getConnectedPeers()
})
];
@@ -641,7 +643,10 @@ export class RoomStateSyncEffects {
if (!room || !incomingRoom)
return EMPTY;
const roomChanges = sanitizeRoomSnapshot(incomingRoom);
const roomChanges = {
...sanitizeRoomSnapshot(incomingRoom),
channels: reconcileRoomSnapshotChannels(room.channels, incomingRoom.channels)
};
const bans = normalizeIncomingBans(room.id, event.bans);
return this.syncBansToLocalRoom(room.id, bans).pipe(

View File

@@ -0,0 +1,45 @@
import {
reconcileRoomSnapshotChannels,
sanitizeRoomSnapshot
} from './rooms.helpers';
describe('room snapshot helpers', () => {
it('drops empty channel arrays from outgoing snapshots', () => {
expect(sanitizeRoomSnapshot({ channels: [] }).channels).toBeUndefined();
});
it('keeps cached channels when incoming snapshot has none', () => {
const cachedChannels = [
{ id: 'general', name: 'general', type: 'text', position: 0 },
{ id: 'updates', name: 'updates', type: 'text', position: 1 }
] as const;
expect(reconcileRoomSnapshotChannels(cachedChannels as never, undefined)).toEqual(cachedChannels);
expect(reconcileRoomSnapshotChannels(cachedChannels as never, [] as never)).toEqual(cachedChannels);
});
it('keeps richer cached channels when incoming snapshot is smaller', () => {
const cachedChannels = [
{ id: 'general', name: 'general', type: 'text', position: 0 },
{ id: 'updates', name: 'updates', type: 'text', position: 1 },
{ id: 'voice', name: 'General', type: 'voice', position: 0 }
] as const;
const incomingChannels = [
{ id: 'general', name: 'general', type: 'text', position: 0 }
] as const;
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(cachedChannels);
});
it('accepts incoming channels when snapshot is at least as complete', () => {
const cachedChannels = [
{ id: 'general', name: 'general', type: 'text', position: 0 }
] as const;
const incomingChannels = [
{ id: 'general', name: 'general', type: 'text', position: 0 },
{ id: 'updates', name: 'updates', type: 'text', position: 1 }
] as const;
expect(reconcileRoomSnapshotChannels(cachedChannels as never, incomingChannels as never)).toEqual(incomingChannels);
});
});

View File

@@ -57,6 +57,8 @@ export const RoomsActions = createActionGroup({
'Forget Room': props<{ roomId: string; nextOwnerKey?: string }>(),
'Forget Room Success': props<{ roomId: string }>(),
'Reset Rooms State': emptyProps(),
'Update Room Settings': props<{ roomId: string; settings: Partial<RoomSettings> }>(),
'Update Room Settings Success': props<{ roomId: string; settings: RoomSettings }>(),
'Update Room Settings Failure': props<{ error: string }>(),

View File

@@ -97,6 +97,31 @@ export function resolveRoomChannels(
return undefined;
}
/**
* Peer room-state snapshots can lag behind cached room metadata.
* Keep richer cached channels until an equally rich or richer snapshot arrives.
*/
export function reconcileRoomSnapshotChannels(
cachedChannels: Room['channels'] | undefined,
incomingChannels: Room['channels'] | undefined
): Room['channels'] | undefined {
if (hasPersistedChannels(cachedChannels) && !hasPersistedChannels(incomingChannels)) {
return cachedChannels;
}
if (hasPersistedChannels(cachedChannels) && hasPersistedChannels(incomingChannels)) {
return incomingChannels.length >= cachedChannels.length
? incomingChannels
: cachedChannels;
}
if (hasPersistedChannels(incomingChannels)) {
return incomingChannels;
}
return undefined;
}
export function resolveTextChannelId(
channels: Room['channels'] | undefined,
preferredChannelId?: string | null
@@ -136,7 +161,7 @@ export function sanitizeRoomSnapshot(room: Partial<Room>): Partial<Room> {
iconUpdatedAt: typeof room.iconUpdatedAt === 'number' ? room.iconUpdatedAt : undefined,
slowModeInterval: typeof room.slowModeInterval === 'number' ? room.slowModeInterval : undefined,
permissions: room.permissions ? { ...room.permissions } : undefined,
channels: Array.isArray(room.channels) ? room.channels : undefined,
channels: hasPersistedChannels(room.channels) ? room.channels : undefined,
members: Array.isArray(room.members) ? room.members : undefined,
roles: Array.isArray(room.roles) ? room.roles : undefined,
roleAssignments: Array.isArray(room.roleAssignments) ? room.roleAssignments : undefined,

View File

@@ -105,6 +105,10 @@ export const initialState: RoomsState = {
export const roomsReducer = createReducer(
initialState,
on(RoomsActions.resetRoomsState, () => ({
...initialState
})),
// Load rooms
on(RoomsActions.loadRooms, (state) => ({
...state,

View File

@@ -235,6 +235,71 @@ describe('users reducer - status', () => {
// The buildPresenceAwareUser function takes incoming status when non-offline
expect(state.entities['u1']?.status).toBe('online');
});
it('preserves omitted live peer presence and voice state during stale server snapshot', () => {
const remoteUser = createUser({
id: 'u2',
oderId: 'u2',
displayName: 'Voice Peer',
presenceServerIds: ['s1'],
voiceState: {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: true,
roomId: 'voice-1',
serverId: 's1'
},
cameraState: { isEnabled: true },
screenShareState: { isSharing: true }
});
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
const state = usersReducer(withUser, UsersActions.syncServerPresence({
roomId: 's1',
users: [],
connectedPeerIds: ['u2']
}));
expect(state.entities['u2']?.presenceServerIds).toEqual(['s1']);
expect(state.entities['u2']?.isOnline).toBe(true);
expect(state.entities['u2']?.voiceState?.isConnected).toBe(true);
expect(state.entities['u2']?.voiceState?.roomId).toBe('voice-1');
expect(state.entities['u2']?.cameraState?.isEnabled).toBe(true);
expect(state.entities['u2']?.screenShareState?.isSharing).toBe(true);
});
it('clears omitted peer live state when transport is gone', () => {
const remoteUser = createUser({
id: 'u3',
oderId: 'u3',
displayName: 'Dropped Peer',
presenceServerIds: ['s1'],
voiceState: {
isConnected: true,
isMuted: false,
isDeafened: false,
isSpeaking: true,
roomId: 'voice-1',
serverId: 's1'
},
cameraState: { isEnabled: true },
screenShareState: { isSharing: true }
});
const withUser = usersReducer(baseState, UsersActions.userJoined({ user: remoteUser }));
const state = usersReducer(withUser, UsersActions.syncServerPresence({
roomId: 's1',
users: [],
connectedPeerIds: []
}));
expect(state.entities['u3']?.presenceServerIds).toBeUndefined();
expect(state.entities['u3']?.isOnline).toBe(false);
expect(state.entities['u3']?.status).toBe('offline');
expect(state.entities['u3']?.voiceState?.isConnected).toBe(false);
expect(state.entities['u3']?.voiceState?.roomId).toBeUndefined();
expect(state.entities['u3']?.cameraState?.isEnabled).toBe(false);
expect(state.entities['u3']?.screenShareState?.isSharing).toBe(false);
});
});
describe('manual status overrides auto idle', () => {

View File

@@ -18,6 +18,7 @@ import {
export const UsersActions = createActionGroup({
source: 'Users',
events: {
'Authenticate User': props<{ user: User }>(),
'Load Current User': emptyProps(),
'Load Current User Success': props<{ user: User }>(),
'Load Current User Failure': props<{ error: string }>(),
@@ -31,7 +32,7 @@ export const UsersActions = createActionGroup({
'User Joined': props<{ user: User }>(),
'User Left': props<{ userId: string; serverId?: string; serverIds?: string[] }>(),
'Sync Server Presence': props<{ roomId: string; users: User[] }>(),
'Sync Server Presence': props<{ roomId: string; users: User[]; connectedPeerIds?: string[] }>(),
'Update User': props<{ userId: string; updates: Partial<User> }>(),
'Update User Role': props<{ userId: string; role: User['role'] }>(),
@@ -58,6 +59,7 @@ export const UsersActions = createActionGroup({
'Sync Users': props<{ users: User[] }>(),
'Clear Users': emptyProps(),
'Reset Users State': emptyProps(),
'Update Host': props<{ userId: string }>(),
'Update Voice State': props<{ userId: string; voiceState: Partial<VoiceState> }>(),

View File

@@ -23,6 +23,7 @@ import {
switchMap
} from 'rxjs/operators';
import { v4 as uuidv4 } from 'uuid';
import { MessagesActions } from '../messages/messages.actions';
import { UsersActions } from './users.actions';
import { RoomsActions } from '../rooms/rooms.actions';
import {
@@ -46,6 +47,9 @@ import {
Room,
User
} from '../../shared-kernel';
import {
setStoredCurrentUserId
} from '../../core/storage/current-user-storage';
import { findRoomMember, removeRoomMember } from '../rooms/room-members.helpers';
type IncomingModerationExtraAction =
@@ -65,6 +69,27 @@ export class UsersEffects {
private serverDirectory = inject(ServerDirectoryFacade);
private webrtc = inject(RealtimeSessionFacade);
/** Prepares persisted state for a successful login before exposing the user in-memory. */
authenticateUser$ = createEffect(() =>
this.actions$.pipe(
ofType(UsersActions.authenticateUser),
switchMap(({ user }) =>
from(this.prepareAuthenticatedUserStorage(user.id)).pipe(
mergeMap(() => [
MessagesActions.clearMessages(),
UsersActions.resetUsersState(),
RoomsActions.resetRoomsState(),
UsersActions.setCurrentUser({ user }),
RoomsActions.loadRooms()
]),
catchError((error: Error) =>
of(UsersActions.loadCurrentUserFailure({ error: error.message || 'Failed to prepare local user state.' }))
)
)
)
)
);
// Load current user from storage
/** Loads the persisted current user from the local database on startup. */
loadCurrentUser$ = createEffect(() =>
@@ -124,6 +149,11 @@ export class UsersEffects {
};
}
private async prepareAuthenticatedUserStorage(userId: string): Promise<void> {
setStoredCurrentUserId(userId);
await this.db.initialize();
}
/** Loads all users associated with a specific room from the local database. */
loadRoomUsers$ = createEffect(() =>
this.actions$.pipe(

View File

@@ -30,6 +30,16 @@ function mergePresenceServerIds(
return normalizePresenceServerIds([...(existingServerIds ?? []), ...(incomingServerIds ?? [])]);
}
function hasLivePeerTransport(user: User, connectedPeerIds: ReadonlySet<string>): boolean {
if (connectedPeerIds.size === 0) {
return false;
}
return connectedPeerIds.has(user.id)
|| connectedPeerIds.has(user.oderId)
|| (!!user.peerId && connectedPeerIds.has(user.peerId));
}
interface AvatarFields {
avatarUrl?: string;
avatarHash?: string;
@@ -262,6 +272,10 @@ export const initialState: UsersState = usersAdapter.getInitialState({
export const usersReducer = createReducer(
initialState,
on(UsersActions.resetUsersState, () => ({
...initialState
})),
on(UsersActions.loadCurrentUser, (state) => ({
...state,
loading: true,
@@ -344,10 +358,11 @@ export const usersReducer = createReducer(
on(UsersActions.userJoined, (state, { user }) =>
usersAdapter.upsertOne(buildPresenceAwareUser(state.entities[user.id], user), state)
),
on(UsersActions.syncServerPresence, (state, { roomId, users }) => {
on(UsersActions.syncServerPresence, (state, { roomId, users, connectedPeerIds }) => {
let nextState = state;
const seenUserIds = new Set<string>();
const connectedPeerIdSet = new Set(connectedPeerIds ?? []);
for (const user of users) {
seenUserIds.add(user.id);
@@ -363,6 +378,7 @@ export const usersReducer = createReducer(
&& user.id !== nextState.currentUserId
&& user.presenceServerIds?.includes(roomId) === true
&& !seenUserIds.has(user.id)
&& !hasLivePeerTransport(user, connectedPeerIdSet)
)
.map((user) => ({
id: user.id,