diff --git a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts index 07c57df1eed..220fbb3ce7e 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-items.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-items.service.ts @@ -109,7 +109,10 @@ export class VaultPopupItemsService { map((a) => a?.id), filter((userId): userId is UserId => userId != null), switchMap((userId) => - merge(this.cipherService.ciphers$(userId), this.cipherService.localData$(userId)).pipe( + merge( + this.cipherService.ciphers$(userId), + this.cipherService.ciphersWithLocalData$(userId), + ).pipe( runInsideAngular(this.ngZone), tap(() => this._ciphersLoading$.next()), waitUntilSync(this.syncService), @@ -163,6 +166,7 @@ export class VaultPopupItemsService { }), ), ), + shareReplay({ refCount: false, bufferSize: 1 }), ); /** diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 9aefd960b2f..f5fac9faaf0 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -32,6 +32,16 @@ export abstract class CipherService implements UserKeyRotationDataProvider; abstract ciphers$(userId: UserId): Observable>; abstract localData$(userId: UserId): Observable>; + /** + * Emits decrypted (or list-view) ciphers merged with their LocalData. + * + * The emitted items must be treated as {@link CipherViewLike}, + * since the underlying implementation may emit either `CipherView` + * or `CipherListView` depending on feature flags. + * + * Never emits `null`; always emits an array (empty or populated). + */ + abstract ciphersWithLocalData$(userId: UserId): Observable; /** * An observable monitoring the add/edit cipher info saved to memory. */ diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index 1e7e5302d41..1ee858f53d0 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -168,6 +168,57 @@ export class CipherService implements CipherServiceAbstraction { ); }); + private cipherListWithoutLocalData$ = perUserCache$((userId: UserId) => { + return this.configService.getFeatureFlag$(FeatureFlag.PM22134SdkCipherListView).pipe( + switchMap((useSdk) => { + if (!useSdk) { + return this.cipherViews$(userId).pipe( + // filter out "null" decrypt-in-progress if it ever appears + filter((ciphers): ciphers is CipherView[] => ciphers != null), + ); + } + + return combineLatest([ + this.encryptedCiphersState(userId).state$, + this.keyService.cipherDecryptionKeys$(userId, true), + ]).pipe( + filter(([cipherDataState, keys]) => cipherDataState != null && keys != null), + map(([cipherDataState]) => Object.values(cipherDataState)), + switchMap(async (cipherDataArray) => { + const ciphers = cipherDataArray.map((c) => new Cipher(c)); + const [decrypted, failures] = await this.decryptCiphersWithSdk(ciphers, userId, false); + await this.setFailedDecryptedCiphers(failures, userId); + return decrypted; + }), + ); + }), + ); + }); + + ciphersWithLocalData$(userId: UserId): Observable { + return combineLatest([this.cipherListViews$(userId), this.localData$(userId)]).pipe( + map(([ciphers, localData]) => { + if (!ciphers) { + return [] as CipherViewLike[]; + } + + return ciphers.map((cipher) => { + const cipherId = uuidAsString(cipher.id) as CipherId; + const local = localData?.[cipherId]; + + if (!local) { + return cipher as CipherViewLike; + } + + return { + ...(cipher as CipherViewLike), + localData: local, + } as CipherViewLike; + }); + }), + ); + } + /** * Observable that emits an array of decrypted ciphers for the active user. * This observable will not emit until the encrypted ciphers have either been loaded from state or after sync. @@ -178,10 +229,9 @@ export class CipherService implements CipherServiceAbstraction { cipherViews$ = perUserCache$((userId: UserId): Observable => { return combineLatest([ this.encryptedCiphersState(userId).state$, - this.localData$(userId), this.keyService.cipherDecryptionKeys$(userId), ]).pipe( - filter(([ciphers, _, keys]) => ciphers != null && keys != null), // Skip if ciphers haven't been loaded yor synced yet + filter(([ciphers, keys]) => ciphers != null && keys != null), switchMap(() => this.getAllDecrypted(userId)), tap(() => { this.messageSender.send("updateOverlayCiphers"); @@ -796,27 +846,18 @@ export class CipherService implements CipherServiceAbstraction { } const cipherId = id as CipherId; - if (ciphersLocalData[cipherId]) { - ciphersLocalData[cipherId].lastUsedDate = new Date().getTime(); - } else { - ciphersLocalData[cipherId] = { lastUsedDate: new Date().getTime() }; - } + const now = new Date().getTime(); - await this.localDataState(userId).update(() => ciphersLocalData); - - const decryptedCipherCache = await this.getDecryptedCiphers(userId); - if (!decryptedCipherCache) { + if (ciphersLocalData[cipherId]?.lastUsedDate === now) { return; } - for (let i = 0; i < decryptedCipherCache.length; i++) { - const cached = decryptedCipherCache[i]; - if (cached.id === id) { - cached.localData = ciphersLocalData[id as CipherId]; - break; - } - } - await this.setDecryptedCiphers(decryptedCipherCache, userId); + ciphersLocalData[cipherId] = { + ...(ciphersLocalData[cipherId] ?? {}), + lastUsedDate: now, + }; + + await this.localDataState(userId).update(() => ciphersLocalData); } async updateLastLaunchedDate(id: string, userId: UserId): Promise {