libp2r2p 0.11.21 → 0.11.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ // Admission only: never wait while holding an inbox operation, IDB transaction
2
+ // or the cross-instance retry lock. A busy channel cannot stall another one.
3
+ export function createDecryptionScheduler ({ ready = true, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
4
+ const channels = new Map()
5
+ const readyWaiters = new Set()
6
+ let timer
7
+ let running = false
8
+ let closed
9
+ let foreground = false
10
+ let foregroundAt = now()
11
+ let priority = null
12
+ let previous
13
+ let batch = 0
14
+ const stateFor = pubkey => {
15
+ if (!channels.has(pubkey)) channels.set(pubkey, { active: 0, at: now(), deadline: null, jobs: [] })
16
+ return channels.get(pubkey)
17
+ }
18
+ const quietAt = state => state.active || foreground ? Infinity : Math.max(state.at, foregroundAt) + 250
19
+ const schedule = () => {
20
+ if (timer !== undefined) clearTimer(timer)
21
+ timer = undefined
22
+ if (closed || !ready || running) return
23
+ const pending = [...channels.values()].filter(state => state.jobs.length)
24
+ if (!pending.length) return
25
+ const at = Math.min(...pending.map(state => Math.min(quietAt(state), state.deadline)))
26
+ timer = setTimer(pump, Math.max(0, at - now()))
27
+ }
28
+ async function pump () {
29
+ timer = undefined
30
+ if (closed || !ready || running) return
31
+ const time = now()
32
+ const eligible = [...channels].filter(([, state]) => state.jobs.length && Math.min(quietAt(state), state.deadline) <= time)
33
+ eligible.sort(([a, x], [b, y]) => {
34
+ const overdue = Number(y.deadline <= time) - Number(x.deadline <= time)
35
+ if (overdue) return overdue
36
+ if (x.deadline <= time && x.deadline !== y.deadline) return x.deadline - y.deadline
37
+ if (a === previous && batch >= 4) return 1
38
+ if (b === previous && batch >= 4) return -1
39
+ return Number(b === priority) - Number(a === priority)
40
+ })
41
+ if (!eligible.length) { schedule(); return }
42
+ const [pubkey, state] = eligible[0]
43
+ const job = state.jobs.shift()
44
+ running = true
45
+ if (previous === pubkey) batch++
46
+ else { previous = pubkey; batch = 1 }
47
+ try {
48
+ job.signal?.throwIfAborted()
49
+ job.resolve(await job.work())
50
+ } catch (error) { job.reject(error) } finally {
51
+ job.signal?.removeEventListener('abort', job.abort)
52
+ state.deadline = now() + 5000
53
+ running = false
54
+ // Always use a new task, including between records in the same batch.
55
+ schedule()
56
+ }
57
+ }
58
+ const rejectJobs = (state, error) => {
59
+ for (const job of state.jobs.splice(0)) {
60
+ job.signal?.removeEventListener('abort', job.abort)
61
+ job.reject(error)
62
+ }
63
+ }
64
+ return {
65
+ activity (pubkey) {
66
+ if (closed) return () => {}
67
+ const state = stateFor(pubkey)
68
+ state.active++; state.at = now(); schedule()
69
+ let released = false
70
+ return () => {
71
+ if (released) return
72
+ released = true
73
+ state.active--; state.at = now(); schedule()
74
+ }
75
+ },
76
+ foreground (active) { foreground = active; foregroundAt = now(); schedule() },
77
+ priority (pubkey) { priority = pubkey; schedule() },
78
+ setReady (value) {
79
+ if (ready === value || closed) return
80
+ ready = value
81
+ if (ready) {
82
+ for (const state of channels.values()) { state.at = now(); state.deadline = state.jobs.length ? now() + 5000 : null }
83
+ for (const wake of [...readyWaiters]) wake()
84
+ }
85
+ schedule()
86
+ },
87
+ async waitReady (signal) {
88
+ while (true) {
89
+ signal?.throwIfAborted()
90
+ if (closed) throw closed
91
+ if (ready) return
92
+ await new Promise((resolve, reject) => {
93
+ const finish = () => {
94
+ readyWaiters.delete(finish); signal?.removeEventListener('abort', finish)
95
+ if (closed || signal?.aborted) reject(closed || signal.reason)
96
+ else resolve()
97
+ }
98
+ readyWaiters.add(finish); signal?.addEventListener('abort', finish, { once: true })
99
+ })
100
+ }
101
+ },
102
+ run (pubkey, work, signal) {
103
+ signal?.throwIfAborted()
104
+ if (closed) return Promise.reject(closed)
105
+ const state = stateFor(pubkey)
106
+ state.deadline ??= now() + 5000
107
+ return new Promise((resolve, reject) => {
108
+ const job = { work, resolve, reject, signal }
109
+ job.abort = () => {
110
+ const index = state.jobs.indexOf(job)
111
+ if (index < 0) return
112
+ state.jobs.splice(index, 1); reject(signal.reason); schedule()
113
+ }
114
+ signal?.addEventListener('abort', job.abort, { once: true })
115
+ state.jobs.push(job); schedule()
116
+ })
117
+ },
118
+ remove (pubkey) {
119
+ const state = channels.get(pubkey)
120
+ if (state) rejectJobs(state, new Error('PRIVATE_CHANNEL_CHANGED'))
121
+ channels.delete(pubkey); schedule()
122
+ },
123
+ finish (pubkey) {
124
+ const state = channels.get(pubkey)
125
+ if (state && !state.jobs.length) state.deadline = null
126
+ },
127
+ close () {
128
+ closed ||= new Error('PRIVATE_MESSENGER_CLOSED')
129
+ if (timer !== undefined) clearTimer(timer)
130
+ for (const state of channels.values()) rejectJobs(state, closed)
131
+ channels.clear()
132
+ for (const wake of [...readyWaiters]) wake()
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,95 @@
1
+ import { deliveryInfo } from '../../private-channel/helpers/rumor.js'
2
+
3
+ const encoder = new TextEncoder()
4
+ const indexName = 'byChannelTypeEventId'
5
+ const oldPath = ['item.channelPubkey', 'item.type', 'item.event.id']
6
+ const newPath = [...oldPath, 'item.provenance']
7
+ const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b)
8
+
9
+ function open (indexedDB, name, version, upgrade) {
10
+ return new Promise((resolve, reject) => {
11
+ const request = indexedDB.open(name, version)
12
+ let rejected = false
13
+ request.onblocked = () => { rejected = true; reject(new Error('IDB_DATABASE_BLOCKED')) }
14
+ request.onerror = () => reject(request.error)
15
+ request.onupgradeneeded = () => {
16
+ if (rejected) { request.transaction.abort(); return }
17
+ try { upgrade?.(request.result, request.transaction) } catch (error) {
18
+ request.transaction.abort(); reject(error)
19
+ }
20
+ }
21
+ request.onsuccess = () => {
22
+ const db = request.result
23
+ db.onversionchange = () => db.close()
24
+ if (rejected) db.close()
25
+ else resolve(db)
26
+ }
27
+ })
28
+ }
29
+
30
+ function matchesLegacy (items) {
31
+ if (!items.indexNames.contains(indexName)) return false
32
+ const index = items.index(indexName)
33
+ return equal(items.keyPath, 'position') && equal(index.keyPath, oldPath) && index.unique && !index.multiEntry &&
34
+ items.indexNames.contains('byChannel') && items.index('byChannel').keyPath === 'item.channelPubkey' &&
35
+ !items.index('byChannel').unique && !items.index('byChannel').multiEntry
36
+ }
37
+
38
+ // The generic queue remains strict. Only this historical messenger schema is
39
+ // migrated, atomically with its records and capacity accounting.
40
+ export async function migrateMessageQueue (indexedDB, prefix) {
41
+ if (!indexedDB?.open) throw new Error('IDB_UNAVAILABLE')
42
+ const name = `${prefix}:idb-queue`
43
+ for (let attempt = 0; attempt < 3; attempt++) {
44
+ const db = await open(indexedDB, name)
45
+ if (!db.objectStoreNames.contains('items') || !db.objectStoreNames.contains('state')) { db.close(); return }
46
+ const tx = db.transaction(['items', 'state'], 'readonly')
47
+ const legacy = matchesLegacy(tx.objectStore('items')) && tx.objectStore('state').keyPath === 'key'
48
+ const version = db.version + 1
49
+ await new Promise((resolve, reject) => { tx.oncomplete = resolve; tx.onabort = () => reject(tx.error) })
50
+ db.close()
51
+ if (!legacy) return
52
+ try {
53
+ const upgraded = await open(indexedDB, name, version, (_db, tx) => {
54
+ const items = tx.objectStore('items')
55
+ if (!matchesLegacy(items)) return
56
+ items.deleteIndex(indexName)
57
+ items.createIndex(indexName, newPath, { unique: true })
58
+ let delta = 0
59
+ const cursor = items.openCursor()
60
+ cursor.onsuccess = () => {
61
+ try {
62
+ const row = cursor.result
63
+ if (!row) {
64
+ const state = tx.objectStore('state')
65
+ const request = state.get('queue')
66
+ request.onsuccess = () => {
67
+ if (request.result) state.put({ ...request.result, usedBytes: request.result.usedBytes + delta })
68
+ }
69
+ return
70
+ }
71
+ const record = row.value
72
+ const item = { ...record.item }
73
+ const sender = item.senderPubkey || item.meta?.senderPubkey || item.meta?.router?.tags?.find(tag => tag[0] === 'f')?.[1] || ''
74
+ const info = deliveryInfo(item.event, sender)
75
+ item.senderPubkey = sender
76
+ item.provenance = item.provenance === 'hearsay' ? 'hearsay' : info.provenance
77
+ let byteSize = 0
78
+ while (true) {
79
+ const size = encoder.encode(JSON.stringify({ byteSize, item })).length
80
+ if (size === byteSize) break
81
+ byteSize = size
82
+ }
83
+ delta += byteSize - record.byteSize
84
+ row.update({ ...record, item, byteSize })
85
+ row.continue()
86
+ } catch { tx.abort() }
87
+ }
88
+ })
89
+ upgraded.close()
90
+ return
91
+ } catch (error) {
92
+ if (error?.name !== 'VersionError' || attempt === 2) throw error
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,126 @@
1
+ import { sha256 } from '@noble/hashes/sha2.js'
2
+ import { bytesToHex } from '../../base16/index.js'
3
+ import { base64ToBytes } from '../../base64/index.js'
4
+ import { createQueue } from '../../idb-queue/index.js'
5
+ import { isDeferredDecryptionError } from '../../private-channel/helpers/decryption-error.js'
6
+
7
+ const encoder = new TextEncoder()
8
+ const decoder = new TextDecoder()
9
+ export const PENDING_DECRYPTION_MAX_BYTES = 16 * 1024 * 1024
10
+
11
+ function storedBytes (item) {
12
+ let byteSize = 0
13
+ while (true) {
14
+ const size = encoder.encode(JSON.stringify({ byteSize, item })).length
15
+ if (size === byteSize) return size
16
+ byteSize = size
17
+ }
18
+ }
19
+
20
+ function packetSnapshot (packet) {
21
+ const event = (value, router = false) => {
22
+ const copy = Object.fromEntries(['id', 'kind', 'pubkey', 'created_at', 'content', 'sig'].filter(key => value?.[key] !== undefined).map(key => [key, value[key]]))
23
+ if (value?.tags) {
24
+ copy.tags = router
25
+ ? value.tags.filter(tag => ['f', 'p', 'imkc', 'c', 'i'].includes(tag[0])).map(tag => tag.slice(0, tag[0] === 'imkc' || tag[0] === 'c' ? 3 : 2))
26
+ : structuredClone(value.tags)
27
+ }
28
+ return copy
29
+ }
30
+ return { format: packet.format, channelPubkey: packet.channelPubkey, outer: event(packet.outer), ...(packet.router ? { router: event(packet.router, true) } : {}) }
31
+ }
32
+
33
+ // Stable across live, history and synthesized recovery wrappers.
34
+ function packetId (record) {
35
+ const rows = record.format === 'routerRow_v1'
36
+ ? decoder.decode(base64ToBytes(record.router.content)).split('\n').filter(Boolean).map(line => {
37
+ const row = JSON.parse(line)
38
+ return row.length === 1 ? row : [row[0], row[1], row[2] || '', row[3] || '']
39
+ })
40
+ : null
41
+ const value = rows ? [record.channelPubkey, record.router.pubkey, record.router.tags.filter(tag => tag[0] !== 'c'), rows] : [record.channelPubkey, record.outer.id]
42
+ return bytesToHex(sha256(encoder.encode(JSON.stringify(value))))
43
+ }
44
+
45
+ export async function createPendingDecryption ({ prefix, indexedDB, maxBytes = PENDING_DECRYPTION_MAX_BYTES, report = () => {} }) {
46
+ const queue = await createQueue({
47
+ prefix: `${prefix}:pending-decryption`, indexedDB, maxBytes, evictionPolicy: 'fifo',
48
+ indexes: { byId: { keyPath: 'id', unique: true }, byChannel: 'channelPubkey' }
49
+ })
50
+ let tail = Promise.resolve()
51
+ let timer
52
+ let counts = {}
53
+ const flush = () => {
54
+ clearTimeout(timer); timer = null
55
+ if (Object.keys(counts).length) {
56
+ const summary = counts; counts = {}
57
+ report({ action: 'summary', counts: summary })
58
+ console.debug('private-messenger pending decryption', JSON.stringify(summary))
59
+ }
60
+ }
61
+ const serial = work => { const result = tail.then(work); tail = result.catch(() => {}); return result }
62
+ const notify = (action, detail) => {
63
+ report({ action, ...detail })
64
+ const key = `${action}:${detail.code || detail.reason || ''}`
65
+ counts[key] = { count: (counts[key]?.count || 0) + 1, example: detail }
66
+ if (!timer) { timer = setTimeout(flush, 1000); timer.unref?.() }
67
+ }
68
+ return {
69
+ queue,
70
+ has: record => serial(() => queue.someBy('byId', packetId(packetSnapshot(record)))),
71
+ save ({ error, record }) {
72
+ record = packetSnapshot(record)
73
+ return serial(async () => {
74
+ const id = packetId(record)
75
+ if (await queue.someBy('byId', id)) return true
76
+ const item = { id, channelPubkey: record.channelPubkey, packet: structuredClone(record), code: error.code, macAttempts: error.code === 'INVALID_MAC' ? 1 : 0 }
77
+ const requiredBytes = storedBytes(item)
78
+ if (requiredBytes > maxBytes) {
79
+ notify('discarded', { reason: 'oversized', channelPubkey: item.channelPubkey, id }); return true
80
+ }
81
+ const before = await queue.getCapacity()
82
+ try { await queue.enqueue(item) } catch (error) {
83
+ if (error.name === 'ConstraintError') return true
84
+ throw error
85
+ }
86
+ const after = await queue.getCapacity()
87
+ const evictedBytes = Math.max(0, before.usedBytes + requiredBytes - after.usedBytes)
88
+ if (evictedBytes) notify('discarded', { reason: 'capacity', bytes: evictedBytes })
89
+ const coordinates = Object.fromEntries(['eventId', 'eventKind', 'senderPubkey', 'receiverPubkey', 'ownContentPubkey', 'peerContentPubkey', 'hasOwnContentKey']
90
+ .filter(key => ['string', 'boolean', 'number'].includes(typeof error[key])).map(key => [key, error[key]]))
91
+ notify('deferred', { ...coordinates, code: error.code, stage: error.stage, id, channelPubkey: item.channelPubkey, capacityBytesBefore: before.usedBytes, capacityBytesAfter: after.usedBytes })
92
+ return true
93
+ })
94
+ },
95
+ async records ({ force = false } = {}) {
96
+ await tail
97
+ const records = []
98
+ for await (const item of queue.storedItems()) if (force || item.macAttempts < 3) records.push(item)
99
+ return records
100
+ },
101
+ current (item, { force = false } = {}) {
102
+ return serial(async () => {
103
+ const stored = await queue.getBy('byId', item.id)
104
+ return stored && (force || stored.macAttempts < 3) ? stored : null
105
+ })
106
+ },
107
+ recovered (item) {
108
+ return serial(async () => {
109
+ await queue.removeBy('byId', item.id)
110
+ notify('recovered', { id: item.id, channelPubkey: item.channelPubkey })
111
+ })
112
+ },
113
+ failed (item, error) {
114
+ return serial(async () => {
115
+ if (!isDeferredDecryptionError(error)) throw error
116
+ const current = await queue.getBy('byId', item.id)
117
+ if (!current) return
118
+ const updated = { ...current, code: error.code, macAttempts: current.macAttempts + (error.code === 'INVALID_MAC' ? 1 : 0) }
119
+ await queue.putBy('byId', updated, { existingOnly: true })
120
+ if (updated.macAttempts >= 3 && item.macAttempts < 3) notify('suspended', { id: item.id, code: error.code, channelPubkey: item.channelPubkey })
121
+ })
122
+ },
123
+ clearChannel: channel => serial(() => queue.removeBy('byChannel', channel)),
124
+ async close () { await tail; flush(); await queue.close() }
125
+ }
126
+ }
@@ -185,6 +185,7 @@ function storageDatabaseNames (userPubkey) {
185
185
  const prefix = `libp2r2p:private-messenger:${userPubkey}`
186
186
  return [
187
187
  `${prefix}:idb-queue`,
188
+ `${prefix}:pending-decryption:idb-queue`,
188
189
  `${prefix}:seeds:idb-queue`,
189
190
  `${prefix}:file-seeds:idb-queue`,
190
191
  `${prefix}:file-authorizations:idb-queue`,
@@ -4,6 +4,25 @@
4
4
  outbox and IRFS attachments without presentation state. It builds on
5
5
  PrivateMessenger and the file coordinator, not another recovery protocol.
6
6
 
7
+ `retryPendingDecryptions({ force = false } = {})` forwards a background pass to
8
+ the account messenger while available. Call after committed content-key changes;
9
+ ordinary availability recovery already requests a pass. Ciphertext stays in the
10
+ separate bounded FIFO until admitted to the normal inbox, evicted or explicitly
11
+ cleared. This does not retry application storage/permission failures as crypto.
12
+
13
+ Construction accepts `pendingDecryptionReady` (default `true`). Pass `false` to
14
+ hold pending queue reads/replay until `setPendingDecryptionReady(true)` releases
15
+ them after initial presentation. This never pauses live reception, sends or
16
+ channel preparation. Recovery waits for 250 ms of channel inactivity; a five-second
17
+ maximum admits one packet during sustained activity. It yields between records,
18
+ with at most four consecutive preferred records before another eligible channel.
19
+ `setPendingDecryptionPriority(peer | null)` retains a peer preference through
20
+ preparation; self-chat/`null` or removal clear it. Deadlines prevent preference
21
+ from starving other channels. Account unavailability/close still cancel recovery;
22
+ `force` bypasses only the MAC attempt limit. Awaiting the retry API awaits its
23
+ scheduled pass, so do not await it on the interface's critical startup path.
24
+
25
+
7
26
  Provide `owner`, a byte-based NIP-44 v3 `signer` with `withSharedKey`, and either
8
27
  `eventStore` or a `messageStorage.save(event, { peerPubkey, hearsay })` adapter.
9
28
  Optional `chunkStorage` and `recoveryStorage` (`seeds`, `authorizations`) select
@@ -3,15 +3,19 @@
3
3
  export function createForegroundWork () {
4
4
  let active = 0
5
5
  const waiters = new Set()
6
+ const observers = new Set()
7
+ const notify = () => { for (const listener of observers) listener(active > 0) }
6
8
  const lifetime = new AbortController()
7
9
  return {
8
10
  enter () {
9
11
  active++
12
+ notify()
10
13
  let released = false
11
14
  return () => {
12
15
  if (released) return
13
16
  released = true
14
17
  if (--active === 0) for (const wake of [...waiters]) wake()
18
+ notify()
15
19
  }
16
20
  },
17
21
  wait (signal) {
@@ -24,6 +28,7 @@ export function createForegroundWork () {
24
28
  combined.addEventListener('abort', finish, { once: true })
25
29
  })
26
30
  },
27
- close () { lifetime.abort(); waiters.clear() }
31
+ observe (listener) { observers.add(listener); listener(active > 0); return () => observers.delete(listener) },
32
+ close () { lifetime.abort(); waiters.clear(); observers.clear() }
28
33
  }
29
34
  }
@@ -44,9 +44,11 @@ function messengerStatusSnapshot (value) {
44
44
  return { closed: value.closed, paused: value.paused, pauseReasons: [...value.pauseReasons] }
45
45
  }
46
46
 
47
- export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
47
+ export function createPrivateMessageSession ({ owner, signer, eventStore, messageStorage, chunkStorage, recoveryStorage, fallbackRelays = [], fallbackDelayMs = null, mode = 'seeder', seedersForPeer = peer => [peer], allowedKinds = [5, 9, 1063, 34601], useContentKeys = true, pendingDecryptionReady = true, onMedia = () => {}, onOutbox = () => {}, onError = () => {}, onSendError = () => {}, Messenger = createPrivateMessenger, openOutbox = createChatOutbox, FileTransfer = createPrivateFileTransfer, _onOnline = onOnline, _isOnline = isOnline, _getIykcProofs = getIykcProofs, _setTimeout = setTimeout, _clearTimeout = clearTimeout, _random = Math.random, openDownloads = options => createChatOutbox({ ...options, namespace: 'downloads' }) }) {
48
48
  fallbackRelays = normalizeFallbackRelays(fallbackRelays)
49
49
  fallbackDelayMs = normalizeFallbackDelay(fallbackDelayMs)
50
+ if (typeof pendingDecryptionReady !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
51
+ let pendingDecryptionPeer = null
50
52
  const reportedErrors = new WeakSet()
51
53
  const reportError = error => {
52
54
  if (error && (typeof error === 'object' || typeof error === 'function')) {
@@ -139,7 +141,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
139
141
  let candidate
140
142
  try {
141
143
  candidate = await Messenger({
142
- fallbackRelays, fallbackDelayMs, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait,
144
+ fallbackRelays, fallbackDelayMs, pendingDecryptionReady, seedStorage: recoveryStorage?.seeds, userSigner, channels: [], useContentKeys, _waitForForeground: foreground.wait, _observeForeground: foreground.observe,
143
145
  onMessageQueued: () => { if (messengerBinding === binding) return drain() },
144
146
  onStateChanged: state => {
145
147
  if (closed || messengerBinding !== binding) return
@@ -152,6 +154,8 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
152
154
  if (typeof candidate?.readStatus !== 'function' || !notified) throw new ValidationError('MESSENGER_STATE_CONTRACT_REQUIRED')
153
155
  const status = messengerStatusSnapshot(candidate.readStatus())
154
156
  messenger = candidate
157
+ messenger.setPendingDecryptionReady?.(pendingDecryptionReady)
158
+ messenger.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
155
159
  stateChanged(status)
156
160
  } catch (error) {
157
161
  messengerBinding = null
@@ -168,6 +172,7 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
168
172
  signal.throwIfAborted()
169
173
  const channel = { signer: scoped, pubkey, readerPubkey: pubkey, mode, seeders: seedersForPeer(peer) }
170
174
  channels.set(peer, channel)
175
+ if (peer === pendingDecryptionPeer) messenger?.setPendingDecryptionPriority?.(channel.pubkey)
171
176
  await schedule()
172
177
  signal.throwIfAborted()
173
178
  return channel
@@ -558,6 +563,21 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
558
563
  await writeDownloadIntent(() => downloads?.remove(id))
559
564
  }
560
565
  return {
566
+ setPendingDecryptionReady (ready) {
567
+ if (typeof ready !== 'boolean') throw new ValidationError('INVALID_PENDING_DECRYPTION_READY')
568
+ pendingDecryptionReady = ready
569
+ if (!closed) messenger?.setPendingDecryptionReady?.(ready)
570
+ },
571
+ setPendingDecryptionPriority (peer) {
572
+ if (peer !== null && !/^[0-9a-f]{64}$/.test(peer || '')) throw new ValidationError('INVALID_PENDING_DECRYPTION_PRIORITY')
573
+ pendingDecryptionPeer = peer === owner ? null : peer
574
+ if (!closed) messenger?.setPendingDecryptionPriority?.(channels.get(pendingDecryptionPeer)?.pubkey ?? null)
575
+ },
576
+ async retryPendingDecryptions (options) {
577
+ if (closed || !available) return
578
+ await ensureMessenger()
579
+ if (!closed && available) return messenger?.retryPendingDecryptions(options)
580
+ },
561
581
  download, cancelDownload, preparePeer,
562
582
  // Peers-based so callers can prefetch as soon as a chat route opens, before
563
583
  // the shared-key channel exists. When group chats get real membership, add
@@ -600,6 +620,10 @@ export function createPrivateMessageSession ({ owner, signer, eventStore, messag
600
620
  if (next.size === peers.size && [...next].every(peer => peers.has(peer))) return configuring
601
621
  peers.clear(); for (const peer of next) peers.add(peer)
602
622
  preparation.reconcile(peers)
623
+ if (pendingDecryptionPeer && !peers.has(pendingDecryptionPeer)) {
624
+ pendingDecryptionPeer = null
625
+ messenger?.setPendingDecryptionPriority?.(null)
626
+ }
603
627
  for (const peer of channels.keys()) {
604
628
  if (!peers.has(peer)) {
605
629
  channels.delete(peer); pendingPriorityRanges.delete(peer)