diff --git a/.gitignore b/.gitignore index 543ace7..ac50aff 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ data.json # Pocket integration .__pocket_access_info__ + +# Astroturf css +**/*.module.css diff --git a/package.json b/package.json index 1aca641..f16408f 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "author": "", "license": "MIT", "devDependencies": { + "@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-commonjs": "^18.0.0", "@rollup/plugin-json": "^4.1.0", "@rollup/plugin-node-resolve": "^11.2.1", @@ -19,10 +20,18 @@ "@types/jest": "^26.0.23", "@types/node": "^14.14.37", "@types/qs": "^6.9.6", + "@types/react": "^17.0.11", + "@types/react-dom": "^17.0.7", "@types/sqlite3": "^3.1.7", + "@types/uuid": "^8.3.0", + "astroturf": "^1.0.0-beta.21", "jest": "^27.0.4", "obsidian": "^0.12.0", + "postcss": "^8.3.5", + "postcss-nested": "^5.0.5", "rollup": "^2.32.1", + "rollup-plugin-delete": "^2.0.0", + "rollup-plugin-postcss": "^4.0.0", "ts-jest": "^27.0.2", "ts-node": "^10.0.0", "tslib": "^2.2.0", @@ -31,7 +40,11 @@ "dependencies": { "cors-anywhere": "^0.4.4", "idb": "^6.1.2", - "qs": "^6.10.1" + "immutability-helper": "^3.1.1", + "qs": "^6.10.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "uuid": "^8.3.2" }, "jest": { "clearMocks": true, diff --git a/rollup.config.js b/rollup.config.js index 2482a63..deea7dd 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -2,6 +2,10 @@ import typescript from "@rollup/plugin-typescript"; import { nodeResolve } from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import json from "@rollup/plugin-json"; +import babel from "@rollup/plugin-babel"; +import postcss from "rollup-plugin-postcss"; +import postcssNested from "postcss-nested"; +import del from "rollup-plugin-delete"; const isProd = process.env.BUILD === "production"; @@ -23,11 +27,26 @@ export default { }, external: ["obsidian"], plugins: [ - typescript(), - nodeResolve({ browser: true }), commonjs({ include: "node_modules/**", }), + babel({ + babelHelpers: "bundled", + extensions: [".tsx", ".ts"], + plugins: ["astroturf/plugin"], + }), + postcss({ + extract: "styles.css", + modules: true, + plugins: [postcssNested], + }), + typescript(), + nodeResolve({ browser: true }), json(), + del({ + targets: ["**/*.module.css"], + hook: "buildEnd", + runOnce: true, + }), ], }; diff --git a/src/pocket_api.ts b/src/PocketAPI.ts similarity index 98% rename from src/pocket_api.ts rename to src/PocketAPI.ts index c112310..67ce7ae 100644 --- a/src/pocket_api.ts +++ b/src/PocketAPI.ts @@ -1,5 +1,5 @@ import * as qs from "qs"; -import { PocketGetItemsResponse } from "./pocket_api_types"; +import { PocketGetItemsResponse } from "./PocketAPITypes"; export type RequestToken = string; export type AccessToken = string; diff --git a/src/pocket_api_types.ts b/src/PocketAPITypes.ts similarity index 93% rename from src/pocket_api_types.ts rename to src/PocketAPITypes.ts index f066a6c..e1238fb 100644 --- a/src/pocket_api_types.ts +++ b/src/PocketAPITypes.ts @@ -19,6 +19,9 @@ export interface DeletedPocketItem extends BasePocketItem { export interface SavedPocketItem extends BasePocketItem { status: PocketItemStatus.Unread | PocketItemStatus.Archived; + resolved_title: string; + resolved_url: string; + excerpt: string; } export type PocketItem = SavedPocketItem | DeletedPocketItem; diff --git a/src/auth.ts b/src/PocketAuth.ts similarity index 98% rename from src/auth.ts rename to src/PocketAuth.ts index 7a75bf0..744aa2b 100644 --- a/src/auth.ts +++ b/src/PocketAuth.ts @@ -4,7 +4,7 @@ import { buildAuthorizationURL, getRequestToken, RequestToken, -} from "./pocket_api"; +} from "./PocketAPI"; export type AccessInfo = AccessTokenResponse; diff --git a/src/PocketItemListView.tsx b/src/PocketItemListView.tsx new file mode 100644 index 0000000..187d175 --- /dev/null +++ b/src/PocketItemListView.tsx @@ -0,0 +1,41 @@ +import { ItemView, WorkspaceLeaf } from "obsidian"; +import React from "react"; +import PocketSync from "./main"; +import { PocketItemList } from "./components/PocketItemList"; + +export const POCKET_ITEM_LIST_VIEW_TYPE = "pocket_item_list"; + +export class PocketItemListView extends ItemView { + plugin: PocketSync; + id: string = (this.leaf as any).id; + + constructor(leaf: WorkspaceLeaf, plugin: PocketSync) { + // TODO: Get the username in here + super(leaf); + + if (!plugin.pocketAuthenticated) { + throw new Error( + "Tried to display PocketItemListView when not Pocket-authenticated" + ); + } + + this.plugin = plugin; + this.plugin.viewManager.addView(this.id, this); + } + + getViewType(): string { + return POCKET_ITEM_LIST_VIEW_TYPE; + } + getDisplayText(): string { + return `Pocket list for ${this.plugin.pocketUsername}`; + } + + async onClose() { + console.log("onClose"); + this.plugin.viewManager.removeView(this.id); + } + + getPortal() { + return ; + } +} diff --git a/src/PocketItemStore.ts b/src/PocketItemStore.ts new file mode 100644 index 0000000..18f52cf --- /dev/null +++ b/src/PocketItemStore.ts @@ -0,0 +1,154 @@ +import { IDBPDatabase, openDB } from "idb"; +import { v4 as uuidv4 } from "uuid"; +import { UpdateTimestamp } from "./PocketAPI"; +import { + isDeletedPocketItem, + isSavedPocketItem, + PocketItemId, + PocketItemRecord, + SavedPocketItem, +} from "./PocketAPITypes"; +import { ViewName } from "./ViewManager"; + +const DATABASE_NAME = "pocket_db"; +const ITEM_STORE_NAME = "items"; + +const METADATA_STORE_NAME = "metadata"; +const LAST_UPDATED_TIMESTAMP_KEY = "last_updated_timestamp"; + +export type OnChangeCallback = () => Promise; +export type CallbackId = string; + +export class PocketItemStore { + db: IDBPDatabase; + onChangeCallbacks: Map; + + constructor(db: IDBPDatabase) { + this.db = db; + this.onChangeCallbacks = new Map(); + } + + mergeUpdates = async ( + lastUpdateTimestamp: UpdateTimestamp, + items: PocketItemRecord + ): Promise => { + const updates = []; + + // TODO: Should all of this be happening in a transaction? + console.log("Applying updates"); + for (const key in items) { + const item = items[key]; + if (isDeletedPocketItem(item)) { + updates.push(this.deleteItem(item.item_id, false)); + } else if (isSavedPocketItem(item)) { + updates.push(this.putItem(item, false)); + } else { + throw new Error("unexpected"); + } + } + + // Wait on all changes, update timestamp, then trigger registered onChange handlers + await Promise.all(updates); + console.log("Updates applied"); + this.setLastUpdateTimestamp(lastUpdateTimestamp); + console.log("Running onChange handlers"); + await this.handleOnChange(); + }; + + addItem = async (item: SavedPocketItem, triggerOnChangeHandlers = true) => { + await this.db.add(ITEM_STORE_NAME, item); + triggerOnChangeHandlers && (await this.handleOnChange()); + }; + + putItem = async ( + item: SavedPocketItem, + triggerOnChangeHandlers?: boolean + ) => { + await this.db.put(ITEM_STORE_NAME, item); + triggerOnChangeHandlers && (await this.handleOnChange()); + }; + + getItem = async (itemId: PocketItemId): Promise => { + return this.db.get(ITEM_STORE_NAME, itemId); + }; + + getAllItems = async (): Promise => { + return this.db.getAll(ITEM_STORE_NAME); + }; + + getAllItemsBySortId = async (): Promise => { + return this.db.getAllFromIndex(ITEM_STORE_NAME, "sort_id"); + }; + + deleteItem = async ( + itemId: PocketItemId, + triggerOnChangeHandlers?: boolean + ) => { + await this.db.delete(ITEM_STORE_NAME, itemId); + triggerOnChangeHandlers && (await this.handleOnChange()); + }; + + // This Unix timestamp is the last time that the Pocket item store was synced + // via the Pocket API. It is used in subsequent requests to only get updates + // since the timestamp. If the timestamp is null, it means that no requests + // have been done so far. + + setLastUpdateTimestamp = async ( + timestamp: UpdateTimestamp, + triggerOnChangeHandlers?: boolean + ): Promise => { + console.log("Updating update timestamp"); + await this.db.put( + METADATA_STORE_NAME, + timestamp, + LAST_UPDATED_TIMESTAMP_KEY + ); + triggerOnChangeHandlers && (await this.handleOnChange()); + }; + + getLastUpdateTimestamp = async (): Promise => { + return this.db.get(METADATA_STORE_NAME, LAST_UPDATED_TIMESTAMP_KEY); + }; + + subscribeOnChange = (cb: OnChangeCallback): CallbackId => { + const callbackId = uuidv4(); + this.onChangeCallbacks.set(callbackId, cb); + return callbackId; + }; + + unsubscribeOnChange = (cbId: CallbackId): void => { + this.onChangeCallbacks.delete(cbId); + }; + + private handleOnChange = async () => { + const cbExecs = Array.from(this.onChangeCallbacks.values()).map((cb) => + cb() + ); + await Promise.all(cbExecs); + }; +} + +export const openPocketItemStore = async (): Promise => { + const dbVersion = 2; + const db = await openDB(DATABASE_NAME, dbVersion, { + upgrade: (db, oldVersion, newVersion, tx) => { + if (oldVersion !== newVersion) { + console.log( + `Upgrading pocket item store to version ${newVersion} from version ${oldVersion}` + ); + } + + switch (oldVersion) { + case 0: + db.createObjectStore(ITEM_STORE_NAME, { + keyPath: "item_id", + }); + db.createObjectStore(METADATA_STORE_NAME); + case 1: + const itemStore = tx.objectStore(ITEM_STORE_NAME); + itemStore.createIndex("sort_id", "sort_id", { unique: false }); + } + }, + }); + return new PocketItemStore(db); +}; diff --git a/src/ReactApp.tsx b/src/ReactApp.tsx new file mode 100644 index 0000000..55df54e --- /dev/null +++ b/src/ReactApp.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { PocketItemListView } from "./PocketItemListView"; +import { ViewManager } from "./ViewManager"; + +export const createReactApp = (viewManager: ViewManager) => ( + +); + +export type ViewProps = { + view: PocketItemListView; +}; + +export class View extends React.Component { + constructor(props: ViewProps) { + super(props); + } + + render() { + return createPortal(this.props.view.getPortal(), this.props.view.contentEl); + } +} + +export type ReactAppProps = { + viewManager: ViewManager; +}; + +export const ReactApp = ({ viewManager }: ReactAppProps) => { + const [views, _setViews] = viewManager.useState(); + const portals = []; + for (const view of Array.from(views.values())) { + portals.push(); + } + + return <>{...portals}; +}; diff --git a/src/ViewManager.ts b/src/ViewManager.ts new file mode 100644 index 0000000..ed6f28f --- /dev/null +++ b/src/ViewManager.ts @@ -0,0 +1,44 @@ +import update from "immutability-helper"; +import { useEffect, useState } from "react"; +import { PocketItemListView } from "./PocketItemListView"; + +export type ViewName = string; +export type ViewMapping = Map; +export type SetViewMapping = (viewMapping: ViewMapping) => void; + +export class ViewManager { + views: ViewMapping; + setState: SetViewMapping; + + constructor() { + this.views = new Map(); + } + + useState(): [ViewMapping, SetViewMapping] { + const [state, setState] = useState(this.views); + + // Make sure setState reference is stored in this instance + useEffect(() => { + this.setState = setState; + }, [this]); + + return [state, setState]; + } + + addView(viewName: ViewName, view: PocketItemListView): void { + console.log(`Adding view for ${viewName}`); + this.views = update(this.views, { $add: [[viewName, view]] }); + this.setState(this.views); + console.log(`views: ${Array.from(this.views.keys())}`); + } + removeView(viewName: ViewName): void { + console.log(`Removing view for ${viewName}`); + this.views = update(this.views, { $remove: [viewName] }); + this.setState(this.views); + console.log(`views: ${Array.from(this.views.keys())}`); + } + clearViews(): void { + this.views = update(this.views, { $set: new Map() }); + this.setState(this.views); + } +} diff --git a/src/components/PocketItem.tsx b/src/components/PocketItem.tsx new file mode 100644 index 0000000..8d900f8 --- /dev/null +++ b/src/components/PocketItem.tsx @@ -0,0 +1,46 @@ +import { stylesheet } from "astroturf"; +import React from "react"; +import { SavedPocketItem } from "../PocketAPITypes"; + +const styles = stylesheet` + .item { + color: black; + border: 1px solid black; + display: block; + + padding: 4px 8px; + } + .item > span { + display: block; + } + + .itemTitle { + font-weight: 600; + flex-grow: 1; + width: 100%; + } + + .itemExcerpt { + font-weight: 300; + line-height: 1.5; + flex-grow: 1; + width: 100%; + } +`; + +export type PocketItemProps = { + item: SavedPocketItem; +}; + +export const PocketItem = ({ item }: PocketItemProps) => { + const displayText = + item.resolved_title.length !== 0 ? item.resolved_title : item.resolved_url; + return ( +
+ {displayText} + {item.excerpt && ( + {item.excerpt} + )} +
+ ); +}; diff --git a/src/components/PocketItemList.tsx b/src/components/PocketItemList.tsx new file mode 100644 index 0000000..83d1927 --- /dev/null +++ b/src/components/PocketItemList.tsx @@ -0,0 +1,60 @@ +import { stylesheet } from "astroturf"; +import React, { useEffect, useState } from "react"; +import { SavedPocketItem } from "src/PocketAPITypes"; +import { PocketItemStore } from "src/PocketItemStore"; +import { PocketItem } from "./PocketItem"; + +const styles = stylesheet` + .list { + list-style-type: none; + } + + .item { + margin: 8px; + } +`; + +export type PocketItemListProps = { + itemStore: PocketItemStore; +}; + +export const PocketItemList = ({ itemStore }: PocketItemListProps) => { + const [items, setItems] = useState([]); + + useEffect(() => { + var subscribed = true; + const fetch = async () => { + const allItems = await itemStore.getAllItemsBySortId(); + subscribed && setItems(allItems); + }; + fetch(); + + return () => { + subscribed = false; + }; + }, []); + + useEffect(() => { + const cbId = itemStore.subscribeOnChange(async () => { + const updatedItems = await itemStore.getAllItemsBySortId(); + setItems(updatedItems); + }); + return () => { + itemStore.unsubscribeOnChange(cbId); + }; + }, [itemStore]); + + if (items.length === 0) { + return <>No items synced!; + } else { + return ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ ); + } +}; diff --git a/src/main.ts b/src/main.ts index 1f016cb..716da97 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,16 @@ import * as cors_proxy from "cors-anywhere"; import { App, Modal, Notice, Plugin } from "obsidian"; -import { openPocketItemStore, PocketItemStore } from "./pocket_item_store"; -import { PocketSettingTab } from "./settings"; +import ReactDOM from "react-dom"; +import { Username as PocketUsername } from "./PocketAPI"; +import { loadPocketAccessInfo } from "./PocketAuth"; +import { + PocketItemListView, + POCKET_ITEM_LIST_VIEW_TYPE, +} from "./PocketItemListView"; +import { openPocketItemStore, PocketItemStore } from "./PocketItemStore"; +import { createReactApp } from "./ReactApp"; +import { PocketSettingTab } from "./Settings"; +import { ViewManager } from "./ViewManager"; interface PocketSyncSettings { mySetting: string; @@ -25,14 +34,20 @@ const setupCORSProxy = () => { export default class PocketSync extends Plugin { settings: PocketSyncSettings; itemStore: PocketItemStore; + appEl: HTMLDivElement; + viewManager: ViewManager; + pocketUsername: PocketUsername | null; + pocketAuthenticated: boolean; async onload() { console.log("loading plugin"); await this.loadSettings(); + // Set up CORS proxy for Pocket API calls console.log("setting up CORS proxy"); setupCORSProxy(); + // Set up Pocket item store console.log("opening Pocket item store"); this.itemStore = await openPocketItemStore(); @@ -40,25 +55,9 @@ export default class PocketSync extends Plugin { new Notice("This is a notice!"); }); - this.addStatusBarItem().setText("Status Bar Text"); + this.addCommands(); - this.addCommand({ - id: "open-sample-modal", - name: "Open Sample Modal", - // callback: () => { - // console.log('Simple Callback'); - // }, - checkCallback: (checking: boolean) => { - let leaf = this.app.workspace.activeLeaf; - if (leaf) { - if (!checking) { - new SampleModal(this.app).open(); - } - return true; - } - return false; - }, - }); + this.addStatusBarItem().setText("Status Bar Text"); this.addSettingTab(new PocketSettingTab(this.app, this)); @@ -73,10 +72,44 @@ export default class PocketSync extends Plugin { this.registerInterval( window.setInterval(() => console.log("setInterval"), 5 * 60 * 1000) ); + + const accessInfo = await loadPocketAccessInfo(this); + if (!accessInfo) { + console.log(`Not authenticated to Pocket`); + } + + this.pocketAuthenticated = !!accessInfo; + this.pocketUsername = accessInfo?.username; + + // Set up React-based Pocket item list view + this.viewManager = new ViewManager(); + this.mount(); + this.registerView( + POCKET_ITEM_LIST_VIEW_TYPE, + (leaf) => new PocketItemListView(leaf, this) + ); } + // Mount React app + mount = () => { + console.log("mounting React components"); + ReactDOM.render( + createReactApp(this.viewManager), + this.appEl ?? (this.appEl = document.body.createDiv()) + ); + console.log("done mounting React components"); + }; + onunload() { console.log("unloading plugin"); + + this.viewManager.clearViews(); + this.viewManager = null; + + if (this.appEl) { + ReactDOM.unmountComponentAtNode(this.appEl); + this.appEl.detach(); + } } async loadSettings() { @@ -86,6 +119,22 @@ export default class PocketSync extends Plugin { async saveSettings() { await this.saveData(this.settings); } + + openPocketList = async () => { + await this.app.workspace.activeLeaf.setViewState({ + type: POCKET_ITEM_LIST_VIEW_TYPE, + }); + }; + + addCommands = () => { + this.addCommand({ + id: "open-pocket-list", + name: "Open Pocket list", + callback: () => { + this.openPocketList(); + }, + }); + }; } class SampleModal extends Modal { diff --git a/src/pocket_item_store.ts b/src/pocket_item_store.ts deleted file mode 100644 index 30dfbdd..0000000 --- a/src/pocket_item_store.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { IDBPDatabase, openDB } from "idb"; -import { UpdateTimestamp } from "./pocket_api"; -import { - isDeletedPocketItem, - isSavedPocketItem, - PocketItemId, - PocketItemRecord, - SavedPocketItem, -} from "./pocket_api_types"; - -const DATABASE_NAME = "pocket_db"; -const ITEM_STORE_NAME = "items"; - -const METADATA_STORE_NAME = "metadata"; -const LAST_UPDATED_TIMESTAMP_KEY = "last_updated_timestamp"; - -export class PocketItemStore { - db: IDBPDatabase; - - constructor(db: IDBPDatabase) { - this.db = db; - } - - mergeUpdates = async ( - lastUpdateTimestamp: UpdateTimestamp, - items: PocketItemRecord - ): Promise => { - const updates = []; - - for (const key in items) { - const item = items[key]; - if (isDeletedPocketItem(item)) { - updates.push(this.deleteItem(item.item_id)); - } else if (isSavedPocketItem(item)) { - updates.push(this.putItem(item)); - } else { - throw new Error("unexpected"); - } - } - - this.setLastUpdateTimestamp(lastUpdateTimestamp); - - // get Unix timestamp and write it - await Promise.all(updates); - }; - - addItem = async (item: SavedPocketItem): Promise => { - this.db.add(ITEM_STORE_NAME, item); - }; - - putItem = async (item: SavedPocketItem): Promise => { - this.db.put(ITEM_STORE_NAME, item); - }; - - getItem = async (itemId: PocketItemId): Promise => { - return this.db.get(ITEM_STORE_NAME, itemId); - }; - - deleteItem = async (itemId: PocketItemId): Promise => { - this.db.delete(ITEM_STORE_NAME, itemId); - }; - - // This Unix timestamp is the last time that the Pocket item store was synced - // via the Pocket API. It is used in subsequent requests to only get updates - // since the timestamp. If the timestamp is null, it means that no requests - // have been done so far. - - setLastUpdateTimestamp = async ( - timestamp: UpdateTimestamp - ): Promise => { - this.db.put(METADATA_STORE_NAME, timestamp, LAST_UPDATED_TIMESTAMP_KEY); - }; - - getLastUpdateTimestamp = async (): Promise => { - return this.db.get(METADATA_STORE_NAME, LAST_UPDATED_TIMESTAMP_KEY); - }; -} - -export const openPocketItemStore = async (): Promise => { - const db = await openDB(DATABASE_NAME, 1, { - upgrade: (db) => { - const _itemStore = db.createObjectStore(ITEM_STORE_NAME, { - keyPath: "item_id", - }); - - const _metadataStore = db.createObjectStore(METADATA_STORE_NAME); - }, - }); - return new PocketItemStore(db); -}; diff --git a/src/settings.ts b/src/settings.ts index 4d658f8..c09f247 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,9 +4,9 @@ import { OBSIDIAN_AUTH_PROTOCOL_ACTION, setupAuth, storePocketAccessInfo, -} from "./auth"; +} from "./PocketAuth"; import PocketSync from "./main"; -import { getAccessToken, getPocketItems } from "./pocket_api"; +import { getAccessToken, getPocketItems } from "./PocketAPI"; const CONNECT_POCKET_CTA = "Connect your Pocket account"; const SYNC_POCKET_CTA = "Sync Pocket items"; @@ -45,7 +45,13 @@ const addTestAuthSetting = (plugin: PocketSync, containerEl: HTMLElement) => lastUpdateTimestamp ); - plugin.itemStore.mergeUpdates( + console.log( + `Fetched ${ + Object.keys(getPocketItemsResponse.response.list).length + } updates` + ); + + await plugin.itemStore.mergeUpdates( getPocketItemsResponse.timestamp, getPocketItemsResponse.response.list ); @@ -66,6 +72,8 @@ export class PocketSettingTab extends PluginSettingTab { async (params) => { const accessInfo = await getAccessToken(); storePocketAccessInfo(this.plugin, accessInfo); + this.plugin.pocketAuthenticated = true; + this.plugin.pocketUsername = accessInfo.username; } ); diff --git a/styles.css b/styles.css index e69de29..fb4e2cb 100644 --- a/styles.css +++ b/styles.css @@ -0,0 +1,30 @@ +.PocketItem-styles-module_item__2EqeQ { + color: black; + border: 1px solid black; + display: block; + + padding: 4px 8px; + } + .PocketItem-styles-module_item__2EqeQ > span { + display: block; + } + + .PocketItem-styles-module_itemTitle__1Y0M_ { + font-weight: 600; + flex-grow: 1; + width: 100%; + } + + .PocketItem-styles-module_itemExcerpt__21asC { + font-weight: 300; + line-height: 1.5; + flex-grow: 1; + width: 100%; + } +.PocketItemList-styles-module_list__2MnxN { + list-style-type: none; + } + + .PocketItemList-styles-module_item__1WnQ- { + margin: 8px; + } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 558e259..1df4878 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "moduleResolution": "node", "importHelpers": true, "lib": ["dom", "es5", "scripthost", "es2015"], - "esModuleInterop": true + "esModuleInterop": true, + "jsx": "react" }, "include": ["**/*.ts"], "exclude": ["node_modules", "**/*.spec.ts"]