fix: should now sync with other devices
All checks were successful
Queue Release Build / prepare (push) Successful in 25s
Deploy Web Apps / deploy (push) Successful in 7m8s
Queue Release Build / build-windows (push) Successful in 28m10s
Queue Release Build / build-linux (push) Successful in 44m38s
Queue Release Build / build-android (push) Successful in 18m36s
Queue Release Build / finalize (push) Successful in 1m40s

This commit is contained in:
2026-06-09 22:00:39 +02:00
parent 1274ad9b46
commit d0aff6319d
16 changed files with 619 additions and 5 deletions

View File

@@ -170,6 +170,8 @@ Browsers do not reliably fire WebSocket close events during page refresh or navi
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.
Account-owned state (saved servers, friends, profile avatar/card text, custom emoji library, server icons, message edits/reactions) syncs through **`account_sync`** WebSocket messages. The client wraps relayable P2P broadcast events and the server forwards them to other connections for the same identity via `notifyOtherConnectionsForOderId`. When a new device identifies, existing connections receive `account_sync_peer_online` and push a full snapshot.
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`.
Join and leave broadcasts are also identity-aware: `handleJoinServer` only broadcasts `user_joined` when the identity is genuinely new to that server (not just a second WebSocket connection for the same user), and `handleLeaveServer` / dead-connection cleanup only broadcast `user_left` when no other open connection for that identity remains in the server. The `user_left` payload includes `serverIds` listing the rooms the identity still belongs to, so the client can subtract correctly without over-removing.

View File

@@ -0,0 +1,59 @@
import {
iterateBlobChunks,
P2P_BASE64_CHUNK_SIZE_BYTES,
type User
} from '../../../shared-kernel';
import type { RealtimeSessionFacade } from '../../../core/realtime';
async function dataUrlToBlob(dataUrl: string, mimeType: string): Promise<Blob> {
const base64 = dataUrl.split(',', 2)[1] ?? '';
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimeType });
}
export async function pushProfileViaAccountSync(
webrtc: Pick<RealtimeSessionFacade, 'relayAccountSync'>,
user: User
): Promise<void> {
const userKey = user.oderId || user.id;
const blob = user.avatarUrl
? await dataUrlToBlob(user.avatarUrl, user.avatarMime || 'image/webp')
: null;
const total = blob ? Math.ceil(blob.size / P2P_BASE64_CHUNK_SIZE_BYTES) : 0;
webrtc.relayAccountSync({
type: 'user-avatar-full',
oderId: userKey,
username: user.username,
displayName: user.displayName,
description: user.description,
profileUpdatedAt: user.profileUpdatedAt,
avatarHash: user.avatarHash,
avatarMime: blob ? (user.avatarMime || blob.type || 'image/webp') : undefined,
avatarUpdatedAt: user.avatarUpdatedAt || 0,
total
});
if (!blob) {
return;
}
for await (const chunk of iterateBlobChunks(blob, P2P_BASE64_CHUNK_SIZE_BYTES)) {
webrtc.relayAccountSync({
type: 'user-avatar-chunk',
oderId: userKey,
avatarHash: user.avatarHash,
avatarMime: user.avatarMime || blob.type || 'image/webp',
avatarUpdatedAt: user.avatarUpdatedAt || Date.now(),
index: chunk.index,
total: chunk.total,
data: chunk.base64
});
}
}

View File

@@ -0,0 +1,181 @@
/* eslint-disable @typescript-eslint/member-ordering */
import { Injectable, inject } from '@angular/core';
import {
Actions,
createEffect,
ofType
} from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
EMPTY,
from,
mergeMap,
tap
} from 'rxjs';
import { RealtimeSessionFacade } from '../../../core/realtime';
import { DatabaseService } from '../../persistence';
import { RoomsActions } from '../../../store/rooms/rooms.actions';
import { selectSavedRooms } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { FriendService } from '../../../domains/direct-message/application/services/friend.service';
import { CustomEmojiService } from '../../../domains/custom-emoji/application/custom-emoji.service';
import { shouldApplyAccountSyncPayload } from './account-sync.rules';
import { pushProfileViaAccountSync } from './account-sync-profile.helper';
import type { Room } from '../../../shared-kernel';
import type { IncomingSignalingMessage } from '../signaling/signaling-message-handler';
@Injectable()
export class AccountSyncEffects {
private readonly actions$ = inject(Actions);
private readonly store = inject(Store);
private readonly webrtc = inject(RealtimeSessionFacade);
private readonly db = inject(DatabaseService);
private readonly friends = inject(FriendService);
private readonly customEmoji = inject(CustomEmojiService);
broadcastSavedRoom$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess),
tap(({ room }) => {
this.webrtc.relayAccountSync({
type: 'saved-room-sync',
room
});
})
),
{ dispatch: false }
);
broadcastForgottenRoom$ = createEffect(
() =>
this.actions$.pipe(
ofType(RoomsActions.forgetRoomSuccess),
tap(({ roomId }) => {
this.webrtc.relayAccountSync({
type: 'saved-room-remove',
roomId
});
})
),
{ dispatch: false }
);
applySavedRoomSync$ = createEffect(() =>
this.webrtc.onMessageReceived.pipe(
mergeMap((event) => {
if (event.type === 'saved-room-sync' && event.room) {
return from(this.applySavedRoom(event.room));
}
if (event.type === 'saved-room-remove' && event.roomId) {
return from(this.db.deleteRoom(event.roomId)).pipe(
mergeMap(() => [RoomsActions.remoteForgetSavedRoom({ roomId: event.roomId })])
);
}
return EMPTY;
})
)
);
applyFriendSync$ = createEffect(
() =>
this.webrtc.onMessageReceived.pipe(
mergeMap((event) =>
from((async () => {
if (event.type === 'friend-added' && event.userId && typeof event.addedAt === 'number') {
await this.friends.applyRemoteFriendAdded(event.userId, event.addedAt);
}
if (event.type === 'friend-removed' && event.userId) {
await this.friends.applyRemoteFriendRemoved(event.userId);
}
})()).pipe(mergeMap(() => EMPTY))
)
),
{ dispatch: false }
);
pushStateWhenPeerDeviceComesOnline$ = createEffect(
() =>
this.webrtc.onSignalingMessage.pipe(
tap((message) => {
if (!this.isPeerOnlineMessage(message)) {
return;
}
if (!shouldApplyAccountSyncPayload(
message.clientInstanceId,
this.webrtc.getClientInstanceId()
)) {
return;
}
void this.pushFullAccountState();
})
),
{ dispatch: false }
);
private isPeerOnlineMessage(message: IncomingSignalingMessage): message is IncomingSignalingMessage & {
type: 'account_sync_peer_online';
clientInstanceId?: string;
} {
return message.type === 'account_sync_peer_online';
}
private async applySavedRoom(room: Room): Promise<ReturnType<typeof RoomsActions.importSavedRoom>> {
await this.db.saveRoom(room);
return RoomsActions.importSavedRoom({ room });
}
private async pushFullAccountState(): Promise<void> {
const currentUser = this.store.selectSignal(selectCurrentUser)();
const ownerId = currentUser?.oderId || currentUser?.id;
if (!ownerId) {
return;
}
const savedRooms = this.store.selectSignal(selectSavedRooms)();
for (const room of savedRooms) {
this.webrtc.relayAccountSync({ type: 'saved-room-sync', room });
}
const friends = await this.friends.friends();
for (const friend of friends) {
this.webrtc.relayAccountSync({
type: 'friend-added',
userId: friend.userId,
addedAt: friend.addedAt
});
}
await this.customEmoji.ensureLoaded(ownerId);
if (currentUser) {
await pushProfileViaAccountSync(this.webrtc, currentUser);
}
for (const emoji of this.customEmoji.emojis()) {
await this.relayCustomEmoji(emoji.id);
}
}
private async relayCustomEmoji(emojiId: string): Promise<void> {
const emoji = this.customEmoji.findEmoji(emojiId);
if (!emoji) {
return;
}
this.webrtc.relayAccountSync({
type: 'custom-emoji-full',
customEmoji: emoji
});
}
}

View File

@@ -0,0 +1,39 @@
import {
isRelayableAccountSyncEvent,
shouldApplyAccountSyncPayload,
unwrapAccountSyncPayload
} from './account-sync.rules';
describe('account-sync.rules', () => {
it('relays profile, emoji, room, and moderation events but not chat-message or voice-state', () => {
expect(isRelayableAccountSyncEvent({ type: 'user-avatar-summary', oderId: 'u1', avatarUpdatedAt: 1 })).toBe(true);
expect(isRelayableAccountSyncEvent({ type: 'custom-emoji-full', customEmoji: {} as never })).toBe(true);
expect(isRelayableAccountSyncEvent({ type: 'server-icon-update', roomId: 'r1', icon: 'x', iconUpdatedAt: 1 })).toBe(true);
expect(isRelayableAccountSyncEvent({ type: 'saved-room-sync', room: { id: 'r1' } as never })).toBe(true);
expect(isRelayableAccountSyncEvent({ type: 'friend-added', userId: 'u2', addedAt: 1 })).toBe(true);
expect(isRelayableAccountSyncEvent({ type: 'chat-message', message: {} as never })).toBe(false);
expect(isRelayableAccountSyncEvent({ type: 'voice-state', voiceState: {} as never })).toBe(false);
});
it('skips payloads that originated on this client instance', () => {
expect(shouldApplyAccountSyncPayload('device-a', 'device-a')).toBe(false);
expect(shouldApplyAccountSyncPayload('device-a', 'device-b')).toBe(true);
expect(shouldApplyAccountSyncPayload(undefined, 'device-a')).toBe(true);
});
it('unwraps account_sync signaling envelopes into chat events', () => {
const payload = { type: 'friend-added', userId: 'bob', addedAt: 10 };
const event = unwrapAccountSyncPayload({
type: 'account_sync',
payload,
clientInstanceId: 'device-a',
fromUserId: 'alice'
});
expect(event).toEqual({
...payload,
fromPeerId: 'alice',
clientInstanceId: 'device-a'
});
});
});

View File

@@ -0,0 +1,56 @@
import type { ChatEvent } from '../../../shared-kernel';
const DEDICATED_SIGNALING_RELAY_TYPES = new Set([
'chat-message',
'voice-state'
]);
const RELAYABLE_ACCOUNT_SYNC_TYPES = new Set([
'user-avatar-summary',
'user-avatar-request',
'user-avatar-full',
'user-avatar-chunk',
'custom-emoji-summary',
'custom-emoji-request',
'custom-emoji-full',
'custom-emoji-chunk',
'server-icon-summary',
'server-icon-request',
'server-icon-full',
'server-icon-update',
'saved-room-sync',
'saved-room-remove',
'friend-added',
'friend-removed',
'message-edited',
'message-deleted',
'reaction-added',
'reaction-removed'
]);
export interface AccountSyncSignalingMessage {
type: 'account_sync';
payload: ChatEvent;
clientInstanceId?: string;
fromUserId?: string;
}
export function isRelayableAccountSyncEvent(event: ChatEvent): boolean {
return RELAYABLE_ACCOUNT_SYNC_TYPES.has(event.type)
&& !DEDICATED_SIGNALING_RELAY_TYPES.has(event.type);
}
export function shouldApplyAccountSyncPayload(
originClientInstanceId: string | undefined,
localClientInstanceId: string
): boolean {
return !originClientInstanceId || originClientInstanceId !== localClientInstanceId;
}
export function unwrapAccountSyncPayload(message: AccountSyncSignalingMessage): ChatEvent {
return {
...message.payload,
fromPeerId: message.fromUserId ?? message.payload.fromPeerId,
clientInstanceId: message.clientInstanceId ?? message.payload.clientInstanceId
};
}

View File

@@ -17,9 +17,15 @@ import {
inject,
OnDestroy
} from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { Observable, Subject, merge } from 'rxjs';
import { ChatEvent } from '../../shared-kernel';
import type { SignalingMessage } from '../../shared-kernel';
import {
isRelayableAccountSyncEvent,
shouldApplyAccountSyncPayload,
unwrapAccountSyncPayload,
type AccountSyncSignalingMessage
} from './account-sync/account-sync.rules';
import { TimeSyncService } from '../../core/services/time-sync.service';
import { DebuggingService } from '../../core/services/debugging';
import { ScreenShareSourcePickerService } from '../../domains/screen-share';
@@ -83,12 +89,14 @@ export class WebRTCService implements OnDestroy {
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
readonly onSignalingMessage = this.signalingMessage$.asObservable();
private readonly accountSyncRelay$ = new Subject<ChatEvent>();
private readonly signalingReconnectedSubject$ = new Subject<string>();
readonly signalingReconnected$ = this.signalingReconnectedSubject$.asObservable();
// Delegates to managers
get onMessageReceived(): Observable<ChatEvent> {
return this.peerMediaFacade.onMessageReceived;
return merge(this.peerMediaFacade.onMessageReceived, this.accountSyncRelay$);
}
get onPeerConnected(): Observable<string> {
return this.peerMediaFacade.onPeerConnected;
@@ -304,6 +312,19 @@ export class WebRTCService implements OnDestroy {
return;
}
if (message.type === 'account_sync') {
const accountMessage = message as AccountSyncSignalingMessage;
if (shouldApplyAccountSyncPayload(
accountMessage.clientInstanceId,
this.clientInstance.getClientInstanceId()
)) {
this.accountSyncRelay$.next(unwrapAccountSyncPayload(accountMessage));
}
return;
}
this.signalingMessage$.next(message);
this.signalingMessageHandler.handleMessage(message, signalUrl);
}
@@ -451,6 +472,24 @@ export class WebRTCService implements OnDestroy {
this.relayBroadcastEvent(event);
}
/** Relay account-owned state to the user's other connected devices. */
relayAccountSync(event: ChatEvent): void {
if (!isRelayableAccountSyncEvent(event)) {
return;
}
const clientInstanceId = this.clientInstance.getClientInstanceId();
this.signalingTransportHandler.sendRawMessage({
type: 'account_sync',
clientInstanceId,
payload: {
...event,
clientInstanceId
}
});
}
/**
* Send a {@link ChatEvent} to a specific peer.
*
@@ -742,7 +781,11 @@ export class WebRTCService implements OnDestroy {
},
clientInstanceId
});
return;
}
this.relayAccountSync(event);
}
requestVoiceClientTakeover(): void {