Skip to content

Commit 9c74028

Browse files
AchoArnoldCopilot
andauthored
feat(web): WhatsApp-style formatting for URL-backed phone messages (NdoleStudio#1016)
* feat(web): add WhatsApp-style formatting for URL-backed phone messages - 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 * fix(web): simplify URL notification token detection to https:// prefix 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 * fix(web): address code review on WhatsApp formatter - 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 * fix(web): only strip URL trailing marker when it pairs with an opener 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 * fix(web): avoid CodeQL incomplete-URL-substring-sanitization alert 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 * fix(tests): raise sent/delivered poll timeout in rate-limit test to 30s 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. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a795297d-527c-4f30-a35a-ab379f2d50a8
1 parent 8520120 commit 9c74028

6 files changed

Lines changed: 791 additions & 411 deletions

File tree

‎tests/integration_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,14 @@ func TestSendSMS_RateLimit(t *testing.T) {
200200
assert.GreaterOrEqual(t, gapMs, int64(5500), "rate limit gap should be >= 5500ms (6s minus timing tolerance), got %dms", gapMs)
201201

202202
fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT")
203-
pollMessageStatus(ctx, t, msgID1, "sent", 15*time.Second)
203+
pollMessageStatus(ctx, t, msgID1, "sent", 30*time.Second)
204204
fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "SENT")
205-
pollMessageStatus(ctx, t, msgID2, "sent", 15*time.Second)
205+
pollMessageStatus(ctx, t, msgID2, "sent", 30*time.Second)
206206
fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED")
207207
fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "DELIVERED")
208208

209-
msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 15*time.Second)
210-
msg2 := pollMessageStatus(ctx, t, msgID2, "delivered", 15*time.Second)
209+
msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 30*time.Second)
210+
msg2 := pollMessageStatus(ctx, t, msgID2, "delivered", 30*time.Second)
211211
assert.Equal(t, "delivered", msg1.Status)
212212
assert.Equal(t, "delivered", msg2.Status)
213213

‎web/app/pages/threads/[id]/index.vue‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ import type { Channel } from 'pusher-js'
2222
import { isValidPhoneNumber } from 'libphonenumber-js'
2323
import { storeToRefs } from 'pinia'
2424
import type { EntitiesMessage } from '~~/shared/types/api'
25+
import {
26+
isUrlNotificationToken,
27+
formatWhatsappText,
28+
} from '~/utils/whatsapp-format'
2529
2630
definePageMeta({
2731
middleware: ['auth'],
@@ -88,6 +92,11 @@ function isMissedCall(message: EntitiesMessage): boolean {
8892
return message.type === 'call/missed'
8993
}
9094
95+
function isWhatsappFormatted(message: EntitiesMessage): boolean {
96+
const phone = phonesStore.phones.find((x) => x.phone_number === message.owner)
97+
return isUrlNotificationToken(phone?.fcm_token)
98+
}
99+
91100
function isPending(message: EntitiesMessage): boolean {
92101
return ['sending', 'pending', 'scheduled'].includes(message.status)
93102
}
@@ -487,7 +496,13 @@ onBeforeUnmount(() => {
487496
class="text-break"
488497
style="white-space: pre-line"
489498
>
490-
<span v-if="!isMissedCall(message)">{{
499+
<span
500+
v-if="
501+
!isMissedCall(message) && isWhatsappFormatted(message)
502+
"
503+
v-html="formatWhatsappText(message.content)"
504+
/>
505+
<span v-else-if="!isMissedCall(message)">{{
491506
message.content
492507
}}</span>
493508
<span v-else class="text-medium-emphasis"
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import assert from 'node:assert/strict'
2+
import { test } from 'node:test'
3+
4+
import { formatWhatsappText, isUrlNotificationToken } from './whatsapp-format'
5+
6+
test('isUrlNotificationToken', async (t) => {
7+
await t.test('returns true for an https:// token', () => {
8+
assert.equal(isUrlNotificationToken('https://example.com/notify'), true)
9+
})
10+
11+
await t.test('returns false for an FCM token', () => {
12+
assert.equal(isUrlNotificationToken('some-fcm-token'), false)
13+
})
14+
15+
await t.test('returns false for http:// (non-https) tokens', () => {
16+
assert.equal(isUrlNotificationToken('http://example.com/notify'), false)
17+
})
18+
19+
await t.test('returns false for null/undefined/empty tokens', () => {
20+
assert.equal(isUrlNotificationToken(undefined), false)
21+
assert.equal(isUrlNotificationToken(null), false)
22+
assert.equal(isUrlNotificationToken(''), false)
23+
})
24+
25+
await t.test('trims whitespace and is case-insensitive', () => {
26+
assert.equal(isUrlNotificationToken(' HTTPS://example.com '), true)
27+
})
28+
})
29+
30+
test('formatWhatsappText - boundaries', async (t) => {
31+
await t.test('bolds text surrounded by whitespace', () => {
32+
assert.equal(
33+
formatWhatsappText('*boldme* test'),
34+
'<strong>boldme</strong> test',
35+
)
36+
})
37+
38+
await t.test('does not bold a marker with no boundary at the end', () => {
39+
assert.equal(
40+
formatWhatsappText('*donotboldme*no space or end of string'),
41+
'*donotboldme*no space or end of string',
42+
)
43+
})
44+
45+
await t.test('bolds text bounded by punctuation', () => {
46+
assert.equal(
47+
formatWhatsappText('your *IUC/Smartcard number*.'),
48+
'your <strong>IUC/Smartcard number</strong>.',
49+
)
50+
})
51+
52+
await t.test('does not format a marker embedded mid-word', () => {
53+
assert.equal(
54+
formatWhatsappText('(parenthesis*bold*test)'),
55+
'(parenthesis*bold*test)',
56+
)
57+
})
58+
59+
await t.test('formats italic and strikethrough', () => {
60+
assert.equal(formatWhatsappText('_it_ and ~s~'), '<em>it</em> and <s>s</s>')
61+
})
62+
})
63+
64+
test('formatWhatsappText - quotes', async (t) => {
65+
await t.test('wraps "> " prefixed lines in a quote span', () => {
66+
assert.equal(
67+
formatWhatsappText('> quoted text'),
68+
'<span class="d-block pl-2" style="border-left: 3px solid currentColor; opacity: 0.8;">quoted text</span>',
69+
)
70+
})
71+
72+
await t.test('leaves a bare ">" with no following space untouched', () => {
73+
assert.equal(formatWhatsappText('>threshold'), '&gt;threshold')
74+
})
75+
})
76+
77+
test('formatWhatsappText - code', async (t) => {
78+
await t.test('wraps inline code', () => {
79+
assert.equal(formatWhatsappText('`code`'), '<code>code</code>')
80+
})
81+
82+
await t.test('wraps multiline triple-backtick code blocks', () => {
83+
assert.equal(
84+
formatWhatsappText('```\nline one\nline two\n```'),
85+
'<code>\nline one\nline two\n</code>',
86+
)
87+
})
88+
89+
await t.test('does not reformat markdown characters inside code', () => {
90+
assert.equal(formatWhatsappText('`a *b* c`'), '<code>a *b* c</code>')
91+
})
92+
})
93+
94+
test('formatWhatsappText - links', async (t) => {
95+
await t.test('linkifies a raw URL', () => {
96+
assert.equal(
97+
formatWhatsappText('visit https://example.com today'),
98+
'visit <a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a> today',
99+
)
100+
})
101+
102+
await t.test('strips unbalanced trailing punctuation from a URL', () => {
103+
assert.equal(
104+
formatWhatsappText('Check this out (https://example.com).'),
105+
'Check this out (<a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a>).',
106+
)
107+
})
108+
109+
await t.test(
110+
'keeps a balanced trailing parenthesis as part of the URL',
111+
() => {
112+
assert.equal(
113+
formatWhatsappText('See https://example.com/page_(v2) for details.'),
114+
'See <a href="https://example.com/page_(v2)" target="_blank" rel="noopener noreferrer">https://example.com/page_(v2)</a> for details.',
115+
)
116+
},
117+
)
118+
119+
await t.test(
120+
'preserves a legitimate trailing marker character that is part of the URL',
121+
() => {
122+
assert.equal(
123+
formatWhatsappText('Download https://example.com/file_name_ now'),
124+
'Download <a href="https://example.com/file_name_" target="_blank" rel="noopener noreferrer">https://example.com/file_name_</a> now',
125+
)
126+
},
127+
)
128+
129+
await t.test('bolds a URL wrapped in asterisks', () => {
130+
assert.equal(
131+
formatWhatsappText('*https://example.com*'),
132+
'<strong><a href="https://example.com" target="_blank" rel="noopener noreferrer">https://example.com</a></strong>',
133+
)
134+
})
135+
})
136+
137+
test('formatWhatsappText - escaping and placeholders', async (t) => {
138+
await t.test('escapes HTML special characters', () => {
139+
assert.equal(
140+
formatWhatsappText('<script>alert(1)</script>'),
141+
'&lt;script&gt;alert(1)&lt;/script&gt;',
142+
)
143+
})
144+
145+
await t.test(
146+
'does not let literal placeholder-like text leak a link into the output',
147+
() => {
148+
const input = 'no links here, just text'
149+
assert.equal(formatWhatsappText(input), input)
150+
},
151+
)
152+
153+
await t.test('two calls do not interfere with each other', () => {
154+
const first = formatWhatsappText('visit https://a.example today')
155+
const second = formatWhatsappText('visit https://b.example today')
156+
assert.match(first, /href="https:\/\/a\.example"/)
157+
assert.match(second, /href="https:\/\/b\.example"/)
158+
assert.doesNotMatch(first, /https:\/\/b\.example/)
159+
})
160+
})

0 commit comments

Comments
 (0)