diff --git a/eslint.config.mjs b/eslint.config.mjs index ce9836a..5998ce0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,8 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import js from '@eslint/js'; import { FlatCompat } from '@eslint/eslintrc'; +import tseslint from '@typescript-eslint/eslint-plugin'; +import tsparser from '@typescript-eslint/parser'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -17,7 +19,8 @@ export default [ ...compat.extends( 'eslint:recommended', 'plugin:react/recommended', - 'plugin:jasmine/recommended' + 'plugin:jasmine/recommended', + 'plugin:@typescript-eslint/recommended' ), { ignores: [ @@ -27,9 +30,10 @@ export default [ ], }, { - files: ['**/*.{js,jsx,mjs}'], + files: ['**/*.{js,jsx,mjs,ts,tsx}'], plugins: { jasmine, + '@typescript-eslint': tseslint, }, settings: { @@ -39,6 +43,7 @@ export default [ }, languageOptions: { + parser: tsparser, globals: { ...globals.browser, ...globals.jasmine, @@ -47,10 +52,8 @@ export default [ SharedArrayBuffer: 'readonly', React: 'readonly', }, - ecmaVersion: 'latest', sourceType: 'module', - parserOptions: { ecmaFeatures: { jsx: true, @@ -60,6 +63,8 @@ export default [ rules: { 'react/prop-types': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], }, } ]; diff --git a/package.json b/package.json index 4501751..97f931e 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,19 @@ "@babel/core": "^7.26.0", "@babel/preset-env": "^7.26.0", "@babel/preset-react": "^7.25.9", + "@babel/preset-typescript": "^7.23.3", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.14.0", + "@types/chrome": "^0.0.260", + "@types/react": "^18.2.64", + "@types/react-dom": "^18.2.21", + "@typescript-eslint/eslint-plugin": "^7.1.1", + "@typescript-eslint/parser": "^7.1.1", "babel-loader": "^9.2.1", "classnames": "^2.5.1", "copy-webpack-plugin": "^12.0.2", "css-loader": "^7.1.2", - "eslint": "^9.14.0", + "eslint": "^8.56.0", "eslint-plugin-jasmine": "^4.2.2", "eslint-plugin-react": "^7.37.2", "globals": "^15.12.0", @@ -32,6 +38,7 @@ "sass": "^1.81.0", "sass-loader": "^16.0.3", "style-loader": "^0.23.1", + "typescript": "^5.3.3", "webpack": "^5.96.1", "webpack-cli": "^5.1.4", "webpack-merge": "^6.0.1" diff --git a/src/assets/js/components/popup-spinner.jsx b/src/assets/js/components/popup-spinner.tsx similarity index 87% rename from src/assets/js/components/popup-spinner.jsx rename to src/assets/js/components/popup-spinner.tsx index d8461f4..c4243c5 100644 --- a/src/assets/js/components/popup-spinner.jsx +++ b/src/assets/js/components/popup-spinner.tsx @@ -1,4 +1,4 @@ -export const PopupSpinner = () => { +export const PopupSpinner: React.FC = () => { return (
{
); -}; - +}; \ No newline at end of file diff --git a/src/assets/js/components/proposal.jsx b/src/assets/js/components/proposal.tsx similarity index 52% rename from src/assets/js/components/proposal.jsx rename to src/assets/js/components/proposal.tsx index 4f101e9..582dcdb 100644 --- a/src/assets/js/components/proposal.jsx +++ b/src/assets/js/components/proposal.tsx @@ -21,81 +21,106 @@ import { openSettings, } from '@/features/popupSlice'; -export const Proposal = () => { +interface ErrorState { + show: boolean; + message: string; +} + +interface ProposalState { + user: any; // TODO: Define proper user type + title: string; + url: string; + cookieJar: Cookie[]; + state?: SavedAssetState; +} + +interface SavedAssetState { + assetId: string; + withCookies: boolean; +} + +interface Cookie { + domain: string; + name: string; + value: string; + hostOnly?: boolean; +} + +interface ResourceEntry { + name: string; +} + +type ButtonState = 'add' | 'update' | 'loading'; + +export const Proposal: React.FC = () => { const dispatch = useDispatch(); - const [isLoading, setIsLoading] = useState(false); - const [assetTitle, setAssetTitle] = useState(''); - const [assetUrl, setAssetUrl] = useState(''); - const [assetHostname, setAssetHostname] = useState(''); - const [buttonState, setButtonState] = useState('add'); - const [error, setError] = useState({ + const [isLoading, setIsLoading] = useState(false); + const [assetTitle, setAssetTitle] = useState(''); + const [assetUrl, setAssetUrl] = useState(''); + const [assetHostname, setAssetHostname] = useState(''); + const [buttonState, setButtonState] = useState('add'); + const [error, setError] = useState({ show: false, message: 'Failed to add or update asset' }); - const [bypassVerification, setBypassVerification] = useState(false); - const [saveAuthentication, setSaveAuthentication] = useState(false); - const [proposal, setProposal] = useState(null); + const [bypassVerification, setBypassVerification] = useState(false); + const [saveAuthentication, setSaveAuthentication] = useState(false); + const [proposal, setProposal] = useState(null); - const updateProposal = (newProposal) => { - setError((prev) => { - return { - ...prev, - show: false - }; - }); + const updateProposal = async (newProposal: ProposalState) => { + setError((prev) => ({ + ...prev, + show: false + })); let currentProposal = newProposal; const url = currentProposal.url; - return State.getSavedAssetState(url) - .then((state) => { - if (state) - // Does the asset still exist? - return getWebAsset(state.assetId, newProposal.user) - .then(() => { - // Yes it does. Proceed with the update path. - return state; - }) - .catch((error) => { - if (error.status === 404) { - // It's gone. Proceed like if we never had it. - State.setSavedAssetState(url, null); - return undefined; - } - - throw error; - }); - }) - .then((state) => { - currentProposal.state = state; - - setAssetTitle(currentProposal.title); - setAssetUrl(currentProposal.url); - setAssetHostname(new URL(url).hostname); - - setProposal(currentProposal); - - if (state) { - setSaveAuthentication(state.withCookies); - setButtonState('update'); - } else { - setButtonState('add'); + try { + const state = await State.getSavedAssetState(url); + + if (state) { + try { + await getWebAsset(state.assetId, newProposal.user); + currentProposal.state = state; + } catch (error: any) { + if (error.status === 404) { + State.setSavedAssetState(url, null); + currentProposal.state = undefined; + } else { + throw error; + } } - }) - .catch((error) => { - // Unknown error. - setError((prev) => { - return { - ...prev, - show: true, - message: 'Failed to check asset.' - }; - }); - throw error; - }); - } + } + + setAssetTitle(currentProposal.title); + setAssetUrl(currentProposal.url); + setAssetHostname(new URL(url).hostname); - const proposeToAddToScreenly = async(user, url, title, cookieJar) => { + setProposal(currentProposal); + + if (currentProposal.state) { + setSaveAuthentication(currentProposal.state.withCookies); + setButtonState('update'); + } else { + setButtonState('add'); + } + } catch (error) { + setError((prev) => ({ + ...prev, + show: true, + message: 'Failed to check asset.' + })); + throw error; + } + }; + + const proposeToAddToScreenly = async ( + user: any, + url: string, + title: string, + cookieJar: Cookie[] + ) => { await updateProposal({ user, title, @@ -104,7 +129,7 @@ export const Proposal = () => { }); }; - const prepareToAddToScreenly = async() => { + const prepareToAddToScreenly = async () => { const onlyPrimaryDomain = true; const user = await getUser(); @@ -112,12 +137,14 @@ export const Proposal = () => { return; } - let tabs = await browser.tabs.query({ active: true, currentWindow: true }); - let tabId = tabs[0].id; + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs[0].id; + + if (!tabId) return; try { - let result = await browser.scripting.executeScript({ - target: { tabId: tabId }, + const result = await browser.scripting.executeScript({ + target: { tabId }, func: () => { return [ window.location.href, @@ -127,7 +154,7 @@ export const Proposal = () => { } }); - let [pageUrl, pageTitle, resourceEntries] = result[0].result; + const [pageUrl, pageTitle, resourceEntries] = result[0].result; if (!resourceEntries) { return; @@ -135,7 +162,7 @@ export const Proposal = () => { const originDomain = new URL(pageUrl).host; - let results = await Promise.all( + const results = await Promise.all( resourceEntries.map(url => browser.cookies.getAll({ url }) ) @@ -155,10 +182,9 @@ export const Proposal = () => { ); if (onlyPrimaryDomain) { - // noinspection JSUnresolvedVariable cookieJar = cookieJar.filter(cookie => cookie.domain === originDomain || (!cookie.hostOnly && originDomain.endsWith(cookie.domain)) - ) + ); } await proposeToAddToScreenly(user, pageUrl, pageTitle, cookieJar); @@ -175,123 +201,108 @@ export const Proposal = () => { }); }, []); - const handleSettings = (event) => { + const handleSettings = (event: React.MouseEvent) => { event.preventDefault(); dispatch(openSettings()); }; - const handleSubmission = async (event) => { + const handleSubmission = async (event: React.FormEvent) => { event.preventDefault(); - let currentProposal = proposal; - - if (buttonState === 'loading') { + if (!proposal || buttonState === 'loading') { return; } setButtonState('loading'); - let headers = {}; + let headers: Record = {}; - if (saveAuthentication && currentProposal.cookieJar) { + if (saveAuthentication && proposal.cookieJar) { headers = { - 'Cookie': currentProposal.cookieJar.map( + 'Cookie': proposal.cookieJar.map( cookie => cookiejs.serialize(cookie.name, cookie.value) ).join('; ') }; } - const state = currentProposal.state; - let action = ( - !state ? - createWebAsset( - currentProposal.user, - currentProposal.url, - currentProposal.title, + const state = proposal.state; + try { + const result = !state + ? await createWebAsset( + proposal.user, + proposal.url, + proposal.title, headers, bypassVerification - ) : - updateWebAsset( + ) + : await updateWebAsset( state.assetId, - currentProposal.user, - currentProposal.url, - currentProposal.title, + proposal.user, + proposal.url, + proposal.title, headers, bypassVerification - ) - ); + ); - action - .then((result) => { - if (result.length === 0) { - throw 'No asset data returned'; - } + if (result.length === 0) { + throw new Error('No asset data returned'); + } - State.setSavedAssetState( - currentProposal.url, - result[0].id, - saveAuthentication, - bypassVerification - ); + State.setSavedAssetState( + proposal.url, + result[0].id, + saveAuthentication, + bypassVerification + ); - setButtonState(state ? 'update' : 'add'); + setButtonState(state ? 'update' : 'add'); - const event = new CustomEvent('set-asset-dashboard-link', { - detail: getAssetDashboardLink(result[0].id) - }); - document.dispatchEvent(event); - - dispatch(notifyAssetSaveSuccess()); - }) - .catch((error) => { - if (error.statusCode === 401) { - setError((prev) => { - return { - ...prev, - show: true, - message: 'Screenly authentication failed. Try signing out and back in again.' - }; - }); - return; + const event = new CustomEvent('set-asset-dashboard-link', { + detail: getAssetDashboardLink(result[0].id) + }); + document.dispatchEvent(event); + + dispatch(notifyAssetSaveSuccess()); + } catch (error: any) { + if (error.statusCode === 401) { + setError((prev) => ({ + ...prev, + show: true, + message: 'Screenly authentication failed. Try signing out and back in again.' + })); + return; + } + + try { + const errorJson = await error.json(); + if ( + errorJson.type && + errorJson.type[0] === 'AssetUnreachableError' + ) { + setBypassVerification(true); + setError((prev) => ({ + ...prev, + show: true, + message: 'Screenly couldn\'t reach this web page. To save it anyhow, use the Bypass Verification option.' + })); + } else if (!errorJson.type) { + throw JSON.stringify(errorJson); + } else { + throw new Error('Unknown error'); } + } catch (jsonError) { + const prefix = state + ? 'Failed to update asset' + : 'Failed to save web page'; - error.json() - .then((errorJson) => { - if ( - errorJson.type && - errorJson.type[0] === 'AssetUnreachableError' - ) { - setBypassVerification(true); - setError((prev) => { - return { - ...prev, - show: true, - message: 'Screenly couldn\'t reach this web page. To save it anyhow, use the Bypass Verification option.' - }; - }); - } else if (!errorJson.type) { - throw JSON.stringify(errorJson); - } else { - throw 'Unknown error'; - } - }).catch((error) => { - const prefix = ( - state ? - 'Failed to update asset' : - 'Failed to save web page' - ); - setError((prev) => { - return { - ...prev, - show: true, - message: ( - `${prefix}: ${error}` - ) - }; - }); - - setButtonState(state ? 'update' : 'add'); - }); - }); + setError((prev) => ({ + ...prev, + show: true, + message: `${prefix}: ${jsonError}` + })); + + setButtonState(state ? 'update' : 'add'); + } + } }; if (isLoading) { @@ -325,7 +336,7 @@ export const Proposal = () => { className="form-check-label" htmlFor="with-auth-check" > - Save Authentication + Save Authentication