| ... | @@ -181,7 +181,7 @@ function shouldUpdateLiveActivity(input: { | ... | @@ -181,7 +181,7 @@ function shouldUpdateLiveActivity(input: { |
| 181 | // A thread finishing must never be throttled away: when a completion and a | 181 | // A thread finishing must never be throttled away: when a completion and a |
| 182 | // new start land in the same window, activeCount is unchanged and the Done | 182 | // new start land in the same window, activeCount is unchanged and the Done |
| 183 | // transition (and its alert) would otherwise be suppressed. | 183 | // transition (and its alert) would otherwise be suppressed. |
| 184 | if (newlyTerminalRows(input.previousAggregate, input.nextAggregate).length > 0) { | 184 | if (newlyTerminalRows(input.previousAggregate, input.nextAggregate, true).length > 0) { |
| 185 | return true; | 185 | return true; |
| 186 | } | 186 | } |
| 187 | const lastDeliveryAtMs = | 187 | const lastDeliveryAtMs = |
| ... | @@ -228,6 +228,7 @@ function chooseLiveActivityDelivery(input: { | ... | @@ -228,6 +228,7 @@ function chooseLiveActivityDelivery(input: { |
| 228 | readonly target: LiveActivities.TargetRow; | 228 | readonly target: LiveActivities.TargetRow; |
| 229 | readonly aggregate: RelayAgentActivityAggregateState | null; | 229 | readonly aggregate: RelayAgentActivityAggregateState | null; |
| 230 | readonly nowMs: number; | 230 | readonly nowMs: number; |
| | 231 | readonly replay?: boolean; |
| 231 | }): ChosenLiveActivityDelivery | "suppressed" | null { | 232 | }): ChosenLiveActivityDelivery | "suppressed" | null { |
| 232 | const preferences = parsePreferences(input.target.preferences_json); | 233 | const preferences = parsePreferences(input.target.preferences_json); |
| 233 | if (preferences?.liveActivitiesEnabled === false) { | 234 | if (preferences?.liveActivitiesEnabled === false) { |
| ... | @@ -286,18 +287,20 @@ function chooseLiveActivityDelivery(input: { | ... | @@ -286,18 +287,20 @@ function chooseLiveActivityDelivery(input: { |
| 286 | kind: "live_activity_update", | 287 | kind: "live_activity_update", |
| 287 | token: input.target.activity_push_token, | 288 | token: input.target.activity_push_token, |
| 288 | aggregate: nextAggregate, | 289 | aggregate: nextAggregate, |
| 289 | alert: | 290 | alert: input.replay |
| 290 | alertForAttentionTransition({ | 291 | ? null |
| 291 | previousAggregate, | 292 | : (alertForAttentionTransition({ |
| 292 | nextAggregate, | 293 | previousAggregate, |
| 293 | preferences, | 294 | nextAggregate, |
| 294 | }) ?? | 295 | preferences, |
| 295 | alertForNewlyTerminal({ | 296 | }) ?? |
| 296 | previousAggregate, | 297 | alertForNewlyTerminal({ |
| 297 | nextAggregate, | 298 | previousAggregate, |
| 298 | preferences, | 299 | nextAggregate, |
| 299 | nowMs: input.nowMs, | 300 | preferences, |
| 300 | }), | 301 | nowMs: input.nowMs, |
| | 302 | includeUnobserved: true, |
| | 303 | })), |
| 301 | } | 304 | } |
| 302 | : "suppressed"; | 305 | : "suppressed"; |
| 303 | } | 306 | } |
| ... | @@ -306,6 +309,7 @@ function chooseDelivery(input: { | ... | @@ -306,6 +309,7 @@ function chooseDelivery(input: { |
| 306 | readonly target: LiveActivities.TargetRow; | 309 | readonly target: LiveActivities.TargetRow; |
| 307 | readonly aggregate: RelayAgentActivityAggregateState | null; | 310 | readonly aggregate: RelayAgentActivityAggregateState | null; |
| 308 | readonly nowMs: number; | 311 | readonly nowMs: number; |
| | 312 | readonly replay?: boolean; |
| 309 | }): ChosenDelivery | null { | 313 | }): ChosenDelivery | null { |
| 310 | const liveActivityDelivery = chooseLiveActivityDelivery(input); | 314 | const liveActivityDelivery = chooseLiveActivityDelivery(input); |
| 311 | if (liveActivityDelivery === "suppressed") { | 315 | if (liveActivityDelivery === "suppressed") { |
| ... | @@ -314,7 +318,7 @@ function chooseDelivery(input: { | ... | @@ -314,7 +318,7 @@ function chooseDelivery(input: { |
| 314 | if (liveActivityDelivery) { | 318 | if (liveActivityDelivery) { |
| 315 | return liveActivityDelivery; | 319 | return liveActivityDelivery; |
| 316 | } | 320 | } |
| 317 | const notification = notificationForAggregate(input); | 321 | const notification = input.replay ? null : notificationForAggregate(input); |
| 318 | return notification && input.target.push_token | 322 | return notification && input.target.push_token |
| 319 | ? { | 323 | ? { |
| 320 | kind: "push_notification", | 324 | kind: "push_notification", |
| ... | @@ -519,6 +523,7 @@ export class ApnsDeliveries extends Context.Service< | ... | @@ -519,6 +523,7 @@ export class ApnsDeliveries extends Context.Service< |
| 519 | readonly target: LiveActivities.TargetRow; | 523 | readonly target: LiveActivities.TargetRow; |
| 520 | readonly aggregate: RelayAgentActivityAggregateState | null; | 524 | readonly aggregate: RelayAgentActivityAggregateState | null; |
| 521 | readonly nowMs: number; | 525 | readonly nowMs: number; |
| | 526 | readonly replay?: boolean; |
| 522 | }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>; | 527 | }) => Effect.Effect<RelayDeliveryResult | null, ApnsDeliveryError>; |
| 523 | readonly sendPushNotificationForTarget: (input: { | 528 | readonly sendPushNotificationForTarget: (input: { |
| 524 | readonly target: LiveActivities.TargetRow; | 529 | readonly target: LiveActivities.TargetRow; |
| ... | @@ -652,7 +657,7 @@ export const make = Effect.gen(function* () { | ... | @@ -652,7 +657,7 @@ export const make = Effect.gen(function* () { |
| 652 | }); | 657 | }); |
| 653 | }); | 658 | }); |
| 654 | | 659 | |
| 655 | const isCurrentSignedJobToken = Effect.fnUntraced(function* (input: { | 660 | const currentSignedJobTarget = Effect.fnUntraced(function* (input: { |
| 656 | readonly target: LiveActivityDeliveryTarget; | 661 | readonly target: LiveActivityDeliveryTarget; |
| 657 | readonly kind: RelayDeliveryKind; | 662 | readonly kind: RelayDeliveryKind; |
| 658 | readonly token: string; | 663 | readonly token: string; |
| ... | @@ -660,10 +665,10 @@ export const make = Effect.gen(function* () { | ... | @@ -660,10 +665,10 @@ export const make = Effect.gen(function* () { |
| 660 | return yield* liveActivities.listTargets({ userId: input.target.user_id }).pipe( | 665 | return yield* liveActivities.listTargets({ userId: input.target.user_id }).pipe( |
| 661 | Effect.map((targets) => { | 666 | Effect.map((targets) => { |
| 662 | const currentTarget = targets.find((row) => row.device_id === input.target.device_id); | 667 | const currentTarget = targets.find((row) => row.device_id === input.target.device_id); |
| 663 | return ( | 668 | return currentTarget && |
| 664 | currentTarget !== undefined && | | |
| 665 | expectedCurrentToken({ target: currentTarget, kind: input.kind }) === input.token | 669 | expectedCurrentToken({ target: currentTarget, kind: input.kind }) === input.token |
| 666 | ); | 670 | ? currentTarget |
| | 671 | : null; |
| 667 | }), | 672 | }), |
| 668 | ); | 673 | ); |
| 669 | }); | 674 | }); |
| ... | @@ -679,11 +684,7 @@ export const make = Effect.gen(function* () { | ... | @@ -679,11 +684,7 @@ export const make = Effect.gen(function* () { |
| 679 | const now = yield* DateTime.now; | 684 | const now = yield* DateTime.now; |
| 680 | const aggregate = | 685 | const aggregate = |
| 681 | input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate); | 686 | input.aggregate === null ? null : sanitizeAgentActivityAggregateState(input.aggregate); |
| 682 | const { epochSeconds, iso, request } = makeLiveActivityDeliveryRequest( | 687 | let alert = input.alert ?? null; |
| 683 | apns, | | |
| 684 | { ...input, aggregate } as SendLiveActivityDeliveryInput, | | |
| 685 | now, | | |
| 686 | ); | | |
| 687 | const recoverTransportError = (cause: Apns.ApnsError) => | 688 | const recoverTransportError = (cause: Apns.ApnsError) => |
| 688 | recoverApnsDeliveryTransportError( | 689 | recoverApnsDeliveryTransportError( |
| 689 | { | 690 | { |
| ... | @@ -709,18 +710,37 @@ export const make = Effect.gen(function* () { | ... | @@ -709,18 +710,37 @@ export const make = Effect.gen(function* () { |
| 709 | if (claim === "in_flight") { | 710 | if (claim === "in_flight") { |
| 710 | return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); | 711 | return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); |
| 711 | } | 712 | } |
| 712 | const tokenIsCurrent = yield* isCurrentSignedJobToken({ | 713 | const currentTarget = yield* currentSignedJobTarget({ |
| 713 | target: input.target, | 714 | target: input.target, |
| 714 | kind: input.kind, | 715 | kind: input.kind, |
| 715 | token: input.token, | 716 | token: input.token, |
| 716 | }); | 717 | }); |
| 717 | if (!tokenIsCurrent) { | 718 | if (!currentTarget) { |
| 718 | yield* attempts.completeSourceJob({ | 719 | yield* attempts.completeSourceJob({ |
| 719 | sourceJobId: input.sourceJobId, | 720 | sourceJobId: input.sourceJobId, |
| 720 | apnsReason: "Stale APNs delivery job skipped.", | 721 | apnsReason: "Stale APNs delivery job skipped.", |
| 721 | }); | 722 | }); |
| 722 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); | 723 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); |
| 723 | } | 724 | } |
| | 725 | if (alert) { |
| | 726 | const preferences = parsePreferences(currentTarget.preferences_json); |
| | 727 | const previousAggregate = parseAggregate(currentTarget.last_aggregate_json); |
| | 728 | alert = |
| | 729 | !preferences?.notificationsEnabled || !aggregate |
| | 730 | ? null |
| | 731 | : (alertForAttentionTransition({ |
| | 732 | previousAggregate, |
| | 733 | nextAggregate: aggregate, |
| | 734 | preferences, |
| | 735 | }) ?? |
| | 736 | alertForNewlyTerminal({ |
| | 737 | previousAggregate, |
| | 738 | nextAggregate: aggregate, |
| | 739 | preferences, |
| | 740 | nowMs: now.epochMilliseconds, |
| | 741 | includeUnobserved: true, |
| | 742 | })); |
| | 743 | } |
| 724 | if ( | 744 | if ( |
| 725 | input.kind !== "live_activity_start" && | 745 | input.kind !== "live_activity_start" && |
| 726 | aggregate !== null && | 746 | aggregate !== null && |
| ... | @@ -752,6 +772,11 @@ export const make = Effect.gen(function* () { | ... | @@ -752,6 +772,11 @@ export const make = Effect.gen(function* () { |
| 752 | } | 772 | } |
| 753 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); | 773 | return staleJobResult({ deviceId: input.target.device_id, kind: input.kind }); |
| 754 | } | 774 | } |
| | 775 | const { epochSeconds, iso, request } = makeLiveActivityDeliveryRequest( |
| | 776 | apns, |
| | 777 | { ...input, aggregate, alert } as SendLiveActivityDeliveryInput, |
| | 778 | now, |
| | 779 | ); |
| 755 | const result = yield* apns | 780 | const result = yield* apns |
| 756 | .sendLiveActivityRequest({ | 781 | .sendLiveActivityRequest({ |
| 757 | credentials: credentialsForTarget(config.apns, input.target), | 782 | credentials: credentialsForTarget(config.apns, input.target), |
| ... | @@ -859,12 +884,12 @@ export const make = Effect.gen(function* () { | ... | @@ -859,12 +884,12 @@ export const make = Effect.gen(function* () { |
| 859 | if (claim === "in_flight") { | 884 | if (claim === "in_flight") { |
| 860 | return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); | 885 | return yield* new ApnsDeliveryJobClaimInFlight({ sourceJobId: input.sourceJobId }); |
| 861 | } | 886 | } |
| 862 | const tokenIsCurrent = yield* isCurrentSignedJobToken({ | 887 | const currentTarget = yield* currentSignedJobTarget({ |
| 863 | target: input.target, | 888 | target: input.target, |
| 864 | kind: "push_notification", | 889 | kind: "push_notification", |
| 865 | token: input.token, | 890 | token: input.token, |
| 866 | }); | 891 | }); |
| 867 | if (!tokenIsCurrent) { | 892 | if (!currentTarget) { |
| 868 | yield* attempts.completeSourceJob({ | 893 | yield* attempts.completeSourceJob({ |
| 869 | sourceJobId: input.sourceJobId, | 894 | sourceJobId: input.sourceJobId, |
| 870 | apnsReason: "Stale APNs delivery job skipped.", | 895 | apnsReason: "Stale APNs delivery job skipped.", |
| ... | @@ -889,6 +914,24 @@ export const make = Effect.gen(function* () { | ... | @@ -889,6 +914,24 @@ export const make = Effect.gen(function* () { |
| 889 | kind: "push_notification", | 914 | kind: "push_notification", |
| 890 | }); | 915 | }); |
| 891 | } | 916 | } |
| | 917 | const preferences = parsePreferences(currentTarget.preferences_json); |
| | 918 | const alertAllowed = |
| | 919 | notification.phase !== undefined && notification.updatedAt !== undefined |
| | 920 | ? shouldAlertForActivity({ |
| | 921 | ...notification, |
| | 922 | phase: notification.phase, |
| | 923 | updatedAt: notification.updatedAt, |
| | 924 | preferences, |
| | 925 | nowMs: now.epochMilliseconds, |
| | 926 | }) |
| | 927 | : preferences?.notificationsEnabled === true; |
| | 928 | if (!alertAllowed) { |
| | 929 | yield* attempts.completeSourceJob({ |
| | 930 | sourceJobId: input.sourceJobId, |
| | 931 | apnsReason: "Notification is disabled or no longer fresh.", |
| | 932 | }); |
| | 933 | return staleJobResult({ deviceId: input.target.device_id, kind: "push_notification" }); |
| | 934 | } |
| 892 | } | 935 | } |
| 893 | const result = yield* apns | 936 | const result = yield* apns |
| 894 | .sendPushNotificationRequest({ | 937 | .sendPushNotificationRequest({ |
| ... | @@ -1057,6 +1100,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1057,6 +1100,7 @@ export const make = Effect.gen(function* () { |
| 1057 | target: input.target, | 1100 | target: input.target, |
| 1058 | aggregate: input.aggregate, | 1101 | aggregate: input.aggregate, |
| 1059 | nowMs: input.nowMs, | 1102 | nowMs: input.nowMs, |
| | 1103 | replay: input.replay ?? false, |
| 1060 | }); | 1104 | }); |
| 1061 | if (!delivery) { | 1105 | if (!delivery) { |
| 1062 | return null; | 1106 | return null; |
| ... | @@ -1072,17 +1116,20 @@ export const make = Effect.gen(function* () { | ... | @@ -1072,17 +1116,20 @@ export const make = Effect.gen(function* () { |
| 1072 | }); | 1116 | }); |
| 1073 | return result; | 1117 | return result; |
| 1074 | } | 1118 | } |
| 1075 | const notification = notificationForAggregate({ | 1119 | const notification = input.replay |
| 1076 | target: input.target, | 1120 | ? null |
| 1077 | aggregate: input.aggregate, | 1121 | : notificationForAggregate({ |
| 1078 | nowMs: input.nowMs, | 1122 | target: input.target, |
| 1079 | }); | 1123 | aggregate: input.aggregate, |
| | 1124 | nowMs: input.nowMs, |
| | 1125 | }); |
| 1080 | // The end event doubles as the "task finished" moment. When a companion | 1126 | // The end event doubles as the "task finished" moment. When a companion |
| 1081 | // push notification is about to ring the device (below), the activity end | 1127 | // push notification is about to ring the device (below), the activity end |
| 1082 | // stays silent; otherwise the end itself carries the alert so LA-only | 1128 | // stays silent; otherwise the end itself carries the alert so LA-only |
| 1083 | // users still get the buzz. | 1129 | // users still get the buzz. |
| 1084 | const alert = | 1130 | const alert = input.replay |
| 1085 | delivery.kind === "live_activity_end" | 1131 | ? null |
| | 1132 | : delivery.kind === "live_activity_end" |
| 1086 | ? notification && input.target.push_token | 1133 | ? notification && input.target.push_token |
| 1087 | ? null | 1134 | ? null |
| 1088 | : alertForTerminalAggregate({ | 1135 | : alertForTerminalAggregate({ |