fix: No longer displays edited on all messages and fix Disconnected from signaling server on multiple clients
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 8m19s
Queue Release Build / build-windows (push) Successful in 27m48s
Queue Release Build / build-linux (push) Successful in 47m35s
Queue Release Build / build-android (push) Successful in 21m15s
Queue Release Build / finalize (push) Successful in 2m30s

This commit is contained in:
2026-06-07 15:05:12 +02:00
parent 83456c018c
commit 80d7728e66
11 changed files with 175 additions and 20 deletions

View File

@@ -25,6 +25,13 @@ Durable rules for AI agents working on this project. Read this file at session s
## Lessons ## Lessons
### Store clientInstanceId in sessionStorage not localStorage [realtime] [multi-device]
- **Trigger:** same user logged in on two tabs, browsers, or synced profiles sees alternating "Disconnected from signaling server" and no cross-device chat/voice sync.
- **Rule:** persist `metoyou.clientInstanceId` in `sessionStorage` (one id per tab/window) and clear any legacy `localStorage` copy on first read.
- **Why:** server identify evicts stale sockets with the same `(oderId, connectionScope, clientInstanceId)` tuple; a shared localStorage id makes each client kick the other in a reconnect loop.
- **Example:** `ClientInstanceService.getClientInstanceId()` writes to `sessionStorage`; two tabs get different ids and stay connected simultaneously.
### Revalidate IndexedDB scope without reinitializing on every read [persistence] [performance] ### Revalidate IndexedDB scope without reinitializing on every read [persistence] [performance]
- **Trigger:** `DatabaseService.ensureReady()` called `initialize()` before every delegated read/write to fix user-scope races. - **Trigger:** `DatabaseService.ensureReady()` called `initialize()` before every delegated read/write to fix user-scope races.

View File

@@ -24,7 +24,7 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra
| **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" | | **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" |
| **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" | | **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" |
| **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" | | **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" |
| **Client instance** | Stable per-install UUID (`metoyou.clientInstanceId`) sent on WebSocket `identify` and voice-state payloads so the signaling server can route multi-device sessions. | "device id", "session id" | | **Client instance** | Stable per-tab UUID (`metoyou.clientInstanceId` in `sessionStorage`) sent on WebSocket `identify` and voice-state payloads so the signaling server can route multi-device sessions without evicting other tabs or synced profiles. | "device id", "session id" |
| **Voice owner connection** | The single client instance whose `clientInstanceId` matches the user's active `voiceState.clientInstanceId` and therefore owns mic/WebRTC for that identity. | "active voice client" | | **Voice owner connection** | The single client instance whose `clientInstanceId` matches the user's active `voiceState.clientInstanceId` and therefore owns mic/WebRTC for that identity. | "active voice client" |
## Relationships ## Relationships

View File

@@ -7,17 +7,27 @@ import {
} from 'vitest'; } from 'vitest';
import { ClientInstanceService } from './client-instance.service'; import { ClientInstanceService } from './client-instance.service';
const STORAGE_KEY = 'metoyou.clientInstanceId'; const SESSION_STORAGE_KEY = 'metoyou.clientInstanceId';
const LEGACY_LOCAL_STORAGE_KEY = 'metoyou.clientInstanceId';
describe('ClientInstanceService', () => { describe('ClientInstanceService', () => {
const storage = new Map<string, string>(); const sessionStorage = new Map<string, string>();
const localStorage = new Map<string, string>();
beforeEach(() => { beforeEach(() => {
storage.clear(); sessionStorage.clear();
localStorage.clear();
vi.stubGlobal('sessionStorage', {
getItem: (key: string) => sessionStorage.get(key) ?? null,
setItem: (key: string, value: string) => { sessionStorage.set(key, value); },
removeItem: (key: string) => { sessionStorage.delete(key); }
});
vi.stubGlobal('localStorage', { vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null, getItem: (key: string) => localStorage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, value); }, setItem: (key: string, value: string) => { localStorage.set(key, value); },
removeItem: (key: string) => { storage.delete(key); } removeItem: (key: string) => { localStorage.delete(key); }
}); });
}); });
@@ -25,19 +35,37 @@ describe('ClientInstanceService', () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it('creates and persists a stable client instance id', () => { it('creates and persists a stable id for the same tab session', () => {
const service = new ClientInstanceService(); const service = new ClientInstanceService();
const first = service.getClientInstanceId(); const first = service.getClientInstanceId();
const second = new ClientInstanceService().getClientInstanceId(); const second = new ClientInstanceService().getClientInstanceId();
expect(first).toMatch(/^[0-9a-f-]{36}$/i); expect(first).toMatch(/^[0-9a-f-]{36}$/i);
expect(second).toBe(first); expect(second).toBe(first);
expect(storage.get(STORAGE_KEY)).toBe(first); expect(sessionStorage.get(SESSION_STORAGE_KEY)).toBe(first);
}); });
it('reuses a stored client instance id', () => { it('uses independent ids across separate tab sessions', () => {
storage.set(STORAGE_KEY, 'device-123'); sessionStorage.set(SESSION_STORAGE_KEY, 'tab-a');
expect(new ClientInstanceService().getClientInstanceId()).toBe('device-123'); const tabA = new ClientInstanceService().getClientInstanceId();
sessionStorage.clear();
sessionStorage.set(SESSION_STORAGE_KEY, 'tab-b');
const tabB = new ClientInstanceService().getClientInstanceId();
expect(tabA).toBe('tab-a');
expect(tabB).toBe('tab-b');
});
it('does not reuse legacy localStorage ids that collide across tabs or synced browsers', () => {
localStorage.set(LEGACY_LOCAL_STORAGE_KEY, 'synced-device-id');
const id = new ClientInstanceService().getClientInstanceId();
expect(id).not.toBe('synced-device-id');
expect(sessionStorage.get(SESSION_STORAGE_KEY)).toBe(id);
expect(localStorage.has(LEGACY_LOCAL_STORAGE_KEY)).toBe(false);
}); });
}); });

View File

@@ -1,7 +1,14 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
const STORAGE_KEY = 'metoyou.clientInstanceId'; const SESSION_STORAGE_KEY = 'metoyou.clientInstanceId';
const LEGACY_LOCAL_STORAGE_KEY = 'metoyou.clientInstanceId';
/**
* Stable id for this browser tab/window session.
*
* Stored in sessionStorage so multiple tabs or synced browser profiles do not
* share the same id and evict each other on the signaling server.
*/
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ClientInstanceService { export class ClientInstanceService {
private cachedId: string | null = null; private cachedId: string | null = null;
@@ -18,9 +25,11 @@ export class ClientInstanceService {
return stored; return stored;
} }
this.clearLegacyLocalStorageId();
const created = crypto.randomUUID(); const created = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, created); this.writeStoredId(created);
this.cachedId = created; this.cachedId = created;
return created; return created;
@@ -28,11 +37,27 @@ export class ClientInstanceService {
private readStoredId(): string | null { private readStoredId(): string | null {
try { try {
const raw = localStorage.getItem(STORAGE_KEY)?.trim(); const raw = sessionStorage.getItem(SESSION_STORAGE_KEY)?.trim();
return raw || null; return raw || null;
} catch { } catch {
return null; return null;
} }
} }
private writeStoredId(id: string): void {
try {
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
} catch {
// Ignore quota / private-mode failures; in-memory cache still works for this session.
}
}
private clearLegacyLocalStorageId(): void {
try {
localStorage.removeItem(LEGACY_LOCAL_STORAGE_KEY);
} catch {
// Ignore storage access failures.
}
}
} }

View File

@@ -55,7 +55,21 @@ describe('message-revision.builder.rules', () => {
expect(revision.content).toBe('edited'); expect(revision.content).toBe('edited');
}); });
it('materializes message state from a revision', async () => { it('materializes create revisions without an editedAt label timestamp', async () => {
const revision = await buildMessageRevision({
message: createMessage(),
type: 'create',
actorId: 'user-1',
editedAt: 1_000
});
const materialized = materializeMessageFromRevision(null, revision);
expect(materialized.timestamp).toBe(1_000);
expect(materialized.editedAt).toBeUndefined();
expect(materialized.revision).toBe(0);
});
it('materializes message state from an edit revision', async () => {
const revision = await buildMessageRevision({ const revision = await buildMessageRevision({
message: createMessage(), message: createMessage(),
type: 'author-edit', type: 'author-edit',
@@ -67,6 +81,7 @@ describe('message-revision.builder.rules', () => {
expect(materialized.revision).toBe(1); expect(materialized.revision).toBe(1);
expect(materialized.content).toBe('edited'); expect(materialized.content).toBe('edited');
expect(materialized.editedAt).toBe(2_000);
expect(materialized.headHash).toBe(revision.headHash); expect(materialized.headHash).toBe(revision.headHash);
}); });

View File

@@ -85,7 +85,7 @@ export function materializeMessageFromRevision(
senderId: revision.senderId, senderId: revision.senderId,
senderName: revision.senderName ?? base.senderName, senderName: revision.senderName ?? base.senderName,
content: revision.isDeleted ? DELETED_MESSAGE_CONTENT : (revision.content ?? base.content), content: revision.isDeleted ? DELETED_MESSAGE_CONTENT : (revision.content ?? base.content),
editedAt: revision.editedAt, editedAt: revision.type === 'create' ? undefined : revision.editedAt,
revision: revision.revision, revision: revision.revision,
headHash: revision.headHash, headHash: revision.headHash,
isDeleted: revision.isDeleted, isDeleted: revision.isDeleted,

View File

@@ -0,0 +1,59 @@
import {
describe,
it,
expect
} from 'vitest';
import type { Message } from '../../../../shared-kernel';
import { shouldShowMessageEditedLabel } from './message.rules';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 'message-1',
roomId: 'room-1',
senderId: 'user-1',
senderName: 'User 1',
content: 'hello',
timestamp: 1_000,
reactions: [],
isDeleted: false,
...overrides
};
}
describe('message.rules', () => {
describe('shouldShowMessageEditedLabel', () => {
it('returns false for newly created messages without an edit revision', () => {
expect(shouldShowMessageEditedLabel(createMessage())).toBe(false);
});
it('returns false when editedAt equals the original timestamp (legacy create rows)', () => {
expect(shouldShowMessageEditedLabel(createMessage({
editedAt: 1_000,
timestamp: 1_000,
revision: 0
}))).toBe(false);
});
it('returns true when a message has an edit revision', () => {
expect(shouldShowMessageEditedLabel(createMessage({
editedAt: 2_000,
revision: 1
}))).toBe(true);
});
it('returns true for legacy edited messages with editedAt after timestamp', () => {
expect(shouldShowMessageEditedLabel(createMessage({
editedAt: 2_000,
timestamp: 1_000
}))).toBe(true);
});
it('returns false for deleted messages even when editedAt is set', () => {
expect(shouldShowMessageEditedLabel(createMessage({
editedAt: 2_000,
revision: 1,
isDeleted: true
}))).toBe(false);
});
});
});

View File

@@ -1,10 +1,26 @@
import { DELETED_MESSAGE_CONTENT, type Message } from '../../../../shared-kernel'; import { DELETED_MESSAGE_CONTENT, type Message } from '../../../../shared-kernel';
import { getMessageRevision } from './message-integrity.rules';
/** Extracts the effective timestamp from a message (editedAt takes priority). */ /** Extracts the effective timestamp from a message (editedAt takes priority). */
export function getMessageTimestamp(msg: Message): number { export function getMessageTimestamp(msg: Message): number {
return msg.editedAt || msg.timestamp || 0; return msg.editedAt || msg.timestamp || 0;
} }
/** Whether the UI should show the "(edited)" label for a message. */
export function shouldShowMessageEditedLabel(
message: Pick<Message, 'editedAt' | 'timestamp' | 'revision' | 'isDeleted'>
): boolean {
if (message.isDeleted || message.editedAt === undefined) {
return false;
}
if (getMessageRevision(message) > 0) {
return true;
}
return typeof message.timestamp === 'number' && message.editedAt > message.timestamp;
}
/** Computes the most recent timestamp across a batch of messages. */ /** Computes the most recent timestamp across a batch of messages. */
export function getLatestTimestamp(messages: Message[]): number { export function getLatestTimestamp(messages: Message[]): number {
return messages.reduce((max, msg) => Math.max(max, getMessageTimestamp(msg)), 0); return messages.reduce((max, msg) => Math.max(max, getMessageTimestamp(msg)), 0);

View File

@@ -69,7 +69,7 @@
>{{ msg.senderName }}</span >{{ msg.senderName }}</span
> >
<span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span> <span class="text-xs text-muted-foreground">{{ formatTimestamp(msg.timestamp) }}</span>
@if (msg.editedAt && !msg.isDeleted) { @if (showEditedLabel(msg)) {
<span class="text-xs text-muted-foreground">{{ 'chat.message.edited' | translate }}</span> <span class="text-xs text-muted-foreground">{{ 'chat.message.edited' | translate }}</span>
} }
</div> </div>

View File

@@ -52,6 +52,7 @@ import { ExperimentalMediaSettingsService } from '../../../../../experimental-me
import { ExperimentalVlcPlayerComponent } from '../../../../../experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component'; import { ExperimentalVlcPlayerComponent } from '../../../../../experimental-media/feature/experimental-vlc-player/experimental-vlc-player.component';
import { KlipyService } from '../../../../application/services/klipy.service'; import { KlipyService } from '../../../../application/services/klipy.service';
import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules'; import { hasDedicatedChatEmbed } from '../../../../domain/rules/link-embed.rules';
import { shouldShowMessageEditedLabel } from '../../../../domain/rules/message.rules';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n'; import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../../../core/i18n';
import { Message, User } from '../../../../../../shared-kernel'; import { Message, User } from '../../../../../../shared-kernel';
import { ThemeNodeDirective } from '../../../../../theme'; import { ThemeNodeDirective } from '../../../../../theme';
@@ -594,6 +595,10 @@ export class ChatMessageItemComponent implements OnDestroy {
})); }));
} }
showEditedLabel(message: Message): boolean {
return shouldShowMessageEditedLabel(message);
}
formatTimestamp(timestamp: number): string { formatTimestamp(timestamp: number): string {
const date = new Date(timestamp); const date = new Date(timestamp);
const now = new Date(); const now = new Date();

View File

@@ -164,9 +164,9 @@ The browser also sends a lightweight `keepalive` message on the signaling socket
### Server-side connection hygiene ### Server-side connection hygiene
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). On `identify`, the server evicts stale sockets that share the same `(oderId, connectionScope, clientInstanceId)` tuple so a refreshed tab does not leave a zombie connection behind. Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). On `identify`, the server evicts stale sockets that share the same `(oderId, connectionScope, clientInstanceId)` tuple so a refreshed tab does not leave a zombie connection behind. Each browser tab/window stores its own `clientInstanceId` in `sessionStorage` so multiple tabs or synced browser profiles do not share an id and evict each other in a reconnect loop.
Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events. Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values per tab/device). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events.
RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`. RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`.