-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontentScript.js
More file actions
1193 lines (1035 loc) · 42.1 KB
/
contentScript.js
File metadata and controls
1193 lines (1035 loc) · 42.1 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
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Inject marker IMMEDIATELY to indicate extension is installed
// This MUST run before any other code to ensure the main site can detect it
if (document.documentElement) {
document.documentElement.setAttribute('data-copus-extension-installed', 'true');
}
// Set up message listener IMMEDIATELY so sidepanel can communicate
// Function declarations below are hoisted, so they'll be available
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
// collectPageData is SYNCHRONOUS - do NOT return true
if (message.type === 'collectPageData') {
const currentUrl = window.location.href;
const isYouTube = currentUrl.includes('youtube.com');
const isYouTubeVideo = isYouTube && currentUrl.includes('/watch');
const images = collectPageImages();
let ogImageContent = null;
let title = '';
if (isYouTube) {
if (isYouTubeVideo) {
const videoIdMatch = currentUrl.match(/[?&]v=([^&]+)/);
const videoId = videoIdMatch ? videoIdMatch[1] : null;
ogImageContent = videoId ? `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg` : null;
// Get title from DOM h1 element
const h1Selectors = [
'h1.ytd-watch-metadata yt-formatted-string',
'#above-the-fold #title yt-formatted-string',
'h1.ytd-video-primary-info-renderer yt-formatted-string'
];
for (const selector of h1Selectors) {
const el = document.querySelector(selector);
if (el && el.textContent && el.textContent.trim()) {
title = el.textContent.trim();
break;
}
}
if (!title) {
title = document.title.replace(/^\(\d+\)\s*/, '').replace(/ - YouTube$/, '');
}
} else {
ogImageContent = null;
title = 'YouTube';
}
} else {
// Try both property and name attributes for og:image
const ogImage = document.querySelector("meta[property='og:image']") ||
document.querySelector("meta[name='og:image']");
ogImageContent = ogImage ? ogImage.content : null;
const ogTitleMeta = document.querySelector("meta[property='og:title']") ||
document.querySelector("meta[name='og:title']");
title = ogTitleMeta ? ogTitleMeta.content : document.title;
}
sendResponse({
title: title || '',
url: currentUrl,
images,
ogImageContent
});
return; // Synchronous - no return true needed
}
// collectPageDataWithRetry - simple: wait briefly then read og:title and og:image
if (message.type === 'collectPageDataWithRetry') {
const collectWithRetry = async () => {
// Always get CURRENT url at time of collection
const getCurrentUrl = () => window.location.href;
const currentUrl = getCurrentUrl();
const isYouTube = currentUrl.includes('youtube.com');
const isYouTubeVideo = isYouTube && currentUrl.includes('/watch');
let ogImageContent = null;
let title = '';
if (isYouTube) {
if (isYouTubeVideo) {
// Get video ID from CURRENT URL
const getVideoId = () => {
const url = window.location.href;
const match = url.match(/[?&]v=([^&]+)/);
return match ? match[1] : null;
};
const videoId = getVideoId();
ogImageContent = videoId ? `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg` : null;
// Helper to get title from DOM
const getTitleFromDOM = () => {
const selectors = [
'h1.ytd-watch-metadata yt-formatted-string',
'#above-the-fold #title yt-formatted-string',
'h1.ytd-video-primary-info-renderer yt-formatted-string'
];
for (const selector of selectors) {
const el = document.querySelector(selector);
if (el && el.textContent && el.textContent.trim()) {
return el.textContent.trim();
}
}
return null;
};
// Get initial title (might be stale)
const initialTitle = getTitleFromDOM();
// Poll until title changes or timeout (2 seconds max)
for (let i = 0; i < 10; i++) {
await new Promise(r => setTimeout(r, 200));
// Check if URL changed during wait (user navigated again)
if (getVideoId() !== videoId) {
// URL changed, abort this request
return null;
}
const currentTitle = getTitleFromDOM();
// If title changed from initial, use it
if (currentTitle && currentTitle !== initialTitle) {
title = currentTitle;
break;
}
// If no initial title but now we have one, use it
if (!initialTitle && currentTitle) {
title = currentTitle;
break;
}
}
// If still no title change, use whatever we have
if (!title) {
title = getTitleFromDOM() || document.title.replace(/^\(\d+\)\s*/, '').replace(/ - YouTube$/, '');
}
} else {
// YouTube homepage/other pages
await new Promise(r => setTimeout(r, 300));
ogImageContent = null;
title = 'YouTube';
}
} else {
// Non-YouTube sites - wait for SPA frameworks to update meta tags
await new Promise(r => setTimeout(r, 1500));
// Try both property and name attributes for og:image
const ogImageMeta = document.querySelector("meta[property='og:image']") ||
document.querySelector("meta[name='og:image']");
ogImageContent = ogImageMeta ? ogImageMeta.content : null;
const ogTitleMeta = document.querySelector("meta[property='og:title']") ||
document.querySelector("meta[name='og:title']");
title = ogTitleMeta ? ogTitleMeta.content : document.title;
}
// Final URL check
const finalUrl = getCurrentUrl();
const images = collectPageImages();
return {
title: title || '',
url: finalUrl,
images,
ogImageContent
};
};
collectWithRetry().then(data => {
// Always respond with current state
if (!data) {
// URL changed during collection, get fresh data
const freshUrl = window.location.href;
const isYT = freshUrl.includes('youtube.com');
const isYTVideo = isYT && freshUrl.includes('/watch');
let img = null;
let ttl = '';
if (isYTVideo) {
const m = freshUrl.match(/[?&]v=([^&]+)/);
img = m ? `https://i.ytimg.com/vi/${m[1]}/maxresdefault.jpg` : null;
const el = document.querySelector('h1.ytd-watch-metadata yt-formatted-string');
ttl = el?.textContent?.trim() || document.title.replace(/^\(\d+\)\s*/, '').replace(/ - YouTube$/, '');
} else if (isYT) {
ttl = 'YouTube';
} else {
const ogImg = document.querySelector("meta[property='og:image']") ||
document.querySelector("meta[name='og:image']");
img = ogImg?.content || null;
const ogTtl = document.querySelector("meta[property='og:title']") ||
document.querySelector("meta[name='og:title']");
ttl = ogTtl?.content || document.title;
}
sendResponse({ title: ttl, url: freshUrl, images: [], ogImageContent: img });
} else {
sendResponse(data);
}
});
return true; // Async response
}
// recheckAuth calls async function but we don't wait for it
if (message.type === 'recheckAuth') {
checkForAuthToken(true); // Fire and forget
sendResponse({ success: true });
return;
}
// Show traces indicator on page
if (message.type === 'showTracesIndicator') {
showTracesFloatingIndicator(message.count, message.traces);
sendResponse({ success: true });
return;
}
// Hide traces indicator
if (message.type === 'hideTracesIndicator') {
hideTracesFloatingIndicator();
sendResponse({ success: true });
return;
}
// Quick save toast notification
if (message.type === 'quickSaveToast') {
showQuickSaveToast(message.message, message.toastType);
sendResponse({ success: true });
return;
}
// injectToken is SYNCHRONOUS
if (message.type === 'injectToken') {
const { token, user } = message;
if (token) {
const existingToken = localStorage.getItem('copus_token') || sessionStorage.getItem('copus_token');
if (!existingToken || existingToken !== token) {
localStorage.setItem('copus_token', token);
if (user) {
localStorage.setItem('copus_user', JSON.stringify(user));
}
window.dispatchEvent(new StorageEvent('storage', {
key: 'copus_token',
newValue: token,
storageArea: localStorage
}));
window.dispatchEvent(new CustomEvent('copus_token_injected', {
detail: { token: token }
}));
sendResponse({ success: true, injected: true });
} else {
sendResponse({ success: true, injected: false, reason: 'already_present' });
}
} else {
sendResponse({ success: false, error: 'No token provided' });
}
return;
}
});
// Sync tokens between website and extension
// Website is ALWAYS the source of truth - when user logs in/out on website, extension follows
// Important: Check BOTH localStorage AND sessionStorage since mainsite can use either
// CRITICAL: Don't auto-clear extension storage just because website storage is empty on page load
// (new tabs start with empty sessionStorage even if user is logged in elsewhere)
async function syncTokens() {
const allowedDomains = ['copus.ai', 'www.copus.ai', 'copus.network', 'www.copus.network', 'localhost', '127.0.0.1'];
const currentDomain = window.location.hostname;
if (!allowedDomains.includes(currentDomain) && !currentDomain.includes('copus')) {
return; // Not a copus domain
}
try {
// Check BOTH localStorage and sessionStorage (mainsite uses sessionStorage when "Remember me" is disabled)
const websiteToken = localStorage.getItem('copus_token') || sessionStorage.getItem('copus_token');
const websiteUser = localStorage.getItem('copus_user') || sessionStorage.getItem('copus_user');
const result = await chrome.storage.local.get(['copus_token', 'copus_user']);
// Case 1: Both have tokens - ensure they match (website wins if different)
if (websiteToken && result.copus_token) {
if (websiteToken !== result.copus_token) {
await chrome.storage.local.set({
copus_token: websiteToken,
copus_user: websiteUser ? JSON.parse(websiteUser) : null
});
} else {
}
}
// Case 2: Website has token, extension doesn't - sync TO extension (login)
else if (websiteToken && !result.copus_token) {
await chrome.storage.local.set({
copus_token: websiteToken,
copus_user: websiteUser ? JSON.parse(websiteUser) : null
});
}
// Case 3: Extension has token, website doesn't
// IMPORTANT: Don't auto-clear! The website might just have empty sessionStorage on a new tab
// Only clear when we receive an explicit logout event from the website
else if (!websiteToken && result.copus_token) {
// ALWAYS inject into localStorage for reliability
// The mainsite's storage utility checks both localStorage and sessionStorage
// Using localStorage ensures it persists across tab reloads
localStorage.setItem('copus_token', result.copus_token);
if (result.copus_user) {
localStorage.setItem('copus_user', JSON.stringify(result.copus_user));
}
// Dispatch storage event to notify React app of the new token
window.dispatchEvent(new StorageEvent('storage', {
key: 'copus_token',
newValue: result.copus_token,
storageArea: localStorage
}));
// Also dispatch a custom event that the React app might listen to
window.dispatchEvent(new CustomEvent('copus_token_injected', {
detail: { token: result.copus_token }
}));
}
// Case 4: Both empty - already in sync (logged out)
else {
}
} catch (error) {
console.error('[Copus Extension] Error syncing tokens:', error);
}
}
// Sync tokens immediately on page load
syncTokens();
// Listen for logout postMessage from the website
window.addEventListener('message', async (event) => {
// Verify message is from Copus website
if (event.data.type === 'COPUS_LOGOUT' && event.data.source === 'copus-website') {
try {
// Clear extension's stored token and user data
await chrome.storage.local.remove(['copus_token', 'copus_user']);
// Notify background script to ensure all extension state is cleared
chrome.runtime.sendMessage({ type: 'clearAuthToken' });
// Clear validation cache
lastValidationTime = 0;
lastValidatedToken = null;
} catch (error) {
console.error('[Copus Extension] Error clearing extension storage on logout:', error);
}
}
});
// Listen for logout custom events from the website (legacy support)
window.addEventListener('copus_logout', async (event) => {
try {
// Clear extension's stored token and user data
await chrome.storage.local.remove(['copus_token', 'copus_user']);
// Clear validation cache
lastValidationTime = 0;
lastValidatedToken = null;
// Notify website that extension has finished clearing
// This keeps extension and website in perfect sync
window.dispatchEvent(new CustomEvent('copus_logout_complete'));
} catch (error) {
console.error('[Copus Extension] Error clearing extension storage on logout:', error);
// Still send completion event even on error so website doesn't hang
window.dispatchEvent(new CustomEvent('copus_logout_complete'));
}
});
function getAbsoluteUrl(url) {
try {
return new URL(url, window.location.href).href;
} catch (error) {
return url;
}
}
// Extract the actual page title from DOM content (for pages where document.title doesn't update)
// Returns { title: string, isStale: boolean } to indicate if the DOM might still be showing old content
function extractPageTitle() {
const url = window.location.href;
// For Copus treasury/space pages, look for the name in the page content
if (url.includes('/treasury/') || url.includes('/space/')) {
// Look for treasury/space specific elements first
const treasuryNameElement = document.querySelector('[class*="space-name"], [class*="treasury-name"], [class*="SpaceName"]');
if (treasuryNameElement && treasuryNameElement.textContent) {
const text = treasuryNameElement.textContent.trim();
if (text && text.length > 0 && text.length < 100) {
return { title: text + ' | Copus', isStale: false };
}
}
// Try h1, but check if it looks like treasury content (short, no article-like patterns)
const h1 = document.querySelector('h1');
if (h1 && h1.textContent) {
const text = h1.textContent.trim();
// Treasury names are usually short and simple
// Work titles often have punctuation, are longer, or have article-like patterns
const looksLikeTreasuryName = text.length < 30 && !text.includes('|') && !text.includes('!') && !text.includes('?');
if (looksLikeTreasuryName) {
return { title: text + ' | Copus', isStale: false };
}
}
// If we're on treasury URL but can't find treasury-specific content, DOM is probably stale
return { title: null, isStale: true };
}
// For profile pages
if (url.includes('/profile/') || url.includes('/user/')) {
const selectors = [
'[class*="username"]',
'[class*="profile-name"]',
'[class*="user-name"]',
'[class*="UserName"]'
];
for (const selector of selectors) {
const element = document.querySelector(selector);
if (element && element.textContent) {
const text = element.textContent.trim();
if (text && text.length > 0 && text.length < 50 && !text.includes('|')) {
return { title: text + ' | Copus', isStale: false };
}
}
}
return { title: null, isStale: true };
}
return { title: null, isStale: false }; // Use document.title as fallback
}
function collectPageImages() {
const rawImages = Array.from(document.images || []);
const uniqueSources = new Set();
const images = [];
// First, check for og:image (standard SEO meta tag) - highest priority
const ogImage = document.querySelector("meta[property='og:image']");
if (ogImage && ogImage.content) {
const ogSrc = getAbsoluteUrl(ogImage.content);
if (ogSrc && !uniqueSources.has(ogSrc)) {
images.push({
src: ogSrc,
width: 0,
height: 0,
isOgImage: true
});
uniqueSources.add(ogSrc);
}
}
// Then collect all page images
rawImages.forEach((image) => {
if (!image || !image.src) {
return;
}
const absoluteSrc = getAbsoluteUrl(image.src);
if (!absoluteSrc || uniqueSources.has(absoluteSrc)) {
return;
}
uniqueSources.add(absoluteSrc);
images.push({
src: absoluteSrc,
width: image.naturalWidth || image.width || 0,
height: image.naturalHeight || image.height || 0
});
});
return images;
}
// Cache for validation results to avoid redundant API calls
let lastValidationTime = 0;
let lastValidatedToken = null;
const VALIDATION_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Debounce timer for auth checks
let authCheckDebounceTimer = null;
// Function to check for authentication token in localStorage
async function checkForAuthToken(force = false) {
// Check for Copus domains including localhost development server
const allowedDomains = ['copus.ai', 'www.copus.ai', 'copus.network', 'www.copus.network', 'api-prod.copus.network', 'localhost', '127.0.0.1'];
const currentDomain = window.location.hostname;
const currentPort = window.location.port;
// Also check for localhost with specific port (5177 for dev server)
const isLocalDev = (currentDomain === 'localhost' || currentDomain === '127.0.0.1') &&
(currentPort === '5177' || currentPort === '3000' || currentPort === '5173');
if (allowedDomains.includes(currentDomain) || currentDomain.includes('copus') || isLocalDev) {
// Check for the correct token storage key from main site
// Check BOTH localStorage and sessionStorage (mainsite uses sessionStorage when "Remember me" is NOT checked)
const token = localStorage.getItem('copus_token') || sessionStorage.getItem('copus_token');
const userData = localStorage.getItem('copus_user') || sessionStorage.getItem('copus_user');
if (token) {
// Check cache to avoid redundant API calls
const now = Date.now();
const isCacheValid = (now - lastValidationTime) < VALIDATION_CACHE_DURATION;
const isSameToken = token === lastValidatedToken;
if (!force && isCacheValid && isSameToken) {
return;
}
// Check if it's a valid JWT format (3 parts separated by dots)
const tokenParts = token.split('.');
if (tokenParts.length === 3) {
try {
// Detect environment and use appropriate API
// IMPORTANT: Must match the main site's API endpoints
const isTestEnv = currentDomain.includes('test') || isLocalDev;
const apiBaseUrl = isTestEnv ? 'https://api-test.copus.network' : 'https://api-prod.copus.network';
const apiUrl = `${apiBaseUrl}/client/user/userInfo`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const userInfo = await response.json();
// Update cache
lastValidationTime = Date.now();
lastValidatedToken = token;
// Store both token and user data in extension storage
chrome.runtime.sendMessage({
type: 'storeAuthData',
token: token,
user: userInfo.data
}, (response) => {
});
} else {
// Clear cache but DON'T remove token from storage
// User might be offline or API might be down
lastValidationTime = 0;
lastValidatedToken = null;
}
} catch (error) {
console.error('[Copus Extension] Token validation error:', error);
// Clear cache but DON'T remove token from storage
// User might be offline or have network issues
lastValidationTime = 0;
lastValidatedToken = null;
}
} else {
chrome.runtime.sendMessage({
type: 'clearAuthToken'
});
}
} else {
// No token found in this tab's localStorage
// DON'T clear extension storage - user might have closed the copus.network tab
// Extension should keep the token until user explicitly logs out
lastValidationTime = 0;
lastValidatedToken = null;
}
} else {
}
}
// Debounced version of checkForAuthToken to prevent rapid successive calls
function debouncedAuthCheck(delay = 1000) {
if (authCheckDebounceTimer) {
clearTimeout(authCheckDebounceTimer);
}
authCheckDebounceTimer = setTimeout(() => {
checkForAuthToken();
}, delay);
}
// Check for token on page load and when localStorage changes
setTimeout(() => checkForAuthToken(false), 1000); // Delay to ensure page is fully loaded
// Monitor localStorage changes for auth token
const originalSetItem = localStorage.setItem;
const originalRemoveItem = localStorage.removeItem;
localStorage.setItem = function(key, value) {
originalSetItem.apply(this, arguments);
if (key === 'copus_token') {
// Sync token from website to extension
syncTokens();
// Also validate it
debouncedAuthCheck(2000); // 2 second debounce
}
};
localStorage.removeItem = function(key) {
originalRemoveItem.apply(this, arguments);
if (key === 'copus_token') {
// Sync logout from website to extension
syncTokens();
// Clear cache when token is removed
lastValidationTime = 0;
lastValidatedToken = null;
}
};
// Also monitor for storage events (for cross-tab changes)
window.addEventListener('storage', function(e) {
if (e.key === 'copus_token') {
// Sync token changes from other tabs
syncTokens();
// Use debounced check to prevent rapid successive calls from cross-tab updates
debouncedAuthCheck(2000); // 2 second debounce
}
});
// ========== QUICK SAVE TOAST ==========
// Shows a toast notification for quick-save results
const QUICK_SAVE_TOAST_ID = 'copus-quick-save-toast';
let quickSaveToastTimer = null;
function showQuickSaveToast(message, type) {
// Remove existing toast
const existing = document.getElementById(QUICK_SAVE_TOAST_ID);
if (existing) existing.remove();
const existingStyle = document.getElementById(QUICK_SAVE_TOAST_ID + '-styles');
if (existingStyle) existingStyle.remove();
if (quickSaveToastTimer) clearTimeout(quickSaveToastTimer);
// Dismiss type just removes the toast
if (type === 'dismiss') return;
if (!document.body) return;
const toast = document.createElement('div');
toast.id = QUICK_SAVE_TOAST_ID;
const checkSvg = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M13.3 4.3L6 11.6L2.7 8.3" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
const errorSvg = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12 4L4 12M4 4L12 12" stroke="white" stroke-width="2" stroke-linecap="round"/></svg>`;
const savingSvg = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" stroke="white" stroke-width="2" stroke-dasharray="28" stroke-dashoffset="8"><animateTransform attributeName="transform" type="rotate" from="0 8 8" to="360 8 8" dur="0.8s" repeatCount="indefinite"/></circle></svg>`;
const icon = type === 'success' ? checkSvg : type === 'error' ? errorSvg : savingSvg;
toast.innerHTML = `
<div class="copus-qst-bubble">
<div class="copus-qst-icon">${icon}</div>
<div class="copus-qst-text">${message}</div>
</div>
`;
const style = document.createElement('style');
style.id = QUICK_SAVE_TOAST_ID + '-styles';
style.textContent = `
#${QUICK_SAVE_TOAST_ID} {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation: copusQstSlideIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes copusQstSlideIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes copusQstSlideOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(12px); }
}
.copus-qst-bubble {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
background: ${type === 'success' ? '#1f9d55' : type === 'error' ? '#ff4a17' : '#f23a00'};
color: white;
font-size: 13px;
font-weight: 500;
line-height: 1.2;
white-space: nowrap;
}
.copus-qst-icon {
display: flex;
align-items: center;
flex-shrink: 0;
}
.copus-qst-text {
padding-right: 2px;
}
`;
document.head.appendChild(style);
document.body.appendChild(toast);
// Auto-dismiss success/error after 2.5s
if (type !== 'saving') {
quickSaveToastTimer = setTimeout(() => {
toast.style.animation = 'copusQstSlideOut 0.25s ease forwards';
setTimeout(() => {
toast.remove();
style.remove();
}, 250);
}, 2500);
}
}
// ========== TRACES FLOATING INDICATOR ==========
// Shows a floating indicator when others have curated this page
const TRACES_INDICATOR_ID = 'copus-traces-indicator';
function showTracesFloatingIndicator(count, traces = []) {
// Remove existing indicator if any
hideTracesFloatingIndicator();
// Don't show on Copus pages
if (window.location.hostname.includes('copus.network') ||
window.location.hostname.includes('copus.io') ||
window.location.hostname.includes('copus.ai')) {
return;
}
// Wait for document.body if not ready
if (!document.body) {
const observer = new MutationObserver((mutations, obs) => {
if (document.body) {
obs.disconnect();
showTracesFloatingIndicator(count, traces);
}
});
observer.observe(document.documentElement, { childList: true });
return;
}
// Create the floating indicator
const indicator = document.createElement('div');
indicator.id = TRACES_INDICATOR_ID;
// Inline SVG for the white octopus on red background
const logoSvg = `<svg width="32" height="32" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="30" height="30" rx="15" fill="#F23A00"/>
<path d="M15.8975 5C16.5927 4.99742 16.9432 5.06308 17.5049 5.2998C18.0231 5.51823 18.3723 5.76429 18.8037 6.21582C19.4729 6.91628 19.8268 7.67954 19.9404 8.66406C20.0344 9.47896 19.8862 10.2536 19.3428 11.7793C19.1515 12.3164 18.9688 12.9074 18.9365 13.0928C18.8216 13.7532 19.0357 14.2181 19.5898 14.5098C20.1689 14.8146 20.8332 15.0153 21.4082 14.7266C21.8047 14.5274 21.9571 13.8716 22.1934 13C22.1934 12.0001 22.3793 11.3848 23.1016 11C23.8239 10.6152 24.9945 11.2777 25 12C25.0054 12.7223 24.4752 14.3995 23.6387 15C22.2458 15.9999 21.2031 16.3627 20.0684 16.4238C19.7604 16.4404 19.5141 16.4723 19.5215 16.4941C19.5639 16.6197 20.3337 17.4868 20.6221 17.7344C21.068 18.1171 21.4135 18.3388 22.3789 18.8604C22.8185 19.0978 23.3252 19.3964 23.5049 19.5234C23.9148 19.8132 24.3132 20.2492 24.4756 20.5869C24.9135 21.4976 24.5211 22.7095 23.6387 23.1729C23.3614 23.3183 22.9461 23.3466 22.752 23.2334C22.6416 23.1689 22.6438 23.1654 22.9121 23.0059C23.2917 22.7801 23.6247 22.4364 23.7725 22.1182C23.9276 21.7839 23.9203 21.3592 23.7549 21.1143C23.6121 20.903 23.1369 20.6528 22.1934 20.292C20.3378 19.5824 19.8205 19.2288 18.1055 17.4912C17.669 17.049 17.3027 16.6959 17.291 16.707C17.2799 16.7188 17.3915 17.0973 17.5391 17.5479C17.9339 18.7535 18.2609 19.4303 18.9014 20.3711C19.5971 21.393 19.7735 21.7009 19.918 22.1387C20.2116 23.0284 20.0718 23.824 19.5068 24.4756C19.0308 25.0244 18.4561 25.16 18.1367 24.7988C18.0004 24.6446 18.0121 24.6048 18.1924 24.6035C18.5488 24.6007 19.0075 24.2218 19.1309 23.8281C19.2755 23.3664 19.0227 22.6858 18.4561 22.0146C18.2653 21.7887 17.9419 21.4062 17.7373 21.165C17.0086 20.3059 16.4516 19.2641 15.9658 17.8477C15.8276 17.4448 15.705 17.1491 15.6934 17.1904C15.6816 17.2324 15.6244 17.5023 15.5664 17.791C15.3394 18.9211 14.9482 19.9421 14.4873 20.6045C14.3654 20.7796 13.9944 21.2499 13.6631 21.6494C13.3318 22.0489 12.9823 22.4962 12.8867 22.6436C12.7058 22.9223 12.5694 23.3179 12.5693 23.5635C12.5693 23.7693 12.6937 24.0533 12.8691 24.248C13.0161 24.411 13.5232 24.705 13.6572 24.7051C13.7107 24.7052 13.7109 24.7188 13.6602 24.7832C13.4797 25.0124 13.0936 25.068 12.8027 24.9062C12.3444 24.651 11.9117 23.8478 11.8311 23.1016C11.7547 22.3934 12.0836 21.5001 12.7227 20.6797C13.5297 19.6438 13.6636 19.3336 14.0156 17.6797C14.0844 17.3563 14.1551 17.0516 14.1729 17.0029C14.1903 16.9546 14.0029 17.1382 13.7559 17.4111C13.1633 18.0659 12.362 18.8238 11.9297 19.1387C11.4307 19.5021 10.8512 19.7908 9.9541 20.1211C9.5226 20.28 9.03171 20.475 8.86328 20.5547C7.54808 21.1769 7.54703 22.1224 8.86035 23.0518C8.95099 23.1159 9.03522 23.1772 9.04883 23.1885C9.10548 23.2364 8.90976 23.3011 8.70508 23.3018C8.11474 23.3035 7.4131 22.6145 7.21777 21.8418C7.12266 21.4653 7.13242 21.0127 7.24414 20.6396C7.48775 19.8266 8.15302 19.2333 9.3125 18.7939C9.84615 18.5917 10.569 18.2156 10.9619 17.9365C11.1265 17.8196 11.5361 17.4963 11.8711 17.2188L12.4795 16.7139L12.3223 16.6807C12.2355 16.6624 11.9857 16.6218 11.7666 16.5898C10.757 16.4427 10.1917 16.5896 8.32031 16C6.44901 15.4104 5.94824 14.5 5.94824 14.5C5.94269 14.4954 5 13.7155 5 12C5.00007 10.2796 7.00741 10.0817 7.37109 11C7.67957 11.7794 7.21835 12.5 7.21777 13C7.21726 13.5 7.3957 14.4527 8.05078 14.7266C8.70582 15.0002 9.74555 15 10.6943 15C11.4782 15 12.5037 14.6918 12.7158 13.8721C12.8467 13.3659 12.6671 12.3436 12.2207 11.0479C11.8663 10.0192 11.7851 9.62287 11.7891 8.94922C11.7931 8.26706 11.8864 7.84241 12.1641 7.24512C12.5376 6.44168 13.0857 5.88169 13.9004 5.4707C14.5578 5.13912 15.1353 5.00287 15.8975 5ZM14.877 11.5645C14.2001 11.5646 13.9601 12.2255 13.96 12.5078C13.96 12.9587 14.3868 13.4042 14.877 13.4043C15.4936 13.4043 15.7998 12.892 15.7998 12.5078C15.7997 12.1236 15.5038 11.5645 14.877 11.5645ZM23.5449 11.915C23.2771 11.915 23.0596 12.1446 23.0596 12.4268C23.0598 12.7088 23.2772 12.9375 23.5449 12.9375C23.8124 12.9372 24.0291 12.7086 24.0293 12.4268C24.0293 12.1447 23.8126 11.9153 23.5449 11.915ZM6.50098 10.9424C6.23313 10.9424 6.01563 11.1709 6.01562 11.4531C6.01562 11.7353 6.23313 11.9639 6.50098 11.9639C6.76866 11.9637 6.98535 11.7352 6.98535 11.4531C6.98534 11.1711 6.76865 10.9426 6.50098 10.9424ZM13.5674 9.43359C13.2997 9.4337 13.0831 9.66233 13.083 9.94434C13.083 10.2265 13.2996 10.455 13.5674 10.4551C13.8352 10.4551 14.0527 10.2265 14.0527 9.94434C14.0526 9.66227 13.8351 9.43359 13.5674 9.43359ZM16.4775 9.43359C16.2098 9.43359 15.9923 9.66227 15.9922 9.94434C15.9922 10.2265 16.2097 10.4551 16.4775 10.4551C16.7454 10.4551 16.9629 10.2265 16.9629 9.94434C16.9628 9.66228 16.7453 9.43362 16.4775 9.43359Z" fill="white"/>
</svg>`;
indicator.innerHTML = `
<div class="copus-traces-bubble">
<div class="copus-traces-icon">
${logoSvg}
</div>
<div class="copus-traces-content">
<span class="copus-traces-count">${count}</span> ${count === 1 ? 'person' : 'people'} curated this
</div>
</div>
`;
// Add styles
const style = document.createElement('style');
style.id = TRACES_INDICATOR_ID + '-styles';
style.textContent = `
#${TRACES_INDICATOR_ID} {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
animation: copusTracesSlideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes copusTracesSlideIn {
from {
opacity: 0;
transform: translateY(20px) scale(0.8);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes copusTracesWave {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-8deg); }
75% { transform: rotate(8deg); }
}
.copus-traces-bubble {
display: flex;
align-items: center;
gap: 8px;
background: white;
color: #333;
padding: 3px 10px 3px 3px;
border-radius: 20px;
cursor: pointer;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
transition: all 0.2s ease;
}
.copus-traces-bubble:hover {
transform: scale(1.05);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.copus-traces-bubble:hover .copus-traces-icon svg {
animation: copusTracesWave 0.4s ease-in-out;
}
.copus-traces-icon {
display: flex;
align-items: center;
justify-content: center;
}
.copus-traces-icon svg {
width: 28px;
height: 28px;
}
.copus-traces-content {
font-size: 12px;
line-height: 1.2;
color: #555;
white-space: nowrap;
}
.copus-traces-count {
font-weight: 600;
color: #f23a00;
}
/* Minimized state after a while */
#${TRACES_INDICATOR_ID}.minimized .copus-traces-bubble {
padding: 2px;
border-radius: 50%;
}
#${TRACES_INDICATOR_ID}.minimized .copus-traces-content {
display: none;
}
#${TRACES_INDICATOR_ID}.minimized .copus-traces-icon svg {
width: 24px;
height: 24px;
}
`;
// Append to DOM with error handling
try {
if (document.head) {
document.head.appendChild(style);
} else {
document.documentElement.appendChild(style);
}
document.body.appendChild(indicator);
} catch (e) {
return;
}
// Click handler - open extension sidepanel with traces view
indicator.addEventListener('click', () => {
chrome.runtime.sendMessage({
type: 'openTracesPanel',
traces: traces
}, (response) => {
if (response && response.success) {
} else {
}
});
});
// Minimize after 8 seconds
setTimeout(() => {
if (document.getElementById(TRACES_INDICATOR_ID)) {
indicator.classList.add('minimized');
}
}, 8000);
}
function hideTracesFloatingIndicator() {
const indicator = document.getElementById(TRACES_INDICATOR_ID);
const styles = document.getElementById(TRACES_INDICATOR_ID + '-styles');
if (indicator) indicator.remove();
if (styles) styles.remove();
}
// Show inline traces panel (sidebar injected into page)
const TRACES_PANEL_ID = 'copus-traces-panel';
function showTracesInlinePanel(traces) {
// Remove existing panel
hideTracesInlinePanel();
const panel = document.createElement('div');
panel.id = TRACES_PANEL_ID;
// Build traces HTML
const tracesHtml = traces.map(trace => {
const author = trace.authorInfo || {};
const avatarUrl = author.faceUrl || 'https://c.animaapp.com/mg0kz9olCQ44yb/img/ic-fractopus-open.svg';
const username = author.username || 'Anonymous';
const timeAgo = formatTimeAgo(trace.createAt);
const truncatedContent = trace.content.length > 120 ? trace.content.substring(0, 120) + '...' : trace.content;
return `
<div class="copus-trace-card">
<div class="copus-trace-header">
<img class="copus-trace-avatar" src="${avatarUrl}" alt="${username}" onerror="this.src='https://c.animaapp.com/mg0kz9olCQ44yb/img/ic-fractopus-open.svg'" />
<div class="copus-trace-author">
<span class="copus-trace-username">${username}</span>
<span class="copus-trace-time">${timeAgo}</span>
</div>
</div>
<p class="copus-trace-text">${truncatedContent}</p>
<div class="copus-trace-stats">
<span>💬 ${trace.commentCount || 0}</span>
<span class="copus-treasure-stat">💎 ${trace.collectCount || 0}</span>
</div>
</div>
`;
}).join('');
panel.innerHTML = `
<div class="copus-panel-backdrop"></div>
<div class="copus-panel-content">
<div class="copus-panel-header">
<div class="copus-panel-title">
<img src="https://c.animaapp.com/mg0kz9olCQ44yb/img/ic-fractopus-open.svg" alt="Copus" width="24" height="24" />
<span>Traces on this page</span>
</div>
<button class="copus-panel-close">×</button>
</div>
<div class="copus-panel-body">
${tracesHtml}
</div>
<div class="copus-panel-footer">
<a href="https://copus.network" target="_blank" class="copus-panel-link">
Powered by Copus
</a>
</div>
</div>
`;
const style = document.createElement('style');
style.id = TRACES_PANEL_ID + '-styles';
style.textContent = `
#${TRACES_PANEL_ID} {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.copus-panel-backdrop {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
animation: copusFadeIn 0.2s ease-out;
}
@keyframes copusFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.copus-panel-content {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 360px;
max-width: 90vw;
background: #f8f9fa;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
animation: copusSlideIn 0.3s ease-out;
}