From e3911504554d0d14052a1d4796040030ae81bd64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 6 Jun 2026 16:27:59 +0200 Subject: [PATCH] fix(notifications): strip emoji from routine reminder push content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reminder pushes (medication-due, mood, briefing, cycle) carried the colour-coded phase markers the Telegram channel uses, which leaked onto the iOS lock screen and other push channels as decorative bubbles. Strip emoji in the APNs, Web Push and ntfy senders for every event except SYSTEM_ALERT, so routine reminders render as plain text while system / failure alerts keep their severity glyphs. Telegram is unchanged — emoji stay idiomatic there. --- .../__tests__/strip-emoji.test.ts | 49 +++++++++++++++++++ src/lib/notifications/senders/apns.ts | 6 ++- src/lib/notifications/senders/ntfy.ts | 10 ++-- src/lib/notifications/senders/web-push.ts | 8 ++- src/lib/notifications/strip-emoji.ts | 47 ++++++++++++++++++ 5 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 src/lib/notifications/__tests__/strip-emoji.test.ts create mode 100644 src/lib/notifications/strip-emoji.ts diff --git a/src/lib/notifications/__tests__/strip-emoji.test.ts b/src/lib/notifications/__tests__/strip-emoji.test.ts new file mode 100644 index 000000000..22e064285 --- /dev/null +++ b/src/lib/notifications/__tests__/strip-emoji.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; + +import { + stripDecorativeEmoji, + shouldStripEmoji, + plainPushText, +} from "@/lib/notifications/strip-emoji"; + +describe("stripDecorativeEmoji", () => { + it("removes the colour-coded reminder phase markers and tidies whitespace", () => { + expect(stripDecorativeEmoji("🟢 Reminder: Ramipril")).toBe( + "Reminder: Ramipril", + ); + expect(stripDecorativeEmoji("🔴 Missed: Metformin")).toBe( + "Missed: Metformin", + ); + }); + + it("strips clock / skip / check emoji and regional indicators", () => { + expect(stripDecorativeEmoji("🕐 1h ⏭ Skip ✅")).toBe("1h Skip"); + }); + + it("leaves plain text untouched", () => { + expect(stripDecorativeEmoji("Due soon: Vitamin D (1 tablet)")).toBe( + "Due soon: Vitamin D (1 tablet)", + ); + }); + + it("spares ™ © ® so brand names survive", () => { + expect(stripDecorativeEmoji("Tylenol® reminder")).toBe("Tylenol® reminder"); + }); +}); + +describe("shouldStripEmoji / plainPushText", () => { + it("strips for routine reminder events", () => { + expect(shouldStripEmoji("MEDICATION_REMINDER")).toBe(true); + expect(shouldStripEmoji("MOOD_REMINDER")).toBe(true); + expect(plainPushText("🟢 Take Ramipril", "MEDICATION_REMINDER")).toBe( + "Take Ramipril", + ); + }); + + it("keeps emoji for system / failure alerts", () => { + expect(shouldStripEmoji("SYSTEM_ALERT")).toBe(false); + expect(plainPushText("⚠️ Sync failed", "SYSTEM_ALERT")).toBe( + "⚠️ Sync failed", + ); + }); +}); diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 70f1f5ad5..28ac3b196 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -30,6 +30,7 @@ import { prisma } from "@/lib/db"; import { getEvent } from "@/lib/logging/context"; import type { SendOutcome } from "@/lib/notifications/retry-policy"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { plainPushText } from "@/lib/notifications/strip-emoji"; export interface ApnsPayload { /** APNs `aps.alert.title` + `aps.alert.body`. */ @@ -615,7 +616,10 @@ export async function sendViaApns( deviceToken: device.apnsToken, environment: attemptEnv, payload: { - alert: { title: payload.title, body: stripHtml(payload.message) }, + alert: { + title: plainPushText(payload.title, payload.eventType), + body: plainPushText(stripHtml(payload.message), payload.eventType), + }, data: { eventType: routingEvent, // In discreet mode also drop cycle-semantic metadata (e.g. `phase`) diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index a71a05cd1..7878c8c92 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -7,6 +7,7 @@ import { classifyHttpStatus } from "@/lib/notifications/retry-policy"; import { getEvent } from "@/lib/logging/context"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; import { safeFetch } from "@/lib/safe-fetch"; +import { plainPushText } from "@/lib/notifications/strip-emoji"; /** * Send notification via ntfy (simple HTTP POST). @@ -26,7 +27,7 @@ export async function sendViaNtfy( const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`; const headers: Record = { - Title: payload.title, + Title: plainPushText(payload.title, payload.eventType), Priority: payload.eventType === "MEDICATION_REMINDER" ? "high" : "default", // Discreet mode (cycle privacy): the X-Tags header is visible on the @@ -41,8 +42,11 @@ export async function sendViaNtfy( headers["Authorization"] = `Bearer ${config.authToken}`; } - // Strip HTML tags for ntfy (plain text only) - const body = payload.message.replace(/<[^>]*>/g, ""); + // Strip HTML tags for ntfy (plain text only) + emoji on routine reminders + const body = plainPushText( + payload.message.replace(/<[^>]*>/g, ""), + payload.eventType, + ); // requirePublicHost adds the DNS-rebinding pin (issue #217) on top // of the input-time isPublicUrl guard already enforced when the diff --git a/src/lib/notifications/senders/web-push.ts b/src/lib/notifications/senders/web-push.ts index c0dba549c..973078649 100644 --- a/src/lib/notifications/senders/web-push.ts +++ b/src/lib/notifications/senders/web-push.ts @@ -7,6 +7,7 @@ import { getVapidConfig } from "@/lib/notifications/vapid-config"; import { getEvent } from "@/lib/logging/context"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; import { isPublicUrl } from "@/lib/validations/notifications"; +import { plainPushText } from "@/lib/notifications/strip-emoji"; /** * Send Web Push notification to all subscribed devices of a user. @@ -78,8 +79,11 @@ export async function sendViaWebPush( } const pushPayload = JSON.stringify({ - title: payload.title, - body: payload.message.replace(/<[^>]*>/g, ""), + title: plainPushText(payload.title, payload.eventType), + body: plainPushText( + payload.message.replace(/<[^>]*>/g, ""), + payload.eventType, + ), // Discreet mode (cycle privacy): the coalescing `tag` must not name the // event. Web-Push payloads are VAPID-encrypted (only the SW sees this), // but the discreet contract still requires no event name on the wire — diff --git a/src/lib/notifications/strip-emoji.ts b/src/lib/notifications/strip-emoji.ts new file mode 100644 index 000000000..475b4f79e --- /dev/null +++ b/src/lib/notifications/strip-emoji.ts @@ -0,0 +1,47 @@ +/** + * Strip decorative emoji from push notification content. + * + * Operator + iOS contract (2026-06): routine reminder pushes + * (medication-due, mood-nudge, daily-briefing, cycle reminders) must be + * plain text — no emoji, no decorative bubbles. iOS renders the APNs + * `title`/`body` verbatim, so the colour-coded phase markers (🟢🟡🟠🔴) the + * reminder strings carry for the Telegram channel leak onto the lock + * screen. Emoji stay acceptable only for system / failure alerts + * (`SYSTEM_ALERT`), where they flag severity. + * + * The push senders (APNs, Web Push, ntfy) run reminder content through + * this; Telegram keeps the emoji (idiomatic there, and its inline keyboard + * relies on glyph-labelled action buttons). `shouldStripEmoji` keeps the + * SYSTEM_ALERT carve-out in one place. + * + * The range set targets the actual emoji blocks (supplementary + * pictographs, misc symbols, dingbats, geometric/arrow emoji, regional + * indicators, variation selectors, keycap) and deliberately spares the + * BMP punctuation that doubles as text — ™ © ® and the like — so a brand + * name in a medication title is not mangled. + */ +const EMOJI_RE = + /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{1F1E6}-\u{1F1FF}\u{20E3}\u{231A}\u{231B}\u{23E9}-\u{23FA}]/gu; + +/** Remove decorative emoji and collapse the whitespace they leave behind. */ +export function stripDecorativeEmoji(text: string): string { + return text.replace(EMOJI_RE, "").replace(/\s+/g, " ").trim(); +} + +/** + * Whether a push channel should strip emoji for this event. Everything + * except `SYSTEM_ALERT` is a routine notification and goes plain. Accepts a + * bare string so it composes with senders that carry `eventType` widened to + * `string` on the wire payload. + */ +export function shouldStripEmoji(eventType: string): boolean { + return eventType !== "SYSTEM_ALERT"; +} + +/** + * Apply the reminder plain-text rule to a single push field: strip emoji + * unless the event is a system / failure alert. + */ +export function plainPushText(text: string, eventType: string): string { + return shouldStripEmoji(eventType) ? stripDecorativeEmoji(text) : text; +}