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
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:
@@ -17,6 +17,7 @@ import { usersReducer } from './store/users/users.reducer';
|
||||
import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||
import { NotificationsEffects } from './domains/notifications';
|
||||
import { CustomEmojiSyncEffects } from './domains/custom-emoji';
|
||||
import { AccountSyncEffects } from './infrastructure/realtime/account-sync/account-sync.effects';
|
||||
import { MessagesEffects } from './store/messages/messages.effects';
|
||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||
import { UserAvatarEffects } from './store/users/user-avatar.effects';
|
||||
@@ -45,6 +46,7 @@ export const appConfig: ApplicationConfig = {
|
||||
}),
|
||||
provideEffects([
|
||||
NotificationsEffects,
|
||||
AccountSyncEffects,
|
||||
CustomEmojiSyncEffects,
|
||||
MessagesEffects,
|
||||
MessagesSyncEffects,
|
||||
|
||||
@@ -317,6 +317,46 @@ export class CustomEmojiService {
|
||||
const peers = this.webrtc.getConnectedPeers();
|
||||
|
||||
await Promise.all(peers.map((peerId) => this.sendEmojiToPeer(peerId, emoji)));
|
||||
await this.relayEmojiViaAccountSync(emoji);
|
||||
}
|
||||
|
||||
private async relayEmojiViaAccountSync(emoji: CustomEmoji): Promise<void> {
|
||||
if (canInlineCustomEmojiTransfer(emoji)) {
|
||||
this.webrtc.relayAccountSync({
|
||||
type: 'custom-emoji-full',
|
||||
customEmoji: emoji
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const transfer = splitCustomEmojiDataUrl(emoji.dataUrl);
|
||||
const manifest: CustomEmojiTransferManifest = {
|
||||
id: emoji.id,
|
||||
name: emoji.name,
|
||||
creatorUserId: emoji.creatorUserId,
|
||||
hash: emoji.hash,
|
||||
mime: emoji.mime,
|
||||
size: emoji.size,
|
||||
createdAt: emoji.createdAt,
|
||||
updatedAt: emoji.updatedAt
|
||||
};
|
||||
|
||||
this.webrtc.relayAccountSync({
|
||||
type: 'custom-emoji-full',
|
||||
customEmojiTransfer: manifest,
|
||||
total: transfer.total
|
||||
});
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < transfer.chunks.length; chunkIndex++) {
|
||||
this.webrtc.relayAccountSync({
|
||||
type: 'custom-emoji-chunk',
|
||||
customEmojiId: emoji.id,
|
||||
index: chunkIndex,
|
||||
total: transfer.total,
|
||||
data: transfer.chunks[chunkIndex]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendEmojiToPeer(peerId: string, emoji: CustomEmoji): Promise<void> {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { FriendRepository } from '../../infrastructure/friend.repository';
|
||||
import type { Friend } from '../../domain/models/direct-message.model';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
@@ -15,6 +16,7 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
export class FriendService {
|
||||
private readonly repository = inject(FriendRepository);
|
||||
private readonly store = inject(Store);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private readonly friendsSignal = signal<Friend[]>([]);
|
||||
private loadedOwnerId: string | null = null;
|
||||
@@ -36,11 +38,42 @@ export class FriendService {
|
||||
|
||||
await this.repository.addFriend(ownerId, friend);
|
||||
await this.loadForOwner(ownerId, true);
|
||||
this.webrtc.relayAccountSync({
|
||||
type: 'friend-added',
|
||||
userId,
|
||||
addedAt: friend.addedAt
|
||||
});
|
||||
}
|
||||
|
||||
async removeFriend(userId: string): Promise<void> {
|
||||
const ownerId = await this.requireOwnerId();
|
||||
|
||||
await this.repository.removeFriend(ownerId, userId);
|
||||
await this.loadForOwner(ownerId, true);
|
||||
this.webrtc.relayAccountSync({
|
||||
type: 'friend-removed',
|
||||
userId
|
||||
});
|
||||
}
|
||||
|
||||
async applyRemoteFriendAdded(userId: string, addedAt: number): Promise<void> {
|
||||
const ownerId = await this.requireOwnerId();
|
||||
|
||||
if (this.isFriend(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.repository.addFriend(ownerId, { userId, addedAt });
|
||||
await this.loadForOwner(ownerId, true);
|
||||
}
|
||||
|
||||
async applyRemoteFriendRemoved(userId: string): Promise<void> {
|
||||
const ownerId = await this.requireOwnerId();
|
||||
|
||||
if (!this.isFriend(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.repository.removeFriend(ownerId, userId);
|
||||
await this.loadForOwner(ownerId, true);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface ChatInventoryItem {
|
||||
|
||||
export interface ChatEventBase {
|
||||
fromPeerId?: string;
|
||||
clientInstanceId?: string;
|
||||
messageId?: string;
|
||||
message?: Message;
|
||||
reaction?: Reaction;
|
||||
@@ -311,6 +312,27 @@ export interface ServerIconUpdateEvent extends ChatEventBase {
|
||||
iconUpdatedAt: number;
|
||||
}
|
||||
|
||||
export interface SavedRoomSyncEvent extends ChatEventBase {
|
||||
type: 'saved-room-sync';
|
||||
room: Room;
|
||||
}
|
||||
|
||||
export interface SavedRoomRemoveEvent extends ChatEventBase {
|
||||
type: 'saved-room-remove';
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export interface FriendAddedSyncEvent extends ChatEventBase {
|
||||
type: 'friend-added';
|
||||
userId: string;
|
||||
addedAt: number;
|
||||
}
|
||||
|
||||
export interface FriendRemovedSyncEvent extends ChatEventBase {
|
||||
type: 'friend-removed';
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface UserAvatarSummaryEvent extends ChatEventBase {
|
||||
type: 'user-avatar-summary';
|
||||
oderId: string;
|
||||
@@ -507,6 +529,10 @@ export type ChatEvent =
|
||||
| ServerIconRequestEvent
|
||||
| ServerIconFullEvent
|
||||
| ServerIconUpdateEvent
|
||||
| SavedRoomSyncEvent
|
||||
| SavedRoomRemoveEvent
|
||||
| FriendAddedSyncEvent
|
||||
| FriendRemovedSyncEvent
|
||||
| ServerStateRequestEvent
|
||||
| ServerStateFullEvent
|
||||
| MemberRosterRequestEvent
|
||||
|
||||
@@ -45,6 +45,9 @@ export const RoomsActions = createActionGroup({
|
||||
'Join Room Success': props<{ room: Room }>(),
|
||||
'Join Room Failure': props<{ error: string }>(),
|
||||
|
||||
'Import Saved Room': props<{ room: Room }>(),
|
||||
'Remote Forget Saved Room': props<{ roomId: string }>(),
|
||||
|
||||
'Leave Room': emptyProps(),
|
||||
'Leave Room Success': emptyProps(),
|
||||
|
||||
|
||||
@@ -217,6 +217,17 @@ export const roomsReducer = createReducer(
|
||||
error
|
||||
})),
|
||||
|
||||
on(RoomsActions.importSavedRoom, (state, { room }) => ({
|
||||
...state,
|
||||
savedRooms: upsertRoom(state.savedRooms, enrichRoom(room))
|
||||
})),
|
||||
|
||||
on(RoomsActions.remoteForgetSavedRoom, (state, { roomId }) => ({
|
||||
...state,
|
||||
savedRooms: state.savedRooms.filter((room) => room.id !== roomId),
|
||||
currentRoom: state.currentRoom?.id === roomId ? null : state.currentRoom
|
||||
})),
|
||||
|
||||
// Leave room
|
||||
on(RoomsActions.leaveRoom, (state) => ({
|
||||
...state,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatEvent, User } from '../../shared-kernel';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import { pushProfileViaAccountSync as relayProfileViaAccountSync } from '../../infrastructure/realtime/account-sync/account-sync-profile.helper';
|
||||
import { DatabaseService } from '../../infrastructure/persistence';
|
||||
import { UsersActions } from './users.actions';
|
||||
import { selectAllUsers, selectCurrentUser } from './users.selectors';
|
||||
@@ -33,6 +34,8 @@ import { findRoomMember } from '../rooms/room-members.helpers';
|
||||
|
||||
interface PendingAvatarTransfer {
|
||||
displayName: string;
|
||||
description?: string;
|
||||
profileUpdatedAt?: number;
|
||||
mime?: string;
|
||||
oderId: string;
|
||||
total: number;
|
||||
@@ -206,6 +209,7 @@ export class UserAvatarEffects {
|
||||
}
|
||||
|
||||
this.webrtc.broadcastMessage(this.buildAvatarSummary(currentUser));
|
||||
void relayProfileViaAccountSync(this.webrtc, currentUser);
|
||||
})
|
||||
),
|
||||
{ dispatch: false }
|
||||
@@ -236,7 +240,7 @@ export class UserAvatarEffects {
|
||||
]) => {
|
||||
switch (event.type) {
|
||||
case 'user-avatar-summary':
|
||||
return this.handleAvatarSummary(event, allUsers);
|
||||
return this.handleAvatarSummary(event, allUsers, currentUser ?? null);
|
||||
|
||||
case 'user-avatar-request':
|
||||
return this.handleAvatarRequest(event, currentUser ?? null);
|
||||
@@ -263,11 +267,17 @@ export class UserAvatarEffects {
|
||||
};
|
||||
}
|
||||
|
||||
private handleAvatarSummary(event: ChatEvent, allUsers: User[]) {
|
||||
private handleAvatarSummary(event: ChatEvent, allUsers: User[], currentUser: User | null) {
|
||||
if (!event.fromPeerId || !event.oderId || !event.avatarUpdatedAt) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const currentUserKey = currentUser?.oderId || currentUser?.id;
|
||||
|
||||
if (currentUserKey && event.oderId === currentUserKey) {
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
const existingUser = allUsers.find((user) => user.id === event.oderId || user.oderId === event.oderId);
|
||||
|
||||
if (!shouldRequestAvatarData(existingUser, event)) {
|
||||
@@ -301,6 +311,8 @@ export class UserAvatarEffects {
|
||||
return from(this.buildRemoteAvatarAction({
|
||||
chunks: [],
|
||||
displayName: event.displayName || 'User',
|
||||
description: event.description,
|
||||
profileUpdatedAt: event.profileUpdatedAt,
|
||||
mime: event.avatarMime,
|
||||
oderId: event.oderId,
|
||||
total: 0,
|
||||
@@ -319,6 +331,8 @@ export class UserAvatarEffects {
|
||||
this.pendingTransfers.set(event.oderId, {
|
||||
chunks: new Array<string | undefined>(event.total),
|
||||
displayName: event.displayName || 'User',
|
||||
description: event.description,
|
||||
profileUpdatedAt: event.profileUpdatedAt,
|
||||
mime: event.avatarMime,
|
||||
oderId: event.oderId,
|
||||
total: event.total,
|
||||
@@ -387,6 +401,8 @@ export class UserAvatarEffects {
|
||||
oderId: existingUser?.oderId || transfer.oderId,
|
||||
username: existingUser?.username || transfer.username,
|
||||
displayName: transfer.displayName || existingUser?.displayName || 'User',
|
||||
description: transfer.description ?? existingUser?.description,
|
||||
profileUpdatedAt: transfer.profileUpdatedAt ?? existingUser?.profileUpdatedAt,
|
||||
avatarUrl: dataUrl,
|
||||
avatarHash: transfer.hash,
|
||||
avatarMime: transfer.mime,
|
||||
|
||||
Reference in New Issue
Block a user