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