mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(api): correct apns clear payload and badge handling (#2092)
This commit is contained in:
1 parent
8db4f5cb63
commit
cb889b1160
2 files changed
+48
-16
No files matched your search
@@ -143,7 +143,7 @@ function buildApnsHeaders(params: {
|
|||||||
'apns-topic': params.topic,
|
'apns-topic': params.topic,
|
||||||
'apns-push-type': isClear ? APNS_PUSH_TYPE_BACKGROUND : APNS_PUSH_TYPE_ALERT,
|
'apns-push-type': isClear ? APNS_PUSH_TYPE_BACKGROUND : APNS_PUSH_TYPE_ALERT,
|
||||||
'apns-priority': isClear ? '5' : '10',
|
'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',
|
'content-type': 'application/json',
|
||||||
};
|
};
|
||||||
if (collapseId && Buffer.byteLength(collapseId) <= 64) {
|
if (collapseId && Buffer.byteLength(collapseId) <= 64) {
|
||||||
@@ -155,18 +155,11 @@ function buildApnsHeaders(params: {
|
|||||||
function buildApnsPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
function buildApnsPayload(payload: Record<string, unknown>): Record<string, unknown> {
|
||||||
if (isClearNotificationPayload(payload)) {
|
if (isClearNotificationPayload(payload)) {
|
||||||
const data = isRecord(payload.data) ? payload.data : {};
|
const data = isRecord(payload.data) ? payload.data : {};
|
||||||
const badge = normalizeBadgeCount(data.badge_count);
|
|
||||||
const aps: Record<string, unknown> = {
|
|
||||||
'content-available': 1,
|
|
||||||
};
|
|
||||||
if (badge !== undefined) {
|
|
||||||
aps.badge = badge;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
type: 'notification_clear',
|
type: 'notification_clear',
|
||||||
action: 'clear_channel',
|
action: 'clear_channel',
|
||||||
aps,
|
aps: {'content-available': 1},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const data = isRecord(payload.data) ? payload.data : {};
|
const data = isRecord(payload.data) ? payload.data : {};
|
||||||
@@ -177,16 +170,18 @@ function buildApnsPayload(payload: Record<string, unknown>): Record<string, unkn
|
|||||||
const channelId = optionalString(data.channel_id);
|
const channelId = optionalString(data.channel_id);
|
||||||
const threadId =
|
const threadId =
|
||||||
optionalString(data.notification_tag) ?? (channelId ? `channel:${channelId}` : undefined) ?? 'fluxer-message';
|
optionalString(data.notification_tag) ?? (channelId ? `channel:${channelId}` : undefined) ?? 'fluxer-message';
|
||||||
const imageUrl = firstString([payload.image_url, notification.image, notification.icon, payload.icon]);
|
const imageUrl = firstString([payload.image_url, notification.image]);
|
||||||
const aps: Record<string, unknown> = {
|
const aps: Record<string, unknown> = {
|
||||||
alert: {title, body},
|
alert: {title, body},
|
||||||
sound: APNS_DEFAULT_SOUND,
|
sound: APNS_DEFAULT_SOUND,
|
||||||
badge,
|
|
||||||
'thread-id': threadId,
|
'thread-id': threadId,
|
||||||
category: APNS_CATEGORY_MESSAGE,
|
category: APNS_CATEGORY_MESSAGE,
|
||||||
'interruption-level': 'active',
|
'interruption-level': 'active',
|
||||||
'relevance-score': 0.5,
|
'relevance-score': 0.5,
|
||||||
};
|
};
|
||||||
|
if (badge !== undefined) {
|
||||||
|
aps.badge = badge;
|
||||||
|
}
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
aps['mutable-content'] = 1;
|
aps['mutable-content'] = 1;
|
||||||
}
|
}
|
||||||
@@ -292,13 +287,13 @@ function isPermanentApnsFailure(statusCode: number, reason: string): boolean {
|
|||||||
return reason === 'Unregistered';
|
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 === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const parsed = Number.parseInt(value, 10);
|
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<unknown>): string | undefined {
|
function firstString(values: Array<unknown>): string | undefined {
|
||||||
|
|||||||
@@ -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({
|
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
|
||||||
type: 'notification_clear',
|
type: 'notification_clear',
|
||||||
action: 'clear_channel',
|
action: 'clear_channel',
|
||||||
@@ -56,11 +56,11 @@ describe('ApnsPushService', () => {
|
|||||||
badge_count: 0,
|
badge_count: 0,
|
||||||
aps: {
|
aps: {
|
||||||
'content-available': 1,
|
'content-available': 1,
|
||||||
badge: 0,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(payload.aps).not.toHaveProperty('alert');
|
expect(payload.aps).not.toHaveProperty('alert');
|
||||||
expect(payload.aps).not.toHaveProperty('sound');
|
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', () => {
|
it('uses APNs push-type and priority headers that match alert versus background delivery', () => {
|
||||||
const alertHeaders = ApnsPushServiceTestHooks.buildApnsHeaders({
|
const alertHeaders = ApnsPushServiceTestHooks.buildApnsHeaders({
|
||||||
@@ -94,6 +94,43 @@ describe('ApnsPushService', () => {
|
|||||||
'apns-priority': '5',
|
'apns-priority': '5',
|
||||||
'apns-collapse-id': 'channel:123',
|
'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<string, unknown>).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', () => {
|
it('marks only permanent APNs token failures as subscription deletion signals', () => {
|
||||||
expect(ApnsPushServiceTestHooks.isPermanentApnsFailure(410, 'Unregistered')).toBe(true);
|
expect(ApnsPushServiceTestHooks.isPermanentApnsFailure(410, 'Unregistered')).toBe(true);
|
||||||
|
|||||||
Reference in new issue
Block a user