From b37a6b07b39c8c7827052dc6fb97f490f1e0f514 Mon Sep 17 00:00:00 2001 From: Markus Hatvan Date: Thu, 18 Nov 2021 17:37:45 +0100 Subject: chore: convert various files to TS (#2246) * convert various files to TS * removed outdated docs/example-feature folder * turn off unicorn/no-empty-file * update eslint config --- .eslintrc.js | 6 + docs/example-feature/actions.js | 10 - docs/example-feature/api.js | 5 - docs/example-feature/state.js | 14 - docs/example-feature/store.js | 36 -- src/I18n.js | 42 -- src/I18n.tsx | 42 ++ src/api/server/ServerApi.js | 610 --------------------- src/api/server/ServerApi.ts | 616 ++++++++++++++++++++++ src/api/utils/auth.ts | 2 +- src/components/services/content/ServiceWebview.js | 12 +- src/components/ui/Tabs/TabItem.tsx | 4 +- src/features/communityRecipes/store.ts | 2 +- src/features/publishDebugInfo/index.js | 19 - src/features/publishDebugInfo/index.ts | 19 + src/i18n/translations.ts | 24 +- src/internal-server/database/factory.js | 1 - src/routes.js | 98 ---- src/routes.tsx | 97 ++++ src/stores/AppStore.js | 4 +- src/stores/ServicesStore.js | 56 +- src/webview/lib/RecipeWebview.js | 166 ------ src/webview/lib/RecipeWebview.ts | 177 +++++++ src/webview/lib/Userscript.js | 138 ----- src/webview/lib/Userscript.ts | 107 ++++ 25 files changed, 1118 insertions(+), 1189 deletions(-) delete mode 100644 docs/example-feature/actions.js delete mode 100644 docs/example-feature/api.js delete mode 100644 docs/example-feature/state.js delete mode 100644 docs/example-feature/store.js delete mode 100644 src/I18n.js create mode 100644 src/I18n.tsx delete mode 100644 src/api/server/ServerApi.js create mode 100644 src/api/server/ServerApi.ts delete mode 100644 src/features/publishDebugInfo/index.js create mode 100644 src/features/publishDebugInfo/index.ts delete mode 100644 src/routes.js create mode 100644 src/routes.tsx delete mode 100644 src/webview/lib/RecipeWebview.js create mode 100644 src/webview/lib/RecipeWebview.ts delete mode 100644 src/webview/lib/Userscript.js create mode 100644 src/webview/lib/Userscript.ts diff --git a/.eslintrc.js b/.eslintrc.js index b18927381..f7ca7b620 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -83,6 +83,7 @@ module.exports = { 'react/state-in-constructor': 0, 'react/static-property-placement': 0, 'react/function-component-definition': 0, + 'react/jsx-no-useless-fragment': 0, // eslint-plugin-jsx-a11y 'jsx-a11y/click-events-have-key-events': 1, 'jsx-a11y/mouse-events-have-key-events': 1, @@ -116,6 +117,8 @@ module.exports = { }, ], 'unicorn/consistent-destructuring': 0, + // INFO: Turned off due to src/internal-server/database/factory.js + 'unicorn/no-empty-file': 0, // eslint-plugin-prettier 'prettier/prettier': 1, }, @@ -167,6 +170,7 @@ module.exports = { 'react/state-in-constructor': 1, 'react/sort-comp': 0, 'react/function-component-definition': 0, + 'react/jsx-no-useless-fragment': 0, // eslint-plugin-jsx-a11y 'jsx-a11y/click-events-have-key-events': 1, 'jsx-a11y/no-static-element-interactions': 1, @@ -189,6 +193,8 @@ module.exports = { }, ], 'unicorn/consistent-destructuring': 0, + // INFO: Turned off due to src/internal-server/database/factory.js + 'unicorn/no-empty-file': 0, // eslint-plugin-prettier 'prettier/prettier': 1, }, diff --git a/docs/example-feature/actions.js b/docs/example-feature/actions.js deleted file mode 100644 index c4d49b708..000000000 --- a/docs/example-feature/actions.js +++ /dev/null @@ -1,10 +0,0 @@ -import PropTypes from 'prop-types'; -import { createActionsFromDefinitions } from '../../src/actions/lib/actions'; - -export const exampleFeatureActions = createActionsFromDefinitions({ - greet: { - name: PropTypes.string.isRequired, - }, -}, PropTypes.checkPropTypes); - -export default exampleFeatureActions; diff --git a/docs/example-feature/api.js b/docs/example-feature/api.js deleted file mode 100644 index d9c769c91..000000000 --- a/docs/example-feature/api.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - async getName() { - return Promise.resolve('Ferdi'); - }, -}; diff --git a/docs/example-feature/state.js b/docs/example-feature/state.js deleted file mode 100644 index 676717da7..000000000 --- a/docs/example-feature/state.js +++ /dev/null @@ -1,14 +0,0 @@ -import { observable } from 'mobx'; - -const defaultState = { - name: null, - isFeatureActive: false, -}; - -export const exampleFeatureState = observable(defaultState); - -export function resetState() { - Object.assign(exampleFeatureState, defaultState); -} - -export default exampleFeatureState; diff --git a/docs/example-feature/store.js b/docs/example-feature/store.js deleted file mode 100644 index 9fc86de36..000000000 --- a/docs/example-feature/store.js +++ /dev/null @@ -1,36 +0,0 @@ -import { action, observable, reaction } from 'mobx'; -import Store from '../../src/stores/lib/Store'; -import Request from '../../src/stores/lib/Request'; - -const debug = require('debug')('Ferdi:feature:EXAMPLE_FEATURE:store'); - -export class ExampleFeatureStore extends Store { - @observable getNameRequest = new Request(this.api, 'getName'); - - constructor(stores, api, actions, state) { - super(stores, api, actions); - this.state = state; - } - - setup() { - debug('fetching name from api'); - this.getNameRequest.execute(); - - // Update the name on the state when the request resolved - reaction( - () => ( - this.getNameRequest.result - ), - (name) => { - this._setName(name); - }, - ); - } - - @action _setName = (name) => { - debug('setting name', name); - this.state.name = name; - }; -} - -export default ExampleFeatureStore; diff --git a/src/I18n.js b/src/I18n.js deleted file mode 100644 index b10c5a94b..000000000 --- a/src/I18n.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import { inject, observer } from 'mobx-react'; -import { IntlProvider } from 'react-intl'; - -import { oneOrManyChildElements } from './prop-types'; -import translations from './i18n/translations'; -import UserStore from './stores/UserStore'; -import AppStore from './stores/AppStore'; - -@inject('stores') -@observer -class I18N extends Component { - componentDidUpdate() { - window['ferdi'].menu.rebuild(); - } - - render() { - const { stores, children } = this.props; - const { locale } = stores.app; - return ( - { - window['ferdi'].intl = intlProvider ? intlProvider.state.intl : null; - }} - > - {children} - - ); - } -} - -I18N.wrappedComponent.propTypes = { - stores: PropTypes.shape({ - app: PropTypes.instanceOf(AppStore).isRequired, - user: PropTypes.instanceOf(UserStore).isRequired, - }).isRequired, - children: oneOrManyChildElements.isRequired, -}; - -export default I18N; diff --git a/src/I18n.tsx b/src/I18n.tsx new file mode 100644 index 000000000..39b5273c1 --- /dev/null +++ b/src/I18n.tsx @@ -0,0 +1,42 @@ +import { Component, ReactNode } from 'react'; +import { inject, observer } from 'mobx-react'; +import { IntlProvider } from 'react-intl'; + +import { generatedTranslations } from './i18n/translations'; +import UserStore from './stores/UserStore'; +import AppStore from './stores/AppStore'; + +const translations = generatedTranslations(); + +type Props = { + stores: { + app: typeof AppStore; + user: typeof UserStore; + }; + children: ReactNode; +}; + +@inject('stores') +@observer +class I18N extends Component { + componentDidUpdate() { + window['ferdi'].menu.rebuild(); + } + + render() { + const { stores, children } = this.props; + const { locale } = stores.app; + return ( + { + window['ferdi'].intl = intlProvider ? intlProvider.state.intl : null; + }} + > + {children} + + ); + } +} + +export default I18N; diff --git a/src/api/server/ServerApi.js b/src/api/server/ServerApi.js deleted file mode 100644 index bcc72ffdc..000000000 --- a/src/api/server/ServerApi.js +++ /dev/null @@ -1,610 +0,0 @@ -/* eslint-disable import/no-import-module-exports */ -/* eslint-disable global-require */ -import { join } from 'path'; -import tar from 'tar'; -import { - readdirSync, - statSync, - writeFileSync, - copySync, - ensureDirSync, - pathExistsSync, - readJsonSync, - removeSync, -} from 'fs-extra'; -import fetch from 'electron-fetch'; - -import ServiceModel from '../../models/Service'; -import RecipePreviewModel from '../../models/RecipePreview'; -import RecipeModel from '../../models/Recipe'; -import UserModel from '../../models/User'; - -import { sleep } from '../../helpers/async-helpers'; - -import { SERVER_NOT_LOADED } from '../../config'; -import { userDataRecipesPath, userDataPath } from '../../environment-remote'; -import { asarRecipesPath } from '../../helpers/asar-helpers'; -import apiBase from '../apiBase'; -import { prepareAuthRequest, sendAuthRequest } from '../utils/auth'; - -import { - getRecipeDirectory, - getDevRecipeDirectory, - loadRecipeConfig, -} from '../../helpers/recipe-helpers'; - -import { removeServicePartitionDirectory } from '../../helpers/service-helpers'; - -const debug = require('debug')('Ferdi:ServerApi'); - -module.paths.unshift(getDevRecipeDirectory(), getRecipeDirectory()); - -export default class ServerApi { - recipePreviews = []; - - recipes = []; - - // User - async login(email, passwordHash) { - const request = await sendAuthRequest( - `${apiBase()}/auth/login`, - { - method: 'POST', - headers: { - Authorization: `Basic ${window.btoa(`${email}:${passwordHash}`)}`, - }, - }, - false, - ); - if (!request.ok) { - throw request; - } - const u = await request.json(); - - debug('ServerApi::login resolves', u); - return u.token; - } - - async signup(data) { - const request = await sendAuthRequest( - `${apiBase()}/auth/signup`, - { - method: 'POST', - body: JSON.stringify(data), - }, - false, - ); - if (!request.ok) { - throw request; - } - const u = await request.json(); - - debug('ServerApi::signup resolves', u); - return u.token; - } - - async inviteUser(data) { - const request = await sendAuthRequest(`${apiBase()}/invite`, { - method: 'POST', - body: JSON.stringify(data), - }); - if (!request.ok) { - throw request; - } - - debug('ServerApi::inviteUser'); - return true; - } - - async retrievePassword(email) { - const request = await sendAuthRequest( - `${apiBase()}/auth/password`, - { - method: 'POST', - body: JSON.stringify({ - email, - }), - }, - false, - ); - if (!request.ok) { - throw request; - } - const r = await request.json(); - - debug('ServerApi::retrievePassword'); - return r; - } - - async userInfo() { - if (apiBase() === SERVER_NOT_LOADED) { - throw new Error('Server not loaded'); - } - - const request = await sendAuthRequest(`${apiBase()}/me`); - if (!request.ok) { - throw request; - } - const data = await request.json(); - - const user = new UserModel(data); - debug('ServerApi::userInfo resolves', user); - - return user; - } - - async updateUserInfo(data) { - const request = await sendAuthRequest(`${apiBase()}/me`, { - method: 'PUT', - body: JSON.stringify(data), - }); - if (!request.ok) { - throw request; - } - const updatedData = await request.json(); - - const user = Object.assign(updatedData, { - data: new UserModel(updatedData.data), - }); - debug('ServerApi::updateUserInfo resolves', user); - return user; - } - - async deleteAccount() { - const request = await sendAuthRequest(`${apiBase()}/me`, { - method: 'DELETE', - }); - if (!request.ok) { - throw request; - } - const data = await request.json(); - - debug('ServerApi::deleteAccount resolves', data); - return data; - } - - // Services - async getServices() { - if (apiBase() === SERVER_NOT_LOADED) { - throw new Error('Server not loaded'); - } - - const request = await sendAuthRequest(`${apiBase()}/me/services`); - if (!request.ok) { - throw request; - } - const data = await request.json(); - - const services = await this._mapServiceModels(data); - const filteredServices = services.filter(service => !!service); - debug('ServerApi::getServices resolves', filteredServices); - return filteredServices; - } - - async createService(recipeId, data) { - const request = await sendAuthRequest(`${apiBase()}/service`, { - method: 'POST', - body: JSON.stringify({ recipeId, ...data }), - }); - if (!request.ok) { - throw request; - } - const serviceData = await request.json(); - - if (data.iconFile) { - const iconData = await this.uploadServiceIcon( - serviceData.data.id, - data.iconFile, - ); - - serviceData.data = iconData; - } - - const service = Object.assign(serviceData, { - data: await this._prepareServiceModel(serviceData.data), - }); - - debug('ServerApi::createService resolves', service); - return service; - } - - async updateService(serviceId, rawData) { - const data = rawData; - - if (data.iconFile) { - await this.uploadServiceIcon(serviceId, data.iconFile); - } - - const request = await sendAuthRequest(`${apiBase()}/service/${serviceId}`, { - method: 'PUT', - body: JSON.stringify(data), - }); - - if (!request.ok) { - throw request; - } - - const serviceData = await request.json(); - - const service = Object.assign(serviceData, { - data: await this._prepareServiceModel(serviceData.data), - }); - - debug('ServerApi::updateService resolves', service); - return service; - } - - async uploadServiceIcon(serviceId, icon) { - const formData = new FormData(); - formData.append('icon', icon); - - const requestData = prepareAuthRequest({ - method: 'PUT', - body: formData, - }); - - delete requestData.headers['Content-Type']; - - const request = await window.fetch( - `${apiBase()}/service/${serviceId}`, - requestData, - ); - - if (!request.ok) { - throw request; - } - - const serviceData = await request.json(); - - return serviceData.data; - } - - async reorderService(data) { - const request = await sendAuthRequest(`${apiBase()}/service/reorder`, { - method: 'PUT', - body: JSON.stringify(data), - }); - if (!request.ok) { - throw request; - } - const serviceData = await request.json(); - debug('ServerApi::reorderService resolves', serviceData); - return serviceData; - } - - async deleteService(id) { - const request = await sendAuthRequest(`${apiBase()}/service/${id}`, { - method: 'DELETE', - }); - if (!request.ok) { - throw request; - } - const data = await request.json(); - - removeServicePartitionDirectory(id, true); - - debug('ServerApi::deleteService resolves', data); - return data; - } - - // Features - async getDefaultFeatures() { - const request = await sendAuthRequest(`${apiBase()}/features/default`); - if (!request.ok) { - throw request; - } - const data = await request.json(); - - const features = data; - debug('ServerApi::getDefaultFeatures resolves', features); - return features; - } - - async getFeatures() { - if (apiBase() === SERVER_NOT_LOADED) { - throw new Error('Server not loaded'); - } - - const request = await sendAuthRequest(`${apiBase()}/features`); - if (!request.ok) { - throw request; - } - const data = await request.json(); - - const features = data; - debug('ServerApi::getFeatures resolves', features); - return features; - } - - // Recipes - async getInstalledRecipes() { - const recipesDirectory = getRecipeDirectory(); - const paths = readdirSync(recipesDirectory).filter( - file => - statSync(join(recipesDirectory, file)).isDirectory() && - file !== 'temp' && - file !== 'dev', - ); - - this.recipes = paths - .map(id => { - // eslint-disable-next-line import/no-dynamic-require - const Recipe = require(id)(RecipeModel); - return new Recipe(loadRecipeConfig(id)); - }) - .filter(recipe => recipe.id); - - // eslint-disable-next-line unicorn/prefer-spread - this.recipes = this.recipes.concat(this._getDevRecipes()); - - debug('StubServerApi::getInstalledRecipes resolves', this.recipes); - return this.recipes; - } - - async getRecipeUpdates(recipeVersions) { - const request = await sendAuthRequest(`${apiBase()}/recipes/update`, { - method: 'POST', - body: JSON.stringify(recipeVersions), - }); - if (!request.ok) { - throw request; - } - const recipes = await request.json(); - debug('ServerApi::getRecipeUpdates resolves', recipes); - return recipes; - } - - // Recipes Previews - async getRecipePreviews() { - const request = await sendAuthRequest(`${apiBase()}/recipes`); - if (!request.ok) throw request; - const data = await request.json(); - const recipePreviews = this._mapRecipePreviewModel(data); - debug('ServerApi::getRecipes resolves', recipePreviews); - return recipePreviews; - } - - async getFeaturedRecipePreviews() { - // TODO: If we are hitting the internal-server, we need to return an empty list, else we can hit the remote server and get the data - const request = await sendAuthRequest(`${apiBase()}/recipes/popular`); - if (!request.ok) throw request; - - const data = await request.json(); - const recipePreviews = this._mapRecipePreviewModel(data); - debug('ServerApi::getFeaturedRecipes resolves', recipePreviews); - return recipePreviews; - } - - async searchRecipePreviews(needle) { - const url = `${apiBase()}/recipes/search?needle=${needle}`; - const request = await sendAuthRequest(url); - if (!request.ok) throw request; - - const data = await request.json(); - const recipePreviews = this._mapRecipePreviewModel(data); - debug('ServerApi::searchRecipePreviews resolves', recipePreviews); - return recipePreviews; - } - - async getRecipePackage(recipeId) { - try { - const recipesDirectory = userDataRecipesPath(); - const recipeTempDirectory = join(recipesDirectory, 'temp', recipeId); - const tempArchivePath = join(recipeTempDirectory, 'recipe.tar.gz'); - - const internalRecipeFile = asarRecipesPath(`${recipeId}.tar.gz`); - - ensureDirSync(recipeTempDirectory); - - let archivePath; - - if (pathExistsSync(internalRecipeFile)) { - debug('[ServerApi::getRecipePackage] Using internal recipe file'); - archivePath = internalRecipeFile; - } else { - debug('[ServerApi::getRecipePackage] Downloading recipe from server'); - archivePath = tempArchivePath; - - const packageUrl = `${apiBase()}/recipes/download/${recipeId}`; - - const res = await fetch(packageUrl); - debug('Recipe downloaded', recipeId); - const buffer = await res.buffer(); - writeFileSync(archivePath, buffer); - } - debug(archivePath); - - await sleep(10); - - await tar.x({ - file: archivePath, - cwd: recipeTempDirectory, - preservePaths: true, - unlink: true, - preserveOwner: false, - onwarn: x => debug('warn', recipeId, x), - }); - - await sleep(10); - - const { id } = readJsonSync(join(recipeTempDirectory, 'package.json')); - const recipeDirectory = join(recipesDirectory, id); - copySync(recipeTempDirectory, recipeDirectory); - removeSync(recipeTempDirectory); - removeSync(join(recipesDirectory, recipeId, 'recipe.tar.gz')); - - return id; - } catch (error) { - console.error(error); - - return false; - } - } - - // Health Check - async healthCheck() { - if (apiBase() === SERVER_NOT_LOADED) { - throw new Error('Server not loaded'); - } - - const request = await sendAuthRequest( - `${apiBase(false)}/health`, - { - method: 'GET', - }, - false, - ); - if (!request.ok) { - throw request; - } - debug('ServerApi::healthCheck resolves'); - } - - async getLegacyServices() { - const file = userDataPath('settings', 'services.json'); - - try { - const config = readJsonSync(file); - - if (Object.prototype.hasOwnProperty.call(config, 'services')) { - const services = await Promise.all( - config.services.map(async s => { - const service = s; - const request = await sendAuthRequest( - `${apiBase()}/recipes/${s.service}`, - ); - - if (request.status === 200) { - const data = await request.json(); - service.recipe = new RecipePreviewModel(data); - } - - return service; - }), - ); - - debug('ServerApi::getLegacyServices resolves', services); - return services; - } - } catch { - console.error('ServerApi::getLegacyServices no config found'); - } - - return []; - } - - // Helper - async _mapServiceModels(services) { - const recipes = services.map(s => s.recipeId); - await this._bulkRecipeCheck(recipes); - /* eslint-disable no-return-await */ - return Promise.all( - services.map(async service => await this._prepareServiceModel(service)), - ); - /* eslint-enable no-return-await */ - } - - async _prepareServiceModel(service) { - let recipe; - try { - recipe = this.recipes.find(r => r.id === service.recipeId); - - if (!recipe) { - console.warn(`Recipe ${service.recipeId} not loaded`); - return null; - } - - return new ServiceModel(service, recipe); - } catch (error) { - debug(error); - return null; - } - } - - async _bulkRecipeCheck(unfilteredRecipes) { - // Filter recipe duplicates as we don't need to download 3 Slack recipes - const recipes = unfilteredRecipes.filter( - (elem, pos, arr) => arr.indexOf(elem) === pos, - ); - - return Promise.all( - recipes.map(async recipeId => { - let recipe = this.recipes.find(r => r.id === recipeId); - - if (!recipe) { - console.warn( - `Recipe '${recipeId}' not installed, trying to fetch from server`, - ); - - await this.getRecipePackage(recipeId); - - debug('Rerun ServerAPI::getInstalledRecipes'); - await this.getInstalledRecipes(); - - recipe = this.recipes.find(r => r.id === recipeId); - - if (!recipe) { - console.warn(`Could not load recipe ${recipeId}`); - return null; - } - } - - return recipe; - }), - ).catch(error => console.error("Can't load recipe", error)); - } - - _mapRecipePreviewModel(recipes) { - return recipes - .map(recipe => { - try { - return new RecipePreviewModel(recipe); - } catch (error) { - console.error(error); - return null; - } - }) - .filter(recipe => recipe !== null); - } - - _getDevRecipes() { - const recipesDirectory = getDevRecipeDirectory(); - try { - const paths = readdirSync(recipesDirectory).filter( - file => - statSync(join(recipesDirectory, file)).isDirectory() && - file !== 'temp', - ); - - const recipes = paths - .map(id => { - let Recipe; - try { - // eslint-disable-next-line import/no-dynamic-require - Recipe = require(id)(RecipeModel); - return new Recipe(loadRecipeConfig(id)); - } catch (error) { - console.error(error); - } - - return false; - }) - .filter(recipe => recipe.id) - .map(data => { - const recipe = data; - - recipe.icons = { - svg: `${recipe.path}/icon.svg`, - }; - recipe.local = true; - - return data; - }); - - return recipes; - } catch { - debug('Could not load dev recipes'); - return false; - } - } -} diff --git a/src/api/server/ServerApi.ts b/src/api/server/ServerApi.ts new file mode 100644 index 000000000..2fd1a8d0d --- /dev/null +++ b/src/api/server/ServerApi.ts @@ -0,0 +1,616 @@ +/* eslint-disable import/no-import-module-exports */ +/* eslint-disable global-require */ +import { join } from 'path'; +import tar from 'tar'; +import { + readdirSync, + statSync, + writeFileSync, + copySync, + ensureDirSync, + pathExistsSync, + readJsonSync, + removeSync, + PathOrFileDescriptor, +} from 'fs-extra'; +import fetch from 'electron-fetch'; + +import ServiceModel from '../../models/Service'; +import RecipePreviewModel from '../../models/RecipePreview'; +import RecipeModel from '../../models/Recipe'; +import UserModel from '../../models/User'; + +import { sleep } from '../../helpers/async-helpers'; + +import { SERVER_NOT_LOADED } from '../../config'; +import { userDataRecipesPath, userDataPath } from '../../environment-remote'; +import { asarRecipesPath } from '../../helpers/asar-helpers'; +import apiBase from '../apiBase'; +import { prepareAuthRequest, sendAuthRequest } from '../utils/auth'; + +import { + getRecipeDirectory, + getDevRecipeDirectory, + loadRecipeConfig, +} from '../../helpers/recipe-helpers'; + +import { removeServicePartitionDirectory } from '../../helpers/service-helpers'; + +const debug = require('debug')('Ferdi:ServerApi'); + +module.paths.unshift(getDevRecipeDirectory(), getRecipeDirectory()); + +export default class ServerApi { + recipePreviews: any[] = []; + + recipes: any[] = []; + + // User + async login(email: string, passwordHash: string) { + const request = await sendAuthRequest( + `${apiBase()}/auth/login`, + { + method: 'POST', + headers: { + Authorization: `Basic ${window.btoa(`${email}:${passwordHash}`)}`, + }, + }, + false, + ); + if (!request.ok) { + throw new Error(request.statusText); + } + const u = await request.json(); + + debug('ServerApi::login resolves', u); + return u.token; + } + + async signup(data: any) { + const request = await sendAuthRequest( + `${apiBase()}/auth/signup`, + { + method: 'POST', + body: JSON.stringify(data), + }, + false, + ); + if (!request.ok) { + throw new Error(request.statusText); + } + const u = await request.json(); + + debug('ServerApi::signup resolves', u); + return u.token; + } + + async inviteUser(data: any) { + const request = await sendAuthRequest(`${apiBase()}/invite`, { + method: 'POST', + body: JSON.stringify(data), + }); + if (!request.ok) { + throw new Error(request.statusText); + } + + debug('ServerApi::inviteUser'); + return true; + } + + async retrievePassword(email: string) { + const request = await sendAuthRequest( + `${apiBase()}/auth/password`, + { + method: 'POST', + body: JSON.stringify({ + email, + }), + }, + false, + ); + if (!request.ok) { + throw new Error(request.statusText); + } + const r = await request.json(); + + debug('ServerApi::retrievePassword'); + return r; + } + + async userInfo() { + if (apiBase() === SERVER_NOT_LOADED) { + throw new Error('Server not loaded'); + } + + const request = await sendAuthRequest(`${apiBase()}/me`); + if (!request.ok) { + throw new Error(request.statusText); + } + const data = await request.json(); + + const user = new UserModel(data); + debug('ServerApi::userInfo resolves', user); + + return user; + } + + async updateUserInfo(data: any) { + const request = await sendAuthRequest(`${apiBase()}/me`, { + method: 'PUT', + body: JSON.stringify(data), + }); + if (!request.ok) { + throw new Error(request.statusText); + } + const updatedData = await request.json(); + + const user = Object.assign(updatedData, { + data: new UserModel(updatedData.data), + }); + debug('ServerApi::updateUserInfo resolves', user); + return user; + } + + async deleteAccount() { + const request = await sendAuthRequest(`${apiBase()}/me`, { + method: 'DELETE', + }); + if (!request.ok) { + throw new Error(request.statusText); + } + const data = await request.json(); + + debug('ServerApi::deleteAccount resolves', data); + return data; + } + + // Services + async getServices() { + if (apiBase() === SERVER_NOT_LOADED) { + throw new Error('Server not loaded'); + } + + const request = await sendAuthRequest(`${apiBase()}/me/services`); + if (!request.ok) { + throw new Error(request.statusText); + } + const data = await request.json(); + + const services = await this._mapServiceModels(data); + const filteredServices = services.filter(service => !!service); + debug('ServerApi::getServices resolves', filteredServices); + return filteredServices; + } + + async createService(recipeId: string, data: { iconFile: any }) { + const request = await sendAuthRequest(`${apiBase()}/service`, { + method: 'POST', + body: JSON.stringify({ recipeId, ...data }), + }); + if (!request.ok) { + throw new Error(request.statusText); + } + const serviceData = await request.json(); + + if (data.iconFile) { + const iconData = await this.uploadServiceIcon( + serviceData.data.id, + data.iconFile, + ); + + serviceData.data = iconData; + } + + const service = Object.assign(serviceData, { + data: await this._prepareServiceModel(serviceData.data), + }); + + debug('ServerApi::createService resolves', service); + return service; + } + + async updateService(serviceId: string, rawData: any) { + const data = rawData; + + if (data.iconFile) { + await this.uploadServiceIcon(serviceId, data.iconFile); + } + + const request = await sendAuthRequest(`${apiBase()}/service/${serviceId}`, { + method: 'PUT', + body: JSON.stringify(data), + }); + + if (!request.ok) { + throw new Error(request.statusText); + } + + const serviceData = await request.json(); + + const service = Object.assign(serviceData, { + data: await this._prepareServiceModel(serviceData.data), + }); + + debug('ServerApi::updateService resolves', service); + return service; + } + + async uploadServiceIcon(serviceId: string, icon: string | Blob) { + const formData = new FormData(); + formData.append('icon', icon); + + const requestData = prepareAuthRequest({ + method: 'PUT', + // @ts-expect-error Argument of type '{ method: string; body: FormData; }' is not assignable to parameter of type '{ method: string; }'. + body: formData, + }); + + delete requestData.headers['Content-Type']; + + const request = await window.fetch( + `${apiBase()}/service/${serviceId}`, + // @ts-expect-error Argument of type '{ method: string; } & { mode: string; headers: any; }' is not assignable to parameter of type 'RequestInit | undefined'. + requestData, + ); + + if (!request.ok) { + throw new Error(request.statusText); + } + + const serviceData = await request.json(); + + return serviceData.data; + } + + async reorderService(data: any) { + const request = await sendAuthRequest(`${apiBase()}/service/reorder`, { + method: 'PUT', + body: JSON.stringify(data), + }); + if (!request.ok) { + throw new Error(request.statusText); + } + const serviceData = await request.json(); + debug('ServerApi::reorderService resolves', serviceData); + return serviceData; + } + + async deleteService(id: string) { + const request = await sendAuthRequest(`${apiBase()}/service/${id}`, { + method: 'DELETE', + }); + if (!request.ok) { + throw new Error(request.statusText); + } + const data = await request.json(); + + removeServicePartitionDirectory(id, true); + + debug('ServerApi::deleteService resolves', data); + return data; + } + + // Features + async getDefaultFeatures() { + const request = await sendAuthRequest(`${apiBase()}/features/default`); + if (!request.ok) { + throw new Error(request.statusText); + } + const data = await request.json(); + + const features = data; + debug('ServerApi::getDefaultFeatures resolves', features); + return features; + } + + async getFeatures() { + if (apiBase() === SERVER_NOT_LOADED) { + throw new Error('Server not loaded'); + } + + const request = await sendAuthRequest(`${apiBase()}/features`); + if (!request.ok) { + throw new Error(request.statusText); + } + const data = await request.json(); + + const features = data; + debug('ServerApi::getFeatures resolves', features); + return features; + } + + // Recipes + async getInstalledRecipes() { + const recipesDirectory = getRecipeDirectory(); + const paths = readdirSync(recipesDirectory).filter( + file => + statSync(join(recipesDirectory, file)).isDirectory() && + file !== 'temp' && + file !== 'dev', + ); + + this.recipes = paths + .map(id => { + // eslint-disable-next-line import/no-dynamic-require + const Recipe = require(id)(RecipeModel); + return new Recipe(loadRecipeConfig(id)); + }) + .filter(recipe => recipe.id); + + // eslint-disable-next-line unicorn/prefer-spread + this.recipes = this.recipes.concat(this._getDevRecipes()); + + debug('StubServerApi::getInstalledRecipes resolves', this.recipes); + return this.recipes; + } + + async getRecipeUpdates(recipeVersions: any) { + const request = await sendAuthRequest(`${apiBase()}/recipes/update`, { + method: 'POST', + body: JSON.stringify(recipeVersions), + }); + if (!request.ok) { + throw new Error(request.statusText); + } + const recipes = await request.json(); + debug('ServerApi::getRecipeUpdates resolves', recipes); + return recipes; + } + + // Recipes Previews + async getRecipePreviews() { + const request = await sendAuthRequest(`${apiBase()}/recipes`); + if (!request.ok) throw new Error(request.statusText); + const data = await request.json(); + const recipePreviews = this._mapRecipePreviewModel(data); + debug('ServerApi::getRecipes resolves', recipePreviews); + return recipePreviews; + } + + async getFeaturedRecipePreviews() { + // TODO: If we are hitting the internal-server, we need to return an empty list, else we can hit the remote server and get the data + const request = await sendAuthRequest(`${apiBase()}/recipes/popular`); + if (!request.ok) throw new Error(request.statusText); + + const data = await request.json(); + const recipePreviews = this._mapRecipePreviewModel(data); + debug('ServerApi::getFeaturedRecipes resolves', recipePreviews); + return recipePreviews; + } + + async searchRecipePreviews(needle: string) { + const url = `${apiBase()}/recipes/search?needle=${needle}`; + const request = await sendAuthRequest(url); + if (!request.ok) throw new Error(request.statusText); + + const data = await request.json(); + const recipePreviews = this._mapRecipePreviewModel(data); + debug('ServerApi::searchRecipePreviews resolves', recipePreviews); + return recipePreviews; + } + + async getRecipePackage(recipeId: string) { + try { + const recipesDirectory = userDataRecipesPath(); + const recipeTempDirectory = join(recipesDirectory, 'temp', recipeId); + const tempArchivePath = join(recipeTempDirectory, 'recipe.tar.gz'); + + const internalRecipeFile = asarRecipesPath(`${recipeId}.tar.gz`); + + ensureDirSync(recipeTempDirectory); + + let archivePath: PathOrFileDescriptor; + + if (pathExistsSync(internalRecipeFile)) { + debug('[ServerApi::getRecipePackage] Using internal recipe file'); + archivePath = internalRecipeFile; + } else { + debug('[ServerApi::getRecipePackage] Downloading recipe from server'); + archivePath = tempArchivePath; + + const packageUrl = `${apiBase()}/recipes/download/${recipeId}`; + + const res = await fetch(packageUrl); + debug('Recipe downloaded', recipeId); + const buffer = await res.buffer(); + writeFileSync(archivePath, buffer); + } + debug(archivePath); + + await sleep(10); + + // @ts-expect-error No overload matches this call. + await tar.x({ + file: archivePath, + cwd: recipeTempDirectory, + preservePaths: true, + unlink: true, + preserveOwner: false, + onwarn: x => debug('warn', recipeId, x), + }); + + await sleep(10); + + const { id } = readJsonSync(join(recipeTempDirectory, 'package.json')); + const recipeDirectory = join(recipesDirectory, id); + copySync(recipeTempDirectory, recipeDirectory); + removeSync(recipeTempDirectory); + removeSync(join(recipesDirectory, recipeId, 'recipe.tar.gz')); + + return id; + } catch (error) { + console.error(error); + + return false; + } + } + + // Health Check + async healthCheck() { + if (apiBase() === SERVER_NOT_LOADED) { + throw new Error('Server not loaded'); + } + + const request = await sendAuthRequest( + `${apiBase(false)}/health`, + { + method: 'GET', + }, + false, + ); + if (!request.ok) { + throw new Error(request.statusText); + } + debug('ServerApi::healthCheck resolves'); + } + + async getLegacyServices() { + const file = userDataPath('settings', 'services.json'); + + try { + const config = readJsonSync(file); + + if (Object.prototype.hasOwnProperty.call(config, 'services')) { + const services = await Promise.all( + config.services.map(async (s: { service: any }) => { + const service = s; + const request = await sendAuthRequest( + `${apiBase()}/recipes/${s.service}`, + ); + + if (request.status === 200) { + const data = await request.json(); + // @ts-expect-error Property 'recipe' does not exist on type '{ service: any; }'. + service.recipe = new RecipePreviewModel(data); + } + + return service; + }), + ); + + debug('ServerApi::getLegacyServices resolves', services); + return services; + } + } catch { + console.error('ServerApi::getLegacyServices no config found'); + } + + return []; + } + + // Helper + async _mapServiceModels(services: any[]) { + const recipes = services.map((s: { recipeId: string }) => s.recipeId); + await this._bulkRecipeCheck(recipes); + /* eslint-disable no-return-await */ + return Promise.all( + services.map(async (service: any) => this._prepareServiceModel(service)), + ); + /* eslint-enable no-return-await */ + } + + async _prepareServiceModel(service: { recipeId: string }) { + let recipe: undefined; + try { + recipe = this.recipes.find(r => r.id === service.recipeId); + + if (!recipe) { + console.warn(`Recipe ${service.recipeId} not loaded`); + return null; + } + + return new ServiceModel(service, recipe); + } catch (error) { + debug(error); + return null; + } + } + + async _bulkRecipeCheck(unfilteredRecipes: any[]) { + // Filter recipe duplicates as we don't need to download 3 Slack recipes + const recipes = unfilteredRecipes.filter( + (elem: any, pos: number, arr: string | any[]) => + arr.indexOf(elem) === pos, + ); + + return Promise.all( + recipes.map(async (recipeId: string) => { + let recipe = this.recipes.find(r => r.id === recipeId); + + if (!recipe) { + console.warn( + `Recipe '${recipeId}' not installed, trying to fetch from server`, + ); + + await this.getRecipePackage(recipeId); + + debug('Rerun ServerAPI::getInstalledRecipes'); + await this.getInstalledRecipes(); + + recipe = this.recipes.find(r => r.id === recipeId); + + if (!recipe) { + console.warn(`Could not load recipe ${recipeId}`); + return null; + } + } + + return recipe; + }), + ).catch(error => console.error("Can't load recipe", error)); + } + + _mapRecipePreviewModel(recipes: any[]) { + return recipes + .map(recipe => { + try { + return new RecipePreviewModel(recipe); + } catch (error) { + console.error(error); + return null; + } + }) + .filter(recipe => recipe !== null); + } + + _getDevRecipes() { + const recipesDirectory = getDevRecipeDirectory(); + try { + const paths = readdirSync(recipesDirectory).filter( + file => + statSync(join(recipesDirectory, file)).isDirectory() && + file !== 'temp', + ); + + const recipes = paths + .map(id => { + let Recipe; + try { + // eslint-disable-next-line import/no-dynamic-require + Recipe = require(id)(RecipeModel); + return new Recipe(loadRecipeConfig(id)); + } catch (error) { + console.error(error); + } + + return false; + }) + .filter(recipe => recipe.id) + .map(data => { + const recipe = data; + + recipe.icons = { + svg: `${recipe.path}/icon.svg`, + }; + recipe.local = true; + + return data; + }); + + return recipes; + } catch { + debug('Could not load dev recipes'); + return false; + } + } +} diff --git a/src/api/utils/auth.ts b/src/api/utils/auth.ts index 98295d1a4..899881e88 100644 --- a/src/api/utils/auth.ts +++ b/src/api/utils/auth.ts @@ -31,7 +31,7 @@ export const prepareAuthRequest = ( export const sendAuthRequest = ( url: RequestInfo, - options: { method: string } | undefined, + options?: { method: string; headers?: any; body?: any }, auth?: boolean, ) => // @ts-expect-error Argument of type '{ method: string; } & { mode: string; headers: any; }' is not assignable to parameter of type 'RequestInit | undefined'. diff --git a/src/components/services/content/ServiceWebview.js b/src/components/services/content/ServiceWebview.js index 187785f82..c70494edd 100644 --- a/src/components/services/content/ServiceWebview.js +++ b/src/components/services/content/ServiceWebview.js @@ -31,11 +31,13 @@ class ServiceWebview extends Component { debug('Service logged a message:', e.message); }); this.webview.view.addEventListener('did-navigate', () => { - document.title = `Ferdi - ${this.props.service.name} ${ - this.props.service.dialogTitle - ? ` - ${this.props.service.dialogTitle}` - : '' - } ${`- ${this.props.service._webview.getTitle()}`}`; + if (this.props.service._webview) { + document.title = `Ferdi - ${this.props.service.name} ${ + this.props.service.dialogTitle + ? ` - ${this.props.service.dialogTitle}` + : '' + } ${`- ${this.props.service._webview.getTitle()}`}`; + } }); } }, diff --git a/src/components/ui/Tabs/TabItem.tsx b/src/components/ui/Tabs/TabItem.tsx index 81ea0ea2b..9fcc3c41e 100644 --- a/src/components/ui/Tabs/TabItem.tsx +++ b/src/components/ui/Tabs/TabItem.tsx @@ -1,3 +1 @@ -export const TabItem = ({ children }) => { - children; -}; +export const TabItem = ({ children }) => <>{children}; diff --git a/src/features/communityRecipes/store.ts b/src/features/communityRecipes/store.ts index a8d358ba0..c7a51c311 100644 --- a/src/features/communityRecipes/store.ts +++ b/src/features/communityRecipes/store.ts @@ -26,7 +26,7 @@ export class CommunityRecipesStore extends FeatureStore { (recipePreview: { isDevRecipe: boolean; author: any[] }) => { // TODO: Need to figure out if this is even necessary/used recipePreview.isDevRecipe = !!recipePreview.author.some( - (author: { email: any }) => + (author: { email: string }) => author.email === this.stores.user.data.email, ); diff --git a/src/features/publishDebugInfo/index.js b/src/features/publishDebugInfo/index.js deleted file mode 100644 index 43841b530..000000000 --- a/src/features/publishDebugInfo/index.js +++ /dev/null @@ -1,19 +0,0 @@ -import { state as ModalState } from './store'; - -export { default as Component } from './Component'; - -const state = ModalState; -const debug = require('debug')('Ferdi:feature:publishDebugInfo'); - -export default function initialize() { - debug('Initialize publishDebugInfo feature'); - - function showModal() { - state.isModalVisible = true; - } - - window['ferdi'].features.publishDebugInfo = { - state, - showModal, - }; -} diff --git a/src/features/publishDebugInfo/index.ts b/src/features/publishDebugInfo/index.ts new file mode 100644 index 000000000..43841b530 --- /dev/null +++ b/src/features/publishDebugInfo/index.ts @@ -0,0 +1,19 @@ +import { state as ModalState } from './store'; + +export { default as Component } from './Component'; + +const state = ModalState; +const debug = require('debug')('Ferdi:feature:publishDebugInfo'); + +export default function initialize() { + debug('Initialize publishDebugInfo feature'); + + function showModal() { + state.isModalVisible = true; + } + + window['ferdi'].features.publishDebugInfo = { + state, + showModal, + }; +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index cc5ecf83a..9b23497e1 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -1,16 +1,16 @@ -/* eslint-disable import/no-import-module-exports */ /* eslint-disable global-require */ import { APP_LOCALES } from './languages'; -const translations = []; -for (const key of Object.keys(APP_LOCALES)) { - try { - // eslint-disable-next-line import/no-dynamic-require - const translation = require(`./locales/${key}.json`); - translations[key] = translation; - } catch { - console.warn(`Can't find translations for ${key}`); +export const generatedTranslations = () => { + const translations = []; + for (const key of Object.keys(APP_LOCALES)) { + try { + // eslint-disable-next-line import/no-dynamic-require + const translation = require(`./locales/${key}.json`); + translations[key] = translation; + } catch { + console.warn(`Can't find translations for ${key}`); + } } -} - -module.exports = translations; + return translations; +}; diff --git a/src/internal-server/database/factory.js b/src/internal-server/database/factory.js index 8cd45a80d..8534fc20a 100644 --- a/src/internal-server/database/factory.js +++ b/src/internal-server/database/factory.js @@ -1,4 +1,3 @@ -/* eslint-disable unicorn/no-empty-file */ /* |-------------------------------------------------------------------------- | Factory diff --git a/src/routes.js b/src/routes.js deleted file mode 100644 index 9891e5d43..000000000 --- a/src/routes.js +++ /dev/null @@ -1,98 +0,0 @@ -import { Component } from 'react'; -import PropTypes from 'prop-types'; -import { inject, observer } from 'mobx-react'; -import { Router, Route, IndexRedirect } from 'react-router'; - -import AppLayoutContainer from './containers/layout/AppLayoutContainer'; -import SettingsWindow from './containers/settings/SettingsWindow'; -import RecipesScreen from './containers/settings/RecipesScreen'; -import ServicesScreen from './containers/settings/ServicesScreen'; -import EditServiceScreen from './containers/settings/EditServiceScreen'; -import AccountScreen from './containers/settings/AccountScreen'; -import TeamScreen from './containers/settings/TeamScreen'; -import EditUserScreen from './containers/settings/EditUserScreen'; -import EditSettingsScreen from './containers/settings/EditSettingsScreen'; -import InviteSettingsScreen from './containers/settings/InviteScreen'; -import SupportFerdiScreen from './containers/settings/SupportScreen'; -import WelcomeScreen from './containers/auth/WelcomeScreen'; -import LoginScreen from './containers/auth/LoginScreen'; -import LockedScreen from './containers/auth/LockedScreen'; -import PasswordScreen from './containers/auth/PasswordScreen'; -import ChangeServerScreen from './containers/auth/ChangeServerScreen'; -import SignupScreen from './containers/auth/SignupScreen'; -import ImportScreen from './containers/auth/ImportScreen'; -import SetupAssistentScreen from './containers/auth/SetupAssistantScreen'; -import InviteScreen from './containers/auth/InviteScreen'; -import AuthLayoutContainer from './containers/auth/AuthLayoutContainer'; -import WorkspacesScreen from './features/workspaces/containers/WorkspacesScreen'; -import EditWorkspaceScreen from './features/workspaces/containers/EditWorkspaceScreen'; -import { WORKSPACES_ROUTES } from './features/workspaces/constants'; - -import SettingsStore from './stores/SettingsStore'; - -@inject('stores', 'actions') -@observer -class Routes extends Component { - render() { - const { locked } = this.props.stores.settings.app; - - const { history } = this.props; - - if (locked) { - return ; - } - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); - } -} - -Routes.wrappedComponent.propTypes = { - stores: PropTypes.shape({ - settings: PropTypes.instanceOf(SettingsStore).isRequired, - }).isRequired, - history: PropTypes.any.isRequired, -}; - -export default Routes; diff --git a/src/routes.tsx b/src/routes.tsx new file mode 100644 index 000000000..569da06a7 --- /dev/null +++ b/src/routes.tsx @@ -0,0 +1,97 @@ +import { Component } from 'react'; +import { inject, observer } from 'mobx-react'; +import { Router, Route, IndexRedirect } from 'react-router'; + +import AppLayoutContainer from './containers/layout/AppLayoutContainer'; +import SettingsWindow from './containers/settings/SettingsWindow'; +import RecipesScreen from './containers/settings/RecipesScreen'; +import ServicesScreen from './containers/settings/ServicesScreen'; +import EditServiceScreen from './containers/settings/EditServiceScreen'; +import AccountScreen from './containers/settings/AccountScreen'; +import TeamScreen from './containers/settings/TeamScreen'; +import EditUserScreen from './containers/settings/EditUserScreen'; +import EditSettingsScreen from './containers/settings/EditSettingsScreen'; +import InviteSettingsScreen from './containers/settings/InviteScreen'; +import SupportFerdiScreen from './containers/settings/SupportScreen'; +import WelcomeScreen from './containers/auth/WelcomeScreen'; +import LoginScreen from './containers/auth/LoginScreen'; +import LockedScreen from './containers/auth/LockedScreen'; +import PasswordScreen from './containers/auth/PasswordScreen'; +import ChangeServerScreen from './containers/auth/ChangeServerScreen'; +import SignupScreen from './containers/auth/SignupScreen'; +import ImportScreen from './containers/auth/ImportScreen'; +import SetupAssistentScreen from './containers/auth/SetupAssistantScreen'; +import InviteScreen from './containers/auth/InviteScreen'; +import AuthLayoutContainer from './containers/auth/AuthLayoutContainer'; +import WorkspacesScreen from './features/workspaces/containers/WorkspacesScreen'; +import EditWorkspaceScreen from './features/workspaces/containers/EditWorkspaceScreen'; +import { WORKSPACES_ROUTES } from './features/workspaces/constants'; + +import SettingsStore from './stores/SettingsStore'; + +type Props = { + stores: { + settings: typeof SettingsStore; + }; + history: any; +}; + +@inject('stores', 'actions') +@observer +class Routes extends Component { + render() { + const { locked } = this.props.stores.settings.app; + + const { history } = this.props; + + if (locked) { + return ; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); + } +} + +export default Routes; diff --git a/src/stores/AppStore.js b/src/stores/AppStore.js index d652276ea..5881e37a4 100644 --- a/src/stores/AppStore.js +++ b/src/stores/AppStore.js @@ -19,7 +19,7 @@ import Request from './lib/Request'; import { CHECK_INTERVAL, DEFAULT_APP_SETTINGS } from '../config'; import { isMac, electronVersion, osRelease } from '../environment'; import { ferdiVersion, userDataPath, ferdiLocale } from '../environment-remote'; -import locales from '../i18n/translations'; +import { generatedTranslations } from '../i18n/translations'; import { getLocale } from '../helpers/i18n-helpers'; import { @@ -42,6 +42,8 @@ const autoLauncher = new AutoLaunch({ const CATALINA_NOTIFICATION_HACK_KEY = '_temp_askedForCatalinaNotificationPermissions'; +const locales = generatedTranslations(); + export default class AppStore extends Store { updateStatusTypes = { CHECKING: 'CHECKING', diff --git a/src/stores/ServicesStore.js b/src/stores/ServicesStore.js index 16deb91c5..3d418c4c5 100644 --- a/src/stores/ServicesStore.js +++ b/src/stores/ServicesStore.js @@ -655,20 +655,20 @@ export default class ServicesStore extends Store { @action _setWebviewReference({ serviceId, webview }) { const service = this.one(serviceId); - - service.webview = webview; - - if (!service.isAttached) { - debug('Webview is not attached, initializing'); - service.initializeWebViewEvents({ - handleIPCMessage: this.actions.service.handleIPCMessage, - openWindow: this.actions.service.openWindow, - stores: this.stores, - }); - service.initializeWebViewListener(); + if (service) { + service.webview = webview; + + if (!service.isAttached) { + debug('Webview is not attached, initializing'); + service.initializeWebViewEvents({ + handleIPCMessage: this.actions.service.handleIPCMessage, + openWindow: this.actions.service.openWindow, + stores: this.stores, + }); + service.initializeWebViewListener(); + } + service.isAttached = true; } - - service.isAttached = true; } @action _detachService({ service }) { @@ -690,20 +690,22 @@ export default class ServicesStore extends Store { // TODO: add checks to not focus service when router path is /settings or /auth const service = this.active; if (service) { - document.title = `Ferdi - ${service.name} ${ - service.dialogTitle ? ` - ${service.dialogTitle}` : '' - } ${service._webview ? `- ${service._webview.getTitle()}` : ''}`; - this._focusService({ serviceId: service.id }); - if (this.stores.settings.app.splitMode && !focusEvent) { - setTimeout(() => { - document - .querySelector('.services__webview-wrapper.is-active') - .scrollIntoView({ - behavior: 'smooth', - block: 'end', - inline: 'nearest', - }); - }, 10); + if (service._webview) { + document.title = `Ferdi - ${service.name} ${ + service.dialogTitle ? ` - ${service.dialogTitle}` : '' + } ${service._webview ? `- ${service._webview.getTitle()}` : ''}`; + this._focusService({ serviceId: service.id }); + if (this.stores.settings.app.splitMode && !focusEvent) { + setTimeout(() => { + document + .querySelector('.services__webview-wrapper.is-active') + .scrollIntoView({ + behavior: 'smooth', + block: 'end', + inline: 'nearest', + }); + }, 10); + } } } else { debug('No service is active'); diff --git a/src/webview/lib/RecipeWebview.js b/src/webview/lib/RecipeWebview.js deleted file mode 100644 index ebe88ed85..000000000 --- a/src/webview/lib/RecipeWebview.js +++ /dev/null @@ -1,166 +0,0 @@ -import { ipcRenderer } from 'electron'; -import { BrowserWindow } from '@electron/remote'; -import { pathExistsSync, readFileSync, existsSync } from 'fs-extra'; - -const debug = require('debug')('Ferdi:Plugin:RecipeWebview'); - -class RecipeWebview { - constructor( - badgeHandler, - dialogTitleHandler, - notificationsHandler, - sessionHandler, - ) { - this.badgeHandler = badgeHandler; - this.dialogTitleHandler = dialogTitleHandler; - this.notificationsHandler = notificationsHandler; - this.sessionHandler = sessionHandler; - - ipcRenderer.on('poll', () => { - this.loopFunc(); - - debug('Poll event'); - - // This event is for checking if the service recipe is still actively - // communicating with the client - ipcRenderer.sendToHost('alive'); - }); - } - - loopFunc = () => null; - - darkModeHandler = false; - - // TODO Remove this once we implement a proper wrapper. - get ipcRenderer() { - return ipcRenderer; - } - - // TODO Remove this once we implement a proper wrapper. - get BrowserWindow() { - return BrowserWindow; - } - - /** - * Initialize the loop - * - * @param {Function} Function that will be executed - */ - loop(fn) { - this.loopFunc = fn; - } - - /** - * Set the unread message badge - * - * @param {string | number | undefined | null} direct Set the count of direct messages - * eg. Slack direct mentions, or a - * message to @channel - * @param {string | number | undefined | null} indirect Set a badge that defines there are - * new messages but they do not involve - * me directly to me eg. in a channel - */ - setBadge(direct = 0, indirect = 0) { - this.badgeHandler.setBadge(direct, indirect); - } - - /** - * Set the active dialog title to the app title - * - * @param {string | undefined | null} title Set the active dialog title - * to the app title - * eg. WhatsApp contact name - */ - setDialogTitle(title) { - this.dialogTitleHandler.setDialogTitle(title); - } - - /** - * Safely parse the given text into an integer - * - * @param {string | number | undefined | null} text to be parsed - */ - safeParseInt(text) { - return this.badgeHandler.safeParseInt(text); - } - - /** - * Injects the contents of a CSS file into the current webview - * - * @param {Array} files CSS files that should be injected. This must - * be an absolute path to the file - */ - injectCSS(...files) { - // eslint-disable-next-line unicorn/no-array-for-each - files.forEach(file => { - if (pathExistsSync(file)) { - const styles = document.createElement('style'); - styles.innerHTML = readFileSync(file, 'utf8'); - - document.querySelector('head').append(styles); - - debug('Append styles', styles); - } - }); - } - - injectJSUnsafe(...files) { - Promise.all( - files.map(file => { - if (existsSync(file)) { - return readFileSync(file, 'utf8'); - } - debug('Script not found', file); - return null; - }), - ).then(scripts => { - const scriptsFound = scripts.filter(script => script !== null); - if (scriptsFound.length > 0) { - debug('Inject scripts to main world', scriptsFound); - ipcRenderer.sendToHost('inject-js-unsafe', ...scriptsFound); - } - }); - } - - /** - * Set a custom handler for turning on and off dark mode - * - * @param {function} handler - */ - handleDarkMode(handler) { - this.darkModeHandler = handler; - } - - onNotify(fn) { - if (typeof fn === 'function') { - this.notificationsHandler.onNotify = fn; - } - } - - initialize(fn) { - if (typeof fn === 'function') { - fn(); - } - } - - clearStorageData(serviceId, targetsToClear) { - ipcRenderer.send('clear-storage-data', { - serviceId, - targetsToClear, - }); - } - - releaseServiceWorkers() { - this.sessionHandler.releaseServiceWorkers(); - } - - setAvatarImage(avatarUrl) { - ipcRenderer.sendToHost('avatar', avatarUrl); - } - - openNewWindow(url) { - ipcRenderer.sendToHost('new-window', url); - } -} - -export default RecipeWebview; diff --git a/src/webview/lib/RecipeWebview.ts b/src/webview/lib/RecipeWebview.ts new file mode 100644 index 000000000..09dc462ed --- /dev/null +++ b/src/webview/lib/RecipeWebview.ts @@ -0,0 +1,177 @@ +import { ipcRenderer } from 'electron'; +import { BrowserWindow } from '@electron/remote'; +import { pathExistsSync, readFileSync, existsSync } from 'fs-extra'; + +const debug = require('debug')('Ferdi:Plugin:RecipeWebview'); + +class RecipeWebview { + badgeHandler: any; + + dialogTitleHandler: any; + + notificationsHandler: any; + + sessionHandler: any; + + constructor( + badgeHandler, + dialogTitleHandler, + notificationsHandler, + sessionHandler, + ) { + this.badgeHandler = badgeHandler; + this.dialogTitleHandler = dialogTitleHandler; + this.notificationsHandler = notificationsHandler; + this.sessionHandler = sessionHandler; + + ipcRenderer.on('poll', () => { + this.loopFunc(); + + debug('Poll event'); + + // This event is for checking if the service recipe is still actively + // communicating with the client + ipcRenderer.sendToHost('alive'); + }); + } + + loopFunc = () => null; + + darkModeHandler = false; + + // TODO Remove this once we implement a proper wrapper. + get ipcRenderer() { + return ipcRenderer; + } + + // TODO Remove this once we implement a proper wrapper. + get BrowserWindow() { + return BrowserWindow; + } + + /** + * Initialize the loop + * + * @param {Function} Function that will be executed + */ + loop(fn) { + this.loopFunc = fn; + } + + /** + * Set the unread message badge + * + * @param {string | number | undefined | null} direct Set the count of direct messages + * eg. Slack direct mentions, or a + * message to @channel + * @param {string | number | undefined | null} indirect Set a badge that defines there are + * new messages but they do not involve + * me directly to me eg. in a channel + */ + setBadge(direct = 0, indirect = 0) { + this.badgeHandler.setBadge(direct, indirect); + } + + /** + * Set the active dialog title to the app title + * + * @param {string | undefined | null} title Set the active dialog title + * to the app title + * eg. WhatsApp contact name + */ + setDialogTitle(title) { + this.dialogTitleHandler.setDialogTitle(title); + } + + /** + * Safely parse the given text into an integer + * + * @param {string | number | undefined | null} text to be parsed + */ + safeParseInt(text) { + return this.badgeHandler.safeParseInt(text); + } + + /** + * Injects the contents of a CSS file into the current webview + * + * @param {Array} files CSS files that should be injected. This must + * be an absolute path to the file + */ + injectCSS(...files) { + // eslint-disable-next-line unicorn/no-array-for-each + files.forEach(file => { + if (pathExistsSync(file)) { + const styles = document.createElement('style'); + styles.innerHTML = readFileSync(file, 'utf8'); + + const head = document.querySelector('head'); + + if (head) { + head.append(styles); + debug('Append styles', styles); + } + } + }); + } + + injectJSUnsafe(...files) { + Promise.all( + files.map(file => { + if (existsSync(file)) { + return readFileSync(file, 'utf8'); + } + debug('Script not found', file); + return null; + }), + ).then(scripts => { + const scriptsFound = scripts.filter(script => script !== null); + if (scriptsFound.length > 0) { + debug('Inject scripts to main world', scriptsFound); + ipcRenderer.sendToHost('inject-js-unsafe', ...scriptsFound); + } + }); + } + + /** + * Set a custom handler for turning on and off dark mode + * + * @param {function} handler + */ + handleDarkMode(handler) { + this.darkModeHandler = handler; + } + + onNotify(fn) { + if (typeof fn === 'function') { + this.notificationsHandler.onNotify = fn; + } + } + + initialize(fn) { + if (typeof fn === 'function') { + fn(); + } + } + + clearStorageData(serviceId, targetsToClear) { + ipcRenderer.send('clear-storage-data', { + serviceId, + targetsToClear, + }); + } + + releaseServiceWorkers() { + this.sessionHandler.releaseServiceWorkers(); + } + + setAvatarImage(avatarUrl) { + ipcRenderer.sendToHost('avatar', avatarUrl); + } + + openNewWindow(url) { + ipcRenderer.sendToHost('new-window', url); + } +} + +export default RecipeWebview; diff --git a/src/webview/lib/Userscript.js b/src/webview/lib/Userscript.js deleted file mode 100644 index f7bb99206..000000000 --- a/src/webview/lib/Userscript.js +++ /dev/null @@ -1,138 +0,0 @@ -import { ipcRenderer } from 'electron'; - -export default class Userscript { - // Current ./lib/RecipeWebview instance - recipe = null; - - // Current ./recipe.js instance - controller = null; - - // Service configuration - config = {}; - - // Ferdi and service settings - settings = {}; - - settingsUpdateHandler = null; - - constructor(recipe, controller, config) { - this.recipe = recipe; - this.controller = controller; - this.internal_setSettings(controller.settings); - this.config = config; - } - - /** - * Set internal copy of Ferdi's settings. - * This is only used internally and can not be used to change any settings - * - * @param {*} settings - */ - // eslint-disable-next-line camelcase - internal_setSettings(settings) { - // This is needed to get a clean JS object from the settings itself to provide better accessibility - // Otherwise this will be a mobX instance - this.settings = JSON.parse(JSON.stringify(settings)); - - if (typeof this.settingsUpdateHandler === 'function') { - this.settingsUpdateHandler(); - } - } - - /** - * Register a settings handler to be executed when the settings change - * - * @param {function} handler - */ - onSettingsUpdate(handler) { - this.settingsUpdateHandler = handler; - } - - /** - * Set badge count for the current service - * @param {*} direct Direct messages - * @param {*} indirect Indirect messages - */ - setBadge(direct = 0, indirect = 0) { - if (this.recipe && this.recipe.setBadge) { - this.recipe.setBadge(direct, indirect); - } - } - - /** - * Set active dialog title to the app title - * @param {*} title Dialog title - */ - setDialogTitle(title) { - if (this.recipe && this.recipe.setDialogTitle) { - this.recipe.setDialogTitle(title); - } - } - - /** - * Inject CSS files into the current page - * - * @param {...string} files - */ - injectCSSFiles(...files) { - if (this.recipe && this.recipe.injectCSS) { - this.recipe.injectCSS(...files); - } - } - - /** - * Inject a CSS string into the page - * - * @param {string} css - */ - injectCSS(css) { - const style = document.createElement('style'); - style.textContent = css; - document.head.append(style); - } - - /** - * Open "Find in Page" popup - */ - openFindInPage() { - this.controller.openFindInPage(); - } - - /** - * Set or update value in storage - * - * @param {*} key - * @param {*} value - */ - set(key, value) { - window.localStorage.setItem(`ferdi-user-${key}`, JSON.stringify(value)); - } - - /** - * Get value from storage - * - * @param {*} key - * @return Value of the key - */ - get(key) { - return JSON.parse(window.localStorage.getItem(`ferdi-user-${key}`)); - } - - /** - * Open a URL in an external browser - * - * @param {*} url - */ - externalOpen(url) { - ipcRenderer.sendToHost('new-window', url); - } - - /** - * Open a URL in the current service - * - * @param {*} url - */ - internalOpen(url) { - window.location.href = url; - } -} diff --git a/src/webview/lib/Userscript.ts b/src/webview/lib/Userscript.ts new file mode 100644 index 000000000..c50941dc7 --- /dev/null +++ b/src/webview/lib/Userscript.ts @@ -0,0 +1,107 @@ +type Recipe = { + setBadge: (direct: number, indirect: number) => void; + setDialogTitle: (title: string) => void; + injectCSS: (css: string | string[]) => void; +}; + +export default class Userscript { + // Current ./lib/RecipeWebview instance + recipe: Recipe | null = null; + + // Current ./recipe.js instance + controller = null; + + // Service configuration + config = {}; + + // Ferdi and service settings + settings = {}; + + constructor(recipe, controller, config) { + this.recipe = recipe; + this.controller = controller; + this.internal_setSettings(controller.settings); + this.config = config; + } + + /** + * Set internal copy of Ferdi's settings. + * This is only used internally and can not be used to change any settings + * + * @param {*} settings + */ + // eslint-disable-next-line camelcase + internal_setSettings(settings: any) { + // This is needed to get a clean JS object from the settings itself to provide better accessibility + // Otherwise this will be a mobX instance + this.settings = JSON.parse(JSON.stringify(settings)); + } + + /** + * Set badge count for the current service + * @param {number} direct Direct messages + * @param {number} indirect Indirect messages + */ + setBadge(direct: number = 0, indirect: number = 0) { + if (this.recipe && this.recipe.setBadge) { + this.recipe.setBadge(direct, indirect); + } + } + + /** + * Set active dialog title to the app title + * @param {*} title Dialog title + */ + setDialogTitle(title: string) { + if (this.recipe && this.recipe.setDialogTitle) { + this.recipe.setDialogTitle(title); + } + } + + /** + * Inject CSS files into the current page + * + * @param {...string} files + */ + injectCSSFiles(...files: string[]) { + if (this.recipe && this.recipe.injectCSS) { + // @ts-expect-error A spread argument must either have a tuple type or be passed to a rest parameter. + this.recipe.injectCSS(...files); + } + } + + /** + * Inject a CSS string into the page + * + * @param {string} css + */ + injectCSS(css: string) { + const style = document.createElement('style'); + style.textContent = css; + document.head.append(style); + } + + /** + * Set or update value in storage + * + * @param {string} key + * @param {any} value + */ + set(key: string, value: string) { + window.localStorage.setItem(`ferdi-user-${key}`, JSON.stringify(value)); + } + + /** + * Get value from storage + * + * @param {string} key + * @return Value of the key + */ + get(key: string) { + const ferdiUserKey = window.localStorage.getItem(`ferdi-user-${key}`); + + if (ferdiUserKey) { + return JSON.parse(ferdiUserKey); + } + } +} -- cgit v1.2.3-54-g00ecf