diff --git a/example/package-lock.json b/example/package-lock.json index 4a3cab0..b83641b 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -51,7 +51,8 @@ "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "convex-helpers": "0.1.111" + "convex-helpers": "0.1.111", + "expo-server-sdk": "^5.0.0" }, "devDependencies": { "@edge-runtime/vm": "5.0.0", diff --git a/package-lock.json b/package-lock.json index a1ec1be..95b4591 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.3.1", "license": "Apache-2.0", "dependencies": { - "convex-helpers": "0.1.111" + "convex-helpers": "0.1.111", + "expo-server-sdk": "^5.0.0" }, "devDependencies": { "@edge-runtime/vm": "5.0.0", @@ -2339,6 +2340,11 @@ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", "dev": true }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -2644,6 +2650,19 @@ "node": ">=12.0.0" } }, + "node_modules/expo-server-sdk": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-5.0.0.tgz", + "integrity": "sha512-GEp1XYLU80iS/hdRo3c2n092E8TgTXcHSuw6Lw68dSoWaAgiLPI2R+e5hp5+hGF1TtJZOi2nxtJX63+XA3iz9g==", + "dependencies": { + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1", + "undici": "^7.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3608,6 +3627,23 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3738,6 +3774,14 @@ "node": ">=4" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "engines": { + "node": ">= 4" + } + }, "node_modules/rollup": { "version": "4.56.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", @@ -4152,6 +4196,14 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -6088,6 +6140,11 @@ "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", "dev": true }, + "err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + }, "error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -6308,6 +6365,16 @@ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true }, + "expo-server-sdk": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/expo-server-sdk/-/expo-server-sdk-5.0.0.tgz", + "integrity": "sha512-GEp1XYLU80iS/hdRo3c2n092E8TgTXcHSuw6Lw68dSoWaAgiLPI2R+e5hp5+hGF1TtJZOi2nxtJX63+XA3iz9g==", + "requires": { + "promise-limit": "^2.7.0", + "promise-retry": "^2.0.1", + "undici": "^7.2.0" + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6991,6 +7058,20 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true }, + "promise-limit": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/promise-limit/-/promise-limit-2.7.0.tgz", + "integrity": "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==" + }, + "promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "requires": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + } + }, "punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7079,6 +7160,11 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==" + }, "rollup": { "version": "4.56.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", @@ -7371,6 +7457,11 @@ "@typescript-eslint/utils": "8.54.0" } }, + "undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==" + }, "undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 1a3aca3..90db0d0 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,8 @@ "convex": "^1.24.8" }, "dependencies": { - "convex-helpers": "0.1.111" + "convex-helpers": "0.1.111", + "expo-server-sdk": "^5.0.0" }, "devDependencies": { "@edge-runtime/vm": "5.0.0", diff --git a/src/component/_generated/component.ts b/src/component/_generated/component.ts index 615e85c..5b54339 100644 --- a/src/component/_generated/component.ts +++ b/src/component/_generated/component.ts @@ -52,7 +52,9 @@ export type ComponentApi = mutableContent?: boolean; numPreviousFailures: number; priority?: "default" | "normal" | "high"; - sound?: string | null; + icon?: string; + richContent?: { image?: string }; + sound?: string | null | { critical?: boolean; name?: string | null; volume?: number }; state: | "awaiting_delivery" | "in_progress" @@ -93,7 +95,9 @@ export type ComponentApi = mutableContent?: boolean; numPreviousFailures: number; priority?: "default" | "normal" | "high"; - sound?: string | null; + icon?: string; + richContent?: { image?: string }; + sound?: string | null | { critical?: boolean; name?: string | null; volume?: number }; state: | "awaiting_delivery" | "in_progress" @@ -168,7 +172,9 @@ export type ComponentApi = | "time-sensitive"; mutableContent?: boolean; priority?: "default" | "normal" | "high"; - sound?: string | null; + icon?: string; + richContent?: { image?: string }; + sound?: string | null | { critical?: boolean; name?: string | null; volume?: number }; subtitle?: string; title?: string; ttl?: number; @@ -200,7 +206,9 @@ export type ComponentApi = | "time-sensitive"; mutableContent?: boolean; priority?: "default" | "normal" | "high"; - sound?: string | null; + icon?: string; + richContent?: { image?: string }; + sound?: string | null | { critical?: boolean; name?: string | null; volume?: number }; subtitle?: string; title?: string; ttl?: number; diff --git a/src/component/internal.ts b/src/component/internal.ts index 7ad86e0..a5de6ee 100644 --- a/src/component/internal.ts +++ b/src/component/internal.ts @@ -1,9 +1,13 @@ -import { v, type JSONValue } from "convex/values"; +import { v } from "convex/values"; import { internalAction, internalMutation } from "./functions.js"; import { internal } from "./_generated/api.js"; import type { Id } from "./_generated/dataModel.js"; import { ensureCoordinator } from "./helpers.js"; import { notificationFields } from "./schema.js"; +import type { + ExpoPushSuccessTicket, + ExpoPushErrorTicket, +} from "expo-server-sdk"; export const markNotificationState = internalMutation({ args: { @@ -158,6 +162,8 @@ export const coordinateSendingPushNotifications = internalMutation({ badge: n.metadata.badge ?? undefined, interruptionLevel: n.metadata.interruptionLevel ?? undefined, channelId: n.metadata.channelId ?? undefined, + icon: n.metadata.icon ?? undefined, + richContent: n.metadata.richContent ?? undefined, categoryId: n.metadata.categoryId ?? undefined, mutableContent: n.metadata.mutableContent ?? undefined, }, @@ -274,10 +280,7 @@ export const action_sendPushNotifications = internalAction({ return; } const responseBody: { - data: Array< - | { status: "ok"; id: string } - | { status: "error"; message: string; details: JSONValue } - >; + data: Array; } = await response.json(); ctx.logger.debug( `Response from Expo's API: ${JSON.stringify(responseBody)}`, diff --git a/src/component/schema.ts b/src/component/schema.ts index dae4a97..aaeb88e 100644 --- a/src/component/schema.ts +++ b/src/component/schema.ts @@ -1,5 +1,6 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +import type { ExpoPushMessage } from "expo-server-sdk"; // https://docs.expo.dev/push-notifications/sending-notifications/#message-request-format export const notificationFields = { @@ -13,7 +14,17 @@ export const notificationFields = { v.union(v.literal("default"), v.literal("normal"), v.literal("high")), ), subtitle: v.optional(v.string()), - sound: v.optional(v.union(v.string(), v.null())), + sound: v.optional( + v.union( + v.string(), + v.null(), + v.object({ + critical: v.optional(v.boolean()), + name: v.optional(v.union(v.string(), v.null())), + volume: v.optional(v.number()), + }), + ), + ), badge: v.optional(v.number()), interruptionLevel: v.optional( v.union( @@ -24,126 +35,13 @@ export const notificationFields = { ), ), channelId: v.optional(v.string()), + icon: v.optional(v.string()), + richContent: v.optional(v.object({ image: v.optional(v.string()) })), categoryId: v.optional(v.string()), mutableContent: v.optional(v.boolean()), }; -/** - * Notification fields for push notifications. - */ -export type NotificationFields = { - /** - * iOS Only - * - * When this is set to true, the notification will cause the iOS app to start in the background to run a background task. - * Your app needs to be configured to support this. - */ - _contentAvailable?: boolean; - - /** - * Android and iOS - * - * A JSON object delivered to your app. It may be up to about 4KiB; - * the total notification payload sent to Apple and Google must be at most 4KiB or else you will get a "Message Too Big" error. - */ - data?: any; - - /** - * Android and iOS - * - * The title to display in the notification. Often displayed above the notification body. - * Maps to AndroidNotification.title and aps.alert.title. - */ - title: string; - - /** - * Android and iOS - * - * The message to display in the notification. Maps to AndroidNotification.body and aps.alert.body. - */ - body?: string; - - /** - * Android and iOS - * - * Time to Live: the number of seconds for which the message may be kept around for redelivery - * if it hasn't been delivered yet. Defaults to undefined to use the respective defaults of each provider - * (1 month for Android/FCM as well as iOS/APNs). - */ - ttl?: number; - - /** - * Android and iOS - * - * Timestamp since the Unix epoch specifying when the message expires. - * Same effect as ttl (ttl takes precedence over expiration). - */ - expiration?: number; - - /** - * Android and iOS - * - * The delivery priority of the message. - * Specify default or omit this field to use the default priority on each platform ("normal" on Android and "high" on iOS). - */ - priority?: "default" | "normal" | "high"; - - /** - * iOS Only - * - * The subtitle to display in the notification below the title. - * Maps to aps.alert.subtitle. - */ - subtitle?: string; - - /** - * iOS Only - * - * Play a sound when the recipient receives this notification. Specify default to play the device's default notification sound, - * or omit this field to play no sound. Custom sounds need to be configured via the config plugin and - * then specified including the file extension. Example: bells_sound.wav. - */ - sound?: string | null; - - /** - * iOS Only - * - * Number to display in the badge on the app icon. Specify zero to clear the badge. - */ - badge?: number; - - /** - * iOS Only - * - * The importance and delivery timing of a notification. - * The string values correspond to the UNNotificationInterruptionLevel enumeration cases. - */ - interruptionLevel?: "active" | "critical" | "passive" | "time-sensitive"; - - /** - * Android Only - * - * ID of the Notification Channel through which to display this notification. - * If an ID is specified but the corresponding channel does not exist on the device (that has not yet been created by your app), - * the notification will not be displayed to the user. - */ - channelId?: string; - - /** - * Android and iOS - * - * ID of the notification category that this notification is associated with. - */ - categoryId?: string; - - /** - * iOS Only - * - * Specifies whether this notification can be intercepted by the client app. - * Defaults to false. - */ - mutableContent?: boolean; -}; +export type NotificationFields = Omit; export const notificationState = v.union( v.literal("awaiting_delivery"),