diff --git a/ui/package-lock.json b/ui/package-lock.json index 74b0d61..b6aa9a3 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -42,6 +42,7 @@ "react-dnd-touch-backend": "^15.1.1", "react-dom": "^17.0.2", "react-error-boundary": "^3.1.3", + "react-hook-form": "^7.38.0", "react-router-dom": "^5.2.0", "slugify": "^1.6.0", "urbit-ob": "^5.0.1", @@ -6285,6 +6286,21 @@ "react": ">=16.13.1" } }, + "node_modules/react-hook-form": { + "version": "7.38.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.38.0.tgz", + "integrity": "sha512-gxWW1kMeru9xR1GoR+Iw4hA+JBOM3SHfr4DWCUKY0xc7Vv1MLsF109oHtBeWl9shcyPFx67KHru44DheN0XY5A==", + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-is": { "version": "16.13.1", "license": "MIT" @@ -11775,6 +11791,12 @@ "@babel/runtime": "^7.12.5" } }, + "react-hook-form": { + "version": "7.38.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.38.0.tgz", + "integrity": "sha512-gxWW1kMeru9xR1GoR+Iw4hA+JBOM3SHfr4DWCUKY0xc7Vv1MLsF109oHtBeWl9shcyPFx67KHru44DheN0XY5A==", + "requires": {} + }, "react-is": { "version": "16.13.1" }, diff --git a/ui/package.json b/ui/package.json index 53336e9..0496eb6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -49,6 +49,7 @@ "react-dnd-touch-backend": "^15.1.1", "react-dom": "^17.0.2", "react-error-boundary": "^3.1.3", + "react-hook-form": "^7.38.0", "react-router-dom": "^5.2.0", "slugify": "^1.6.0", "urbit-ob": "^5.0.1", diff --git a/ui/public/mockServiceWorker.js b/ui/public/mockServiceWorker.js index 0966a9d..ab63a84 100644 --- a/ui/public/mockServiceWorker.js +++ b/ui/public/mockServiceWorker.js @@ -2,22 +2,21 @@ /* tslint:disable */ /** - * Mock Service Worker (0.39.2). + * Mock Service Worker (0.47.4). * @see https://github.com/mswjs/msw * - Please do NOT modify this file. * - Please do NOT serve this file on production. */ -const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929' -const bypassHeaderName = 'x-msw-bypass' +const INTEGRITY_CHECKSUM = 'b3066ef78c2f9090b4ce87e874965995' const activeClientIds = new Set() self.addEventListener('install', function () { - return self.skipWaiting() + self.skipWaiting() }) -self.addEventListener('activate', async function (event) { - return self.clients.claim() +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) }) self.addEventListener('message', async function (event) { @@ -33,7 +32,9 @@ self.addEventListener('message', async function (event) { return } - const allClients = await self.clients.matchAll() + const allClients = await self.clients.matchAll({ + type: 'window', + }) switch (event.data) { case 'KEEPALIVE_REQUEST': { @@ -83,161 +84,6 @@ self.addEventListener('message', async function (event) { } }) -// Resolve the "main" client for the given event. -// Client that issues a request doesn't necessarily equal the client -// that registered the worker. It's with the latter the worker should -// communicate with during the response resolving phase. -async function resolveMainClient(event) { - const client = await self.clients.get(event.clientId) - - if (client.frameType === 'top-level') { - return client - } - - const allClients = await self.clients.matchAll() - - return allClients - .filter((client) => { - // Get only those clients that are currently visible. - return client.visibilityState === 'visible' - }) - .find((client) => { - // Find the client ID that's recorded in the - // set of clients that have registered the worker. - return activeClientIds.has(client.id) - }) -} - -async function handleRequest(event, requestId) { - const client = await resolveMainClient(event) - const response = await getResponse(event, client, requestId) - - // Send back the response clone for the "response:*" life-cycle events. - // Ensure MSW is active and ready to handle the message, otherwise - // this message will pend indefinitely. - if (client && activeClientIds.has(client.id)) { - ;(async function () { - const clonedResponse = response.clone() - sendToClient(client, { - type: 'RESPONSE', - payload: { - requestId, - type: clonedResponse.type, - ok: clonedResponse.ok, - status: clonedResponse.status, - statusText: clonedResponse.statusText, - body: - clonedResponse.body === null ? null : await clonedResponse.text(), - headers: serializeHeaders(clonedResponse.headers), - redirected: clonedResponse.redirected, - }, - }) - })() - } - - return response -} - -async function getResponse(event, client, requestId) { - const { request } = event - const requestClone = request.clone() - const getOriginalResponse = () => fetch(requestClone) - - // Bypass mocking when the request client is not active. - if (!client) { - return getOriginalResponse() - } - - // Bypass initial page load requests (i.e. static assets). - // The absence of the immediate/parent client in the map of the active clients - // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet - // and is not ready to handle requests. - if (!activeClientIds.has(client.id)) { - return await getOriginalResponse() - } - - // Bypass requests with the explicit bypass header - if (requestClone.headers.get(bypassHeaderName) === 'true') { - const cleanRequestHeaders = serializeHeaders(requestClone.headers) - - // Remove the bypass header to comply with the CORS preflight check. - delete cleanRequestHeaders[bypassHeaderName] - - const originalRequest = new Request(requestClone, { - headers: new Headers(cleanRequestHeaders), - }) - - return fetch(originalRequest) - } - - // Send the request to the client-side MSW. - const reqHeaders = serializeHeaders(request.headers) - const body = await request.text() - - const clientMessage = await sendToClient(client, { - type: 'REQUEST', - payload: { - id: requestId, - url: request.url, - method: request.method, - headers: reqHeaders, - cache: request.cache, - mode: request.mode, - credentials: request.credentials, - destination: request.destination, - integrity: request.integrity, - redirect: request.redirect, - referrer: request.referrer, - referrerPolicy: request.referrerPolicy, - body, - bodyUsed: request.bodyUsed, - keepalive: request.keepalive, - }, - }) - - switch (clientMessage.type) { - case 'MOCK_SUCCESS': { - return delayPromise( - () => respondWithMock(clientMessage), - clientMessage.payload.delay, - ) - } - - case 'MOCK_NOT_FOUND': { - return getOriginalResponse() - } - - case 'NETWORK_ERROR': { - const { name, message } = clientMessage.payload - const networkError = new Error(message) - networkError.name = name - - // Rejecting a request Promise emulates a network error. - throw networkError - } - - case 'INTERNAL_ERROR': { - const parsedBody = JSON.parse(clientMessage.payload.body) - - console.error( - `\ -[MSW] Uncaught exception in the request handler for "%s %s": - -${parsedBody.location} - -This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ -`, - request.method, - request.url, - ) - - return respondWithMock(clientMessage) - } - } - - return getOriginalResponse() -} - self.addEventListener('fetch', function (event) { const { request } = event const accept = request.headers.get('accept') || '' @@ -265,9 +111,10 @@ self.addEventListener('fetch', function (event) { return } - const requestId = uuidv4() + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2) - return event.respondWith( + event.respondWith( handleRequest(event, requestId).catch((error) => { if (error.name === 'NetworkError') { console.warn( @@ -290,14 +137,142 @@ self.addEventListener('fetch', function (event) { ) }) -function serializeHeaders(headers) { - const reqHeaders = {} - headers.forEach((value, name) => { - reqHeaders[name] = reqHeaders[name] - ? [].concat(reqHeaders[name]).concat(value) - : value +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event) + const response = await getResponse(event, client, requestId) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + ;(async function () { + const clonedResponse = response.clone() + sendToClient(client, { + type: 'RESPONSE', + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }) + })() + } + + return response +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (client.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', }) - return reqHeaders + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +async function getResponse(event, client, requestId) { + const { request } = event + const clonedRequest = request.clone() + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()) + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers['x-msw-bypass'] + + return fetch(clonedRequest, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get('x-msw-bypass') === 'true') { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'MOCK_NOT_FOUND': { + return passthrough() + } + + case 'NETWORK_ERROR': { + const { name, message } = clientMessage.data + const networkError = new Error(message) + networkError.name = name + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError + } + } + + return passthrough() } function sendToClient(client, message) { @@ -312,27 +287,17 @@ function sendToClient(client, message) { resolve(event.data) } - client.postMessage(JSON.stringify(message), [channel.port2]) + client.postMessage(message, [channel.port2]) }) } -function delayPromise(cb, duration) { +function sleep(timeMs) { return new Promise((resolve) => { - setTimeout(() => resolve(cb()), duration) + setTimeout(resolve, timeMs) }) } -function respondWithMock(clientMessage) { - return new Response(clientMessage.payload.body, { - ...clientMessage.payload, - headers: clientMessage.payload.headers, - }) -} - -function uuidv4() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { - const r = (Math.random() * 16) | 0 - const v = c == 'x' ? r : (r & 0x3) | 0x8 - return v.toString(16) - }) +async function respondWithMock(response) { + await sleep(response.delay) + return new Response(response.body, response) } diff --git a/ui/src/components/icons/SlidersIcon.tsx b/ui/src/components/icons/SlidersIcon.tsx new file mode 100644 index 0000000..0b32ab3 --- /dev/null +++ b/ui/src/components/icons/SlidersIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { IconProps } from './icon'; + +export default function SlidersIcon({ className }: IconProps) { + return ( + + + + ); +} diff --git a/ui/src/preferences/StoragePrefs.tsx b/ui/src/preferences/StoragePrefs.tsx index 0d7cb25..e2c30ec 100644 --- a/ui/src/preferences/StoragePrefs.tsx +++ b/ui/src/preferences/StoragePrefs.tsx @@ -1,14 +1,17 @@ import React, { useCallback, useState, FormEvent, useEffect } from 'react'; import api from '../state/api'; import { - addBucket, setAccessKeyId, setCurrentBucket, setEndpoint, setSecretAccessKey, } from '@urbit/api'; +import { useForm } from 'react-hook-form'; +import cn from 'classnames'; import { useAsyncCall } from '../logic/useAsyncCall'; import { useStorageState } from '../state/storage'; +import { Button } from '../components/Button'; +import { Spinner } from '../components/Spinner'; interface CredentialsSubmit { endpoint: string; @@ -18,85 +21,125 @@ interface CredentialsSubmit { } export const StoragePrefs = () => { - const { s3, ...storageState } = useStorageState(); + const { s3, loaded, ...storageState } = useStorageState(); - useEffect(() => { - useStorageState.getState().initialize(api); - }, []); + const { + register, + handleSubmit, + reset, + formState: { isSubmitting, isDirty, isValid, isSubmitSuccessful }, + } = useForm({ + mode: 'onChange', + }); const { call: addS3Credentials, status } = useAsyncCall( useCallback(async (data: CredentialsSubmit) => { api.poke(setEndpoint(data.endpoint)); api.poke(setAccessKeyId(data.accessId)); api.poke(setSecretAccessKey(data.accessSecret)); - api.poke(addBucket(data.bucket)); api.poke(setCurrentBucket(data.bucket)); }, []) ); + useEffect(() => { + useStorageState.getState().initialize(api); + }, []); + + useEffect(() => { + loaded && reset(); + }, [loaded, reset]); + return (

Remote Storage

-
+

Configure your urbit to enable uploading your own images or other files in Urbit applications.

+ Read more about setting up S3 storage in the{' '} - Read more about setting up S3 storage - {' '} - on Urbit.org. + Urbit Operator's Manual + + .

- -
- - -
-
- - -
-
- - -
-
- - -
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
); }; diff --git a/ui/src/preferences/SystemPreferences.tsx b/ui/src/preferences/SystemPreferences.tsx index a1a3e2e..9ba1ba7 100644 --- a/ui/src/preferences/SystemPreferences.tsx +++ b/ui/src/preferences/SystemPreferences.tsx @@ -29,6 +29,7 @@ import BellIcon from '../components/icons/BellIcon'; import BurstIcon from '../components/icons/BurstIcon'; import PencilIcon from '../components/icons/PencilIcon'; import ForwardSlashIcon from '../components/icons/ForwardSlashIcon'; +import SlidersIcon from '../components/icons/SlidersIcon'; import Sig16Icon from '../components/icons/Sig16Icon'; import { useSystemUpdate } from '../logic/useSystemUpdate'; import { Bullet } from '../components/icons/Bullet'; @@ -184,7 +185,7 @@ export const SystemPreferences = ( url={subUrl('storage')} active={matchSub('storage')} > - + Remote Storage diff --git a/ui/src/state/storage.ts b/ui/src/state/storage.ts index 3f11dff..8e83ffa 100644 --- a/ui/src/state/storage.ts +++ b/ui/src/state/storage.ts @@ -76,7 +76,9 @@ export const useStorageState = createState( createSubscription('s3-store', '/all', (e) => { const d = _.get(e, 's3-update', false); if (d) { + console.log(d) reduceStateN(get(), d, reduce); + set({ loaded: true }); } }) ]