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; +}