Merge pull request #4475 from urbit/lf/settings

settings: move onto settings store
This commit is contained in:
matildepark 2021-02-26 14:56:35 -05:00 committed by GitHub
commit ca6d50da45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
38 changed files with 1148 additions and 344 deletions

View File

@ -1782,31 +1782,26 @@
"dependencies": { "dependencies": {
"@babel/runtime": { "@babel/runtime": {
"version": "7.12.5", "version": "7.12.5",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", "bundled": true,
"integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==",
"requires": { "requires": {
"regenerator-runtime": "^0.13.4" "regenerator-runtime": "^0.13.4"
} }
}, },
"@urbit/eslint-config": { "@urbit/eslint-config": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@urbit/eslint-config/-/eslint-config-1.0.0.tgz", "bundled": true
"integrity": "sha512-Xmzb6MvM7KorlPJEq/hURZZ4BHSVy/7CoQXWogsBSTv5MOZnMqwNKw6yt24k2AO/2UpHwjGptimaNLqFfesJbw=="
}, },
"big-integer": { "big-integer": {
"version": "1.6.48", "version": "1.6.48",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz", "bundled": true
"integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w=="
}, },
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "bundled": true
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
}, },
"regenerator-runtime": { "regenerator-runtime": {
"version": "0.13.7", "version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz", "bundled": true
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
} }
} }
}, },

View File

@ -11,7 +11,7 @@ export default class SettingsApi extends BaseApi<StoreState> {
} }
putBucket(key: Key, bucket: Bucket) { putBucket(key: Key, bucket: Bucket) {
this.storeAction({ return this.storeAction({
'put-bucket': { 'put-bucket': {
'bucket-key': key, 'bucket-key': key,
'bucket': bucket 'bucket': bucket
@ -20,7 +20,7 @@ export default class SettingsApi extends BaseApi<StoreState> {
} }
delBucket(key: Key) { delBucket(key: Key) {
this.storeAction({ return this.storeAction({
'del-bucket': { 'del-bucket': {
'bucket-key': key 'bucket-key': key
} }
@ -38,7 +38,7 @@ export default class SettingsApi extends BaseApi<StoreState> {
} }
delEntry(buc: Key, key: Key) { delEntry(buc: Key, key: Key) {
this.storeAction({ return this.storeAction({
'put-entry': { 'put-entry': {
'bucket-key': buc, 'bucket-key': buc,
'entry-key': key 'entry-key': key
@ -47,8 +47,10 @@ export default class SettingsApi extends BaseApi<StoreState> {
} }
async getAll() { async getAll() {
const data = await this.scry('settings-store', '/all'); const { all } = await this.scry("settings-store", "/all");
this.store.handleEvent({ data: { 'settings-data': data.all } }); this.store.handleEvent({data:
{"settings-data": { all } }
});
} }
async getBucket(bucket: Key) { async getBucket(bucket: Key) {

View File

@ -0,0 +1,72 @@
import useLocalState, { LocalState } from "~/logic/state/local";
import useSettingsState from "~/logic/state/settings";
import GlobalApi from "../api/global";
import { BackgroundConfig, RemoteContentPolicy } from "~/types";
const getBackgroundString = (bg: BackgroundConfig) => {
if (bg?.type === "url") {
return bg.url;
} else if (bg?.type === "color") {
return bg.color;
} else {
return "";
}
};
export function useMigrateSettings(api: GlobalApi) {
const local = useLocalState();
const { display, remoteContentPolicy, calm } = useSettingsState();
return async () => {
if (!localStorage?.has("localReducer")) {
return;
}
let promises: Promise<any>[] = [];
if (local.hideAvatars !== calm.hideAvatars) {
promises.push(
api.settings.putEntry("calm", "hideAvatars", local.hideAvatars)
);
}
if (local.hideNicknames !== calm.hideNicknames) {
promises.push(
api.settings.putEntry("calm", "hideNicknames", local.hideNicknames)
);
}
if (
local?.background?.type &&
display.background !== getBackgroundString(local.background)
) {
promises.push(
api.settings.putEntry(
"display",
"background",
getBackgroundString(local.background)
)
);
promises.push(
api.settings.putEntry(
"display",
"backgroundType",
local.background?.type
)
);
}
Object.keys(local.remoteContentPolicy).forEach((_key) => {
const key = _key as keyof RemoteContentPolicy;
const localVal = local.remoteContentPolicy[key];
if (localVal !== remoteContentPolicy[key]) {
promises.push(
api.settings.putEntry("remoteContentPolicy", key, localVal)
);
}
});
await Promise.all(promises);
localStorage.removeItem("localReducer");
};
}

View File

@ -1,7 +1,7 @@
import { cite } from '~/logic/lib/util'; import { cite } from '~/logic/lib/util';
import { isChannelAdmin } from '~/logic/lib/group'; import { isChannelAdmin } from '~/logic/lib/group';
const indexes = new Map([ const makeIndexes = () => new Map([
['ships', []], ['ships', []],
['commands', []], ['commands', []],
['subscriptions', []], ['subscriptions', []],
@ -70,18 +70,27 @@ const appIndex = function (apps) {
return applications; return applications;
}; };
const otherIndex = function() { const otherIndex = function(config) {
const other = []; const other = [];
other.push(result('My Channels', '/~landscape/home', 'home', null)); const idx = {
other.push(result('Notifications', '/~notifications', 'inbox', null)); mychannel: result('My Channels', '/~landscape/home', 'home', null),
other.push(result('Profile and Settings', `/~profile/~${window.ship}`, 'profile', null)); updates: result('Notifications', '/~notifications', 'inbox', null),
other.push(result('Messages', '/~landscape/messages', 'messages', null)); profile: result('Profile', `/~profile/~${window.ship}`, 'profile', null),
other.push(result('Log Out', '/~/logout', 'logout', null)); messages: result('Messages', '/~landscape/messages', 'messages', null),
logout: result('Log Out', '/~/logout', 'logout', null)
};
for(let cat of config.categories) {
if(idx[cat]) {
other.push(idx[cat]);
}
}
return other; return other;
}; };
export default function index(contacts, associations, apps, currentGroup, groups) { export default function index(contacts, associations, apps, currentGroup, groups, hide) {
const indexes = makeIndexes();
indexes.set('ships', shipIndex(contacts)); indexes.set('ships', shipIndex(contacts));
// all metadata from all apps is indexed // all metadata from all apps is indexed
// into subscriptions and landscape // into subscriptions and landscape
@ -141,7 +150,7 @@ export default function index(contacts, associations, apps, currentGroup, groups
indexes.set('subscriptions', subscriptions); indexes.set('subscriptions', subscriptions);
indexes.set('groups', landscape); indexes.set('groups', landscape);
indexes.set('apps', appIndex(apps)); indexes.set('apps', appIndex(apps));
indexes.set('other', otherIndex()); indexes.set('other', otherIndex(hide));
return indexes; return indexes;
}; };

View File

@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import _ from 'lodash'; import _ from "lodash";
import f, { memoize } from 'lodash/fp'; import f, { memoize } from "lodash/fp";
import bigInt, { BigInteger } from 'big-integer'; import bigInt, { BigInteger } from "big-integer";
import { Contact } from '@urbit/api'; import { Contact } from '~/types';
import useLocalState from '../state/local'; import useSettingsState from '../state/settings';
export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i; export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i;
@ -376,8 +376,8 @@ export function pluralize(text: string, isPlural = false, vowel = false) {
// Hide is an optional second parameter for when this function is used in class components // Hide is an optional second parameter for when this function is used in class components
export function useShowNickname(contact: Contact | null, hide?: boolean): boolean { export function useShowNickname(contact: Contact | null, hide?: boolean): boolean {
const hideNicknames = typeof hide !== 'undefined' ? hide : useLocalState(state => state.hideNicknames); const hideNicknames = typeof hide !== 'undefined' ? hide : useSettingsState(state => state.calm.hideNicknames);
return Boolean(contact && contact.nickname && !hideNicknames); return !!(contact && contact.nickname && !hideNicknames);
} }
interface useHoveringInterface { interface useHoveringInterface {

View File

@ -1,77 +1,83 @@
import _ from 'lodash'; import _ from 'lodash';
import { StoreState } from '../../store/type'; import { SettingsUpdate } from '~/types/settings';
import { import useSettingsState, { SettingsStateZus } from "~/logic/state/settings";
SettingsUpdate import produce from 'immer';
} from '@urbit/api/settings';
type SettingsState = Pick<StoreState, 'settings'>; export default class SettingsStateZusettingsReducer{
reduce(json: any) {
export default class SettingsReducer<S extends SettingsState> { const old = useSettingsState.getState();
reduce(json: Cage, state: S) { const newState = produce(old, state => {
let data = json['settings-event']; let data = json["settings-event"];
if (data) { if (data) {
this.putBucket(data, state); console.log(data);
this.delBucket(data, state); this.putBucket(data, state);
this.putEntry(data, state); this.delBucket(data, state);
this.delEntry(data, state); this.putEntry(data, state);
} this.delEntry(data, state);
data = json['settings-data']; }
if (data) { data = json["settings-data"];
this.getAll(data, state); if (data) {
this.getBucket(data, state); console.log(data);
this.getEntry(data, state); this.getAll(data, state);
} this.getBucket(data, state);
this.getEntry(data, state);
}
});
useSettingsState.setState(newState);
} }
putBucket(json: SettingsUpdate, state: S) { putBucket(json: SettingsUpdate, state: SettingsStateZus) {
const data = _.get(json, 'put-bucket', false); const data = _.get(json, 'put-bucket', false);
if (data) { if (data) {
state.settings[data['bucket-key']] = data.bucket; state[data["bucket-key"]] = data.bucket;
} }
} }
delBucket(json: SettingsUpdate, state: S) { delBucket(json: SettingsUpdate, state: SettingsStateZus) {
const data = _.get(json, 'del-bucket', false); const data = _.get(json, 'del-bucket', false);
if (data) { if (data) {
delete state.settings[data['bucket-key']]; delete settings[data['bucket-key']];
} }
} }
putEntry(json: SettingsUpdate, state: S) { putEntry(json: SettingsUpdate, state: SettingsStateZus) {
const data = _.get(json, 'put-entry', false); const data = _.get(json, 'put-entry', false);
if (data) { if (data) {
if (!state.settings[data['bucket-key']]) { if (!state[data["bucket-key"]]) {
state.settings[data['bucket-key']] = {}; state[data["bucket-key"]] = {};
} }
state.settings[data['bucket-key']][data['entry-key']] = data.value; state[data["bucket-key"]][data["entry-key"]] = data.value;
} }
} }
delEntry(json: SettingsUpdate, state: S) { delEntry(json: SettingsUpdate, state: SettingsStateZus) {
const data = _.get(json, 'del-entry', false); const data = _.get(json, 'del-entry', false);
if (data) { if (data) {
delete state.settings[data['bucket-key']][data['entry-key']]; delete state[data["bucket-key"]][data["entry-key"]];
} }
} }
getAll(json: any, state: S) { getAll(json: any, state: SettingsStateZus) {
state.settings = json; const data = _.get(json, 'all');
if(data) {
_.merge(state, data);
}
} }
getBucket(json: any, state: S) { getBucket(json: any, state: SettingsStateZus) {
const key = _.get(json, 'bucket-key', false); const key = _.get(json, 'bucket-key', false);
const bucket = _.get(json, 'bucket', false); const bucket = _.get(json, 'bucket', false);
if (key && bucket) { if (key && bucket) {
state.settings[key] = bucket; state[key] = bucket;
} }
} }
getEntry(json: any, state: S) { getEntry(json: any, state: SettingsStateZus) {
const bucketKey = _.get(json, 'bucket-key', false); const bucketKey = _.get(json, 'bucket-key', false);
const entryKey = _.get(json, 'entry-key', false); const entryKey = _.get(json, 'entry-key', false);
const entry = _.get(json, 'entry', false); const entry = _.get(json, 'entry', false);
if (bucketKey && entryKey && entry) { if (bucketKey && entryKey && entry) {
state.settings[bucketKey][entryKey] = entry; state[bucketKey][entryKey] = entry;
} }
} }
} }

View File

@ -3,17 +3,20 @@ import f from 'lodash/fp';
import create, { State } from 'zustand'; import create, { State } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import produce from 'immer'; import produce from 'immer';
import { BackgroundConfig, RemoteContentPolicy, TutorialProgress, tutorialProgress } from '~/types/local-update'; import { BackgroundConfig, RemoteContentPolicy, TutorialProgress, tutorialProgress, LeapCategories } from "~/types/local-update";
export interface LocalState extends State {
export interface LocalState {
hideAvatars: boolean; hideAvatars: boolean;
hideNicknames: boolean; hideNicknames: boolean;
remoteContentPolicy: RemoteContentPolicy; remoteContentPolicy: RemoteContentPolicy;
tutorialProgress: TutorialProgress; tutorialProgress: TutorialProgress;
hideGroups: boolean;
tutorialRef: HTMLElement | null, tutorialRef: HTMLElement | null,
hideTutorial: () => void; hideTutorial: () => void;
nextTutStep: () => void; nextTutStep: () => void;
prevTutStep: () => void; prevTutStep: () => void;
hideLeapCats: LeapCategories[];
setTutorialRef: (el: HTMLElement | null) => void; setTutorialRef: (el: HTMLElement | null) => void;
dark: boolean; dark: boolean;
background: BackgroundConfig; background: BackgroundConfig;
@ -21,15 +24,20 @@ export interface LocalState extends State {
suspendedFocus?: HTMLElement; suspendedFocus?: HTMLElement;
toggleOmnibox: () => void; toggleOmnibox: () => void;
set: (fn: (state: LocalState) => void) => void set: (fn: (state: LocalState) => void) => void
} };
export const selectLocalState =
type LocalStateZus = LocalState & State;
export const selectLocalState =
<K extends keyof LocalState>(keys: K[]) => f.pick<LocalState, K>(keys); <K extends keyof LocalState>(keys: K[]) => f.pick<LocalState, K>(keys);
const useLocalState = create<LocalState>(persist((set, get) => ({ const useLocalState = create<LocalStateZus>(persist((set, get) => ({
dark: false, dark: false,
background: undefined, background: undefined,
hideAvatars: false, hideAvatars: false,
hideNicknames: false, hideNicknames: false,
hideLeapCats: [],
hideGroups: false,
tutorialProgress: 'hidden', tutorialProgress: 'hidden',
tutorialRef: null, tutorialRef: null,
setTutorialRef: (el: HTMLElement | null) => set(produce((state) => { setTutorialRef: (el: HTMLElement | null) => set(produce((state) => {

View File

@ -0,0 +1,68 @@
import React, { ReactNode } from "react";
import f from 'lodash/fp';
import create, { State } from 'zustand';
import { persist } from 'zustand/middleware';
import produce from 'immer';
import { BackgroundConfig, RemoteContentPolicy, TutorialProgress, tutorialProgress, LeapCategories, leapCategories } from "~/types/local-update";
export interface SettingsState {
display: {
backgroundType: 'none' | 'url' | 'color';
background?: string;
dark: boolean;
};
calm: {
hideNicknames: boolean;
hideAvatars: boolean;
hideUnreads: boolean;
hideGroups: boolean;
};
remoteContentPolicy: RemoteContentPolicy;
leap: {
categories: LeapCategories[];
}
set: (fn: (state: SettingsState) => void) => void
};
export type SettingsStateZus = SettingsState & State;
export const selectSettingsState =
<K extends keyof SettingsState>(keys: K[]) => f.pick<SettingsState, K>(keys);
export const selectCalmState = (s: SettingsState) => s.calm;
const useSettingsState = create<SettingsStateZus>((set) => ({
display: {
backgroundType: 'none',
background: undefined,
dark: false,
},
calm: {
hideNicknames: false,
hideAvatars: false,
hideUnreads: false,
hideGroups: false
},
remoteContentPolicy: {
imageShown: true,
oembedShown: true,
audioShown: true,
videoShown: true
},
leap: {
categories: leapCategories,
},
set: (fn: (state: SettingsState) => void) => set(produce(fn))
}));
function withSettingsState<P, S extends keyof SettingsState>(Component: any, stateMemberKeys?: S[]) {
return React.forwardRef((props: Omit<P, S>, ref) => {
const localState = stateMemberKeys
? useSettingsState(selectSettingsState(stateMemberKeys))
: useSettingsState();
return <Component ref={ref} {...localState} {...props} />
});
}
export { useSettingsState as default, withSettingsState };

View File

@ -114,7 +114,7 @@ export default class GlobalStore extends BaseStore<StoreState> {
GraphReducer(data, this.state); GraphReducer(data, this.state);
HarkReducer(data, this.state); HarkReducer(data, this.state);
ContactReducer(data, this.state); ContactReducer(data, this.state);
this.settingsReducer.reduce(data, this.state); this.settingsReducer.reduce(data);
GroupViewReducer(data, this.state); GroupViewReducer(data, this.state);
} }
} }

View File

@ -1,5 +1,9 @@
export const tutorialProgress = ['hidden', 'start', 'group-desc', 'channels', 'chat', 'link', 'publish', 'profile', 'leap', 'notifications', 'done', 'exit'] as const; export const tutorialProgress = ['hidden', 'start', 'group-desc', 'channels', 'chat', 'link', 'publish', 'profile', 'leap', 'notifications', 'done', 'exit'] as const;
export const leapCategories = ["mychannel", "messages", "updates", "profile", "logout"] as const;
export type LeapCategories = typeof leapCategories[number];
export type TutorialProgress = typeof tutorialProgress[number]; export type TutorialProgress = typeof tutorialProgress[number];
interface LocalUpdateSetDark { interface LocalUpdateSetDark {
setDark: boolean; setDark: boolean;

View File

@ -28,19 +28,20 @@ import GlobalApi from '~/logic/api/global';
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from '~/logic/lib/util';
import { foregroundFromBackground } from '~/logic/lib/sigil'; import { foregroundFromBackground } from '~/logic/lib/sigil';
import { withLocalState } from '~/logic/state/local'; import { withLocalState } from '~/logic/state/local';
import { withSettingsState } from '~/logic/state/settings';
const Root = styled.div` const Root = withSettingsState(styled.div`
font-family: ${p => p.theme.fonts.sans}; font-family: ${p => p.theme.fonts.sans};
height: 100%; height: 100%;
width: 100%; width: 100%;
padding: 0; padding: 0;
margin: 0; margin: 0;
${p => p.background?.type === 'url' ? ` ${p => p.display.backgroundType === 'url' ? `
background-image: url('${p.background?.url}'); background-image: url('${p.display.background}');
background-size: cover; background-size: cover;
` : p.background?.type === 'color' ? ` ` : p.display.backgroundType === 'color' ? `
background-color: ${p.background.color}; background-color: ${p.display.background};
` : `background-color: ${p.theme.colors.white};` ` : `background-color: ${p.theme.colors.white};`
} }
display: flex; display: flex;
@ -64,7 +65,7 @@ const Root = styled.div`
border-radius: 1rem; border-radius: 1rem;
border: 0px solid transparent; border: 0px solid transparent;
} }
`; `, ['display']);
const StatusBarWithRouter = withRouter(StatusBar); const StatusBarWithRouter = withRouter(StatusBar);
@ -148,7 +149,7 @@ class App extends React.Component {
? <link rel="icon" type="image/svg+xml" href={this.faviconString()} /> ? <link rel="icon" type="image/svg+xml" href={this.faviconString()} />
: null} : null}
</Helmet> </Helmet>
<Root background={background}> <Root>
<Router> <Router>
<TutorialModal api={this.api} /> <TutorialModal api={this.api} />
<ErrorBoundary> <ErrorBoundary>

View File

@ -16,6 +16,7 @@ import {
cite, cite,
writeText, writeText,
useShowNickname, useShowNickname,
useHideAvatar,
useHovering useHovering
} from '~/logic/lib/util'; } from '~/logic/lib/util';
import { import {
@ -32,6 +33,7 @@ import RemoteContent from '~/views/components/RemoteContent';
import { Mention } from '~/views/components/MentionText'; import { Mention } from '~/views/components/MentionText';
import styled from 'styled-components'; import styled from 'styled-components';
import useLocalState from '~/logic/state/local'; import useLocalState from '~/logic/state/local';
import useSettingsState, {selectCalmState} from "~/logic/state/settings";
import Timestamp from '~/views/components/Timestamp'; import Timestamp from '~/views/components/Timestamp';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D'; export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
@ -238,7 +240,7 @@ export const MessageAuthor = ({
const contact = const contact =
`~${msg.author}` in contacts ? contacts[`~${msg.author}`] : false; `~${msg.author}` in contacts ? contacts[`~${msg.author}`] : false;
const showNickname = useShowNickname(contact); const showNickname = useShowNickname(contact);
const { hideAvatars } = useLocalState(({ hideAvatars }) => ({ hideAvatars })); const { hideAvatars } = useSettingsState(selectCalmState);
const shipName = showNickname ? contact.nickname : cite(msg.author); const shipName = showNickname ? contact.nickname : cite(msg.author);
const copyNotice = 'Copied'; const copyNotice = 'Copied';
const color = contact const color = contact

View File

@ -29,6 +29,8 @@ import {
TUTORIAL_CHAT, TUTORIAL_CHAT,
TUTORIAL_LINKS TUTORIAL_LINKS
} from '~/logic/lib/tutorialModal'; } from '~/logic/lib/tutorialModal';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
const ScrollbarLessBox = styled(Box)` const ScrollbarLessBox = styled(Box)`
scrollbar-width: none !important; scrollbar-width: none !important;
@ -38,7 +40,7 @@ const ScrollbarLessBox = styled(Box)`
} }
`; `;
const tutSelector = f.pick(['tutorialProgress', 'nextTutStep']); const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']);
export default function LaunchApp(props) { export default function LaunchApp(props) {
const history = useHistory(); const history = useHistory();
@ -82,6 +84,8 @@ export default function LaunchApp(props) {
}, [query]); }, [query]);
const { tutorialProgress, nextTutStep } = useLocalState(tutSelector); const { tutorialProgress, nextTutStep } = useLocalState(tutSelector);
let { hideGroups } = useLocalState(tutSelector);
!hideGroups ? { hideGroups } = useSettingsState(selectCalmState) : null;
const waiter = useWaitForProps(props); const waiter = useWaitForProps(props);
@ -197,8 +201,9 @@ export default function LaunchApp(props) {
> >
<JoinGroup {...props} /> <JoinGroup {...props} />
</ModalButton> </ModalButton>
{!hideGroups &&
<Groups unreads={props.unreads} groups={props.groups} associations={props.associations} /> (<Groups unreads={props.unreads} groups={props.groups} associations={props.associations} />)
}
</Box> </Box>
<Box alignSelf="flex-start" display={["block", "none"]}>{hashBox}</Box> <Box alignSelf="flex-start" display={["block", "none"]}>{hashBox}</Box>
</ScrollbarLessBox> </ScrollbarLessBox>

View File

@ -9,6 +9,7 @@ import { getUnreadCount, getNotificationCount } from '~/logic/lib/hark';
import Tile from '../components/tiles/tile'; import Tile from '../components/tiles/tile';
import { useTutorialModal } from '~/views/components/useTutorialModal'; import { useTutorialModal } from '~/views/components/useTutorialModal';
import { TUTORIAL_HOST, TUTORIAL_GROUP } from '~/logic/lib/tutorialModal'; import { TUTORIAL_HOST, TUTORIAL_GROUP } from '~/logic/lib/tutorialModal';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
interface GroupsProps { interface GroupsProps {
associations: Associations; associations: Associations;
@ -80,11 +81,12 @@ function Group(props: GroupProps) {
isTutorialGroup, isTutorialGroup,
anchorRef.current anchorRef.current
); );
const { hideUnreads } = useSettingsState(selectCalmState)
return ( return (
<Tile ref={anchorRef} position="relative" bg={isTutorialGroup ? 'lightBlue' : undefined} to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}> <Tile ref={anchorRef} position="relative" bg={isTutorialGroup ? 'lightBlue' : undefined} to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}>
<Col height="100%" justifyContent="space-between"> <Col height="100%" justifyContent="space-between">
<Text>{title}</Text> <Text>{title}</Text>
<Col> {!hideUnreads && (<Col>
{updates > 0 && {updates > 0 &&
(<Text mt="1" color="blue">{updates} update{updates !== 1 && 's'} </Text>) (<Text mt="1" color="blue">{updates} update{updates !== 1 && 's'} </Text>)
} }
@ -92,7 +94,7 @@ function Group(props: GroupProps) {
(<Text color="lightGray">{unreads}</Text>) (<Text color="lightGray">{unreads}</Text>)
} }
</Col> </Col>
)}
</Col> </Col>
</Tile> </Tile>
); );

View File

@ -30,12 +30,10 @@ export default function NotificationPreferences(
mentions: graphConfig.mentions, mentions: graphConfig.mentions,
watchOnSelf: graphConfig.watchOnSelf, watchOnSelf: graphConfig.watchOnSelf,
dnd, dnd,
watching: graphConfig.watching
}; };
const onSubmit = useCallback( const onSubmit = useCallback(
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => { async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
console.log(values);
try { try {
const promises: Promise<any>[] = []; const promises: Promise<any>[] = [];
if (values.mentions !== graphConfig.mentions) { if (values.mentions !== graphConfig.mentions) {

View File

@ -10,7 +10,7 @@ import {
} from "@tlon/indigo-react"; } from "@tlon/indigo-react";
import RichText from '~/views/components/RichText' import RichText from '~/views/components/RichText'
import useLocalState from "~/logic/state/local"; import useSettingsState, {selectCalmState} from "~/logic/state/settings";
import { Sigil } from '~/logic/lib/sigil'; import { Sigil } from '~/logic/lib/sigil';
import { ViewProfile } from './ViewProfile'; import { ViewProfile } from './ViewProfile';
import { EditProfile } from './EditProfile'; import { EditProfile } from './EditProfile';
@ -18,11 +18,11 @@ import { SetStatusBarModal } from '~/views/components/SetStatusBarModal';
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from '~/logic/lib/util';
import { useTutorialModal } from '~/views/components/useTutorialModal'; import { useTutorialModal } from '~/views/components/useTutorialModal';
export function Profile(props: any): ReactElement { export function Profile(props: any): ReactElement {
const { hideAvatars } = useLocalState(({ hideAvatars }) => ({ const { hideAvatars } = useSettingsState(selectCalmState);
hideAvatars const history = useHistory();
}));
const history = useHistory();
if (!props.ship) { if (!props.ship) {
return null; return null;

View File

@ -8,17 +8,16 @@ import {
Text, Text,
Row, Row,
Col, Col,
} from '@tlon/indigo-react'; } from "@tlon/indigo-react";
import RichText from "~/views/components/RichText";
import {GroupLink} from "~/views/components/GroupLink";
import {lengthOrder} from "~/logic/lib/util";
import useSettingsState, {selectCalmState} from "~/logic/state/settings";
import RichText from '~/views/components/RichText';
import { GroupLink } from '~/views/components/GroupLink';
import { lengthOrder } from '~/logic/lib/util';
import useLocalState from '~/logic/state/local';
export function ViewProfile(props: any): ReactElement { export function ViewProfile(props: any) {
const { hideNicknames } = useLocalState(({ hideNicknames }) => ({ const history = useHistory();
hideNicknames const { hideNicknames } = useSettingsState(selectCalmState);
}));
const { api, contact, nacked, isPublic, ship, associations, groups } = props; const { api, contact, nacked, isPublic, ship, associations, groups } = props;
return ( return (

View File

@ -4,9 +4,10 @@ import Helmet from 'react-helmet';
import { Box } from '@tlon/indigo-react'; import { Box } from '@tlon/indigo-react';
import { Profile } from './components/Profile'; import { Profile } from "./components/Profile";
export default function ProfileScreen(props: any) { export default function ProfileScreen(props: any) {
const { dark } = props;
return ( return (
<> <>
<Helmet defer={false}> <Helmet defer={false}>

View File

@ -0,0 +1,11 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Text } from '@tlon/indigo-react';
export function BackButton(props: {}) {
return (
<Link to="/~settings">
<Text fontSize="2" fontWeight="medium">{"<- Back to System Preferences"}</Text>
</Link>
);
}

View File

@ -1,6 +1,8 @@
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import { import {
Box,
Text,
Row, Row,
Label, Label,
Col, Col,
@ -26,31 +28,38 @@ export function BackgroundPicker({
s3: S3State; s3: S3State;
}): ReactElement { }): ReactElement {
const rowSpace = { my: 0, alignItems: 'center' }; const rowSpace = { my: 0, alignItems: 'center' };
const radioProps = { my: 4, mr: 4, name: 'bgType' }; const colProps = { my: 3, mr: 4, gapY: 1 };
return ( return (
<Col> <Col>
<Label mb="2">Landscape Background</Label> <Label>Landscape Background</Label>
<Row flexWrap="wrap" {...rowSpace}> <Row flexWrap="wrap" {...rowSpace}>
<Radio {...radioProps} label="Image" id="url" /> <Col {...colProps}>
{bgType === 'url' && ( <Radio mb="1" name="bgType" label="Image" id="url" />
<Text ml="5" gray>Set an image background</Text>
<ImageInput <ImageInput
ml="3" ml="5"
api={api} api={api}
s3={s3} s3={s3}
id="bgUrl" id="bgUrl"
placeholder="Drop or upload a file, or paste a link here"
name="bgUrl" name="bgUrl"
label="URL" url={bgUrl || ""}
url={bgUrl || ''}
/> />
)} </Col>
</Row> </Row>
<Row {...rowSpace}> <Row {...rowSpace}>
<Radio label="Color" id="color" {...radioProps} /> <Col {...colProps}>
{bgType === 'color' && ( <Radio mb="1" label="Color" id="color" name="bgType" />
<ColorInput id="bgColor" label="Color" /> <Text ml="5" gray>Set a hex-based background</Text>
)} <ColorInput placeholder="FFFFFF" ml="5" id="bgColor" />
</Col>
</Row> </Row>
<Radio label="None" id="none" {...radioProps} /> <Radio
my="3"
caption="Your home screen will simply render as its respective day/night mode color"
name="bgType"
label="None"
id="none" />
</Col> </Col>
); );
} }

View File

@ -1,5 +1,5 @@
import React, { ReactElement, useCallback } from 'react'; import React, { ReactElement, useCallback, useState } from "react";
import { Formik } from 'formik'; import { Formik, FormikHelpers } from 'formik';
import { import {
ManagedTextInputField as Input, ManagedTextInputField as Input,
@ -10,8 +10,9 @@ import {
Menu, Menu,
MenuButton, MenuButton,
MenuList, MenuList,
MenuItem MenuItem,
} from '@tlon/indigo-react'; Row,
} from "@tlon/indigo-react";
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
@ -26,9 +27,12 @@ export function BucketList({
}): ReactElement { }): ReactElement {
const _buckets = Array.from(buckets); const _buckets = Array.from(buckets);
const [adding, setAdding] = useState(false);
const onSubmit = useCallback( const onSubmit = useCallback(
(values: { newBucket: string }) => { (values: { newBucket: string }, actions: FormikHelpers<any>) => {
api.s3.addBucket(values.newBucket); api.s3.addBucket(values.newBucket);
actions.resetForm({ values: { newBucket: "" } });
}, },
[api] [api]
); );
@ -67,7 +71,7 @@ export function BucketList({
alignItems="center" alignItems="center"
borderRadius={1} borderRadius={1}
border={1} border={1}
borderColor="washedGray" borderColor="lightGray"
fontSize={1} fontSize={1}
pl={2} pl={2}
mb={2} mb={2}
@ -91,10 +95,27 @@ export function BucketList({
)} )}
</Box> </Box>
))} ))}
<Input mt="2" label="New Bucket" id="newBucket" /> {adding && (
<Button mt="2" style={{ cursor: 'pointer' }} borderColor="washedGray" type="submit"> <Input
Add placeholder="Enter your new bucket"
</Button> mt="2"
label="New Bucket"
id="newBucket"
/>
)}
<Row gapX="3" mt="3">
<Button type="button" onClick={() => setAdding(false)}>
Cancel
</Button>
<Button
width="fit-content"
primary
type={adding ? "submit" : "button"}
onClick={() => setAdding((s) => !s)}
>
{adding ? "Submit" : "Add new bucket"}
</Button>
</Row>
</Form> </Form>
</Formik> </Formik>
); );

View File

@ -0,0 +1,137 @@
import React, {useCallback} from "react";
import {
Box,
ManagedToggleSwitchField as Toggle,
Button,
Col,
Text,
} from "@tlon/indigo-react";
import { Formik, Form, FormikHelpers } from "formik";
import * as Yup from "yup";
import { BackButton } from "./BackButton";
import useSettingsState, {selectSettingsState} from "~/logic/state/settings";
import GlobalApi from "~/logic/api/global";
import {AsyncButton} from "~/views/components/AsyncButton";
interface FormSchema {
hideAvatars: boolean;
hideNicknames: boolean;
hideUnreads: boolean;
hideGroups: boolean;
imageShown: boolean;
audioShown: boolean;
oembedShown: boolean;
videoShown: boolean;
}
const settingsSel = selectSettingsState(["calm", "remoteContentPolicy"]);
export function CalmPrefs(props: {
api: GlobalApi;
}) {
const { api } = props;
const {
calm: {
hideAvatars,
hideNicknames,
hideUnreads,
hideGroups
},
remoteContentPolicy: {
imageShown,
videoShown,
oembedShown,
audioShown,
}
} = useSettingsState(settingsSel);
const initialValues: FormSchema = {
hideAvatars,
hideNicknames,
hideUnreads,
hideGroups,
imageShown,
videoShown,
oembedShown,
audioShown,
};
const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => {
await Promise.all([
api.settings.putEntry('calm', 'hideAvatars', v.hideAvatars),
api.settings.putEntry('calm', 'hideNicknames', v.hideNicknames),
api.settings.putEntry('calm', 'hideUnreads', v.hideUnreads),
api.settings.putEntry('calm', 'hideGroups', v.hideGroups),
api.settings.putEntry('remoteContentPolicy', 'imageShown', v.imageShown),
api.settings.putEntry('remoteContentPolicy', 'videoShown', v.videoShown),
api.settings.putEntry('remoteContentPolicy', 'audioShown', v.audioShown),
api.settings.putEntry('remoteContentPolicy', 'oembedShown', v.oembedShown),
]);
actions.setStatus({ success: null });
}, [api]);
return (
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<Form>
<Col borderBottom="1" borderBottomColor="washedGray" p="5" pt="4" gapY="5">
<Col gapY="1">
<Text color="black" fontSize={2} fontWeight="medium">
CalmEngine
</Text>
<Text gray>
Modulate various elements across Landscape to maximize calmness
</Text>
</Col>
<Text fontWeight="medium">Home screen</Text>
<Toggle
label="Hide unread counts"
id="hideUnreads"
caption="Do not show unread counts on group tiles"
/>
<Toggle
label="Hide group tiles"
id="hideGroups"
caption="Do not show group tiles"
/>
<Text fontWeight="medium">User-set identity</Text>
<Toggle
label="Disable avatars"
id="hideAvatars"
caption="Do not show user-set avatars"
/>
<Toggle
label="Disable nicknames"
id="hideNicknames"
caption="Do not show user-set nicknames"
/>
<Text fontWeight="medium">Remote Content</Text>
<Toggle
label="Load images"
id="imageShown"
caption="Images will be replaced with an inline placeholder that must be clicked to be viewed"
/>
<Toggle
label="Load audio files"
id="audioShown"
caption="Audio content will be replaced with an inline placeholder that must be clicked to be viewed"
/>
<Toggle
label="Load video files"
id="videoShown"
caption="Video content will be replaced with an inline placeholder that must be clicked to be viewed"
/>
<Toggle
label="Load embedded content"
id="oembedShown"
caption="Embedded content may contain scripts that can track you"
/>
<AsyncButton primary width="fit-content" type="submit">
Save
</AsyncButton>
</Col>
</Form>
</Formik>
);
}

View File

@ -1,35 +1,31 @@
import React from 'react'; import React from "react";
import { import {
Box, Col,
ManagedCheckboxField as Checkbox, Text,
Button } from "@tlon/indigo-react";
} from '@tlon/indigo-react'; import { Formik, Form } from "formik";
import { Formik, Form } from 'formik'; import * as Yup from "yup";
import * as Yup from 'yup';
import GlobalApi from '~/logic/api/global'; import GlobalApi from "~/logic/api/global";
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from "~/logic/lib/util";
import { S3State, BackgroundConfig } from '@urbit/api'; import { S3State, BackgroundConfig } from "~/types";
import { BackgroundPicker, BgType } from './BackgroundPicker'; import { BackgroundPicker, BgType } from "./BackgroundPicker";
import useLocalState, { LocalState } from '~/logic/state/local'; import useSettingsState, { SettingsState, selectSettingsState } from "~/logic/state/settings";
import {AsyncButton} from "~/views/components/AsyncButton";
const formSchema = Yup.object().shape({ const formSchema = Yup.object().shape({
bgType: Yup.string() bgType: Yup.string()
.oneOf(['none', 'color', 'url'], 'invalid') .oneOf(["none", "color", "url"], "invalid")
.required('Required'), .required("Required"),
bgUrl: Yup.string().url(), background: Yup.string(),
bgColor: Yup.string(),
avatars: Yup.boolean(),
nicknames: Yup.boolean()
}); });
interface FormSchema { interface FormSchema {
bgType: BgType; bgType: BgType;
bgColor: string | undefined; bgColor: string | undefined;
bgUrl: string | undefined; bgUrl: string | undefined;
avatars: boolean;
nicknames: boolean;
} }
interface DisplayFormProps { interface DisplayFormProps {
@ -37,79 +33,78 @@ interface DisplayFormProps {
s3: S3State; s3: S3State;
} }
const settingsSel = selectSettingsState(["display"]);
export default function DisplayForm(props: DisplayFormProps) { export default function DisplayForm(props: DisplayFormProps) {
const { api, s3 } = props; const { api, s3 } = props;
const { hideAvatars, hideNicknames, background, set: setLocalState } = useLocalState(); const {
display: {
background,
backgroundType,
}
} = useSettingsState(settingsSel);
let bgColor, bgUrl; let bgColor, bgUrl;
if (background?.type === 'url') { if (backgroundType === "url") {
bgUrl = background.url; bgUrl = background;
} }
if (background?.type === 'color') { if (backgroundType === "color") {
bgColor = background.color; bgColor = background;
} }
const bgType = background?.type || 'none'; const bgType = backgroundType || "none";
return ( return (
<Formik <Formik
validationSchema={formSchema} validationSchema={formSchema}
initialValues={ initialValues={
{ {
bgType, bgType: backgroundType,
bgColor: bgColor || '', bgColor: bgColor || "",
bgUrl, bgUrl
avatars: hideAvatars,
nicknames: hideNicknames
} as FormSchema } as FormSchema
} }
onSubmit={(values, actions) => { onSubmit={async (values, actions) => {
const bgConfig: BackgroundConfig = let promises = [] as Promise<any>[];
values.bgType === 'color' promises.push(api.settings.putEntry('display', 'backgroundType', values.bgType));
? { type: 'color', color: `#${uxToHex(values.bgColor || '0x0')}` }
: values.bgType === 'url' promises.push(
? { type: 'url', url: values.bgUrl || '' } api.settings.putEntry('display', 'background',
: undefined; values.bgType === "color"
? `#${uxToHex(values.bgColor || "0x0")}`
: values.bgType === "url"
? values.bgUrl || ""
: false
));
await Promise.all(promises);
actions.setStatus({ success: null });
setLocalState((state: LocalState) => {
state.background = bgConfig;
state.hideAvatars = values.avatars;
state.hideNicknames = values.nicknames;
});
actions.setSubmitting(false);
}} }}
> >
{props => ( {(props) => (
<Form> <Form>
<Box <Col p="5" pt="4" gapY="5">
display="grid" <Col gapY="2">
gridTemplateColumns="100%" <Text color="black" fontSize={2} fontWeight="medium">
gridTemplateRows="auto" Display Preferences
gridRowGap={5} </Text>
> <Text gray>
<Box color="black" fontSize={1} mb={3} fontWeight={900}> Customize visual interfaces across your Landscape
Display Preferences </Text>
</Box> </Col>
<BackgroundPicker <BackgroundPicker
bgType={props.values.bgType} bgType={props.values.bgType}
bgUrl={props.values.bgUrl} bgUrl={props.values.bgUrl}
api={api} api={api}
s3={s3} s3={s3}
/> />
<Checkbox <AsyncButton primary width="fit-content" type="submit">
label="Disable avatars"
id="avatars"
caption="Do not show user-set avatars"
/>
<Checkbox
label="Disable nicknames"
id="nicknames"
caption="Do not show user-set nicknames"
/>
<Button border={1} style={{ cursor: 'pointer' }} borderColor="washedGray" type="submit">
Save Save
</Button> </AsyncButton>
</Box> </Col>
</Form> </Form>
)} )}
</Formik> </Formik>

View File

@ -0,0 +1,100 @@
import React, { useCallback } from "react";
import _ from "lodash";
import {
Col,
Text,
ManagedToggleSwitchField as Toggle,
ManagedCheckboxField,
BaseInput,
} from "@tlon/indigo-react";
import { Form, FormikHelpers, useField, useFormikContext } from "formik";
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
import { BackButton } from "./BackButton";
import GlobalApi from "~/logic/api/global";
import {
NotificationGraphConfig,
LeapCategories,
leapCategories,
} from "~/types";
import useSettingsState, { selectSettingsState } from "~/logic/state/settings";
import { ShuffleFields } from "~/views/components/ShuffleFields";
const labels: Record<LeapCategories, string> = {
mychannel: "My Channel",
updates: "Notifications",
profile: "Profile",
messages: "Messages",
logout: "Log Out",
};
interface FormSchema {
categories: { display: boolean; category: LeapCategories }[];
}
function CategoryCheckbox(props: { index: number }) {
const { index } = props;
const { values } = useFormikContext<FormSchema>();
const cats = values.categories;
const catNameId = `categories[${index}].category`;
const [field] = useField(catNameId);
const { category } = cats[index];
const label = labels[category];
return (
<ManagedCheckboxField id={`categories[${index}].display`} label={label} />
);
}
const settingsSel = selectSettingsState(["leap", "set"]);
export function LeapSettings(props: { api: GlobalApi; }) {
const { api } = props;
const { leap, set: setSettingsState } = useSettingsState(settingsSel);
const categories = leap.categories as LeapCategories[];
const missing = _.difference(leapCategories, categories);
console.log(categories);
const initialValues = {
categories: [
...categories.map((cat) => ({
category: cat,
display: true,
})),
...missing.map((cat) => ({ category: cat, display: false })),
],
};
const onSubmit = async (values: FormSchema) => {
const result = values.categories.reduce(
(acc, { display, category }) => (display ? [...acc, category] : acc),
[] as LeapCategories[]
);
await api.settings.putEntry('leap', 'categories', result);
};
return (
<Col p="5" pt="4" gapY="5">
<Col gapY="1">
<Text fontSize="2" fontWeight="medium">
Leap
</Text>
<Text gray>
Customize Leap ordering, omit modules or results
</Text>
</Col>
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Form>
<Col gapY="4">
<Text fontWeight="medium">
Customize default Leap sections
</Text>
<ShuffleFields name="categories">
{(index, helpers) => <CategoryCheckbox index={index} />}
</ShuffleFields>
</Col>
</Form>
</FormikOnBlur>
</Col>
);
}

View File

@ -0,0 +1,87 @@
import React, { useCallback } from "react";
import {
Col,
Text,
ManagedToggleSwitchField as Toggle,
} from "@tlon/indigo-react";
import { Form, FormikHelpers } from "formik";
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
import { BackButton } from "./BackButton";
import GlobalApi from "~/logic/api/global";
import {NotificationGraphConfig} from "~/types";
interface FormSchema {
mentions: boolean;
dnd: boolean;
watchOnSelf: boolean;
}
export function NotificationPreferences(props: {
api: GlobalApi;
graphConfig: NotificationGraphConfig;
dnd: boolean;
}) {
const { graphConfig, api, dnd } = props;
const initialValues = {
mentions: graphConfig.mentions,
dnd: dnd,
watchOnSelf: graphConfig.watchOnSelf,
};
const onSubmit = useCallback(async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
try {
let promises: Promise<any>[] = [];
if (values.mentions !== graphConfig.mentions) {
promises.push(api.hark.setMentions(values.mentions));
}
if (values.watchOnSelf !== graphConfig.watchOnSelf) {
promises.push(api.hark.setWatchOnSelf(values.watchOnSelf));
}
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
promises.push(api.hark.setDoNotDisturb(values.dnd))
}
await Promise.all(promises);
actions.setStatus({ success: null });
actions.resetForm({ values: initialValues });
} catch (e) {
console.error(e);
actions.setStatus({ error: e.message });
}
}, [api]);
return (
<Col p="5" pt="4" gapY="5">
<Col gapY="1">
<Text fontSize="2" fontWeight="medium">
Notification Preferences
</Text>
<Text gray>
Set notification visibility and default behaviours for groups and
messaging
</Text>
</Col>
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Form>
<Col gapY="4">
<Toggle
label="Do not disturb"
id="dnd"
caption="You won't see the notification badge, but notifications will still appear in your inbox."
/>
<Toggle
label="Watch for replies"
id="watchOnSelf"
caption="Automatically follow a post for notifications when it's yours"
/>
<Toggle
label="Watch for mentions"
id="mentions"
caption="Notify me if someone mentions my @p in a channel I've joined"
/>
</Col>
</Form>
</FormikOnBlur>
</Col>
);
}

View File

@ -5,12 +5,14 @@ import {
ManagedTextInputField as Input, ManagedTextInputField as Input,
ManagedForm as Form, ManagedForm as Form,
Box, Box,
Text,
Button, Button,
Col Col,
Anchor
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import { BucketList } from './BucketList'; import GlobalApi from "~/logic/api/global";
import GlobalApi from '~/logic/api/global'; import { BucketList } from "./BucketList";
import { S3State } from '~/types/s3-update'; import { S3State } from '~/types/s3-update';
interface FormSchema { interface FormSchema {
@ -47,7 +49,7 @@ export default function S3Form(props: S3FormProps): ReactElement {
); );
return ( return (
<> <>
<Col> <Col p="5" pt="4" borderBottom="1" borderBottomColor="washedGray">
<Formik <Formik
initialValues={ initialValues={
{ {
@ -60,30 +62,49 @@ export default function S3Form(props: S3FormProps): ReactElement {
} }
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<Form <Form>
display="grid" <Col maxWidth="600px" gapY="5">
gridTemplateColumns="100%" <Col gapY="1">
gridAutoRows="auto" <Text color="black" fontSize={2} fontWeight="medium">
gridRowGap={5} S3 Storage Setup
> </Text>
<Box color="black" fontSize={1} fontWeight={900}> <Text gray>
S3 Credentials Store credentials for your S3 object storage buckets on your
</Box> Urbit ship, and upload media freely to various modules.
<Input label="Endpoint" id="s3endpoint" /> <Anchor
<Input label="Access Key ID" id="s3accessKeyId" /> target="_blank"
<Input style={{ textDecoration: 'none' }}
type="password" borderBottom="1"
label="Secret Access Key" ml="1"
id="s3secretAccessKey" href="https://urbit.org/using/operations/using-your-ship/#bucket-setup">
/> Learn more
<Button style={{ cursor: 'pointer' }} type="submit">Submit</Button> </Anchor>
</Text>
</Col>
<Input label="Endpoint" id="s3endpoint" />
<Input label="Access Key ID" id="s3accessKeyId" />
<Input
type="password"
label="Secret Access Key"
id="s3secretAccessKey"
/>
<Button style={{ cursor: "pointer" }} type="submit">
Submit
</Button>
</Col>
</Form> </Form>
</Formik> </Formik>
</Col> </Col>
<Col> <Col maxWidth="600px" p="5" gapY="4">
<Box color="black" mb={4} fontSize={1} fontWeight={700}> <Col gapY="1">
S3 Buckets <Text color="black" mb={4} fontSize={2} fontWeight="medium">
</Box> S3 Buckets
</Text>
<Text gray>
Your 'active' bucket will be the one used when Landscape uploads a
file
</Text>
</Col>
<BucketList <BucketList
buckets={s3.configuration.buckets} buckets={s3.configuration.buckets}
selected={s3.configuration.currentBucket} selected={s3.configuration.currentBucket}

View File

@ -1,41 +1,58 @@
import React from 'react'; import React, { useState } from "react";
import { Box, Button } from '@tlon/indigo-react'; import {
Box,
Text,
Button,
Col,
StatelessCheckboxField,
} from "@tlon/indigo-react";
import GlobalApi from '../../../../api/global'; import GlobalApi from "~/logic/api/global";
interface SecuritySettingsProps { interface SecuritySettingsProps {
api: GlobalApi; api: GlobalApi;
} }
export default function SecuritySettings({ api }: SecuritySettingsProps) { export default function SecuritySettings({ api }: SecuritySettingsProps) {
const [allSessions, setAllSessions] = useState(false);
return ( return (
<Box display="grid" gridTemplateRows="auto" gridTemplateColumns="1fr" gridRowGap={2}> <Col gapY="5" p="5" pt="4">
<Box color="black" fontSize={1} mb={4} fontWeight={900}> <Col gapY="1">
Security <Text fontSize={2} fontWeight="medium">
</Box> Security Preferences
<Box color="black" fontSize={0} fontWeight={700}> </Text>
Log out of this session <Text gray>
</Box> Manage sessions, login credentials and Landscape access
<Box fontSize={0} mt={2} color="gray"> </Text>
You will be logged out of your Urbit on this browser. </Col>
<Col gapY="1">
<Text color="black">
Log out of this session
</Text>
<Text mb="3" gray>
{allSessions
? "You will be logged out of all browsers that have currently logged into your Urbit."
: "You will be logged out of your Urbit on this browser."}
</Text>
<StatelessCheckboxField
mb="3"
selected={allSessions}
onChange={() => setAllSessions((s) => !s)}
>
<Text>Log out of all sessions</Text>
</StatelessCheckboxField>
<form method="post" action="/~/logout"> <form method="post" action="/~/logout">
<Button mt='4' border={1} style={{ cursor: 'pointer' }}> {allSessions && <input type="hidden" name="all" />}
<Button
primary
destructive
border={1}
style={{ cursor: "pointer" }}
>
Logout Logout
</Button> </Button>
</form> </form>
</Box> </Col>
<Box color="black" fontSize={0} mt={4} fontWeight={700}> </Col>
Log out of all sessions
</Box>
<Box fontSize={0} mt={2} color="gray">
You will be logged out of all browsers that have currently logged into your Urbit.
<form method="post" action="/~/logout">
<input type="hidden" name="all" />
<Button destructive mt={4} border={1} style={{ cursor: 'pointer' }}>
Logout
</Button>
</form>
</Box>
</Box>
); );
} }

View File

@ -1,37 +1,97 @@
import React from 'react'; import React from "react";
import { Box } from '@tlon/indigo-react'; import { Row, Icon, Box, Col, Text } from "@tlon/indigo-react";
import GlobalApi from '~/logic/api/global'; import GlobalApi from "~/logic/api/global";
import { StoreState } from '~/logic/store/type'; import { StoreState } from "~/logic/store/type";
import DisplayForm from './lib/DisplayForm'; import DisplayForm from "./lib/DisplayForm";
import S3Form from './lib/S3Form'; import S3Form from "./lib/S3Form";
import SecuritySettings from './lib/Security'; import SecuritySettings from "./lib/Security";
import RemoteContentForm from './lib/RemoteContent'; import RemoteContentForm from "./lib/RemoteContent";
import { NotificationPreferences } from "./lib/NotificationPref";
import { CalmPrefs } from "./lib/CalmPref";
import { Link } from "react-router-dom";
type ProfileProps = StoreState & { api: GlobalApi; ship: string }; export function SettingsItem(props: {
title: string;
export default function Settings({ description: string;
api, to: string;
s3 }) {
}: ProfileProps) { const { to, title, description } = props;
return ( return (
<Box <Link to={`/~settings/${to}`}>
backgroundColor="white" <Row alignItems="center" gapX="3">
display="grid" <Box
gridTemplateRows="auto" borderRadius="2"
gridTemplateColumns="1fr" backgroundColor="blue"
gridRowGap={7} width="64px"
p={4} height="64px"
maxWidth="500px" />
> <Col gapY="2">
<DisplayForm <Text>{title}</Text>
api={api} <Text gray>{description}</Text>
s3={s3} </Col>
/> </Row>
<RemoteContentForm api={api} /> </Link>
<S3Form api={api} s3={s3} /> );
<SecuritySettings api={api} /> }
</Box>
export default function Settings(props: {}) {
return (
<Col gapY="5" p="5">
<Col gapY="1">
<Text fontSize="2">System Preferences</Text>
<Text gray>Configure and customize Landscape</Text>
</Col>
<Box
display="grid"
width="100%"
height="100%"
gridTemplateColumns={["100%", "1fr 1fr"]}
gridGap="3"
>
<SettingsItem
to="notifications"
title="Notifications"
description="Set notification visibility and default behaviours for groups and messaging"
/>
<SettingsItem
to="display"
title="Display"
description="Customize visual interfaces across your Landscape"
/>
<SettingsItem
to="calm"
title="CalmEngine"
description="Modulate vearious elements across Landscape to maximize calmness"
/>
<SettingsItem
to="s3"
title="Remote Storage"
description="Configure S3-compatible storage solutions"
/>
<SettingsItem
to="security"
title="Security"
description="Manage sessions, login credentials, and Landscape access"
/>
{/*
<SettingsItem
to="keyboard"
title="Keyboard"
description="Shortcuts, Keyboard Settings, Meta Key Assignments, etc."
/>
<SettingsItem
to="hosting"
title="Hosting Services"
description="Hosting-specific service configuration"
/>*/}
<SettingsItem
to="leap"
title="Leap"
description="Customize Leap ordering, omit modules or results"
/>
</Box>
</Col>
); );
} }

View File

@ -1,47 +1,138 @@
import React, { ReactElement } from 'react'; import React, { ReactNode } from "react";
import { Route } from 'react-router-dom'; import { useLocation } from "react-router-dom";
import Helmet from 'react-helmet'; import Helmet from "react-helmet";
import { Box } from '@tlon/indigo-react'; import { Text, Box, Col, Row } from '@tlon/indigo-react';
import Settings from './components/settings'; import { NotificationPreferences } from "./components/lib/NotificationPref";
import useLocalState from '~/logic/state/local'; import DisplayForm from "./components/lib/DisplayForm";
import S3Form from "./components/lib/S3Form";
import { CalmPrefs } from "./components/lib/CalmPref";
import SecuritySettings from "./components/lib/Security";
import { LeapSettings } from "./components/lib/LeapSettings";
import { useHashLink } from "~/logic/lib/useHashLink";
import { SidebarItem as BaseSidebarItem } from "~/views/landscape/components/SidebarItem";
import { PropFunc } from "~/types";
export const Skeleton = (props: { children: ReactNode }) => (
<Box height="100%" width="100%" px={[0, 3]} pb={[0, 3]} borderRadius={1}>
<Box
height="100%"
width="100%"
borderRadius={1}
bg="white"
border={1}
borderColor="washedGray"
>
{props.children}
</Box>
</Box>
);
type ProvSideProps = "to" | "selected";
type BaseProps = PropFunc<typeof BaseSidebarItem>;
function SidebarItem(props: { hash: string } & Omit<BaseProps, ProvSideProps>) {
const { hash, icon, text, ...rest } = props;
const to = `/~settings#${hash}`;
const location = useLocation();
const selected = location.hash.slice(1) === hash;
return (
<BaseSidebarItem
{...rest}
icon={icon}
text={text}
to={to}
selected={selected}
/>
);
}
function SettingsItem(props: { children: ReactNode }) {
const { children } = props;
return (
<Box borderBottom="1" borderBottomColor="washedGray">
{children}
</Box>
);
}
export default function SettingsScreen(props: any) {
const location = useLocation();
const hash = location.hash.slice(1)
export default function SettingsScreen(props: any): ReactElement {
return ( return (
<> <>
<Helmet defer={false}> <Helmet defer={false}>
<title>Landscape - Settings</title> <title>Landscape - Settings</title>
</Helmet> </Helmet>
<Route <Skeleton>
path={['/~settings']} <Row height="100%" overflow="hidden">
render={() => { <Col
return ( height="100%"
<Box height="100%" borderRight="1"
width="100%" borderRightColor="washedGray"
px={[0, 3]} display={["none", "flex"]}
pb={[0, 3]} minWidth="250px"
borderRadius={1} maxWidth="350px"
>
<Text
display="block"
my="4"
mx="3"
fontSize="2"
fontWeight="medium"
> >
<Box System Preferences
height="100%" </Text>
width="100%" <Col gapY="1">
display="grid" <SidebarItem
gridTemplateColumns={['100%', '400px 1fr']} icon="Inbox"
gridTemplateRows={['48px 1fr', '1fr']} text="Notifications"
borderRadius={1} hash="notifications"
bg="white" />
border={1} <SidebarItem icon="Image" text="Display" hash="display" />
borderColor="washedGray" <SidebarItem icon="Upload" text="Remote Storage" hash="s3" />
overflowY="auto" <SidebarItem icon="LeapArrow" text="Leap" hash="leap" />
flexGrow <SidebarItem icon="Node" text="CalmEngine" hash="calm" />
> <SidebarItem
<Settings {...props} /> icon="Locked"
</Box> text="Devices + Security"
</Box> hash="security"
); />
}} </Col>
/> </Col>
<Col flexGrow={1} overflowY="auto">
<SettingsItem>
{hash === "notifications" && (
<NotificationPreferences
{...props}
graphConfig={props.notificationsGraphConfig}
/>
)}
{hash === "display" && (
<DisplayForm s3={props.s3} api={props.api} />
)}
{hash === "s3" && (
<S3Form s3={props.s3} api={props.api} />
)}
{hash === "leap" && (
<LeapSettings api={props.api} />
)}
{hash === "calm" && (
<CalmPrefs api={props.api} />
)}
{hash === "security" && (
<SecuritySettings api={props.api} />
)}
</SettingsItem>
</Col>
</Row>
</Skeleton>
</> </>
); );
} }

View File

@ -14,12 +14,13 @@ import { hexToUx } from '~/logic/lib/util';
type ColorInputProps = Parameters<typeof Col>[0] & { type ColorInputProps = Parameters<typeof Col>[0] & {
id: string; id: string;
label: string; label?: string;
placeholder?: string;
disabled?: boolean; disabled?: boolean;
}; };
export function ColorInput(props: ColorInputProps): ReactElement { export function ColorInput(props: ColorInputProps) {
const { id, label, caption, disabled, ...rest } = props; const { id, placeholder, label, caption, disabled, ...rest } = props;
const [{ value, onBlur }, meta, { setValue }] = useField(id); const [{ value, onBlur }, meta, { setValue }] = useField(id);
const hex = value.replace('#', '').replace('0x','').replace('.', ''); const hex = value.replace('#', '').replace('0x','').replace('.', '');
@ -54,6 +55,7 @@ export function ColorInput(props: ColorInputProps): ReactElement {
value={hex} value={hex}
disabled={disabled || false} disabled={disabled || false}
borderRight={0} borderRight={0}
placeholder={placeholder}
/> />
<Box <Box
borderBottomRightRadius={1} borderBottomRightRadius={1}

View File

@ -0,0 +1,56 @@
import React, { ReactNode, useMemo, useCallback } from "react";
import {
FieldArray,
FieldArrayRenderProps,
Field,
useFormikContext,
} from "formik";
import { Icon, Col, Row, Box } from "@tlon/indigo-react";
interface ShuffleFieldsProps<N extends string> {
name: N;
children: (index: number, props: FieldArrayRenderProps) => ReactNode;
}
type Value<I extends string, T> = {
[k in I]: T[];
};
export function ShuffleFields<N extends string, T, F extends Value<N, T>>(
props: ShuffleFieldsProps<N>
) {
const { name, children } = props;
const { values } = useFormikContext<F>();
const fields: T[] = useMemo(() => values[name], [values, name]);
return (
<FieldArray
name={name}
render={(arrayHelpers) => {
const goUp = (i: number) => () => {
if(i > 0) {
arrayHelpers.swap(i - 1, i);
}
};
const goDown = (i: number) => () => {
if(i < fields.length - 1) {
arrayHelpers.swap(i + 1, i);
}
};
return (
<Box gridColumnGap="2" gridRowGap="3" display="grid" gridAutoRows="auto" gridTemplateColumns="32px 32px 1fr">
{fields.map((field, i) => (
<React.Fragment key={i}>
<Icon width="3" height="3" icon="ChevronNorth" onClick={goUp(i)} />
<Icon width="3" height="3" icon="ChevronSouth" onClick={goDown(i)} />
{children(i, arrayHelpers)}
</React.Fragment>
))}
</Box>
);
}}
/>
);
}

View File

@ -21,17 +21,18 @@ import { uxToHex } from "~/logic/lib/util";
import { SetStatusBarModal } from './SetStatusBarModal'; import { SetStatusBarModal } from './SetStatusBarModal';
import { useTutorialModal } from './useTutorialModal'; import { useTutorialModal } from './useTutorialModal';
import useLocalState from '~/logic/state/local'; import useLocalState, { selectLocalState } from '~/logic/state/local';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
const localSel = selectLocalState(['toggleOmnibox']);
const StatusBar = (props) => { const StatusBar = (props) => {
const { ourContact, api, ship } = props; const { ourContact, api, ship } = props;
const invites = [].concat(...Object.values(props.invites).map(obj => Object.values(obj))); const invites = [].concat(...Object.values(props.invites).map(obj => Object.values(obj)));
const metaKey = (window.navigator.platform.includes('Mac')) ? '⌘' : 'Ctrl+'; const metaKey = (window.navigator.platform.includes('Mac')) ? '⌘' : 'Ctrl+';
const { toggleOmnibox, hideAvatars } = const { toggleOmnibox } = useLocalState(localSel);
useLocalState(({ toggleOmnibox, hideAvatars }) => const { hideAvatars } = useSettingsState(selectCalmState);
({ toggleOmnibox, hideAvatars })
);
const color = !!ourContact ? `#${uxToHex(props.ourContact.color)}` : '#000'; const color = !!ourContact ? `#${uxToHex(props.ourContact.color)}` : '#000';
const xPadding = (!hideAvatars && ourContact?.avatar) ? '0' : '2'; const xPadding = (!hideAvatars && ourContact?.avatar) ? '0' : '2';

View File

@ -9,12 +9,13 @@ import { Associations, Contacts, Groups, Invites } from '@urbit/api';
import makeIndex from '~/logic/lib/omnibox'; import makeIndex from '~/logic/lib/omnibox';
import OmniboxInput from './OmniboxInput'; import OmniboxInput from './OmniboxInput';
import OmniboxResult from './OmniboxResult'; import OmniboxResult from './OmniboxResult';
import { withLocalState } from '~/logic/state/local';
import { deSig } from '~/logic/lib/util'; import { deSig } from '~/logic/lib/util';
import { withLocalState } from '~/logic/state/local';
import defaultApps from '~/logic/lib/default-apps'; import defaultApps from '~/logic/lib/default-apps';
import { useOutsideClick } from '~/logic/lib/useOutsideClick'; import {useOutsideClick} from '~/logic/lib/useOutsideClick';
import { Portal } from '../Portal'; import {Portal} from '../Portal';
import useSettingsState, {SettingsState} from '~/logic/state/settings';
import { Tile } from '~/types'; import { Tile } from '~/types';
interface OmniboxProps { interface OmniboxProps {
@ -31,11 +32,13 @@ interface OmniboxProps {
} }
const SEARCHED_CATEGORIES = ['ships', 'other', 'commands', 'groups', 'subscriptions', 'apps']; const SEARCHED_CATEGORIES = ['ships', 'other', 'commands', 'groups', 'subscriptions', 'apps'];
const settingsSel = (s: SettingsState) => s.leap;
export function Omnibox(props: OmniboxProps) { export function Omnibox(props: OmniboxProps) {
const location = useLocation(); const location = useLocation();
const history = useHistory(); const history = useHistory();
const omniboxRef = useRef<HTMLDivElement | null>(null); const leapConfig = useSettingsState(settingsSel);
const omniboxRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
@ -48,18 +51,30 @@ export function Omnibox(props: OmniboxProps) {
: props.contacts; : props.contacts;
}, [props.contacts, query]); }, [props.contacts, query]);
const index = useMemo(() => { const selectedGroup = useMemo(
const selectedGroup = location.pathname.startsWith('/~landscape/ship/') () => location.pathname.startsWith('/~landscape/ship/')
? '/' + location.pathname.split('/').slice(2,5).join('/') ? '/' + location.pathname.split('/').slice(2,5).join('/')
: null; : null,
[location.pathname]
);
const index = useMemo(() => {
return makeIndex( return makeIndex(
contacts, contacts,
props.associations, props.associations,
props.tiles, props.tiles,
selectedGroup, selectedGroup,
props.groups props.groups,
leapConfig,
); );
}, [location.pathname, contacts, props.associations, props.groups, props.tiles]); }, [
selectedGroup,
leapConfig,
contacts,
props.associations,
props.groups,
props.tiles
]);
const onOutsideClick = useCallback(() => { const onOutsideClick = useCallback(() => {
props.show && props.toggle(); props.show && props.toggle();

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react'; import React, { Component, useEffect } from 'react';
import { Box } from '@tlon/indigo-react'; import { Box } from '@tlon/indigo-react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
@ -12,6 +12,8 @@ import ErrorComponent from '~/views/components/Error';
import Notifications from '~/views/apps/notifications/notifications'; import Notifications from '~/views/apps/notifications/notifications';
import GraphApp from '../../apps/graph/app'; import GraphApp from '../../apps/graph/app';
import { useMigrateSettings } from '~/logic/lib/migrateSettings';
export const Container = styled(Box)` export const Container = styled(Box)`
flex-grow: 1; flex-grow: 1;
@ -22,6 +24,14 @@ export const Container = styled(Box)`
export const Content = (props) => { export const Content = (props) => {
const doMigrate = useMigrateSettings();
useEffect(() => {
setTimeout(() => {
doMigrate();
}, 10000);
}, []);
return ( return (
<Container> <Container>
<Switch> <Switch>

View File

@ -29,7 +29,7 @@ import { roleForShip, resourceFromPath } from '~/logic/lib/group';
import { Dropdown } from '~/views/components/Dropdown'; import { Dropdown } from '~/views/components/Dropdown';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction'; import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import useLocalState from '~/logic/state/local'; import useSettingsState, { selectCalmState } from '~/logic/state/settings';
const TruncText = styled(Text)` const TruncText = styled(Text)`
white-space: nowrap; white-space: nowrap;
@ -79,13 +79,14 @@ function getParticipants(cs: Contacts, group: Group) {
const emptyContact = (patp: string, pending: boolean): Participant => ({ const emptyContact = (patp: string, pending: boolean): Participant => ({
nickname: '', nickname: '',
email: '', bio: '',
phone: '', status: '',
color: '', color: '',
avatar: null, avatar: null,
notes: '', cover: null,
website: '', groups: [],
patp, patp,
'last-updated': 0,
pending pending
}); });
@ -256,9 +257,7 @@ function Participant(props: {
}) { }) {
const { contact, association, group, api } = props; const { contact, association, group, api } = props;
const { title } = association.metadata; const { title } = association.metadata;
const { hideAvatars, hideNicknames } = useLocalState( const { hideAvatars, hideNicknames } = useSettingsState(selectCalmState);
({ hideAvatars, hideNicknames }) => ({ hideAvatars, hideNicknames })
);
const color = uxToHex(contact.color); const color = uxToHex(contact.color);
const isInvite = 'invite' in group.policy; const isInvite = 'invite' in group.policy;

View File

@ -8,10 +8,11 @@ import { HoverBoxLink } from '~/views/components/HoverBox';
import { Sigil } from '~/logic/lib/sigil'; import { Sigil } from '~/logic/lib/sigil';
import { getModuleIcon, getItemTitle, uxToHex } from '~/logic/lib/util'; import { getModuleIcon, getItemTitle, uxToHex } from '~/logic/lib/util';
import { useTutorialModal } from '~/views/components/useTutorialModal'; import { useTutorialModal } from '~/views/components/useTutorialModal';
import useLocalState from '~/logic/state/local';
import { TUTORIAL_HOST, TUTORIAL_GROUP } from '~/logic/lib/tutorialModal'; import { TUTORIAL_HOST, TUTORIAL_GROUP } from '~/logic/lib/tutorialModal';
import { SidebarAppConfigs, SidebarItemStatus } from './types'; import { SidebarAppConfigs, SidebarItemStatus } from './types';
import { Workspace } from '~/types/workspace'; import { Workspace } from '~/types/workspace';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
function SidebarItemIndicator(props: { status?: SidebarItemStatus }) { function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
switch (props.status) { switch (props.status) {
@ -56,9 +57,8 @@ export function SidebarItem(props: {
return null; return null;
} }
const DM = (isUnmanaged && props.workspace?.type === 'messages'); const DM = (isUnmanaged && props.workspace?.type === 'messages');
const { hideAvatars, hideNicknames } = useLocalState(({ hideAvatars, hideNicknames }) => ({ const { hideAvatars, hideNicknames } = useSettingsState(selectCalmState);
hideAvatars, hideNicknames
}));
const itemStatus = app.getStatus(path); const itemStatus = app.getStatus(path);
const hasUnread = itemStatus === 'unread' || itemStatus === 'mention'; const hasUnread = itemStatus === 'unread' || itemStatus === 'mention';

View File

@ -34,7 +34,7 @@ export const SidebarItem = ({
justifyContent="space-between" justifyContent="space-between"
{...rest} {...rest}
> >
<Row> <Row alignItems="center">
<Icon color={color} icon={icon as any} mr="2" /> <Icon color={color} icon={icon as any} mr="2" />
<Text color={color}>{text}</Text> <Text color={color}>{text}</Text>
</Row> </Row>

View File

@ -1,5 +1,5 @@
export type Key = string; export type Key = string;
export type Value = string | boolean | number; export type Value = string | string[] | boolean | number;
export type Bucket = Map<string, Value>; export type Bucket = Map<string, Value>;
export type Settings = Map<string, Bucket>; export type Settings = Map<string, Bucket>;