Skip to content

Commit 8655261

Browse files
authored
fix: stip markdown in tickets (#3115)
* fix: stip markdown in tickets * changeset * fixes * fix with read * fix
1 parent 9dbc05e commit 8655261

File tree

5 files changed

+353
-3
lines changed

5 files changed

+353
-3
lines changed

.changeset/ninety-experts-own.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': minor
3+
---
4+
5+
Strip markdown in tickets list
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
import {
2+
stripMarkdown,
3+
truncateText,
4+
formatRelativeTime,
5+
} from '../../../extensions/conversations/external/components/utils'
6+
7+
describe('conversations utils', () => {
8+
describe('stripMarkdown', () => {
9+
it('should return empty string for undefined input', () => {
10+
expect(stripMarkdown(undefined)).toBe('')
11+
})
12+
13+
it('should return empty string for empty string input', () => {
14+
expect(stripMarkdown('')).toBe('')
15+
})
16+
17+
it('should return plain text unchanged', () => {
18+
expect(stripMarkdown('Hello world')).toBe('Hello world')
19+
})
20+
21+
describe('headers', () => {
22+
it('should strip h1 headers', () => {
23+
expect(stripMarkdown('# Header 1')).toBe('Header 1')
24+
})
25+
26+
it('should strip h2 headers', () => {
27+
expect(stripMarkdown('## Header 2')).toBe('Header 2')
28+
})
29+
30+
it('should strip h6 headers', () => {
31+
expect(stripMarkdown('###### Header 6')).toBe('Header 6')
32+
})
33+
34+
it('should strip headers with text after', () => {
35+
expect(stripMarkdown('# Title\nSome content')).toBe('Title\nSome content')
36+
})
37+
})
38+
39+
describe('bold and italic', () => {
40+
it('should strip bold with asterisks', () => {
41+
expect(stripMarkdown('This is **bold** text')).toBe('This is bold text')
42+
})
43+
44+
it('should strip bold with underscores', () => {
45+
expect(stripMarkdown('This is __bold__ text')).toBe('This is bold text')
46+
})
47+
48+
it('should strip italic with asterisks', () => {
49+
expect(stripMarkdown('This is *italic* text')).toBe('This is italic text')
50+
})
51+
52+
it('should strip italic with underscores', () => {
53+
expect(stripMarkdown('This is _italic_ text')).toBe('This is italic text')
54+
})
55+
56+
it('should strip nested bold and italic', () => {
57+
expect(stripMarkdown('This is ***bold and italic*** text')).toBe('This is bold and italic text')
58+
})
59+
})
60+
61+
describe('strikethrough', () => {
62+
it('should strip strikethrough', () => {
63+
expect(stripMarkdown('This is ~~deleted~~ text')).toBe('This is deleted text')
64+
})
65+
})
66+
67+
describe('links', () => {
68+
it('should convert links to just text', () => {
69+
expect(stripMarkdown('Check [this link](https://example.com)')).toBe('Check this link')
70+
})
71+
72+
it('should handle links with complex URLs', () => {
73+
expect(stripMarkdown('[Click here](https://example.com/path?query=1&foo=bar)')).toBe('Click here')
74+
})
75+
76+
it('should handle multiple links', () => {
77+
expect(stripMarkdown('[Link 1](url1) and [Link 2](url2)')).toBe('Link 1 and Link 2')
78+
})
79+
})
80+
81+
describe('images', () => {
82+
it('should remove images completely', () => {
83+
expect(stripMarkdown('Text ![alt text](image.png) more text')).toBe('Text more text')
84+
})
85+
86+
it('should remove images with empty alt text', () => {
87+
expect(stripMarkdown('![](image.png)')).toBe('')
88+
})
89+
})
90+
91+
describe('code', () => {
92+
it('should strip inline code backticks', () => {
93+
expect(stripMarkdown('Use `console.log()` to debug')).toBe('Use console.log() to debug')
94+
})
95+
96+
it('should remove code blocks', () => {
97+
expect(stripMarkdown('```javascript\nconst x = 1;\n```')).toBe('')
98+
})
99+
100+
it('should remove code blocks with content around', () => {
101+
expect(stripMarkdown('Before\n```\ncode\n```\nAfter')).toBe('Before\nAfter')
102+
})
103+
})
104+
105+
describe('blockquotes', () => {
106+
it('should strip blockquote markers', () => {
107+
expect(stripMarkdown('> This is a quote')).toBe('This is a quote')
108+
})
109+
110+
it('should handle nested blockquotes', () => {
111+
// Each > at start of line is stripped, so "> >" becomes " " (space remains from second >)
112+
expect(stripMarkdown('> First level\n> > Nested')).toBe('First level\n Nested')
113+
})
114+
})
115+
116+
describe('horizontal rules', () => {
117+
it('should remove horizontal rules with dashes', () => {
118+
expect(stripMarkdown('Above\n---\nBelow')).toBe('Above\nBelow')
119+
})
120+
121+
it('should remove horizontal rules with asterisks', () => {
122+
expect(stripMarkdown('Above\n***\nBelow')).toBe('Above\nBelow')
123+
})
124+
125+
it('should remove horizontal rules with underscores', () => {
126+
expect(stripMarkdown('Above\n___\nBelow')).toBe('Above\nBelow')
127+
})
128+
})
129+
130+
describe('lists', () => {
131+
it('should strip unordered list markers with dash', () => {
132+
expect(stripMarkdown('- Item 1\n- Item 2')).toBe('Item 1\nItem 2')
133+
})
134+
135+
it('should strip unordered list markers with asterisk', () => {
136+
expect(stripMarkdown('* Item 1\n* Item 2')).toBe('Item 1\nItem 2')
137+
})
138+
139+
it('should strip unordered list markers with plus', () => {
140+
expect(stripMarkdown('+ Item 1\n+ Item 2')).toBe('Item 1\nItem 2')
141+
})
142+
143+
it('should strip ordered list markers', () => {
144+
expect(stripMarkdown('1. First\n2. Second\n3. Third')).toBe('First\nSecond\nThird')
145+
})
146+
147+
it('should handle indented list items', () => {
148+
expect(stripMarkdown(' - Nested item')).toBe('Nested item')
149+
})
150+
})
151+
152+
describe('HTML tags', () => {
153+
it('should remove HTML tags', () => {
154+
expect(stripMarkdown('Text with <strong>HTML</strong> tags')).toBe('Text with HTML tags')
155+
})
156+
157+
it('should remove self-closing tags', () => {
158+
expect(stripMarkdown('Line<br/>break')).toBe('Linebreak')
159+
})
160+
161+
it('should remove tags with attributes', () => {
162+
expect(stripMarkdown('<a href="url">Link</a>')).toBe('Link')
163+
})
164+
165+
it('should remove incomplete/partial tags for security', () => {
166+
expect(stripMarkdown('<script')).toBe('script')
167+
expect(stripMarkdown('text<script>alert(1)')).toBe('textalert(1)')
168+
})
169+
170+
it('should remove lone angle brackets', () => {
171+
// '< b >' is treated as a tag and removed entirely
172+
expect(stripMarkdown('a < b > c')).toBe('a c')
173+
// Lone < without matching > is stripped
174+
expect(stripMarkdown('a < b')).toBe('a b')
175+
// Lone > without matching < is stripped
176+
expect(stripMarkdown('a > b')).toBe('a b')
177+
})
178+
})
179+
180+
describe('whitespace handling', () => {
181+
it('should collapse multiple newlines', () => {
182+
expect(stripMarkdown('Line 1\n\n\n\nLine 2')).toBe('Line 1\nLine 2')
183+
})
184+
185+
it('should trim leading and trailing whitespace', () => {
186+
expect(stripMarkdown(' text with spaces ')).toBe('text with spaces')
187+
})
188+
})
189+
190+
describe('combined markdown', () => {
191+
it('should handle complex markdown', () => {
192+
const markdown = `# Welcome
193+
194+
This is **bold** and *italic* text with a [link](https://example.com).
195+
196+
- Item 1
197+
- Item 2
198+
199+
\`\`\`
200+
code block
201+
\`\`\`
202+
203+
> A quote
204+
205+
Done!`
206+
207+
const expected = `Welcome
208+
This is bold and italic text with a link.
209+
Item 1
210+
Item 2
211+
A quote
212+
Done!`
213+
214+
expect(stripMarkdown(markdown)).toBe(expected)
215+
})
216+
217+
it('should handle message-like content', () => {
218+
const markdown = 'Hey! Check out this **new feature** at [our docs](https://docs.example.com) 🎉'
219+
expect(stripMarkdown(markdown)).toBe('Hey! Check out this new feature at our docs 🎉')
220+
})
221+
})
222+
})
223+
224+
describe('truncateText', () => {
225+
it('should return "No messages yet" for undefined input', () => {
226+
expect(truncateText(undefined, 60)).toBe('No messages yet')
227+
})
228+
229+
it('should return "No messages yet" for empty string', () => {
230+
expect(truncateText('', 60)).toBe('No messages yet')
231+
})
232+
233+
it('should return text unchanged if shorter than max length', () => {
234+
expect(truncateText('Short text', 60)).toBe('Short text')
235+
})
236+
237+
it('should return text unchanged if equal to max length', () => {
238+
const text = 'a'.repeat(60)
239+
expect(truncateText(text, 60)).toBe(text)
240+
})
241+
242+
it('should truncate text longer than max length with ellipsis', () => {
243+
const text = 'a'.repeat(70)
244+
const result = truncateText(text, 60)
245+
expect(result.length).toBe(60)
246+
expect(result.endsWith('...')).toBe(true)
247+
})
248+
})
249+
250+
describe('formatRelativeTime', () => {
251+
beforeEach(() => {
252+
jest.useFakeTimers()
253+
jest.setSystemTime(new Date('2024-01-15T12:00:00Z'))
254+
})
255+
256+
afterEach(() => {
257+
jest.useRealTimers()
258+
})
259+
260+
it('should return empty string for undefined input', () => {
261+
expect(formatRelativeTime(undefined)).toBe('')
262+
})
263+
264+
it('should return "Just now" for times less than a minute ago', () => {
265+
const now = new Date('2024-01-15T11:59:30Z').toISOString()
266+
expect(formatRelativeTime(now)).toBe('Just now')
267+
})
268+
269+
it('should return minutes ago for times less than an hour ago', () => {
270+
const thirtyMinsAgo = new Date('2024-01-15T11:30:00Z').toISOString()
271+
expect(formatRelativeTime(thirtyMinsAgo)).toBe('30m ago')
272+
})
273+
274+
it('should return hours ago for times less than a day ago', () => {
275+
const fiveHoursAgo = new Date('2024-01-15T07:00:00Z').toISOString()
276+
expect(formatRelativeTime(fiveHoursAgo)).toBe('5h ago')
277+
})
278+
279+
it('should return "Yesterday" for times one day ago', () => {
280+
const yesterday = new Date('2024-01-14T12:00:00Z').toISOString()
281+
expect(formatRelativeTime(yesterday)).toBe('Yesterday')
282+
})
283+
284+
it('should return days ago for times less than a week ago', () => {
285+
const threeDaysAgo = new Date('2024-01-12T12:00:00Z').toISOString()
286+
expect(formatRelativeTime(threeDaysAgo)).toBe('3d ago')
287+
})
288+
289+
it('should return formatted date for times a week or more ago', () => {
290+
const twoWeeksAgo = new Date('2024-01-01T12:00:00Z').toISOString()
291+
const result = formatRelativeTime(twoWeeksAgo)
292+
expect(result).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/)
293+
})
294+
})
295+
})

packages/browser/src/extensions/conversations/external/components/TicketListItem.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { h, FunctionComponent } from 'preact'
33
import { Ticket, TicketStatus } from '../../../../posthog-conversations-types'
44
import { getStyles } from './styles'
5-
import { formatRelativeTime, truncateText } from './utils'
5+
import { formatRelativeTime, truncateText, stripMarkdown } from './utils'
66

77
interface TicketListItemProps {
88
ticket: Ticket
@@ -54,7 +54,7 @@ export const TicketListItem: FunctionComponent<TicketListItemProps> = ({ ticket,
5454
<div style={styles.ticketItemContent}>
5555
<div style={styles.ticketItemHeader}>
5656
<span style={hasUnread ? styles.ticketPreviewUnread : styles.ticketPreview}>
57-
{truncateText(ticket.last_message, 60)}
57+
{truncateText(stripMarkdown(ticket.last_message), 60)}
5858
</span>
5959
{hasUnread && <span style={styles.ticketUnreadBadge}>{ticket.unread_count}</span>}
6060
</div>

packages/browser/src/extensions/conversations/external/components/TicketListView.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,12 @@ export const TicketListView: FunctionComponent<TicketListViewProps> = ({
8484
return dateB - dateA // Descending order (newest first)
8585
})
8686
.map((ticket) => (
87-
<TicketListItem key={ticket.id} ticket={ticket} styles={styles} onClick={onSelectTicket} />
87+
<TicketListItem
88+
key={`${ticket.id}-${ticket.last_message_at || ticket.created_at}-${ticket.unread_count}`}
89+
ticket={ticket}
90+
styles={styles}
91+
onClick={onSelectTicket}
92+
/>
8893
))}
8994
</div>
9095

packages/browser/src/extensions/conversations/external/components/utils.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,48 @@ export function truncateText(text: string | undefined, maxLength: number): strin
4040
}
4141
return text.substring(0, maxLength - 3) + '...'
4242
}
43+
44+
/**
45+
* Strip markdown formatting from text for plain text display
46+
* Lightweight regex-based approach without external dependencies
47+
*/
48+
export function stripMarkdown(text: string | undefined): string {
49+
if (!text) {
50+
return ''
51+
}
52+
53+
return (
54+
text
55+
// Remove code blocks first (before other processing)
56+
.replace(/```[\s\S]*?```/g, '')
57+
// Remove inline code
58+
.replace(/`([^`]+)`/g, '$1')
59+
// Remove images
60+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
61+
// Convert links to just text
62+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
63+
// Remove headers
64+
.replace(/^#{1,6}\s+/gm, '')
65+
// Remove blockquotes
66+
.replace(/^>\s*/gm, '')
67+
// Remove horizontal rules (must be before list markers to avoid conflicts)
68+
.replace(/^[-*_]{3,}\s*$/gm, '')
69+
// Remove list markers (must be before bold/italic to avoid conflicts with *)
70+
.replace(/^[\s]*[-*+]\s+/gm, '')
71+
.replace(/^[\s]*\d+\.\s+/gm, '')
72+
// Remove bold/italic (order matters: ** before *)
73+
.replace(/\*\*([^*]+)\*\*/g, '$1')
74+
.replace(/\*([^*]+)\*/g, '$1')
75+
.replace(/__([^_]+)__/g, '$1')
76+
.replace(/_([^_]+)_/g, '$1')
77+
// Remove strikethrough
78+
.replace(/~~([^~]+)~~/g, '$1')
79+
// Remove HTML tags entirely, then strip any remaining angle brackets for security
80+
.replace(/<[^>]*>/g, '')
81+
.replace(/[<>]/g, '')
82+
// Collapse multiple newlines
83+
.replace(/\n{2,}/g, '\n')
84+
// Trim whitespace
85+
.trim()
86+
)
87+
}

0 commit comments

Comments
 (0)