Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,13 @@ await activitysmith.notifications.send({

## Live Activities

There are four types of Live Activities:
There are five types of Live Activities:

- `stats`: best for showing business numbers side by side, such as revenue, sales, new users, conversion, refunds, or any other value you want visible at a glance
- `metrics`: best for live percentage values that change often, like server CPU, memory usage, disk usage, or error rate
- `segmented_progress`: best for anything that moves through clear stages, like deployments, onboarding flows, backups, ETL pipelines, migrations, and AI agent runs
- `progress`: best for tracking real-time progress with percentage, like tasks, backups, migrations, syncs, or uploads
- `alert`: best for status updates, such as feature adoption, reactivation, onboarding blockers, incidents, escalations, and other operational states

### Start & Update Live Activity

Expand Down Expand Up @@ -216,6 +217,32 @@ await activitysmith.liveActivities.stream("search-reindex", {
});
```

#### Alert

<p align="center">
<img src="https://cdn.activitysmith.com/features/alert-live-activity.png" alt="Alert Live Activity stream example" width="680" />
</p>

```ts
await activitysmith.liveActivities.stream("customer-ops", {
content_state: ActivitySmith.contentState({
title: "Reactivation",
message: "Lumen came back after 2 weeks",
type: "alert",
icon: ActivitySmith.alertIcon("cloud.sun", { color: "yellow" }),
badge: ActivitySmith.alertBadge("Customer", { color: "magenta" }),
}),
});
```

The `icon.symbol` value is an Apple SF Symbol name. Browse the catalog with one of these tools:

- [ActivitySmith app](https://apps.apple.com/us/app/activitysmith/id6752254835) - Open Settings -> SF Symbols to browse 45 hand-picked icons ready to use
- [SF Symbols](https://developer.apple.com/sf-symbols/) - Apple's official macOS app
- [Interactful](https://apps.apple.com/app/interactful/id1528095640) - free third-party iOS app listing all SF Symbols under Foundations -> Iconography

`icon` and `badge` are optional. If you omit either one, that element is not shown in the Live Activity.

### End Live Activity

Call `endStream(...)` with the same `streamKey` to dismiss the Live Activity. You can include final values before it is removed. By default, iOS removes the Live Activity after two minutes. Set `auto_dismiss_minutes` to choose a different dismissal time, including `0` for immediate dismissal.
Expand All @@ -238,6 +265,7 @@ await activitysmith.liveActivities.endStream("prod-web-1", {
### Live Activity Action

Live Activities can include one optional action button. Use it to open a URL from the Live Activity or trigger a backend webhook.
For Alert Live Activities, set `content_state.color` to tint the action button. `icon.color` and `badge.color` only affect the icon and badge.

<p align="center">
<img src="https://cdn.activitysmith.com/features/live-activity-with-action.png?v=20260319-1" alt="Live Activity with action button" width="680" />
Expand Down
12 changes: 6 additions & 6 deletions generated/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export interface ContentStateEnd {
*/
type?: ContentStateEndTypeEnum;
/**
* Optional. Accent color for progress, segmented_progress, and metrics Live Activities.
* Optional. Accent color for progress, segmented_progress, and metrics Live Activities. For Alert Live Activities, this tints the action button when action is included.
* @type {string}
* @memberof ContentStateEnd
*/
Expand Down Expand Up @@ -360,7 +360,7 @@ export interface ContentStateStart {
*/
type: ContentStateStartTypeEnum;
/**
* Optional. Accent color for progress, segmented_progress, and metrics Live Activities.
* Optional. Accent color for progress, segmented_progress, and metrics Live Activities. For Alert Live Activities, this tints the action button when action is included.
* @type {string}
* @memberof ContentStateStart
*/
Expand Down Expand Up @@ -523,7 +523,7 @@ export interface ContentStateUpdate {
*/
type?: ContentStateUpdateTypeEnum;
/**
* Optional. Accent color for progress, segmented_progress, and metrics Live Activities.
* Optional. Accent color for progress, segmented_progress, and metrics Live Activities. For Alert Live Activities, this tints the action button when action is included.
* @type {string}
* @memberof ContentStateUpdate
*/
Expand Down Expand Up @@ -676,7 +676,7 @@ export const LiveActivityActionType = {
export type LiveActivityActionType = typeof LiveActivityActionType[keyof typeof LiveActivityActionType];

/**
* Optional badge for alert Live Activities.
* Optional badge for Alert Live Activities.
* @export
* @interface LiveActivityAlertBadge
*/
Expand All @@ -696,7 +696,7 @@ export interface LiveActivityAlertBadge {
color?: LiveActivityColor;
}
/**
* Optional SF Symbol icon for alert Live Activities.
* Optional SF Symbol icon for Alert Live Activities.
* @export
* @interface LiveActivityAlertIcon
*/
Expand Down Expand Up @@ -1541,7 +1541,7 @@ export interface StreamContentState {
*/
type?: StreamContentStateTypeEnum;
/**
* Optional. Accent color for progress, segmented_progress, and metrics Live Activities.
* Optional. Accent color for progress, segmented_progress, and metrics Live Activities. For Alert Live Activities, this tints the action button when action is included.
* @type {string}
* @memberof StreamContentState
*/
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "activitysmith",
"version": "1.3.1",
"version": "1.4.0",
"description": "Official ActivitySmith Node.js SDK",
"keywords": [
"activitysmith",
Expand Down
115 changes: 102 additions & 13 deletions src/ActivitySmith.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Configuration, PushNotificationsApi, LiveActivitiesApi, MetricsApi } from "../generated/index";

const SDK_VERSION = "1.3.1";
const SDK_VERSION = "1.4.0";
const SDK_HEADER_NAME = "X-ActivitySmith-SDK";
const SDK_HEADER_VALUE = `node-v${SDK_VERSION}`;

Expand Down Expand Up @@ -28,18 +28,65 @@ type MetricUpdateOptions = Omit<MetricUpdateRequestBody, "value">;
type MetricInitOverrides = Parameters<MetricsApi["updateMetricValue"]>[1];
type ChannelTargetInput = { channels?: string[] };
type PushSendRequest = PushRequestBody & { channels?: string[] };
type LiveStartSendRequest = StartRequestBody & { channels?: string[] };
type LiveStreamSendRequest = StreamRequestBody & { channels?: string[] };

const LiveActivityTypes = {
segmentedProgress: "segmented_progress",
progress: "progress",
metrics: "metrics",
stats: "stats",
alert: "alert",
} as const;

function withTargetChannels<T extends { target?: ChannelTargetInput }>(
request: T & { channels?: string[] },
export type LiveActivityType = (typeof LiveActivityTypes)[keyof typeof LiveActivityTypes];

export type LiveActivityAlertIcon = {
symbol: string;
color?: string;
};

export type LiveActivityAlertBadge = {
title: string;
color?: string;
};

export type LiveActivityContentState = Record<string, unknown> & {
title?: string;
subtitle?: string;
type?: LiveActivityType | string;
message?: string;
icon?: LiveActivityAlertIcon;
badge?: LiveActivityAlertBadge;
color?: string;
};

type LiveActivityAlertIconOptions = {
color?: string;
};

type LiveActivityAlertBadgeOptions = {
color?: string;
};

type LiveStartSendRequest = Omit<StartRequestBody, "content_state"> & {
content_state: LiveActivityContentState;
channels?: string[];
};
type LiveUpdateSendRequest = Omit<UpdateRequestBody, "content_state"> & {
content_state: LiveActivityContentState;
};
type LiveEndSendRequest = Omit<EndRequestBody, "content_state"> & {
content_state: LiveActivityContentState;
};
type LiveStreamSendRequest = Omit<StreamRequestBody, "content_state"> & {
content_state: LiveActivityContentState;
channels?: string[];
};
type LiveStreamDeleteSendRequest = Omit<StreamDeleteRequestBody, "content_state"> & {
content_state?: LiveActivityContentState;
};

function withTargetChannels<T extends object>(
request: T & { target?: ChannelTargetInput; channels?: string[] },
): T {
const channels = request.channels;
if (!channels || channels.length === 0 || request.target) {
Expand All @@ -54,6 +101,30 @@ function withTargetChannels<T extends { target?: ChannelTargetInput }>(
} as T;
}

function compactObject<T extends Record<string, unknown>>(value: T): T {
return Object.fromEntries(
Object.entries(value).filter(([, entryValue]) => entryValue !== undefined),
) as T;
}

function contentState(value: LiveActivityContentState): LiveActivityContentState {
return compactObject(value);
}

function alertIcon(
symbol: string,
options: LiveActivityAlertIconOptions = {},
): LiveActivityAlertIcon {
return compactObject({ symbol, color: options.color });
}

function alertBadge(
title: string,
options: LiveActivityAlertBadgeOptions = {},
): LiveActivityAlertBadge {
return compactObject({ title, color: options.color });
}

function hasMediaValue(media: unknown): boolean {
if (typeof media === "string") {
return media.trim().length > 0;
Expand Down Expand Up @@ -140,37 +211,52 @@ export class LiveActivitiesResource {

start(request: LiveStartSendRequest, initOverrides?: LiveInitOverrides) {
return this.api.startLiveActivity(
{ liveActivityStartRequest: withTargetChannels(request) },
{
liveActivityStartRequest: withTargetChannels<LiveStartSendRequest>(
request,
) as StartRequestBody,
},
initOverrides,
);
}

update(request: UpdateRequestBody, initOverrides?: LiveInitOverrides) {
return this.api.updateLiveActivity({ liveActivityUpdateRequest: request }, initOverrides);
update(request: LiveUpdateSendRequest, initOverrides?: LiveInitOverrides) {
return this.api.updateLiveActivity(
{ liveActivityUpdateRequest: request as UpdateRequestBody },
initOverrides,
);
}

end(request: EndRequestBody, initOverrides?: LiveInitOverrides) {
return this.api.endLiveActivity({ liveActivityEndRequest: request }, initOverrides);
end(request: LiveEndSendRequest, initOverrides?: LiveInitOverrides) {
return this.api.endLiveActivity(
{ liveActivityEndRequest: request as EndRequestBody },
initOverrides,
);
}

stream(streamKey: string, request: LiveStreamSendRequest, initOverrides?: LiveInitOverrides) {
return this.api.reconcileLiveActivityStream(
{
streamKey,
liveActivityStreamRequest: withTargetChannels(request),
liveActivityStreamRequest: withTargetChannels<LiveStreamSendRequest>(
request,
) as StreamRequestBody,
},
initOverrides,
);
}

endStream(
streamKey: string,
request?: StreamDeleteRequestBody,
request?: LiveStreamDeleteSendRequest,
initOverrides?: LiveInitOverrides,
) {
if (request) {
return this.api.endLiveActivityStream(
{ streamKey, liveActivityStreamDeleteRequest: request },
{
streamKey,
liveActivityStreamDeleteRequest: request as StreamDeleteRequestBody,
},
initOverrides,
);
}
Expand Down Expand Up @@ -259,6 +345,9 @@ export class MetricsResource {

export class ActivitySmith {
public static readonly liveActivityTypes = LiveActivityTypes;
public static readonly contentState = contentState;
public static readonly alertIcon = alertIcon;
public static readonly alertBadge = alertBadge;

public readonly notifications: NotificationsResource;
public readonly liveActivities: LiveActivitiesResource;
Expand Down
40 changes: 40 additions & 0 deletions tests/resources.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,46 @@ describe("resource wrappers", () => {
expect(startSpy).toHaveBeenCalledWith({ liveActivityStartRequest: payload }, undefined);
});

it("passes through alert content_state with icon and badge colors", async () => {
const ActivitySmith = require("../dist/src/index.js");
const generated = require("../dist/generated/index.js");

const streamSpy = vi
.spyOn(generated.LiveActivitiesApi.prototype, "reconcileLiveActivityStream")
.mockResolvedValue({ operation: "started", stream_key: "customer-ops" });

const client = new ActivitySmith({ apiKey: "test" });
const payload = {
content_state: ActivitySmith.contentState({
title: "Reactivation",
message: "Lumen came back after 2 weeks",
type: ActivitySmith.liveActivityTypes.alert,
color: "red",
icon: ActivitySmith.alertIcon("cloud.sun", { color: "yellow" }),
badge: ActivitySmith.alertBadge("Customer", { color: "magenta" }),
}),
};

await client.liveActivities.stream("customer-ops", payload);

expect(streamSpy).toHaveBeenCalledWith(
{
streamKey: "customer-ops",
liveActivityStreamRequest: {
content_state: {
title: "Reactivation",
message: "Lumen came back after 2 weeks",
type: ActivitySmith.liveActivityTypes.alert,
color: "red",
icon: { symbol: "cloud.sun", color: "yellow" },
badge: { title: "Customer", color: "magenta" },
},
},
},
undefined,
);
});

it("wraps live activity stream payloads for short methods", async () => {
const ActivitySmith = require("../dist/src/index.js");
const generated = require("../dist/generated/index.js");
Expand Down