Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0aff6319d | |||
| 1274ad9b46 |
@@ -195,7 +195,7 @@ export class LoginPage {
|
|||||||
| ------------------- | ------------------ | ----------------------- |
|
| ------------------- | ------------------ | ----------------------- |
|
||||||
| `/login` | `LoginPage` | `LoginComponent` |
|
| `/login` | `LoginPage` | `LoginComponent` |
|
||||||
| `/register` | `RegisterPage` | `RegisterComponent` |
|
| `/register` | `RegisterPage` | `RegisterComponent` |
|
||||||
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
|
| `/servers` | `FindServersPage` | `FindServersComponent` |
|
||||||
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
|
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
|
||||||
| `/settings` | `SettingsPage` | `SettingsComponent` |
|
| `/settings` | `SettingsPage` | `SettingsComponent` |
|
||||||
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
|
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ Durable rules for AI agents working on this project. Read this file at session s
|
|||||||
|
|
||||||
## Lessons
|
## Lessons
|
||||||
|
|
||||||
|
### When renaming an Angular route, sweep every navigate/url-match/doc reference [routing]
|
||||||
|
|
||||||
|
- **Trigger:** the find-servers route was renamed `/search` → `/servers` in `app.routes.ts`, but `servers-rail.component.ts` still called `router.navigate(['/search'])` (leave-server) and matched `startsWith('/search')` for the user-bar visibility signal, throwing `NG04002: 'search'` on leave and never showing the user-bar on the discovery page.
|
||||||
|
- **Rule:** after changing a `path:` in `app.routes.ts`, grep the whole repo for the old literal (`/search`) across `*.ts`/`*.html` (router calls, `startsWith`/url-match signals) and docs (`docs-site`, `.agents/skills/playwright-e2e/SKILL.md` route tables, domain READMEs) and update them all in the same change.
|
||||||
|
- **Why:** `router.navigate` to a non-existent path raises `NG04002` and aborts navigation, and stale `startsWith` matches silently break route-derived UI state — neither is caught by the build (string literals) and there was no `servers-rail` spec to catch it.
|
||||||
|
- **Example:** fixed `isOnServers`/`router.navigate(['/servers'])` in `servers-rail.component.{ts,html}`; canonical post-leave/discovery route is `/servers` (`FindServersComponent`), matching `DashboardComponent`'s `router.navigate(['/servers'])`.
|
||||||
|
|
||||||
|
### Server discovery (featured/trending) must fan out across all online endpoints like search [server-directory]
|
||||||
|
|
||||||
|
- **Trigger:** the `/servers` (find-servers) page showed no servers by default but found them as soon as the user typed in the search box. Discovery (`getDiscoveryServers`) queried only the *active* endpoint via `getApiBaseUrl()`, and when that endpoint is a discovery-unsupported production host (`signal.toju.app` / `signal-sweden.toju.app` in `DISCOVERY_UNSUPPORTED_HOSTS`) it short-circuited to `[]`; search meanwhile fans out across every online endpoint, so typing surfaced the servers that lived on other endpoints (e.g. localhost).
|
||||||
|
- **Rule:** make `getFeaturedServers`/`getTrendingServers` fan out across `getSearchableEndpoints()` with `forkJoin` + `deduplicateById` (mirroring all-endpoint search), and apply the `endpointSupportsServerDiscovery` gate *per endpoint* (skip → `[]`) instead of short-circuiting the whole request on the active endpoint.
|
||||||
|
- **Why:** the empty-query find-servers view renders discovery sections, not search results, so any divergence between discovery's endpoint set and search's endpoint set makes the default view look broken while search works.
|
||||||
|
- **Example:** `getDiscoveryServers` + `fetchDiscoveryFromEndpoint` in `server-directory-api.service.ts`; verified the live server returns 12 featured/12 trending while the active production host is gated out client-side.
|
||||||
|
|
||||||
### Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget [server-directory] [rooms]
|
### Server registration needs `ownerPublicKey: oderId || id`, and must not be fire-and-forget [server-directory] [rooms]
|
||||||
|
|
||||||
- **Trigger:** creating a server appeared to work (the creator landed in the room view) but the server didn't exist on the backend — invite-link creation and search both 404'd. `createRoom$` sent `ownerPublicKey: currentUser.oderId` with no fallback; on restored sessions `oderId` can be falsy (identify still works because it falls back to `id`), so `POST /api/servers` returned `400 Missing required fields`, and the `.subscribe()` swallowed the error while `createRoomSuccess` fired regardless.
|
- **Trigger:** creating a server appeared to work (the creator landed in the room view) but the server didn't exist on the backend — invite-link creation and search both 404'd. `createRoom$` sent `ownerPublicKey: currentUser.oderId` with no fallback; on restored sessions `oderId` can be falsy (identify still works because it falls back to `id`), so `POST /api/servers` returned `400 Missing required fields`, and the `.subscribe()` swallowed the error while `createRoomSuccess` fired regardless.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ Require `Authorization: Bearer`:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `oderId` must match the token's user id when provided.
|
- `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.
|
- Server responds with `auth_error` or `auth_required` when authentication fails.
|
||||||
|
|
||||||
## Multi-device sessions
|
## 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.
|
- 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.
|
- 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": "<per-tab-uuid>",
|
||||||
|
"payload": { "type": "saved-room-sync", "room": { "...": "..." } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Server response to other connections includes `fromUserId` set to the sender's `oderId`.
|
||||||
|
|
||||||
## Client storage
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -10,14 +10,20 @@ This page maps the app routes and important DOM areas. It is useful for plugin a
|
|||||||
|
|
||||||
| Route | Component | Purpose |
|
| Route | Component | Purpose |
|
||||||
| ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
|
| ---------------------------- | ------------------------- | --------------------------------------------------------------------- |
|
||||||
| `/` | Redirect | Redirects to `/search`. |
|
| `/` | Redirect | Redirects to `/dashboard`. |
|
||||||
| `/login` | `LoginComponent` | User login. |
|
| `/login` | `LoginComponent` | User login. |
|
||||||
| `/register` | `RegisterComponent` | User registration. |
|
| `/register` | `RegisterComponent` | User registration. |
|
||||||
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
|
| `/invite/:inviteId` | `InviteComponent` | Resolve and accept invite links. |
|
||||||
| `/search` | `ServerSearchComponent` | Search and join servers. |
|
| `/dashboard` | `DashboardComponent` | Landing dashboard after sign-in. |
|
||||||
|
| `/people` | `FindPeopleComponent` | Discover and start direct messages with people. |
|
||||||
|
| `/servers` | `FindServersComponent` | Search, discover, and join servers. |
|
||||||
|
| `/create-server` | `CreateServerComponent` | Create a new server. |
|
||||||
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
|
| `/room/:roomId` | `ChatRoomComponent` | Main server page with text, voice, members, and plugin panels. |
|
||||||
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
|
| `/dm` | `DmWorkspaceComponent` | Direct-message workspace. |
|
||||||
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
|
| `/dm/:conversationId` | `DmWorkspaceComponent` | A selected direct-message conversation. |
|
||||||
|
| `/pm` | `DmWorkspaceComponent` | Private-message workspace (alias of the DM workspace). |
|
||||||
|
| `/pm/:conversationId` | `DmWorkspaceComponent` | A selected private-message conversation. |
|
||||||
|
| `/call/:callId` | `PrivateCallComponent` | Active private (1:1) call. |
|
||||||
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
|
| `/settings` | `SettingsComponent` | App, voice, server, plugin, desktop, theme, local API settings. |
|
||||||
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
|
| `/plugin-store` | `PluginStoreComponent` | Browse plugin sources and install/update plugins. |
|
||||||
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |
|
| `/plugins/:pluginId/:pageId` | `PluginPageHostComponent` | Host for plugin app pages registered with `api.ui.registerAppPage()`. |
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ Important routes:
|
|||||||
|
|
||||||
| Route | Purpose |
|
| Route | Purpose |
|
||||||
| ------------------------------- | ------------------------------------------------------------------- |
|
| ------------------------------- | ------------------------------------------------------------------- |
|
||||||
| `/search` | Search and join servers. |
|
| `/servers` | Search, discover, and join servers. |
|
||||||
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
|
| `/room/:roomId` | Main server workspace with text, voice, members, and plugin panels. |
|
||||||
| `/dm` and `/dm/:conversationId` | Direct-message workspace. |
|
| `/dm` and `/dm/:conversationId` | Direct-message workspace. |
|
||||||
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
|
| `/settings` | App, voice, server, plugin, desktop, theme, and local API settings. |
|
||||||
|
|||||||
@@ -220,4 +220,50 @@ describe('server websocket handler - multi-client sessions', () => {
|
|||||||
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
|
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
|
||||||
expect(connectedUsers.get('conn-new')?.authenticated).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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -296,6 +296,11 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
|||||||
connectedUsers.set(connectionId, user);
|
connectedUsers.set(connectionId, user);
|
||||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
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]) =>
|
const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) =>
|
||||||
otherConnectionId !== connectionId
|
otherConnectionId !== connectionId
|
||||||
&& otherUser.oderId === newOderId
|
&& otherUser.oderId === newOderId
|
||||||
@@ -541,6 +546,21 @@ function handleVoiceClientTakeover(user: ConnectedUser, message: WsMessage, conn
|
|||||||
}, connectionId);
|
}, 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 {
|
function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
|
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);
|
handleVoiceClientTakeover(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'account_sync':
|
||||||
|
handleAccountSync(user, message, connectionId);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'typing':
|
case 'typing':
|
||||||
handleTyping(user, message, connectionId);
|
handleTyping(user, message, connectionId);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { usersReducer } from './store/users/users.reducer';
|
|||||||
import { roomsReducer } from './store/rooms/rooms.reducer';
|
import { roomsReducer } from './store/rooms/rooms.reducer';
|
||||||
import { NotificationsEffects } from './domains/notifications';
|
import { NotificationsEffects } from './domains/notifications';
|
||||||
import { CustomEmojiSyncEffects } from './domains/custom-emoji';
|
import { CustomEmojiSyncEffects } from './domains/custom-emoji';
|
||||||
|
import { AccountSyncEffects } from './infrastructure/realtime/account-sync/account-sync.effects';
|
||||||
import { MessagesEffects } from './store/messages/messages.effects';
|
import { MessagesEffects } from './store/messages/messages.effects';
|
||||||
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
|
||||||
import { UserAvatarEffects } from './store/users/user-avatar.effects';
|
import { UserAvatarEffects } from './store/users/user-avatar.effects';
|
||||||
@@ -45,6 +46,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
}),
|
}),
|
||||||
provideEffects([
|
provideEffects([
|
||||||
NotificationsEffects,
|
NotificationsEffects,
|
||||||
|
AccountSyncEffects,
|
||||||
CustomEmojiSyncEffects,
|
CustomEmojiSyncEffects,
|
||||||
MessagesEffects,
|
MessagesEffects,
|
||||||
MessagesSyncEffects,
|
MessagesSyncEffects,
|
||||||
|
|||||||
@@ -317,6 +317,46 @@ export class CustomEmojiService {
|
|||||||
const peers = this.webrtc.getConnectedPeers();
|
const peers = this.webrtc.getConnectedPeers();
|
||||||
|
|
||||||
await Promise.all(peers.map((peerId) => this.sendEmojiToPeer(peerId, emoji)));
|
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> {
|
private async sendEmojiToPeer(peerId: string, emoji: CustomEmoji): Promise<void> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||||
import { FriendRepository } from '../../infrastructure/friend.repository';
|
import { FriendRepository } from '../../infrastructure/friend.repository';
|
||||||
import type { Friend } from '../../domain/models/direct-message.model';
|
import type { Friend } from '../../domain/models/direct-message.model';
|
||||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||||
@@ -15,6 +16,7 @@ import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
|||||||
export class FriendService {
|
export class FriendService {
|
||||||
private readonly repository = inject(FriendRepository);
|
private readonly repository = inject(FriendRepository);
|
||||||
private readonly store = inject(Store);
|
private readonly store = inject(Store);
|
||||||
|
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
private readonly friendsSignal = signal<Friend[]>([]);
|
private readonly friendsSignal = signal<Friend[]>([]);
|
||||||
private loadedOwnerId: string | null = null;
|
private loadedOwnerId: string | null = null;
|
||||||
@@ -36,11 +38,42 @@ export class FriendService {
|
|||||||
|
|
||||||
await this.repository.addFriend(ownerId, friend);
|
await this.repository.addFriend(ownerId, friend);
|
||||||
await this.loadForOwner(ownerId, true);
|
await this.loadForOwner(ownerId, true);
|
||||||
|
this.webrtc.relayAccountSync({
|
||||||
|
type: 'friend-added',
|
||||||
|
userId,
|
||||||
|
addedAt: friend.addedAt
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeFriend(userId: string): Promise<void> {
|
async removeFriend(userId: string): Promise<void> {
|
||||||
const ownerId = await this.requireOwnerId();
|
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.repository.removeFriend(ownerId, userId);
|
||||||
await this.loadForOwner(ownerId, true);
|
await this.loadForOwner(ownerId, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ Beyond free-text search, the directory exposes curated discovery lists that powe
|
|||||||
- `ServerDirectoryFacade.getFeaturedServers()` → `GET /api/servers/featured`
|
- `ServerDirectoryFacade.getFeaturedServers()` → `GET /api/servers/featured`
|
||||||
- `ServerDirectoryFacade.getTrendingServers()` → `GET /api/servers/trending`
|
- `ServerDirectoryFacade.getTrendingServers()` → `GET /api/servers/trending`
|
||||||
|
|
||||||
Both pass through `ServerDirectoryService` to `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()`, which share a private `getDiscoveryServers(path)` HTTP helper and normalise results into `ServerInfo[]` exactly like search. The server ranks featured servers (stable curation) and trending servers (recent activity) via `server-ranking.util.ts`; each route caps results at 50 (`parseDiscoveryLimit`). The discovery routes are registered before the parameterised `/:id` route so `featured`/`trending` are not captured as server IDs.
|
Both pass through `ServerDirectoryService` to `ServerDirectoryApiService.getFeaturedServers()` / `getTrendingServers()`, which share a private `getDiscoveryServers(path)` HTTP helper and normalise results into `ServerInfo[]` exactly like search. Discovery **fans out across every online endpoint** (`getSearchableEndpoints()` + `forkJoin`, deduplicated by ID), mirroring all-endpoint search — querying only the active endpoint made the default `/servers` view appear empty whenever that endpoint was a discovery-unsupported production host (`endpointSupportsServerDiscovery`), even though plenty of servers existed on other online endpoints. Each endpoint that is in `DISCOVERY_UNSUPPORTED_HOSTS` is skipped (returns `[]`) per-endpoint rather than short-circuiting the whole request. The server ranks featured servers (stable curation) and trending servers (recent activity) via `server-ranking.util.ts`; each route caps results at 50 (`parseDiscoveryLimit`). The discovery routes are registered before the parameterised `/:id` route so `featured`/`trending` are not captured as server IDs.
|
||||||
|
|
||||||
`FindServersComponent` (`/servers`) composes these into discovery sections — **Recently active** (the user's saved rooms, capped at 6), **Featured servers**, and **Trending** — and renders them through the reusable `app-server-browser`. `DashboardComponent` (`/dashboard`) uses the same facade methods for its quick search results.
|
`FindServersComponent` (`/servers`) composes these into discovery sections — **Recently active** (the user's saved rooms, capped at 6), **Featured servers**, and **Trending** — and renders them through the reusable `app-server-browser`. `DashboardComponent` (`/dashboard`) uses the same facade methods for its quick search results.
|
||||||
|
|
||||||
|
|||||||
@@ -80,3 +80,80 @@ describe('ServerDirectoryApiService discovery endpoints', () => {
|
|||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createMultiEndpointHarness(
|
||||||
|
endpoints: { id: string; name: string; url: string; status: string }[],
|
||||||
|
getImpl: (url: string) => unknown
|
||||||
|
) {
|
||||||
|
const get = vi.fn((url: string) => of(getImpl(url) ?? { servers: [], total: 0 }));
|
||||||
|
const http = { get } as unknown as HttpClient;
|
||||||
|
const endpointState = {
|
||||||
|
activeServer: () => endpoints[0],
|
||||||
|
activeServers: () => endpoints,
|
||||||
|
servers: () => [],
|
||||||
|
resolveCanonicalEndpoint: (endpoint: unknown) => endpoint ?? null,
|
||||||
|
findServerByUrl: () => null,
|
||||||
|
sanitiseUrl: (value: string) => value
|
||||||
|
} as unknown as ServerEndpointStateService;
|
||||||
|
const injector = Injector.create({
|
||||||
|
providers: [
|
||||||
|
ServerDirectoryApiService,
|
||||||
|
{ provide: HttpClient, useValue: http },
|
||||||
|
{ provide: ServerEndpointStateService, useValue: endpointState }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
const service = runInInjectionContext(injector, () => injector.get(ServerDirectoryApiService));
|
||||||
|
|
||||||
|
return { service, get };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ServerDirectoryApiService discovery fan-out', () => {
|
||||||
|
const endpoints = [
|
||||||
|
{ id: 'ep-1', name: 'Local', url: 'https://local.test', status: 'online' },
|
||||||
|
{ id: 'ep-2', name: 'Other', url: 'https://other.test', status: 'online' },
|
||||||
|
{ id: 'ep-3', name: 'Prod', url: 'https://signal.toju.app', status: 'online' }
|
||||||
|
];
|
||||||
|
|
||||||
|
it('aggregates discovery results across all online endpoints', async () => {
|
||||||
|
const { service, get } = createMultiEndpointHarness(endpoints, (url) => {
|
||||||
|
if (url.startsWith('https://local.test'))
|
||||||
|
return { servers: [{ id: 's1', name: 'Alpha' }], total: 1 };
|
||||||
|
|
||||||
|
if (url.startsWith('https://other.test'))
|
||||||
|
return { servers: [{ id: 's2', name: 'Beta' }], total: 1 };
|
||||||
|
|
||||||
|
return { servers: [], total: 0 };
|
||||||
|
});
|
||||||
|
const result = await firstValueFrom(service.getFeaturedServers());
|
||||||
|
|
||||||
|
expect(result.map((server) => server.id).sort()).toEqual(['s1', 's2']);
|
||||||
|
const calledUrls = get.mock.calls.map((call) => call[0] as string);
|
||||||
|
|
||||||
|
expect(calledUrls).toContain('https://local.test/api/servers/featured');
|
||||||
|
expect(calledUrls).toContain('https://other.test/api/servers/featured');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not query discovery-unsupported hosts but still returns the supported endpoints', async () => {
|
||||||
|
const { service, get } = createMultiEndpointHarness(endpoints, (url) => {
|
||||||
|
if (url.startsWith('https://local.test'))
|
||||||
|
return { servers: [{ id: 's1', name: 'Alpha' }], total: 1 };
|
||||||
|
|
||||||
|
return { servers: [], total: 0 };
|
||||||
|
});
|
||||||
|
const result = await firstValueFrom(service.getTrendingServers());
|
||||||
|
|
||||||
|
expect(result.map((server) => server.id)).toEqual(['s1']);
|
||||||
|
const calledUrls = get.mock.calls.map((call) => call[0] as string);
|
||||||
|
|
||||||
|
expect(calledUrls.some((url) => url.includes('signal.toju.app'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates the same server returned by multiple endpoints', async () => {
|
||||||
|
const { service } = createMultiEndpointHarness(endpoints, () =>
|
||||||
|
({ servers: [{ id: 'dup', name: 'Shared' }], total: 1 })
|
||||||
|
);
|
||||||
|
const result = await firstValueFrom(service.getFeaturedServers());
|
||||||
|
|
||||||
|
expect(result.map((server) => server.id)).toEqual(['dup']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -297,18 +297,43 @@ export class ServerDirectoryApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getDiscoveryServers(kind: 'featured' | 'trending', limit?: number): Observable<ServerInfo[]> {
|
private getDiscoveryServers(kind: 'featured' | 'trending', limit?: number): Observable<ServerInfo[]> {
|
||||||
const baseUrl = this.resolveBaseServerUrl();
|
const params = typeof limit === 'number' ? new HttpParams().set('limit', String(limit)) : undefined;
|
||||||
|
const onlineEndpoints = this.getSearchableEndpoints();
|
||||||
|
|
||||||
|
// Fan discovery out across every online endpoint (mirroring search) so the
|
||||||
|
// default find-servers view isn't empty just because the *active* endpoint
|
||||||
|
// is a discovery-unsupported production host. Querying only the active
|
||||||
|
// endpoint made servers invisible until the user typed a search query.
|
||||||
|
if (onlineEndpoints.length === 0) {
|
||||||
|
return this.fetchDiscoveryFromEndpoint(
|
||||||
|
kind,
|
||||||
|
this.resolveBaseServerUrl(),
|
||||||
|
this.endpointState.activeServer(),
|
||||||
|
params
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return forkJoin(
|
||||||
|
onlineEndpoints.map((endpoint) =>
|
||||||
|
this.fetchDiscoveryFromEndpoint(kind, endpoint.url, endpoint, params)
|
||||||
|
)
|
||||||
|
).pipe(map((resultArrays) => this.deduplicateById(resultArrays.flat())));
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchDiscoveryFromEndpoint(
|
||||||
|
kind: 'featured' | 'trending',
|
||||||
|
baseUrl: string,
|
||||||
|
source: ServerEndpoint | null | undefined,
|
||||||
|
params?: HttpParams
|
||||||
|
): Observable<ServerInfo[]> {
|
||||||
if (!endpointSupportsServerDiscovery(baseUrl)) {
|
if (!endpointSupportsServerDiscovery(baseUrl)) {
|
||||||
return of([]);
|
return of([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const params = typeof limit === 'number' ? new HttpParams().set('limit', String(limit)) : undefined;
|
|
||||||
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers/${kind}`, params ? { params } : {})
|
.get<{ servers: ServerInfo[]; total: number }>(`${baseUrl}/api/servers/${kind}`, params ? { params } : {})
|
||||||
.pipe(
|
.pipe(
|
||||||
map((response) => this.normalizeServerList(response, this.endpointState.activeServer())),
|
map((response) => this.normalizeServerList(response, source ?? null)),
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
console.error(`Failed to get ${kind} servers:`, error);
|
console.error(`Failed to get ${kind} servers:`, error);
|
||||||
return of([]);
|
return of([]);
|
||||||
|
|||||||
@@ -165,11 +165,11 @@
|
|||||||
<div
|
<div
|
||||||
class="grid w-full overflow-hidden duration-200 ease-out motion-reduce:transition-none"
|
class="grid w-full overflow-hidden duration-200 ease-out motion-reduce:transition-none"
|
||||||
style="transition-property: grid-template-rows, opacity"
|
style="transition-property: grid-template-rows, opacity"
|
||||||
[style.gridTemplateRows]="isOnSearch() ? '1fr' : '0fr'"
|
[style.gridTemplateRows]="isOnServers() ? '1fr' : '0fr'"
|
||||||
[style.opacity]="isOnSearch() ? '1' : '0'"
|
[style.opacity]="isOnServers() ? '1' : '0'"
|
||||||
[style.visibility]="isOnSearch() ? 'visible' : 'hidden'"
|
[style.visibility]="isOnServers() ? 'visible' : 'hidden'"
|
||||||
[class.pointer-events-none]="!isOnSearch()"
|
[class.pointer-events-none]="!isOnServers()"
|
||||||
[attr.aria-hidden]="isOnSearch() ? null : 'true'"
|
[attr.aria-hidden]="isOnServers() ? null : 'true'"
|
||||||
>
|
>
|
||||||
<div class="overflow-hidden">
|
<div class="overflow-hidden">
|
||||||
<app-user-bar />
|
<app-user-bar />
|
||||||
|
|||||||
@@ -100,12 +100,12 @@ export class ServersRailComponent {
|
|||||||
currentUser = this.store.selectSignal(selectCurrentUser);
|
currentUser = this.store.selectSignal(selectCurrentUser);
|
||||||
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
onlineUsers = this.store.selectSignal(selectOnlineUsers);
|
||||||
bannedRoomLookup = signal<Record<string, boolean>>({});
|
bannedRoomLookup = signal<Record<string, boolean>>({});
|
||||||
isOnSearch = toSignal(
|
isOnServers = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
filter((navigationEvent): navigationEvent is NavigationEnd => navigationEvent instanceof NavigationEnd),
|
||||||
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/search'))
|
map((navigationEvent) => navigationEvent.urlAfterRedirects.startsWith('/servers'))
|
||||||
),
|
),
|
||||||
{ initialValue: this.router.url.startsWith('/search') }
|
{ initialValue: this.router.url.startsWith('/servers') }
|
||||||
);
|
);
|
||||||
isOnDirectMessage = toSignal(
|
isOnDirectMessage = toSignal(
|
||||||
this.router.events.pipe(
|
this.router.events.pipe(
|
||||||
@@ -393,7 +393,7 @@ export class ServersRailComponent {
|
|||||||
|
|
||||||
if (isCurrentRoom) {
|
if (isCurrentRoom) {
|
||||||
this.optimisticSelectedRoomId.set(null);
|
this.optimisticSelectedRoomId.set(null);
|
||||||
this.router.navigate(['/search']);
|
this.router.navigate(['/servers']);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.showLeaveConfirm.set(false);
|
this.showLeaveConfirm.set(false);
|
||||||
|
|||||||
@@ -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.
|
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`.
|
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.
|
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,
|
inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Observable, Subject } from 'rxjs';
|
import { Observable, Subject, merge } from 'rxjs';
|
||||||
import { ChatEvent } from '../../shared-kernel';
|
import { ChatEvent } from '../../shared-kernel';
|
||||||
import type { SignalingMessage } 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 { TimeSyncService } from '../../core/services/time-sync.service';
|
||||||
import { DebuggingService } from '../../core/services/debugging';
|
import { DebuggingService } from '../../core/services/debugging';
|
||||||
import { ScreenShareSourcePickerService } from '../../domains/screen-share';
|
import { ScreenShareSourcePickerService } from '../../domains/screen-share';
|
||||||
@@ -83,12 +89,14 @@ export class WebRTCService implements OnDestroy {
|
|||||||
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
|
private readonly signalingMessage$ = new Subject<IncomingSignalingMessage>();
|
||||||
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
readonly onSignalingMessage = this.signalingMessage$.asObservable();
|
||||||
|
|
||||||
|
private readonly accountSyncRelay$ = new Subject<ChatEvent>();
|
||||||
|
|
||||||
private readonly signalingReconnectedSubject$ = new Subject<string>();
|
private readonly signalingReconnectedSubject$ = new Subject<string>();
|
||||||
readonly signalingReconnected$ = this.signalingReconnectedSubject$.asObservable();
|
readonly signalingReconnected$ = this.signalingReconnectedSubject$.asObservable();
|
||||||
|
|
||||||
// Delegates to managers
|
// Delegates to managers
|
||||||
get onMessageReceived(): Observable<ChatEvent> {
|
get onMessageReceived(): Observable<ChatEvent> {
|
||||||
return this.peerMediaFacade.onMessageReceived;
|
return merge(this.peerMediaFacade.onMessageReceived, this.accountSyncRelay$);
|
||||||
}
|
}
|
||||||
get onPeerConnected(): Observable<string> {
|
get onPeerConnected(): Observable<string> {
|
||||||
return this.peerMediaFacade.onPeerConnected;
|
return this.peerMediaFacade.onPeerConnected;
|
||||||
@@ -304,6 +312,19 @@ export class WebRTCService implements OnDestroy {
|
|||||||
return;
|
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.signalingMessage$.next(message);
|
||||||
this.signalingMessageHandler.handleMessage(message, signalUrl);
|
this.signalingMessageHandler.handleMessage(message, signalUrl);
|
||||||
}
|
}
|
||||||
@@ -451,6 +472,24 @@ export class WebRTCService implements OnDestroy {
|
|||||||
this.relayBroadcastEvent(event);
|
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.
|
* Send a {@link ChatEvent} to a specific peer.
|
||||||
*
|
*
|
||||||
@@ -742,7 +781,11 @@ export class WebRTCService implements OnDestroy {
|
|||||||
},
|
},
|
||||||
clientInstanceId
|
clientInstanceId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.relayAccountSync(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
requestVoiceClientTakeover(): void {
|
requestVoiceClientTakeover(): void {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface ChatInventoryItem {
|
|||||||
|
|
||||||
export interface ChatEventBase {
|
export interface ChatEventBase {
|
||||||
fromPeerId?: string;
|
fromPeerId?: string;
|
||||||
|
clientInstanceId?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
message?: Message;
|
message?: Message;
|
||||||
reaction?: Reaction;
|
reaction?: Reaction;
|
||||||
@@ -311,6 +312,27 @@ export interface ServerIconUpdateEvent extends ChatEventBase {
|
|||||||
iconUpdatedAt: number;
|
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 {
|
export interface UserAvatarSummaryEvent extends ChatEventBase {
|
||||||
type: 'user-avatar-summary';
|
type: 'user-avatar-summary';
|
||||||
oderId: string;
|
oderId: string;
|
||||||
@@ -507,6 +529,10 @@ export type ChatEvent =
|
|||||||
| ServerIconRequestEvent
|
| ServerIconRequestEvent
|
||||||
| ServerIconFullEvent
|
| ServerIconFullEvent
|
||||||
| ServerIconUpdateEvent
|
| ServerIconUpdateEvent
|
||||||
|
| SavedRoomSyncEvent
|
||||||
|
| SavedRoomRemoveEvent
|
||||||
|
| FriendAddedSyncEvent
|
||||||
|
| FriendRemovedSyncEvent
|
||||||
| ServerStateRequestEvent
|
| ServerStateRequestEvent
|
||||||
| ServerStateFullEvent
|
| ServerStateFullEvent
|
||||||
| MemberRosterRequestEvent
|
| MemberRosterRequestEvent
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export const RoomsActions = createActionGroup({
|
|||||||
'Join Room Success': props<{ room: Room }>(),
|
'Join Room Success': props<{ room: Room }>(),
|
||||||
'Join Room Failure': props<{ error: string }>(),
|
'Join Room Failure': props<{ error: string }>(),
|
||||||
|
|
||||||
|
'Import Saved Room': props<{ room: Room }>(),
|
||||||
|
'Remote Forget Saved Room': props<{ roomId: string }>(),
|
||||||
|
|
||||||
'Leave Room': emptyProps(),
|
'Leave Room': emptyProps(),
|
||||||
'Leave Room Success': emptyProps(),
|
'Leave Room Success': emptyProps(),
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,17 @@ export const roomsReducer = createReducer(
|
|||||||
error
|
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
|
// Leave room
|
||||||
on(RoomsActions.leaveRoom, (state) => ({
|
on(RoomsActions.leaveRoom, (state) => ({
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
} from '../../shared-kernel';
|
} from '../../shared-kernel';
|
||||||
import type { ChatEvent, User } from '../../shared-kernel';
|
import type { ChatEvent, User } from '../../shared-kernel';
|
||||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
|
import { pushProfileViaAccountSync as relayProfileViaAccountSync } from '../../infrastructure/realtime/account-sync/account-sync-profile.helper';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import { DatabaseService } from '../../infrastructure/persistence';
|
||||||
import { UsersActions } from './users.actions';
|
import { UsersActions } from './users.actions';
|
||||||
import { selectAllUsers, selectCurrentUser } from './users.selectors';
|
import { selectAllUsers, selectCurrentUser } from './users.selectors';
|
||||||
@@ -33,6 +34,8 @@ import { findRoomMember } from '../rooms/room-members.helpers';
|
|||||||
|
|
||||||
interface PendingAvatarTransfer {
|
interface PendingAvatarTransfer {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
profileUpdatedAt?: number;
|
||||||
mime?: string;
|
mime?: string;
|
||||||
oderId: string;
|
oderId: string;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -206,6 +209,7 @@ export class UserAvatarEffects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.webrtc.broadcastMessage(this.buildAvatarSummary(currentUser));
|
this.webrtc.broadcastMessage(this.buildAvatarSummary(currentUser));
|
||||||
|
void relayProfileViaAccountSync(this.webrtc, currentUser);
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
@@ -236,7 +240,7 @@ export class UserAvatarEffects {
|
|||||||
]) => {
|
]) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'user-avatar-summary':
|
case 'user-avatar-summary':
|
||||||
return this.handleAvatarSummary(event, allUsers);
|
return this.handleAvatarSummary(event, allUsers, currentUser ?? null);
|
||||||
|
|
||||||
case 'user-avatar-request':
|
case 'user-avatar-request':
|
||||||
return this.handleAvatarRequest(event, currentUser ?? null);
|
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) {
|
if (!event.fromPeerId || !event.oderId || !event.avatarUpdatedAt) {
|
||||||
return EMPTY;
|
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);
|
const existingUser = allUsers.find((user) => user.id === event.oderId || user.oderId === event.oderId);
|
||||||
|
|
||||||
if (!shouldRequestAvatarData(existingUser, event)) {
|
if (!shouldRequestAvatarData(existingUser, event)) {
|
||||||
@@ -301,6 +311,8 @@ export class UserAvatarEffects {
|
|||||||
return from(this.buildRemoteAvatarAction({
|
return from(this.buildRemoteAvatarAction({
|
||||||
chunks: [],
|
chunks: [],
|
||||||
displayName: event.displayName || 'User',
|
displayName: event.displayName || 'User',
|
||||||
|
description: event.description,
|
||||||
|
profileUpdatedAt: event.profileUpdatedAt,
|
||||||
mime: event.avatarMime,
|
mime: event.avatarMime,
|
||||||
oderId: event.oderId,
|
oderId: event.oderId,
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -319,6 +331,8 @@ export class UserAvatarEffects {
|
|||||||
this.pendingTransfers.set(event.oderId, {
|
this.pendingTransfers.set(event.oderId, {
|
||||||
chunks: new Array<string | undefined>(event.total),
|
chunks: new Array<string | undefined>(event.total),
|
||||||
displayName: event.displayName || 'User',
|
displayName: event.displayName || 'User',
|
||||||
|
description: event.description,
|
||||||
|
profileUpdatedAt: event.profileUpdatedAt,
|
||||||
mime: event.avatarMime,
|
mime: event.avatarMime,
|
||||||
oderId: event.oderId,
|
oderId: event.oderId,
|
||||||
total: event.total,
|
total: event.total,
|
||||||
@@ -387,6 +401,8 @@ export class UserAvatarEffects {
|
|||||||
oderId: existingUser?.oderId || transfer.oderId,
|
oderId: existingUser?.oderId || transfer.oderId,
|
||||||
username: existingUser?.username || transfer.username,
|
username: existingUser?.username || transfer.username,
|
||||||
displayName: transfer.displayName || existingUser?.displayName || 'User',
|
displayName: transfer.displayName || existingUser?.displayName || 'User',
|
||||||
|
description: transfer.description ?? existingUser?.description,
|
||||||
|
profileUpdatedAt: transfer.profileUpdatedAt ?? existingUser?.profileUpdatedAt,
|
||||||
avatarUrl: dataUrl,
|
avatarUrl: dataUrl,
|
||||||
avatarHash: transfer.hash,
|
avatarHash: transfer.hash,
|
||||||
avatarMime: transfer.mime,
|
avatarMime: transfer.mime,
|
||||||
|
|||||||
Reference in New Issue
Block a user