linkfeed-pro 1.0.7

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.
Files changed (65) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.output/chrome-mv3/_locales/de/messages.json +214 -0
  3. package/.output/chrome-mv3/_locales/en/messages.json +214 -0
  4. package/.output/chrome-mv3/_locales/es/messages.json +214 -0
  5. package/.output/chrome-mv3/_locales/fr/messages.json +214 -0
  6. package/.output/chrome-mv3/_locales/hi/messages.json +214 -0
  7. package/.output/chrome-mv3/_locales/id/messages.json +214 -0
  8. package/.output/chrome-mv3/_locales/it/messages.json +214 -0
  9. package/.output/chrome-mv3/_locales/nl/messages.json +214 -0
  10. package/.output/chrome-mv3/_locales/pl/messages.json +214 -0
  11. package/.output/chrome-mv3/_locales/pt_BR/messages.json +214 -0
  12. package/.output/chrome-mv3/_locales/pt_PT/messages.json +214 -0
  13. package/.output/chrome-mv3/_locales/tr/messages.json +214 -0
  14. package/.output/chrome-mv3/assets/popup-Z_g1HFs5.css +1 -0
  15. package/.output/chrome-mv3/background.js +42 -0
  16. package/.output/chrome-mv3/chunks/popup-IxiPwS1E.js +42 -0
  17. package/.output/chrome-mv3/content-scripts/content.js +179 -0
  18. package/.output/chrome-mv3/icon-128.png +0 -0
  19. package/.output/chrome-mv3/icon-16.png +0 -0
  20. package/.output/chrome-mv3/icon-48.png +0 -0
  21. package/.output/chrome-mv3/icon.svg +9 -0
  22. package/.output/chrome-mv3/manifest.json +1 -0
  23. package/.output/chrome-mv3/popup.html +247 -0
  24. package/.wxt/eslint-auto-imports.mjs +56 -0
  25. package/.wxt/tsconfig.json +28 -0
  26. package/.wxt/types/globals.d.ts +15 -0
  27. package/.wxt/types/i18n.d.ts +593 -0
  28. package/.wxt/types/imports-module.d.ts +20 -0
  29. package/.wxt/types/imports.d.ts +50 -0
  30. package/.wxt/types/paths.d.ts +32 -0
  31. package/.wxt/wxt.d.ts +7 -0
  32. package/entrypoints/background.ts +112 -0
  33. package/entrypoints/content.ts +656 -0
  34. package/entrypoints/popup/main.ts +452 -0
  35. package/entrypoints/popup/modules/auth-modal.ts +219 -0
  36. package/entrypoints/popup/modules/settings.ts +78 -0
  37. package/entrypoints/popup/modules/ui-state.ts +95 -0
  38. package/entrypoints/popup/style.css +844 -0
  39. package/entrypoints/popup.html +261 -0
  40. package/lib/constants.ts +9 -0
  41. package/lib/device-meta.ts +173 -0
  42. package/lib/i18n.ts +201 -0
  43. package/lib/license.ts +470 -0
  44. package/lib/selectors.ts +24 -0
  45. package/lib/storage.ts +95 -0
  46. package/lib/telemetry.ts +94 -0
  47. package/package.json +30 -0
  48. package/public/_locales/de/messages.json +214 -0
  49. package/public/_locales/en/messages.json +214 -0
  50. package/public/_locales/es/messages.json +214 -0
  51. package/public/_locales/fr/messages.json +214 -0
  52. package/public/_locales/hi/messages.json +214 -0
  53. package/public/_locales/id/messages.json +214 -0
  54. package/public/_locales/it/messages.json +214 -0
  55. package/public/_locales/nl/messages.json +214 -0
  56. package/public/_locales/pl/messages.json +214 -0
  57. package/public/_locales/pt_BR/messages.json +214 -0
  58. package/public/_locales/pt_PT/messages.json +214 -0
  59. package/public/_locales/tr/messages.json +214 -0
  60. package/public/icon-128.png +0 -0
  61. package/public/icon-16.png +0 -0
  62. package/public/icon-48.png +0 -0
  63. package/public/icon.svg +9 -0
  64. package/tsconfig.json +3 -0
  65. package/wxt.config.ts +50 -0
@@ -0,0 +1,261 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <title>LinkFeed Pro Settings</title>
7
+ <link rel="stylesheet" href="./popup/style.css" />
8
+ </head>
9
+
10
+ <body>
11
+ <div class="header">
12
+ <a href="https://linkfeed.pro" target="_blank" class="brand">
13
+ <div class="brand-text">
14
+ <h1>LinkFeed <span id="status-badge" class="status-badge free">Free</span></h1>
15
+ </div>
16
+ </a>
17
+ <div class="header-actions">
18
+ <button id="open-hide-btn" class="icon-btn header-btn" type="button" title="Hide">
19
+ <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
20
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
21
+ <path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"></path>
22
+ <circle cx="12" cy="12" r="3"></circle>
23
+ <line x1="3" y1="3" x2="21" y2="21"></line>
24
+ </svg>
25
+ </button>
26
+ <button id="open-display-btn" class="icon-btn header-btn" type="button" title="Display">
27
+ <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
28
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
29
+ <polyline points="9 3 3 3 3 9"></polyline>
30
+ <polyline points="15 3 21 3 21 9"></polyline>
31
+ <polyline points="15 21 21 21 21 15"></polyline>
32
+ <polyline points="9 21 3 21 3 15"></polyline>
33
+ <line x1="3" y1="3" x2="10" y2="10"></line>
34
+ <line x1="21" y1="3" x2="14" y2="10"></line>
35
+ <line x1="21" y1="21" x2="14" y2="14"></line>
36
+ <line x1="3" y1="21" x2="10" y2="14"></line>
37
+ </svg>
38
+ </button>
39
+ <button id="open-settings-btn" class="icon-btn header-btn" type="button" title="Settings">
40
+ <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
41
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
42
+ <path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"></path>
43
+ <path
44
+ d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.01a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z">
45
+ </path>
46
+ </svg>
47
+ </button>
48
+ <label id="global-toggle-label" class="global-toggle-btn" title="Enable/Disable all features">
49
+ <input type="checkbox" id="globalEnabled" checked />
50
+ <div class="icon-btn header-btn">
51
+ <svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none"
52
+ stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
53
+ class="lucide lucide-power">
54
+ <path d="M12 2v10" />
55
+ <path d="M18.36 6.64a9 9 0 1 1-12.73 0" />
56
+ </svg>
57
+ </div>
58
+ </label>
59
+ </div>
60
+ </div>
61
+
62
+ <div id="hide-view" class="settings-view">
63
+ <div class="settings-container feature-container">
64
+ <!-- FEATURE: Hide Navigation Bar (Free) -->
65
+ <label class="setting-card" data-feature="free">
66
+ <input type="checkbox" id="hideNavBar" />
67
+ <span class="setting-label">Hide Navigation Bar</span>
68
+ <span class="icon-btn">
69
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
70
+ stroke-linejoin="round">
71
+ <rect x="3" y="4" width="18" height="4" rx="1"></rect>
72
+ <rect x="3" y="10" width="18" height="10" rx="1"></rect>
73
+ </svg>
74
+ </span>
75
+ </label>
76
+
77
+ <!-- FEATURE: Hide Sidebars (Free) -->
78
+ <label class="setting-card" data-feature="free">
79
+ <input type="checkbox" id="hideSidebars" />
80
+ <span class="setting-label">Hide Side Bar</span>
81
+ <span class="icon-btn">
82
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
83
+ stroke-linejoin="round">
84
+ <rect x="3" y="3" width="7" height="18" rx="1"></rect>
85
+ <rect x="14" y="3" width="7" height="18" rx="1"></rect>
86
+ </svg>
87
+ </span>
88
+ </label>
89
+
90
+ <!-- FEATURE: Hide Messenger (Free) -->
91
+ <label class="setting-card" data-feature="free">
92
+ <input type="checkbox" id="hideMessenger" />
93
+ <span class="setting-label">Hide Messenger</span>
94
+ <span class="icon-btn">
95
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
96
+ stroke-linejoin="round">
97
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
98
+ <line x1="4" y1="4" x2="20" y2="20"></line>
99
+ </svg>
100
+ </span>
101
+ </label>
102
+
103
+ <!-- FEATURE: Hide Start a Post (Free) -->
104
+ <label class="setting-card" data-feature="free">
105
+ <input type="checkbox" id="hideStartPost" />
106
+ <span class="setting-label">Hide 'Start Post'</span>
107
+ <span class="icon-btn">
108
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
109
+ stroke-linejoin="round">
110
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"></path>
111
+ <line x1="2" y1="2" x2="22" y2="22"></line>
112
+ </svg>
113
+ </span>
114
+ </label>
115
+
116
+ <!-- FEATURE: Hide Promoted (Free) -->
117
+ <label class="setting-card" data-feature="free">
118
+ <input type="checkbox" id="hidePromoted" />
119
+ <span class="setting-label">Hide 'Promoted'</span>
120
+ <span class="icon-btn">
121
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
122
+ stroke-linejoin="round">
123
+ <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
124
+ <line x1="4" y1="4" x2="20" y2="20"></line>
125
+ </svg>
126
+ </span>
127
+ </label>
128
+
129
+ <!-- FEATURE: Hide Removed Cards (Premium) -->
130
+ <div class="setting-card premium-feature" data-feature="premium" id="hideRemovedFeedCardsCard">
131
+ <input type="checkbox" id="hideRemovedFeedCards" />
132
+ <span class="setting-label">Hide 'Post Removed'</span>
133
+ <span class="pro-badge">PRO</span>
134
+ <span class="icon-btn">
135
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
136
+ stroke-linejoin="round">
137
+ <line x1="4" y1="4" x2="20" y2="20"></line>
138
+ <line x1="20" y1="4" x2="4" y2="20"></line>
139
+ </svg>
140
+ </span>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <div id="display-view" class="settings-view hidden">
146
+ <div class="settings-container feature-container">
147
+ <!-- FEATURE: Auto-Expand Posts (Premium) -->
148
+ <div class="setting-card premium-feature" data-feature="premium" id="autoExpandCard">
149
+ <input type="checkbox" id="autoExpandPosts" />
150
+ <span class="setting-label">Auto-Expand Posts</span>
151
+ <span class="pro-badge">PRO</span>
152
+ <span class="icon-btn">
153
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
154
+ stroke-linejoin="round">
155
+ <polyline points="7 13 12 18 17 13"></polyline>
156
+ <polyline points="7 6 12 11 17 6"></polyline>
157
+ </svg>
158
+ </span>
159
+ </div>
160
+ <!-- FEATURE: Text Size (Premium) -->
161
+ <div class="slider-section premium-feature" data-feature="premium">
162
+ <div class="section-top">
163
+ <label class="section-label">Text Size</label>
164
+ <span class="pro-badge">PRO</span>
165
+ <span id="fontSize-value" class="value-display">20px</span>
166
+ </div>
167
+ <input type="range" id="fontSize" min="14" max="32" step="1" />
168
+ </div>
169
+
170
+ <!-- FEATURE: Feed Width (Premium) -->
171
+ <div class="slider-section premium-feature" data-feature="premium">
172
+ <div class="section-top">
173
+ <label class="section-label">Feed Width</label>
174
+ <span class="pro-badge">PRO</span>
175
+ <span id="feedWidth-value" class="value-display">1500px</span>
176
+ </div>
177
+ <input type="range" id="feedWidth" min="600" max="2500" step="50" />
178
+ </div>
179
+
180
+ <!-- FEATURE: Post Spacing (Premium) -->
181
+ <div class="slider-section premium-feature" data-feature="premium">
182
+ <div class="section-top">
183
+ <label class="section-label">Post Spacing</label>
184
+ <span class="pro-badge">PRO</span>
185
+ <span id="feedSpacing-value" class="value-display">40px</span>
186
+ </div>
187
+ <input type="range" id="feedSpacing" min="0" max="80" step="2" />
188
+ </div>
189
+ </div>
190
+ </div>
191
+
192
+ <div id="settings-view" class="settings-view hidden">
193
+ <div class="settings-panel">
194
+ <h2 id="settings-title">Settings</h2>
195
+ <label id="settings-language-label" class="settings-label" for="locale-select">Language</label>
196
+ <div class="locale-select-wrap">
197
+ <select id="locale-select" class="locale-select"></select>
198
+ <span class="locale-select-caret" aria-hidden="true">
199
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"
200
+ stroke-linejoin="round">
201
+ <polyline points="6 9 12 15 18 9"></polyline>
202
+ </svg>
203
+ </span>
204
+ </div>
205
+ <p id="settings-language-help" class="settings-help">Force the popup language. Auto follows your browser language.</p>
206
+ </div>
207
+ <div class="settings-panel">
208
+ <h2 id="settings-subscription-title">Subscription</h2>
209
+ <div class="subscription-row">
210
+ <span id="settings-subscription-status-label" class="settings-label">Status</span>
211
+ <span id="settings-subscription-status" class="subscription-status-badge subscription-status-free">Free</span>
212
+ </div>
213
+ <p id="settings-subscription-detail" class="settings-help"></p>
214
+ <div class="settings-actions">
215
+ <button id="settings-account-btn" class="settings-btn secondary" type="button">My Account</button>
216
+ <button id="settings-upgrade-btn" class="settings-btn primary hidden" type="button">Upgrade now</button>
217
+ </div>
218
+ </div>
219
+ <div class="settings-footer">
220
+ <span id="settings-version-value">-</span>
221
+ </div>
222
+ </div>
223
+
224
+ <!-- Modal Overlay for Trial Activation -->
225
+ <div id="trial-modal" class="modal-overlay hidden">
226
+ <div class="modal-content">
227
+ <button id="modal-close-btn" class="modal-close">&times;</button>
228
+
229
+ <!-- Step 1: Email Input -->
230
+ <div id="modal-email-step" class="modal-step">
231
+ <h2>Unlock Pro Features</h2>
232
+ <p class="modal-desc">Enter your email to link this device and unlock Pro.</p>
233
+ <input type="email" id="modal-email" placeholder="Enter your email" />
234
+ <button id="modal-send-link-btn" class="modal-btn primary">Send Magic Link</button>
235
+ <p id="modal-email-error" class="modal-error hidden"></p>
236
+ </div>
237
+
238
+ <!-- Step 2: Email Confirmation -->
239
+ <div id="modal-check-email-step" class="modal-step hidden">
240
+ <h2>Check Your Email</h2>
241
+ <p class="modal-desc">We sent a confirmation link to <span id="modal-email-display"></span></p>
242
+
243
+ <button id="modal-verify-btn" class="modal-btn primary">I've clicked the link</button>
244
+ <p id="modal-check-email-error" class="modal-error hidden"></p>
245
+ <button id="modal-back-btn" class="modal-link-btn">Use a different email</button>
246
+ </div>
247
+
248
+ <!-- Step 3: Trial Expired -->
249
+ <div id="modal-expired-step" class="modal-step hidden">
250
+ <h2>Trial Expired</h2>
251
+ <p class="modal-desc">To use Pro features please upgrade.</p>
252
+ <button id="modal-upgrade-btn" class="modal-btn primary">See Plans</button>
253
+ </div>
254
+
255
+ </div>
256
+ </div>
257
+
258
+ <script type="module" src="./popup/main.ts"></script>
259
+ </body>
260
+
261
+ </html>
@@ -0,0 +1,9 @@
1
+ export const API_BASE_URL = "https://linkfeed.pro";
2
+
3
+ export const APP_URLS = {
4
+ HOME: "https://linkfeed.pro",
5
+ AUTH: "https://linkfeed.pro/auth",
6
+ ACCOUNT: "https://linkfeed.pro/account",
7
+ PRICING: "https://linkfeed.pro/pricing",
8
+ UPGRADE: "https://linkfeed.pro/pricing"
9
+ };
@@ -0,0 +1,173 @@
1
+ export type DeviceMeta = {
2
+ label?: string;
3
+ osName?: string;
4
+ osVersion?: string;
5
+ browserName?: string;
6
+ browserVersion?: string;
7
+ };
8
+
9
+ type UADataBrand = { brand: string; version: string };
10
+
11
+ type UAData = {
12
+ platform?: string;
13
+ brands?: UADataBrand[];
14
+ getHighEntropyValues?: (hints: string[]) => Promise<{
15
+ platform?: string;
16
+ platformVersion?: string;
17
+ fullVersionList?: UADataBrand[];
18
+ }>;
19
+ };
20
+
21
+ const BRAND_PREFERENCE = [
22
+ 'Microsoft Edge',
23
+ 'Brave',
24
+ 'Google Chrome',
25
+ 'Chrome',
26
+ 'Chromium',
27
+ ];
28
+
29
+ function normalizeBrand(brand: string): string {
30
+ if (/microsoft edge/i.test(brand)) return 'Edge';
31
+ if (/brave/i.test(brand)) return 'Brave';
32
+ if (/google chrome/i.test(brand)) return 'Chrome';
33
+ if (/chromium/i.test(brand)) return 'Chrome';
34
+ if (/chrome/i.test(brand)) return 'Chrome';
35
+ if (/firefox/i.test(brand)) return 'Firefox';
36
+ if (/safari/i.test(brand)) return 'Safari';
37
+ return brand;
38
+ }
39
+
40
+ function parseMajor(version?: string): string | undefined {
41
+ if (!version) return undefined;
42
+ const major = version.split('.')[0];
43
+ return major || undefined;
44
+ }
45
+
46
+ function pickBrand(brands?: UADataBrand[]): { name?: string; version?: string } {
47
+ if (!brands?.length) return {};
48
+ const filtered = brands.filter((brand) => !/not.?a.?brand/i.test(brand.brand));
49
+ if (!filtered.length) return {};
50
+
51
+ const sorted = [...filtered].sort((a, b) => {
52
+ const aIndex = BRAND_PREFERENCE.findIndex((preferred) => preferred.toLowerCase() === a.brand.toLowerCase());
53
+ const bIndex = BRAND_PREFERENCE.findIndex((preferred) => preferred.toLowerCase() === b.brand.toLowerCase());
54
+ return (aIndex === -1 ? 999 : aIndex) - (bIndex === -1 ? 999 : bIndex);
55
+ });
56
+
57
+ const pick = sorted[0];
58
+ return pick ? { name: normalizeBrand(pick.brand), version: parseMajor(pick.version) } : {};
59
+ }
60
+
61
+ function detectBrowserFromUA(ua: string): { name?: string; version?: string } {
62
+ const edge = ua.match(/Edg\/([\d.]+)/);
63
+ if (edge) return { name: 'Edge', version: parseMajor(edge[1]) };
64
+
65
+ const chrome = ua.match(/Chrome\/([\d.]+)/);
66
+ if (chrome) return { name: 'Chrome', version: parseMajor(chrome[1]) };
67
+
68
+ const firefox = ua.match(/Firefox\/([\d.]+)/);
69
+ if (firefox) return { name: 'Firefox', version: parseMajor(firefox[1]) };
70
+
71
+ const safari = ua.match(/Version\/([\d.]+).*Safari/);
72
+ if (safari) return { name: 'Safari', version: parseMajor(safari[1]) };
73
+
74
+ return {};
75
+ }
76
+
77
+ function detectOsFromUA(ua: string): { name?: string; device?: string } {
78
+ if (/iPad/i.test(ua)) return { name: 'iOS', device: 'iPad' };
79
+ if (/iPhone/i.test(ua)) return { name: 'iOS', device: 'iPhone' };
80
+
81
+ if (/Android/i.test(ua)) return { name: 'Android' };
82
+ if (/Mac OS X|Macintosh/i.test(ua)) return { name: 'macOS' };
83
+ if (/Windows NT/i.test(ua)) return { name: 'Windows' };
84
+ if (/CrOS/i.test(ua)) return { name: 'ChromeOS' };
85
+ if (/Linux/i.test(ua)) return { name: 'Linux' };
86
+
87
+ return {};
88
+ }
89
+
90
+ function normalizeOsName(osName?: string): string | undefined {
91
+ if (!osName) return undefined;
92
+ if (/mac/i.test(osName)) return 'macOS';
93
+ if (/windows/i.test(osName)) return 'Windows';
94
+ if (/android/i.test(osName)) return 'Android';
95
+ if (/ios/i.test(osName)) return 'iOS';
96
+ if (/linux/i.test(osName)) return 'Linux';
97
+ if (/chrome os/i.test(osName)) return 'ChromeOS';
98
+ return osName;
99
+ }
100
+
101
+ function formatOsVersion(osName: string | undefined, platformVersion?: string): string | undefined {
102
+ const major = parseMajor(platformVersion);
103
+ if (!major) return undefined;
104
+
105
+ if (osName === 'Windows') {
106
+ if (major === '15') return '11';
107
+ if (major === '10') return '10';
108
+ return major;
109
+ }
110
+
111
+ return major;
112
+ }
113
+
114
+ function labelForOs(osName?: string, deviceHint?: string): string | undefined {
115
+ if (deviceHint) return deviceHint;
116
+ if (!osName) return undefined;
117
+ if (osName === 'macOS') return 'Mac';
118
+ if (osName === 'Windows') return 'Windows PC';
119
+ if (osName === 'Linux') return 'Linux PC';
120
+ if (osName === 'ChromeOS') return 'Chromebook';
121
+ if (osName === 'Android') return 'Android';
122
+ if (osName === 'iOS') return 'iPhone';
123
+ return osName;
124
+ }
125
+
126
+ export async function getDeviceMetadata(): Promise<DeviceMeta | null> {
127
+ if (typeof navigator === 'undefined') return null;
128
+
129
+ const ua = navigator.userAgent || '';
130
+ const uaData = (navigator as Navigator & { userAgentData?: UAData }).userAgentData;
131
+
132
+ let platform = uaData?.platform;
133
+ let platformVersion: string | undefined;
134
+ let fullVersionList: UADataBrand[] | undefined;
135
+
136
+ if (uaData?.getHighEntropyValues) {
137
+ try {
138
+ const highEntropy = await uaData.getHighEntropyValues([
139
+ 'platform',
140
+ 'platformVersion',
141
+ 'fullVersionList',
142
+ ]);
143
+ platform = highEntropy.platform || platform;
144
+ platformVersion = highEntropy.platformVersion;
145
+ fullVersionList = highEntropy.fullVersionList;
146
+ } catch {
147
+ // Ignore and fall back to UA parsing
148
+ }
149
+ }
150
+
151
+ const osFromUa = detectOsFromUA(ua);
152
+ const osName = normalizeOsName(platform || osFromUa.name);
153
+ const osVersion = formatOsVersion(osName, platformVersion);
154
+
155
+ const brandFromUaData = pickBrand(fullVersionList || uaData?.brands);
156
+ const browserFromUa = detectBrowserFromUA(ua);
157
+
158
+ const browserName = brandFromUaData.name || browserFromUa.name;
159
+ const browserVersion = brandFromUaData.version || browserFromUa.version;
160
+
161
+ const label = labelForOs(osName, osFromUa.device);
162
+
163
+ const meta: DeviceMeta = {
164
+ label,
165
+ osName,
166
+ osVersion,
167
+ browserName,
168
+ browserVersion,
169
+ };
170
+
171
+ const hasValue = Object.values(meta).some((value) => !!value);
172
+ return hasValue ? meta : null;
173
+ }
package/lib/i18n.ts ADDED
@@ -0,0 +1,201 @@
1
+ import { browser } from "#imports";
2
+ import { uiLocaleStorage, type UiLocalePreference } from "./storage";
3
+
4
+ export const SUPPORTED_LOCALES = [
5
+ "en",
6
+ "fr",
7
+ "de",
8
+ "es",
9
+ "pt-pt",
10
+ "pt-br",
11
+ "it",
12
+ "nl",
13
+ "pl",
14
+ "tr",
15
+ "id",
16
+ "hi",
17
+ ] as const;
18
+
19
+ export type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
20
+
21
+ type LocaleMessages = Record<
22
+ string,
23
+ {
24
+ message: string;
25
+ placeholders?: Record<string, { content: string }>;
26
+ }
27
+ >;
28
+
29
+ type LocaleOption = {
30
+ value: SupportedLocale;
31
+ flag: string;
32
+ label: string;
33
+ };
34
+
35
+ const DEFAULT_LOCALE: SupportedLocale = "en";
36
+ const localeSet = new Set<string>(SUPPORTED_LOCALES);
37
+ const localeOptions: LocaleOption[] = [
38
+ { value: "en", flag: "🇬🇧", label: "English (United Kingdom)" },
39
+ { value: "fr", flag: "🇫🇷", label: "Français (France)" },
40
+ { value: "de", flag: "🇩🇪", label: "Deutsch (Deutschland)" },
41
+ { value: "es", flag: "🇪🇸", label: "Español (España)" },
42
+ { value: "pt-pt", flag: "🇵🇹", label: "Português (Portugal)" },
43
+ { value: "pt-br", flag: "🇧🇷", label: "Português (Brasil)" },
44
+ { value: "it", flag: "🇮🇹", label: "Italiano (Italia)" },
45
+ { value: "nl", flag: "🇳🇱", label: "Nederlands (Nederland)" },
46
+ { value: "pl", flag: "🇵🇱", label: "Polski (Polska)" },
47
+ { value: "tr", flag: "🇹🇷", label: "Türkçe (Türkiye)" },
48
+ { value: "id", flag: "🇮🇩", label: "Bahasa Indonesia" },
49
+ { value: "hi", flag: "🇮🇳", label: "हिंदी (भारत)" },
50
+ ];
51
+
52
+ let initialized = false;
53
+ let initializing: Promise<void> | null = null;
54
+ let activeLocale: SupportedLocale = DEFAULT_LOCALE;
55
+ let activeMessages: LocaleMessages = {};
56
+ let fallbackMessages: LocaleMessages = {};
57
+
58
+ function normalizeLocale(input: string | null | undefined): SupportedLocale | null {
59
+ if (!input) {
60
+ return null;
61
+ }
62
+
63
+ const normalized = input.trim().toLowerCase();
64
+ if (!normalized) {
65
+ return null;
66
+ }
67
+
68
+ const canonical = normalized.replace(/_/g, "-");
69
+ if (localeSet.has(canonical)) {
70
+ return canonical as SupportedLocale;
71
+ }
72
+
73
+ const base = canonical.split("-")[0];
74
+ if (base === "pt") {
75
+ return "pt-pt";
76
+ }
77
+
78
+ if (base && localeSet.has(base)) {
79
+ return base as SupportedLocale;
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ async function loadLocaleMessages(locale: SupportedLocale): Promise<LocaleMessages> {
86
+ try {
87
+ const localeDir = locale === "pt-br" ? "pt_BR" : locale === "pt-pt" ? "pt_PT" : locale;
88
+ const url = browser.runtime.getURL(`/_locales/${localeDir}/messages.json` as never);
89
+ const response = await fetch(url);
90
+ if (!response.ok) {
91
+ return {};
92
+ }
93
+ const payload = (await response.json()) as LocaleMessages;
94
+ return payload ?? {};
95
+ } catch {
96
+ return {};
97
+ }
98
+ }
99
+
100
+ function applySubstitutions(
101
+ message: string,
102
+ substitutions?: string | number | Array<string | number>
103
+ ): string {
104
+ if (substitutions === undefined || substitutions === null) {
105
+ return message;
106
+ }
107
+
108
+ const values = Array.isArray(substitutions) ? substitutions : [substitutions];
109
+ let index = 0;
110
+
111
+ return message.replace(/\$[A-Z0-9_]+\$/g, () => {
112
+ const value = values[index];
113
+ index += 1;
114
+ return value !== undefined && value !== null ? String(value) : "";
115
+ });
116
+ }
117
+
118
+ function getBrowserPreferredLocale(): SupportedLocale {
119
+ const uiLanguage = browser.i18n.getUILanguage?.();
120
+ return normalizeLocale(uiLanguage) ?? DEFAULT_LOCALE;
121
+ }
122
+
123
+ async function resolveActiveLocale(): Promise<SupportedLocale> {
124
+ const preference = await uiLocaleStorage.getValue();
125
+ if (preference && preference !== "auto") {
126
+ return normalizeLocale(preference) ?? DEFAULT_LOCALE;
127
+ }
128
+ return getBrowserPreferredLocale();
129
+ }
130
+
131
+ async function initializeInternal() {
132
+ fallbackMessages = await loadLocaleMessages(DEFAULT_LOCALE);
133
+ activeLocale = await resolveActiveLocale();
134
+ activeMessages =
135
+ activeLocale === DEFAULT_LOCALE
136
+ ? fallbackMessages
137
+ : await loadLocaleMessages(activeLocale);
138
+
139
+ initialized = true;
140
+ }
141
+
142
+ export async function initLocalization() {
143
+ if (initialized) {
144
+ return;
145
+ }
146
+
147
+ if (!initializing) {
148
+ initializing = initializeInternal().finally(() => {
149
+ initializing = null;
150
+ });
151
+ }
152
+
153
+ await initializing;
154
+ }
155
+
156
+ export async function refreshLocalization() {
157
+ initialized = false;
158
+ activeMessages = {};
159
+ fallbackMessages = {};
160
+ await initLocalization();
161
+ }
162
+
163
+ export function t(
164
+ key: string,
165
+ substitutions?: string | number | Array<string | number>
166
+ ): string {
167
+ const message =
168
+ activeMessages[key]?.message ??
169
+ fallbackMessages[key]?.message ??
170
+ browser.i18n.getMessage(
171
+ key as never,
172
+ Array.isArray(substitutions)
173
+ ? substitutions.map((value) => String(value))
174
+ : substitutions !== undefined && substitutions !== null
175
+ ? [String(substitutions)]
176
+ : undefined
177
+ );
178
+
179
+ if (!message) {
180
+ return key;
181
+ }
182
+
183
+ return applySubstitutions(message, substitutions);
184
+ }
185
+
186
+ export function getActiveLocale(): SupportedLocale {
187
+ return activeLocale;
188
+ }
189
+
190
+ export async function getLocalePreference(): Promise<UiLocalePreference> {
191
+ return await uiLocaleStorage.getValue();
192
+ }
193
+
194
+ export async function setLocalePreference(next: UiLocalePreference) {
195
+ await uiLocaleStorage.setValue(next);
196
+ await refreshLocalization();
197
+ }
198
+
199
+ export function getLocaleOptions(): LocaleOption[] {
200
+ return localeOptions;
201
+ }