diff --git a/src/manifest-ff.json b/src/manifest-ff.json index 6ee61c2..31c4e88 100644 --- a/src/manifest-ff.json +++ b/src/manifest-ff.json @@ -6,7 +6,7 @@ "homepage_url": "https://tosdr.org", "permissions": ["tabs", "storage"], "background": { - "scripts": ["scripts/background.js"] + "page": "views/background.html" }, "browser_specific_settings": { "gecko": { diff --git a/src/manifest.json b/src/manifest.json index f363f3f..aecec94 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -6,7 +6,8 @@ "homepage_url": "https://tosdr.org", "permissions": ["tabs", "storage"], "background": { - "service_worker": "scripts/background.js" + "service_worker": "scripts/background.js", + "type": "module" }, "action": { "default_popup": "views/popup.html", diff --git a/src/scripts/background.ts b/src/scripts/background.ts index 32d3956..ba84d52 100644 --- a/src/scripts/background.ts +++ b/src/scripts/background.ts @@ -1,326 +1,32 @@ -// import * as Sentry from '@sentry/browser'; - -interface DatabaseEntry { - id: string; - url: string; - rating: string; -} - -interface Service { - id: string; - rating: string; -} - -const ALLOWED_PROTOCOLS = ['http:', 'https:']; - -var apiUrl = 'api.tosdr.org'; - -// let sentry = false; - -function setPopup(tabId: number | null, popup: string): void { - if (!tabId) { - console.log('tabid is undefined, goodbye'); - // Sentry.captureException(`tabid is undefined! - ${popup}`); - return; - } - chrome.action.setPopup({ - tabId, - popup, - }); -} - -function serviceDetected(tab: chrome.tabs.Tab, service: Service): void { - setTabIcon(tab, service.rating.toLowerCase()); - - setPopup(tab?.id ?? null, `/views/popup.html?service-id=${service.id}`); - setTabBadgeNotification(false, tab); -} - -function initializePageAction(tab: chrome.tabs.Tab): void { - if (!tab || !tab.url) { - console.log('tab is undefined'); - setPopup(null, '/views/popup.html'); - setTabIcon(tab, 'logo'); - setTabBadgeNotification(true, tab); - return; - } - const url = new URL(tab.url); - if (!ALLOWED_PROTOCOLS.includes(url.protocol)) { - // we only want to check http and https - setPopup(tab?.id ?? null, '/views/popup.html'); - setTabIcon(tab, 'logo'); - setTabBadgeNotification(true, tab); - return; - } - - if (tab.url == '') { - setPopup(tab?.id ?? null, '/views/popup.html'); - setTabIcon(tab, 'logo'); - setTabBadgeNotification(true, tab); - return; - } - - // change icon to icons/loading.png - setTabIcon(tab, 'loading'); - setTabBadgeNotification(false, tab); - - // get database from chrome.storage - chrome.storage.local.get(['db'], function (result) { - if (result["db"]) { - // parse the database - const db = result["db"] as DatabaseEntry[]; - - var domain = url.hostname; - - if (domain.startsWith('www.')) { - domain = domain.substring(4); - } - - console.log(domain); - - var domainEntry = db.filter((entry) => - entry.url.split(',').includes(domain) - ); - if (domainEntry.length === 1 && domainEntry[0]) { - console.log('exact match!'); - serviceDetected(tab, domainEntry[0] as Service); - return; - } else { - const maxTries = 4; - var current = 0; - - while (current < maxTries) { - const domainParts = domain.split('.'); - if (domainParts.length > 2) { - domain = domainParts.slice(1).join('.'); - console.log(`try ${current}: ${domain}`); - domainEntry = db.filter((entry) => - entry.url.split(',').includes(domain) - ); - if (domainEntry.length === 1 && domainEntry[0]) { - console.log('exact match!'); - current = maxTries + 1; - serviceDetected(tab, domainEntry[0] as Service); - return; - } - } else { - break; - } - current++; - } - } - - // we didnt find the domain in the database try parent else show notfound.png - setPopup(tab?.id ?? null, `/views/popup.html?url=${domain}`); - setTabIcon(tab, 'notfound'); - } else { - // database is not in chrome.storage, download it - console.log('Database is not in chrome.storage'); - downloadDatabase().then(() => { - initializePageAction(tab); - }); - } - }); -} - -// Removed unused function handleRuntimeError - -function setTabIcon(tab: chrome.tabs.Tab | null, icon: string): void { - const iconDetails: chrome.action.TabIconDetails = { - path: { - 32: `/icons/${icon}/${icon}32.png`, - 48: `/icons/${icon}/${icon}48.png`, - 128: `/icons/${icon}/${icon}128.png`, - }, - }; - - if (tab) { - iconDetails.tabId = tab.id; - } - - chrome.action.setIcon(iconDetails); -} - -async function setTabBadgeNotification(on: boolean, tab: chrome.tabs.Tab): Promise { - // Retrieve the value from storage and ensure it's a boolean - const data = await chrome.storage.local.get('displayDonationReminder'); - const dDR = Boolean(data["displayDonationReminder"]?.active); - - if (on && dDR) { - chrome.action.setBadgeText({ text: '!', tabId: tab.id }); - chrome.action.setBadgeBackgroundColor({ color: 'red' }); - } else { - chrome.action.setBadgeText({ text: '', tabId: tab.id }); - } -} - -async function downloadDatabase() { - // get the database directly from the new endpoint - const db_url = `https://${apiUrl}/appdb/version/v2`; - const response = await fetch(db_url, { - headers: { - apikey: atob('Y29uZ3JhdHMgb24gZ2V0dGluZyB0aGUga2V5IDpQ'), - }, - }); - - if (response.status >= 300) { - chrome.action.setBadgeText({ text: 'err ' + response.status }); - return; - } - - const data = await response.json(); - - chrome.storage.local.set( - { - db: data, - lastModified: new Date().toISOString(), - }, - function () { - console.log('Database downloaded and saved to chrome.storage'); - } - ); -} +import { checkIfUpdateNeeded } from './background/database'; +import { checkDonationReminder } from './background/donation'; +import { handleExtensionInstalled } from './background/install'; +import { initializePageAction } from './background/pageAction'; chrome.action.setBadgeText({ text: '' }); -//check if its time to show a donation reminder -async function checkDonationReminder() { - // Retrieve the value from storage and ensure it's a boolean - const data = await chrome.storage.local.get('displayDonationReminder'); - const displayDonationReminder = data["displayDonationReminder"]; - const dDR = Boolean(displayDonationReminder?.active); - - if ( - dDR !== true && - displayDonationReminder?.allowedPlattform === true - ) { - const currentDate = new Date(); - const currentYear = currentDate.getFullYear(); - - try { - const result: any = await chrome.storage.local.get( - 'lastDismissedReminder' - ); - const lastDismissedReminder = result.lastDismissedReminder; - const lastDismissedYear = lastDismissedReminder?.year; - console.log(lastDismissedYear); - - if ( - currentYear > lastDismissedYear || - lastDismissedYear === undefined - ) { - chrome.action.setBadgeText({ text: '!' }); - chrome.storage.local.set({ - displayDonationReminder: { - active: true, - allowedPlattform: - displayDonationReminder?.allowedPlattform, - }, - }); - } - } catch (error) { - console.error('Error in checkDonationReminder:', error); - } - } else { - chrome.action.setBadgeText({ text: '!' }); - } -} -function checkIfUpdateNeeded(firstStart = false) { - chrome.storage.local.get( - ['db', 'lastModified', 'interval', 'api', 'sentry'], - function (result) { - // if (result.sentry) { - // sentry = result.sentry; - // Sentry.init({ - // dsn: 'https://07c0ebcab5894cff990fd0d3871590f0@sentry.internal.jrbit.de/38', - // }); - // } - if (result["api"]) { - if (result["api"].length !== 0) apiUrl = result["api"]; - } - - if (result["db"] && result["lastModified"]) { - var interval = 8; - if (result["interval"]) { - interval = result["interval"]; - interval++; - } - // check if the database is less than 7 days old - const lastModified = new Date(result["lastModified"]); - const today = new Date(); - const diffTime = Math.abs( - today.getTime() - lastModified.getTime() - ); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - if (diffDays < interval) { - console.log( - `Database is less than ${interval - 1 - } days old, skipping download` - ); - return; - } - } - downloadDatabase().then(() => { - if (firstStart) { - chrome.runtime.openOptionsPage(); - } - }); - } - ); -} - -chrome.tabs.onUpdated.addListener(function (_tabId, changeInfo, tab) { +chrome.tabs.onUpdated.addListener((_, changeInfo, tab) => { if (changeInfo.status === 'complete') { - initializePageAction(tab); + void initializePageAction(tab); } }); -chrome.tabs.onCreated.addListener(function (tab) { - initializePageAction(tab); +chrome.tabs.onCreated.addListener((tab) => { + void initializePageAction(tab); }); -chrome.tabs.onActivated.addListener(function (activeInfo) { - chrome.tabs.get(activeInfo.tabId, function (tab) { - initializePageAction(tab); +chrome.tabs.onActivated.addListener((activeInfo) => { + chrome.tabs.get(activeInfo.tabId, (tab) => { + void initializePageAction(tab); }); }); -chrome.runtime.onInstalled.addListener(function () { - const userAgent = navigator.userAgent; - let donationReminderAllowed: boolean; - if (userAgent.indexOf('Mac') != -1 && userAgent.indexOf('Safari') != -1) { - console.log('MacOS and Safari detected' + userAgent); - donationReminderAllowed = false; - } else { - console.log('MacOS and Safari NOT detected' + userAgent); - donationReminderAllowed = true; - } - - chrome.storage.local.set( - { - themeHeader: true, - sentry: false, - displayDonationReminder: { - active: false, - allowedPlattform: donationReminderAllowed, - }, - }, - function () { - console.log('enabled theme header by default'); - checkIfUpdateNeeded(true); - chrome.tabs.query( - { active: true, currentWindow: true }, - function (tabs) { - if (tabs[0]) { - initializePageAction(tabs[0]); - } - } - ); - } - ); +chrome.runtime.onInstalled.addListener(() => { + void handleExtensionInstalled(); }); -chrome.runtime.onStartup.addListener(function () { - checkIfUpdateNeeded(); +chrome.runtime.onStartup.addListener(() => { + void checkIfUpdateNeeded(); }); -checkDonationReminder(); + +void checkDonationReminder(); diff --git a/src/scripts/background/database.ts b/src/scripts/background/database.ts new file mode 100644 index 0000000..9782402 --- /dev/null +++ b/src/scripts/background/database.ts @@ -0,0 +1,90 @@ +import { API_HEADERS, DEFAULT_API_URL } from '../constants'; +import { getLocal, setLocal } from '../lib/chromeStorage'; +import type { DatabaseEntry } from './types'; + +export async function resolveApiUrl(): Promise { + const data = await getLocal('api'); + const api = data['api']; + if (typeof api === 'string' && api.length > 0) { + return api; + } + return DEFAULT_API_URL; +} + +export async function downloadDatabase(apiUrl?: string): Promise { + const targetApi = apiUrl ?? (await resolveApiUrl()); + + try { + const response = await fetch(`https://${targetApi}/appdb/version/v2`, { + headers: API_HEADERS, + }); + + if (response.status >= 300) { + chrome.action.setBadgeText({ text: `err ${response.status}` }); + return; + } + + const data = (await response.json()) as DatabaseEntry[]; + + await setLocal({ + db: data, + lastModified: new Date().toISOString(), + }); + } catch (error) { + console.error('Failed to download database', error); + } +} + +export async function checkIfUpdateNeeded(firstStart = false): Promise { + const data = await getLocal([ + 'db', + 'lastModified', + 'interval', + 'api', + ]); + + const api = await resolveApiUrlFromData(data); + + const db = data['db'] as DatabaseEntry[] | undefined; + const lastModifiedRaw = data['lastModified']; + + if (db && typeof lastModifiedRaw === 'string') { + const intervalDays = computeIntervalDays(data['interval']); + const lastModified = new Date(lastModifiedRaw); + const today = new Date(); + const diffTime = Math.abs(today.getTime() - lastModified.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < intervalDays) { + return; + } + } + + await downloadDatabase(api); + + if (firstStart) { + chrome.runtime.openOptionsPage(); + } +} + +function computeIntervalDays(rawValue: unknown): number { + const DEFAULT_INTERVAL = 8; + if (typeof rawValue === 'number') { + return rawValue + 1; + } + if (typeof rawValue === 'string') { + const parsed = Number(rawValue); + if (!Number.isNaN(parsed)) { + return parsed + 1; + } + } + return DEFAULT_INTERVAL; +} + +async function resolveApiUrlFromData(data: Record): Promise { + const api = data['api']; + if (typeof api === 'string' && api.length > 0) { + return api; + } + return resolveApiUrl(); +} diff --git a/src/scripts/background/donation.ts b/src/scripts/background/donation.ts new file mode 100644 index 0000000..d5f7e0c --- /dev/null +++ b/src/scripts/background/donation.ts @@ -0,0 +1,48 @@ +import { DONATION_BADGE_TEXT } from '../constants'; +import { getLocal, setLocal } from '../lib/chromeStorage'; +import type { DonationReminderState } from './types'; + +export async function checkDonationReminder(): Promise { + const data = await getLocal('displayDonationReminder'); + const displayDonationReminder = data['displayDonationReminder'] as + | DonationReminderState + | undefined; + + const isActive = displayDonationReminder?.active === true; + const allowed = displayDonationReminder?.allowedPlattform === true; + + if (!isActive && allowed) { + const currentYear = new Date().getFullYear(); + + try { + const reminderData = await getLocal('lastDismissedReminder'); + const lastDismissedReminder = reminderData['lastDismissedReminder'] as + | { year?: number } + | undefined; + const lastDismissedYear = lastDismissedReminder?.year; + + if (lastDismissedYear === undefined || currentYear > lastDismissedYear) { + chrome.action.setBadgeText({ text: DONATION_BADGE_TEXT }); + await setLocal({ + displayDonationReminder: { + active: true, + allowedPlattform: + displayDonationReminder?.allowedPlattform ?? true, + }, + }); + } + } catch (error) { + console.error('Error in checkDonationReminder:', error); + } + + return; + } + + chrome.action.setBadgeText({ text: DONATION_BADGE_TEXT }); +} + +export function donationReminderAllowed(userAgent: string): boolean { + const isMac = userAgent.includes('Mac'); + const isSafari = userAgent.includes('Safari'); + return !(isMac && isSafari); +} diff --git a/src/scripts/background/install.ts b/src/scripts/background/install.ts new file mode 100644 index 0000000..293eb5c --- /dev/null +++ b/src/scripts/background/install.ts @@ -0,0 +1,32 @@ +import { setLocal } from '../lib/chromeStorage'; +import { checkIfUpdateNeeded } from './database'; +import { donationReminderAllowed } from './donation'; +import { initializePageAction } from './pageAction'; + +export async function handleExtensionInstalled(): Promise { + const donationAllowed = donationReminderAllowed(navigator.userAgent); + + await setLocal({ + themeHeader: true, + sentry: false, + displayDonationReminder: { + active: false, + allowedPlattform: donationAllowed, + }, + }); + + await checkIfUpdateNeeded(true); + + const [activeTab] = await queryActiveTab(); + if (activeTab) { + await initializePageAction(activeTab); + } +} + +async function queryActiveTab(): Promise { + return new Promise((resolve) => { + chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { + resolve(tabs); + }); + }); +} diff --git a/src/scripts/background/pageAction.ts b/src/scripts/background/pageAction.ts new file mode 100644 index 0000000..026c27b --- /dev/null +++ b/src/scripts/background/pageAction.ts @@ -0,0 +1,93 @@ +import { + ALLOWED_PROTOCOLS, + DEFAULT_POPUP_PATH, +} from '../constants'; +import { getLocal } from '../lib/chromeStorage'; +import type { DatabaseEntry } from './types'; +import { findServiceMatch } from './serviceDetection'; +import { + serviceDetected, + setPopup, + setTabBadgeNotification, + setTabIcon, +} from './tabUi'; +import { downloadDatabase, resolveApiUrl } from './database'; + +export async function initializePageAction( + tab?: chrome.tabs.Tab | null +): Promise { + if (!tab || !tab.url) { + setPopup(null, DEFAULT_POPUP_PATH); + setTabIcon(tab, 'logo'); + await setTabBadgeNotification(true, tab); + return; + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(tab.url); + } catch (error) { + console.error('Invalid URL for tab', error); + setPopup(tab.id, DEFAULT_POPUP_PATH); + setTabIcon(tab, 'logo'); + await setTabBadgeNotification(true, tab); + return; + } + + if (!isAllowedProtocol(parsedUrl.protocol)) { + setPopup(tab.id, DEFAULT_POPUP_PATH); + setTabIcon(tab, 'logo'); + await setTabBadgeNotification(true, tab); + return; + } + + if (tab.url.trim() === '') { + setPopup(tab.id, DEFAULT_POPUP_PATH); + setTabIcon(tab, 'logo'); + await setTabBadgeNotification(true, tab); + return; + } + + setTabIcon(tab, 'loading'); + await setTabBadgeNotification(false, tab); + + const db = await getDatabase(); + if (!db) { + setPopup(tab.id, DEFAULT_POPUP_PATH); + setTabIcon(tab, 'logo'); + await setTabBadgeNotification(true, tab); + return; + } + + const { service, normalizedDomain } = findServiceMatch( + parsedUrl.hostname, + db + ); + + if (service) { + await serviceDetected(tab, service); + return; + } + + setPopup(tab.id, `${DEFAULT_POPUP_PATH}?url=${normalizedDomain}`); + setTabIcon(tab, 'notfound'); +} + +async function getDatabase(): Promise { + const stored = await getLocal('db'); + let db = stored['db'] as DatabaseEntry[] | undefined; + + if (db) { + return db; + } + + await downloadDatabase(await resolveApiUrl()); + + const refreshed = await getLocal('db'); + db = refreshed['db'] as DatabaseEntry[] | undefined; + return db; +} + +function isAllowedProtocol(protocol: string): boolean { + return (ALLOWED_PROTOCOLS as readonly string[]).includes(protocol); +} diff --git a/src/scripts/background/serviceDetection.ts b/src/scripts/background/serviceDetection.ts new file mode 100644 index 0000000..bab2ab5 --- /dev/null +++ b/src/scripts/background/serviceDetection.ts @@ -0,0 +1,54 @@ +import { MAX_DOMAIN_REDUCTIONS } from '../constants'; +import type { DatabaseEntry, Service } from './types'; + +export interface ServiceMatchResult { + service: Service | null; + normalizedDomain: string; +} + +export function findServiceMatch( + hostname: string, + db: DatabaseEntry[] +): ServiceMatchResult { + let domain = stripWwwPrefix(hostname); + + const directMatch = lookupDomain(domain, db); + if (directMatch) { + return { service: directMatch, normalizedDomain: domain }; + } + + let attempts = 0; + while (attempts < MAX_DOMAIN_REDUCTIONS) { + const reduced = reduceDomain(domain); + if (!reduced) { + break; + } + + domain = reduced; + const match = lookupDomain(domain, db); + if (match) { + return { service: match, normalizedDomain: domain }; + } + + attempts += 1; + } + + return { service: null, normalizedDomain: domain }; +} + +function stripWwwPrefix(domain: string): string { + return domain.startsWith('www.') ? domain.substring(4) : domain; +} + +function reduceDomain(domain: string): string | null { + const parts = domain.split('.'); + if (parts.length <= 2) { + return null; + } + return parts.slice(1).join('.'); +} + +function lookupDomain(domain: string, db: DatabaseEntry[]): Service | null { + const match = db.find((entry) => entry.url.split(',').includes(domain)); + return match ? { id: match.id, rating: match.rating } : null; +} diff --git a/src/scripts/background/tabUi.ts b/src/scripts/background/tabUi.ts new file mode 100644 index 0000000..d1484f3 --- /dev/null +++ b/src/scripts/background/tabUi.ts @@ -0,0 +1,65 @@ +import { DEFAULT_POPUP_PATH, DONATION_BADGE_TEXT } from '../constants'; +import { getLocal } from '../lib/chromeStorage'; +import type { DonationReminderState, Service } from './types'; + +export function setPopup( + tabId: number | undefined | null, + popup: string = DEFAULT_POPUP_PATH +): void { + if (typeof tabId !== 'number') { + chrome.action.setPopup({ popup }); + return; + } + + chrome.action.setPopup({ tabId, popup }); +} + +export function setTabIcon( + tab: chrome.tabs.Tab | null | undefined, + icon: string +): void { + const iconDetails: chrome.action.TabIconDetails = { + path: { + 32: `/icons/${icon}/${icon}32.png`, + 48: `/icons/${icon}/${icon}48.png`, + 128: `/icons/${icon}/${icon}128.png`, + }, + }; + + if (tab?.id) { + iconDetails.tabId = tab.id; + } + + chrome.action.setIcon(iconDetails); +} + +export async function setTabBadgeNotification( + on: boolean, + tab?: chrome.tabs.Tab | null +): Promise { + if (!tab?.id) { + return; + } + + const data = await getLocal('displayDonationReminder'); + const reminder = data['displayDonationReminder'] as + | DonationReminderState + | undefined; + + if (on && reminder?.active) { + chrome.action.setBadgeText({ text: DONATION_BADGE_TEXT, tabId: tab.id }); + chrome.action.setBadgeBackgroundColor({ color: 'red' }); + return; + } + + chrome.action.setBadgeText({ text: '', tabId: tab.id }); +} + +export async function serviceDetected( + tab: chrome.tabs.Tab, + service: Service +): Promise { + setTabIcon(tab, service.rating.toLowerCase()); + setPopup(tab.id, `${DEFAULT_POPUP_PATH}?service-id=${service.id}`); + await setTabBadgeNotification(false, tab); +} diff --git a/src/scripts/background/types.ts b/src/scripts/background/types.ts new file mode 100644 index 0000000..66644f5 --- /dev/null +++ b/src/scripts/background/types.ts @@ -0,0 +1,15 @@ +export interface DatabaseEntry { + id: string; + url: string; + rating: string; +} + +export interface Service { + id: string; + rating: string; +} + +export interface DonationReminderState { + active?: boolean; + allowedPlattform?: boolean; +} diff --git a/src/scripts/constants.ts b/src/scripts/constants.ts new file mode 100644 index 0000000..9cd109e --- /dev/null +++ b/src/scripts/constants.ts @@ -0,0 +1,15 @@ +export const DEFAULT_API_URL = 'api.tosdr.org'; + +export const ALLOWED_PROTOCOLS = ['http:', 'https:'] as const; + +export const MAX_DOMAIN_REDUCTIONS = 4; + +export const DONATION_BADGE_TEXT = '!'; + +export const DEFAULT_POPUP_PATH = '/views/popup.html'; + +export const API_HEADERS = { + apikey: atob('Y29uZ3JhdHMgb24gZ2V0dGluZyB0aGUga2V5IDpQ'), +}; + +export const SUPPORTED_LANGUAGES = ['en', 'de', 'nl', 'fr', 'es'] as const; \ No newline at end of file diff --git a/src/scripts/lib/chromeStorage.ts b/src/scripts/lib/chromeStorage.ts new file mode 100644 index 0000000..ac6fae6 --- /dev/null +++ b/src/scripts/lib/chromeStorage.ts @@ -0,0 +1,17 @@ +export async function getLocal( + keys: T +): Promise> { + return new Promise((resolve) => { + chrome.storage.local.get(keys, (result) => { + resolve(result as Record); + }); + }); +} + +export async function setLocal(items: Record): Promise { + return new Promise((resolve) => { + chrome.storage.local.set(items, () => { + resolve(); + }); + }); +} diff --git a/src/scripts/lib/language.ts b/src/scripts/lib/language.ts new file mode 100644 index 0000000..8aad9bb --- /dev/null +++ b/src/scripts/lib/language.ts @@ -0,0 +1,53 @@ +import { SUPPORTED_LANGUAGES } from "../constants"; + +export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; + +export function normalizeLanguage( + value: string +): SupportedLanguage | undefined { + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return undefined; + } + + const baseCode = trimmed.split('-')[0]; + if (SUPPORTED_LANGUAGES.includes(baseCode as SupportedLanguage)) { + return baseCode as SupportedLanguage; + } + + return undefined; +} + +export function detectBrowserLanguage(): SupportedLanguage { + const candidateLanguages: string[] = []; + + if (typeof navigator !== 'undefined') { + if (Array.isArray(navigator.languages)) { + candidateLanguages.push(...navigator.languages); + } + + if (typeof navigator.language === 'string') { + candidateLanguages.push(navigator.language); + } + } + + for (const candidate of candidateLanguages) { + const normalized = normalizeLanguage(candidate); + if (normalized) { + return normalized; + } + } + + return 'en'; +} + +export function resolveLanguage(candidate: unknown): SupportedLanguage { + if (typeof candidate === 'string') { + const normalized = normalizeLanguage(candidate); + if (normalized) { + return normalized; + } + } + + return detectBrowserLanguage(); +} diff --git a/src/scripts/views/popup.ts b/src/scripts/views/popup.ts index fd74394..4b716c7 100644 --- a/src/scripts/views/popup.ts +++ b/src/scripts/views/popup.ts @@ -1,417 +1,48 @@ -let curatorMode = false; -var apiUrl = 'api.tosdr.org'; +import { hydrateState, PopupPreferences } from './popup/state'; +import { registerUiEventHandlers } from './popup/events'; +import { initializePopupFromLocation } from './popup/navigation'; +import { adjustLayoutForFirefoxDesktop } from './popup/layout'; -chrome.storage.local.get(['api'], function (result) { - if (result["api"] && result["api"].length !== 0) { - apiUrl = result["api"]; - } -}); - -async function donationReminderLogic(): Promise { - const result = await chrome.storage.local.get('displayDonationReminder'); - console.log('displayDonationReminder:', result["displayDonationReminder"]); - - if (result["displayDonationReminder"]?.active) { - try { - const currentDate = new Date(); - const currentMonth = currentDate.getMonth(); - const currentYear = currentDate.getFullYear(); - - // Reset the badge text for all tabs - const tabs = await chrome.tabs.query({}); - for (const tab of tabs) { - if (tab.id) { - await chrome.action.setBadgeText({ text: '', tabId: tab.id }); - } - } - - await chrome.storage.local.set({ - lastDismissedReminder: { - month: currentMonth, - year: currentYear, - }, - displayDonationReminder: { - active: false, - allowedPlattform: result["displayDonationReminder"].allowedPlattform, - }, - }); - - const donationReminder = document.getElementById('donationReminder'); - if (donationReminder) { - donationReminder.style.display = 'block'; - } - } catch (error) { - console.error('Error in donationReminderLogic:', error); - } - } -} - -async function handleUrlInURLIfExists(urlOriginal: string): Promise { - const url = urlOriginal.split('?url=')[1]; - if (!url) { - await donationReminderLogic(); - const idElement = document.getElementById('id'); - const loadingElement = document.getElementById('loading'); - const loadedElement = document.getElementById('loaded'); - const nourlElement = document.getElementById('nourl'); - const pointListElement = document.getElementById('pointList'); - - if (idElement) idElement.innerHTML = 'Error: no service-id in url'; - if (loadingElement) loadingElement.style.display = 'none'; - if (loadedElement) loadedElement.style.display = 'none'; - if (nourlElement) nourlElement.style.display = 'block'; - if (pointListElement) pointListElement.style.display = 'none'; - return; - } - - const result = await searchToSDR(url); - - if (result) { - const phoenixButton = document.getElementById('phoenixButton'); - if (phoenixButton) { - phoenixButton.onclick = () => { - window.open(`https://edit.tosdr.org/services/${result}`); - }; - } - - themeHeaderIfEnabled(result); - - const logo = document.getElementById('logo') as HTMLImageElement; - if (logo) { - logo.src = `https://s3.tosdr.org/logos/${result}.png`; - } - - const idElement = document.getElementById('id'); - if (idElement) { - idElement.innerText = result; - } - - await getServiceDetails(result, true); - } else { - await donationReminderLogic(); - const idElement = document.getElementById('id'); - const loadingElement = document.getElementById('loading'); - const loadedElement = document.getElementById('loaded'); - const nourlElement = document.getElementById('nourl'); - const pointListElement = document.getElementById('pointList'); - - if (idElement) idElement.innerText = 'Error: no service-id in url'; - if (loadingElement) loadingElement.style.display = 'none'; - if (loadedElement) loadedElement.style.display = 'none'; - if (nourlElement) nourlElement.style.display = 'block'; - if (pointListElement) pointListElement.style.display = 'none'; - } -} - -function getServiceIDFromURL(url: string): void { - const serviceID = url.split('?service-id=')[1]?.replace('#', ''); - - if (!serviceID) { - handleUrlInURLIfExists(url); - return; - } - - if (serviceID === '-1') { - donationReminderLogic(); - const idElement = document.getElementById('id'); - const loadingElement = document.getElementById('loading'); - const loadedElement = document.getElementById('loaded'); - const nourlElement = document.getElementById('nourl'); - const notreviewedElement = document.getElementById('notreviewed'); - const pointListElement = document.getElementById('pointList'); - const edittextElement = document.getElementById('edittext'); - - if (idElement) idElement.innerHTML = 'Error: no service-id in url'; - if (loadingElement) loadingElement.style.display = 'none'; - if (loadedElement) loadedElement.style.display = 'none'; - if (nourlElement) nourlElement.style.display = 'block'; - if (notreviewedElement) notreviewedElement.style.display = 'block'; - if (pointListElement) pointListElement.style.display = 'none'; - if (edittextElement) { - edittextElement.onclick = () => { - window.open('https://edit.tosdr.org'); - }; - } - return; - } - - const phoenixButton = document.getElementById('phoenixButton'); - const webbutton = document.getElementById('webbutton'); - const logo = document.getElementById('logo') as HTMLImageElement; - const idElement = document.getElementById('id'); - - if (phoenixButton) { - phoenixButton.onclick = () => { - window.open(`https://edit.tosdr.org/services/${serviceID}`); - }; - } - - if (webbutton) { - webbutton.onclick = () => { - window.open(`https://tosdr.org/en/service/${serviceID}`); - }; - } - - themeHeaderIfEnabled(serviceID); - - if (logo) { - logo.src = `https://s3.tosdr.org/logos/${serviceID}.png`; - } - - if (idElement) { - idElement.innerHTML = serviceID; - } - - getServiceDetails(serviceID); -} - -function themeHeaderIfEnabled(serviceID: string): void { - chrome.storage.local.get(['themeHeader'], function (result) { - if (result["themeHeader"]) { - const blurredTemplate = `.header::before { - content: ''; - position: absolute; - background-image: url('https://s3.tosdr.org/logos/${serviceID}.png'); - top: 0; - left: 0; - width: 100%; - height: 90%; - background-repeat: no-repeat; - background-position: center; - background-size: cover; - filter: blur(30px); - z-index: -2; - }`; - - const styleElement = document.createElement('style'); - document.head.appendChild(styleElement); - styleElement.sheet?.insertRule(blurredTemplate); - } - }); -} - -function themeHeaderColorIfEnabled(rating: string): void { - chrome.storage.local.get(['themeHeaderRating'], function (result) { - if (result["themeHeaderRating"]) { - const header = document.getElementById('headerPopup'); - if (header) { - header.classList.add(rating); - } - } - }); -} - -async function getServiceDetails(id: string, unverified = false) { - const service_url = `https://${apiUrl}/service/v3?id=${id}`; - const response = await fetch(service_url); - - // check if we got a 200 response - if (response.status >= 300) { - document.getElementById('loading')!.style.display = 'none'; - document.getElementById('loaded')!.style.display = 'none'; - document.getElementById('error')!.style.display = 'flex'; - return; - } - - const data = await response.json(); - - const name = data.name; - const rating = data.rating; - const points = data.points; - - const serviceNames = document.getElementsByClassName('serviceName'); - - for (let i = 0; i < serviceNames.length; i++) { - (serviceNames[i] as HTMLElement).innerText = name; - } +void (async function initPopup(): Promise { + await waitForDomReady(); - document.getElementById('title')!.innerText = name; - if (rating) { - document - .getElementById('gradelabel')! - .classList.add(rating.toLowerCase()); - themeHeaderColorIfEnabled(rating.toLowerCase()); - document.getElementById('grade')!.innerText = rating; - } else { - document.getElementById('grade')!.innerText = 'N/A'; - } - document.getElementById('pointsCount')!.innerText = - points.length.toString(); + const preferences = await hydrateState(); + applyPreferences(preferences); - document.getElementById('loading')!.style.opacity = '0'; - document.getElementById('loaded')!.style.filter = 'none'; - setTimeout(function () { - document.getElementById('loading')!.style.display = 'none'; - }, 200); + registerUiEventHandlers(); + await initializePopupFromLocation(window.location.href); + adjustLayoutForFirefoxDesktop(); +})(); - if (unverified) { - document.getElementById('notreviewedShown')!.style.display = 'block'; +function applyPreferences(preferences: PopupPreferences): void { + if (preferences.darkmode) { + document.body.classList.add('dark-mode'); } - populateList(points); -} - -function populateList(points: any) { - const pointsList = document.getElementById('pointList'); - - if (!curatorMode) { - points = points.filter((point: any) => point.status === 'approved'); - } else { - points = points.filter( - (point: any) => - point.status === 'approved' || point.status === 'pending' - ); + const curatorElement = document.getElementById('curator'); + if (curatorElement) { + curatorElement.style.display = preferences.curatorMode + ? 'block' + : 'none'; } - const blockerPoints = points.filter( - (point: any) => point.case.classification === 'blocker' - ); - const badPoints = points.filter( - (point: any) => point.case.classification === 'bad' + const translationWarningElement = document.getElementById( + 'translationWarning' ); - const goodPoints = points.filter( - (point: any) => point.case.classification === 'good' - ); - const neutralPoints = points.filter( - (point: any) => point.case.classification === 'neutral' - ); - - createPointList(blockerPoints, pointsList, false); - createPointList(badPoints, pointsList, false); - createPointList(goodPoints, pointsList, false); - createPointList(neutralPoints, pointsList, true); -} - -function curatorTag(pointStatus: string) { - if (!curatorMode || pointStatus === 'approved') { - return ''; - } - return ""; -} - -function createPointList(pointsFiltered: any, pointsList: any, last: boolean) { - var added = 0; - for (let i = 0; i < pointsFiltered.length; i++) { - const point = document.createElement('div'); - var temp = ` -
- -

${pointsFiltered[i].title}

- ${curatorTag(pointsFiltered[i].status)} -
`; - point.innerHTML = temp.trim(); - pointsList.appendChild(point.firstChild); - added++; - if (i !== pointsFiltered.length - 1) { - const divider = document.createElement('hr'); - pointsList.appendChild(divider); - } - } - if (added !== 0 && !last) { - const divider = document.createElement('hr'); - divider.classList.add('group'); - pointsList.appendChild(divider); + if (translationWarningElement) { + translationWarningElement.style.display = + preferences.language === 'en' ? 'none' : 'block'; } } -async function searchToSDR(term: string) { - const service_url = `https://${apiUrl}/search/v5/?query=${term}`; - const response = await fetch(service_url); - - if (response.status !== 200) { - document.getElementById('loading')!.style.display = 'none'; - document.getElementById('loaded')!.style.display = 'none'; - document.getElementById('error')!.style.display = 'flex'; +async function waitForDomReady(): Promise { + if (document.readyState !== 'loading') { return; } - const data = await response.json(); - - if (data.services.length !== 0) { - const urls = data.services[0].urls as string[]; - for (let i = 0; i < urls.length; i++) { - if (urls[i] === term) { - return data.services[0].id; - } - } - } -} - -getServiceIDFromURL(window.location.href); - -// Get settings -chrome.storage.local.get(['darkmode', 'curatorMode', 'api'], function (result) { - if (result["darkmode"]) { - const body = document.querySelector('body')!; - body.classList.toggle('dark-mode'); - } - - if (result["curatorMode"]) { - document.getElementById('curator')!.style.display = 'block'; - curatorMode = true; - } else { - document.getElementById('curator')!.style.display = 'none'; - } -}); - -// Event listeners -document.addEventListener('DOMContentLoaded', () => { - const toggleButton = document.getElementById('toggleButton'); - const settingsButton = document.getElementById('settingsButton'); - const sourceButton = document.getElementById('sourceButton'); - const donationButton = document.getElementById('donationButton'); - const source = document.getElementById('source'); - const opentosdr = document.getElementById('opentosdr'); - - if (toggleButton) { - toggleButton.onclick = () => { - const body = document.querySelector('body'); - if (body) { - body.classList.toggle('dark-mode'); - const darkmode = body.classList.contains('dark-mode'); - chrome.storage.local.set({ darkmode }); - } - }; - } - - if (settingsButton) { - settingsButton.onclick = () => { - chrome.runtime.openOptionsPage(); - }; - } - - if (sourceButton) { - sourceButton.onclick = () => { - window.open('https://github.com/tosdr/browser-extensions'); - }; - } - - if (donationButton) { - donationButton.onclick = () => { - window.open('https://tosdr.org/en/sites/donate'); - }; - } - - if (source) { - source.onclick = () => { - window.open('https://github.com/tosdr'); - }; - } - - if (opentosdr) { - opentosdr.onclick = () => { - window.open('https://tosdr.org/'); - }; - } -}); - -function ifFirefoxDesktopResize(): void { - if ( - navigator.userAgent.includes('Firefox') && - !navigator.userAgent.includes('Mobile') - ) { - document.body.style.width = '350px'; - } + await new Promise((resolve) => { + document.addEventListener('DOMContentLoaded', () => resolve(), { + once: true, + }); + }); } - -ifFirefoxDesktopResize(); diff --git a/src/scripts/views/popup/donation.ts b/src/scripts/views/popup/donation.ts new file mode 100644 index 0000000..7b29002 --- /dev/null +++ b/src/scripts/views/popup/donation.ts @@ -0,0 +1,52 @@ +import { getLocal, setLocal } from '../../lib/chromeStorage'; + +export async function showDonationReminderIfNeeded(): Promise { + const result = await getLocal('displayDonationReminder'); + const state = result['displayDonationReminder'] as + | { + active?: boolean; + allowedPlattform?: boolean; + } + | undefined; + + if (!state?.active) { + return; + } + + try { + const currentDate = new Date(); + const currentMonth = currentDate.getMonth(); + const currentYear = currentDate.getFullYear(); + + const tabs = await queryAllTabs(); + for (const tab of tabs) { + if (tab.id) { + await chrome.action.setBadgeText({ text: '', tabId: tab.id }); + } + } + + await setLocal({ + lastDismissedReminder: { + month: currentMonth, + year: currentYear, + }, + displayDonationReminder: { + active: false, + allowedPlattform: state.allowedPlattform, + }, + }); + + const donationReminder = document.getElementById('donationReminder'); + if (donationReminder) { + donationReminder.style.display = 'block'; + } + } catch (error) { + console.error('Error in donation reminder logic:', error); + } +} + +async function queryAllTabs(): Promise { + return new Promise((resolve) => { + chrome.tabs.query({}, (tabs) => resolve(tabs)); + }); +} diff --git a/src/scripts/views/popup/events.ts b/src/scripts/views/popup/events.ts new file mode 100644 index 0000000..5c54ea0 --- /dev/null +++ b/src/scripts/views/popup/events.ts @@ -0,0 +1,61 @@ +import { setLocal } from '../../lib/chromeStorage'; + +export function registerUiEventHandlers(): void { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setupHandlers, { once: true }); + } else { + setupHandlers(); + } +} + +function setupHandlers(): void { + const toggleButton = document.getElementById('toggleButton'); + const settingsButton = document.getElementById('settingsButton'); + const sourceButton = document.getElementById('sourceButton'); + const donationButton = document.getElementById('donationButton'); + const source = document.getElementById('source'); + const opentosdr = document.getElementById('opentosdr'); + + if (toggleButton) { + toggleButton.addEventListener('click', () => { + const body = document.querySelector('body'); + if (!body) { + return; + } + + body.classList.toggle('dark-mode'); + const darkmode = body.classList.contains('dark-mode'); + void setLocal({ darkmode }); + }); + } + + if (settingsButton) { + settingsButton.addEventListener('click', () => { + chrome.runtime.openOptionsPage(); + }); + } + + if (sourceButton) { + sourceButton.addEventListener('click', () => { + window.open('https://github.com/tosdr/browser-extensions'); + }); + } + + if (donationButton) { + donationButton.addEventListener('click', () => { + window.open('https://tosdr.org/en/sites/donate'); + }); + } + + if (source) { + source.addEventListener('click', () => { + window.open('https://github.com/tosdr'); + }); + } + + if (opentosdr) { + opentosdr.addEventListener('click', () => { + window.open('https://tosdr.org/'); + }); + } +} diff --git a/src/scripts/views/popup/layout.ts b/src/scripts/views/popup/layout.ts new file mode 100644 index 0000000..566d784 --- /dev/null +++ b/src/scripts/views/popup/layout.ts @@ -0,0 +1,9 @@ +export function adjustLayoutForFirefoxDesktop(): void { + const userAgent = navigator.userAgent; + const isFirefox = userAgent.includes('Firefox'); + const isMobile = userAgent.includes('Mobile'); + + if (isFirefox && !isMobile) { + document.body.style.width = '350px'; + } +} diff --git a/src/scripts/views/popup/navigation.ts b/src/scripts/views/popup/navigation.ts new file mode 100644 index 0000000..ad3be89 --- /dev/null +++ b/src/scripts/views/popup/navigation.ts @@ -0,0 +1,132 @@ +import { showDonationReminderIfNeeded } from './donation'; +import { displayServiceDetails, searchService } from './service'; +import { applyHeaderTheme } from './theme'; + +export async function initializePopupFromLocation(locationHref: string): Promise { + const serviceId = extractServiceId(locationHref); + + if (!serviceId) { + await handleUrlParameter(locationHref); + return; + } + + if (serviceId === '-1') { + await handleMissingServiceId(); + return; + } + + configureServiceButtons(serviceId); + updateLogo(serviceId); + updateServiceIdentifier(serviceId); + void applyHeaderTheme(serviceId); + + await displayServiceDetails(serviceId); +} + +async function handleUrlParameter(locationHref: string): Promise { + const url = extractUrlParameter(locationHref); + if (!url) { + await showMissingServiceUi(); + return; + } + + const result = await searchService(url); + + if (result) { + configureServiceButtons(result); + updateLogo(result); + updateServiceIdentifier(result); + void applyHeaderTheme(result); + await displayServiceDetails(result, { unverified: true }); + return; + } + + await showMissingServiceUi(); +} + +async function handleMissingServiceId(): Promise { + await showDonationReminderIfNeeded(); + updateServiceIdentifier('Error: no service-id in url'); + hideElement('loading'); + hideElement('loaded'); + showElement('nourl', 'block'); + showElement('notreviewed', 'block'); + hideElement('pointList'); + + const editTextElement = document.getElementById('edittext'); + if (editTextElement) { + editTextElement.onclick = () => { + window.open('https://edit.tosdr.org'); + }; + } +} + +async function showMissingServiceUi(): Promise { + await showDonationReminderIfNeeded(); + + updateServiceIdentifier('Error: no service-id in url'); + hideElement('loading'); + hideElement('loaded'); + showElement('nourl', 'block'); + hideElement('pointList'); +} + +function configureServiceButtons(serviceId: string): void { + const phoenixButton = document.getElementById('phoenixButton'); + if (phoenixButton) { + phoenixButton.onclick = () => { + window.open(`https://edit.tosdr.org/services/${serviceId}`); + }; + } + + const webbutton = document.getElementById('webbutton'); + if (webbutton) { + webbutton.onclick = () => { + window.open(`https://tosdr.org/en/service/${serviceId}`); + }; + } +} + +function updateLogo(serviceId: string): void { + const logo = document.getElementById('logo') as HTMLImageElement | null; + if (logo) { + logo.src = `https://s3.tosdr.org/logos/${serviceId}.png`; + } +} + +function updateServiceIdentifier(message: string): void { + const idElement = document.getElementById('id'); + if (idElement) { + idElement.innerText = message; + } +} + +function extractServiceId(locationHref: string): string | undefined { + const match = locationHref.split('?service-id=')[1]; + if (!match) { + return undefined; + } + return match.replace('#', ''); +} + +function extractUrlParameter(locationHref: string): string | undefined { + const match = locationHref.split('?url=')[1]; + if (!match) { + return undefined; + } + return match; +} + +function hideElement(elementId: string): void { + const element = document.getElementById(elementId); + if (element) { + element.style.display = 'none'; + } +} + +function showElement(elementId: string, display: string): void { + const element = document.getElementById(elementId); + if (element) { + element.style.display = display; + } +} diff --git a/src/scripts/views/popup/service.ts b/src/scripts/views/popup/service.ts new file mode 100644 index 0000000..0f1ff71 --- /dev/null +++ b/src/scripts/views/popup/service.ts @@ -0,0 +1,355 @@ +import { getApiUrl, getLanguage, isCuratorMode } from './state'; +import { applyHeaderColor } from './theme'; + +interface ServicePoint { + status: string; + title: string; + case?: { + classification?: string; + localized_title?: string | null; + }; +} + +interface ServiceResponse { + name: string; + rating?: string; + points: ServicePoint[]; +} + +interface SearchResponse { + services: Array<{ + id: string; + urls: string[]; + }>; +} + +export async function displayServiceDetails( + id: string, + options: { unverified?: boolean } = {} +): Promise { + try { + const language = getLanguage(); + const response = await fetch( + `https://${getApiUrl()}/service/v3?id=${encodeURIComponent(id)}&lang=${encodeURIComponent(language)}` + ); + + if (!response.ok) { + hideLoadingState(); + const errorDescription = await formatHttpError(response); + showErrorOverlay( + 'Unable to load service details.', + errorDescription + ); + return; + } + + const data = (await response.json()) as ServiceResponse; + const rating = data.rating; + + updateServiceName(data.name); + updateTitle(data.name); + updateGrade(rating); + updatePointsCount(data.points.length); + revealLoadedState(options.unverified === true); + + populateList(data.points); + } catch (error) { + hideLoadingState(); + showErrorOverlay( + 'Unable to load service details.', + formatUnknownError(error) + ); + } +} + +export async function searchService(term: string): Promise { + try { + const response = await fetch( + `https://${getApiUrl()}/search/v5/?query=${encodeURIComponent(term)}` + ); + + if (response.status !== 200) { + hideLoadingState(); + const errorDescription = await formatHttpError(response); + showErrorOverlay( + 'Unable to search for matching services.', + errorDescription + ); + return undefined; + } + + const data = (await response.json()) as SearchResponse; + + if (data.services.length === 0) { + return undefined; + } + + const [firstService] = data.services; + if (firstService) { + for (const url of firstService.urls) { + if (url === term) { + return firstService.id; + } + } + } + + return undefined; + } catch (error) { + hideLoadingState(); + showErrorOverlay( + 'Unable to search for matching services.', + formatUnknownError(error) + ); + return undefined; + } +} + +function updateServiceName(name: string): void { + const serviceNames = document.getElementsByClassName('serviceName'); + for (const element of Array.from(serviceNames)) { + (element as HTMLElement).innerText = name; + } +} + +function updateTitle(name: string): void { + const titleElement = document.getElementById('title'); + if (titleElement) { + titleElement.innerText = name; + } +} + +function updateGrade(rating?: string): void { + const gradeLabel = document.getElementById('gradelabel'); + const gradeElement = document.getElementById('grade'); + + if (!gradeElement) { + return; + } + + if (rating) { + if (gradeLabel) { + gradeLabel.classList.add(rating.toLowerCase()); + } + void applyHeaderColor(rating.toLowerCase()); + gradeElement.innerText = rating; + } else { + gradeElement.innerText = 'N/A'; + } +} + +function updatePointsCount(count: number): void { + const pointsCount = document.getElementById('pointsCount'); + if (pointsCount) { + pointsCount.innerText = count.toString(); + } +} + +function revealLoadedState(unverified: boolean): void { + const loadingElement = document.getElementById('loading'); + const loadedElement = document.getElementById('loaded'); + + if (loadingElement) { + loadingElement.style.opacity = '0'; + setTimeout(() => { + loadingElement.style.display = 'none'; + }, 200); + } + + if (loadedElement) { + loadedElement.style.filter = 'none'; + } + + if (unverified) { + showElement('notreviewedShown'); + } +} + +function populateList(points: ServicePoint[]): void { + const pointsList = document.getElementById('pointList'); + if (!pointsList) { + return; + } + + pointsList.style.display = 'block'; + pointsList.innerHTML = ''; + + const filteredPoints = filterPoints(points); + + appendPointGroup(filteredPoints.blocker, pointsList, false); + appendPointGroup(filteredPoints.bad, pointsList, false); + appendPointGroup(filteredPoints.good, pointsList, false); + appendPointGroup(filteredPoints.neutral, pointsList, true); +} + +function filterPoints(points: ServicePoint[]): { + blocker: ServicePoint[]; + bad: ServicePoint[]; + good: ServicePoint[]; + neutral: ServicePoint[]; +} { + const curatedPoints = points.filter((point) => { + if (!isCuratorMode()) { + return point.status === 'approved'; + } + return point.status === 'approved' || point.status === 'pending'; + }); + + return { + blocker: curatedPoints.filter( + (point) => point.case?.classification === 'blocker' + ), + bad: curatedPoints.filter( + (point) => point.case?.classification === 'bad' + ), + good: curatedPoints.filter( + (point) => point.case?.classification === 'good' + ), + neutral: curatedPoints.filter( + (point) => point.case?.classification === 'neutral' + ), + }; +} + +function appendPointGroup( + points: ServicePoint[], + container: HTMLElement, + isLastGroup: boolean +): void { + let added = 0; + + points.forEach((point, index) => { + const wrapper = document.createElement('div'); + const classification = point.case?.classification ?? 'neutral'; + const pointTitle = point.case?.localized_title ?? point.title; + wrapper.innerHTML = ` +
+ +

${pointTitle}

+ ${renderCuratorTag(point.status)} +
+ `.trim(); + if (wrapper.firstChild) { + container.appendChild(wrapper.firstChild as HTMLElement); + } + added += 1; + + if (index !== points.length - 1) { + const divider = document.createElement('hr'); + container.appendChild(divider); + } + }); + + if (added > 0 && !isLastGroup) { + const divider = document.createElement('hr'); + divider.classList.add('group'); + container.appendChild(divider); + } +} + +function renderCuratorTag(status: string): string { + if (!isCuratorMode() || status === 'approved') { + return ''; + } + return ""; +} + +function hideLoadingState(): void { + const loadingElement = document.getElementById('loading'); + const loadedElement = document.getElementById('loaded'); + + if (loadingElement) { + loadingElement.style.display = 'none'; + } + if (loadedElement) { + loadedElement.style.display = 'none'; + } +} + +function showElement(elementId: string): void { + const element = document.getElementById(elementId); + if (element) { + element.style.display = 'block'; + } +} + +async function formatHttpError(response: Response): Promise { + const statusSummary = `${response.status} ${response.statusText}`.trim(); + + try { + const contentType = response.headers.get('content-type') ?? ''; + const bodyText = await response.text(); + + if (!bodyText) { + return statusSummary || 'Request failed.'; + } + + if (contentType.includes('application/json')) { + try { + const parsed = JSON.parse(bodyText) as { + error?: string; + message?: string; + } | null; + + const jsonMessage = + typeof parsed?.message === 'string' + ? parsed.message + : typeof parsed?.error === 'string' + ? parsed.error + : undefined; + + if (jsonMessage) { + return statusSummary + ? `${statusSummary} – ${jsonMessage}` + : jsonMessage; + } + } catch { + // Fall back to using the raw body text below. + } + } + + const trimmedBody = bodyText.trim(); + if (!trimmedBody) { + return statusSummary || 'Request failed.'; + } + + return statusSummary + ? `${statusSummary} – ${trimmedBody}` + : trimmedBody; + } catch { + return statusSummary || 'Request failed.'; + } +} + +function showErrorOverlay(title: string, description: string): void { + const errorContainer = document.getElementById('error'); + const titleElement = document.getElementById('errorTitle'); + const descriptionElement = document.getElementById('errorDescription'); + + if (titleElement) { + titleElement.innerText = title; + } + + if (descriptionElement) { + descriptionElement.innerText = description; + } + + if (errorContainer) { + errorContainer.style.display = 'flex'; + } +} + +function formatUnknownError(error: unknown): string { + if (error instanceof Error) { + return error.message || error.name; + } + + if (typeof error === 'string') { + return error; + } + + try { + return JSON.stringify(error); + } catch { + return 'An unexpected error occurred.'; + } +} diff --git a/src/scripts/views/popup/state.ts b/src/scripts/views/popup/state.ts new file mode 100644 index 0000000..f1e1ed2 --- /dev/null +++ b/src/scripts/views/popup/state.ts @@ -0,0 +1,64 @@ +import { DEFAULT_API_URL } from '../../constants'; +import { getLocal } from '../../lib/chromeStorage'; +import { + SupportedLanguage, + resolveLanguage, +} from '../../lib/language'; + +let curatorMode = false; +let apiUrl = DEFAULT_API_URL; +let language: SupportedLanguage = 'en'; + +export interface PopupPreferences { + darkmode: boolean; + curatorMode: boolean; + language: SupportedLanguage; +} + +export function isCuratorMode(): boolean { + return curatorMode; +} + +export function setCuratorMode(value: boolean): void { + curatorMode = value; +} + +export function getApiUrl(): string { + return apiUrl; +} + +export function setApiUrl(url: string): void { + apiUrl = url; +} + +export async function hydrateState(): Promise { + const result = await getLocal(['darkmode', 'curatorMode', 'api', 'language']); + + const darkmode = Boolean(result['darkmode']); + const storedCuratorMode = Boolean(result['curatorMode']); + setCuratorMode(storedCuratorMode); + + const api = result['api']; + if (typeof api === 'string' && api.length > 0) { + setApiUrl(api); + } else { + setApiUrl(DEFAULT_API_URL); + } + + const resolvedLanguage = resolveLanguage(result['language']); + setLanguage(resolvedLanguage); + + return { + darkmode, + curatorMode: storedCuratorMode, + language: resolvedLanguage, + }; +} + +export function getLanguage(): SupportedLanguage { + return language; +} + +export function setLanguage(value: SupportedLanguage): void { + language = value; +} diff --git a/src/scripts/views/popup/theme.ts b/src/scripts/views/popup/theme.ts new file mode 100644 index 0000000..eabc069 --- /dev/null +++ b/src/scripts/views/popup/theme.ts @@ -0,0 +1,41 @@ +import { getLocal } from '../../lib/chromeStorage'; + +export async function applyHeaderTheme(serviceId: string): Promise { + const result = await getLocal('themeHeader'); + if (!result['themeHeader']) { + return; + } + + const blurredTemplate = `.header::before { + content: ''; + position: absolute; + background-image: url('https://s3.tosdr.org/logos/${serviceId}.png'); + top: 0; + left: 0; + width: 100%; + height: 90%; + background-repeat: no-repeat; + background-position: center; + background-size: cover; + filter: blur(30px); + z-index: -2; + }`; + + const styleElement = document.createElement('style'); + document.head.appendChild(styleElement); + styleElement.sheet?.insertRule(blurredTemplate); +} + +export async function applyHeaderColor(rating: string): Promise { + const result = await getLocal('themeHeaderRating'); + if (!result['themeHeaderRating']) { + return; + } + + const header = document.getElementById('headerPopup'); + if (!header) { + return; + } + + header.classList.add(rating); +} diff --git a/src/scripts/views/settings.ts b/src/scripts/views/settings.ts index c4684ef..10cd8cf 100644 --- a/src/scripts/views/settings.ts +++ b/src/scripts/views/settings.ts @@ -1,113 +1,20 @@ -const updateInput = document.getElementById('update') as HTMLInputElement; -const themeInput = document.getElementById('theme') as HTMLInputElement; -const themeRatingInput = document.getElementById('themeRating') as HTMLInputElement; -const curatorModeInput = document.getElementById('curatorMode') as HTMLInputElement; -// const telemetryInput = document.getElementById('telemetry') as HTMLInputElement; -const apiInput = document.getElementById('api') as HTMLInputElement; - - -chrome.storage.local.get( - [ - 'db', - 'lastModified', - 'interval', - 'themeHeader', - 'themeHeaderRating', - 'curatorMode', - 'sentry', - 'api' - ], - function (result) { - if (result["db"]) { - const db = result["db"]; - const lastModified = new Date(result["lastModified"]); - - document.getElementById('date')!.innerText = - lastModified.toLocaleDateString('en-US'); - document.getElementById('indexed')!.innerText = db.length; - } else { - const elements = document.getElementsByClassName('dbavailable'); - for (let i = 0; i < elements.length; i++) { - elements[i]?.remove(); - } - } - - if (result["interval"]) { - document.getElementById('days')!.innerText = result["interval"]; - updateInput.value = result["interval"]; - } - - if (result["themeHeader"]) { - themeInput.checked = result["themeHeader"]; - } - - if (result["themeHeaderRating"]) { - themeRatingInput.checked = result["themeHeaderRating"]; - } - - if (result["curatorMode"]) { - curatorModeInput.checked = result["curatorMode"]; - } - - if (result["api"]) { - if (result["api"].length !== 0) apiInput.value = result["api"]; - } - - // if (result.sentry) { - // telemetryInput.checked = result.sentry; - // } +import { populateSettingsForm } from './settings/state'; +import { registerSettingsHandlers } from './settings/handlers'; + +void (async function initSettings(): Promise { + await waitForDomReady(); + await populateSettingsForm(); + registerSettingsHandlers(); +})(); + +async function waitForDomReady(): Promise { + if (document.readyState !== 'loading') { + return; } -); - -updateInput.onchange = function () { - document.getElementById('days')!.innerText = updateInput.value; - chrome.storage.local.set({ interval: updateInput.value }, function () { - console.log('database update interval changed'); + await new Promise((resolve) => { + document.addEventListener('DOMContentLoaded', () => resolve(), { + once: true, + }); }); -}; - -themeInput.addEventListener('change', function () { - chrome.storage.local.set( - { themeHeader: themeInput.checked }, - function () { - console.log('theme header value changed'); - } - ); -}); - -themeRatingInput.addEventListener('change', function () { - chrome.storage.local.set( - { themeHeaderRating: themeRatingInput.checked }, - function () { - console.log('theme header (rating) value changed'); - } - ); -}); - -curatorModeInput.addEventListener('change', function () { - chrome.storage.local.set( - { curatorMode: curatorModeInput.checked }, - function () { - console.log('curatormode has been toggled.'); - } - ); -}); - -apiInput.addEventListener('change', function () { - chrome.storage.local.set( - { api: apiInput.value }, - function () { - console.log('api url has been changed.'); - } - ); -}); - -// telemetryInput.addEventListener('change', function () { -// chrome.storage.local.set( -// { sentry: telemetryInput.checked }, -// function () { -// console.log('telemetry has been toggled.'); -// } -// ); -// }); \ No newline at end of file +} diff --git a/src/scripts/views/settings/handlers.ts b/src/scripts/views/settings/handlers.ts new file mode 100644 index 0000000..a32ddc0 --- /dev/null +++ b/src/scripts/views/settings/handlers.ts @@ -0,0 +1,55 @@ +import { setLocal } from '../../lib/chromeStorage'; +import { normalizeLanguage } from '../../lib/language'; + +export function registerSettingsHandlers(): void { + const updateInput = document.getElementById('update') as HTMLInputElement | null; + const themeInput = document.getElementById('theme') as HTMLInputElement | null; + const themeRatingInput = document.getElementById('themeRating') as HTMLInputElement | null; + const curatorModeInput = document.getElementById('curatorMode') as HTMLInputElement | null; + const apiInput = document.getElementById('api') as HTMLInputElement | null; + const languageSelect = document.getElementById('language') as HTMLSelectElement | null; + + if (updateInput) { + updateInput.addEventListener('change', () => { + const daysElement = document.getElementById('days'); + if (daysElement) { + daysElement.textContent = updateInput.value; + } + void setLocal({ interval: updateInput.value }); + }); + } + + if (themeInput) { + themeInput.addEventListener('change', () => { + void setLocal({ themeHeader: themeInput.checked }); + }); + } + + if (themeRatingInput) { + themeRatingInput.addEventListener('change', () => { + void setLocal({ themeHeaderRating: themeRatingInput.checked }); + }); + } + + if (curatorModeInput) { + curatorModeInput.addEventListener('change', () => { + void setLocal({ curatorMode: curatorModeInput.checked }); + }); + } + + if (apiInput) { + apiInput.addEventListener('change', () => { + void setLocal({ api: apiInput.value }); + }); + } + + if (languageSelect) { + languageSelect.addEventListener('change', () => { + const normalized = normalizeLanguage(languageSelect.value) ?? 'en'; + if (languageSelect.value !== normalized) { + languageSelect.value = normalized; + } + void setLocal({ language: normalized }); + }); + } +} diff --git a/src/scripts/views/settings/state.ts b/src/scripts/views/settings/state.ts new file mode 100644 index 0000000..a86d711 --- /dev/null +++ b/src/scripts/views/settings/state.ts @@ -0,0 +1,78 @@ +import { getLocal } from '../../lib/chromeStorage'; +import { resolveLanguage } from '../../lib/language'; + +export async function populateSettingsForm(): Promise { + const elements = collectElements(); + const result = await getLocal([ + 'db', + 'lastModified', + 'interval', + 'themeHeader', + 'themeHeaderRating', + 'curatorMode', + 'sentry', + 'api', + 'language', + ]); + + if (Array.isArray(result['db'])) { + const lastModified = new Date(String(result['lastModified'])); + if (!Number.isNaN(lastModified.getTime()) && elements.date) { + elements.date.textContent = lastModified.toLocaleDateString('en-US'); + } + if (elements.indexed) { + elements.indexed.textContent = String(result['db'].length); + } + } else { + removeDatabaseIndicators(); + } + + if (typeof result['interval'] === 'number' || typeof result['interval'] === 'string') { + if (elements.days) { + elements.days.textContent = String(result['interval']); + } + if (elements.updateInput) { + elements.updateInput.value = String(result['interval']); + } + } + + if (elements.themeInput) { + elements.themeInput.checked = Boolean(result['themeHeader']); + } + + if (elements.themeRatingInput) { + elements.themeRatingInput.checked = Boolean(result['themeHeaderRating']); + } + + if (elements.curatorModeInput) { + elements.curatorModeInput.checked = Boolean(result['curatorMode']); + } + + if (elements.apiInput && typeof result['api'] === 'string') { + elements.apiInput.value = result['api']; + } + + if (elements.languageSelect) { + const language = resolveLanguage(result['language']); + elements.languageSelect.value = language; + } +} + +function collectElements() { + return { + updateInput: document.getElementById('update') as HTMLInputElement | null, + themeInput: document.getElementById('theme') as HTMLInputElement | null, + themeRatingInput: document.getElementById('themeRating') as HTMLInputElement | null, + curatorModeInput: document.getElementById('curatorMode') as HTMLInputElement | null, + apiInput: document.getElementById('api') as HTMLInputElement | null, + languageSelect: document.getElementById('language') as HTMLSelectElement | null, + date: document.getElementById('date') as HTMLElement | null, + indexed: document.getElementById('indexed') as HTMLElement | null, + days: document.getElementById('days') as HTMLElement | null, + }; +} + +function removeDatabaseIndicators(): void { + const availableElements = document.getElementsByClassName('dbavailable'); + Array.from(availableElements).forEach((element) => element.remove()); +} diff --git a/src/views/background.html b/src/views/background.html new file mode 100644 index 0000000..ee00358 --- /dev/null +++ b/src/views/background.html @@ -0,0 +1,10 @@ + + + + + ToS;DR + + + + + diff --git a/src/views/popup.html b/src/views/popup.html index 2cfd36b..a2ef6a3 100644 --- a/src/views/popup.html +++ b/src/views/popup.html @@ -50,7 +50,10 @@

Please wait, loading...

@@ -109,6 +112,9 @@

- - v6.0.0 + - + diff --git a/src/views/settings/settings.html b/src/views/settings/settings.html index 3447074..df1b3a9 100644 --- a/src/views/settings/settings.html +++ b/src/views/settings/settings.html @@ -54,6 +54,27 @@

Appearance Settings

+
+
+
+
+ +

Language

+
+

+ Controls the language used for service details. +

+
+ +
+
+

Database Settings

@@ -153,6 +174,6 @@

Advanced Debugging Settings

- + diff --git a/src/views/settings/style/settings.css b/src/views/settings/style/settings.css index 3cc523f..d7d5c18 100644 --- a/src/views/settings/style/settings.css +++ b/src/views/settings/style/settings.css @@ -159,4 +159,14 @@ input[type='text'] { padding: 0.2rem; font-size: 1rem; font-weight: normal; -} \ No newline at end of file +} + +.languageSelect { + width: 100%; + border: none; + border-bottom: 1px solid #ccc; + background-color: transparent; + padding: 0.2rem; + font-size: 1rem; + font-weight: normal; +} diff --git a/src/views/style/popup.css b/src/views/style/popup.css index 438c439..ab8f89f 100644 --- a/src/views/style/popup.css +++ b/src/views/style/popup.css @@ -201,6 +201,13 @@ html { cursor: pointer; } +.translationWarning { + margin: 0.5rem 1rem 1.5rem; + font-size: 0.8rem; + color: #555; + text-align: center; +} + .labeltext { font-size: 1rem; font-weight: normal; @@ -221,6 +228,22 @@ html { align-items: center; position: absolute; color: white; + flex-direction: row; + gap: 1.5rem; + padding: 1.5rem; + text-align: left; +} + +.errorText { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 260px; +} + +.errorText > h2, +.errorText > p { + margin: 0; } #loading { @@ -373,4 +396,4 @@ button { padding: .5rem; margin-bottom: .5rem; line-height: 1; -} \ No newline at end of file +} diff --git a/vite.chrome.config.ts b/vite.chrome.config.ts index 13d4d46..8290d56 100644 --- a/vite.chrome.config.ts +++ b/vite.chrome.config.ts @@ -1,6 +1,6 @@ import { defineConfig, mergeConfig } from 'vite'; import baseConfig from './vite.config'; -import { copyFileSync, mkdirSync, existsSync, cpSync } from 'fs'; +import { copyFileSync, mkdirSync, existsSync, cpSync, rmSync } from 'fs'; // Custom plugin to copy Chrome-specific assets function copyChromeAssetsPlugin() { @@ -53,14 +53,25 @@ function copyChromeAssetsPlugin() { } copyFileSync(`${outDir}/src/views/popup.html`, `${outDir}/views/popup.html`); } - + if (existsSync(`${outDir}/src/views/settings/settings.html`)) { if (!existsSync(`${outDir}/views/settings`)) { mkdirSync(`${outDir}/views/settings`, { recursive: true }); } copyFileSync(`${outDir}/src/views/settings/settings.html`, `${outDir}/views/settings/settings.html`); } - + + if (existsSync(`${outDir}/src/views/background.html`)) { + if (!existsSync(`${outDir}/views`)) { + mkdirSync(`${outDir}/views`, { recursive: true }); + } + copyFileSync(`${outDir}/src/views/background.html`, `${outDir}/views/background.html`); + } + + if (existsSync(`${outDir}/src`)) { + rmSync(`${outDir}/src`, { recursive: true, force: true }); + } + console.log('Chrome assets copied successfully'); } }; @@ -79,4 +90,4 @@ export default defineConfig( minify: true } }) -); \ No newline at end of file +); diff --git a/vite.config.ts b/vite.config.ts index 4502a5d..b08206d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,7 +23,8 @@ export default defineConfig({ popup: resolve(__dirname, 'src/scripts/views/popup.ts'), settings: resolve(__dirname, 'src/scripts/views/settings.ts'), 'popup-html': resolve(__dirname, 'src/views/popup.html'), - 'settings-html': resolve(__dirname, 'src/views/settings/settings.html') + 'settings-html': resolve(__dirname, 'src/views/settings/settings.html'), + 'background-html': resolve(__dirname, 'src/views/background.html') }, output: { entryFileNames: (chunkInfo) => { @@ -62,4 +63,4 @@ export default defineConfig({ define: { 'process.env.NODE_ENV': JSON.stringify(process.env['NODE_ENV'] || 'development') } -}); \ No newline at end of file +}); diff --git a/vite.firefox.config.ts b/vite.firefox.config.ts index 825b398..e977266 100644 --- a/vite.firefox.config.ts +++ b/vite.firefox.config.ts @@ -1,6 +1,6 @@ import { defineConfig, mergeConfig } from 'vite'; import baseConfig from './vite.config'; -import { copyFileSync, mkdirSync, existsSync, cpSync } from 'fs'; +import { copyFileSync, mkdirSync, existsSync, cpSync, rmSync } from 'fs'; // Custom plugin to copy Firefox-specific assets function copyFirefoxAssetsPlugin() { @@ -53,14 +53,25 @@ function copyFirefoxAssetsPlugin() { } copyFileSync(`${outDir}/src/views/popup.html`, `${outDir}/views/popup.html`); } - + if (existsSync(`${outDir}/src/views/settings/settings.html`)) { if (!existsSync(`${outDir}/views/settings`)) { mkdirSync(`${outDir}/views/settings`, { recursive: true }); } copyFileSync(`${outDir}/src/views/settings/settings.html`, `${outDir}/views/settings/settings.html`); } - + + if (existsSync(`${outDir}/src/views/background.html`)) { + if (!existsSync(`${outDir}/views`)) { + mkdirSync(`${outDir}/views`, { recursive: true }); + } + copyFileSync(`${outDir}/src/views/background.html`, `${outDir}/views/background.html`); + } + + if (existsSync(`${outDir}/src`)) { + rmSync(`${outDir}/src`, { recursive: true, force: true }); + } + console.log('Firefox assets copied successfully'); } }; @@ -79,4 +90,4 @@ export default defineConfig( minify: true } }) -); \ No newline at end of file +);