diff --git a/docs/adr/0002-notification-message-text.md b/docs/adr/0002-notification-message-text.md index d6335c555f..c26239db57 100644 --- a/docs/adr/0002-notification-message-text.md +++ b/docs/adr/0002-notification-message-text.md @@ -1,6 +1,6 @@ # Notification message text -A [mobile push notification](../../CONTEXT.md#mobile-push-notification) for a message shows the message's own text as its [notification body](../../CONTEXT.md#notification-body), derived the same way on every platform. The body keeps the message's line breaks, the text of code, and bare URLs. It drops the Markdown syntax around them: code fences and their language, inline-code backticks, the `[…](…)` of links and images (keeping the link text or image alt text), and `**` and `~~` outside code. A link label or a bold run may contain code, and a code fence with no closing fence runs to the end of the text, as in CommonMark. Runs of spaces collapse outside code, and three or more line breaks become one blank line. Other Markdown (quotes, lists, headings, single `*` or `_`) stays as typed, because stripping it would also mangle ordinary text such as `snake_case` and code identifiers. +A [mobile push notification](../../CONTEXT.md#mobile-push-notification) for a message shows the message's own text as its [notification body](../../CONTEXT.md#notification-body), derived the same way on every platform. The body keeps the message's line breaks, the text of code, and bare URLs. It drops the Markdown syntax around them: code fences and their language, inline-code backticks, the `[…](…)` of links and images (keeping the link text or image alt text), and `**` and `~~` outside code. A link label or a bold run may contain code, a fenced block closes only at a fence at least as long as its opening fence that ends its line, so backticks inside its code don't end it, and a fence with no closing fence runs to the end of the text, as in CommonMark. Runs of spaces collapse outside code, and three or more line breaks become one blank line. Other Markdown (quotes, lists, headings, single `*` or `_`) stays as typed, because stripping it would also mangle ordinary text such as `snake_case` and code identifiers. The body is cut only where the platform would otherwise cut it. iOS shows at most 256 UTF-16 code units of a body, even when the notification is expanded, and cuts the rest without an ellipsis, often mid-word. So the body is at most 256 UTF-16 code units: a longer one keeps whole characters up to 255 units and ends with "…", so the reader can tell there is more. A platform that shows more of a body than iOS may raise its own cap to what it shows, but never lower it below that. The body is built from at most the first 4096 UTF-16 code units of the content (whole characters), which bounds the work for very long messages; when that cut drops text, the body ends with "…" too. diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift index b5e5127f8b..42451e88de 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushNotificationResolver.swift @@ -684,11 +684,15 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { /// whitespace in the prefix exceed about 3.8K units. static let previewInputLimit = 4096 - /// A fenced code block (with an optional language line) or an inline code - /// span. A fence with no closing fence runs to the end of the text, as in - /// CommonMark, which also covers a block cut by `previewInputLimit`. + /// A fenced span on one line, a fenced code block, or an inline code span. + /// A block's opening line (a fence of three or more backticks, then an + /// optional language) is dropped, and the block closes only at a fence at + /// least as long that ends its line, so backticks inside the code (a "```" + /// string literal) don't end it. A fence with no closing fence runs to the + /// end of the text, as in CommonMark, which also covers a block cut by + /// `previewInputLimit`. Group 2 is the block's fence; the others hold code. private static let previewCode = try! NSRegularExpression( - pattern: #"```(?:[^\n`]*\n)?([\s\S]*?)(?:\n?```|\z)|`([^`\n]*)`"#) + pattern: #"```([^\n]*?)(?:```|\z)|(`{3,})[^\n]*\n([\s\S]*?)(?:\n?\2`*[ \t]*(?=\n|\z)|\z)|`([^`\n]*)`"#) /// Stands in for a code span while the prose rules run over the whole /// text, so a link label or a bold run can contain code. Private-use @@ -725,7 +729,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { var position = 0 for match in previewCode.matches(in: input, range: NSRange(location: 0, length: text.length)) { prose += text.substring(with: NSRange(location: position, length: match.range.location - position)) - let body = match.range(at: 1).location != NSNotFound ? match.range(at: 1) : match.range(at: 2) + let body = [1, 3, 4].map(match.range(at:)).first { $0.location != NSNotFound }! prose += previewCodeOpen + String(codes.count) + previewCodeClose codes.append(text.substring(with: body)) position = NSMaxRange(match.range) diff --git a/test-fixtures/notification-body.json b/test-fixtures/notification-body.json index 76509d0ced..9ba167c064 100644 --- a/test-fixtures/notification-body.json +++ b/test-fixtures/notification-body.json @@ -37,6 +37,36 @@ "input": "Run ```just ci``` first", "expected": "Run just ci first" }, + { + "name": "backticks inside a fenced block don't close it", + "input": "```js\nconst fence = \"```\";\nconst text = \"**literal**\";\n```\nDone", + "expected": "const fence = \"```\";\nconst text = \"**literal**\";\nDone" + }, + { + "name": "a closing fence at the end of the last code line closes the block", + "input": "```\nlet x = 1```\n**After**", + "expected": "let x = 1\nAfter" + }, + { + "name": "a block fenced with four backticks keeps its code", + "input": "````\nlet text = \"**literal**\";\n````\n**After**", + "expected": "let text = \"**literal**\";\nAfter" + }, + { + "name": "a four-backtick block can hold a three-backtick fence", + "input": "````\n```\n**literal**\n```\n````\n**After**", + "expected": "```\n**literal**\n```\nAfter" + }, + { + "name": "an unclosed fence on one line runs to the end of the text", + "input": "Run ```**literal**", + "expected": "Run **literal**" + }, + { + "name": "a block's opening line is dropped even when it has a backtick", + "input": "```code with `tick`\nmore\n```", + "expected": "more" + }, { "name": "inline code keeps its text", "input": "Call `fetch()` again",