feat(web): WhatsApp-style formatting for URL-backed phone messages - #1016
Conversation
- Render *bold*, _italic_, ~strikethrough~, `code`/```code block``` and "> " quoted lines on the thread page, only for messages whose owner phone uses an HTTP notification URL instead of an FCM token. - Auto-link raw URLs as clickable anchors, protected from markdown replacements via placeholder tokens. - Require markers to be bounded by whitespace, punctuation, or the start/end of the string, matching WhatsApp's own formatting rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a795297d-527c-4f30-a35a-ab379f2d50a8
| * transport) instead of an FCM token. This mirrors `isNotificationURLCandidate` in | ||
| * api/pkg/entities/phone.go. | ||
| */ | ||
| export function isUrlNotificationToken( |
There was a problem hiding this comment.
use tioken.trim() starts with "https://" only.
Address review comment: use token.trim().toLowerCase().startsWith('https://') instead of checking multiple schemes/substrings.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: a795297d-527c-4f30-a35a-ab379f2d50a8
|
| const trailing = match.match(/[),.;:!?'"]+$/)?.[0] ?? '' | ||
| const urlText = trailing ? match.slice(0, -trailing.length) : match |
There was a problem hiding this comment.
Fixed in ad45dfe. splitTrailingPunctuation() now balance-checks parentheses (counts '(' vs ')' before the candidate split point) instead of unconditionally stripping a trailing ')'. https://example.com/page_(v2) now keeps the closing paren in the href.
| const formatted = lines | ||
| .join('\n') | ||
| .replace( | ||
| /(?<=^|[\s.,!?;:'"()[\]{}-])```(\S(?:[^`]*\S)?)```(?=$|[\s.,!?;:'"()[\]{}-])/g, |
There was a problem hiding this comment.
Fixed in ad45dfe. CODE_BLOCK_PATTERN changed from requiring a non-whitespace boundary char on each side of the fenced content to just '[^backtick]+', so a fence whose content starts/ends with a newline (code on its own lines) still matches.
| return text.replace(/(https?:\/\/[^\s<]+|www\.[^\s<]+)/gi, (match) => { | ||
| const trailing = match.match(/[),.;:!?'"]+$/)?.[0] ?? '' | ||
| const urlText = trailing ? match.slice(0, -trailing.length) : match |
There was a problem hiding this comment.
Fixed, refined across ad45dfe and 131ed20. linkifyUrls now checks the character immediately preceding the URL match: a trailing marker char is only stripped from the URL when it matches that preceding character (confirms a real opening delimiter), so https://example.com bolds a clean link instead of leaving the marker in the href/text.
| .replace( | ||
| /(?<=^|[\s.,!?;:'"()[\]{}-])`(\S(?:[^`\n]*\S)?)`(?=$|[\s.,!?;:'"()[\]{}-])/g, | ||
| '<code>$1</code>', | ||
| ) | ||
| .replace( | ||
| /(?<=^|[\s.,!?;:'"()[\]{}-])\*(\S(?:[^*\n]*\S)?)\*(?=$|[\s.,!?;:'"()[\]{}-])/g, |
There was a problem hiding this comment.
Fixed in ad45dfe. Added extractCodeSpans(), which replaces inline/fenced code with placeholder tokens (same placeholder store used for links) before the bold/italic/strikethrough passes run, then restores them untouched at the end. 'a b c' inside backticks now stays literal.
| const withPlaceholders = linkifyUrls(escaped, links) | ||
|
|
||
| const lines = withPlaceholders.split('\n').map((line) => { | ||
| const quoteMatch = line.match(/^>\s?(.*)$/) |
There was a problem hiding this comment.
Fixed in ad45dfe. The quote-line regex now requires a literal greater-than followed by a space, instead of an optional space, so '>threshold' is left as literal text.
| return formatted.replace( | ||
| LINK_PLACEHOLDER_PATTERN, | ||
| (_, idx: string) => links[Number(idx)] ?? '', | ||
| ) |
There was a problem hiding this comment.
Placeholder collision changes message text The restoration pattern also matches the same private-use-character sequence when it appears in a message. If the text contains
\uE0000\uE000 without a URL, that text disappears; with a URL, it can be replaced by the link. This silently changes what the reader sees.
There was a problem hiding this comment.
Fixed in ad45dfe. createPlaceholderStore() now generates a random per-call nonce embedded in the placeholder token, so literal message text can never collide with another call's placeholders.
| * - "> " quoted lines | ||
| * - raw URLs, turned into clickable links | ||
| */ | ||
| export function formatWhatsappText(text: string): string { |
There was a problem hiding this comment.
Formatter lacks automated tests The new formatter depends on the order of linkification, quote handling, markdown replacements, and placeholder restoration, but has no automated input/output tests. Focused tests for boundaries, code blocks, URLs, and HTML escaping would make regressions easier to catch than manual checks alone.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
Added in ad45dfe: web/app/utils/whatsapp-format.test.ts with 29 cases covering boundaries, quotes, code blocks, links, and escaping/placeholder safety, run via Node's built-in test runner (pnpm test).
- Preserve balanced parentheses in linkified URLs (e.g. https://example.com/page_(v2)) instead of always stripping the closing paren. - Support multiline fenced code blocks (```...```) where the content starts on the line after the opening fence. - Strip trailing formatting markers (*_~`) from a matched URL so "*https://example.com*" bolds the link instead of leaving an unmatched opening marker. - Protect already-rendered inline/fenced code from the bold/italic/ strikethrough passes so markers inside code render literally. - Use a per-call random nonce for placeholder tokens so literal message text can never collide with a placeholder and silently disappear or get replaced. - Require the quote prefix to be exactly "> " (space required) so a bare ">" with no following space is left untouched. - Add an automated test suite (app/utils/whatsapp-format.test.ts) run via Node's built-in test runner (`pnpm test`), covering boundaries, quotes, code blocks, links, and escaping/placeholder safety. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a795297d-527c-4f30-a35a-ab379f2d50a8
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 59 |
| Duplication | -2 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Formatting boundaries, URL parsing, and code-span processing currently produce incorrect output for valid inputs.
Review effort: Balanced
Findings: 1
Open (2)
What changed in this PR
Adds WhatsApp-style formatting for messages associated with URL-backed phones.
Changes:
- Adds sanitized formatting, quoting, code, and URL linkification.
- Conditionally renders formatted message HTML.
- Adds formatter tests and a TypeScript test runner.
| File | Description |
|---|---|
web/app/utils/whatsapp-format.ts |
Implements formatting and URL detection. |
web/app/utils/whatsapp-format.test.ts |
Tests formatting behavior. |
web/app/pages/threads/[id]/index.vue |
Renders eligible messages as formatted HTML. |
web/package.json |
Configures TypeScript tests. |
web/pnpm-lock.yaml |
Locks the added test dependency. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| '*', | ||
| '_', | ||
| '~', | ||
| '`', |
There was a problem hiding this comment.
Fixed. Trailing marker chars are now only stripped when the same character immediately precedes the URL (confirming it is a paired opening delimiter, e.g. https://x.com). Otherwise kept as part of the URL -- https://example.com/file_name_ now links correctly. Added a regression test.
Trailing *_~` characters were stripped from every linkified URL, even when they're legitimate URL path characters (e.g. https://example.com/file_name_ linked to /file_name instead of /file_name_). Now a trailing marker is only dropped when the same character immediately precedes the URL, confirming it's an actual wrapping delimiter (e.g. *https://example.com*) rather than part of the URL itself. Sentence punctuation and balanced-parenthesis handling are unchanged. Also fixes a typo ("oe" -> "or") in a test case. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a795297d-527c-4f30-a35a-ab379f2d50a8
Replace .includes() URL substring checks in a test assertion with regex matches against the rendered href attribute. CodeQL flags raw .includes(url) checks as a potential (flawed) security validation pattern; this was only a test assertion on rendered output, but the rewrite is also a more precise check (it verifies the URL became an anchor's href rather than just appearing anywhere in the string). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a795297d-527c-4f30-a35a-ab379f2d50a8
TestSendSMS_RateLimit failed intermittently in CI: fireEvent dispatches the SENT/DELIVERED cloud event through an async loopback HTTP push (emulatorPushQueue), and the 15s poll window was too tight under CI load, failing right at the boundary. Other tests already poll the 'delivered' status with a 30s timeout; align this test with that margin for both 'sent' and 'delivered' polls.


Summary
Add WhatsApp-style text formatting to the thread view (
web/app/pages/threads/[id]/index.vue), applied only to messages whose owner phone is backed by an HTTP notification URL rather than an FCM token.What changed
web/app/utils/whatsapp-format.ts:isUrlNotificationToken(fcmToken)— mirrorsisNotificationURLCandidateinapi/pkg/entities/phone.goto detect whether a phone'sfcm_tokenis actually an HTTP notification URL.formatWhatsappText(text)— converts WhatsApp markdown into sanitized HTML:*bold*,_italic_,~strikethrough~,`code`/```code block```"> "quoted linestarget="_blank"anchors.,!?;:'"()[]{}-), or the start/end of the string — matching WhatsApp's own rule that markers can't have internal leading/trailing whitespace and can't be embedded mid-word (e.g.*donotboldme*no spaceis left untouched, but*word*.bolds correctly)._) aren't misinterpreted as formatting.threads/[id]/index.vue: looks up the message owner's phone, and whenisUrlNotificationTokenis true, renders the message content viav-html="formatWhatsappText(message.content)"instead of plain text.Why
Phones that use an HTTP push-notification URL instead of FCM are typically relaying messages from services (e.g. WhatsApp Business-style bots) that already use WhatsApp's own markdown conventions. Rendering that formatting makes those messages readable instead of showing raw
*/_/~characters.Testing
pnpm lint:js,eslint, andprettier --checkpass on changed files.