diff --git a/docs/SYNC-merge-vectors.json b/docs/SYNC-merge-vectors.json index 1f8e342..fecd4bd 100644 --- a/docs/SYNC-merge-vectors.json +++ b/docs/SYNC-merge-vectors.json @@ -1324,5 +1324,1343 @@ "channelsLeftAt": {} } } - ] + ], + "publish": { + "base": { + "sentMessages": { + "0x00000000000000000000000000000000000000a1/Pombo-DM-1": [ + { + "id": "m1", + "type": "text", + "text": "text of m1", + "timestamp": 1000 + } + ] + }, + "sentDeletedAt": { + "0x00000000000000000000000000000000000000a1/Pombo-DM-1": { + "m0": 900 + } + }, + "sentReactions": { + "0x00000000000000000000000000000000000000a1/Pombo-DM-1": { + "m1": { + "👍": [ + "0x0000000000000000000000000000000000000001" + ] + } + } + }, + "channels": [ + { + "messageStreamId": "0x00000000000000000000000000000000000000a1/c0ffee-1", + "name": "Channel", + "type": "gated", + "createdAt": 1000, + "joinedAt": 1000, + "storageDays": 30, + "accessSnapshot": [ + "0x0000000000000000000000000000000000000001" + ], + "ephemeralStreamId": "0x00000000000000000000000000000000000000a1/c0ffee-2", + "adminStreamId": "0x00000000000000000000000000000000000000a1/c0ffee-3", + "keysStreamId": "0x00000000000000000000000000000000000000a1/c0ffee-4", + "interactionsStreamId": "0x00000000000000000000000000000000000000a1/c0ffee-5", + "inboxStreamId": "0x0000000000000000000000000000000000000009/Pombo-DM-1", + "storageProvider": "streamr", + "gate": { + "address": "0x0000000000000000000000000000000000000007" + }, + "wireIdentity": "sealed", + "createdBy": "0x0000000000000000000000000000000000000008", + "password": "secret", + "members": [ + "0x0000000000000000000000000000000000000001" + ], + "rotatedForNoAccess": [ + "0x0000000000000000000000000000000000000002" + ], + "knownBanned": [ + "0x0000000000000000000000000000000000000003" + ], + "storageEnabled": true, + "adminStorageDays": 30, + "keysStorageDays": 30, + "interactionsStorageDays": 30, + "exposure": "hidden", + "description": "about", + "language": "en", + "category": "news", + "metaUpdatedAt": 2000, + "readOnly": true, + "writeOnly": true, + "classification": "club", + "peerAddress": "0x0000000000000000000000000000000000000004", + "fieldTs": { + "name": 3000 + } + } + ], + "channelsLeftAt": { + "0x0000000000000000000000000000000000000005/gone-1": 500 + }, + "epochKeys": { + "0x00000000000000000000000000000000000000a1/c0ffee-1": { + "epochs": { + "k1": { + "keyHex": "0xaa", + "keyHash": "0xa1", + "epoch": 1 + } + }, + "announces": { + "1": { + "keyId": "k1", + "keyHash": "0xa1", + "timestamp": 100, + "validFrom": 100 + } + }, + "currentEpoch": 1, + "pendingRequests": { + "r1": { + "fromEpoch": 1, + "sentAt": 100 + } + }, + "helloEpochs": [ + 1 + ], + "helloName": "Bob", + "helloTs": 100, + "seenRequesters": [ + "0x0000000000000000000000000000000000000001" + ], + "pubKey": { + "keyId": "p1", + "keyHex": "0xbb", + "rev": 1 + }, + "pubAnnounce": { + "keyId": "p1", + "rev": 1, + "timestamp": 100 + }, + "intKey": { + "keyId": "i1", + "keyHex": "0xcc", + "rev": 1 + }, + "intAnnounce": { + "keyId": "i1", + "rev": 1, + "timestamp": 100 + } + } + }, + "blockedPeers": [ + "0x0000000000000000000000000000000000000006" + ], + "dmLeftAt": { + "0x0000000000000000000000000000000000000010": 700 + }, + "trustedContacts": { + "0x0000000000000000000000000000000000000011": { + "nickname": "Carol", + "addedAt": 800 + } + }, + "ensCache": { + "0x0000000000000000000000000000000000000011": { + "name": "carol.eth", + "timestamp": 100 + } + }, + "username": "Bob", + "graphApiKey": "key", + "sliceTs": { + "username": 100 + } + }, + "cases": [ + { + "what": "a change in sentMessages is news", + "patch": { + "set": [ + [ + [ + "sentMessages" + ], + { + "0x00000000000000000000000000000000000000a1/Pombo-DM-1": [ + { + "id": "m1", + "type": "text", + "text": "text of m1", + "timestamp": 1000 + } + ], + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in sentDeletedAt is news", + "patch": { + "set": [ + [ + [ + "sentDeletedAt" + ], + { + "0x00000000000000000000000000000000000000a1/Pombo-DM-1": { + "m0": 900 + }, + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in sentReactions is news", + "patch": { + "set": [ + [ + [ + "sentReactions" + ], + { + "0x00000000000000000000000000000000000000a1/Pombo-DM-1": { + "m1": { + "👍": [ + "0x0000000000000000000000000000000000000001" + ] + } + }, + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in channelsLeftAt is news", + "patch": { + "set": [ + [ + [ + "channelsLeftAt" + ], + { + "0x0000000000000000000000000000000000000005/gone-1": 500, + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in blockedPeers is news", + "patch": { + "set": [ + [ + [ + "blockedPeers" + ], + [ + "0x0000000000000000000000000000000000000006", + "x" + ] + ] + ] + }, + "same": false + }, + { + "what": "a change in dmLeftAt is news", + "patch": { + "set": [ + [ + [ + "dmLeftAt" + ], + { + "0x0000000000000000000000000000000000000010": 700, + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in trustedContacts is news", + "patch": { + "set": [ + [ + [ + "trustedContacts" + ], + { + "0x0000000000000000000000000000000000000011": { + "nickname": "Carol", + "addedAt": 800 + }, + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in username is news", + "patch": { + "set": [ + [ + [ + "username" + ], + "Bobx" + ] + ] + }, + "same": false + }, + { + "what": "a change in graphApiKey is news", + "patch": { + "set": [ + [ + [ + "graphApiKey" + ], + "keyx" + ] + ] + }, + "same": false + }, + { + "what": "a change in ensCache is not news", + "patch": { + "set": [ + [ + [ + "ensCache" + ], + { + "0x0000000000000000000000000000000000000011": { + "name": "carol.eth", + "timestamp": 100 + }, + "x": 1 + } + ] + ] + }, + "same": true + }, + { + "what": "a change in sliceTs is not news", + "patch": { + "set": [ + [ + [ + "sliceTs" + ], + { + "username": 100, + "x": 1 + } + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's name is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "name" + ], + "Channelx" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's type is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "type" + ], + "gatedx" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's createdAt is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "createdAt" + ], + 1001 + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's joinedAt is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "joinedAt" + ], + 1001 + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's storageDays is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "storageDays" + ], + 31 + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's accessSnapshot is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "accessSnapshot" + ], + [ + "0x0000000000000000000000000000000000000001", + "x" + ] + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's gate is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "gate" + ], + { + "address": "0x0000000000000000000000000000000000000007", + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's wireIdentity is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "wireIdentity" + ], + "sealedx" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's createdBy is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "createdBy" + ], + "0x0000000000000000000000000000000000000008x" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's password is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "password" + ], + "secretx" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's members is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "members" + ], + [ + "0x0000000000000000000000000000000000000001", + "x" + ] + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's rotatedForNoAccess is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "rotatedForNoAccess" + ], + [ + "0x0000000000000000000000000000000000000002", + "x" + ] + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's knownBanned is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "knownBanned" + ], + [ + "0x0000000000000000000000000000000000000003", + "x" + ] + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's storageEnabled is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "storageEnabled" + ], + false + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's adminStorageDays is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "adminStorageDays" + ], + 31 + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's keysStorageDays is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "keysStorageDays" + ], + 31 + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's interactionsStorageDays is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "interactionsStorageDays" + ], + 31 + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's exposure is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "exposure" + ], + "hiddenx" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's description is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "description" + ], + "aboutx" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's language is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "language" + ], + "enx" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's category is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "category" + ], + "newsx" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's metaUpdatedAt is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "metaUpdatedAt" + ], + 2001 + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's readOnly is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "readOnly" + ], + false + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's writeOnly is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "writeOnly" + ], + false + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's classification is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "classification" + ], + "clubx" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's peerAddress is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "peerAddress" + ], + "0x0000000000000000000000000000000000000004x" + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's fieldTs is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "fieldTs" + ], + { + "name": 3000, + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's ephemeralStreamId is not news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "ephemeralStreamId" + ], + "0x00000000000000000000000000000000000000a1/c0ffee-2x" + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's adminStreamId is not news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "adminStreamId" + ], + "0x00000000000000000000000000000000000000a1/c0ffee-3x" + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's keysStreamId is not news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "keysStreamId" + ], + "0x00000000000000000000000000000000000000a1/c0ffee-4x" + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's interactionsStreamId is not news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "interactionsStreamId" + ], + "0x00000000000000000000000000000000000000a1/c0ffee-5x" + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's inboxStreamId is not news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "inboxStreamId" + ], + "0x0000000000000000000000000000000000000009/Pombo-DM-1x" + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's storageProvider is not news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "storageProvider" + ], + "streamrx" + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's epoch keys epochs is news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "epochs" + ], + { + "k1": { + "keyHex": "0xaa", + "keyHash": "0xa1", + "epoch": 1 + }, + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's epoch keys currentEpoch is news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "currentEpoch" + ], + 2 + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's epoch keys pubKey is news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "pubKey" + ], + { + "keyId": "p1", + "keyHex": "0xbb", + "rev": 1, + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's epoch keys intKey is news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "intKey" + ], + { + "keyId": "i1", + "keyHex": "0xcc", + "rev": 1, + "x": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a change in a channel's epoch keys announces is not news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "announces" + ], + { + "1": { + "keyId": "k1", + "keyHash": "0xa1", + "timestamp": 100, + "validFrom": 100 + }, + "x": 1 + } + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's epoch keys pendingRequests is not news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "pendingRequests" + ], + { + "r1": { + "fromEpoch": 1, + "sentAt": 100 + }, + "x": 1 + } + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's epoch keys helloEpochs is not news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "helloEpochs" + ], + [ + 1, + "x" + ] + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's epoch keys helloName is not news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "helloName" + ], + "Bobx" + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's epoch keys helloTs is not news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "helloTs" + ], + 101 + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's epoch keys seenRequesters is not news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "seenRequesters" + ], + [ + "0x0000000000000000000000000000000000000001", + "x" + ] + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's epoch keys pubAnnounce is not news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "pubAnnounce" + ], + { + "keyId": "p1", + "rev": 1, + "timestamp": 100, + "x": 1 + } + ] + ] + }, + "same": true + }, + { + "what": "a change in a channel's epoch keys intAnnounce is not news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1", + "intAnnounce" + ], + { + "keyId": "i1", + "rev": 1, + "timestamp": 100, + "x": 1 + } + ] + ] + }, + "same": true + }, + { + "what": "a channel joined is news", + "patch": { + "set": [ + [ + [ + "channels", + 1 + ], + { + "messageStreamId": "0x0000000000000000000000000000000000000012/new-1", + "name": "Channel", + "type": "public", + "createdAt": 1000, + "joinedAt": 1000, + "storageDays": 30, + "accessSnapshot": [] + } + ] + ] + }, + "same": false + }, + { + "what": "a channel left is news", + "patch": { + "set": [ + [ + [ + "channels" + ], + [] + ] + ] + }, + "same": false + }, + { + "what": "a new channel in the epoch keys is news", + "patch": { + "set": [ + [ + [ + "epochKeys", + "0x0000000000000000000000000000000000000012/new-1" + ], + { + "currentEpoch": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "a slice this client does not know is news", + "patch": { + "set": [ + [ + [ + "somethingNew" + ], + { + "a": 1 + } + ] + ] + }, + "same": false + }, + { + "what": "the order of the keys is not news", + "patch": { + "reverseKeys": [ + [ + "channels", + 0 + ], + [ + "epochKeys", + "0x00000000000000000000000000000000000000a1/c0ffee-1" + ], + [] + ] + }, + "same": true + }, + { + "what": "the order of the channel list is not news", + "basePatch": { + "set": [ + [ + [ + "channels", + 1 + ], + { + "messageStreamId": "0x0000000000000000000000000000000000000012/aaa-1", + "name": "Channel", + "type": "public", + "createdAt": 1000, + "joinedAt": 1000, + "storageDays": 30, + "accessSnapshot": [] + } + ] + ] + }, + "patch": { + "reverse": [ + [ + "channels" + ] + ] + }, + "same": true + }, + { + "what": "an empty field and a missing one are the same state", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "inviteCode" + ], + null + ], + [ + [ + "channels", + 0, + "note" + ], + "" + ], + [ + [ + "channels", + 0, + "pinned" + ], + false + ], + [ + [ + "channels", + 0, + "tags" + ], + [] + ], + [ + [ + "channels", + 0, + "extra" + ], + {} + ], + [ + [ + "sentReactions", + "0x00000000000000000000000000000000000000a1/Pombo-DM-1", + "m9" + ], + { + "👍": [] + } + ] + ] + }, + "same": true + }, + { + "what": "a field emptied is news", + "patch": { + "set": [ + [ + [ + "channels", + 0, + "password" + ], + null + ] + ] + }, + "same": false + } + ] + } } diff --git a/src/js/channels.js b/src/js/channels.js index fdfefd4..5eb4200 100644 --- a/src/js/channels.js +++ b/src/js/channels.js @@ -518,6 +518,8 @@ class ChannelManager { record.fieldTs = stamps; this.channels.get(record.messageStreamId).fieldTs = stamps; } + const changed = channelsData.length !== this._persisted.size || channelsData.some(record => + JSON.stringify(record) !== JSON.stringify(this._persisted.get(record.messageStreamId))); this._rememberPersisted(channelsData); Logger.debug('Saving channels to secure storage:', channelsData.length); @@ -525,7 +527,7 @@ class ChannelManager { Logger.debug('Channels saved to secure storage (metadata only)'); // Schedule auto-push to sync (debounced 30s) - this.onChannelsSaved?.(); + if (changed) this.onChannelsSaved?.(); } catch (error) { Logger.error('Failed to save channels:', error); throw new StorageError( diff --git a/src/js/identity.js b/src/js/identity.js index 8869828..0a53578 100644 --- a/src/js/identity.js +++ b/src/js/identity.js @@ -1053,7 +1053,7 @@ class IdentityManager { */ async removeTrustedContact(address) { const normalizedAddress = address.toLowerCase(); - this.trustedContacts.delete(normalizedAddress); + if (!this.trustedContacts.delete(normalizedAddress)) return; await this.saveTrustedContacts(); this.onTrustedContactsChanged?.({ type: 'remove', address: normalizedAddress }); Logger.info('Removed trusted contact:', address); diff --git a/src/js/secureStorage.js b/src/js/secureStorage.js index 8a9e0dc..dae1306 100644 --- a/src/js/secureStorage.js +++ b/src/js/secureStorage.js @@ -624,8 +624,9 @@ class SecureStorage { */ async setTrustedContacts(contacts) { if (!this.isUnlocked) return; + const changed = JSON.stringify(this.cache.trustedContacts) !== JSON.stringify(contacts); this.cache.trustedContacts = contacts; - this._stampSliceTs('trustedContacts'); + if (changed) this._stampSliceTs('trustedContacts'); await this.saveToStorage(); } @@ -659,8 +660,8 @@ class SecureStorage { */ async setUsername(username) { if (!this.isUnlocked) return; + if (this.cache.username !== username) this._stampSliceTs('username'); this.cache.username = username; - this._stampSliceTs('username'); // Also store in plain localStorage for pre-unlock display (unlock modal) // Skip in guest mode — guest sessions are memory-only if (this.address && !this.isGuestMode) { @@ -751,8 +752,8 @@ class SecureStorage { */ async setGraphApiKey(apiKey) { if (!this.isUnlocked) return; + if (this.cache.graphApiKey !== apiKey) this._stampSliceTs('graphApiKey'); this.cache.graphApiKey = apiKey; - this._stampSliceTs('graphApiKey'); await this.saveToStorage(); } @@ -1026,6 +1027,7 @@ class SecureStorage { if (!messages) return; const msg = messages.find(m => m.id === messageId); if (!msg) return; + if (Object.entries(fields).every(([key, value]) => JSON.stringify(msg[key]) === JSON.stringify(value))) return; Object.assign(msg, fields); await this.saveToStorage(); this.onSentDataChanged?.({ type: 'sentMessage', streamId }); @@ -1081,6 +1083,9 @@ class SecureStorage { */ async addSentReaction(streamId, messageId, emoji, user, action = 'add') { if (!this.isUnlocked) return; + const held = (this.cache.sentReactions?.[streamId]?.[messageId]?.[emoji] || []) + .some(u => u.toLowerCase() === user.toLowerCase()); + if (held === (action !== 'remove')) return; if (!this.cache.sentReactions) { this.cache.sentReactions = {}; } diff --git a/src/js/syncManager.js b/src/js/syncManager.js index 7b15b2e..324a10e 100644 --- a/src/js/syncManager.js +++ b/src/js/syncManager.js @@ -27,6 +27,7 @@ import { mergePayloadSeries as mergeSyncPayloadSeries, mergeSentMessages as merg import { syncWorkerClient } from './workers/syncWorkerClient.js'; import { cryptoManager } from './crypto.js'; import { splitSyncPayload, reassembleSyncPayloads } from './syncChunks.js'; +import { syncStateKey } from './syncStateKey.js'; /** A snapshot is a RUN of messages, so the window must hold several of them. */ const SYNC_FETCH_COUNT = 60; @@ -95,6 +96,10 @@ class SyncManager { this._ownRowKeys = new Set(); this._publishing = false; this._confirmRun = 0; + // A pushed state counts as sent while its read-back runs. Only a + // confirmed one is remembered across restarts: a push storage never + // kept has to go out again. + this._pendingHash = null; this._confirmTimer = null; this._blobLeaveTimer = null; this._snapshotWatch = null; @@ -240,7 +245,7 @@ class SyncManager { } async _stateHash(state) { - const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(state))); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(syncStateKey(state))); return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join(''); } @@ -449,8 +454,8 @@ class SyncManager { // Gather state from secureStorage const state = secureStorage.exportForSync(); const hash = await this._stateHash(state); - if (this._isConfirmedState(hash)) { - Logger.info('Sync: State unchanged since the last confirmed push, not publishing'); + if (this._isConfirmedState(hash) || hash === this._pendingHash) { + Logger.info('Sync: State unchanged since the last push, not publishing'); if (!this.autoPushTimeout && !this.pushQueued) { this.clearDirty(); } @@ -522,6 +527,7 @@ class SyncManager { */ _confirmPush(inboxStreamId, hash, rows) { const run = ++this._confirmRun; + this._pendingHash = hash; clearTimeout(this._confirmTimer); const wanted = new Set(rows.map(rowKey)); const startedAt = Date.now(); @@ -540,6 +546,7 @@ class SyncManager { Math.max(0, startedAt + SYNC_CONFIRM_AT_MS[index + 1] - Date.now())); return; } + this._pendingHash = null; if (confirmed) { this._writeConfirmedState(hash); Logger.info('Sync: Push confirmed by storage'); @@ -608,6 +615,7 @@ class SyncManager { /** On disconnect: a confirmation still running would act on the next account. */ cancelPushConfirmation() { this._confirmRun++; + this._pendingHash = null; clearTimeout(this._confirmTimer); clearTimeout(this._blobLeaveTimer); this._pulledRowTs = 0; diff --git a/src/js/syncStateKey.js b/src/js/syncStateKey.js new file mode 100644 index 0000000..1146039 --- /dev/null +++ b/src/js/syncStateKey.js @@ -0,0 +1,68 @@ +/** + * What a sync state carries that is not worth a push of its own: caches, + * bookkeeping the network or the next real change brings back, and stream ids + * derived from the channel's own id. Everything else is news, including a + * slice this client does not know. Android's SyncStateKey.kt keeps the same + * lists; the "publish" parity vectors in docs/SYNC-merge-vectors.json lock them. + */ +export const SYNC_STATE_IGNORED = Object.freeze({ + slices: Object.freeze(['ensCache', 'sliceTs']), + epochKeyFields: Object.freeze([ + 'announces', 'pendingRequests', 'helloEpochs', 'helloName', 'helloTs', + 'seenRequesters', 'pubAnnounce', 'intAnnounce' + ]), + channelFields: Object.freeze([ + 'ephemeralStreamId', 'adminStreamId', 'keysStreamId', 'interactionsStreamId', + 'inboxStreamId', 'storageProvider' + ]) +}); + +const isEmpty = (value) => value === null || value === undefined || value === '' || value === false + || (Array.isArray(value) && value.length === 0) + || (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0); + +/** Sorted keys, and an empty entry (null, '', false, [], {}) dropped: it is the same state as a missing one. */ +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') { + const out = {}; + for (const key of Object.keys(value).sort()) { + const entry = canonical(value[key]); + if (!isEmpty(entry)) out[key] = entry; + } + return out; + } + return value; +} + +function without(record, fields) { + if (!record || typeof record !== 'object' || Array.isArray(record)) return record; + const out = { ...record }; + for (const field of fields) delete out[field]; + return out; +} + +/** + * The news a sync state carries, as one canonical string: two states with the + * same key need no push between them. The order of the channel list is local + * and is not part of it. + * @param {Object} state - A sync export (exportForSync shape) + * @returns {string} + */ +export function syncStateKey(state) { + const projected = {}; + for (const [slice, value] of Object.entries(state || {})) { + if (SYNC_STATE_IGNORED.slices.includes(slice)) continue; + if (slice === 'channels' && Array.isArray(value)) { + projected.channels = value + .map(channel => without(channel, SYNC_STATE_IGNORED.channelFields)) + .sort((a, b) => String(a?.messageStreamId).localeCompare(String(b?.messageStreamId))); + } else if (slice === 'epochKeys' && value && typeof value === 'object') { + projected.epochKeys = Object.fromEntries(Object.entries(value) + .map(([streamId, entry]) => [streamId, without(entry, SYNC_STATE_IGNORED.epochKeyFields)])); + } else { + projected[slice] = value; + } + } + return JSON.stringify(canonical(projected)); +} diff --git a/tests/unit/channels.test.js b/tests/unit/channels.test.js index dc4fffc..396d8fd 100644 --- a/tests/unit/channels.test.js +++ b/tests/unit/channels.test.js @@ -1255,6 +1255,25 @@ describe('ChannelManager', () => { expect(saved().fieldTs).toEqual({ name: 5000 }); }); + it('asks the sync for a push only when a save changed a record', async () => { + channelManager.onChannelsSaved = vi.fn(); + secureStorage.getChannels.mockReturnValue([stored()]); + channelManager.loadChannels(); + + await channelManager.saveChannels(); + expect(channelManager.onChannelsSaved).not.toHaveBeenCalled(); + + channelManager.channels.get('stream1').name = 'Renamed'; + await channelManager.saveChannels(); + expect(channelManager.onChannelsSaved).toHaveBeenCalledTimes(1); + + channelManager.channels.set('stream2', stored({ messageStreamId: 'stream2' })); + await channelManager.saveChannels(); + channelManager.channels.delete('stream2'); + await channelManager.saveChannels(); + expect(channelManager.onChannelsSaved).toHaveBeenCalledTimes(3); + }); + it('does not stamp a record created here', async () => { secureStorage.getChannels.mockReturnValue([]); channelManager.loadChannels(); diff --git a/tests/unit/identity.test.js b/tests/unit/identity.test.js index 1104873..97a4534 100644 --- a/tests/unit/identity.test.js +++ b/tests/unit/identity.test.js @@ -302,9 +302,14 @@ describe('IdentityManager', () => { expect(identityManager.trustedContacts.has('0xremove')).toBe(false); }); - it('should handle removing non-existent contact', async () => { - // Should not throw + it('should handle removing non-existent contact without saving or asking for a push', async () => { + identityManager.onTrustedContactsChanged = vi.fn(); + vi.clearAllMocks(); + await identityManager.removeTrustedContact('0xNonExistent'); + + expect(secureStorage.setTrustedContacts).not.toHaveBeenCalled(); + expect(identityManager.onTrustedContactsChanged).not.toHaveBeenCalled(); }); it('should save contacts after removing', async () => { diff --git a/tests/unit/secureStorage.noopChanges.test.js b/tests/unit/secureStorage.noopChanges.test.js new file mode 100644 index 0000000..3c9537e --- /dev/null +++ b/tests/unit/secureStorage.noopChanges.test.js @@ -0,0 +1,53 @@ +/** + * A write that changes nothing must not ask the sync for a push, and must not + * stamp its slice newer: a fresh stamp on an unchanged value beats a real + * change another device made in the meantime. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { secureStorage } from '../../src/js/secureStorage.js'; + +const DM = '0xpeer/Pombo-DM-1'; + +describe('writes that change nothing', () => { + let saved; + + beforeEach(() => { + saved = { cache: secureStorage.cache, isUnlocked: secureStorage.isUnlocked, isGuestMode: secureStorage.isGuestMode, address: secureStorage.address, onSentDataChanged: secureStorage.onSentDataChanged }; + secureStorage.initAsGuest('0x1234567890abcdef1234567890abcdef12345678'); + secureStorage.cache.sentMessages = { [DM]: [{ id: 'm1', type: 'text', text: 'hello', timestamp: 100 }] }; + secureStorage.onSentDataChanged = vi.fn(); + }); + + afterEach(() => Object.assign(secureStorage, saved)); + + it('an edit that sets the same text asks for no push', async () => { + await secureStorage.updateSentMessage(DM, 'm1', { text: 'hello' }); + expect(secureStorage.onSentDataChanged).not.toHaveBeenCalled(); + + await secureStorage.updateSentMessage(DM, 'm1', { text: 'edited' }); + expect(secureStorage.onSentDataChanged).toHaveBeenCalledTimes(1); + }); + + it('a reaction already held, or removed when absent, asks for no push and leaves no empty entry', async () => { + await secureStorage.addSentReaction(DM, 'm1', '👍', '0xAbc', 'remove'); + expect(secureStorage.cache.sentReactions?.[DM]).toBeUndefined(); + + await secureStorage.addSentReaction(DM, 'm1', '👍', '0xAbc', 'add'); + await secureStorage.addSentReaction(DM, 'm1', '👍', '0xabc', 'add'); + expect(secureStorage.onSentDataChanged).toHaveBeenCalledTimes(1); + }); + + it('setting the same username or Graph key does not stamp the slice again', async () => { + await secureStorage.setUsername('Bob'); + await secureStorage.setGraphApiKey('key'); + await secureStorage.setTrustedContacts({ '0xc1': { nickname: 'Carol' } }); + Object.assign(secureStorage.cache.sliceTs, { username: 1, graphApiKey: 1, trustedContacts: 1 }); + + await secureStorage.setUsername('Bob'); + await secureStorage.setGraphApiKey('key'); + await secureStorage.setTrustedContacts({ '0xc1': { nickname: 'Carol' } }); + + expect(secureStorage.cache.sliceTs).toMatchObject({ username: 1, graphApiKey: 1, trustedContacts: 1 }); + }); +}); diff --git a/tests/unit/syncManager.extended.test.js b/tests/unit/syncManager.extended.test.js index bbbe542..501b7a7 100644 --- a/tests/unit/syncManager.extended.test.js +++ b/tests/unit/syncManager.extended.test.js @@ -115,6 +115,7 @@ describe('syncManager extended', () => { syncManager.autoPushTimeout = null; syncManager.pushQueued = false; syncManager.autoPushRetryCount = 0; + syncManager.cancelPushConfirmation(); localStorage.clear(); authManager.wallet = { privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' }; authManager.isGuestMode.mockReturnValue(false); @@ -750,6 +751,15 @@ describe('syncManager extended', () => { expect(dmManager.sealAndPublish).not.toHaveBeenCalled(); }); + it('does not publish the same state again while its read-back runs', async () => { + await syncManager.pushSync(); + dmManager.sealAndPublish.mockClear(); + + await syncManager.pushSync(); + + expect(dmManager.sealAndPublish).not.toHaveBeenCalled(); + }); + it('leaves an unconfirmed push too, and sends that state again', async () => { await syncManager.pushSync(); streamrController.fetchPartitionHistory.mockResolvedValue([]); diff --git a/tests/unit/syncManager.test.js b/tests/unit/syncManager.test.js index a62dfe4..3e0dd2e 100644 --- a/tests/unit/syncManager.test.js +++ b/tests/unit/syncManager.test.js @@ -149,6 +149,7 @@ describe('syncManager', () => { syncManager.handlers = []; syncManager.pushQueued = false; syncManager.autoPushRetryCount = 0; + syncManager.cancelPushConfirmation(); // Reset authManager state authManager.wallet = { privateKey: '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef' }; authManager.isGuestMode.mockReturnValue(false); diff --git a/tests/unit/syncMerge.vectors.test.js b/tests/unit/syncMerge.vectors.test.js index 3208843..c8323b5 100644 --- a/tests/unit/syncMerge.vectors.test.js +++ b/tests/unit/syncMerge.vectors.test.js @@ -5,6 +5,7 @@ import { readFileSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { mergeState, stampedSliceTs } from '../../src/js/syncMerge.js'; +import { syncStateKey } from '../../src/js/syncStateKey.js'; const vectors = JSON.parse(readFileSync( join(dirname(fileURLToPath(import.meta.url)), '..', '..', @@ -12,6 +13,20 @@ const vectors = JSON.parse(readFileSync( const SLICES = ['blockedPeers', 'dmLeftAt', 'trustedContacts', 'username', 'graphApiKey']; +// The publish cases are patches on a shared base (see the generator). +const at = (root, path) => path.reduce((node, key) => node[key], root); +function applyPatch(state, patch = {}) { + const out = structuredClone(state); + for (const [path, value] of patch.set || []) at(out, path.slice(0, -1))[path.at(-1)] = structuredClone(value); + for (const path of patch.reverse || []) at(out, path).reverse(); + for (const path of patch.reverseKeys || []) { + const reversed = Object.fromEntries(Object.entries(at(out, path)).reverse()); + if (!path.length) return reversed; + at(out, path.slice(0, -1))[path.at(-1)] = reversed; + } + return out; +} + describe('sync merge parity vectors', () => { for (const v of vectors.merge) { it(v.what, () => { @@ -35,6 +50,13 @@ describe('sync merge parity vectors', () => { }); } + for (const v of vectors.publish.cases) { + it(v.what, () => { + const from = applyPatch(vectors.publish.base, v.basePatch); + expect(syncStateKey(from) === syncStateKey(applyPatch(from, v.patch))).toBe(v.same); + }); + } + for (const v of vectors.sent) { it(v.what, () => { const merged = mergeState(v.base, v.incoming); diff --git a/tests/vectors/gen_sync_merge_vectors.mjs b/tests/vectors/gen_sync_merge_vectors.mjs index d1b72aa..1322913 100644 --- a/tests/vectors/gen_sync_merge_vectors.mjs +++ b/tests/vectors/gen_sync_merge_vectors.mjs @@ -8,9 +8,11 @@ // // The vectors fix the slice outcome of one merge step (base = this device, // incoming = a remote snapshot), the stamping of unstamped values before a -// state leaves the device, how sent DMs carry deletions and edits, and how two -// copies of a channel record merge. +// state leaves the device, how sent DMs carry deletions and edits, how two +// copies of a channel record merge, and which changes to a state are news +// worth a push. import { mergeState, stampedSliceTs } from '../../src/js/syncMerge.js'; +import { syncStateKey } from '../../src/js/syncStateKey.js'; const SLICES = ['blockedPeers', 'dmLeftAt', 'trustedContacts', 'username', 'graphApiKey']; @@ -55,6 +57,121 @@ const channel = (what, base, incoming) => { return { what, base, incoming, expected: { channels: merged.channels, channelsLeftAt: merged.channelsLeftAt } }; }; +// Publishing: a push goes out only when a state differs in its news. The +// expectations are written here by hand, field by field, so the vectors are the +// spec rather than a copy of the implementation's lists. +const A = (n) => `0x${String(n).padStart(40, '0')}`; +const PUBLISH_BASE = { + sentMessages: { [DM]: [text('m1', 1000)] }, + sentDeletedAt: { [DM]: { m0: 900 } }, + sentReactions: { [DM]: { m1: { '👍': [A(1)] } } }, + channels: [record({ + type: 'gated', ephemeralStreamId: `${CH.slice(0, -2)}-2`, adminStreamId: `${CH.slice(0, -2)}-3`, + keysStreamId: `${CH.slice(0, -2)}-4`, interactionsStreamId: `${CH.slice(0, -2)}-5`, inboxStreamId: `${A(9)}/Pombo-DM-1`, + storageProvider: 'streamr', gate: { address: A(7) }, wireIdentity: 'sealed', createdBy: A(8), password: 'secret', + members: [A(1)], rotatedForNoAccess: [A(2)], accessSnapshot: [A(1)], knownBanned: [A(3)], storageEnabled: true, + adminStorageDays: 30, keysStorageDays: 30, interactionsStorageDays: 30, exposure: 'hidden', description: 'about', + language: 'en', category: 'news', metaUpdatedAt: 2000, readOnly: true, writeOnly: true, classification: 'club', + peerAddress: A(4), fieldTs: { name: 3000 } + })], + channelsLeftAt: { [`${A(5)}/gone-1`]: 500 }, + epochKeys: { + [CH]: { + epochs: { k1: { keyHex: '0xaa', keyHash: '0xa1', epoch: 1 } }, + announces: { 1: { keyId: 'k1', keyHash: '0xa1', timestamp: 100, validFrom: 100 } }, + currentEpoch: 1, + pendingRequests: { r1: { fromEpoch: 1, sentAt: 100 } }, + helloEpochs: [1], helloName: 'Bob', helloTs: 100, seenRequesters: [A(1)], + pubKey: { keyId: 'p1', keyHex: '0xbb', rev: 1 }, pubAnnounce: { keyId: 'p1', rev: 1, timestamp: 100 }, + intKey: { keyId: 'i1', keyHex: '0xcc', rev: 1 }, intAnnounce: { keyId: 'i1', rev: 1, timestamp: 100 } + } + }, + blockedPeers: [A(6)], + dmLeftAt: { [A(10)]: 700 }, + trustedContacts: { [A(11)]: { nickname: 'Carol', addedAt: 800 } }, + ensCache: { [A(11)]: { name: 'carol.eth', timestamp: 100 } }, + username: 'Bob', + graphApiKey: 'key', + sliceTs: { username: 100 } +}; +const NOT_NEWS = { + slices: ['ensCache', 'sliceTs'], + channel: ['ephemeralStreamId', 'adminStreamId', 'keysStreamId', 'interactionsStreamId', 'inboxStreamId', 'storageProvider'], + epochKeys: ['announces', 'pendingRequests', 'helloEpochs', 'helloName', 'helloTs', 'seenRequesters', 'pubAnnounce', 'intAnnounce'] +}; +const NEWS = { + slices: ['sentMessages', 'sentDeletedAt', 'sentReactions', 'channelsLeftAt', 'blockedPeers', 'dmLeftAt', 'trustedContacts', 'username', 'graphApiKey'], + channel: ['name', 'type', 'createdAt', 'joinedAt', 'storageDays', 'accessSnapshot', 'gate', 'wireIdentity', 'createdBy', 'password', + 'members', 'rotatedForNoAccess', 'knownBanned', 'storageEnabled', 'adminStorageDays', 'keysStorageDays', 'interactionsStorageDays', + 'exposure', 'description', 'language', 'category', 'metaUpdatedAt', 'readOnly', 'writeOnly', 'classification', 'peerAddress', 'fieldTs'], + epochKeys: ['epochs', 'currentEpoch', 'pubKey', 'intKey'] +}; +const mutate = (value) => { + if (typeof value === 'string') return `${value}x`; + if (typeof value === 'number') return value + 1; + if (typeof value === 'boolean') return !value; + if (Array.isArray(value)) return [...value, 'x']; + return { ...value, x: 1 }; +}; +const classified = (level, keys) => { + for (const key of keys) { + if (NEWS[level].includes(key) === NOT_NEWS[level].includes(key)) throw new Error(`publish: ${level} field ${key} must be news or not, once`); + } +}; +classified('slices', Object.keys(PUBLISH_BASE).filter((k) => k !== 'channels' && k !== 'epochKeys')); +classified('channel', Object.keys(PUBLISH_BASE.channels[0]).filter((k) => k !== 'messageStreamId')); +classified('epochKeys', Object.keys(PUBLISH_BASE.epochKeys[CH])); + +// A case is a small patch on the shared base, so the file stays readable: +// `set` puts values at paths, `reverseKeys` reverses the key order of the +// objects at paths, `reverse` reverses the arrays at paths. `basePatch`, when +// present, is applied to the base first and the case compares against that. +const at = (root, path) => path.reduce((node, key) => node[key], root); +const applyPatch = (state, patch = {}) => { + const out = structuredClone(state); + for (const [path, value] of patch.set || []) at(out, path.slice(0, -1))[path.at(-1)] = structuredClone(value); + for (const path of patch.reverse || []) at(out, path).reverse(); + for (const path of patch.reverseKeys || []) { + const reversed = Object.fromEntries(Object.entries(at(out, path)).reverse()); + if (!path.length) return reversed; + at(out, path.slice(0, -1))[path.at(-1)] = reversed; + } + return out; +}; +const publish = (what, patch, same, basePatch = null) => { + const from = applyPatch(PUBLISH_BASE, basePatch || {}); + if ((syncStateKey(from) === syncStateKey(applyPatch(from, patch))) !== same) { + throw new Error(`publish vector "${what}": expected ${same ? 'no push' : 'a push'}`); + } + return basePatch ? { what, basePatch, patch, same } : { what, patch, same }; +}; +const field = (path) => ({ set: [[path, mutate(at(PUBLISH_BASE, path))]] }); +const publishCases = [ + ...[...NEWS.slices, ...NOT_NEWS.slices].map((slice) => publish( + `a change in ${slice} ${NEWS.slices.includes(slice) ? 'is' : 'is not'} news`, + field([slice]), NOT_NEWS.slices.includes(slice))), + ...[...NEWS.channel, ...NOT_NEWS.channel].map((name) => publish( + `a change in a channel's ${name} ${NEWS.channel.includes(name) ? 'is' : 'is not'} news`, + field(['channels', 0, name]), NOT_NEWS.channel.includes(name))), + ...[...NEWS.epochKeys, ...NOT_NEWS.epochKeys].map((name) => publish( + `a change in a channel's epoch keys ${name} ${NEWS.epochKeys.includes(name) ? 'is' : 'is not'} news`, + field(['epochKeys', CH, name]), NOT_NEWS.epochKeys.includes(name))), + publish('a channel joined is news', { set: [[['channels', 1], record({ messageStreamId: `${A(12)}/new-1` })]] }, false), + publish('a channel left is news', { set: [[['channels'], []]] }, false), + publish('a new channel in the epoch keys is news', { set: [[['epochKeys', `${A(12)}/new-1`], { currentEpoch: 1 }]] }, false), + publish('a slice this client does not know is news', { set: [[['somethingNew'], { a: 1 }]] }, false), + publish('the order of the keys is not news', { reverseKeys: [['channels', 0], ['epochKeys', CH], []] }, true), + publish('the order of the channel list is not news', { reverse: [['channels']] }, true, + { set: [[['channels', 1], record({ messageStreamId: `${A(12)}/aaa-1` })]] }), + publish('an empty field and a missing one are the same state', { + set: [ + [['channels', 0, 'inviteCode'], null], [['channels', 0, 'note'], ''], [['channels', 0, 'pinned'], false], + [['channels', 0, 'tags'], []], [['channels', 0, 'extra'], {}], [['sentReactions', DM, 'm9'], { '👍': [] }] + ] + }, true), + publish('a field emptied is news', { set: [[['channels', 0, 'password'], null]] }, false) +]; + // Sent DMs: a deletion on any device removes the message on every device, // and the latest edit wins. const sent = (what, base, incoming) => { @@ -153,5 +270,6 @@ console.log(JSON.stringify({ channel('a join newer than the leave keeps the channel and retires the leave', { channels: [record({ joinedAt: 7000 })] }, { channels: [], channelsLeftAt: { [CH]: 5000 } }) - ] + ], + publish: { base: PUBLISH_BASE, cases: publishCases } }, null, 2));