@idealyst/live-activity 1.2.114

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,198 @@
1
+ // ============================================================================
2
+ // Native Live Activity Implementation
3
+ // Bridges to the IdealystLiveActivity TurboModule (iOS: ActivityKit, Android: ProgressStyle).
4
+ // ============================================================================
5
+
6
+ import { NativeEventEmitter, Platform } from 'react-native';
7
+ import type {
8
+ LiveActivityEventHandler,
9
+ LiveActivityEvent,
10
+ } from '../types';
11
+ import { createLiveActivityError, normalizeLiveActivityError } from '../errors';
12
+ import { LIVE_ACTIVITY_EVENT } from '../constants';
13
+ import NativeLiveActivity from '../NativeLiveActivitySpec';
14
+
15
+ function assertNativeModule(): void {
16
+ if (!NativeLiveActivity) {
17
+ throw createLiveActivityError(
18
+ 'not_available',
19
+ '@idealyst/live-activity native module is not linked. Ensure the package is installed and your project is rebuilt.',
20
+ );
21
+ }
22
+ }
23
+
24
+ // ============================================================================
25
+ // Availability
26
+ // ============================================================================
27
+
28
+ export async function checkAvailability(): Promise<{
29
+ supported: boolean;
30
+ enabled: boolean;
31
+ }> {
32
+ if (!NativeLiveActivity) {
33
+ return { supported: false, enabled: false };
34
+ }
35
+
36
+ try {
37
+ const supported = NativeLiveActivity.isSupported();
38
+ const enabled = supported ? await NativeLiveActivity.isEnabled() : false;
39
+ return { supported, enabled };
40
+ } catch {
41
+ return { supported: false, enabled: false };
42
+ }
43
+ }
44
+
45
+ // ============================================================================
46
+ // Lifecycle
47
+ // ============================================================================
48
+
49
+ export async function start(
50
+ templateType: string,
51
+ attributesJson: string,
52
+ contentStateJson: string,
53
+ optionsJson: string,
54
+ ): Promise<string> {
55
+ assertNativeModule();
56
+
57
+ try {
58
+ return await NativeLiveActivity.startActivity(
59
+ templateType,
60
+ attributesJson,
61
+ contentStateJson,
62
+ optionsJson,
63
+ );
64
+ } catch (error) {
65
+ throw normalizeLiveActivityError(error);
66
+ }
67
+ }
68
+
69
+ export async function update(
70
+ activityId: string,
71
+ contentStateJson: string,
72
+ alertConfigJson: string | null,
73
+ ): Promise<void> {
74
+ assertNativeModule();
75
+
76
+ try {
77
+ await NativeLiveActivity.updateActivity(
78
+ activityId,
79
+ contentStateJson,
80
+ alertConfigJson,
81
+ );
82
+ } catch (error) {
83
+ throw normalizeLiveActivityError(error);
84
+ }
85
+ }
86
+
87
+ export async function end(
88
+ activityId: string,
89
+ finalContentStateJson: string | null,
90
+ dismissalPolicy: string,
91
+ dismissAfter: number | null,
92
+ ): Promise<void> {
93
+ assertNativeModule();
94
+
95
+ try {
96
+ await NativeLiveActivity.endActivity(
97
+ activityId,
98
+ finalContentStateJson,
99
+ dismissalPolicy,
100
+ // TurboModule codegen requires non-optional number — use -1 as sentinel
101
+ dismissAfter ?? -1,
102
+ );
103
+ } catch (error) {
104
+ throw normalizeLiveActivityError(error);
105
+ }
106
+ }
107
+
108
+ export async function endAll(
109
+ dismissalPolicy: string,
110
+ dismissAfter: number | null,
111
+ ): Promise<void> {
112
+ assertNativeModule();
113
+
114
+ try {
115
+ await NativeLiveActivity.endAllActivities(
116
+ dismissalPolicy,
117
+ dismissAfter ?? -1,
118
+ );
119
+ } catch (error) {
120
+ throw normalizeLiveActivityError(error);
121
+ }
122
+ }
123
+
124
+ // ============================================================================
125
+ // Queries
126
+ // ============================================================================
127
+
128
+ export async function getActivity(
129
+ activityId: string,
130
+ ): Promise<string | null> {
131
+ assertNativeModule();
132
+
133
+ try {
134
+ return await NativeLiveActivity.getActivity(activityId);
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ export async function listActivities(): Promise<string> {
141
+ assertNativeModule();
142
+
143
+ try {
144
+ return await NativeLiveActivity.listActivities();
145
+ } catch {
146
+ return '[]';
147
+ }
148
+ }
149
+
150
+ export async function getPushToken(
151
+ activityId: string,
152
+ ): Promise<string | null> {
153
+ assertNativeModule();
154
+
155
+ try {
156
+ return await NativeLiveActivity.getPushToken(activityId);
157
+ } catch {
158
+ return null;
159
+ }
160
+ }
161
+
162
+ // ============================================================================
163
+ // Events
164
+ // ============================================================================
165
+
166
+ let emitter: NativeEventEmitter | null = null;
167
+
168
+ function getEmitter(): NativeEventEmitter {
169
+ if (!emitter) {
170
+ emitter = new NativeEventEmitter(NativeLiveActivity as any);
171
+ }
172
+ return emitter;
173
+ }
174
+
175
+ export function addEventListener(
176
+ handler: LiveActivityEventHandler,
177
+ ): () => void {
178
+ if (!NativeLiveActivity) {
179
+ return () => {};
180
+ }
181
+
182
+ const subscription = getEmitter().addListener(
183
+ LIVE_ACTIVITY_EVENT,
184
+ (rawEvent: unknown) => {
185
+ try {
186
+ const event =
187
+ typeof rawEvent === 'string'
188
+ ? (JSON.parse(rawEvent) as LiveActivityEvent)
189
+ : (rawEvent as LiveActivityEvent);
190
+ handler(event);
191
+ } catch {
192
+ // Malformed event — ignore
193
+ }
194
+ },
195
+ );
196
+
197
+ return () => subscription.remove();
198
+ }
@@ -0,0 +1,78 @@
1
+ // ============================================================================
2
+ // Web Stub Implementation
3
+ // Live Activities are not supported on web — all functions return graceful defaults.
4
+ // ============================================================================
5
+
6
+ import type {
7
+ LiveActivityEventHandler,
8
+ LiveActivityEvent,
9
+ } from '../types';
10
+ import { createLiveActivityError } from '../errors';
11
+
12
+ const NOT_SUPPORTED_ERROR = createLiveActivityError(
13
+ 'not_supported',
14
+ 'Live Activities are not supported on web',
15
+ );
16
+
17
+ export async function checkAvailability(): Promise<{
18
+ supported: boolean;
19
+ enabled: boolean;
20
+ }> {
21
+ return { supported: false, enabled: false };
22
+ }
23
+
24
+ export async function start(
25
+ _templateType: string,
26
+ _attributesJson: string,
27
+ _contentStateJson: string,
28
+ _optionsJson: string,
29
+ ): Promise<string> {
30
+ throw NOT_SUPPORTED_ERROR;
31
+ }
32
+
33
+ export async function update(
34
+ _activityId: string,
35
+ _contentStateJson: string,
36
+ _alertConfigJson: string | null,
37
+ ): Promise<void> {
38
+ throw NOT_SUPPORTED_ERROR;
39
+ }
40
+
41
+ export async function end(
42
+ _activityId: string,
43
+ _finalContentStateJson: string | null,
44
+ _dismissalPolicy: string,
45
+ _dismissAfter: number | null,
46
+ ): Promise<void> {
47
+ throw NOT_SUPPORTED_ERROR;
48
+ }
49
+
50
+ export async function endAll(
51
+ _dismissalPolicy: string,
52
+ _dismissAfter: number | null,
53
+ ): Promise<void> {
54
+ throw NOT_SUPPORTED_ERROR;
55
+ }
56
+
57
+ export async function getActivity(
58
+ _activityId: string,
59
+ ): Promise<string | null> {
60
+ return null;
61
+ }
62
+
63
+ export async function listActivities(): Promise<string> {
64
+ return '[]';
65
+ }
66
+
67
+ export async function getPushToken(
68
+ _activityId: string,
69
+ ): Promise<string | null> {
70
+ return null;
71
+ }
72
+
73
+ export function addEventListener(
74
+ _handler: LiveActivityEventHandler,
75
+ ): () => void {
76
+ // No-op on web
77
+ return () => {};
78
+ }
@@ -0,0 +1,267 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react';
2
+ import type {
3
+ UseLiveActivityOptions,
4
+ UseLiveActivityResult,
5
+ LiveActivityInfo,
6
+ LiveActivityError,
7
+ LiveActivityToken,
8
+ LiveActivityDeps,
9
+ StartActivityOptions,
10
+ UpdateActivityOptions,
11
+ EndActivityOptions,
12
+ TemplateType,
13
+ } from '../types';
14
+ import { normalizeLiveActivityError } from '../errors';
15
+
16
+ /**
17
+ * Factory that creates a useLiveActivity hook bound to platform-specific functions.
18
+ * Each platform entry point calls this with the correct implementations.
19
+ */
20
+ export function createUseLiveActivityHook(fns: LiveActivityDeps) {
21
+ return function useLiveActivity(
22
+ options: UseLiveActivityOptions = {},
23
+ ): UseLiveActivityResult {
24
+ const { autoCheckAvailability = false, onEvent } = options;
25
+
26
+ const [isSupported, setIsSupported] = useState(false);
27
+ const [isEnabled, setIsEnabled] = useState<boolean | null>(null);
28
+ const [activities, setActivities] = useState<LiveActivityInfo[]>([]);
29
+ const [isLoading, setIsLoading] = useState(false);
30
+ const [error, setError] = useState<LiveActivityError | null>(null);
31
+ const mountedRef = useRef(true);
32
+ const initializedRef = useRef(false);
33
+
34
+ const currentActivity = activities.length > 0 ? activities[0] : null;
35
+
36
+ const checkAvailability = useCallback(async (): Promise<{
37
+ supported: boolean;
38
+ enabled: boolean;
39
+ }> => {
40
+ try {
41
+ const result = await fns.checkAvailability();
42
+ if (mountedRef.current) {
43
+ setIsSupported(result.supported);
44
+ setIsEnabled(result.enabled);
45
+ }
46
+ return result;
47
+ } catch (err) {
48
+ const liveError = normalizeLiveActivityError(err);
49
+ if (mountedRef.current) setError(liveError);
50
+ return { supported: false, enabled: false };
51
+ }
52
+ }, []);
53
+
54
+ const refreshActivities = useCallback(async () => {
55
+ try {
56
+ const json = await fns.listActivities();
57
+ const list: LiveActivityInfo[] = JSON.parse(json);
58
+ if (mountedRef.current) {
59
+ setActivities(list);
60
+ }
61
+ } catch {
62
+ // Best-effort refresh
63
+ }
64
+ }, []);
65
+
66
+ const start = useCallback(
67
+ async <T extends TemplateType>(
68
+ opts: StartActivityOptions<T>,
69
+ ): Promise<LiveActivityInfo> => {
70
+ setIsLoading(true);
71
+ setError(null);
72
+ try {
73
+ const resultJson = await fns.start(
74
+ opts.templateType,
75
+ JSON.stringify(opts.attributes),
76
+ JSON.stringify(opts.contentState),
77
+ JSON.stringify({
78
+ enablePushUpdates: opts.enablePushUpdates,
79
+ ios: opts.ios,
80
+ android: opts.android,
81
+ }),
82
+ );
83
+ const info: LiveActivityInfo = JSON.parse(resultJson);
84
+ if (mountedRef.current) {
85
+ setActivities((prev) => [info, ...prev]);
86
+ }
87
+ return info;
88
+ } catch (err) {
89
+ const liveError = normalizeLiveActivityError(err);
90
+ if (mountedRef.current) setError(liveError);
91
+ throw liveError;
92
+ } finally {
93
+ if (mountedRef.current) setIsLoading(false);
94
+ }
95
+ },
96
+ [],
97
+ );
98
+
99
+ const update = useCallback(
100
+ async <T extends TemplateType>(
101
+ activityId: string,
102
+ opts: UpdateActivityOptions<T>,
103
+ ): Promise<void> => {
104
+ setError(null);
105
+ try {
106
+ await fns.update(
107
+ activityId,
108
+ JSON.stringify(opts.contentState),
109
+ opts.alert ? JSON.stringify(opts.alert) : null,
110
+ );
111
+ await refreshActivities();
112
+ } catch (err) {
113
+ const liveError = normalizeLiveActivityError(err);
114
+ if (mountedRef.current) setError(liveError);
115
+ throw liveError;
116
+ }
117
+ },
118
+ [refreshActivities],
119
+ );
120
+
121
+ const endActivity = useCallback(
122
+ async (activityId: string, opts?: EndActivityOptions): Promise<void> => {
123
+ setError(null);
124
+ try {
125
+ await fns.end(
126
+ activityId,
127
+ opts?.finalContentState
128
+ ? JSON.stringify(opts.finalContentState)
129
+ : null,
130
+ opts?.dismissalPolicy ?? 'default',
131
+ opts?.dismissAfter ?? null,
132
+ );
133
+ if (mountedRef.current) {
134
+ setActivities((prev) =>
135
+ prev.filter((a) => a.id !== activityId),
136
+ );
137
+ }
138
+ } catch (err) {
139
+ const liveError = normalizeLiveActivityError(err);
140
+ if (mountedRef.current) setError(liveError);
141
+ throw liveError;
142
+ }
143
+ },
144
+ [],
145
+ );
146
+
147
+ const endAllActivities = useCallback(
148
+ async (opts?: EndActivityOptions): Promise<void> => {
149
+ setError(null);
150
+ try {
151
+ await fns.endAll(
152
+ opts?.dismissalPolicy ?? 'default',
153
+ opts?.dismissAfter ?? null,
154
+ );
155
+ if (mountedRef.current) {
156
+ setActivities([]);
157
+ }
158
+ } catch (err) {
159
+ const liveError = normalizeLiveActivityError(err);
160
+ if (mountedRef.current) setError(liveError);
161
+ throw liveError;
162
+ }
163
+ },
164
+ [],
165
+ );
166
+
167
+ const getActivity = useCallback(
168
+ async (activityId: string): Promise<LiveActivityInfo | null> => {
169
+ try {
170
+ const json = await fns.getActivity(activityId);
171
+ if (!json) return null;
172
+ return JSON.parse(json) as LiveActivityInfo;
173
+ } catch {
174
+ return null;
175
+ }
176
+ },
177
+ [],
178
+ );
179
+
180
+ const listAllActivities = useCallback(async (): Promise<
181
+ LiveActivityInfo[]
182
+ > => {
183
+ try {
184
+ const json = await fns.listActivities();
185
+ const list: LiveActivityInfo[] = JSON.parse(json);
186
+ if (mountedRef.current) {
187
+ setActivities(list);
188
+ }
189
+ return list;
190
+ } catch {
191
+ return [];
192
+ }
193
+ }, []);
194
+
195
+ const getPushToken = useCallback(
196
+ async (activityId: string): Promise<LiveActivityToken | null> => {
197
+ try {
198
+ const json = await fns.getPushToken(activityId);
199
+ if (!json) return null;
200
+ return JSON.parse(json) as LiveActivityToken;
201
+ } catch {
202
+ return null;
203
+ }
204
+ },
205
+ [],
206
+ );
207
+
208
+ const clearError = useCallback(() => setError(null), []);
209
+
210
+ // Subscribe to native events
211
+ useEffect(() => {
212
+ mountedRef.current = true;
213
+
214
+ const unsubscribe = fns.addEventListener((event) => {
215
+ if (!mountedRef.current) return;
216
+
217
+ // Update local state based on event
218
+ if (event.type === 'ended') {
219
+ setActivities((prev) =>
220
+ prev.filter((a) => a.id !== event.activityId),
221
+ );
222
+ } else if (event.type === 'stale') {
223
+ setActivities((prev) =>
224
+ prev.map((a) =>
225
+ a.id === event.activityId ? { ...a, state: 'stale' } : a,
226
+ ),
227
+ );
228
+ } else if (event.type === 'error' && event.payload?.error) {
229
+ setError(event.payload.error);
230
+ }
231
+
232
+ // Forward to user's handler
233
+ onEvent?.(event);
234
+ });
235
+
236
+ // Auto-check availability on mount
237
+ if (autoCheckAvailability && !initializedRef.current) {
238
+ initializedRef.current = true;
239
+ checkAvailability();
240
+ refreshActivities();
241
+ }
242
+
243
+ return () => {
244
+ mountedRef.current = false;
245
+ unsubscribe();
246
+ };
247
+ }, [autoCheckAvailability, onEvent, checkAvailability, refreshActivities]);
248
+
249
+ return {
250
+ isSupported,
251
+ isEnabled,
252
+ currentActivity,
253
+ activities,
254
+ isLoading,
255
+ error,
256
+ checkAvailability,
257
+ start,
258
+ update,
259
+ end: endActivity,
260
+ endAll: endAllActivities,
261
+ getActivity,
262
+ listActivities: listAllActivities,
263
+ getPushToken,
264
+ clearError,
265
+ };
266
+ };
267
+ }
@@ -0,0 +1,39 @@
1
+ import type { TemplateType } from './types';
2
+
3
+ // ============================================================================
4
+ // Template Identifiers
5
+ // ============================================================================
6
+
7
+ /** Mapping of template types to their native identifiers. */
8
+ export const TEMPLATE_IDS: Record<TemplateType, string> = {
9
+ delivery: 'IdealystDeliveryActivity',
10
+ timer: 'IdealystTimerActivity',
11
+ media: 'IdealystMediaActivity',
12
+ progress: 'IdealystProgressActivity',
13
+ custom: 'custom',
14
+ };
15
+
16
+ // ============================================================================
17
+ // Android Notification Defaults
18
+ // ============================================================================
19
+
20
+ export const DEFAULT_CHANNEL_ID = 'idealyst_live_activity';
21
+ export const DEFAULT_CHANNEL_NAME = 'Live Activities';
22
+ export const DEFAULT_CHANNEL_DESCRIPTION = 'Real-time activity updates';
23
+ export const DEFAULT_SMALL_ICON = 'ic_notification';
24
+
25
+ // ============================================================================
26
+ // Limits
27
+ // ============================================================================
28
+
29
+ /** iOS allows a maximum of 5 concurrent Live Activities per app. */
30
+ export const MAX_CONCURRENT_ACTIVITIES_IOS = 5;
31
+
32
+ /** Android has no hard limit, but we enforce a reasonable default. */
33
+ export const MAX_CONCURRENT_ACTIVITIES_ANDROID = 10;
34
+
35
+ // ============================================================================
36
+ // Event Names
37
+ // ============================================================================
38
+
39
+ export const LIVE_ACTIVITY_EVENT = 'IdealystLiveActivityEvent';
package/src/errors.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { LiveActivityError, LiveActivityErrorCode } from './types';
2
+
3
+ export function createLiveActivityError(
4
+ code: LiveActivityErrorCode,
5
+ message: string,
6
+ originalError?: unknown,
7
+ ): LiveActivityError {
8
+ return { code, message, originalError };
9
+ }
10
+
11
+ /**
12
+ * Normalize an unknown error into a LiveActivityError.
13
+ */
14
+ export function normalizeLiveActivityError(error: unknown): LiveActivityError {
15
+ if (
16
+ error &&
17
+ typeof error === 'object' &&
18
+ 'code' in error &&
19
+ 'message' in error
20
+ ) {
21
+ const typed = error as { code?: string; message?: string };
22
+ const code = isLiveActivityErrorCode(typed.code)
23
+ ? typed.code
24
+ : 'unknown';
25
+ return createLiveActivityError(
26
+ code,
27
+ typed.message || 'An unknown Live Activity error occurred',
28
+ error,
29
+ );
30
+ }
31
+
32
+ if (error instanceof Error) {
33
+ return createLiveActivityError('unknown', error.message, error);
34
+ }
35
+
36
+ return createLiveActivityError('unknown', String(error), error);
37
+ }
38
+
39
+ const VALID_CODES: LiveActivityErrorCode[] = [
40
+ 'not_available',
41
+ 'not_supported',
42
+ 'permission_denied',
43
+ 'start_failed',
44
+ 'update_failed',
45
+ 'end_failed',
46
+ 'activity_not_found',
47
+ 'template_not_found',
48
+ 'invalid_attributes',
49
+ 'too_many_activities',
50
+ 'unknown',
51
+ ];
52
+
53
+ function isLiveActivityErrorCode(
54
+ value: unknown,
55
+ ): value is LiveActivityErrorCode {
56
+ return typeof value === 'string' && VALID_CODES.includes(value as LiveActivityErrorCode);
57
+ }
@@ -0,0 +1,59 @@
1
+ export * from './types';
2
+ export {
3
+ TEMPLATE_IDS,
4
+ DEFAULT_CHANNEL_ID,
5
+ DEFAULT_CHANNEL_NAME,
6
+ DEFAULT_CHANNEL_DESCRIPTION,
7
+ DEFAULT_SMALL_ICON,
8
+ MAX_CONCURRENT_ACTIVITIES_IOS,
9
+ MAX_CONCURRENT_ACTIVITIES_ANDROID,
10
+ LIVE_ACTIVITY_EVENT,
11
+ } from './constants';
12
+ export { createLiveActivityError, normalizeLiveActivityError } from './errors';
13
+
14
+ // Template presets
15
+ export {
16
+ deliveryActivity,
17
+ timerActivity,
18
+ mediaActivity,
19
+ progressActivity,
20
+ } from './templates/presets';
21
+
22
+ // Activity functions — native implementations
23
+ export {
24
+ checkAvailability,
25
+ start,
26
+ update,
27
+ end,
28
+ endAll,
29
+ getActivity,
30
+ listActivities,
31
+ getPushToken,
32
+ addEventListener,
33
+ } from './activity/activity.native';
34
+
35
+ // Hook — bound to native implementations
36
+ import { createUseLiveActivityHook } from './activity/useLiveActivity';
37
+ import {
38
+ checkAvailability,
39
+ start,
40
+ update,
41
+ end,
42
+ endAll,
43
+ getActivity,
44
+ listActivities,
45
+ getPushToken,
46
+ addEventListener,
47
+ } from './activity/activity.native';
48
+
49
+ export const useLiveActivity = createUseLiveActivityHook({
50
+ checkAvailability,
51
+ start,
52
+ update,
53
+ end,
54
+ endAll,
55
+ getActivity,
56
+ listActivities,
57
+ getPushToken,
58
+ addEventListener,
59
+ });