-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontent.js
More file actions
292 lines (246 loc) · 8.97 KB
/
Copy pathcontent.js
File metadata and controls
292 lines (246 loc) · 8.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
// Validate that URL is from trusted Thread domain
// Accepts either a URL string or an element with src attribute
function isValidThreadURL(input, baseUrl = null) {
try {
let url;
if (typeof input === 'string') {
url = baseUrl ? new URL(input, baseUrl) : new URL(input);
} else if (input && input.getAttribute) {
const src = input.getAttribute('src');
if (!src) return false;
url = new URL(src, baseUrl || location.href);
} else {
return false;
}
return (
url.protocol === 'https:' &&
url.hostname === 'inbox.getthread.com' &&
url.pathname.startsWith('/autotask/chat')
);
} catch {
return false;
}
}
// Store references for cleanup
let escapeHandler = null;
// Create the overlay once
function ensureOverlay() {
let overlay = document.getElementById('thread-expander-overlay');
if (overlay) return overlay;
overlay = document.createElement('div');
overlay.id = 'thread-expander-overlay';
// Build DOM structure without innerHTML for security
const container = document.createElement('div');
container.id = 'thread-expander-container';
const iframe = document.createElement('iframe');
iframe.id = 'thread-expander-frame';
iframe.referrerPolicy = 'no-referrer-when-downgrade';
container.appendChild(iframe);
overlay.appendChild(container);
document.documentElement.appendChild(overlay);
// Close when clicking backdrop
overlay.addEventListener('click', (e) => {
if (e.target === overlay) hideOverlay();
});
// Esc key - store reference for cleanup
escapeHandler = (e) => {
if (e.key === 'Escape') hideOverlay();
};
document.addEventListener('keydown', escapeHandler);
return overlay;
}
function showOverlay(src, sandbox) {
// Security: Validate URL before loading
if (!src || !isValidThreadURL(src)) {
console.error('[Thread Expander] Invalid or untrusted URL:', src);
return;
}
const overlay = ensureOverlay();
const frame = overlay.querySelector('#thread-expander-frame');
// Security: Copy sandbox from original iframe or use secure defaults
// We preserve the original sandbox to maintain functionality but validate it
const securedSandbox = sandbox || 'allow-forms allow-modals allow-popups allow-same-origin allow-scripts';
// Apply sandbox BEFORE setting src (important for security)
frame.setAttribute('sandbox', securedSandbox);
// Use the current signed URL from the mini insight every time
frame.src = src;
overlay.classList.add('active');
}
function hideOverlay() {
const overlay = document.getElementById('thread-expander-overlay');
if (!overlay) return;
overlay.classList.remove('active');
// Security: Clear iframe completely when hidden
const frame = overlay.querySelector('#thread-expander-frame');
if (frame) {
frame.removeAttribute('src');
// Also clear sandbox to reset permissions
frame.removeAttribute('sandbox');
}
}
async function addExpandUI(insightIframe) {
if (!insightIframe || insightIframe.dataset.threadExpanderBound === '1') return;
// Mark iframe as processed immediately to prevent race conditions
insightIframe.dataset.threadExpanderBound = '1';
// Check if there's already a button for this iframe (shouldn't happen but just in case)
const existingButton = insightIframe.nextElementSibling;
if (existingButton && existingButton.classList.contains('thread-expander-row')) {
return;
}
// Create a right-aligned row container
const row = document.createElement('div');
row.className = 'thread-expander-row';
row.dataset.threadExpanderButton = 'true';
const btn = document.createElement('button');
btn.className = 'thread-expander-button';
btn.type = 'button';
// Build button content using DOM methods instead of innerHTML
const iconSpan = document.createElement('span');
iconSpan.className = 'icon';
iconSpan.setAttribute('aria-hidden', 'true');
const textSpan = document.createElement('span');
textSpan.textContent = 'Expand Thread';
try {
const iconUrl = chrome.runtime.getURL('icon.svg');
const response = await fetch(iconUrl);
if (response.ok) {
const iconSvg = await response.text();
// Parse SVG safely using DOMParser
const parser = new DOMParser();
const svgDoc = parser.parseFromString(iconSvg, 'image/svg+xml');
const svgElement = svgDoc.querySelector('svg');
if (svgElement && !svgDoc.querySelector('parsererror')) {
iconSpan.appendChild(svgElement);
btn.appendChild(iconSpan);
}
}
} catch (error) {
console.error('[Thread Expander] Could not load icon SVG.', error);
}
btn.appendChild(textSpan);
row.appendChild(btn);
// Verify iframe still exists in DOM before inserting button
if (!insightIframe.parentNode) {
return;
}
insightIframe.insertAdjacentElement('afterend', row);
btn.addEventListener('click', () => {
const src = insightIframe.getAttribute('src');
const sandbox = insightIframe.getAttribute('sandbox') || 'allow-forms allow-modals allow-popups allow-same-origin allow-scripts';
if (!src) {
console.warn('[Thread Expander] Insight iframe has no src yet.');
return;
}
showOverlay(src, sandbox);
});
}
function isThreadChatIframe(el) {
return isValidThreadURL(el, location.href);
}
function cleanupOrphanedButtons() {
// Find all our buttons
const buttons = document.querySelectorAll('.thread-expander-row[data-thread-expander-button="true"]');
buttons.forEach(button => {
// Check if the previous sibling is a valid Thread iframe
const prevSibling = button.previousElementSibling;
const isValidIframe = prevSibling &&
prevSibling.tagName &&
prevSibling.tagName.toLowerCase() === 'iframe' &&
isValidThreadURL(prevSibling, location.href);
if (!isValidIframe) {
button.remove();
}
});
}
// Track if we've already found iframes to avoid re-logging
let lastIframeCount = -1;
function scanAndBind() {
// First clean up any orphaned buttons
cleanupOrphanedButtons();
const iframes = document.querySelectorAll('iframe[src*="inbox.getthread.com/autotask/chat"]');
// Only log if the number of iframes has changed
if (iframes.length !== lastIframeCount) {
if (iframes.length > 0) {
console.log(`[Thread Expander] Found ${iframes.length} Thread iframe(s)`);
}
lastIframeCount = iframes.length;
}
iframes.forEach((iframe) => {
// Process silently - only log errors
addExpandUI(iframe);
});
}
// Global keyboard shortcut handler (Shift+E)
let hotkeyHandler = null;
function bindGlobalHotkey() {
if (hotkeyHandler) return;
hotkeyHandler = (e) => {
if (e.shiftKey && e.key.toLowerCase() === 'e') {
const iframe = document.querySelector('iframe[src*="inbox.getthread.com/autotask/chat"]');
if (iframe) {
const src = iframe.getAttribute('src');
if (src && isValidThreadURL(src)) {
showOverlay(src, iframe.getAttribute('sandbox'));
}
}
}
};
document.addEventListener('keydown', hotkeyHandler, { passive: true });
}
// Check if this script is already running in this frame
if (!window.__threadExpanderInitialized) {
window.__threadExpanderInitialized = true;
// Only log initialization in main frame, not iframes
if (window === window.top) {
console.log('[Thread Expander] Extension initialized');
}
// Initial scan
scanAndBind();
bindGlobalHotkey();
// Debounce mechanism to prevent excessive rescanning
let scanTimeout;
function debouncedScan() {
clearTimeout(scanTimeout);
scanTimeout = setTimeout(() => {
scanAndBind();
}, 500); // Wait 500ms after last mutation before scanning
}
// Re-scan when page updates (SPA/partial reloads)
const observer = new MutationObserver((mutations) => {
// Check if any of the mutations might have added Thread iframes
let shouldScan = false;
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
// Check if any added nodes might contain iframes
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check if it's an iframe or might contain iframes
if (node.tagName === 'IFRAME' ||
(node.querySelector && node.querySelector('iframe'))) {
shouldScan = true;
break;
}
}
}
}
if (shouldScan) break;
}
// Only scan if we found potential iframe changes
if (shouldScan) {
debouncedScan();
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
// Cleanup function for all resources
function cleanup() {
observer.disconnect();
if (escapeHandler) {
document.removeEventListener('keydown', escapeHandler);
}
if (hotkeyHandler) {
document.removeEventListener('keydown', hotkeyHandler);
}
}
// Tidy up on navigation/unload
window.addEventListener('beforeunload', cleanup);
}