@ng-native/expo 0.9.0 → 0.10.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.
package/dist/updates.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * one *restarts the app*. So the shape that matters is: know one is ready, and let the app choose
15
15
  * a moment the user will not lose anything at. Nothing here does it on its own.
16
16
  */
17
- import { InjectionToken, Service, computed, inject, signal } from '@angular/core';
17
+ import { InjectionToken, Service, inject, signal } from '@angular/core';
18
18
  import { expoModule } from "./native.js";
19
19
  import * as i0 from "@angular/core";
20
20
  export class Updates {
@@ -39,11 +39,15 @@ export class Updates {
39
39
  ...(ngDevMode ? [{ debugName: "current" }] : /* istanbul ignore next */ []));
40
40
  failure = signal(null, /* @ts-ignore */
41
41
  ...(ngDevMode ? [{ debugName: "failure" }] : /* istanbul ignore next */ []));
42
+ downloaded = signal(false, /* @ts-ignore */
43
+ ...(ngDevMode ? [{ debugName: "downloaded" }] : /* istanbul ignore next */ []));
42
44
  state = this.current.asReadonly();
43
45
  error = this.failure.asReadonly();
44
- /** Downloaded and waiting. The only state in which `apply()` does anything. */
45
- ready = computed(() => this.current() === 'ready', /* @ts-ignore */
46
- ...(ngDevMode ? [{ debugName: "ready" }] : /* istanbul ignore next */ []));
46
+ /**
47
+ * Downloaded and waiting, including while a later check runs and after one fails. The only time
48
+ * `apply()` does anything.
49
+ */
50
+ ready = this.downloaded.asReadonly();
47
51
  /**
48
52
  * Disabled in development and in Expo Go, where the bundle comes from Metro. An app that shows
49
53
  * an "update available" banner should hide it here rather than show one that can never resolve.
@@ -63,20 +67,20 @@ export class Updates {
63
67
  try {
64
68
  this.current.set('checking');
65
69
  const { isAvailable } = await this.native.check();
66
- if (!isAvailable) {
67
- this.current.set('idle');
68
- return false;
70
+ if (isAvailable) {
71
+ this.current.set('downloading');
72
+ const { isNew } = await this.native.fetch();
73
+ if (isNew)
74
+ this.downloaded.set(true);
69
75
  }
70
- this.current.set('downloading');
71
- const { isNew } = await this.native.fetch();
72
- this.current.set(isNew ? 'ready' : 'idle');
73
- return isNew;
76
+ this.failure.set(null);
77
+ this.current.set(this.downloaded() ? 'ready' : 'idle');
74
78
  }
75
79
  catch (error) {
76
80
  this.failure.set(error);
77
- this.current.set('error');
78
- return false;
81
+ this.current.set(this.downloaded() ? 'ready' : 'error');
79
82
  }
83
+ return this.downloaded();
80
84
  }
81
85
  /**
82
86
  * Restart into the downloaded update.
@@ -85,7 +89,7 @@ export class Updates {
85
89
  * app knows and this does not - not mid-form, not mid-upload, usually on next foreground.
86
90
  */
87
91
  async apply() {
88
- if (this.current() === 'ready')
92
+ if (this.downloaded())
89
93
  await this.native?.reload();
90
94
  }
91
95
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.2.0", ngImport: i0, type: Updates, deps: [], target: i0.ɵɵFactoryTarget.Service });
@@ -1 +1 @@
1
- {"version":3,"file":"updates.js","sourceRoot":"","sources":["../src/updates.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `Updates`, bound to `expo-updates`.\n *\n * ```ts\n * private readonly updates = inject(Updates);\n *\n * // On foreground, where a restart costs the user nothing.\n * async refresh() {\n * if (await this.updates.check()) await this.updates.apply();\n * }\n * ```\n *\n * The awkward part is not checking or downloading - both are plain promises - it is that applying\n * one *restarts the app*. So the shape that matters is: know one is ready, and let the app choose\n * a moment the user will not lose anything at. Nothing here does it on its own.\n */\nimport { InjectionToken, Service, computed, inject, signal, type Signal } from '@angular/core';\nimport { expoModule } from './native.ts';\n\nexport interface NativeUpdates {\n readonly enabled: boolean;\n check(): Promise<{ isAvailable: boolean }>;\n fetch(): Promise<{ isNew: boolean }>;\n reload(): Promise<void>;\n}\n\nexport type UpdateState = 'idle' | 'checking' | 'downloading' | 'ready' | 'error';\n\n@Service()\nexport class Updates {\n /** Overridden in a test to offer an update without a server. */\n static readonly SOURCE = new InjectionToken<NativeUpdates | null>(\n 'angular-native.updatesSource',\n {\n factory: () => {\n const expo = expoModule(\n 'expo-updates',\n () => require('expo-updates') as typeof import('expo-updates'),\n );\n if (!expo) return null;\n return {\n // False in development and in Expo Go, where the bundle comes from Metro. A banner shown\n // there is one that can never resolve.\n enabled: expo.isEnabled,\n check: async () => ({ isAvailable: (await expo.checkForUpdateAsync()).isAvailable }),\n fetch: async () => ({ isNew: (await expo.fetchUpdateAsync()).isNew }),\n reload: () => expo.reloadAsync(),\n };\n },\n },\n );\n\n private readonly native = inject(Updates.SOURCE);\n private readonly current = signal<UpdateState>('idle');\n private readonly failure = signal<unknown>(null);\n\n readonly state: Signal<UpdateState> = this.current.asReadonly();\n readonly error: Signal<unknown> = this.failure.asReadonly();\n\n /** Downloaded and waiting. The only state in which `apply()` does anything. */\n readonly ready: Signal<boolean> = computed(() => this.current() === 'ready');\n\n /**\n * Disabled in development and in Expo Go, where the bundle comes from Metro. An app that shows\n * an \"update available\" banner should hide it here rather than show one that can never resolve.\n */\n get enabled(): boolean {\n return this.native?.enabled ?? false;\n }\n\n /**\n * Check, and download if there is one. Resolves to whether an update is now waiting.\n *\n * One method rather than two because there is nothing useful to do between them: an app that\n * checks and does not download has learnt something it cannot act on.\n */\n async check(): Promise<boolean> {\n if (!this.native?.enabled) return false;\n\n try {\n this.current.set('checking');\n const { isAvailable } = await this.native.check();\n if (!isAvailable) {\n this.current.set('idle');\n return false;\n }\n\n this.current.set('downloading');\n const { isNew } = await this.native.fetch();\n this.current.set(isNew ? 'ready' : 'idle');\n return isNew;\n } catch (error) {\n this.failure.set(error);\n this.current.set('error');\n return false;\n }\n }\n\n /**\n * Restart into the downloaded update.\n *\n * **This restarts the app**, which is why it is never automatic: the moment to do it is one the\n * app knows and this does not - not mid-form, not mid-upload, usually on next foreground.\n */\n async apply(): Promise<void> {\n if (this.current() === 'ready') await this.native?.reload();\n }\n}\n"]}
1
+ {"version":3,"file":"updates.js","sourceRoot":"","sources":["../src/updates.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `Updates`, bound to `expo-updates`.\n *\n * ```ts\n * private readonly updates = inject(Updates);\n *\n * // On foreground, where a restart costs the user nothing.\n * async refresh() {\n * if (await this.updates.check()) await this.updates.apply();\n * }\n * ```\n *\n * The awkward part is not checking or downloading - both are plain promises - it is that applying\n * one *restarts the app*. So the shape that matters is: know one is ready, and let the app choose\n * a moment the user will not lose anything at. Nothing here does it on its own.\n */\nimport { InjectionToken, Service, inject, signal, type Signal } from '@angular/core';\nimport { expoModule } from './native.ts';\n\nexport interface NativeUpdates {\n readonly enabled: boolean;\n check(): Promise<{ isAvailable: boolean }>;\n fetch(): Promise<{ isNew: boolean }>;\n reload(): Promise<void>;\n}\n\nexport type UpdateState = 'idle' | 'checking' | 'downloading' | 'ready' | 'error';\n\n@Service()\nexport class Updates {\n /** Overridden in a test to offer an update without a server. */\n static readonly SOURCE = new InjectionToken<NativeUpdates | null>(\n 'angular-native.updatesSource',\n {\n factory: () => {\n const expo = expoModule(\n 'expo-updates',\n () => require('expo-updates') as typeof import('expo-updates'),\n );\n if (!expo) return null;\n return {\n // False in development and in Expo Go, where the bundle comes from Metro. A banner shown\n // there is one that can never resolve.\n enabled: expo.isEnabled,\n check: async () => ({ isAvailable: (await expo.checkForUpdateAsync()).isAvailable }),\n fetch: async () => ({ isNew: (await expo.fetchUpdateAsync()).isNew }),\n reload: () => expo.reloadAsync(),\n };\n },\n },\n );\n\n private readonly native = inject(Updates.SOURCE);\n private readonly current = signal<UpdateState>('idle');\n private readonly failure = signal<unknown>(null);\n private readonly downloaded = signal(false);\n\n readonly state: Signal<UpdateState> = this.current.asReadonly();\n readonly error: Signal<unknown> = this.failure.asReadonly();\n\n /**\n * Downloaded and waiting, including while a later check runs and after one fails. The only time\n * `apply()` does anything.\n */\n readonly ready: Signal<boolean> = this.downloaded.asReadonly();\n\n /**\n * Disabled in development and in Expo Go, where the bundle comes from Metro. An app that shows\n * an \"update available\" banner should hide it here rather than show one that can never resolve.\n */\n get enabled(): boolean {\n return this.native?.enabled ?? false;\n }\n\n /**\n * Check, and download if there is one. Resolves to whether an update is now waiting.\n *\n * One method rather than two because there is nothing useful to do between them: an app that\n * checks and does not download has learnt something it cannot act on.\n */\n async check(): Promise<boolean> {\n if (!this.native?.enabled) return false;\n\n try {\n this.current.set('checking');\n const { isAvailable } = await this.native.check();\n if (isAvailable) {\n this.current.set('downloading');\n const { isNew } = await this.native.fetch();\n if (isNew) this.downloaded.set(true);\n }\n this.failure.set(null);\n this.current.set(this.downloaded() ? 'ready' : 'idle');\n } catch (error) {\n this.failure.set(error);\n this.current.set(this.downloaded() ? 'ready' : 'error');\n }\n return this.downloaded();\n }\n\n /**\n * Restart into the downloaded update.\n *\n * **This restarts the app**, which is why it is never automatic: the moment to do it is one the\n * app knows and this does not - not mid-form, not mid-upload, usually on next foreground.\n */\n async apply(): Promise<void> {\n if (this.downloaded()) await this.native?.reload();\n }\n}\n"]}
package/dist/widget.d.ts CHANGED
@@ -23,17 +23,31 @@ export interface NativeWidget<T extends object> {
23
23
  getTimeline(): Promise<readonly WidgetEntry<T>[]>;
24
24
  reload(): void;
25
25
  }
26
+ /** A tap on a button, as `expo-widgets` reports it while the app is running. */
27
+ export interface WidgetTap {
28
+ /** The widget's name, or the id of the Live Activity. */
29
+ readonly source: string;
30
+ /** The button's `target`. */
31
+ readonly target: string;
32
+ /** When it was tapped, in milliseconds since the epoch. */
33
+ readonly timestamp: number;
34
+ }
26
35
  /** When to collect taps, and when to ask iOS to redraw the widget. */
27
36
  export interface WidgetEvents {
28
- /** A widget button was tapped while the app was running. */
29
- onTap(listener: () => void): () => void;
37
+ /** A button of a widget or a Live Activity was tapped while the app was running. */
38
+ onTap(listener: (tap: WidgetTap) => void): () => void;
39
+ /**
40
+ * The taps made on Live Activities with these ids before the app was listening, each answered
41
+ * once. A tap can start the app, and is over before its JavaScript has loaded.
42
+ */
43
+ takeHeld?(sources: readonly string[]): readonly WidgetTap[];
30
44
  onForeground(listener: () => void): () => void;
31
45
  onBackground(listener: () => void): () => void;
32
46
  }
33
47
  export interface WidgetOptions {
34
48
  /**
35
49
  * Called with the targets of the buttons tapped since the last call, oldest first, once the write
36
- * that clears them from the widget has worked, so a tap is handed over once. A handler that throws
50
+ * that clears them from the widget has worked, so a tap is handed over once. A handler that throws, or rejects,
37
51
  * goes to the `ErrorHandler`, and its taps are still cleared.
38
52
  */
39
53
  readonly onTaps?: (taps: readonly string[]) => void;
package/dist/widget.js CHANGED
@@ -9,7 +9,9 @@
9
9
  * the app's layout file, so this package imports `expo-widgets` only for its tap events.
10
10
  */
11
11
  import { DestroyRef, ErrorHandler, InjectionToken, effect, inject, signal, untracked, } from '@angular/core';
12
- import { optional } from "./native.js";
12
+ import { isolated, optional } from "./native.js";
13
+ const heldTaps = () => globalThis.expo?.modules
14
+ ?.NgNativeLiveActivityTaps;
13
15
  /** Overridden in a test to tap a widget that is not there. */
14
16
  export const WIDGET_EVENTS = new InjectionToken('angular-native.widgetEvents', {
15
17
  factory: () => {
@@ -27,6 +29,7 @@ export const WIDGET_EVENTS = new InjectionToken('angular-native.widgetEvents', {
27
29
  const subscription = widgets?.addUserInteractionListener(listener);
28
30
  return () => subscription?.remove();
29
31
  },
32
+ takeHeld: (sources) => heldTaps()?.takeTaps?.(sources) ?? [],
30
33
  onForeground: onState('active'),
31
34
  onBackground: onState('background'),
32
35
  };
@@ -57,14 +60,7 @@ export function widget(native, props, options = {}) {
57
60
  }
58
61
  return taps;
59
62
  };
60
- const hand = (taps) => {
61
- try {
62
- options.onTaps?.(taps);
63
- }
64
- catch (failure) {
65
- errors.handleError(failure);
66
- }
67
- };
63
+ const hand = (taps) => isolated(() => options.onTaps?.(taps), (failure) => errors.handleError(failure));
68
64
  const run = async () => {
69
65
  try {
70
66
  const taps = await collect();
@@ -1 +1 @@
1
- {"version":3,"file":"widget.js","sourceRoot":"","sources":["../src/widget.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `widget`, which keeps a home screen widget from `expo-widgets` in step with a signal, and hands the\n * app the taps on its buttons.\n *\n * A widget button runs in the widget extension, not in the app, which iOS keeps suspended, so a\n * tap's own event is lost unless the app is running. The widget's layout records each tap in its\n * own props instead, as `taps`, and `widget` collects them when the app next runs: at once while it\n * is running, and when it comes back to the foreground. It takes the widget `createWidget` makes in\n * the app's layout file, so this package imports `expo-widgets` only for its tap events.\n */\nimport {\n DestroyRef,\n ErrorHandler,\n InjectionToken,\n effect,\n inject,\n signal,\n untracked,\n type Signal,\n} from '@angular/core';\nimport { optional } from './native.ts';\n\n/** One entry of a widget's timeline, as `expo-widgets` stores it. */\nexport interface WidgetEntry<T extends object> {\n readonly date: Date;\n /** The entry's props, with the targets of the buttons tapped since the app last collected them. */\n readonly props: T & { readonly taps?: readonly string[] };\n}\n\n/** What `createWidget` answers. */\nexport interface NativeWidget<T extends object> {\n updateSnapshot(props: T): void;\n getTimeline(): Promise<readonly WidgetEntry<T>[]>;\n reload(): void;\n}\n\n/** When to collect taps, and when to ask iOS to redraw the widget. */\nexport interface WidgetEvents {\n /** A widget button was tapped while the app was running. */\n onTap(listener: () => void): () => void;\n onForeground(listener: () => void): () => void;\n onBackground(listener: () => void): () => void;\n}\n\nexport interface WidgetOptions {\n /**\n * Called with the targets of the buttons tapped since the last call, oldest first, once the write\n * that clears them from the widget has worked, so a tap is handed over once. A handler that throws\n * goes to the `ErrorHandler`, and its taps are still cleared.\n */\n readonly onTaps?: (taps: readonly string[]) => void;\n}\n\n/** A home screen widget kept in step with a signal. */\nexport interface WidgetRef {\n /**\n * Why the last sync did not happen: the taps could not be read, or the widget not written. It also\n * goes to the `ErrorHandler`, and is null again once a sync works. A failing `onTaps` is not kept\n * here.\n */\n readonly error: Signal<unknown>;\n /** Collects the taps now, then shows the signal's value. */\n sync(): Promise<void>;\n /** Asks iOS to redraw the widget. */\n reload(): void;\n}\n\ntype ReactNative = typeof import('react-native');\ntype ExpoWidgets = {\n addUserInteractionListener(listener: () => void): { remove(): void };\n};\n\n/** Overridden in a test to tap a widget that is not there. */\nexport const WIDGET_EVENTS = new InjectionToken<WidgetEvents>('angular-native.widgetEvents', {\n factory: () => {\n const widgets = optional(() => require('expo-widgets') as ExpoWidgets);\n const native = optional(() => require('react-native') as ReactNative);\n const onState = (wanted: string) => (listener: () => void) => {\n const subscription = native?.AppState.addEventListener('change', (state) => {\n if (state === wanted) listener();\n });\n return () => subscription?.remove();\n };\n return {\n onTap: (listener) => {\n const subscription = widgets?.addUserInteractionListener(listener);\n return () => subscription?.remove();\n },\n onForeground: onState('active'),\n onBackground: onState('background'),\n };\n },\n});\n\n/**\n * Shows `props` on the widget and follows the signal, and hands taps on its buttons to `onTaps`.\n * A write replaces the props that hold the taps, so every write comes after the taps are read, and\n * nothing is written while they cannot be; a tap is handed over once the write that clears it has\n * worked. Going to the background asks iOS to redraw the widget, which it otherwise does on its own\n * schedule. Destroying the injector stops both. Call it in an injection context.\n */\nexport function widget<T extends object>(\n native: NativeWidget<T>,\n props: Signal<T>,\n options: WidgetOptions = {},\n): WidgetRef {\n const errors = inject(ErrorHandler);\n const events = inject(WIDGET_EVENTS);\n const error = signal<unknown>(null);\n let syncing: Promise<void> | null = null;\n\n const queued = async () => (await native.getTimeline()).at(-1)?.props.taps ?? [];\n\n /** The taps, read again until no more land, since the write after it clears them. */\n const collect = async () => {\n let taps = await queued();\n while (taps.length) {\n const more = await queued();\n if (more.length <= taps.length) break;\n taps = more;\n }\n return taps;\n };\n\n const hand = (taps: readonly string[]) => {\n try {\n options.onTaps?.(taps);\n } catch (failure) {\n errors.handleError(failure);\n }\n };\n\n const run = async () => {\n try {\n const taps = await collect();\n native.updateSnapshot(untracked(props));\n error.set(null);\n if (taps.length) hand(taps);\n } catch (failure) {\n error.set(failure);\n errors.handleError(failure);\n }\n };\n\n /**\n * One sync at a time. One asked for during it is already covered: its write reads the signal then,\n * and nothing is awaited after it.\n */\n const sync = () => (syncing ??= run().finally(() => (syncing = null)));\n\n effect(() => {\n props();\n untracked(() => void sync());\n });\n\n const stops = [\n events.onTap(() => void sync()),\n events.onForeground(() => void sync()),\n events.onBackground(() => native.reload()),\n ];\n inject(DestroyRef).onDestroy(() => stops.forEach((stop) => stop()));\n\n return { error: error.asReadonly(), sync, reload: () => native.reload() };\n}\n"]}
1
+ {"version":3,"file":"widget.js","sourceRoot":"","sources":["../src/widget.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `widget`, which keeps a home screen widget from `expo-widgets` in step with a signal, and hands the\n * app the taps on its buttons.\n *\n * A widget button runs in the widget extension, not in the app, which iOS keeps suspended, so a\n * tap's own event is lost unless the app is running. The widget's layout records each tap in its\n * own props instead, as `taps`, and `widget` collects them when the app next runs: at once while it\n * is running, and when it comes back to the foreground. It takes the widget `createWidget` makes in\n * the app's layout file, so this package imports `expo-widgets` only for its tap events.\n */\nimport {\n DestroyRef,\n ErrorHandler,\n InjectionToken,\n effect,\n inject,\n signal,\n untracked,\n type Signal,\n} from '@angular/core';\nimport { isolated, optional } from './native.ts';\n\n/** One entry of a widget's timeline, as `expo-widgets` stores it. */\nexport interface WidgetEntry<T extends object> {\n readonly date: Date;\n /** The entry's props, with the targets of the buttons tapped since the app last collected them. */\n readonly props: T & { readonly taps?: readonly string[] };\n}\n\n/** What `createWidget` answers. */\nexport interface NativeWidget<T extends object> {\n updateSnapshot(props: T): void;\n getTimeline(): Promise<readonly WidgetEntry<T>[]>;\n reload(): void;\n}\n\n/** A tap on a button, as `expo-widgets` reports it while the app is running. */\nexport interface WidgetTap {\n /** The widget's name, or the id of the Live Activity. */\n readonly source: string;\n /** The button's `target`. */\n readonly target: string;\n /** When it was tapped, in milliseconds since the epoch. */\n readonly timestamp: number;\n}\n\n/** When to collect taps, and when to ask iOS to redraw the widget. */\nexport interface WidgetEvents {\n /** A button of a widget or a Live Activity was tapped while the app was running. */\n onTap(listener: (tap: WidgetTap) => void): () => void;\n /**\n * The taps made on Live Activities with these ids before the app was listening, each answered\n * once. A tap can start the app, and is over before its JavaScript has loaded.\n */\n takeHeld?(sources: readonly string[]): readonly WidgetTap[];\n onForeground(listener: () => void): () => void;\n onBackground(listener: () => void): () => void;\n}\n\nexport interface WidgetOptions {\n /**\n * Called with the targets of the buttons tapped since the last call, oldest first, once the write\n * that clears them from the widget has worked, so a tap is handed over once. A handler that throws, or rejects,\n * goes to the `ErrorHandler`, and its taps are still cleared.\n */\n readonly onTaps?: (taps: readonly string[]) => void;\n}\n\n/** A home screen widget kept in step with a signal. */\nexport interface WidgetRef {\n /**\n * Why the last sync did not happen: the taps could not be read, or the widget not written. It also\n * goes to the `ErrorHandler`, and is null again once a sync works. A failing `onTaps` is not kept\n * here.\n */\n readonly error: Signal<unknown>;\n /** Collects the taps now, then shows the signal's value. */\n sync(): Promise<void>;\n /** Asks iOS to redraw the widget. */\n reload(): void;\n}\n\ntype ReactNative = typeof import('react-native');\ntype ExpoWidgets = {\n addUserInteractionListener(listener: (tap: WidgetTap) => void): { remove(): void };\n};\n\n/** This package's own native module, which holds a Live Activity's taps from launch. Absent off iOS. */\ntype HeldTaps = { takeTaps?(sources: readonly string[]): readonly WidgetTap[] };\nconst heldTaps = () =>\n (globalThis as { expo?: { modules?: { NgNativeLiveActivityTaps?: HeldTaps } } }).expo?.modules\n ?.NgNativeLiveActivityTaps;\n\n/** Overridden in a test to tap a widget that is not there. */\nexport const WIDGET_EVENTS = new InjectionToken<WidgetEvents>('angular-native.widgetEvents', {\n factory: () => {\n const widgets = optional(() => require('expo-widgets') as ExpoWidgets);\n const native = optional(() => require('react-native') as ReactNative);\n const onState = (wanted: string) => (listener: () => void) => {\n const subscription = native?.AppState.addEventListener('change', (state) => {\n if (state === wanted) listener();\n });\n return () => subscription?.remove();\n };\n return {\n onTap: (listener) => {\n const subscription = widgets?.addUserInteractionListener(listener);\n return () => subscription?.remove();\n },\n takeHeld: (sources) => heldTaps()?.takeTaps?.(sources) ?? [],\n onForeground: onState('active'),\n onBackground: onState('background'),\n };\n },\n});\n\n/**\n * Shows `props` on the widget and follows the signal, and hands taps on its buttons to `onTaps`.\n * A write replaces the props that hold the taps, so every write comes after the taps are read, and\n * nothing is written while they cannot be; a tap is handed over once the write that clears it has\n * worked. Going to the background asks iOS to redraw the widget, which it otherwise does on its own\n * schedule. Destroying the injector stops both. Call it in an injection context.\n */\nexport function widget<T extends object>(\n native: NativeWidget<T>,\n props: Signal<T>,\n options: WidgetOptions = {},\n): WidgetRef {\n const errors = inject(ErrorHandler);\n const events = inject(WIDGET_EVENTS);\n const error = signal<unknown>(null);\n let syncing: Promise<void> | null = null;\n\n const queued = async () => (await native.getTimeline()).at(-1)?.props.taps ?? [];\n\n /** The taps, read again until no more land, since the write after it clears them. */\n const collect = async () => {\n let taps = await queued();\n while (taps.length) {\n const more = await queued();\n if (more.length <= taps.length) break;\n taps = more;\n }\n return taps;\n };\n\n const hand = (taps: readonly string[]) =>\n isolated(\n () => options.onTaps?.(taps),\n (failure) => errors.handleError(failure),\n );\n\n const run = async () => {\n try {\n const taps = await collect();\n native.updateSnapshot(untracked(props));\n error.set(null);\n if (taps.length) hand(taps);\n } catch (failure) {\n error.set(failure);\n errors.handleError(failure);\n }\n };\n\n /**\n * One sync at a time. One asked for during it is already covered: its write reads the signal then,\n * and nothing is awaited after it.\n */\n const sync = () => (syncing ??= run().finally(() => (syncing = null)));\n\n effect(() => {\n props();\n untracked(() => void sync());\n });\n\n const stops = [\n events.onTap(() => void sync()),\n events.onForeground(() => void sync()),\n events.onBackground(() => native.reload()),\n ];\n inject(DestroyRef).onDestroy(() => stops.forEach((stop) => stop()));\n\n return { error: error.asReadonly(), sync, reload: () => native.reload() };\n}\n"]}
@@ -0,0 +1,10 @@
1
+ {
2
+ "platforms": ["apple", "android"],
3
+ "apple": {
4
+ "modules": ["LiveActivityTapsModule"],
5
+ "appDelegateSubscribers": ["LiveActivityTapsObserver"]
6
+ },
7
+ "android": {
8
+ "modules": ["dev.ngnative.expo.OngoingNotificationModule"]
9
+ }
10
+ }
@@ -0,0 +1,70 @@
1
+ import ExpoModulesCore
2
+
3
+ /**
4
+ The taps on a Live Activity's buttons made before any JavaScript was listening.
5
+
6
+ `expo-widgets` runs a button's tap in the app, starting it in the background when it has to, and
7
+ posts it as a notification that nothing keeps. The tap that starts the app is posted before the
8
+ app's JavaScript has loaded, so this holds what is posted from launch until JavaScript takes it.
9
+ */
10
+ final class HeldTaps {
11
+ static let shared = HeldTaps()
12
+
13
+ private let lock = NSLock()
14
+ private var taps: [[String: Any]] = []
15
+ private var observer: NSObjectProtocol?
16
+
17
+ /// Holds each tap `expo-widgets` posts from now on.
18
+ func start() {
19
+ lock.lock()
20
+ defer { lock.unlock() }
21
+ guard observer == nil else { return }
22
+ observer = NotificationCenter.default.addObserver(
23
+ forName: Notification.Name("onExpoWidgetsUserInteraction"),
24
+ object: nil,
25
+ queue: nil
26
+ ) { [weak self] notification in
27
+ guard let self, let tap = notification.userInfo?["eventData"] as? [String: Any] else { return }
28
+ self.lock.lock()
29
+ defer { self.lock.unlock() }
30
+ if self.observer != nil { self.taps.append(tap) }
31
+ }
32
+ }
33
+
34
+ /**
35
+ The held taps from `sources`, which are then held no longer. JavaScript listens to
36
+ `expo-widgets` itself before it asks, so the first call also stops holding new ones.
37
+ */
38
+ func take(sources: [String]) -> [[String: Any]] {
39
+ lock.lock()
40
+ defer { lock.unlock() }
41
+ if let observer {
42
+ NotificationCenter.default.removeObserver(observer)
43
+ self.observer = nil
44
+ }
45
+ let from = { (tap: [String: Any]) in sources.contains(tap["source"] as? String ?? "") }
46
+ let taken = taps.filter(from)
47
+ taps.removeAll(where: from)
48
+ return taken
49
+ }
50
+ }
51
+
52
+ public final class LiveActivityTapsObserver: ExpoAppDelegateSubscriber {
53
+ public func application(
54
+ _ application: UIApplication,
55
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
56
+ ) -> Bool {
57
+ HeldTaps.shared.start()
58
+ return true
59
+ }
60
+ }
61
+
62
+ public final class LiveActivityTapsModule: Module {
63
+ public func definition() -> ModuleDefinition {
64
+ Name("NgNativeLiveActivityTaps")
65
+
66
+ Function("takeTaps") { (sources: [String]) -> [[String: Any]] in
67
+ HeldTaps.shared.take(sources: sources)
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,22 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'NgNativeExpo'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = 'Angular Native'
12
+ s.homepage = 'https://github.com/ng-native/ng-native'
13
+ s.platforms = { :ios => '16.4' }
14
+ s.swift_version = '5.9'
15
+ s.source = { git: 'https://github.com/ng-native/ng-native.git' }
16
+ s.static_framework = true
17
+
18
+ s.dependency 'ExpoModulesCore'
19
+
20
+ s.source_files = '**/*.swift'
21
+ s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
22
+ end
package/package.json CHANGED
@@ -1,13 +1,16 @@
1
1
  {
2
2
  "name": "@ng-native/expo",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Expo modules as Angular services for Angular Native, plus registerExpoView for a module's native view.",
5
5
  "keywords": [
6
6
  "angular",
7
7
  "react-native",
8
8
  "angular-native",
9
9
  "ios",
10
- "android",
10
+ "android/build.gradle",
11
+ "android/src",
12
+ "android/build.gradle",
13
+ "android/src",
11
14
  "fabric",
12
15
  "native",
13
16
  "expo",
@@ -148,6 +151,10 @@
148
151
  "types": "./dist/notifications.d.ts",
149
152
  "default": "./dist/notifications.js"
150
153
  },
154
+ "./ongoing-notification": {
155
+ "types": "./dist/ongoing-notification.d.ts",
156
+ "default": "./dist/ongoing-notification.js"
157
+ },
151
158
  "./orientation": {
152
159
  "types": "./dist/orientation.d.ts",
153
160
  "default": "./dist/orientation.js"
@@ -206,7 +213,7 @@
206
213
  "access": "public"
207
214
  },
208
215
  "dependencies": {
209
- "@ng-native/fabric": "0.9.0"
216
+ "@ng-native/fabric": "0.10.0"
210
217
  },
211
218
  "nx": {
212
219
  "tags": [
@@ -445,7 +452,11 @@
445
452
  "directory": "packages/expo"
446
453
  },
447
454
  "files": [
448
- "dist"
455
+ "dist",
456
+ "ios",
457
+ "android/build.gradle",
458
+ "android/src",
459
+ "expo-module.config.json"
449
460
  ],
450
461
  "scripts": {
451
462
  "build": "ngc -p tsconfig.build.json",