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:
@@ -147,6 +147,18 @@ Browser chat views render audio/video larger than 50 MB with the same generic fi
|
||||
|
||||
An optional experimental VLC.js adapter can be enabled from General settings. When enabled, unsupported downloaded audio/video files show a manual Play action that lazy-loads `/vlcjs/metoyou-vlc-player.js`. The runtime is intentionally isolated in the experimental media domain and is not part of the default attachment path.
|
||||
|
||||
## Ownership and the "Shared from your device" label
|
||||
|
||||
`uploaderPeerId` is the **user** id of whoever uploaded the file, not a per-device id. It is intentionally stable across a user's devices so an uploader can recognise their own attachments after sync. Because of that, "did *this* device upload it?" and "does *this* device hold the bytes?" are two different questions, and the UI must key the *sharing* affordance off the latter.
|
||||
|
||||
`attachment-sharing.rules.ts` makes this explicit:
|
||||
|
||||
- `isUploaderUser(attachment, currentUserId)` — the current user is the uploader (same user, any device).
|
||||
- `deviceHasLocalCopy(attachment)` — this device physically holds the bytes (`available` + a blob `objectUrl`, or a non-empty `savedPath`/`filePath`). Synced metadata alone does not count, because P2P/account sync strips local paths.
|
||||
- `isSharingFromThisDevice(attachment, currentUserId)` — `isUploaderUser && deviceHasLocalCopy`. Only this returns the "Shared from your device" state.
|
||||
|
||||
The chat message item renders "Shared from your device" (and hides the request/download affordance) **only** when `isSharingFromThisDevice` is true. A second device of the same user that merely synced the message metadata is the uploader-user but holds no local copy, so it falls back to the normal recipient flow (request/download) instead of falsely claiming ownership and blocking the file (regression: the old check used `uploaderPeerId === currentUserId` and so claimed ownership on every device of the uploader). The transfer service uses the same rule to decide whether a no-peers failure should read "your original upload is missing" (sharing device) or "no connected peers" (any other device).
|
||||
|
||||
## Persistence
|
||||
|
||||
Attachment file persistence is platform-agnostic. `AttachmentStorageService` owns the `server/<room>/<bucket>` and `direct-messages/...` path layout and delegates the raw byte IO to a pluggable `AttachmentFileStore` chosen by `PlatformService` (mirroring how `DatabaseService` picks a DB backend):
|
||||
|
||||
@@ -8,6 +8,7 @@ import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
||||
import { isImageAttachment, resolvePublishAttachmentIsImage } from '../../domain/logic/attachment-image.rules';
|
||||
import { isSharingFromThisDevice } from '../../domain/logic/attachment-sharing.rules';
|
||||
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
|
||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||
import {
|
||||
@@ -170,13 +171,15 @@ export class AttachmentTransferService {
|
||||
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
const currentUserId = await this.resolveCurrentUserId();
|
||||
const isUploader = !!attachment.uploaderPeerId &&
|
||||
!!currentUserId &&
|
||||
attachment.uploaderPeerId === currentUserId;
|
||||
// Only the device that actually still holds the original bytes should report a
|
||||
// missing local upload. A second device of the same user that merely synced the
|
||||
// metadata is not the sharing device, so it falls back to the regular peer-request
|
||||
// flow (and the "no connected peers" error when offline) like any other recipient.
|
||||
const sharingFromThisDevice = isSharingFromThisDevice(attachment, currentUserId);
|
||||
|
||||
if (connectedPeers.length === 0) {
|
||||
this.runtimeStore.deletePendingRequest(requestKey);
|
||||
attachment.requestError = isUploader
|
||||
attachment.requestError = sharingFromThisDevice
|
||||
? this.appI18n.instant(UPLOADER_LOCAL_FILE_MISSING_ERROR_KEY)
|
||||
: this.appI18n.instant(NO_CONNECTED_PEERS_REQUEST_ERROR_KEY);
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
deviceHasLocalCopy,
|
||||
isSharingFromThisDevice,
|
||||
isUploaderUser
|
||||
} from './attachment-sharing.rules';
|
||||
|
||||
describe('attachment sharing rules', () => {
|
||||
describe('isUploaderUser', () => {
|
||||
it('is true when the attachment uploader matches the current user', () => {
|
||||
expect(isUploaderUser({ uploaderPeerId: 'user-1' }, 'user-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when the uploader is a different user', () => {
|
||||
expect(isUploaderUser({ uploaderPeerId: 'user-2' }, 'user-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when either id is missing', () => {
|
||||
expect(isUploaderUser({ uploaderPeerId: undefined }, 'user-1')).toBe(false);
|
||||
expect(isUploaderUser({ uploaderPeerId: 'user-1' }, null)).toBe(false);
|
||||
expect(isUploaderUser({ uploaderPeerId: 'user-1' }, '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deviceHasLocalCopy', () => {
|
||||
it('is true when an available blob object url is present', () => {
|
||||
expect(deviceHasLocalCopy({ available: true, objectUrl: 'blob:abc' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is true when a savedPath or filePath is present', () => {
|
||||
expect(deviceHasLocalCopy({ available: false, savedPath: '/appdata/file.bin' })).toBe(true);
|
||||
expect(deviceHasLocalCopy({ available: false, filePath: '/home/me/file.bin' })).toBe(true);
|
||||
});
|
||||
|
||||
it('is false when only metadata exists (no bytes, no paths)', () => {
|
||||
expect(deviceHasLocalCopy({ available: false })).toBe(false);
|
||||
expect(deviceHasLocalCopy({ available: false, savedPath: ' ', filePath: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('is false when marked available but no object url is present yet', () => {
|
||||
expect(deviceHasLocalCopy({ available: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSharingFromThisDevice', () => {
|
||||
it('is true for the uploader device that still holds the file locally', () => {
|
||||
expect(
|
||||
isSharingFromThisDevice({ uploaderPeerId: 'user-1', available: true, objectUrl: 'blob:abc' }, 'user-1')
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
isSharingFromThisDevice({ uploaderPeerId: 'user-1', available: false, savedPath: '/appdata/file.bin' }, 'user-1')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false on a second device of the same user that only synced metadata', () => {
|
||||
// This is the regression: the user uploaded from another device, so the metadata
|
||||
// carries uploaderPeerId === currentUserId, but this device holds no local bytes.
|
||||
expect(
|
||||
isSharingFromThisDevice({ uploaderPeerId: 'user-1', available: false }, 'user-1')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is false for a different user even if they downloaded the file locally', () => {
|
||||
expect(
|
||||
isSharingFromThisDevice({ uploaderPeerId: 'user-1', available: true, objectUrl: 'blob:abc' }, 'user-2')
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Attachment } from '../models/attachment.model';
|
||||
|
||||
/** True when the current user is the one who originally uploaded this attachment. */
|
||||
export function isUploaderUser(
|
||||
attachment: Pick<Attachment, 'uploaderPeerId'>,
|
||||
currentUserId: string | null | undefined
|
||||
): boolean {
|
||||
return !!attachment.uploaderPeerId && !!currentUserId && attachment.uploaderPeerId === currentUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this specific device physically holds the file bytes - either hydrated
|
||||
* in memory (available blob object url) or persisted to disk / present at its original
|
||||
* upload path. Synced metadata alone (no bytes, no local paths) does not count.
|
||||
*/
|
||||
export function deviceHasLocalCopy(
|
||||
attachment: Pick<Attachment, 'available' | 'objectUrl' | 'savedPath' | 'filePath'>
|
||||
): boolean {
|
||||
return (attachment.available === true && hasNonEmptyString(attachment.objectUrl)) ||
|
||||
hasNonEmptyString(attachment.savedPath) ||
|
||||
hasNonEmptyString(attachment.filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when the current user uploaded the file AND this device still holds a local
|
||||
* copy to serve. A second device of the same user that only synced the message metadata
|
||||
* is the uploader-user but has no local bytes, so it must behave like any other peer
|
||||
* (request/download from peers) instead of claiming "Shared from your device" and hiding
|
||||
* the download affordance.
|
||||
*/
|
||||
export function isSharingFromThisDevice(
|
||||
attachment: Pick<Attachment, 'uploaderPeerId' | 'available' | 'objectUrl' | 'savedPath' | 'filePath'>,
|
||||
currentUserId: string | null | undefined
|
||||
): boolean {
|
||||
return isUploaderUser(attachment, currentUserId) && deviceHasLocalCopy(attachment);
|
||||
}
|
||||
|
||||
function hasNonEmptyString(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './application/facades/attachment.facade';
|
||||
export * from './domain/constants/attachment.constants';
|
||||
export * from './domain/logic/attachment-sharing.rules';
|
||||
export * from './domain/logic/local-file-path.rules';
|
||||
export * from './domain/models/attachment.model';
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
<div class="text-xs text-muted-foreground">{{ formatBytes(att.size) }}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@if (!att.isUploader) {
|
||||
@if (!att.isSharingFromThisDevice) {
|
||||
@if (!att.available) {
|
||||
<div class="h-1.5 w-24 rounded bg-muted">
|
||||
<div
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentFacade,
|
||||
isSharingFromThisDevice,
|
||||
MAX_BROWSER_INLINE_MEDIA_SIZE_BYTES,
|
||||
MAX_AUTO_SAVE_SIZE_BYTES
|
||||
} from '../../../../../attachment';
|
||||
@@ -107,7 +108,7 @@ interface ChatMessageAttachmentViewModel extends Attachment {
|
||||
canUseExperimentalPlayer: boolean;
|
||||
experimentalPlayerActive: boolean;
|
||||
isAudio: boolean;
|
||||
isUploader: boolean;
|
||||
isSharingFromThisDevice: boolean;
|
||||
isVideo: boolean;
|
||||
mediaActionLabel: string;
|
||||
mediaStatusText: string;
|
||||
@@ -711,10 +712,8 @@ export class ChatMessageItemComponent implements OnDestroy {
|
||||
return attachment.requestError ? this.appI18n.instant('chat.message.retry') : this.appI18n.instant('chat.message.request');
|
||||
}
|
||||
|
||||
isUploader(attachment: Attachment): boolean {
|
||||
const currentUserId = this.currentUserId();
|
||||
|
||||
return !!attachment.uploaderPeerId && !!currentUserId && attachment.uploaderPeerId === currentUserId;
|
||||
isSharingFromThisDevice(attachment: Attachment): boolean {
|
||||
return isSharingFromThisDevice(attachment, this.currentUserId());
|
||||
}
|
||||
|
||||
requestAttachment(attachment: Attachment): void {
|
||||
@@ -860,7 +859,7 @@ export class ChatMessageItemComponent implements OnDestroy {
|
||||
canUseExperimentalPlayer,
|
||||
experimentalPlayerActive: canUseExperimentalPlayer && this.experimentalPlayerAttachmentId() === attachment.id,
|
||||
isAudio,
|
||||
isUploader: this.isUploader(attachment),
|
||||
isSharingFromThisDevice: this.isSharingFromThisDevice(attachment),
|
||||
isVideo,
|
||||
mediaActionLabel: requiresMediaDownloadAcceptance
|
||||
? attachment.requestError
|
||||
|
||||
@@ -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