diff --git a/fluxer_api/src/api/push/ApnsPushService.ts b/fluxer_api/src/api/push/ApnsPushService.ts index 2bcb9c472..23c8ee3a3 100644 --- a/fluxer_api/src/api/push/ApnsPushService.ts +++ b/fluxer_api/src/api/push/ApnsPushService.ts @@ -143,7 +143,7 @@ function buildApnsHeaders(params: { 'apns-topic': params.topic, 'apns-push-type': isClear ? APNS_PUSH_TYPE_BACKGROUND : APNS_PUSH_TYPE_ALERT, 'apns-priority': isClear ? '5' : '10', - 'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 300 : 86400)), + 'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 3600 : 86400)), 'content-type': 'application/json', }; if (collapseId && Buffer.byteLength(collapseId) <= 64) { @@ -155,18 +155,11 @@ function buildApnsHeaders(params: { function buildApnsPayload(payload: Record): Record { if (isClearNotificationPayload(payload)) { const data = isRecord(payload.data) ? payload.data : {}; - const badge = normalizeBadgeCount(data.badge_count); - const aps: Record = { - 'content-available': 1, - }; - if (badge !== undefined) { - aps.badge = badge; - } return { ...data, type: 'notification_clear', action: 'clear_channel', - aps, + aps: {'content-available': 1}, }; } const data = isRecord(payload.data) ? payload.data : {}; @@ -177,16 +170,18 @@ function buildApnsPayload(payload: Record): Record = { alert: {title, body}, sound: APNS_DEFAULT_SOUND, - badge, 'thread-id': threadId, category: APNS_CATEGORY_MESSAGE, 'interruption-level': 'active', 'relevance-score': 0.5, }; + if (badge !== undefined) { + aps.badge = badge; + } if (imageUrl) { aps['mutable-content'] = 1; } @@ -292,13 +287,13 @@ function isPermanentApnsFailure(statusCode: number, reason: string): boolean { return reason === 'Unregistered'; } -function normalizeBadgeCount(value: unknown): number { +function normalizeBadgeCount(value: unknown): number | undefined { if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value)); if (typeof value === 'string') { const parsed = Number.parseInt(value, 10); - return Number.isFinite(parsed) ? Math.max(0, parsed) : 0; + return Number.isFinite(parsed) ? Math.max(0, parsed) : undefined; } - return 0; + return undefined; } function firstString(values: Array): string | undefined { diff --git a/fluxer_api/src/api/push/tests/ApnsPushService.test.ts b/fluxer_api/src/api/push/tests/ApnsPushService.test.ts index 5be0d364d..d3878a30d 100644 --- a/fluxer_api/src/api/push/tests/ApnsPushService.test.ts +++ b/fluxer_api/src/api/push/tests/ApnsPushService.test.ts @@ -38,7 +38,7 @@ describe('ApnsPushService', () => { }, }); }); - it('builds silent APNs clear payloads with badge when badge_count is present', () => { + it('builds silent APNs clear payloads as pure background pushes', () => { const payload = ApnsPushServiceTestHooks.buildApnsPayload({ type: 'notification_clear', action: 'clear_channel', @@ -56,11 +56,11 @@ describe('ApnsPushService', () => { badge_count: 0, aps: { 'content-available': 1, - badge: 0, }, }); expect(payload.aps).not.toHaveProperty('alert'); expect(payload.aps).not.toHaveProperty('sound'); + expect(payload.aps).not.toHaveProperty('badge'); }); it('uses APNs push-type and priority headers that match alert versus background delivery', () => { const alertHeaders = ApnsPushServiceTestHooks.buildApnsHeaders({ @@ -94,6 +94,43 @@ describe('ApnsPushService', () => { 'apns-priority': '5', 'apns-collapse-id': 'channel:123', }); + const now = Math.floor(Date.now() / 1000); + expect(Number(clearHeaders['apns-expiration']) - now).toBeGreaterThan(1800); + }); + + it('omits the badge entirely when no usable badge count is supplied', () => { + const payload = ApnsPushServiceTestHooks.buildApnsPayload({ + tag: 'channel:123:456', + data: {channel_id: '123', message_id: '456'}, + notification: {title: 'Alice', body: 'Hello'}, + }); + expect(payload.aps).not.toHaveProperty('badge'); + const unparseable = ApnsPushServiceTestHooks.buildApnsPayload({ + tag: 'channel:123:456', + data: {channel_id: '123', message_id: '456', badge_count: 'not-a-number'}, + notification: {title: 'Alice', body: 'Hello'}, + }); + expect(unparseable.aps).not.toHaveProperty('badge'); + }); + + it('keeps a genuine zero badge so reading the last message clears the app icon', () => { + const payload = ApnsPushServiceTestHooks.buildApnsPayload({ + tag: 'channel:123:456', + data: {channel_id: '123', message_id: '456', badge_count: 0}, + notification: {title: 'Alice', body: 'Hello'}, + }); + expect((payload.aps as Record).badge).toBe(0); + }); + + it('does not use the sender avatar as the notification media image', () => { + const payload = ApnsPushServiceTestHooks.buildApnsPayload({ + tag: 'channel:123:456', + data: {channel_id: '123', message_id: '456', author_avatar_url: 'https://cdn.example/avatar.png'}, + notification: {title: 'Alice', body: 'Hello', icon: 'https://cdn.example/avatar.png'}, + }); + expect(payload.image_url).toBeUndefined(); + expect(payload.aps).not.toHaveProperty('mutable-content'); + expect(payload.author_avatar_url).toBe('https://cdn.example/avatar.png'); }); it('marks only permanent APNs token failures as subscription deletion signals', () => { expect(ApnsPushServiceTestHooks.isPermanentApnsFailure(410, 'Unregistered')).toBe(true);