Merge branch 'release/next-js' into release/next-userspace

This commit is contained in:
Matilde Park 2021-03-04 12:01:22 -05:00
commit d07f041a5e
76 changed files with 1952 additions and 1114 deletions

View File

@ -5,7 +5,7 @@
/- glob
/+ default-agent, verb, dbug
|%
++ hash 0v5.ip41o.9jcdb.4jb1f.sd508.fdssj
++ hash 0v9flom.311gv.90jce.591n4.d09bf
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states
$% state-0

View File

@ -24,6 +24,6 @@
<div id="portal-root"></div>
<script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.js"></script>
<script src="/~landscape/js/bundle/index.01423cd5af57c2f23adc.js"></script>
<script src="/~landscape/js/bundle/index.579404e0378c0c8cd2fe.js"></script>
</body>
</html>

View File

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

View File

@ -95,7 +95,7 @@
"tsc": "tsc",
"tsc:watch": "tsc --watch",
"build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
"build:prod": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
"build:prod": "cd ../npm/api && npm i && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
"start": "webpack-dev-server --config config/webpack.dev.js",
"test": "echo \"Error: no test specified\" && exit 1"
},

View File

@ -60,7 +60,6 @@ export default class BaseApi<S extends object = {}> {
}
scry<T>(app: string, path: Path): Promise<T> {
console.log(path);
return fetch(`/~/scry/${app}${path}.json`).then(r => r.json() as Promise<T>);
}

View File

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

View File

@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
export function useIdlingState() {
const [idling, setIdling] = useState(false);
useEffect(() => {
function blur() {
setIdling(true);
}
function focus() {
setIdling(false);
}
window.addEventListener('blur', blur);
window.addEventListener('focus', focus);
return () => {
window.removeEventListener('blur', blur);
window.removeEventListener('focus', focus);
}
}, []);
return idling;
}

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

View File

@ -0,0 +1,6 @@
const ua = window.navigator.userAgent;
export const IS_IOS = ua.includes('iPhone');
console.log(IS_IOS);

View File

@ -1,6 +1,6 @@
import urbitOb from 'urbit-ob';
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source));
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+\w)/.source));
const isUrl = (string) => {
try {

View File

@ -1,9 +1,9 @@
import { useEffect, useState } from 'react';
import _ from 'lodash';
import f, { memoize } from 'lodash/fp';
import bigInt, { BigInteger } from 'big-integer';
import { Contact } from '@urbit/api';
import useLocalState from '../state/local';
import _ from "lodash";
import f, { memoize } from "lodash/fp";
import bigInt, { BigInteger } from "big-integer";
import { Contact } from '~/types';
import useSettingsState from '../state/settings';
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
export function useShowNickname(contact: Contact | null, hide?: boolean): boolean {
const hideNicknames = typeof hide !== 'undefined' ? hide : useLocalState(state => state.hideNicknames);
return Boolean(contact && contact.nickname && !hideNicknames);
const hideNicknames = typeof hide !== 'undefined' ? hide : useSettingsState(state => state.calm.hideNicknames);
return !!(contact && contact.nickname && !hideNicknames);
}
interface useHoveringInterface {

View File

@ -0,0 +1,47 @@
import React, {
useContext,
useState,
useCallback,
useLayoutEffect,
} from "react";
export interface VirtualContextProps {
save: () => void;
restore: () => void;
}
const fallback: VirtualContextProps = {
save: () => {},
restore: () => {},
};
export const VirtualContext = React.createContext(fallback);
export function useVirtual() {
return useContext(VirtualContext);
}
export const withVirtual = <P extends {}>(Component: React.ComponentType<P>) =>
React.forwardRef((props: P, ref) => (
<VirtualContext.Consumer>
{(context) => <Component ref={ref} {...props} {...context} />}
</VirtualContext.Consumer>
));
export function useVirtualResizeState(s: boolean) {
const [state, _setState] = useState(s);
const { save, restore } = useVirtual();
const setState = useCallback(
(sta: boolean) => {
save();
_setState(sta);
},
[_setState, save]
);
useLayoutEffect(() => {
restore();
}, [state]);
return [state, setState] as const;
}

View File

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

View File

@ -3,17 +3,22 @@ import f from 'lodash/fp';
import create, { State } from 'zustand';
import { persist } from 'zustand/middleware';
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 {
theme: "light" | "dark" | "auto";
hideAvatars: boolean;
hideNicknames: boolean;
remoteContentPolicy: RemoteContentPolicy;
tutorialProgress: TutorialProgress;
hideGroups: boolean;
hideUtilities: boolean;
tutorialRef: HTMLElement | null,
hideTutorial: () => void;
nextTutStep: () => void;
prevTutStep: () => void;
hideLeapCats: LeapCategories[];
setTutorialRef: (el: HTMLElement | null) => void;
dark: boolean;
background: BackgroundConfig;
@ -21,15 +26,22 @@ export interface LocalState extends State {
suspendedFocus?: HTMLElement;
toggleOmnibox: () => void;
set: (fn: (state: LocalState) => void) => void
}
};
type LocalStateZus = LocalState & State;
export const selectLocalState =
<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,
background: undefined,
theme: "auto",
hideAvatars: false,
hideNicknames: false,
hideLeapCats: [],
hideGroups: false,
hideUtilities: false,
tutorialProgress: 'hidden',
tutorialRef: null,
setTutorialRef: (el: HTMLElement | null) => set(produce((state) => {

View File

@ -0,0 +1,74 @@
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;
theme: "light" | "dark" | "auto";
};
calm: {
hideNicknames: boolean;
hideAvatars: boolean;
hideUnreads: boolean;
hideGroups: boolean;
hideUtilities: 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;
export const selectDisplayState = (s: SettingsState) => s.display;
const useSettingsState = create<SettingsStateZus>((set) => ({
display: {
backgroundType: 'none',
background: undefined,
dark: false,
theme: "auto"
},
calm: {
hideNicknames: false,
hideAvatars: false,
hideUnreads: false,
hideGroups: false,
hideUtilities: 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);
HarkReducer(data, this.state);
ContactReducer(data, this.state);
this.settingsReducer.reduce(data, this.state);
this.settingsReducer.reduce(data);
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 leapCategories = ["mychannel", "messages", "updates", "profile", "logout"] as const;
export type LeapCategories = typeof leapCategories[number];
export type TutorialProgress = typeof tutorialProgress[number];
interface LocalUpdateSetDark {
setDark: boolean;

View File

@ -28,19 +28,20 @@ import GlobalApi from '~/logic/api/global';
import { uxToHex } from '~/logic/lib/util';
import { foregroundFromBackground } from '~/logic/lib/sigil';
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};
height: 100%;
width: 100%;
padding: 0;
margin: 0;
${p => p.background?.type === 'url' ? `
background-image: url('${p.background?.url}');
${p => p.display.backgroundType === 'url' ? `
background-image: url('${p.display.background}');
background-size: cover;
` : p.background?.type === 'color' ? `
background-color: ${p.background.color};
` : p.display.backgroundType === 'color' ? `
background-color: ${p.display.background};
` : `background-color: ${p.theme.colors.white};`
}
display: flex;
@ -64,10 +65,9 @@ const Root = styled.div`
border-radius: 1rem;
border: 0px solid transparent;
}
`;
`, ['display']);
const StatusBarWithRouter = withRouter(StatusBar);
class App extends React.Component {
constructor(props) {
super(props);
@ -134,13 +134,14 @@ class App extends React.Component {
const { state, props } = this;
const associations = state.associations ?
state.associations : { contacts: {} };
const theme = props.dark ? dark : light;
const background = this.props.background;
const theme =
((props.dark && props?.display?.theme == "auto") ||
props?.display?.theme == "dark"
) ? dark : light;
const notificationsCount = state.notificationsCount || 0;
const doNotDisturb = state.doNotDisturb || false;
const ourContact = this.state.contacts[`~${this.ship}`] || null;
return (
<ThemeProvider theme={theme}>
<Helmet>
@ -148,7 +149,7 @@ class App extends React.Component {
? <link rel="icon" type="image/svg+xml" href={this.faviconString()} />
: null}
</Helmet>
<Root background={background}>
<Root>
<Router>
<TutorialModal api={this.api} />
<ErrorBoundary>
@ -195,5 +196,4 @@ class App extends React.Component {
}
}
export default withLocalState(process.env.NODE_ENV === 'production' ? App : hot(App));
export default withSettingsState(withLocalState(process.env.NODE_ENV === 'production' ? App : hot(App)), ['display']);

View File

@ -38,7 +38,7 @@ export function ChatResource(props: ChatResourceProps) {
const canWrite = isWriter(group, station);
useEffect(() => {
const count = Math.min(50, unreadCount + 15);
const count = 100 + unreadCount;
props.api.graph.getNewest(owner, name, count);
}, [station]);
@ -149,6 +149,7 @@ export function ChatResource(props: ChatResourceProps) {
/>
{dragging && <SubmitDragger />}
<ChatWindow
key={station}
history={props.history}
graph={graph}
unreadCount={unreadCount}

View File

@ -130,7 +130,13 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
props.ourContact &&
((props.ourContact.avatar !== null) && !props.hideAvatars)
)
? <BaseImage src={props.ourContact.avatar} height={16} width={16} className="dib" />
? <BaseImage
src={props.ourContact.avatar}
height={16}
width={16}
style={{ objectFit: 'cover' }}
display='inline-block'
/>
: <Sigil
ship={window.ship}
size={16}

View File

@ -4,10 +4,12 @@ import React, {
useEffect,
useRef,
Component,
PureComponent
PureComponent,
useCallback
} from 'react';
import moment from 'moment';
import _ from 'lodash';
import VisibilitySensor from 'react-visibility-sensor';
import { Box, Row, Text, Rule, BaseImage } from '@tlon/indigo-react';
import { Sigil } from '~/logic/lib/sigil';
import OverlaySigil from '~/views/components/OverlaySigil';
@ -16,6 +18,7 @@ import {
cite,
writeText,
useShowNickname,
useHideAvatar,
useHovering
} from '~/logic/lib/util';
import {
@ -32,7 +35,9 @@ import RemoteContent from '~/views/components/RemoteContent';
import { Mention } from '~/views/components/MentionText';
import styled from 'styled-components';
import useLocalState from '~/logic/state/local';
import useSettingsState, {selectCalmState} from "~/logic/state/settings";
import Timestamp from '~/views/components/Timestamp';
import {useIdlingState} from '~/logic/lib/idling';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
@ -58,7 +63,20 @@ export const DayBreak = ({ when, shimTop = false }: DayBreakProps) => (
</Row>
);
export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
export const UnreadMarker = React.forwardRef(({ dayBreak, when, api, association }, ref) => {
const [visible, setVisible] = useState(false);
const idling = useIdlingState();
const dismiss = useCallback(() => {
api.hark.markCountAsRead(association, '/', 'message');
}, [api, association]);
useEffect(() => {
if(visible && !idling) {
dismiss();
}
}, [visible, idling]);
return (
<Row
position='absolute'
ref={ref}
@ -70,15 +88,16 @@ export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
width='100%'
>
<Rule borderColor='lightBlue' />
<VisibilitySensor onChange={setVisible}>
<Text color='blue' fontSize={0} flexShrink='0' px={2}>
New messages below
</Text>
</VisibilitySensor>
<Rule borderColor='lightBlue' />
</Row>
));
)});
interface ChatMessageProps {
measure(element): void;
msg: Post;
previousMsg?: Post;
nextMsg?: Post;
@ -96,9 +115,10 @@ interface ChatMessageProps {
api: GlobalApi;
highlighted?: boolean;
renderSigil?: boolean;
innerRef: (el: HTMLDivElement | null) => void;
}
export default class ChatMessage extends Component<ChatMessageProps> {
class ChatMessage extends Component<ChatMessageProps> {
private divRef: React.RefObject<HTMLDivElement>;
constructor(props) {
@ -107,9 +127,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
}
componentDidMount() {
if (this.divRef.current) {
this.props.measure(this.divRef.current);
}
}
render() {
@ -124,7 +141,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
className = '',
isPending,
style,
measure,
scrollWindow,
isLastMessage,
unreadMarkerRef,
@ -157,9 +173,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
.unix(msg['time-sent'] / 1000)
.format(renderSigil ? 'h:mm A' : 'h:mm');
const reboundMeasure = (event) => {
return measure(this.divRef.current);
};
const messageProps = {
msg,
@ -167,7 +180,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
contacts,
association,
group,
measure: reboundMeasure.bind(this),
style,
containerClass,
isPending,
@ -177,7 +189,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
highlighted,
fontSize,
associations,
groups
groups,
};
const unreadContainerStyle = {
@ -186,10 +198,11 @@ export default class ChatMessage extends Component<ChatMessageProps> {
return (
<Box
ref={this.divRef}
ref={this.props.innerRef}
pt={renderSigil ? 2 : 0}
pb={isLastMessage ? 4 : 2}
className={containerClass}
backgroundColor={highlighted ? 'blue' : 'white'}
style={style}
>
{dayBreak && !isLastRead ? (
@ -206,6 +219,8 @@ export default class ChatMessage extends Component<ChatMessageProps> {
<Box style={unreadContainerStyle}>
{isLastRead ? (
<UnreadMarker
association={association}
api={api}
dayBreak={dayBreak}
when={msg['time-sent']}
ref={unreadMarkerRef}
@ -217,11 +232,12 @@ export default class ChatMessage extends Component<ChatMessageProps> {
}
}
export default React.forwardRef((props, ref) => <ChatMessage {...props} innerRef={ref} />);
export const MessageAuthor = ({
timestamp,
contacts,
msg,
measure,
group,
api,
associations,
@ -238,7 +254,7 @@ export const MessageAuthor = ({
const contact =
`~${msg.author}` in contacts ? contacts[`~${msg.author}`] : false;
const showNickname = useShowNickname(contact);
const { hideAvatars } = useLocalState(({ hideAvatars }) => ({ hideAvatars }));
const { hideAvatars } = useSettingsState(selectCalmState);
const shipName = showNickname ? contact.nickname : cite(msg.author);
const copyNotice = 'Copied';
const color = contact
@ -278,6 +294,7 @@ export const MessageAuthor = ({
contact?.avatar && !hideAvatars ? (
<BaseImage
display='inline-block'
style={{ objectFit: 'cover' }}
src={contact.avatar}
height={16}
width={16}
@ -366,7 +383,6 @@ export const Message = ({
timestamp,
contacts,
msg,
measure,
group,
api,
associations,
@ -400,7 +416,6 @@ export const Message = ({
<TextContent
associations={associations}
groups={groups}
measure={measure}
api={api}
fontSize={1}
lineHeight={'20px'}
@ -418,8 +433,8 @@ export const Message = ({
color='black'
>
<RemoteContent
key={content.url}
url={content.url}
onLoad={measure}
imageProps={{
style: {
maxWidth: 'min(100%,18rem)',

View File

@ -50,6 +50,8 @@ interface ChatWindowState {
unreadIndex: BigInteger;
}
const virtScrollerStyle = { height: '100%' };
export default class ChatWindow extends Component<
ChatWindowProps,
ChatWindowState
@ -59,6 +61,7 @@ export default class ChatWindow extends Component<
private prevSize = 0;
private loadedNewest = false;
private loadedOldest = false;
private fetchPending = false;
INITIALIZATION_MAX_TIME = 100;
@ -77,7 +80,6 @@ export default class ChatWindow extends Component<
this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleWindowFocus = this.handleWindowFocus.bind(this);
this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
this.dismissIfLineVisible = this.dismissIfLineVisible.bind(this);
this.virtualList = null;
this.unreadMarkerRef = React.createRef();
@ -86,23 +88,15 @@ export default class ChatWindow extends Component<
componentDidMount() {
this.calculateUnreadIndex();
this.virtualList?.calculateVisibleItems();
window.addEventListener('blur', this.handleWindowBlur);
window.addEventListener('focus', this.handleWindowFocus);
setTimeout(() => {
if (this.props.scrollTo) {
this.scrollToUnread();
}
this.setState({ initialized: true });
}, this.INITIALIZATION_MAX_TIME);
}
componentWillUnmount() {
window.removeEventListener('blur', this.handleWindowBlur);
window.removeEventListener('focus', this.handleWindowFocus);
}
calculateUnreadIndex() {
const { graph, unreadCount } = this.props;
const unreadIndex = graph.keys()[unreadCount];
@ -131,24 +125,14 @@ export default class ChatWindow extends Component<
componentDidUpdate(prevProps: ChatWindowProps, prevState) {
const { history, graph, unreadCount, station } = this.props;
if (graph.size !== prevProps.graph.size && this.state.fetchPending) {
this.setState({ fetchPending: false });
if (graph.size !== prevProps.graph.size && this.fetchPending) {
this.fetchPending = false;
}
if (unreadCount > prevProps.unreadCount && this.state.idle) {
if (unreadCount > prevProps.unreadCount) {
this.calculateUnreadIndex();
}
if (this.prevSize !== graph.size) {
if (this.state.unreadIndex.eq(bigInt.zero)) {
this.calculateUnreadIndex();
this.scrollToUnread();
}
this.prevSize = graph.size;
this.virtualList?.calculateVisibleItems();
this.stayLockedIfActive();
}
if (station !== prevProps.station) {
this.virtualList?.resetScroll();
this.calculateUnreadIndex();
@ -168,7 +152,7 @@ export default class ChatWindow extends Component<
return;
}
this.virtualList?.scrollToData(unreadIndex);
this.virtualList?.scrollToIndex(this.state.unreadIndex);
}
dismissUnread() {
@ -176,65 +160,111 @@ export default class ChatWindow extends Component<
if (this.state.fetchPending) return;
if (this.props.unreadCount === 0) return;
this.props.api.hark.markCountAsRead(association, '/', 'message');
this.props.api.hark.markCountAsRead(association, '/', 'mention');
}
async fetchMessages(newer: boolean, force = false): Promise<void> {
const { api, station, graph } = this.props;
if (this.state.fetchPending && !force) {
return new Promise((resolve, reject) => {});
setActive = () => {
if(this.state.idle) {
this.setState({ idle: false });
}
}
this.setState({ fetchPending: true });
fetchMessages = async (newer: boolean): Promise<boolean> => {
const { api, station, graph } = this.props;
if(this.fetchPending) {
return false;
}
this.fetchPending = true;
const [, , ship, name] = station.split('/');
const currSize = graph.size;
if (newer && !this.loadedNewest) {
if (newer) {
const [index] = graph.peekLargest()!;
await api.graph.getYoungerSiblings(
ship,
name,
20,
100,
`/${index.toString()}`
);
if (currSize === graph.size) {
console.log('loaded all newest');
this.loadedNewest = true;
}
} else if (!newer && !this.loadedOldest) {
} else {
const [index] = graph.peekSmallest()!;
await api.graph.getOlderSiblings(ship, name, 20, `/${index.toString()}`);
await api.graph.getOlderSiblings(ship, name, 100, `/${index.toString()}`);
this.calculateUnreadIndex();
if (currSize === graph.size) {
console.log('loaded all oldest');
this.loadedOldest = true;
}
}
this.setState({ fetchPending: false });
this.fetchPending = false;
return currSize === graph.size;
}
onScroll({ scrollTop, scrollHeight, windowHeight }) {
onScroll = ({ scrollTop, scrollHeight, windowHeight }) => {
if (!this.state.idle && scrollTop > IDLE_THRESHOLD) {
this.setState({ idle: true });
}
this.dismissIfLineVisible();
}
dismissIfLineVisible() {
if (this.props.unreadCount === 0) return;
if (!this.unreadMarkerRef.current || !this.virtualList?.window) return;
const parent = this.unreadMarkerRef.current.parentElement?.parentElement;
if (!parent) return;
const { scrollTop, scrollHeight, offsetHeight } = this.virtualList.window;
if (
scrollHeight - parent.offsetTop > scrollTop &&
scrollHeight - parent.offsetTop < scrollTop + offsetHeight
) {
this.dismissUnread();
renderer = React.forwardRef(({ index, scrollWindow }, ref) => {
const {
api,
association,
group,
contacts,
graph,
history,
groups,
associations
} = this.props;
const { unreadMarkerRef } = this;
const messageProps = {
association,
group,
contacts,
unreadMarkerRef,
history,
api,
groups,
associations
};
const msg = graph.get(index)?.post;
if (!msg) return null;
if (!this.state.initialized) {
return (
<MessagePlaceholder
key={index.toString()}
height='64px'
index={index}
/>
);
}
}
const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
const isLastMessage = index.eq(
graph.peekLargest()?.[0] ?? bigInt.zero
);
const highlighted = false; // this.state.unreadIndex.eq(index);
const keys = graph.keys().reverse();
const graphIdx = keys.findIndex((idx) => idx.eq(index));
const prevIdx = keys[graphIdx + 1];
const nextIdx = keys[graphIdx - 1];
const isLastRead: boolean = this.state.unreadIndex.eq(index);
const props = {
highlighted,
scrollWindow,
isPending,
isLastRead,
isLastMessage,
msg,
...messageProps
};
return (
<ChatMessage
key={index.toString()}
ref={ref}
previousMsg={prevIdx && graph.get(prevIdx)?.post}
nextMsg={nextIdx && graph.get(nextIdx)?.post}
{...props}
/>
);
});
render() {
const {
@ -262,7 +292,6 @@ export default class ChatWindow extends Component<
groups,
associations
};
const keys = graph.keys().reverse();
const unreadIndex = graph.keys()[this.props.unreadCount];
const unreadMsg = unreadIndex && graph.get(unreadIndex);
@ -284,58 +313,17 @@ export default class ChatWindow extends Component<
ref={(list) => {
this.virtualList = list;
}}
offset={unreadCount}
origin='bottom'
style={{ height: '100%' }}
onStartReached={() => {
this.setState({ idle: false });
this.dismissUnread();
}}
onScroll={this.onScroll.bind(this)}
style={virtScrollerStyle}
onStartReached={this.setActive}
onScroll={this.onScroll}
data={graph}
size={graph.size}
renderer={({ index, measure, scrollWindow }) => {
const msg = graph.get(index)?.post;
if (!msg) return null;
if (!this.state.initialized) {
return (
<MessagePlaceholder
key={index.toString()}
height='64px'
index={index}
/>
);
}
const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
const isLastMessage = index.eq(
graph.peekLargest()?.[0] ?? bigInt.zero
);
const highlighted = bigInt(this.props.scrollTo || -1).eq(index);
const graphIdx = keys.findIndex((idx) => idx.eq(index));
const prevIdx = keys[graphIdx + 1];
const nextIdx = keys[graphIdx - 1];
const isLastRead: boolean = this.state.unreadIndex.eq(index);
const props = {
measure,
highlighted,
scrollWindow,
isPending,
isLastRead,
isLastMessage,
msg,
...messageProps
};
return (
<ChatMessage
key={index.toString()}
previousMsg={prevIdx && graph.get(prevIdx)?.post}
nextMsg={nextIdx && graph.get(nextIdx)?.post}
{...props}
/>
);
}}
loadRows={(newer) => {
this.fetchMessages(newer);
}}
id={association.resource}
averageHeight={22}
renderer={this.renderer}
loadRows={this.fetchMessages}
/>
</Col>
);

View File

@ -40,6 +40,18 @@ const renderers = {
</Text>
);
},
blockquote: ({ children }) => {
return (
<Text
lineHeight="20px"
display="block"
borderLeft="1px solid"
color="black"
paddingLeft={2}>
{children}
</Text>
)
},
paragraph: ({ children }) => {
return (
<Text fontSize='1' lineHeight={'20px'}>
@ -131,7 +143,6 @@ export default function TextContent(props) {
const resource = `/ship/${content.text}`;
return (
<GroupLink
measure={props.measure}
resource={resource}
api={props.api}
associations={props.associations}

View File

@ -1,6 +1,7 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import moment from 'moment';
import { Box, Text } from '@tlon/indigo-react';
import VisibilitySensor from 'react-visibility-sensor';
import Timestamp from '~/views/components/Timestamp';

View File

@ -50,12 +50,6 @@ button {
background-color: #fff;
}
a {
color: #000;
font-weight: 400;
text-decoration: none;
}
h2 {
font-weight: 400;
}
@ -80,10 +74,6 @@ h2 {
font-family: 'Source Code Pro', monospace;
}
.bg-welcome-green {
background-color: #ecf6f2;
}
.c-default {
cursor: default;
}
@ -158,38 +148,11 @@ h2 {
/* responsive */
@media all and (max-width: 34.375em) {
.dn-s {
display: none;
}
.flex-basis-full-s {
flex-basis: 100%;
}
.h-100-minus-96-s {
height: calc(100% - 96px);
}
.unread-notice {
top: 96px;
}
}
@media all and (min-width: 34.375em) and (max-width: 46.875em) {
.flex-basis-250-m {
flex-basis: 250px;
}
}
@media all and (min-width: 46.875em) and (max-width: 60em) {
.flex-basis-250-l {
flex-basis: 250px;
}
}
@media all and (min-width: 60em) {
.flex-basis-250-xl {
flex-basis: 250px;
}
}
blockquote {
padding: 0 0 0 16px;
margin: 0;
@ -345,79 +308,13 @@ pre.CodeMirror-placeholder.CodeMirror-line-like {
/* dark */
@media (prefers-color-scheme: dark) {
.bg-black-d {
background-color: black;
}
.white-d {
color: white;
}
.gray1-d {
color: #4d4d4d;
}
.gray2-d {
color: #7f7f7f;
}
.gray3-d {
color: #b1b2b3;
}
.gray4-d {
color: #e6e6e6;
}
.bg-gray0-d {
background-color: #333;
}
.bg-gray1-d {
background-color: #4d4d4d;
}
.b--gray0-d {
border-color: #333;
}
.b--gray1-d {
border-color: #4d4d4d;
}
.b--gray2-d {
border-color: #7f7f7f;
}
.b--white-d {
border-color: #fff;
}
.b--green2-d {
border-color: #2aa779;
}
.bb-d {
border-bottom-width: 1px;
border-bottom-style: solid;
}
.invert-d {
filter: invert(1);
}
.o-80-d {
opacity: 0.8;
}
.focus-b--white-d:focus {
border-color: #fff;
}
a {
color: #fff;
}
.hover-bg-gray1-d:hover {
background-color: #4d4d4d;
}
blockquote {
border-left: 1px solid white;
}
.contrast-10-d {
filter: contrast(0.1);
}
.bg-none-d {
background: none;
border-left: 1px solid inherit;
}
/* codemirror */
.chat .cm-s-tlon.CodeMirror {
color: #fff;
color: inherit;
}
.chat .cm-s-tlon span.cm-def {
@ -468,7 +365,7 @@ pre.CodeMirror-placeholder.CodeMirror-line-like {
/* set rules w/ both color & bg-color last to preserve legibility */
.chat .CodeMirror-selected {
background: var(--medium-gray) !important;
color: white;
color: inherit;
}
.chat .cm-s-tlon span.cm-comment {

View File

@ -29,6 +29,8 @@ import {
TUTORIAL_CHAT,
TUTORIAL_LINKS
} from '~/logic/lib/tutorialModal';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
const ScrollbarLessBox = styled(Box)`
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) {
const history = useHistory();
@ -81,7 +83,10 @@ export default function LaunchApp(props) {
}
}, [query]);
const { hideUtilities } = useSettingsState(selectCalmState);
const { tutorialProgress, nextTutStep } = useLocalState(tutSelector);
let { hideGroups } = useLocalState(tutSelector);
!hideGroups ? { hideGroups } = useSettingsState(selectCalmState) : null;
const waiter = useWaitForProps(props);
@ -157,6 +162,7 @@ export default function LaunchApp(props) {
p={2}
pt={0}
>
{!hideUtilities && <>
<Tile
bg="white"
color="scales.black20"
@ -197,8 +203,10 @@ export default function LaunchApp(props) {
>
<JoinGroup {...props} />
</ModalButton>
<Groups unreads={props.unreads} groups={props.groups} associations={props.associations} />
</>}
{!hideGroups &&
(<Groups unreads={props.unreads} groups={props.groups} associations={props.associations} />)
}
</Box>
<Box alignSelf="flex-start" display={["block", "none"]}>{hashBox}</Box>
</ScrollbarLessBox>

View File

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

View File

@ -17,7 +17,6 @@ export default class CustomTile extends React.PureComponent {
>
<BaseImage
position='absolute'
className="invert-d"
style={{ left: 38, top: 38 }}
src='/~launch/img/UnknownCustomTile.png'
width='48px'

View File

@ -53,25 +53,10 @@ button {
/* dark */
@media all and (prefers-color-scheme: dark) {
.bg-gray0-d {
background-color: #333;
}
.bg-gray1-d {
background-color: #4d4d4d;
}
.bg-gray2-d {
background-color: #7f7f7f;
}
.b--gray1-d {
border-color: #4d4d4d;
}
.white-d {
color: #fff;
}
.invert-d {
filter: invert(1);
}
.hover-bg-gray1-d:hover {
background-color: #4d4d4d;
}
}

View File

@ -68,6 +68,7 @@ export function LinkResource(props: LinkResourceProps) {
render={(props) => {
return (
<LinkWindow
key={rid}
s3={s3}
association={resource}
contacts={contacts}

View File

@ -33,20 +33,13 @@ interface LinkWindowProps {
}
export function LinkWindow(props: LinkWindowProps) {
const { graph, api, association } = props;
const virtualList = useRef<VirtualScroller>();
const fetchLinks = useCallback(
async (newer: boolean) => {
return true;
/* stubbed, should we generalize the display of graphs in virtualscroller? */
}, []
);
useEffect(() => {
const list = virtualList?.current;
if(!list)
return;
list.calculateVisibleItems();
}, [graph.size]);
const first = graph.peekLargest()?.[0];
const [,,ship, name] = association.resource.split('/');
const canWrite = isWriter(props.group, association.resource);
@ -74,15 +67,16 @@ return;
}
return (
<Col width="100%" height="100%" position="relative">
<VirtualScroller
ref={l => (virtualList.current = l ?? undefined)}
origin="top"
style={style}
onStartReached={() => {}}
onScroll={() => {}}
data={graph}
averageHeight={100}
size={graph.size}
renderer={({ index, measure, scrollWindow }) => {
renderer={({ index, scrollWindow }) => {
const node = graph.get(index);
const post = node?.post;
if (!node || !post)
@ -90,7 +84,6 @@ return null;
const linkProps = {
...props,
node,
measure
};
if(canWrite && index.eq(first ?? bigInt.zero)) {
return (
@ -106,5 +99,6 @@ return null;
}}
loadRows={fetchLinks}
/>
</Col>
);
}

View File

@ -19,7 +19,6 @@ interface LinkItemProps {
path: string;
contacts: Rolodex;
unreads: Unreads;
measure: (el: any) => void;
}
export const LinkItem = (props: LinkItemProps): ReactElement => {
@ -30,7 +29,6 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
group,
path,
contacts,
measure,
...rest
} = props;
@ -94,14 +92,6 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
const commColor = (props.unreads.graph?.[appPath]?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
const isUnread = props.unreads.graph?.[appPath]?.['/']?.unreads?.has(node.post.index);
const onMeasure = useCallback(() => {
ref.current && measure(ref.current);
}, [ref.current, measure]);
useEffect(() => {
onMeasure();
}, [onMeasure]);
return (
<Box mx="auto" px={3} maxWidth="768px" ref={ref} width="100%" {...rest}>
<Box
@ -124,7 +114,6 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
url={href}
text={contents[0].text}
unfold={true}
onLoad={onMeasure}
style={{ alignSelf: 'center' }}
oembedProps={{
p: 2,

View File

@ -16,23 +16,4 @@
left: 0;
width: 100%;
height: 100%;
}
/* responsive */
@media all and (max-width: 34.375em) {
.dn-s {
display: none;
}
.flex-basis-100-s, .flex-basis-full-s {
flex-basis: 100%;
}
}
@media all and (min-width: 34.375em) {
.db-ns {
display: block;
}
.flex-basis-30-ns {
flex-basis: 30vw;
}
}

View File

@ -132,7 +132,6 @@ const GraphNodeContent = ({
<ChatMessage
renderSigil={false}
containerClass='items-top cf hide-child'
measure={() => {}}
group={group}
contacts={contacts}
groups={{}}

View File

@ -8,7 +8,6 @@ import { Box, Col, Text, Row } from '@tlon/indigo-react';
import { Body } from '~/views/components/Body';
import { PropFunc } from '~/types/util';
import Inbox from './inbox';
import NotificationPreferences from './preferences';
import { Dropdown } from '~/views/components/Dropdown';
import { FormikOnBlur } from '~/views/components/FormikOnBlur';
import GroupSearch from '~/views/components/GroupSearch';
@ -76,18 +75,6 @@ export default function NotificationsScreen(props: any): ReactElement {
borderBottomColor="washedGray"
>
<Text>Updates</Text>
<Row>
<Box>
<HeaderLink ref={anchorRef} current={view} view="">
Inbox
</HeaderLink>
</Box>
<Box>
<HeaderLink current={view} view="preferences">
Preferences
</HeaderLink>
</Box>
</Row>
<Row
justifyContent="space-between"
>
@ -137,13 +124,6 @@ export default function NotificationsScreen(props: any): ReactElement {
</Dropdown>
</Row>
</Row>
{view === 'preferences' && (
<NotificationPreferences
graphConfig={props.notificationsGraphConfig}
api={props.api}
dnd={props.doNotDisturb}
/>
)}
{!view && <Inbox {...props} filter={filter.groups} />}
</Col>
</Body>

View File

@ -1,88 +0,0 @@
import React, { ReactElement, useCallback } from 'react';
import { Form, FormikHelpers } from 'formik';
import _ from 'lodash';
import { Col, ManagedCheckboxField as Checkbox } from '@tlon/indigo-react';
import { NotificationGraphConfig } from '@urbit/api';
import { FormikOnBlur } from '~/views/components/FormikOnBlur';
import GlobalApi from '~/logic/api/global';
interface FormSchema {
mentions: boolean;
dnd: boolean;
watchOnSelf: boolean;
watching: string[];
}
interface NotificationPreferencesProps {
graphConfig: NotificationGraphConfig;
dnd: boolean;
api: GlobalApi;
}
export default function NotificationPreferences(
props: NotificationPreferencesProps
): ReactElement {
const { graphConfig, api, dnd } = props;
const initialValues: FormSchema = {
mentions: graphConfig.mentions,
watchOnSelf: graphConfig.watchOnSelf,
dnd,
watching: graphConfig.watching
};
const onSubmit = useCallback(
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
console.log(values);
try {
const 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, graphConfig]
);
return (
<FormikOnBlur
initialValues={initialValues}
onSubmit={onSubmit}
>
<Form>
<Col maxWidth="384px" p="3" gapY="4">
<Checkbox
label="Do not disturb"
id="dnd"
caption="You won't see the notification badge, but notifications will still appear in your inbox."
/>
<Checkbox
label="Watch for replies"
id="watchOnSelf"
caption="Automatically follow a post for notifications when it's yours"
/>
<Checkbox
label="Watch for mentions"
id="mentions"
caption="Notify me if someone mentions my @p in a channel I've joined"
/>
</Col>
</Form>
</FormikOnBlur>
);
}

View File

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

View File

@ -8,17 +8,16 @@ import {
Text,
Row,
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 {
const { hideNicknames } = useLocalState(({ hideNicknames }) => ({
hideNicknames
}));
export function ViewProfile(props: any) {
const history = useHistory();
const { hideNicknames } = useSettingsState(selectCalmState);
const { api, contact, nacked, isPublic, ship, associations, groups } = props;
return (

View File

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

View File

@ -3,7 +3,7 @@ import moment from 'moment';
import { Link } from 'react-router-dom';
import { BigInteger } from 'big-integer';
import { Box } from '@tlon/indigo-react';
import { Box, Text } from '@tlon/indigo-react';
import { Graph } from '@urbit/api';
import { getLatestRevision } from '~/logic/lib/publish';
@ -24,13 +24,14 @@ function NavigationItem(props: {
textAlign={props.prev ? 'left' : 'right'}
>
<Link to={props.url}>
<Box color="gray" mb={2}>
<Text display='block' color="gray">
{props.prev ? 'Previous' : 'Next'}
</Box>
<Box mb={1}>{props.title}</Box>
</Text>
<Text display='block' lineHeight="tall">{props.title}</Text>
<Timestamp
stamp={moment(props.date)}
time={false}
fontSize="1"
justifyContent={props.prev ? 'flex-start' : 'flex-end'}
/>
</Link>

View File

@ -5,41 +5,6 @@
--light-gray: rgba(0,0,0,0.08);
}
.bg-welcome-green {
background-color: #ECF6F2;
}
@media all and (max-width: 34.375em) {
.dn-s {
display: none;
}
.flex-basis-100-s, .flex-basis-full-s {
flex-basis: 100%;
}
.h-100-m-40-s {
height: calc(100% - 40px);
}
.black-s {
color: #000;
}
}
@media all and (min-width: 34.375em) {
.db-ns {
display: block;
}
.flex-basis-250-ns {
flex-basis: 250px;
}
.h-100-m-40-ns {
height: calc(100% - 40px);
}
}
.bg-light-green {
background: rgba(42, 167, 121, 0.1);
}
.NotebookButton {
border-radius:2px;
cursor: pointer;
@ -207,57 +172,6 @@
}
@media all and (prefers-color-scheme: dark) {
.bg-black-d {
background-color: black;
}
.white-d {
color: white;
}
.gray1-d {
color: #4d4d4d;
}
.gray2-d {
color: #7f7f7f;
}
.gray3-d {
color: #b1b2b3;
}
.gray4-d {
color: #e6e6e6;
}
.bg-gray0-d {
background-color: #333;
}
.bg-gray1-d {
background-color: #4d4d4d;
}
.b--gray0-d {
border-color: #333;
}
.b--gray1-d {
border-color: #4d4d4d;
}
.b--gray2-d {
border-color: #7f7f7f;
}
.b--white-d {
border-color: #fff;
}
.invert-d {
filter: invert(1);
}
.o-60-d {
opacity: .6;
}
a {
color: #fff;
}
.focus-b--white-d:focus {
border-color: #fff;
}
.hover-bg-gray1-d:hover {
background-color: #4d4d4d;
}
.options.open {
background-color: #4d4d4d;
}
@ -266,32 +180,32 @@
}
.publish .cm-s-tlon.CodeMirror {
background: unset;
color: #fff;
color: inherit;
}
.publish .cm-s-tlon span.cm-def {
color: white;
color: inherit;
}
.publish .cm-s-tlon span.cm-variable {
color: white;
color: inherit;
}
.publish .cm-s-tlon span.cm-variable-2 {
color: white;
color: inherit;
}
.publish .cm-s-tlon span.cm-variable-3,
.publish .cm-s-tlon span.cm-type {
color: white;
color: inherit;
}
.publish .cm-s-tlon span.cm-property {
color: white;
color: inherit;
}
.publish .cm-s-tlon span.cm-operator {
color: white;
color: inherit;
}
@ -325,7 +239,7 @@
color: black;
display: inline-block;
padding: 0;
background-color: rgba(255,255,255, 0.3);
background-color: rgba(0,255,255, 0.3);
border-radius: 2px;
}
}

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 display={["block", "none"]} fontSize="2" fontWeight="medium">{"<- Back to System Preferences"}</Text>
</Link>
);
}

View File

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

View File

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

View File

@ -0,0 +1,147 @@
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;
hideUtilities: 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,
hideUtilities
},
remoteContentPolicy: {
imageShown,
videoShown,
oembedShown,
audioShown,
}
} = useSettingsState(settingsSel);
const initialValues: FormSchema = {
hideAvatars,
hideNicknames,
hideUnreads,
hideGroups,
hideUtilities,
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('calm', 'hideUtilities', v.hideUtilities),
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>
<BackButton/>
<Col borderBottom="1" borderBottomColor="washedGray" p="5" pt="4" gapY="5">
<Col gapY="1" mt="0">
<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 utility tiles"
id="hideUtilities"
caption="Do not show home screen utilities"
/>
<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,37 @@
import React from 'react';
import React from "react";
import {
Box,
ManagedCheckboxField as Checkbox,
Button
} from '@tlon/indigo-react';
import { Formik, Form } from 'formik';
import * as Yup from 'yup';
Col,
Text,
Label,
ManagedRadioButtonField as Radio
} from "@tlon/indigo-react";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import GlobalApi from '~/logic/api/global';
import { uxToHex } from '~/logic/lib/util';
import { S3State, BackgroundConfig } from '@urbit/api';
import { BackgroundPicker, BgType } from './BackgroundPicker';
import useLocalState, { LocalState } from '~/logic/state/local';
import GlobalApi from "~/logic/api/global";
import { uxToHex } from "~/logic/lib/util";
import { S3State, BackgroundConfig } from "~/types";
import { BackgroundPicker, BgType } from "./BackgroundPicker";
import useSettingsState, { SettingsState, selectSettingsState } from "~/logic/state/settings";
import {AsyncButton} from "~/views/components/AsyncButton";
import { BackButton } from "./BackButton";
const formSchema = Yup.object().shape({
bgType: Yup.string()
.oneOf(['none', 'color', 'url'], 'invalid')
.required('Required'),
bgUrl: Yup.string().url(),
bgColor: Yup.string(),
avatars: Yup.boolean(),
nicknames: Yup.boolean()
.oneOf(["none", "color", "url"], "invalid")
.required("Required"),
background: Yup.string(),
theme: Yup.string()
.oneOf(["light", "dark", "auto"])
.required("Required")
});
interface FormSchema {
bgType: BgType;
bgColor: string | undefined;
bgUrl: string | undefined;
avatars: boolean;
nicknames: boolean;
theme: string;
}
interface DisplayFormProps {
@ -37,79 +39,85 @@ interface DisplayFormProps {
s3: S3State;
}
const settingsSel = selectSettingsState(["display"]);
export default function DisplayForm(props: DisplayFormProps) {
const { api, s3 } = props;
const { hideAvatars, hideNicknames, background, set: setLocalState } = useLocalState();
const {
display: {
background,
backgroundType,
theme
}
} = useSettingsState(settingsSel);
let bgColor, bgUrl;
if (background?.type === 'url') {
bgUrl = background.url;
if (backgroundType === "url") {
bgUrl = background;
}
if (background?.type === 'color') {
bgColor = background.color;
if (backgroundType === "color") {
bgColor = background;
}
const bgType = background?.type || 'none';
const bgType = backgroundType || "none";
return (
<Formik
validationSchema={formSchema}
initialValues={
{
bgType,
bgColor: bgColor || '',
bgType: backgroundType,
bgColor: bgColor || "",
bgUrl,
avatars: hideAvatars,
nicknames: hideNicknames
theme
} as FormSchema
}
onSubmit={(values, actions) => {
const bgConfig: BackgroundConfig =
values.bgType === 'color'
? { type: 'color', color: `#${uxToHex(values.bgColor || '0x0')}` }
: values.bgType === 'url'
? { type: 'url', url: values.bgUrl || '' }
: undefined;
onSubmit={async (values, actions) => {
let promises = [] as Promise<any>[];
promises.push(api.settings.putEntry('display', 'backgroundType', values.bgType));
promises.push(
api.settings.putEntry('display', 'background',
values.bgType === "color"
? `#${uxToHex(values.bgColor || "0x0")}`
: values.bgType === "url"
? values.bgUrl || ""
: false
));
promises.push(api.settings.putEntry('display', 'theme', values.theme));
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>
<Box
display="grid"
gridTemplateColumns="100%"
gridTemplateRows="auto"
gridRowGap={5}
>
<Box color="black" fontSize={1} mb={3} fontWeight={900}>
Display Preferences
</Box>
<BackButton/>
<Col p="5" pt="4" gapY="5">
<Col gapY="1" mt="0">
<Text color="black" fontSize={2} fontWeight="medium">
Display Preferences
</Text>
<Text gray>
Customize visual interfaces across your Landscape
</Text>
</Col>
<BackgroundPicker
bgType={props.values.bgType}
bgUrl={props.values.bgUrl}
api={api}
s3={s3}
/>
<Checkbox
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">
<Label>Theme</Label>
<Radio name="theme" id="light" label="Light"/>
<Radio name="theme" id="dark" label="Dark" />
<Radio name="theme" id="auto" label="Auto" />
<AsyncButton primary width="fit-content" type="submit">
Save
</Button>
</Box>
</AsyncButton>
</Col>
</Form>
)}
</Formik>

View File

@ -0,0 +1,103 @@
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 (
<>
<BackButton/>
<Col p="5" pt="4" gapY="5">
<Col gapY="1" mt="0">
<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,90 @@
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 (
<>
<BackButton/>
<Col p="5" pt="4" gapY="5">
<Col gapY="1" mt="0">
<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,13 +5,16 @@ import {
ManagedTextInputField as Input,
ManagedForm as Form,
Box,
Text,
Button,
Col
Col,
Anchor
} 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 { BackButton } from './BackButton';
interface FormSchema {
s3bucket: string;
@ -47,7 +50,7 @@ export default function S3Form(props: S3FormProps): ReactElement {
);
return (
<>
<Col>
<Col p="5" pt="4" borderBottom="1" borderBottomColor="washedGray">
<Formik
initialValues={
{
@ -60,30 +63,50 @@ export default function S3Form(props: S3FormProps): ReactElement {
}
onSubmit={onSubmit}
>
<Form
display="grid"
gridTemplateColumns="100%"
gridAutoRows="auto"
gridRowGap={5}
>
<Box color="black" fontSize={1} fontWeight={900}>
S3 Credentials
</Box>
<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>
<Form>
<BackButton/>
<Col maxWidth="600px" gapY="5">
<Col gapY="1" mt="0">
<Text color="black" fontSize={2} fontWeight="medium">
S3 Storage Setup
</Text>
<Text gray>
Store credentials for your S3 object storage buckets on your
Urbit ship, and upload media freely to various modules.
<Anchor
target="_blank"
style={{ textDecoration: 'none' }}
borderBottom="1"
ml="1"
href="https://urbit.org/using/operations/using-your-ship/#bucket-setup">
Learn more
</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>
</Formik>
</Col>
<Col>
<Box color="black" mb={4} fontSize={1} fontWeight={700}>
S3 Buckets
</Box>
<Col maxWidth="600px" p="5" gapY="4">
<Col gapY="1">
<Text color="black" mb={4} fontSize={2} fontWeight="medium">
S3 Buckets
</Text>
<Text gray>
Your 'active' bucket will be the one used when Landscape uploads a
file
</Text>
</Col>
<BucketList
buckets={s3.configuration.buckets}
selected={s3.configuration.currentBucket}

View File

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

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 { StoreState } from '~/logic/store/type';
import DisplayForm from './lib/DisplayForm';
import S3Form from './lib/S3Form';
import SecuritySettings from './lib/Security';
import RemoteContentForm from './lib/RemoteContent';
import GlobalApi from "~/logic/api/global";
import { StoreState } from "~/logic/store/type";
import DisplayForm from "./lib/DisplayForm";
import S3Form from "./lib/S3Form";
import SecuritySettings from "./lib/Security";
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 default function Settings({
api,
s3
}: ProfileProps) {
export function SettingsItem(props: {
title: string;
description: string;
to: string;
}) {
const { to, title, description } = props;
return (
<Box
backgroundColor="white"
display="grid"
gridTemplateRows="auto"
gridTemplateColumns="1fr"
gridRowGap={7}
p={4}
maxWidth="500px"
>
<DisplayForm
api={api}
s3={s3}
/>
<RemoteContentForm api={api} />
<S3Form api={api} s3={s3} />
<SecuritySettings api={api} />
</Box>
<Link to={`/~settings/${to}`}>
<Row alignItems="center" gapX="3">
<Box
borderRadius="2"
backgroundColor="blue"
width="64px"
height="64px"
/>
<Col gapY="2">
<Text>{title}</Text>
<Text gray>{description}</Text>
</Col>
</Row>
</Link>
);
}
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,139 @@
import React, { ReactElement } from 'react';
import { Route } from 'react-router-dom';
import Helmet from 'react-helmet';
import React, { ReactNode } from "react";
import { useLocation } from "react-router-dom";
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 useLocalState from '~/logic/state/local';
import { NotificationPreferences } from "./components/lib/NotificationPref";
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 (
<>
<Helmet defer={false}>
<title>Landscape - Settings</title>
</Helmet>
<Route
path={['/~settings']}
render={() => {
return (
<Box height="100%"
width="100%"
px={[0, 3]}
pb={[0, 3]}
borderRadius={1}
<Skeleton>
<Row height="100%" overflow="hidden">
<Col
height="100%"
borderRight="1"
borderRightColor="washedGray"
display={hash === "" ? "flex" : ["none", "flex"]}
minWidth="250px"
width="100%"
maxWidth={["100vw", "350px"]}
>
<Text
display="block"
my="4"
mx="3"
fontSize="2"
fontWeight="medium"
>
<Box
height="100%"
width="100%"
display="grid"
gridTemplateColumns={['100%', '400px 1fr']}
gridTemplateRows={['48px 1fr', '1fr']}
borderRadius={1}
bg="white"
border={1}
borderColor="washedGray"
overflowY="auto"
flexGrow
>
<Settings {...props} />
</Box>
</Box>
);
}}
/>
System Preferences
</Text>
<Col gapY="1">
<SidebarItem
icon="Inbox"
text="Notifications"
hash="notifications"
/>
<SidebarItem icon="Image" text="Display" hash="display" />
<SidebarItem icon="Upload" text="Remote Storage" hash="s3" />
<SidebarItem icon="LeapArrow" text="Leap" hash="leap" />
<SidebarItem icon="Node" text="CalmEngine" hash="calm" />
<SidebarItem
icon="Locked"
text="Devices + Security"
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

@ -47,6 +47,7 @@ export default function Author(props: AuthorProps): ReactElement {
<BaseImage
display='inline-block'
src={contact.avatar}
style={{ objectFit: 'cover' }}
height={16}
width={16}
/>

View File

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

View File

@ -9,6 +9,7 @@ import { JoinGroup } from '../landscape/components/JoinGroup';
import { useModal } from '~/logic/lib/useModal';
import { GroupSummary } from '../landscape/components/GroupSummary';
import { PropFunc } from '~/types';
import {useVirtual} from '~/logic/lib/virtualContext';
export function GroupLink(
props: {
@ -16,16 +17,17 @@ export function GroupLink(
resource: string;
associations: Associations;
groups: Groups;
measure: () => void;
detailed?: boolean;
} & PropFunc<typeof Row>
): ReactElement {
const { resource, api, associations, groups, measure, ...rest } = props;
const { resource, api, associations, groups, ...rest } = props;
const name = resource.slice(6);
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
const joined = resource in props.associations.groups;
const { save, restore } = useVirtual();
const { modal, showModal } = useModal({
modal:
joined && preview ? (
@ -48,16 +50,19 @@ export function GroupLink(
useEffect(() => {
(async () => {
setPreview(await api.metadata.preview(resource));
const prev = await api.metadata.preview(resource);
save();
setPreview(prev);
})();
return () => {
save();
setPreview(null);
};
}, [resource]);
useLayoutEffect(() => {
measure();
restore();
}, [preview]);
return (

View File

@ -42,7 +42,7 @@ export function Mention(props: {
}) {
const { contacts, ship, scrollWindow, first, ...rest } = props;
let { contact } = props;
contact = contact?.color ? contact : contacts?.[ship];
contact = contact?.color ? contact : contacts?.[`~${ship}`];
const history = useHistory();
const showNickname = useShowNickname(contact);
const name = showNickname ? contact?.nickname : cite(ship);

View File

@ -98,6 +98,7 @@ class ProfileOverlay extends PureComponent<
contact?.avatar && !hideAvatars ? (
<BaseImage
display='inline-block'
style={{ objectFit: 'cover' }}
src={contact.avatar}
height={72}
width={72}

View File

@ -1,11 +1,13 @@
import React, { PureComponent, Fragment } from 'react';
import React, { Component, Fragment } from 'react';
import { BaseAnchor, BaseImage, Box, Button, Text } from '@tlon/indigo-react';
import { hasProvider } from 'oembed-parser';
import EmbedContainer from 'react-oembed-container';
import { withLocalState } from '~/logic/state/local';
import { RemoteContentPolicy } from '~/types/local-update';
import { VirtualContextProps, withVirtual } from "~/logic/lib/virtualContext";
import { IS_IOS } from '~/logic/lib/platform';
interface RemoteContentProps {
type RemoteContentProps = VirtualContextProps & {
url: string;
text?: string;
unfold?: boolean;
@ -17,7 +19,6 @@ interface RemoteContentProps {
oembedProps?: any;
textProps?: any;
style?: any;
onLoad?(): void;
}
interface RemoteContentState {
@ -30,9 +31,11 @@ const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i);
const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState> {
class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
private fetchController: AbortController | undefined;
containerRef: HTMLDivElement | null = null;
private saving = false;
constructor(props) {
super(props);
this.state = {
@ -46,7 +49,25 @@ class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState
this.onError = this.onError.bind(this);
}
save = () => {
console.log(`saving for: ${this.props.url}`);
if(this.saving) {
return;
}
this.saving = true;
this.props.save();
};
restore = () => {
console.log(`restoring for: ${this.props.url}`);
this.saving = false;
this.props.restore();
}
componentWillUnmount() {
if(this.saving) {
this.restore();
}
if (this.fetchController) {
this.fetchController.abort();
}
@ -56,8 +77,35 @@ class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState
event.stopPropagation();
let unfoldState = this.state.unfold;
unfoldState = !unfoldState;
this.save();
this.setState({ unfold: unfoldState });
setTimeout(this.props.onLoad, 500);
requestAnimationFrame(() => {
this.restore();
});
}
componentDidUpdate(prevProps, prevState) {
if(prevState.embed !== this.state.embed) {
//console.log('remotecontent: restoring');
//prevProps.shiftLayout.restore();
}
const { url } = this.props;
if(url !== prevProps.url && (IMAGE_REGEX.test(url) || AUDIO_REGEX.test(url) || VIDEO_REGEX.test(url))) {
this.save();
};
}
componentDidMount() {
}
onLoad = () => {
window.requestAnimationFrame(() => {
const { restore } = this;
restore();
});
}
loadOembed() {
@ -91,6 +139,7 @@ return;
}
onError(e: Event) {
this.restore();
this.setState({ noCors: true });
}
@ -107,9 +156,9 @@ return;
oembedProps = {},
textProps = {},
style = {},
onLoad = () => {},
...props
} = this.props;
const { onLoad } = this;
const { noCors } = this.state;
const isImage = IMAGE_REGEX.test(url);
const isAudio = AUDIO_REGEX.test(url);
@ -140,6 +189,7 @@ return;
className="db"
src={url}
style={style}
onLoad={onLoad}
{...audioProps}
{...props}
/>
@ -193,13 +243,14 @@ return;
className='embed-container'
style={style}
flexShrink={0}
onLoad={onLoad}
onLoad={this.onLoad}
{...oembedProps}
{...props}
>
{this.state.embed && this.state.embed.html && this.state.unfold
? <EmbedContainer markup={this.state.embed.html}>
<div className="embed-container" ref={(el) => {
this.onLoad();
this.containerRef = el;
}}
dangerouslySetInnerHTML={{ __html: this.state.embed.html }}
@ -217,4 +268,4 @@ return;
}
}
export default withLocalState(RemoteContent, ['remoteContentPolicy']);
export default withLocalState(withVirtual(RemoteContent), ['remoteContentPolicy']);

View File

@ -3,7 +3,7 @@ import RemoteContent from '~/views/components/RemoteContent';
import { hasProvider } from 'oembed-parser';
import ReactMarkdown from 'react-markdown';
import RemarkDisableTokenizers from 'remark-disable-tokenizers';
import { BaseAnchor, Text } from '@tlon/indigo-react';
import { Anchor, Text } from '@tlon/indigo-react';
import { isValidPatp } from 'urbit-ob';
import { deSig } from '~/logic/lib/util';
@ -37,7 +37,7 @@ const RichText = React.memo(({ disableRemoteContent, ...props }) => (
return <RemoteContent className="mw-100" url={linkProps.href} />;
}
return <BaseAnchor target='_blank' rel='noreferrer noopener' borderBottom='1px solid' remoteContentPolicy={remoteContentPolicy} {...linkProps}>{linkProps.children}</BaseAnchor>;
return <Anchor target='_blank' rel='noreferrer noopener' borderBottom='1px solid' remoteContentPolicy={remoteContentPolicy} {...linkProps}>{linkProps.children}</Anchor>;
},
linkReference: (linkProps) => {
const linkText = String(linkProps.children[0].props.children);
@ -46,6 +46,18 @@ const RichText = React.memo(({ disableRemoteContent, ...props }) => (
}
return linkText;
},
blockquote: (blockquoteProps) => {
return (
<Text
lineHeight="20px"
display="block"
borderLeft="1px solid"
color="black"
paddingLeft={2} {...props}>
{blockquoteProps.children}
</Text>
)
},
paragraph: (paraProps) => {
return <Text display={props.inline ? 'inline' : 'block'} mb='2' {...props}>{paraProps.children}</Text>;
}

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 { 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 { ourContact, api, ship } = props;
const invites = [].concat(...Object.values(props.invites).map(obj => Object.values(obj)));
const metaKey = (window.navigator.platform.includes('Mac')) ? '⌘' : 'Ctrl+';
const { toggleOmnibox, hideAvatars } =
useLocalState(({ toggleOmnibox, hideAvatars }) =>
({ toggleOmnibox, hideAvatars })
);
const { toggleOmnibox } = useLocalState(localSel);
const { hideAvatars } = useSettingsState(selectCalmState);
const color = !!ourContact ? `#${uxToHex(props.ourContact.color)}` : '#000';
const xPadding = (!hideAvatars && ourContact?.avatar) ? '0' : '2';

View File

@ -1,196 +1,214 @@
import React, { Component } from 'react';
import React, { Component, useCallback } from 'react';
import _ from 'lodash';
import normalizeWheel from 'normalize-wheel';
import bigInt, { BigInteger } from 'big-integer';
import styled from 'styled-components';
import { Box } from '@tlon/indigo-react';
import { Box, LoadingSpinner, Row, Center } from '@tlon/indigo-react';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import {VirtualContext} from '~/logic/lib/virtualContext';
import { IS_IOS } from '~/logic/lib/platform';
const ScrollbarLessBox = styled(Box)`
scrollbar-width: none !important;
::-webkit-scrollbar {
display: none;
}
`;
interface RendererProps {
index: BigInteger;
measure: (el: any) => void;
scrollWindow: any
scrollWindow: any;
ref: (el: HTMLElement | null) => void;
}
interface VirtualScrollerProps {
interface VirtualScrollerProps<T> {
origin: 'top' | 'bottom';
loadRows(newer: boolean): void;
data: BigIntOrderedMap<BigInteger, any>;
loadRows(newer: boolean): Promise<boolean>;
data: BigIntOrderedMap<T>;
id: string;
renderer: (props: RendererProps) => JSX.Element | null;
onStartReached?(): void;
onEndReached?(): void;
size: number;
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<BigInteger, JSX.Element>): void;
totalSize: number;
averageHeight: number;
offset: number;
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<T>): void;
onScroll?({ scrollTop, scrollHeight, windowHeight }): void;
style?: any;
}
interface VirtualScrollerState {
startgap: number | undefined;
visibleItems: BigIntOrderedMap<BigInteger, Element>;
endgap: number | undefined;
totalHeight: number;
averageHeight: number;
scrollTop: number;
interface VirtualScrollerState<T> {
visibleItems: BigIntOrderedMap<T>;
scrollbar: number;
}
export default class VirtualScroller extends Component<VirtualScrollerProps, VirtualScrollerState> {
private scrollContainer: React.RefObject<HTMLDivElement>;
public window: HTMLDivElement | null;
private cache: BigIntOrderedMap<any>;
private pendingLoad: {
start: BigInteger;
end: BigInteger
timeout: ReturnType<typeof setTimeout>;
} | undefined;
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
let logLevel = ['bail', 'scroll', 'reflow'] as LogLevel[];
overscan = 150;
const log = (level: LogLevel, message: string) => {
if(logLevel.includes(level)) {
console.log(`[${level}]: ${message}`);
}
OVERSCAN_SIZE = 100; // Minimum number of messages on either side before loadRows is called
}
constructor(props: VirtualScrollerProps) {
const ZONE_SIZE = IS_IOS ? 10 : 40;
// nb: in this file, an index refers to a BigInteger and an offset refers to a
// number used to index a listified BigIntOrderedMap
export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState<T>> {
/**
* A reference to our scroll container
*/
private window: HTMLDivElement | null = null;
/**
* A map of child refs, used to calculate scroll position
*/
private childRefs = new BigIntOrderedMap<HTMLElement>();
/**
* If saving, the bottommost visible element that we pin our scroll to
*/
private savedIndex: BigInteger | null = null;
/**
* If saving, the distance between the top of `this.savedEl` and the bottom
* of the screen
*/
private savedDistance = 0;
/**
* If saving, the number of requested saves. If several images are loading
* at once, we save the scroll pos the first time we see it and restore
* once the number of requested saves is zero
*/
private saveDepth = 0;
private isUpdating = false;
private scrollLocked = true;
private pageSize = 50;
private pageDelta = 15;
private scrollRef: HTMLElement | null = null;
private loaded = {
top: false,
bottom: false
};
constructor(props: VirtualScrollerProps<T>) {
super(props);
this.state = {
startgap: props.origin === 'top' ? 0 : undefined,
visibleItems: new BigIntOrderedMap(),
endgap: props.origin === 'bottom' ? 0 : undefined,
totalHeight: 0,
averageHeight: 130,
scrollTop: props.origin === 'top' ? 0 : undefined
scrollbar: 0
};
this.scrollContainer = React.createRef();
this.window = null;
this.cache = new BigIntOrderedMap();
this.updateVisible = this.updateVisible.bind(this);
this.recalculateTotalHeight = _.throttle(this.recalculateTotalHeight.bind(this), 200);
this.calculateVisibleItems = _.throttle(this.calculateVisibleItems.bind(this), 200);
this.estimateIndexFromScrollTop = this.estimateIndexFromScrollTop.bind(this);
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
this.heightOf = this.heightOf.bind(this);
this.setScrollTop = this.setScrollTop.bind(this);
this.scrollToData = this.scrollToData.bind(this);
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 400) : this.onScroll.bind(this);
this.scrollKeyMap = this.scrollKeyMap.bind(this);
this.loadRows = _.debounce(this.loadRows, 300, { leading: true }).bind(this);
this.setWindow = this.setWindow.bind(this);
}
componentDidMount() {
this.calculateVisibleItems();
this.recalculateTotalHeight();
}
componentDidUpdate(prevProps: VirtualScrollerProps, prevState: VirtualScrollerState) {
const {
scrollContainer, window,
props: { origin },
state: { totalHeight, scrollTop }
} = this;
}
scrollToData(targetIndex: BigInteger): Promise<void> {
if (!this.window) {
return new Promise((resolve, reject) => {
reject();
});
}
const { offsetHeight } = this.window;
let scrollTop = 0;
let itemHeight = 0;
new BigIntOrderedMap([...this.props.data].reverse()).forEach((datum, index) => {
const height = this.heightOf(index);
if (index.geq(targetIndex)) {
scrollTop += height;
if (index.eq(targetIndex)) {
itemHeight = height;
}
}
});
return this.setScrollTop(scrollTop - (offsetHeight / 2) + itemHeight);
}
recalculateTotalHeight() {
let { averageHeight } = this.state;
let totalHeight = 0;
this.props.data.forEach((datum, index) => {
totalHeight += Math.max(this.heightOf(index), 0);
});
averageHeight = Number((totalHeight / this.props.data.size).toFixed());
totalHeight += (this.props.size - this.props.data.size) * averageHeight;
this.setState({ totalHeight, averageHeight });
}
estimateIndexFromScrollTop(targetScrollTop: number): BigInteger | undefined {
if (!this.window)
return undefined;
const index = bigInt(this.props.size);
const { averageHeight } = this.state;
let height = 0;
while (height < targetScrollTop) {
const itemHeight = this.cache.has(index) ? this.cache.get(index).height : averageHeight;
height += itemHeight;
index.subtract(bigInt.one);
}
return index;
}
heightOf(index: BigInteger): number {
return this.cache.has(index) ? this.cache.get(index).height : this.state.averageHeight;
}
calculateVisibleItems() {
if (!this.window)
return;
let startgap = 0, heightShown = 0, endgap = 0;
let startGapFilled = false;
const visibleItems = new BigIntOrderedMap<any>();
const { scrollTop, offsetHeight: windowHeight } = this.window;
const { averageHeight, totalHeight } = this.state;
const { data, size: totalSize, onCalculateVisibleItems } = this.props;
[...data].forEach(([index, datum]) => {
const height = this.heightOf(index);
if (startgap < (scrollTop - this.overscan) && !startGapFilled) {
startgap += height;
} else if (heightShown < (windowHeight + this.overscan)) {
startGapFilled = true;
visibleItems.set(index, datum);
heightShown += height;
}
});
endgap = totalHeight - heightShown - startgap;
const firstVisibleKey = visibleItems.peekSmallest()?.[0] ?? this.estimateIndexFromScrollTop(scrollTop)!;
const smallest = data.peekSmallest();
if (smallest && smallest[0].eq(firstVisibleKey)) {
if(true) {
this.updateVisible(0);
this.resetScroll();
this.loadRows(false);
return;
}
const lastVisibleKey =
visibleItems.peekLargest()?.[0]
?? bigInt(this.estimateIndexFromScrollTop(scrollTop + windowHeight)!);
}
const largest = data.peekLargest();
if (largest && largest[0].eq(lastVisibleKey)) {
this.loadRows(true);
// manipulate scrollbar manually, to dodge change detection
updateScroll = IS_IOS ? () => {} : _.throttle(() => {
if(!this.window || !this.scrollRef) {
return;
}
const { scrollTop, scrollHeight, offsetHeight } = this.window;
const unloaded = (this.startOffset() / this.pageSize);
const totalpages = this.props.size / this.pageSize;
const loaded = (scrollTop / scrollHeight);
const total = unloaded + loaded;
const result = ((unloaded + loaded) / totalpages) *this.window.offsetHeight;
this.scrollRef.style[this.props.origin] = `${result}px`;
}, 50);
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) {
const { id, size, data, offset } = this.props;
const { visibleItems } = this.state;
if(size !== prevProps.size) {
if(this.scrollLocked) {
this.updateVisible(0);
this.resetScroll();
}
}
}
componentWillUnmount() {
window.removeEventListener('keydown', this.invertedKeyHandler);
}
startOffset() {
const startIndex = this.state?.visibleItems?.peekLargest()?.[0];
if(!startIndex) {
return 0;
}
const offset = [...this.props.data].findIndex(([i]) => i.eq(startIndex))
if(offset === -1) {
throw new Error("a");
}
return offset;
}
/**
* Updates the `startOffset` and adjusts visible items accordingly.
* Saves the scroll positions before repainting and restores it afterwards
*/
updateVisible(newOffset: number) {
if (!this.window) {
return;
}
log('reflow', `from: ${this.startOffset()} to: ${newOffset}`);
this.isUpdating = true;
const { data, onCalculateVisibleItems } = this.props;
const visibleItems = new BigIntOrderedMap<any>(
[...data].slice(newOffset, newOffset + this.pageSize)
);
this.save();
onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null;
this.setState({
startgap: Number(startgap.toFixed()),
visibleItems,
endgap: Number(endgap.toFixed())
});
}
}, () => {
requestAnimationFrame(() => {
this.restore();
requestAnimationFrame(() => {
this.isUpdating = false;
loadRows(newer: boolean) {
this.props.loadRows(newer);
});
});
});
}
scrollKeyMap(): Map<string, number> {
return new Map([
['ArrowUp', this.state.averageHeight],
['ArrowDown', this.state.averageHeight * -1],
['ArrowUp', this.props.averageHeight],
['ArrowDown', this.props.averageHeight * -1],
['PageUp', this.window!.offsetHeight],
['PageDown', this.window!.offsetHeight * -1],
['Home', this.window!.scrollHeight],
@ -213,13 +231,12 @@ return;
}
}
componentWillUnmount() {
window.removeEventListener('keydown', this.invertedKeyHandler);
}
setWindow(element) {
if (!element)
return;
return;
console.log('resetting window');
this.save();
if (this.window) {
if (this.window.isSameNode(element)) {
return;
@ -227,10 +244,11 @@ return;
window.removeEventListener('keydown', this.invertedKeyHandler);
}
}
this.overscan = Math.max(element.offsetHeight * 3, 500);
const { averageHeight } = this.props;
this.window = element;
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 5.5));
this.pageDelta = Math.floor(this.pageSize / 3);
if (this.props.origin === 'bottom') {
element.addEventListener('wheel', (event) => {
event.preventDefault();
@ -241,48 +259,174 @@ return;
window.addEventListener('keydown', this.invertedKeyHandler, { passive: false });
}
this.resetScroll();
this.restore();
}
resetScroll(): Promise<void> {
if (!this.window)
return new Promise((resolve, reject) => {
reject();
});
return this.setScrollTop(0);
resetScroll() {
if (!this.window) {
return;
}
this.window.scrollTop = 0;
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
}
setScrollTop(distance: number, delay = 100): Promise<void> {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (!this.window) {
reject();
return;
}
this.window.scrollTop = distance;
resolve();
}, delay);
loadRows = _.throttle(async (newer: boolean) => {
const dir = newer ? 'bottom' : 'top';
if(this.loaded[dir]) {
return;
}
log('network', `loading more at ${dir}`);
const done = await this.props.loadRows(newer);
if(done) {
this.loaded[dir] = true;
}
}, 100);
onScroll(event: UIEvent) {
this.updateScroll();
if(!this.window) {
// bail if we're going to adjust scroll anyway
return;
}
if(this.saveDepth > 0) {
log('bail', 'deep scroll queue');
return;
}
const { onStartReached, onEndReached } = this.props;
const windowHeight = this.window.offsetHeight;
const { scrollTop, scrollHeight } = this.window;
const startOffset = this.startOffset();
if (scrollTop < ZONE_SIZE) {
log('scroll', `Entered start zone ${scrollTop}`);
if (startOffset === 0 && onStartReached) {
onStartReached();
}
const newOffset = Math.max(0, startOffset - this.pageDelta);
if(newOffset < 10) {
this.loadRows(true);
}
if(newOffset === 0) {
this.scrollLocked = true;
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
}
else if (scrollTop + windowHeight >= scrollHeight - ZONE_SIZE) {
this.scrollLocked = false;
log('scroll', `Entered end zone ${scrollTop}`);
const newOffset = Math.min(startOffset + this.pageDelta, this.props.data.size - this.pageSize);
if (onEndReached && startOffset === 0) {
onEndReached();
}
if((newOffset + (3 * this.pageSize) > this.props.data.size)) {
this.loadRows(false)
}
if(newOffset !== startOffset) {
this.updateVisible(newOffset);
}
} else {
this.scrollLocked = false;
}
}
restore() {
if(!this.window || !this.savedIndex) {
return;
}
if(this.saveDepth !== 1) {
log('bail', 'Deep restore');
return;
}
const ref = this.childRefs.get(this.savedIndex)!;
const newScrollTop = this.window.scrollHeight - ref.offsetTop - this.savedDistance;
this.window.scrollTo(0, newScrollTop);
requestAnimationFrame(() => {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth--;
});
}
onScroll(event) {
if (!this.window)
return;
const { onStartReached, onEndReached, onScroll } = this.props;
const windowHeight = this.window.offsetHeight;
const { scrollTop, scrollHeight } = this.window;
if (scrollTop !== scrollHeight) {
this.setState({ scrollTop });
scrollToIndex = (index: BigInteger) => {
let ref = this.childRefs.get(index);
if(!ref) {
const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index));
if(offset === -1) {
return;
}
this.updateVisible(Math.max(offset - this.pageDelta, 0));
requestAnimationFrame(() => {
ref = this.childRefs.get(index);
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
ref?.scrollIntoView({ block: 'center' });
});
} else {
this.savedIndex = null;
this.savedDistance = 0;
this.saveDepth = 0;
ref?.scrollIntoView({ block: 'center' });
}
};
save() {
if(!this.window || this.savedIndex) {
return;
}
this.saveDepth++;
if(this.saveDepth !== 1) {
console.log('bail', 'deep save');
return;
}
this.calculateVisibleItems();
onScroll ? onScroll({ scrollTop, scrollHeight, windowHeight }) : null;
if (scrollTop === 0) {
if (onStartReached)
onStartReached();
} else if (scrollTop + windowHeight >= scrollHeight) {
if (onEndReached)
onEndReached();
let bottomIndex: BigInteger | null = null;
const { scrollTop, scrollHeight } = this.window;
const topSpacing = scrollHeight - scrollTop;
[...Array.from(this.state.visibleItems)].reverse().forEach(([index, datum]) => {
const el = this.childRefs.get(index);
if(!el) {
return;
}
const { offsetTop } = el;
if(offsetTop < topSpacing) {
bottomIndex = index;
}
});
if(!bottomIndex) {
// weird, shouldn't really happen
this.saveDepth--;
return;
}
this.savedIndex = bottomIndex;
const ref = this.childRefs.get(bottomIndex)!;
const { offsetTop } = ref;
this.savedDistance = topSpacing - offsetTop
}
shiftLayout = { save: this.save.bind(this), restore: this.restore.bind(this) };
setRef = (element: HTMLElement | null, index: BigInteger) => {
if(element) {
this.childRefs.set(index, element);
} else {
setTimeout(() => {
this.childRefs.delete(index);
});
}
}
@ -295,37 +439,66 @@ onEndReached();
const {
origin = 'top',
loadRows,
renderer,
style,
data
} = this.props;
const indexesToRender = origin === 'top' ? visibleItems.keys() : visibleItems.keys().reverse();
const isTop = origin === 'top';
const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const indexesToRender = isTop ? visibleItems.keys() : visibleItems.keys().reverse();
const render = (index: BigInteger) => {
const measure = (element: any) => {
if (element) {
this.cache.set(index, {
height: element.offsetHeight,
element
});
this.recalculateTotalHeight();
}
};
return renderer({ index, measure, scrollWindow: this.window });
};
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
const loaded = this.props.data.size > 0;
const atStart = loaded && this.props.data.peekLargest()?.[0].eq(visibleItems.peekLargest()?.[0] || bigInt.zero);
const atEnd = this.loaded.top;
return (
<Box overflowY='scroll' ref={this.setWindow.bind(this)} onScroll={this.onScroll.bind(this)} style={{ ...style, ...{ transform } }}>
<Box ref={this.scrollContainer} style={{ transform, width: '100%' }}>
<Box style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></Box>
{indexesToRender.map(render)}
<Box style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></Box>
<>
{!IS_IOS && (<Box borderRadius="3" top ={isTop ? "0" : undefined} bottom={!isTop ? "0" : undefined} ref={el => { this.scrollRef = el; }} right="0" height="50px" position="absolute" width="4px" backgroundColor="lightGray" />)}
<ScrollbarLessBox overflowY='scroll' ref={this.setWindow} onScroll={this.onScroll} style={{ ...style, ...{ transform }, "-webkit-overflow-scrolling": "auto" }}>
<Box style={{ transform, width: 'calc(100% - 4px)' }}>
{(isTop ? !atStart : !atEnd) && (<Center height="5">
<LoadingSpinner />
</Center>)}
<VirtualContext.Provider value={this.shiftLayout}>
{indexesToRender.map(index => (
<VirtualChild
key={index.toString()}
setRef={this.setRef}
index={index}
scrollWindow={this.window}
renderer={renderer}
/>
))}
</VirtualContext.Provider>
{(!isTop ? !atStart : !atEnd) &&
(<Center height="5">
<LoadingSpinner />
</Center>)}
</Box>
</Box>
</ScrollbarLessBox>
</>
);
}
}
interface VirtualChildProps {
index: BigInteger;
scrollWindow: any;
setRef: (el: HTMLElement | null, index: BigInteger) => void;
renderer: (p: RendererProps) => JSX.Element | null;
}
function VirtualChild(props: VirtualChildProps) {
const { setRef, renderer: Renderer, ...rest } = props;
const ref = useCallback((el: HTMLElement | null) => {
setRef(el, props.index);
}, [setRef, props.index])
return (<Renderer ref={ref} {...rest} />);
};

View File

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

View File

@ -161,7 +161,7 @@ export function GraphPermissions(props: GraphPermissionsProps) {
>
<Form style={{ display: 'contents' }}>
<Col mt="4" flexShrink={0} gapY="5">
<Col gapY="1">
<Col gapY="1" mt="0">
<Text id="permissions" fontWeight="bold" fontSize="2">
Permissions
</Text>

View File

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

View File

@ -6,7 +6,8 @@ import {
Box,
ManagedTextInputField as Input,
ManagedToggleSwitchField as Checkbox,
Col
Col,
Text
} from '@tlon/indigo-react';
import { Enc } from '@urbit/api';
import { Group, GroupPolicy } from '@urbit/api/groups';
@ -104,7 +105,7 @@ return null;
onSubmit={onSubmit}
>
<Form>
<Box p="4" fontWeight="600" fontSize="2" id="group-details">Group Details</Box>
<Box p="4" id="group-details"><Text fontWeight="600" fontSize="2">Group Details</Text></Box>
<Col pb="4" px="4" maxWidth="384px" gapY={4}>
<Input
id="title"

View File

@ -4,7 +4,7 @@ import {
Col,
Label,
BaseLabel,
BaseAnchor
Text
} from '@tlon/indigo-react';
import { GroupNotificationsConfig } from '@urbit/api';
import { Association } from '@urbit/api/metadata';
@ -28,7 +28,7 @@ export function GroupPersonalSettings(props: {
return (
<Col px="4" pb="4" gapY="4">
<BaseAnchor pt="4" fontWeight="600" id="notifications" fontSize="2">Group Notifications</BaseAnchor>
<Text pt="4" fontWeight="600" id="notifications" fontSize="2">Group Notifications</Text>
<BaseLabel
htmlFor="asyncToggle"
display="flex"

View File

@ -115,10 +115,14 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps): ReactE
return (
<Col overflowY="auto" p={3} backgroundColor="white">
<Box pb='3' display={['block', 'none']} onClick={() => history.push(props.baseUrl)}>
<Text fontSize='0' bold>{'<- Back'}</Text>
<Box
pb='3'
display={workspace?.type === 'messages' ? 'none' : ['block', 'none']}
onClick={() => history.push(props.baseUrl)}
>
<Text>{'<- Back'}</Text>
</Box>
<Box color="black">
<Box>
<Text fontSize={2} bold>{workspace?.type === 'messages' ? 'Direct Message' : 'New Channel'}</Text>
</Box>
<Formik

View File

@ -10,6 +10,7 @@ import {
Row,
Text,
Icon,
Image,
Action,
StatelessTextInput as Input
} from '@tlon/indigo-react';
@ -29,7 +30,7 @@ import { roleForShip, resourceFromPath } from '~/logic/lib/group';
import { Dropdown } from '~/views/components/Dropdown';
import GlobalApi from '~/logic/api/global';
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
import useLocalState from '~/logic/state/local';
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
const TruncText = styled(Text)`
white-space: nowrap;
@ -79,13 +80,14 @@ function getParticipants(cs: Contacts, group: Group) {
const emptyContact = (patp: string, pending: boolean): Participant => ({
nickname: '',
email: '',
phone: '',
bio: '',
status: '',
color: '',
avatar: null,
notes: '',
website: '',
cover: null,
groups: [],
patp,
'last-updated': 0,
pending
});
@ -256,9 +258,7 @@ function Participant(props: {
}) {
const { contact, association, group, api } = props;
const { title } = association.metadata;
const { hideAvatars, hideNicknames } = useLocalState(
({ hideAvatars, hideNicknames }) => ({ hideAvatars, hideNicknames })
);
const { hideAvatars, hideNicknames } = useSettingsState(selectCalmState);
const color = uxToHex(contact.color);
const isInvite = 'invite' in group.policy;
@ -297,7 +297,13 @@ function Participant(props: {
const avatar =
contact?.avatar !== null && !hideAvatars ? (
<img src={contact.avatar} height={32} width={32} className="dib" />
<Image
src={contact.avatar}
height={32}
width={32}
display='inline-block'
style={{ objectFit: 'cover' }}
/>
) : (
<Sigil ship={contact.patp} size={32} color={`#${color}`} />
);

View File

@ -88,9 +88,9 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
display={['block', 'none']}
flexShrink={0}
>
<Link to={`/~landscape${workspace}`}> {'<- Back'}</Link>
<Link to={`/~landscape${workspace}`}><Text>{'<- Back'}</Text></Link>
</Box>
<Box px={1} mr={2} minWidth={0} display="flex" flexShrink={0}>
<Box px={1} mr={2} minWidth={0} display="flex" flexShrink={[1, 0]}>
<Text
mono={urbitOb.isValidPatp(title)}
fontSize='2'
@ -101,7 +101,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
overflow="hidden"
whiteSpace="pre"
minWidth={0}
flexShrink={0}
flexShrink={1}
>
{title}
</Text>
@ -109,7 +109,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
<Row
display={['none', 'flex']}
verticalAlign="middle"
flexShrink={1}
flexShrink={2}
minWidth={0}
title={association?.metadata?.description}
>
@ -125,7 +125,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
{(workspace === '/messages') ? recipient : association?.metadata?.description}
</TruncatedText>
</Row>
<Box flexGrow={1} />
<Box flexGrow={1} flexShrink={0} />
{canWrite && (
<Link to={resourcePath('/new')} style={{ flexShrink: '0' }}>
<Text bold pr='3' color='blue'>+ New Post</Text>

View File

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

View File

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

View File

@ -6,27 +6,6 @@
margin: 0 auto;
}
/* responsive */
@media all and (max-width: 34.375em) {
.dn-s {
display: none;
}
.flex-basis-100-s {
flex-basis: 100%;
}
}
@media all and (min-width: 34.375em) {
.db-ns {
display: block;
}
.flex-basis-30-ns {
flex-basis: 30vw;
}
}
@media all and (prefers-color-scheme: dark) {
.o-60-d {
opacity: .6;
}
a, a:any-link {
text-decoration: none;
}

View File

@ -1,5 +1,5 @@
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 Settings = Map<string, Bucket>;

View File

@ -2,7 +2,10 @@
set -ex
cd pkg/interface
cd pkg/npm/api
npm install &
cd ../../interface
npm install
npm run build:prod &