diff --git a/package.json b/package.json index 9e6e976..d987832 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,16 @@ "webpack-cli": "^3.1.0" }, "dependencies": { + "@types/classnames": "^2.2.6", + "@types/lodash": "^4.14.115", + "@types/micromatch": "^3.1.0", + "@types/node-localstorage": "^1.3.0", + "@types/react": "^16.4.14", + "@types/react-dom": "^16.0.8", + "@types/react-portal": "^4.0.1", + "@types/react-transition-group": "^2.0.14", + "@types/stats.js": "^0.17.0", + "@types/text-encoding": "^0.0.34", "automerge": "^0.9.1", "browser-process-hrtime": "^0.1.2", "bs58": "^4.0.1", @@ -70,6 +80,7 @@ "js-crc": "^0.2.0", "lodash": "^4.17.10", "micromatch": "^3.1.10", + "node-localstorage": "^1.3.1", "random-access-chrome-file": "^1.1.1", "react": "^16.5.2", "react-dom": "^16.5.2", @@ -79,7 +90,9 @@ "rxjs": "^6.3.2", "stats.js": "^0.17.0", "tap-browser-el": "^2.0.0", + "text-encoding": "^0.7.0", "utp": "github:pvh/utp", - "ws": "^6.1.0" + "ws": "^6.1.0", + "yargs": "^12.0.2" } } diff --git a/src/apps/make-bot/.gitignore b/src/apps/make-bot/.gitignore new file mode 100644 index 0000000..4d3dae7 --- /dev/null +++ b/src/apps/make-bot/.gitignore @@ -0,0 +1 @@ +default/ diff --git a/src/apps/make-bot/Bot.ts b/src/apps/make-bot/Bot.ts new file mode 100644 index 0000000..5014d9a --- /dev/null +++ b/src/apps/make-bot/Bot.ts @@ -0,0 +1,57 @@ +import * as React from "react" +import * as Reify from "../../data/Reify" +import * as Widget from "../../components/Widget" +import { AnyDoc } from "automerge/frontend" + +export interface Model { + id: string + code: string +} + +interface Props extends Widget.Props {} + +interface State { + err?: string +} + +class Bot extends React.Component { + state = { + err: undefined, + } + + static reify(doc: AnyDoc): Model { + return { + id: Reify.string(doc.id), + code: Reify.string(doc.string), + } + } + + componentDidMount() { + this.runCode() + } + + componentDidUpdate(prevProps: Props) { + if (this.props.doc.code !== prevProps.doc.code) { + this.runCode() + } + } + + runCode = () => { + const { code } = this.props.doc + let err + + try { + eval(`(() => { ${code} })()`) + } catch (e) { + err = e + } + + this.setState({ err }) + } + + render() { + return null + } +} + +export default Widget.create("Bot", Bot, Bot.reify) diff --git a/src/apps/make-bot/bots/journaling.js b/src/apps/make-bot/bots/journaling.js new file mode 100644 index 0000000..cb49ddb --- /dev/null +++ b/src/apps/make-bot/bots/journaling.js @@ -0,0 +1,45 @@ +const last = arr => arr[arr.length - 1] + +const addTimestamp = type => { + const url = Content.create("Text") + + Content.change(url, doc => { + doc.content = + type === "date" + ? new Date().toDateString().split("") + : new Date().toTimeString().split("") + }) + + Content.open(Content.store.getWorkspace()).once(workspace => { + const boardUrl = + workspace.navStack.length > 0 + ? last(workspace.navStack).url + : workspace.rootUrl + + const id = UUID.create() + + const card = { + id, + x: 50, + y: 50, + z: 100, + width: type === "date" ? 200 : 400, + height: 40, + url, + } + + Content.open(boardUrl).change(doc => { + doc.cards[id] = card + }) + }) +} + +makeBot("journaling", bot => { + bot.action("Add time", () => { + addTimestamp("time") + }) + + bot.action("Add date", () => { + addTimestamp("date") + }) +}) diff --git a/src/apps/make-bot/bots/organizer.js b/src/apps/make-bot/bots/organizer.js new file mode 100644 index 0000000..7eae157 --- /dev/null +++ b/src/apps/make-bot/bots/organizer.js @@ -0,0 +1,65 @@ +const last = arr => arr[arr.length - 1] + +const cleanUp = () => { + // constants for gallery + const MARGIN = 20 + const WINDOW_HEIGHT = window.innerHeight + + // open workspace + Content.open(Content.store.getWorkspace()) + .once(workspace => { + // grab a board + const boardUrl = + workspace.navStack.length > 0 + ? last(workspace.navStack).url + : workspace.rootUrl + + Content.change(boardUrl, board => { + // all cards that are not a bot + const nonBotCards = Object.values(board.cards).filter( + card => card.url.indexOf("Bot") < 0, + ) + + // calculate average width of a card + const avgWidth = + nonBotCards.reduce((memo, card) => card.width + memo, 0) / + nonBotCards.length + + // some imperative code to arrange in columns + let column = 0 + let topOffset = 0 + + nonBotCards.forEach(card => { + // update card aspect ratio + const aspect = card.width / card.height + card.width = avgWidth + card.height = avgWidth / aspect + + // move to new column if needed + if (topOffset + card.height + MARGIN * 2 > WINDOW_HEIGHT) { + column++ + topOffset = 0 + } + + // update x & y pos + card.x = column * (avgWidth + MARGIN) + MARGIN + card.y = topOffset + MARGIN + + // store offset + topOffset = card.y + card.height + }) + }).close() + }) + .close() +} + +makeBot("organizer", bot => { + // and now, instead of working as a button-based action + // bot.action("Clean Up!", cleanUp) + + // we can make it autonomous! + bot.autonomous( + "Board", // act on any change on board + cleanUp, + ) +}) diff --git a/src/apps/make-bot/make-bot b/src/apps/make-bot/make-bot new file mode 100755 index 0000000..53354c1 --- /dev/null +++ b/src/apps/make-bot/make-bot @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +../../../node_modules/.bin/cross-env TS_NODE_FILES=1 TS_NODE_CACHE_DIRECTORY=.cache TS_NODE_SKIP_IGNORE=1 node -r ts-node/register ./make-bot.ts $@ diff --git a/src/apps/make-bot/make-bot.ts b/src/apps/make-bot/make-bot.ts new file mode 100644 index 0000000..6f3b893 --- /dev/null +++ b/src/apps/make-bot/make-bot.ts @@ -0,0 +1,118 @@ +const { argv } = require("yargs") +import * as fs from "fs" + +const { workspace, id: botId } = argv +const fileName = argv._[0] + +if (!workspace || !botId || !fileName || !fs.existsSync(fileName)) { + console.log( + "Usage: ./make-bot --workspace workspaceId --id botId bot-code.js", + ) + process.exit(0) +} + +const code = fs.readFileSync(fileName, "utf-8") + +import { LocalStorage } from "node-localstorage" + +interface Global { + localStorage: LocalStorage +} +declare var global: Global + +global.localStorage = new LocalStorage("./localstorage") + +const raf = require("random-access-file") +import { Doc } from "automerge/frontend" +import { last, once } from "lodash" + +import * as Link from "../../data/Link" +import Store from "../../data/Store" +import StoreBackend from "../../data/StoreBackend" + +import CloudClient from "discovery-cloud/Client" +import { Hypermerge, FrontendManager } from "hypermerge" + +import "./Bot" // we have local bot implementation since the Capstone one uses css imports +import * as Board from "../../components/Board" +import * as DataImport from "../../components/DataImport" +import * as Workspace from "../../components/Workspace" +import Content from "../../components/Content" + +const hm = new Hypermerge({ storage: raf }) +const storeBackend = new StoreBackend(hm) +Content.store = new Store() + +storeBackend.sendQueue.subscribe(msg => Content.store.onMessage(msg)) +Content.store.sendQueue.subscribe(msg => storeBackend.onMessage(msg)) + +hm.joinSwarm( + new CloudClient({ + url: "wss://discovery-cloud.herokuapp.com", + id: hm.id, + stream: hm.stream, + }), +) + +hm.ready.then(hm => { + console.log("Ready!") + + Content.open(workspace).once(workspace => { + console.log("Opened workspace", workspace) + + const boardUrl = + workspace.navStack.length > 0 + ? last(workspace.navStack)!.url + : workspace.rootUrl + + if (!boardUrl) { + console.log("Can't find a board, exiting...") + return + } + + console.log(`Using board: ${boardUrl}`) + + const boardHandle = Content.open(boardUrl).once(doc => { + const botExists = !!doc.cards[botId] + + // console.log("board doc", doc) + + if (botExists) { + console.log(`Updating bot ${botId}`) + + const botUrl = doc.cards[botId]!.url + + if (!botUrl) return + + // update + const botHandle = Content.open(botUrl).change(bot => { + bot.code = code + }) + } else { + console.log(`Creating new bot: ${botId}`) + + // create + const botUrl = Content.create("Bot") + + const botHandle = Content.open(botUrl).change(doc => { + doc.id = botId + doc.code = code + }) + + boardHandle.change(board => { + const card = { + id: botId, + z: 0, + x: 0, + y: 0, + width: 200, + height: 200, + url: botUrl, + } + + board.cards[botId] = card + }) + } + }) + }) +}) diff --git a/src/components/App.tsx b/src/components/App.tsx index a0c344e..238d608 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -12,15 +12,25 @@ import "./Text" import "./Table" import "./Workspace" import "./HTML" +import "./Bot" + +import * as Workspace from "./Workspace" const log = Debug("component:app") +require("events").EventEmitter.prototype._maxListeners = 1000 + type State = { url?: string shouldHideFPSCounter?: boolean } -export default class App extends React.Component { +type Props = {} + +import * as UUID from "../data/UUID" +window.UUID = UUID + +export default class App extends React.Component { state: State = {} initWorkspace() { diff --git a/src/components/Bot.tsx b/src/components/Bot.tsx new file mode 100644 index 0000000..2101272 --- /dev/null +++ b/src/components/Bot.tsx @@ -0,0 +1,159 @@ +import * as Link from "../data/Link" +import * as React from "react" +import * as Reify from "../data/Reify" +import * as Widget from "./Widget" +import * as css from "./css/Bot.css" +import { AnyChange } from "./Widget" +import { AnyDoc } from "automerge/frontend" +import { DocumentActor } from "./Content" + +window.BotStore = window.BotStore || new Map() + +window.makeBot = (id: string, callback: Function) => { + if (!id || !callback) { + console.log("id or callback missing, ignoring bot") + return + } + + if (window.BotStore.has(id)) { + window.BotStore.delete(id) + } + + window.BotStore.set(id, {}) + + callback({ + autonomous: (type: string, cb: Function) => { + window.BotStore.set(id, { + ...window.BotStore.get(id), + autonomous: { + ...(window.BotStore.get(id).autonomous || {}), + [type]: cb, + }, + }) + }, + + action: (name: string, cb: Function) => { + window.BotStore.set(id, { + ...window.BotStore.get(id), + actions: { + ...(window.BotStore.get(id).actions || {}), + [name]: cb, + }, + }) + }, + }) +} + +export interface Model { + id: string + code: string +} + +interface Props extends Widget.Props {} + +interface State { + error?: string + lastUpdate: number +} + +const safeEval = (code: string) => { + let error + + try { + eval(`(() => { ${code} })()`) + } catch (e) { + error = e + } + + return error +} + +class BotActor extends DocumentActor { + async onMessage(message: AnyChange) { + const bot = window.BotStore.get(this.doc.id) + + if (!bot || !bot.autonomous) return + if (!message.from) return + + console.log("GOT ANYCHANGE", message) + + const { type } = Link.parse(message.from) + bot.autonomous[type] && bot.autonomous[type]() + } +} + +class Bot extends React.Component { + state = { + error: undefined, + lastUpdate: 0, + } + + static reify(doc: AnyDoc): Model { + return { + id: Reify.string(doc.id), + code: Reify.string(doc.string), + } + } + + componentDidMount() { + this.runCode() + } + + componentDidUpdate(prevProps: Props) { + if (this.props.doc.code !== prevProps.doc.code) { + this.runCode() + } + } + + runCode = () => { + const error = safeEval(this.props.doc.code) + + this.setState({ + lastUpdate: Date.now(), + error: error && error.toString(), + }) + } + + runAction = (actionName: string) => { + const action = window.BotStore.get(this.props.doc.id).actions[actionName] + action && action() + } + + render() { + const botInStore = + window.BotStore && window.BotStore.has(this.props.doc.id) + ? window.BotStore.get(this.props.doc.id) + : undefined + + const isAutonomuos = botInStore + ? Object.keys(botInStore.autonomous || {}).length > 0 + : false + + const actions = botInStore ? Object.keys(botInStore.actions || {}) : [] + + return ( +
+

{this.props.doc.id}

+ + {/*
{this.state.lastUpdate}
*/} + + {isAutonomuos &&

(autonomous)

} + + {actions.map(actionName => ( +
this.runAction(actionName)}> + {actionName} +
+ ))} + + {this.state.error && ( +
{this.state.error}
+ )} +
+ ) + } +} + +export default Widget.create("Bot", Bot, Bot.reify, BotActor) diff --git a/src/components/DataImport.tsx b/src/components/DataImport.tsx index 2c27cc3..d6fdcca 100644 --- a/src/components/DataImport.tsx +++ b/src/components/DataImport.tsx @@ -89,6 +89,12 @@ export const addImage = (src: string) => { }) } +export const addBot = async (code: string) => { + return addDoc("Bot", doc => { + doc.code = code.split("") + }) +} + export const addDoc = async (type: string, changeFn: ChangeFn) => { const url = Content.create(type) diff --git a/src/components/InteractableCard.tsx b/src/components/InteractableCard.tsx index 4f632de..402272b 100644 --- a/src/components/InteractableCard.tsx +++ b/src/components/InteractableCard.tsx @@ -41,6 +41,20 @@ export default class InteractableCard extends React.Component { } } + componentWillReceiveProps(nextProps: Props) { + if ( + nextProps.card.width !== this.props.card.width || + nextProps.card.height !== this.props.card.height + ) { + this.setState({ + currentSize: { + width: nextProps.card.width, + height: nextProps.card.height, + }, + }) + } + } + start = () => { this.props.onDragStart && this.props.onDragStart(this.props.card.id) } diff --git a/src/components/Widget.tsx b/src/components/Widget.tsx index 4fd7660..b9c9288 100644 --- a/src/components/Widget.tsx +++ b/src/components/Widget.tsx @@ -8,7 +8,12 @@ import Content, { Mode, MessageHandlerClass, } from "./Content" + import Handle from "../data/Handle" +import { once, last } from "lodash" +import { Model as BoardModel } from "./Board" +import { Model as WorkspaceModel } from "./Workspace" +import * as Link from "../data/Link" export interface Props { doc: Doc @@ -32,6 +37,11 @@ type WrappedComponentClass = { initDoc?: () => T } +export interface AnyChange extends Message { + type: "AnyChange" + body: any +} + export function create( type: string, WrappedComponent: WrappedComponentClass, @@ -50,8 +60,46 @@ export function create( } componentDidMount() { - this.handle = Content.open(this.props.url).subscribe(doc => { + this.handle = Content.open(this.props.url).subscribe((doc: any) => { this.setState({ doc }) + + if (Link.parse(this.props.url).type === "Bot") return + + const workspaceUrl = Content.store && Content.store.getWorkspace() + if (!workspaceUrl) return + + // send AnyChange to bot(s) on current board + Content.open(workspaceUrl).once(workspace => { + const boardUrl = + workspace.navStack.length > 0 + ? last(workspace.navStack)!.url + : workspace.rootUrl + + if (!boardUrl) return + + Content.open(boardUrl).once(board => { + Object.values(board.cards).forEach(card => { + if (!card || (card && card.url && card.url.indexOf("Bot") < 0)) + return + + setTimeout(() => { + console.log({ + type: "AnyChange", + body: doc, + from: this.props.url, + to: card.url, + }) + + Content.send({ + type: "AnyChange", + body: doc, + from: this.props.url, + to: card.url, + }) + }, 200) + }) + }) + }) }) } diff --git a/src/components/css/Bot.css b/src/components/css/Bot.css new file mode 100644 index 0000000..90ad312 --- /dev/null +++ b/src/components/css/Bot.css @@ -0,0 +1,13 @@ +.Bot { + padding: 10px; + text-align: center; +} + +.BotTriggerButton { + font-size: 12px; + border-radius: 3px; + background: #222; + color: #fff; + padding: 6px; + border: 1px solid #e8e8e8; +} diff --git a/src/data/Env.ts b/src/data/Env.ts index c6e7f00..2d95a59 100644 --- a/src/data/Env.ts +++ b/src/data/Env.ts @@ -5,10 +5,22 @@ export interface Env { device: "capstone" | "sidecar" } +declare global { + namespace NodeJS { + interface Global { + navigator: any + } + } +} + export function defaultEnv(): Env { return { - isTouchscreen: navigator.maxTouchPoints > 0, - device: navigator.userAgent.includes("CrOS") ? "capstone" : "sidecar", + isTouchscreen: global.navigator ? navigator.maxTouchPoints > 0 : false, + device: global.navigator + ? navigator.userAgent.includes("CrOS") + ? "capstone" + : "sidecar" + : "sidecar", } } diff --git a/src/data/Store.ts b/src/data/Store.ts index 0a4ee2c..5e39097 100644 --- a/src/data/Store.ts +++ b/src/data/Store.ts @@ -8,10 +8,15 @@ import { FrontendManager } from "hypermerge/frontend" import Queue from "./Queue" const log = Debug("store:front") -;(window as any).peek = () => { - console.log("please use peek() on the backend console") + +function isId(id: string) { + return id.length >= 32 && id.length <= 44 } +// ;(window as any).peek = () => { +// console.log("please use peek() on the backend console") +// } + export type Activity = Msg.UploadActivity | Msg.DownloadActivity export default class Store { diff --git a/src/logic/JsonBuffer.ts b/src/logic/JsonBuffer.ts index c97f6af..dab5686 100644 --- a/src/logic/JsonBuffer.ts +++ b/src/logic/JsonBuffer.ts @@ -1,3 +1,5 @@ +import { TextEncoder, TextDecoder } from "text-encoding" + export function parse(buffer: ArrayBuffer | ArrayBufferView): any { const decoder = new TextDecoder() return JSON.parse(decoder.decode(buffer)) diff --git a/src/types/index.d.ts b/src/types/index.d.ts index fb3964a..06bf773 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -1,11 +1,15 @@ import Content from "../components/Content" import * as StoreMsg from "../data/StoreMsg" +import * as UUID from "../data/UUID" declare global { interface Window { Content: typeof Content visualViewport: VisualViewport requestIdleCallback: (cb: () => void, options?: { timeout: number }) => void + makeBot: Function + BotStore: Map + UUID: UUID } interface PointerEvent { diff --git a/yarn.lock b/yarn.lock index cda56b0..9600845 100644 --- a/yarn.lock +++ b/yarn.lock @@ -70,6 +70,13 @@ dependencies: "@types/braces" "*" +"@types/node-localstorage@^1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@types/node-localstorage/-/node-localstorage-1.3.0.tgz#8341b89abd8ad00afcd3fa9242ed956b8f5ad32c" + integrity sha512-9+s5CWGhkYitklhLgnbf4s5ncCEx0An2jhBuhvw/sh9WNQ+/WvNFkPLyLjXGy+Oeo8CjPl69oz6M2FzZH+KwWA== + dependencies: + "@types/events" "*" + "@types/node@*": version "10.9.4" resolved "https://registry.yarnpkg.com/@types/node/-/node-10.9.4.tgz#0f4cb2dc7c1de6096055357f70179043c33e9897" @@ -127,6 +134,11 @@ dependencies: "@types/node" "*" +"@types/text-encoding@^0.0.34": + version "0.0.34" + resolved "https://registry.yarnpkg.com/@types/text-encoding/-/text-encoding-0.0.34.tgz#2d4038c88e277c5d7204986067a03d1fcb909a19" + integrity sha512-7LLzNINHFh+AH83ABaocBI25F1jI1Xku1RMiNj3hxmZdA+BeXIlM3LagIxd9YPeQlAX1xWys/+UJ8y6KBwtGtw== + "@types/ws@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.1.tgz#ca7a3f3756aa12f62a0a62145ed14c6db25d5a28" @@ -3492,6 +3504,13 @@ node-libs-browser@^2.0.0: util "^0.10.3" vm-browserify "0.0.4" +node-localstorage@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/node-localstorage/-/node-localstorage-1.3.1.tgz#3177ef42837f398aee5dd75e319b281e40704243" + integrity sha512-NMWCSWWc6JbHT5PyWlNT2i8r7PgGYXVntmKawY83k/M0UJScZ5jirb61TLnqKwd815DfBQu+lR3sRw08SPzIaQ== + dependencies: + write-file-atomic "^1.1.4" + node-pre-gyp@^0.10.0: version "0.10.3" resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" @@ -4714,6 +4733,11 @@ slash@^1.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55" integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU= +slide@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" + integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc= + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -5130,6 +5154,11 @@ tar@^4: safe-buffer "^5.1.2" yallist "^3.0.2" +text-encoding@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.7.0.tgz#f895e836e45990624086601798ea98e8f36ee643" + integrity sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA== + through2@^2.0.0, through2@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be" @@ -5605,6 +5634,15 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= +write-file-atomic@^1.1.4: + version "1.3.4" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.4.tgz#f807a4f0b1d9e913ae7a48112e6cc3af1991b45f" + integrity sha1-+Aek8LHZ6ROuekgRLmzDrxmRtF8= + dependencies: + graceful-fs "^4.1.11" + imurmurhash "^0.1.4" + slide "^1.1.5" + ws@^5.2.0: version "5.2.2" resolved "https://registry.yarnpkg.com/ws/-/ws-5.2.2.tgz#dffef14866b8e8dc9133582514d1befaf96e980f"