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.
- package/.claude/settings.local.json +9 -0
- package/.output/chrome-mv3/_locales/de/messages.json +214 -0
- package/.output/chrome-mv3/_locales/en/messages.json +214 -0
- package/.output/chrome-mv3/_locales/es/messages.json +214 -0
- package/.output/chrome-mv3/_locales/fr/messages.json +214 -0
- package/.output/chrome-mv3/_locales/hi/messages.json +214 -0
- package/.output/chrome-mv3/_locales/id/messages.json +214 -0
- package/.output/chrome-mv3/_locales/it/messages.json +214 -0
- package/.output/chrome-mv3/_locales/nl/messages.json +214 -0
- package/.output/chrome-mv3/_locales/pl/messages.json +214 -0
- package/.output/chrome-mv3/_locales/pt_BR/messages.json +214 -0
- package/.output/chrome-mv3/_locales/pt_PT/messages.json +214 -0
- package/.output/chrome-mv3/_locales/tr/messages.json +214 -0
- package/.output/chrome-mv3/assets/popup-Z_g1HFs5.css +1 -0
- package/.output/chrome-mv3/background.js +42 -0
- package/.output/chrome-mv3/chunks/popup-IxiPwS1E.js +42 -0
- package/.output/chrome-mv3/content-scripts/content.js +179 -0
- package/.output/chrome-mv3/icon-128.png +0 -0
- package/.output/chrome-mv3/icon-16.png +0 -0
- package/.output/chrome-mv3/icon-48.png +0 -0
- package/.output/chrome-mv3/icon.svg +9 -0
- package/.output/chrome-mv3/manifest.json +1 -0
- package/.output/chrome-mv3/popup.html +247 -0
- package/.wxt/eslint-auto-imports.mjs +56 -0
- package/.wxt/tsconfig.json +28 -0
- package/.wxt/types/globals.d.ts +15 -0
- package/.wxt/types/i18n.d.ts +593 -0
- package/.wxt/types/imports-module.d.ts +20 -0
- package/.wxt/types/imports.d.ts +50 -0
- package/.wxt/types/paths.d.ts +32 -0
- package/.wxt/wxt.d.ts +7 -0
- package/entrypoints/background.ts +112 -0
- package/entrypoints/content.ts +656 -0
- package/entrypoints/popup/main.ts +452 -0
- package/entrypoints/popup/modules/auth-modal.ts +219 -0
- package/entrypoints/popup/modules/settings.ts +78 -0
- package/entrypoints/popup/modules/ui-state.ts +95 -0
- package/entrypoints/popup/style.css +844 -0
- package/entrypoints/popup.html +261 -0
- package/lib/constants.ts +9 -0
- package/lib/device-meta.ts +173 -0
- package/lib/i18n.ts +201 -0
- package/lib/license.ts +470 -0
- package/lib/selectors.ts +24 -0
- package/lib/storage.ts +95 -0
- package/lib/telemetry.ts +94 -0
- package/package.json +30 -0
- package/public/_locales/de/messages.json +214 -0
- package/public/_locales/en/messages.json +214 -0
- package/public/_locales/es/messages.json +214 -0
- package/public/_locales/fr/messages.json +214 -0
- package/public/_locales/hi/messages.json +214 -0
- package/public/_locales/id/messages.json +214 -0
- package/public/_locales/it/messages.json +214 -0
- package/public/_locales/nl/messages.json +214 -0
- package/public/_locales/pl/messages.json +214 -0
- package/public/_locales/pt_BR/messages.json +214 -0
- package/public/_locales/pt_PT/messages.json +214 -0
- package/public/_locales/tr/messages.json +214 -0
- package/public/icon-128.png +0 -0
- package/public/icon-16.png +0 -0
- package/public/icon-48.png +0 -0
- package/public/icon.svg +9 -0
- package/tsconfig.json +3 -0
- 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
|
+
}
|
package/lib/selectors.ts
ADDED
|
@@ -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
|
+
});
|
package/lib/telemetry.ts
ADDED
|
@@ -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
|
+
}
|