@chipmobilesdk/rn-notification 0.4.1 → 0.6.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +108 -12
  3. package/package.json +10 -2
  4. package/skills/sdk-rn-notification/SKILL.md +6 -5
  5. package/src/advanced.ts +35 -0
  6. package/src/channel/migrate.ts +176 -158
  7. package/src/channel/tone-map.ts +2 -10
  8. package/src/contract/errors.ts +21 -6
  9. package/src/contract/hash.ts +26 -0
  10. package/src/diagnostics/events.ts +10 -1
  11. package/src/diagnostics/redact.ts +9 -26
  12. package/src/diagnostics/snapshot.ts +34 -1
  13. package/src/engine/adapter.ts +21 -1
  14. package/src/engine/notify-kit/events.ts +155 -0
  15. package/src/engine/notify-kit/module.ts +148 -0
  16. package/src/engine/notify-kit/platform/android.ts +172 -0
  17. package/src/engine/notify-kit/platform/index.ts +55 -0
  18. package/src/engine/notify-kit/platform/ios.ts +54 -0
  19. package/src/engine/notify-kit/platform/types.ts +40 -0
  20. package/src/engine/notify-kit-engine.ts +96 -468
  21. package/src/index.ts +9 -0
  22. package/src/initialize.ts +10 -3
  23. package/src/notifications.ts +195 -0
  24. package/src/permission/index.ts +65 -4
  25. package/src/push/fcm-provider.ts +28 -11
  26. package/src/push/gate.ts +27 -5
  27. package/src/reconcile/diff.ts +38 -27
  28. package/src/reconcile/index.ts +13 -4
  29. package/src/recurrence/index.ts +2 -1
  30. package/src/repeat/expiry.ts +2 -6
  31. package/src/repeat/index.ts +45 -1
  32. package/src/repeat/marker.ts +1 -22
  33. package/src/repeat/place.ts +74 -35
  34. package/src/schedule/anchor.ts +35 -19
  35. package/src/schedule/index.ts +10 -4
  36. package/src/schedule/partition.ts +0 -19
  37. package/src/schedule/validate-identifier.ts +8 -6
  38. package/src/schedule/writer.ts +13 -4
  39. package/src/state/caps.ts +28 -3
  40. package/src/state/interaction-buffer.ts +30 -2
  41. package/src/state/mmkv-store.ts +54 -37
  42. package/src/state/permission-history.ts +25 -0
  43. package/src/state/record.ts +114 -20
  44. package/src/state/store.ts +3 -0
  45. package/src/testing/fake-engine.ts +17 -1
@@ -8,7 +8,11 @@
8
8
  * An app that declares no repeating tone needs none of this.
9
9
  */
10
10
 
11
+ import type { InteractionEvent } from '../contract/interaction';
11
12
  import type { NotificationEngineAdapter } from '../engine/adapter';
13
+ import { buffer } from '../state/interaction-buffer';
14
+ import { StateManager } from '../state/record';
15
+ import type { StateStore } from '../state/store';
12
16
  import { createRepeatExpiryHandler } from './expiry';
13
17
 
14
18
  export { createRepeatExpiryHandler } from './expiry';
@@ -18,6 +22,22 @@ export { REPEAT_STOP_PREFIX, isRepeatStopId, markerIdFor, repeatStopFor } from '
18
22
  export interface BackgroundHandlerRegistration {
19
23
  readonly engine: NotificationEngineAdapter;
20
24
  readonly emit?: (code: string, message: string, context?: Record<string, unknown>) => void;
25
+ /**
26
+ * Handles a tap, action or dismissal that arrives while no part of the app is
27
+ * listening — typically a background action in a process the system started
28
+ * just for it (0.6.0). Runs inside the platform's background window, so keep
29
+ * it short. When the app's own `onInteraction` handlers are alive they get the
30
+ * event instead, with `appState: 'background'`.
31
+ */
32
+ readonly onInteraction?: (event: InteractionEvent) => Promise<void> | void;
33
+ /**
34
+ * Where to keep an interaction nobody handled, so `onInteraction` delivers it
35
+ * when the app next starts: the same store the runtime uses
36
+ * (`createMmkvStore()`). Used when `onInteraction` is absent or throws.
37
+ */
38
+ readonly store?: StateStore;
39
+ /** The clock for what is kept. Defaults to the system clock; injected in tests. */
40
+ readonly now?: () => number;
21
41
  }
22
42
 
23
43
  /**
@@ -37,7 +57,31 @@ export function registerBackgroundNotificationHandler(
37
57
  ): void {
38
58
  const handler = createRepeatExpiryHandler(registration);
39
59
 
40
- registration.engine.registerBackgroundDeliveryHandler(event => handler.handle(event));
60
+ const keep = (event: InteractionEvent): void => {
61
+ if (registration.store === undefined) return;
62
+ // A manager of its own: this runs in a process with no runtime, and the
63
+ // runtime created later loads what is written here.
64
+ buffer(
65
+ new StateManager({ store: registration.store, now: registration.now ?? (() => Date.now()) }),
66
+ event,
67
+ );
68
+ };
69
+
70
+ registration.engine.registerBackgroundDeliveryHandler(
71
+ event => handler.handle(event),
72
+ async event => {
73
+ if (registration.onInteraction === undefined) {
74
+ keep(event);
75
+ return;
76
+ }
77
+ try {
78
+ await registration.onInteraction(event);
79
+ } catch {
80
+ // The app's handler failed; the interaction is kept rather than lost.
81
+ keep(event);
82
+ }
83
+ },
84
+ );
41
85
 
42
86
  // Both paths, not just the background one. The engine routes a delivery to
43
87
  // the foreground listener while the app is alive and to the headless task
@@ -14,6 +14,7 @@
14
14
  * consumer identifier ends up colliding with an internal one.
15
15
  */
16
16
 
17
+ import { fnv1a, hex8 } from '../contract/hash';
17
18
  import type { RepeatStop } from '../engine/adapter';
18
19
 
19
20
  /**
@@ -25,28 +26,6 @@ import type { RepeatStop } from '../engine/adapter';
25
26
  */
26
27
  export const REPEAT_STOP_PREFIX = 'cms.stop:';
27
28
 
28
- /**
29
- * FNV-1a, 32-bit. Deterministic, dependency-free, and stable across releases.
30
- *
31
- * Bitwise operators are the algorithm, not a micro-optimisation: arithmetic
32
- * equivalents would not wrap to 32 bits, and a marker id that changed between
33
- * releases would orphan every marker already scheduled on a device.
34
- */
35
- /* eslint-disable no-bitwise */
36
- function fnv1a(value: string, basis: number): number {
37
- let hash = basis;
38
- for (let index = 0; index < value.length; index += 1) {
39
- hash ^= value.codePointAt(index) ?? 0;
40
- hash = Math.imul(hash, 0x01000193) >>> 0;
41
- }
42
- return hash >>> 0;
43
- }
44
- /* eslint-enable no-bitwise */
45
-
46
- function hex8(value: number): string {
47
- return value.toString(16).padStart(8, '0');
48
- }
49
-
50
29
  /**
51
30
  * The marker identifier for a target notification.
52
31
  *
@@ -27,47 +27,86 @@ export interface StopPlacement {
27
27
  sync(targetId: string, tone: ResolvedTone, deliversAt: number): Promise<void>;
28
28
  /** Drops any marker still guarding an identifier. */
29
29
  release(targetId: string): Promise<void>;
30
+ /** How many markers are outstanding: platform capacity they hold. */
31
+ count(): Promise<number>;
32
+ /**
33
+ * A placement for one pass of many operations (NOT-08). It reads the
34
+ * outstanding markers once and keeps that view current as it places and
35
+ * releases, instead of reading every pending trigger across the bridge for
36
+ * each operation — which made a reconcile of a few hundred changes do a few
37
+ * hundred full reads.
38
+ */
39
+ pass(): StopPlacement;
30
40
  }
31
41
 
32
42
  export function createStopPlacement(engine: NotificationEngineAdapter): StopPlacement {
33
- const release = async (targetId: string): Promise<void> => {
34
- const outstanding = await engine.listRepeatStops();
35
- const held = outstanding.find(stop => stop.targetId === targetId);
36
- if (held !== undefined) {
37
- await engine.cancel(held.markerId);
38
- }
39
- };
43
+ /** `snapshot`: read once and kept current, for a pass; otherwise read on every call. */
44
+ function placement(snapshot: boolean): StopPlacement {
45
+ let known: Map<string, string> | undefined;
46
+
47
+ /** targetId → markerId of every outstanding stop. */
48
+ const outstanding = async (): Promise<Map<string, string>> => {
49
+ if (snapshot && known) {
50
+ return known;
51
+ }
52
+ const read = new Map(
53
+ (await engine.listRepeatStops()).map(stop => [stop.targetId, stop.markerId] as const),
54
+ );
55
+ if (snapshot) {
56
+ known = read;
57
+ }
58
+ return read;
59
+ };
60
+
61
+ const release = async (targetId: string): Promise<void> => {
62
+ const stops = await outstanding();
63
+ const markerId = stops.get(targetId);
64
+ if (markerId !== undefined) {
65
+ await engine.cancel(markerId);
66
+ stops.delete(targetId);
67
+ }
68
+ };
69
+
70
+ return {
71
+ release,
72
+
73
+ async count() {
74
+ return (await outstanding()).size;
75
+ },
76
+
77
+ async sync(targetId, tone, deliversAt) {
78
+ await release(targetId);
40
79
 
41
- return {
42
- release,
80
+ const forMs = tone.android.repeatForMs;
81
+ if (!tone.android.loopSound || forMs === undefined) return;
43
82
 
44
- async sync(targetId, tone, deliversAt) {
45
- await release(targetId);
83
+ const capabilities = await engine.getCapabilities();
84
+ if (!capabilities.supportsRepeatingAlert) return;
46
85
 
47
- const forMs = tone.android.repeatForMs;
48
- if (!tone.android.loopSound || forMs === undefined) return;
86
+ const stop = repeatStopFor(targetId, deliversAt, forMs);
87
+ await engine.schedule({
88
+ request: {
89
+ id: stop.markerId,
90
+ // Deliberately empty. A marker is delivered and removed within the
91
+ // same handler invocation; anything readable here would be text a
92
+ // user might glimpse and be confused by.
93
+ content: { title: '', body: '' },
94
+ tone: tone.toneId,
95
+ // Absolute: the bound is a duration from a known instant, not a
96
+ // wall-clock time that should follow the user across time zones.
97
+ moment: { at: stop.dueAt, anchoring: 'absolute' },
98
+ },
99
+ resolvedAt: stop.dueAt,
100
+ tone,
101
+ approximate: false,
102
+ repeatStop: stop,
103
+ });
104
+ known?.set(targetId, stop.markerId);
105
+ },
49
106
 
50
- const capabilities = await engine.getCapabilities();
51
- if (!capabilities.supportsRepeatingAlert) return;
107
+ pass: () => placement(true),
108
+ };
109
+ }
52
110
 
53
- const stop = repeatStopFor(targetId, deliversAt, forMs);
54
- await engine.schedule({
55
- request: {
56
- id: stop.markerId,
57
- // Deliberately empty. A marker is delivered and removed within the
58
- // same handler invocation; anything readable here would be text a
59
- // user might glimpse and be confused by.
60
- content: { title: '', body: '' },
61
- tone: tone.toneId,
62
- // Absolute: the bound is a duration from a known instant, not a
63
- // wall-clock time that should follow the user across time zones.
64
- moment: { at: stop.dueAt, anchoring: 'absolute' },
65
- },
66
- resolvedAt: stop.dueAt,
67
- tone,
68
- approximate: false,
69
- repeatStop: stop,
70
- });
71
- },
72
- };
111
+ return placement(false);
73
112
  }
@@ -16,12 +16,27 @@
16
16
  * each entry is what makes that detectable instead of invisible.
17
17
  */
18
18
 
19
+ import {
20
+ getDateTimeFormat,
21
+ isValidTimeZone as isZoneValid,
22
+ lazyPeer,
23
+ } from '@chipmobilesdk/rn-core';
24
+
19
25
  import type { AnchoringMode, PendingEntry } from '../contract/notification';
20
26
  import { DEFAULT_ANCHORING_MODE } from '../contract/notification';
21
27
 
22
28
  /** Reads the platform time zone. Injected so the pure logic needs no native module. */
23
29
  export type TimeZoneReader = () => string;
24
30
 
31
+ const localizePeer = lazyPeer('react-native-localize', () => {
32
+ // Inside `try`, so Metro bundles an app that leaves this optional peer out.
33
+ try {
34
+ return require('react-native-localize') as { getTimeZone(): string };
35
+ } catch (cause) {
36
+ throw cause;
37
+ }
38
+ });
39
+
25
40
  /**
26
41
  * Resolves the platform zone via `react-native-localize`.
27
42
  *
@@ -32,9 +47,10 @@ export type TimeZoneReader = () => string;
32
47
  */
33
48
  export function createPlatformTimeZoneReader(): TimeZoneReader {
34
49
  return () => {
50
+ const localize = localizePeer.get();
35
51
  try {
36
- const localize = require('react-native-localize') as { getTimeZone(): string };
37
- return localize.getTimeZone();
52
+ if (localize.ok) return localize.module.getTimeZone();
53
+ return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
38
54
  } catch {
39
55
  try {
40
56
  return Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
@@ -65,15 +81,22 @@ export interface WallClockParts {
65
81
  * tone id here — so it must be checked before it reaches `Intl`.
66
82
  */
67
83
  export function isValidTimeZone(timeZone: string): boolean {
68
- if (timeZone.length === 0) return false;
69
- try {
70
- new Intl.DateTimeFormat('en-US', { timeZone }).format(0);
71
- return true;
72
- } catch {
73
- return false;
74
- }
84
+ // rn-core memoizes the answer per zone: a recurrence expansion asks thousands
85
+ // of times about the same few zones (PERF-01).
86
+ return isZoneValid(timeZone);
75
87
  }
76
88
 
89
+ /** The options `partsInZone` formats with; one shared formatter per zone. */
90
+ const PARTS_OPTIONS = {
91
+ year: 'numeric',
92
+ month: '2-digit',
93
+ day: '2-digit',
94
+ hour: '2-digit',
95
+ minute: '2-digit',
96
+ second: '2-digit',
97
+ hour12: false,
98
+ } as const;
99
+
77
100
  /**
78
101
  * Decomposes an instant into the clock reading it produces in a given zone.
79
102
  *
@@ -82,16 +105,9 @@ export function isValidTimeZone(timeZone: string): boolean {
82
105
  */
83
106
  export function partsInZone(instant: number, timeZone: string): WallClockParts {
84
107
  const zone = isValidTimeZone(timeZone) ? timeZone : 'UTC';
85
- const formatter = new Intl.DateTimeFormat('en-US', {
86
- timeZone: zone,
87
- year: 'numeric',
88
- month: '2-digit',
89
- day: '2-digit',
90
- hour: '2-digit',
91
- minute: '2-digit',
92
- second: '2-digit',
93
- hour12: false,
94
- });
108
+ // Built once per zone and reused (PERF-01): constructing one per call was
109
+ // most of the cost of expanding a recurrence.
110
+ const formatter = getDateTimeFormat('en-US', { ...PARTS_OPTIONS, timeZone: zone });
95
111
 
96
112
  const fields: Record<string, number> = {};
97
113
  for (const part of formatter.formatToParts(new Date(instant))) {
@@ -97,8 +97,12 @@ export function createScheduler(deps: ScheduleDependencies) {
97
97
  const anchored = anchorMoment(request.moment, zone);
98
98
  const resolution = resolveMoment(anchored.resolvedAt, deps.now());
99
99
 
100
- const existing = await deps.engine.getPending();
101
- const replaced = existing.some(entry => entry.id === request.id);
100
+ // Identifiers only where the engine can list them cheaply: reading every
101
+ // pending payload across the bridge just to answer "was this one already
102
+ // there" cost a full read per schedule (NOT-08).
103
+ const replaced = deps.engine.pendingIds
104
+ ? (await deps.engine.pendingIds()).includes(request.id)
105
+ : (await deps.engine.getPending()).some(entry => entry.id === request.id);
102
106
 
103
107
  // Approximate mode is a degradation, not a refusal: the notification is
104
108
  // still scheduled and the caller is told it may arrive late.
@@ -149,7 +153,8 @@ export function createScheduler(deps: ScheduleDependencies) {
149
153
  validateIdentifier(groupTag, 'groupTag');
150
154
  const ids = idsForTag(await deps.engine.getPending(), 'group', groupTag);
151
155
  await deps.engine.cancelMany(ids);
152
- for (const id of ids) await writer.releaseStop(id);
156
+ const pass = writer.pass();
157
+ for (const id of ids) await pass.releaseStop(id);
153
158
  return ids;
154
159
  },
155
160
 
@@ -157,7 +162,8 @@ export function createScheduler(deps: ScheduleDependencies) {
157
162
  validateIdentifier(ownerTag, 'ownerTag');
158
163
  const ids = idsForTag(await deps.engine.getPending(), 'owner', ownerTag);
159
164
  await deps.engine.cancelMany(ids);
160
- for (const id of ids) await writer.releaseStop(id);
165
+ const pass = writer.pass();
166
+ for (const id of ids) await pass.releaseStop(id);
161
167
  return ids;
162
168
  },
163
169
 
@@ -67,22 +67,3 @@ export function partition(
67
67
 
68
68
  return { tagged, untagged };
69
69
  }
70
-
71
- /**
72
- * True when cancelling `tag` would affect any entry outside it.
73
- *
74
- * Exists so isolation can be asserted directly rather than inferred from a
75
- * count — the property that matters is that one account's sign-out changes
76
- * exactly zero of another account's notifications.
77
- */
78
- export function wouldAffectOtherTags(
79
- entries: readonly PendingEntry[],
80
- kind: PartitionKind,
81
- tag: string,
82
- ): boolean {
83
- const selected = new Set(idsForTag(entries, kind, tag));
84
- return entries.some(entry => {
85
- const entryTag = kind === 'owner' ? entry.ownerTag : entry.groupTag;
86
- return selected.has(entry.id) && entryTag !== tag;
87
- });
88
- }
@@ -108,12 +108,14 @@ export function validateIdentifier(value: unknown, kind: IdentifierKind = 'id'):
108
108
 
109
109
  /** Non-throwing form, for validating a batch without unwinding on the first failure. */
110
110
  export function isValidIdentifier(value: unknown): value is string {
111
- return (
112
- typeof value === 'string' &&
113
- value.length > 0 &&
114
- value.length <= MAX_IDENTIFIER_LENGTH &&
115
- IDENTIFIER_PATTERN.test(value)
116
- );
111
+ // The throwing form is the rule; this one only reports whether it holds, so
112
+ // the two cannot disagree (the reserved marker prefix was missing here).
113
+ try {
114
+ validateIdentifier(value);
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
117
119
  }
118
120
 
119
121
  /** Validates an optional tag, returning undefined when absent rather than failing. */
@@ -14,7 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { NotificationEngineAdapter, EngineScheduleRequest } from '../engine/adapter';
17
- import { createStopPlacement } from '../repeat/place';
17
+ import { createStopPlacement, type StopPlacement } from '../repeat/place';
18
18
 
19
19
  export interface ScheduleWriter {
20
20
  /** Writes one notification and brings its stop marker in line with it. */
@@ -28,11 +28,19 @@ export interface ScheduleWriter {
28
28
  * real capacity, so the usable ceiling is the platform's minus theirs.
29
29
  */
30
30
  capacity(): Promise<number>;
31
+ /**
32
+ * A writer for one pass of many writes (NOT-08): it reads the outstanding stop
33
+ * markers once and keeps that view current, instead of a full read of every
34
+ * pending trigger per operation.
35
+ */
36
+ pass(): ScheduleWriter;
31
37
  }
32
38
 
33
39
  export function createScheduleWriter(engine: NotificationEngineAdapter): ScheduleWriter {
34
- const stops = createStopPlacement(engine);
40
+ return writerOver(engine, createStopPlacement(engine));
41
+ }
35
42
 
43
+ function writerOver(engine: NotificationEngineAdapter, stops: StopPlacement): ScheduleWriter {
36
44
  return {
37
45
  async write(input) {
38
46
  await engine.schedule(input);
@@ -48,8 +56,9 @@ export function createScheduleWriter(engine: NotificationEngineAdapter): Schedul
48
56
 
49
57
  async capacity() {
50
58
  const capabilities = await engine.getCapabilities();
51
- const markerCost = (await engine.listRepeatStops()).length;
52
- return Math.max(0, capabilities.pendingCeiling - markerCost);
59
+ return Math.max(0, capabilities.pendingCeiling - (await stops.count()));
53
60
  },
61
+
62
+ pass: () => writerOver(engine, stops.pass()),
54
63
  };
55
64
  }
package/src/state/caps.ts CHANGED
@@ -26,8 +26,21 @@ export interface EvictionOutcome<T> {
26
26
  readonly kept: readonly T[];
27
27
  readonly evicted: readonly T[];
28
28
  readonly reason: 'none' | 'cap' | 'retention' | 'cap+retention';
29
+ /** Entries kept beyond the cap because none of them may be evicted (NOT-16). */
30
+ readonly overCap: number;
29
31
  }
30
32
 
33
+ /**
34
+ * Which entries a cap may drop (NOT-16). The migration and series ledgers record
35
+ * live facts: an active channel's applied version, a series' count. Dropping one
36
+ * makes the package treat a migrated tone as new and restart a series from zero,
37
+ * so only retired and exhausted entries go; the rest are kept and reported.
38
+ */
39
+ const EVICTABLE: Partial<Record<CollectionName, (entry: unknown) => boolean>> = {
40
+ migrationLedger: entry => (entry as { retired?: boolean }).retired === true,
41
+ seriesLedger: entry => (entry as { exhausted?: boolean }).exhausted === true,
42
+ };
43
+
31
44
  /**
32
45
  * Applies retention first, then the size cap.
33
46
  *
@@ -55,8 +68,15 @@ export function evict<T extends Timestamped>(
55
68
  // Oldest first, so the tail is what we keep when trimming.
56
69
  live.sort((a, b) => a.at - b.at);
57
70
 
58
- const overflow = live.length > maxEntries ? live.slice(0, live.length - maxEntries) : [];
59
- const kept = live.length > maxEntries ? live.slice(live.length - maxEntries) : live;
71
+ const evictable = EVICTABLE[collection] ?? (() => true);
72
+ const excess = Math.max(0, live.length - maxEntries);
73
+ const overflow: T[] = [];
74
+ for (const entry of live) {
75
+ if (overflow.length === excess) break;
76
+ if (evictable(entry)) overflow.push(entry);
77
+ }
78
+ const dropped = new Set(overflow);
79
+ const kept = overflow.length === 0 ? live : live.filter(entry => !dropped.has(entry));
60
80
 
61
81
  let reason: EvictionOutcome<T>['reason'] = 'none';
62
82
  if (expired.length > 0 && overflow.length > 0) {
@@ -67,5 +87,10 @@ export function evict<T extends Timestamped>(
67
87
  reason = 'retention';
68
88
  }
69
89
 
70
- return { kept, evicted: [...expired, ...overflow], reason };
90
+ return {
91
+ kept,
92
+ evicted: [...expired, ...overflow],
93
+ reason,
94
+ overCap: Math.max(0, kept.length - maxEntries),
95
+ };
71
96
  }
@@ -9,11 +9,28 @@
9
9
  * cannot be delivered without its routing payload. It is evicted the moment it
10
10
  * is delivered, and time-limited regardless, so an event nobody ever collects
11
11
  * cannot sit on disk forever.
12
+ *
13
+ * Text the user typed into a reply action is never written to disk (NOT-14): it
14
+ * is free text, and the store is not encrypted. It is held in memory beside the
15
+ * buffer, so a handler registered later in the same process still receives it;
16
+ * if the process dies first, the event is delivered without `inputText`.
12
17
  */
13
18
 
14
19
  import type { StateManager, BufferedInteraction } from './record';
15
20
  import type { InteractionEvent } from '../contract/interaction';
16
21
 
22
+ /** Typed replies, in memory only, per state manager and buffered event id. */
23
+ const typedReplies = new WeakMap<StateManager, Map<string, string>>();
24
+
25
+ function repliesOf(state: StateManager): Map<string, string> {
26
+ let replies = typedReplies.get(state);
27
+ if (!replies) {
28
+ replies = new Map();
29
+ typedReplies.set(state, replies);
30
+ }
31
+ return replies;
32
+ }
33
+
17
34
  /** Identity for a buffered event, derived so re-buffering cannot duplicate. */
18
35
  function bufferedId(event: InteractionEvent): string {
19
36
  return `${event.kind}:${event.notificationId}:${event.actionId ?? ''}:${event.occurredAt}`;
@@ -21,6 +38,9 @@ function bufferedId(event: InteractionEvent): string {
21
38
 
22
39
  export function buffer(state: StateManager, event: InteractionEvent): void {
23
40
  const id = bufferedId(event);
41
+ if (event.inputText !== undefined) {
42
+ repliesOf(state).set(id, event.inputText);
43
+ }
24
44
 
25
45
  state.update('interactionBuffer', current => {
26
46
  if (current.some(entry => entry.id === id)) return current;
@@ -31,7 +51,6 @@ export function buffer(state: StateManager, event: InteractionEvent): void {
31
51
  kind: event.kind,
32
52
  notificationId: event.notificationId,
33
53
  actionId: event.actionId,
34
- inputText: event.inputText,
35
54
  routing: event.routing,
36
55
  ownerTag: event.ownerTag,
37
56
  };
@@ -52,12 +71,15 @@ export function drain(state: StateManager): readonly InteractionEvent[] {
52
71
  if (buffered.length === 0) return [];
53
72
 
54
73
  state.update('interactionBuffer', () => []);
74
+ const replies = repliesOf(state);
55
75
 
56
76
  return buffered.map(entry => ({
57
77
  kind: entry.kind,
58
78
  notificationId: entry.notificationId,
59
79
  actionId: entry.actionId,
60
- inputText: entry.inputText,
80
+ // A record written before 0.5 may still carry the text on disk; it is
81
+ // delivered once, and the buffer was just cleared.
82
+ inputText: takeReply(replies, entry.id) ?? entry.inputText,
61
83
  routing: entry.routing,
62
84
  ownerTag: entry.ownerTag,
63
85
  occurredAt: entry.at,
@@ -71,3 +93,9 @@ export function drain(state: StateManager): readonly InteractionEvent[] {
71
93
  export function bufferedCount(state: StateManager): number {
72
94
  return state.read().interactionBuffer.length;
73
95
  }
96
+
97
+ function takeReply(replies: Map<string, string>, id: string): string | undefined {
98
+ const text = replies.get(id);
99
+ replies.delete(id);
100
+ return text;
101
+ }