Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/engine/state.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {ensurePendingMessagePlaintext} from "src/util/messagePlaintext"
import {derived, readable, writable} from "svelte/store"
import type {Readable} from "svelte/store"
import {
Expand Down Expand Up @@ -134,10 +135,9 @@ export const ensureMessagePlaintext = async (e: TrustedEvent) => {

if (!other) return undefined

return $app
.use(Plaintext)
.ensure(e.content, () => $user.signer.nip04.decrypt(other, e.content))
.catch(() => undefined)
return ensurePendingMessagePlaintext($user.signer, other, e.content, () =>
$app.use(Plaintext).ensure(e.content, () => $user.signer.nip04.decrypt(other, e.content)),
).catch(() => undefined)
}

// Tracker
Expand Down
32 changes: 32 additions & 0 deletions src/util/messagePlaintext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type {ISigner} from "@welshman/signer"

// A completed plaintext cache cannot prevent multiple views/updates from asking
// the signer to decrypt the same message while its first approval is pending.
const pendingBySigner = new WeakMap<ISigner, Map<string, Promise<string>>>()

export const ensurePendingMessagePlaintext = (
signer: ISigner,
peer: string,
ciphertext: string,
decrypt: () => Promise<string>,
): Promise<string> => {
let pending = pendingBySigner.get(signer)

if (!pending) {
pending = new Map()
pendingBySigner.set(signer, pending)
}

const key = JSON.stringify([peer, ciphertext])
const existing = pending.get(key)

if (existing) return existing

const result = Promise.resolve()
.then(decrypt)
.finally(() => pending.delete(key))

pending.set(key, result)

return result
}
63 changes: 63 additions & 0 deletions tests/unit/util/messagePlaintext.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {describe, expect, it, vi} from "vitest"
import {Plaintext} from "@welshman/app"
import type {IApp} from "@welshman/app"
import type {ISigner} from "@welshman/signer"
import {ensurePendingMessagePlaintext} from "../../../src/util/messagePlaintext"

const signer = () => ({}) as ISigner

describe("pending message plaintext", () => {
it("shares one signer approval between three concurrent renders", async () => {
const owner = signer()
const cache = new Plaintext({} as IApp)
let resolve!: (value: string) => void
const decrypt = vi.fn(
() =>
new Promise<string>(done => {
resolve = done
}),
)
const requests = Array.from({length: 3}, () =>
ensurePendingMessagePlaintext(owner, "peer", "ciphertext", () =>
cache.ensure("ciphertext", () => cache.ensure("ciphertext", decrypt)),
),
)
await Promise.resolve()
expect(decrypt).toHaveBeenCalledTimes(1)
resolve("message")
expect(await Promise.all(requests)).toEqual(["message", "message", "message"])
})

it("does not merge accounts, peers or different ciphertexts", async () => {
const owner = signer()
const decrypt = vi.fn(async () => "message")
await Promise.all([
ensurePendingMessagePlaintext(owner, "a", "one", decrypt),
ensurePendingMessagePlaintext(owner, "b", "one", decrypt),
ensurePendingMessagePlaintext(owner, "a", "two", decrypt),
ensurePendingMessagePlaintext(signer(), "a", "one", decrypt),
])
expect(decrypt).toHaveBeenCalledTimes(4)
})

it("clears rejected requests so an explicit retry can succeed", async () => {
const owner = signer()
const decrypt = vi.fn().mockRejectedValueOnce(new Error("denied")).mockResolvedValue("message")
const one = ensurePendingMessagePlaintext(owner, "peer", "ciphertext", decrypt)
const two = ensurePendingMessagePlaintext(owner, "peer", "ciphertext", decrypt)
expect(one).toBe(two)
await expect(one).rejects.toThrow("denied")
await expect(ensurePendingMessagePlaintext(owner, "peer", "ciphertext", decrypt)).resolves.toBe(
"message",
)
expect(decrypt).toHaveBeenCalledTimes(2)
})

it("does not retain plaintext after completion", async () => {
const owner = signer()
const decrypt = vi.fn(async () => "message")
await ensurePendingMessagePlaintext(owner, "peer", "ciphertext", decrypt)
await ensurePendingMessagePlaintext(owner, "peer", "ciphertext", decrypt)
expect(decrypt).toHaveBeenCalledTimes(2)
})
})