diff --git a/agents-docs/features/authentication.md b/agents-docs/features/authentication.md index b7c6952..51eb381 100644 --- a/agents-docs/features/authentication.md +++ b/agents-docs/features/authentication.md @@ -52,7 +52,7 @@ Require `Authorization: Bearer`: ``` - `oderId` must match the token's user id when provided. -- `clientInstanceId` is a stable per-install UUID generated by the product client (`metoyou.clientInstanceId` in `localStorage`). The signaling server uses it to distinguish multiple WebSocket connections for the same user and to route voice ownership. +- `clientInstanceId` is a stable per-tab UUID generated by the product client (`metoyou.clientInstanceId` in `sessionStorage`). The signaling server uses it to distinguish multiple WebSocket connections for the same user and to route voice ownership. - Server responds with `auth_error` or `auth_required` when authentication fails. ## Multi-device sessions @@ -62,6 +62,39 @@ Require `Authorization: Bearer`: - Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device. - Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple. +### Account-owned state sync (`account_sync`) + +When the same account is logged in on multiple devices, account-owned data is kept in sync through the signaling server: + +| Data | Mechanism | +|---|---| +| Server chat messages | Existing `chat_message` relay (connection-scoped broadcast) | +| Voice / typing | Existing `voice_state` / `user_typing` relays | +| Saved servers (join/leave) | `account_sync` payload `saved-room-sync` / `saved-room-remove` | +| Profile avatar + card text | `account_sync` `user-avatar-full` + `user-avatar-chunk` | +| Custom emoji library | `account_sync` `custom-emoji-full` + `custom-emoji-chunk` | +| Friends list | `account_sync` `friend-added` / `friend-removed` | +| Server icons, edits, reactions | `account_sync` relay of existing P2P broadcast event types | + +Client rules: + +- `broadcastMessage()` still fans out over peer data channels; relayable events are **also** wrapped in `account_sync` and sent on the WebSocket. +- The server forwards `account_sync` to every other open connection for the same `oderId` via `notifyOtherConnectionsForOderId`. +- Receivers ignore payloads whose `clientInstanceId` matches the local tab id. +- When a new device identifies, the server notifies existing connections with `account_sync_peer_online`; those devices push a full snapshot (saved rooms, friends, profile, emoji library). + +WebSocket envelope: + +```json +{ + "type": "account_sync", + "clientInstanceId": "", + "payload": { "type": "saved-room-sync", "room": { "...": "..." } } +} +``` + +Server response to other connections includes `fromUserId` set to the sender's `oderId`. + ## Client storage The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server. diff --git a/server/src/websocket/handler-multi-client.spec.ts b/server/src/websocket/handler-multi-client.spec.ts index a89d9c1..598c125 100644 --- a/server/src/websocket/handler-multi-client.spec.ts +++ b/server/src/websocket/handler-multi-client.spec.ts @@ -220,4 +220,50 @@ describe('server websocket handler - multi-client sessions', () => { expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true); expect(connectedUsers.get('conn-new')?.authenticated).toBe(true); }); + + it('relays account_sync payloads to other connections for the same user', async () => { + createConnectedUser('conn-a1', { + authenticated: true, + oderId: 'user-1', + serverIds: new Set(['server-1']), + clientInstanceId: 'device-a' + }); + const receiver = createConnectedUser('conn-a2', { + authenticated: true, + oderId: 'user-1', + serverIds: new Set(['server-1']), + clientInstanceId: 'device-b' + }); + + getSentMessages(receiver).length = 0; + + await handleWebSocketMessage('conn-a1', { + type: 'account_sync', + clientInstanceId: 'device-a', + payload: { + type: 'friend-added', + userId: 'friend-1', + addedAt: 123 + } + }); + + const messages = getSentMessages(receiver).map((raw) => JSON.parse(raw) as { + type: string; + payload?: { type: string; userId?: string }; + clientInstanceId?: string; + fromUserId?: string; + }); + const relay = messages.find((message) => message.type === 'account_sync'); + + expect(relay).toEqual({ + type: 'account_sync', + clientInstanceId: 'device-a', + fromUserId: 'user-1', + payload: { + type: 'friend-added', + userId: 'friend-1', + addedAt: 123 + } + }); + }); }); diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index 75e8cea..476b363 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -296,6 +296,11 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio connectedUsers.set(connectionId, user); console.log(`User identified: ${user.displayName} (${user.oderId})`); + notifyOtherConnectionsForOderId(newOderId, { + type: 'account_sync_peer_online', + clientInstanceId: newClientInstanceId + }, connectionId); + const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) => otherConnectionId !== connectionId && otherUser.oderId === newOderId @@ -541,6 +546,21 @@ function handleVoiceClientTakeover(user: ConnectedUser, message: WsMessage, conn }, connectionId); } +function handleAccountSync(user: ConnectedUser, message: WsMessage, connectionId: string): void { + const payload = message['payload']; + + if (!payload || typeof payload !== 'object' || typeof (payload as { type?: unknown }).type !== 'string') { + return; + } + + notifyOtherConnectionsForOderId(user.oderId, { + type: 'account_sync', + clientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId, + fromUserId: user.oderId, + payload + }, connectionId); +} + function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void { const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general'; @@ -747,6 +767,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe handleVoiceClientTakeover(user, message, connectionId); break; + case 'account_sync': + handleAccountSync(user, message, connectionId); + break; + case 'typing': handleTyping(user, message, connectionId); break; diff --git a/toju-app/src/app/app.config.ts b/toju-app/src/app/app.config.ts index 9302175..b637e75 100644 --- a/toju-app/src/app/app.config.ts +++ b/toju-app/src/app/app.config.ts @@ -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, diff --git a/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts b/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts index f750331..cb26a1e 100644 --- a/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts +++ b/toju-app/src/app/domains/custom-emoji/application/custom-emoji.service.ts @@ -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 { + 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 { diff --git a/toju-app/src/app/domains/direct-message/application/services/friend.service.ts b/toju-app/src/app/domains/direct-message/application/services/friend.service.ts index 541cc77..a5b8917 100644 --- a/toju-app/src/app/domains/direct-message/application/services/friend.service.ts +++ b/toju-app/src/app/domains/direct-message/application/services/friend.service.ts @@ -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([]); 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 { 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 { + 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 { + const ownerId = await this.requireOwnerId(); + + if (!this.isFriend(userId)) { + return; + } + await this.repository.removeFriend(ownerId, userId); await this.loadForOwner(ownerId, true); } diff --git a/toju-app/src/app/infrastructure/realtime/README.md b/toju-app/src/app/infrastructure/realtime/README.md index cdb804d..30512d2 100644 --- a/toju-app/src/app/infrastructure/realtime/README.md +++ b/toju-app/src/app/infrastructure/realtime/README.md @@ -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. diff --git a/toju-app/src/app/infrastructure/realtime/account-sync/account-sync-profile.helper.ts b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync-profile.helper.ts new file mode 100644 index 0000000..29fd15e --- /dev/null +++ b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync-profile.helper.ts @@ -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 { + 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, + user: User +): Promise { + 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 + }); + } +} diff --git a/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.effects.ts b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.effects.ts new file mode 100644 index 0000000..4161811 --- /dev/null +++ b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.effects.ts @@ -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> { + await this.db.saveRoom(room); + + return RoomsActions.importSavedRoom({ room }); + } + + private async pushFullAccountState(): Promise { + 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 { + const emoji = this.customEmoji.findEmoji(emojiId); + + if (!emoji) { + return; + } + + this.webrtc.relayAccountSync({ + type: 'custom-emoji-full', + customEmoji: emoji + }); + } +} diff --git a/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.spec.ts b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.spec.ts new file mode 100644 index 0000000..cb704bf --- /dev/null +++ b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.spec.ts @@ -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' + }); + }); +}); diff --git a/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.ts b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.ts new file mode 100644 index 0000000..f832e3d --- /dev/null +++ b/toju-app/src/app/infrastructure/realtime/account-sync/account-sync.rules.ts @@ -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 + }; +} diff --git a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts index 7c3c933..7a9da8d 100644 --- a/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts +++ b/toju-app/src/app/infrastructure/realtime/realtime-session.service.ts @@ -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(); readonly onSignalingMessage = this.signalingMessage$.asObservable(); + private readonly accountSyncRelay$ = new Subject(); + private readonly signalingReconnectedSubject$ = new Subject(); readonly signalingReconnected$ = this.signalingReconnectedSubject$.asObservable(); // Delegates to managers get onMessageReceived(): Observable { - return this.peerMediaFacade.onMessageReceived; + return merge(this.peerMediaFacade.onMessageReceived, this.accountSyncRelay$); } get onPeerConnected(): Observable { 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 { diff --git a/toju-app/src/app/shared-kernel/chat-events.ts b/toju-app/src/app/shared-kernel/chat-events.ts index 02d6df7..89d77e9 100644 --- a/toju-app/src/app/shared-kernel/chat-events.ts +++ b/toju-app/src/app/shared-kernel/chat-events.ts @@ -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 diff --git a/toju-app/src/app/store/rooms/rooms.actions.ts b/toju-app/src/app/store/rooms/rooms.actions.ts index 6f1d293..7c14937 100644 --- a/toju-app/src/app/store/rooms/rooms.actions.ts +++ b/toju-app/src/app/store/rooms/rooms.actions.ts @@ -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(), diff --git a/toju-app/src/app/store/rooms/rooms.reducer.ts b/toju-app/src/app/store/rooms/rooms.reducer.ts index 85e0968..58fa524 100644 --- a/toju-app/src/app/store/rooms/rooms.reducer.ts +++ b/toju-app/src/app/store/rooms/rooms.reducer.ts @@ -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, diff --git a/toju-app/src/app/store/users/user-avatar.effects.ts b/toju-app/src/app/store/users/user-avatar.effects.ts index ef01d97..80ce43b 100644 --- a/toju-app/src/app/store/users/user-avatar.effects.ts +++ b/toju-app/src/app/store/users/user-avatar.effects.ts @@ -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(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,