react-native-notify-sphere 1.1.0 → 1.2.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-notify-sphere",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Notify sphere npm package",
5
5
  "main": "./lib/module/index",
6
6
  "types": "lib/typescript/src/index.d.ts",
@@ -78,6 +78,7 @@
78
78
  "react": "19.1.0",
79
79
  "react-native": "0.81.1",
80
80
  "react-native-builder-bob": "^0.40.13",
81
+ "react-native-device-info": "^15.0.1",
81
82
  "release-it": "^19.0.4",
82
83
  "turbo": "^2.5.6",
83
84
  "typescript": "^5.9.2"
@@ -91,7 +92,8 @@
91
92
  "@react-native-firebase/app": "^22.2.1",
92
93
  "@react-native-firebase/messaging": "^22.2.1",
93
94
  "react": "*",
94
- "react-native": "*"
95
+ "react-native": "*",
96
+ "react-native-device-info": ">=10.0.0"
95
97
  },
96
98
  "workspaces": [
97
99
  "example"
package/src/index.tsx CHANGED
@@ -1,6 +1,8 @@
1
1
  import messaging from '@react-native-firebase/messaging';
2
+ import DeviceInfo from 'react-native-device-info';
2
3
  import notifee, {
3
4
  AndroidImportance,
5
+ AndroidVisibility,
4
6
  AndroidStyle,
5
7
  EventType,
6
8
  type AndroidChannel,
@@ -9,9 +11,10 @@ import axios from 'axios';
9
11
  import AsyncStorage from '@react-native-async-storage/async-storage';
10
12
  import { PermissionsAndroid, Platform } from 'react-native';
11
13
 
12
- const STORAGE_KEY_TOKEN = '@notifysphere_fcm_token';
13
- const STORAGE_KEY_SUB_ID = '@notifysphere_subscription_id';
14
+ const STORAGE_KEY_TOKEN = '@notifysphere_fcm_token';
15
+ const STORAGE_KEY_SUB_ID = '@notifysphere_subscription_id';
14
16
  const STORAGE_KEY_USER_HASH = '@notifysphere_user_hash';
17
+ const STORAGE_KEY_CHANNELS = '@notifysphere_channels';
15
18
 
16
19
  // ─── Type definitions ──────────────────────────────────────────────────────────
17
20
 
@@ -52,6 +55,28 @@ export type NotificationCallback = (
52
55
  type?: string
53
56
  ) => void;
54
57
 
58
+ /** Shape of a single notification channel returned by the registration API */
59
+ export interface BackendChannelConfig {
60
+ channel_id: string;
61
+ name: string;
62
+ description?: string;
63
+ /** Android importance level sent by backend — mapped to AndroidImportance */
64
+ Importance?: number;
65
+ /** 1 = enable LED, 0 = disable */
66
+ LED?: number;
67
+ led_color?: string | null;
68
+ /** 'Custom' | 'Default' | 'None' */
69
+ sound?: string;
70
+ /** Raw resource name used when sound === 'Custom' (e.g. 'nowait_sound') */
71
+ soundDetail?: string;
72
+ /** 1 = vibration on, 0 = off */
73
+ vibration?: number;
74
+ vibrationDetail?: string | null;
75
+ badge?: number;
76
+ /** 1 = PUBLIC, 0 = PRIVATE, -1 = SECRET */
77
+ lockScreen?: number;
78
+ }
79
+
55
80
  export type InitializeConfig = {
56
81
  /** Your application's user ID for this device */
57
82
  applicationUserId: number;
@@ -113,14 +138,27 @@ const warn = (...args: unknown[]) => {
113
138
  const logError = (...args: unknown[]) =>
114
139
  console.error('[NotifySphere]', ...args);
115
140
 
141
+ // ─── SDK version ──────────────────────────────────────────────────────────────
142
+
143
+ const SDK_VERSION = '1.2.1';
144
+
145
+ // ─── Device info result shape ─────────────────────────────────────────────────
146
+
147
+ interface DeviceInfoResult {
148
+ sdk: string;
149
+ device_model: string;
150
+ device_os: string;
151
+ device_version: string;
152
+ carrier: string;
153
+ app_version: string;
154
+ timezone_id: string;
155
+ }
156
+
116
157
  // ─── Default endpoints ────────────────────────────────────────────────────────
117
158
 
118
- const DEFAULT_BASE_URL = 'https://api.notifysphere.in';
159
+ const DEFAULT_BASE_URL = 'https://api.notifysphere.in/client/';
119
160
  // const DEFAULT_BASE_URL = 'https://apinotify.dothejob.in';
120
- const DEFAULT_TRACKING_URL =
121
- DEFAULT_BASE_URL+'/onpress/handle';
122
- const DEFAULT_DELIVERY_URL =
123
- DEFAULT_BASE_URL+'/delivery/confirm';;
161
+ //const DEFAULT_BASE_URL = 'https://apinotify.dothejob.in/client/';
124
162
 
125
163
  // ─── NotifySphere class ───────────────────────────────────────────────────────
126
164
 
@@ -130,8 +168,8 @@ class NotifySphere {
130
168
  private static appId: string | null = null;
131
169
  private static subscriptionId: string | null = null;
132
170
  private static baseUrl: string = DEFAULT_BASE_URL;
133
- private static trackingUrl: string = DEFAULT_TRACKING_URL;
134
- private static deliveryUrl: string = DEFAULT_DELIVERY_URL;
171
+ private static trackingUrl: string = '';
172
+ private static deliveryUrl: string = '';
135
173
  private static apiKey: string | null = null;
136
174
  private static unsubscribers: Array<() => void> = [];
137
175
  private static initialized = false;
@@ -166,7 +204,7 @@ class NotifySphere {
166
204
  subscription_id: NotifySphere.subscriptionId,
167
205
  onPressStatus: 1,
168
206
  },
169
- { headers, maxBodyLength: Infinity }
207
+ { headers, maxBodyLength: 1024 * 50, timeout: 10000 }
170
208
  );
171
209
 
172
210
  log('onPress tracking response:', response.data);
@@ -200,7 +238,7 @@ class NotifySphere {
200
238
  if (NotifySphere.apiKey) {
201
239
  headers['Authorization'] = `Bearer ${NotifySphere.apiKey}`;
202
240
  }
203
- console.log("NotifySphere.deliveryUrl",NotifySphere.deliveryUrl);
241
+ log('Sending delivery confirmation to:', NotifySphere.deliveryUrl);
204
242
  const response = await axios.post(
205
243
  NotifySphere.deliveryUrl,
206
244
  {
@@ -208,7 +246,7 @@ class NotifySphere {
208
246
  subscription_id: NotifySphere.subscriptionId,
209
247
  onDeliveredStatus: 1,
210
248
  },
211
- { headers, maxBodyLength: Infinity }
249
+ { headers, maxBodyLength: 1024 * 50, timeout: 10000 }
212
250
  );
213
251
 
214
252
  log('Delivery confirmation response:', response.data);
@@ -240,6 +278,44 @@ class NotifySphere {
240
278
  }
241
279
  }
242
280
 
281
+ // ─── Device info ──────────────────────────────────────────────────────────
282
+
283
+ /**
284
+ * Collects device metadata sent to the registration API.
285
+ * Uses react-native-device-info for model, carrier, and app version.
286
+ * Falls back gracefully if any call fails so registration is never blocked.
287
+ */
288
+ private static async getDeviceInfo(): Promise<DeviceInfoResult> {
289
+ try {
290
+ // getCarrier() is the only async call — run everything in parallel
291
+ const [carrier] = await Promise.all([
292
+ DeviceInfo.getCarrier().catch(() => ''),
293
+ ]);
294
+
295
+ return {
296
+ sdk: SDK_VERSION,
297
+ device_model: DeviceInfo.getModel(),
298
+ device_os: Platform.OS,
299
+ device_version: DeviceInfo.getSystemVersion(),
300
+ carrier: carrier ?? '',
301
+ app_version: DeviceInfo.getVersion(),
302
+ // Intl is available in Hermes/JSC without any extra package
303
+ timezone_id: Intl.DateTimeFormat().resolvedOptions().timeZone ?? '',
304
+ };
305
+ } catch (err) {
306
+ warn('getDeviceInfo failed, using fallback values:', err);
307
+ return {
308
+ sdk: SDK_VERSION,
309
+ device_model: '',
310
+ device_os: Platform.OS,
311
+ device_version: String(Platform.Version),
312
+ carrier: '',
313
+ app_version: '',
314
+ timezone_id: '',
315
+ };
316
+ }
317
+ }
318
+
243
319
  // ─── User details fingerprint ─────────────────────────────────────────────
244
320
 
245
321
  /**
@@ -290,9 +366,13 @@ class NotifySphere {
290
366
  ): Promise<string | undefined> {
291
367
  _debug = config.debug ?? false;
292
368
  NotifySphere.appId = config.appId;
293
- NotifySphere.baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
294
- NotifySphere.trackingUrl = config.trackingUrl ?? DEFAULT_TRACKING_URL;
295
- NotifySphere.deliveryUrl = config.deliveryUrl ?? DEFAULT_DELIVERY_URL;
369
+ NotifySphere.baseUrl = NotifySphere.enforceHttps(config.baseUrl ?? DEFAULT_BASE_URL, true);
370
+ NotifySphere.trackingUrl = NotifySphere.enforceHttps(
371
+ config.trackingUrl ?? `${NotifySphere.baseUrl}${config.appId}/onpress/handle`
372
+ );
373
+ NotifySphere.deliveryUrl = NotifySphere.enforceHttps(
374
+ config.deliveryUrl ?? `${NotifySphere.baseUrl}${config.appId}/delivery/confirm`
375
+ );
296
376
  NotifySphere.apiKey = config.apiKey ?? null;
297
377
 
298
378
  const hasPermission = await NotifySphere.checkApplicationPermission();
@@ -315,11 +395,21 @@ class NotifySphere {
315
395
  // ── Only call the registration API when the FCM token OR any user
316
396
  // detail has changed. This avoids a redundant network call on every
317
397
  // app open while still syncing updates (email, phone, location, etc.)
318
- const [cachedToken, cachedUserHash] = await Promise.all([
398
+ const [cachedToken, cachedUserHash, cachedChannels] = await Promise.all([
319
399
  AsyncStorage.getItem(STORAGE_KEY_TOKEN),
320
400
  AsyncStorage.getItem(STORAGE_KEY_USER_HASH),
401
+ AsyncStorage.getItem(STORAGE_KEY_CHANNELS),
321
402
  ]);
322
403
 
404
+ // Re-create all backend-configured channels on every launch so they
405
+ // always exist before a background notification arrives — even after
406
+ // a reinstall or app-data clear. Notifee's createChannel is idempotent.
407
+ if (cachedChannels) {
408
+ const channels = JSON.parse(cachedChannels) as BackendChannelConfig[];
409
+ await NotifySphere.createChannelsFromConfig(channels);
410
+ log('Channels restored from cache:', channels.length);
411
+ }
412
+
323
413
  const currentUserHash = NotifySphere.buildUserHash(config);
324
414
  let subscriptionId: string | undefined = cachedSubId ?? undefined;
325
415
 
@@ -369,8 +459,10 @@ class NotifySphere {
369
459
  fcmToken: string,
370
460
  userHash?: string
371
461
  ): Promise<string | undefined> {
462
+
463
+ const devInfo = await NotifySphere.getDeviceInfo();
372
464
  const res = await axios.post(
373
- `${NotifySphere.baseUrl}/apps/${config.appId}/users`,
465
+ `${NotifySphere.baseUrl}${config.appId}/users`,
374
466
  {
375
467
  applicationUserId: config.applicationUserId,
376
468
  token: fcmToken,
@@ -384,11 +476,24 @@ class NotifySphere {
384
476
  email: config.email,
385
477
  phone: config.phone,
386
478
  tags: config.tags,
479
+ sdk: devInfo.sdk,
480
+ device_model: devInfo.device_model,
481
+ device_os: devInfo.device_os,
482
+ device_version: devInfo.device_version,
483
+ carrier: devInfo.carrier,
484
+ app_version: devInfo.app_version,
485
+ timezone_id: devInfo.timezone_id,
387
486
  },
388
487
  },
389
488
  {
390
- headers: { 'Content-Type': 'application/json' },
391
- maxBodyLength: Infinity,
489
+ headers: {
490
+ 'Content-Type': 'application/json',
491
+ ...(NotifySphere.apiKey
492
+ ? { Authorization: `Bearer ${NotifySphere.apiKey}` }
493
+ : {}),
494
+ },
495
+ maxBodyLength: 1024 * 50,
496
+ timeout: 10000,
392
497
  }
393
498
  );
394
499
 
@@ -396,6 +501,17 @@ class NotifySphere {
396
501
  const subscriptionId: string | undefined = res.data?.subscription_id;
397
502
  NotifySphere.subscriptionId = subscriptionId ?? null;
398
503
 
504
+ // Extract channels from the response and create them immediately.
505
+ // This ensures every backend-configured channel exists on the device
506
+ // before any background / terminated-state notification can arrive.
507
+ const channels: BackendChannelConfig[] | undefined =
508
+ res.data?.notification_channels;
509
+ if (channels && channels.length > 0) {
510
+ await NotifySphere.createChannelsFromConfig(channels);
511
+ await AsyncStorage.setItem(STORAGE_KEY_CHANNELS, JSON.stringify(channels));
512
+ log('Channels created and cached from registration response:', channels.length);
513
+ }
514
+
399
515
  // Persist token, subscription_id, and user hash for future launch comparisons
400
516
  const hash = userHash ?? NotifySphere.buildUserHash(config);
401
517
  await Promise.all([
@@ -457,7 +573,7 @@ class NotifySphere {
457
573
 
458
574
  try {
459
575
  const res = await axios.post(
460
- `${NotifySphere.baseUrl}/apps/${appId}/users`,
576
+ `${NotifySphere.baseUrl}${appId}/users`,
461
577
  {
462
578
  applicationUserId: params.applicationUserId,
463
579
  token: NotifySphere.fcmToken,
@@ -465,8 +581,14 @@ class NotifySphere {
465
581
  user: { tags: params.tags },
466
582
  },
467
583
  {
468
- headers: { 'Content-Type': 'application/json' },
469
- maxBodyLength: Infinity,
584
+ headers: {
585
+ 'Content-Type': 'application/json',
586
+ ...(NotifySphere.apiKey
587
+ ? { Authorization: `Bearer ${NotifySphere.apiKey}` }
588
+ : {}),
589
+ },
590
+ maxBodyLength: 1024 * 50,
591
+ timeout: 10000,
470
592
  }
471
593
  );
472
594
 
@@ -536,7 +658,7 @@ class NotifySphere {
536
658
  // Apply any config overrides immediately — before the handler fires —
537
659
  // so they are in place even when the app starts from a terminated state.
538
660
  if (config?.deliveryUrl) {
539
- NotifySphere.deliveryUrl = config.deliveryUrl;
661
+ NotifySphere.deliveryUrl = NotifySphere.enforceHttps(config.deliveryUrl);
540
662
  }
541
663
  if (config?.apiKey) {
542
664
  NotifySphere.apiKey = config.apiKey;
@@ -545,9 +667,35 @@ class NotifySphere {
545
667
  NotifySphere.subscriptionId = config.subscriptionId;
546
668
  }
547
669
 
670
+ // Pre-create the default channel immediately so it is guaranteed to exist
671
+ // (with sound enabled) before any background or terminated-state message
672
+ // arrives. Without this, the channel would be created inside the async
673
+ // background handler — on some devices that races with the notification
674
+ // display and results in a silent notification.
675
+ notifee.createChannel({
676
+ id: 'channel_default',
677
+ name: 'Default Channel',
678
+ importance: AndroidImportance.HIGH,
679
+ lights: true,
680
+ lightColor: '#0000FF',
681
+ vibration: true,
682
+ });
683
+
548
684
  messaging().setBackgroundMessageHandler(async (remoteMessage) => {
549
685
  const msg = remoteMessage as FirebaseRemoteMessage;
550
- // Run both in parallel — display and delivery receipt
686
+
687
+ // Always display via Notifee so the backend-configured channel (with its
688
+ // custom sound, importance, and LED settings) is used in every state.
689
+ //
690
+ // On iOS, setBackgroundMessageHandler never fires for messages that
691
+ // contain a `notification` field — APNs intercepts them — so there is
692
+ // no duplicate risk on iOS.
693
+ //
694
+ // On Android, if the FCM payload contains a `notification` field, FCM
695
+ // will also auto-display, which causes a duplicate notification. To avoid
696
+ // this, the backend should send data-only messages to Android tokens
697
+ // (omit the top-level `notification` field). Notifee then has full
698
+ // control over display and sound in all states.
551
699
  await Promise.all([
552
700
  NotifySphere.displayLocalNotification(msg),
553
701
  NotifySphere.callbackOnDelivery(msg),
@@ -640,6 +788,68 @@ class NotifySphere {
640
788
  );
641
789
  }
642
790
 
791
+ // ─── Private: backend channel helpers ────────────────────────────────────
792
+
793
+ /** Maps the backend importance value to a Notifee AndroidImportance level. */
794
+ private static mapImportance(value: number | undefined): AndroidImportance {
795
+ const v = value ?? 3;
796
+ if (v >= 4) return AndroidImportance.HIGH;
797
+ if (v === 3) return AndroidImportance.DEFAULT;
798
+ if (v === 2) return AndroidImportance.LOW;
799
+ if (v === 1) return AndroidImportance.MIN;
800
+ return AndroidImportance.DEFAULT;
801
+ }
802
+
803
+ /** Maps the backend lockScreen value to a Notifee AndroidVisibility level. */
804
+ private static mapVisibility(lockScreen: number | undefined): AndroidVisibility {
805
+ if (lockScreen === 1) return AndroidVisibility.PUBLIC;
806
+ if (lockScreen === -1) return AndroidVisibility.SECRET;
807
+ return AndroidVisibility.PRIVATE;
808
+ }
809
+
810
+ /**
811
+ * Creates Notifee channels from the array returned by the registration API.
812
+ * Called immediately after registration and on every subsequent launch from
813
+ * the AsyncStorage cache — so channels always exist before any notification
814
+ * arrives, including in background / terminated state.
815
+ */
816
+ private static async createChannelsFromConfig(
817
+ channels: BackendChannelConfig[]
818
+ ): Promise<void> {
819
+ for (const ch of channels) {
820
+ // sound === 'Custom' → use soundDetail as the raw resource name
821
+ // sound === 'None' → no sound (don't spread sound key)
822
+ // sound === 'Default' → omit key so Android uses system default
823
+ let soundValue: string | undefined;
824
+ if (ch.sound === 'Custom' && ch.soundDetail) {
825
+ soundValue = ch.soundDetail.replace(/\..+$/, ''); // strip extension
826
+ }
827
+ // 'None' and 'Default' intentionally leave soundValue as undefined
828
+
829
+ console.log('this is sound value',soundValue)
830
+
831
+ const channelDef: AndroidChannel = {
832
+ id: ch.channel_id,
833
+ name: ch.name,
834
+ importance: NotifySphere.mapImportance(ch.Importance),
835
+ lights: (ch.LED ?? 0) === 1,
836
+ vibration: (ch.vibration ?? 0) === 1,
837
+ visibility: NotifySphere.mapVisibility(ch.lockScreen),
838
+ ...(ch.description ? { description: ch.description } : {}),
839
+ ...(ch.led_color ? { lightColor: ch.led_color } : {}),
840
+ // Only spread sound when it is a real custom resource name.
841
+ // For 'Default', omitting the key tells Android to use the system
842
+ // default sound. For 'None', omitting it silences the channel only
843
+ // if importance is set to LOW/MIN — HIGH importance always plays sound.
844
+ ...(soundValue ? { sound: soundValue } : {}),
845
+ };
846
+
847
+ await notifee.createChannel(channelDef);
848
+ NotifySphere.createdChannels.add(ch.channel_id);
849
+ log('Channel created from backend config:', ch.channel_id, `(${ch.name})`);
850
+ }
851
+ }
852
+
643
853
  // ─── Private: channel management ─────────────────────────────────────────
644
854
 
645
855
  /**
@@ -659,7 +869,11 @@ class NotifySphere {
659
869
  id,
660
870
  name,
661
871
  importance: AndroidImportance.HIGH,
662
- sound: soundName !== 'default' ? soundName : undefined,
872
+ // For custom sounds, use the raw resource name (without extension).
873
+ // For 'default', omit the field entirely so Android uses the device's
874
+ // default notification sound. Setting it to undefined explicitly can
875
+ // be treated as "no sound" by Notifee on some devices.
876
+ ...(soundName !== 'default' ? { sound: soundName } : {}),
663
877
  lights: true,
664
878
  lightColor: '#0000FF',
665
879
  vibration: true,
@@ -678,7 +892,11 @@ class NotifySphere {
678
892
  // Use || so empty strings fall back just like null/undefined
679
893
  const soundRaw = remoteMessage.data?.sound || 'default';
680
894
  const soundName = soundRaw.replace(/\..+$/, '');
681
- const channelId = `channel_${soundName}`;
895
+
896
+ // If the backend specifies a channelId (e.g. a UUID created on the dashboard),
897
+ // use it directly — this lets the backend control which channel the notification
898
+ // is assigned to. Otherwise fall back to a channel derived from the sound name.
899
+ const channelId = remoteMessage.data?.channelId || `channel_${soundName}`;
682
900
 
683
901
  await NotifySphere.ensureChannel(channelId, `Channel ${soundName}`, soundRaw);
684
902
 
@@ -696,20 +914,34 @@ class NotifySphere {
696
914
  const body =
697
915
  remoteMessage.data?.body || remoteMessage.notification?.body || undefined;
698
916
 
699
- // smallIcon falls back to 'ic_stat_onesignal_default' if empty/missing
700
- // largeIcon is removed entirely
701
- const smallIcon =
702
- NotifySphere.resolveIcon(remoteMessage.data?.smallIcon) ?? 'ic_stat_onesignal_default';
917
+ // Resolve smallIcon and largeIcon both fall back to
918
+ // 'ic_stat_notifysphere_default' if not provided in the payload.
919
+ // Add a drawable with this name to your app's res/drawable folder.
920
+ const smallIconResolved =
921
+ NotifySphere.resolveIcon(remoteMessage.data?.smallIcon) ??
922
+ 'ic_stat_notifysphere_default';
923
+ const largeIconResolved =
924
+ NotifySphere.resolveIcon(remoteMessage.data?.largeIcon) ??
925
+ 'ic_stat_notifysphere_default';
926
+
927
+ // Determine Android expand style:
928
+ // - Image present → BIGPICTURE (shows image when expanded)
929
+ // - Long body (>44 chars) → BIGTEXT (shows full text when expanded)
930
+ // - Otherwise → no style (single-line collapsed notification)
931
+ const androidStyle = image
932
+ ? ({ type: AndroidStyle.BIGPICTURE, picture: image } as const)
933
+ : body && body.length > 44
934
+ ? ({ type: AndroidStyle.BIGTEXT, text: body } as const)
935
+ : undefined;
703
936
 
704
937
  await notifee.displayNotification({
705
938
  title,
706
939
  body,
707
940
  android: {
708
941
  channelId,
709
- smallIcon,
710
- style: image
711
- ? { type: AndroidStyle.BIGPICTURE, picture: image }
712
- : undefined,
942
+ smallIcon: smallIconResolved,
943
+ largeIcon: largeIconResolved,
944
+ style: androidStyle,
713
945
  },
714
946
  ios: {
715
947
  sound: soundRaw,
@@ -734,6 +966,27 @@ class NotifySphere {
734
966
  return value;
735
967
  }
736
968
 
969
+ /**
970
+ * Rejects plain HTTP URLs to prevent user data (FCM token, email, phone,
971
+ * location) being sent over an unencrypted connection.
972
+ * Also ensures the URL ends with a trailing slash so appId is always
973
+ * correctly appended (e.g. baseUrl + appId + '/users').
974
+ */
975
+ private static enforceHttps(url: string, ensureTrailingSlash = false): string {
976
+ if (url.startsWith('http://')) {
977
+ logError(
978
+ `Insecure URL rejected: "${url}". NotifySphere only allows HTTPS endpoints.`
979
+ );
980
+ throw new Error(
981
+ `NotifySphere: insecure HTTP URL is not allowed — use HTTPS instead.`
982
+ );
983
+ }
984
+ if (ensureTrailingSlash && !url.endsWith('/')) {
985
+ return url + '/';
986
+ }
987
+ return url;
988
+ }
989
+
737
990
  // ─── Private: fire consumer callback ─────────────────────────────────────
738
991
 
739
992
  /**