diff --git a/.vscode/settings.json b/.vscode/settings.json index fafe782..c5f8f1a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,7 @@ "Lyqydate", "Mutex", "Popconfirm", + "Precache", "Sider", "Syncable", "Transpiler", @@ -26,6 +27,7 @@ "chartjs", "clientside", "commonmeta", + "cryptocurrency", "dont", "firstseen", "jwalelset", @@ -34,9 +36,11 @@ "localisation", "metaname", "middot", + "midiots", "motd", "multiline", "pnpm", + "precaching", "privatekeys", "readonly", "serialised", @@ -49,6 +53,7 @@ "tsdoc", "typeahead", "unmount", + "unregistering", "unsyncable" ], "i18next.defaultTranslatedLocale": "en", diff --git a/package.json b/package.json index 1a5a01f..820835a 100644 --- a/package.json +++ b/package.json @@ -49,14 +49,17 @@ "typesafe-actions": "^5.1.0", "uuid": "^8.3.2", "web-vitals": "^1.1.0", - "websocket-as-promised": "^2.0.1" + "websocket-as-promised": "^2.0.1", + "workbox-core": "^6.1.1", + "workbox-precaching": "^6.1.1", + "workbox-routing": "^6.1.1" }, "scripts": { "start": "craco start", "clean": "rimraf build", "build": "craco build", "optimise": "gzip -kr build/static", - "full-build": "npm run clean; GENERATE_SOURCEMAP=false npm run build; npm run optimise", + "full-build": "npm run clean; NODE_ENV=production GENERATE_SOURCEMAP=false npm run build; npm run optimise", "analyze-build": "FORCE_ANALYZE=true npm run build", "test": "craco test" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7694be2..2e46f87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ uuid: 8.3.2 web-vitals: 1.1.0 websocket-as-promised: 2.0.1 + workbox-core: 6.1.1 + workbox-precaching: 6.1.1 + workbox-routing: 6.1.1 devDependencies: '@craco/craco': 6.1.1_react-scripts@4.0.3 '@types/classnames': 2.2.11 @@ -13511,6 +13514,10 @@ dev: true resolution: integrity: sha512-+4iRQan/1D8I81nR2L5vcbaaFskZC2CL17TLbvWVzQ4qiF/ytOGF6XeV54pVxAvKUtkLANhk8TyIUMtiMw2oDg== + /workbox-core/6.1.1: + dev: false + resolution: + integrity: sha512-xsc/72AQxFtt2BHmwU8QtnVV+W5ln4nnYGuz9Q5sPWYGqW4cH0P+FpZDoGM59bmNEyNf+W9bEmidW//e5GsbwQ== /workbox-expiration/5.1.4: dependencies: workbox-core: 5.1.4 @@ -13538,6 +13545,14 @@ dev: true resolution: integrity: sha512-gCIFrBXmVQLFwvAzuGLCmkUYGVhBb7D1k/IL7pUJUO5xacjLcFUaLnnsoVepBGAiKw34HU1y/YuqvTKim9qAZA== + /workbox-precaching/6.1.1: + dependencies: + workbox-core: 6.1.1 + workbox-routing: 6.1.1 + workbox-strategies: 6.1.1 + dev: false + resolution: + integrity: sha512-x8OKwtjd5ewe/x3VlKcXri1P3Tm0uV+uChdMYg/QryrCR9K8x9xwhAw8PZPkwrY0bLLsJMUoX9/lBu8DmjVqTA== /workbox-range-requests/5.1.4: dependencies: workbox-core: 5.1.4 @@ -13550,6 +13565,12 @@ dev: true resolution: integrity: sha512-8ljknRfqE1vEQtnMtzfksL+UXO822jJlHTIR7+BtJuxQ17+WPZfsHqvk1ynR/v0EHik4x2+826Hkwpgh4GKDCw== + /workbox-routing/6.1.1: + dependencies: + workbox-core: 6.1.1 + dev: false + resolution: + integrity: sha512-Az3Gt3cHNK+W0gTfSb4eKGfwEap9Slak16Krr5SiLhE1gXUY2C2O123HucVCedXgIoqTLOXMtNj71Cm6SwYDEg== /workbox-strategies/5.1.4: dependencies: workbox-core: 5.1.4 @@ -13557,6 +13578,12 @@ dev: true resolution: integrity: sha512-VVS57LpaJTdjW3RgZvPwX0NlhNmscR7OQ9bP+N/34cYMDzXLyA6kqWffP6QKXSkca1OFo/v6v7hW7zrrguo6EA== + /workbox-strategies/6.1.1: + dependencies: + workbox-core: 6.1.1 + dev: false + resolution: + integrity: sha512-7qYA9Eiq6hnP2dyenlD7ZtWI1ArBMT8yhTvHVlaOl9kYY7W+Iv3lAfRCjj/nucOKeVXATx4iVJEuFPn5J+8lzw== /workbox-streams/5.1.4: dependencies: workbox-core: 5.1.4 @@ -13825,3 +13852,6 @@ webpack-bundle-analyzer: ^4.4.0 webpackbar: ^5.0.0-3 websocket-as-promised: ^2.0.1 + workbox-core: ^6.1.1 + workbox-precaching: ^6.1.1 + workbox-routing: ^6.1.1 diff --git a/public/index.html b/public/index.html index 37034a5..53c27fc 100644 --- a/public/index.html +++ b/public/index.html @@ -11,10 +11,66 @@ /> + KristWeb + + -
+
+
+
+ Loading KristWeb... +
+
diff --git a/public/locales/en.json b/public/locales/en.json index af93e57..bb0342d 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -55,7 +55,11 @@ "madeBy": "Made by <1>{{authorName}}", "hostedBy": "Hosted by <1>{{host}}", "github": "GitHub", - "credits": "Credits" + "credits": "Credits", + + "updateTitle": "Update available!", + "updateDescription": "A new version of KristWeb is available. Please reload.", + "updateReload": "Reload" }, "dialog": { diff --git a/src/App.tsx b/src/App.tsx index ca7d842..e2a1fe4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,14 +16,20 @@ // FIXME: Apparently the import order of my CSS is important. Who knew! import "./App.less"; +import { AppLoading } from "./global/AppLoading"; import { CheckStatus } from "./pages/CheckStatus"; import { AppServices } from "./global/AppServices"; +import Debug from "debug"; +const debug = Debug("kristweb:app"); + export const store = initStore(); export type AppDispatch = typeof store.dispatch; function App(): JSX.Element { - return {/* TODO */} + debug("whole app is being rendered!"); + + return }> {/* TODO */} diff --git a/src/components/DateTime.less b/src/components/DateTime.less index 4081a0b..cd3629e 100644 --- a/src/components/DateTime.less +++ b/src/components/DateTime.less @@ -1,3 +1,6 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt @import (reference) "../App.less"; .date-time { diff --git a/src/global/AppLoading.tsx b/src/global/AppLoading.tsx new file mode 100644 index 0000000..a3482a9 --- /dev/null +++ b/src/global/AppLoading.tsx @@ -0,0 +1,16 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React from "react"; + +export function AppLoading(): JSX.Element { + return
+ {/* Spinner */} +
+ + {/* Loading hint */} + {/* NOTE: This is not translated, as usually this component is shown when + the translations are being loaded! */} + Loading KristWeb... +
; +} diff --git a/src/index.tsx b/src/index.tsx index 21d92cf..845e5f1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -9,8 +9,13 @@ import "./index.css"; import App from "./App"; +import Debug from "debug"; +const debug = Debug("kristweb:index"); + // import reportWebVitals from "./reportWebVitals"; +debug("============================ APP STARTING ============================"); +debug("performing initial render"); ReactDOM.render( // , diff --git a/src/layout/AppLayout.less b/src/layout/AppLayout.less index 65ff30b..87523cc 100644 --- a/src/layout/AppLayout.less +++ b/src/layout/AppLayout.less @@ -204,16 +204,28 @@ user-select: none; - &.site-sidebar-total-balance { + h5 { + margin-bottom: 0; + + font-size: @font-size-sm; + font-weight: bolder; + + color: @text-color-secondary; + } + + &.site-sidebar-update { + padding: 1rem; + + background: @primary-color; + color: @kw-darkest; + h5 { - margin-bottom: 0; - - font-size: @font-size-sm; - font-weight: bolder; - - color: @text-color-secondary; + color: @kw-darkest; + margin-bottom: @padding-xs; } + } + &.site-sidebar-total-balance { .anticon svg { width: 14px; height: 14px; diff --git a/src/layout/sidebar/ServiceWorkerCheck.tsx b/src/layout/sidebar/ServiceWorkerCheck.tsx new file mode 100644 index 0000000..ee77f11 --- /dev/null +++ b/src/layout/sidebar/ServiceWorkerCheck.tsx @@ -0,0 +1,60 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +import React, { useState, useEffect } from "react"; +import { Button } from "antd"; + +import { useTranslation } from "react-i18next"; + +import * as serviceWorker from "../../utils/serviceWorkerRegistration"; + +import Debug from "debug"; +const debug = Debug("kristweb:service-worker-check"); + +export function ServiceWorkerCheck(): JSX.Element | null { + const { t } = useTranslation(); + + const [showReload, setShowReload] = useState(false); + const [waitingWorker, setWaitingWorker] = useState(null); + + function onUpdate(registration: ServiceWorkerRegistration) { + setShowReload(true); + setWaitingWorker(registration.waiting); + } + + /** Force the service worker to update, wait for it to become active, then + * reload the page. */ + function reloadPage() { + debug("emitting skipWaiting now"); + + waitingWorker?.postMessage({ type: "SKIP_WAITING" }); + setShowReload(false); + + waitingWorker?.addEventListener("statechange", () => { + debug("SW state changed to %s", waitingWorker?.state); + + if (waitingWorker?.state === "activated") { + debug("reloading now!"); + window.location.reload(); + } + }); + } + + // NOTE: The update checker is also responsible for registering the service + // worker in the first place. + useEffect(() => { + debug("Registering service worker"); + serviceWorker.register({ onUpdate }); + }, []); + + return showReload ? ( +
+
{t("sidebar.updateTitle")}
+

{t("sidebar.updateDescription")}

+ + +
+ ) : null; +} diff --git a/src/layout/sidebar/Sidebar.tsx b/src/layout/sidebar/Sidebar.tsx index 1a66859..ff6facc 100644 --- a/src/layout/sidebar/Sidebar.tsx +++ b/src/layout/sidebar/Sidebar.tsx @@ -8,6 +8,7 @@ import { TFunction, useTranslation } from "react-i18next"; import { Link, useLocation } from "react-router-dom"; +import { ServiceWorkerCheck } from "./ServiceWorkerCheck"; import { SidebarTotalBalance } from "./SidebarTotalBalance"; import { SidebarFooter } from "./SidebarFooter"; @@ -62,8 +63,14 @@ width={240} className={"site-sidebar " + (collapsed ? "collapsed" : "")} > + {/* Service worker update checker, which may appear at the top of the + * sidebar if an update is available. */} + + + {/* Total balance */} + {/* Menu items */} {getSidebarItems(t)} @@ -72,6 +79,7 @@ + {/* Credits footer */} ; } diff --git a/src/pages/dashboard/MOTDCard.tsx b/src/pages/dashboard/MOTDCard.tsx index 855d2a5..92538bb 100644 --- a/src/pages/dashboard/MOTDCard.tsx +++ b/src/pages/dashboard/MOTDCard.tsx @@ -1,3 +1,6 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React from "react"; import { Card, Alert } from "antd"; diff --git a/src/pages/dashboard/WhatsNewCard.tsx b/src/pages/dashboard/WhatsNewCard.tsx index 33cd4e9..b177dbe 100644 --- a/src/pages/dashboard/WhatsNewCard.tsx +++ b/src/pages/dashboard/WhatsNewCard.tsx @@ -1,3 +1,6 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt import React from "react"; import { Card } from "antd"; diff --git a/src/service-worker.ts b/src/service-worker.ts new file mode 100644 index 0000000..cb7896d --- /dev/null +++ b/src/service-worker.ts @@ -0,0 +1,63 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt +/// +/* eslint-disable no-restricted-globals */ + +// This service worker can be customized! +// See https://developers.google.com/web/tools/workbox/modules +// for the list of available Workbox modules, or add any other +// code you'd like. +// You can also remove this file if you'd prefer not to use a +// service worker, and the Workbox build step will be skipped. + +import { clientsClaim } from "workbox-core"; +import { precacheAndRoute, createHandlerBoundToURL } from "workbox-precaching"; +import { registerRoute } from "workbox-routing"; + +declare const self: ServiceWorkerGlobalScope; + +clientsClaim(); + +// Precache all of the assets generated by your build process. +// Their URLs are injected into the manifest variable below. +// This variable must be present somewhere in your service worker file, +// even if you decide not to use precaching. See https://cra.link/PWA +precacheAndRoute(self.__WB_MANIFEST); + +// Set up App Shell-style routing, so that all navigation requests +// are fulfilled with your index.html shell. Learn more at +// https://developers.google.com/web/fundamentals/architecture/app-shell +const fileExtensionRegexp = new RegExp("/[^/?]+\\.[^/]+$"); +registerRoute( + // Return false to exempt requests from being fulfilled by index.html. + ({ request, url }: { request: Request; url: URL }) => { + // If this isn't a navigation, skip. + if (request.mode !== "navigate") { + return false; + } + + // If this is a URL that starts with /_, skip. + if (url.pathname.startsWith("/_")) { + return false; + } + + // If this looks like a URL for a resource, because it contains + // a file extension, skip. + if (url.pathname.match(fileExtensionRegexp)) { + return false; + } + + // Return true to signal that we want to use the handler. + return true; + }, + createHandlerBoundToURL(process.env.PUBLIC_URL + "/index.html") +); + +// This allows the web app to trigger skipWaiting via +// registration.waiting.postMessage({type: 'SKIP_WAITING'}) +self.addEventListener("message", (event) => { + if (event.data && event.data.type === "SKIP_WAITING") { + self.skipWaiting(); + } +}); diff --git a/src/utils/consoleWarning.ts b/src/utils/consoleWarning.ts new file mode 100644 index 0000000..a8a1c15 --- /dev/null +++ b/src/utils/consoleWarning.ts @@ -0,0 +1,15 @@ +// Present a warning to the user warning about the dangers of Self-XSS. +// Shamelessly based on Facebook and Discord's warning. +// +// REVIEW: There's an interesting article debating whether a warning is the best +// way forward. Since this is sort of a cryptocurrency app, and we deal +// with far too many midiots on a daily basis, I figured that a +// semi-aggressive warning is probably going to be better in the long +// run. That said, this is still a pretty good read: +// http://booktwo.org/notebook/welcome-js/ +export function showConsoleWarning(): void { + console.log("%cHold up!", "color: CornFlowerBlue; -webkit-text-stroke: 2px black; font-size: 72px; font-weight: bold;"); + console.log("%cDon't paste anything here!", "color: red; font-size: 18px; font-weight: bold;"); + console.log("%cThis console is a feature intended for developers. Pasting code in here may result in you getting scammed, and losing your Krist.", "font-size: 18px; font-weight: bold;"); + console.log("%cIf you know what you're doing, then please, carry on. Check out the GitHub: https://github.com/tmpim/KristWeb2", "font-size: 13px;"); +} diff --git a/src/utils/serviceWorkerRegistration.ts b/src/utils/serviceWorkerRegistration.ts new file mode 100644 index 0000000..a8a2e5a --- /dev/null +++ b/src/utils/serviceWorkerRegistration.ts @@ -0,0 +1,163 @@ +// Copyright (c) 2020-2021 Drew Lemmy +// This file is part of KristWeb 2 under GPL-3.0. +// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt + +// This optional code is used to register a service worker. +// register() is not called by default. + +// This lets the app load faster on subsequent visits in production, and gives +// it offline capabilities. However, it also means that developers (and users) +// will only see deployed updates on subsequent visits to a page, after all the +// existing tabs open on the page have been closed, since previously cached +// resources are updated in the background. + +// To learn more about the benefits of this model and instructions on how to +// opt-in, read https://cra.link/PWA + +import { isLocalhost } from "./"; + +import Debug from "debug"; +const debug = Debug("kristweb:service-worker"); + +interface Config { + onSuccess?: (registration: ServiceWorkerRegistration) => void; + onUpdate?: (registration: ServiceWorkerRegistration) => void; +} + +export function register(config?: Config): void { + debug("SW: %s (%b), %b, %b", process.env.NODE_ENV, process.env.NODE_ENV === "production", "serviceWorker" in navigator, isLocalhost); + + if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { + debug("PUBLIC_URL: %s origin: %s", process.env.PUBLIC_URL, window.location.origin); + + // The URL constructor is available in all browsers that support SW. + const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); + debug("publicUrl: %s", publicUrl); + + if (publicUrl.origin !== window.location.origin) { + // Our service worker won't work if PUBLIC_URL is on a different origin + // from what our page is served on. This might happen if a CDN is used to + // serve assets; see https://github.com/facebook/create-react-app/issues/2374 + debug("PUBLIC_URL is not correct"); + return; + } + + // REVIEW: The following code was previously wrapped in window's `load` + // event listener, but because we register the service worker from + // a component, the window is already loaded. Is this still a valid + // way to register the service worker? + + /*window.addEventListener("load", () => { + debug("window load");*/ + const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; + + if (isLocalhost) { + debug("running as localhost"); + + // This is running on localhost. Let's check if a service worker still exists or not. + checkValidServiceWorker(swUrl, config); + + // Add some additional logging to localhost, pointing developers to the + // service worker/PWA documentation. + navigator.serviceWorker.ready.then(() => { + console.log( + "This web app is being served cache-first by a service " + + "worker. To learn more, visit https://cra.link/PWA" + ); + }); + } else { + // Is not localhost. Just register service worker + registerValidSW(swUrl, config); + } + /*});*/ + } +} + +function registerValidSW(swUrl: string, config?: Config) { + debug("registering valid SW"); + + navigator.serviceWorker + .register(swUrl) + .then((registration) => { + registration.onupdatefound = () => { + const installingWorker = registration.installing; + if (installingWorker === null) { + debug("no installingWorker"); + return; + } + installingWorker.onstatechange = () => { + if (installingWorker.state === "installed") { + if (navigator.serviceWorker.controller) { + // At this point, the updated precached content has been fetched, + // but the previous service worker will still serve the older + // content until all client tabs are closed. + console.log( + "New content is available and will be used when all " + + "tabs for this page are closed. See https://cra.link/PWA." + ); + + // Execute callback + if (config && config.onUpdate) { + config.onUpdate(registration); + } + } else { + // At this point, everything has been precached. + // It's the perfect time to display a + // "Content is cached for offline use." message. + debug("content is cached for offline use"); + + // Execute callback + if (config && config.onSuccess) { + config.onSuccess(registration); + } + } + } + }; + }; + }) + .catch((error) => { + console.error("Error during service worker registration:", error); + }); +} + +function checkValidServiceWorker(swUrl: string, config?: Config) { + // Check if the service worker can be found. If it can't reload the page. + debug("fetching service worker at %s", swUrl); + fetch(swUrl, { + headers: { "Service-Worker": "script" }, + }) + .then((response) => { + // Ensure service worker exists, and that we really are getting a JS file. + const contentType = response.headers.get("content-type"); + if ( + response.status === 404 || + (contentType !== null && contentType.indexOf("javascript") === -1) + ) { + // No service worker found. Probably a different app. Reload the page. + navigator.serviceWorker.ready.then((registration) => { + registration.unregister().then(() => { + window.location.reload(); + }); + }); + } else { + // Service worker found. Proceed as normal. + registerValidSW(swUrl, config); + } + }) + .catch(() => { + console.log("No internet connection found. App is running in offline mode."); + }); +} + +export function unregister(): void { + debug("unregistering service worker"); + if ("serviceWorker" in navigator) { + navigator.serviceWorker.ready + .then((registration) => { + registration.unregister(); + }) + .catch((error) => { + console.error(error.message); + }); + } +} diff --git a/src/utils/setup.ts b/src/utils/setup.ts index fb5e7fa..f1c2111 100644 --- a/src/utils/setup.ts +++ b/src/utils/setup.ts @@ -9,3 +9,6 @@ Debug.formatters.b = (v: boolean) => v ? "true" : "false"; // Buffers as hex strings (%x) Debug.formatters.x = (v: ArrayBufferLike | Uint8Array) => toHex(v); + +import { showConsoleWarning } from "./consoleWarning"; +showConsoleWarning();