fix: Bug - Two devices sharing same user says "Shared from your device"
Gate the "Shared from your device" label and the hidden download affordance on whether this device actually holds the file bytes, not on whether the current user uploaded it. uploaderPeerId is the user id, so the old check claimed ownership on every device of the uploader, blocking view/download on second devices that only synced metadata. Also include attachment metadata in the account_sync chat-sync-batch so sibling devices learn about synced attachments at all. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -172,7 +172,7 @@ 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, **chat message creates/revisions**) 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 including chunked `chat-sync-batch` history for every saved room.
|
||||
Account-owned state (saved servers, friends, profile avatar/card text, custom emoji library, server icons, message edits/reactions, **chat message creates/revisions**) 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 including chunked `chat-sync-batch` history for every saved room. Each `chat-sync-batch` carries its messages' attachment metadata (`attachments` map, local paths stripped) so sibling devices learn about synced attachments without holding the bytes.
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pushSavedRoomMessagesViaAccountSync } from './account-sync-chat.helper';
|
||||
import type { Message } from '../../../shared-kernel';
|
||||
import type { Message, ChatAttachmentMeta } from '../../../shared-kernel';
|
||||
|
||||
function createMessage(id: string, roomId: string): Message {
|
||||
return {
|
||||
@@ -14,6 +14,18 @@ function createMessage(id: string, roomId: string): Message {
|
||||
};
|
||||
}
|
||||
|
||||
function createAttachmentMeta(id: string, messageId: string): ChatAttachmentMeta {
|
||||
return {
|
||||
id,
|
||||
messageId,
|
||||
filename: `${id}.bin`,
|
||||
size: 10,
|
||||
mime: 'application/octet-stream',
|
||||
isImage: false,
|
||||
uploaderPeerId: 'user-1'
|
||||
};
|
||||
}
|
||||
|
||||
describe('pushSavedRoomMessagesViaAccountSync', () => {
|
||||
it('relays saved room messages in chat-sync-batch chunks to sibling devices', async () => {
|
||||
const relayAccountSync = vi.fn();
|
||||
@@ -34,7 +46,8 @@ describe('pushSavedRoomMessagesViaAccountSync', () => {
|
||||
await pushSavedRoomMessagesViaAccountSync(
|
||||
{ relayAccountSync },
|
||||
loadRoomMessages,
|
||||
['room-a', 'room-b']
|
||||
['room-a', 'room-b'],
|
||||
() => ({})
|
||||
);
|
||||
|
||||
expect(loadRoomMessages).toHaveBeenCalledWith('room-a');
|
||||
@@ -51,4 +64,48 @@ describe('pushSavedRoomMessagesViaAccountSync', () => {
|
||||
messages: roomB
|
||||
});
|
||||
});
|
||||
|
||||
it('includes attachment metadata so sibling devices learn about synced attachments', async () => {
|
||||
const relayAccountSync = vi.fn();
|
||||
const roomA = [createMessage('m1', 'room-a'), createMessage('m2', 'room-a')];
|
||||
const attachmentForM1 = createAttachmentMeta('att-1', 'm1');
|
||||
const loadRoomMessages = vi.fn(async () => roomA);
|
||||
const loadAttachmentMetas = vi.fn((messageIds: string[]) =>
|
||||
messageIds.includes('m1') ? { m1: [attachmentForM1] } : {}
|
||||
);
|
||||
|
||||
await pushSavedRoomMessagesViaAccountSync(
|
||||
{ relayAccountSync },
|
||||
loadRoomMessages,
|
||||
['room-a'],
|
||||
loadAttachmentMetas
|
||||
);
|
||||
|
||||
expect(loadAttachmentMetas).toHaveBeenCalledWith(['m1', 'm2']);
|
||||
expect(relayAccountSync).toHaveBeenCalledWith({
|
||||
type: 'chat-sync-batch',
|
||||
roomId: 'room-a',
|
||||
messages: roomA,
|
||||
attachments: { m1: [attachmentForM1] }
|
||||
});
|
||||
});
|
||||
|
||||
it('omits the attachments field when a chunk has no attachments', async () => {
|
||||
const relayAccountSync = vi.fn();
|
||||
const roomA = [createMessage('m1', 'room-a')];
|
||||
const loadRoomMessages = vi.fn(async () => roomA);
|
||||
|
||||
await pushSavedRoomMessagesViaAccountSync(
|
||||
{ relayAccountSync },
|
||||
loadRoomMessages,
|
||||
['room-a'],
|
||||
() => ({})
|
||||
);
|
||||
|
||||
expect(relayAccountSync).toHaveBeenCalledWith({
|
||||
type: 'chat-sync-batch',
|
||||
roomId: 'room-a',
|
||||
messages: roomA
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Message } from '../../../shared-kernel';
|
||||
import type {
|
||||
Message,
|
||||
ChatAttachmentMeta,
|
||||
ChatEvent
|
||||
} from '../../../shared-kernel';
|
||||
import type { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
@@ -6,22 +10,50 @@ import {
|
||||
chunkArray
|
||||
} from '../../../store/messages/messages.helpers';
|
||||
|
||||
type AttachmentMetaMap = Record<string, ChatAttachmentMeta[]>;
|
||||
|
||||
export async function pushSavedRoomMessagesViaAccountSync(
|
||||
webrtc: Pick<RealtimeSessionFacade, 'relayAccountSync'>,
|
||||
loadRoomMessages: (roomId: string) => Promise<Message[]>,
|
||||
roomIds: readonly string[]
|
||||
roomIds: readonly string[],
|
||||
loadAttachmentMetas: (messageIds: string[]) => AttachmentMetaMap
|
||||
): Promise<void> {
|
||||
for (const roomId of roomIds) {
|
||||
const messages = await loadRoomMessages(roomId);
|
||||
|
||||
for (const chunk of chunkArray(messages, CHUNK_SIZE)) {
|
||||
webrtc.relayAccountSync({
|
||||
const chunkAttachments = collectChunkAttachments(chunk, loadAttachmentMetas);
|
||||
const batch: ChatEvent = {
|
||||
type: 'chat-sync-batch',
|
||||
roomId,
|
||||
messages: chunk
|
||||
});
|
||||
};
|
||||
|
||||
if (Object.keys(chunkAttachments).length > 0) {
|
||||
batch.attachments = chunkAttachments;
|
||||
}
|
||||
|
||||
webrtc.relayAccountSync(batch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectChunkAttachments(
|
||||
chunk: Message[],
|
||||
loadAttachmentMetas: (messageIds: string[]) => AttachmentMetaMap
|
||||
): AttachmentMetaMap {
|
||||
const attachmentMetas = loadAttachmentMetas(chunk.map((message) => message.id));
|
||||
const chunkAttachments: AttachmentMetaMap = {};
|
||||
|
||||
for (const message of chunk) {
|
||||
const metas = attachmentMetas[message.id];
|
||||
|
||||
if (metas && metas.length > 0) {
|
||||
chunkAttachments[message.id] = metas;
|
||||
}
|
||||
}
|
||||
|
||||
return chunkAttachments;
|
||||
}
|
||||
|
||||
export const ACCOUNT_SYNC_MESSAGE_LIMIT = FULL_SYNC_LIMIT;
|
||||
|
||||
@@ -19,6 +19,7 @@ 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 { AttachmentFacade } from '../../../domains/attachment';
|
||||
import { shouldApplyAccountSyncPayload } from './account-sync.rules';
|
||||
import { ACCOUNT_SYNC_MESSAGE_LIMIT, pushSavedRoomMessagesViaAccountSync } from './account-sync-chat.helper';
|
||||
import { pushProfileViaAccountSync } from './account-sync-profile.helper';
|
||||
@@ -33,6 +34,7 @@ export class AccountSyncEffects {
|
||||
private readonly db = inject(DatabaseService);
|
||||
private readonly friends = inject(FriendService);
|
||||
private readonly customEmoji = inject(CustomEmojiService);
|
||||
private readonly attachments = inject(AttachmentFacade);
|
||||
|
||||
broadcastSavedRoom$ = createEffect(
|
||||
() =>
|
||||
@@ -149,7 +151,8 @@ export class AccountSyncEffects {
|
||||
await pushSavedRoomMessagesViaAccountSync(
|
||||
this.webrtc,
|
||||
(roomId) => this.db.getMessages(roomId, ACCOUNT_SYNC_MESSAGE_LIMIT, 0),
|
||||
savedRooms.map((room) => room.id)
|
||||
savedRooms.map((room) => room.id),
|
||||
(messageIds) => this.attachments.getAttachmentMetasForMessages(messageIds)
|
||||
);
|
||||
|
||||
const friends = await this.friends.friends();
|
||||
|
||||
Reference in New Issue
Block a user