@joewinke/jatui 0.17.0 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ChipInput.svelte +1 -1
- package/dist/components/ChipInput.svelte.d.ts +1 -1
- package/dist/components/CommentThread.svelte +5 -32
- package/dist/components/FloatingActionBar.svelte +2 -2
- package/dist/components/FloatingActionBar.svelte.d.ts +2 -2
- package/dist/components/MapView.svelte +2 -42
- package/dist/components/messaging/MessageInput.svelte +3 -10
- package/dist/showcase/Dock.svelte +1 -1
- package/dist/showcase/Dock.svelte.d.ts +1 -1
- package/dist/utils/commentBody.d.ts +21 -0
- package/dist/utils/commentBody.js +45 -0
- package/dist/utils/mapInfo.d.ts +4 -0
- package/dist/utils/mapInfo.js +50 -0
- package/dist/utils/mentionParser.d.ts +17 -1
- package/dist/utils/mentionParser.js +38 -4
- package/package.json +1 -1
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
displayText: string;
|
|
27
27
|
/** Data attributes to set on the chip element */
|
|
28
28
|
dataAttrs: Record<string, string>;
|
|
29
|
-
/** Optional HTML content (overrides displayText) */
|
|
29
|
+
/** Optional HTML content (overrides displayText). TRUSTED markup, rendered unescaped — never pass user-authored data. */
|
|
30
30
|
html?: string;
|
|
31
31
|
/** If true, insert as plain text instead of a chip element */
|
|
32
32
|
insertAsText?: boolean;
|
|
@@ -20,7 +20,7 @@ export interface ChipInfo {
|
|
|
20
20
|
displayText: string;
|
|
21
21
|
/** Data attributes to set on the chip element */
|
|
22
22
|
dataAttrs: Record<string, string>;
|
|
23
|
-
/** Optional HTML content (overrides displayText) */
|
|
23
|
+
/** Optional HTML content (overrides displayText). TRUSTED markup, rendered unescaped — never pass user-authored data. */
|
|
24
24
|
html?: string;
|
|
25
25
|
/** If true, insert as plain text instead of a chip element */
|
|
26
26
|
insertAsText?: boolean;
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { untrack } from 'svelte'
|
|
22
22
|
import UserAvatar from './UserAvatar.svelte'
|
|
23
23
|
import { formatRelativeTime, formatFullDate } from '../utils/dateFormatters'
|
|
24
|
+
import { renderCommentBody } from '../utils/commentBody'
|
|
24
25
|
import type {
|
|
25
26
|
Comment,
|
|
26
27
|
CommentCallbacks,
|
|
@@ -130,36 +131,8 @@
|
|
|
130
131
|
return !!c.deleted_at || c.body === '[deleted]'
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// the UI highlights == what the server notifies. Email addresses
|
|
136
|
-
// (`foo@bar.com`) are not mentions because `@` follows a word char.
|
|
137
|
-
function escapeHtml(s: string): string {
|
|
138
|
-
return s
|
|
139
|
-
.replace(/&/g, '&')
|
|
140
|
-
.replace(/</g, '<')
|
|
141
|
-
.replace(/>/g, '>')
|
|
142
|
-
.replace(/"/g, '"')
|
|
143
|
-
.replace(/'/g, ''')
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function renderBody(body: string): string {
|
|
147
|
-
const re = /(^|[^\w@])@([a-zA-Z0-9_.-]+)/g
|
|
148
|
-
let out = ''
|
|
149
|
-
let last = 0
|
|
150
|
-
let m: RegExpExecArray | null
|
|
151
|
-
while ((m = re.exec(body)) !== null) {
|
|
152
|
-
const handle = m[2].replace(/[.\-_]+$/, '')
|
|
153
|
-
if (!handle) continue
|
|
154
|
-
out += escapeHtml(body.slice(last, m.index))
|
|
155
|
-
out += escapeHtml(m[1])
|
|
156
|
-
out += `<span class="text-primary font-medium">@${escapeHtml(handle)}</span>`
|
|
157
|
-
out += escapeHtml(m[2].slice(handle.length))
|
|
158
|
-
last = m.index + m[0].length
|
|
159
|
-
}
|
|
160
|
-
out += escapeHtml(body.slice(last))
|
|
161
|
-
return out.replace(/\n/g, '<br>')
|
|
162
|
-
}
|
|
134
|
+
// Body rendering (escape → @mention → <br>) lives in utils/commentBody so
|
|
135
|
+
// its XSS contract is unit-tested — the result goes into {@html}.
|
|
163
136
|
|
|
164
137
|
function findTop(id: string): Comment | undefined {
|
|
165
138
|
return items.find((c) => c.id === id)
|
|
@@ -705,10 +678,10 @@
|
|
|
705
678
|
{:else if isDeleted(c)}
|
|
706
679
|
<p class="text-base-content/40 mt-0.5 text-sm italic">[deleted]</p>
|
|
707
680
|
{:else}
|
|
708
|
-
<!-- Body is escaped in
|
|
681
|
+
<!-- Body is escaped in renderCommentBody() before interpolation. -->
|
|
709
682
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
710
683
|
<div class="mt-0.5 text-sm break-words whitespace-pre-wrap">
|
|
711
|
-
{@html
|
|
684
|
+
{@html renderCommentBody(c.body)}
|
|
712
685
|
</div>
|
|
713
686
|
{/if}
|
|
714
687
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
id: string;
|
|
21
21
|
/** Button label */
|
|
22
22
|
label: string;
|
|
23
|
-
/** SVG path string for a 24×24 icon (optional) */
|
|
23
|
+
/** SVG path string for a 24×24 icon (optional). TRUSTED markup, rendered unescaped — never pass user-authored data. */
|
|
24
24
|
icon?: string;
|
|
25
25
|
/** Color variant */
|
|
26
26
|
variant?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
id: string;
|
|
45
45
|
/** Button label */
|
|
46
46
|
label: string;
|
|
47
|
-
/** SVG path string for a 24×24 icon (optional) */
|
|
47
|
+
/** SVG path string for a 24×24 icon (optional). TRUSTED markup, rendered unescaped — never pass user-authored data. */
|
|
48
48
|
icon?: string;
|
|
49
49
|
/** Color variant */
|
|
50
50
|
variant?: 'default' | 'primary' | 'info' | 'purple';
|
|
@@ -3,7 +3,7 @@ export interface ActionButton {
|
|
|
3
3
|
id: string;
|
|
4
4
|
/** Button label */
|
|
5
5
|
label: string;
|
|
6
|
-
/** SVG path string for a 24×24 icon (optional) */
|
|
6
|
+
/** SVG path string for a 24×24 icon (optional). TRUSTED markup, rendered unescaped — never pass user-authored data. */
|
|
7
7
|
icon?: string;
|
|
8
8
|
/** Color variant */
|
|
9
9
|
variant?: 'default' | 'primary' | 'danger' | 'warning' | 'success';
|
|
@@ -25,7 +25,7 @@ export interface ActionDropdown {
|
|
|
25
25
|
id: string;
|
|
26
26
|
/** Button label */
|
|
27
27
|
label: string;
|
|
28
|
-
/** SVG path string for a 24×24 icon (optional) */
|
|
28
|
+
/** SVG path string for a 24×24 icon (optional). TRUSTED markup, rendered unescaped — never pass user-authored data. */
|
|
29
29
|
icon?: string;
|
|
30
30
|
/** Color variant */
|
|
31
31
|
variant?: 'default' | 'primary' | 'info' | 'purple';
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
import { loadGoogleMapsAPI, isGoogleMapsLoaded } from '../utils/googleMapsLoader';
|
|
24
24
|
import UserAvatar from './UserAvatar.svelte';
|
|
25
25
|
import type { MapJob, MapAgent } from '../types/maps';
|
|
26
|
+
// InfoWindow content is parsed as HTML — the builders escape every field (mos-qqyy6).
|
|
27
|
+
import { STATUS_COLORS, buildJobInfo, buildAgentInfo } from '../utils/mapInfo';
|
|
26
28
|
|
|
27
29
|
export type { MapJob, MapAgent };
|
|
28
30
|
|
|
@@ -47,15 +49,6 @@
|
|
|
47
49
|
onAgentClick
|
|
48
50
|
}: Props = $props();
|
|
49
51
|
|
|
50
|
-
// Job status → hex color (hardcoded by design — not themed, maps sit outside DaisyUI theming)
|
|
51
|
-
const STATUS_COLORS: Record<string, string> = {
|
|
52
|
-
pending: '#f59e0b',
|
|
53
|
-
scheduled: '#3b82f6',
|
|
54
|
-
in_progress: '#10b981',
|
|
55
|
-
completed: '#6b7280',
|
|
56
|
-
cancelled: '#ef4444'
|
|
57
|
-
};
|
|
58
|
-
|
|
59
52
|
let mapContainer: HTMLElement;
|
|
60
53
|
let map: google.maps.Map;
|
|
61
54
|
let jobMarkers: any[] = [];
|
|
@@ -234,39 +227,6 @@
|
|
|
234
227
|
});
|
|
235
228
|
}
|
|
236
229
|
|
|
237
|
-
function buildJobInfo(job: MapJob): string {
|
|
238
|
-
const statusBadge = `<span class="badge badge-sm" style="background:${STATUS_COLORS[job.status] ?? '#888'};color:#fff">${job.status.replace(/_/g, ' ')}</span>`;
|
|
239
|
-
const priority =
|
|
240
|
-
job.priority === 'urgent'
|
|
241
|
-
? `<span class="badge badge-error badge-sm">urgent</span>`
|
|
242
|
-
: '';
|
|
243
|
-
return `<div style="padding:8px;min-width:180px;font-family:sans-serif;font-size:13px">
|
|
244
|
-
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;font-weight:600">
|
|
245
|
-
${job.customer?.name ?? 'Unknown Customer'} ${statusBadge}
|
|
246
|
-
</div>
|
|
247
|
-
${job.type ? `<div><b>Service:</b> ${job.type.replace(/_/g, ' ')}</div>` : ''}
|
|
248
|
-
${job.customer?.phone ? `<div><b>Phone:</b> ${job.customer.phone}</div>` : ''}
|
|
249
|
-
${job.location?.address ? `<div><b>Address:</b> ${job.location.address}</div>` : ''}
|
|
250
|
-
${job.scheduled_at ? `<div><b>Scheduled:</b> ${new Date(job.scheduled_at).toLocaleString()}</div>` : ''}
|
|
251
|
-
${priority}
|
|
252
|
-
${job.description ? `<div style="margin-top:4px"><b>Notes:</b> ${job.description}</div>` : ''}
|
|
253
|
-
</div>`;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
function buildAgentInfo(agent: MapAgent): string {
|
|
257
|
-
const gpsAge = agent.location?.age_seconds ?? 0;
|
|
258
|
-
const gpsMins = Math.floor(gpsAge / 60);
|
|
259
|
-
const hasGps = !!(agent.location?.latitude && agent.location?.longitude);
|
|
260
|
-
return `<div style="padding:8px;min-width:180px;font-family:sans-serif;font-size:13px">
|
|
261
|
-
<div style="font-weight:600;margin-bottom:6px">${agent.name}</div>
|
|
262
|
-
${agent.status ? `<div><b>Status:</b> ${agent.status}</div>` : ''}
|
|
263
|
-
${agent.capacity ? `<div><b>Capacity:</b> ${agent.capacity}</div>` : ''}
|
|
264
|
-
${hasGps ? `<div><b>GPS:</b> <span style="color:${gpsMins < 5 ? '#16a34a' : '#d97706'}">${gpsMins < 1 ? 'Live' : `${gpsMins}m ago`}</span></div>` : ''}
|
|
265
|
-
${agent.specializations?.length ? `<div><b>Specializations:</b> ${agent.specializations.join(', ')}</div>` : ''}
|
|
266
|
-
${agent.vehicle?.name ? `<div><b>Vehicle:</b> ${agent.vehicle.name}</div>` : ''}
|
|
267
|
-
</div>`;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
230
|
function fitMapToMarkers() {
|
|
271
231
|
if (!map) return;
|
|
272
232
|
const all = [...jobMarkers, ...agentMarkers];
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
findPartialMention,
|
|
13
13
|
createMentionString,
|
|
14
14
|
replaceMentionInContent,
|
|
15
|
-
|
|
15
|
+
renderMentionsForComposer,
|
|
16
16
|
extractPlainText,
|
|
17
17
|
parseMentions,
|
|
18
18
|
} from '../../utils/mentionParser'
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
const hasMentions = inputText.includes('[@')
|
|
112
112
|
if (hasMentions) {
|
|
113
113
|
isInternalUpdate = true
|
|
114
|
-
const renderedHTML =
|
|
114
|
+
const renderedHTML = renderMentionsForComposer(inputText)
|
|
115
115
|
if (contentEditableDiv.innerHTML !== renderedHTML) {
|
|
116
116
|
const selection = window.getSelection()
|
|
117
117
|
const cursorOffset = selection?.rangeCount ? getAbsoluteCursorPosition(selection.getRangeAt(0)) : 0
|
|
@@ -187,13 +187,6 @@
|
|
|
187
187
|
}
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
function renderMentionsWithDegradedStyling(content: string): string {
|
|
191
|
-
let result = renderMentionsAsHTML(content)
|
|
192
|
-
const degradedMentionRegex = /(?<!>)[@$#!~>/+]\w+(?![^<]*<\/a>)/g
|
|
193
|
-
result = result.replace(degradedMentionRegex, (match) => `<span class="degraded-mention">${match}</span>`)
|
|
194
|
-
return result
|
|
195
|
-
}
|
|
196
|
-
|
|
197
190
|
function updateAutocompletePosition() {
|
|
198
191
|
if (!contentEditableDiv) return
|
|
199
192
|
const selection = window.getSelection()
|
|
@@ -222,7 +215,7 @@
|
|
|
222
215
|
isInternalUpdate = true
|
|
223
216
|
inputText = newContent
|
|
224
217
|
if (contentEditableDiv) {
|
|
225
|
-
contentEditableDiv.innerHTML =
|
|
218
|
+
contentEditableDiv.innerHTML = renderMentionsForComposer(inputText)
|
|
226
219
|
setTimeout(() => {
|
|
227
220
|
setContentEditableCursor(newCursorPosition)
|
|
228
221
|
contentEditableDiv.focus()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment body rendering for <CommentThread> — escape → @mention highlight →
|
|
3
|
+
* newlines. Extracted from the component (mos-3xvn1) so the XSS contract is
|
|
4
|
+
* unit-tested: the result goes into `{@html}`, and bodies are user-authored
|
|
5
|
+
* across teams, users and portal clients.
|
|
6
|
+
*
|
|
7
|
+
* Bodies are PLAIN TEXT, never HTML. Every character of the body is escaped;
|
|
8
|
+
* the only markup this emits is a fixed `<span>` around a mention handle
|
|
9
|
+
* (itself restricted to [a-zA-Z0-9_.-] and escaped anyway) and `<br>`. That
|
|
10
|
+
* makes it safe by construction without a sanitizer: there is no input for
|
|
11
|
+
* which a caller-supplied tag or attribute reaches the output. Do not add
|
|
12
|
+
* markdown or HTML pass-through here without routing it through a vetted
|
|
13
|
+
* sanitizer (DOMPurify) first.
|
|
14
|
+
*/
|
|
15
|
+
export declare function escapeHtml(s: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Mention rule mirrors the server's parseMentions regex exactly, so what the
|
|
18
|
+
* UI highlights == what the server notifies. Email addresses (`foo@bar.com`)
|
|
19
|
+
* are not mentions because `@` follows a word char.
|
|
20
|
+
*/
|
|
21
|
+
export declare function renderCommentBody(body: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comment body rendering for <CommentThread> — escape → @mention highlight →
|
|
3
|
+
* newlines. Extracted from the component (mos-3xvn1) so the XSS contract is
|
|
4
|
+
* unit-tested: the result goes into `{@html}`, and bodies are user-authored
|
|
5
|
+
* across teams, users and portal clients.
|
|
6
|
+
*
|
|
7
|
+
* Bodies are PLAIN TEXT, never HTML. Every character of the body is escaped;
|
|
8
|
+
* the only markup this emits is a fixed `<span>` around a mention handle
|
|
9
|
+
* (itself restricted to [a-zA-Z0-9_.-] and escaped anyway) and `<br>`. That
|
|
10
|
+
* makes it safe by construction without a sanitizer: there is no input for
|
|
11
|
+
* which a caller-supplied tag or attribute reaches the output. Do not add
|
|
12
|
+
* markdown or HTML pass-through here without routing it through a vetted
|
|
13
|
+
* sanitizer (DOMPurify) first.
|
|
14
|
+
*/
|
|
15
|
+
export function escapeHtml(s) {
|
|
16
|
+
return s
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''');
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Mention rule mirrors the server's parseMentions regex exactly, so what the
|
|
25
|
+
* UI highlights == what the server notifies. Email addresses (`foo@bar.com`)
|
|
26
|
+
* are not mentions because `@` follows a word char.
|
|
27
|
+
*/
|
|
28
|
+
export function renderCommentBody(body) {
|
|
29
|
+
const re = /(^|[^\w@])@([a-zA-Z0-9_.-]+)/g;
|
|
30
|
+
let out = '';
|
|
31
|
+
let last = 0;
|
|
32
|
+
let m;
|
|
33
|
+
while ((m = re.exec(body)) !== null) {
|
|
34
|
+
const handle = m[2].replace(/[.\-_]+$/, '');
|
|
35
|
+
if (!handle)
|
|
36
|
+
continue;
|
|
37
|
+
out += escapeHtml(body.slice(last, m.index));
|
|
38
|
+
out += escapeHtml(m[1]);
|
|
39
|
+
out += `<span class="text-primary font-medium">@${escapeHtml(handle)}</span>`;
|
|
40
|
+
out += escapeHtml(m[2].slice(handle.length));
|
|
41
|
+
last = m.index + m[0].length;
|
|
42
|
+
}
|
|
43
|
+
out += escapeHtml(body.slice(last));
|
|
44
|
+
return out.replace(/\n/g, '<br>');
|
|
45
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InfoWindow content for <MapView> (mos-qqyy6). Google Maps parses a string
|
|
3
|
+
* `content` as HTML, and the fields here are customer/job/agent data — often
|
|
4
|
+
* typed by a customer on a public booking form — so every interpolated value
|
|
5
|
+
* is escaped. Extracted from the component so that contract is unit-tested.
|
|
6
|
+
*
|
|
7
|
+
* The only markup emitted is the fixed layout below; colors come from a fixed
|
|
8
|
+
* status map, never from the data.
|
|
9
|
+
*/
|
|
10
|
+
import { escapeHtml } from './commentBody';
|
|
11
|
+
// Job status → hex color (hardcoded by design — not themed, maps sit outside DaisyUI theming)
|
|
12
|
+
export const STATUS_COLORS = {
|
|
13
|
+
pending: '#f59e0b',
|
|
14
|
+
scheduled: '#3b82f6',
|
|
15
|
+
in_progress: '#10b981',
|
|
16
|
+
completed: '#6b7280',
|
|
17
|
+
cancelled: '#ef4444'
|
|
18
|
+
};
|
|
19
|
+
const e = (v) => escapeHtml(String(v));
|
|
20
|
+
export function buildJobInfo(job) {
|
|
21
|
+
const color = Object.prototype.hasOwnProperty.call(STATUS_COLORS, job.status) ? STATUS_COLORS[job.status] : '#888';
|
|
22
|
+
const statusBadge = `<span class="badge badge-sm" style="background:${color};color:#fff">${e(job.status.replace(/_/g, ' '))}</span>`;
|
|
23
|
+
const priority = job.priority === 'urgent'
|
|
24
|
+
? `<span class="badge badge-error badge-sm">urgent</span>`
|
|
25
|
+
: '';
|
|
26
|
+
return `<div style="padding:8px;min-width:180px;font-family:sans-serif;font-size:13px">
|
|
27
|
+
<div style="display:flex;align-items:center;gap:6px;margin-bottom:6px;font-weight:600">
|
|
28
|
+
${e(job.customer?.name ?? 'Unknown Customer')} ${statusBadge}
|
|
29
|
+
</div>
|
|
30
|
+
${job.type ? `<div><b>Service:</b> ${e(job.type.replace(/_/g, ' '))}</div>` : ''}
|
|
31
|
+
${job.customer?.phone ? `<div><b>Phone:</b> ${e(job.customer.phone)}</div>` : ''}
|
|
32
|
+
${job.location?.address ? `<div><b>Address:</b> ${e(job.location.address)}</div>` : ''}
|
|
33
|
+
${job.scheduled_at ? `<div><b>Scheduled:</b> ${e(new Date(job.scheduled_at).toLocaleString())}</div>` : ''}
|
|
34
|
+
${priority}
|
|
35
|
+
${job.description ? `<div style="margin-top:4px"><b>Notes:</b> ${e(job.description)}</div>` : ''}
|
|
36
|
+
</div>`;
|
|
37
|
+
}
|
|
38
|
+
export function buildAgentInfo(agent) {
|
|
39
|
+
const gpsAge = agent.location?.age_seconds ?? 0;
|
|
40
|
+
const gpsMins = Math.floor(gpsAge / 60);
|
|
41
|
+
const hasGps = !!(agent.location?.latitude && agent.location?.longitude);
|
|
42
|
+
return `<div style="padding:8px;min-width:180px;font-family:sans-serif;font-size:13px">
|
|
43
|
+
<div style="font-weight:600;margin-bottom:6px">${e(agent.name)}</div>
|
|
44
|
+
${agent.status ? `<div><b>Status:</b> ${e(agent.status)}</div>` : ''}
|
|
45
|
+
${agent.capacity ? `<div><b>Capacity:</b> ${e(agent.capacity)}</div>` : ''}
|
|
46
|
+
${hasGps ? `<div><b>GPS:</b> <span style="color:${gpsMins < 5 ? '#16a34a' : '#d97706'}">${gpsMins < 1 ? 'Live' : `${gpsMins}m ago`}</span></div>` : ''}
|
|
47
|
+
${agent.specializations?.length ? `<div><b>Specializations:</b> ${e(agent.specializations.join(', '))}</div>` : ''}
|
|
48
|
+
${agent.vehicle?.name ? `<div><b>Vehicle:</b> ${e(agent.vehicle.name)}</div>` : ''}
|
|
49
|
+
</div>`;
|
|
50
|
+
}
|
|
@@ -27,9 +27,25 @@ export declare function setMentionUrlGenerator(generator: MentionUrlGenerator):
|
|
|
27
27
|
*/
|
|
28
28
|
export declare function parseMentions(content: string, urlGenerator?: MentionUrlGenerator): MentionMatch[];
|
|
29
29
|
/**
|
|
30
|
-
* Convert message content with mentions to HTML
|
|
30
|
+
* Convert message content with mentions to HTML.
|
|
31
|
+
*
|
|
32
|
+
* The result is meant for `{@html}` and message content is user-authored,
|
|
33
|
+
* so every interpolated value (url, type, id, display name) is escaped and
|
|
34
|
+
* the href is scheme-checked — see mos-3xvn1.
|
|
31
35
|
*/
|
|
32
36
|
export declare function renderMentionsAsHTML(content: string, urlGenerator?: MentionUrlGenerator): string;
|
|
37
|
+
/**
|
|
38
|
+
* The <MessageInput> composer's contenteditable HTML: renderMentionsAsHTML,
|
|
39
|
+
* plus un-resolved `@name`/`#tag`-style tokens wrapped in a fixed
|
|
40
|
+
* `.degraded-mention` span. The wrap runs over already-escaped HTML, so it
|
|
41
|
+
* must skip anything inside a tag (`(?<![<>])` + the `</a>` lookahead) and
|
|
42
|
+
* inside a character reference (`(?<!&)`). Before mos-qqyy6 it did neither:
|
|
43
|
+
* every resolved mention's `</a>` became `<<span>/a</span>>`, and `don't`
|
|
44
|
+
* (escaped to `don't`) became `&<span>#039</span>;`, so the composer
|
|
45
|
+
* showed raw markup. The wrapped token is `[@$#!~>/+]\w+`,
|
|
46
|
+
* so it can carry no markup of its own.
|
|
47
|
+
*/
|
|
48
|
+
export declare function renderMentionsForComposer(content: string): string;
|
|
33
49
|
/**
|
|
34
50
|
* Create a mention string for insertion into message input
|
|
35
51
|
*/
|
|
@@ -8,9 +8,14 @@
|
|
|
8
8
|
* routing via the `urlGenerator` parameter or by setting a default.
|
|
9
9
|
*/
|
|
10
10
|
// Regex to match mention pattern: [@Display Name](type:id)
|
|
11
|
-
|
|
11
|
+
//
|
|
12
|
+
// `type` is an identifier token, never free text (mos-3xvn1): it lands in
|
|
13
|
+
// class/data attributes, and the old `[^:]+` let a typed
|
|
14
|
+
// `[@x](a"><img onerror=…>:y)` break out of the attribute. Rendering escapes
|
|
15
|
+
// every interpolated value as well — the token is the first wall only.
|
|
16
|
+
const MENTION_REGEX = /\[@([^\]]+)\]\(([A-Za-z0-9_-]+):([^)]+)\)/g;
|
|
12
17
|
// Default URL generator (apps can override)
|
|
13
|
-
let defaultUrlGenerator = (type, id) => `#${type}/${id}`;
|
|
18
|
+
let defaultUrlGenerator = (type, id) => `#${type}/${encodeURIComponent(id)}`;
|
|
14
19
|
/**
|
|
15
20
|
* Set the default URL generator for mentions.
|
|
16
21
|
* Call this once at app startup to configure mention link routing.
|
|
@@ -41,7 +46,22 @@ export function parseMentions(content, urlGenerator) {
|
|
|
41
46
|
return mentions;
|
|
42
47
|
}
|
|
43
48
|
/**
|
|
44
|
-
*
|
|
49
|
+
* A URL generator receives the sender-typed id, so its output is untrusted:
|
|
50
|
+
* a generator that echoes the id (`(t, id) => id`) would hand a sender a
|
|
51
|
+
* `javascript:` href. Script-capable schemes collapse to `#`. Browsers
|
|
52
|
+
* ignore ASCII whitespace/control chars inside a scheme, so they are
|
|
53
|
+
* stripped before the check.
|
|
54
|
+
*/
|
|
55
|
+
function safeHref(url) {
|
|
56
|
+
const scheme = url.replace(/[\u0000-\u0020]/g, '').toLowerCase();
|
|
57
|
+
return /^(javascript|data|vbscript):/.test(scheme) ? '#' : url;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Convert message content with mentions to HTML.
|
|
61
|
+
*
|
|
62
|
+
* The result is meant for `{@html}` and message content is user-authored,
|
|
63
|
+
* so every interpolated value (url, type, id, display name) is escaped and
|
|
64
|
+
* the href is scheme-checked — see mos-3xvn1.
|
|
45
65
|
*/
|
|
46
66
|
export function renderMentionsAsHTML(content, urlGenerator) {
|
|
47
67
|
const mentions = parseMentions(content, urlGenerator);
|
|
@@ -52,12 +72,26 @@ export function renderMentionsAsHTML(content, urlGenerator) {
|
|
|
52
72
|
let lastIndex = 0;
|
|
53
73
|
mentions.forEach((mention) => {
|
|
54
74
|
result += escapeHTML(content.substring(lastIndex, mention.startIndex));
|
|
55
|
-
result += `<a href="${mention.url}" class="mention-link mention-${mention.type}" data-mention-type="${mention.type}" data-mention-id="${mention.id}">@${escapeHTML(mention.displayName)}</a>`;
|
|
75
|
+
result += `<a href="${escapeHTML(safeHref(mention.url))}" class="mention-link mention-${escapeHTML(mention.type)}" data-mention-type="${escapeHTML(mention.type)}" data-mention-id="${escapeHTML(mention.id)}">@${escapeHTML(mention.displayName)}</a>`;
|
|
56
76
|
lastIndex = mention.endIndex;
|
|
57
77
|
});
|
|
58
78
|
result += escapeHTML(content.substring(lastIndex));
|
|
59
79
|
return result;
|
|
60
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* The <MessageInput> composer's contenteditable HTML: renderMentionsAsHTML,
|
|
83
|
+
* plus un-resolved `@name`/`#tag`-style tokens wrapped in a fixed
|
|
84
|
+
* `.degraded-mention` span. The wrap runs over already-escaped HTML, so it
|
|
85
|
+
* must skip anything inside a tag (`(?<![<>])` + the `</a>` lookahead) and
|
|
86
|
+
* inside a character reference (`(?<!&)`). Before mos-qqyy6 it did neither:
|
|
87
|
+
* every resolved mention's `</a>` became `<<span>/a</span>>`, and `don't`
|
|
88
|
+
* (escaped to `don't`) became `&<span>#039</span>;`, so the composer
|
|
89
|
+
* showed raw markup. The wrapped token is `[@$#!~>/+]\w+`,
|
|
90
|
+
* so it can carry no markup of its own.
|
|
91
|
+
*/
|
|
92
|
+
export function renderMentionsForComposer(content) {
|
|
93
|
+
return renderMentionsAsHTML(content).replace(/(?<![<>&])[@$#!~>/+]\w+(?![^<]*<\/a>)/g, (match) => `<span class="degraded-mention">${match}</span>`);
|
|
94
|
+
}
|
|
61
95
|
/**
|
|
62
96
|
* Create a mention string for insertion into message input
|
|
63
97
|
*/
|