diff --git a/.babelrc b/.babelrc index f1f71f8b36..a2e1b77b2f 100644 --- a/.babelrc +++ b/.babelrc @@ -34,9 +34,6 @@ "transform-react-jsx-img-import", ["@babel/proposal-class-properties", { "loose": true }], "@babel/proposal-object-rest-spread", - // Samsung Internet on the Oculus Go version is stuck at version 5.2, which is a - // Chromium 51, as of this writing. It needs babel to transpile async/await. - "@babel/plugin-transform-async-to-generator", "@babel/plugin-proposal-optional-chaining" ] } diff --git a/.defaults.env b/.defaults.env index 5f5a0da627..5a0293b808 100644 --- a/.defaults.env +++ b/.defaults.env @@ -29,3 +29,7 @@ DEFAULT_SCENE_SID="JGLt8DP" # Uncomment to load the app config from the reticulum server in development. # Useful when testing the admin panel. # LOAD_APP_CONFIG=true + +IMMERS_SERVER="https://localhost:8081" +IMMERS_SCOPE="modAdditive" +IMMERS_ALLOW_GUESTS="true" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..98f45f6e69 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +certs +dist +admin/node_modules +admin/certs +admin/dist +npm-debug.log +.vscode +.cache +.parcel-cache +.env +.ret.credentials diff --git a/.vscode/settings.json b/.vscode/settings.json index a722e4fd69..0e949af89a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,4 @@ { - // Format on save for Prettier - "editor.formatOnSave": true, - // Disable html formatting for now "html.format.enable": false, // Disable the default javascript formatter "javascript.format.enable": false, diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000..7319276401 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:14 + +WORKDIR /usr/src/hubs +COPY package*.json ./ + +WORKDIR /usr/src/hubs/admin +COPY admin/package*.json ./ +RUN npm ci + +WORKDIR /usr/src/hubs +RUN npm ci + +WORKDIR /usr/src/hubs +COPY . . + +RUN npm run deploy -- --skipCI --noUpload --envPlaceholders + +CMD [ "/bin/bash", "dockerdeploy.sh" ] diff --git a/dockerdeploy.sh b/dockerdeploy.sh new file mode 100755 index 0000000000..8cd6e13800 --- /dev/null +++ b/dockerdeploy.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -e +echo "Logging into to hub $hub as $email" +npm run login -- --host $hub --email $email +echo "Updating hubs config for immer $domain" +# this one reads from env because of issues with dollar sign in payment pointer +npm run immers-configure +echo "Deploying Immers Space hubs client" +npm run deploy -- --noBuild --replacePlaceholders + +echo "Done" diff --git a/package-lock.json b/package-lock.json index 2573babc75..2f5b04f53f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "hubs", - "version": "0.0.1", + "version": "1.10.1", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -6971,6 +6971,11 @@ "react-lifecycles-compat": "^3.0.4" } }, + "@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "@storybook/addon-actions": { "version": "6.1.15", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-6.1.15.tgz", @@ -19954,7 +19959,9 @@ "colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", - "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==" + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "optional": true }, "combined-stream": { "version": "1.0.8", @@ -21332,6 +21339,11 @@ "domelementtype": "1" } }, + "dompurify": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.0.tgz", + "integrity": "sha512-Be9tbQMZds4a3C6xTmz68NlMfeONA//4dOavl/1rNw50E+/QO0KVpbcU0PcaW0nsQxurXls9ZocqFxk8R2mWEA==" + }, "domutils": { "version": "1.5.1", "resolved": "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz", @@ -21571,23 +21583,6 @@ "stream-shift": "^1.0.0" } }, - "easyrtc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/easyrtc/-/easyrtc-1.1.0.tgz", - "integrity": "sha1-9Ek39xMsuLW6jgvBzD48zEcqPvQ=", - "requires": { - "async": "0.2.x", - "colors": "*", - "underscore": "1.5.x" - }, - "dependencies": { - "async": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", - "integrity": "sha1-trvgsGdLnXGXCMo43owjfLUmw9E=" - } - } - }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -21746,6 +21741,43 @@ "objectorarray": "^1.0.4" } }, + "engine.io-client": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.2.2.tgz", + "integrity": "sha512-8ZQmx0LQGRTYkHuogVZuGSpDqYZtCM/nv8zQ68VZ+JkOpazJ7ICdsSpaO6iXwvaU30oFg5QJOJWj8zWqhbKjkQ==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.0.3", + "ws": "~8.2.3", + "xmlhttprequest-ssl": "~2.0.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "ws": { + "version": "8.2.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz", + "integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA==" + } + } + }, + "engine.io-parser": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.4.tgz", + "integrity": "sha512-+nVFp+5z1E3HcToEnO7ZIj3g+3k9389DvWtvJZz0T6/eOCPIyyxehFcedoYrZQrp0LgQbD9pPXhpMBKMd5QURg==" + }, "enhanced-resolve": { "version": "4.1.0", "resolved": "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz", @@ -24618,6 +24650,23 @@ "integrity": "sha512-O3sR1/opvCDGLEVcvrGTMtLac8GJ5IwZC4puPrLuRj3l7ICKvkmA0vGuU9OW8mV9WIBRnaxp5GJh9IEAaNOoYg==", "dev": true }, + "immers-client": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/immers-client/-/immers-client-2.12.0.tgz", + "integrity": "sha512-GAbg7vCDyJ66Cm13TZrwC/Hwx8uxYCc22mhplRQ9JFfrjnlW8zpFckEoab9/Bf3jawfXcXh46fZgYn8lW1mSUg==", + "requires": { + "core-js": "^3.17.2", + "dompurify": "^2.3.6", + "socket.io-client": "^4.0.0" + }, + "dependencies": { + "core-js": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.26.0.tgz", + "integrity": "sha512-+DkDrhoR4Y0PxDz6rurahuB+I45OsEUv8E1maPTB6OuHRohMMcznBq9TMpdpDMm/hUPob/mJJS3PqgbHpMTQgw==" + } + } + }, "immutable": { "version": "3.7.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz", @@ -25031,6 +25080,12 @@ "loose-envify": "^1.0.0" } }, + "invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true + }, "ip": { "version": "1.1.5", "resolved": "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz", @@ -26482,6 +26537,15 @@ } } }, + "lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "requires": { + "invert-kv": "^2.0.0" + } + }, "leven": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-2.1.0.tgz", @@ -26541,6 +26605,11 @@ "uc.micro": "^1.0.1" } }, + "linkifyjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-3.0.5.tgz", + "integrity": "sha512-1Y9XQH65eQKA9p2xtk+zxvnTeQBG7rdAXSkUG97DmuI/Xhji9uaUzaWxRj6rf9YC0v8KKHkxav7tnLX82Sz5Fg==" + }, "load-json-file": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz", @@ -27116,14 +27185,22 @@ } }, "mem": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mem/-/mem-4.1.0.tgz", - "integrity": "sha512-I5u6Q1x7wxO0kdOpYBB28xueHADYps5uty/zg936CiG8NTe5sJL8EjrCuLneuDW3PlMdZBGDIn8BirEVdovZvg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", "dev": true, "requires": { "map-age-cleaner": "^0.1.1", - "mimic-fn": "^1.0.0", + "mimic-fn": "^2.0.0", "p-is-promise": "^2.0.0" + }, + "dependencies": { + "mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true + } } }, "memoizerific": { @@ -27592,6 +27669,11 @@ "dev": true, "optional": true }, + "nanoid": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", + "integrity": "sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==" + }, "nanomatch": { "version": "1.2.13", "resolved": "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz", @@ -27691,12 +27773,12 @@ "version": "github:mozillareality/networked-aframe#a691b90cd1c817283fbd4ce32f63b0b184311f4c", "from": "github:mozillareality/networked-aframe#master", "requires": { - "buffered-interpolation": "github:Infinitelee/buffered-interpolation#5bb18421ebf2bf11664645cdc7a15bd77ee2156b", - "easyrtc": "1.1.0" + "buffered-interpolation": "github:Infinitelee/buffered-interpolation#5bb18421ebf2bf11664645cdc7a15bd77ee2156b" }, "dependencies": { "buffered-interpolation": { - "version": "github:infinitelee/buffered-interpolation#5bb18421ebf2bf11664645cdc7a15bd77ee2156b" + "version": "github:Infinitelee/buffered-interpolation#5bb18421ebf2bf11664645cdc7a15bd77ee2156b", + "from": "github:Infinitelee/buffered-interpolation#5bb18421ebf2bf11664645cdc7a15bd77ee2156b" } } }, @@ -28643,21 +28725,6 @@ "pump": "^3.0.0" } }, - "invert-kv": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", - "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", - "dev": true - }, - "lcid": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", - "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", - "dev": true, - "requires": { - "invert-kv": "^2.0.0" - } - }, "pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -28961,6 +29028,11 @@ "integrity": "sha1-dLkdLLhnXRG5mXagBl9s4X+hvMg=", "dev": true }, + "parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, "parse-url": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-5.0.2.tgz", @@ -29140,6 +29212,11 @@ "websocket": "^1.0.24" } }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, "picomatch": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz", @@ -29296,11 +29373,6 @@ "find-up": "^2.1.0" } }, - "platform-command": { - "version": "git+https://gitlab.com/gitlabdev/platform-command.git#3f02478b2cbe88d516c0d17b2c221d3c3b96e16f", - "from": "git+https://gitlab.com/gitlabdev/platform-command.git", - "dev": true - }, "plur": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/plur/-/plur-3.1.1.tgz", @@ -33529,6 +33601,95 @@ } } }, + "sanitize-html": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.7.1.tgz", + "integrity": "sha512-oOpe8l4J8CaBk++2haoN5yNI5beekjuHv3JRPKUx/7h40Rdr85pemn4NkvUB3TcBP7yjat574sPlcMAyv4UQig==", + "requires": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^6.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "dependencies": { + "deepmerge": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", + "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==" + }, + "dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + } + }, + "domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" + }, + "domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "requires": { + "domelementtype": "^2.2.0" + } + }, + "domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "requires": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + } + }, + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==" + }, + "escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==" + }, + "htmlparser2": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "requires": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==" + }, + "postcss": { + "version": "8.4.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.16.tgz", + "integrity": "sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ==", + "requires": { + "nanoid": "^3.3.4", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + } + } + }, "sass": { "version": "1.26.10", "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.10.tgz", @@ -34018,6 +34179,56 @@ "kind-of": "^3.2.0" } }, + "socket.io-client": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.5.1.tgz", + "integrity": "sha512-e6nLVgiRYatS+AHXnOnGi4ocOpubvOUCGhyWw8v+/FxW8saHkinG6Dfhi9TU0Kt/8mwJIAASxvw6eujQmjdZVA==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.2.1", + "socket.io-parser": "~4.2.0" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, + "socket.io-parser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.1.tgz", + "integrity": "sha512-V4GrkLy+HeF1F/en3SpUaM+7XxYXpuMUWLGde1kSSh5nQMN4hLrbPIkD+otwh6q9R6NOQBN4AMaOZ2zVjui82g==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "dependencies": { + "debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "requires": { + "ms": "2.1.2" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "sockjs": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.19.tgz", @@ -34089,6 +34300,11 @@ "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", "dev": true }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" + }, "source-map-resolve": { "version": "0.5.2", "resolved": "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz", @@ -34308,7 +34524,6 @@ "glob": "~3.2.6", "mustache": "~0.7.2", "optimist": "~0.6.0", - "platform-command": "git+https://gitlab.com/gitlabdev/platform-command.git", "underscore": "~1.5.2" }, "dependencies": { @@ -36628,7 +36843,8 @@ "underscore": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.5.2.tgz", - "integrity": "sha1-EzXF5PXm0zu7SwBrqMhqAPVW3gg=" + "integrity": "sha1-EzXF5PXm0zu7SwBrqMhqAPVW3gg=", + "dev": true }, "unfetch": { "version": "4.2.0", @@ -37491,6 +37707,11 @@ "defaults": "^1.0.3" } }, + "web-monetization-polyfill": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/web-monetization-polyfill/-/web-monetization-polyfill-2.0.0.tgz", + "integrity": "sha512-qrt1PawK4pKtc+aZtu2rxubm8pF25QOcHaU04K3sGMcyqJYr7WwGdXlfK7fA0TkQI0niMO7ZhaqyQ1T2tnVH6A==" + }, "web-namespaces": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz", @@ -37958,6 +38179,15 @@ "ms": "^2.1.1" } }, + "decamelize": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-2.0.0.tgz", + "integrity": "sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg==", + "dev": true, + "requires": { + "xregexp": "4.0.0" + } + }, "default-gateway": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/default-gateway/-/default-gateway-2.7.2.tgz", @@ -37983,6 +38213,15 @@ "strip-eof": "^1.0.0" } }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, "internal-ip": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/internal-ip/-/internal-ip-3.0.1.tgz", @@ -37993,12 +38232,46 @@ "ipaddr.js": "^1.5.2" } }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, "ms": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", "dev": true }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, "schema-utils": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", @@ -38015,6 +38288,35 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==", "dev": true + }, + "yargs": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.2.tgz", + "integrity": "sha512-e7SkEx6N6SIZ5c5H22RTZae61qtn3PYUE8JYbBFlK9sYmh3DMQ6E5ygtaG/2BW0JZi4WGgTR2IV5ChqlqrDGVQ==", + "dev": true, + "requires": { + "cliui": "^4.0.0", + "decamelize": "^2.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^10.1.0" + } + }, + "yargs-parser": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz", + "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } } } }, @@ -38375,6 +38677,11 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true }, + "xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" + }, "xregexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz", @@ -38412,97 +38719,121 @@ "dev": true }, "yargs": { - "version": "12.0.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.2.tgz", - "integrity": "sha512-e7SkEx6N6SIZ5c5H22RTZae61qtn3PYUE8JYbBFlK9sYmh3DMQ6E5ygtaG/2BW0JZi4WGgTR2IV5ChqlqrDGVQ==", + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", "dev": true, "requires": { - "cliui": "^4.0.0", - "decamelize": "^2.0.0", - "find-up": "^3.0.0", - "get-caller-file": "^1.0.1", - "os-locale": "^3.0.0", + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "require-main-filename": "^1.0.1", - "set-blocking": "^2.0.0", - "string-width": "^2.0.0", - "which-module": "^2.0.0", - "y18n": "^3.2.1 || ^4.0.0", - "yargs-parser": "^10.1.0" + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" }, "dependencies": { - "camelcase": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", - "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true }, - "decamelize": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-2.0.0.tgz", - "integrity": "sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg==", + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "requires": { - "xregexp": "4.0.0" + "color-convert": "^2.0.1" } }, - "find-up": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", - "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", "dev": true, "requires": { - "locate-path": "^3.0.0" + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" } }, - "locate-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", - "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "requires": { - "p-locate": "^3.0.0", - "path-exists": "^3.0.0" + "color-name": "~1.1.4" } }, - "p-limit": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.1.0.tgz", - "integrity": "sha512-NhURkNcrVB+8hNfLuysU8enY5xn2KXphsHBaC2YmRNTZRc7RWusw6apSpdEj3jo4CMb6W9nrF6tTnsJsJeyu6g==", + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true + }, + "emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true + }, + "string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "requires": { - "p-try": "^2.0.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" } }, - "p-locate": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", - "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "requires": { - "p-limit": "^2.0.0" + "ansi-regex": "^5.0.1" } }, - "p-try": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.0.0.tgz", - "integrity": "sha512-hMp0onDKIajHfIkdRk3P4CdCmErkYAxxDtP3Wx/4nZ3aGlau2VKh3mZpcuFkH27WQkL/3WBCPOktzA9ZOAnMQQ==", - "dev": true + "wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "requires": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + } }, - "path-exists": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", - "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true }, "yargs-parser": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-10.1.0.tgz", - "integrity": "sha512-VCIyR1wJoEBZUqk5PA+oOBF6ypbwh5aNB3I50guxAL/quggdfs4TtNHQrSazFA3fYZ+tEqfs0zIGlv0c/rgjbQ==", - "dev": true, - "requires": { - "camelcase": "^4.1.0" - } + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true } } }, @@ -38545,4 +38876,4 @@ "dev": true } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 04621c2dc2..a63edff604 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hubs", - "version": "0.0.1", + "version": "1.10.1", "description": "Duck-themed multi-user virtual spaces in WebVR.", "main": "src/index.js", "license": "MPL-2.0", @@ -27,6 +27,9 @@ "login": "node -r @babel/register -r esm -r ./scripts/shim scripts/login.js", "logout": "node -r @babel/register -r esm -r ./scripts/shim scripts/logout.js", "deploy": "node -r @babel/register -r esm -r ./scripts/shim scripts/deploy.js", + "immers-configure": "node -r @babel/register -r esm -r ./scripts/shim scripts/immers-configure.js", + "immers-build:image": "docker build -t immersspace/hubs .", + "immers-publish:image": "docker tag immersspace/hubs:latest immersspace/hubs:v$npm_package_version && docker push immersspace/hubs:latest && docker push immersspace/hubs:v$npm_package_version", "undeploy": "node -r @babel/register -r esm -r ./scripts/shim scripts/undeploy.js", "test": "npm run lint && npm run test:unit && npm run build", "test:unit": "ava", @@ -104,11 +107,13 @@ "history": "^4.7.2", "hls.js": "^0.14.6", "html2canvas": "^1.0.0-rc.7", + "immers-client": "^2.12.0", "js-cookie": "^2.2.0", "jsonschema": "^1.2.2", "jwt-decode": "^2.2.0", "lib-hubs": "github:mozillareality/lib-hubs#master", "linkify-it": "^2.0.3", + "linkifyjs": "^3.0.0-beta.3", "markdown-it": "^8.4.2", "moving-average": "^1.0.0", "networked-aframe": "github:mozillareality/networked-aframe#master", @@ -132,9 +137,11 @@ "react-textarea-autosize": "^8.2.0", "react-use-css-breakpoints": "^1.0.4", "resize-observer-polyfill": "^1.5.1", + "sanitize-html": "^2.3.2", "screenfull": "^4.0.1", "sdp-transform": "^2.14.1", "semver": "^7.3.2", + "socket.io-client": "^4.0.0", "three": "github:mozillareality/three.js#hubs-patches-133", "three-ammo": "github:infinitelee/three-ammo", "three-mesh-bvh": "^0.3.7", @@ -143,6 +150,7 @@ "troika-three-text": "^0.45.0", "use-clipboard-copy": "^0.1.2", "uuid": "^3.2.1", + "web-monetization-polyfill": "^2.0.0", "webrtc-adapter": "^7.7.0", "webxr-polyfill": "^2.0.3", "zip-loader": "^1.1.0" @@ -219,7 +227,8 @@ "webpack-bundle-analyzer": "^3.3.2", "webpack-cli": "^3.2.3", "webpack-dev-server": "^3.1.14", - "worker-loader": "^2.0.0" + "worker-loader": "^2.0.0", + "yargs": "^16.2.0" }, "optionalDependencies": { "fsevents": "^2.2.1" diff --git a/scripts/deploy.js b/scripts/deploy.js index 1b3c0fa4ad..d2fa80e3fd 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -6,13 +6,22 @@ import tar from "tar"; import ora from "ora"; import FormData from "form-data"; import path from "path"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +const { skipCI, noBuild, noUpload, envPlaceholders, replacePlaceholders } = yargs(hideBin(process.argv)).argv; + +let host; +let token; if (!existsSync(".ret.credentials")) { - console.log("Not logged in, so cannot deploy. To log in, run npm run login."); - process.exit(0); + if (!noUpload && !envPlaceholders) { + console.log("Not logged in, so cannot deploy. To log in, run npm run login."); + process.exit(1); + } +} else { + ({ host, token } = JSON.parse(readFileSync(".ret.credentials"))); } -const { host, token } = JSON.parse(readFileSync(".ret.credentials")); console.log(`Deploying to ${host}.`); const step = ora({ indent: 2 }).start(); @@ -31,74 +40,123 @@ const getTs = (() => { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }; - - const res = await fetch(`https://${host}/api/ita/configs/hubs`, { headers }); - const hubsConfigs = await res.json(); - const buildEnv = {}; - for (const [k, v] of Object.entries(hubsConfigs.general)) { - buildEnv[k.toUpperCase()] = v; + let buildEnv = {}; + let hubsConfigs + if (envPlaceholders) { + buildEnv = { + CORS_PROXY_SERVER: "__IMMERS_PLACEHOLDER_CORS_PROXY_SERVER", + BASE_ASSETS_PATH: "__IMMERS_PLACEHOLDER_BASE_ASSETS_PATH", + SHORTLINK_DOMAIN: "__IMMERS_PLACEHOLDER_SHORTLINK_DOMAIN", + SENTRY_DSN: "__IMMERS_PLACEHOLDER_SENTRY_DSN", + GA_TRACKING_ID: "__IMMERS_PLACEHOLDER_GA_TRACKING_ID", + RETICULUM_SERVER: "__IMMERS_PLACEHOLDER_RETICULUM_SERVER", + THUMBNAIL_SERVER: "__IMMERS_PLACEHOLDER_THUMBNAIL_SERVER", + NON_CORS_PROXY_DOMAINS: "__IMMERS_PLACEHOLDER_NON_CORS_PROXY_DOMAINS", + }; + } else { + const res = await fetch(`https://${host}/api/ita/configs/hubs`, { headers }); + hubsConfigs = await res.json(); + for (const [k, v] of Object.entries(hubsConfigs.general)) { + buildEnv[k.toUpperCase()] = v; + } } const version = getTs(); - buildEnv.BUILD_VERSION = `1.0.0.${version}`; + buildEnv.BUILD_VERSION = `${process.env.npm_package_version}.${version}`; buildEnv.ITA_SERVER = ""; buildEnv.POSTGREST_SERVER = ""; buildEnv.CONFIGURABLE_SERVICES = "janus-gateway,reticulum,hubs,spoke"; const env = Object.assign(process.env, buildEnv); + if (!noBuild) { + for (const d in ["./dist", "./admin/dist"]) { + rmdir(d, err => { + if (err) { + console.error(err); + process.exit(1); + } + }); + } - for (const d of ["./dist", "./admin/dist"]) { - rmdir(d, err => { - if (err) { - console.error(err); - process.exit(1); - } - }); - } + for (const d of ["./dist", "./admin/dist"]) { + rmdir(d, err => { + if (err) { + console.error(err); + process.exit(1); + } + }); + } - step.text = "Building Client."; + step.text = "Building Client."; - await new Promise((resolve, reject) => { - exec("npm ci", {}, err => { - if (err) reject(err); - resolve(); + await new Promise((resolve, reject) => { + if (skipCI) { + return resolve(); + } + exec("npm ci", {}, err => { + if (err) reject(err); + resolve(); + }); }); - }); - await new Promise((resolve, reject) => { - exec("npm run build", { env }, err => { - if (err) reject(err); - resolve(); + await new Promise((resolve, reject) => { + exec("npm run build", { env }, err => { + if (err) reject(err); + resolve(); + }); }); - }); - step.text = "Building Admin Console."; + step.text = "Building Admin Console."; - await new Promise((resolve, reject) => { - exec("npm ci", { cwd: "./admin" }, err => { - if (err) reject(err); - resolve(); + await new Promise((resolve, reject) => { + if (skipCI) { + return resolve(); + } + exec("npm ci", { cwd: "./admin" }, err => { + if (err) reject(err); + resolve(); + }); }); - }); - await new Promise((resolve, reject) => { - exec("npm run build", { cwd: "./admin", env }, err => { - if (err) reject(err); - resolve(); + await new Promise((resolve, reject) => { + exec("npm run build", { cwd: "./admin", env }, err => { + if (err) reject(err); + resolve(); + }); }); - }); - await new Promise(res => { - ncp("./admin/dist", "./dist", err => { - if (err) { - console.error(err); - process.exit(1); - } + await new Promise(res => { + ncp("./admin/dist", "./dist", err => { + if (err) { + console.error(err); + process.exit(1); + } - res(); + res(); + }); }); - }); + } + if (replacePlaceholders) { + // update prebuilt bundle placeholders with server-specific values + for (const [k, v] of Object.entries(hubsConfigs.general)) { + await new Promise((resolve, reject) => { + const ph = v.endsWith("/") + // avoid double slash on substitution + ? `__IMMERS_PLACEHOLDER_${k.toUpperCase()}/\\?` + : `__IMMERS_PLACEHOLDER_${k.toUpperCase()}`; + exec(`find ./dist -iregex ".*\\.\\(js\\|html\\|css\\|map\\)" -print0 | xargs -0 sed -i -e 's|${ph}|${v}|g'`, { env }, err => { + if (err) reject(err); + resolve(); + }); + }); + } + } + if (noUpload) { + step.text = `Skipping deploy.`; + step.succeed(); + process.exit(0); + } step.text = "Preparing Deploy."; step.text = "Packaging Build."; diff --git a/scripts/immers-configure.js b/scripts/immers-configure.js new file mode 100644 index 0000000000..a7ef45b2e6 --- /dev/null +++ b/scripts/immers-configure.js @@ -0,0 +1,70 @@ +import { readFileSync, existsSync } from "fs"; +// use env due to complications of reading $ in payment pointer via cli +const { domain: immer, monetizationPointer: wallet } = process.env; +if (!immer || !wallet) { + console.log("Missing required ENV: domain, monetizationPointer"); + process.exit(1); +} +if (!existsSync(".ret.credentials")) { + console.log("Not logged in, so cannot configure. To log in, run npm run login."); + process.exit(1); +} +const { host, token } = JSON.parse(readFileSync(".ret.credentials")); + +(async () => { + const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json" + }; + // server settings + const cfg = { + extra_csp: { + // connect to home immer + connect_src: "https: wss:" + }, + security: { + // fetch remote avatars + cors_origins: "*" + }, + uploads: { + // keep media for 6 months so it remains in chat history + ttl: 15778476 + }, + extra_html: {} + }; + // add local immers server env variable and web monetization payment pointer to all pages + const extraHeader = ``; + ["extra_avatar_html", "extra_index_html", "extra_room_html", "extra_scene_html"].forEach(setting => { + cfg.extra_html[setting] = extraHeader; + }); + await fetch(`https://${host}/api/ita/configs/reticulum`, { + headers, + method: "PATCH", + body: JSON.stringify(cfg) + }) + .then(res => { + if (!res.ok) { + throw new Error(`Response ${res.status}`); + } + }) + .catch(err => console.log("Error updating server config: ", err.message)); + + // App Settings + await fetch(`https://${host}/api/v1/app_configs`, { + headers, + method: "POST", + body: JSON.stringify({ + features: { + // disallow hubs/reticulum accounts to enforce monetized features and avoid confusion with immers accounts + disable_sign_up: true + } + }) + }) + .then(res => { + if (!res.ok) { + throw new Error(`Response ${res.status}`); + } + }) + .catch(err => console.log("Error updating server config: ", err.message)); + process.exit(0); +})(); diff --git a/scripts/login.js b/scripts/login.js index 6d013b9a3f..b741e916ef 100644 --- a/scripts/login.js +++ b/scripts/login.js @@ -5,6 +5,9 @@ import AuthChannel from "../src/utils/auth-channel"; import configs from "../src/utils/configs.js"; import { Socket } from "phoenix-channels"; import { writeFileSync } from "fs"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +const argv = yargs(hideBin(process.argv)).argv; const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -12,8 +15,8 @@ const ask = q => new Promise(res => rl.question(q, res)); (async () => { console.log("Logging into Hubs Cloud.\n"); - const host = await ask("Host (eg hubs.mozilla.com): "); - if (!host) { + const host = argv.host || (await ask("Host (eg hubs.mozilla.com): ")); + if (!host || host === true) { console.log("Invalid host."); process.exit(1); } @@ -37,8 +40,9 @@ const ask = q => new Promise(res => rl.question(q, res)); const socket = await connectToReticulum(false, null, Socket); const store = new Store(); - const email = await ask("Your admin account email (eg admin@yoursite.com): "); + const email = argv.email || (await ask("Your admin account email (eg admin@yoursite.com): ")); console.log(`Logging into ${host} as ${email}. Click on the link in your email to continue.`); + const authChannel = new AuthChannel(store); authChannel.setSocket(socket); const { authComplete } = await authChannel.startAuthentication(email); diff --git a/src/assets/images/immers_logo.png b/src/assets/images/immers_logo.png new file mode 100644 index 0000000000..d776333002 Binary files /dev/null and b/src/assets/images/immers_logo.png differ diff --git a/src/assets/images/sprites/action/immers-action.png b/src/assets/images/sprites/action/immers-action.png new file mode 100644 index 0000000000..454430e803 Binary files /dev/null and b/src/assets/images/sprites/action/immers-action.png differ diff --git a/src/assets/images/sprites/action/immers-bg-hover.png b/src/assets/images/sprites/action/immers-bg-hover.png new file mode 100644 index 0000000000..c123567f98 Binary files /dev/null and b/src/assets/images/sprites/action/immers-bg-hover.png differ diff --git a/src/assets/images/sprites/action/immers-bg.png b/src/assets/images/sprites/action/immers-bg.png new file mode 100644 index 0000000000..15521bbd03 Binary files /dev/null and b/src/assets/images/sprites/action/immers-bg.png differ diff --git a/src/assets/images/spritesheets/sprite-system-action-spritesheet.json b/src/assets/images/spritesheets/sprite-system-action-spritesheet.json index 523e941f10..225f6a5152 100644 --- a/src/assets/images/spritesheets/sprite-system-action-spritesheet.json +++ b/src/assets/images/spritesheets/sprite-system-action-spritesheet.json @@ -285,7 +285,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "inspect-action.png": + "immers-action.png": { "frame": {"x":328,"y":738,"w":64,"h":64}, "rotated": false, @@ -293,7 +293,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "mute-action.png": + "immers-bg-hover.png": { "frame": {"x":408,"y":738,"w":64,"h":64}, "rotated": false, @@ -301,7 +301,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "next.png": + "immers-bg.png": { "frame": {"x":488,"y":738,"w":64,"h":64}, "rotated": false, @@ -309,7 +309,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "pin-action.png": + "inspect-action.png": { "frame": {"x":568,"y":738,"w":64,"h":64}, "rotated": false, @@ -317,7 +317,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "prev.png": + "mute-action.png": { "frame": {"x":648,"y":738,"w":64,"h":64}, "rotated": false, @@ -325,7 +325,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "recenter-action.png": + "next.png": { "frame": {"x":728,"y":738,"w":64,"h":64}, "rotated": false, @@ -333,7 +333,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "record-action-alpha.png": + "pin-action.png": { "frame": {"x":882,"y":8,"w":64,"h":64}, "rotated": false, @@ -341,7 +341,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "record-action.png": + "prev.png": { "frame": {"x":882,"y":88,"w":64,"h":64}, "rotated": false, @@ -349,7 +349,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "remove-action.png": + "recenter-action.png": { "frame": {"x":882,"y":168,"w":64,"h":64}, "rotated": false, @@ -357,7 +357,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "rotate-action.png": + "record-action-alpha.png": { "frame": {"x":882,"y":248,"w":64,"h":64}, "rotated": false, @@ -365,7 +365,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "scale-action.png": + "record-action.png": { "frame": {"x":882,"y":328,"w":64,"h":64}, "rotated": false, @@ -373,7 +373,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "serialize-action.png": + "remove-action.png": { "frame": {"x":882,"y":408,"w":64,"h":64}, "rotated": false, @@ -381,7 +381,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "snap-page.png": + "rotate-action.png": { "frame": {"x":882,"y":488,"w":64,"h":64}, "rotated": false, @@ -389,7 +389,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "spawn_message.png": + "scale-action.png": { "frame": {"x":882,"y":568,"w":64,"h":64}, "rotated": false, @@ -397,7 +397,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "spawn_message_dark-hover.png": + "serialize-action.png": { "frame": {"x":882,"y":648,"w":64,"h":64}, "rotated": false, @@ -405,7 +405,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "spawn_message_dark.png": + "snap-page.png": { "frame": {"x":882,"y":728,"w":64,"h":64}, "rotated": false, @@ -413,7 +413,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "stop-action.png": + "spawn_message.png": { "frame": {"x":8,"y":818,"w":64,"h":64}, "rotated": false, @@ -421,7 +421,7 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "undo-action.png": + "spawn_message_dark-hover.png": { "frame": {"x":88,"y":818,"w":64,"h":64}, "rotated": false, @@ -429,13 +429,37 @@ "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} }, - "unmute-action.png": + "spawn_message_dark.png": { "frame": {"x":168,"y":818,"w":64,"h":64}, "rotated": false, "trimmed": false, "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, "sourceSize": {"w":64,"h":64} + }, + "stop-action.png": + { + "frame": {"x":248,"y":818,"w":64,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, + "sourceSize": {"w":64,"h":64} + }, + "undo-action.png": + { + "frame": {"x":328,"y":818,"w":64,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, + "sourceSize": {"w":64,"h":64} + }, + "unmute-action.png": + { + "frame": {"x":408,"y":818,"w":64,"h":64}, + "rotated": false, + "trimmed": false, + "spriteSourceSize": {"x":0,"y":0,"w":64,"h":64}, + "sourceSize": {"w":64,"h":64} } } } \ No newline at end of file diff --git a/src/assets/images/spritesheets/sprite-system-action-spritesheet.png b/src/assets/images/spritesheets/sprite-system-action-spritesheet.png index c215d6e77d..36c1040b2b 100644 Binary files a/src/assets/images/spritesheets/sprite-system-action-spritesheet.png and b/src/assets/images/spritesheets/sprite-system-action-spritesheet.png differ diff --git a/src/assets/images/spritesheets/sprite-system-notice-spritesheet.png b/src/assets/images/spritesheets/sprite-system-notice-spritesheet.png index 46bc3764e0..dcb2df162c 100644 Binary files a/src/assets/images/spritesheets/sprite-system-notice-spritesheet.png and b/src/assets/images/spritesheets/sprite-system-notice-spritesheet.png differ diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json new file mode 100644 index 0000000000..9b00821f23 --- /dev/null +++ b/src/assets/translations.data.json @@ -0,0 +1,429 @@ +{ + "en": { + "app-name": "App", + "editor-name": "Scene Editor", + "contact-email": "app@company.com", + "company-name": "Company", + "share-hashtag": "#app", + "app-description": "Share a virtual room with friends.\nWatch videos, play with 3D objects, or just hang out.", + "app-tagline": "Private social VR in your web browser", + "auth.verified-title": "Email Verified!", + "auth.verify-failed": "Unable to sign in with this link. It may have already been used or has expired.", + "auth.verified": "Your email has been verified!\nYou can now close this browser tab and return to %app-name%.", + "auth.spoke-verified": "Your email has been verified!\nYou can now close this browser tab and return to %editor-name%.", + "sign-in.prompt": "Sign in to your Immers profile.", + "sign-in.admin": "Check your email for a verification email. Once verified, enter your email to create your account or sign in.", + "sign-in.admin-no-permission": "You don't have access to admin tools. Sign into another account or ask an administrator to grant you permission.", + "sign-in.hub": "An account is required to join rooms.\n\nEnter your email to create your account or sign in.", + "sign-in.auth-started": "Email sent!\n\nTo continue, click on the link in the email using your phone, tablet, or PC.\n\nNo email? You may not be able to create an account.", + "sign-in.pin": "You'll need to sign in to pin objects.", + "sign-in.pin-complete": "You are now signed in.", + "sign-in.unpin": "You'll need to sign in to un-pin objects.", + "sign-in.unpin-complete": "You are now signed in.", + "sign-in.change-scene": "You'll need to sign in to change the scene.", + "sign-in.change-scene-complete": "You are now signed in.", + "sign-in.room-settings": "You'll need to sign in to change the room's settings.", + "sign-in.room-settings-complete": "You are now signed in.", + "sign-in.close-room": "You'll need to sign in to close the room.", + "sign-in.close-room-complete": "You are now signed in.", + "sign-in.mute-user": "You'll need to sign in to mute other users.", + "sign-in.mute-user-complete": "You are now signed in.", + "sign-in.kick-user": "You'll need to sign in to kick other users.", + "sign-in.kick-user-complete": "You are now signed in.", + "sign-in.add-owner": "You'll need to sign in to assign moderators.", + "sign-in.add-owner-complete": "You are now signed in.", + "sign-in.remove-owner": "You'll need to sign in to assign moderators.", + "sign-in.remove-owner-complete": "You are now signed in.", + "sign-in.create-avatar": "You'll need to sign in to create avatars.", + "sign-in.create-avatar-complete": "You are now signed in.", + "sign-in.favorite-room": "You'll need to sign in to add this room to your favorites.", + "sign-in.favorite-rooms": "You'll need to sign in to add favorite rooms.", + "sign-in.favorite-room-complete": "You are now signed in.", + "sign-in.favorite-rooms-complete": "You are now signed in.", + "sign-in.tweet": "You'll need to sign in to send tweets.", + "sign-in.tweet-complete": "You are now signed in.", + "sign-in.as": "Signed in as", + "sign-in.in": "Sign In", + "sign-in.out": "Sign Out", + "room-settings.apply": "Apply", + "room-settings.name-subtitle": "Room Name", + "room-settings.description-subtitle": "Room Description", + "room-settings.room-access-subtitle": "Room Access", + "room-settings.permissions-subtitle": "Room Member Permissions", + "room-settings.room-size-subtitle": "Room Size", + "room-settings.spawn_and_move_media": "Create and move objects", + "room-settings.spawn_camera": "Create cameras", + "room-settings.spawn_drawing": "Create drawings", + "room-settings.pin_objects": "Pin objects", + "room-settings.spawn_emoji": "Create emoji", + "room-settings.fly": "Allow flying", + "room-settings.access-private": "Private", + "room-settings.access-private-subtitle": "Only those with the link can join", + "room-settings.access-public": "Public", + "room-settings.access-public-subtitle": "Listed on the homepage", + "room-info.title": "Room & Scene Info", + "room-info.scene-info": "Scene Info", + "close-room.message": "Closing this room will remove yourself and others from the room, shutting it down permanently.\n\nAre you sure? This action cannot be undone.", + "close-room.confirm": "Yes, Close Room", + "close-room.cancel": "Cancel", + "promote.message": "Promoting a user will grant full access to room settings and moderation tools.\n\nAre you sure?", + "promote.confirm-prefix": "Yes, Promote ", + "promote.cancel": "Cancel", + "entry.room": "lobby", + "entry.enter-room-title": "Lobby", + "entry.enter-room": "Enter Room", + "entry.leave-room": "Leave Room", + "entry.change-scene": "Choose Scene", + "entry.in-lobby-notice": "You are viewing this room from the lobby.", + "entry.screen-prefix": "Enter on ", + "entry.desktop-screen": "Screen", + "entry.mobile-screen": "Phone", + "entry.mobile-safari": "Safari", + "entry.generic-prefix": " ", + "entry.generic-medium": "Connected VR Headset", + "entry.generic-subtitle-desktop": "Oculus or SteamVR", + "entry.gearvr-prefix": "Enter on ", + "entry.gearvr-medium": "Gear VR", + "entry.choose-device": "Choose Device", + "entry.device-medium": "Enter on Standalone VR", + "entry.device-subtitle-desktop": "Wireless VR Headsets", + "entry.device-subtitle-mobile": "Wireless VR Headsets", + "entry.device-subtitle-vr": "Phone or PC", + "entry.entry-disallowed": "Watch from Lobby", + "entry.entry-disallowed-subtitle": "Room is Full", + "entry.watch-from-lobby": "Watch from Lobby", + "entry.watch-from-lobby-subtitle": "Others will not be able to see or hear you", + "entry.cardboard": "Enter on Google Cardboard", + "entry.checkingForDeviceAvailability": "Checking for device availability...", + "entry.daydream-prefix": "Enter on ", + "entry.daydream-medium": "Daydream", + "entry.daydream-via-chrome": "Using Google Chrome", + "entry.invite-others": "invite others", + "entry.share-button": "Share", + "entry.desktop.invite-tip": "Nobody is here yet. Share this room to get together.", + "entry.mobile.invite-tip": "Share to get together.", + "entry.return-to-vr": "Return to VR", + "entry.open-in-window": "Open in Tab", + "entry.lobby": "Lobby", + "entry.back": "Back", + "entry.notify_me": "Notify me when others arrive", + "entry.mute-on-entry": "Mute my microphone", + "profile.save": "Accept", + "profile.display_name.validation_warning": "Alphanumerics and hyphens. At least 3 characters, no more than 32", + "profile.header": "Name & Avatar", + "profile.terms_of_use": "Terms of Use", + "profile.privacy_notice": "Privacy Notice", + "profile.choose_avatar": "Browse Avatars", + "profile.tabs.legacy": "Default", + "profile.tabs.skinnable": "Custom Skin", + "profile.tabs.url": "Custom Model", + "avatar-editor.info": "Find more custom avatar resources", + "avatar-editor.info-link": "here", + "avatar-editor.external-editor-info": "Create a custom skin for this avatar: ", + "avatar-preview.loading-failed": "Loading failed\nPlease choose another avatar", + "media-browser.search-placeholder.scenes": "Search Scenes...", + "media-browser.search-placeholder.avatars": "Search Avatars...", + "media-browser.search-placeholder.videos": "Search for Videos...", + "media-browser.search-placeholder.images": "Search for Images...", + "media-browser.search-placeholder.youtube": "Search for Youtube videos...", + "media-browser.search-placeholder.gifs": "Search for GIFs...", + "media-browser.search-placeholder.twitch": "Search for Twitch streams...", + "media-browser.search-placeholder.sketchfab": "Search Sketchfab Models...", + "media-browser.search-placeholder.poly": "Search Google Poly Models...", + "media-browser.search-placeholder.base": "Search...", + "media-browser.favorites-header": "Favorite Rooms", + "media-browser.add_custom_object": "Custom URL or File", + "media-browser.add_custom_scene": "Custom Scene", + "media-browser.add_custom_avatar": "Avatar GLB URL", + "media-browser.privacy_policy": "Privacy Policy", + "media-browser.report_issue": "Report Issue", + "media-browser.powered_by.images": "Search by Bing | ", + "media-browser.powered_by.videos": "Search by Bing | ", + "media-browser.powered_by.youtube": "Search by Google | ", + "media-browser.powered_by.gifs": "Search by Tenor | ", + "media-browser.powered_by.sketchfab": "Search by Sketchfab | ", + "media-browser.powered_by.poly": "Search by Google | ", + "media-browser.powered_by.twitch": "Search by Twitch | ", + "media-browser.powered_by.scenes": "Made with ", + "media-browser.powered_by.avatars": " ", + "media-browser.empty.images": "No results. Try entering a new search above.", + "media-browser.empty.videos": "No results. Try entering a new search above.", + "media-browser.empty.youtube": "No results. Try entering a new search above.", + "media-browser.empty.gifs": "No result. Try entering a new search above.", + "media-browser.empty.sketchfab": "No results. Try entering a new search above.", + "media-browser.empty.poly": "No results. Try entering a new search above.", + "media-browser.empty.twitch": "No results. Try entering a new search above.", + "media-browser.empty.favorites": "You don't have any favorites. Click a ⭐ to add to your favorites.", + "media-browser.nav_title.youtube": "YouTube", + "media-browser.nav_title.videos": "Videos", + "media-browser.nav_title.images": "Images", + "media-browser.nav_title.gifs": "GIFs", + "media-browser.nav_title.scenes": "Scenes", + "media-browser.nav_title.avatars": "Avatars", + "media-browser.nav_title.sketchfab": "Sketchfab", + "media-browser.nav_title.poly": "Google Poly", + "media-browser.nav_title.twitch": "Twitch", + "media-browser.create-avatar": "Create Avatar", + "media-browser.create-scene": "Create Scene with %editor-name%", + "media-browser.hub.joined-prefix": "Joined ", + "media-browser.hub.joined-prefix": "Visited ", + "media-browser.similar-to-facet": "Similar to: \"{name}\"", + "audio.title": "Audio Setup", + "audio.talk_to_test": "talk", + "audio.click_to_test": "click", + "audio.subtitle-desktop": "Confirm HMD speaker output", + "audio.subtitle-mobile": "Earphones are recommended", + "audio.enter-now": "Enter Now", + "audio.hmd-mic-warning": "Your HMD mic is not chosen", + "audio.grant-title": "Grant mic permissions", + "audio.grant-subtitle": "Mic access needed to be heard by others", + "audio.granted-title": "Mic permissions granted", + "audio.granted-subtitle": "You can still mute yourself in-game", + "audio.granted-next": "Next", + "exit.subtitle.exited": "Your session has ended. Refresh your browser to start a new one.", + "exit.subtitle.closed": "This room is no longer available.", + "exit.subtitle.denied": "You are not permitted to join this room. Please request permission from the room creator.", + "exit.subtitle.disconnected": "You have disconnected from the room. Refresh the page to try to reconnect.", + "exit.subtitle.left": "You have left the room.", + "exit.subtitle.full": "This room is full, please try again later.", + "exit.subtitle.scene_error": "The scene failed to load.", + "exit.subtitle.connect_error": "Unable to connect to this room, please try again later.", + "exit.subtitle.version_mismatch": "The version you deployed is not available yet. Your browser will refresh in 5 seconds.", + "autoexit.title": "Auto-ending session in ", + "autoexit.title_units": " seconds", + "autoexit.concurrent_subtitle": "You have started another session.", + "autoexit.idle_subtitle": "You have been idle for too long.", + "autoexit.cancel": "CANCEL", + "presence.entered_room": "entered the room.", + "presence.entered_lobby": "entered the lobby.", + "presence.join_lobby": "joined the lobby.", + "presence.join_room": "joined the room.", + "presence.leave": "left.", + "presence.name_change": "is now known as", + "presence.scene_change": "changed the scene to", + "presence.hub_name_change": "changed the name of the room to", + "presence.in_lobby": "Lobby", + "presence.entering": "Entering Room", + "presence.in_room": "In Room", + "home.create_a_room": "Create a Room", + "home.desktop.add_pwa": "Install Desktop App", + "home.mobile.add_pwa": "Add to Home Screen", + "home.take_a_tour": "Take a Tour", + "home.room_create_options": "options", + "home.room_create_button": "Create Room", + "home.create_name.validation_warning": "Invalid name, limited to 4 to 64 characters and limited symbols.", + "home.powered_by_prefix": "Powered by", + "home.powered_by_link": "Hubs Cloud", + "home.join_us": "Join the Conversation", + "home.subscribe_to_mailing_list": "Subscribe for Updates", + "home.have_code": "Have a room code?", + "home.add_to_discord_1": "Add the", + "home.add_to_discord_2": "%app-name% Bot", + "home.add_to_discord_3": "to Discord", + "home.create_with_spoke": "Create a Scene", + "home.report_issue": "Report Issues", + "home.source_link": "Source", + "home.whats_new_link": "What's New", + "home.about_link": "About", + "home.community_link": "Community", + "home.docs_link": "Docs", + "home.cloud_link": "Hubs Cloud", + "home.admin": "Admin", + "home.privacy_notice": "Privacy Notice", + "home.terms_of_use": "Terms of Use", + "home.made_with_love": "made with 🦆 by ", + "home.environment_author_by": " by ", + "dialog.close": "close", + "link.link_page_header_entry": "Enter your code:", + "link.link_page_header_headset": "Enter code:", + "link.linking_a_headset": "Have a letter code?", + "link.create_a_room": "Create a new room", + "link.try_again": "We couldn't find that code.\nPlease try again.", + "help.report_issue": "Report an Issue", + "scene.create_button": "Create a room with this scene", + "scene.tweet_button": "Share on Twitter", + "scene.unavailable": "This scene is no longer available.", + "scene.remix_button": "Remix in %editor-name%", + "scene.edit_button": "Edit in %editor-name%", + "link.in_your_browser": "In your device's web browser, go to:", + "link.enter_code": "Then, enter this one-time letter code:", + "link.do_not_close": "Your account and avatar will be transferred to the device.\nKeep this page open to use this code.", + "link.connect_headset": "Enter on Device", + "link.cancel": "cancel", + "invite.enter_via": "Or use expiring ", + "invite.enter_via_modal": "Others may enter via ", + "invite.tweet": "tweet", + "invite.and_enter_code": " code:", + "invite.duration_of_code" : "new code every 72 hours", + "invite.or_visit": "Share room link:", + "invite.or_visit_modal": "or by visiting permalink", + "invite.embed": "or embed on a page", + "invite.embed-tip": "Please be mindful of where you embed a %app-name% room.\nGo to Room Settings to lock down permissions for this room.", + "commands.fly": "Toggle fly mode.", + "commands.grow": "Increase your avatar's size.", + "commands.shrink": "Decrease your avatar's size.", + "commands.scene": "Change the scene.", + "commands.rename": "Rename the room.", + "commands.help": "Show help.", + "commands.leave": "Disconnect from the room.", + "commands.duck": "The duck tested well. Quack.", + "commands.capture": "Capture a 15 second video.", + "commands.debug": "Toggle physics debug rendering.", + "commands.vrstats": "Toggle stats in VR.", + "commands.audiomode": "(experimental) Toggle positional audio.", + "preferences.muteMicOnEntry": "Mute microphone on entry", + "preferences.enableOnScreenJoystickLeft": "Enable left on-screen joystick for moving around", + "preferences.enableOnScreenJoystickRight": "Enable right on-screen joystick for looking around", + "preferences.onlyShowNametagsInFreeze": "Only show nametags while frozen", + "preferences.maxResolution": "Max Resolution (width x height in pixels)", + "preferences.globalVoiceVolume": "Incoming Voice Volume", + "preferences.globalMediaVolume": "Media Volume", + "preferences.disableSoundEffects": "Disable Sound Effects", + "preferences.materialQualitySetting": "Material quality (requires restart)", + "preferences.snapRotationDegrees": "Rotation per snap (in degrees)", + "preferences.allowMultipleHubsInstances": "Disable auto-exit when multiple hubs instances are open", + "preferences.disableIdleDetection": "Disable auto-exit when idle or backgrounded", + "preferences.preferMobileObjectInfoPanel": "Prefer Mobile Object Info Panel", + "preferences.baseMovementSpeed": "Base movement speed", + "preferences.disableMovement": "Disable movement", + "preferences.disableBackwardsMovement": "Disable backwards movement", + "preferences.disableStrafing": "Disable strafing", + "preferences.disableTeleporter": "Disable teleporter", + "preferences.movementSpeedModifier": "Movement speed modifier", + "preferences.disableAutoPixelRatio": "Disable automatic pixel ratio adjustments", + "preferences.disableEchoCancellation": "Disable microphone echo cancellation (requires restart)", + "preferences.disableNoiseSuppression": "Disable microphone noise supression (requires restart)", + "preferences.disableAutoGainControl": "Disable microphone automatic gain control (requires restart)", + "settings.return-to-vr": "Return to VR", + "settings.change-avatar": "Set Name & Avatar", + "settings.change-scene": "Choose a Scene", + "settings.favorites": "Favorite Rooms", + "settings.preferences": "Preferences", + "settings.room-settings": "Room Settings", + "settings.close-room": "Close Room...", + "settings.room-info": "Room & Scene Info", + "settings.create-room": "New Room", + "settings.enable-streamer-mode": "Camera Mode", + "settings.disable-streamer-mode": "Exit Camera Mode", + "settings.send-feedback": "Send Feedback", + "settings.whats-new": "What's New", + "settings.controls": "Controls", + "settings.community": "Join Discord", + "settings.tips": "Start Tour", + "settings.report": "Report Issue", + "settings.terms": "Terms of Use", + "settings.privacy": "Privacy Notice", + "settings.help": "Help", + "settings.row-profile": "You", + "settings.row-room": "Room", + "settings.row-tools": "Tools", + "tips.mobile.video_share_mode": "Tap to stop sharing video.", + "tips.mobile.pen_mode": "Tap icon again to end drawing.", + "tips.mobile.mute_mode": "You are muted. Tap to unmute.", + "tips.mobile.freeze_mode": "Two-finger tap to hide menus.", + "tips.mobile.look": "Welcome! 👋 Tap and drag to look around.", + "tips.mobile.locomotion": "Great! To move, pinch with two fingers.", + "tips.mobile.spawn_menu-pre": "Use the ", + "tips.mobile.spawn_menu-post": "at the top to create objects.", + "tips.mobile.freeze_gesture": "Two-finger tap to show menus.", + "tips.mobile.object_grab": "Drag an object to move or throw it.", + "tips.mobile.object_rotate_button-pre": "Tap and drag ", + "tips.mobile.object_rotate_button-post": " to rotate.", + "tips.mobile.object_scale_button-pre": "Tap and drag ", + "tips.mobile.object_scale_button-post": " to scale.", + "tips.mobile.object_recenter_button-pre": "Tap ", + "tips.mobile.object_recenter_button-post": " to have the object face you.", + "tips.mobile.object_pin": "Pin an object to save it to the room.", + "tips.mobile.video_share_failed": "No permission granted.", + "tips.mobile.invite": "Use the Share button to share this room.", + "tips.mobile.feedback": "🦆 Quack! Want to help Hubs?", + "tips.mobile.feedback-link": "Tell the Duck.", + "tips.desktop.video_share_mode": "You're streaming. Select again to stop sharing video.", + "tips.desktop.pen_mode": "Press ESC or right click to drop pen. Ctrl-Z to undo.", + "tips.desktop.mute_mode": "You are muted. Click or press M to un-mute.", + "tips.desktop.look": "Welcome to %app-name%! Let's take a quick tour. 👋 Click and drag to look around.", + "tips.desktop.locomotion": "Use the W A S D keys to move. Hold shift to boost.", + "tips.desktop.turning": "Perfect. Use the Q and E keys to rotate.", + "tips.desktop.spawn_menu-pre": "To create objects, click the ", + "tips.desktop.spawn_menu-post": "button at the top of the screen, or press Ctrl 1 through 7.", + "tips.desktop.freeze_gesture": "Press and hold spacebar to show object menus.", + "tips.desktop.menu_hover": "Now, point the cursor at an object to show the menu.", + "tips.desktop.object_grab": "Click and drag an object to move it. Or, flick to throw it!", + "tips.desktop.object_pin": "Pin an object to save it to the room.", + "tips.desktop.object_zoom": "Scroll to move this object towards and away from you.", + "tips.desktop.object_rotate_button-pre": "Click and drag ", + "tips.desktop.object_rotate_button-post": " to rotate the object.", + "tips.desktop.object_scale_button-pre": "Click and drag ", + "tips.desktop.object_scale_button-post": " to scale the object.", + "tips.desktop.object_recenter_button-pre": "Click ", + "tips.desktop.object_recenter_button-post": " to rotate the object to face you. Helpful if you drop it!", + "tips.desktop.object_scale": "Hold shift and scroll to scale it bigger and smaller.", + "tips.desktop.pen_color": "Use Shift-Q and Shift-E to change the pen color.", + "tips.desktop.pen_size": "Hold Shift and scroll to change the pen size.", + "tips.desktop.invite": "Nobody else is here. Use the Share button at the top to share this room.", + "tips.desktop.video_share_failed": "You need to grant permissions to stream video.", + "tips.streaming": "Now broadcasting to the lobby. Click to exit.", + "tips.desktop.watching": "You're in the lobby. Others cannot see or hear you.", + "tips.desktop.feedback": "🦆 Quack! Want to help us make Hubs better?", + "tips.desktop.feedback-link": "Talk to the Feedback Duck.", + "tips.watching.back": "Enter Room", + "tips.mobile.watching": "You're in the lobby.", + "tips.watching-exit": "Back", + "lobby.watching": "Watching ", + "loader.entering_lobby": "Entering lobby...", + "loader.loading": "Loading ", + "loader.connecting": "Connecting...", + "loader.connected": "Connected.", + "loader.object": "object", + "loader.objects": "objects", + "change-scene-dialog.change-scene": "Change Scene", + "change-scene-dialog.create-in-spoke": "Or, create a new scene using %editor-name%.", + "change-scene-dialog.new-spoke-project": "Launch %editor-name%", + "invalid-scene-url": "This URL does not point to a scene or valid GLB.", + "avatar-url-dialog.apply": "Apply", + "interstitial.prompt": "Continue", + "feedback.prompt": "🦆 Feedback ", + "help.prompt": "Help", + "hide-ui.prompt": "Hide All", + "discord.primary_tagline": "Share a virtual room with your community.\nWatch videos, play with 3D objects, or just hang out.", + "discord.secondary_tagline": "No downloads or sign up. Full VR support too.", + "discord.contact_us": "Invite Bot to Server", + "discord.community_link": "Hubs Discord", + "discord.splash_tag": "Designed for serious businessing.", + "client-info.kick-button": "Kick", + "client-info.hide-button": "Hide", + "client-info.unhide-button": "Un-hide ", + "client-info.mute-button": "Mute", + "client-info.cancel": "Cancel", + "client-info.add-owner": "Promote", + "client-info.remove-owner": "Demote", + "object-info.no-media": "There is no media in the room yet", + "object-info.remove-button": "Remove", + "object-info.open-link": "Open Link", + "object-info.waypoint": "Go To", + "object-info.pin-button": "Pin", + "object-info.raise-lights": "Show Background", + "object-info.lower-lights": "Hide Background", + "object-info.unpin-button": "Unpin", + "avatar-landing.select": "Select", + "avatar-landing.selected": "This is your current avatar", + "leave-room-dialog.join-room.message": "Joining a new room will leave this one. Are you sure?", + "leave-room-dialog.join-room.confirm": "Join Room", + "leave-room-dialog.create-room.message": "Creating a new room will leave this one. Are you sure?", + "leave-room-dialog.create-room.confirm": "Leave Room", + "embed.load-button": "Load Room", + "embed.presence-warning": "This room is embedded, so may be visible to visitors on other websites.", + "tweet-dialog.tweet": "Tweet", + "tweet-dialog.posted": "Your tweet has been posted.", + "tweet-dialog.close": "close", + "oauth-dialog.sign-in.twitter": "Connect to Twitter", + "oauth-dialog.sign-in.discord": "Sign in to Discord", + "oauth-dialog.message.twitter": "Connect to Twitter to send tweets from %app-name%.", + "cloud.primary_tagline": "Hubs Cloud deploys server infrastructure for private,\ncollaborative 3D rooms that can be accessed on your\ndesktop, mobile phone, or VR headset.", + "cloud.secondary_tagline": "Get it today on the AWS Marketplace.", + "cloud.call_to_action_personal": "Get Hubs Cloud Personal", + "cloud.call_to_action_enterprise": "Get Hubs Cloud Enterprise", + "cloud.aws_quick_start": "Quick Start Guide", + "cloud.splash_tag": "Configurable infrastructure for your own Hubs stack" + } +} diff --git a/src/components/block-button.js b/src/components/block-button.js index 5d70870187..6a9b8b0ddb 100644 --- a/src/components/block-button.js +++ b/src/components/block-button.js @@ -5,20 +5,44 @@ */ AFRAME.registerComponent("block-button", { init() { + this.textEl = this.el.querySelector("[text]"); this.onClick = () => { this.block(this.owner); + this.el.emit("immers-block", { clientId: this.owner }); + }; + this.onScopeChange = () => { + if ( + this.playerEl?.getAttribute("player-info").immersId && + this.el.sceneEl.states.includes("immers-scope-addBlocks") + ) { + this.textEl.setAttribute("text", "value", "Block"); + } else { + this.textEl.setAttribute("text", "value", "Hide"); + } }; NAF.utils.getNetworkedEntity(this.el).then(networkedEl => { + this.playerEl = networkedEl; this.owner = networkedEl.components.networked.data.owner; + this.playerEl.addEventListener("immers-id-changed", this.onScopeChange); }); + this.onScopeChange(); }, play() { this.el.object3D.addEventListener("interact", this.onClick); + this.el.sceneEl.addEventListener("stateadded", this.onScopeChange); + this.el.sceneEl.addEventListener("stateremoved", this.onScopeChange); + if (this.playerEl) { + this.playerEl.addEventListener("immers-id-changed", this.onScopeChange); + } }, pause() { this.el.object3D.removeEventListener("interact", this.onClick); + this.el.sceneEl.removeEventListener("stateremoved", this.onScopeChange); + if (this.playerEl) { + this.playerEl.removeEventListener("immers-id-changed", this.onScopeChange); + } }, block(clientId) { diff --git a/src/components/immers/README.md b/src/components/immers/README.md new file mode 100644 index 0000000000..6aaa6da752 --- /dev/null +++ b/src/components/immers/README.md @@ -0,0 +1,38 @@ +# Immers Space Hubs components + +## spoke-tagger +System allows you to encode interactivity when editing scenes in Spoke. +Add names to entities in spoke that begin with `st-` followed by a component name +and `spoke-tagger` will add that component to the entity in Hubs. +Separate multiple components with a space, prefixing each with `st-`. +Any terms in the name not starting with `st-` will be ignored. + +E.g. name an object `bonus-content st-monetization-visible st-monetization-networked` +and `spoke-tagger` will add `monetization-visible` and `monetization-networked` +to the entity, making it visible for everyone in the room when at least one immerser +is monetized. + +## monetization-interactable + +Add this to an entity that would normally be interactable (e.g. a link) to make it +only interactable for users that are monetized + +## monetization-visible + +Only appear for users that are monetized + +## monetization-invisible + +Only appear for uses that are not monetized + +## monetization-required + +Add to an object to turn it into a monetization explainer. +It adds a hover menu that shows either "payment required" button +with a link to info about how to sign up +or a "thanks for paying!" button that does nothing. + +## monetization-networked + +Add this to an entity to make any of the above monetization features apply +for everyone in the room if at least one immerser is monetized. \ No newline at end of file diff --git a/src/components/immers/immers-follow-button.js b/src/components/immers/immers-follow-button.js new file mode 100644 index 0000000000..5f533bed57 --- /dev/null +++ b/src/components/immers/immers-follow-button.js @@ -0,0 +1,91 @@ +/** + * Registers a click handler publishes a follow request + * @namespace immers + * @component immers-follow-button + */ +AFRAME.registerComponent("immers-follow-button", { + schema: { relation: { type: "string", default: "none", oneOf: ["none", "request", "friend", "pending"] } }, + init() { + this.showIfLoggedIn = () => { + this.el.object3D.visible = !!this.playerEl?.getAttribute("player-info").immersId; + }; + NAF.utils.getNetworkedEntity(this.el).then(networkedEl => { + this.playerEl = networkedEl; + this.playerEl.addEventListener("stateadded", this.onState); + if (this.playerEl.is("immers-follow-friend")) { + this.el.setAttribute("immers-follow-button", { relation: "friend" }); + } else if (this.playerEl.is("immers-follow-request")) { + this.el.setAttribute("immers-follow-button", { relation: "request" }); + } + this.showIfLoggedIn(); + this.playerEl.addEventListener("immers-id-changed", this.showIfLoggedIn); + }); + this.textEl = this.el.querySelector("[text]"); + // avoid accidental double clicks + let lastClickTime = 0; + this.onClick = () => { + const now = Date.now(); + if (now - lastClickTime < 500) { + return; + } + lastClickTime = now; + switch (this.data.relation) { + case "none": + this.action("immers-follow", "pending"); + break; + case "request": + this.action("immers-follow-accept", "friend"); + break; + case "friend": + this.action("immers-follow-reject", "none"); + break; + } + }; + this.onState = event => { + const friendState = event.detail.split("immers-follow-")[1]; + if (friendState) { + this.el.setAttribute("immers-follow-button", { relation: friendState }); + } + }; + }, + + play() { + this.el.object3D.addEventListener("interact", this.onClick); + if (this.playerEl) { + this.playerEl.addEventListener("stateadded", this.onState); + this.playerEl.addEventListener("immers-id-changed", this.showIfLoggedIn); + } + }, + + update() { + let newText; + switch (this.data.relation) { + case "request": + newText = "Accept friend"; + break; + case "pending": + newText = "Request sent"; + break; + case "friend": + newText = "Unfriend"; + break; + default: + newText = "Add friend"; + } + this.textEl.setAttribute("text", "value", newText); + }, + + pause() { + this.el.object3D.removeEventListener("interact", this.onClick); + if (this.playerEl) { + this.playerEl.removeEventListener("stateadded", this.onState); + this.playerEl.removeEventListener("immers-id-changed", this.showIfLoggedIn); + } + }, + + action(eventName, newRelation) { + const targetId = this.playerEl.getAttribute("player-info").immersId; + this.el.emit(eventName, targetId); + this.el.setAttribute("immers-follow-button", { relation: newRelation }); + } +}); diff --git a/src/components/immers/immers-share-button.js b/src/components/immers/immers-share-button.js new file mode 100644 index 0000000000..8c92753565 --- /dev/null +++ b/src/components/immers/immers-share-button.js @@ -0,0 +1,39 @@ +import immersMessageDispatch from "../../utils/immers/immers-message-dispatch"; + +AFRAME.registerComponent("immers-share-button", { + schema: { type: "string", oneOf: ["public", "friends", "local"], default: "public" }, + init() { + this.textEl = this.el.querySelector("[text]"); + NAF.utils + .getNetworkedEntity(this.el) + .then(networkedEl => { + this.targetEl = networkedEl; + }) + .catch(() => { + // Non-networked, do not handle for now, and hide button. + this.el.object3D.visible = false; + }); + + this.onClick = () => { + if (this.shared) { + return; + } + const { src, contentSubtype } = this.targetEl.components["media-loader"].data; + immersMessageDispatch.dispatch({ + type: contentSubtype.split(/[-/ ]/)[0], + body: { src }, + audience: this.data + }); + this.shared = true; + this.textEl.setAttribute("text", "value", "shared"); + }; + }, + + play() { + this.el.object3D.addEventListener("interact", this.onClick); + }, + + pause() { + this.el.object3D.removeEventListener("interact", this.onClick); + } +}); diff --git a/src/components/immers/immers-visible-if-permitted.js b/src/components/immers/immers-visible-if-permitted.js new file mode 100644 index 0000000000..c337fe015b --- /dev/null +++ b/src/components/immers/immers-visible-if-permitted.js @@ -0,0 +1,19 @@ +AFRAME.registerComponent("immers-visible-if-permitted", { + schema: { + type: "string", + oneOf: ["viewFriends", "postLocation", "viewPrivate", "creative", "addFriends", "addBlocks", "destructive"] + }, + init() { + this.updateVisibility = this.updateVisibility.bind(this); + this.el.sceneEl.addEventListener("stateadded", this.updateVisibility); + this.el.sceneEl.addEventListener("stateremoved", this.updateVisibility); + this.updateVisibility(); + }, + updateVisibility() { + this.el.object3D.visible = this.el.sceneEl.states.includes(`immers-scope-${this.data}`); + }, + remove() { + this.el.sceneEl.removeEventListener("stateadded", this.updateVisibility); + this.el.sceneEl.removeEventListener("stateremoved", this.updateVisibility); + } +}); diff --git a/src/components/immers/index.js b/src/components/immers/index.js new file mode 100644 index 0000000000..271048bda4 --- /dev/null +++ b/src/components/immers/index.js @@ -0,0 +1,9 @@ +import "./immers-follow-button"; +import "./immers-share-button"; +import "./spoke-tagger"; +import "./monetization-visible"; +import "./monetization-invisible"; +import "./monetization-required"; +import "./monetization-interactable"; +import "./monetization-networked"; +import "./immers-visible-if-permitted"; diff --git a/src/components/immers/monetization-interactable.js b/src/components/immers/monetization-interactable.js new file mode 100644 index 0000000000..6cd8d38fec --- /dev/null +++ b/src/components/immers/monetization-interactable.js @@ -0,0 +1,20 @@ +import { listenForMonetization, unlistenForMonetization } from "./utils"; + +AFRAME.registerComponent("monetization-interactable", { + init() { + this.onMonetizationStart = this.onMonetizationStart.bind(this); + this.onMonetizationStop = this.onMonetizationStop.bind(this); + }, + play() { + listenForMonetization(this.el, this.onMonetizationStart, this.onMonetizationStop); + }, + pause() { + unlistenForMonetization(this.el, this.onMonetizationStart, this.onMonetizationStop); + }, + onMonetizationStart() { + this.el.classList.add("interactable"); + }, + onMonetizationStop() { + this.el.classList.remove("interactable"); + } +}); diff --git a/src/components/immers/monetization-invisible.js b/src/components/immers/monetization-invisible.js new file mode 100644 index 0000000000..470f76a437 --- /dev/null +++ b/src/components/immers/monetization-invisible.js @@ -0,0 +1,20 @@ +import { listenForMonetization, unlistenForMonetization } from "./utils"; + +AFRAME.registerComponent("monetization-invisible", { + init() { + this.onMonetizationStart = this.onMonetizationStart.bind(this); + this.onMonetizationStop = this.onMonetizationStop.bind(this); + }, + play() { + listenForMonetization(this.el, this.onMonetizationStart, this.onMonetizationStop); + }, + pause() { + unlistenForMonetization(this.el, this.onMonetizationStart, this.onMonetizationStop); + }, + onMonetizationStart() { + this.el.setAttribute("visible", false); + }, + onMonetizationStop() { + this.el.setAttribute("visible", true); + } +}); diff --git a/src/components/immers/monetization-networked.js b/src/components/immers/monetization-networked.js new file mode 100644 index 0000000000..8cfc75ced8 --- /dev/null +++ b/src/components/immers/monetization-networked.js @@ -0,0 +1,58 @@ +/* Simple use case for room monetization status. Certain Spoke entities are + * shown or hiden based on whether anyone in the room is monetized. + * + * To use without additional client customisation, add entities or groups in Spoke + * with the name "monetization-visible", and this + * component will attach to it and make it invisible unless someone in the + * room is monetized. + * + * For elements created via custom client extension, + * give them the "monetization-visible" component to enable to same behavior. + */ +const players = {}; + +AFRAME.registerSystem("monetization-networked", { + init() { + this.onMonetizationChange = this.onMonetizationChange.bind(this); + this.entities = []; + this.el.addEventListener("immers-player-monetization", this.onMonetizationChange); + }, + play() { + this.el.addEventListener("immers-player-monetization", this.onMonetizationChange); + }, + pause() { + this.el.removeEventListener("immers-player-monetization", this.onMonetizationChange); + }, + registerMe(el) { + this.entities.push(el); + }, + + unregisterMe(el) { + const index = this.entities.indexOf(el); + this.entities.splice(index, 1); + }, + onMonetizationChange(event) { + players[event.detail.immersId] = event.detail.monetized; + const numMonetized = Object.values(players).reduce((a, b) => a + b, 0); + for (const entity of this.entities) { + entity.components["monetization-networked"].shareMonetization(numMonetized); + } + } +}); + +AFRAME.registerComponent("monetization-networked", { + shareMonetization(count) { + const monetized = !!count; + if (monetized !== this.lastMonetized) { + this.el.emit(`immers-monetization-${monetized ? "started" : "stopped"}`, undefined, false); + } + this.lastMonetized = monetized; + }, + init() { + this.lastMonetized = false; + this.system.registerMe(this.el); + }, + remove() { + this.system.unregisterMe(this.el); + } +}); diff --git a/src/components/immers/monetization-required.js b/src/components/immers/monetization-required.js new file mode 100644 index 0000000000..d196297fee --- /dev/null +++ b/src/components/immers/monetization-required.js @@ -0,0 +1,64 @@ +import { handleExitTo2DInterstitial } from "../../utils/vr-interstitial"; +import { listenForMonetization, unlistenForMonetization } from "./utils"; + +// inject the hover menu template so we don't have to alter hub.html +document.addEventListener( + "DOMContentLoaded", + () => { + const t = document.createElement("template"); + t.setAttribute("id", "monetization-required-hover-menu"); + t.innerHTML = ` + + + + `; + document.querySelector("a-assets").appendChild(t); + }, + { once: true } +); + +AFRAME.registerComponent("monetization-required", { + init() { + this.el.setAttribute("hover-menu", { + template: "#monetization-required-hover-menu", + isFlat: true + }); + this.el.classList.add("interactable"); + this.el.setAttribute("body-helper", "type: static; mass: 1; collisionFilterGroup: 1; collisionFilterMask: 1;"); + this.el.setAttribute("is-remote-hover-target", true); + this.el.setAttribute("tags", "isStatic: true; togglesHoveredActionSet: true; inspectable: true;"); + } +}); + +AFRAME.registerComponent("monetization-required-button", { + init() { + this.onMonetizationStart = this.onMonetizationStart.bind(this); + this.onMonetizationStop = this.onMonetizationStop.bind(this); + this.label = this.el.querySelector("[text]"); + this.monetized = false; + this.onClick = async () => { + if (this.monetized) { + return; + } + await handleExitTo2DInterstitial(false, () => {}, true); + window.open("https://web.immers.space/monetization-required/"); + }; + }, + play() { + listenForMonetization(this.el, this.onMonetizationStart, this.onMonetizationStop); + this.el.object3D.addEventListener("interact", this.onClick); + }, + + pause() { + unlistenForMonetization(this.el, this.onMonetizationStart, this.onMonetizationStop); + this.el.object3D.removeEventListener("interact", this.onClick); + }, + onMonetizationStart() { + this.monetized = true; + this.label.setAttribute("text", "value", "thanks for paying!"); + }, + onMonetizationStop() { + this.monetized = false; + this.label.setAttribute("text", "value", "payment required"); + } +}); diff --git a/src/components/immers/monetization-visible.js b/src/components/immers/monetization-visible.js new file mode 100644 index 0000000000..594035c954 --- /dev/null +++ b/src/components/immers/monetization-visible.js @@ -0,0 +1,20 @@ +import { listenForMonetization, unlistenForMonetization } from "./utils"; + +AFRAME.registerComponent("monetization-visible", { + init() { + this.onMonetizationStart = this.onMonetizationStart.bind(this); + this.onMonetizationStop = this.onMonetizationStop.bind(this); + }, + play() { + listenForMonetization(this.el, this.onMonetizationStart, this.onMonetizationStop); + }, + pause() { + unlistenForMonetization(this.el, this.onMonetizationStart, this.onMonetizationStop); + }, + onMonetizationStart() { + this.el.setAttribute("visible", true); + }, + onMonetizationStop() { + this.el.setAttribute("visible", false); + } +}); diff --git a/src/components/immers/spoke-tagger.js b/src/components/immers/spoke-tagger.js new file mode 100644 index 0000000000..3ea932cfa5 --- /dev/null +++ b/src/components/immers/spoke-tagger.js @@ -0,0 +1,34 @@ +AFRAME.registerSystem("spoke-tagger", { + init() { + this.tag = this.tag.bind(this); + this.onMutation = this.onMutation.bind(this); + + this.mo = new MutationObserver(this.onMutation); + this.mo.observe(this.el, { subtree: true, childList: true }); + }, + + // inject components into spoke scene entities (spoke saves names as classes) + onMutation(records) { + for (const record of records) { + for (const node of record.addedNodes) { + this.tag(node); + } + } + }, + + tag(el) { + if (!(el.nodeType === document.ELEMENT_NODE) || !el.classList.length) { + return; + } + // spoke converts spaces in names to _ in class + const tags = el.classList.value.split("_"); + for (const tag of tags) { + if (!tag.startsWith("st-")) { + continue; + } + el.setAttribute(tag.substring(3), ""); + } + // recurse children of added node + el.childNodes.forEach(this.tag); + } +}); diff --git a/src/components/immers/utils.js b/src/components/immers/utils.js new file mode 100644 index 0000000000..46a831d841 --- /dev/null +++ b/src/components/immers/utils.js @@ -0,0 +1,15 @@ +export function listenForMonetization (el, onMonetizationStart, onMonetizationStop) { + el.sceneEl.addEventListener("immers-monetization-started", onMonetizationStart); + el.sceneEl.addEventListener("immers-monetization-stopped", onMonetizationStop); + // listen on self for monetization-networked events + el.addEventListener("immers-monetization-started", onMonetizationStart); + el.addEventListener("immers-monetization-stopped", onMonetizationStop); + +} + +export function unlistenForMonetization(el, onMonetizationStart, onMonetizationStop) { + el.sceneEl.removeEventListener("immers-monetization-started", onMonetizationStart); + el.sceneEl.removeEventListener("immers-monetization-stopped", onMonetizationStop); + el.removeEventListener("immers-monetization-started", onMonetizationStart); + el.removeEventListener("immers-monetization-stopped", onMonetizationStop); +} diff --git a/src/components/in-world-hud.js b/src/components/in-world-hud.js index 738bd0e82d..54a260b4e2 100644 --- a/src/components/in-world-hud.js +++ b/src/components/in-world-hud.js @@ -10,8 +10,10 @@ AFRAME.registerComponent("in-world-hud", { this.spawn = this.el.querySelector(".spawn"); this.pen = this.el.querySelector(".penhud"); this.cameraBtn = this.el.querySelector(".camera-btn"); + this.immersBtn = this.el.querySelector(".immers-btn"); this.inviteBtn = this.el.querySelector(".invite-btn"); this.background = this.el.querySelector(".bg"); + this.notificationText = this.el.querySelector("#hud-presence-notification"); this.onMicStateChanged = () => { this.mic.setAttribute("mic-button", "active", APP.dialog.isMicEnabled); @@ -22,15 +24,17 @@ AFRAME.registerComponent("in-world-hud", { this.mic.setAttribute("mic-button", "active", APP.dialog.isMicEnabled); this.pen.setAttribute("icon-button", "active", this.el.sceneEl.is("pen")); this.cameraBtn.setAttribute("icon-button", "active", this.el.sceneEl.is("camera")); + this.notificationText.setAttribute("text", "value", this.el.sceneEl.is("notification") ? "*" : ""); if (window.APP.hubChannel) { this.spawn.setAttribute("icon-button", "disabled", !window.APP.hubChannel.can("spawn_and_move_media")); this.pen.setAttribute("icon-button", "disabled", !window.APP.hubChannel.can("spawn_drawing")); this.cameraBtn.setAttribute("icon-button", "disabled", !window.APP.hubChannel.can("spawn_camera")); } + this.immersBtn.object3D.visible = !this.el.sceneEl.is("immers-connected"); }; this.onStateChange = evt => { - if (!(evt.detail === "frozen" || evt.detail === "pen" || evt.detail === "camera")) return; + if (!(evt.detail === "frozen" || evt.detail === "pen" || evt.detail === "camera" || evt.detail === 'notification')) return; this.updateButtonStates(); }; @@ -54,6 +58,10 @@ AFRAME.registerComponent("in-world-hud", { this.el.emit("action_toggle_camera"); }; + this.onImmersClick = () => { + this.el.emit("action_immers_register"); + }; + this.onInviteClick = () => { this.el.emit("action_invite"); }; @@ -75,6 +83,7 @@ AFRAME.registerComponent("in-world-hud", { this.pen.object3D.addEventListener("interact", this.onPenClick); this.cameraBtn.object3D.addEventListener("interact", this.onCameraClick); this.inviteBtn.object3D.addEventListener("interact", this.onInviteClick); + this.immersBtn.object3D.addEventListener("interact", this.onImmersClick); }, pause() { @@ -88,5 +97,6 @@ AFRAME.registerComponent("in-world-hud", { this.pen.object3D.removeEventListener("interact", this.onPenClick); this.cameraBtn.object3D.removeEventListener("interact", this.onCameraClick); this.inviteBtn.object3D.removeEventListener("interact", this.onInviteClick); + this.immersBtn.object3D.removeEventListener("interact", this.onImmersClick); } }); diff --git a/src/components/player-info.js b/src/components/player-info.js index 70753f36c9..deedba5881 100644 --- a/src/components/player-info.js +++ b/src/components/player-info.js @@ -36,6 +36,8 @@ AFRAME.registerComponent("player-info", { avatarSrc: { type: "string" }, avatarType: { type: "string", default: AVATAR_TYPES.SKINNABLE }, muted: { default: false }, + immersId: { type: "string" }, + monetized: { type: "boolean" }, isSharingAvatarCamera: { default: false } }, init() { @@ -64,6 +66,10 @@ AFRAME.registerComponent("player-info", { }, remove() { + this.el.sceneEl.emit("immers-player-monetization", { + monetized: false, + immersId: this.data.immersId + }); const avatarEl = this.el.querySelector("[avatar-audio-source]"); APP.isAudioPaused.delete(avatarEl); deregisterComponentInstance(this, "player-info"); @@ -148,6 +154,23 @@ AFRAME.registerComponent("player-info", { this.el.emit("remote_mute_updated", { muted: this.data.muted }); } this.applyProperties(); + if (oldData.immersId !== undefined && this.data.immersId !== oldData.immersId) { + this.el.emit("immers-id-changed", this.data.immersId); + this.el.sceneEl.emit("immers-player-monetization", { + monetized: false, + immersId: oldData.immersId + }); + this.el.sceneEl.emit("immers-player-monetization", { + monetized: this.data.monetized, + immersId: this.data.immersId + }); + } + if (this.data.monetized !== oldData.monetized) { + this.el.sceneEl.emit("immers-player-monetization", { + monetized: this.data.monetized, + immersId: this.data.immersId + }); + } }, can(perm) { diff --git a/src/components/troika-text.js b/src/components/troika-text.js index aa32aaba32..4c4ea4845c 100644 --- a/src/components/troika-text.js +++ b/src/components/troika-text.js @@ -95,7 +95,7 @@ AFRAME.registerComponent("text", { const mesh = this.troikaTextMesh; // Update the text mesh - mesh.text = data.value || ""; + mesh.text = (data.value || "").replace(/\\n/g, "\n").replace(/\\t/g, "\t"); mesh.textAlign = data.textAlign; mesh.anchorX = data.anchorX; mesh.anchorY = data.anchorY; diff --git a/src/hub.html b/src/hub.html index ecda3e2365..a67972d580 100644 --- a/src/hub.html +++ b/src/hub.html @@ -181,6 +181,11 @@ + + + + + @@ -317,6 +322,7 @@ - - + + + + + + + + + @@ -931,9 +954,26 @@ @@ -1046,6 +1086,24 @@ alphaTest: 0.1; src: #button" > + @@ -1150,7 +1208,9 @@ - + + + @@ -1220,6 +1280,24 @@ class="hud camera-btn" material="alphaTest:0.1;" > + diff --git a/src/hub.js b/src/hub.js index 9b481ab4ac..687f21db52 100644 --- a/src/hub.js +++ b/src/hub.js @@ -195,6 +195,7 @@ import MediaDevicesManager from "./utils/media-devices-manager"; import PinningHelper from "./utils/pinning-helper"; import { sleep } from "./utils/async-utils"; import { platformUnsupported } from "./support"; +import * as immers from "./utils/immers"; window.APP = new App(); window.APP.dialog = new DialogAdapter(); @@ -253,6 +254,8 @@ import { SignInMessages } from "./react-components/auth/SignInModal"; import { ThemeProvider } from "./react-components/styles/theme"; import { LogMessageType } from "./react-components/room/ChatSidebar"; +import "./components/immers/index"; + const PHOENIX_RELIABLE_NAF = "phx-reliable"; NAF.options.firstSyncSource = PHOENIX_RELIABLE_NAF; NAF.options.syncSource = PHOENIX_RELIABLE_NAF; @@ -835,7 +838,8 @@ document.addEventListener("DOMContentLoaded", async () => { remountUI({ performConditionalSignIn, embed: isEmbed, - showPreload: isEmbed + showPreload: isEmbed, + showSignInDialog: false }); entryManager.performConditionalSignIn = performConditionalSignIn; entryManager.init(); @@ -1408,4 +1412,6 @@ document.addEventListener("DOMContentLoaded", async () => { authChannel.setSocket(socket); linkChannel.setSocket(socket); + + immers.initialize(store, scene, remountUI, messageDispatch, createInWorldLogMessage); }); diff --git a/src/index.js b/src/index.js index 7d2c6f0d8e..1902ba18d2 100644 --- a/src/index.js +++ b/src/index.js @@ -8,6 +8,10 @@ import { HomePage } from "./react-components/home/HomePage"; import { AuthContextProvider } from "./react-components/auth/AuthContext"; import "./react-components/styles/global.scss"; import { ThemeProvider } from "./react-components/styles/theme"; +import { catchToken } from "./utils/immers/authUtils"; + +// homepage is used as redirectURI in popup OAuth flow (because using the hub uri causes duplicate session disconnect) +catchToken(); registerTelemetry("/home", "Hubs Home Page"); diff --git a/src/react-components/auth/SignInModal.js b/src/react-components/auth/SignInModal.js index 5a68e7334c..583a1b9ff5 100644 --- a/src/react-components/auth/SignInModal.js +++ b/src/react-components/auth/SignInModal.js @@ -96,14 +96,15 @@ export function SubmitEmail({ onSubmitEmail, initialEmail, privacyUrl, termsUrl, }, [setEmail] ); - + // immers: disable signin prompt + message = undefined; return (

{message ? ( intl.formatMessage(message) ) : ( - + )}

b.member_count - a.member_count); const sortedPublicRooms = Array.from(publicRooms).sort((a, b) => b.member_count - a.member_count); @@ -52,6 +57,19 @@ export function HomePage() { } }, []); + useEffect(() => { + // save in closure in case this changes between renders + const monetization = document.monetization; + const onMonetizationStart = () => setIsMonetized(true); + const onMonetizationStop = () => setIsMonetized(false); + monetization.addEventListener("monetizationstart", onMonetizationStart); + monetization.addEventListener("monetizationstop", onMonetizationStop); + return () => { + monetization.removeEventListener("monetizationstart", onMonetizationStart); + monetization.removeEventListener("monetizationstop", onMonetizationStop); + }; + }); + const canCreateRooms = !configs.feature("disable_room_creation") || auth.isAdmin; const email = auth.email; return ( @@ -100,6 +118,46 @@ export function HomePage() { + {premiumScenes.length > 0 && ( + +

+ + +

+ + {isMonetized ? ( +
Thanks for paying! You can also create rooms with these exclusive scenes:
+ ) : ( +
+ + Sign up for Web Monetization + {" "} + to unlock exclusive scenes. +
+ )} + + {premiumScenes.map(scene => { + // obfuscate the scene url as you can create a room from there + scene.url = "#"; + const onClick = isMonetized + ? () => createAndRedirectToNewHub(null, scene.id, false) + : e => e.preventDefault(); + return ( + + scaledThumbnailUrlFor(entry.images.preview.url, width, height) + } + onClick={onClick} + /> + ); + })} + +
+
+ )} {configs.feature("show_feature_panels") && ( diff --git a/src/react-components/home/HomePage.scss b/src/react-components/home/HomePage.scss index 3506f2a4d2..58d422fa95 100644 --- a/src/react-components/home/HomePage.scss +++ b/src/react-components/home/HomePage.scss @@ -160,6 +160,25 @@ font-size: 24px; margin-bottom: 16px; } + +:local(.scenes-heading) { + margin-left: 10px; + font-size: 24px; + margin-bottom: 16px; + display: flex; + align-items: center; + svg { + margin-right: 5px; + } +} + +:local(.scene-disabled) { + filter: blur(2px); + cursor: not-allowed !important; + a { + cursor: not-allowed !important; + } +} :local(.rooms) { background-color: theme.$background2-color; diff --git a/src/react-components/home/SignInButton.js b/src/react-components/home/SignInButton.js index e6d2304dcd..9560cb6c7e 100644 --- a/src/react-components/home/SignInButton.js +++ b/src/react-components/home/SignInButton.js @@ -6,8 +6,8 @@ import { Button } from "../input/Button"; export function SignInButton({ mobile }) { return ( - ); } diff --git a/src/react-components/home/SignInButton.scss b/src/react-components/home/SignInButton.scss index 748e67d36e..4d75efd10c 100644 --- a/src/react-components/home/SignInButton.scss +++ b/src/react-components/home/SignInButton.scss @@ -8,6 +8,7 @@ } :local(.mobile-sign-in) { + z-index: 5; /* prevent menu header from blocking button */ display: flex; @media(min-width: theme.$breakpoint-lg) { display: none; diff --git a/src/react-components/home/usePremiumScenes.js b/src/react-components/home/usePremiumScenes.js new file mode 100644 index 0000000000..45de5b277c --- /dev/null +++ b/src/react-components/home/usePremiumScenes.js @@ -0,0 +1,14 @@ +import { useCallback, useContext } from "react"; +import { usePaginatedAPI } from "./usePaginatedAPI"; +import { fetchReticulumAuthenticated } from "../../utils/phoenix-utils"; +import { AuthContext } from "../auth/AuthContext"; + +export function usePremiumScenes() { + const auth = useContext(AuthContext); // Re-render when you log in/out. + const getMoreScenes = useCallback( + cursor => fetchReticulumAuthenticated(`/api/v1/media/search?filter=premium&source=scene_listings&cursor=${cursor}`), + // eslint-disable-next-line react-hooks/exhaustive-deps + [auth.isSignedIn] + ); + return usePaginatedAPI(getMoreScenes); +} diff --git a/src/react-components/icons/wm-icon.svg b/src/react-components/icons/wm-icon.svg new file mode 100644 index 0000000000..6b0d436039 --- /dev/null +++ b/src/react-components/icons/wm-icon.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/react-components/input/ToolbarButton.js b/src/react-components/input/ToolbarButton.js index bceeccc671..1ebcb287d6 100644 --- a/src/react-components/input/ToolbarButton.js +++ b/src/react-components/input/ToolbarButton.js @@ -21,7 +21,20 @@ export const statusColors = ["recording", "unread", "enabled", "disabled"]; export const ToolbarButton = forwardRef( ( - { preset, className, iconContainerClassName, children, icon, label, selected, large, statusColor, type, ...rest }, + { + preset, + className, + iconContainerClassName, + children, + icon, + label, + selected, + large, + small, + statusColor, + type, + ...rest + }, ref ) => { return ( @@ -31,7 +44,7 @@ export const ToolbarButton = forwardRef( styles.toolbarButton, styles[preset], styles[type], - { [styles.selected]: selected, [styles.large]: large }, + { [styles.selected]: selected, [styles.large]: large, [styles.small]: small }, className )} {...rest} diff --git a/src/react-components/input/ToolbarButton.scss b/src/react-components/input/ToolbarButton.scss index c5de0dd301..7f385b5bb0 100644 --- a/src/react-components/input/ToolbarButton.scss +++ b/src/react-components/input/ToolbarButton.scss @@ -43,6 +43,14 @@ } } +:local(.small) { + width: 48px; +} + +:local(.small) :local(.icon-container) { + flex-shrink: 0; +} + :local(.large) :local(.icon-container) { width: 96px; height: 96px; diff --git a/src/react-components/layout/Header.scss b/src/react-components/layout/Header.scss index ecbaac12ee..82901e93ce 100644 --- a/src/react-components/layout/Header.scss +++ b/src/react-components/layout/Header.scss @@ -94,7 +94,7 @@ header { a { margin-left: 8px; - color: theme.$link-color; + // color: theme.$link-color; } @media(min-width: theme.$breakpoint-lg) { diff --git a/src/react-components/layout/List.scss b/src/react-components/layout/List.scss index 815754cb9a..e25617c0e0 100644 --- a/src/react-components/layout/List.scss +++ b/src/react-components/layout/List.scss @@ -24,11 +24,11 @@ border: none; width: 100%; - &:hover { + &:hover:not([disabled]) { background-color: theme.$list-bg-color-hover; } - &:active { + &:active:not([disabled]) { background-color: theme.$list-bg-color-pressed; } @@ -36,6 +36,10 @@ box-shadow: inset 0 0 0 3px theme.$outline-color; } + &[disabled] { + cursor: default; + } + &:local(.selected) { color: theme.$active-text-color; background-color: theme.$active-color; diff --git a/src/react-components/media-browser.js b/src/react-components/media-browser.js index 306dfde538..58e53c3290 100644 --- a/src/react-components/media-browser.js +++ b/src/react-components/media-browser.js @@ -485,14 +485,16 @@ class MediaBrowserContainer extends Component { entries.length > 0 || !showEmptyStringOnNoResult ? ( <> - {urlSource === "avatars" && ( - } - /> - )} + {this.props.hubChannel.signedIn && + urlSource === "avatars" && ( + } + /> + )} {urlSource === "scenes" && + this.props.hubChannel.signedIn && configs.feature("enable_spoke") && ( this.handleCopyScene(e, entry); } + if (!this.props.hubChannel.signedIn) { + onCopy = null; + } + return ( + + Changes will only apply in this Immer. Need permission to update profile or save new avatars + ); } diff --git a/src/react-components/room/ChatSidebar.js b/src/react-components/room/ChatSidebar.js index 0981bbf38d..848bfaed84 100644 --- a/src/react-components/room/ChatSidebar.js +++ b/src/react-components/room/ChatSidebar.js @@ -357,7 +357,7 @@ MessageBubble.propTypes = { children: PropTypes.node }; -function getMessageComponent(message) { +export function getMessageComponent(message) { switch (message.type) { case "chat": { const { formattedBody, monospace, emoji } = formatMessageBody(message.body); @@ -413,10 +413,10 @@ ChatMessageList.propTypes = { children: PropTypes.node }; -export function ChatSidebar({ onClose, children, ...rest }) { +export function ChatSidebar({ onClose, title, children, ...rest }) { return ( } + title={title || } beforeTitle={} contentClassName={styles.content} disableOverflowScroll @@ -429,6 +429,7 @@ export function ChatSidebar({ onClose, children, ...rest }) { ChatSidebar.propTypes = { onClose: PropTypes.func, + title: PropTypes.any, onScrollList: PropTypes.func, children: PropTypes.node, listRef: PropTypes.func @@ -440,7 +441,9 @@ export function ChatToolbarButton(props) { {...props} icon={} preset="accent4" - label={} + small + title="Chat with room occupants that is not saved" + label={} /> ); } diff --git a/src/react-components/room/ChatSidebarContainer.js b/src/react-components/room/ChatSidebarContainer.js index 3959c31c63..8e7bb189ee 100644 --- a/src/react-components/room/ChatSidebarContainer.js +++ b/src/react-components/room/ChatSidebarContainer.js @@ -93,6 +93,7 @@ function updateMessageGroups(messageGroups, newMessage) { case "image": case "photo": case "video": + case "activity": return processChatMessage(messageGroups, newMessage); default: return messageGroups; diff --git a/src/react-components/room/ContentMenu.js b/src/react-components/room/ContentMenu.js index f661cc783f..d00623fb2a 100644 --- a/src/react-components/room/ContentMenu.js +++ b/src/react-components/room/ContentMenu.js @@ -7,17 +7,19 @@ import { ReactComponent as ObjectsIcon } from "../icons/Objects.svg"; import { ReactComponent as PeopleIcon } from "../icons/People.svg"; import { FormattedMessage } from "react-intl"; -export function ContentMenuButton({ active, children, ...props }) { +export function ContentMenuButton({ active, children, notification, ...props }) { return ( ); } ContentMenuButton.propTypes = { children: PropTypes.node, - active: PropTypes.bool + active: PropTypes.bool, + notification: PropTypes.bool }; export function ObjectsMenuButton(props) { diff --git a/src/react-components/room/ContentMenu.scss b/src/react-components/room/ContentMenu.scss index a217ecd488..5345252d5b 100644 --- a/src/react-components/room/ContentMenu.scss +++ b/src/react-components/room/ContentMenu.scss @@ -83,4 +83,11 @@ width: 1px; margin: 0 8px; background-color: theme.$border1-color; -} \ No newline at end of file +} + +/* TODO: change to theme color */ +:local(.notifier) { + color: theme.$status-unread-color; + font-size: 1.5em; + margin-left: -8px; +} diff --git a/src/react-components/room/ImmersFeedSidebarContainer.js b/src/react-components/room/ImmersFeedSidebarContainer.js new file mode 100644 index 0000000000..baa8b75a56 --- /dev/null +++ b/src/react-components/room/ImmersFeedSidebarContainer.js @@ -0,0 +1,275 @@ +import React, { createContext, useCallback, useContext, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { ChatSidebar, ChatMessageList, ChatInput, SendMessageButton } from "./ChatSidebar"; +import { useMaintainScrollPosition } from "../misc/useMaintainScrollPosition"; +import { FormattedMessage, useIntl } from "react-intl"; +import { ImmersChatMessage, ImmersIcon, ImmersMoreHistoryButton, ImmersPermissionUpgradeButton } from "./ImmersReact"; +import { ToolbarButton } from "../input/ToolbarButton"; +import { ReactComponent as PublicIcon } from "../icons/Scene.svg"; +import { ReactComponent as FriendsIcon } from "../icons/People.svg"; +import { ReactComponent as LocalIcon } from "../icons/Home.svg"; +import { IconButton } from "../input/IconButton"; +import styles from "./ChatSidebar.scss"; + +export const ImmersFeedContext = createContext({ messageGroups: [], sendMessage: () => {}, permissions: [] }); + +let uniqueMessageId = 0; + +function processChatMessage(messageGroups, newMessage) { + const { name, sent, sessionId, ...messageProps } = newMessage; + + if (messageProps.isImmersFeed) { + // insert according to timestamp + const newMessageGroups = messageGroups.slice(); + const i = newMessageGroups.findIndex(group => messageProps.timestamp < group.timestamp); + newMessageGroups.splice(i === -1 ? newMessageGroups.length : i, 0, { + id: uniqueMessageId++, + isImmersFeed: messageProps.isImmersFeed, + isFriend: messageProps.isFriend, + timestamp: messageProps.timestamp, + sent: sent, + sender: name, + icon: messageProps.icon, + senderSessionId: sessionId, + context: messageProps.context, + immer: messageProps.immer, + messages: [{ id: uniqueMessageId++, ...messageProps }] + }); + return newMessageGroups; + } +} + +// Returns the new message groups array when we receive a message. +// If the message is ignored, we return the original message group array. +function updateMessageGroups(messageGroups, newMessage) { + switch (newMessage.type) { + case "chat": + case "image": + case "photo": + case "video": + case "activity": + return processChatMessage(messageGroups, newMessage); + default: + return messageGroups; + } +} + +export function ImmersFeedContextProvider({ messageDispatch, children, permissions = [], reAuthorize }) { + const [messageGroups, setMessageGroups] = useState([]); + const [unreadMessages, setUnreadMessages] = useState(false); + const [audience, setAudience] = useState("public"); + + useEffect( + () => { + function onReceiveMessage(event) { + const newMessage = event.detail; + if (!newMessage.isImmersFeed) { + return; + } + setMessageGroups(messages => updateMessageGroups(messages, newMessage)); + if ( + newMessage.type === "chat" || + newMessage.type === "image" || + newMessage.type === "photo" || + newMessage.type === "video" + ) { + setUnreadMessages(true); + } + } + + if (messageDispatch) { + messageDispatch.addEventListener("message", onReceiveMessage); + } + + return () => { + if (messageDispatch) { + messageDispatch.removeEventListener("message", onReceiveMessage); + } + }; + }, + [messageDispatch, setMessageGroups, setUnreadMessages] + ); + + const sendMessage = useCallback( + body => { + if (messageDispatch) { + messageDispatch.dispatch({ type: "chat", body, audience }); + } + }, + [messageDispatch, audience] + ); + + const setMessagesRead = useCallback( + () => { + setUnreadMessages(false); + }, + [setUnreadMessages] + ); + + return ( + + {children} + + ); +} + +ImmersFeedContextProvider.propTypes = { + children: PropTypes.node, + messageDispatch: PropTypes.object, + permissions: PropTypes.array, + reAuthorize: PropTypes.func +}; + +export function ImmersFeedSidebarContainer({ onClose }) { + const { messageGroups, sendMessage, setMessagesRead, audience, setAudience, permissions } = useContext( + ImmersFeedContext + ); + const [onScrollList, listRef, scrolledToBottom] = useMaintainScrollPosition(messageGroups); + const [message, setMessage] = useState(""); + const intl = useIntl(); + + const onKeyDown = useCallback( + e => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + sendMessage(e.target.value); + setMessage(""); + } + }, + [sendMessage, setMessage] + ); + + const onSendMessage = useCallback( + () => { + sendMessage(message); + setMessage(""); + }, + [message, sendMessage, setMessage] + ); + + useEffect( + () => { + if (scrolledToBottom) { + setMessagesRead(); + } + }, + [messageGroups, scrolledToBottom, setMessagesRead] + ); + + let placeholder; + switch (audience) { + case "local": + placeholder = intl.formatMessage({ + id: "immersfeed-sidebar-container.input-placeholder.local", + defaultMessage: "Post to room" + }); + break; + case "friends": + placeholder = intl.formatMessage({ + id: "immersfeed-sidebar-container.input-placeholder.friends", + defaultMessage: "Post to room and friends" + }); + break; + case "public": + placeholder = intl.formatMessage({ + id: "immersfeed-sidebar-container.input-placeholder.public", + defaultMessage: "Post publicly" + }); + break; + } + const audiences = ["local", "friends", "public"]; + const audienceButton = ( + setAudience(audiences[(audiences.indexOf(audience) + 1) % 3])} + title="Change audience" + > + {audience === "public" && } + {audience === "friends" && } + {audience === "local" && } + + ); + const canPost = permissions.includes("creative"); + if (!canPost) { + placeholder = intl.formatMessage({ + id: "immersfeed-sidebar-container.input-placeholder.forbidden", + defaultMessage: "Need permission to post" + }); + } + return ( + + + + {messageGroups.map(({ id, ...rest }) => { + return ; + })} + + setMessage(e.target.value)} + disabled={!canPost} + placeholder={placeholder} + value={message} + afterInput={ + canPost ? ( + <> + + {audienceButton} + + ) : ( + + ) + } + /> + + ); +} + +ImmersFeedSidebarContainer.propTypes = { + canSpawnMessages: PropTypes.bool, + presences: PropTypes.object.isRequired, + occupantCount: PropTypes.number.isRequired, + scene: PropTypes.object.isRequired, + onClose: PropTypes.func.isRequired +}; + +export function ImmersFeedToolbarButtonContainer(props) { + const { unreadMessages } = useContext(ImmersFeedContext); + return ( + } + statusColor={unreadMessages ? "unread" : undefined} + preset="basic" + small + title="Chat across the metaverse that is saved in your profile" + label={} + /> + ); +} + +export function ImmersRegisterToolbarButtonContainer(props) { + return ( + } + preset="basic" + small + title="Register your Immers Space account to save your avatar and make friends" + label={} + /> + ); +} diff --git a/src/react-components/room/ImmersReact.js b/src/react-components/room/ImmersReact.js new file mode 100644 index 0000000000..4a1a2cacb1 --- /dev/null +++ b/src/react-components/room/ImmersReact.js @@ -0,0 +1,232 @@ +import React, { useContext, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { getMessageComponent } from "./ChatSidebar"; +import chatStyles from "./ChatSidebar.scss"; +import styles from "./ImmersReact.scss"; +import { FormattedMessage, FormattedRelativeTime } from "react-intl"; +import { proxiedUrlFor } from "../../utils/media-url-utils"; +import immersLogo from "../../assets/images/immers_logo.png"; +import merge from "deepmerge"; +import { ImmersFeedContext } from "./ImmersFeedSidebarContainer"; +import { Modal } from "../modal/Modal"; +import { Button } from "../input/Button"; +import { Column } from "../layout/Column"; +import { CloseButton } from "../input/CloseButton"; +import { formatMessageBody } from "../../utils/chat-message"; + +function proxyAndGetMessageComponent(message) { + // media urls need proxy to pass CSP & CORS + if (message.body?.src) { + message = merge({}, message); + message.body.src = proxiedUrlFor(message.body.src); + } + return getMessageComponent(message); +} + +export function ImmerLink({ place }) { + if (!place) { + return null; + } + let placeUrl = place.url; + // inject user handle into desintation url so they don't have to type it + try { + const url = new URL(placeUrl); + if ( + `${url.host}${url.pathname}${url.search}` === + `${window.location.host}${window.location.pathname}${window.location.search}` + ) { + placeUrl = null; + } else { + const hashParams = new URLSearchParams(); + hashParams.set("me", window.APP.store.state.profile.handle); + url.hash = hashParams.toString(); + placeUrl = url.toString(); + } + } catch (ignore) { + /* if fail, leave original url unchanged */ + } + return placeUrl ? {place.name ?? "unkown"} : "here"; +} + +ImmerLink.propTypes = { + place: PropTypes.object +}; + +export function ImmersChatMessage({ sent, sender, timestamp, isFriend, icon, immer, context, messages }) { + if (messages[0].type === "activity") { + return ( +
  • +
    + {sent ? "You" : sender} + + {formatMessageBody(messages[0].body).formattedBody} + + +
    +
  • + ); + } + return ( +
  • +

    + {isFriend && } + {icon && } + {sender} + [{immer}] |  |{" "} + +

    +
      + {messages.map(message => proxyAndGetMessageComponent(message))} +
    +
  • + ); +} + +ImmersChatMessage.propTypes = { + sent: PropTypes.bool, + sender: PropTypes.string, + timestamp: PropTypes.any, + messages: PropTypes.array, + immer: PropTypes.string, + icon: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + isFriend: PropTypes.bool, + context: PropTypes.object +}; + +export function ImmersImageIcon({ src, title, button }) { + return ( + + {src && } + + ); +} +ImmersImageIcon.propTypes = { + src: PropTypes.string, + title: PropTypes.string, + button: PropTypes.bool +}; + +export function ImmersFriendIcon() { + return ; +} + +export function ImmersIcon(props) { + return ; +} + +export function ImmersAvatarIcon({ avi }) { + // support both Image objects & direct url + const src = avi.url || avi; + return ; +} +ImmersAvatarIcon.propTypes = { + avi: PropTypes.any +}; + +export function ImmersMoreHistoryButton() { + const [isLoading, setIsLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); + useEffect( + () => { + const onMoreHistory = evt => { + setIsLoading(false); + setHasMore(evt.detail); + }; + window.addEventListener("immers-more-history-loaded", onMoreHistory); + return () => window.removeEventListener("immers-more-history-loaded", onMoreHistory); + }, + [setIsLoading, setHasMore] + ); + const handleClick = evt => { + evt.preventDefault(); + setIsLoading(true); + window.dispatchEvent(new CustomEvent("immers-load-more-history")); + }; + return ( + hasMore && ( +
    + {isLoading ? ( + Loading... + ) : ( + + Load more + + )} +
    + ) + ); +} + +export function ImmersPermissionUpgrade({ scope, role, children }) { + const { permissions, reAuthorize } = useContext(ImmersFeedContext); + if (permissions.includes(scope) || !reAuthorize) { + return null; + } + return ( +
    + +

    + {children}. +

    +
    + ); +} + +ImmersPermissionUpgrade.propTypes = { + children: PropTypes.node, + scope: PropTypes.string, + role: PropTypes.string +}; + +export function ImmersPermissionUpgradeButton({ role }) { + const { reAuthorize } = useContext(ImmersFeedContext); + if (!reAuthorize) { + // initial auth has not occurred + return null; + } + return ( + reAuthorize(role)}> + Reload & change + + ); +} + +ImmersPermissionUpgradeButton.propTypes = { + role: PropTypes.string +}; + +export function ImmersClaimAccountModal({ scene, startImmersAuth, onClose }) { + const onAuthDone = ({ detail }) => { + if (detail === "immers-authorizing") { + scene.removeEventListener("stateremoved", onAuthDone); + onClose(); + } + }; + const handleClick = event => { + scene.addEventListener("stateremoved", onAuthDone); + startImmersAuth(event); + }; + return ( + }> + +

    + Create an account that you can use to login anywhere in the Immers Space metaverse. You'll save your current + avatar and be able to start adding friends. +

    + +
    +
    + ); +} + +ImmersClaimAccountModal.propTypes = { + scene: PropTypes.object, + startImmersAuth: PropTypes.func, + onClose: PropTypes.func +}; diff --git a/src/react-components/room/ImmersReact.scss b/src/react-components/room/ImmersReact.scss new file mode 100644 index 0000000000..ea0b07f0c1 --- /dev/null +++ b/src/react-components/room/ImmersReact.scss @@ -0,0 +1,75 @@ +@use "../styles/theme.scss"; + +:local(.image-icon-wrapper) { + width: 20px; + height: 20px; + overflow: hidden; +} + +:local(.button-icon) { + margin-right: 8px; + flex-shrink: 0; +} + +:local(.image-icon) { + width: 20px; +} + +:local(.immer-name) { + color: theme.$grey; +} + +:local(.immer-chat-label) { + align-items: center; +} + +:local(.sent) { + :local(.message-group-label) { + align-self: flex-end; + } + + :local(.message-bubble) { + background-color: theme.$blue; + color: theme.$white; + align-self: flex-end; + + a { + color: theme.$white; + + &:hover { + color: theme.$white-hover; + } + + &:active { + color: theme.$white-pressed; + } + } + } +} + +:local(.history-button) { + text-align: center; + padding-top: 5px; + font-size: theme.$font-size-sm; +} + +:local(.permissions) { + display: flex; + font-size: theme.$font-size-sm; + padding-left: 5px; + + p { + padding-left: 5px; + } +} + +:local(.permissions-button) { + padding-right: 5px; +} + +:local(.divider) { + &:after { + content: "|"; + } + padding: 0 1ch; +} \ No newline at end of file diff --git a/src/react-components/room/MediaTiles.js b/src/react-components/room/MediaTiles.js index 4d88d7edcd..44369e0240 100644 --- a/src/react-components/room/MediaTiles.js +++ b/src/react-components/room/MediaTiles.js @@ -203,7 +203,7 @@ export function MediaTile({ entry, processThumbnailUrl, onClick, onEdit, onShowS )}
    - {entry.type === "avatar" && ( + {entry.type === "__disabled:avatar" && ( )} {entry.type === "avatar_listing" && + onCopy && entry.allow_remixing && ( )} {entry.type === "scene_listing" && + onCopy && entry.allow_remixing && ( + {onlineMsg} + + ); + } + case "Leave": + return intl.formatMessage({ id: "people-sidebar.immers.offline", defaultMessage: "Offline" }); + default: + return ""; + } +} + function getPersonName(person, intl) { const you = intl.formatMessage({ id: "people-sidebar.person-name.you", @@ -92,7 +110,7 @@ function getPersonName(person, intl) { export function PeopleSidebar({ people, onSelectPerson, onClose, showMuteAll, onMuteAll }) { const intl = useIntl(); - + const myHandle = people.find(person => person.isMe)?.profile.handle; return ( onSelectPerson(person, e)} + disabled={person.remote} > + {person.friendStatus && } {person.hand_raised && } - {} - {!person.context.discord && VoiceIcon && } + {!person.remote && } + {person.remote ? ( + person.friendStatus?.actor?.icon && + ) : ( + !person.context.discord && + !person.remote && + VoiceIcon && + )}

    {getPersonName(person, intl)}

    {person.roles.owner && ( )} -

    {getPresenceMessage(person.presence, intl)}

    +

    + {getPresenceMessage(person.presence, intl) ?? getLocationMessage(person.friendStatus, myHandle, intl)} +

    ); })} +
  • + + Need permission to load friends + +
  • ); diff --git a/src/react-components/room/PeopleSidebar.scss b/src/react-components/room/PeopleSidebar.scss index 9a3344bff7..782a48b522 100644 --- a/src/react-components/room/PeopleSidebar.scss +++ b/src/react-components/room/PeopleSidebar.scss @@ -35,3 +35,13 @@ flex: 1; justify-content: flex-end; } + +:local(.image-icon-wrapper) { + width: 20px; + height: 20px; + overflow: hidden; +} + +:local(.image-icon) { + width: 20px; +} diff --git a/src/react-components/room/PeopleSidebarContainer.js b/src/react-components/room/PeopleSidebarContainer.js index 4e7c1df725..28b653c2bf 100644 --- a/src/react-components/room/PeopleSidebarContainer.js +++ b/src/react-components/room/PeopleSidebarContainer.js @@ -10,24 +10,63 @@ export function userFromPresence(sessionId, presence, micPresences, mySessionId) const micPresence = micPresences.get(sessionId); return { id: sessionId, isMe: mySessionId === sessionId, micPresence, ...meta }; } - -function usePeopleList(presences, mySessionId, micUpdateFrequency = 500) { +// sometimes the mic presence timeout fails to clear +let lastTimeout; +function usePeopleList(presences, mySessionId, friends, micUpdateFrequency = 500) { const [people, setPeople] = useState([]); - useEffect( () => { + clearTimeout(lastTimeout); let timeout; + const friendsAndPresences = Object.assign({}, presences); + const presenceFriendLookup = Object.fromEntries( + Object.entries(presences).map(([, presence]) => [ + presence.metas[presence.metas.length - 1].profile.id, + presence + ]) + ); + friends.sort((a, b) => { + if (a.type === b.type) { + return 0; + } + if (a.type === "Leave") { + return 1; + } + return -1; + }); + friends.forEach(friend => { + const localPresence = presenceFriendLookup[friend.actor.id]; + if (localPresence) { + localPresence.metas[localPresence.metas.length - 1].friendStatus = friend; + } else { + friendsAndPresences[friend.id] = { + id: friend.actor.id, + metas: [ + { + context: {}, + profile: { + displayName: friend.actor.name + }, + roles: {}, + friendStatus: friend, + remote: true + } + ] + }; + } + }); function updateMicrophoneState() { const micPresences = getMicrophonePresences(); setPeople( - Object.entries(presences).map(([id, presence]) => { + Object.entries(friendsAndPresences).map(([id, presence]) => { return userFromPresence(id, presence, micPresences, mySessionId); }) ); timeout = setTimeout(updateMicrophoneState, micUpdateFrequency); + lastTimeout = timeout; } updateMicrophoneState(); @@ -36,7 +75,7 @@ function usePeopleList(presences, mySessionId, micUpdateFrequency = 500) { clearTimeout(timeout); }; }, - [presences, micUpdateFrequency, setPeople, mySessionId] + [presences, friends, micUpdateFrequency, setPeople, mySessionId] ); return people; @@ -75,6 +114,7 @@ PeopleListContainer.propTypes = { export function PeopleSidebarContainer({ hubChannel, presences, + friends, mySessionId, displayNameOverride, store, @@ -84,7 +124,7 @@ export function PeopleSidebarContainer({ showNonHistoriedDialog, onClose }) { - const people = usePeopleList(presences, mySessionId); + const people = usePeopleList(presences, mySessionId, friends); const [selectedPersonId, setSelectedPersonId] = useState(null); const selectedPerson = people.find(person => person.id === selectedPersonId); const setSelectedPerson = useCallback( @@ -137,6 +177,7 @@ PeopleSidebarContainer.propTypes = { onClose: PropTypes.func.isRequired, mySessionId: PropTypes.string.isRequired, presences: PropTypes.object.isRequired, + friends: PropTypes.array.isRequired, performConditionalSignIn: PropTypes.func.isRequired, onCloseDialog: PropTypes.func.isRequired, showNonHistoriedDialog: PropTypes.func.isRequired diff --git a/src/react-components/room/RoomEntryModal.js b/src/react-components/room/RoomEntryModal.js index 91e80ff536..2c45f84ebf 100644 --- a/src/react-components/room/RoomEntryModal.js +++ b/src/react-components/room/RoomEntryModal.js @@ -7,12 +7,14 @@ import { ReactComponent as EnterIcon } from "../icons/Enter.svg"; import { ReactComponent as VRIcon } from "../icons/VR.svg"; import { ReactComponent as ShowIcon } from "../icons/Show.svg"; import { ReactComponent as SettingsIcon } from "../icons/Settings.svg"; +import { ReactComponent as WMIcon } from "../icons/wm-icon.svg"; import { ReactComponent as HmcLogo } from "../icons/HmcLogo.svg"; import styles from "./RoomEntryModal.scss"; import styleUtils from "../styles/style-utils.scss"; import { useCssBreakpoints } from "react-use-css-breakpoints"; import { Column } from "../layout/Column"; import { FormattedMessage } from "react-intl"; +import { ImmersIcon } from "./ImmersReact"; import configs from "../../utils/configs"; export function RoomEntryModal({ @@ -20,6 +22,10 @@ export function RoomEntryModal({ logoSrc, className, roomName, + showLoginToImmers, + showGuestEntry, + onLoginToImmers, + onGuestEntry, showJoinRoom, onJoinRoom, showEnterOnDevice, @@ -28,6 +34,9 @@ export function RoomEntryModal({ onSpectate, showOptions, onOptions, + showRoomFull, + showMonetizationRequired, + showMonetized, ...rest }) { const breakpoint = useCssBreakpoints(); @@ -48,21 +57,51 @@ export function RoomEntryModal({

    {roomName}

    - {showJoinRoom && ( - - )} - {showEnterOnDevice && ( - + {showLoginToImmers ? ( + <> + + {showGuestEntry ? ( + + ) : ( + <> + +

    Login or create a free account to join this space

    + + )} + + ) : ( + <> + {showJoinRoom && ( + + )} + {showEnterOnDevice && ( + + )} + )} {showSpectate && ( )} +
    + {showMonetized && ( + <> + +

    + Thanks for paying! {showRoomFull && You can join this space even though it is full.}{" "} + Search for this icon in the space to find other premium features. +

    + + )} + {showMonetizationRequired && ( + <> + +

    + This space has no more free slots available.{" "} + + Sign up for Web Monetization + {" "} + to join anyway. +

    + + )} +
    + {showOptions && breakpoint !== "sm" && ( <> @@ -102,7 +165,9 @@ RoomEntryModal.propTypes = { showSpectate: PropTypes.bool, onSpectate: PropTypes.func, showOptions: PropTypes.bool, - onOptions: PropTypes.func + onOptions: PropTypes.func, + showMonetizationRequired: PropTypes.bool, + showMonetized: PropTypes.bool }; RoomEntryModal.defaultProps = { diff --git a/src/react-components/room/RoomEntryModal.scss b/src/react-components/room/RoomEntryModal.scss index 0e50c90cdb..4bab1f4fee 100644 --- a/src/react-components/room/RoomEntryModal.scss +++ b/src/react-components/room/RoomEntryModal.scss @@ -10,6 +10,10 @@ @media(min-width: theme.$breakpoint-lg) and (min-height: theme.$breakpoint-vr) { padding: 24px; } + p { + font-size: theme.$font-size-sm; + max-width: 275px; + } } :local(.logo-container) { @@ -50,3 +54,12 @@ } } } + +:local(.webmon) { + display: flex; + flex-direction: row; + align-items: center; + svg { + flex-shrink: 0; + } +} diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index d44c9caace..1865061ef3 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -93,6 +93,13 @@ import { TweetModalContainer } from "./room/TweetModalContainer"; import { TipContainer, FullscreenTip } from "./room/TipContainer"; import { SpectatingLabel } from "./room/SpectatingLabel"; import { SignInMessages } from "./auth/SignInModal"; +import { + ImmersFeedContextProvider, + ImmersFeedSidebarContainer, + ImmersFeedToolbarButtonContainer, + ImmersRegisterToolbarButtonContainer +} from "./room/ImmersFeedSidebarContainer"; +import { ImmersClaimAccountModal } from "./room/ImmersReact"; import { MediaDevicesEvents } from "../utils/media-devices-utils"; const avatarEditorDebug = qsTruthy("avatarEditorDebug"); @@ -139,6 +146,12 @@ class UIRoot extends Component { isSupportAvailable: PropTypes.bool, presenceLogEntries: PropTypes.array, presences: PropTypes.object, + friends: PropTypes.array, + handle: PropTypes.string, + immersScopes: PropTypes.array, + isImmersConnected: PropTypes.bool, + startImmersAuth: PropTypes.func, + isMonetized: PropTypes.bool, sessionId: PropTypes.string, subscriptions: PropTypes.object, initialIsFavorited: PropTypes.bool, @@ -165,6 +178,12 @@ class UIRoot extends Component { breakpoint: PropTypes.string }; + static defaultProps = { + friends: [], + immersScopes: [], + isImmersConnected: false + }; + state = { enterInVR: false, entered: false, @@ -194,9 +213,12 @@ class UIRoot extends Component { videoShareMediaSource: null, showVideoShareFailed: false, + guestEntry: false, + objectInfo: null, objectSrc: "", sidebarId: null, + hasUnreadFriendUpdate: false, presenceCount: 0, chatInputEffect: () => {} }; @@ -261,6 +283,12 @@ class UIRoot extends Component { }); } + if (prevProps.friends !== this.props.friends && this.state.sidebarId !== "people") { + this.setState({ hasUnreadFriendUpdate: true }); + this.props.scene.addState("notification"); + } else if (!this.state.hasUnreadFriendUpdate) { + this.props.scene.removeState("notification"); + } if (!this.props.selectedObject || !prevProps.selectedObject) { const sceneEl = this.props.scene; @@ -375,6 +403,7 @@ class UIRoot extends Component { this.playerRig = scene.querySelector("#avatar-rig"); scene.addEventListener("action_media_tweet", this.onTweet); + scene.addEventListener("action_immers_register", this.onImmersRegister); } UNSAFE_componentWillMount() { @@ -388,6 +417,7 @@ class UIRoot extends Component { this.props.scene.removeEventListener("share_video_disabled", this.onShareVideoDisabled); this.props.scene.removeEventListener("share_video_failed", this.onShareVideoFailed); this.props.scene.removeEventListener("action_media_tweet", this.onTweet); + this.props.scene.removeEventListener("action_immers_register", this.onImmersRegister); this.props.store.removeEventListener("statechanged", this.storeUpdated); window.removeEventListener("concurrentload", this.onConcurrentLoad); window.removeEventListener("idle_detected", this.onIdleDetected); @@ -741,6 +771,19 @@ class UIRoot extends Component { }); }; + showImmersRegister = () => { + this.showNonHistoriedDialog(ImmersClaimAccountModal, { + startImmersAuth: this.props.startImmersAuth, + scene: this.props.scene + }); + }; + + onImmersRegister = () => { + handleExitTo2DInterstitial(true, () => {}).then(() => { + this.showImmersRegister(); + }); + }; + onChangeScene = () => { this.props.performConditionalSignIn( () => this.props.hubChannel.can("update_hub"), @@ -807,7 +850,13 @@ class UIRoot extends Component { renderEntryStartPanel = () => { const { hasAcceptedProfile, hasChangedName } = this.props.store.state.activity; const promptForNameAndAvatarBeforeEntry = this.props.hubIsBound ? !hasAcceptedProfile : !hasChangedName; - + const pageIsMonetized = !!document.querySelector("meta[name=monetization]"); + const showLogin = !this.props.isImmersConnected && !this.state.guestEntry; + const showGuestEntry = configs.IMMERS_ALLOW_GUESTS !== "false"; + // monetized users can bypass room limit + const canEnter = !this.props.entryDisallowed || !!this.props.isMonetized; + // only show when joining is not possible to reduce number of choices shown + const canSpectate = !showLogin && !canEnter; // TODO: What does onEnteringCanceled do? return ( <> @@ -815,7 +864,11 @@ class UIRoot extends Component { appName={configs.translation("app-name")} logoSrc={configs.image("logo")} roomName={this.props.hub.name} - showJoinRoom={!this.state.waitingOnAudio && !this.props.entryDisallowed} + showLoginToImmers={showLogin} + onLoginToImmers={this.props.startImmersAuth} + showGuestEntry={showGuestEntry} + onGuestEntry={() => this.setState({ guestEntry: !this.state.guestEntry })} + showJoinRoom={!this.state.waitingOnAudio && canEnter} onJoinRoom={() => { if (promptForNameAndAvatarBeforeEntry || !this.props.forcedVREntryType) { this.setState({ entering: true }); @@ -831,9 +884,9 @@ class UIRoot extends Component { this.handleForceEntry(); } }} - showEnterOnDevice={!this.state.waitingOnAudio && !this.props.entryDisallowed && !isMobileVR} + showEnterOnDevice={!this.state.waitingOnAudio && canEnter && !isMobileVR} onEnterOnDevice={() => this.attemptLink()} - showSpectate={!this.state.waitingOnAudio} + showSpectate={!this.state.waitingOnAudio && canSpectate} onSpectate={() => this.setState({ watching: true })} showOptions={this.props.hubChannel.canOrWillIfCreator("update_hub")} onOptions={() => { @@ -843,6 +896,11 @@ class UIRoot extends Component { SignInMessages.roomSettings ); }} + showRoomFull={!this.state.waitingOnAudio && !showLogin && this.props.entryDisallowed} + showMonetizationRequired={ + !this.state.waitingOnAudio && pageIsMonetized && !showLogin && !this.props.isMonetized && !canEnter + } + showMonetized={!this.state.waitingOnAudio && !showLogin && this.props.isMonetized} /> {!this.state.waitingOnAudio && ( , + label: , icon: EnterIcon, onClick: () => this.showContextualSignInDialog() }, @@ -1133,13 +1191,13 @@ class UIRoot extends Component { reason: LeaveReason.createRoom }) }, - { + this.props.isImmersConnected && { id: "user-profile", label: , icon: AvatarIcon, onClick: () => this.setSidebar("profile") }, - { + this.state.signedIn && { id: "favorite-rooms", label: , icon: FavoritesIcon, @@ -1178,14 +1236,14 @@ class UIRoot extends Component { icon: InviteIcon, onClick: () => this.props.scene.emit("action_invite") }, - this.isFavorited() + this.state.signedIn && this.isFavorited() ? { id: "unfavorite-room", label: , icon: StarIcon, onClick: () => this.toggleFavorited() } - : { + : this.state.signedIn && { id: "favorite-room", label: , icon: StarOutlineIcon, @@ -1376,7 +1434,8 @@ class UIRoot extends Component { )} this.toggleSidebar("people")} + onClick={() => this.toggleSidebar("people", { hasUnreadFriendUpdate: false })} + notification={this.state.hasUnreadFriendUpdate} presencecount={this.state.presenceCount} /> @@ -1442,6 +1501,15 @@ class UIRoot extends Component { inputEffect={this.state.chatInputEffect} /> )} + {this.state.sidebarId === "feed" && ( + this.setSidebar(null)} + /> + )} {this.state.sidebarId === "objects" && ( this.setSidebar(null)} onCloseDialog={() => this.closeDialog()} showNonHistoriedDialog={this.showNonHistoriedDialog} @@ -1572,6 +1641,11 @@ class UIRoot extends Component { )} this.toggleSidebar("chat")} /> + {this.props.isImmersConnected ? ( + this.toggleSidebar("feed")} /> + ) : ( + + )} {entered && isMobileVR && ( - - - + + + + + ); } @@ -1658,7 +1738,10 @@ function UIRootHooksWrapper(props) { UIRootHooksWrapper.propTypes = { scene: PropTypes.object.isRequired, messageDispatch: PropTypes.object, - store: PropTypes.object.isRequired + store: PropTypes.object.isRequired, + immersMessageDispatch: PropTypes.object, + immersScopes: PropTypes.array, + immersReAuth: PropTypes.func }; export default UIRootHooksWrapper; diff --git a/src/scene-entry-manager.js b/src/scene-entry-manager.js index 37ee70c58e..b5471e28b7 100644 --- a/src/scene-entry-manager.js +++ b/src/scene-entry-manager.js @@ -167,12 +167,17 @@ export default class SceneEntryManager { _setPlayerInfoFromProfile = async (force = false) => { const avatarId = this.store.state.profile.avatarId; - if (!force && this._lastFetchedAvatarId === avatarId) return; // Avoid continually refetching based upon state changing + const immersId = this.store.state.profile.id; + if (!force && this._lastFetchedAvatarId === avatarId) { + // share immersId with room if registered after join + this.avatarRig.setAttribute("player-info", { immersId }); + return; // Avoid continually refetching avatar based upon state changing + } this._lastFetchedAvatarId = avatarId; const avatarSrc = await getAvatarSrc(avatarId); - this.avatarRig.setAttribute("player-info", { avatarSrc, avatarType: getAvatarType(avatarId) }); + this.avatarRig.setAttribute("player-info", { avatarSrc, avatarType: getAvatarType(avatarId), immersId }); }; _setupKicking = () => { diff --git a/src/storage/media-search-store.js b/src/storage/media-search-store.js index 2e4d83a158..5c8bf18ea4 100644 --- a/src/storage/media-search-store.js +++ b/src/storage/media-search-store.js @@ -2,6 +2,7 @@ import { EventTarget } from "event-target-shim"; import configs from "../utils/configs"; import { getReticulumFetchUrl, fetchReticulumAuthenticated, hasReticulumServer } from "../utils/phoenix-utils"; import { pushHistoryPath, sluglessPath, withSlug } from "../utils/history"; +import { fetchMyImmersAvatars } from "../utils/immers"; const EMPTY_RESULT = { entries: [], meta: {} }; @@ -106,11 +107,16 @@ export default class MediaSearchStore extends EventTarget { this.isFetching = true; this.dispatchEvent(new CustomEvent("statechanged")); - const result = fetch ? await fetchReticulumAuthenticated(path) : EMPTY_RESULT; + let result = fetch ? await fetchReticulumAuthenticated(path) : EMPTY_RESULT; + // immers personal avatar collection + if (source === "avatars" && isMy) { + result = await fetchMyImmersAvatars(searchParams.get("cursor")); + } if (this.requestIndex != currentRequestIndex) return; this.result = result; + this.nextCursor = this.result && this.result.meta && this.result.meta.next_cursor; this.lastFetchedUrl = url; this.isFetching = false; diff --git a/src/storage/store.js b/src/storage/store.js index bab9389fda..ccfa2d636f 100644 --- a/src/storage/store.js +++ b/src/storage/store.js @@ -48,8 +48,13 @@ export const SCHEMA = { type: "object", additionalProperties: false, properties: { + handle: { type: "string" }, displayName: { type: "string", pattern: "^[A-Za-z0-9_~ -]{3,32}$" }, avatarId: { type: "string" }, + id: { type: "string" }, + outbox: { type: "string" }, + inbox: { type: "string" }, + followers: { type: "string" }, // personalAvatarId is obsolete, but we need it here for backwards compatibility. personalAvatarId: { type: "string" } } @@ -60,7 +65,10 @@ export const SCHEMA = { additionalProperties: false, properties: { token: { type: ["null", "string"] }, - email: { type: ["null", "string"] } + email: { type: ["null", "string"] }, + immerToken: { type: ["null", "string"] }, + immerHome: { type: ["null", "string"] }, + immerScopes: { type: ["null", "array"] } } }, @@ -231,7 +239,8 @@ export default class Store extends EventTarget { this._preferences = {}; - if (localStorage.getItem(LOCAL_STORE_KEY) === null) { + const savedState = localStorage.getItem(LOCAL_STORE_KEY); + if (savedState === null || !validator.validate(JSON.parse(savedState), SCHEMA).valid) { localStorage.setItem(LOCAL_STORE_KEY, JSON.stringify({})); } diff --git a/src/systems/exit-on-blur.js b/src/systems/exit-on-blur.js index 731ea56766..5dc4a080c7 100644 --- a/src/systems/exit-on-blur.js +++ b/src/systems/exit-on-blur.js @@ -29,6 +29,7 @@ AFRAME.registerSystem("exit-on-blur", { if ( this.isOculusBrowser && this.enteredVR && + !this.el.is("immers-authorizing") && (this.lastTimeoutCheck === 0 || t - this.lastTimeoutCheck >= 1000.0) // Don't do this clear every frame, slow. ) { this.lastTimeoutCheck = t; @@ -42,7 +43,7 @@ AFRAME.registerSystem("exit-on-blur", { }, onBlur() { - if (this.el.isMobile) { + if (this.el.isMobile && !this.el.is("immers-authorizing")) { clearTimeout(this.exitTimeout); this.exitTimeout = setTimeout(this.onTimeout, 30 * 1000); } diff --git a/src/utils/chat-message.js b/src/utils/chat-message.js index c333af7832..df8cad2774 100644 --- a/src/utils/chat-message.js +++ b/src/utils/chat-message.js @@ -1,9 +1,37 @@ import React from "react"; -import Linkify from "react-linkify"; +import linkifyHtml from "linkifyjs/html"; import { toArray as toEmojis } from "react-emoji-render"; +import sanitize from "sanitize-html"; const emojiRegex = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|[\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|[\ud83c[\ude32-\ude3a]|[\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/; +const sanitizeOpts = { + allowedTags: sanitize.defaults.allowedTags.concat(["img"]), + allowedAttributes: { + a: ["href", "name", "target", "rel"] + }, + transformTags: { + // convert any existing links safe new tabs + a: sanitize.simpleTransform("a", { rel: "noopener referrer", target: "_blank" }) + } +}; + +const linkifyOpts = { + defaultProtocol: "https", + // also any newly created links are safe new tabs + attributes: { + rel: "noopener referrer" + }, + target: { + url: "_blank" + } +}; + +function safeHTML(text) { + return { + __html: linkifyHtml(sanitize(text, sanitizeOpts), linkifyOpts) + }; +} export const MAX_MESSAGE_LENGTH = 750; export function formatMessageBody(body, { emojiClassName } = {}) { @@ -12,13 +40,15 @@ export function formatMessageBody(body, { emojiClassName } = {}) { const monospace = body.startsWith("`") && body.endsWith("`"); const cleanedBody = (monospace ? body.substring(1, bodyLength - 1) : body.substring(0, bodyLength)).trim(); - const messages = cleanedBody.split("\n").map((message, i) => ( -

    - - {toEmojis(message, { className: emojiClassName })} - -

    - )); + const messages = cleanedBody + .split("\n") + .map((message, i) => ( +

    + {toEmojis(message, { className: emojiClassName }).map( + (node, i) => (typeof node === "string" ? : node) + )} +

    + )); const multiline = messages.length > 1; diff --git a/src/utils/configs.js b/src/utils/configs.js index cec4bf3572..f35d3b6ab2 100644 --- a/src/utils/configs.js +++ b/src/utils/configs.js @@ -16,6 +16,9 @@ let isAdmin = false; "SENTRY_DSN", "GA_TRACKING_ID", "SHORTLINK_DOMAIN", + "IMMERS_SERVER", + "IMMERS_SCOPE", + "IMMERS_ALLOW_GUESTS", "BASE_ASSETS_PATH", "UPLOADS_HOST" ].forEach(x => { diff --git a/src/utils/hub-channel.js b/src/utils/hub-channel.js index 76a027f0c4..2328c6d304 100644 --- a/src/utils/hub-channel.js +++ b/src/utils/hub-channel.js @@ -60,7 +60,8 @@ export default class HubChannel extends EventTarget { // Returns true if the current session has the given permission, *or* will get the permission // if they sign in and become the creator. canOrWillIfCreator(permission) { - if (this._getCreatorAssignmentToken() && HUB_CREATOR_PERMISSIONS.includes(permission)) return true; + // immers: just show current perms; avoid showing options that aren't available + // if (this._getCreatorAssignmentToken() && HUB_CREATOR_PERMISSIONS.includes(permission)) return true; return this.can(permission); } @@ -142,6 +143,7 @@ export default class HubChannel extends EventTarget { // Note: token is not verified. this.token = token; this._permissions = jwtDecode(token); + this._permissions.pin_objects = this._permissions.pin_objects && this._signedIn; configs.setIsAdmin(this._permissions.postgrest_role === "ret_admin"); this.dispatchEvent(new CustomEvent("permissions_updated")); @@ -325,8 +327,8 @@ export default class HubChannel extends EventTarget { this.channel .push("sign_in", { token, creator_assignment_token }) .receive("ok", ({ perms_token }) => { - this.setPermissionsFromToken(perms_token); this._signedIn = true; + this.setPermissionsFromToken(perms_token); resolve(); }) .receive("error", err => { diff --git a/src/utils/immers.js b/src/utils/immers.js new file mode 100644 index 0000000000..d87e4521e6 --- /dev/null +++ b/src/utils/immers.js @@ -0,0 +1,516 @@ +import { ImmersClient } from "immers-client"; +import configs from "./configs"; +import { fetchAvatar } from "./avatar-utils"; +import { SOUND_CHAT_MESSAGE } from "../systems/sound-effects-system"; +import { setupMonetization } from "./immers/monetization"; +import immersMessageDispatch from "./immers/immers-message-dispatch"; +import Activities from "./immers/activities"; +const replaceArrays = { arrayMerge: (destinationArray, sourceArray) => sourceArray }; +const localImmer = configs.IMMERS_SERVER; +// immer can set a requested scope, but user can override +const preferredScope = configs.IMMERS_SCOPE; +console.log("immers.space client v1.10.1"); +const jsonldMime = "application/activity+json"; +export const immersClient = new ImmersClient(`${localImmer}/o/immer`, { + allowStorage: true, + localImmer +}); +const activities = new Activities(localImmer); +let homeImmer; +let place; +let token; +let authorizedScopes; +let hubScene; +let localPlayer; +let actorObj; +let handle; +let avatarsCollection; +let blockList; +// map of avatar urls to model objects to avoid recreating their AP representation +// when donned from personal avatars collection +const myAvatars = {}; + +// copy of URL used for sharing/authorization request +const hashParams = new URLSearchParams(window.location.hash.substring(1)); +if (hashParams.has("me")) { + handle = hashParams.get("me"); + // remove your handle before sharing with friends + window.location.hash = ""; +} +const hubUri = new URL(window.location); + +export function getUrlFromAvatar(avatar) { + const links = Array.isArray(avatar.url) ? avatar.url : [avatar.url]; + // prefer gltf + const gltfUrl = links.find(link => link.mediaType.includes("gltf")); + if (gltfUrl) { + return gltfUrl.href; + } + // gamble on a url of unkown type + return ImmersClient.URLFromProperty(links[0]); +} + +export function getAvatarFromActor(actorObj) { + if (!actorObj.avatar) { + return null; + } + const avatar = Array.isArray(actorObj.avatar) ? actorObj.avatar[0] : actorObj.avatar; + return getUrlFromAvatar(avatar); +} + +export async function getObject(IRI) { + if (IRI.startsWith(localImmer) || IRI.startsWith(homeImmer)) { + const headers = { Accept: jsonldMime }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + const result = await window.fetch(IRI, { headers }); + if (!result.ok) { + throw new Error(`Object fetch error ${result.message}`); + } + return result.json(); + } else { + throw new Error("Object fetch proxy not implemented"); + } +} + +export async function getActor() { + const response = await window.fetch(`${homeImmer}/auth/me`, { + headers: { + Accept: jsonldMime, + Authorization: `Bearer ${token}` + } + }); + if (!response.ok) { + throw new Error(`Error fetching actor ${response.status} ${response.statusText}`); + } + return response.json(); +} + +export function postActivity(outbox, activity) { + return window.fetch(outbox, { + method: "POST", + headers: { + "Content-Type": jsonldMime, + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(activity) + }); +} + +export function updateProfile(actorObj, update) { + update.id = actorObj.id; + const activity = { + type: "Update", + actor: actorObj.id, + object: update, + to: actorObj.followers + }; + return postActivity(actorObj.outbox, activity); +} + +// Adds a new avatar to an immerser's inventory +export async function createAndUseAvatar(hubsAvatarId) { + const hubsAvatar = await fetchAvatar(hubsAvatarId); + const created = await immersClient.createAvatar( + hubsAvatar.name ?? "Imported avatar", + await fetch(hubsAvatar.gltf_url).then(res => res.blob()), + await fetch(hubsAvatar.files?.thumbnail || configs.image("logo")).then(res => res.blob()), + "friends" + ); + await immersClient.addAvatar(created); + await immersClient.useAvatar(created); + return (await immersClient.activities.getObject(created)).object; +} + +export async function fetchMyImmersAvatars(page) { + let collectionPage; + let items; + const hubsResult = { + meta: { + source: "avatar", + next_cursor: null + }, + entries: [], + suggestions: null + }; + if (!actorObj.streams?.avatars) { + return hubsResult; + } + try { + if (!avatarsCollection) { + // cache base collection object + avatarsCollection = await getObject(actorObj.streams.avatars); + } + // check if the collection is not paginated + items = avatarsCollection.orderedItems; + if (!items && avatarsCollection.first) { + // otherwise get page + collectionPage = await getObject(page || avatarsCollection.first); + items = collectionPage.orderedItems; + hubsResult.meta.next_cursor = collectionPage.next; + } + items.forEach(createActivity => { + const avatar = createActivity.object; + const avatarGltfUrl = ImmersClient.URLFromProperty(avatar); + // cache results for lookup by url when donned + myAvatars[avatarGltfUrl] = avatar; + // form object for Hubs MediaBrowser + const icon = Array.isArray(avatar.icon) ? avatar.icon[0] : avatar.icon; + hubsResult.entries.push({ + type: "avatar", + name: avatar.name, + // id used by hubs to set the avatar, put model url here + id: avatarGltfUrl, + images: { + preview: { + // width/height ignored for avatar media + url: ImmersClient.URLFromProperty(icon) + } + }, + // display source immer name & link in description field + attributions: { publisher: { name: avatar.generator?.name } }, + url: avatar.generator?.url || avatar.id + }); + }); + } catch (err) { + console.error("Cannot fetch avatar collection", err); + } + return hubsResult; +} + +export async function getFriends(actorObj) { + const response = await window.fetch(`${actorObj.id}/friends`, { + headers: { + Accept: jsonldMime, + Authorization: `Bearer ${token}` + } + }); + if (!response.ok) { + throw new Error(`Unable to fech friends: ${response.statusText}`); + } + return response.json(); +} + +function getAuthCallback(store, scope) { + return evt => { + // send to token endpoint at local immer, it handles + // detecting remote users and sending them on to their home to login + let _authPromise; + if (evt?.currentTarget?.classList.contains("registration")) { + let suggestedHandle; + if (store.state.activity.hasAcceptedProfile) { + // if registering after joining, pre-fill form with current name + suggestedHandle = `${store.state.profile.displayName}[${localImmer}]`; + } + _authPromise = immersClient.login(hubUri.origin, scope || preferredScope, suggestedHandle, "Register"); + } else { + _authPromise = immersClient.login(hubUri.origin, scope || preferredScope, handle || immersClient.handle); + } + hubScene.addState("immers-authorizing"); + return _authPromise.then(newToken => { + hubScene.removeState("immers-authorizing"); + token = newToken; + homeImmer = `https://${immersClient.profile.homeImmer}`; + authorizedScopes = immersClient.authorizedScopes; + return getActor(); + }); + }; +} + +// force re-login to change authorized scopes +async function resetAuth(store, remountUI, scope) { + token = undefined; + store.update({ + credentials: { + immerToken: null, + immerHome: null, + immerScopes: null + } + }); + immersClient.disconnect(); + getAuthCallback(store, scope)(); + await immersClient.waitUntilConnected(); + store.update({ + credentials: { + immerToken: token, + immerHome: homeImmer, + immerScopes: authorizedScopes + } + }); + // could, in theory, handle scope upgrades without reloading, but it would be a lot of work + return window.location.reload(); +} + +export async function initialize(store, scene, remountUI, messageDispatch, createInWorldLogMessage) { + hubScene = scene; + localPlayer = document.getElementById("avatar-rig"); + place = await getObject(`${localImmer}/o/immer`); + place.url = hubUri.href; // include room id + activities.place = place; + // check if we're already logged in + if (await immersClient.restoreSession()) { + token = store.state.credentials.immerToken; + homeImmer = `https://${immersClient.profile.homeImmer}`; + authorizedScopes = immersClient.authorizedScopes; + } else { + remountUI({ isImmersConnected: false, startImmersAuth: getAuthCallback(store) }); + await immersClient.waitUntilConnected(); + } + actorObj = immersClient.activities.actor; + activities.token = token; + activities.homeImmer = homeImmer; + activities.authorizedScopes = authorizedScopes; + activities.actor = actorObj; + // wait for hubs room metadata to load + if (!window.APP?.hub?.hub_id) { + await new Promise(resolve => { + hubScene.addEventListener("hub_updated", resolve, { once: true }); + }); + } + // set online status to here + immersClient.enter({ + name: APP.hub.name, + url: hubUri.href, + privacy: "friends", + previewImage: APP.hub.scene.screenshot_url + }); + const initialAvi = store.state.profile.avatarId; + const actorAvi = immersClient.profile.avatarModel; + // cache current avatar so doesn't get recreated during a profile update + myAvatars[actorAvi] = immersClient.profile.avatarObject; + store.update( + { + profile: { + id: immersClient.profile.id, + avatarId: actorAvi || initialAvi, + displayName: immersClient.profile.displayName, + handle: immersClient.profile.handle, + inbox: actorObj.inbox, + outbox: actorObj.outbox, + followers: actorObj.followers + }, + credentials: { + immerToken: token, + // record user's home server in case redirected during auth + immerHome: homeImmer, + immerScopes: authorizedScopes + } + }, + // don't accumulate scopes + replaceArrays + ); + // listen for changes makes in other apps or on profile page + immersClient.addEventListener("immers-client-profile-update", () => { + let anyUpdate = false; + const { avatarModel, displayName } = immersClient.profile; + const profile = {}; + if (avatarModel !== store.state.profile.avatarId) { + profile.avatarId = avatarModel; + anyUpdate = true; + } + if (displayName !== store.state.profile.displayName) { + profile.displayName = displayName; + anyUpdate = true; + } + if (anyUpdate) { + store.update({ profile }); + } + }); + + authorizedScopes.forEach(scope => hubScene.addState(`immers-scope-${scope}`)); + const immerSocket = immersClient.streaming.socket; + + // friends list + let friendsCol; + const setFriendState = (immersId, el) => { + // friends not loaded or is myself + if (!friendsCol || immersId === actorObj.id) { + return; + } + const friendStatus = friendsCol.orderedItems.find(act => act.actor.id === immersId); + // inReplyTo on a follow means it is a followback, don't show accept prompt (already a friend) + if (friendStatus?.type === "Follow" && !friendStatus?.inReplyTo) { + el.removeState("immers-follow-friend"); + el.removeState("immers-follow-none"); + el.addState("immers-follow-request"); + } else if (friendStatus && friendStatus.type !== "Reject") { + el.removeState("immers-follow-request"); + el.removeState("immers-follow-none"); + el.addState("immers-follow-friend"); + } else { + el.removeState("immers-follow-request"); + el.removeState("immers-follow-friend"); + el.addState("immers-follow-none"); + } + }; + const updateFriends = async () => { + if (store.state.profile.id) { + const profile = store.state.profile; + try { + friendsCol = await getFriends(profile); + activities.friends = friendsCol.orderedItems; + remountUI({ + friends: friendsCol.orderedItems.filter(act => act.type !== "Reject" && act.actor.id), + handle: profile.handle + }); + } catch (err) { + console.warn(err.message); + remountUI({ friends: [], handle: profile.handle }); + } + // update follow button for new friends + const players = window.APP.componentRegistry["player-info"]; + players?.forEach(infoComp => setFriendState(infoComp.data.immersId, infoComp.el)); + } + }; + updateFriends(); + immerSocket.on("friends-update", updateFriends); + + blockList = await activities.blockList(); + // hide any blocked users currently in the room + Object.entries(window.APP.hubChannel.presence.state).forEach(([clientId, presence]) => { + const immersId = presence.metas[presence.metas.length - 1]?.profile.id; + if (blockList.includes(immersId)) { + window.APP.hubChannel.hide(clientId); + } + }); + // hide blocked users as soon as they connect + scene.addEventListener("presence_updated", ({ detail: { sessionId, profile } }) => { + if (blockList.includes(profile.id)) { + window.APP.hubChannel.hide(sessionId); + } + }); + + const updateProfileNameAndAvi = async () => { + const { displayName, avatarId } = store.state.profile; + // disable the first-time entry name & avatar prompt + store.update({ + activity: { + hasChangedName: true + } + }); + if (!authorizedScopes.includes("creative")) { + return; + } + if (displayName !== immersClient.profile.displayName) { + await immersClient.updateProfileInfo({ displayName }); + } + if (immersClient.profile.avatarModel !== avatarId) { + if (myAvatars[avatarId]) { + await immersClient.useAvatar(myAvatars[avatarId]); + } else { + // caches new avatar under hubs id (unlike others that are cached by gltf url), + // which avoids re-creating the avatar if it is selected again during this session + myAvatars[avatarId] = await createAndUseAvatar(avatarId); + } + } + }; + scene.addEventListener("avatar_updated", updateProfileNameAndAvi); + // registered after joining, save selected avatar & name to profile + if (!actorAvi && store.state.activity.hasAcceptedProfile) { + updateProfileNameAndAvi(); + } + + // entity interactions + scene.addEventListener("immers-id-changed", event => setFriendState(event.detail, event.target)); + + scene.addEventListener("immers-follow", event => { + if (!event.detail) { + return; + } + activities.follow(event.detail).catch(err => console.error("Error sending follow request:", err.message)); + }); + scene.addEventListener("immers-follow-accept", event => { + const follow = friendsCol.orderedItems.find(act => act.actor.id === event.detail && act.type === "Follow"); + if (!follow) { + return; + } + activities.accept(follow).catch(err => console.err("Error sending follow accept:", err.message)); + }); + // unfriend + scene.addEventListener("immers-follow-reject", event => { + // server converts actorId to followId for reject object + activities.reject(event.detail, event.detail).catch(err => console.error("Error sending unfollow:", err.message)); + }); + // blocked + scene.addEventListener("immers-block", ({ detail: { clientId } }) => { + const presence = window.APP.hubChannel.presence.state[clientId]; + const immersId = presence?.metas[presence.metas.length - 1]?.profile.id; + if (immersId) { + activities.block(immersId); + // update local copy in case blocked user reconnects + blockList.push(immersId); + } + }); + + setupMonetization(hubScene, localPlayer, remountUI); + + // news feed and chat integration, behind a feature switch as it needs the new hubs ui + if (createInWorldLogMessage) { + // fetch news feed + const updateFeed = async () => { + const { messages, more } = await activities.feedAsChat(); + messages.forEach(detail => { + immersMessageDispatch.dispatchEvent(new CustomEvent("message", { detail })); + }); + return more; + }; + updateFeed(); + // event from "load more" button at top of in chat sidebar + window.addEventListener("immers-load-more-history", async () => { + const more = await updateFeed(); + window.dispatchEvent(new CustomEvent("immers-more-history-loaded", { detail: more })); + }); + + // stream new activity while in room + immerSocket.on("inbox-update", activity => { + activity = JSON.parse(activity); + const message = activities.activityAsChat(activity); + if (message.body) { + if (message.type !== "activity") { + // play sound for chat/image/video updates + scene.systems["hubs-systems"].soundEffectsSystem.playSoundOneShot(SOUND_CHAT_MESSAGE); + } + immersMessageDispatch.dispatchEvent(new CustomEvent("message", { detail: message })); + if (scene.is("vr-mode")) { + createInWorldLogMessage(message); + } + } + }); + immersMessageDispatch.setDispatchHandler(message => { + // include local room occupants + const localAudience = Object.values(window.APP.hubChannel.presence.state) + .map(presence => presence.metas[presence.metas.length - 1]?.profile.id) + .filter(id => id && id !== actorObj.id); + // send activity + let task; + switch (message.type) { + case "chat": + task = activities.note(message.body, localAudience, message.audience, null); + break; + case "image": + case "photo": + task = activities.image(message.body.src, localAudience, message.audience, null); + break; + case "video": + task = activities.video(message.body.src, localAudience, message.audience, null); + break; + default: + return console.log("Chat message not shared", message); + } + task + .then(async postResult => { + if (!postResult.ok) { + throw new Error(postResult.status); + } + // fetch the newly created activity and feed it back into chat system so your outgoing messages appear in panel + const chat = activities.activityAsChat(await getObject(postResult.headers.get("Location")), true); + immersMessageDispatch.dispatchEvent(new CustomEvent("message", { detail: chat })); + }) + .catch(err => console.error(`Error sharing chat: ${err.message}`)); + }); + const immersReAuth = scope => resetAuth(store, remountUI, scope); + hubScene.addState("immers-connected"); + remountUI({ immersMessageDispatch, immersScopes: authorizedScopes, isImmersConnected: true, immersReAuth }); + } +} diff --git a/src/utils/immers/activities.js b/src/utils/immers/activities.js new file mode 100644 index 0000000000..0feb28ee21 --- /dev/null +++ b/src/utils/immers/activities.js @@ -0,0 +1,278 @@ +import { ImmersClient } from "immers-client"; + +export default class Activities { + static JSONLDMime = "application/activity+json"; + static PublicAddress = "as:Public"; + constructor(localImmer) { + this.localImmer = localImmer; + this.homeImmer = null; + this.token = null; + this.authorizedScopes = null; + this.actor = null; + this.place = null; + this.nextInboxPage = null; + this.nextOutboxPage = null; + this.inboxStartDate = new Date(); + this.outboxStartDate = this.inboxStartDate; + this.friends = []; + } + + trustedIRI(IRI) { + return IRI.startsWith(this.localImmer) || IRI.startsWith(this.homeImmer); + } + + async getObject(IRI) { + if (this.trustedIRI(IRI)) { + const headers = { Accept: Activities.JSONLDMime }; + if (this.token) { + headers.Authorization = `Bearer ${this.token}`; + } + const result = await window.fetch(IRI, { headers }); + if (!result.ok) { + throw new Error(`Object fetch error ${result.message}`); + } + return result.json(); + } else { + throw new Error("Object fetch proxy not implemented"); + } + } + + async inbox() { + let col; + if (this.nextInboxPage === null) { + col = await this.getObject(this.actor.inbox); + if (!col.orderedItems && col.first) { + col = await this.getObject(col.first); + } + } else if (this.nextInboxPage) { + col = await this.getObject(this.nextInboxPage); + } + this.nextInboxPage = col?.next; + return col; + } + + async outbox() { + let col; + if (this.nextOutboxPage === null) { + col = await this.getObject(this.actor.outbox); + if (!col.orderedItems && col.first) { + col = await this.getObject(col.first); + } + } else if (this.nextOutboxPage) { + col = await this.getObject(this.nextOutboxPage); + } + this.nextOutboxPage = col?.next; + return col; + } + + async blockList() { + const blocked = []; + // use blocklist IRI if specified, fallback to immers default + const blockedIRI = this.actor.streams?.blocked || `${this.homeImmer}/blocked/${this.actor.preferredUsername}`; + let col; + try { + col = await this.getObject(blockedIRI); + } catch (err) { + console.warn("Unable to fetch blocklist: ", err.message); + return blocked; + } + if (col.orderedItems?.length) { + blocked.push(...col.orderedItems); + } else { + col = await this.getObject(col.first); + blocked.push(...col.orderedItems); + } + // fetch entire collection + while (col.next) { + col = await this.getObject(col.next); + if (!col.orderedItems?.length) { + break; + } + blocked.push(...col.orderedItems); + } + return blocked.map(b => (typeof b === "object" ? b.id : b)); + } + + async inboxAsChat() { + const inbox = await this.inbox(); + if (!inbox?.orderedItems?.length) { + return []; + } + this.inboxStartDate = new Date(inbox.orderedItems[inbox.orderedItems.length - 1].published); + return inbox.orderedItems.map(act => this.activityAsChat(act)).filter(message => message.body); + } + + async outboxAsChat() { + const outbox = await this.outbox(); + if (!outbox?.orderedItems?.length) { + return []; + } + this.outboxStartDate = new Date(outbox.orderedItems[outbox.orderedItems.length - 1].published); + return outbox.orderedItems.map(act => this.activityAsChat(act, true)).filter(message => message.body); + } + + async feedAsChat() { + const messages = (await this.inboxAsChat()).concat(await this.outboxAsChat()); + // try to balance amount of time covered by inbox & outbox feeds + if (this.inboxStartDate > this.outboxStartDate) { + while (this.inboxStartDate > this.outboxStartDate && this.nextInboxPage) { + messages.push(...(await this.inboxAsChat())); + } + } else { + while (this.outboxStartDate > this.inboxStartDate && this.nextOutboxPage) { + messages.push(...(await this.outboxAsChat())); + } + } + return { + messages, + more: this.nextOutboxPage || this.nextInboxPage + }; + } + + postActivity(activity) { + if (!this.trustedIRI(this.actor.outbox)) { + throw new Error("Inavlid outbox address"); + } + return window.fetch(this.actor.outbox, { + method: "POST", + headers: { + "Content-Type": Activities.JSONLDMime, + Authorization: `Bearer ${this.token}` + }, + body: JSON.stringify(activity) + }); + } + + accept(follow) { + return this.postActivity({ + type: "Accept", + actor: this.actor.id, + object: follow.id, + to: follow.actor + }); + } + + reject(objectId, recipientId) { + return this.postActivity({ + type: "Reject", + actor: this.actor.id, + object: objectId, + to: recipientId + }); + } + + follow(targetId) { + return this.postActivity({ + type: "Follow", + actor: this.actor.id, + object: targetId, + to: targetId + }); + } + + note(content, to, audience, summary) { + const obj = { + content, + type: "Note", + attributedTo: this.actor.id, + context: this.place, + to: to.slice() + }; + if (summary) { + obj.summary = summary; + } + if (audience === "friends" || audience === "public") { + obj.to.push(this.actor.followers); + } + if (audience === "public") { + obj.to.push(Activities.PublicAddress); + } + return this.postActivity(obj); + } + + image(url, to, audience, summary) { + const obj = { + url, + type: "Image", + attributedTo: this.actor.id, + context: this.place, + to: to.slice() + }; + if (summary) { + obj.summary = summary; + } + if (audience === "friends" || audience === "public") { + obj.to.push(this.actor.followers); + } + if (audience === "public") { + obj.to.push(Activities.PublicAddress); + } + return this.postActivity(obj); + } + + video(url, to, audience, summary) { + const obj = { + url, + type: "Video", + attributedTo: this.actor.id, + context: this.place, + to: to.slice() + }; + if (summary) { + obj.summary = summary; + } + if (audience === "friends" || audience === "public") { + obj.to.push(this.actor.followers); + } + if (audience === "public") { + obj.to.push(Activities.PublicAddress); + } + return this.postActivity(obj); + } + + block(blockeeId) { + return this.postActivity({ + type: "Block", + actor: this.actor.id, + object: blockeeId + }); + } + + activityAsChat(activity, outbox = false) { + if (outbox) { + // avoid apex api inconsistency that returns actor as id string for direct activity fetch + activity.actor = this.actor; + } + const message = { + isImmersFeed: true, + isFriend: this.friends.some(status => status.actor.id === activity.actor.id), + sent: outbox, + context: activity.object?.context, + timestamp: new Date(activity.published).getTime(), + name: activity.actor.name, + sessionId: activity.actor.id, + icon: activity.actor.icon, + immer: new URL(activity.actor.id).hostname + }; + switch (activity.object?.type) { + case "Note": + message.type = "chat"; + message.body = activity.object.content; + break; + case "Image": + message.type = "photo"; + message.body = { src: ImmersClient.URLFromProperty(activity.object.url) }; + break; + case "Video": + message.type = "video"; + message.body = { src: ImmersClient.URLFromProperty(activity.object.url) }; + break; + default: + if (activity.type === "Arrive" || activity.type === "Leave") { + message.type = "activity"; + message.body = activity.summary; + } + } + return message; + } +} diff --git a/src/utils/immers/authUtils.js b/src/utils/immers/authUtils.js new file mode 100644 index 0000000000..93e908545a --- /dev/null +++ b/src/utils/immers/authUtils.js @@ -0,0 +1,32 @@ +export const allScopes = [ + "viewProfile", + "viewPublic", + "viewFriends", + "postLocation", + "viewPrivate", + "creative", + "addFriends", + "addBlocks", + "destructive" +]; + +export function catchToken() { + const hashParams = new URLSearchParams(window.location.hash.substring(1)); + if (hashParams.has("access_token")) { + // not safe to update store here, will be saved later in initialize() + const token = hashParams.get("access_token"); + const homeImmer = hashParams.get("issuer"); + const authorizedScopes = hashParams.get("scope")?.split(" ") || []; + window.location.hash = ""; + // If this is an oauth popup, pass the results back up and close + // todo check origin + if (window.opener) { + window.opener.postMessage({ + type: "ImmersAuth", + token, + homeImmer, + authorizedScopes + }); + } + } +} diff --git a/src/utils/immers/immers-message-dispatch.js b/src/utils/immers/immers-message-dispatch.js new file mode 100644 index 0000000000..c3e7d2edd4 --- /dev/null +++ b/src/utils/immers/immers-message-dispatch.js @@ -0,0 +1,11 @@ +class ImmersMessageDispatch extends EventTarget { + setDispatchHandler(dispatchHandler) { + this.dispatchHandler = dispatchHandler; + } + + dispatch(message) { + this.dispatchHandler(message); + } +} + +export default new ImmersMessageDispatch(); diff --git a/src/utils/immers/monetization.js b/src/utils/immers/monetization.js new file mode 100644 index 0000000000..e5f4e24e92 --- /dev/null +++ b/src/utils/immers/monetization.js @@ -0,0 +1,85 @@ +/* Handles monetization feature-checking, api, and initialization race. + * Immers creators can listen for events on the scene that are guaranteed to + * occur after initial scene and entity load. Monetization status is synced to + * the room currently via player-info. This has two limitations, 1) easy to spoof + * and 2) doesn't sync while players are still in lobby. A better implemenation + * would be to extend the reticulum server to support this over hubChannel. + * + * Events (emitted from scene element): + * immers-monetization-started - local user monetization began/resumed + * immers-monetization-stopped - local user monetization ceased + * immers-monetization-progress - local user micropayment received. + * detail: + * amount: Number, amount received on this transation + * totalAmoint: Number, amount received so far during this session + * currency: String, currency of the transation + */ +import "web-monetization-polyfill"; + +const monetization = { + amountPaid: 0, + currency: undefined, + state: undefined +}; +let localPlayer; +let hubScene; +let updateUI; + +// sync player's monetization status with room via player-info component +function onMonetizationStart() { + monetization.state = "started"; + localPlayer.setAttribute("player-info", { monetized: true }); + hubScene.emit("immers-monetization-started"); + updateUI({ isMonetized: true }); +} +function onMonetizationStop() { + monetization.state = "stopped"; + localPlayer.setAttribute("player-info", { monetized: false }); + hubScene.emit("immers-monetization-stopped"); + updateUI({ isMonetized: false }); +} + +// tallies total amount paid during curent session +// (no cross-session tracking is permitted) +function onMonetizationProgress(event) { + const amount = Number.parseInt(event.detail.amount) * Math.pow(10, -event.detail.assetScale); + if (amount) { + monetization.amountPaid += amount; + monetization.currency = event.detail.assetCode; + hubScene.emit("immers-monetization-progress", { + amount, + totalAmount: monetization.amountPaid, + currency: monetization.currency + }); + } +} + +function onSceneLoaded() { + if (document.monetization.state === "started") { + onMonetizationStart(); + } else { + onMonetizationStop(); + } + document.monetization.addEventListener("monetizationstart", onMonetizationStart); + document.monetization.addEventListener("monetizationstop", onMonetizationStop); + document.monetization.addEventListener("monetizationprogress", onMonetizationProgress); +} + +// wait until scene is fully loaded to trigger monetization events so creators don't +// have to worry about whether entities are loaded +export function setupMonetization(scene, player, remountUI) { + hubScene = scene; + localPlayer = player; + updateUI = remountUI; + if (hubScene.is("loaded")) { + onSceneLoaded(); + } else { + const sceneStateListener = ({ detail }) => { + if (detail === "loaded") { + onSceneLoaded(); + hubScene.removeEventListener("stateadded", sceneStateListener); + } + }; + hubScene.addEventListener("stateadded", sceneStateListener); + } +} diff --git a/webpack.config.js b/webpack.config.js index 48e5570135..1ab2b62366 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -676,6 +676,9 @@ module.exports = async (env, argv) => { SENTRY_DSN: process.env.SENTRY_DSN, GA_TRACKING_ID: process.env.GA_TRACKING_ID, POSTGREST_SERVER: process.env.POSTGREST_SERVER, + IMMERS_SERVER: process.env.IMMERS_SERVER, + IMMERS_SCOPE: process.env.IMMERS_SCOPE, + IMMERS_ALLOW_GUESTS: process.env.IMMERS_ALLOW_GUESTS, UPLOADS_HOST: process.env.UPLOADS_HOST, BASE_ASSETS_PATH: process.env.BASE_ASSETS_PATH, APP_CONFIG: appConfig