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
package/lib/license.ts ADDED
@@ -0,0 +1,470 @@
1
+ import type { LicenseState, CachedLicenseState, FeatureFlags } from "@linkfeed/shared";
2
+ import { OFFLINE_GRACE_HOURS } from "@linkfeed/shared";
3
+ import { deviceIdStorage, userEmailStorage, cachedLicenseStorage, deviceTokenStorage, licenseRefreshStorage } from "./storage";
4
+ import { API_BASE_URL } from "./constants";
5
+ import { getDeviceMetadata } from "./device-meta";
6
+ import { t } from "./i18n";
7
+ import { browser } from "#imports";
8
+ import { z } from "zod";
9
+
10
+ const FeatureFlagsSchema = z.object({
11
+ wideFeed: z.boolean(),
12
+ fontSize: z.boolean(),
13
+ autoExpandPosts: z.boolean(),
14
+ });
15
+
16
+ const LicenseStateSchema = z.object({
17
+ status: z.enum(["active", "trial-active", "trial-expired", "revoked"]),
18
+ features: FeatureFlagsSchema,
19
+ expiresAt: z.string().optional(),
20
+ });
21
+
22
+ const FREE_FEATURES: FeatureFlags = {
23
+ wideFeed: false,
24
+ fontSize: false,
25
+ autoExpandPosts: false,
26
+ };
27
+
28
+ const IS_SERVICE_WORKER = typeof window === "undefined";
29
+ const ACTIVE_CACHE_TTL_HOURS = 24;
30
+ const TRIAL_CACHE_TTL_HOURS = 1;
31
+ const REVOKED_CACHE_TTL_HOURS = 6;
32
+ const ERROR_BACKOFF_MS = [5 * 60 * 1000, 30 * 60 * 1000, 6 * 60 * 60 * 1000];
33
+ const MAX_FAILURE_COUNT = ERROR_BACKOFF_MS.length;
34
+
35
+ type ApiResponse<T> = {
36
+ ok: boolean;
37
+ status: number;
38
+ data: T | null;
39
+ };
40
+
41
+ async function apiFetchJson<T>(path: string, init?: RequestInit): Promise<ApiResponse<T>> {
42
+ const url = `${API_BASE_URL}${path.startsWith("/") ? "" : "/"}${path}`;
43
+ const headers = new Headers(init?.headers ?? undefined);
44
+ headers.set("x-linkfeed-extension-version", browser.runtime.getManifest().version);
45
+
46
+ if (IS_SERVICE_WORKER) {
47
+ const response = await fetch(url, {
48
+ ...init,
49
+ headers,
50
+ });
51
+ const data = (await response.json().catch(() => null)) as T | null;
52
+ return { ok: response.ok, status: response.status, data };
53
+ }
54
+
55
+ const serializedHeaders: Record<string, string> = {};
56
+ headers.forEach((value, key) => {
57
+ serializedHeaders[key] = value;
58
+ });
59
+
60
+ const payload = {
61
+ type: "linkfeed_api_fetch",
62
+ url,
63
+ method: init?.method || "GET",
64
+ headers: serializedHeaders,
65
+ body: typeof init?.body === "string" ? init.body : undefined,
66
+ };
67
+
68
+ const result = await browser.runtime.sendMessage(payload) as ApiResponse<T> | undefined;
69
+ if (!result) {
70
+ return { ok: false, status: 0, data: null };
71
+ }
72
+ return result;
73
+ }
74
+
75
+ type LicenseFetchOptions = { force?: boolean };
76
+
77
+ async function requestLicenseStateFromBackground(options?: LicenseFetchOptions): Promise<LicenseState | null> {
78
+ try {
79
+ const result = await browser.runtime.sendMessage({
80
+ type: "linkfeed_license_refresh",
81
+ force: Boolean(options?.force),
82
+ }) as LicenseState | null;
83
+ return result ?? null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Generate or retrieve a persistent device ID
91
+ */
92
+ export async function getDeviceId(): Promise<string> {
93
+ return (await deviceIdStorage.getValue()) as string;
94
+ }
95
+
96
+ /**
97
+ * Get stored user email
98
+ */
99
+ export async function getUserEmail(): Promise<string | null> {
100
+ return await userEmailStorage.getValue();
101
+ }
102
+
103
+ /**
104
+ * Store user email
105
+ */
106
+ export async function setUserEmail(email: string): Promise<void> {
107
+ await userEmailStorage.setValue(email);
108
+ }
109
+
110
+ /**
111
+ * Get cached license state
112
+ */
113
+ export async function getCachedLicense(): Promise<CachedLicenseState | null> {
114
+ return (await cachedLicenseStorage.getValue()) as CachedLicenseState | null;
115
+ }
116
+
117
+ /**
118
+ * Cache license state
119
+ */
120
+ async function cacheLicenseState(state: LicenseState): Promise<void> {
121
+ const cached: CachedLicenseState = {
122
+ ...state,
123
+ cachedAt: Date.now(),
124
+ };
125
+ await cachedLicenseStorage.setValue(cached);
126
+ }
127
+
128
+ /**
129
+ * Check if cached license is still valid (within offline grace period)
130
+ */
131
+ function isCacheValid(cached: CachedLicenseState): boolean {
132
+ const graceMs = OFFLINE_GRACE_HOURS * 60 * 60 * 1000;
133
+ return Date.now() - cached.cachedAt < graceMs;
134
+ }
135
+
136
+ function getCacheTtlMs(cached: CachedLicenseState): number {
137
+ if (cached.status === "trial-active") return TRIAL_CACHE_TTL_HOURS * 60 * 60 * 1000;
138
+ if (cached.status === "active") return ACTIVE_CACHE_TTL_HOURS * 60 * 60 * 1000;
139
+ return REVOKED_CACHE_TTL_HOURS * 60 * 60 * 1000;
140
+ }
141
+
142
+ function isCacheFresh(cached: CachedLicenseState): boolean {
143
+ return Date.now() - cached.cachedAt < getCacheTtlMs(cached);
144
+ }
145
+
146
+ async function getRefreshState() {
147
+ return await licenseRefreshStorage.getValue();
148
+ }
149
+
150
+ async function setRefreshState(state: { failureCount: number; retryAfter: number }) {
151
+ await licenseRefreshStorage.setValue(state);
152
+ }
153
+
154
+ function getBackoffDelayMs(failureCount: number): number {
155
+ const index = Math.min(Math.max(failureCount - 1, 0), ERROR_BACKOFF_MS.length - 1);
156
+ return ERROR_BACKOFF_MS[index];
157
+ }
158
+
159
+ /**
160
+ * Fetch license status from API
161
+ */
162
+ export async function fetchLicenseStatus(options?: LicenseFetchOptions): Promise<LicenseState> {
163
+ const deviceToken = await deviceTokenStorage.getValue();
164
+ const cached = await getCachedLicense();
165
+ const refreshState = await getRefreshState();
166
+ const force = Boolean(options?.force);
167
+
168
+ if (!deviceToken) {
169
+ // Not logged in - return free features
170
+ return {
171
+ status: "revoked",
172
+ features: FREE_FEATURES,
173
+ };
174
+ }
175
+
176
+ // If cache is still fresh, return it without hitting the network
177
+ if (!force && cached && isCacheFresh(cached)) {
178
+ return cached;
179
+ }
180
+
181
+ // Respect global backoff after previous failures
182
+ if (!force && refreshState.retryAfter && Date.now() < refreshState.retryAfter) {
183
+ if (cached && isCacheValid(cached)) return cached;
184
+ return {
185
+ status: "revoked",
186
+ features: FREE_FEATURES,
187
+ };
188
+ }
189
+
190
+ // If offline, immediately use cache if possible
191
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
192
+ if (cached && isCacheValid(cached)) {
193
+ console.log("Offline: Using cached license");
194
+ return cached;
195
+ }
196
+ }
197
+
198
+ try {
199
+ const url = new URL(`${API_BASE_URL}/api/license/status`);
200
+ url.searchParams.set("deviceToken", deviceToken);
201
+
202
+ const deviceMeta = await getDeviceMetadata();
203
+ const response = await apiFetchJson<LicenseState>(`/api/license/status?deviceToken=${encodeURIComponent(deviceToken)}`, {
204
+ method: "GET",
205
+ headers: {
206
+ "Content-Type": "application/json",
207
+ ...(deviceMeta ? { "X-Linkfeed-Device-Meta": JSON.stringify(deviceMeta) } : {}),
208
+ },
209
+ });
210
+
211
+ if (!response.ok || !response.data) {
212
+ const failureCount = Math.min(refreshState.failureCount + 1, MAX_FAILURE_COUNT);
213
+ const retryAfter = Date.now() + getBackoffDelayMs(failureCount);
214
+ await setRefreshState({ failureCount, retryAfter });
215
+ if (cached && isCacheValid(cached)) {
216
+ return cached;
217
+ }
218
+ return {
219
+ status: "revoked",
220
+ features: FREE_FEATURES,
221
+ };
222
+ }
223
+
224
+ const data = LicenseStateSchema.parse(response.data);
225
+ await cacheLicenseState(data);
226
+ await setRefreshState({ failureCount: 0, retryAfter: 0 });
227
+ return data;
228
+ } catch (error) {
229
+ const failureCount = Math.min(refreshState.failureCount + 1, MAX_FAILURE_COUNT);
230
+ const retryAfter = Date.now() + getBackoffDelayMs(failureCount);
231
+ await setRefreshState({ failureCount, retryAfter });
232
+
233
+ // Try to use cached license
234
+ if (cached && isCacheValid(cached)) {
235
+ return cached;
236
+ }
237
+
238
+ // Fallback to free features
239
+ return {
240
+ status: "revoked",
241
+ features: FREE_FEATURES,
242
+ };
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Get current license state (with offline grace)
248
+ */
249
+ export async function getLicenseState(options?: LicenseFetchOptions): Promise<LicenseState> {
250
+ if (IS_SERVICE_WORKER) {
251
+ return fetchLicenseStatus(options);
252
+ }
253
+
254
+ const fromBackground = await requestLicenseStateFromBackground(options);
255
+ if (fromBackground) return fromBackground;
256
+
257
+ const cached = await getCachedLicense();
258
+ if (cached) return cached;
259
+
260
+ return {
261
+ status: "revoked",
262
+ features: FREE_FEATURES,
263
+ };
264
+ }
265
+
266
+ /**
267
+ * Check if a specific feature is enabled
268
+ */
269
+ export async function isFeatureEnabled(feature: keyof FeatureFlags): Promise<boolean> {
270
+ const state = await getLicenseState();
271
+ return state.features[feature];
272
+ }
273
+
274
+ /**
275
+ * Start trial via API
276
+ */
277
+ export async function startTrial(email: string): Promise<{ success: boolean; message: string; expiresAt?: string }> {
278
+ const result = await requestLicenseLink(email);
279
+ return {
280
+ success: result.success,
281
+ message: result.message,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Clear stored license data (for logout)
287
+ */
288
+ export async function clearLicenseData(): Promise<void> {
289
+ await Promise.all([
290
+ userEmailStorage.removeValue(),
291
+ cachedLicenseStorage.removeValue(),
292
+ deviceTokenStorage.removeValue(),
293
+ licenseRefreshStorage.removeValue(),
294
+ ]);
295
+ }
296
+
297
+ export async function setDeviceToken(token: string): Promise<void> {
298
+ await deviceTokenStorage.setValue(token);
299
+ }
300
+
301
+ export async function getDeviceToken(): Promise<string | null> {
302
+ return await deviceTokenStorage.getValue();
303
+ }
304
+
305
+ export async function requestLicenseLink(email: string): Promise<{ success: boolean; message: string; activationToken?: string }> {
306
+ const deviceId = await getDeviceId();
307
+ const deviceMeta = await getDeviceMetadata();
308
+
309
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
310
+ return {
311
+ success: false,
312
+ message: t('errorNoInternet'),
313
+ };
314
+ }
315
+
316
+ try {
317
+ const response = await apiFetchJson<{ success: boolean; message?: string; activationToken?: string; error?: string }>(`/api/license/link/request`, {
318
+ method: "POST",
319
+ headers: { "Content-Type": "application/json" },
320
+ body: JSON.stringify({
321
+ email,
322
+ deviceId,
323
+ ...(deviceMeta ? { deviceMeta } : {}),
324
+ }),
325
+ });
326
+
327
+ const rawData = (response.data ?? {}) as {
328
+ success?: boolean;
329
+ message?: string;
330
+ activationToken?: string;
331
+ error?: string;
332
+ };
333
+ if (!response.ok) {
334
+ return {
335
+ success: false,
336
+ message: rawData.error || "Failed to send link",
337
+ };
338
+ }
339
+
340
+ return {
341
+ success: true,
342
+ message: rawData.message || "Check your email to confirm",
343
+ activationToken: rawData.activationToken,
344
+ };
345
+ } catch (error) {
346
+ console.error("Failed to request license link:", error);
347
+ return {
348
+ success: false,
349
+ message: error instanceof z.ZodError ? t('errorServerInvalid') : t('errorConnectFailed'),
350
+ };
351
+ }
352
+ }
353
+
354
+ export async function activateLicenseLink(activationToken: string): Promise<{ success: boolean; message: string; status?: string; license?: LicenseState; deviceToken?: string }> {
355
+ const deviceId = await getDeviceId();
356
+
357
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
358
+ return {
359
+ success: false,
360
+ message: t('errorNoInternet'),
361
+ };
362
+ }
363
+
364
+ try {
365
+ const deviceMeta = await getDeviceMetadata();
366
+ const payload = deviceMeta
367
+ ? { activationToken, deviceId, deviceMeta }
368
+ : { activationToken, deviceId };
369
+ const response = await apiFetchJson<{ success?: boolean; message?: string; status?: string; license?: LicenseState; deviceToken?: string; email?: string; error?: string }>(`/api/license/link/activate`, {
370
+ method: "POST",
371
+ headers: { "Content-Type": "application/json" },
372
+ body: JSON.stringify(payload),
373
+ });
374
+
375
+ const rawData = (response.data ?? {}) as {
376
+ success?: boolean;
377
+ message?: string;
378
+ status?: string;
379
+ license?: LicenseState;
380
+ deviceToken?: string;
381
+ email?: string;
382
+ error?: string;
383
+ };
384
+ if (!response.ok) {
385
+ return {
386
+ success: false,
387
+ message: rawData.error || "Failed to activate license",
388
+ status: rawData.status,
389
+ };
390
+ }
391
+
392
+ if (rawData.deviceToken) {
393
+ await setDeviceToken(rawData.deviceToken);
394
+ }
395
+ if (rawData.email) {
396
+ await setUserEmail(rawData.email);
397
+ }
398
+ if (rawData.license) {
399
+ await cacheLicenseState(rawData.license);
400
+ }
401
+
402
+ return {
403
+ success: true,
404
+ message: "License linked",
405
+ license: rawData.license,
406
+ deviceToken: rawData.deviceToken,
407
+ };
408
+ } catch (error) {
409
+ console.error("Failed to activate license:", error);
410
+ return {
411
+ success: false,
412
+ message: error instanceof z.ZodError ? t('errorServerInvalid') : t('errorConnectFailed'),
413
+ };
414
+ }
415
+ }
416
+
417
+ export async function createStripeCheckoutForEmail(
418
+ email: string,
419
+ options?: {
420
+ planType?: 'subscription' | 'lifetime';
421
+ billingCycle?: 'monthly' | 'yearly';
422
+ currency?: 'USD' | 'EUR';
423
+ }
424
+ ): Promise<{ success: boolean; url?: string; message?: string }> {
425
+ if (typeof navigator !== "undefined" && !navigator.onLine) {
426
+ return {
427
+ success: false,
428
+ message: t("errorNoInternet"),
429
+ };
430
+ }
431
+
432
+ try {
433
+ const response = await apiFetchJson<{ url?: string; error?: string }>(`/api/stripe/checkout-email`, {
434
+ method: "POST",
435
+ headers: { "Content-Type": "application/json" },
436
+ body: JSON.stringify({
437
+ email,
438
+ planType: options?.planType ?? 'subscription',
439
+ billingCycle: options?.billingCycle ?? 'monthly',
440
+ currency: options?.currency ?? 'USD',
441
+ }),
442
+ });
443
+
444
+ const rawData = (response.data ?? {}) as { url?: string; error?: string };
445
+ if (!response.ok) {
446
+ return {
447
+ success: false,
448
+ message: rawData.error || t("errorConnectFailed"),
449
+ };
450
+ }
451
+
452
+ if (!rawData.url) {
453
+ return {
454
+ success: false,
455
+ message: t("errorServerInvalid"),
456
+ };
457
+ }
458
+
459
+ return {
460
+ success: true,
461
+ url: rawData.url,
462
+ };
463
+ } catch (error) {
464
+ console.error("Failed to create Stripe checkout:", error);
465
+ return {
466
+ success: false,
467
+ message: t("errorConnectFailed"),
468
+ };
469
+ }
470
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Centralized LinkedIn specific selectors for resilience
3
+ * If LinkedIn updates their UI, we only need to update this file.
4
+ */
5
+ export const LNK_SELECTORS = {
6
+ // Layout
7
+ SCROLL_CONTENT: '.scaffold-finite-scroll__content',
8
+ MAIN_GRID: '.scaffold-layout__content',
9
+ FEED_ITEM: '.scaffold-finite-scroll__content-item, .feed-shared-update-v2, .feed-shared-update-v1',
10
+ FEED_SKIP_LINK: 'h2.feed-skip-link__container',
11
+ SIDEBAR_RIGHT: '.scaffold-layout__aside',
12
+ SIDEBAR_LEFT: '.scaffold-layout__sidebar',
13
+ NAV_BAR: '.global-nav, header[role="banner"]',
14
+
15
+ // Components
16
+ SEE_MORE_BTN: '.feed-shared-inline-show-more-text__see-more-less-toggle',
17
+ POST_CONTAINER: '.feed-shared-update-v2, .feed-shared-update-v1',
18
+ ACTION_BUTTON: 'button, a[role="button"], span[role="button"]',
19
+ SHARE_BOX: '.share-box-feed-entry__closed-share-box, .share-box-feed-entry',
20
+ MESSENGER: '.msg-overlay-container, aside.msg-overlay-list-bubble, .msg-overlay-bubble-header',
21
+
22
+ // Text
23
+ POST_TEXT: '.feed-shared-update-v2__description, .feed-shared-text, .update-components-text'
24
+ };
package/lib/storage.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { storage } from '#imports';
2
+ import type { CachedLicenseState } from '@linkfeed/shared';
3
+
4
+ // ============================
5
+ // SETTINGS
6
+ // ============================
7
+ export interface Settings {
8
+ globalEnabled: boolean;
9
+ hideSidebars: boolean;
10
+ hidePromoted: boolean;
11
+ feedWidth: number;
12
+ feedSpacing: number;
13
+ autoExpandPosts: boolean;
14
+ hideRemovedFeedCards: boolean;
15
+ hideStartPost: boolean;
16
+ hideMessenger: boolean;
17
+ hideNavBar: boolean;
18
+ fontSize: number;
19
+ }
20
+
21
+ export type UiLocalePreference =
22
+ | "auto"
23
+ | "en"
24
+ | "fr"
25
+ | "de"
26
+ | "es"
27
+ | "pt-pt"
28
+ | "pt-br"
29
+ | "it"
30
+ | "nl"
31
+ | "pl"
32
+ | "tr"
33
+ | "id"
34
+ | "hi";
35
+
36
+ export const DEFAULT_SETTINGS: Settings = {
37
+ globalEnabled: true,
38
+ hideSidebars: true,
39
+ hidePromoted: false,
40
+ feedWidth: 1500,
41
+ feedSpacing: 40,
42
+ autoExpandPosts: true,
43
+ hideRemovedFeedCards: true,
44
+ hideStartPost: false,
45
+ hideMessenger: false,
46
+ hideNavBar: false,
47
+ fontSize: 20,
48
+ };
49
+
50
+ export const settingsStorage = storage.defineItem<Settings>('local:settings', {
51
+ fallback: DEFAULT_SETTINGS,
52
+ });
53
+
54
+ // ============================
55
+ // IDENTITY & LICENSE
56
+ // ============================
57
+
58
+ export const deviceIdStorage = storage.defineItem<string>('local:deviceId', {
59
+ init: () => crypto.randomUUID(),
60
+ });
61
+
62
+ export const userEmailStorage = storage.defineItem<string | null>('local:userEmail', {
63
+ fallback: null,
64
+ });
65
+
66
+ export const cachedLicenseStorage = storage.defineItem<CachedLicenseState | null>('local:licenseState', {
67
+ fallback: null,
68
+ });
69
+
70
+ export const deviceTokenStorage = storage.defineItem<string | null>('local:deviceToken', {
71
+ fallback: null,
72
+ });
73
+
74
+ export interface LicenseRefreshState {
75
+ failureCount: number;
76
+ retryAfter: number;
77
+ }
78
+
79
+ export const licenseRefreshStorage = storage.defineItem<LicenseRefreshState>('local:licenseRefresh', {
80
+ fallback: { failureCount: 0, retryAfter: 0 },
81
+ });
82
+
83
+ // Telemetry state (internal)
84
+ export const telemetryIdentitySentStorage = storage.defineItem<{ email: string | null; status: string | null }>('local:telemetry:identitySent', {
85
+ fallback: { email: null, status: null }
86
+ });
87
+
88
+ // Pending Trial Flow state
89
+ export const pendingTrialStorage = storage.defineItem<{ email: string; activationToken: string; timestamp: number } | null>('local:pendingTrial', {
90
+ fallback: null,
91
+ });
92
+
93
+ export const uiLocaleStorage = storage.defineItem<UiLocalePreference>('local:uiLocalePreference', {
94
+ fallback: "auto",
95
+ });
@@ -0,0 +1,94 @@
1
+ import { init } from '@extension-report/js';
2
+ import { userEmailStorage, cachedLicenseStorage, settingsStorage, telemetryIdentitySentStorage } from './storage';
3
+
4
+ const PROJECT_PUBLIC_KEY = 'pk_er_80fa598197dccecd7219fa262c47e6219005';
5
+
6
+ export const sdk = init({
7
+ projectPublicKey: PROJECT_PUBLIC_KEY,
8
+ app: {
9
+ type: 'extension'
10
+ }
11
+ });
12
+
13
+ interface UserIdentity {
14
+ email: string | null;
15
+ status: string | null; // free / trial / pro
16
+ }
17
+
18
+ export async function syncIdentity(force = false) {
19
+ try {
20
+ const email = await userEmailStorage.getValue();
21
+ const license = await cachedLicenseStorage.getValue();
22
+
23
+ let status = 'free';
24
+ if (license?.status === 'active') status = 'pro';
25
+ else if (license?.status === 'trial-active') status = 'trial';
26
+ else if (license?.status === 'trial-expired') status = 'expired';
27
+
28
+ const currentIdentity: UserIdentity = {
29
+ email: email,
30
+ status: status
31
+ };
32
+
33
+ const lastSent = await telemetryIdentitySentStorage.getValue();
34
+
35
+ // Check if identity changed
36
+ const changed =
37
+ currentIdentity.email !== lastSent.email ||
38
+ currentIdentity.status !== lastSent.status;
39
+
40
+ if (!currentIdentity.email) {
41
+ return;
42
+ }
43
+
44
+ if (changed || force) {
45
+ await sdk.track('user_identify', {
46
+ user_id: currentIdentity.email,
47
+ email: currentIdentity.email,
48
+ status: currentIdentity.status,
49
+ traits: {
50
+ license_status: currentIdentity.status
51
+ }
52
+ });
53
+
54
+ await telemetryIdentitySentStorage.setValue(currentIdentity);
55
+ }
56
+ } catch (err) {
57
+ console.warn('[Telemetry] Failed to sync identity', err);
58
+ }
59
+ }
60
+
61
+ export async function getDailyPayload() {
62
+ try {
63
+ const settings = await settingsStorage.getValue();
64
+ const email = await userEmailStorage.getValue();
65
+ const license = await cachedLicenseStorage.getValue();
66
+
67
+ let status = 'free';
68
+ if (license?.status === 'active') status = 'pro';
69
+ else if (license?.status === 'trial-active') status = 'trial';
70
+ else if (license?.status === 'trial-expired') status = 'expired';
71
+
72
+ const hideSidebars = settings.hideSidebars ?? (settings as { removeSidebars?: boolean }).removeSidebars;
73
+
74
+ return {
75
+ settings: {
76
+ hideSidebars: Boolean(hideSidebars),
77
+ hidePromoted: Boolean(settings.hidePromoted),
78
+ hideStartPost: Boolean(settings.hideStartPost),
79
+ hideMessenger: Boolean(settings.hideMessenger),
80
+ autoExpandPosts: Boolean(settings.autoExpandPosts),
81
+ textSize: Number(settings.fontSize || 16),
82
+ feedWidth: Number(settings.feedWidth || 800),
83
+ feedSpacing: Number(settings.feedSpacing || 24)
84
+ },
85
+ user: {
86
+ email: email,
87
+ status: status
88
+ }
89
+ };
90
+ } catch (err) {
91
+ console.warn('[Telemetry] Failed to get daily payload', err);
92
+ return {};
93
+ }
94
+ }